word-waltz / files / src / components / Keyboard.ts
Keyboard.ts
f755bca6 2026-03-23 Pavel Safronov | 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 | ||
1c548168 2026-03-24 Pavel Safronov | 9 | export type LetterState = 'correct' | 'present' | 'absent'; |
| 10 | ||
| 11 | const ROWS = ['QWERTYUIOP', 'ASDFGHJKL', 'ENTERZCVBNM⌫'] as const; | |
f755bca6 2026-03-23 Pavel Safronov | 12 | |
| 13 | interface KeyboardProps { | |
| 14 | onKey: (key: string) => void; | |
1c548168 2026-03-24 Pavel Safronov | 15 | letterStates: Readonly<Record<string, LetterState>>; |
f755bca6 2026-03-23 Pavel Safronov | 16 | } |
| 17 | ||
| 18 | export function mountKeyboard(container: HTMLElement, props: KeyboardProps): () => void { | |
1c548168 2026-03-24 Pavel Safronov | 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 | |
e86661e4 2026-03-24 Pavel Safronov | 30 | appendKey(rowEl, 'Enter', 'Enter', onKey, true, 'Submit guess'); |
1c548168 2026-03-24 Pavel Safronov | 31 | for (const ch of rowStr.slice('ENTER'.length, -1)) { |
| 32 | appendKey(rowEl, ch, ch, onKey, false); | |
| 33 | } | |
e86661e4 2026-03-24 Pavel Safronov | 34 | appendKey(rowEl, '⌫', 'Backspace', onKey, true, 'Delete letter'); |
1c548168 2026-03-24 Pavel Safronov | 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, | |
e86661e4 2026-03-24 Pavel Safronov | 67 | ariaLabel?: string, |
1c548168 2026-03-24 Pavel Safronov | 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; | |
e86661e4 2026-03-24 Pavel Safronov | 74 | if (ariaLabel) btn.setAttribute('aria-label', ariaLabel); |
1c548168 2026-03-24 Pavel Safronov | 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 | }); | |
f755bca6 2026-03-23 Pavel Safronov | 97 | } |