keyboard contracts and spoken strings for the a11y layer
This commit is contained in:
@@ -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())
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user