screen reader grid and readout shortcuts
This commit is contained in:
@@ -70,7 +70,9 @@ Instances talk to each other over ActivityPub, and nobody stands in the middle.
|
|||||||
|
|
||||||
**Comfort and access**
|
**Comfort and access**
|
||||||
- Light, dark, and system themes, warm palettes, and text scaling up to 200%
|
- 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
|
- Sound effects and a tor-friendly mode that keeps the page light for slow connections
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRef } from 'react'
|
import { useRef, useEffect } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { motion, AnimatePresence } from 'motion/react'
|
import { motion, AnimatePresence } from 'motion/react'
|
||||||
import { useStore } from '../store'
|
import { useStore } from '../store'
|
||||||
@@ -17,6 +17,8 @@ function getCellSize(gridSize: number): number {
|
|||||||
export default function Grid() {
|
export default function Grid() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const svgRef = useRef<SVGSVGElement>(null)
|
const svgRef = useRef<SVGSVGElement>(null)
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||||
|
const cellRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||||
const puzzle = useStore((s) => s.puzzle)
|
const puzzle = useStore((s) => s.puzzle)
|
||||||
const entries = useStore((s) => s.entries)
|
const entries = useStore((s) => s.entries)
|
||||||
const cellStatus = useStore((s) => s.cellStatus)
|
const cellStatus = useStore((s) => s.cellStatus)
|
||||||
@@ -29,9 +31,17 @@ export default function Grid() {
|
|||||||
const mpPlayers = useStore((s) => s.mpPlayers)
|
const mpPlayers = useStore((s) => s.mpPlayers)
|
||||||
const inputMode = useStore((s) => s.inputMode)
|
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
|
if (!puzzle) return null
|
||||||
|
|
||||||
const { width, height } = puzzle
|
|
||||||
const CELL = getCellSize(Math.max(width, height))
|
const CELL = getCellSize(Math.max(width, height))
|
||||||
const NUM_SIZE = Math.round(CELL * 0.26)
|
const NUM_SIZE = Math.round(CELL * 0.26)
|
||||||
const LETTER_SIZE = Math.round(CELL * 0.52)
|
const LETTER_SIZE = Math.round(CELL * 0.52)
|
||||||
@@ -45,6 +55,7 @@ export default function Grid() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={wrapperRef}
|
||||||
className={`grid-wrapper${inputMode === 'stylus' ? ' stylus-active' : ''}`}
|
className={`grid-wrapper${inputMode === 'stylus' ? ' stylus-active' : ''}`}
|
||||||
style={{ maxWidth: width * CELL, width: '100%' }}
|
style={{ maxWidth: width * CELL, width: '100%' }}
|
||||||
onContextMenu={inputMode === 'stylus' ? (e => e.preventDefault()) : undefined}
|
onContextMenu={inputMode === 'stylus' ? (e => e.preventDefault()) : undefined}
|
||||||
@@ -53,10 +64,7 @@ export default function Grid() {
|
|||||||
ref={svgRef}
|
ref={svgRef}
|
||||||
viewBox={`0 0 ${width * CELL} ${height * CELL}`}
|
viewBox={`0 0 ${width * CELL} ${height * CELL}`}
|
||||||
style={{ width: '100%', maxWidth: width * CELL, display: 'block' }}
|
style={{ width: '100%', maxWidth: width * CELL, display: 'block' }}
|
||||||
tabIndex={0}
|
aria-hidden="true"
|
||||||
role="grid"
|
|
||||||
aria-label={t('grid.ariaLabel', { width, height })}
|
|
||||||
aria-activedescendant={selectedCell ? `cell-${selectedCell.row}-${selectedCell.col}` : undefined}
|
|
||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
<style>{`
|
<style>{`
|
||||||
@@ -87,9 +95,6 @@ export default function Grid() {
|
|||||||
if (status === 'incorrect') textColor = 'var(--incorrect)'
|
if (status === 'incorrect') textColor = 'var(--incorrect)'
|
||||||
if (status === 'revealed') textColor = 'var(--revealed)'
|
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 (
|
return (
|
||||||
<g
|
<g
|
||||||
key={`${r},${c}`}
|
key={`${r},${c}`}
|
||||||
@@ -97,8 +102,6 @@ export default function Grid() {
|
|||||||
data-cell={`${r},${c}`}
|
data-cell={`${r},${c}`}
|
||||||
onClick={() => !isBlack && selectCell(r, c)}
|
onClick={() => !isBlack && selectCell(r, c)}
|
||||||
style={{ cursor: isBlack ? 'default' : 'pointer' }}
|
style={{ cursor: isBlack ? 'default' : 'pointer' }}
|
||||||
role="gridcell"
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
>
|
>
|
||||||
<rect
|
<rect
|
||||||
x={x} y={y}
|
x={x} y={y}
|
||||||
@@ -190,6 +193,36 @@ export default function Grid() {
|
|||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
/>
|
/>
|
||||||
</svg>
|
</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} />
|
<CellFlip svgRef={svgRef} />
|
||||||
<InkOverlay svgRef={svgRef} />
|
<InkOverlay svgRef={svgRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -164,6 +164,17 @@ function NarratorSettingsBlock() {
|
|||||||
]} />
|
]} />
|
||||||
</div>
|
</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 })}
|
<Toggle checked={narratorSettings.sounds} onChange={v => setNarratorSettings({ sounds: v })}
|
||||||
label={t('settings.narrator.soundEffects')} />
|
label={t('settings.narrator.soundEffects')} />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useStore } from '../store'
|
import { useStore } from '../store'
|
||||||
|
import type { Direction } from '../types'
|
||||||
import * as narrator from '../narrator'
|
import * as narrator from '../narrator'
|
||||||
|
|
||||||
function readCurrentCell() {
|
function readCurrentCell() {
|
||||||
@@ -23,6 +24,23 @@ function readCurrentCell() {
|
|||||||
narrator.speak(msg)
|
narrator.speak(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readWord(dir: Direction) {
|
||||||
|
const { puzzle, selectedCell, entries, wordSpans } = useStore.getState()
|
||||||
|
if (!puzzle || !selectedCell) {
|
||||||
|
narrator.speak('No cell selected')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const span = wordSpans.find(s =>
|
||||||
|
s.direction === dir && s.cells.some(([r, c]) => r === selectedCell.row && c === selectedCell.col)
|
||||||
|
)
|
||||||
|
if (!span) {
|
||||||
|
narrator.speak(`No ${dir} word here`, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const letters = span.cells.map(([r, c]) => entries[r]?.[c] || 'blank')
|
||||||
|
narrator.speak(`${dir === 'across' ? 'Across' : 'Down'} ${span.number}, ${span.cells.length} letters: ${letters.join(', ')}.`, false)
|
||||||
|
}
|
||||||
|
|
||||||
function readCurrentClue() {
|
function readCurrentClue() {
|
||||||
const { puzzle, activeClue, direction, wordSpans, entries, narratorSettings } = useStore.getState()
|
const { puzzle, activeClue, direction, wordSpans, entries, narratorSettings } = useStore.getState()
|
||||||
if (!puzzle || activeClue === null) {
|
if (!puzzle || activeClue === null) {
|
||||||
@@ -83,7 +101,7 @@ function getGridEl(): HTMLElement | null {
|
|||||||
let gridHasFocus = false
|
let gridHasFocus = false
|
||||||
|
|
||||||
function updateGridFocus(target: EventTarget | null) {
|
function updateGridFocus(target: EventTarget | null) {
|
||||||
gridHasFocus = target instanceof Node && !!getGridEl()?.contains(target)
|
gridHasFocus = !!getGridEl()?.contains(document.activeElement) || (target instanceof Element && !!getGridEl()?.contains(target))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useKeyboard() {
|
export function useKeyboard() {
|
||||||
@@ -109,8 +127,8 @@ export function useKeyboard() {
|
|||||||
|
|
||||||
// give the grid initial keyboard focus when nothing else is focused
|
// give the grid initial keyboard focus when nothing else is focused
|
||||||
if (document.activeElement === document.body || document.activeElement === null) {
|
if (document.activeElement === document.body || document.activeElement === null) {
|
||||||
const svg = getGridEl()?.querySelector('svg')
|
const firstCell = getGridEl()?.querySelector('button.grid-sr-cell')
|
||||||
if (svg instanceof SVGSVGElement) svg.focus()
|
if (firstCell instanceof HTMLButtonElement) firstCell.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
@@ -144,6 +162,16 @@ export function useKeyboard() {
|
|||||||
readPuzzleOverview()
|
readPuzzleOverview()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (e.altKey && key.toLowerCase() === 'a') {
|
||||||
|
e.preventDefault()
|
||||||
|
readWord('across')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.altKey && key.toLowerCase() === 'd') {
|
||||||
|
e.preventDefault()
|
||||||
|
readWord('down')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (key === 'Tab') {
|
if (key === 'Tab') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
tabClue(!e.shiftKey)
|
tabClue(!e.shiftKey)
|
||||||
|
|||||||
@@ -66,6 +66,22 @@
|
|||||||
[data-colorblind="deuteranopia"] { --correct: #3b82f6; --incorrect: #f97316; }
|
[data-colorblind="deuteranopia"] { --correct: #3b82f6; --incorrect: #f97316; }
|
||||||
[data-colorblind="tritanopia"] { --correct: #ef4444; --incorrect: #06b6d4; }
|
[data-colorblind="tritanopia"] { --correct: #ef4444; --incorrect: #06b6d4; }
|
||||||
|
|
||||||
|
.grid-sr-grid {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-sr-cell {
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
background: transparent;
|
||||||
|
pointer-events: none;
|
||||||
|
outline: none;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.visually-hidden {
|
.visually-hidden {
|
||||||
position: absolute; width: 1px; height: 1px;
|
position: absolute; width: 1px; height: 1px;
|
||||||
padding: 0; margin: -1px; overflow: hidden;
|
padding: 0; margin: -1px; overflow: hidden;
|
||||||
|
|||||||
@@ -86,6 +86,10 @@
|
|||||||
"cellNumber": ", number {{num}}",
|
"cellNumber": ", number {{num}}",
|
||||||
"cellLetter": ", letter {{letter}}",
|
"cellLetter": ", letter {{letter}}",
|
||||||
"cellEmpty": ", empty",
|
"cellEmpty": ", empty",
|
||||||
|
"cellCorrect": ", correct",
|
||||||
|
"cellIncorrect": ", incorrect",
|
||||||
|
"cellRevealed": ", revealed",
|
||||||
|
"cellPencil": ", pencil letter {{letter}}",
|
||||||
"handwritingCanvas": "Handwriting input area"
|
"handwritingCanvas": "Handwriting input area"
|
||||||
},
|
},
|
||||||
"welcome": {
|
"welcome": {
|
||||||
@@ -544,6 +548,10 @@
|
|||||||
"brief": "Brief",
|
"brief": "Brief",
|
||||||
"normal": "Normal",
|
"normal": "Normal",
|
||||||
"verbose": "Verbose",
|
"verbose": "Verbose",
|
||||||
|
"output": "Output",
|
||||||
|
"outputVoice": "Voice",
|
||||||
|
"outputScreenReader": "Screen reader",
|
||||||
|
"outputBoth": "Both",
|
||||||
"soundEffects": "Sound effects",
|
"soundEffects": "Sound effects",
|
||||||
"notSupported": "Your browser does not support speech synthesis"
|
"notSupported": "Your browser does not support speech synthesis"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export interface NarratorSettings {
|
|||||||
pitch: number
|
pitch: number
|
||||||
voice: string
|
voice: string
|
||||||
verbosity: 'brief' | 'normal' | 'verbose'
|
verbosity: 'brief' | 'normal' | 'verbose'
|
||||||
|
mode: 'voice' | 'screenreader' | 'both'
|
||||||
sounds: boolean
|
sounds: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ export const defaultNarratorSettings: NarratorSettings = {
|
|||||||
pitch: 1.0,
|
pitch: 1.0,
|
||||||
voice: '',
|
voice: '',
|
||||||
verbosity: 'normal',
|
verbosity: 'normal',
|
||||||
|
mode: 'voice',
|
||||||
sounds: true,
|
sounds: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,8 +38,42 @@ export function cancel() {
|
|||||||
window.speechSynthesis.cancel()
|
window.speechSynthesis.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// a polite live region that screen readers announce from, created on demand
|
||||||
|
let srRegion: HTMLElement | null = null
|
||||||
|
|
||||||
|
function getSrRegion(): HTMLElement {
|
||||||
|
if (!srRegion) {
|
||||||
|
srRegion = document.createElement('div')
|
||||||
|
srRegion.setAttribute('aria-live', 'polite')
|
||||||
|
srRegion.setAttribute('role', 'status')
|
||||||
|
srRegion.className = 'visually-hidden'
|
||||||
|
document.body.appendChild(srRegion)
|
||||||
|
}
|
||||||
|
return srRegion
|
||||||
|
}
|
||||||
|
|
||||||
|
function announceToScreenReader(text: string) {
|
||||||
|
const region = getSrRegion()
|
||||||
|
const msg = document.createElement('span')
|
||||||
|
msg.textContent = `${text} `
|
||||||
|
region.appendChild(msg)
|
||||||
|
while (region.children.length > 6) {
|
||||||
|
const first = region.firstChild
|
||||||
|
if (first) region.removeChild(first)
|
||||||
|
}
|
||||||
|
setTimeout(() => msg.remove(), 15000)
|
||||||
|
}
|
||||||
|
|
||||||
export function speak(text: string, interrupt = true) {
|
export function speak(text: string, interrupt = true) {
|
||||||
if (!isSupported() || !settings.enabled) return
|
if (!settings.enabled) return
|
||||||
|
|
||||||
|
const mode = settings.mode
|
||||||
|
const wantsVoice = (mode === 'voice' || mode === 'both') && isSupported()
|
||||||
|
const wantsSr = mode === 'screenreader' || mode === 'both' || !isSupported()
|
||||||
|
|
||||||
|
if (wantsSr) announceToScreenReader(text)
|
||||||
|
if (!wantsVoice) return
|
||||||
|
|
||||||
if (interrupt) cancel()
|
if (interrupt) cancel()
|
||||||
|
|
||||||
const utterance = new SpeechSynthesisUtterance(text)
|
const utterance = new SpeechSynthesisUtterance(text)
|
||||||
@@ -57,9 +93,6 @@ export function speak(text: string, interrupt = true) {
|
|||||||
export function preloadVoices() {
|
export function preloadVoices() {
|
||||||
if (!isSupported()) return
|
if (!isSupported()) return
|
||||||
getVoices()
|
getVoices()
|
||||||
if (window.speechSynthesis.onvoiceschanged !== undefined) {
|
|
||||||
window.speechSynthesis.onvoiceschanged = () => {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
preloadVoices()
|
preloadVoices()
|
||||||
Reference in New Issue
Block a user