Flicker's combobox, multi-select, faceted search, and command palette are built to the WAI-ARIA APG editable-combobox pattern, with screen-reader announcements for every state change the visuals convey silently. This guide is the accessibility statement — what's automated and verified, what still needs a manual assistive-technology pass — plus the test script for running that manual pass.

Accessibility statement

Pattern followed: the WAI-ARIA APG "Editable Combobox With List Autocomplete" pattern — role stays on the text input (role="combobox"), DOM focus never leaves it, and the highlighted option is communicated via aria-activedescendant rather than moving real focus into the listbox. This holds across Flicker.select/1 (single- and multi-select), Flicker.search/1 (standalone faceted search), and Flicker.palette/1 (a Flicker.select/1 inside a labelled role="dialog" overlay).

Semantics, per component:

  • The input carries role="combobox", aria-expanded (kept in exact sync with whether the listbox is actually rendered — never a stale true over an empty popup), aria-controls pointing at the listbox, aria-autocomplete="list", and aria-haspopup="listbox".
  • The listbox (role="listbox") has an accessible name via aria-labelledby, pointing at the same visually-hidden <label> that labels the input — the listbox and the input it belongs to are announced as one thing, not an anonymous popup.
  • Options (role="option") get aria-selected toggled by the keyboard hook as the active-descendant index moves; each result renders as one <li> with a plain-text (or slotted) label.
  • Multi-select's chip list is a labelled role="list" (aria-label="Selected items") of role="listitem" chips; each chip's remove control carries an aria-label naming exactly what it removes ("Remove Casey Nguyen"), never a bare "Remove".
  • Flicker.palette/1's overlay is role="dialog" aria-modal="true" with an aria-label, a focus trap, and focus restored to whatever had it before the palette opened.

Announcements: one polite live region (aria-live="polite") per component instance — not one per message, and not one per concern — so a screen reader never gets more than one in-flight announcement from a single Flicker component competing with itself. Its content is a pure function of the same assigns that drive the visible render (the private announcement/1 helper in Flicker.Components.Select and Flicker.Components.Search) — there is no second, independently-updated "what did we last announce" assign that visible state and announced state could drift apart on. Covered:

  • result counts, after every search settles ("5 results available", "No results available") — and only the latest query's count, ever: the same start_async/3 same-name-cancellation LiveView provides for render state (a slower response for an earlier keystroke is discarded before it reaches any assign) means a stale count can't reach the announcement either, since the announcement reads off exactly the assigns the cancellation already protects.
  • loading ("Loading...") while a search is in flight, ahead of the count.
  • selection made ("Casey Nguyen selected") once a single-select choice closes the listbox.
  • the multi-select running total ("2 items selected") on every chip add or remove — a chip removed is simply the count going down, so one message key covers both without a second, harder-to-verify "you just removed X" sentence naming a chip that's no longer part of render state by the time it would be spoken.
  • reaching max_selections ("Maximum of 2 selections reached").
  • faceted-search cursor-context changes (Flicker.search/1, and Flicker.select/1 when given facets) — "Typing a facet name", "Typing a value for status", "Typing free text" — plus the matching suggestion count for whichever context is active, so the otherwise invisible key/value/free-text state machine is legible to someone who can't see the listbox change shape.

Rapid keystrokes don't queue a backlog of stale announcements because the announcement can only change as often as the underlying render state does, and that in turn is gated by the same phx-debounce={@debounce} that already governs when a query event reaches the server at all — no separate announcement-only debounce timer to keep in sync with the render one.

