keyboard contracts and spoken strings for the a11y layer

This commit is contained in:
2026-08-20 15:26:00 +03:00
parent a2938e0d64
commit 306c15de65
7 changed files with 172 additions and 61 deletions
+15 -13
View File
@@ -199,19 +199,21 @@ export default function MultiplayerPanel({ onClose }: { onClose: () => void }) {
// already selected, do nothing
} else {
const rect = e.currentTarget.getBoundingClientRect()
const canvas = document.createElement('canvas')
canvas.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:9999'
document.body.appendChild(canvas)
const myConfetti = confetti.create(canvas, { resize: true })
myConfetti({
particleCount: 60,
spread: 70,
startVelocity: 25,
origin: {
x: (rect.left + rect.width / 2) / window.innerWidth,
y: (rect.top + rect.height / 2) / window.innerHeight,
},
}).then(() => canvas.remove())
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
const canvas = document.createElement('canvas')
canvas.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:9999'
document.body.appendChild(canvas)
const myConfetti = confetti.create(canvas, { resize: true })
myConfetti({
particleCount: 60,
spread: 70,
startVelocity: 25,
origin: {
x: (rect.left + rect.width / 2) / window.innerWidth,
y: (rect.top + rect.height / 2) / window.innerHeight,
},
}).then(() => canvas.remove())
}
}
}}
>
+24 -5
View File
@@ -1,3 +1,5 @@
import { useTranslation } from 'react-i18next'
interface Props {
value: number
min?: number
@@ -9,14 +11,31 @@ interface Props {
}
export default function NumberInput({ value, min = 0, max = 999, onChange, className, style, 'aria-label': ariaLabel }: Props) {
const { t } = useTranslation()
const clamp = (n: number) => Math.min(max, Math.max(min, n))
const onKeyDown = (e: React.KeyboardEvent) => {
let next: number | null = null
if (e.key === 'ArrowUp') next = value + 1
else if (e.key === 'ArrowDown') next = value - 1
else if (e.key === 'PageUp') next = value + 10
else if (e.key === 'PageDown') next = value - 10
else if (e.key === 'Home') next = min
else if (e.key === 'End') next = max
if (next === null) return
e.preventDefault()
onChange(clamp(next))
}
return (
<div className={`cv-number ${className || ''}`} style={style}
role="spinbutton" aria-valuenow={value} aria-valuemin={min} aria-valuemax={max} aria-label={ariaLabel}>
<button className="cv-number-btn" onClick={() => onChange(clamp(value - 1))} type="button" disabled={value <= min} aria-label="Decrease">-</button>
<span className="cv-number-value">{value}</span>
<button className="cv-number-btn" onClick={() => onChange(clamp(value + 1))} type="button" disabled={value >= max} aria-label="Increase">+</button>
role="spinbutton" aria-valuenow={value} aria-valuemin={min} aria-valuemax={max}
aria-label={ariaLabel} tabIndex={0} onKeyDown={onKeyDown}>
<button className="cv-number-btn" onClick={() => onChange(clamp(value - 1))} type="button" disabled={value <= min}
aria-label={t('ui.decrease')} tabIndex={-1}>-</button>
<span className="cv-number-value" aria-hidden="true">{value}</span>
<button className="cv-number-btn" onClick={() => onChange(clamp(value + 1))} type="button" disabled={value >= max}
aria-label={t('ui.increase')} tabIndex={-1}>+</button>
</div>
)
}
}
+59 -9
View File
@@ -19,10 +19,14 @@ export default function Select({ value, options, onChange, className, style }: P
const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
const dropRef = useRef<HTMLDivElement>(null)
const optionRefs = useRef<(HTMLButtonElement | null)[]>([])
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 })
const listboxId = useId()
const selected = options.find(o => String(o.value) === String(value))
const selectedIndex = options.findIndex(o => String(o.value) === String(value))
const close = useCallback(() => setOpen(false), [])
const updatePos = useCallback(() => {
if (!triggerRef.current) return
@@ -30,9 +34,16 @@ export default function Select({ value, options, onChange, className, style }: P
setPos({ top: rect.bottom, left: rect.left, width: rect.width })
}, [])
// move real focus to the option matching the current value (or the first)
useEffect(() => {
if (!open) return
updatePos()
const target = optionRefs.current[selectedIndex >= 0 ? selectedIndex : 0]
target?.focus()
}, [open, selectedIndex, updatePos])
useEffect(() => {
if (!open) return
function onDown(e: MouseEvent) {
const target = e.target
if (target instanceof Node && dropRef.current && !dropRef.current.contains(target) &&
@@ -40,36 +51,75 @@ export default function Select({ value, options, onChange, className, style }: P
setOpen(false)
}
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false)
}
window.addEventListener('scroll', updatePos, true)
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey)
return () => {
window.removeEventListener('scroll', updatePos, true)
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey)
}
}, [open, updatePos])
const moveFocus = useCallback((delta: number) => {
const refs = optionRefs.current
const current = refs.findIndex(el => el === document.activeElement)
const next = Math.min(refs.length - 1, Math.max(0, (current >= 0 ? current : 0) + delta))
refs[next]?.focus()
}, [])
// trigger-level keys: arrows cycle values like a native select while closed
const onTriggerKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
const delta = e.key === 'ArrowDown' ? 1 : -1
const next = Math.min(options.length - 1, Math.max(0, selectedIndex + delta))
if (next !== selectedIndex && options[next]) onChange(options[next].value)
return
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setOpen(!open)
return
}
if (e.key === 'Escape') {
close()
}
}
const onOptionKeyDown = (e: React.KeyboardEvent, index: number) => {
if (e.key === 'ArrowDown') { e.preventDefault(); moveFocus(1); return }
if (e.key === 'ArrowUp') { e.preventDefault(); moveFocus(-1); return }
if (e.key === 'Home') { e.preventDefault(); optionRefs.current[0]?.focus(); return }
if (e.key === 'End') { e.preventDefault(); optionRefs.current[options.length - 1]?.focus(); return }
if (e.key === 'Escape') { e.preventDefault(); close(); triggerRef.current?.focus(); return }
if (e.key === 'Tab') { close(); return }
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
const o = options[index]
if (o) { onChange(o.value); close(); triggerRef.current?.focus() }
}
}
return (
<div className={`cv-select ${className || ''}`} style={style}>
<button className="cv-select-trigger" ref={triggerRef} onClick={() => setOpen(!open)} type="button"
aria-haspopup="listbox" aria-expanded={open} aria-controls={open ? listboxId : undefined}>
aria-haspopup="listbox" aria-expanded={open} aria-controls={open ? listboxId : undefined}
onKeyDown={onTriggerKeyDown}>
<span>{selected?.label || String(value)}</span>
<IconChevronDownFilled size={10} style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 150ms' }} />
</button>
{open && createPortal(
<div className="cv-select-dropdown" ref={dropRef} id={listboxId} role="listbox"
style={{ top: pos.top, left: pos.left, minWidth: pos.width }}>
{options.map(o => (
{options.map((o, i) => (
<button
key={String(o.value)}
ref={el => { optionRefs.current[i] = el }}
role="option"
aria-selected={String(o.value) === String(value)}
tabIndex={i === (selectedIndex >= 0 ? selectedIndex : 0) ? 0 : -1}
className={`cv-select-option ${String(o.value) === String(value) ? 'cv-select-option-active' : ''}`}
onClick={() => { onChange(o.value); setOpen(false) }}
onClick={() => { onChange(o.value); close() }}
onKeyDown={e => onOptionKeyDown(e, i)}
type="button"
>
{o.label}
@@ -80,4 +130,4 @@ export default function Select({ value, options, onChange, className, style }: P
)}
</div>
)
}
}