screen reader and keyboard usability pass

This commit is contained in:
2026-08-23 17:51:46 +03:00
parent db54882933
commit 093b27f3f4
33 changed files with 638 additions and 640 deletions
+38 -3
View File
@@ -38,11 +38,18 @@ export default function DatePicker({ value, onChange, className }: Props) {
const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
const calRef = useRef<HTMLDivElement>(null)
const dayRefs = useRef<(HTMLButtonElement | null)[]>([])
const [focusDay, setFocusDay] = useState<number | null>(null)
const { year, month } = parse(value)
const [viewYear, setViewYear] = useState(year)
const [viewMonth, setViewMonth] = useState(month)
const [pos, setPos] = useState({ top: 0, left: 0 })
// the day of the selected value, when it falls inside the current view
const selected = parse(value)
const selectedInView =
selected.year === viewYear && selected.month === viewMonth ? selected.day : 1
const updatePos = useCallback(() => {
if (!triggerRef.current) return
const rect = triggerRef.current.getBoundingClientRect()
@@ -60,7 +67,7 @@ export default function DatePicker({ value, onChange, className }: Props) {
}
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false)
if (e.key === 'Escape') { setFocusDay(null); setOpen(false); triggerRef.current?.focus() }
}
window.addEventListener('scroll', updatePos, true)
document.addEventListener('mousedown', onDown)
@@ -87,12 +94,38 @@ export default function DatePicker({ value, onChange, className }: Props) {
const pick = (d: number) => {
onChange(fmt(viewYear, viewMonth, d))
setFocusDay(null)
setOpen(false)
}
const focusDayRef = (d: number) => {
setFocusDay(d)
dayRefs.current[d - 1]?.focus()
}
const onGridKeyDown = (e: { key: string; preventDefault(): void }) => {
const cur = focusDay != null ? focusDay : selectedInView
let next = cur
if (e.key === 'ArrowRight') next = cur + 1
else if (e.key === 'ArrowLeft') next = cur - 1
else if (e.key === 'ArrowDown') next = cur + 7
else if (e.key === 'ArrowUp') next = cur - 7
else return
e.preventDefault()
if (next < 1 || next > days) return
focusDayRef(next)
}
useEffect(() => {
if (!open) return
const d = focusDay != null && focusDay >= 1 && focusDay <= days ? focusDay : selectedInView
dayRefs.current[d - 1]?.focus()
}, [open, days, selectedInView])
return (
<div className={`cv-datepicker ${className || ''}`}>
<button className="cv-select-trigger" ref={triggerRef} onClick={() => setOpen(!open)} type="button">
<button className="cv-select-trigger" ref={triggerRef} onClick={() => setOpen(!open)} type="button"
aria-haspopup="dialog" aria-expanded={open}>
<span>{value}</span>
<IconChevronDownFilled size={10} style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 150ms' }} />
</button>
@@ -107,7 +140,7 @@ export default function DatePicker({ value, onChange, className }: Props) {
<div className="cv-cal-days" role="grid">
{WEEKDAYS.map(d => <span key={d} className="cv-cal-dayname">{d}</span>)}
</div>
<div className="cv-cal-grid" role="grid">
<div className="cv-cal-grid" role="grid" onKeyDown={onGridKeyDown}>
{Array.from({ length: offset }, (_, i) => <span key={`pad-${i}`} />)}
{Array.from({ length: days }, (_, i) => {
const d = i + 1
@@ -117,8 +150,10 @@ export default function DatePicker({ value, onChange, className }: Props) {
return (
<button
key={d}
ref={el => { dayRefs.current[d - 1] = el }}
className={`cv-cal-day ${isSelected ? 'cv-cal-day-selected' : ''} ${isToday ? 'cv-cal-day-today' : ''}`}
onClick={() => pick(d)}
onFocus={() => setFocusDay(d)}
type="button"
aria-selected={isSelected}
>