A custom dropdown that ignores Arrow keys, a modal that leaks focus to the page behind it, a tab bar operable only by mouse — keyboard bugs are the most common serious accessibility failures in custom widgets, and the ARIA Authoring Practices Guide already specifies exactly how each widget should behave. Repair against the spec, verify with a screen reader.
Tier: Core · Workflow area: Accessibility
This playbook uses AI to implement APG-conformant keyboard behavior — roving tabindex, focus traps, ARIA states — while keeping the acceptance test firmly human: hands on keys, screen reader talking.
Skip it when a maintained accessible primitive exists for the widget (Radix, React Aria, your design system) — adopting it is almost always the better repair than reimplementing the pattern.
Name the pattern and diff against its contract. For each broken widget, identify the APG pattern it should be (menu button? combobox? tabs with automatic or manual activation?). Then have the assistant produce the conformance diff:
Here is our TabBar component source. Against the APG "Tabs" pattern:
1. List every keyboard interaction the pattern requires (Tab, Arrow
keys, Home/End, activation model) and whether our code implements it.
2. List every required role/aria-* attribute (tablist, tab, tabpanel,
aria-selected, aria-controls) and whether ours is present and correct.
3. List focus-management requirements (roving tabindex vs
aria-activedescendant) and what we currently do.
Output a table: requirement | APG says | our code does | gap.
Verify the "APG says" column against the actual APG page — models paraphrase specs with small, load-bearing errors.
Implement the mechanics with AI, one concern per pass. Work the gap table in this order, as separate edits: (a) roles and states, (b) the keyboard event model, (c) focus management. For roving tabindex, be explicit:
Implement roving tabindex for this toolbar per APG: exactly one child
has tabindex="0" at any time, the rest tabindex="-1"; Arrow keys move
both focus and the tabindex; focus re-entry returns to the last-active
item; Home/End jump to first/last. Do not add aria-activedescendant —
we are using the roving pattern.
For dialogs: focus moves into the dialog on open (to the dialog or its first sensible control), Tab cycles inside it, Escape closes, and focus returns to the invoking element on close. Insist on the return-focus half — generated traps routinely forget it.
Wire the states the pattern requires, not decorative ARIA. aria-expanded on the trigger, aria-selected on tabs, aria-checked on menuitemcheckbox, aria-current where applicable — driven by the same state as the visual change, never a parallel copy. Have the assistant sweep for contradictions: ARIA claiming one state while the rendered UI shows another is worse than no ARIA.
Verify by hand, then by ear. Keyboard pass first: operate the widget completely without the mouse, checking every row of the gap table. Then the screen-reader pass: with NVDA or VoiceOver, confirm role and state are announced ("tab, selected, 2 of 4"), that arrowing produces sensible speech, and that opening/closing announces correctly. The model's claim of conformance counts for nothing here — only what you hear.
Pin the behavior in tests. Convert the gap table into RTL tests using userEvent.keyboard and accessible queries: arrow-key traversal, Home/End, trap-and-return for dialogs, and toHaveAttribute("aria-selected", "true") assertions. These tests are what stop the next refactor — human or AI — from silently regressing the pattern.
The APG is the contract, AI is the implementer, and your hands and ears are the acceptance test. A widget is fixed when the gap table is empty in the running app — not when the diff looks conformant.
Modifier.semantics/accessibility traits, and TalkBack/VoiceOver traversal replace tabindex mechanics.role="menu" without the keyboard model makes things worse: the screen reader promises menu behavior the widget cannot deliver. Roles are contracts; only claim ones you implement.Keyboard and focus repair is fully local work: the APG is public documentation, and screen readers run on your machine.
Do not let an agent "verify" screen-reader behavior by reading the DOM. Accessibility tree inspection is a useful proxy during development, but real AT has real quirks — acceptance is a human with NVDA or VoiceOver, every time.