Building an Accessible React DatePicker to WCAG 2.2 AA Standards
Our dev team started with Claude-generated DatePicker code based on the WAI-ARIA APG "Date Picker Dialog" pattern. The initial prompt specified WCAG 2.1/2.2 Level AA requirements: input with aria-describedby, trigger button, modal dialog with role="dialog", and a grid-based calendar table. However, the raw version revealed critical issues with focus, navigation, and accessibility.
The AI implemented basic keyboard navigation (arrow keys, Home/End, PageUp/Down, Enter/Space, Escape), roving tabindex on gridcell elements, and aria-live="polite" for month changes. But testing with VoiceOver and NVDA exposed problems: focus didn't land on the selected date when opening, aria-live announcements queued up, and the dialog flickered on repeated clicks.
Deviations from APG for Real-World Accessibility
The APG spec calls for <td role="gridcell"> without nested buttons, relying on manual onClick/onKeyDown and tabindex. Real-world testing showed clear advantages to using <button> inside <td>:
- Native focus handling and Enter/Space support without extra code.
- Automatic semantics for screen readers.
- Built-in disabled state without aria-disabled or preventDefault.
- Reliable click events from mouse or keyboard.
This was a deliberate choice: APG is a guide, but device testing takes priority. We ensured ≥4.5:1 contrast, used CSS Modules, and native Date/Intl APIs for formatting—no external dependencies beyond React/TypeScript.
Fixing Functional and UX Issues
The raw code wouldn't compile: missing index.html and TypeScript errors. After fixes, more issues surfaced:
Functional bugs:
- Dates outside minDate/maxDate jumped to another month instead of showing disabled cells.
- No way to clear the input.
Visual problems:
- Missing focus styles on cells.
- Uneven td sizing.
Accessibility:
- No focus on selected date when opening—screen readers stayed silent.
- aria-live="polite" on month header caused announcement queues during fast navigation.
- Blur + click on trigger caused dialog flicker.
Fixes:
- On open, focus lands on value or today; screen reader announces context (weekday, date, month, year).
- aria-live="assertive" in a separate hidden region for instant month announcements.
- After selection, focus returns to trigger with updated aria-label.
- Show value's month even with disabled days.
- Removed OK/Cancel: Enter confirms, Esc closes.
Component Structure and Keyboard Navigation
Props: value (Date | null), onChange, minDate?, maxDate?, disabledDates?, locale?.
Key ARIA:
- Input: aria-describedby for format, readonly.
- Trigger: dynamic aria-label ("Selected: MM/DD/YYYY" or "Pick a date").
- Dialog: role="dialog", aria-modal="true", focus trap.
- Grid: table role="grid", td with button role="gridcell", aria-selected on value, aria-disabled on unavailable dates.
- Month header: aria-live="assertive" in polite region.
Navigation:
- Arrow keys: adjacent days.
- Home/End: first/last day of month.
- PageUp/Down: ±1 month.
- Shift+PageUp/Down: ±1 year.
- Enter/Space: select.
- Escape: close.
Key Takeaways
- AI speeds up the skeleton (ARIA, navigation), but screen reader testing is essential.
- <button> inside <td> is more reliable than pure gridcell in real browsers + screen readers.
- aria-live="assertive" prevents queues during month navigation.
- Focus management: open on value/today, return to trigger.
- Minimal stack: React + TS, no libraries.
The component is production-ready as a single-date picker, with open code for extensions (years, ranges).
— Editorial Team
No comments yet.