Accessibility
The two contracts — colour, which is tested, and interaction, which is centralised.
Two things in this system are easy to get wrong and impossible to see: what colour a token resolves to in the other mode, and where focus goes. Both now have a single place where they are decided.
Colour is tested
Every threshold is enforced by npm test, in both modes, before CI builds
anything. The full table lives in Tokens.
Writing those rules down found seven defects that had already shipped and been
reviewed by eye — text/error at 4.44:1 on its own tint, dark bg/brand-hover
putting white text at 3.9:1, dark text/placeholder at 2.86:1 on a card.
Two of the rules are not accessibility rules at all. They exist because the two worst colour bugs this system shipped were invisibility, not contrast:
- Adjacent layers are never equal. An active tab whose pill and track were
both
bg/raisedhad 1.00:1 against what it sat on. It was simply not there. - A tint is ≤ 2:1 against the canvas. Dark tints once aliased saturated
900primitives, so every recommendation block was a solid slab.
Focus is centralised
aria-modal="true" is a promise that the rest of the page is inert. Nothing in
the DOM enforces it. Without a Tab handler the third press walks focus out
through the scrim and into the page behind, where the user is typing into a form
they cannot see.
Modal, Drawer and the command palette all made that promise and none of them
kept it. They now share one hook — useDialog — which owns four things:
| Focus in | lands on the first control, not the container, so the dialog announces something |
| Focus trapped | Tab and Shift+Tab cycle inside the panel; focus that escapes is pulled back |
| Escape closes | every dismissable surface, always |
| Focus restored | back to whatever opened it |
It also locks the page behind, holding the scrollbar's width as padding — the
Drawer was locking the body without that, so opening one shifted the whole
page sideways.
const panel = useDialog<HTMLDivElement>({ open, onClose });
return (
<Scrim onClick={onClose}>
<div ref={panel} role="dialog" aria-modal="true" aria-labelledby={id} tabIndex={-1}>
…
</div>
</Scrim>
);Anything new that covers the page uses this hook. Writing the four behaviours by hand is how three surfaces ended up with three different subsets of them.
lockScroll: false is available for a drawer that deliberately sits beside a
still-scrollable page.
Naming the thing that opened
A role="dialog" with no accessible name is announced as "dialog" and nothing
else. Modal derives its name from its title via aria-labelledby; Drawer
and the palette use aria-label; Popover takes an aria-label prop and
defaults to something rather than nothing.
State belongs on the control, not its wrapper
Popover used to put aria-expanded and aria-controls on the wrapper div
that caught the click, while the render prop returned the actual <Button>.
Mouse and keyboard both worked — the click bubbled — but a screen reader landed
on the button and heard an ordinary button, because the state was on an element
it never visited.
The wrapper still catches the click; the ARIA is now cloned onto the returned element. If you build a trigger-plus-panel component, put the state on whatever the user actually focuses.
Focus is tested too
tests/dom/dialog.test.tsx asserts the contract against the real Modal and
Drawer rather than against the hook in isolation — the hook was never the
problem, three hand-written subsets of it were.
| Focus lands on the first control, not the panel | Modal |
Tab wraps last → first | Modal, Drawer |
Shift+Tab wraps first → last | Modal |
| Focus that escapes is pulled back | Modal |
| Escape closes | Modal |
| Focus returns to the opener | Modal |
| Page locked while open, released after | Modal |
| Scrollbar compensated, so the page does not jump | Drawer |
| A panel with nothing focusable holds focus | useDialog |
lockScroll: false leaves the page scrollable | useDialog |
These were checked against the pre-fix implementation before being kept: with the trap removed, the first-control focus reverted and the scrollbar padding dropped, six of the eleven fail. A test that passes against the broken code is not a test.
Tab is dispatched as a keydown on document, which is exactly what the hook
listens for. happy-dom has no native tab order, so driving it through
user-event would be testing the simulation rather than the trap.
The colour contract stays on node's built-in runner — it reads JSON and does
arithmetic, so it needs no transform, no DOM and no dependencies. npm test
runs both.
What is not done
Tooltipopens on hover and focus but its content is not reachable by keyboard, which is correct for a tooltip and wrong if anyone puts a link in one. UsePopoverfor interactive content.- Reduced-motion preferences are not yet honoured by the transition utilities.