Skip to content

fix(core): use roving tabindex in TabList overflow menu#3728

Merged
cixzhang merged 1 commit into
facebook:mainfrom
bhamodi:a11y/tabmenu-roving
Jul 10, 2026
Merged

fix(core): use roving tabindex in TabList overflow menu#3728
cixzhang merged 1 commit into
facebook:mainfrom
bhamodi:a11y/tabmenu-roving

Conversation

@bhamodi

@bhamodi bhamodi commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Summary

The TabMenu overflow dropdown made every menu item a separate Tab stop: each item rendered with tabIndex={0} (TabMenu.tsx:374) and useListFocus was invoked without hasRovingTabIndex (TabMenu.tsx:275-278). A keyboard user Tabbing through the open menu walked item-by-item instead of the APG menu pattern's single Tab stop with arrow-key navigation.

This converts the menu to a roving tabindex, mirroring the established useListFocus({hasRovingTabIndex: true}) wiring in Toolbar.tsx:245-250 and the menu-button focus/close handling in DropdownMenu.tsx:

  • pass hasRovingTabIndex: true so the hook owns item tabindex -- exactly one enabled item is tabIndex="0", the rest -1, and the stop follows arrow navigation
  • render items tabIndex={-1} (the hook promotes one to the tab stop)
  • attach the hook's handleFocus to the menu's onFocus to keep the stop synced after clicks / programmatic focus
  • focus the first item on open (requestAnimationFrame(focusFirst)) so arrow keys work and the stop lands on a real item (previously focus stayed on the trigger, since the popover uses hasAutoFocus: false)
  • close on Tab -- the popover's focus trap (FOCUSABLE_SELECTOR excludes tabindex="-1") would otherwise trap Tab on the single stop and leave the menu stuck open -- and add an onHide that returns focus to the trigger, so Escape / Tab / select / light-dismiss never drop focus to <body>. This is the same wiring DropdownMenu uses.

Selecting an overflow tab via click and Enter/Space, and Escape-to-close, are unchanged.

Changes

  • TabList/TabMenu.tsx: useListFocus({hasRovingTabIndex: true}); items tabIndex={-1}; onFocus={handleFocus}; focus-first on open; close-on-Tab; onHide returns focus to the trigger.
  • TabList/TabList.test.tsx: seven new tests under TabMenu keyboard navigation (roving tabindex).

Test plan

  • node_modules/.bin/vitest run --root . packages/core/src/TabList -- 42 pass. Seven new tests:
    • exposes the overflow menu as a single Tab stop (one item tabbable, rest -1)
    • moves focus between items with ArrowDown and ArrowUp
    • moves the roving tab stop with arrow navigation
    • jumps to first and last item with Home and End
    • selects an item with Enter and calls onChange
    • closes the menu when Tab is pressed inside it (APG menu-button)
    • closes the menu when Escape is pressed
    • The single-tab-stop, roving-stop-movement, and Tab-close tests were confirmed failing before the fix (every overflow item was tabIndex="0" and Tab did not close). The arrow / Home/End / Enter / Escape tests passed both before and after -- focus movement and Escape already worked; the bug was tab-stop management, not navigation.
  • node_modules/.bin/tsc --project packages/core/tsconfig.json --noEmit -- clean.
  • node_modules/.bin/eslint on changed files -- clean.
  • Regression: vitest run packages/core/src/DropdownMenu packages/core/src/ContextMenu packages/core/src/Toolbar packages/core/src/hooks/useListFocus.test.ts -- 111 pass. The shared useListFocus hook is unchanged, and no other core component embeds TabMenu.

Notes

Found during an a11y audit; scoped to the roving-tabindex conversion plus the minimal focus-entry / Tab-out adjustments required to keep the menu usable under the new model. Typeahead is a separate finding and is not included here. No existing test encoded the buggy all-items-tabbable behavior, so none were weakened. TabMenu.doc.mjs / TabList.doc.mjs were left unchanged: no props changed and neither documents the menu's internal tab-stop behavior.

@vercel

vercel Bot commented Jul 9, 2026

Copy link
Copy Markdown

@bhamodi is attempting to deploy a commit to the Meta Open Source Team on Vercel.

A member of the Team first needs to authorize it.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Jul 9, 2026
@bhamodi bhamodi force-pushed the a11y/tabmenu-roving branch from 723fab2 to bf66afb Compare July 9, 2026 07:24
@vercel

vercel Bot commented Jul 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
astryx Ready Ready Preview, Comment Jul 10, 2026 3:47am

Request Review

@cixzhang cixzhang merged commit 99724c9 into facebook:main Jul 10, 2026
15 of 16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants