Skip to content

Add keyboard navigation to Dropdown menus#320

Open
jonatankruszewski wants to merge 1 commit into
dcouple:mainfrom
jonatankruszewski:319-dropdown-keyboard-navigation
Open

Add keyboard navigation to Dropdown menus#320
jonatankruszewski wants to merge 1 commit into
dcouple:mainfrom
jonatankruszewski:319-dropdown-keyboard-navigation

Conversation

@jonatankruszewski

Copy link
Copy Markdown
Contributor

Description

Closes #319.

The shared Dropdown never moved focus into its menu, so once open it was unreachable by keyboard: arrow keys were unhandled, no item was ever highlighted, and because the menu is portaled to the end of <body>, Tab couldn't reach it either. Only Enter/Space on the trigger and Escape were wired up, which made it feel keyboard-accessible right up until you tried to pick something. It's the shared component, so this affected every menu in the app — the Theme picker is just the easiest place to see it.

Opening now seeds an active item (the selected one, so the theme menu opens on the active theme) and moves focus to it:

  • ArrowUp/ArrowDown move the highlight, wrapping at the ends and skipping disabled items
  • Enter/Space selects; Escape and Tab close
  • Closing returns focus to the trigger whenever focus is still inside the menu, so mouse users don't lose their place in the tab order either
  • Hovering syncs the highlight, so pointer and keyboard can't disagree about which item is active
  • Menus driven by selectedId are single-select, so their items are exposed as menuitemradio/aria-checked rather than plain menuitem; the trigger carries aria-haspopup/aria-expanded

The index math (wrapping, skipping disabled entries, seeding from the current selection) is pulled out into dropdownNavigation.ts so it can be unit-tested without React.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)

Checklist

  • I have read the CONTRIBUTING.md guidelines
  • My code follows the code style of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I have run pnpm typecheck and pnpm lint locally
  • I have tested the Electron app locally with pnpm electron-dev

Critical Areas Modified

None of the listed areas. The change is contained to frontend/src/components/ui/Dropdown.tsx plus a new pure helper module and its tests.

Additional Notes

Tests: 12 unit tests for the navigation helpers, and a Playwright spec that drives the real Theme picker end to end (arrow keys move focus, Enter applies the theme, Escape closes without committing a change).

Two things I deliberately left out of scope, happy to fold either in if you'd prefer:

  • The trigger is a double tab stop. Dropdown wraps its trigger in a <div tabIndex={0}> and callers pass a real <button>, so one control gets two tab stops. That predates this PR, and fixing it properly means removing the wrapper, which touches every Dropdown call site. I tried adding role="button" to the wrapper and it made things worse — the wrapper and the inner button both appear as buttons with the same accessible name (it broke getByRole('button', ...) in the existing smoke test), so the ARIA can't be fixed without removing the wrapper.
  • Arrow-key handling is now implemented three times (CommandPalette, InterceptorDropdown, and here). A shared hook would be nice, but that felt like a bigger refactor than this fix warrants.

The shared Dropdown never moved focus into its menu, so once open it was
unreachable by keyboard: arrow keys were unhandled, no item was ever
highlighted, and because the menu is portaled to the end of <body>, Tab
couldn't reach it either. Only Enter/Space on the trigger and Escape were
wired up, which made it feel keyboard-accessible right up until you tried to
pick something.

Opening now seeds an active item -- the selected one, so the theme menu opens
on the active theme -- and moves focus to it. ArrowUp/ArrowDown move the
highlight, wrapping at the ends and skipping disabled items; Enter/Space
selects; Escape and Tab close. Closing returns focus to the trigger whenever
focus is still inside the menu, so mouse users don't lose their place in the
tab order either. Hovering syncs the highlight so the pointer and keyboard
can't disagree about which item is active.

Menus driven by selectedId are single-select, so their items are now exposed
as menuitemradio/aria-checked rather than plain menuitem, and the trigger
carries aria-haspopup/aria-expanded.

The index math (wrapping, skipping disabled entries, seeding from the current
selection) lives in dropdownNavigation.ts and is unit-tested; a Playwright
spec drives the real theme picker end to end.

Closes dcouple#319
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.

Dropdown menus have no keyboard navigation (arrow keys do nothing)

1 participant