Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 43 additions & 33 deletions docs/src/app/(docs)/react/components/context-menu/types.md

Large diffs are not rendered by default.

85 changes: 48 additions & 37 deletions docs/src/app/(docs)/react/components/menu/types.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/src/app/(docs)/react/components/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -999,7 +999,7 @@ A list of actions in a dropdown, enhanced with keyboard navigation.
- Menu - LinkItem
- Props: className, closeOnClick, label, render, style
- Data Attributes: data-highlighted
- Types: Menu.Arrow.Props, Menu.Arrow.State, Menu.Backdrop.Props, Menu.Backdrop.State, Menu.CheckboxItem.ChangeEventDetails, Menu.CheckboxItem.ChangeEventReason, Menu.CheckboxItem.Props, Menu.CheckboxItem.State, Menu.CheckboxItemIndicator.Props, Menu.CheckboxItemIndicator.State, Menu.Group.Props, Menu.Group.State, Menu.GroupLabel.Props, Menu.GroupLabel.State, Menu.Item.Props, Menu.Item.State, Menu.LinkItem.Props, Menu.LinkItem.State, Menu.Popup.Props, Menu.Popup.State, Menu.Portal.Props, Menu.Portal.State, Menu.Positioner.Props, Menu.Positioner.State, Menu.RadioGroup.ChangeEventDetails, Menu.RadioGroup.ChangeEventReason, Menu.RadioGroup.Props, Menu.RadioGroup.State, Menu.RadioItem.Props, Menu.RadioItem.State, Menu.RadioItemIndicator.Props, Menu.RadioItemIndicator.State, Menu.Root.Actions, Menu.Root.ChangeEventDetails, Menu.Root.ChangeEventReason, Menu.Root.Orientation, Menu.Root.Props, Menu.Root.State, Menu.Separator.Props, Menu.Separator.State, Menu.SubmenuRoot.ChangeEventDetails, Menu.SubmenuRoot.ChangeEventReason, Menu.SubmenuRoot.Props, Menu.SubmenuRoot.State, Menu.SubmenuTrigger.Props, Menu.SubmenuTrigger.State, Menu.Trigger.Props, Menu.Trigger.State, Menu.Viewport.Props, Menu.Viewport.State
- Types: Menu.Arrow.Props, Menu.Arrow.State, Menu.Backdrop.Props, Menu.Backdrop.State, Menu.CheckboxItem.ChangeEventDetails, Menu.CheckboxItem.ChangeEventReason, Menu.CheckboxItem.Props, Menu.CheckboxItem.State, Menu.CheckboxItemIndicator.Props, Menu.CheckboxItemIndicator.State, Menu.Group.Props, Menu.Group.State, Menu.GroupLabel.Props, Menu.GroupLabel.State, Menu.Item.Props, Menu.Item.State, Menu.LinkItem.Props, Menu.LinkItem.State, Menu.Popup.Props, Menu.Popup.State, Menu.Portal.Props, Menu.Portal.State, Menu.Positioner.Props, Menu.Positioner.State, Menu.RadioGroup.ChangeEventDetails, Menu.RadioGroup.ChangeEventReason, Menu.RadioGroup.Props, Menu.RadioGroup.State, Menu.RadioItem.Props, Menu.RadioItem.State, Menu.RadioItemIndicator.Props, Menu.RadioItemIndicator.State, Menu.Root.Actions, Menu.Root.ChangeEventDetails, Menu.Root.ChangeEventReason, Menu.Root.FocusItem, Menu.Root.Orientation, Menu.Root.Props, Menu.Root.State, Menu.Separator.Props, Menu.Separator.State, Menu.SubmenuRoot.ChangeEventDetails, Menu.SubmenuRoot.ChangeEventReason, Menu.SubmenuRoot.Props, Menu.SubmenuRoot.State, Menu.SubmenuTrigger.Props, Menu.SubmenuTrigger.State, Menu.Trigger.Props, Menu.Trigger.State, Menu.Viewport.Props, Menu.Viewport.State

</details>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1122,5 +1122,36 @@ describe('<MenuRoot />', () => {

expect(trigger2).toHaveAttribute('aria-expanded', 'false');
});

it('focuses the first item when opening with `focusItem: first`', async () => {
const menuHandle = Menu.createHandle();
await render(
<div>
<Menu.Trigger handle={menuHandle} id="trigger">
Trigger
</Menu.Trigger>
<Menu.Root handle={menuHandle}>
<Menu.Portal>
<Menu.Positioner>
<Menu.Popup>
<Menu.Item data-testid="item-1">One</Menu.Item>
<Menu.Item data-testid="item-2">Two</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
</div>,
);

await act(async () => {
menuHandle.open('trigger', 'first');
});

const firstItem = await screen.findByTestId('item-1');
await waitFor(() => {
expect(firstItem).toHaveFocus();
});
expect(firstItem).toHaveAttribute('tabindex', '0');
});
});
});
66 changes: 66 additions & 0 deletions packages/react/src/menu/root/MenuRoot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1345,6 +1345,7 @@ describe('<Menu.Root />', () => {
current: {
unmount: vi.fn(),
close: vi.fn(),
focusItem: vi.fn(),
},
};

Expand Down Expand Up @@ -1388,6 +1389,71 @@ describe('<Menu.Root />', () => {
expect(screen.queryByRole('menu')).toBe(null);
});
});

