diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 42b3dd3..36ef639 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -25,6 +25,7 @@ import { useNarrator } from './hooks/useNarrator' import { useSounds } from './hooks/useSounds' import { useStore } from './store' import { generateShareText } from './share' +import * as api from './api' import * as offlineStore from './offline' export default function App() { @@ -33,6 +34,7 @@ export default function App() { const error = useStore((s) => s.error) const solved = useStore((s) => s.solved) const inputMode = useStore((s) => s.inputMode) + const setInputMode = useStore((s) => s.setInputMode) const fetchSession = useStore((s) => s.fetchSession) const loadByShortId = useStore((s) => s.loadByShortId) const checkPendingReceipts = useStore((s) => s.checkPendingReceipts) @@ -307,6 +309,16 @@ export default function App() {

{t('app.crosswordGrid')}

+
+ {(['auto', 'keyboard', 'stylus'] as const).map(m => ( + + ))} +
{inputMode !== 'keyboard' && {}} />}

{t('app.gameControls')}

diff --git a/frontend/src/components/Grid.tsx b/frontend/src/components/Grid.tsx index c2d90e2..0b8cba5 100644 --- a/frontend/src/components/Grid.tsx +++ b/frontend/src/components/Grid.tsx @@ -19,6 +19,7 @@ export default function Grid() { const svgRef = useRef(null) const wrapperRef = useRef(null) const cellRefs = useRef<(HTMLButtonElement | null)[]>([]) + const typingRef = useRef(null) const puzzle = useStore((s) => s.puzzle) const entries = useStore((s) => s.entries) const cellStatus = useStore((s) => s.cellStatus) @@ -35,7 +36,8 @@ export default function Grid() { const height = puzzle?.height ?? 0 useEffect(() => { - if (!selectedCell || !wrapperRef.current?.contains(document.activeElement)) return + if (!selectedCell || !wrapperRef.current) return + if (!wrapperRef.current.contains(document.activeElement)) return const idx = selectedCell.row * width + selectedCell.col cellRefs.current[idx]?.focus() }, [selectedCell, width]) @@ -53,6 +55,31 @@ export default function Grid() { activeSpan?.cells.map(([r, c]) => `${r},${c}`) ?? [], ) + // on touch devices, move focus into the hidden typing field so the + // on-screen keyboard appears, with the tapped cell announced as its label + const buildCellLabel = (r: number, c: number): string => { + const st = useStore.getState() + if (!st.puzzle) return t('grid.cellEmpty') + if (st.puzzle.cells[r][c] === 'black') return t('grid.blackCell') + const num = st.puzzle.cell_numbers[`${r},${c}`] + const letter = st.entries[r]?.[c] || '' + const pencilLetter = !letter && st.pencilEntries[r]?.[c] ? st.pencilEntries[r][c] : '' + const status: CellStatus = st.cellStatus[r]?.[c] || 'default' + return t('grid.cellLabel', { row: r + 1, col: c + 1 }) + + (num ? t('grid.cellNumber', { num }) : '') + + (letter ? t('grid.cellLetter', { letter }) : t('grid.cellEmpty')) + + (status === 'correct' ? t('grid.cellCorrect') : status === 'incorrect' ? t('grid.cellIncorrect') : status === 'revealed' ? t('grid.cellRevealed') : pencilLetter ? t('grid.cellPencil', { letter: pencilLetter }) : '') + } + + const focusTypingField = (r: number, c: number) => { + if (!window.matchMedia('(pointer: coarse)').matches) return + const field = typingRef.current + if (field) { + field.setAttribute('aria-label', buildCellLabel(r, c)) + field.focus({ preventScroll: true }) + } + } + return (
!isBlack && selectCell(r, c)} + onClick={() => { + if (isBlack) return + selectCell(r, c) + focusTypingField(r, c) + }} style={{ cursor: isBlack ? 'default' : 'pointer' }} > - {/* screen reader layer: one focusable cell per grid cell, invisible to the eye */} + {/* screen reader layer: a real ARIA grid mirroring the visual one */}
- {Array.from({ length: height }, (_, r) => - Array.from({ length: width }, (_, c) => { - const isBlack = puzzle.cells[r][c] === 'black' - const isSelected = selectedCell?.row === r && selectedCell?.col === c - const num = puzzle.cell_numbers[`${r},${c}`] - const letter = entries[r]?.[c] || '' - const pencilLetter = !letter && pencilEntries[r]?.[c] ? pencilEntries[r][c] : '' - const status: CellStatus = cellStatus[r]?.[c] || 'default' - const label = isBlack ? t('grid.blackCell') : - t('grid.cellLabel', { row: r + 1, col: c + 1 }) + (num ? t('grid.cellNumber', { num }) : '') + (letter ? t('grid.cellLetter', { letter }) : t('grid.cellEmpty')) + - (status === 'correct' ? t('grid.cellCorrect') : status === 'incorrect' ? t('grid.cellIncorrect') : status === 'revealed' ? t('grid.cellRevealed') : pencilLetter ? t('grid.cellPencil', { letter: pencilLetter }) : '') - return ( -
+ ))}
+ { + const value = e.target.value + e.target.value = '' + for (const ch of value.toLowerCase()) { + if (ch >= 'a' && ch <= 'z') useStore.getState().typeLetter(ch) + else if (ch === ' ') useStore.getState().toggleDirection() + } + }} + onKeyDown={e => { + if (e.key === 'Backspace' && !e.currentTarget.value) { + e.preventDefault() + useStore.getState().deleteLetter() + } + }} + />
diff --git a/frontend/src/index.css b/frontend/src/index.css index cdc1cb4..93d6141 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -86,6 +86,27 @@ cursor: default; } +/* hidden typing field so touch devices can summon the on-screen keyboard */ +.grid-type-input { + position: fixed; + bottom: 0; + left: 0; + width: 2px; + height: 2px; + padding: 0; + border: 0; + opacity: 0.01; + background: transparent; + color: transparent; + caret-color: transparent; +} + +.game-input-mode { + display: flex; + gap: 0.375rem; + margin-top: 0.5rem; +} + .visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden;