import { customElement, DeesElement, type TemplateResult, html, property, css, cssManager, state, } from '@design.estate/dees-element'; import { DeesIcon } from '@design.estate/dees-catalog'; import { demo } from './eco-applauncher-keyboard.demo.js'; // Ensure components are registered DeesIcon; declare global { interface HTMLElementTagNameMap { 'eco-applauncher-keyboard': EcoApplauncherKeyboard; } } export type TKeyboardLayout = 'qwerty' | 'numbers' | 'symbols'; export interface IKeyConfig { key: string; display?: string; width?: number; // multiplier, default 1 type?: 'char' | 'special' | 'modifier' | 'space' | 'layout'; action?: string; } // Long-press alternatives map const alternativesMap: Record = { 'a': ['à', 'á', 'â', 'ä', 'æ', 'ã', 'å', 'ā'], 'c': ['ç', 'ć', 'č'], 'e': ['è', 'é', 'ê', 'ë', 'ē', 'ė', 'ę'], 'i': ['î', 'ï', 'í', 'ī', 'į', 'ì'], 'n': ['ñ', 'ń'], 'o': ['ô', 'ö', 'ò', 'ó', 'œ', 'ø', 'ō', 'õ'], 's': ['ß', 'ś', 'š'], 'u': ['û', 'ü', 'ù', 'ú', 'ū'], 'y': ['ÿ'], 'z': ['ž', 'ź', 'ż'], // Numbers '0': ['°', '⁰'], '1': ['¹', '½', '⅓'], '2': ['²', '⅔'], '3': ['³', '¾'], // Punctuation '-': ['–', '—', '•'], '/': ['\\'], '$': ['€', '£', '¥', '¢'], '&': ['§'], '"': ['"', '"', '«', '»'], '.': ['…'], '?': ['¿'], '!': ['¡'], "'": ['\u2018', '\u2019', '`'], }; // Keyboard layouts const qwertyLayout: IKeyConfig[][] = [ [ { key: 'q' }, { key: 'w' }, { key: 'e' }, { key: 'r' }, { key: 't' }, { key: 'y' }, { key: 'u' }, { key: 'i' }, { key: 'o' }, { key: 'p' }, ], [ { key: 'a' }, { key: 's' }, { key: 'd' }, { key: 'f' }, { key: 'g' }, { key: 'h' }, { key: 'j' }, { key: 'k' }, { key: 'l' }, ], [ { key: 'shift', display: '⇧', width: 1.5, type: 'modifier' }, { key: 'z' }, { key: 'x' }, { key: 'c' }, { key: 'v' }, { key: 'b' }, { key: 'n' }, { key: 'm' }, { key: 'backspace', display: '⌫', width: 1.5, type: 'special' }, ], [ { key: '123', display: '123', width: 1.5, type: 'layout', action: 'numbers' }, { key: 'globe', display: '🌐', type: 'special' }, { key: 'space', display: '', width: 3, type: 'space' }, { key: 'left', display: '←', type: 'special', action: 'arrow-left' }, { key: 'up', display: '↑', type: 'special', action: 'arrow-up' }, { key: 'down', display: '↓', type: 'special', action: 'arrow-down' }, { key: 'right', display: '→', type: 'special', action: 'arrow-right' }, { key: 'enter', display: '↵', width: 1.5, type: 'special' }, ], ]; const numbersLayout: IKeyConfig[][] = [ [ { key: '1' }, { key: '2' }, { key: '3' }, { key: '4' }, { key: '5' }, { key: '6' }, { key: '7' }, { key: '8' }, { key: '9' }, { key: '0' }, ], [ { key: '-' }, { key: '/' }, { key: ':' }, { key: ';' }, { key: '(' }, { key: ')' }, { key: '$' }, { key: '&' }, { key: '@' }, { key: '"' }, ], [ { key: '#+=', display: '#+=' , width: 1.5, type: 'layout', action: 'symbols' }, { key: '.' }, { key: ',' }, { key: '?' }, { key: '!' }, { key: "'" }, { key: 'backspace', display: '⌫', width: 2.5, type: 'special' }, ], [ { key: 'ABC', display: 'ABC', width: 1.5, type: 'layout', action: 'qwerty' }, { key: 'globe', display: '🌐', type: 'special' }, { key: 'space', display: '', width: 3, type: 'space' }, { key: 'left', display: '←', type: 'special', action: 'arrow-left' }, { key: 'up', display: '↑', type: 'special', action: 'arrow-up' }, { key: 'down', display: '↓', type: 'special', action: 'arrow-down' }, { key: 'right', display: '→', type: 'special', action: 'arrow-right' }, { key: 'enter', display: '↵', width: 1.5, type: 'special' }, ], ]; const symbolsLayout: IKeyConfig[][] = [ [ { key: '[' }, { key: ']' }, { key: '{' }, { key: '}' }, { key: '#' }, { key: '%' }, { key: '^' }, { key: '*' }, { key: '+' }, { key: '=' }, ], [ { key: '_' }, { key: '\\' }, { key: '|' }, { key: '~' }, { key: '<' }, { key: '>' }, { key: '€' }, { key: '£' }, { key: '¥' }, { key: '•' }, ], [ { key: '123', display: '123', width: 1.5, type: 'layout', action: 'numbers' }, { key: '.' }, { key: ',' }, { key: '?' }, { key: '!' }, { key: "'" }, { key: 'backspace', display: '⌫', width: 2.5, type: 'special' }, ], [ { key: 'ABC', display: 'ABC', width: 1.5, type: 'layout', action: 'qwerty' }, { key: 'globe', display: '🌐', type: 'special' }, { key: 'space', display: '', width: 3, type: 'space' }, { key: 'left', display: '←', type: 'special', action: 'arrow-left' }, { key: 'up', display: '↑', type: 'special', action: 'arrow-up' }, { key: 'down', display: '↓', type: 'special', action: 'arrow-down' }, { key: 'right', display: '→', type: 'special', action: 'arrow-right' }, { key: 'enter', display: '↵', width: 1.5, type: 'special' }, ], ]; const layouts: Record = { qwerty: qwertyLayout, numbers: numbersLayout, symbols: symbolsLayout, }; @customElement('eco-applauncher-keyboard') export class EcoApplauncherKeyboard extends DeesElement { public static demo = demo; public static demoGroup = 'App Launcher'; public static styles = [ cssManager.defaultStyles, css` :host { display: block; width: 100%; height: 100%; } :host(:not([visible])) { display: none; } .keyboard-container { background: ${cssManager.bdTheme('hsl(220 15% 92%)', 'hsl(240 6% 12%)')}; padding: 8px 4px; height: 100%; display: flex; flex-direction: column; gap: 6px; box-sizing: border-box; position: relative; } .keyboard-row { display: flex; justify-content: center; gap: 4px; flex: 1; } .keyboard-row.offset { padding-left: 16px; } .key { flex: 1; max-width: 40px; height: 100%; background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(240 5% 22%)')}; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: 500; color: ${cssManager.bdTheme('hsl(0 0% 10%)', 'hsl(0 0% 95%)')}; cursor: pointer; user-select: none; transition: background 0.1s ease, transform 0.05s ease; box-shadow: 0 1px 3px ${cssManager.bdTheme('rgba(0, 0, 0, 0.15)', 'rgba(0, 0, 0, 0.4)')}; text-transform: none; -webkit-tap-highlight-color: transparent; } .key:active, .key.pressed { transform: scale(0.95); background: ${cssManager.bdTheme('hsl(220 15% 92%)', 'hsl(240 5% 28%)')}; } .key:focus { outline: none; } .key.special { background: ${cssManager.bdTheme('hsl(220 10% 88%)', 'hsl(240 5% 16%)')}; font-size: 16px; } .key.modifier { background: ${cssManager.bdTheme('hsl(220 10% 88%)', 'hsl(240 5% 16%)')}; font-size: 16px; } .key.modifier.active { background: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(217 91% 50%)')}; color: white; } .key.layout { background: ${cssManager.bdTheme('hsl(220 10% 88%)', 'hsl(240 5% 16%)')}; font-size: 14px; font-weight: 600; } .key.space { background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(240 5% 22%)')}; } .key.wide-1-5 { flex: 1.5; max-width: 60px; } .key.wide-2 { flex: 2; max-width: 80px; } .key.wide-2-5 { flex: 2.5; max-width: 100px; } .key.wide-3 { flex: 3; max-width: 140px; } .key.wide-4 { flex: 4; max-width: 180px; } /* Alternatives popup */ .alternatives-popup { position: absolute; display: flex; gap: 2px; background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(240 6% 18%)')}; border-radius: 10px; padding: 6px; box-shadow: 0 4px 20px ${cssManager.bdTheme('rgba(0, 0, 0, 0.25)', 'rgba(0, 0, 0, 0.6)')}; z-index: 10000; pointer-events: none; } .alternative-key { min-width: 36px; height: 44px; display: flex; align-items: center; justify-content: center; border-radius: 6px; font-size: 20px; font-weight: 500; color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 90%)')}; transition: background 0.1s ease; } .alternative-key.selected { background: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(217 91% 50%)')}; color: white; } /* Key preview on press */ .key-preview { position: absolute; width: 48px; height: 56px; background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(240 5% 25%)')}; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 24px; font-weight: 500; color: ${cssManager.bdTheme('hsl(0 0% 10%)', 'hsl(0 0% 95%)')}; box-shadow: 0 4px 16px ${cssManager.bdTheme('rgba(0, 0, 0, 0.2)', 'rgba(0, 0, 0, 0.5)')}; z-index: 10000; pointer-events: none; } `, ]; @property({ type: Boolean, reflect: true }) accessor visible = false; @property({ type: String }) accessor layout: TKeyboardLayout = 'qwerty'; @state() accessor shiftActive = false; @state() accessor capsLock = false; @state() accessor alternativesPopup: { key: string; alternatives: string[]; x: number; y: number; selectedIndex: number; } | null = null; @state() accessor keyPreview: { key: string; x: number; y: number } | null = null; private longPressTimer: ReturnType | null = null; private longPressStartX = 0; private currentLongPressKey: string | null = null; public render(): TemplateResult { const currentLayout = layouts[this.layout]; return html`
${currentLayout.map((row, rowIndex) => this.renderRow(row, rowIndex))} ${this.alternativesPopup ? this.renderAlternativesPopup() : ''} ${this.keyPreview ? this.renderKeyPreview() : ''}
`; } private renderRow(row: IKeyConfig[], rowIndex: number): TemplateResult { const isSecondRow = rowIndex === 1 && this.layout === 'qwerty'; return html`
${row.map((keyConfig) => this.renderKey(keyConfig))}
`; } private renderKey(config: IKeyConfig): TemplateResult { const type = config.type || 'char'; const widthClass = config.width ? `wide-${String(config.width).replace('.', '-')}` : ''; const isShift = config.key === 'shift'; const isActive = isShift && (this.shiftActive || this.capsLock); let displayValue = config.display ?? config.key; if (type === 'char' && this.layout === 'qwerty') { displayValue = (this.shiftActive || this.capsLock) ? displayValue.toUpperCase() : displayValue.toLowerCase(); } return html`
this.handlePointerDown(e, config)} @pointerup=${(e: PointerEvent) => this.handlePointerUp(e, config)} @pointerleave=${(e: PointerEvent) => this.handlePointerLeave(e, config)} @pointermove=${(e: PointerEvent) => this.handlePointerMove(e, config)} @mousedown=${(e: MouseEvent) => e.preventDefault()} > ${displayValue}
`; } private renderAlternativesPopup(): TemplateResult { if (!this.alternativesPopup) return html``; const { alternatives, x, y, selectedIndex } = this.alternativesPopup; const popupWidth = alternatives.length * 40; return html`
${alternatives.map((alt, index) => html`
${alt}
`)}
`; } private renderKeyPreview(): TemplateResult { if (!this.keyPreview) return html``; const { key, x, y } = this.keyPreview; return html`
${(this.shiftActive || this.capsLock) ? key.toUpperCase() : key}
`; } private handlePointerDown(e: PointerEvent, config: IKeyConfig): void { e.preventDefault(); e.stopPropagation(); const target = e.currentTarget as HTMLElement; target.setPointerCapture(e.pointerId); const type = config.type || 'char'; // Show key preview for character keys if (type === 'char') { const rect = target.getBoundingClientRect(); const containerRect = this.shadowRoot!.querySelector('.keyboard-container')!.getBoundingClientRect(); this.keyPreview = { key: config.key, x: rect.left + rect.width / 2 - containerRect.left, y: rect.top - containerRect.top, }; } // Start long press timer for keys with alternatives const keyLower = config.key.toLowerCase(); if (alternativesMap[keyLower] && type === 'char') { this.longPressStartX = e.clientX; this.currentLongPressKey = keyLower; this.longPressTimer = setTimeout(() => { const alternatives = alternativesMap[keyLower]; if (alternatives) { const rect = target.getBoundingClientRect(); const containerRect = this.shadowRoot!.querySelector('.keyboard-container')!.getBoundingClientRect(); this.alternativesPopup = { key: keyLower, alternatives, x: rect.left + rect.width / 2 - containerRect.left, y: rect.top - containerRect.top, selectedIndex: -1, }; this.keyPreview = null; } }, 500); } } private handlePointerMove(e: PointerEvent, config: IKeyConfig): void { if (this.alternativesPopup) { // Calculate which alternative is being hovered based on x position const deltaX = e.clientX - this.longPressStartX; const alternatives = this.alternativesPopup.alternatives; const keyWidth = 40; const totalWidth = alternatives.length * keyWidth; const startX = -totalWidth / 2; // Map deltaX to an index const relativeX = deltaX - startX; const index = Math.floor(relativeX / keyWidth); const clampedIndex = Math.max(-1, Math.min(alternatives.length - 1, index)); if (clampedIndex !== this.alternativesPopup.selectedIndex) { this.alternativesPopup = { ...this.alternativesPopup, selectedIndex: clampedIndex, }; } } } private handlePointerUp(e: PointerEvent, config: IKeyConfig): void { e.preventDefault(); e.stopPropagation(); this.clearLongPressTimer(); this.keyPreview = null; const type = config.type || 'char'; // Handle alternatives selection if (this.alternativesPopup) { const { selectedIndex, alternatives, key } = this.alternativesPopup; if (selectedIndex >= 0 && selectedIndex < alternatives.length) { this.emitKeyPress(alternatives[selectedIndex]); } else { // No alternative selected, emit original key this.emitKeyPress(key); } this.alternativesPopup = null; this.handleShiftAfterKeyPress(); return; } // Handle different key types switch (type) { case 'char': const char = (this.shiftActive || this.capsLock) ? config.key.toUpperCase() : config.key.toLowerCase(); this.emitKeyPress(char); this.handleShiftAfterKeyPress(); break; case 'special': this.handleSpecialKey(config); break; case 'modifier': this.handleModifierKey(config); break; case 'space': this.dispatchEvent(new CustomEvent('space', { bubbles: true, composed: true, })); this.dispatchEvent(new CustomEvent('key-press', { detail: { key: ' ', type: 'space' }, bubbles: true, composed: true, })); break; case 'layout': this.handleLayoutChange(config); break; } } private handlePointerLeave(e: PointerEvent, config: IKeyConfig): void { if (!this.alternativesPopup) { this.clearLongPressTimer(); this.keyPreview = null; } } private clearLongPressTimer(): void { if (this.longPressTimer) { clearTimeout(this.longPressTimer); this.longPressTimer = null; } this.currentLongPressKey = null; } private emitKeyPress(key: string): void { this.dispatchEvent(new CustomEvent('key-press', { detail: { key, type: 'char' }, bubbles: true, composed: true, })); } private handleSpecialKey(config: IKeyConfig): void { switch (config.key) { case 'backspace': this.dispatchEvent(new CustomEvent('backspace', { bubbles: true, composed: true, })); this.dispatchEvent(new CustomEvent('key-press', { detail: { key: 'Backspace', type: 'special' }, bubbles: true, composed: true, })); break; case 'enter': this.dispatchEvent(new CustomEvent('enter', { bubbles: true, composed: true, })); this.dispatchEvent(new CustomEvent('key-press', { detail: { key: 'Enter', type: 'special' }, bubbles: true, composed: true, })); break; case 'left': this.dispatchEvent(new CustomEvent('arrow', { detail: { direction: 'left' }, bubbles: true, composed: true, })); break; case 'right': this.dispatchEvent(new CustomEvent('arrow', { detail: { direction: 'right' }, bubbles: true, composed: true, })); break; case 'up': this.dispatchEvent(new CustomEvent('arrow', { detail: { direction: 'up' }, bubbles: true, composed: true, })); break; case 'down': this.dispatchEvent(new CustomEvent('arrow', { detail: { direction: 'down' }, bubbles: true, composed: true, })); break; case 'globe': // Could be used for language switching this.dispatchEvent(new CustomEvent('globe-press', { bubbles: true, composed: true, })); break; } } private handleModifierKey(config: IKeyConfig): void { if (config.key === 'shift') { if (this.capsLock) { // If caps lock is on, turn it off this.capsLock = false; this.shiftActive = false; } else if (this.shiftActive) { // Double tap shift = caps lock this.capsLock = true; this.shiftActive = false; } else { // Single tap = shift this.shiftActive = true; } } } private handleShiftAfterKeyPress(): void { // Turn off shift after typing (unless caps lock is on) if (this.shiftActive && !this.capsLock) { this.shiftActive = false; } } private handleLayoutChange(config: IKeyConfig): void { const action = config.action as TKeyboardLayout; if (action && layouts[action]) { this.layout = action; } } }