ReviewOS

stacks/stx

a11y: Dropdown, Listbox, Combobox and Popover cannot be operated from the keyboard

#2032
Closed glennmichael123 opened this 9 days ago ·
9 days ago

Four of the composite widgets handle no keys at all. grep -l 'keydown\|ArrowDown\|key ===' packages/components/src/**/*.stx:

handles keyshandles none
Accordion, Tabs, RadioGroup, Dialog, Drawer, CommandPalette, Sidebar, TwoFactorChallenge, Card, DateRangePickerDropdown, Listbox, Combobox, Popover

Select is a native <select>, so the browser handles it — not a gap.

Each of the four opens its panel on click and offers no way to reach what's inside, or to dismiss it, without a pointer. That is WCAG 2.1.1 (Keyboard), and for Listbox/Combobox it also means the one piece of state a user is there to change is pointer-only.

What APG asks for, per widget:

  • Popover — smallest gap. The trigger is a real <button>, so Enter/Space already work; it needs Escape to close and focus returned to the trigger. Dialog and Drawer already do exactly this (Drawer.stx:147), so there is a pattern in-tree to copy.
  • Dropdown (menu button → role="menu") — ArrowDown/ArrowUp to move between menuitems, Home/End, Enter/Space to activate, Escape to close, and ArrowDown on the closed trigger should open it and focus the first item.
  • Listbox — ArrowDown/Up to move the selection, Home/End, Enter/Space to commit, Escape to cancel, and ideally printable-character type-ahead.
  • Combobox — ArrowDown from the input moves into the list without taking focus off the input, Enter commits, Escape closes. This one additionally needs aria-activedescendant, which is deliberately absent today precisely because there is no navigation to track: see the note in Combobox.stx and the test asserting its absence in disclosure-expanded-state.test.ts.

Found while giving those four their aria-expanded / aria-controls (cd52f36 and its two siblings). That work made the state announceable; it did not make the widgets operable, and the two are independent. Filing rather than folding it in because keyboard navigation is a feature-sized change per widget, with focus management and aria-activedescendant to get right, not a missing attribute.

Worth doing as four separate changes, smallest first — Popover's Escape is a handful of lines and the in-tree pattern already exists.

9 days ago

The dismissal half is done in ea44a4dc5c — all four now close on Escape, bound on the document for the reason <Drawer> records (opening doesn't move focus, so a panel-level handler never sees the first Escape), with focus returned to the trigger only when it had actually moved into the panel.

That settles Popover entirely: its trigger is a real <button>, so Enter/Space already worked, and Escape was the only thing missing.

What's left is arrow navigation, per widget:

  • Dropdown — ArrowDown/Up between menuitems, Home/End, Enter/Space to activate, ArrowDown on the closed trigger to open-and-focus-first
  • Listbox — ArrowDown/Up to move selection, Home/End, Enter/Space to commit, printable-character type-ahead
  • Combobox — ArrowDown from the input moves through options without taking focus off the input, which is also what unblocks aria-activedescendant (still absent, still asserted absent, for the reason noted in Combobox.stx)

Each needs its own focus model, which is why they didn't come along with the Escape change.

9 days ago

All four are now keyboard-operable.

Popoverea44a4dc5c — Escape; its trigger is a real <button>, so Enter/Space already worked and Escape was the only gap
Dropdowna01915096f — ArrowDown/Up (wrapping, and opening the closed trigger onto first/last), Home/End, Enter/Space, Tab
Listbox20608624b3 — the same, plus first-letter type-ahead, and opening onto the current selection so a listbox with a value does not hide it below the fold
Comboboxec97f05c9f — ArrowDown/Up tracking an active option via aria-activedescendant without moving focus, Home/End, Enter, Escape, Tab

Two things worth keeping from the work:

<DropdownItem>, <ListboxOption> and <ComboboxOption> were not focusable at all. A div role="menuitem" and an li role="option" have no implicit tabindex, so there was nothing to move focus to, and neither activates on Enter the way a button does. Half of each fix was tabindex="-1"; Combobox's options deliberately got none, since focus stays in its input.

Space is an activation key on two of the four and not on the third. Dropdown and Listbox activate the focused item with it; Combobox must leave it to type a space, or a query could never contain one.

aria-activedescendant arrived with the Combobox movement, as the ARIA commit said it would. The test that asserted its absence was inverted rather than deleted, so the record shows it came with the navigation rather than before it.

Enter is asserted as far as the click it delivers on Listbox and Combobox, not the selection that follows: selecting is the option component's own path and it is broken for more than one option by stacksjs/stx#2033, whose root cause is now identified there (every instance renders id="__stx_evt_0" and the binding resolves by getElementById). Clicking with a mouse is equally dead, so this is not a regression — but asserting the selection here would have pinned that bug as expected behaviour.

Sign in to comment on this issue.