Every one of these strings — visible text and announcement alike — comes from Flicker.Messages (see Flicker.Messages.English for the complete, documented key list), per ADR-009. There are no inline user-facing literals in any template; a grep for bare text nodes in lib/flicker/components/*.ex outside message/2 calls finds none, and CI can re-run that grep as a regression check.

What's automated today:

  • ExUnit assertions on live-region content (the rendered #<id>-announcer element) for every server-reachable transition in the Spec 001 keyboard map: opening, typing (narrowing/zero/loading), selecting, escape/close, clearing, chip add/remove (button and Backspace), max_selections, and facet cursor-context changes — see test/flicker/select_announcements_test.exs and test/flicker/search_announcements_test.exs.
  • A dedicated regression test proving stale-result cancellation reaches the announcement text, not just the visible result list ("the announced count reflects only the latest query, never a slower stale one").
  • ARIA semantics (role/aria-expanded/aria-controls/aria-labelledby/ aria-selected/labelled chip list) are exercised incidentally by the full component test suite's HTML assertions.

What's automated, browser-driven (Spec 007's test/flicker/browser/ suite, @moduletag :browser — excluded from the default mix test run, run via mix test --only browser locally or the dedicated CI job; see test/support/browser_case.ex):

  • axe-core runs against every dev/ playground route (the playground is Spec 005's fixture), the command palette scanned both closed and open — zero violations, enforced in CI (test/flicker/browser/axe_test.exs).
  • Arrow-key/Enter/two-stage-Escape/Tab highlight movement and the aria-activedescendant wiring — client-side JS (Flicker.Components.Select's colocated .Nav hook) ExUnit/PhoenixTest cannot drive on its own — plus mod+k open/toggle, focus trap and restore on the palette, and windowed-search load-more, all driven through a real Chrome via Wallaby (test/flicker/browser/keyboard_test.exs).

What still needs a manual pass (tracked in Spec 007, left in-progress deliberately — this guide is not a claim that the matrix below has been run):

  • The manual AT test script below, executed against VoiceOver+Safari, NVDA+Firefox, and (budget/licensing permitting) JAWS+Chrome, with results recorded in the repo — this is the one remaining gap; nothing substitutes for an actual screen reader.
  • Voice-control (Dragon) and switch-access are explicitly out of scope for v1 (Spec 007's non-goals) and untested.
  • High-contrast/forced-colors visual theming is a theme-preset concern, not this guide's.

Until that manual pass is recorded, treat "first-class screen-reader support" as implemented and unit-verified, not yet field-verified — the honest, versioned distinction this statement exists to make.

Known gap: no manual AT pass recorded

The automated half of this work is enforced in CI — axe-core at zero violations across every playground route, and the client-side keyboard map covered in a real browser. The manual matrix below has never been executed against this codebase, so no claim is made about how Flicker actually sounds in VoiceOver, NVDA or JAWS. That obligation recurs each release and lives in docs/RELEASE_CHECKLIST.md; results land there. Until they do, treat the statement above as describing the pattern implemented, not a verified experience.

Manual AT test script

Run this against each browser/AT pair in the matrix (VoiceOver+Safari, NVDA+Firefox, JAWS+Chrome). Use the dev/ playground (mix dev or however your checkout runs it) — the single-select, multi-select, and faceted-search pages. For each step, confirm the expected announcement is what the AT actually speaks, not just that the visual state is correct.

Single-select (Flicker.select/1, single mode)

#StepExpected announcement
1Tab to the search inputInput's accessible name is spoken (its placeholder/label text); combobox role announced
2Press ArrowDownListbox opens, first option becomes active; "N results available"
3Type a query that narrows results (e.g. a name prefix)"N results available" (or "1 result available" — singular) reflecting the narrowed count
4Type a query matching nothing"No results available"
5Clear the query back to empty while focusedFull unfiltered "N results available"
6ArrowDown/ArrowUp through optionsEach option's label spoken as it becomes active (via aria-activedescendant)
7Press Enter on a highlighted optionListbox closes; "<Name> selected"
8Press the clear-selection buttonSelection cleared; announcement no longer mentions the cleared item
9Re-open and press EscapeListbox closes, typed text preserved; no stale result-count announcement lingers
10Press Escape again (closed, text present)Input clears
11Click outside the component while openListbox closes without selecting

Multi-select (Flicker.select/1 with multiple)

#StepExpected announcement
1Select a first result"1 item selected"
2Select a second result"2 items selected"
3Activate a chip's remove button"1 item selected"; the remove button's own accessible name named the chip before activation ("Remove <Name>")
4With the input empty, press BackspaceLast chip removed; selected-count announcement decreases
5Select up to max_selections"Maximum of N selections reached" once the cap is hit; no further options rendered
6Remove a chip while at the capCap message stops; selection becomes possible again
7Press "Clear all"Selected count returns to "No items selected"; all chips gone from the labelled list

Faceted search (Flicker.search/1, and Flicker.select/1 with facets)

#StepExpected announcement
1Type the start of a facet key (e.g. stat)"Typing a facet name"; "N matching facets"
2Pick a facet-key suggestionText completes to status:; "Typing a value for status"; the value picklist's count
3Pick a facet valueToken completes (e.g. status:active); cursor context returns to free text
4Continue typing free text after a completed facet"Typing free text"; ordinary result/suggestion count, no stale facet-context announcement
5Type an unrecognised key:value pairDegrades to free-text context; no crash, no stuck facet-context announcement
6Type a relationship facet's value prefix (e.g. genre:In)"Typing a value for genre"; nested search runs; "Loading..." while in flight, then a count

Trigger character (facet_trigger, Spec 024)

With a trigger configured, a bare word no longer lists facet keys — so the hint is the only thing telling a screen reader user the facets exist. That makes step 1 the one that matters most here.

#StepExpected announcement
1Tab to a facet_trigger="@" inputThe input's name, then its description: "Type @ to filter"
2Type a bare word (e.g. stat)"Typing free text" — no facet-key context, no suggestion count
3Type @"Typing a facet name"; "N matching facets"; the listbox is expanded
4Continue to @statThe narrowed count; each option's name is the canonical token (status:), with no @
5Pick a key suggestionAs Spec 019 step 1 below — the editor's dialog is announced; the input holds status:, never @status:
6Type an email address (casey@example.com)Stays "Typing free text" throughout; no facet context mid-word
7With a scoped trigger (%{"@" => [...], "#" => [...]}), type eachOnly that trigger's facets are offered and counted

Facet editors (Flicker.FacetEditor.*, Spec 019)

The pop-out is a modal sub-context, so the transitions in and out of it are the ones most likely to be got wrong.

#StepExpected announcement
1Pick a facet-key suggestion for a facet that has an editor (e.g. status:)"Status editor, dialog"; focus moves into the pop-out
2Arrow around the day grid of a date-range facetEach focused day is spoken; the picker's own listbox is silent (its keyboard model is suspended)
3Pick the first day of a rangeThe footer's draft state is spoken ("Jun 18, 2026 → pick an end date"); nothing is committed
4Pick the second dayThe facet commits; the pop-out closes; focus and caret return to the input
5Press Escape mid-draftThe pop-out closes with nothing committed; focus returns
6Tab repeatedly inside the pop-outFocus stays within the dialog (trap), cycling rather than escaping to the page
7Toggle a boolean facet's switchrole="switch" state is spoken; no pop-out opens; the value commits on toggle
8Reopen a committed facet from its pill"Edit Status" names the control; the editor opens pre-filled

Invalid facet values and corrections (Spec 023)

#StepExpected announcement
1Type a value outside a closed set (e.g. status:activ)The input is aria-invalid; its aria-describedby message is spoken ("must be one of: …")
2With a near-miss typedThe correction is reachable and named ("Did you mean Active?")
3Accept the correctionThe token is corrected and the error state clears
4With on_invalid: :requireThe withheld-dispatch state is spoken ("Filter not applied — fix the highlighted facet"), not merely coloured

Facet value counts and recents (Specs 021, 022)

#StepExpected announcement
1Open a counted facet's value listEach option's name includes its count as one string ("Active, 12 results available") — not a detached number
2Reach a zero-count valueIt is still reachable and selectable, and its 0 is spoken
3Open a facet with recent valuesThe Recent group is announced as a group before its members

Command palette (Flicker.palette/1)

#StepExpected announcement
1Trigger the activate_with_keyboard chordPalette opens; focus moves into the search input; dialog's aria-label spoken
2Type a querySame result-count/loading announcements as single-select, spoken from within the dialog
3Select a resultNavigation occurs (if meta.href is set); dialog closes
4Press Escape with no text and the listbox closedDialog closes; focus returns to whatever triggered it
5Trigger the chord again while already openDialog closes (toggle behaviour)

Record results (pass/fail per row, AT/browser combination, and any notes) in the repository alongside the release they were run against — this guide intentionally doesn't embed a stale set of results.