Skip to content

[menu] Expose setActiveIndex on Menu.Root actionsRef#4815

Open
chsmc-ant wants to merge 1 commit into
mui:masterfrom
chsmc-ant:menu-actionsref-setactiveindex
Open

[menu] Expose setActiveIndex on Menu.Root actionsRef#4815
chsmc-ant wants to merge 1 commit into
mui:masterfrom
chsmc-ant:menu-actionsref-setactiveindex

Conversation

@chsmc-ant
Copy link
Copy Markdown

@chsmc-ant chsmc-ant commented May 12, 2026

When a menu is opened programmatically (via controlled open or by calling onOpenChange(true) from a custom interaction that isn't one of Menu.Trigger's built-in open keys), useListNavigation leaves activeIndex as null. This is correct for pointer-driven opens, but for custom keyboard shortcuts it means every item renders with tabindex="-1" and FloatingFocusManager moves focus to the popup container rather than the first item — the user then has to press ↓ once more before arrow navigation works.

The underlying useListNavigation hook supports this via focusItemOnOpen, but the option only takes effect when Floating UI's own trigger handler sets the internal keyRef — which doesn't happen for external/custom keydowns.

This PR exposes the existing setActiveIndex state setter on Menu.Root's actionsRef (alongside unmount and close), so callers can do:

const actionsRef = React.useRef<Menu.Root.Actions>(null);

function onCustomKeyDown(event: React.KeyboardEvent) {
  if (event.key === 'ArrowRight') {
    setOpen(true);
    actionsRef.current?.setActiveIndex(0);
  }
}

<Menu.Root open={open} onOpenChange={setOpen} actionsRef={actionsRef}>

Because the two state updates batch into one render, the popup mounts with activeIndex already set and useListNavigation's existing layout effect focuses the item — no requestAnimationFrame or manual DOM focus needed on the caller's side.

setActiveIndex is already part of the internal MenuRootContext, so this only widens the imperative handle; no new state is introduced.

If maintainers would prefer a declarative alternative, plumbing useListNavigation's focusItemOnOpen option through as a Menu.Root prop is an option I'm happy to explore — it's a larger surface change and can't target an arbitrary index, but it avoids exposing the raw setter.

@code-infra-dashboard
Copy link
Copy Markdown

code-infra-dashboard Bot commented May 12, 2026

Bundle size

Bundle Parsed size Gzip size
@base-ui/react 🔺+21B(0.00%) 🔺+7B(0.00%)

Details of bundle changes

Performance

Total duration: 1,128.30 ms +36.15 ms(+3.3%) | Renders: 50 (+0) | Paint: 1,727.56 ms +63.78 ms(+3.8%)

No significant changes.


Check out the code infra dashboard for more information about this PR.

@netlify
Copy link
Copy Markdown

netlify Bot commented May 12, 2026

Deploy Preview for base-ui ready!

Built without sensitive environment variables

Name Link
🔨 Latest commit 60e54e8
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/6a03b38648fc280008d8b275
😎 Deploy Preview https://deploy-preview-4815--base-ui.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@oliviertassinari oliviertassinari added the component: menu Changes related to the menu component. label May 12, 2026
@oliviertassinari oliviertassinari changed the title [Menu] Expose setActiveIndex on Menu.Root actionsRef [menu] Expose setActiveIndex on Menu.Root actionsRef May 12, 2026
@oliviertassinari oliviertassinari changed the title [menu] Expose setActiveIndex on Menu.Root actionsRef [menu] Expose setActiveIndex() on Menu.Root actionsRef May 12, 2026
@oliviertassinari
Copy link
Copy Markdown
Member

oliviertassinari commented May 12, 2026

To help the team a bit (and your team on Claude), here is a reproduction that seems to reproduce the problem: https://stackblitz.com/edit/va1fcbzq?file=src%2FApp.tsx (repro based on this demo):

Screen.Recording.2026-05-13.at.00.37.47.mov

It's not clear that this should be solved with a new API; this feels like a bug: I would expect the same behavior regardless of how the menu is open: with a trigger, with the open prop.

For example, in https://mui.com/material-ui/react-menu/, the focus is placed on the first menu item when the menu opens (with the keyboard) but the behavior can be configured with a prop.

Let's wait for team inputs.

@chsmc-ant chsmc-ant force-pushed the menu-actionsref-setactiveindex branch from 60e54e8 to e1cad93 Compare May 12, 2026 23:09
@chsmc-ant chsmc-ant changed the title [menu] Expose setActiveIndex() on Menu.Root actionsRef [Menu] Always highlight the first item on open May 12, 2026
@chsmc-ant chsmc-ant force-pushed the menu-actionsref-setactiveindex branch from e1cad93 to 60e54e8 Compare May 12, 2026 23:11
@chsmc-ant chsmc-ant changed the title [Menu] Always highlight the first item on open [Menu] Expose setActiveIndex on Menu.Root actionsRef May 12, 2026
@chsmc-ant
Copy link
Copy Markdown
Author

Thanks @oliviertassinari and good call! I opened #4816 as well so we can compare this approach to that one. Will defer to the team on which they'd prefer.

@oliviertassinari oliviertassinari changed the title [Menu] Expose setActiveIndex on Menu.Root actionsRef [menu] Expose setActiveIndex on Menu.Root actionsRef May 12, 2026
@atomiks atomiks added the type: new feature Expand the scope of the product to solve a new problem. label May 14, 2026
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 14, 2026

commit: 60e54e8

@atomiks
Copy link
Copy Markdown
Contributor

atomiks commented May 14, 2026

Thanks for the PR @chsmc-ant

Makes sense as controlled-opens are assumed to always be pointer-driven, but a keyboard-driven controlled open is also valid in which case the highlight should be set on the first item.

An imperative action also makes more sense than a prop like autoHighlight (which Autocomplete has) since this is a one-shot/event-driven action that probably shouldn't need state.

The only issue is naming: setActiveIndex is quite broad and "active" is more so an internal name as we use highlighted or focus publicly.

I'm also not sure if exposing the index is a good idea, because 1) disabled items should be skipped on initial open, which should pass through the internal logic to skip them, and 2) it's likely hard to keep track of which index is the last item if the controlled open is an ArrowUp for example.

Possible alternative: .focusItem('first' | 'last' | 'none') or .focusItem('start' | 'end' | 'none')

cc: @colmtuite


Side note: in case you need this behavior before release, this technically already works, if hacky:

firstItem.focus();
// or firstItem.dispatchEvent(new MouseEvent('mousemove', { bubbles: true }))

@michaldudak
Copy link
Copy Markdown
Member

We have another imperative API that has the same issue: MenuHandle.open. Whatever solution we land on for the actionsRef, we should also apply it there. I like @atomiks idea with .focusItem('first' | 'last' | 'none'). We could add an optional parameter to the handle's open method: open(triggerId: string, focusItem?: 'first' | 'last' | 'none').

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component: menu Changes related to the menu component. type: new feature Expand the scope of the product to solve a new problem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants