word-waltz / files / src / components / Keyboard.ts
Keyboard.ts
| 1 | /** |
| 2 | * Keyboard — on-screen keyboard |
| 3 | * |
| 4 | * Displays a QWERTY keyboard. Keys are colored to reflect the best-known |
| 5 | * state for each letter across all guesses in the current round: |
| 6 | * 'correct' > 'present' > 'absent' > (no state) |
| 7 | */ |
| 8 | |
| 9 | export type LetterState = 'correct' | 'present' | 'absent'; |
| 10 | |
| 11 | const ROWS = ['QWERTYUIOP', 'ASDFGHJKL', 'ENTERZCVBNM⌫'] as const; |
| 12 | |
| 13 | interface KeyboardProps { |
| 14 | onKey: (key: string) => void; |
| 15 | letterStates: Readonly<Record<string, LetterState>>; |
| 16 | } |
| 17 | |
| 18 | export function mountKeyboard(container: HTMLElement, props: KeyboardProps): () => void { |
| 19 | const { onKey } = props; |
| 20 | |
| 21 | const root = document.createElement('div'); |
| 22 | root.className = 'keyboard'; |
| 23 | |
| 24 | for (const rowStr of ROWS) { |
| 25 | const rowEl = document.createElement('div'); |
| 26 | rowEl.className = 'keyboard__row'; |
| 27 | |
| 28 | if (rowStr.startsWith('ENTER')) { |
| 29 | // Bottom row: Enter — middle letters — Backspace |
| 30 | appendKey(rowEl, 'Enter', 'Enter', onKey, true, 'Submit guess'); |
| 31 | for (const ch of rowStr.slice('ENTER'.length, -1)) { |
| 32 | appendKey(rowEl, ch, ch, onKey, false); |
| 33 | } |
| 34 | appendKey(rowEl, '⌫', 'Backspace', onKey, true, 'Delete letter'); |
| 35 | } else { |
| 36 | for (const ch of rowStr) { |
| 37 | appendKey(rowEl, ch, ch, onKey, false); |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | root.appendChild(rowEl); |
| 42 | } |
| 43 | |
| 44 | applyStates(root, props.letterStates); |
| 45 | container.appendChild(root); |
| 46 | |
| 47 | function handleKeydown(e: KeyboardEvent) { |
| 48 | if (e.ctrlKey || e.metaKey || e.altKey) return; |
| 49 | const k = e.key; |
| 50 | if (k === 'Enter' || k === 'Backspace') { onKey(k); return; } |
| 51 | if (/^[a-zA-Z]$/.test(k)) onKey(k.toUpperCase()); |
| 52 | } |
| 53 | window.addEventListener('keydown', handleKeydown); |
| 54 | |
| 55 | return function cleanup() { |
| 56 | window.removeEventListener('keydown', handleKeydown); |
| 57 | container.removeChild(root); |
| 58 | }; |
| 59 | } |
| 60 | |
| 61 | function appendKey( |
| 62 | row: HTMLElement, |
| 63 | label: string, |
| 64 | key: string, |
| 65 | onKey: (k: string) => void, |
| 66 | wide: boolean, |
| 67 | ariaLabel?: string, |
| 68 | ): void { |
| 69 | const btn = document.createElement('button'); |
| 70 | btn.type = 'button'; |
| 71 | btn.className = 'keyboard__key' + (wide ? ' keyboard__key--wide' : ''); |
| 72 | btn.textContent = label; |
| 73 | btn.dataset['key'] = key; |
| 74 | if (ariaLabel) btn.setAttribute('aria-label', ariaLabel); |
| 75 | btn.addEventListener('click', () => onKey(key)); |
| 76 | row.appendChild(btn); |
| 77 | } |
| 78 | |
| 79 | export function updateKeyboard( |
| 80 | container: HTMLElement, |
| 81 | letterStates: Readonly<Record<string, LetterState>>, |
| 82 | ): void { |
| 83 | const keyboard = container.querySelector('.keyboard'); |
| 84 | if (keyboard) applyStates(keyboard as HTMLElement, letterStates); |
| 85 | } |
| 86 | |
| 87 | function applyStates( |
| 88 | root: HTMLElement, |
| 89 | letterStates: Readonly<Record<string, LetterState>>, |
| 90 | ): void { |
| 91 | root.querySelectorAll<HTMLButtonElement>('[data-key]').forEach((btn) => { |
| 92 | const key = btn.dataset['key']!; |
| 93 | const state = letterStates[key]; |
| 94 | btn.classList.remove('keyboard__key--correct', 'keyboard__key--present', 'keyboard__key--absent'); |
| 95 | if (state) btn.classList.add(`keyboard__key--${state}`); |
| 96 | }); |
| 97 | } |