screen reader grid and readout shortcuts

This commit is contained in:
2026-08-20 14:20:00 +03:00
parent ae28fd2dcb
commit 4689cb5971
7 changed files with 151 additions and 20 deletions
+44 -11
View File
@@ -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<SVGSVGElement>(null)
const wrapperRef = useRef<HTMLDivElement>(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 (
<div
ref={wrapperRef}
className={`grid-wrapper${inputMode === 'stylus' ? ' stylus-active' : ''}`}
style={{ maxWidth: width * CELL, width: '100%' }}
onContextMenu={inputMode === 'stylus' ? (e => 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"
>
<defs>
<style>{`
@@ -87,9 +95,6 @@ export default function Grid() {
if (status === 'incorrect') textColor = 'var(--incorrect)'
if (status === 'revealed') textColor = 'var(--revealed)'
const ariaLabel = 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'))
return (
<g
key={`${r},${c}`}
@@ -97,8 +102,6 @@ export default function Grid() {
data-cell={`${r},${c}`}
onClick={() => !isBlack && selectCell(r, c)}
style={{ cursor: isBlack ? 'default' : 'pointer' }}
role="gridcell"
aria-label={ariaLabel}
>
<rect
x={x} y={y}
@@ -190,6 +193,36 @@ export default function Grid() {
strokeWidth={1.5}
/>
</svg>
{/* screen reader layer: one focusable cell per grid cell, invisible to the eye */}
<div
className="grid-sr-grid"
style={{ gridTemplateColumns: `repeat(${width}, 1fr)`, aspectRatio: `${width} / ${height}` }}
>
{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 (
<button
key={`sr-${r}-${c}`}
ref={el => { cellRefs.current[r * width + c] = el }}
type="button"
className="grid-sr-cell"
tabIndex={isSelected ? 0 : -1}
aria-label={label}
onClick={() => !isBlack && selectCell(r, c)}
/>
)
}),
)}
</div>
<CellFlip svgRef={svgRef} />
<InkOverlay svgRef={svgRef} />
</div>
+11
View File
@@ -164,6 +164,17 @@ function NarratorSettingsBlock() {
]} />
</div>
<div className="settings-narrator-item">
<label className="settings-sublabel">{t('settings.narrator.output')}</label>
<Select value={narratorSettings.mode}
onChange={v => { const s = String(v); if (s === 'voice' || s === 'screenreader' || s === 'both') setNarratorSettings({ mode: s }) }}
options={[
{ value: 'voice', label: t('settings.narrator.outputVoice') },
{ value: 'screenreader', label: t('settings.narrator.outputScreenReader') },
{ value: 'both', label: t('settings.narrator.outputBoth') },
]} />
</div>
<Toggle checked={narratorSettings.sounds} onChange={v => setNarratorSettings({ sounds: v })}
label={t('settings.narrator.soundEffects')} />
</motion.div>