diff --git a/README.md b/README.md index 1d6f22b..559bd90 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,9 @@ Instances talk to each other over ActivityPub, and nobody stands in the middle. **Comfort and access** - Light, dark, and system themes, warm palettes, and text scaling up to 200% -- Colorblind palettes, a screen-reader narrator, keyboard-only play, and reduced-motion cell animations +- A screen-reader native grid: every cell is focusable and announced with its number, letter, and state, and Alt+A / Alt+D read the across and down words aloud (/ reads the clue, space reads the cell, Ctrl+H the puzzle overview) +- A voice narrator with three verbosity levels and an output mode (voice, screen reader, or both) so blind players get the same flow through their own screen reader +- Colorblind palettes and reduced-motion cell animations - Sound effects and a tor-friendly mode that keeps the page light for slow connections --- diff --git a/frontend/src/components/Grid.tsx b/frontend/src/components/Grid.tsx index d224cb4..fb7650f 100644 --- a/frontend/src/components/Grid.tsx +++ b/frontend/src/components/Grid.tsx @@ -1,4 +1,4 @@ -import { useRef } from 'react' +import { useRef, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { motion, AnimatePresence } from 'motion/react' import { useStore } from '../store' @@ -17,6 +17,8 @@ function getCellSize(gridSize: number): number { export default function Grid() { const { t } = useTranslation() const svgRef = useRef(null) + const wrapperRef = useRef(null) + const cellRefs = useRef<(HTMLButtonElement | null)[]>([]) const puzzle = useStore((s) => s.puzzle) const entries = useStore((s) => s.entries) const cellStatus = useStore((s) => s.cellStatus) @@ -29,9 +31,17 @@ export default function Grid() { const mpPlayers = useStore((s) => s.mpPlayers) const inputMode = useStore((s) => s.inputMode) + const width = puzzle?.width ?? 0 + const height = puzzle?.height ?? 0 + + useEffect(() => { + if (!selectedCell || !wrapperRef.current?.contains(document.activeElement)) return + const idx = selectedCell.row * width + selectedCell.col + cellRefs.current[idx]?.focus() + }, [selectedCell, width]) + if (!puzzle) return null - const { width, height } = puzzle const CELL = getCellSize(Math.max(width, height)) const NUM_SIZE = Math.round(CELL * 0.26) const LETTER_SIZE = Math.round(CELL * 0.52) @@ -45,6 +55,7 @@ export default function Grid() { return (
e.preventDefault()) : undefined} @@ -53,10 +64,7 @@ export default function Grid() { ref={svgRef} viewBox={`0 0 ${width * CELL} ${height * CELL}`} style={{ width: '100%', maxWidth: width * CELL, display: 'block' }} - tabIndex={0} - role="grid" - aria-label={t('grid.ariaLabel', { width, height })} - aria-activedescendant={selectedCell ? `cell-${selectedCell.row}-${selectedCell.col}` : undefined} + aria-hidden="true" >