InfinitiBit Design System

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 play function 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 play function simply isn't keyboard-tested — coverage is explicit.
  • Wired by inheritance. Same path as axe: apps/storybook's test script flows through turbo run testpnpm gateci.yml with 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 play function (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.

On this page