aria grid roles, mobile keyboard entry, on-screen input switch
This commit is contained in:
@@ -25,6 +25,7 @@ import { useNarrator } from './hooks/useNarrator'
|
|||||||
import { useSounds } from './hooks/useSounds'
|
import { useSounds } from './hooks/useSounds'
|
||||||
import { useStore } from './store'
|
import { useStore } from './store'
|
||||||
import { generateShareText } from './share'
|
import { generateShareText } from './share'
|
||||||
|
import * as api from './api'
|
||||||
import * as offlineStore from './offline'
|
import * as offlineStore from './offline'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -33,6 +34,7 @@ export default function App() {
|
|||||||
const error = useStore((s) => s.error)
|
const error = useStore((s) => s.error)
|
||||||
const solved = useStore((s) => s.solved)
|
const solved = useStore((s) => s.solved)
|
||||||
const inputMode = useStore((s) => s.inputMode)
|
const inputMode = useStore((s) => s.inputMode)
|
||||||
|
const setInputMode = useStore((s) => s.setInputMode)
|
||||||
const fetchSession = useStore((s) => s.fetchSession)
|
const fetchSession = useStore((s) => s.fetchSession)
|
||||||
const loadByShortId = useStore((s) => s.loadByShortId)
|
const loadByShortId = useStore((s) => s.loadByShortId)
|
||||||
const checkPendingReceipts = useStore((s) => s.checkPendingReceipts)
|
const checkPendingReceipts = useStore((s) => s.checkPendingReceipts)
|
||||||
@@ -307,6 +309,16 @@ export default function App() {
|
|||||||
<div className="grid-column">
|
<div className="grid-column">
|
||||||
<h2 className="visually-hidden">{t('app.crosswordGrid')}</h2>
|
<h2 className="visually-hidden">{t('app.crosswordGrid')}</h2>
|
||||||
<Grid />
|
<Grid />
|
||||||
|
<div className="game-input-mode" role="group" aria-label={t('settings.display.inputMode')}>
|
||||||
|
{(['auto', 'keyboard', 'stylus'] as const).map(m => (
|
||||||
|
<button key={m} type="button"
|
||||||
|
className={`btn btn-sm ${inputMode === m ? 'btn-primary' : ''}`}
|
||||||
|
aria-pressed={inputMode === m}
|
||||||
|
onClick={() => { setInputMode(m); api.fetchSettings().then(s => api.saveSettings({ ...s, inputMode: m })) }}>
|
||||||
|
{m === 'auto' ? t('settings.display.inputAuto') : m === 'keyboard' ? t('settings.display.inputKeyboard') : t('settings.display.inputStylus')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
{inputMode !== 'keyboard' && <ZoomStrip onDone={() => {}} />}
|
{inputMode !== 'keyboard' && <ZoomStrip onDone={() => {}} />}
|
||||||
<h2 className="visually-hidden">{t('app.gameControls')}</h2>
|
<h2 className="visually-hidden">{t('app.gameControls')}</h2>
|
||||||
<InfoBar />
|
<InfoBar />
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export default function Grid() {
|
|||||||
const svgRef = useRef<SVGSVGElement>(null)
|
const svgRef = useRef<SVGSVGElement>(null)
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||||
const cellRefs = useRef<(HTMLButtonElement | null)[]>([])
|
const cellRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||||
|
const typingRef = useRef<HTMLInputElement>(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)
|
||||||
@@ -35,7 +36,8 @@ export default function Grid() {
|
|||||||
const height = puzzle?.height ?? 0
|
const height = puzzle?.height ?? 0
|
||||||
|
|
||||||
useEffect(() => {
|
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
|
const idx = selectedCell.row * width + selectedCell.col
|
||||||
cellRefs.current[idx]?.focus()
|
cellRefs.current[idx]?.focus()
|
||||||
}, [selectedCell, width])
|
}, [selectedCell, width])
|
||||||
@@ -53,6 +55,31 @@ export default function Grid() {
|
|||||||
activeSpan?.cells.map(([r, c]) => `${r},${c}`) ?? [],
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={wrapperRef}
|
ref={wrapperRef}
|
||||||
@@ -100,7 +127,11 @@ export default function Grid() {
|
|||||||
key={`${r},${c}`}
|
key={`${r},${c}`}
|
||||||
id={`cell-${r}-${c}`}
|
id={`cell-${r}-${c}`}
|
||||||
data-cell={`${r},${c}`}
|
data-cell={`${r},${c}`}
|
||||||
onClick={() => !isBlack && selectCell(r, c)}
|
onClick={() => {
|
||||||
|
if (isBlack) return
|
||||||
|
selectCell(r, c)
|
||||||
|
focusTypingField(r, c)
|
||||||
|
}}
|
||||||
style={{ cursor: isBlack ? 'default' : 'pointer' }}
|
style={{ cursor: isBlack ? 'default' : 'pointer' }}
|
||||||
>
|
>
|
||||||
<rect
|
<rect
|
||||||
@@ -193,36 +224,62 @@ 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 */}
|
{/* screen reader layer: a real ARIA grid mirroring the visual one */}
|
||||||
<div
|
<div
|
||||||
className="grid-sr-grid"
|
className="grid-sr-grid"
|
||||||
|
role="grid"
|
||||||
|
aria-label={t('grid.ariaLabel', { width, height })}
|
||||||
|
aria-rowcount={height}
|
||||||
|
aria-colcount={width}
|
||||||
style={{ gridTemplateColumns: `repeat(${width}, 1fr)`, aspectRatio: `${width} / ${height}` }}
|
style={{ gridTemplateColumns: `repeat(${width}, 1fr)`, aspectRatio: `${width} / ${height}` }}
|
||||||
>
|
>
|
||||||
{Array.from({ length: height }, (_, r) =>
|
{Array.from({ length: height }, (_, r) => (
|
||||||
Array.from({ length: width }, (_, c) => {
|
<div key={`sr-row-${r}`} role="row" aria-rowindex={r + 1} className="grid-sr-row" style={{ display: 'contents' }}>
|
||||||
const isBlack = puzzle.cells[r][c] === 'black'
|
{Array.from({ length: width }, (_, c) => {
|
||||||
const isSelected = selectedCell?.row === r && selectedCell?.col === c
|
const isBlack = puzzle.cells[r][c] === 'black'
|
||||||
const num = puzzle.cell_numbers[`${r},${c}`]
|
const isSelected = selectedCell?.row === r && selectedCell?.col === c
|
||||||
const letter = entries[r]?.[c] || ''
|
const label = buildCellLabel(r, c)
|
||||||
const pencilLetter = !letter && pencilEntries[r]?.[c] ? pencilEntries[r][c] : ''
|
return (
|
||||||
const status: CellStatus = cellStatus[r]?.[c] || 'default'
|
<button
|
||||||
const label = isBlack ? t('grid.blackCell') :
|
key={`sr-${r}-${c}`}
|
||||||
t('grid.cellLabel', { row: r + 1, col: c + 1 }) + (num ? t('grid.cellNumber', { num }) : '') + (letter ? t('grid.cellLetter', { letter }) : t('grid.cellEmpty')) +
|
ref={el => { cellRefs.current[r * width + c] = el }}
|
||||||
(status === 'correct' ? t('grid.cellCorrect') : status === 'incorrect' ? t('grid.cellIncorrect') : status === 'revealed' ? t('grid.cellRevealed') : pencilLetter ? t('grid.cellPencil', { letter: pencilLetter }) : '')
|
type="button"
|
||||||
return (
|
className="grid-sr-cell"
|
||||||
<button
|
role="gridcell"
|
||||||
key={`sr-${r}-${c}`}
|
aria-rowindex={r + 1}
|
||||||
ref={el => { cellRefs.current[r * width + c] = el }}
|
aria-colindex={c + 1}
|
||||||
type="button"
|
tabIndex={isSelected ? 0 : -1}
|
||||||
className="grid-sr-cell"
|
aria-label={label}
|
||||||
tabIndex={isSelected ? 0 : -1}
|
onClick={() => !isBlack && selectCell(r, c)}
|
||||||
aria-label={label}
|
/>
|
||||||
onClick={() => !isBlack && selectCell(r, c)}
|
)
|
||||||
/>
|
})}
|
||||||
)
|
</div>
|
||||||
}),
|
))}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
ref={typingRef}
|
||||||
|
className="grid-type-input"
|
||||||
|
type="text"
|
||||||
|
autoCapitalize="characters"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
inputMode="text"
|
||||||
|
onChange={e => {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<CellFlip svgRef={svgRef} />
|
<CellFlip svgRef={svgRef} />
|
||||||
<InkOverlay svgRef={svgRef} />
|
<InkOverlay svgRef={svgRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -86,6 +86,27 @@
|
|||||||
cursor: default;
|
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 {
|
.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;
|
||||||
|
|||||||
Reference in New Issue
Block a user