Accessibility

Summary

Soma targets WCAG 2.2 AA and treats it as a CI gate: the e2e suite runs an axe-core sweep over every page of this sandbox in all three themes, so a regression fails the pipeline rather than shipping. This page documents the postures that sweep can't fully express (focus management, keyboard contracts, live regions, the semantics the components apply for you) and your part as a consumer, the handful of things only your markup and data handling can get right.

Focus management

Focus rings

Every interactive element shows a visible ring on :focus-visible (keyboard focus), via the --soma-focus-ring token. Mouse clicks don't paint rings. Composite fields (quicksearch, the navbar search, the select) ring the wrapper via :focus-within instead of the inner input. If you build custom controls, consume the same token.

Dialogs: native modality

dialog2 (and the command palette) open a native <dialog> with showModal(): the rest of the page becomes inert (unfocusable, unclickable and hidden from assistive technology), which is containment stronger than any JS focus trap, and the platform restores focus to the opener on close. Initial focus follows the platform (first focusable, or your autofocus). The accessible name is derived automatically from the header heading: write a .soma-dialog2-header-main and the dialog announces itself by its title.

Popovers: focus return

Dropdowns, inline dialogs, the date picker and the select all return focus to their trigger when they close while focus is inside them; focus is never dropped onto <body>. Opening the date picker deliberately does not steal focus from the input: you can keep typing, and moves into the calendar when you want it.

Live regions

Content injected by JavaScript is only announced if it appears inside a live region, so the dynamic factories set the right role automatically: warning/danger toasts, banners and messages get role="alert" (interrupt); info/success get role="status" (queue politely). Static server-rendered markup needs no role — it was there at page load.

Keyboard reference

Every overlay — dialogs and the popover family (menus, inline dialogs, tooltips, the date picker, select menus, the drawer, toasts) — renders in the browser's top layer, so it is always visually above the page it belongs to, and Esc unwinds nested overlays one entry at a time, most recent first: a dropdown opened inside a dialog closes before the dialog does.

Dialogs

KeyAction
Tab / Shift+TabMove focus inside the dialog; the page behind is natively inert, so focus cannot leave.
EscClose the topmost dialog and restore focus (native; a strict modal suppresses it).

Dropdown menus

KeyAction
on the triggerOpen and move to the first item.
/, Tab/Shift+TabMove the cursor with wrap; Tab cannot escape an open menu.
Home / EndFirst / last item.
EnterInvoke the cursor item.
EscClose and refocus the trigger.

Tabs

KeyAction
/Next / previous tab (visual direction, mirrored in RTL), activated on move.
Home / EndFirst / last tab.

Date picker

KeyAction
in the inputOpen (if needed) and focus the calendar grid.
/±1 day (visual direction, mirrored in RTL).
/±1 week.
Home / EndStart / end of week.
PageUp / PageDown±1 month (Shift: ±1 year).
Enter / SpaceSelect the focused day.
EscClose and return to the input (without reopening).

Select

KeyAction
TypeFilter the options.
/Move through matches.
EnterChoose the highlighted option.
Backspace (multi, empty search)Remove the last pill.
EscClose, keeping the selection.

Sortable tables

KeyAction
TabHeaders are focusable (tabindex="0").
Enter / SpaceSort by that column; repeat to reverse. aria-sort tracks the state.

Tree, drawer, expander

KeyAction
TreeOne tab stop (roving tabindex): / move through visible items, / expand/collapse and walk the hierarchy (visual direction in RTL), Home/End jump, Enter/Space toggle; the toggle's label flips between the (localised) Expand/Collapse strings.
DrawerNon-modal: no blanket, no focus trap; the page stays interactive. Esc and outside-click close; focus returns to the trigger.
ExpanderEnter/Space on the trigger; state on aria-expanded.

Colour and contrast

  • Text contrast is audited against the effective translucent surface colour, not white; the tokens file documents each assumption.
  • Colour is never the sole state channel: status pairs with icons or text (badges say "failed", the meter has a value label).
  • --soma-color-text-subtle is decorative-only; anything that must be read uses -muted or stronger.
  • The high-contrast theme raises everything to ≥7:1 and underlines links.

Forced colors (Windows High Contrast)

Distinct from Soma's high-contrast theme: the OS strips author backgrounds and substitutes system colours. _a11y.scss patches what that would erase: icon glyphs (mask + background), menu and calendar selection states, progress/meter fills, the switch track. If you paint state with background-color alone, add your own @media (forced-colors: active) rule.

Reduced motion

Every transition and animation respects prefers-reduced-motion. Spinners keep animating — the motion is the signal — but nothing else moves.

Screen-reader helpers

.soma-assistive renders content for screen readers only (visually hidden, still announced). Use it for icon-only controls that can't take aria-label and for table headers whose meaning is visual (the RESTful table's operations column does exactly this).

Screen-reader semantics

The JS components apply their roles and ARIA attributes themselves: you write the markup contract from each component's page, and the component wires the semantics at init (or on every show, where content can be replaced). One row per component; everything listed is set by Soma automatically:

ComponentSemantics Soma applies
Dialog A native <dialog> opened with showModal(): role, modality, top layer and the inert page come from the platform (no aria-modal or aria-hidden bookkeeping; the native open attribute is the state). The accessible name is derived automatically: the header heading gets an id and the dialog points aria-labelledby at it, so it announces as "Create user, dialog", not as an anonymous dialog.
Dropdown Trigger gets aria-haspopup + aria-expanded. The menu gets role="menu"; structural ul/li get role="none"; items get menuitem, menuitemcheckbox or menuitemradio with aria-checked (without the roles, aria-checked is inert). Roles are re-applied on every show, so ajax-replaced menu content is covered.
Tabs tablist / tab / tabpanel roles with aria-selected, aria-controls from tab to panel and aria-labelledby back; the <li> wrappers get role="presentation" so the tablist owns only tabs (list items inside a tablist are invalid ARIA); hidden panes carry aria-hidden.
Inline dialog Trigger gets aria-haspopup="dialog" + aria-expanded; the popover gets role="dialog" and toggles aria-hidden.
Date picker The input becomes an ARIA 1.2 combobox: role="combobox", aria-haspopup="dialog", aria-expanded, aria-controls (the role is what makes aria-expanded valid on a text input). The popover is a labelled role="dialog" over a role="grid"; every day cell carries a full spoken-date aria-label ("24 July 2026"; the visible text is just a number), today gets aria-current="date", the chosen day aria-selected="true", out-of-range days aria-disabled="true". The month title is aria-live="polite" so paging is announced.
Select The generated control gets role="combobox" + aria-haspopup="listbox" + aria-expanded, and the accessible name is re-pointed from your <label> (which targets the hidden native select) to the control. The list is role="listbox" (aria-multiselectable in multi mode) of role="option" items with aria-selected/aria-disabled; the virtual cursor is exposed via aria-activedescendant on the search field; pill remove buttons are individually labelled.
Quicksearch Input gets role="combobox" + aria-autocomplete="list" + aria-expanded + aria-controls; the results panel is a role="listbox" and the highlighted result is reported through aria-activedescendant while focus stays in the input.
Toasts The shared container is aria-live="polite"; each toast gets role="alert" (warning/danger: interrupt) or role="status" (info/success: queue politely). The dismiss button carries a localised aria-label.
Messages & banners (dynamic) Soma.message.create() and Soma.banner() instances get role="alert" for error/warning (banners: danger/critical/warning) and role="status" for the rest: injected content is only announced from inside a live region. Static server-rendered messages and banners deliberately get no role: they were part of the page at load, not an update to announce.
Progress Soma.progress() ensures role="progressbar" with aria-valuemin="0" / aria-valuemax="100"; .update(v) writes the percentage to aria-valuenow, and .setIndeterminate() removes it — a progressbar without aria-valuenow is what assistive tech reads as indeterminate.
Spinner Programmatic spinners get role="status" and a localised "Loading" aria-label, announced politely when they appear.
Tooltip (JS) The bubble is role="tooltip"; while it is visible the trigger points at it with aria-describedby, so the hint is read together with the control.
Sortable table Sortable headers get tabindex="0" (keyboard-reachable); the sorted column carries aria-sort="ascending|descending"; only one column at a time, so the sort state is unambiguous.
Tree Items with a child group get aria-expanded; each toggle's aria-label flips between the localised Expand/Collapse strings as the state changes. The whole tree is one tab stop (roving tabindex on the treeitems, WAI-ARIA tree pattern): arrows move through visible items, / expand/collapse and walk the hierarchy (visual direction in RTL), Home/End jump, Enter/Space toggle.
Expander State lives where ARIA wants it: aria-expanded on the trigger, aria-hidden on the content, kept in sync on every toggle.
Drawer role="complementary" + aria-hidden. Deliberately non-modal: no blanket, no focus trap, no aria-modal; the page stays operable while it is open.
Sidebar nav groups The toggle is a native <button> (Enter/Space free) pointing aria-controls at its panel. Init normalises the pair from whichever side the markup declared — aria-expanded on the toggle, aria-hidden on the panel — and every toggle flips both.
Sidebar resize The handle is a labelled, focusable role="separator" with aria-orientation="vertical" and aria-valuenow / -valuemin / -valuemax tracking the width.
RESTful table The operations column header gets visually-hidden .soma-assistive text (its meaning is visual); edit-mode inputs are labelled with their column header; the loading row's spinner is a labelled role="status".

