[menu] Add highlightItem('first' | 'last' | 'none') imperative action#4815
[menu] Add highlightItem('first' | 'last' | 'none') imperative action#4815chsmc-ant wants to merge 8 commits into
Conversation
Bundle size
PerformanceTotal duration: 1,138.58 ms +46.42 ms(+4.3%) | Renders: 50 (+0) | Paint: 1,744.43 ms +80.65 ms(+4.8%) No significant changes — details Check out the code infra dashboard for more information about this PR. |
✅ Deploy Preview for base-ui ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
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.movIt'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 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. |
60e54e8 to
e1cad93
Compare
e1cad93 to
60e54e8
Compare
|
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. |
commit: |
|
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 The only issue is naming: 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 Possible alternative: 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 })) |
|
We have another imperative API that has the same issue: |
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.
|
Thanks for the feedback @atomiks @michaldudak — pushed 974094c which:
PR description updated to match. Happy to bikeshed |
3f70508 to
d8abaf8
Compare
| pendingFocusItem, | ||
| onPendingFocusItemChange() { | ||
| store.set('pendingFocusItem', null); | ||
| }, |
There was a problem hiding this comment.
Folded this into useListNavigation as it removes the duplicate effect, and AriaCombobox will likely want this behavior as well: #4745
d8abaf8 to
77d7be1
Compare
|
I think |
|
thanks @atomiks happy to go ahead and implement that rename if you think it's the right approach |
|
Renamed to |
|
I wonder how we will fix this demo https://base-ui.com/react/components/menu#controlled-mode-with-multiple-triggers later on. I guess we will add something like: <button
type="button"
className={styles.Button}
onClick={(event) => {
// Detect if the interaction was explicitly driven by a keyboard. Unless we need isVirtualClick().
const isKeyboard = event.detail === 0;
setOpen(true);
actionsRef.current!.highlightItem(isKeyboard ? 'first' : null);Not fully self managed by the popover (testing for :focus-visible), but this stays kind of simple. |
atomiks
left a comment
There was a problem hiding this comment.
This looks right now, thanks for the contribution @chsmc-ant
I ran Codex & Claude reviews on it and they both found some issues which I hardened in the follow up commits.
Something intentional (that they reported): I removed the waitForListPopulatedFrame as that was unnecessary on top of the queueMicrotask based on all tests:
CompositeList does the index reconciliation, then useCompositeListItem writes the actual DOM node into listRef.current[index] once the item gets its resolved index. That can require one extra layout-effect/update turn for composed items, so the PR waits with queueMicrotask before deciding the list is empty.
It was from the floating-ui fork which added an extra rAF just as a precaution, but only the microtask is needed
Unrelated to this PR, but needs to be fixed as well: there's a bug where if you mouse into the menu that was controlled-open by a different trigger, it closes upon mouseleave unexpectedly, because the hover hook requires a click on its trigger to block the close on mouseleave. I'll fix it in a new PR
|
Thanks @atomiks I think the failing test is flake/unrelated, is there anything else I need to do before we can merge this? |
| | open | `boolean` | - | Whether the menu is currently open. | | ||
| | onOpenChange | `((open: boolean, eventDetails: ContextMenu.Root.ChangeEventDetails) => void)` | - | Event handler called when the menu is opened or closed. | | ||
| | highlightItemOnHover | `boolean` | `true` | Whether moving the pointer over items should highlight them.
Disabling this prop allows CSS `:hover` to be differentiated from the `:focus` (`data-highlighted`) state. | | ||
| | actionsRef | `React.RefObject<MenuRoot.Actions \| null>` | - | A ref to imperative actions. `unmount`: When specified, the menu will not be unmounted when closed.
Instead, the `unmount` function must be called to unmount the menu manually.
Useful when the menu's animation is controlled by an external library.`close`: When specified, the menu can be closed imperatively.`highlightItem`: Highlight the `'first'` or `'last'` item, or `'none'` to clear the highlight.
Useful when opening the menu programmatically from a custom interaction
so that the first item is highlighted instead of the popup container receiving focus. | |
There was a problem hiding this comment.
Unrelated to this PR
@dav-is something to improve in the types.md file generation? close and highlightItem are bullets in the original code (see https://github.com/mui/base-ui/pull/4815/changes#diff-0c9aa23d488587208914fe2b3474310cf0586456d23b0cd370e448b2a5ddc7dfR639-R640). I we can't use bullets inside the markdown table (I've seen some MD renderers allowing to use them when using <br> for new lines), we could add a space at least.
@chsmc-ant yes, we have a flacky test. I've re-run it and now it's all green. |
When a menu is opened programmatically (via controlled
open, orMenuHandle.open(), oronOpenChange(true)from a custom interaction that isn't one ofMenu.Trigger's built-in open keys),useListNavigationleavesactiveIndexasnull. This is correct for pointer-driven opens, but for custom keyboard shortcuts it means every item renders withtabindex="-1"andFloatingFocusManagermoves 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
highlightItem(target: 'first' | 'last' | 'none')action so callers can ask the menu to highlight an item once it opens:Menu.RootactionsRef— alongsideunmount/close:MenuHandle.open(triggerId, highlightItem?)— same values as a second parameter:The requested target is stored as
pendingHighlightItemonMenuStoreand resolved insideuseListNavigation's initial-sync layout effect once the list is populated, usinggetMinListIndex/getMaxListIndexso hidden items are skipped — the same logic used for the built-infocusItemOnOpenpath.useListNavigationthen moves DOM focus to the resolved item (for Menu; virtual-focus consumers like Combobox would only updateactiveIndex).A
Menu.Root.HighlightItemtype alias is exported for the'first' | 'last' | 'none'union.Preview: https://deploy-preview-4815--base-ui.netlify.app/react/components/menu#controlled-mode-with-multiple-triggers