Skip to content

fix(fluid-select)!: make options operable by keyboard [sc-217433] - #191

Open
mnutt wants to merge 1 commit into
mn/accessibilityfrom
mn/fluid-select-keyboard-a11y
Open

fix(fluid-select)!: make options operable by keyboard [sc-217433]#191
mnutt wants to merge 1 commit into
mn/accessibilityfrom
mn/fluid-select-keyboard-a11y

Conversation

@mnutt

@mnutt mnutt commented Aug 26, 2026

Copy link
Copy Markdown
Member

Stacked on #190 — base is mn/accessibility, so review that one first. The diff here is just the fluid-select commit.

Shortcut: [sc-217433]

Closes the two WCAG Level A failures in FluidSelect that were deliberately scoped out of #190 because the fix needed its own design.

Options could not be reached by keyboard — WCAG 2.1.1 (A)

FluidSelect is built on ember-basic-dropdown, not ember-power-select. Its trigger handles only Enter, Space and Escape (basic-dropdown-trigger.ts:85-99) — no arrow-key navigation, no focus management into the popup. The options were <div role="button"> with no tabindex and no key handler, so a keyboard user could open the dropdown and then select nothing. Multi-select escaped this by accident, because it rendered FluidCheckbox — a real <button>.

Now implements the APG listbox pattern for single and multi select:

  • the list is role="listbox" and takes focus on open
  • options are role="option" with aria-selected
  • / (wrapping), Home/End, Enter, Space and Escape drive an active option tracked via aria-activedescendant
  • Escape closes and restores focus to whatever opened it
  • the trigger advertises aria-haspopup="listbox" and drops a redundant role="button"

The search input had no accessible name — WCAG 4.1.2 (A)

It had no label, only an optional placeholder, and carried role="search" — a landmark role that is invalid on an <input>. It is now a properly labelled role="combobox" that forwards arrow keys and Enter to the listbox, while still typing spaces normally.

Two things worth knowing while reviewing

The active option is written straight to the DOM, not held in component state. This looks unidiomatic and is deliberate. Routing it through the template re-renders the popup, which makes ember-basic-dropdown recompute its position, which re-renders the popup. That loop wedges the browser hard enough to kill every subsequent test in the suite. There's a comment on activateOption saying so; please don't "clean it up" into tracked state.

Options register themselves rather than the list enumerating them. list renders options through the await helper, so none of them exist yet when the list's did-insert fires — not even in afterRender.

Breaking change

Multi-select options are no longer a FluidCheckbox. The checkbox is presentational markup (FluidSelect::OptionCheckbox) inside the role="option" element, which owns the click handler — so there is no [role="checkbox"] in the list any more. A real checkbox nested inside an option would be a second focus stop with a conflicting role.

The bundled page object is updated to match, and gets simpler for it: click() no longer branches on option type, and hasCheckbox matches a descendant. Consuming test suites that reach for [role="checkbox"] inside a FluidSelect option will need updating.

Visually identical — same fluid-checkbox classes.

Testing

  • yarn lint-hbs and yarn lint-js clean.
  • yarn test: 96 tests, 95 pass, 1 pre-existing skip, 0 fail. All 18 pre-existing fluid-select tests pass unchanged apart from the page-object updates; 8 new keyboard-navigation tests cover listbox semantics, focus on open, arrow/Home/End wrapping, Enter-to-select, Escape-restores-focus, selected-option-starts-active, multi-select Space, and the combobox search input.
  • Percy will diff the fluid-select snapshots — the option DOM changed, so confirm the diffs are structural only.
  • Manual: Storybook → Components → Fluid Select. Needs NODE_OPTIONS=--openssl-legacy-provider yarn storybook (SB 6.4 bundles webpack 4, whose MD4 hashing OpenSSL 3 rejects).

FluidSelect is built on ember-basic-dropdown, whose trigger handles only
Enter, Space and Escape. There was no arrow-key navigation into the popup and
the options were divs with role="button", no tabindex and no key handler, so a
keyboard user could open the dropdown and then select nothing. Multi-select
escaped this by accident by rendering FluidCheckbox, a real button.

Implements the APG listbox pattern for both modes: the list is a role=listbox
that takes focus on open, options are role=option with aria-selected, and
arrow keys, Home/End, Enter, Space and Escape drive an active option tracked
with aria-activedescendant. Escape restores focus to whatever opened it.

The active option is written straight to the DOM rather than held in component
state, because re-rendering the popup makes ember-basic-dropdown recompute its
position, which re-renders the popup.

Also gives the search input an accessible name and replaces role="search",
which is a landmark role and invalid on an input, with role="combobox".

BREAKING CHANGE: multi-select options are no longer a FluidCheckbox. The
checkbox is now presentational markup inside the role=option element, which
owns the click handler, so no `[role="checkbox"]` element exists in the list.
The bundled page object is updated to match: every option is clicked the same
way and `hasCheckbox` matches a descendant. Consuming tests that reach for
`[role="checkbox"]` inside a FluidSelect option need updating.
@mnutt
mnutt force-pushed the mn/fluid-select-keyboard-a11y branch from c1663e9 to 56da301 Compare August 27, 2026 20:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant