Accessibility
What Poetry guarantees by construction, and a protocol you can run to verify the app you build with it: automated checks first, then a keyboard and screen-reader pass.
What Poetry guarantees
-
Accessible names, required by construction. A
dialog will not render without its title slot; the title is the
accessible name and
aria-labelledbyis always wired. Icons are decorative by default (aria-hidden); passinglabel:promotes one torole="img"with anaria-label. -
ARIA wiring from the field chain. Wrapping a
control in
fieldand spreadingfield.control_attributesassociates the label and hands the controlaria-describedby(the error id first, then the hint id) plusaria-invalid. You never hand-write those attributes, so they cannot drift. - Native form participation. Checkbox, select, combobox, and the rest of the form family keep a real native form control in the DOM. The native element is the serialization truth: what your test reads is what the server receives.
-
Focus management in overlays. Dialogs ride the
native dialog element, so focus trapping, Escape, and focus
return to the trigger come from the platform rather than from
scripting. Every focusable part styles a
focus-visiblering. - Announcements without spam. Toasts announce through the toaster's single live region, once per toast, rather than per DOM node.
- AA-gated theme tokens. The token importer drops any swatch that fails WCAG AA and reports the nearest passing value, so imported palettes cannot ship failing contrast.
The library verifies its own side of this contract with an automated accessibility walk (WCAG 2.0 A and AA rules) over every component state, per theme, as part of its release gates.
What automation cannot see
Automated scanners catch roughly a third of WCAG issues, the structural ones. The rest live in focus management, screen-reader announcements, and the keyboard operability of interactive widgets, and only a person at a keyboard finds them. Scanners also see initial render state only; open overlays, filtered lists, and mid-stream updates are the manual pass's job.
Run the manual protocol before a release, and again whenever an interactive flow changes shape: a new overlay, a custom override on a component, a form that grew a dynamic section.
Set up a pass
- macOS, VoiceOver: toggle with Cmd+F5. Navigate with Ctrl+Option (the VO keys) plus arrows; open the rotor with VO+U (headings, landmarks, form controls); interact with a group via VO+Shift+Down.
- Windows, NVDA (free, and the realistic baseline for real users): arrows in browse mode, Tab between controls, NVDA+Space to toggle browse and focus mode, NVDA+F7 for the elements list.
- Walk your app's real pages. Test light and dark, at 200% browser zoom (WCAG 1.4.4), and with reduced motion switched on.
- Keyboard only for the keyboard passes; ignore the mouse entirely.
The universal checklist
On every page, regardless of which components it uses:
- Tab order matches visual order; nothing interactive is skipped or trapped. Modal focus traps are intentional, and Escape must release them.
- Focus is always visible. Confirm the ring survives your theme and any token overrides.
- Every control has an accessible name: never "button", "blank", or "edit text" in the screen reader.
- State changes are announced as they happen: checked, expanded, selected, disabled, invalid.
- At 200% zoom and with reduced motion on, no content or function is lost and nothing animates against the preference.
Pattern checklists
Group the components on a page by pattern and run the pattern's checks against each.
Form controls
-
Every control is named, through a visible label the field chain
associates, or an explicit
aria-labelwhere the pattern is icon-only. - Space toggles a checkbox, switch, or toggle, and the new state is announced.
- Radio and toggle groups are one tab stop; arrows move the selection and the group itself is named.
- Selects and comboboxes open with Enter, Space, or arrows; typeahead works; selection is announced; Escape closes. A filtering combobox announces its result counts through its live region.
- Sliders and number fields announce value, minimum, and maximum, and step from the keyboard (arrows, Page Up and Page Down, Home and End).
- Date and time fields announce each segment's role and value. The calendar is a grid: arrows move by day, Page Up and Page Down by month, and the selected day and today are announced. A range submits both ends and announces both.
- A masked input's reveal toggle is named, announces its pressed state, and never leaks the masked value to the accessibility tree.
-
Invalid controls announce it, and the error text is associated
via
aria-describedby(the field contract).
Overlays
- Opening moves focus in; closing returns focus to the trigger, also after a Turbo navigation and after a back/forward cache restore.
- Focus is trapped while modal, Escape closes, and backdrop behavior matches the component contract. An alert dialog does not dismiss on outside click, and initial focus lands on the safe action.
-
The surface carries
role="dialog"or"alertdialog"witharia-modal, labelled by its title and described by its body. - Background content is unreachable while modal, by Tab and by the screen-reader cursor.
- Tooltips and hover cards appear on keyboard focus, not just hover; Escape dismisses them; their content is announced.
Menus and command palettes
- The trigger opens with Enter, Space, or ArrowDown; focus lands on the first item; arrows move; typeahead jumps; left and right traverse submenus; Escape closes and returns focus.
- Items announce their menu-item role, including checkbox and radio variants with checked state.
- A context menu also opens from the keyboard, via Shift+F10 or the context-menu key.
- In a command palette, typing filters, arrows plus Enter select, and the result count is announced. Inside a command dialog the overlay checks above apply too.
Disclosure and navigation
-
Accordion and collapsible triggers are buttons with
aria-expanded, and state changes are announced. - Tabs are one tab stop; arrows move between them; the active tab is announced and its panel is reachable.
-
Breadcrumbs and pagination are named
navlandmarks, and the current page carriesaria-current="page". -
The sidebar toggle is named, the active item carries
aria-current, and the mobile sheet traps focus.
Data, feedback, and streaming
-
Tables keep real table semantics. Sortable headers announce
aria-sort, per-row selection checkboxes are named, the action bar that appears on selection is announced and reachable, and sticky headers do not hide focused cells. - Trees expose tree and tree-item roles; arrows navigate and expand; every node is named. Toolbars are one tab stop with arrows inside.
- Carousel slide changes are announced through the live region, and the controls are named, keyboard-operable buttons.
- Server-spawned toasts announce through the toaster's live region, pause their timers on hover, and have a named dismiss button.
- Progress bars and meters announce a role, a name, and their value range. Loading skeletons expose the busy state once, and resolved content announces its arrival.
- Streamed messages announce once per settled update, not once per token, and the jump-to-bottom button stays reachable mid-stream. Chart data points take keyboard focus and announce their values.
Automated checks in your app
Two automated layers are worth wiring into CI before any manual
pass. First, poetry:check catches the structural
class of accessibility bug at lint time: a mistyped component
option, stranded Stimulus wiring, or a raw color off the token
surface.
bin/rails poetry:check # structural lint: wiring, options, raw colors
POETRY_CHECK_DESIGN=1 bin/rails poetry:check # add the design-slop warnings
Second, run an axe scan (the WCAG 2.0 A and AA rulesets) against your key pages from your system tests, through whichever axe integration fits your browser driver. Run it against each theme you ship, because contrast differs per theme, and treat a skip as a documented decision rather than a silent one. Poetry's components pass this scan on their side; what you are checking is your pages, your content, and your overrides.
Recording findings
Log each finding as component, check, assistive technology, severity, note. A finding a scanner could have seen becomes a fix or a documented, reviewed exception, never an undocumented skip. A finding only the manual pass can see gets a regression test at the cheapest tier that catches it, and a browser test only when it genuinely needs a layout engine. The tier doctrine lives in the testing guide.