();
+ await render(
+
+
+ Trigger 1
+
+
+ Trigger 2
+
+
+ {({ payload }: NumberPayload) => (
+
+
+
+ {payload}
+
+
+
+ )}
+
+ ,
+ );
+
+ const trigger1 = screen.getByRole('button', { name: 'Trigger 1' });
+ const trigger2 = screen.getByRole('button', { name: 'Trigger 2' });
+ expect(screen.queryByRole('menu')).to.equal(null);
+
+ await act(async () => {
+ menuHandle.open('trigger2');
+ });
+ await waitFor(() => {
+ expect(screen.queryByRole('menu')).not.to.equal(null);
+ });
+
+ expect(screen.getByTestId('content').textContent).to.equal('2');
+ expect(trigger2).to.have.attribute('aria-expanded', 'true');
+ expect(trigger1).not.to.have.attribute('aria-expanded', 'true');
+
+ await act(async () => {
+ menuHandle.close();
+ });
+ await waitFor(() => {
+ expect(screen.queryByRole('menu')).to.equal(null);
+ });
+
+ expect(trigger2).to.have.attribute('aria-expanded', 'false');
+ });
+ });
+});
diff --git a/packages/react/src/menu/root/MenuRoot.test.tsx b/packages/react/src/menu/root/MenuRoot.test.tsx
index 5d0354b8a5d..cbd6a1ad063 100644
--- a/packages/react/src/menu/root/MenuRoot.test.tsx
+++ b/packages/react/src/menu/root/MenuRoot.test.tsx
@@ -2,6 +2,7 @@ import * as React from 'react';
import { expect } from 'chai';
import { act, flushMicrotasks, waitFor, screen, fireEvent } from '@mui/internal-test-utils';
import { DirectionProvider } from '@base-ui-components/react/direction-provider';
+import { useRefWithInit } from '@base-ui-components/utils/useRefWithInit';
import { Menu } from '@base-ui-components/react/menu';
import userEvent from '@testing-library/user-event';
import { spy } from 'sinon';
@@ -39,990 +40,815 @@ describe(' ', () => {
expectedPopupRole: 'menu',
});
- describe('BaseUIChangeEventDetails', () => {
- it('onOpenChange cancel() prevents opening while uncontrolled', async () => {
- await render(
- {
- if (nextOpen) {
- eventDetails.cancel();
- }
- }}
- >
- Open menu
-
-
-
- Item
-
-
-
- ,
- );
-
- const trigger = screen.getByRole('button', { name: 'Open menu' });
- await userEvent.click(trigger);
-
- await waitFor(() => {
- expect(screen.queryByRole('menu')).to.equal(null);
- });
- });
- });
-
- describe('keyboard navigation', () => {
- it('changes the highlighted item using the arrow keys', async () => {
- await render(
-
- Toggle
-
-
-
- 1
- 2
- 3
-
-
-
- ,
- );
-
- const trigger = screen.getByRole('button', { name: 'Toggle' });
- await act(async () => {
- trigger.focus();
- });
-
- await userEvent.keyboard('[Enter]');
-
- const item1 = screen.getByTestId('item-1');
- const item2 = screen.getByTestId('item-2');
- const item3 = screen.getByTestId('item-3');
+ // All these tests run for contained and detached triggers.
+ // The rendered menubar has the same structure in most cases.
+ describe.for([
+ { name: 'contained triggers', Component: ContainedTriggerMenu },
+ { name: 'detached triggers', Component: DetachedTriggerMenu },
+ ])('when using $name', ({ Component: TestMenu }) => {
+ describe('keyboard navigation', () => {
+ it('changes the highlighted item using the arrow keys', async () => {
+ await render( );
- await waitFor(() => {
- expect(item1).toHaveFocus();
- });
-
- await userEvent.keyboard('{ArrowDown}');
- await waitFor(() => {
- expect(item2).toHaveFocus();
- });
+ const trigger = screen.getByRole('button', { name: 'Toggle' });
+ await act(async () => {
+ trigger.focus();
+ });
- await userEvent.keyboard('{ArrowDown}');
- await waitFor(() => {
- expect(item3).toHaveFocus();
- });
+ await userEvent.keyboard('[Enter]');
- await userEvent.keyboard('{ArrowUp}');
- await waitFor(() => {
- expect(item2).toHaveFocus();
- });
- });
+ const item1 = screen.getByTestId('item-1');
+ const item2 = screen.getByTestId('item-2');
+ const item3 = screen.getByTestId('item-3');
- it('changes the highlighted item using the Home and End keys', async () => {
- await render(
-
- Toggle
-
-
-
- 1
- 2
- 3
-
-
-
- ,
- );
-
- const trigger = screen.getByRole('button', { name: 'Toggle' });
- await act(async () => {
- trigger.focus();
- });
-
- await userEvent.keyboard('[Enter]');
- const item1 = screen.getByTestId('item-1');
- const item3 = screen.getByTestId('item-3');
+ await waitFor(() => {
+ expect(item1).toHaveFocus();
+ });
- await waitFor(() => {
- expect(item1).toHaveFocus();
- });
+ await userEvent.keyboard('{ArrowDown}');
+ await waitFor(() => {
+ expect(item2).toHaveFocus();
+ });
- await userEvent.keyboard('{End}');
- await waitFor(() => {
- expect(item3).toHaveFocus();
- });
+ await userEvent.keyboard('{ArrowDown}');
+ await waitFor(() => {
+ expect(item3).toHaveFocus();
+ });
- await userEvent.keyboard('{Home}');
- await waitFor(() => {
- expect(item1).toHaveFocus();
+ await userEvent.keyboard('{ArrowUp}');
+ await waitFor(() => {
+ expect(item2).toHaveFocus();
+ });
});
- });
- it('includes disabled items during keyboard navigation', async () => {
- await render(
-
- Toggle
-
-
-
- 1
-
- 2
-
-
-
-
- ,
- );
+ it('changes the highlighted item using the Home and End keys', async () => {
+ await render( );
- const trigger = screen.getByRole('button', { name: 'Toggle' });
- await act(async () => {
- trigger.focus();
- });
-
- await userEvent.keyboard('[Enter]');
+ const trigger = screen.getByRole('button', { name: 'Toggle' });
+ await act(async () => {
+ trigger.focus();
+ });
- const item1 = screen.getByTestId('item-1');
- const item2 = screen.getByTestId('item-2');
+ await userEvent.keyboard('[Enter]');
+ const item1 = screen.getByTestId('item-1');
+ const item5 = screen.getByTestId('item-5');
- await waitFor(() => {
- expect(item1).toHaveFocus();
- });
+ await waitFor(() => {
+ expect(item1).toHaveFocus();
+ });
- await userEvent.keyboard('{ArrowDown}');
+ await userEvent.keyboard('{End}');
+ await waitFor(() => {
+ expect(item5).toHaveFocus();
+ });
- await waitFor(() => {
- expect(item2).toHaveFocus();
+ await userEvent.keyboard('{Home}');
+ await waitFor(() => {
+ expect(item1).toHaveFocus();
+ });
});
- expect(item2).to.have.attribute('aria-disabled', 'true');
- });
+ it('includes disabled items during keyboard navigation', async () => {
+ await render( );
- describe('text navigation', () => {
- it('changes the highlighted item', async ({ skip }) => {
- if (isJSDOM) {
- // useMenuPopup Text navigation match menu items using HTMLElement.innerText
- // innerText is not supported by JSDOM
- skip();
- }
+ const trigger = screen.getByRole('button', { name: 'Toggle' });
+ await act(async () => {
+ trigger.focus();
+ });
- const { user } = await render(
-
-
-
-
- Aa
- Ba
- Bb
- Ca
- Cb
- Cd
-
-
-
- ,
- );
+ await userEvent.keyboard('[Enter]');
- const items = screen.getAllByRole('menuitem');
+ const item1 = screen.getByTestId('item-1');
+ const item2 = screen.getByTestId('item-2');
+ const disabledItem3 = screen.getByTestId('item-3');
- await act(async () => {
- items[0].focus();
+ await waitFor(() => {
+ expect(item1).toHaveFocus();
});
- await user.keyboard('c');
+ await userEvent.keyboard('{ArrowDown}');
+
await waitFor(() => {
- expect(screen.getByText('Ca')).toHaveFocus();
+ expect(item2).toHaveFocus();
});
- expect(screen.getByText('Ca')).to.have.attribute('tabindex', '0');
+ await userEvent.keyboard('{ArrowDown}');
- await user.keyboard('d');
await waitFor(() => {
- expect(screen.getByText('Cd')).toHaveFocus();
+ expect(disabledItem3).toHaveFocus();
});
- expect(screen.getByText('Cd')).to.have.attribute('tabindex', '0');
+ expect(disabledItem3).to.have.attribute('aria-disabled', 'true');
});
- it('changes the highlighted item using text navigation on label prop', async ({ skip }) => {
- if (!isJSDOM) {
- // This test is very flaky in real browsers
- skip();
- }
+ describe('text navigation', () => {
+ it('changes the highlighted item', async ({ skip }) => {
+ if (isJSDOM) {
+ // useMenuPopup Text navigation match menu items using HTMLElement.innerText
+ // innerText is not supported by JSDOM
+ skip();
+ }
- const { user } = await render(
-
- Toggle
-
-
-
- 1
- 2
- 3
- 4
-
-
-
- ,
- );
+ const itemElements = [
+ Aa ,
+ Ba ,
+ Bb ,
+ Ca ,
+ Cb ,
+ Cd ,
+ ];
- const trigger = screen.getByRole('button', { name: 'Toggle' });
- await user.click(trigger);
- const items = screen.getAllByRole('menuitem');
- await flushMicrotasks();
+ const { user } = await render(
+ ,
+ );
- await user.keyboard('b');
- await waitFor(() => {
- expect(items[1]).toHaveFocus();
- });
+ const items = screen.getAllByRole('menuitem');
- await waitFor(() => {
- expect(items[1]).to.have.attribute('tabindex', '0');
- });
+ await act(async () => {
+ items[0].focus();
+ });
- await user.keyboard('b');
- await waitFor(() => {
- expect(items[2]).toHaveFocus();
- });
+ await user.keyboard('c');
+ await waitFor(() => {
+ expect(screen.getByText('Ca')).toHaveFocus();
+ });
- await waitFor(() => {
- expect(items[2]).to.have.attribute('tabindex', '0');
- });
+ expect(screen.getByText('Ca')).to.have.attribute('tabindex', '0');
- await user.keyboard('b');
- await waitFor(() => {
- expect(items[2]).toHaveFocus();
- });
+ await user.keyboard('d');
+ await waitFor(() => {
+ expect(screen.getByText('Cd')).toHaveFocus();
+ });
- await waitFor(() => {
- expect(items[2]).to.have.attribute('tabindex', '0');
+ expect(screen.getByText('Cd')).to.have.attribute('tabindex', '0');
});
- });
- it('skips the non-stringifiable items', async ({ skip }) => {
- if (isJSDOM) {
- // useMenuPopup Text navigation match menu items using HTMLElement.innerText
- // innerText is not supported by JSDOM
- skip();
- }
+ it('changes the highlighted item using text navigation on label prop', async ({ skip }) => {
+ if (!isJSDOM) {
+ // This test is very flaky in real browsers
+ skip();
+ }
- const { user } = await render(
-
-
-
-
- Aa
- Ba
-
-
- Nested Content
-
- {undefined}
- {null}
- Bc
-
-
-
- ,
- );
+ const itemElements = [
+
+ 1
+ ,
+
+ 2
+ ,
+
+ 3
+ ,
+
+ 4
+ ,
+ ];
+
+ const { user } = await render( );
+
+ const trigger = screen.getByRole('button', { name: 'Toggle' });
+ await user.click(trigger);
+ const items = screen.getAllByRole('menuitem');
+ await flushMicrotasks();
+
+ await user.keyboard('b');
+ await waitFor(() => {
+ expect(items[1]).toHaveFocus();
+ });
- const items = screen.getAllByRole('menuitem');
+ await waitFor(() => {
+ expect(items[1]).to.have.attribute('tabindex', '0');
+ });
- await act(async () => {
- items[0].focus();
- });
+ await user.keyboard('b');
+ await waitFor(() => {
+ expect(items[2]).toHaveFocus();
+ });
- await user.keyboard('b');
- await waitFor(() => {
- expect(screen.getByText('Ba')).toHaveFocus();
- });
- expect(screen.getByText('Ba')).to.have.attribute('tabindex', '0');
+ await waitFor(() => {
+ expect(items[2]).to.have.attribute('tabindex', '0');
+ });
- await user.keyboard('c');
- await waitFor(() => {
- expect(screen.getByText('Bc')).toHaveFocus();
+ await user.keyboard('b');
+ await waitFor(() => {
+ expect(items[2]).toHaveFocus();
+ });
+
+ await waitFor(() => {
+ expect(items[2]).to.have.attribute('tabindex', '0');
+ });
});
- expect(screen.getByText('Bc')).to.have.attribute('tabindex', '0');
- });
- it('navigate to options with diacritic characters', async ({ skip }) => {
- if (isJSDOM) {
- // useMenuPopup Text navigation match menu items using HTMLElement.innerText
- // innerText is not supported by JSDOM
- skip();
- }
+ it('skips the non-stringifiable items', async ({ skip }) => {
+ if (isJSDOM) {
+ // useMenuPopup Text navigation match menu items using HTMLElement.innerText
+ // innerText is not supported by JSDOM
+ skip();
+ }
- const { user } = await render(
-
-
-
-
- Aa
- Ba
- Bb
- BÄ…
-
-
-
- ,
- );
+ const itemElements = [
+ Aa ,
+ Ba ,
+ ,
+
+ Nested Content
+ ,
+ {undefined} ,
+ {null} ,
+ Bc ,
+ ];
- const items = screen.getAllByRole('menuitem');
+ const { user } = await render(
+ ,
+ );
- await act(async () => {
- items[0].focus();
- });
+ const items = screen.getAllByRole('menuitem');
- await user.keyboard('b');
- await waitFor(() => {
- expect(screen.getByText('Ba')).toHaveFocus();
- });
- expect(screen.getByText('Ba')).to.have.attribute('tabindex', '0');
+ await act(async () => {
+ items[0].focus();
+ });
- await user.keyboard('Ä…');
- await waitFor(() => {
- expect(screen.getByText('BÄ…')).toHaveFocus();
+ await user.keyboard('b');
+ await waitFor(() => {
+ expect(screen.getByText('Ba')).toHaveFocus();
+ });
+ expect(screen.getByText('Ba')).to.have.attribute('tabindex', '0');
+
+ await user.keyboard('c');
+ await waitFor(() => {
+ expect(screen.getByText('Bc')).toHaveFocus();
+ });
+ expect(screen.getByText('Bc')).to.have.attribute('tabindex', '0');
});
- expect(screen.getByText('BÄ…')).to.have.attribute('tabindex', '0');
- });
- it('navigate to next options beginning with diacritic characters', async ({ skip }) => {
- if (isJSDOM) {
- // useMenuPopup Text navigation match menu items using HTMLElement.innerText
- // innerText is not supported by JSDOM
- skip();
- }
+ it('navigate to options with diacritic characters', async ({ skip }) => {
+ if (isJSDOM) {
+ // useMenuPopup Text navigation match menu items using HTMLElement.innerText
+ // innerText is not supported by JSDOM
+ skip();
+ }
- const { user } = await render(
-
-
-
-
- Aa
- Ä…a
- Ä…b
- Ä…c
-
-
-
- ,
- );
+ const itemElements = [
+ Aa ,
+ Ba ,
+ Bb ,
+ BÄ… ,
+ ];
- const items = screen.getAllByRole('menuitem');
+ const { user } = await render(
+ ,
+ );
- await act(async () => {
- items[0].focus();
- });
+ const items = screen.getAllByRole('menuitem');
- await user.keyboard('Ä…');
- await waitFor(() => {
- expect(screen.getByText('Ä…a')).toHaveFocus();
+ await act(async () => {
+ items[0].focus();
+ });
+
+ await user.keyboard('b');
+ await waitFor(() => {
+ expect(screen.getByText('Ba')).toHaveFocus();
+ });
+ expect(screen.getByText('Ba')).to.have.attribute('tabindex', '0');
+
+ await user.keyboard('Ä…');
+ await waitFor(() => {
+ expect(screen.getByText('BÄ…')).toHaveFocus();
+ });
+ expect(screen.getByText('BÄ…')).to.have.attribute('tabindex', '0');
});
- expect(screen.getByText('Ä…a')).to.have.attribute('tabindex', '0');
- });
- it('does not trigger the onClick event when Space is pressed during text navigation', async ({
- skip,
- }) => {
- if (isJSDOM) {
- // useMenuPopup Text navigation match menu items using HTMLElement.innerText
- // innerText is not supported by JSDOM
- skip();
- }
+ it('navigate to next options that begin with diacritic characters', async ({ skip }) => {
+ if (isJSDOM) {
+ // useMenuPopup Text navigation match menu items using HTMLElement.innerText
+ // innerText is not supported by JSDOM
+ skip();
+ }
- const handleClick = spy();
+ const itemElements = [
+ Aa ,
+ Ä…a ,
+ Ä…b ,
+ Ä…c ,
+ ];
- const { user } = await render(
-
-
-
-
- handleClick()}>Item One
- handleClick()}>Item Two
- handleClick()}>Item Three
-
-
-
- ,
- );
+ const { user } = await render(
+ ,
+ );
- const items = screen.getAllByRole('menuitem');
+ const items = screen.getAllByRole('menuitem');
- await act(async () => {
- items[0].focus();
+ await act(async () => {
+ items[0].focus();
+ });
+
+ await user.keyboard('Ä…');
+ await waitFor(() => {
+ expect(screen.getByText('Ä…a')).toHaveFocus();
+ });
+ expect(screen.getByText('Ä…a')).to.have.attribute('tabindex', '0');
});
- await user.keyboard('Item T');
+ it('does not trigger the onClick event when Space is pressed during text navigation', async ({
+ skip,
+ }) => {
+ if (isJSDOM) {
+ // useMenuPopup Text navigation match menu items using HTMLElement.innerText
+ // innerText is not supported by JSDOM
+ skip();
+ }
- expect(handleClick.called).to.equal(false);
+ const handleClick = spy();
- await waitFor(() => {
- expect(items[1]).toHaveFocus();
- });
- });
- });
- });
+ const itemElements = [
+ handleClick()}>
+ Item One
+ ,
+ handleClick()}>
+ Item Two
+ ,
+ handleClick()}>
+ Item Three
+ ,
+ ];
- describe('nested menus', () => {
- (
- [
- ['vertical', 'ltr', 'ArrowRight', 'ArrowLeft'],
- ['vertical', 'rtl', 'ArrowLeft', 'ArrowRight'],
- ['horizontal', 'ltr', 'ArrowDown', 'ArrowUp'],
- ['horizontal', 'rtl', 'ArrowDown', 'ArrowUp'],
- ] as const
- ).forEach(([orientation, direction, openKey, closeKey]) => {
- it.skipIf(isJSDOM)(
- `opens a nested menu of a ${orientation} ${direction.toUpperCase()} menu with ${openKey} key and closes it with ${closeKey}`,
- async () => {
const { user } = await render(
-
-
-
-
-
- 1
-
- 2
-
-
-
- 2.1
- 2.2
-
-
-
-
-
-
-
-
- ,
+ ,
);
- const submenuTrigger = screen.getByTestId('submenu-trigger');
+ const items = screen.getAllByRole('menuitem');
await act(async () => {
- submenuTrigger.focus();
- });
-
- // This check fails in JSDOM
- await waitFor(() => {
- expect(submenuTrigger).toHaveFocus();
+ items[0].focus();
});
- await user.keyboard(`[${openKey}]`);
+ await user.keyboard('Item T');
- let submenu: HTMLElement | null = await screen.findByTestId('submenu');
+ expect(handleClick.called).to.equal(false);
- const submenuItem1 = screen.queryByTestId('submenu-item-1');
- expect(submenuItem1).not.to.equal(null);
await waitFor(() => {
- expect(submenuItem1).toHaveFocus();
+ expect(items[1]).toHaveFocus();
});
+ });
+ });
+ });
- await user.keyboard(`[${closeKey}]`);
+ describe('nested menus', () => {
+ (
+ [
+ ['vertical', 'ltr', 'ArrowRight', 'ArrowLeft'],
+ ['vertical', 'rtl', 'ArrowLeft', 'ArrowRight'],
+ ['horizontal', 'ltr', 'ArrowDown', 'ArrowUp'],
+ ['horizontal', 'rtl', 'ArrowDown', 'ArrowUp'],
+ ] as const
+ ).forEach(([orientation, direction, openKey, closeKey]) => {
+ it.skipIf(isJSDOM)(
+ `opens a nested menu of a ${orientation} ${direction.toUpperCase()} menu with ${openKey} key and closes it with ${closeKey}`,
- submenu = screen.queryByTestId('submenu');
- expect(submenu).to.equal(null);
+ async () => {
+ const { user } = await render(
+
+
+ ,
+ );
- expect(submenuTrigger).toHaveFocus();
- },
- );
- });
+ const submenuTrigger = screen.getByTestId('submenu-trigger');
- it('opens submenu on click when openOnHover is false', async () => {
- const { user } = await render(
-
- Open Main
-
-
-
- Item 1
-
- Submenu
-
-
-
- Submenu Item
-
-
-
-
-
-
-
- ,
- );
+ await act(async () => {
+ submenuTrigger.focus();
+ });
- const mainTrigger = screen.getByRole('button', { name: 'Open Main' });
- await user.click(mainTrigger);
+ // This check fails in JSDOM
+ await waitFor(() => {
+ expect(submenuTrigger).toHaveFocus();
+ });
- const submenu = await screen.findByTestId('menu');
- expect(screen.queryByTestId('submenu')).to.equal(null);
+ await user.keyboard(`[${openKey}]`);
- const submenuTrigger = await screen.findByTestId('submenu-trigger');
- await user.click(submenuTrigger);
+ let submenu: HTMLElement | null = await screen.findByTestId('submenu');
- expect(submenu).not.to.equal(null);
- expect(await screen.findByTestId('submenu-item')).to.have.text('Submenu Item');
- });
+ const submenuItem1 = screen.queryByTestId('item-4_1');
+ expect(submenuItem1).not.to.equal(null);
+ await waitFor(() => {
+ expect(submenuItem1).toHaveFocus();
+ });
- it('closes submenus when focus is lost by shift-tabbing from a nested menu', async () => {
- const { user } = await render(
-
- Open Main
-
-
-
- Item 1
-
- Submenu
-
-
-
- Submenu Item
-
-
-
-
-
-
-
- ,
- );
+ await user.keyboard(`[${closeKey}]`);
- const mainTrigger = screen.getByRole('button', { name: 'Open Main' });
- await user.click(mainTrigger);
+ submenu = screen.queryByTestId('submenu');
+ expect(submenu).to.equal(null);
- await screen.findByTestId('menu');
- expect(screen.queryByTestId('submenu')).to.equal(null);
+ expect(submenuTrigger).toHaveFocus();
+ },
+ );
+ });
- const submenuTrigger = await screen.findByTestId('submenu-trigger');
- await user.hover(submenuTrigger);
+ it('opens submenu on click when openOnHover is false', async () => {
+ const { user } = await render( );
- await waitFor(() => {
- expect(screen.queryByTestId('submenu')).not.to.equal(null);
- });
+ const mainTrigger = screen.getByRole('button', { name: 'Toggle' });
+ await user.click(mainTrigger);
- const submenuItem = await screen.findByTestId('submenu-item');
- await act(async () => {
- submenuItem.focus();
- });
+ const menu = await screen.findByTestId('menu');
+ expect(screen.queryByTestId('submenu')).to.equal(null);
- await waitFor(() => {
- expect(submenuItem).toHaveFocus();
+ const submenuTrigger = await screen.findByTestId('submenu-trigger');
+ await user.click(submenuTrigger);
+
+ expect(menu).not.to.equal(null);
+ expect(await screen.findByTestId('item-4_1')).to.have.text('Item 4.1');
});
- // Shift+Tab should close the submenu and focus should return to the submenu trigger
- await user.keyboard('{Shift>}{Tab}{/Shift}');
+ it('closes submenus when focus is lost by shift-tabbing from a nested menu', async () => {
+ const { user } = await render( );
- await waitFor(() => {
+ const mainTrigger = screen.getByRole('button', { name: 'Toggle' });
+ await user.click(mainTrigger);
+
+ await screen.findByTestId('menu');
expect(screen.queryByTestId('submenu')).to.equal(null);
- });
- expect(submenuTrigger).toHaveFocus();
- });
- });
+ const submenuTrigger = await screen.findByTestId('submenu-trigger');
+ await user.hover(submenuTrigger);
- describe('focus management', () => {
- function Test() {
- return (
-
- Toggle
-
-
-
- 1
- 2
- 3
-
-
-
-
- );
- }
-
- it('focuses the first item after the menu is opened by keyboard', async () => {
- await render( );
-
- const trigger = screen.getByRole('button', { name: 'Toggle' });
- await act(async () => {
- trigger.focus();
- });
+ await waitFor(() => {
+ expect(screen.queryByTestId('submenu')).not.to.equal(null);
+ });
- await userEvent.keyboard('[Enter]');
+ const submenuItem = await screen.findByTestId('item-4_1');
+ await act(async () => {
+ submenuItem.focus();
+ });
- const [firstItem, ...otherItems] = screen.getAllByRole('menuitem');
- await waitFor(() => {
- expect(firstItem.tabIndex).to.equal(0);
- });
- otherItems.forEach((item) => {
- expect(item.tabIndex).to.equal(-1);
- });
- });
+ await waitFor(() => {
+ expect(submenuItem).toHaveFocus();
+ });
- it('focuses the first item when down arrow key opens the menu', async () => {
- const { user } = await render( );
+ // Shift+Tab should close the submenu and focus should return to the submenu trigger
+ await user.keyboard('{Shift>}{Tab}{/Shift}');
- const trigger = screen.getByRole('button', { name: 'Toggle' });
- await act(async () => {
- trigger.focus();
+ await waitFor(() => {
+ expect(screen.queryByTestId('submenu')).to.equal(null);
+ });
+
+ expect(submenuTrigger).toHaveFocus();
});
- await user.keyboard('[ArrowDown]');
+ it('closes the entire tree when clicking outside the deepest submenu', async () => {
+ const { user } = await render(
+
+
+ Outside
+
,
+ );
+
+ const trigger = screen.getByRole('button', { name: 'Toggle' });
+ await user.click(trigger);
+
+ await screen.findByTestId('menu');
+
+ await user.keyboard('[ArrowDown]');
+ await user.keyboard('[ArrowDown]');
+ await user.keyboard('[ArrowDown]');
+ await user.keyboard('[ArrowDown]');
+
+ const submenuTrigger1 = await screen.findByTestId('submenu-trigger');
+ await waitFor(() => {
+ expect(submenuTrigger1).toHaveFocus();
+ });
- const [firstItem, ...otherItems] = screen.getAllByRole('menuitem');
- await waitFor(() => expect(firstItem).toHaveFocus());
- expect(firstItem.tabIndex).to.equal(0);
- otherItems.forEach((item) => {
- expect(item.tabIndex).to.equal(-1);
+ await user.keyboard('[ArrowRight]');
+ await screen.findByTestId('submenu');
+
+ await user.keyboard('[ArrowDown]');
+ await user.keyboard('[ArrowDown]');
+
+ const submenuTrigger2 = await screen.findByTestId('nested-submenu-trigger');
+ await waitFor(() => {
+ expect(submenuTrigger2).toHaveFocus();
+ });
+
+ await user.keyboard('[ArrowRight]');
+ await screen.findByTestId('nested-submenu');
+
+ const outside = screen.getByTestId('outside');
+ await user.click(outside);
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('level-1')).to.equal(null);
+ expect(screen.queryByTestId('level-2')).to.equal(null);
+ expect(screen.queryByTestId('level-3')).to.equal(null);
+ });
});
});
- it('focuses the last item when up arrow key opens the menu', async () => {
- const { user } = await render( );
+ describe('focus management', () => {
+ it('focuses the first item after the menu is opened by keyboard', async () => {
+ await render( );
- const trigger = screen.getByRole('button', { name: 'Toggle' });
+ const trigger = screen.getByRole('button', { name: 'Toggle' });
+ await act(async () => {
+ trigger.focus();
+ });
- await act(async () => {
- trigger.focus();
+ await userEvent.keyboard('[Enter]');
+
+ const [firstItem, ...otherItems] = screen.getAllByRole('menuitem');
+ await waitFor(() => {
+ expect(firstItem.tabIndex).to.equal(0);
+ });
+ otherItems.forEach((item) => {
+ expect(item.tabIndex).to.equal(-1);
+ });
});
- await user.keyboard('[ArrowUp]');
+ it('focuses the first item when down arrow key opens the menu', async () => {
+ const { user } = await render( );
- const [firstItem, secondItem, lastItem] = screen.getAllByRole('menuitem');
- await waitFor(() => {
- expect(lastItem).toHaveFocus();
- });
+ const trigger = screen.getByRole('button', { name: 'Toggle' });
+ await act(async () => {
+ trigger.focus();
+ });
- expect(lastItem.tabIndex).to.equal(0);
- [firstItem, secondItem].forEach((item) => {
- expect(item.tabIndex).to.equal(-1);
+ await user.keyboard('[ArrowDown]');
+
+ const [firstItem, ...otherItems] = screen.getAllByRole('menuitem');
+ await waitFor(() => expect(firstItem).toHaveFocus());
+ expect(firstItem.tabIndex).to.equal(0);
+ otherItems.forEach((item) => {
+ expect(item.tabIndex).to.equal(-1);
+ });
});
- });
- it('focuses the trigger after the menu is closed', async () => {
- const { user } = await render(
-
-
-
- Toggle
-
-
-
- Close
-
-
-
-
-
- ,
- );
+ it('focuses the last item when up arrow key opens the menu', async () => {
+ const { user } = await render( );
- const button = screen.getByRole('button', { name: 'Toggle' });
- await user.click(button);
+ const trigger = screen.getByRole('button', { name: 'Toggle' });
- const menuItem = await screen.findByRole('menuitem');
- await user.click(menuItem);
+ await act(async () => {
+ trigger.focus();
+ });
- expect(button).toHaveFocus();
- });
+ await user.keyboard('[ArrowUp]');
- it('focuses the trigger after the menu is closed but not unmounted', async ({ skip }) => {
- if (isJSDOM) {
- // TODO: this stopped working in vitest JSDOM mode
- skip();
- }
+ const items = screen.getAllByRole('menuitem');
+ await waitFor(() => {
+ expect(items[4]).toHaveFocus();
+ });
- const { user } = await render(
-
-
-
- Toggle
-
-
-
- Close
-
-
-
-
-
- ,
- );
+ expect(items[4].tabIndex).to.equal(0);
+ [items[0], items[1], items[2], items[3]].forEach((item) => {
+ expect(item.tabIndex).to.equal(-1);
+ });
+ });
- const button = screen.getByRole('button', { name: 'Toggle' });
- await user.click(button);
+ it('focuses the trigger after the menu is closed', async () => {
+ const { user } = await render(
+
+
+
+
+
,
+ );
- const menuItem = await screen.findByRole('menuitem');
- await user.click(menuItem);
+ const button = screen.getByRole('button', { name: 'Toggle' });
+ await user.click(button);
+
+ const menuItem = await screen.findAllByRole('menuitem');
+ await user.click(menuItem[0]);
- await waitFor(() => {
expect(button).toHaveFocus();
});
- });
- });
- describe('prop: closeParentOnEsc', () => {
- it('does not close the parent menu when the Escape key is pressed by default', async () => {
- const { user } = await render(
-
- Open
-
-
-
-
-
- ,
- );
+ it('focuses the trigger after the menu is closed but not unmounted', async ({ skip }) => {
+ if (isJSDOM) {
+ // TODO: this stopped working in vitest JSDOM mode
+ skip();
+ }
- const trigger = screen.getByRole('button', { name: 'Open' });
- await act(async () => {
- trigger.focus();
- });
+ const { user } = await render(
+
+
+
+
+
,
+ );
- await user.keyboard('[ArrowDown]');
- await waitFor(() => {
- expect(screen.getByRole('menuitem', { name: '1' })).toHaveFocus();
- });
+ const button = screen.getByRole('button', { name: 'Toggle' });
+ await user.click(button);
- await user.keyboard('[ArrowDown]');
- await waitFor(() => {
- expect(screen.getByRole('menuitem', { name: '2' })).toHaveFocus();
- });
+ const menuItem = await screen.findAllByRole('menuitem');
+ await user.click(menuItem[0]);
- await user.keyboard('[ArrowRight]');
- await waitFor(() => {
- expect(screen.getByRole('menuitem', { name: '2.1' })).toHaveFocus();
+ await waitFor(() => {
+ expect(button).toHaveFocus();
+ });
});
+ });
- await user.keyboard('[Escape]');
+ describe('prop: closeParentOnEsc', () => {
+ it('does not close the parent menu when the Escape key is pressed by default', async () => {
+ const { user } = await render( );
- const menus = screen.queryAllByRole('menu', { hidden: false });
- await waitFor(() => {
- expect(menus.length).to.equal(1);
- });
+ const trigger = screen.getByRole('button', { name: 'Toggle' });
+ await act(async () => {
+ trigger.focus();
+ });
- expect(menus[0].id).to.equal('parent-menu');
- });
+ await user.keyboard('[ArrowDown]');
+ await waitFor(() => {
+ expect(screen.getByTestId('item-1')).toHaveFocus();
+ });
- it('closes the parent menu when the Escape key is pressed if `closeParentOnEsc=true`', async () => {
- const { user } = await render(
-
- Open
-
-
-
- 1
-
- 2
-
-
-
- 2.1
- 2.2
-
-
-
-
-
-
-
- ,
- );
+ await user.keyboard('[ArrowDown]');
+ await waitFor(() => {
+ expect(screen.getByTestId('item-2')).toHaveFocus();
+ });
- const trigger = screen.getByRole('button', { name: 'Open' });
- await act(async () => {
- trigger.focus();
- });
+ await user.keyboard('[ArrowDown]');
+ await waitFor(() => {
+ expect(screen.getByTestId('item-3')).toHaveFocus();
+ });
- await user.keyboard('[ArrowDown]');
- await waitFor(() => {
- expect(screen.getByRole('menuitem', { name: '1' })).toHaveFocus();
- });
+ await user.keyboard('[ArrowDown]');
+ await waitFor(() => {
+ expect(screen.getByTestId('submenu-trigger')).toHaveFocus();
+ });
- await user.keyboard('[ArrowDown]');
- await waitFor(() => {
- expect(screen.getByRole('menuitem', { name: '2' })).toHaveFocus();
- });
+ await user.keyboard('[ArrowRight]');
+ await waitFor(() => {
+ expect(screen.getByTestId('item-4_1')).toHaveFocus();
+ });
- await user.keyboard('[ArrowRight]');
- await waitFor(() => {
- expect(screen.getByRole('menuitem', { name: '2.1' })).toHaveFocus();
+ await user.keyboard('[Escape]');
+
+ const menus = screen.queryAllByRole('menu', { hidden: false });
+ await waitFor(() => {
+ expect(menus.length).to.equal(1);
+ });
+
+ expect(menus[0].dataset.testid).to.equal('menu');
});
- await user.keyboard('[Escape]');
- await flushMicrotasks();
+ it('closes the parent menu when the Escape key is pressed if `closeParentOnEsc=true`', async () => {
+ const { user } = await render( );
- expect(screen.queryByRole('menu', { hidden: false })).to.equal(null);
- });
- });
+ const trigger = screen.getByRole('button', { name: 'Toggle' });
+ await act(async () => {
+ trigger.focus();
+ });
- describe('prop: modal', () => {
- it('should render an internal backdrop when `true`', async () => {
- const { user } = await render(
-
-
- Open
-
-
-
- 1
-
-
-
-
- Outside
- ,
- );
+ await user.keyboard('[ArrowDown]');
+ await waitFor(() => {
+ expect(screen.getByTestId('item-1')).toHaveFocus();
+ });
- const trigger = screen.getByRole('button', { name: 'Open' });
+ await user.keyboard('[ArrowDown]');
+ await waitFor(() => {
+ expect(screen.getByTestId('item-2')).toHaveFocus();
+ });
- await user.click(trigger);
+ await user.keyboard('[ArrowDown]');
+ await waitFor(() => {
+ expect(screen.getByTestId('item-3')).toHaveFocus();
+ });
- await waitFor(() => {
- expect(screen.queryByRole('menu')).not.to.equal(null);
- });
+ await user.keyboard('[ArrowDown]');
+ await waitFor(() => {
+ expect(screen.getByTestId('submenu-trigger')).toHaveFocus();
+ });
+
+ await user.keyboard('[ArrowRight]');
+ await waitFor(() => {
+ expect(screen.getByRole('menuitem', { name: 'Item 4.1' })).toHaveFocus();
+ });
- const positioner = screen.getByTestId('positioner');
+ await user.keyboard('[Escape]');
+ await flushMicrotasks();
- expect(positioner.previousElementSibling).to.have.attribute('role', 'presentation');
+ expect(screen.queryByRole('menu', { hidden: false })).to.equal(null);
+ });
});
- it('should not render an internal backdrop when `false`', async () => {
- const { user } = await render(
-
-
- Open
-
-
-
- 1
-
-
-
-
- Outside
- ,
- );
+ describe('prop: modal', () => {
+ it('should render an internal backdrop when `true`', async () => {
+ const { user } = await render(
+
+
+ Outside
+
,
+ );
- const trigger = screen.getByRole('button', { name: 'Open' });
+ const trigger = screen.getByRole('button', { name: 'Toggle' });
- await user.click(trigger);
+ await user.click(trigger);
- await waitFor(() => {
- expect(screen.queryByRole('menu')).not.to.equal(null);
+ await waitFor(() => {
+ expect(screen.queryByRole('menu')).not.to.equal(null);
+ });
+
+ const positioner = screen.getByTestId('menu-positioner');
+
+ expect(positioner.previousElementSibling).to.have.attribute('role', 'presentation');
});
- const positioner = screen.getByTestId('positioner');
+ it('should not render an internal backdrop when `false`', async () => {
+ const { user } = await render(
+
+
+ Outside
+
,
+ );
- expect(positioner.previousElementSibling).to.equal(null);
- });
- });
+ const trigger = screen.getByRole('button', { name: 'Toggle' });
- describe('prop: actionsRef', () => {
- it('unmounts the menu when the `unmount` method is called', async () => {
- const actionsRef = {
- current: {
- unmount: spy(),
- },
- };
-
- const { user } = await render(
-
- Open
-
-
-
-
-
- ,
- );
-
- const trigger = screen.getByRole('button', { name: 'Open' });
- await act(() => {
- trigger.focus();
- });
+ await user.click(trigger);
- await user.keyboard('{Enter}');
+ await waitFor(() => {
+ expect(screen.queryByRole('menu')).not.to.equal(null);
+ });
- await waitFor(() => {
- expect(screen.queryByRole('menu')).not.to.equal(null);
+ const positioner = screen.getByTestId('menu-positioner');
+
+ expect(positioner.previousElementSibling).to.equal(null);
});
+ });
- await user.click(trigger);
+ describe('prop: actionsRef', () => {
+ it('unmounts the menu when the `unmount` method is called', async () => {
+ const actionsRef = {
+ current: {
+ unmount: spy(),
+ },
+ };
- await waitFor(() => {
- expect(screen.queryByRole('menu')).not.to.equal(null);
- });
+ const { user } = await render( );
- await act(async () => {
- await new Promise((resolve) => {
- requestAnimationFrame(resolve);
+ const trigger = screen.getByRole('button', { name: 'Toggle' });
+ await act(() => {
+ trigger.focus();
});
- actionsRef.current.unmount();
- });
+ await user.keyboard('{Enter}');
- await waitFor(() => {
- expect(screen.queryByRole('menu')).to.equal(null);
+ await waitFor(() => {
+ expect(screen.queryByRole('menu')).not.to.equal(null);
+ });
+
+ await user.click(trigger);
+
+ await waitFor(() => {
+ expect(screen.queryByRole('menu')).not.to.equal(null);
+ });
+
+ await act(async () => {
+ await new Promise((resolve) => {
+ requestAnimationFrame(resolve);
+ });
+
+ actionsRef.current.unmount();
+ });
+
+ await waitFor(() => {
+ expect(screen.queryByRole('menu')).to.equal(null);
+ });
});
});
- });
- describe.skipIf(isJSDOM)('prop: onOpenChangeComplete', () => {
- it('is called on close when there is no exit animation defined', async () => {
- const onOpenChangeComplete = spy();
+ describe.skipIf(isJSDOM)('prop: onOpenChangeComplete', () => {
+ it('is called on close when there is no exit animation defined', async () => {
+ const onOpenChangeComplete = spy();
+
+ function Test() {
+ const [open, setOpen] = React.useState(true);
+ return (
+
+ setOpen(false)}>Close
+
+
+ );
+ }
- function Test() {
- const [open, setOpen] = React.useState(true);
- return (
-
- setOpen(false)}>Close
-
-
-
-
-
-
-
-
- );
- }
+ const { user } = await render( );
- const { user } = await render( );
+ const closeButton = screen.getByText('Close');
+ await user.click(closeButton);
- const closeButton = screen.getByText('Close');
- await user.click(closeButton);
+ await waitFor(() => {
+ expect(screen.queryByTestId('menu')).to.equal(null);
+ });
- await waitFor(() => {
- expect(screen.queryByTestId('popup')).to.equal(null);
+ expect(onOpenChangeComplete.firstCall.args[0]).to.equal(true);
+ expect(onOpenChangeComplete.lastCall.args[0]).to.equal(false);
});
- expect(onOpenChangeComplete.firstCall.args[0]).to.equal(true);
- expect(onOpenChangeComplete.lastCall.args[0]).to.equal(false);
- });
-
- it('is called on close when the exit animation finishes', async () => {
- globalThis.BASE_UI_ANIMATIONS_DISABLED = false;
+ it('is called on close when the exit animation finishes', async () => {
+ globalThis.BASE_UI_ANIMATIONS_DISABLED = false;
- const onOpenChangeComplete = spy();
+ const onOpenChangeComplete = spy();
- function Test() {
- const style = `
+ function Test() {
+ const style = `
@keyframes test-anim {
to {
opacity: 0;
@@ -1034,82 +860,73 @@ describe(' ', () => {
}
`;
- const [open, setOpen] = React.useState(true);
+ const [open, setOpen] = React.useState(true);
+
+ return (
+
+ {/* eslint-disable-next-line react/no-danger */}
+
+ setOpen(false)}>Close
+
+
+ );
+ }
- return (
-
- {/* eslint-disable-next-line react/no-danger */}
-
- setOpen(false)}>Close
-
-
-
-
-
-
-
-
- );
- }
+ const { user } = await render( );
- const { user } = await render( );
+ expect(screen.getByTestId('menu')).not.to.equal(null);
- expect(screen.getByTestId('popup')).not.to.equal(null);
+ // Wait for open animation to finish
+ await waitFor(() => {
+ expect(onOpenChangeComplete.firstCall.args[0]).to.equal(true);
+ });
- // Wait for open animation to finish
- await waitFor(() => {
- expect(onOpenChangeComplete.firstCall.args[0]).to.equal(true);
- });
+ const closeButton = screen.getByText('Close');
+ await user.click(closeButton);
- const closeButton = screen.getByText('Close');
- await user.click(closeButton);
+ await waitFor(() => {
+ expect(screen.queryByTestId('menu')).to.equal(null);
+ });
- await waitFor(() => {
- expect(screen.queryByTestId('popup')).to.equal(null);
+ expect(onOpenChangeComplete.lastCall.args[0]).to.equal(false);
});
- expect(onOpenChangeComplete.lastCall.args[0]).to.equal(false);
- });
+ it('is called on open when there is no enter animation defined', async () => {
+ const onOpenChangeComplete = spy();
- it('is called on open when there is no enter animation defined', async () => {
- const onOpenChangeComplete = spy();
+ function Test() {
+ const [open, setOpen] = React.useState(false);
+ return (
+
+ setOpen(true)}>Open
+
+
+ );
+ }
- function Test() {
- const [open, setOpen] = React.useState(false);
- return (
-
- setOpen(true)}>Open
-
-
-
-
-
-
-
-
- );
- }
+ const { user } = await render( );
- const { user } = await render( );
+ const openButton = screen.getByText('Open');
+ await user.click(openButton);
- const openButton = screen.getByText('Open');
- await user.click(openButton);
+ await waitFor(() => {
+ expect(screen.queryByTestId('menu')).not.to.equal(null);
+ });
- await waitFor(() => {
- expect(screen.queryByTestId('popup')).not.to.equal(null);
+ expect(onOpenChangeComplete.callCount).to.equal(2);
+ expect(onOpenChangeComplete.firstCall.args[0]).to.equal(true);
});
- expect(onOpenChangeComplete.callCount).to.equal(2);
- expect(onOpenChangeComplete.firstCall.args[0]).to.equal(true);
- });
-
- it('is called on open when the enter animation finishes', async () => {
- globalThis.BASE_UI_ANIMATIONS_DISABLED = false;
+ it('is called on open when the enter animation finishes', async () => {
+ globalThis.BASE_UI_ANIMATIONS_DISABLED = false;
- const onOpenChangeComplete = spy();
+ const onOpenChangeComplete = spy();
- function Test() {
- const style = `
+ function Test() {
+ const style = `
@keyframes test-anim {
from {
opacity: 0;
@@ -1121,301 +938,342 @@ describe(' ', () => {
}
`;
- const [open, setOpen] = React.useState(false);
+ const [open, setOpen] = React.useState(false);
+
+ return (
+
+ {/* eslint-disable-next-line react/no-danger */}
+
+ setOpen(true)}>Open
+
+
+ );
+ }
- return (
-
- {/* eslint-disable-next-line react/no-danger */}
-
- setOpen(true)}>Open
-
-
-
-
-
-
-
-
- );
- }
+ const { user } = await render( );
- const { user } = await render( );
+ const openButton = screen.getByText('Open');
+ await user.click(openButton);
- const openButton = screen.getByText('Open');
- await user.click(openButton);
+ // Wait for open animation to finish
+ await waitFor(() => {
+ expect(onOpenChangeComplete.firstCall.args[0]).to.equal(true);
+ });
- // Wait for open animation to finish
- await waitFor(() => {
- expect(onOpenChangeComplete.firstCall.args[0]).to.equal(true);
+ expect(screen.queryByTestId('menu')).not.to.equal(null);
});
- expect(screen.queryByTestId('popup')).not.to.equal(null);
+ it('does not get called on mount when not open', async () => {
+ const onOpenChangeComplete = spy();
+
+ await render( );
+
+ expect(onOpenChangeComplete.callCount).to.equal(0);
+ });
});
- it('does not get called on mount when not open', async () => {
- const onOpenChangeComplete = spy();
+ describe('prop: openOnHover', () => {
+ it('should open the menu when the trigger is hovered', async () => {
+ await render( );
- await render(
-
-
-
-
-
-
- ,
- );
+ const trigger = screen.getByRole('button', { name: 'Toggle' });
- expect(onOpenChangeComplete.callCount).to.equal(0);
- });
- });
+ await act(async () => {
+ trigger.focus();
+ });
- describe('prop: openOnHover', () => {
- it('should open the menu when the trigger is hovered', async () => {
- await render(
-
- Open
-
-
-
- 1
-
-
-
- ,
- );
-
- const trigger = screen.getByRole('button', { name: 'Open' });
+ await userEvent.hover(trigger);
- await act(async () => {
- trigger.focus();
+ await waitFor(() => {
+ expect(screen.queryByRole('menu')).not.to.equal(null);
+ });
});
- await userEvent.hover(trigger);
+ it('should close the menu when the trigger is no longer hovered', async () => {
+ await render(
+ ,
+ );
+
+ const trigger = screen.getByRole('button', { name: 'Toggle' });
- await waitFor(() => {
- expect(screen.queryByRole('menu')).not.to.equal(null);
- });
- });
+ await act(async () => {
+ trigger.focus();
+ });
- it.skipIf(!isJSDOM)('should close the menu when the trigger is no longer hovered', async () => {
- await render(
-
- Open
-
-
-
- 1
-
-
-
- ,
- );
-
- const trigger = screen.getByRole('button', { name: 'Open' });
+ await userEvent.hover(trigger);
- await act(async () => {
- trigger.focus();
- });
+ await waitFor(() => {
+ expect(screen.queryByRole('menu')).not.to.equal(null);
+ });
- await userEvent.hover(trigger);
+ await userEvent.unhover(trigger);
- await waitFor(() => {
- expect(screen.queryByRole('menu')).not.to.equal(null);
+ await waitFor(() => {
+ expect(screen.queryByRole('menu')).to.equal(null);
+ });
});
- await userEvent.unhover(trigger);
+ it('should not close when submenu is hovered after root menu is hovered', async () => {
+ await render(
+ ,
+ );
- await waitFor(() => {
- expect(screen.queryByRole('menu')).to.equal(null);
- });
- });
+ const trigger = screen.getByRole('button', { name: 'Toggle' });
- it('should not close when submenu is hovered after root menu is hovered', async () => {
- await render(
-
- Open
-
-
-
- 1
-
- 2
-
-
-
- 2.1
-
-
-
-
-
-
-
- ,
- );
+ await act(async () => {
+ trigger.focus();
+ });
- const trigger = screen.getByRole('button', { name: 'Open' });
+ await userEvent.hover(trigger);
- await act(async () => {
- trigger.focus();
- });
+ await waitFor(() => {
+ expect(screen.getByTestId('menu')).not.to.equal(null);
+ });
- await userEvent.hover(trigger);
+ const menu = screen.getByTestId('menu');
- await waitFor(() => {
- expect(screen.getByTestId('menu')).not.to.equal(null);
- });
+ await userEvent.hover(menu);
+
+ const submenuTrigger = screen.getByRole('menuitem', { name: 'Item 4' });
- const menu = screen.getByTestId('menu');
+ await userEvent.hover(submenuTrigger);
- await userEvent.hover(menu);
+ await waitFor(() => {
+ expect(screen.getByTestId('menu')).not.to.equal(null);
+ });
+ await waitFor(() => {
+ expect(screen.getByTestId('submenu')).not.to.equal(null);
+ });
- const submenuTrigger = screen.getByRole('menuitem', { name: '2' });
+ const submenu = screen.getByTestId('submenu');
- await userEvent.hover(submenuTrigger);
+ // Use fireEvent to bypass pointer-events checks during safe-polygon pointer events mutation
+ fireEvent.mouseMove(menu);
+ fireEvent.mouseLeave(menu);
+ await userEvent.hover(submenu);
- await waitFor(() => {
- expect(screen.getByTestId('menu')).not.to.equal(null);
- });
- await waitFor(() => {
- expect(screen.getByTestId('submenu')).not.to.equal(null);
+ await waitFor(() => {
+ expect(screen.getByTestId('menu')).not.to.equal(null);
+ });
+ await waitFor(() => {
+ expect(screen.getByTestId('submenu')).not.to.equal(null);
+ });
});
- const submenu = screen.getByTestId('submenu');
+ it('keeps the parent submenu open after a third-level submenu closes due to sibling hover', async () => {
+ await render(
+ ,
+ );
- // Use fireEvent to bypass pointer-events checks during safe-polygon pointer events mutation
- fireEvent.mouseMove(menu);
- fireEvent.mouseLeave(menu);
- await userEvent.hover(submenu);
+ const trigger = screen.getByRole('button', { name: 'Toggle' });
- await waitFor(() => {
- expect(screen.getByTestId('menu')).not.to.equal(null);
- });
- await waitFor(() => {
- expect(screen.getByTestId('submenu')).not.to.equal(null);
- });
- });
+ await act(async () => {
+ trigger.focus();
+ });
- it('keeps the parent submenu open after a third-level submenu closes due to sibling hover', async () => {
- await render(
-
- Open
-
-
-
-
- Level 1
-
-
-
- Parent Sibling
-
- Level 2
-
-
-
- Child Item
-
-
-
-
-
-
-
-
-
-
-
- ,
- );
+ await userEvent.hover(trigger);
- const trigger = screen.getByRole('button', { name: 'Open' });
+ await waitFor(() => {
+ expect(screen.getByTestId('menu')).not.to.equal(null);
+ });
- await act(async () => {
- trigger.focus();
- });
+ // Open first-level submenu
+ const level1Trigger = screen.getByRole('menuitem', { name: 'Item 4' });
+ await userEvent.hover(level1Trigger);
- await userEvent.hover(trigger);
+ await waitFor(() => {
+ expect(screen.getByTestId('submenu')).not.to.equal(null);
+ });
- await waitFor(() => {
- expect(screen.getByTestId('menu')).not.to.equal(null);
- });
+ // Open second-level submenu
+ const level2Trigger = screen.getByRole('menuitem', { name: 'Item 4.3' });
+ await userEvent.hover(level2Trigger);
- // Open first-level submenu
- const level1Trigger = screen.getByRole('menuitem', { name: 'Level 1' });
- await userEvent.hover(level1Trigger);
+ await waitFor(() => {
+ expect(screen.getByTestId('nested-submenu')).not.to.equal(null);
+ });
- await waitFor(() => {
- expect(screen.getByTestId('submenu-1')).not.to.equal(null);
- });
+ // Hover a sibling item in the parent submenu to close the second-level submenu
+ const parentSibling = screen.getByRole('menuitem', { name: 'Item 4.2' });
+ // Use fireEvent to bypass pointer-events checks during safe-polygon pointer events mutation
+ fireEvent.mouseMove(parentSibling);
- // Open second-level submenu
- const level2Trigger = screen.getByRole('menuitem', { name: 'Level 2' });
- await userEvent.hover(level2Trigger);
+ await waitFor(() => {
+ expect(screen.queryByTestId('nested-submenu')).to.equal(null);
+ });
- await waitFor(() => {
- expect(screen.getByTestId('submenu-2')).not.to.equal(null);
+ // Now unhover the parent submenu container; it should remain open
+ const submenu1 = screen.getByTestId('submenu');
+ fireEvent.mouseLeave(submenu1);
+
+ // Parent submenu should still be open
+ await waitFor(() => {
+ expect(screen.getByTestId('submenu')).not.to.equal(null);
+ });
});
+ });
- // Hover a sibling item in the parent submenu to close the second-level submenu
- const parentSibling = screen.getByRole('menuitem', { name: 'Parent Sibling' });
- // Use fireEvent to bypass pointer-events checks during safe-polygon pointer events mutation
- fireEvent.mouseMove(parentSibling);
+ describe('prop: closeDelay', () => {
+ const { render: renderFakeTimers, clock } = createRenderer();
- await waitFor(() => {
- expect(() => screen.getByTestId('submenu-2')).to.throw();
- });
+ clock.withFakeTimers();
+
+ it('should close after delay', async () => {
+ await renderFakeTimers(
+ ,
+ );
- // Now unhover the parent submenu container; it should remain open
- const submenu1 = screen.getByTestId('submenu-1');
- fireEvent.mouseLeave(submenu1);
+ const anchor = screen.getByRole('button');
- // Parent submenu should still be open
- await waitFor(() => {
- expect(screen.getByTestId('submenu-1')).not.to.equal(null);
+ fireEvent.mouseEnter(anchor);
+ fireEvent.mouseMove(anchor);
+
+ await flushMicrotasks();
+
+ expect(screen.getByText('Item 1')).not.to.equal(null);
+
+ fireEvent.mouseLeave(anchor);
+
+ clock.tick(50);
+
+ expect(screen.getByText('Item 1')).not.to.equal(null);
+
+ clock.tick(50);
+
+ expect(screen.queryByText('Item 1')).to.equal(null);
});
});
- });
- describe('prop: closeDelay', () => {
- const { render: renderFakeTimers, clock } = createRenderer();
+ describe.skipIf(isJSDOM)('mouse interaction', () => {
+ afterEach(async () => {
+ const { cleanup } = await import('vitest-browser-react');
+ cleanup();
+ });
+
+ it('triggers a menu item and closes the menu on click, drag, release', async () => {
+ const openChangeSpy = spy();
+ const clickSpy = spy();
+
+ const items = [
+
+ 1
+ ,
+
+ 2
+ ,
+
+ 3
+ ,
+ ];
+
+ await render(
+
+
+
,
+ );
- clock.withFakeTimers();
+ const trigger = screen.getByRole('button', { name: 'Toggle' });
+
+ fireEvent.mouseDown(trigger);
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('menu')).not.to.equal(null);
+ });
+
+ await wait(200);
+
+ const item2 = screen.getByTestId('item-2');
+ fireEvent.mouseUp(item2);
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('menu')).to.equal(null);
+ });
+
+ expect(clickSpy.callCount).to.equal(1);
+
+ expect(openChangeSpy.callCount).to.equal(2);
+ expect(openChangeSpy.firstCall.args[0]).to.equal(true);
+ expect(openChangeSpy.lastCall.args[0]).to.equal(false);
+ expect(openChangeSpy.lastCall.args[1].reason).to.equal(REASONS.itemPress);
+ });
+
+ it('closes the menu on click, drag outside, release', async () => {
+ const { userEvent: user } = await import('@vitest/browser/context');
+ const { render: vbrRender } = await import('vitest-browser-react');
- it('should close after delay', async () => {
- await renderFakeTimers(
-
-
-
-
- Content
-
-
- ,
- );
+ const openChangeSpy = spy();
- const anchor = screen.getByRole('button');
+ const items = [
+
+ 1
+ ,
+
+ 2
+ ,
+
+ 3
+ ,
+ ];
- fireEvent.mouseEnter(anchor);
- fireEvent.mouseMove(anchor);
+ vbrRender(
+ ,
+ );
- await flushMicrotasks();
+ const trigger = screen.getByRole('button', { name: 'Toggle' });
+ const outsideElement = screen.getByTestId('outside');
- expect(screen.getByText('Content')).not.to.equal(null);
+ await user.dragAndDrop(trigger, outsideElement);
- fireEvent.mouseLeave(anchor);
+ await waitFor(() => {
+ expect(screen.queryByTestId('menu')).to.equal(null);
+ });
- clock.tick(50);
+ expect(openChangeSpy.callCount).to.equal(2);
+ expect(openChangeSpy.firstCall.args[0]).to.equal(true);
+ expect(openChangeSpy.lastCall.args[0]).to.equal(false);
+ expect(openChangeSpy.lastCall.args[1].reason).to.equal(REASONS.cancelOpen);
+ });
+ });
- expect(screen.getByText('Content')).not.to.equal(null);
+ describe('BaseUIChangeEventDetails', () => {
+ it('onOpenChange cancel() prevents opening while uncontrolled', async () => {
+ await render(
+ {
+ if (nextOpen) {
+ eventDetails.cancel();
+ }
+ },
+ }}
+ />,
+ );
- clock.tick(50);
+ const trigger = screen.getByRole('button', { name: 'Toggle' });
+ await userEvent.click(trigger);
- expect(screen.queryByText('Content')).to.equal(null);
+ await waitFor(() => {
+ expect(screen.queryByRole('menu')).to.equal(null);
+ });
+ });
});
});
@@ -1489,98 +1347,94 @@ describe(' ', () => {
expect(screen.queryByRole('menuitem', { name: 'Add to Library' })).toHaveFocus();
});
});
+});
- describe.skipIf(isJSDOM)('mouse interaction', () => {
- afterEach(async () => {
- const { cleanup } = await import('vitest-browser-react');
- cleanup();
- });
-
- it('triggers a menu item and closes the menu on click, drag, release', async () => {
- const openChangeSpy = spy();
- const clickSpy = spy();
-
- await render(
-
-
- Toggle
-
-
-
- 1
-
- 2
-
- 3
-
-
-
-
- ,
- );
-
- const trigger = screen.getByRole('button', { name: 'Toggle' });
-
- fireEvent.mouseDown(trigger);
-
- await waitFor(() => {
- expect(screen.queryByTestId('menu')).not.to.equal(null);
- });
-
- await wait(200);
-
- const item2 = screen.getByTestId('item-2');
- fireEvent.mouseUp(item2);
-
- await waitFor(() => {
- expect(screen.queryByTestId('menu')).to.equal(null);
- });
-
- expect(clickSpy.callCount).to.equal(1);
-
- expect(openChangeSpy.callCount).to.equal(2);
- expect(openChangeSpy.firstCall.args[0]).to.equal(true);
- expect(openChangeSpy.lastCall.args[0]).to.equal(false);
- expect(openChangeSpy.lastCall.args[1].reason).to.equal(REASONS.itemPress);
- });
-
- it('closes the menu on click, drag outside, release', async () => {
- const { userEvent: user } = await import('@vitest/browser/context');
- const { render: vbrRender } = await import('vitest-browser-react');
-
- const openChangeSpy = spy();
-
- vbrRender(
-
-
- Toggle
-
-
-
- 1
- 2
- 3
-
-
-
-
-
Outside
-
,
- );
-
- const trigger = screen.getByRole('button', { name: 'Toggle' });
- const outsideElement = screen.getByTestId('outside');
-
- await user.dragAndDrop(trigger, outsideElement);
+function ContainedTriggerMenu(props: TestMenuProps) {
+ const { triggerProps, ...rest } = props;
+ return (
+
+ Toggle
+
+ );
+}
- await waitFor(() => {
- expect(screen.queryByTestId('menu')).to.equal(null);
- });
+function DetachedTriggerMenu(props: TestMenuProps) {
+ const { triggerProps, ...rest } = props;
+ const menuHandle = useRefWithInit(() => new Menu.Handle()).current;
+
+ return (
+
+
+
+ Toggle
+
+
+ );
+}
- expect(openChangeSpy.callCount).to.equal(2);
- expect(openChangeSpy.firstCall.args[0]).to.equal(true);
- expect(openChangeSpy.lastCall.args[0]).to.equal(false);
- expect(openChangeSpy.lastCall.args[1].reason).to.equal(REASONS.cancelOpen);
- });
- });
-});
+type TestMenuProps = {
+ rootProps?: Menu.Root.Props;
+ portalProps?: Menu.Portal.Props;
+ popupProps?: Menu.Popup.Props;
+ triggerProps?: Menu.Trigger.Props;
+ submenuProps?: Menu.SubmenuRoot.Props;
+ submenuTriggerProps?: Menu.SubmenuTrigger.Props;
+ children?: React.ReactNode;
+};
+
+function TestMenuContents(props: TestMenuProps) {
+ const { children, rootProps, portalProps, submenuProps, submenuTriggerProps, popupProps } = props;
+ return (
+
+ {children}
+
+
+
+ {popupProps?.children ?? (
+
+ Item 1
+ Item 2
+
+ Item 3
+
+
+
+ Item 4
+
+
+
+
+ Item 4.1
+ Item 4.2
+
+
+ Item 4.3
+
+
+
+
+ Item 4.3.1
+ Item 4.3.2
+
+
+
+
+
+
+
+
+ Item 5
+
+ )}
+
+
+
+
+ );
+}
diff --git a/packages/react/src/menu/root/MenuRoot.tsx b/packages/react/src/menu/root/MenuRoot.tsx
index 08c14602d0e..0b4aa98538e 100644
--- a/packages/react/src/menu/root/MenuRoot.tsx
+++ b/packages/react/src/menu/root/MenuRoot.tsx
@@ -4,43 +4,43 @@ import * as ReactDOM from 'react-dom';
import { useTimeout } from '@base-ui-components/utils/useTimeout';
import { useStableCallback } from '@base-ui-components/utils/useStableCallback';
import { useId } from '@base-ui-components/utils/useId';
-import { useRefWithInit } from '@base-ui-components/utils/useRefWithInit';
import { useIsoLayoutEffect } from '@base-ui-components/utils/useIsoLayoutEffect';
import { useAnimationFrame } from '@base-ui-components/utils/useAnimationFrame';
import { EMPTY_ARRAY } from '@base-ui-components/utils/empty';
import {
FloatingTree,
- useClick,
useDismiss,
+ useFloatingNodeId,
+ useFloatingParentNodeId,
useFloatingRootContext,
- useFocus,
- useHover,
useInteractions,
useListNavigation,
useRole,
useTypeahead,
- safePolygon,
} from '../../floating-ui-react';
import { MenuRootContext, useMenuRootContext } from './MenuRootContext';
import { MenubarContext, useMenubarContext } from '../../menubar/MenubarContext';
import { useTransitionStatus } from '../../utils/useTransitionStatus';
-import { PATIENT_CLICK_THRESHOLD, TYPEAHEAD_RESET_MS } from '../../utils/constants';
+import { TYPEAHEAD_RESET_MS } from '../../utils/constants';
import { useOpenChangeComplete } from '../../utils/useOpenChangeComplete';
import { useDirection } from '../../direction-provider/DirectionContext';
import { useScrollLock } from '../../utils/useScrollLock';
import { useOpenInteractionType } from '../../utils/useOpenInteractionType';
import type { FloatingUIOpenChangeDetails } from '../../utils/types';
-import type { BaseUIChangeEventDetails } from '../../utils/createBaseUIEventDetails';
+import {
+ createChangeEventDetails,
+ type BaseUIChangeEventDetails,
+} from '../../utils/createBaseUIEventDetails';
import { REASONS } from '../../utils/reasons';
import {
ContextMenuRootContext,
useContextMenuRootContext,
} from '../../context-menu/root/ContextMenuRootContext';
-import { useMenuSubmenuRootContext } from '../submenu-root/MenuSubmenuRootContext';
-import { useMixedToggleClickHandler } from '../../utils/useMixedToggleClickHander';
import { mergeProps } from '../../merge-props';
-import { useFloatingParentNodeId } from '../../floating-ui-react/components/FloatingTree';
import { MenuStore } from '../store/MenuStore';
+import { MenuHandle } from '../store/MenuHandle';
+import { PayloadChildRenderFunction } from '../../utils/popupStoreUtils';
+import { useMenuSubmenuRootContext } from '../submenu-root/MenuSubmenuRootContext';
/**
* Groups all parts of the menu.
@@ -48,7 +48,7 @@ import { MenuStore } from '../store/MenuStore';
*
* Documentation: [Base UI Menu](https://base-ui.com/react/components/menu)
*/
-export const MenuRoot: React.FC = function MenuRoot(props) {
+export function MenuRoot(props: MenuRoot.Props) {
const {
children,
open: openProp,
@@ -60,22 +60,22 @@ export const MenuRoot: React.FC = function MenuRoot(props) {
loopFocus = true,
orientation = 'vertical',
actionsRef,
- openOnHover: openOnHoverProp,
- delay = 100,
- closeDelay = 0,
closeParentOnEsc = true,
+ handle,
+ triggerId: triggerIdProp,
+ defaultTriggerId: defaultTriggerIdProp = null,
} = props;
const contextMenuContext = useContextMenuRootContext(true);
- const parentContext = useMenuRootContext(true);
+ const parentMenuRootContext = useMenuRootContext(true);
const menubarContext = useMenubarContext(true);
const isSubmenu = useMenuSubmenuRootContext();
- const parent: MenuParent = React.useMemo(() => {
- if (isSubmenu && parentContext) {
+ const parentFromContext: MenuParent = React.useMemo(() => {
+ if (isSubmenu && parentMenuRootContext) {
return {
type: 'menu',
- store: parentContext.store,
+ store: parentMenuRootContext.store,
};
}
@@ -89,7 +89,7 @@ export const MenuRoot: React.FC = function MenuRoot(props) {
// Ensure this is not a Menu nested inside ContextMenu.Trigger.
// ContextMenu parentContext is always undefined as ContextMenu.Root is instantiated with
//
- if (contextMenuContext && !parentContext) {
+ if (contextMenuContext && !parentMenuRootContext) {
return {
type: 'context-menu',
context: contextMenuContext,
@@ -99,32 +99,65 @@ export const MenuRoot: React.FC = function MenuRoot(props) {
return {
type: undefined,
};
- }, [contextMenuContext, parentContext, menubarContext, isSubmenu]);
+ }, [contextMenuContext, parentMenuRootContext, menubarContext, isSubmenu]);
- const store = useRefWithInit(() => new MenuStore({ parent })).current;
- store.useControlledProp('open', openProp, defaultOpen);
- store.useSyncedValues({
- disabled: disabledProp,
- modal: modalProp,
- rootId: useId(),
- parent,
+ const store = MenuStore.useStore(handle?.store, {
+ parent: parentFromContext,
});
+
+ const floatingTreeRoot = store.useState('floatingTreeRoot');
+ const floatingNodeIdFromContext = useFloatingNodeId(floatingTreeRoot);
+ const floatingParentNodeIdFromContext = useFloatingParentNodeId();
+
+ useIsoLayoutEffect(() => {
+ if (contextMenuContext && !parentMenuRootContext) {
+ // This is a context menu root.
+ // It doesn't support detached triggers yet, so we have to sync the parent context manually.
+ store.update({
+ parent: {
+ type: 'context-menu',
+ context: contextMenuContext,
+ },
+ floatingNodeId: floatingNodeIdFromContext,
+ floatingParentNodeId: floatingParentNodeIdFromContext,
+ });
+ } else if (parentMenuRootContext) {
+ store.update({
+ floatingNodeId: floatingNodeIdFromContext,
+ floatingParentNodeId: floatingParentNodeIdFromContext,
+ });
+ }
+ }, [
+ contextMenuContext,
+ parentMenuRootContext,
+ floatingNodeIdFromContext,
+ floatingParentNodeIdFromContext,
+ store,
+ ]);
+
+ store.useControlledProp('open', openProp, defaultOpen);
+ store.useControlledProp('activeTriggerId', triggerIdProp, defaultTriggerIdProp);
+
store.useContextCallback('onOpenChangeComplete', onOpenChangeComplete);
const open = store.useState('open');
- const triggerElement = store.useState('triggerElement');
+ const activeTriggerId = store.useState('activeTriggerId');
+ const activeTriggerElement = store.useState('activeTriggerElement');
const positionerElement = store.useState('positionerElement');
const hoverEnabled = store.useState('hoverEnabled');
const modal = store.useState('modal');
const disabled = store.useState('disabled');
const lastOpenChangeReason = store.useState('lastOpenChangeReason');
- const allowMouseEnter = store.useState('allowMouseEnter');
+ const parent = store.useState('parent');
+
const activeIndex = store.useState('activeIndex');
+ const payload = store.useState('payload') as Payload | undefined;
+ const triggers = store.useState('triggers');
+ const floatingParentNodeId = store.useState('floatingParentNodeId');
- const [stickIfOpen, setStickIfOpen] = React.useState(true);
const openEventRef = React.useRef(null);
- const stickIfOpenTimeout = useTimeout();
- const nested = useFloatingParentNodeId() != null;
+
+ const nested = floatingParentNodeId != null;
let floatingEvents: ReturnType['events'];
@@ -136,9 +169,30 @@ export const MenuRoot: React.FC = function MenuRoot(props) {
}
}
- const openOnHover =
- openOnHoverProp ??
- (parent.type === 'menu' || (parent.type === 'menubar' && parent.context.hasSubmenuOpen));
+ store.useSyncedValues({
+ disabled: disabledProp,
+ modal: parent.type === undefined ? modalProp : undefined,
+ rootId: useId(),
+ });
+
+ const { mounted, setMounted, transitionStatus } = useTransitionStatus(open);
+ store.useSyncedValues({ mounted, transitionStatus });
+
+ let resolvedTriggerId: string | null = null;
+ if (mounted === true && triggerIdProp === undefined && triggers.size === 1) {
+ resolvedTriggerId = triggers.keys().next().value || null;
+ } else {
+ resolvedTriggerId = activeTriggerId ?? null;
+ }
+
+ useIsoLayoutEffect(() => {
+ if (open) {
+ store.set('activeTriggerId', resolvedTriggerId);
+ if (resolvedTriggerId == null) {
+ store.set('payload', undefined);
+ }
+ }
+ }, [store, resolvedTriggerId, open]);
const allowOutsidePressDismissalRef = React.useRef(parent.type !== 'context-menu');
const allowOutsidePressDismissalTimeout = useTimeout();
@@ -166,9 +220,6 @@ export const MenuRoot: React.FC = function MenuRoot(props) {
});
}, [allowOutsidePressDismissalTimeout, open, parent.type]);
- const { mounted, setMounted, transitionStatus } = useTransitionStatus(open);
- store.useSyncedValues({ mounted, transitionStatus });
-
const {
openMethod,
triggerProps: interactionTypeProps,
@@ -188,11 +239,11 @@ export const MenuRoot: React.FC = function MenuRoot(props) {
const handleUnmount = useStableCallback(() => {
setMounted(false);
- setStickIfOpen(true);
store.update({
mounted: false,
allowMouseEnter: false,
+ stickIfOpen: true,
});
store.context.onOpenChangeComplete?.(false);
@@ -214,14 +265,27 @@ export const MenuRoot: React.FC = function MenuRoot(props) {
const allowTouchToCloseTimeout = useTimeout();
const setOpen = useStableCallback(
- (nextOpen: boolean, eventDetails: MenuRoot.ChangeEventDetails) => {
+ (
+ nextOpen: boolean,
+ eventDetails: Omit,
+ ) => {
const reason = eventDetails.reason;
- if (open === nextOpen) {
+ if (open === nextOpen && eventDetails.trigger === activeTriggerElement) {
return;
}
- onOpenChange?.(nextOpen, eventDetails);
+ (eventDetails as MenuRoot.ChangeEventDetails).preventUnmountOnClose = () => {
+ store.context.preventUnmountingRef.current = true;
+ };
+
+ // Do not immediately reset the activeTriggerId to allow
+ // exit animations to play and focus to be returned correctly.
+ if (!nextOpen && eventDetails.trigger == null) {
+ eventDetails.trigger = activeTriggerElement ?? undefined;
+ }
+
+ onOpenChange?.(nextOpen, eventDetails as MenuRoot.ChangeEventDetails);
if (eventDetails.isCanceled) {
return;
@@ -279,19 +343,18 @@ export const MenuRoot: React.FC = function MenuRoot(props) {
const isDismissClose = !nextOpen && (reason === REASONS.escapeKey || reason == null);
function changeState() {
- store.set('open', nextOpen);
- store.set('lastOpenChangeReason', reason ?? null);
+ store.update({ open: nextOpen, lastOpenChangeReason: reason ?? null });
openEventRef.current = eventDetails.event ?? null;
+
+ // If a popup is closing, the `trigger` may be null.
+ // We want to keep the previous value so that exit animations are played and focus is returned correctly.
+ const newTriggerId = eventDetails.trigger?.id ?? null;
+ if (newTriggerId || nextOpen) {
+ store.set('activeTriggerId', newTriggerId);
+ }
}
if (reason === REASONS.triggerHover) {
- // Only allow "patient" clicks to close the menu if it's open.
- // If they clicked within 500ms of the menu opening, keep it open.
- setStickIfOpen(true);
- stickIfOpenTimeout.start(PATIENT_CLICK_THRESHOLD, () => {
- setStickIfOpen(false);
- });
-
ReactDOM.flushSync(changeState);
} else {
changeState();
@@ -314,7 +377,28 @@ export const MenuRoot: React.FC = function MenuRoot(props) {
},
);
- React.useImperativeHandle(actionsRef, () => ({ unmount: handleUnmount }), [handleUnmount]);
+ const createMenuEventDetails = React.useCallback(
+ (reason: MenuRoot.ChangeEventReason) => {
+ const details: MenuRoot.ChangeEventDetails =
+ createChangeEventDetails(reason) as MenuRoot.ChangeEventDetails;
+ details.preventUnmountOnClose = () => {
+ store.context.preventUnmountingRef.current = true;
+ };
+
+ return details;
+ },
+ [store],
+ );
+
+ const handleImperativeClose = React.useCallback(() => {
+ store.setOpen(false, createMenuEventDetails(REASONS.imperativeAction));
+ }, [store, createMenuEventDetails]);
+
+ React.useImperativeHandle(
+ props.actionsRef,
+ () => ({ unmount: handleUnmount, close: handleImperativeClose }),
+ [handleUnmount, handleImperativeClose],
+ );
let ctx: ContextMenuRootContext | undefined;
if (parent.type === 'context-menu') {
@@ -329,16 +413,11 @@ export const MenuRoot: React.FC = function MenuRoot(props) {
React.useImperativeHandle(ctx?.actionsRef, () => ({ setOpen }), [setOpen]);
- React.useEffect(() => {
- if (!open) {
- stickIfOpenTimeout.clear();
- }
- }, [stickIfOpenTimeout, open]);
-
const floatingRootContext = useFloatingRootContext({
elements: {
- reference: triggerElement,
+ reference: activeTriggerElement,
floating: positionerElement,
+ triggers: Array.from(triggers.values()),
},
open,
onOpenChange: setOpen,
@@ -364,41 +443,6 @@ export const MenuRoot: React.FC = function MenuRoot(props) {
};
}, [floatingEvents, setOpen]);
- const hover = useHover(floatingRootContext, {
- enabled:
- hoverEnabled &&
- openOnHover &&
- !disabled &&
- parent.type !== 'context-menu' &&
- (parent.type !== 'menubar' || (parent.context.hasSubmenuOpen && !open)),
- handleClose: safePolygon({ blockPointerEvents: true }),
- mouseOnly: true,
- move: parent.type === 'menu',
- restMs:
- parent.type === undefined || (parent.type === 'menu' && allowMouseEnter) ? delay : undefined,
- delay:
- parent.type === 'menu'
- ? { open: allowMouseEnter ? delay : 10 ** 10, close: closeDelay }
- : { close: closeDelay },
- });
-
- const focus = useFocus(floatingRootContext, {
- enabled:
- !disabled &&
- !open &&
- parent.type === 'menubar' &&
- parent.context.hasSubmenuOpen &&
- !contextMenuContext,
- });
-
- const click = useClick(floatingRootContext, {
- enabled: !disabled && parent.type !== 'context-menu',
- event: open && parent.type === 'menubar' ? 'click' : 'mousedown',
- toggle: !openOnHover || parent.type !== 'menu',
- ignoreMouse: openOnHover && parent.type === 'menu',
- stickIfOpen: parent.type === undefined ? stickIfOpen : false,
- });
-
const dismiss = useDismiss(floatingRootContext, {
enabled: !disabled,
bubbles: closeParentOnEsc && parent.type === 'menu',
@@ -409,6 +453,7 @@ export const MenuRoot: React.FC = function MenuRoot(props) {
return allowOutsidePressDismissalRef.current;
},
+ externalTree: nested ? floatingTreeRoot : undefined,
});
const role = useRole(floatingRootContext, {
@@ -419,6 +464,9 @@ export const MenuRoot: React.FC = function MenuRoot(props) {
const setActiveIndex = React.useCallback(
(index: number | null) => {
+ if (store.select('activeIndex') === index) {
+ return;
+ }
store.set('activeIndex', index);
},
[store],
@@ -436,6 +484,7 @@ export const MenuRoot: React.FC = function MenuRoot(props) {
disabledIndices: EMPTY_ARRAY,
onNavigate: setActiveIndex,
openOnArrowKeyDown: parent.type !== 'context-menu',
+ externalTree: nested ? floatingTreeRoot : undefined,
});
const onTypingChange = React.useCallback(
@@ -457,23 +506,14 @@ export const MenuRoot: React.FC = function MenuRoot(props) {
onTypingChange,
});
- const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([
- hover,
- click,
+ const { getReferenceProps, getFloatingProps, getItemProps, getTriggerProps } = useInteractions([
dismiss,
- focus,
role,
listNavigation,
typeahead,
]);
- const mixedToggleHandlers = useMixedToggleClickHandler({
- open,
- enabled: parent.type === 'menubar',
- mouseDownAction: 'open',
- });
-
- const triggerProps = React.useMemo(() => {
+ const activeTriggerProps = React.useMemo(() => {
const referenceProps = mergeProps(
getReferenceProps(),
{
@@ -485,19 +525,28 @@ export const MenuRoot: React.FC = function MenuRoot(props) {
},
},
interactionTypeProps,
- mixedToggleHandlers,
);
delete referenceProps.role;
return referenceProps;
- }, [getReferenceProps, mixedToggleHandlers, store, interactionTypeProps]);
+ }, [getReferenceProps, store, interactionTypeProps]);
const disableHoverTimeout = useAnimationFrame();
+ const inactiveTriggerProps = React.useMemo(() => {
+ const triggerProps = getTriggerProps();
+ if (!triggerProps) {
+ return triggerProps;
+ }
+
+ const { role: roleDiscarded, ['aria-controls']: ariaControlsDiscarded, ...rest } = triggerProps;
+ return rest;
+ }, [getTriggerProps]);
+
const popupProps = React.useMemo(
() =>
getFloatingProps({
onMouseEnter() {
- if (!openOnHover || parent.type === 'menu') {
+ if (parent.type === 'menu') {
disableHoverTimeout.request(() => store.set('hoverEnabled', false));
}
},
@@ -505,41 +554,55 @@ export const MenuRoot: React.FC = function MenuRoot(props) {
store.set('allowMouseEnter', true);
},
onClick() {
- if (openOnHover) {
+ if (store.select('hoverEnabled')) {
store.set('hoverEnabled', false);
}
},
+ onKeyDown(event) {
+ // The Menubar's CompositeRoot captures keyboard events via
+ // event delegation. This works well when Menu.Root is nested inside Menubar,
+ // but with detached triggers we need to manually forward the event to the CompositeRoot.
+ const relay = store.select('keyboardEventRelay');
+ if (relay && !event.isPropagationStopped()) {
+ relay(event);
+ }
+ },
}),
- [getFloatingProps, openOnHover, parent.type, disableHoverTimeout, store],
+ [getFloatingProps, parent.type, disableHoverTimeout, store],
);
const itemProps = React.useMemo(() => getItemProps(), [getItemProps]);
store.useSyncedValues({
- triggerProps,
+ activeTriggerProps,
+ inactiveTriggerProps,
popupProps,
itemProps,
});
- const context = React.useMemo(
+ const context: MenuRootContext = React.useMemo(
() => ({
store,
+ parent: parentFromContext,
}),
- [store],
+ [store, parentFromContext],
);
- const content = {children} ;
+ const content = (
+
+ {typeof children === 'function' ? children({ payload }) : children}
+
+ );
if (parent.type === undefined || parent.type === 'context-menu') {
// set up a FloatingTree to provide the context to nested menus
- return {content} ;
+ return {content} ;
}
return content;
-};
+}
-export interface MenuRootProps {
- children: React.ReactNode;
+export interface MenuRootProps {
/**
* Whether the menu is initially open.
*
@@ -590,31 +653,34 @@ export interface MenuRootProps {
*/
closeParentOnEsc?: boolean;
/**
- * How long to wait before the menu may be opened on hover. Specified in milliseconds.
- *
- * Requires the `openOnHover` prop.
- * @default 100
+ * 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.
*/
- delay?: number;
+ actionsRef?: React.RefObject;
/**
- * How long to wait before closing the menu that was opened on hover.
- * Specified in milliseconds.
- *
- * Requires the `openOnHover` prop.
- * @default 0
+ * ID of the trigger that the popover is associated with.
+ * This is useful in conjuntion with the `open` prop to create a controlled popover.
+ * There's no need to specify this prop when the popover is uncontrolled (i.e. when the `open` prop is not set).
*/
- closeDelay?: number;
+ triggerId?: string | null;
/**
- * Whether the menu should also open when the trigger is hovered.
+ * ID of the trigger that the popover is associated with.
+ * This is useful in conjuntion with the `defaultOpen` prop to create an initially open popover.
*/
- openOnHover?: boolean;
+ defaultTriggerId?: string | 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.
+ * A handle to associate the popover with a trigger.
+ * If specified, allows external triggers to control the popover's open state.
*/
- actionsRef?: React.RefObject;
+ handle?: MenuHandle;
+ /**
+ * The content of the popover.
+ * This can be a regular React node or a render function that receives the `payload` of the active trigger.
+ */
+ children?: React.ReactNode | PayloadChildRenderFunction;
}
export interface MenuRootActions {
@@ -633,16 +699,19 @@ export type MenuRootChangeEventReason =
| typeof REASONS.closePress
| typeof REASONS.siblingOpen
| typeof REASONS.cancelOpen
+ | typeof REASONS.imperativeAction
| typeof REASONS.none;
-export type MenuRootChangeEventDetails = BaseUIChangeEventDetails;
+export type MenuRootChangeEventDetails = BaseUIChangeEventDetails & {
+ preventUnmountOnClose(): void;
+};
export type MenuRootOrientation = 'horizontal' | 'vertical';
export type MenuParent =
| {
type: 'menu';
- store: MenuStore;
+ store: MenuStore;
}
| {
type: 'menubar';
@@ -662,7 +731,7 @@ export type MenuParent =
};
export namespace MenuRoot {
- export type Props = MenuRootProps;
+ export type Props = MenuRootProps;
export type Actions = MenuRootActions;
export type ChangeEventReason = MenuRootChangeEventReason;
export type ChangeEventDetails = MenuRootChangeEventDetails;
diff --git a/packages/react/src/menu/root/MenuRootContext.ts b/packages/react/src/menu/root/MenuRootContext.ts
index 0ea85f6ef51..e3430e78cbe 100644
--- a/packages/react/src/menu/root/MenuRootContext.ts
+++ b/packages/react/src/menu/root/MenuRootContext.ts
@@ -1,9 +1,11 @@
'use client';
import * as React from 'react';
import { type MenuStore } from '../store/MenuStore';
+import { MenuParent } from './MenuRoot';
-export interface MenuRootContext {
- store: MenuStore;
+export interface MenuRootContext {
+ store: MenuStore;
+ parent: MenuParent;
}
export const MenuRootContext = React.createContext(undefined);
diff --git a/packages/react/src/menu/store/MenuHandle.ts b/packages/react/src/menu/store/MenuHandle.ts
new file mode 100644
index 00000000000..595ffc294f2
--- /dev/null
+++ b/packages/react/src/menu/store/MenuHandle.ts
@@ -0,0 +1,56 @@
+import { createChangeEventDetails } from '../../utils/createBaseUIEventDetails';
+import { MenuStore } from './MenuStore';
+
+export class MenuHandle {
+ /**
+ * Internal store holding the menu's state.
+ * @internal
+ */
+ public readonly store: MenuStore;
+
+ constructor() {
+ this.store = new MenuStore();
+ }
+
+ /**
+ * Opens the menu and associates it with the trigger with the given id.
+ * 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.
+ */
+ open(triggerId: string) {
+ const triggerElement = triggerId
+ ? (this.store.state.triggers.get(triggerId) ?? undefined)
+ : undefined;
+
+ if (triggerId && !triggerElement) {
+ throw new Error(`Base UI: MenuHandle.open: No trigger found with id "${triggerId}".`);
+ }
+
+ this.store.setOpen(
+ true,
+ createChangeEventDetails('imperative-action', undefined, triggerElement),
+ );
+ }
+
+ /**
+ * Closes the menu.
+ */
+ close() {
+ this.store.setOpen(false, createChangeEventDetails('imperative-action', undefined, undefined));
+ }
+
+ /**
+ * Indicates whether the menu is currently open.
+ */
+ get isOpen() {
+ return this.store.state.open;
+ }
+}
+
+/**
+ * Creates a new handle to connect a Menu.Root with detached Menu.Trigger components.
+ */
+export function createMenuHandle(): MenuHandle {
+ return new MenuHandle();
+}
diff --git a/packages/react/src/menu/store/MenuStore.ts b/packages/react/src/menu/store/MenuStore.ts
index 32f6f1771b7..f271f0184d5 100644
--- a/packages/react/src/menu/store/MenuStore.ts
+++ b/packages/react/src/menu/store/MenuStore.ts
@@ -1,13 +1,16 @@
import * as React from 'react';
import { createSelector, ReactStore } from '@base-ui-components/utils/store';
import { EMPTY_OBJECT } from '@base-ui-components/utils/empty';
+import { useRefWithInit } from '@base-ui-components/utils/useRefWithInit';
import { MenuParent, MenuRoot } from '../root/MenuRoot';
import { FloatingRootContext } from '../../floating-ui-react';
import { getEmptyContext } from '../../floating-ui-react/hooks/useFloatingRootContext';
+import { FloatingTreeStore } from '../../floating-ui-react/components/FloatingTree';
import { TransitionStatus } from '../../utils/useTransitionStatus';
import { HTMLProps } from '../../utils/types';
+import { PopupTriggerMap } from '../../utils/popupStoreUtils';
-export type State = {
+export type State = {
open: boolean;
disabled: boolean;
modal: boolean;
@@ -17,15 +20,24 @@ export type State = {
rootId: string | undefined;
activeIndex: number | null;
hoverEnabled: boolean;
- triggerElement: HTMLElement | null;
+ stickIfOpen: boolean;
positionerElement: HTMLElement | null;
transitionStatus: TransitionStatus;
instantType: 'dismiss' | 'click' | 'group' | undefined;
lastOpenChangeReason: MenuRoot.ChangeEventReason | null;
floatingRootContext: FloatingRootContext;
+ floatingTreeRoot: FloatingTreeStore;
+ floatingNodeId: string | undefined;
+ floatingParentNodeId: string | null;
itemProps: HTMLProps;
popupProps: HTMLProps;
- triggerProps: HTMLProps;
+ payload: Payload | undefined;
+ triggers: PopupTriggerMap;
+ activeTriggerProps: HTMLProps;
+ inactiveTriggerProps: HTMLProps;
+ activeTriggerId: string | null;
+ closeDelay: number;
+ keyboardEventRelay: ((event: React.KeyboardEvent) => void) | undefined;
};
type Context = {
@@ -35,54 +47,99 @@ type Context = {
itemDomElements: React.RefObject<(HTMLElement | null)[]>;
itemLabels: React.RefObject<(string | null)[]>;
allowMouseUpTriggerRef: React.RefObject;
+ preventUnmountingRef: React.RefObject;
+ triggerFocusTargetRef: React.RefObject;
+ beforeContentFocusGuardRef: React.RefObject;
onOpenChangeComplete: ((open: boolean) => void) | undefined;
};
const selectors = {
- open: createSelector((state: State) => state.open),
- disabled: createSelector((state: State) =>
+ open: createSelector((state: State) => state.open),
+ disabled: createSelector((state: State) =>
state.parent.type === 'menubar'
? state.parent.context.disabled || state.disabled
: state.disabled,
),
modal: createSelector(
- (state: State) =>
+ (state: State) =>
(state.parent.type === undefined || state.parent.type === 'context-menu') &&
(state.modal ?? true),
),
- mounted: createSelector((state: State) => state.mounted),
- allowMouseEnter: createSelector((state: State): boolean =>
+ mounted: createSelector((state: State) => state.mounted),
+ activeTriggerId: createSelector((state: State) => state.activeTriggerId),
+ activeTriggerElement: createSelector((state: State) =>
+ state.mounted && state.activeTriggerId != null
+ ? (state.triggers.get(state.activeTriggerId) ?? null)
+ : null,
+ ),
+ isTriggerActive: createSelector(
+ (state: State, triggerId: string | undefined) =>
+ triggerId !== undefined && state.activeTriggerId === triggerId,
+ ),
+ isOpenedByTrigger: createSelector(
+ (state: State, triggerId: string | undefined) =>
+ triggerId !== undefined && state.activeTriggerId === triggerId && state.open,
+ ),
+ allowMouseEnter: createSelector((state: State): boolean =>
state.parent.type === 'menu'
? state.parent.store.select('allowMouseEnter')
: state.allowMouseEnter,
),
- parent: createSelector((state: State) => state.parent),
- rootId: createSelector((state: State): string | undefined => {
+ stickIfOpen: createSelector((state: State) => state.stickIfOpen),
+ parent: createSelector((state: State) => state.parent),
+ rootId: createSelector((state: State): string | undefined => {
if (state.parent.type === 'menu') {
return state.parent.store.select('rootId');
}
return state.parent.type !== undefined ? state.parent.context.rootId : state.rootId;
}),
- activeIndex: createSelector((state: State) => state.activeIndex),
- isActive: createSelector((state: State, itemIndex: number) => state.activeIndex === itemIndex),
- hoverEnabled: createSelector((state: State) => state.hoverEnabled),
- triggerElement: createSelector((state: State) => state.triggerElement),
- positionerElement: createSelector((state: State) => state.positionerElement),
- transitionStatus: createSelector((state: State) => state.transitionStatus),
- instantType: createSelector((state: State) => state.instantType),
- lastOpenChangeReason: createSelector((state: State) => state.lastOpenChangeReason),
- floatingRootContext: createSelector((state: State) => state.floatingRootContext),
- itemProps: createSelector((state: State) => state.itemProps),
- popupProps: createSelector((state: State) => state.popupProps),
- triggerProps: createSelector((state: State) => state.triggerProps),
+ activeIndex: createSelector((state: State) => state.activeIndex),
+ isActive: createSelector(
+ (state: State, itemIndex: number) => state.activeIndex === itemIndex,
+ ),
+ hoverEnabled: createSelector((state: State) => state.hoverEnabled),
+ positionerElement: createSelector((state: State) => state.positionerElement),
+ transitionStatus: createSelector((state: State) => state.transitionStatus),
+ instantType: createSelector((state: State) => state.instantType),
+ lastOpenChangeReason: createSelector((state: State) => state.lastOpenChangeReason),
+ floatingRootContext: createSelector((state: State) => state.floatingRootContext),
+ floatingTreeRoot: createSelector((state: State): FloatingTreeStore => {
+ if (state.parent.type === 'menu') {
+ return state.parent.store.select('floatingTreeRoot');
+ }
+
+ return state.floatingTreeRoot;
+ }),
+ floatingNodeId: createSelector((state: State) => state.floatingNodeId),
+ floatingParentNodeId: createSelector((state: State) => state.floatingParentNodeId),
+ itemProps: createSelector((state: State) => state.itemProps),
+ popupProps: createSelector((state: State) => state.popupProps),
+ activeTriggerProps: createSelector((state: State) => state.activeTriggerProps),
+ inactiveTriggerProps: createSelector((state: State) => state.inactiveTriggerProps),
+ payload: createSelector((state: State) => state.payload),
+ triggers: createSelector((state: State) => state.triggers),
+ closeDelay: createSelector((state: State) => state.closeDelay),
+ keyboardEventRelay: createSelector(
+ (state: State): React.KeyboardEventHandler | undefined => {
+ if (state.keyboardEventRelay) {
+ return state.keyboardEventRelay;
+ }
+
+ if (state.parent.type === 'menu') {
+ return state.parent.store.select('keyboardEventRelay');
+ }
+
+ return undefined;
+ },
+ ),
};
-export class MenuStore extends ReactStore {
- constructor(initialState?: Partial) {
+export class MenuStore extends ReactStore, Context, typeof selectors> {
+ constructor(initialState?: Partial>) {
super(
{ ...createInitialState(), ...initialState },
{
@@ -92,6 +149,9 @@ export class MenuStore extends ReactStore {
itemDomElements: { current: [] },
itemLabels: { current: [] },
allowMouseUpTriggerRef: { current: false },
+ preventUnmountingRef: { current: false },
+ triggerFocusTargetRef: React.createRef(),
+ beforeContentFocusGuardRef: React.createRef(),
onOpenChangeComplete: undefined,
},
selectors,
@@ -100,8 +160,10 @@ export class MenuStore extends ReactStore {
// Sync `allowMouseEnter` with parent menu if applicable.
this.observe(
createSelector((state) => state.allowMouseEnter),
- (allowMouseEnter) => {
- if (this.state.parent.type === 'menu') {
+ (allowMouseEnter, oldValue) => {
+ // The allowMouseEnter !== oldValue check prevent calling parent store's set
+ // on intialization. Without it, React might complain about updating one component during rendering another.
+ if (this.state.parent.type === 'menu' && allowMouseEnter !== oldValue) {
this.state.parent.store.set('allowMouseEnter', allowMouseEnter);
}
},
@@ -132,30 +194,51 @@ export class MenuStore extends ReactStore {
this.state.floatingRootContext.events.emit('setOpen', { open, eventDetails });
}
+ public static useStore(
+ externalStore: MenuStore | undefined,
+ initialState: Partial>,
+ ) {
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ const store = useRefWithInit(() => {
+ return externalStore ?? new MenuStore(initialState);
+ }).current;
+
+ return store;
+ }
+
private unsubscribeParentListener: (() => void) | null = null;
}
-function createInitialState(): State {
+function createInitialState