Agent docs

15 · Accessibility

The rules that make a control reachable, nameable, and announced, across every component rather…

The rules that make a control reachable, nameable, and announced, across every component rather than per component. The field half of accessibility lives with the forms canon (doc 13); this doc owns the cross-component half: accessible-name precedence, validity and message association, live-region urgency, and taking a hidden subtree out of reach. Kit sources: the *.tsx of Input, Select, Modal, Tabs, Alert, Toast, Dropzone, SidePanel. Law, not contract: read ../../packages/ui/docs/ for which props a component takes.

Audience: an agent adding or reviewing any interactive component, or wiring ARIA on a hand-rolled control.

§A Principles and mental models#

1. Accessible-name precedence: explicit, then generated, then a safe fallback. The order is always the same: a caller's explicit aria-label/aria-labelledby wins; failing that, a generated stable id associates the name the component already renders; failing that, a safe textual default. Input applies it across textarea, checkbox/toggle, and input (input/input.tsx:329), and Modal follows it, a string ModalTitle stamping the id the dialog is labelled by, a caller's aria-label/aria-labelledby still winning (packages/ui/src/modal/modal.tsx:62). A caption merely rendered NEAR a control is not its name; the association has to exist in the markup.

2. Validity is derived, and the message is associated, not just displayed. A truthy error derives aria-invalid unless the caller overrides it: Input (input/input.tsx:345), Select (select/select.tsx:348), Dropzone. A built-in message gets a stable id merged into aria-describedby alongside any caller-supplied ids, so the control points at the text that explains it: Input (input/input.tsx:349), and Select accepts aria-describedby for its externally-rendered message (select/select.tsx:343). An error message carries role="alert" so it is announced (input/input.tsx BottomRow; SchemaForm's field already did this at schema-form/field.tsx:116). The id-merging convention is shared with SchemaForm's describedByIds (schema-form/field.tsx:61).

3. A control that opens something reports that it is open. A collapsed trigger carries aria-expanded on the CLOSED control, not only once a panel has opened: Select's closed control (select/select.tsx:347). A control that owns a panel points at it (aria-controls), and the panel points back (aria-labelledby): Tabs' active panel names itself from the active tab (tabs/tabs.tsx:173).

4. Live-region urgency follows severity, not the component. Error interrupts (role="alert"); info, success, warning, and loading are polite (role="status"); an explicit override handles the exceptional case. Alert defaults by tone (packages/ui/src/alert/alert.tsx:68), Toast by variant (toast/toast.tsx:73), each accepting a role override. A component does not get one fixed politeness for every message it shows.

5. A hidden or offscreen subtree is taken out of reach, not just out of the tree. aria-hidden removes a subtree from the accessibility tree but leaves its descendants in sequential focus. A closed overlay that stays mounted uses inert so its controls are neither focusable nor announced: SidePanel while closed (side-panel/side-panel.tsx:95). A closed drop target is not a nested button: a real Button is the only nested interactive control, the region around it a non-button drag zone.

6. The kit covers a kit component; a hand-rolled control gets nothing. Everything above is wired inside the kit's own components. A raw <button>, a <div onClick>, or a control wrapped only in a Tooltip gets no name, no role, and no association from the kit. The rule holds regardless of layer: reachable, nameable, operable by keyboard (doc 2 §A9 states the principle; this doc says how the kit satisfies it).

§B Presets and shared helpers#

  • The id-merging helper. describedByIds(id, has) (schema-form/field.tsx:61) builds the ordered aria-describedby id list (help, hint, warning, message) that a field's messages need; the same merge (caller ids + the built-in message id) is applied on the direct Input and Dropzone controls.
  • The ModalTitle context. A string ModalTitle registers a stable id (modal/modal-title-context.ts) so the dialog is labelled by its title automatically; a custom-node title leaves labelling to the caller's aria-label/aria-labelledby.
  • The severity-to-role map. error → alert; info, success, warning, loading → status; explicit override for the exceptional case.

§C Cases, through the apps#

  • A direct field now announces its own error. Before finding 4.1, Input/Select/Dropzone showed an error visually but derived no aria-invalid and associated no message, while SchemaForm already did it right (schema-form/field.tsx:116). The fix brought the direct controls up to the SchemaForm pattern: truthy error → aria-invalid, a stable message id in aria-describedby, role="alert" on the message.
  • A Select reports its open state. The closed control was a div role="button" with aria-haspopup and aria-controls but no aria-expanded; it now carries aria-expanded={open} on the closed control (select/select.tsx:347), not only on the search box after opening.
  • A closed SidePanel stops being a keyboard trap. It stayed mounted at width zero with only aria-hidden, leaving its close button and children tabbable; inert while closed (side-panel/side-panel.tsx:95) takes the whole subtree out of the focus order.

Travels with#

  • Doc 13 (forms and fields) owns the field-level accessibility contract; this doc owns the cross-component rules that also reach non-field controls.
  • Doc 2 §A9 owns the reachable/nameable/operable principle; this doc says how the kit satisfies it and where a hand-rolled control falls outside it.
  • The per-component contracts in ../../packages/ui/docs/ say which ARIA props each component accepts.
@versable-git/ui · reference, canon, and method, read in place