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 staletrueover an empty popup),aria-controlspointing at the listbox,aria-autocomplete="list", andaria-haspopup="listbox". - The listbox (
role="listbox") has an accessible name viaaria-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") getaria-selectedtoggled 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") ofrole="listitem"chips; each chip's remove control carries anaria-labelnaming exactly what it removes ("Remove Casey Nguyen"), never a bare "Remove". Flicker.palette/1's overlay isrole="dialog"aria-modal="true"with anaria-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/3same-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, andFlicker.select/1when givenfacets) — "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>-announcerelement) for every server-reachable transition in the Spec 001 keyboard map: opening, typing (narrowing/zero/loading), selecting, escape/close, clearing, chip add/remove (button andBackspace),max_selections, and facet cursor-context changes — seetest/flicker/select_announcements_test.exsandtest/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-coreruns against everydev/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/Tabhighlight movement and thearia-activedescendantwiring — client-side JS (Flicker.Components.Select's colocated.Navhook) ExUnit/PhoenixTest cannot drive on its own — plusmod+kopen/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)
| # | Step | Expected announcement |
|---|---|---|
| 1 | Tab to the search input | Input's accessible name is spoken (its placeholder/label text); combobox role announced |
| 2 | Press ArrowDown | Listbox opens, first option becomes active; "N results available" |
| 3 | Type a query that narrows results (e.g. a name prefix) | "N results available" (or "1 result available" — singular) reflecting the narrowed count |
| 4 | Type a query matching nothing | "No results available" |
| 5 | Clear the query back to empty while focused | Full unfiltered "N results available" |
| 6 | ArrowDown/ArrowUp through options | Each option's label spoken as it becomes active (via aria-activedescendant) |
| 7 | Press Enter on a highlighted option | Listbox closes; "<Name> selected" |
| 8 | Press the clear-selection button | Selection cleared; announcement no longer mentions the cleared item |
| 9 | Re-open and press Escape | Listbox closes, typed text preserved; no stale result-count announcement lingers |
| 10 | Press Escape again (closed, text present) | Input clears |
| 11 | Click outside the component while open | Listbox closes without selecting |
Multi-select (Flicker.select/1 with multiple)
| # | Step | Expected announcement |
|---|---|---|
| 1 | Select a first result | "1 item selected" |
| 2 | Select a second result | "2 items selected" |
| 3 | Activate a chip's remove button | "1 item selected"; the remove button's own accessible name named the chip before activation ("Remove <Name>") |
| 4 | With the input empty, press Backspace | Last chip removed; selected-count announcement decreases |
| 5 | Select up to max_selections | "Maximum of N selections reached" once the cap is hit; no further options rendered |
| 6 | Remove a chip while at the cap | Cap message stops; selection becomes possible again |
| 7 | Press "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)
| # | Step | Expected announcement |
|---|---|---|
| 1 | Type the start of a facet key (e.g. stat) | "Typing a facet name"; "N matching facets" |
| 2 | Pick a facet-key suggestion | Text completes to status:; "Typing a value for status"; the value picklist's count |
| 3 | Pick a facet value | Token completes (e.g. status:active); cursor context returns to free text |
| 4 | Continue typing free text after a completed facet | "Typing free text"; ordinary result/suggestion count, no stale facet-context announcement |
| 5 | Type an unrecognised key:value pair | Degrades to free-text context; no crash, no stuck facet-context announcement |
| 6 | Type 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.
| # | Step | Expected announcement |
|---|---|---|
| 1 | Tab to a facet_trigger="@" input | The input's name, then its description: "Type @ to filter" |
| 2 | Type a bare word (e.g. stat) | "Typing free text" — no facet-key context, no suggestion count |
| 3 | Type @ | "Typing a facet name"; "N matching facets"; the listbox is expanded |
| 4 | Continue to @stat | The narrowed count; each option's name is the canonical token (status:), with no @ |
| 5 | Pick a key suggestion | As Spec 019 step 1 below — the editor's dialog is announced; the input holds status:, never @status: |
| 6 | Type an email address (casey@example.com) | Stays "Typing free text" throughout; no facet context mid-word |
| 7 | With a scoped trigger (%{"@" => [...], "#" => [...]}), type each | Only 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.
| # | Step | Expected announcement |
|---|---|---|
| 1 | Pick a facet-key suggestion for a facet that has an editor (e.g. status:) | "Status editor, dialog"; focus moves into the pop-out |
| 2 | Arrow around the day grid of a date-range facet | Each focused day is spoken; the picker's own listbox is silent (its keyboard model is suspended) |
| 3 | Pick the first day of a range | The footer's draft state is spoken ("Jun 18, 2026 → pick an end date"); nothing is committed |
| 4 | Pick the second day | The facet commits; the pop-out closes; focus and caret return to the input |
| 5 | Press Escape mid-draft | The pop-out closes with nothing committed; focus returns |
| 6 | Tab repeatedly inside the pop-out | Focus stays within the dialog (trap), cycling rather than escaping to the page |
| 7 | Toggle a boolean facet's switch | role="switch" state is spoken; no pop-out opens; the value commits on toggle |
| 8 | Reopen a committed facet from its pill | "Edit Status" names the control; the editor opens pre-filled |
Invalid facet values and corrections (Spec 023)
| # | Step | Expected announcement |
|---|---|---|
| 1 | Type 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: …") |
| 2 | With a near-miss typed | The correction is reachable and named ("Did you mean Active?") |
| 3 | Accept the correction | The token is corrected and the error state clears |
| 4 | With on_invalid: :require | The withheld-dispatch state is spoken ("Filter not applied — fix the highlighted facet"), not merely coloured |
Facet value counts and recents (Specs 021, 022)
| # | Step | Expected announcement |
|---|---|---|
| 1 | Open a counted facet's value list | Each option's name includes its count as one string ("Active, 12 results available") — not a detached number |
| 2 | Reach a zero-count value | It is still reachable and selectable, and its 0 is spoken |
| 3 | Open a facet with recent values | The Recent group is announced as a group before its members |
Command palette (Flicker.palette/1)
| # | Step | Expected announcement |
|---|---|---|
| 1 | Trigger the activate_with_keyboard chord | Palette opens; focus moves into the search input; dialog's aria-label spoken |
| 2 | Type a query | Same result-count/loading announcements as single-select, spoken from within the dialog |
| 3 | Select a result | Navigation occurs (if meta.href is set); dialog closes |
| 4 | Press Escape with no text and the listbox closed | Dialog closes; focus returns to whatever triggered it |
| 5 | Trigger the chord again while already open | Dialog 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.