describe('focusItem', () => {
function createApp(target: Menu.Root.FocusItem) {
const actionsRef: React.RefObject<Menu.Root.Actions | null> = { current: null };

return function App() {
const [open, setOpen] = React.useState(false);
return (
<div>
<button
type="button"
onClick={() => {
setOpen(true);
actionsRef.current?.focusItem(target);
}}
>
external
</button>
<TestMenu rootProps={{ open, onOpenChange: setOpen, actionsRef }} />
</div>
);
};
}

it('focuses the first item when called with `first` while opening programmatically', async () => {
const App = createApp('first');
const { user } = await render(<App />);

await user.click(screen.getByRole('button', { name: 'external' }));

const firstItem = await screen.findByTestId('item-1');
await waitFor(() => {
expect(firstItem).toHaveFocus();
});
expect(firstItem).toHaveAttribute('tabindex', '0');
});

it('focuses the last item when called with `last` while opening programmatically', async () => {
const App = createApp('last');
const { user } = await render(<App />);

await user.click(screen.getByRole('button', { name: 'external' }));

const lastItem = await screen.findByTestId('item-5');
await waitFor(() => {
expect(lastItem).toHaveFocus();
});
expect(lastItem).toHaveAttribute('tabindex', '0');
});

it('focuses the popup when called with `none`', async () => {
const App = createApp('none');
const { user } = await render(<App />);

await user.click(screen.getByRole('button', { name: 'external' }));

const popup = await screen.findByRole('menu');
await waitFor(() => {
expect(popup).toHaveFocus();
});
screen.getAllByRole('menuitem').forEach((item) => {
expect(item).toHaveAttribute('tabindex', '-1');
});
});
});
});

describe.skipIf(isJSDOM)('prop: onOpenChangeComplete', () => {
Expand Down
85 changes: 73 additions & 12 deletions packages/react/src/menu/root/MenuRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useStableCallback } from '@base-ui/utils/useStableCallback';
import { useId } from '@base-ui/utils/useId';
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
import { useOnFirstRender } from '@base-ui/utils/useOnFirstRender';
import { useAnimationFrame } from '@base-ui/utils/useAnimationFrame';
import { EMPTY_ARRAY, EMPTY_OBJECT } from '@base-ui/utils/empty';
import { fastComponent } from '@base-ui/utils/fastHooks';
import {
Expand All @@ -16,6 +17,7 @@ import {
useTypeahead,
useSyncedFloatingRootContext,
} from '../../floating-ui-react';
import { getMaxListIndex, getMinListIndex } from '../../floating-ui-react/utils/composite';
import { MenuRootContext, useMenuRootContext } from './MenuRootContext';
import { MenubarContext, useMenubarContext } from '../../menubar/MenubarContext';
import { TYPEAHEAD_RESET_MS } from '../../internals/constants';
Expand Down Expand Up @@ -365,10 +367,31 @@ export const MenuRoot = fastComponent(function MenuRoot<Payload>(props: MenuRoot
store.setOpen(false, createChangeEventDetails(REASONS.imperativeAction));
}, [store]);

const setActiveIndex = React.useCallback(
(index: number | null) => {
if (store.select('activeIndex') === index) {
return;
}
store.set('activeIndex', index);
},
[store],
);

const handleImperativeFocusItem = React.useCallback(
(target: MenuRoot.FocusItem) => {
store.set('pendingFocusItem', target);
},
[store],
);

React.useImperativeHandle(
actionsRef,
() => ({ unmount: forceUnmount, close: handleImperativeClose }),
[forceUnmount, handleImperativeClose],
() => ({
unmount: forceUnmount,
close: handleImperativeClose,
focusItem: handleImperativeFocusItem,
}),
[forceUnmount, handleImperativeClose, handleImperativeFocusItem],
);

let ctx: ContextMenuRootContext | undefined;
Expand Down Expand Up @@ -399,16 +422,6 @@ export const MenuRoot = fastComponent(function MenuRoot<Payload>(props: MenuRoot

const direction = useDirection();

const setActiveIndex = React.useCallback(
(index: number | null) => {
if (store.select('activeIndex') === index) {
return;
}
store.set('activeIndex', index);
},
[store],
);

const listNavigation = useListNavigation(floatingRootContext, {
enabled: !disabled,
listRef: store.context.itemDomElements,
Expand All @@ -425,6 +438,47 @@ export const MenuRoot = fastComponent(function MenuRoot<Payload>(props: MenuRoot
focusItemOnHover: highlightItemOnHover,
});

const pendingFocusItem = store.useState('pendingFocusItem');
const focusItemFrame = useAnimationFrame();

useIsoLayoutEffect(() => {
if (pendingFocusItem == null || !open) {
return undefined;
}

if (pendingFocusItem === 'none') {
store.update({ activeIndex: null, pendingFocusItem: null });
return undefined;
}

let cancelled = false;
let runs = 0;
const elements = store.context.itemDomElements;

const resolve = () => {
if (cancelled) {
return;
}
if (elements.current[0] == null) {
if (runs < 2) {
runs += 1;
focusItemFrame.request(resolve);
}
return;
}
const index =
pendingFocusItem === 'last' ? getMaxListIndex(elements) : getMinListIndex(elements);
store.update({ activeIndex: index, pendingFocusItem: null });
};

resolve();

return () => {
cancelled = true;
focusItemFrame.cancel();
};
}, [open, positionerElement, pendingFocusItem, store, focusItemFrame]);

const onTyping = React.useCallback(
(nextTyping: boolean) => {
store.context.typingRef.current = nextTyping;
Expand Down Expand Up @@ -618,6 +672,9 @@ export interface MenuRootProps<Payload = unknown> {
* 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.
* - `focusItem`: Move focus to the `'first'` or `'last'` item, or `'none'` to clear the highlight.
* Useful when opening the menu programmatically from a custom interaction
* so that keyboard focus lands on an item instead of the popup container.
*/
actionsRef?: React.RefObject<MenuRoot.Actions | null> | undefined;
/**
Expand Down Expand Up @@ -646,8 +703,11 @@ export interface MenuRootProps<Payload = unknown> {
export interface MenuRootActions {
unmount: () => void;
close: () => void;
focusItem: (target: MenuRoot.FocusItem) => void;
}

export type MenuRootFocusItem = 'first' | 'last' | 'none';

export type MenuRootChangeEventReason =
| typeof REASONS.triggerHover
| typeof REASONS.triggerFocus
Expand Down Expand Up @@ -695,6 +755,7 @@ export namespace MenuRoot {
export type State = MenuRootState;
export type Props<Payload = unknown> = MenuRootProps<Payload>;
export type Actions = MenuRootActions;
export type FocusItem = MenuRootFocusItem;
export type ChangeEventReason = MenuRootChangeEventReason;
export type ChangeEventDetails = MenuRootChangeEventDetails;
export type Orientation = MenuRootOrientation;
Expand Down
8 changes: 7 additions & 1 deletion packages/react/src/menu/store/MenuHandle.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createChangeEventDetails } from '../../internals/createBaseUIEventDetails';
import type { MenuRoot } from '../root/MenuRoot';
import { MenuStore } from './MenuStore';

export class MenuHandle<Payload> {
Expand All @@ -17,8 +18,9 @@ export class MenuHandle<Payload> {
* The trigger must be a Menu.Trigger component with this handle passed as a prop.
*
* @param triggerId ID of the trigger to associate with the menu.
* @param focusItem Optional item to focus once the menu is open: `'first'`, `'last'`, or `'none'`.
*/
open(triggerId: string) {
open(triggerId: string, focusItem?: MenuRoot.FocusItem) {
const triggerElement = triggerId
? (this.store.context.triggerElements.getById(triggerId) as HTMLElement | undefined)
: undefined;
Expand All @@ -27,6 +29,10 @@ export class MenuHandle<Payload> {
throw new Error(`Base UI: MenuHandle.open: No trigger found with id "${triggerId}".`);
}

if (focusItem != null) {
this.store.set('pendingFocusItem', focusItem);
}

this.store.setOpen(
true,
createChangeEventDetails('imperative-action', undefined, triggerElement),
Expand Down
3 changes: 3 additions & 0 deletions packages/react/src/menu/store/MenuStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export type State<Payload> = PopupStoreState<Payload> & {
parent: MenuParent;
rootId: string | undefined;
activeIndex: number | null;
pendingFocusItem: MenuRoot.FocusItem | null;
hoverEnabled: boolean;
stickIfOpen: boolean;
instantType: 'dismiss' | 'click' | 'group' | 'trigger-change' | undefined;
Expand Down Expand Up @@ -71,6 +72,7 @@ const selectors = {
return state.parent.type !== undefined ? state.parent.context.rootId : state.rootId;
}),
activeIndex: createSelector((state: State<unknown>) => state.activeIndex),
pendingFocusItem: createSelector((state: State<unknown>) => state.pendingFocusItem),
isActive: createSelector(
(state: State<unknown>, itemIndex: number) => state.activeIndex === itemIndex,
),
Expand Down Expand Up @@ -199,6 +201,7 @@ function createInitialState<Payload>(): State<Payload> {
},
rootId: undefined,
activeIndex: null,
pendingFocusItem: null,
hoverEnabled: true,
instantType: undefined,
openChangeReason: null,
Expand Down
Loading