Skip to content

[Menu] Add focusItem('first' | 'last' | 'none') imperative action#4815

Open
chsmc-ant wants to merge 2 commits into
mui:masterfrom
chsmc-ant:menu-actionsref-setactiveindex
Open

[Menu] Add focusItem('first' | 'last' | 'none') imperative action#4815
chsmc-ant wants to merge 2 commits 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 MenuHandle.open(), or 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.

This PR adds an imperative focusItem(target: 'first' | 'last' | 'none') action so callers can ask the menu to highlight an item once it opens:

  • Menu.Root actionsRef — alongside unmount / close:

    const actionsRef = React.useRef<Menu.Root.Actions>(null);
    
    function onCustomKeyDown(event: React.KeyboardEvent) {
      if (event.key === 'ArrowRight') {
        setOpen(true);
        actionsRef.current?.focusItem('first');
      }
    }
    
    <Menu.Root open={open} onOpenChange={setOpen} actionsRef={actionsRef}>
  • MenuHandle.open(triggerId, focusItem?) — same values as a second parameter:

    handle.open('my-trigger', 'first');

The requested target is stored as pendingFocusItem on MenuStore and resolved in MenuRoot once itemDomElements is populated, using getMinListIndex / getMaxListIndex so hidden items are skipped — the same logic useListNavigation uses for its own initial sync. useListNavigation's existing layout effect then moves DOM focus to the resolved item.

A Menu.Root.FocusItem type alias is exported for the 'first' | 'last' | 'none' union.


An earlier revision of this PR exposed the raw setActiveIndex(index) setter; reworked per review to use directional targets and to cover MenuHandle.open as well.

@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 🔺+572B(+0.12%) 🔺+176B(+0.12%)

Details of bundle changes

Performance

Total duration: 1,143.38 ms +51.22 ms(+4.7%) | Renders: 50 (+0) | Paint: 1,762.60 ms +98.82 ms(+5.9%)

No significant changes — details


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').

Addresses review feedback:
- Rename to focusItem and use directional targets instead of a raw index,
  so disabled/hidden items are skipped via getMinListIndex/getMaxListIndex.
- Add an optional focusItem parameter to MenuHandle.open() with the same values.
@chsmc-ant chsmc-ant changed the title [menu] Expose setActiveIndex on Menu.Root actionsRef [Menu] Add focusItem('first' | 'last' | 'none') imperative action May 15, 2026
@chsmc-ant
Copy link
Copy Markdown
Author

Thanks for the feedback @atomiks @michaldudak — pushed 974094c which:

  • Replaces setActiveIndex(index) with focusItem('first' | 'last' | 'none') on actionsRef, resolved via getMinListIndex/getMaxListIndex so hidden items are skipped.
  • Adds the same option as a second parameter to MenuHandle.open(triggerId, focusItem?).
  • Exports Menu.Root.FocusItem for the union type.

PR description updated to match. Happy to bikeshed 'first' | 'last' vs 'start' | 'end' if there's a preference.

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