Keyboard & Focus Gate
Per-component tests that interactive controls are operable by keyboard, blocking PRs on failure.
Keyboard & Focus Gate
axe proves a control's structure; it doesn't press Tab or Enter.
The keyboard gate does. It asserts that interactive controls are operable
without a mouse — reachable, in the right tab order, activatable, with a visible
focus ring — and a broken interaction fails the build. Like axe, it runs in a
real browser inside pnpm gate, so it fires locally and in CI with no extra
steps.
Unlike axe (automatic on every story), keyboard checks are authored per
interactive component — operability is component-specific, so there's no
universal assertion to apply globally. You opt a component in by giving its story
a play function.
Use it
Give an interactive story a play function. @storybook/addon-vitest runs it
in the same Chromium as axe and turns a failed assertion into a failed test — no
extra wiring. Assert the four things every interactive control owes a keyboard
user:
import { expect, fn, userEvent, within } from 'storybook/test';
export const Operable = {
args: { onClick: fn() },
play: async ({ canvasElement, args }) => {
const button = within(canvasElement).getByRole('button');
await userEvent.tab(); // Reachable
await expect(button).toHaveFocus();
await expect(button.matches(':focus-visible')).toBe(true); // Visible focus ring
await userEvent.keyboard('{Enter}'); // Operable
await userEvent.keyboard(' ');
await expect(args.onClick).toHaveBeenCalled();
},
};
With more than one focusable control, a second userEvent.tab() plus a
toHaveFocus() assertion checks tab order. Break operability (e.g. a
non-focusable <div role="button">) and the gate goes red on toHaveFocus.
To run it:
pnpm gate # full suite, incl. keyboard
pnpm --filter @infinitibit_gmbh/storybook test # a11y gate only
How it works
- Same harness as axe. The
playfunction executes in the Vitest browser-mode Chromium that renders stories for axe, so keyboard and focus behave like a real user agent (:focus-visible, native Enter/Space activation). - Per-component, on purpose. There's no global keyboard assertion because
what's "operable" depends on the control. A story without a
playfunction simply isn't keyboard-tested — coverage is explicit. - Wired by inheritance. Same path as axe:
apps/storybook'stestscript flows throughturbo run test→pnpm gate→ci.ymlwith no workflow edit — the extension model from Testing & CI.
See ADR 0011 for the full rationale (and why the check is per-component, not global like axe).
Maintain it
- Add coverage by giving an interactive story a
playfunction (above) — the one place a11y coverage is per-component. - Mirror real usage. Assert what a keyboard user does — reach, order, activate, focus visibility — not internal state.
- Automated ≠ full AA. This gate catches a meaningful subset; pair it with the Axe gate and broader manual review from beta.