Taking the frontend to WCAG 2.2 AA
Four gates that each own a class of accessibility defect, and what they found on the way to WCAG 2.2 AA.
Accessibility in the application is held by four gates, and each owns a class of defect the other three cannot see. That split is the main result of the work, because whoever fixes a defect needs to know which gate will catch it coming back. The rest was the defects the gates found once they existed.
Visible is not operable#
The groundwork landed two days earlier, with the rebrand, and started from focus rings that were
mostly invisible. Tailwind compiles an inset ring to a custom property that prefixes every ring
shadow, including the offset one, so any control that used its ring as a resting edge drew its
focus ring inside itself, behind its own fill. The ring colour was also the accent at 45% opacity,
about 1.6:1 against the light card, well under the 3:1 a focus indicator needs. Focus moved to
outline, whose gap is genuinely transparent, in a solid colour per theme. Field frames also lit
up on mouse clicks, because :focus-visible matches any focused element that accepts typing, so
a small module now records whether the last input came from a keyboard or a pointer.
Visible was still not operable. The dropdown had no keyboard handling at all: no arrow keys, no
Escape, no typeahead, and a trigger that was a div with a click handler. I moved menus and
selects onto Headless UI, which the modal already relied on, and split what had been one
component into two. A menu is a list of commands and every choice closes it; a popover is a panel
of state the user keeps adjusting, and it claims no arrow keys. The calendar opened from the
keyboard and then trapped it, because its Escape handler was bound to a panel teleported to the
end of the document, which never saw the key. Table headers and rows were click-only, so sorting
a column and opening a record could not be done without a mouse.
Two defects took far longer than the rest. Every menu item in the application was dead to the
keyboard: spreading an object of attributes onto an element inside a loop makes the Vue compiler
mark its ref for an array, which rode through Headless UI's prop merge onto the menu item's own
ref, so pressing Enter clicked nothing and closed the menu. Three attempts to reason it out from
the source failed; compiling the component and running a differential reproduction found it. The
other was focus restore. Headless UI returns focus to the trigger when a dialog or menu closes,
but not under the application's transitions, which unmount the dialog before its restore runs, so
focus landed on the page body. A small composable now restores it, retrying each frame until the
dialog's focus lock lets go.
Four gates, one defect class each#
Template lint runs in the editor and on commit, with every rule of the accessibility plugin at error. It judges each template node in isolation, so it cannot see the ARIA attributes Headless UI binds at runtime.
A policy spec holds the invariants that span files, whose regressions fail nothing anywhere
else. An icon-font glyph is text on a private-use codepoint that several screen readers read out
as noise, and of 71 icon call sites only 3 remembered aria-hidden. The icon component now hides
itself by default and the spec forbids a raw icon element that bypasses it. The spec also pins the
list of files allowed to open a live region, because a live region is a claim on the user's speech
channel and a new one should be a reviewed decision.
An axe sweep runs in a real browser against WCAG 2.2 AA over nine classes of surface: the
signed-out pages, a list, a form, an open dialog over content, the accessibility statement, and a
kitchen sink that renders every primitive in every state. It is the only gate that sees the
rendered tree, including everything Headless UI adds. Its builder has an ordering trap worth
knowing: options() replaces the object that withTags() writes into, so rules set in the wrong
order silently vanish.
Keyboard journeys judge what no scanner can: where focus lands, where it returns and whether a control can be operated at all. They cover the skip link, the sidebar, dialogs, menus, selects, the grouping tree, notebook tabs and date segments.
What the gates found#
Widening the sweep surfaced defects the narrower one never rendered. The accordion hid a collapsed
body with aria-hidden while its links stayed tabbable, so a screen reader reached stops it could
not narrate; inert removes a subtree from the tab order and the accessibility tree in one move.
The notebook tab strip declared a tab list and then left every tab a separate stop with no arrow
keys and no link to its panel, which reads worse than no ARIA at all, because the role promises a
keyboard contract the widget breaks. It now uses a roving tab index.
An invalid field changed colour and nothing else. Four widgets announced their error and fifteen
rendered a message no control pointed at. Every widget now marks its real control invalid and
points it at its message, and both halves are gated, because a message without an id renders fine
and simply stops being reachable. The select's frame was a button rendered as a div, so the
library stamped aria-expanded onto an element whose role cannot take it: the one critical axe
violation known in the application. The frame is now a plain container, the input owns the combobox
role, and the chevron is the real button, kept out of the tab order.
Every signed-in page gained a skip link as its first tab stop. Its target is the actual scrolling region, which also needed a tab index and a region role, because a scrollable region a keyboard cannot focus is one a keyboard cannot scroll.
Saying so in public#
The European Accessibility Act expects a service to state its conformance, name its known limitations and give people a way to report a barrier. The statement is public and reachable signed out, so someone who is blocked can always read it, and its claim is partial conformance while the product is pre-release. It now lives with the other legal documents on the public site.
The public site came later and needed one more piece. Following a link there was a client-side navigation that moved no focus and announced nothing, so a screen reader user heard nothing at all and the next Tab continued from a page that had gone. Since September, both the site and the application announce each page change and move focus to the new page's content, which is also where the skip link points.