Accessibility
Every UniAria component follows its pattern in the WAI-ARIA Authoring Practices (APG), with React Aria as the reference for the browser and screen-reader edge cases the patterns don’t cover. Where a component deliberately differs from the APG, its page says so and why.
- Roles, states and relationships:
role,aria-checked,aria-expanded,aria-activedescendant, labels and descriptions linked by id, and the rest, kept current as state changes. UniAria never writesaria-disabled="false"oraria-invalid="false", which screen readers read aloud as “not disabled”. - Keyboard support as the APG describes, plus the details that make it work across browsers: Space and Enter timing, typeahead, focus wrapping, right-to-left arrows.
- Focus management: where focus goes when an overlay opens and closes,
focus kept inside a modal dialog, a single tab stop for groups and lists,
a press that focuses what it presses in every browser (Safari doesn’t
focus a clicked button or checkbox on its own), and a
data-focus-visibleattribute that is consistent across browsers. - Input from every device: one press event for mouse, pen, touch, keyboard and screen-reader clicks; hover that ignores touch; presses that a scroll cancels.
- Announcements: a field’s error when it appears, and other changes a screen reader wouldn’t otherwise report, through one shared live region. While a modal dialog is open, the region moves inside it, since the page outside is inert.
- Mobile quirks: iOS scroll locking, emulated mouse events after a touch, dropped clicks after a long press, text selection during a touch press.
Every part another part refers to gets an id: a label that names a control,
a description, a dialog’s title, the list a combo box controls. A part keeps
an id you give it, and everything that refers to the part follows: the
label’s for, aria-labelledby, aria-describedby, aria-controls,
aria-activedescendant. UniAria writes an id only on a part that has none,
and an id you change or remove later is followed too.
<uni-text-field>
<label data-element="Label" id="email-label">Email</label>
<input data-element="Input" id="email" />
</uni-text-field>In React, write the id after the part spread, so it wins over the part’s
own: <label {...ui.parts.label} id="email-label">; in Vue, after v-bind:
<label v-bind="ui.parts.label" id="email-label">. The first render can’t
see the element, so until hydration the server HTML refers to the part’s
own id. Where that matters, as for a label’s for before JavaScript runs,
name the control’s id through the component’s prop instead (inputId,
controlId, triggerId, listId): the server render uses it.
Without either, ids are generated from the component’s id (useId in React
and Vue).
Options and the other repeated parts of a list always get ids made from the
list’s id and their key, <list id>-option-<key>, since a combo box refers
to the highlighted option by its key.
Each component has conformance scenarios, written from its approved specification before its implementation; TextInput, the input inside the text fields, is checked through TextField’s. The same scenarios run:
| Run | When | What |
|---|---|---|
| Browsers | Every push | Real mouse, touch and keyboard input through Playwright, in Chromium, Firefox and WebKit, through the React and Vue adapters and the custom elements. |
| Virtual screen reader | Every push | What a screen reader would say for each state, with Guidepup’s virtual screen reader. |
| VoiceOver and NVDA | Every night | VoiceOver on macOS and NVDA on Windows, with Chromium, driven through Guidepup, for scenarios whose speech only a real reader shows. |
| Render counts | Every push | In React and Vue, hovering, pressing, focusing, highlighting and opening an overlay render the owning component zero times. |
Manual testing with screen readers, on mobile screen readers in particular, hasn’t been done yet.
The nightly runs found behavior that comes from the screen readers and browsers themselves, the same with plain HTML:
- NVDA reads the page again whenever anything in the top layer closes: a
dialog, a popover, even a plain
<dialog>. Its focus report then names the element focus returned to. - VoiceOver is sometimes silent the first time a dialog opens on a page,
also with a plain
<dialog>. - A combo box’s highlight moving through the options isn’t reliably captured from either reader’s speech, so those scenarios check what the reader reports as focused, which names each option.
They also found one UniAria bug, now fixed: NVDA said “clickable” before every control inside a custom element, because the element had click listeners on itself. Custom elements now listen from the document (or shadow root) instead, as React does.
UniAria can’t decide everything a page needs:
- Names. Give every control a label part, a visible label it can
reference, or an
aria-label. UniAria warns in development when a list has no name. Icon-only buttons need anaria-label. - Visible focus. Style
data-focus-visible(or:focus-visible) on every focusable part, with enough contrast. A text field shows focus however it came, so draw its ring ondata-focused. - Contrast, sizes and motion. Colors, target sizes, and respecting
prefers-reduced-motionin your transitions are in your CSS. - Required marks. Mark a visual
*witharia-hidden="true"; the control already says “required”. - Content. Error messages that say how to fix the problem, dialog titles, link text that makes sense on its own.