One semantic is part of the markup contract rather than applied by JS: scrollable log-viewer and diff panes need tabindex="0" so keyboard users can focus and scroll them; the focus ring is styled for it. The CSS-only components (badges, labels, meters, …) need no JS-applied ARIA; their pages document any attributes the markup itself should carry.

Your part as a consumer

The library carries the component-level work; a few things only you can do:

  • Label icon-only controls. A button or trigger whose only content is an icon needs an aria-label: <button class="soma-button" aria-label="Close"><span class="soma-icon soma-icon-close"></span></button>. The icon itself is a CSS mask on an empty <span>: it contributes no accessible name.
  • Give avatars alt text. <img alt="Tomas"> when the identity matters; alt="" when the avatar sits next to the visible name and is decorative.
  • Offer the high-contrast theme to users who asked the OS for it. A three-line matchMedia opt-in maps prefers-contrast: more to Soma.theme('high-contrast') when no explicit data-soma-theme is set; the recipe is on the Themes page. Soma never switches themes on its own.
  • Don't remove the focus ring. If it clashes with your design, restyle :focus-visible (the --soma-focus-ring token is the hook); never outline: none without a visible replacement.
  • Pair inputs with labels. Every form field needs a <label for="…"> (or a wrapping label); placeholder text is not a label: it vanishes on input and is never announced as a name.
  • Wire descriptions and errors to the input. Give the .soma-field-description and .soma-field-message ids and list them in the input's aria-describedby (description first, then message); add aria-invalid="true" to an errored input. Without the wiring the error is only tinted, never announced. On long forms, add an error summary (.soma-form-errors) at the top and focus it after a failed submit; the full contract is on the Forms page.
  • Escape user data in HTML-injecting hooks. bodyHtml (toasts, message.create, banners), tooltip html: true, select formatResult/formatSelection, and restful-table readView all inject raw markup: escape anything user-supplied before it goes in (Soma.escapeHtml exists for exactly this — attribute-safe, unlike the textContent→innerHTML shortcut), or stay on the escaped-by-default paths: title/body are always inserted as text, and the select's default formatters escape the option text for you. The full escaping model is in the repository's SECURITY.md.