diff --git a/packages/react-components/react-menu/library/src/components/Menu/useMenu.test.tsx b/packages/react-components/react-menu/library/src/components/Menu/useMenu.test.tsx
new file mode 100644
index 0000000000000..525ad438c8155
--- /dev/null
+++ b/packages/react-components/react-menu/library/src/components/Menu/useMenu.test.tsx
@@ -0,0 +1,227 @@
+import * as React from 'react';
+import { act, renderHook } from '@testing-library/react-hooks';
+import { useMenu_unstable } from './useMenu';
+import { MenuListProvider } from '../../contexts/menuListContext';
+import type { MenuListContextValue } from '../../contexts/menuListContext';
+
+const defaultMenuListContextValue: MenuListContextValue = {
+ checkedValues: {},
+ setFocusByFirstCharacter: () => null,
+ toggleCheckbox: () => null,
+ selectRadio: () => null,
+ hasIcons: false,
+ hasCheckmarks: false,
+};
+
+const submenuWrapper: React.FC<{ children?: React.ReactNode }> = ({ children }) => (
+ {children}
+);
+
+const trigger = ;
+const popover =
popover
;
+
+describe('useMenu_unstable', () => {
+ describe('components and slots', () => {
+ it('returns a surfaceMotion component', () => {
+ const { result } = renderHook(() => useMenu_unstable({ children: [trigger, popover] }));
+
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ expect(result.current.components.surfaceMotion).toBeDefined();
+ });
+
+ it('configures the surfaceMotion slot with motion defaults derived from open', () => {
+ const { result } = renderHook(() => useMenu_unstable({ children: [trigger, popover], defaultOpen: true }));
+
+ expect(result.current.surfaceMotion).toBeDefined();
+ // visible/appear/unmountOnExit are runtime defaults injected via presenceMotionSlot
+ // and aren't declared on the public Slot type
+ const motionSlot = result.current.surfaceMotion as unknown as Record;
+ expect(motionSlot.visible).toBe(true);
+ expect(motionSlot.appear).toBe(true);
+ expect(motionSlot.unmountOnExit).toBe(true);
+ });
+ });
+
+ describe('default prop values', () => {
+ it('applies documented default values when no props are provided', () => {
+ const { result } = renderHook(() => useMenu_unstable({ children: [trigger, popover] }));
+
+ expect(result.current.hoverDelay).toBe(500);
+ expect(result.current.inline).toBe(false);
+ expect(result.current.hasCheckmarks).toBe(false);
+ expect(result.current.hasIcons).toBe(false);
+ expect(result.current.closeOnScroll).toBe(false);
+ expect(result.current.openOnContext).toBe(false);
+ expect(result.current.persistOnItemClick).toBe(false);
+ expect(result.current.mountNode).toBeNull();
+ expect(result.current.openOnHover).toBe(false);
+ });
+
+ it('openOnHover defaults to true when rendered as a submenu', () => {
+ const { result } = renderHook(() => useMenu_unstable({ children: [trigger, popover] }), {
+ wrapper: submenuWrapper,
+ });
+
+ expect(result.current.isSubmenu).toBe(true);
+ expect(result.current.openOnHover).toBe(true);
+ });
+
+ it('respects an explicit openOnHover prop over the submenu default', () => {
+ const { result } = renderHook(() => useMenu_unstable({ children: [trigger, popover], openOnHover: false }), {
+ wrapper: submenuWrapper,
+ });
+
+ expect(result.current.openOnHover).toBe(false);
+ });
+
+ it('forwards explicit prop values into state', () => {
+ const mountNode = document.createElement('div');
+ const { result } = renderHook(() =>
+ useMenu_unstable({
+ children: [trigger, popover],
+ hoverDelay: 100,
+ inline: true,
+ hasCheckmarks: true,
+ hasIcons: true,
+ closeOnScroll: true,
+ openOnContext: true,
+ persistOnItemClick: true,
+ mountNode,
+ }),
+ );
+
+ expect(result.current.hoverDelay).toBe(100);
+ expect(result.current.inline).toBe(true);
+ expect(result.current.hasCheckmarks).toBe(true);
+ expect(result.current.hasIcons).toBe(true);
+ expect(result.current.closeOnScroll).toBe(true);
+ expect(result.current.openOnContext).toBe(true);
+ expect(result.current.persistOnItemClick).toBe(true);
+ expect(result.current.mountNode).toBe(mountNode);
+ });
+ });
+
+ describe('children routing', () => {
+ it('routes the first of two children to menuTrigger and the second to menuPopover', () => {
+ const { result } = renderHook(() => useMenu_unstable({ children: [trigger, popover] }));
+
+ // React.Children.toArray clones elements with normalized keys, so we compare by
+ // type + key rather than reference equality.
+ expect((result.current.menuTrigger as React.ReactElement).type).toBe('button');
+ expect((result.current.menuTrigger as React.ReactElement).key).toBe('.$trigger');
+ expect((result.current.menuPopover as React.ReactElement).type).toBe('div');
+ expect((result.current.menuPopover as React.ReactElement).key).toBe('.$popover');
+ });
+
+ it('routes a single child to menuPopover and leaves menuTrigger undefined', () => {
+ const { result } = renderHook(() => useMenu_unstable({ children: popover }));
+
+ expect(result.current.menuTrigger).toBeUndefined();
+ expect((result.current.menuPopover as React.ReactElement).type).toBe('div');
+ });
+ });
+
+ describe('open state', () => {
+ it('open defaults to false when neither open nor defaultOpen is set', () => {
+ const { result } = renderHook(() => useMenu_unstable({ children: [trigger, popover] }));
+
+ expect(result.current.open).toBe(false);
+ });
+
+ it('respects defaultOpen on initial render (uncontrolled)', () => {
+ const { result } = renderHook(() => useMenu_unstable({ children: [trigger, popover], defaultOpen: true }));
+
+ expect(result.current.open).toBe(true);
+ });
+
+ it('respects controlled open prop', () => {
+ const { result } = renderHook(() => useMenu_unstable({ children: [trigger, popover], open: true }));
+
+ expect(result.current.open).toBe(true);
+ });
+
+ it('exposes a setOpen function on state', () => {
+ const { result } = renderHook(() => useMenu_unstable({ children: [trigger, popover] }));
+
+ expect(typeof result.current.setOpen).toBe('function');
+ });
+ });
+
+ describe('triggerId', () => {
+ it('generates a non-empty triggerId', () => {
+ const { result } = renderHook(() => useMenu_unstable({ children: [trigger, popover] }));
+
+ expect(typeof result.current.triggerId).toBe('string');
+ expect(result.current.triggerId.length).toBeGreaterThan(0);
+ });
+
+ it('keeps the same triggerId across re-renders', () => {
+ const { result, rerender } = renderHook(() => useMenu_unstable({ children: [trigger, popover] }));
+ const initial = result.current.triggerId;
+ rerender();
+
+ expect(result.current.triggerId).toBe(initial);
+ });
+ });
+
+ describe('selectable state', () => {
+ it('respects defaultCheckedValues on initial render', () => {
+ const defaultCheckedValues = { foo: ['1'] };
+
+ const { result } = renderHook(() => useMenu_unstable({ children: [trigger, popover], defaultCheckedValues }));
+
+ expect(result.current.checkedValues).toEqual(defaultCheckedValues);
+ });
+
+ it('uses controlled checkedValues over defaultCheckedValues', () => {
+ const defaultCheckedValues = { foo: ['1'] };
+ const checkedValues = { bar: ['2'] };
+
+ // Passing both is an anti-pattern that useControllableState warns about; we
+ // silence the expected console.error so the test output stays clean.
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
+ try {
+ const { result } = renderHook(() =>
+ useMenu_unstable({ children: [trigger, popover], defaultCheckedValues, checkedValues }),
+ );
+
+ expect(result.current.checkedValues).toEqual(checkedValues);
+ } finally {
+ consoleErrorSpy.mockRestore();
+ }
+ });
+
+ it('forwards the change to onCheckedValueChange when items toggle', () => {
+ const onCheckedValueChange = jest.fn();
+
+ const { result } = renderHook(() => useMenu_unstable({ children: [trigger, popover], onCheckedValueChange }));
+
+ act(() => {
+ result.current.onCheckedValueChange({} as React.MouseEvent, { name: 'foo', checkedItems: ['1'] });
+ });
+
+ expect(onCheckedValueChange).toHaveBeenCalledTimes(1);
+ expect(onCheckedValueChange).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ name: 'foo', checkedItems: ['1'] }),
+ );
+ expect(result.current.checkedValues).toEqual({ foo: ['1'] });
+ });
+ });
+
+ describe('isSubmenu detection', () => {
+ it('isSubmenu is false at the root menu level', () => {
+ const { result } = renderHook(() => useMenu_unstable({ children: [trigger, popover] }));
+
+ expect(result.current.isSubmenu).toBe(false);
+ });
+
+ it('isSubmenu is true when rendered inside a MenuList parent context', () => {
+ const { result } = renderHook(() => useMenu_unstable({ children: [trigger, popover] }), {
+ wrapper: submenuWrapper,
+ });
+
+ expect(result.current.isSubmenu).toBe(true);
+ });
+ });
+});
diff --git a/packages/react-components/react-menu/library/src/components/MenuItem/useMenuItem.test.tsx b/packages/react-components/react-menu/library/src/components/MenuItem/useMenuItem.test.tsx
new file mode 100644
index 0000000000000..8503963a2976c
--- /dev/null
+++ b/packages/react-components/react-menu/library/src/components/MenuItem/useMenuItem.test.tsx
@@ -0,0 +1,277 @@
+import * as React from 'react';
+import { renderHook } from '@testing-library/react-hooks';
+import * as ReactSharedContexts from '@fluentui/react-shared-contexts';
+import { useMenuItem_unstable } from './useMenuItem';
+import { MenuListProvider } from '../../contexts/menuListContext';
+import { MenuProvider } from '../../contexts/menuContext';
+import { MenuTriggerContextProvider } from '../../contexts/menuTriggerContext';
+import type { MenuListContextValue } from '../../contexts/menuListContext';
+import type { MenuContextValue } from '../../contexts/menuContext';
+import type { ARIAButtonElement } from '@fluentui/react-aria';
+
+jest.mock('@fluentui/react-shared-contexts', () => ({
+ ...jest.requireActual('@fluentui/react-shared-contexts'),
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ useFluent_unstable: jest.fn(() => ({ dir: 'ltr', targetDocument: document })),
+}));
+
+const mockedUseFluent = ReactSharedContexts.useFluent_unstable as jest.Mock;
+
+const defaultMenuListContextValue: MenuListContextValue = {
+ checkedValues: {},
+ setFocusByFirstCharacter: () => null,
+ toggleCheckbox: () => null,
+ selectRadio: () => null,
+ hasIcons: false,
+ hasCheckmarks: false,
+};
+
+const defaultMenuContextValue: MenuContextValue = {
+ open: false,
+ setOpen: () => false,
+ checkedValues: {},
+ onCheckedValueChange: () => null,
+ isSubmenu: false,
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ triggerRef: { current: null } as unknown as React.MutableRefObject,
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ menuPopoverRef: { current: null } as unknown as React.MutableRefObject,
+ mountNode: null,
+ triggerId: '',
+ openOnContext: false,
+ openOnHover: false,
+ hasIcons: false,
+ hasCheckmarks: false,
+ inline: false,
+ persistOnItemClick: false,
+};
+
+function makeWrapper(
+ options: {
+ menuList?: Partial;
+ menu?: Partial;
+ isSubmenuTrigger?: boolean;
+ } = {},
+) {
+ const menuListValue: MenuListContextValue = { ...defaultMenuListContextValue, ...options.menuList };
+ const menuValue: MenuContextValue = { ...defaultMenuContextValue, ...options.menu };
+
+ return ({ children }: { children: React.ReactNode }) => (
+
+
+ {children}
+
+
+ );
+}
+
+describe('useMenuItem_unstable', () => {
+ beforeEach(() => {
+ mockedUseFluent.mockReturnValue({ dir: 'ltr', targetDocument: document });
+ });
+
+ describe('components and slots', () => {
+ it('returns components shape with all MenuItem slots', () => {
+ const ref = React.createRef>();
+
+ const { result } = renderHook(() => useMenuItem_unstable({}, ref), { wrapper: makeWrapper() });
+
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ expect(result.current.components).toEqual({
+ root: 'div',
+ icon: 'span',
+ checkmark: 'span',
+ submenuIndicator: 'span',
+ content: 'span',
+ secondaryContent: 'span',
+ subText: 'span',
+ });
+ });
+
+ it('always returns a root slot', () => {
+ const ref = React.createRef>();
+
+ const { result } = renderHook(() => useMenuItem_unstable({}, ref), { wrapper: makeWrapper() });
+
+ expect(result.current.root).toBeDefined();
+ });
+
+ it('returns disabled=false by default', () => {
+ const ref = React.createRef>();
+
+ const { result } = renderHook(() => useMenuItem_unstable({}, ref), { wrapper: makeWrapper() });
+
+ expect(result.current.disabled).toBe(false);
+ });
+
+ it('reads persistOnClick from MenuContext.persistOnItemClick when prop omitted', () => {
+ const ref = React.createRef>();
+
+ const { result } = renderHook(() => useMenuItem_unstable({}, ref), {
+ wrapper: makeWrapper({ menu: { persistOnItemClick: true } }),
+ });
+
+ expect(result.current.persistOnClick).toBe(true);
+ });
+ });
+
+ describe('root slot', () => {
+ it('sets root.role to "menuitem" by default', () => {
+ const ref = React.createRef>();
+
+ const { result } = renderHook(() => useMenuItem_unstable({}, ref), { wrapper: makeWrapper() });
+
+ expect(result.current.root.role).toBe('menuitem');
+ });
+
+ it('spreads className and aria-label onto root', () => {
+ const ref = React.createRef>();
+
+ const { result } = renderHook(
+ () => useMenuItem_unstable({ className: 'custom-class', 'aria-label': 'item' }, ref),
+ { wrapper: makeWrapper() },
+ );
+
+ expect(result.current.root.className).toBe('custom-class');
+ expect(result.current.root['aria-label']).toBe('item');
+ });
+ });
+
+ describe('hasSubmenu derivation', () => {
+ it('hasSubmenu=true when MenuTriggerContext is true', () => {
+ const ref = React.createRef>();
+
+ const { result } = renderHook(() => useMenuItem_unstable({}, ref), {
+ wrapper: makeWrapper({ isSubmenuTrigger: true }),
+ });
+
+ expect(result.current.hasSubmenu).toBe(true);
+ });
+
+ it('hasSubmenu=false when MenuTriggerContext is false (no override)', () => {
+ const ref = React.createRef>();
+
+ const { result } = renderHook(() => useMenuItem_unstable({}, ref), {
+ wrapper: makeWrapper({ isSubmenuTrigger: false }),
+ });
+
+ expect(result.current.hasSubmenu).toBe(false);
+ });
+
+ it('explicit hasSubmenu prop wins over MenuTriggerContext', () => {
+ const ref = React.createRef>();
+
+ const { result } = renderHook(() => useMenuItem_unstable({ hasSubmenu: true }, ref), {
+ wrapper: makeWrapper({ isSubmenuTrigger: false }),
+ });
+
+ expect(result.current.hasSubmenu).toBe(true);
+ });
+ });
+
+ describe('submenuIndicator slot — default chevron icon injection', () => {
+ it('submenuIndicator is undefined when hasSubmenu=false (renderByDefault: hasSubmenu)', () => {
+ const ref = React.createRef>();
+
+ const { result } = renderHook(() => useMenuItem_unstable({}, ref), {
+ wrapper: makeWrapper({ isSubmenuTrigger: false }),
+ });
+
+ expect(result.current.submenuIndicator).toBeUndefined();
+ });
+
+ it('submenuIndicator is defined when hasSubmenu=true', () => {
+ const ref = React.createRef>();
+
+ const { result } = renderHook(() => useMenuItem_unstable({ hasSubmenu: true }, ref), {
+ wrapper: makeWrapper(),
+ });
+
+ expect(result.current.submenuIndicator).toBeDefined();
+ });
+
+ it('injects a chevron icon as children when hasSubmenu=true and dir="ltr"', () => {
+ mockedUseFluent.mockReturnValue({ dir: 'ltr', targetDocument: document });
+ const ref = React.createRef>();
+
+ const { result } = renderHook(() => useMenuItem_unstable({ hasSubmenu: true }, ref), {
+ wrapper: makeWrapper(),
+ });
+
+ const children = result.current.submenuIndicator?.children as React.ReactElement;
+ expect(children).toBeDefined();
+ expect(typeof children.type).toBe('function');
+ });
+
+ it('injects a different chevron icon for dir="rtl" than for dir="ltr"', () => {
+ const ref = React.createRef>();
+
+ mockedUseFluent.mockReturnValue({ dir: 'ltr', targetDocument: document });
+ const { result: ltrResult } = renderHook(() => useMenuItem_unstable({ hasSubmenu: true }, ref), {
+ wrapper: makeWrapper(),
+ });
+ mockedUseFluent.mockReturnValue({ dir: 'rtl', targetDocument: document });
+ const { result: rtlResult } = renderHook(() => useMenuItem_unstable({ hasSubmenu: true }, ref), {
+ wrapper: makeWrapper(),
+ });
+
+ const ltrChildren = ltrResult.current.submenuIndicator?.children as React.ReactElement;
+ const rtlChildren = rtlResult.current.submenuIndicator?.children as React.ReactElement;
+ expect(ltrChildren.type).not.toBe(rtlChildren.type);
+ });
+
+ it('preserves user-provided submenuIndicator children over default chevron', () => {
+ const ref = React.createRef>();
+ const customIcon = React.createElement('span', { 'data-testid': 'custom-chevron' });
+
+ const { result } = renderHook(
+ () => useMenuItem_unstable({ hasSubmenu: true, submenuIndicator: { children: customIcon } }, ref),
+ { wrapper: makeWrapper() },
+ );
+
+ expect(result.current.submenuIndicator?.children).toBe(customIcon);
+ });
+ });
+
+ describe('icon / checkmark renderByDefault from MenuListContext', () => {
+ it('icon is undefined when hasIcons=false and no icon prop', () => {
+ const ref = React.createRef>();
+
+ const { result } = renderHook(() => useMenuItem_unstable({}, ref), {
+ wrapper: makeWrapper({ menuList: { hasIcons: false } }),
+ });
+
+ expect(result.current.icon).toBeUndefined();
+ });
+
+ it('icon is defined when hasIcons=true (renderByDefault)', () => {
+ const ref = React.createRef>();
+
+ const { result } = renderHook(() => useMenuItem_unstable({}, ref), {
+ wrapper: makeWrapper({ menuList: { hasIcons: true } }),
+ });
+
+ expect(result.current.icon).toBeDefined();
+ });
+
+ it('checkmark is undefined when hasCheckmarks=false and no checkmark prop', () => {
+ const ref = React.createRef>();
+
+ const { result } = renderHook(() => useMenuItem_unstable({}, ref), {
+ wrapper: makeWrapper({ menuList: { hasCheckmarks: false } }),
+ });
+
+ expect(result.current.checkmark).toBeUndefined();
+ });
+
+ it('checkmark is defined when hasCheckmarks=true (renderByDefault)', () => {
+ const ref = React.createRef>();
+
+ const { result } = renderHook(() => useMenuItem_unstable({}, ref), {
+ wrapper: makeWrapper({ menuList: { hasCheckmarks: true } }),
+ });
+
+ expect(result.current.checkmark).toBeDefined();
+ });
+ });
+});
diff --git a/packages/react-components/react-menu/library/src/components/MenuItemCheckbox/useMenuItemCheckbox.test.tsx b/packages/react-components/react-menu/library/src/components/MenuItemCheckbox/useMenuItemCheckbox.test.tsx
new file mode 100644
index 0000000000000..ab03b4af040e1
--- /dev/null
+++ b/packages/react-components/react-menu/library/src/components/MenuItemCheckbox/useMenuItemCheckbox.test.tsx
@@ -0,0 +1,206 @@
+import * as React from 'react';
+import { renderHook } from '@testing-library/react-hooks';
+import { Checkmark16Filled } from '@fluentui/react-icons';
+import { useMenuItemCheckbox_unstable } from './useMenuItemCheckbox';
+import { MenuListProvider } from '../../contexts/menuListContext';
+import { MenuProvider } from '../../contexts/menuContext';
+import { MenuTriggerContextProvider } from '../../contexts/menuTriggerContext';
+import type { MenuListContextValue } from '../../contexts/menuListContext';
+import type { MenuContextValue } from '../../contexts/menuContext';
+import type { ARIAButtonElement } from '@fluentui/react-aria';
+
+const defaultMenuListContextValue: MenuListContextValue = {
+ checkedValues: {},
+ setFocusByFirstCharacter: () => null,
+ toggleCheckbox: () => null,
+ selectRadio: () => null,
+ hasIcons: false,
+ hasCheckmarks: false,
+};
+
+const defaultMenuContextValue: MenuContextValue = {
+ open: false,
+ setOpen: () => false,
+ checkedValues: {},
+ onCheckedValueChange: () => null,
+ isSubmenu: false,
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ triggerRef: { current: null } as unknown as React.MutableRefObject,
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ menuPopoverRef: { current: null } as unknown as React.MutableRefObject,
+ mountNode: null,
+ triggerId: '',
+ openOnContext: false,
+ openOnHover: false,
+ hasIcons: false,
+ hasCheckmarks: false,
+ inline: false,
+ persistOnItemClick: false,
+};
+
+function makeWrapper(
+ options: {
+ menuList?: Partial;
+ menu?: Partial;
+ isSubmenuTrigger?: boolean;
+ } = {},
+) {
+ const menuListValue: MenuListContextValue = { ...defaultMenuListContextValue, ...options.menuList };
+ const menuValue: MenuContextValue = { ...defaultMenuContextValue, ...options.menu };
+
+ return ({ children }: { children: React.ReactNode }) => (
+
+
+ {children}
+
+
+ );
+}
+
+describe('useMenuItemCheckbox_unstable', () => {
+ describe('components and slots', () => {
+ it('returns components shape with all MenuItem slots', () => {
+ const ref = React.createRef>();
+
+ const { result } = renderHook(() => useMenuItemCheckbox_unstable({ name: 'foo', value: '1' }, ref), {
+ wrapper: makeWrapper(),
+ });
+
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ expect(result.current.components).toEqual({
+ root: 'div',
+ icon: 'span',
+ checkmark: 'span',
+ submenuIndicator: 'span',
+ content: 'span',
+ secondaryContent: 'span',
+ subText: 'span',
+ });
+ });
+
+ it('always returns a root slot', () => {
+ const ref = React.createRef>();
+
+ const { result } = renderHook(() => useMenuItemCheckbox_unstable({ name: 'foo', value: '1' }, ref), {
+ wrapper: makeWrapper(),
+ });
+
+ expect(result.current.root).toBeDefined();
+ });
+
+ it('always returns a checkmark slot (renderByDefault: true)', () => {
+ const ref = React.createRef>();
+
+ const { result } = renderHook(() => useMenuItemCheckbox_unstable({ name: 'foo', value: '1' }, ref), {
+ wrapper: makeWrapper(),
+ });
+
+ expect(result.current.checkmark).toBeDefined();
+ });
+ });
+
+ describe('root slot — aria/role wiring', () => {
+ it('sets root.role to "menuitemcheckbox"', () => {
+ const ref = React.createRef>();
+
+ const { result } = renderHook(() => useMenuItemCheckbox_unstable({ name: 'foo', value: '1' }, ref), {
+ wrapper: makeWrapper(),
+ });
+
+ expect(result.current.root.role).toBe('menuitemcheckbox');
+ });
+
+ it('sets root["aria-checked"] to false when value is not in checkedValues[name]', () => {
+ const ref = React.createRef>();
+
+ const { result } = renderHook(() => useMenuItemCheckbox_unstable({ name: 'foo', value: '1' }, ref), {
+ wrapper: makeWrapper({ menuList: { checkedValues: { foo: ['2'] } } }),
+ });
+
+ expect(result.current.root['aria-checked']).toBe(false);
+ });
+
+ it('sets root["aria-checked"] to true when value is in checkedValues[name]', () => {
+ const ref = React.createRef>();
+
+ const { result } = renderHook(() => useMenuItemCheckbox_unstable({ name: 'foo', value: '1' }, ref), {
+ wrapper: makeWrapper({ menuList: { checkedValues: { foo: ['1', '2'] } } }),
+ });
+
+ expect(result.current.root['aria-checked']).toBe(true);
+ });
+
+ it('sets persistOnClick to true (overriding MenuContext.persistOnItemClick)', () => {
+ const ref = React.createRef>();
+
+ const { result } = renderHook(() => useMenuItemCheckbox_unstable({ name: 'foo', value: '1' }, ref), {
+ wrapper: makeWrapper({ menu: { persistOnItemClick: false } }),
+ });
+
+ expect(result.current.persistOnClick).toBe(true);
+ });
+ });
+
+ describe('returned state shape', () => {
+ it('exposes name from props', () => {
+ const ref = React.createRef>();
+
+ const { result } = renderHook(() => useMenuItemCheckbox_unstable({ name: 'foo', value: '1' }, ref), {
+ wrapper: makeWrapper(),
+ });
+
+ expect(result.current.name).toBe('foo');
+ });
+
+ it('exposes value from props', () => {
+ const ref = React.createRef>();
+
+ const { result } = renderHook(() => useMenuItemCheckbox_unstable({ name: 'foo', value: '1' }, ref), {
+ wrapper: makeWrapper(),
+ });
+
+ expect(result.current.value).toBe('1');
+ });
+
+ it.each([
+ ['unchecked', { foo: ['2'] }, false],
+ ['checked', { foo: ['1'] }, true],
+ ['checked among others', { foo: ['1', '2', '3'] }, true],
+ ['no entry for name', {}, false],
+ ])('reflects checked=%s from MenuListContext.checkedValues', (_, checkedValues, expectedChecked) => {
+ const ref = React.createRef>();
+
+ const { result } = renderHook(() => useMenuItemCheckbox_unstable({ name: 'foo', value: '1' }, ref), {
+ wrapper: makeWrapper({ menuList: { checkedValues } }),
+ });
+
+ expect(result.current.checked).toBe(expectedChecked);
+ });
+ });
+
+ describe('checkmark slot — default icon injection', () => {
+ it('injects Checkmark16Filled when no checkmark.children provided', () => {
+ const ref = React.createRef>();
+
+ const { result } = renderHook(() => useMenuItemCheckbox_unstable({ name: 'foo', value: '1' }, ref), {
+ wrapper: makeWrapper(),
+ });
+
+ const children = result.current.checkmark?.children as React.ReactElement;
+ expect(children).toBeDefined();
+ expect(children.type).toBe(Checkmark16Filled);
+ });
+
+ it('preserves user-provided checkmark children over default Checkmark16Filled', () => {
+ const ref = React.createRef>();
+ const customIcon = React.createElement('span', { 'data-testid': 'custom-checkmark' });
+
+ const { result } = renderHook(
+ () => useMenuItemCheckbox_unstable({ name: 'foo', value: '1', checkmark: { children: customIcon } }, ref),
+ { wrapper: makeWrapper() },
+ );
+
+ expect(result.current.checkmark?.children).toBe(customIcon);
+ });
+ });
+});
diff --git a/packages/react-components/react-menu/library/src/components/MenuItemLink/useMenuItemLink.test.tsx b/packages/react-components/react-menu/library/src/components/MenuItemLink/useMenuItemLink.test.tsx
new file mode 100644
index 0000000000000..7b108dcdc428d
--- /dev/null
+++ b/packages/react-components/react-menu/library/src/components/MenuItemLink/useMenuItemLink.test.tsx
@@ -0,0 +1,185 @@
+import * as React from 'react';
+import { renderHook } from '@testing-library/react-hooks';
+import * as ReactSharedContexts from '@fluentui/react-shared-contexts';
+import { useMenuItemLink_unstable } from './useMenuItemLink';
+import { MenuListProvider } from '../../contexts/menuListContext';
+import { MenuProvider } from '../../contexts/menuContext';
+import { MenuTriggerContextProvider } from '../../contexts/menuTriggerContext';
+import type { MenuListContextValue } from '../../contexts/menuListContext';
+import type { MenuContextValue } from '../../contexts/menuContext';
+import type { MenuItemState } from '../MenuItem/MenuItem.types';
+
+jest.mock('@fluentui/react-shared-contexts', () => ({
+ ...jest.requireActual('@fluentui/react-shared-contexts'),
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ useFluent_unstable: jest.fn(() => ({ dir: 'ltr', targetDocument: document })),
+}));
+
+const mockedUseFluent = ReactSharedContexts.useFluent_unstable as jest.Mock;
+
+const defaultMenuListContextValue: MenuListContextValue = {
+ checkedValues: {},
+ setFocusByFirstCharacter: () => null,
+ toggleCheckbox: () => null,
+ selectRadio: () => null,
+ hasIcons: false,
+ hasCheckmarks: false,
+};
+
+const defaultMenuContextValue: MenuContextValue = {
+ open: false,
+ setOpen: () => false,
+ checkedValues: {},
+ onCheckedValueChange: () => null,
+ isSubmenu: false,
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ triggerRef: { current: null } as unknown as React.MutableRefObject,
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ menuPopoverRef: { current: null } as unknown as React.MutableRefObject,
+ mountNode: null,
+ triggerId: '',
+ openOnContext: false,
+ openOnHover: false,
+ hasIcons: false,
+ hasCheckmarks: false,
+ inline: false,
+ persistOnItemClick: false,
+};
+
+function makeWrapper(
+ options: {
+ menuList?: Partial;
+ menu?: Partial;
+ isSubmenuTrigger?: boolean;
+ } = {},
+) {
+ const menuListValue: MenuListContextValue = { ...defaultMenuListContextValue, ...options.menuList };
+ const menuValue: MenuContextValue = { ...defaultMenuContextValue, ...options.menu };
+
+ return ({ children }: { children: React.ReactNode }) => (
+
+
+ {children}
+
+
+ );
+}
+
+describe('useMenuItemLink_unstable', () => {
+ beforeEach(() => {
+ mockedUseFluent.mockReturnValue({ dir: 'ltr', targetDocument: document });
+ });
+
+ describe('components and slots', () => {
+ it('overrides root component to "a" while inheriting other MenuItem slots', () => {
+ const ref = React.createRef();
+
+ const { result } = renderHook(() => useMenuItemLink_unstable({ href: '/foo' }, ref), {
+ wrapper: makeWrapper(),
+ });
+
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ expect(result.current.components.root).toBe('a');
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ expect(result.current.components.icon).toBe('span');
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ expect(result.current.components.checkmark).toBe('span');
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ expect(result.current.components.content).toBe('span');
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ expect(result.current.components.secondaryContent).toBe('span');
+ });
+
+ it('always returns a root slot', () => {
+ const ref = React.createRef();
+
+ const { result } = renderHook(() => useMenuItemLink_unstable({ href: '/foo' }, ref), {
+ wrapper: makeWrapper(),
+ });
+
+ expect(result.current.root).toBeDefined();
+ });
+ });
+
+ describe('root slot', () => {
+ it('sets root.role to "menuitem"', () => {
+ const ref = React.createRef();
+
+ const { result } = renderHook(() => useMenuItemLink_unstable({ href: '/foo' }, ref), {
+ wrapper: makeWrapper(),
+ });
+
+ expect(result.current.root.role).toBe('menuitem');
+ });
+
+ it('spreads href onto root', () => {
+ const ref = React.createRef();
+
+ const { result } = renderHook(() => useMenuItemLink_unstable({ href: '/destination' }, ref), {
+ wrapper: makeWrapper(),
+ });
+
+ expect(result.current.root.href).toBe('/destination');
+ });
+
+ it('spreads className and aria-label onto root', () => {
+ const ref = React.createRef();
+
+ const { result } = renderHook(
+ () => useMenuItemLink_unstable({ href: '/foo', className: 'custom-class', 'aria-label': 'link' }, ref),
+ { wrapper: makeWrapper() },
+ );
+
+ expect(result.current.root.className).toBe('custom-class');
+ expect(result.current.root['aria-label']).toBe('link');
+ });
+
+ it('preserves tabIndex from props', () => {
+ const ref = React.createRef();
+
+ const { result } = renderHook(() => useMenuItemLink_unstable({ href: '/foo', tabIndex: -1 }, ref), {
+ wrapper: makeWrapper(),
+ });
+
+ expect(result.current.root.tabIndex).toBe(-1);
+ });
+ });
+
+ describe('composition with useMenuItem', () => {
+ // useMenuItemLink_unstable spreads ...baseState from useMenuItem_unstable, so MenuItem
+ // state fields are present at runtime. They aren't declared on the narrower
+ // MenuItemLinkState public type, so reads go through MenuItemState.
+ it('inherits persistOnClick from MenuContext.persistOnItemClick when prop omitted', () => {
+ const ref = React.createRef();
+
+ const { result } = renderHook(() => useMenuItemLink_unstable({ href: '/foo' }, ref), {
+ wrapper: makeWrapper({ menu: { persistOnItemClick: true } }),
+ });
+
+ const state = result.current as unknown as MenuItemState;
+ expect(state.persistOnClick).toBe(true);
+ });
+
+ it('inherits hasSubmenu=true when MenuTriggerContext is true', () => {
+ const ref = React.createRef();
+
+ const { result } = renderHook(() => useMenuItemLink_unstable({ href: '/foo' }, ref), {
+ wrapper: makeWrapper({ isSubmenuTrigger: true }),
+ });
+
+ const state = result.current as unknown as MenuItemState;
+ expect(state.hasSubmenu).toBe(true);
+ });
+
+ it('inherits disabled prop into state', () => {
+ const ref = React.createRef();
+
+ const { result } = renderHook(() => useMenuItemLink_unstable({ href: '/foo', disabled: true }, ref), {
+ wrapper: makeWrapper(),
+ });
+
+ const state = result.current as unknown as MenuItemState;
+ expect(state.disabled).toBe(true);
+ });
+ });
+});
diff --git a/packages/react-components/react-menu/library/src/components/MenuItemRadio/useMenuItemRadio.test.tsx b/packages/react-components/react-menu/library/src/components/MenuItemRadio/useMenuItemRadio.test.tsx
new file mode 100644
index 0000000000000..78ec9c8d03a89
--- /dev/null
+++ b/packages/react-components/react-menu/library/src/components/MenuItemRadio/useMenuItemRadio.test.tsx
@@ -0,0 +1,195 @@
+import * as React from 'react';
+import { renderHook } from '@testing-library/react-hooks';
+import { Checkmark16Filled } from '@fluentui/react-icons';
+import { useMenuItemRadio_unstable } from './useMenuItemRadio';
+import { MenuListProvider } from '../../contexts/menuListContext';
+import { MenuProvider } from '../../contexts/menuContext';
+import { MenuTriggerContextProvider } from '../../contexts/menuTriggerContext';
+import type { MenuListContextValue } from '../../contexts/menuListContext';
+import type { MenuContextValue } from '../../contexts/menuContext';
+import type { ARIAButtonElement } from '@fluentui/react-aria';
+
+const defaultMenuListContextValue: MenuListContextValue = {
+ checkedValues: {},
+ setFocusByFirstCharacter: () => null,
+ toggleCheckbox: () => null,
+ selectRadio: () => null,
+ hasIcons: false,
+ hasCheckmarks: false,
+};
+
+const defaultMenuContextValue: MenuContextValue = {
+ open: false,
+ setOpen: () => false,
+ checkedValues: {},
+ onCheckedValueChange: () => null,
+ isSubmenu: false,
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ triggerRef: { current: null } as unknown as React.MutableRefObject,
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ menuPopoverRef: { current: null } as unknown as React.MutableRefObject,
+ mountNode: null,
+ triggerId: '',
+ openOnContext: false,
+ openOnHover: false,
+ hasIcons: false,
+ hasCheckmarks: false,
+ inline: false,
+ persistOnItemClick: false,
+};
+
+function makeWrapper(
+ options: {
+ menuList?: Partial;
+ menu?: Partial;
+ isSubmenuTrigger?: boolean;
+ } = {},
+) {
+ const menuListValue: MenuListContextValue = { ...defaultMenuListContextValue, ...options.menuList };
+ const menuValue: MenuContextValue = { ...defaultMenuContextValue, ...options.menu };
+
+ return ({ children }: { children: React.ReactNode }) => (
+
+
+ {children}
+
+
+ );
+}
+
+describe('useMenuItemRadio_unstable', () => {
+ describe('components and slots', () => {
+ it('returns components shape with all MenuItem slots', () => {
+ const ref = React.createRef>();
+
+ const { result } = renderHook(() => useMenuItemRadio_unstable({ name: 'foo', value: '1' }, ref), {
+ wrapper: makeWrapper(),
+ });
+
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ expect(result.current.components).toEqual({
+ root: 'div',
+ icon: 'span',
+ checkmark: 'span',
+ submenuIndicator: 'span',
+ content: 'span',
+ secondaryContent: 'span',
+ subText: 'span',
+ });
+ });
+
+ it('always returns a root slot', () => {
+ const ref = React.createRef>();
+
+ const { result } = renderHook(() => useMenuItemRadio_unstable({ name: 'foo', value: '1' }, ref), {
+ wrapper: makeWrapper(),
+ });
+
+ expect(result.current.root).toBeDefined();
+ });
+
+ it('always returns a checkmark slot (renderByDefault: true)', () => {
+ const ref = React.createRef>();
+
+ const { result } = renderHook(() => useMenuItemRadio_unstable({ name: 'foo', value: '1' }, ref), {
+ wrapper: makeWrapper(),
+ });
+
+ expect(result.current.checkmark).toBeDefined();
+ });
+ });
+
+ describe('root slot — aria/role wiring', () => {
+ it('sets root.role to "menuitemradio"', () => {
+ const ref = React.createRef>();
+
+ const { result } = renderHook(() => useMenuItemRadio_unstable({ name: 'foo', value: '1' }, ref), {
+ wrapper: makeWrapper(),
+ });
+
+ expect(result.current.root.role).toBe('menuitemradio');
+ });
+
+ it('sets root["aria-checked"] to false when value is not in checkedValues[name]', () => {
+ const ref = React.createRef>();
+
+ const { result } = renderHook(() => useMenuItemRadio_unstable({ name: 'foo', value: '1' }, ref), {
+ wrapper: makeWrapper({ menuList: { checkedValues: { foo: ['2'] } } }),
+ });
+
+ expect(result.current.root['aria-checked']).toBe(false);
+ });
+
+ it('sets root["aria-checked"] to true when value is in checkedValues[name]', () => {
+ const ref = React.createRef>();
+
+ const { result } = renderHook(() => useMenuItemRadio_unstable({ name: 'foo', value: '1' }, ref), {
+ wrapper: makeWrapper({ menuList: { checkedValues: { foo: ['1'] } } }),
+ });
+
+ expect(result.current.root['aria-checked']).toBe(true);
+ });
+ });
+
+ describe('returned state shape', () => {
+ it('exposes name from props', () => {
+ const ref = React.createRef>();
+
+ const { result } = renderHook(() => useMenuItemRadio_unstable({ name: 'foo', value: '1' }, ref), {
+ wrapper: makeWrapper(),
+ });
+
+ expect(result.current.name).toBe('foo');
+ });
+
+ it('exposes value from props', () => {
+ const ref = React.createRef>();
+
+ const { result } = renderHook(() => useMenuItemRadio_unstable({ name: 'foo', value: '1' }, ref), {
+ wrapper: makeWrapper(),
+ });
+
+ expect(result.current.value).toBe('1');
+ });
+
+ it.each([
+ ['unchecked', { foo: ['2'] }, false],
+ ['checked', { foo: ['1'] }, true],
+ ['no entry for name', {}, false],
+ ])('reflects checked=%s from MenuListContext.checkedValues', (_, checkedValues, expectedChecked) => {
+ const ref = React.createRef>();
+
+ const { result } = renderHook(() => useMenuItemRadio_unstable({ name: 'foo', value: '1' }, ref), {
+ wrapper: makeWrapper({ menuList: { checkedValues } }),
+ });
+
+ expect(result.current.checked).toBe(expectedChecked);
+ });
+ });
+
+ describe('checkmark slot — default icon injection', () => {
+ it('injects Checkmark16Filled when no checkmark.children provided', () => {
+ const ref = React.createRef>();
+
+ const { result } = renderHook(() => useMenuItemRadio_unstable({ name: 'foo', value: '1' }, ref), {
+ wrapper: makeWrapper(),
+ });
+
+ const children = result.current.checkmark?.children as React.ReactElement;
+ expect(children).toBeDefined();
+ expect(children.type).toBe(Checkmark16Filled);
+ });
+
+ it('preserves user-provided checkmark children over default Checkmark16Filled', () => {
+ const ref = React.createRef>();
+ const customIcon = React.createElement('span', { 'data-testid': 'custom-checkmark' });
+
+ const { result } = renderHook(
+ () => useMenuItemRadio_unstable({ name: 'foo', value: '1', checkmark: { children: customIcon } }, ref),
+ { wrapper: makeWrapper() },
+ );
+
+ expect(result.current.checkmark?.children).toBe(customIcon);
+ });
+ });
+});
diff --git a/packages/react-components/react-menu/library/src/components/MenuItemSwitch/useMenuItemSwitch.test.tsx b/packages/react-components/react-menu/library/src/components/MenuItemSwitch/useMenuItemSwitch.test.tsx
new file mode 100644
index 0000000000000..d50a39836d6dc
--- /dev/null
+++ b/packages/react-components/react-menu/library/src/components/MenuItemSwitch/useMenuItemSwitch.test.tsx
@@ -0,0 +1,212 @@
+import * as React from 'react';
+import { renderHook } from '@testing-library/react-hooks';
+import { CircleFilled } from '@fluentui/react-icons';
+import { useMenuItemSwitch_unstable } from './useMenuItemSwitch';
+import { circleFilledClassName } from './useMenuItemSwitchStyles.styles';
+import { MenuListProvider } from '../../contexts/menuListContext';
+import { MenuProvider } from '../../contexts/menuContext';
+import { MenuTriggerContextProvider } from '../../contexts/menuTriggerContext';
+import type { MenuListContextValue } from '../../contexts/menuListContext';
+import type { MenuContextValue } from '../../contexts/menuContext';
+
+const defaultMenuListContextValue: MenuListContextValue = {
+ checkedValues: {},
+ setFocusByFirstCharacter: () => null,
+ toggleCheckbox: () => null,
+ selectRadio: () => null,
+ hasIcons: false,
+ hasCheckmarks: false,
+};
+
+const defaultMenuContextValue: MenuContextValue = {
+ open: false,
+ setOpen: () => false,
+ checkedValues: {},
+ onCheckedValueChange: () => null,
+ isSubmenu: false,
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ triggerRef: { current: null } as unknown as React.MutableRefObject,
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ menuPopoverRef: { current: null } as unknown as React.MutableRefObject,
+ mountNode: null,
+ triggerId: '',
+ openOnContext: false,
+ openOnHover: false,
+ hasIcons: false,
+ hasCheckmarks: false,
+ inline: false,
+ persistOnItemClick: false,
+};
+
+function makeWrapper(
+ options: {
+ menuList?: Partial;
+ menu?: Partial;
+ isSubmenuTrigger?: boolean;
+ } = {},
+) {
+ const menuListValue: MenuListContextValue = { ...defaultMenuListContextValue, ...options.menuList };
+ const menuValue: MenuContextValue = { ...defaultMenuContextValue, ...options.menu };
+
+ return ({ children }: { children: React.ReactNode }) => (
+
+
+ {children}
+
+
+ );
+}
+
+describe('useMenuItemSwitch_unstable', () => {
+ describe('components and slots', () => {
+ it('returns components shape including switchIndicator: "span"', () => {
+ const ref = React.createRef();
+
+ const { result } = renderHook(() => useMenuItemSwitch_unstable({ name: 'foo', value: '1' }, ref), {
+ wrapper: makeWrapper(),
+ });
+
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ expect(result.current.components.switchIndicator).toBe('span');
+ });
+
+ it('always returns a root slot', () => {
+ const ref = React.createRef();
+
+ const { result } = renderHook(() => useMenuItemSwitch_unstable({ name: 'foo', value: '1' }, ref), {
+ wrapper: makeWrapper(),
+ });
+
+ expect(result.current.root).toBeDefined();
+ });
+
+ it('runtime state leaks a checkmark slot from the MenuItemCheckboxBase composition (not exposed in the type)', () => {
+ const ref = React.createRef();
+
+ const { result } = renderHook(() => useMenuItemSwitch_unstable({ name: 'foo', value: '1' }, ref), {
+ wrapper: makeWrapper(),
+ });
+
+ const state = result.current as unknown as { checkmark?: unknown };
+ expect(state.checkmark).toBeDefined();
+ });
+
+ it('always returns a switchIndicator slot (renderByDefault: true)', () => {
+ const ref = React.createRef();
+
+ const { result } = renderHook(() => useMenuItemSwitch_unstable({ name: 'foo', value: '1' }, ref), {
+ wrapper: makeWrapper(),
+ });
+
+ expect(result.current.switchIndicator).toBeDefined();
+ });
+ });
+
+ describe('root slot — aria/role wiring (inherited from MenuItemCheckbox)', () => {
+ it('sets root.role to "menuitemcheckbox" (Switch reuses checkbox semantics)', () => {
+ const ref = React.createRef();
+
+ const { result } = renderHook(() => useMenuItemSwitch_unstable({ name: 'foo', value: '1' }, ref), {
+ wrapper: makeWrapper(),
+ });
+
+ expect(result.current.root.role).toBe('menuitemcheckbox');
+ });
+
+ it('sets root["aria-checked"] to false when value is not in checkedValues[name]', () => {
+ const ref = React.createRef();
+
+ const { result } = renderHook(() => useMenuItemSwitch_unstable({ name: 'foo', value: '1' }, ref), {
+ wrapper: makeWrapper({ menuList: { checkedValues: { foo: ['2'] } } }),
+ });
+
+ expect(result.current.root['aria-checked']).toBe(false);
+ });
+
+ it('sets root["aria-checked"] to true when value is in checkedValues[name]', () => {
+ const ref = React.createRef();
+
+ const { result } = renderHook(() => useMenuItemSwitch_unstable({ name: 'foo', value: '1' }, ref), {
+ wrapper: makeWrapper({ menuList: { checkedValues: { foo: ['1'] } } }),
+ });
+
+ expect(result.current.root['aria-checked']).toBe(true);
+ });
+ });
+
+ describe('returned state shape', () => {
+ it('exposes name from props', () => {
+ const ref = React.createRef();
+
+ const { result } = renderHook(() => useMenuItemSwitch_unstable({ name: 'foo', value: '1' }, ref), {
+ wrapper: makeWrapper(),
+ });
+
+ expect(result.current.name).toBe('foo');
+ });
+
+ it('exposes value from props', () => {
+ const ref = React.createRef();
+
+ const { result } = renderHook(() => useMenuItemSwitch_unstable({ name: 'foo', value: '1' }, ref), {
+ wrapper: makeWrapper(),
+ });
+
+ expect(result.current.value).toBe('1');
+ });
+
+ it.each([
+ ['unchecked', { foo: ['2'] }, false],
+ ['checked', { foo: ['1'] }, true],
+ ['no entry for name', {}, false],
+ ])('reflects checked=%s from MenuListContext.checkedValues', (_, checkedValues, expectedChecked) => {
+ const ref = React.createRef();
+
+ const { result } = renderHook(() => useMenuItemSwitch_unstable({ name: 'foo', value: '1' }, ref), {
+ wrapper: makeWrapper({ menuList: { checkedValues } }),
+ });
+
+ expect(result.current.checked).toBe(expectedChecked);
+ });
+ });
+
+ describe('switchIndicator slot — default icon injection', () => {
+ it('injects CircleFilled when no switchIndicator.children provided', () => {
+ const ref = React.createRef();
+
+ const { result } = renderHook(() => useMenuItemSwitch_unstable({ name: 'foo', value: '1' }, ref), {
+ wrapper: makeWrapper(),
+ });
+
+ const children = result.current.switchIndicator?.children as React.ReactElement<{ className?: string }>;
+ expect(children).toBeDefined();
+ expect(children.type).toBe(CircleFilled);
+ expect(children.props.className).toBe(circleFilledClassName);
+ });
+
+ it('preserves user-provided switchIndicator children over default CircleFilled', () => {
+ const ref = React.createRef();
+ const customIcon = React.createElement('span', { 'data-testid': 'custom-switch' });
+
+ const { result } = renderHook(
+ () => useMenuItemSwitch_unstable({ name: 'foo', value: '1', switchIndicator: { children: customIcon } }, ref),
+ { wrapper: makeWrapper() },
+ );
+
+ expect(result.current.switchIndicator?.children).toBe(customIcon);
+ });
+ });
+
+ describe('checkmark slot — no default icon overlay', () => {
+ it('leaked checkmark slot has no default children (Switch wraps base, not styled, MenuItemCheckbox)', () => {
+ const ref = React.createRef();
+
+ const { result } = renderHook(() => useMenuItemSwitch_unstable({ name: 'foo', value: '1' }, ref), {
+ wrapper: makeWrapper(),
+ });
+
+ const state = result.current as unknown as { checkmark?: { children?: unknown } };
+ expect(state.checkmark?.children).toBeUndefined();
+ });
+ });
+});
diff --git a/packages/react-components/react-menu/library/src/components/MenuList/useMenuList.test.ts b/packages/react-components/react-menu/library/src/components/MenuList/useMenuList.test.ts
deleted file mode 100644
index fb8d0422846f2..0000000000000
--- a/packages/react-components/react-menu/library/src/components/MenuList/useMenuList.test.ts
+++ /dev/null
@@ -1,192 +0,0 @@
-import type * as React from 'react';
-import { renderHook, act } from '@testing-library/react-hooks';
-import { useFocusFinders } from '@fluentui/react-tabster';
-import { useMenuList_unstable } from './useMenuList';
-
-jest.mock('@fluentui/react-tabster');
-(useFocusFinders as jest.Mock).mockReturnValue({
- findAllFocusable: jest.fn(),
-});
-
-describe('useMenuList_unstable', () => {
- it('should respect defaultCheckedValues on initial render', () => {
- // Arrange
- const defaultCheckedValues = { foo: ['1'] };
-
- // Act
- const { result } = renderHook(() => useMenuList_unstable({ defaultCheckedValues }, null));
-
- // Assert
- expect(result.current.checkedValues).toEqual(defaultCheckedValues);
- });
-
- it('should use checkedValues if provided with defaultCheckedValues', () => {
- // Arrange
- const defaultCheckedValues = { foo: ['1'] };
- const checkedValues = { bar: ['2'] };
-
- // Act
- const { result } = renderHook(() => useMenuList_unstable({ checkedValues, defaultCheckedValues }, null));
-
- // Assert
- expect(result.current.checkedValues).toEqual(checkedValues);
- });
-
- it('should ignore defaultCheckedValues after first render', () => {
- // Arrange
- const defaultCheckedValues = { foo: ['1'] };
- const expectedCheckedValues = { foo: ['2'] };
-
- // Act
- const { result } = renderHook(() => useMenuList_unstable({ defaultCheckedValues }, null));
- act(() => result.current.selectRadio({} as unknown as React.MouseEvent, 'foo', '2', false));
-
- // Assert
- expect(result.current.checkedValues).toEqual(expectedCheckedValues);
- });
-
- describe('setFocusByFirstCharacter', () => {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- let menuitems: any[];
-
- beforeEach(() => {
- menuitems = [
- { textContent: 'a', focus: jest.fn() },
- { textContent: 'b', focus: jest.fn() },
- { textContent: 'c', focus: jest.fn() },
- { textContent: 'a', focus: jest.fn() },
- { textContent: 'd', focus: jest.fn() },
- ];
-
- (useFocusFinders as jest.Mock).mockReturnValue({
- findAllFocusable: jest.fn().mockReturnValue(menuitems),
- });
- });
-
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const createEvent = (key: string): any => ({ key });
-
- it('should find the next item in a circular way', () => {
- // Arrange
- const current = menuitems[3];
-
- // Act
- const { result } = renderHook(() => useMenuList_unstable({}, null));
- (result.current.root.ref as React.RefCallback)?.(document.createElement('div'));
- result.current.setFocusByFirstCharacter(createEvent(current.textContent), current);
-
- // Assert
- expect(menuitems[0].focus).toHaveBeenCalledTimes(1);
- });
-
- it('should ignore case of textContent', () => {
- // Arrange
- menuitems.forEach((item, i, arr) => {
- menuitems[i].textContent = item.textContent.toUpperCase();
- });
- const current = menuitems[1];
-
- // Act
- const { result } = renderHook(() => useMenuList_unstable({}, null));
- (result.current.root.ref as React.RefCallback)?.(document.createElement('div'));
- result.current.setFocusByFirstCharacter(createEvent('d'), current);
-
- // Assert
- expect(menuitems[4].focus).toHaveBeenCalledTimes(1);
- });
- });
-
- describe('toggleCheckbox', () => {
- it('can be uncontrolled', () => {
- // Arrange
- const name = 'test';
- const value = '1';
-
- // Act
- const { result } = renderHook(() =>
- useMenuList_unstable({ onCheckedValueChange: jest.fn(), checkedValues: undefined }, null),
- );
- act(() => result.current.toggleCheckbox({} as unknown as React.MouseEvent, name, value, false));
-
- // Assert
- expect(result.current.checkedValues).toEqual({ [name]: [value] });
- });
-
- it.each([
- ['check', [], false, ['1']],
- ['check', ['2'], false, ['2', '1']],
- ['uncheck', ['1'], true, []],
- ['uncheck', ['2', '1'], true, ['2']],
- ])('should %s item', (_, checkedItems, checked, expectedResult) => {
- // Arrange
- const name = 'test';
- const value = '1';
-
- const handleCheckedValueChange = jest.fn();
-
- // Act
- const { result } = renderHook(() =>
- useMenuList_unstable(
- { onCheckedValueChange: handleCheckedValueChange, checkedValues: { [name]: checkedItems } },
- null,
- ),
- );
- const state = result.current;
- act(() => state.toggleCheckbox({} as unknown as React.MouseEvent, name, value, checked));
-
- // Assert
- expect(handleCheckedValueChange).toHaveBeenCalledTimes(1);
- expect(handleCheckedValueChange).toHaveBeenCalledWith(
- expect.anything(),
- expect.objectContaining({ name, checkedItems: expectedResult }),
- );
- });
- });
-
- describe('selectRadio', () => {
- it('can be uncontrolled', () => {
- // Arrange
- const name = 'test';
- const value = '1';
-
- // Act
- const { result } = renderHook(() =>
- useMenuList_unstable({ onCheckedValueChange: jest.fn(), checkedValues: undefined }, null),
- );
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- act(() => result.current.selectRadio({} as any, name, value, false));
-
- // Assert
- expect(result.current.checkedValues).toEqual({ [name]: [value] });
- });
-
- it.each([
- ['', [], ['1']],
- ['and keep current item selected', ['1'], ['1']],
- ['and deselect other item', ['2'], ['1']],
- ])('should select radio item %s', (_, checkedItems, expectedResult) => {
- // Arrange
- const name = 'test';
- const value = '1';
-
- const handleCheckedValueChange = jest.fn();
-
- // Act
- const { result } = renderHook(() =>
- useMenuList_unstable(
- { onCheckedValueChange: handleCheckedValueChange, checkedValues: { [name]: checkedItems } },
- null,
- ),
- );
- const state = result.current;
- act(() => state.selectRadio({} as unknown as React.MouseEvent, name, value, true));
-
- // Assert
- expect(handleCheckedValueChange).toHaveBeenCalledTimes(1);
- expect(handleCheckedValueChange).toHaveBeenCalledWith(
- expect.anything(),
- expect.objectContaining({ name, checkedItems: expectedResult }),
- );
- });
- });
-});
diff --git a/packages/react-components/react-menu/library/src/components/MenuList/useMenuList.test.tsx b/packages/react-components/react-menu/library/src/components/MenuList/useMenuList.test.tsx
new file mode 100644
index 0000000000000..23a0cf903c84b
--- /dev/null
+++ b/packages/react-components/react-menu/library/src/components/MenuList/useMenuList.test.tsx
@@ -0,0 +1,290 @@
+import * as React from 'react';
+import { renderHook, act } from '@testing-library/react-hooks';
+import { useFocusFinders } from '@fluentui/react-tabster';
+import { useMenuList_unstable } from './useMenuList';
+import { MenuProvider } from '../../contexts/menuContext';
+import type { MenuContextValue } from '../../contexts/menuContext';
+
+jest.mock('@fluentui/react-tabster', () => ({
+ useArrowNavigationGroup: jest.fn(),
+ useFocusFinders: jest.fn(),
+ TabsterMoveFocusEventName: 'tabster:movefocus',
+}));
+
+jest.mock('@fluentui/react-shared-contexts', () => ({
+ ...jest.requireActual('@fluentui/react-shared-contexts'),
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ useFluent_unstable: jest.fn(() => ({ dir: 'ltr', targetDocument: document })),
+}));
+
+const defaultMenuContextValue: MenuContextValue = {
+ open: false,
+ setOpen: () => false,
+ checkedValues: {},
+ onCheckedValueChange: () => null,
+ isSubmenu: false,
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ triggerRef: { current: null } as unknown as React.MutableRefObject,
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ menuPopoverRef: { current: null } as unknown as React.MutableRefObject,
+ mountNode: null,
+ triggerId: 'trigger-id',
+ openOnContext: false,
+ openOnHover: false,
+ hasIcons: false,
+ hasCheckmarks: false,
+ inline: false,
+ persistOnItemClick: false,
+};
+
+function makeMenuWrapper(overrides: Partial = {}) {
+ const value: MenuContextValue = { ...defaultMenuContextValue, ...overrides };
+ const Wrapper: React.FC<{ children?: React.ReactNode }> = ({ children }) => (
+ {children}
+ );
+ return Wrapper;
+}
+
+beforeEach(() => {
+ (useFocusFinders as jest.Mock).mockReturnValue({ findAllFocusable: jest.fn() });
+});
+
+describe('useMenuList_unstable', () => {
+ describe('components and slots', () => {
+ it('returns components shape with a div root', () => {
+ const { result } = renderHook(() => useMenuList_unstable({}, null));
+
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ expect(result.current.components).toEqual({ root: 'div' });
+ });
+
+ it('always returns a root slot', () => {
+ const { result } = renderHook(() => useMenuList_unstable({}, null));
+
+ expect(result.current.root).toBeDefined();
+ });
+ });
+
+ describe('root slot', () => {
+ it('sets root.role to "menu"', () => {
+ const { result } = renderHook(() => useMenuList_unstable({}, null));
+
+ expect(result.current.root.role).toBe('menu');
+ });
+
+ it('passes aria-labelledby from MenuContext.triggerId', () => {
+ const { result } = renderHook(() => useMenuList_unstable({}, null), {
+ wrapper: makeMenuWrapper({ triggerId: 'my-trigger' }),
+ });
+
+ expect(result.current.root['aria-labelledby']).toBe('my-trigger');
+ });
+
+ it('spreads className and aria-label from props onto root', () => {
+ const { result } = renderHook(
+ () => useMenuList_unstable({ className: 'custom-class', 'aria-label': 'my menu' }, null),
+ {},
+ );
+
+ expect(result.current.root.className).toBe('custom-class');
+ expect(result.current.root['aria-label']).toBe('my menu');
+ });
+ });
+
+ describe('hasIcons / hasCheckmarks', () => {
+ it('default to false when no prop and no MenuContext', () => {
+ const { result } = renderHook(() => useMenuList_unstable({}, null));
+
+ expect(result.current.hasIcons).toBe(false);
+ expect(result.current.hasCheckmarks).toBe(false);
+ });
+
+ it('inherit hasIcons / hasCheckmarks from MenuContext when not passed as props', () => {
+ const { result } = renderHook(() => useMenuList_unstable({}, null), {
+ wrapper: makeMenuWrapper({ hasIcons: true, hasCheckmarks: true }),
+ });
+
+ expect(result.current.hasIcons).toBe(true);
+ expect(result.current.hasCheckmarks).toBe(true);
+ });
+ });
+
+ describe('hasMenuContext flag', () => {
+ it('is false when rendered without a MenuContext', () => {
+ const { result } = renderHook(() => useMenuList_unstable({}, null));
+
+ expect(result.current.hasMenuContext).toBe(false);
+ });
+
+ it('is true when rendered inside a MenuContext', () => {
+ const { result } = renderHook(() => useMenuList_unstable({}, null), {
+ wrapper: makeMenuWrapper(),
+ });
+
+ expect(result.current.hasMenuContext).toBe(true);
+ });
+ });
+
+ describe('checkedValues', () => {
+ it('should respect defaultCheckedValues on initial render', () => {
+ const defaultCheckedValues = { foo: ['1'] };
+
+ const { result } = renderHook(() => useMenuList_unstable({ defaultCheckedValues }, null));
+
+ expect(result.current.checkedValues).toEqual(defaultCheckedValues);
+ });
+
+ it('should use checkedValues if provided with defaultCheckedValues', () => {
+ const defaultCheckedValues = { foo: ['1'] };
+ const checkedValues = { bar: ['2'] };
+
+ // Passing both is an anti-pattern that useControllableState warns about; silence
+ // the expected console.error so the test output stays clean.
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
+ try {
+ const { result } = renderHook(() => useMenuList_unstable({ checkedValues, defaultCheckedValues }, null));
+
+ expect(result.current.checkedValues).toEqual(checkedValues);
+ } finally {
+ consoleErrorSpy.mockRestore();
+ }
+ });
+
+ it('should ignore defaultCheckedValues after first render', () => {
+ const defaultCheckedValues = { foo: ['1'] };
+ const expectedCheckedValues = { foo: ['2'] };
+
+ const { result } = renderHook(() => useMenuList_unstable({ defaultCheckedValues }, null));
+ act(() => result.current.selectRadio({} as unknown as React.MouseEvent, 'foo', '2', false));
+
+ expect(result.current.checkedValues).toEqual(expectedCheckedValues);
+ });
+ });
+
+ describe('setFocusByFirstCharacter', () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let menuitems: any[];
+
+ beforeEach(() => {
+ menuitems = [
+ { textContent: 'a', focus: jest.fn() },
+ { textContent: 'b', focus: jest.fn() },
+ { textContent: 'c', focus: jest.fn() },
+ { textContent: 'a', focus: jest.fn() },
+ { textContent: 'd', focus: jest.fn() },
+ ];
+
+ (useFocusFinders as jest.Mock).mockReturnValue({
+ findAllFocusable: jest.fn().mockReturnValue(menuitems),
+ });
+ });
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const createEvent = (key: string): any => ({ key });
+
+ it('should find the next item in a circular way', () => {
+ const current = menuitems[3];
+
+ const { result } = renderHook(() => useMenuList_unstable({}, null));
+ (result.current.root.ref as React.RefCallback)?.(document.createElement('div'));
+ result.current.setFocusByFirstCharacter(createEvent(current.textContent), current);
+
+ expect(menuitems[0].focus).toHaveBeenCalledTimes(1);
+ });
+
+ it('should ignore case of textContent', () => {
+ menuitems.forEach((item, i) => {
+ menuitems[i].textContent = item.textContent.toUpperCase();
+ });
+ const current = menuitems[1];
+
+ const { result } = renderHook(() => useMenuList_unstable({}, null));
+ (result.current.root.ref as React.RefCallback)?.(document.createElement('div'));
+ result.current.setFocusByFirstCharacter(createEvent('d'), current);
+
+ expect(menuitems[4].focus).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('toggleCheckbox', () => {
+ it('can be uncontrolled', () => {
+ const name = 'test';
+ const value = '1';
+
+ const { result } = renderHook(() =>
+ useMenuList_unstable({ onCheckedValueChange: jest.fn(), checkedValues: undefined }, null),
+ );
+ act(() => result.current.toggleCheckbox({} as unknown as React.MouseEvent, name, value, false));
+
+ expect(result.current.checkedValues).toEqual({ [name]: [value] });
+ });
+
+ it.each([
+ ['check', [], false, ['1']],
+ ['check', ['2'], false, ['2', '1']],
+ ['uncheck', ['1'], true, []],
+ ['uncheck', ['2', '1'], true, ['2']],
+ ])('should %s item', (_, checkedItems, checked, expectedResult) => {
+ const name = 'test';
+ const value = '1';
+
+ const handleCheckedValueChange = jest.fn();
+
+ const { result } = renderHook(() =>
+ useMenuList_unstable(
+ { onCheckedValueChange: handleCheckedValueChange, checkedValues: { [name]: checkedItems } },
+ null,
+ ),
+ );
+ const state = result.current;
+ act(() => state.toggleCheckbox({} as unknown as React.MouseEvent, name, value, checked));
+
+ expect(handleCheckedValueChange).toHaveBeenCalledTimes(1);
+ expect(handleCheckedValueChange).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ name, checkedItems: expectedResult }),
+ );
+ });
+ });
+
+ describe('selectRadio', () => {
+ it('can be uncontrolled', () => {
+ const name = 'test';
+ const value = '1';
+
+ const { result } = renderHook(() =>
+ useMenuList_unstable({ onCheckedValueChange: jest.fn(), checkedValues: undefined }, null),
+ );
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ act(() => result.current.selectRadio({} as any, name, value, false));
+
+ expect(result.current.checkedValues).toEqual({ [name]: [value] });
+ });
+
+ it.each([
+ ['', [], ['1']],
+ ['and keep current item selected', ['1'], ['1']],
+ ['and deselect other item', ['2'], ['1']],
+ ])('should select radio item %s', (_, checkedItems, expectedResult) => {
+ const name = 'test';
+ const value = '1';
+
+ const handleCheckedValueChange = jest.fn();
+
+ const { result } = renderHook(() =>
+ useMenuList_unstable(
+ { onCheckedValueChange: handleCheckedValueChange, checkedValues: { [name]: checkedItems } },
+ null,
+ ),
+ );
+ const state = result.current;
+ act(() => state.selectRadio({} as unknown as React.MouseEvent, name, value, true));
+
+ expect(handleCheckedValueChange).toHaveBeenCalledTimes(1);
+ expect(handleCheckedValueChange).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ name, checkedItems: expectedResult }),
+ );
+ });
+ });
+});
diff --git a/packages/react-components/react-menu/library/src/components/MenuPopover/useMenuPopover.test.tsx b/packages/react-components/react-menu/library/src/components/MenuPopover/useMenuPopover.test.tsx
new file mode 100644
index 0000000000000..178ff21ca3081
--- /dev/null
+++ b/packages/react-components/react-menu/library/src/components/MenuPopover/useMenuPopover.test.tsx
@@ -0,0 +1,371 @@
+import * as React from 'react';
+import { renderHook } from '@testing-library/react-hooks';
+import * as ReactSharedContexts from '@fluentui/react-shared-contexts';
+import { useMenuPopover_unstable } from './useMenuPopover';
+import { MenuProvider } from '../../contexts/menuContext';
+import { MenuListProvider } from '../../contexts/menuListContext';
+import type { MenuContextValue } from '../../contexts/menuContext';
+import type { MenuListContextValue } from '../../contexts/menuListContext';
+import type { MenuPopoverProps } from './MenuPopover.types';
+
+type MutableRef = { current: T | null };
+
+function makeRef(value: T | null = null): MutableRef {
+ return { current: value };
+}
+
+jest.mock('@fluentui/react-shared-contexts', () => ({
+ ...jest.requireActual('@fluentui/react-shared-contexts'),
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ useFluent_unstable: jest.fn(() => ({ dir: 'ltr', targetDocument: document })),
+}));
+
+jest.mock('@fluentui/react-tabster', () => ({
+ ...jest.requireActual('@fluentui/react-tabster'),
+ useRestoreFocusSource: jest.fn(() => ({ 'data-tabster': '{"restorer":{"type":1}}' })),
+}));
+
+const mockedUseFluent = ReactSharedContexts.useFluent_unstable as jest.Mock;
+
+const defaultMenuListContextValue: MenuListContextValue = {
+ checkedValues: {},
+ setFocusByFirstCharacter: () => null,
+ toggleCheckbox: () => null,
+ selectRadio: () => null,
+ hasIcons: false,
+ hasCheckmarks: false,
+};
+
+function makeMenuContextValue(overrides: Partial = {}): MenuContextValue {
+ return {
+ open: false,
+ setOpen: jest.fn(),
+ checkedValues: {},
+ onCheckedValueChange: () => null,
+ isSubmenu: false,
+ triggerRef: makeRef() as MenuContextValue['triggerRef'],
+ menuPopoverRef: makeRef() as MenuContextValue['menuPopoverRef'],
+ mountNode: null,
+ triggerId: 'trigger-id',
+ openOnContext: false,
+ openOnHover: false,
+ hasIcons: false,
+ hasCheckmarks: false,
+ inline: false,
+ persistOnItemClick: false,
+ ...overrides,
+ };
+}
+
+function makeWrapper(
+ options: {
+ menu?: Partial;
+ menuList?: Partial;
+ /** When true, also render MenuListProvider so `useIsSubmenu` returns true via parent context. */
+ insideMenuList?: boolean;
+ } = {},
+) {
+ const menuValue = makeMenuContextValue(options.menu);
+ const menuListValue: MenuListContextValue = { ...defaultMenuListContextValue, ...options.menuList };
+
+ const Wrapper: React.FC<{ children?: React.ReactNode }> = ({ children }) => (
+
+ {options.insideMenuList ? {children} : children}
+
+ );
+
+ return { wrapper: Wrapper, menuValue, menuListValue };
+}
+
+beforeEach(() => {
+ mockedUseFluent.mockReturnValue({ dir: 'ltr', targetDocument: document });
+});
+
+describe('useMenuPopover_unstable', () => {
+ describe('components and slots', () => {
+ it('returns components shape with a div root', () => {
+ const { wrapper } = makeWrapper();
+
+ const { result } = renderHook(() => useMenuPopover_unstable({}, null), { wrapper });
+
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ expect(result.current.components).toEqual({ root: 'div' });
+ });
+
+ it('always returns a root slot with role="presentation"', () => {
+ const { wrapper } = makeWrapper();
+
+ const { result } = renderHook(() => useMenuPopover_unstable({}, null), { wrapper });
+
+ expect(result.current.root).toBeDefined();
+ expect(result.current.root.role).toBe('presentation');
+ });
+ });
+
+ describe('context-derived state', () => {
+ it('defaults inline to false and mountNode to null when MenuContext provides no values', () => {
+ const { wrapper } = makeWrapper();
+
+ const { result } = renderHook(() => useMenuPopover_unstable({}, null), { wrapper });
+
+ expect(result.current.inline).toBe(false);
+ expect(result.current.mountNode).toBeNull();
+ expect(result.current.safeZone).toBeUndefined();
+ });
+
+ it('forwards inline, mountNode, and safeZone from MenuContext', () => {
+ const mountNode = document.createElement('div');
+ const safeZone = ;
+ const { wrapper } = makeWrapper({ menu: { inline: true, mountNode, safeZone } });
+
+ const { result } = renderHook(() => useMenuPopover_unstable({}, null), { wrapper });
+
+ expect(result.current.inline).toBe(true);
+ expect(result.current.mountNode).toBe(mountNode);
+ expect(result.current.safeZone).toBe(safeZone);
+ });
+ });
+
+ describe('root slot wiring', () => {
+ it('passes className and arbitrary props through to root', () => {
+ const { wrapper } = makeWrapper();
+
+ const { result } = renderHook(
+ () =>
+ useMenuPopover_unstable({ className: 'custom-class', 'data-testid': 'popover' } as MenuPopoverProps, null),
+ { wrapper },
+ );
+
+ expect(result.current.root.className).toBe('custom-class');
+ expect((result.current.root as Record)['data-testid']).toBe('popover');
+ });
+
+ it('spreads restoreFocusSource attributes onto root', () => {
+ const { wrapper } = makeWrapper();
+
+ const { result } = renderHook(() => useMenuPopover_unstable({}, null), { wrapper });
+
+ expect((result.current.root as Record)['data-tabster']).toBe('{"restorer":{"type":1}}');
+ });
+ });
+
+ describe('onMouseEnter', () => {
+ it('calls the original onMouseEnter prop', () => {
+ const onMouseEnter = jest.fn();
+ const { wrapper } = makeWrapper();
+
+ const { result } = renderHook(() => useMenuPopover_unstable({ onMouseEnter }, null), { wrapper });
+ result.current.root.onMouseEnter?.({} as React.MouseEvent);
+
+ expect(onMouseEnter).toHaveBeenCalledTimes(1);
+ });
+
+ it('opens the menu when openOnHover is true in context', () => {
+ const setOpen = jest.fn();
+ const { wrapper } = makeWrapper({ menu: { openOnHover: true, setOpen } });
+
+ const { result } = renderHook(() => useMenuPopover_unstable({}, null), { wrapper });
+ result.current.root.onMouseEnter?.({} as React.MouseEvent);
+
+ expect(setOpen).toHaveBeenCalledTimes(1);
+ expect(setOpen).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ open: true, type: 'menuPopoverMouseEnter' }),
+ );
+ });
+
+ it('opens the menu when rendered as a submenu, even if openOnHover is false', () => {
+ const setOpen = jest.fn();
+ const { wrapper } = makeWrapper({ menu: { isSubmenu: true, openOnHover: false, setOpen } });
+
+ const { result } = renderHook(() => useMenuPopover_unstable({}, null), { wrapper });
+ result.current.root.onMouseEnter?.({} as React.MouseEvent);
+
+ expect(setOpen).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ open: true, type: 'menuPopoverMouseEnter' }),
+ );
+ });
+
+ it('does not open the menu when openOnHover is false and not a submenu', () => {
+ const setOpen = jest.fn();
+ const { wrapper } = makeWrapper({ menu: { openOnHover: false, setOpen } });
+
+ const { result } = renderHook(() => useMenuPopover_unstable({}, null), { wrapper });
+ result.current.root.onMouseEnter?.({} as React.MouseEvent);
+
+ expect(setOpen).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('onKeyDown', () => {
+ function buildKeyEvent(
+ overrides: Partial> = {},
+ ): React.KeyboardEvent {
+ const target = document.createElement('div');
+ return {
+ key: 'Escape',
+ target,
+ preventDefault: jest.fn(),
+ isDefaultPrevented: () => false,
+ ...overrides,
+ } as unknown as React.KeyboardEvent;
+ }
+
+ it('calls the original onKeyDown prop', () => {
+ const onKeyDown = jest.fn();
+ const { wrapper } = makeWrapper();
+
+ const { result } = renderHook(() => useMenuPopover_unstable({ onKeyDown }, null), { wrapper });
+ result.current.root.onKeyDown?.(buildKeyEvent({ key: 'a' }));
+
+ expect(onKeyDown).toHaveBeenCalledTimes(1);
+ });
+
+ it('closes the menu on Escape when open and target is inside the popover', () => {
+ const setOpen = jest.fn();
+ const popoverNode = document.createElement('div');
+ const target = document.createElement('span');
+ popoverNode.appendChild(target);
+ const popoverRef = makeRef(popoverNode) as MenuContextValue['menuPopoverRef'];
+ const { wrapper } = makeWrapper({ menu: { open: true, setOpen, menuPopoverRef: popoverRef } });
+
+ const event = buildKeyEvent({ key: 'Escape', target });
+ const { result } = renderHook(() => useMenuPopover_unstable({}, null), { wrapper });
+ result.current.root.onKeyDown?.(event);
+
+ expect(setOpen).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ open: false, keyboard: true, type: 'menuPopoverKeyDown' }),
+ );
+ expect(event.preventDefault).toHaveBeenCalledTimes(1);
+ });
+
+ it('does nothing on Escape when the menu is closed', () => {
+ const setOpen = jest.fn();
+ const { wrapper } = makeWrapper({ menu: { open: false, setOpen } });
+
+ const event = buildKeyEvent({ key: 'Escape' });
+ const { result } = renderHook(() => useMenuPopover_unstable({}, null), { wrapper });
+ result.current.root.onKeyDown?.(event);
+
+ expect(setOpen).not.toHaveBeenCalled();
+ expect(event.preventDefault).not.toHaveBeenCalled();
+ });
+
+ it('does nothing on Escape when defaultPrevented is true', () => {
+ const setOpen = jest.fn();
+ const popoverNode = document.createElement('div');
+ const target = document.createElement('span');
+ popoverNode.appendChild(target);
+ const popoverRef = makeRef(popoverNode) as MenuContextValue['menuPopoverRef'];
+ const { wrapper } = makeWrapper({ menu: { open: true, setOpen, menuPopoverRef: popoverRef } });
+
+ const event = buildKeyEvent({ key: 'Escape', target, isDefaultPrevented: () => true });
+ const { result } = renderHook(() => useMenuPopover_unstable({}, null), { wrapper });
+ result.current.root.onKeyDown?.(event);
+
+ expect(setOpen).not.toHaveBeenCalled();
+ expect(event.preventDefault).not.toHaveBeenCalled();
+ });
+
+ it('closes a submenu on ArrowLeft in ltr', () => {
+ const setOpen = jest.fn();
+ const popoverNode = document.createElement('div');
+ const target = document.createElement('span');
+ popoverNode.appendChild(target);
+ const popoverRef = makeRef(popoverNode) as MenuContextValue['menuPopoverRef'];
+ const { wrapper } = makeWrapper({ menu: { open: true, isSubmenu: true, setOpen, menuPopoverRef: popoverRef } });
+
+ const event = buildKeyEvent({ key: 'ArrowLeft', target });
+ const { result } = renderHook(() => useMenuPopover_unstable({}, null), { wrapper });
+ result.current.root.onKeyDown?.(event);
+
+ expect(setOpen).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ open: false, keyboard: true, type: 'menuPopoverKeyDown' }),
+ );
+ });
+
+ it('does not close on ArrowLeft when MenuListContext sets shouldCloseOnArrowLeft=false', () => {
+ const setOpen = jest.fn();
+ const popoverNode = document.createElement('div');
+ const target = document.createElement('span');
+ popoverNode.appendChild(target);
+ const popoverRef = makeRef(popoverNode) as MenuContextValue['menuPopoverRef'];
+ const { wrapper } = makeWrapper({
+ menu: { open: true, isSubmenu: true, setOpen, menuPopoverRef: popoverRef },
+ menuList: { shouldCloseOnArrowLeft: false },
+ insideMenuList: true,
+ });
+
+ const event = buildKeyEvent({ key: 'ArrowLeft', target });
+ const { result } = renderHook(() => useMenuPopover_unstable({}, null), { wrapper });
+ result.current.root.onKeyDown?.(event);
+
+ expect(setOpen).not.toHaveBeenCalled();
+ });
+
+ it('closes a submenu on ArrowRight in rtl', () => {
+ mockedUseFluent.mockReturnValue({ dir: 'rtl', targetDocument: document });
+ const setOpen = jest.fn();
+ const popoverNode = document.createElement('div');
+ const target = document.createElement('span');
+ popoverNode.appendChild(target);
+ const popoverRef = makeRef(popoverNode) as MenuContextValue['menuPopoverRef'];
+ const { wrapper } = makeWrapper({ menu: { open: true, isSubmenu: true, setOpen, menuPopoverRef: popoverRef } });
+
+ const event = buildKeyEvent({ key: 'ArrowRight', target });
+ const { result } = renderHook(() => useMenuPopover_unstable({}, null), { wrapper });
+ result.current.root.onKeyDown?.(event);
+
+ expect(setOpen).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ open: false, keyboard: true, type: 'menuPopoverKeyDown' }),
+ );
+ });
+
+ it('closes the menu and focuses the trigger on Tab when not a submenu', () => {
+ const setOpen = jest.fn();
+ const triggerEl = document.createElement('button');
+ const focusSpy = jest.spyOn(triggerEl, 'focus');
+ const triggerRef = makeRef(triggerEl) as MenuContextValue['triggerRef'];
+ const { wrapper } = makeWrapper({ menu: { setOpen, triggerRef, isSubmenu: false } });
+
+ const event = buildKeyEvent({ key: 'Tab' });
+ const { result } = renderHook(() => useMenuPopover_unstable({}, null), { wrapper });
+ result.current.root.onKeyDown?.(event);
+
+ expect(setOpen).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ open: false, keyboard: true, type: 'menuPopoverKeyDown' }),
+ );
+ expect(focusSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('closes the menu but does not focus the trigger on Tab when a submenu', () => {
+ const setOpen = jest.fn();
+ const triggerEl = document.createElement('button');
+ const focusSpy = jest.spyOn(triggerEl, 'focus');
+ const triggerRef = makeRef(triggerEl) as MenuContextValue['triggerRef'];
+ const { wrapper } = makeWrapper({ menu: { setOpen, triggerRef, isSubmenu: true } });
+
+ const event = buildKeyEvent({ key: 'Tab' });
+ const { result } = renderHook(() => useMenuPopover_unstable({}, null), { wrapper });
+ result.current.root.onKeyDown?.(event);
+
+ expect(setOpen).toHaveBeenCalledTimes(1);
+ expect(focusSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('cleanup', () => {
+ it('does not throw when unmounted', () => {
+ const { wrapper } = makeWrapper();
+
+ const { unmount } = renderHook(() => useMenuPopover_unstable({}, null), { wrapper });
+
+ expect(() => unmount()).not.toThrow();
+ });
+ });
+});
diff --git a/packages/react-components/react-menu/library/src/components/MenuTrigger/useMenuTrigger.test.tsx b/packages/react-components/react-menu/library/src/components/MenuTrigger/useMenuTrigger.test.tsx
new file mode 100644
index 0000000000000..0b10bc209e060
--- /dev/null
+++ b/packages/react-components/react-menu/library/src/components/MenuTrigger/useMenuTrigger.test.tsx
@@ -0,0 +1,734 @@
+import * as React from 'react';
+import { renderHook } from '@testing-library/react-hooks';
+import * as ReactSharedContexts from '@fluentui/react-shared-contexts';
+import { getReactElementRef } from '@fluentui/react-utilities';
+import { useMenuTrigger_unstable } from './useMenuTrigger';
+import { MenuProvider } from '../../contexts/menuContext';
+import { MenuListProvider } from '../../contexts/menuListContext';
+import type { MenuContextValue } from '../../contexts/menuContext';
+import type { MenuListContextValue } from '../../contexts/menuListContext';
+import type { MenuTriggerProps, MenuTriggerState } from './MenuTrigger.types';
+
+type MutableRef = { current: T | null };
+
+function makeRef(value: T | null = null): MutableRef {
+ return { current: value };
+}
+
+jest.mock('@fluentui/react-shared-contexts', () => ({
+ ...jest.requireActual('@fluentui/react-shared-contexts'),
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ useFluent_unstable: jest.fn(() => ({ dir: 'ltr', targetDocument: document })),
+}));
+
+const findFirstFocusableMock = jest.fn();
+jest.mock('@fluentui/react-tabster', () => ({
+ ...jest.requireActual('@fluentui/react-tabster'),
+ useFocusFinders: jest.fn(() => ({ findFirstFocusable: findFirstFocusableMock })),
+}));
+
+const mockedUseFluent = ReactSharedContexts.useFluent_unstable as jest.Mock;
+
+const defaultMenuListContextValue: MenuListContextValue = {
+ checkedValues: {},
+ setFocusByFirstCharacter: () => null,
+ toggleCheckbox: () => null,
+ selectRadio: () => null,
+ hasIcons: false,
+ hasCheckmarks: false,
+};
+
+function makeMenuContextValue(overrides: Partial = {}): MenuContextValue {
+ return {
+ open: false,
+ setOpen: jest.fn(),
+ checkedValues: {},
+ onCheckedValueChange: () => null,
+ isSubmenu: false,
+ triggerRef: makeRef() as MenuContextValue['triggerRef'],
+ menuPopoverRef: makeRef() as MenuContextValue['menuPopoverRef'],
+ mountNode: null,
+ triggerId: 'trigger-id',
+ openOnContext: false,
+ openOnHover: false,
+ hasIcons: false,
+ hasCheckmarks: false,
+ inline: false,
+ persistOnItemClick: false,
+ ...overrides,
+ };
+}
+
+function makeWrapper(
+ options: {
+ menu?: Partial;
+ menuList?: Partial;
+ /** When true, render a MenuListProvider as parent so `useIsSubmenu` is true via context. */
+ insideMenuList?: boolean;
+ } = {},
+) {
+ const menuValue = makeMenuContextValue(options.menu);
+ const menuListValue: MenuListContextValue = { ...defaultMenuListContextValue, ...options.menuList };
+
+ const Wrapper: React.FC<{ children?: React.ReactNode }> = ({ children }) => (
+
+ {options.insideMenuList ? {children} : children}
+
+ );
+
+ return { wrapper: Wrapper, menuValue, menuListValue };
+}
+
+function getChildProps = Record>(state: MenuTriggerState): T {
+ return (state.children as React.ReactElement).props as T;
+}
+
+function buildMouseEvent(overrides: Partial> = {}): React.MouseEvent {
+ const target = document.createElement('button');
+ return {
+ target,
+ currentTarget: target,
+ preventDefault: jest.fn(),
+ isDefaultPrevented: () => false,
+ ...overrides,
+ } as unknown as React.MouseEvent;
+}
+
+function buildKeyEvent(overrides: Partial> = {}): React.KeyboardEvent {
+ const target = document.createElement('button');
+ return {
+ key: 'a',
+ target,
+ currentTarget: target,
+ preventDefault: jest.fn(),
+ isDefaultPrevented: () => false,
+ ...overrides,
+ } as unknown as React.KeyboardEvent;
+}
+
+beforeEach(() => {
+ mockedUseFluent.mockReturnValue({ dir: 'ltr', targetDocument: document });
+ findFirstFocusableMock.mockReset();
+});
+
+describe('useMenuTrigger_unstable', () => {
+ describe('state shape', () => {
+ it('returns isSubmenu and a cloned children element', () => {
+ const { wrapper } = makeWrapper();
+
+ const { result } = renderHook(
+ () => useMenuTrigger_unstable({ children: } as MenuTriggerProps),
+ { wrapper },
+ );
+
+ expect(typeof result.current.isSubmenu).toBe('boolean');
+ expect(React.isValidElement(result.current.children)).toBe(true);
+ });
+ });
+
+ describe('isSubmenu detection', () => {
+ it('isSubmenu is false at the root menu level', () => {
+ const { wrapper } = makeWrapper();
+
+ const { result } = renderHook(
+ () => useMenuTrigger_unstable({ children: } as MenuTriggerProps),
+ { wrapper },
+ );
+
+ expect(result.current.isSubmenu).toBe(false);
+ });
+
+ it('isSubmenu is true when MenuContext.isSubmenu is true', () => {
+ const { wrapper } = makeWrapper({ menu: { isSubmenu: true } });
+
+ const { result } = renderHook(
+ () => useMenuTrigger_unstable({ children: } as MenuTriggerProps),
+ { wrapper },
+ );
+
+ expect(result.current.isSubmenu).toBe(true);
+ });
+
+ it('isSubmenu is true when wrapped in a parent MenuList context', () => {
+ const { wrapper } = makeWrapper({ insideMenuList: true });
+
+ const { result } = renderHook(
+ () => useMenuTrigger_unstable({ children: } as MenuTriggerProps),
+ { wrapper },
+ );
+
+ expect(result.current.isSubmenu).toBe(true);
+ });
+ });
+
+ describe('trigger child props', () => {
+ it('sets aria-haspopup="menu" and forwards triggerId to id', () => {
+ const { wrapper } = makeWrapper({ menu: { triggerId: 'my-trigger' } });
+
+ const { result } = renderHook(
+ () => useMenuTrigger_unstable({ children: } as MenuTriggerProps),
+ { wrapper },
+ );
+ const props = getChildProps(result.current);
+
+ expect(props['aria-haspopup']).toBe('menu');
+ expect(props.id).toBe('my-trigger');
+ });
+
+ it('omits aria-expanded when closed and not a submenu', () => {
+ const { wrapper } = makeWrapper({ menu: { open: false, isSubmenu: false } });
+
+ const { result } = renderHook(
+ () => useMenuTrigger_unstable({ children: } as MenuTriggerProps),
+ { wrapper },
+ );
+
+ expect(getChildProps(result.current)['aria-expanded']).toBeUndefined();
+ });
+
+ it('sets aria-expanded=false when closed and is a submenu', () => {
+ const { wrapper } = makeWrapper({ menu: { open: false, isSubmenu: true } });
+
+ const { result } = renderHook(
+ () => useMenuTrigger_unstable({ children: } as MenuTriggerProps),
+ { wrapper },
+ );
+
+ expect(getChildProps(result.current)['aria-expanded']).toBe(false);
+ });
+
+ it('sets aria-expanded=true when open', () => {
+ const { wrapper } = makeWrapper({ menu: { open: true } });
+
+ const { result } = renderHook(
+ () => useMenuTrigger_unstable({ children: } as MenuTriggerProps),
+ { wrapper },
+ );
+
+ expect(getChildProps(result.current)['aria-expanded']).toBe(true);
+ });
+
+ it('preserves the triggerRef in the child ref forwarding', () => {
+ const triggerEl = document.createElement('button');
+ const triggerRef = makeRef() as MenuContextValue['triggerRef'];
+ const { wrapper } = makeWrapper({ menu: { triggerRef } });
+
+ const { result } = renderHook(
+ () => useMenuTrigger_unstable({ children: } as MenuTriggerProps),
+ { wrapper },
+ );
+ const childRef = getReactElementRef(result.current.children as React.ReactElement);
+ expect(typeof childRef).toBe('function');
+ (childRef as (el: HTMLElement | null) => void)(triggerEl);
+
+ expect(triggerRef.current).toBe(triggerEl);
+ });
+ });
+
+ describe('disableButtonEnhancement', () => {
+ it('applies useARIAButtonProps for non-button children by default (adds role="button")', () => {
+ const { wrapper } = makeWrapper();
+
+ const { result } = renderHook(
+ () =>
+ useMenuTrigger_unstable({
+ children: trigger
,
+ } as unknown as MenuTriggerProps),
+ { wrapper },
+ );
+
+ expect(getChildProps(result.current).role).toBe('button');
+ });
+
+ it('skips ARIA button enhancement when disableButtonEnhancement is true', () => {
+ const { wrapper } = makeWrapper();
+
+ const { result } = renderHook(
+ () =>
+ useMenuTrigger_unstable({
+ disableButtonEnhancement: true,
+ children: trigger
,
+ } as unknown as MenuTriggerProps),
+ { wrapper },
+ );
+
+ expect(getChildProps(result.current).role).toBeUndefined();
+ });
+ });
+
+ describe('openOnContext mode', () => {
+ it('does not attach onClick or onKeyDown to the child when openOnContext is true', () => {
+ const { wrapper } = makeWrapper({ menu: { openOnContext: true } });
+
+ const { result } = renderHook(
+ () => useMenuTrigger_unstable({ children: } as MenuTriggerProps),
+ { wrapper },
+ );
+ const props = getChildProps(result.current);
+
+ expect(props.onClick).toBeUndefined();
+ expect(props.onKeyDown).toBeUndefined();
+ });
+
+ it('attaches onContextMenu in openOnContext mode', () => {
+ const { wrapper } = makeWrapper({ menu: { openOnContext: true } });
+
+ const { result } = renderHook(
+ () => useMenuTrigger_unstable({ children: } as MenuTriggerProps),
+ { wrapper },
+ );
+
+ expect(typeof getChildProps(result.current).onContextMenu).toBe('function');
+ });
+ });
+
+ describe('onClick', () => {
+ it('calls the original child onClick', () => {
+ const onClick = jest.fn();
+ const { wrapper } = makeWrapper();
+
+ const { result } = renderHook(
+ () => useMenuTrigger_unstable({ children: } as MenuTriggerProps),
+ { wrapper },
+ );
+ const props = getChildProps<{ onClick: (e: React.MouseEvent) => void }>(result.current);
+ props.onClick(buildMouseEvent());
+
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it('toggles open via setOpen with type "menuTriggerClick" when openOnContext is false', () => {
+ const setOpen = jest.fn();
+ const { wrapper } = makeWrapper({ menu: { open: false, setOpen } });
+
+ const { result } = renderHook(
+ () => useMenuTrigger_unstable({ children: } as MenuTriggerProps),
+ { wrapper },
+ );
+ const props = getChildProps<{ onClick: (e: React.MouseEvent) => void }>(result.current);
+ props.onClick(buildMouseEvent());
+
+ expect(setOpen).toHaveBeenCalledTimes(1);
+ expect(setOpen).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ open: true, type: 'menuTriggerClick' }),
+ );
+ });
+
+ it('passes !open as the next open value when already open', () => {
+ const setOpen = jest.fn();
+ const { wrapper } = makeWrapper({ menu: { open: true, setOpen } });
+
+ const { result } = renderHook(
+ () => useMenuTrigger_unstable({ children: } as MenuTriggerProps),
+ { wrapper },
+ );
+ const props = getChildProps<{ onClick: (e: React.MouseEvent) => void }>(result.current);
+ props.onClick(buildMouseEvent());
+
+ expect(setOpen).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ open: false, type: 'menuTriggerClick' }),
+ );
+ });
+
+ it('does not call setOpen when target has aria-disabled="true"', () => {
+ const setOpen = jest.fn();
+ const { wrapper } = makeWrapper({ menu: { setOpen } });
+ const target = document.createElement('button');
+ target.setAttribute('aria-disabled', 'true');
+
+ const { result } = renderHook(
+ () => useMenuTrigger_unstable({ children: } as MenuTriggerProps),
+ { wrapper },
+ );
+ const props = getChildProps<{ onClick: (e: React.MouseEvent) => void }>(result.current);
+ props.onClick(buildMouseEvent({ target, currentTarget: target } as Partial));
+
+ expect(setOpen).not.toHaveBeenCalled();
+ });
+
+ it('does not call setOpen when target is disabled', () => {
+ const setOpen = jest.fn();
+ const { wrapper } = makeWrapper({ menu: { setOpen } });
+ const target = document.createElement('button');
+ target.setAttribute('disabled', '');
+
+ const { result } = renderHook(
+ () => useMenuTrigger_unstable({ children: } as MenuTriggerProps),
+ { wrapper },
+ );
+ const props = getChildProps<{ onClick: (e: React.MouseEvent) => void }>(result.current);
+ props.onClick(buildMouseEvent({ target, currentTarget: target } as Partial));
+
+ expect(setOpen).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('onKeyDown', () => {
+ it('calls the original child onKeyDown', () => {
+ const onKeyDown = jest.fn();
+ const { wrapper } = makeWrapper();
+
+ const { result } = renderHook(
+ () => useMenuTrigger_unstable({ children: } as MenuTriggerProps),
+ { wrapper },
+ );
+ const props = getChildProps<{ onKeyDown: (e: React.KeyboardEvent) => void }>(result.current);
+ props.onKeyDown(buildKeyEvent({ key: 'x' }));
+
+ expect(onKeyDown).toHaveBeenCalledTimes(1);
+ });
+
+ it('opens the menu on ArrowDown when not a submenu', () => {
+ const setOpen = jest.fn();
+ const { wrapper } = makeWrapper({ menu: { setOpen } });
+
+ const { result } = renderHook(
+ () => useMenuTrigger_unstable({ children: } as MenuTriggerProps),
+ { wrapper },
+ );
+ const props = getChildProps<{ onKeyDown: (e: React.KeyboardEvent) => void }>(result.current);
+ props.onKeyDown(buildKeyEvent({ key: 'ArrowDown' }));
+
+ expect(setOpen).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ open: true, keyboard: true, type: 'menuTriggerKeyDown' }),
+ );
+ });
+
+ it('opens a submenu on ArrowRight in ltr', () => {
+ const setOpen = jest.fn();
+ const { wrapper } = makeWrapper({ menu: { isSubmenu: true, setOpen } });
+
+ const { result } = renderHook(
+ () => useMenuTrigger_unstable({ children: } as MenuTriggerProps),
+ { wrapper },
+ );
+ const props = getChildProps<{ onKeyDown: (e: React.KeyboardEvent) => void }>(result.current);
+ props.onKeyDown(buildKeyEvent({ key: 'ArrowRight' }));
+
+ expect(setOpen).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ open: true, keyboard: true, type: 'menuTriggerKeyDown' }),
+ );
+ });
+
+ it('opens a submenu on ArrowLeft in rtl', () => {
+ mockedUseFluent.mockReturnValue({ dir: 'rtl', targetDocument: document });
+ const setOpen = jest.fn();
+ const { wrapper } = makeWrapper({ menu: { isSubmenu: true, setOpen } });
+
+ const { result } = renderHook(
+ () => useMenuTrigger_unstable({ children: } as MenuTriggerProps),
+ { wrapper },
+ );
+ const props = getChildProps<{ onKeyDown: (e: React.KeyboardEvent) => void }>(result.current);
+ props.onKeyDown(buildKeyEvent({ key: 'ArrowLeft' }));
+
+ expect(setOpen).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ open: true, keyboard: true, type: 'menuTriggerKeyDown' }),
+ );
+ });
+
+ it('does not open a submenu on ArrowRight when shouldOpenOnArrowRight is false', () => {
+ const setOpen = jest.fn();
+ const { wrapper } = makeWrapper({
+ menu: { isSubmenu: true, setOpen },
+ menuList: { shouldOpenOnArrowRight: false },
+ insideMenuList: true,
+ });
+
+ const { result } = renderHook(
+ () => useMenuTrigger_unstable({ children: } as MenuTriggerProps),
+ { wrapper },
+ );
+ const props = getChildProps<{ onKeyDown: (e: React.KeyboardEvent) => void }>(result.current);
+ props.onKeyDown(buildKeyEvent({ key: 'ArrowRight' }));
+
+ expect(setOpen).not.toHaveBeenCalled();
+ });
+
+ it('does not open the menu on ArrowDown when openOnContext is true', () => {
+ const setOpen = jest.fn();
+ const { wrapper } = makeWrapper({ menu: { openOnContext: true, setOpen } });
+
+ const { result } = renderHook(
+ () => useMenuTrigger_unstable({ children: } as MenuTriggerProps),
+ { wrapper },
+ );
+ // openOnContext mode does not attach onKeyDown — confirm the child's existing onKeyDown is preserved
+ const props = getChildProps(result.current);
+ expect(props.onKeyDown).toBeUndefined();
+ expect(setOpen).not.toHaveBeenCalled();
+ });
+
+ it('closes the menu on Escape when not a submenu', () => {
+ const setOpen = jest.fn();
+ const { wrapper } = makeWrapper({ menu: { open: true, setOpen, isSubmenu: false } });
+
+ const { result } = renderHook(
+ () => useMenuTrigger_unstable({ children: } as MenuTriggerProps),
+ { wrapper },
+ );
+ const props = getChildProps<{ onKeyDown: (e: React.KeyboardEvent) => void }>(result.current);
+ props.onKeyDown(buildKeyEvent({ key: 'Escape' }));
+
+ expect(setOpen).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ open: false, keyboard: true, type: 'menuTriggerKeyDown' }),
+ );
+ });
+
+ it('does not close on Escape when rendered as a submenu', () => {
+ const setOpen = jest.fn();
+ const { wrapper } = makeWrapper({ menu: { open: true, setOpen, isSubmenu: true } });
+
+ const { result } = renderHook(
+ () => useMenuTrigger_unstable({ children: } as MenuTriggerProps),
+ { wrapper },
+ );
+ const props = getChildProps<{ onKeyDown: (e: React.KeyboardEvent) => void }>(result.current);
+ props.onKeyDown(buildKeyEvent({ key: 'Escape' }));
+
+ expect(setOpen).not.toHaveBeenCalled();
+ });
+
+ it('does nothing when defaultPrevented is true', () => {
+ const setOpen = jest.fn();
+ const { wrapper } = makeWrapper({ menu: { setOpen } });
+
+ const { result } = renderHook(
+ () => useMenuTrigger_unstable({ children: } as MenuTriggerProps),
+ { wrapper },
+ );
+ const props = getChildProps<{ onKeyDown: (e: React.KeyboardEvent) => void }>(result.current);
+ props.onKeyDown(buildKeyEvent({ key: 'ArrowDown', isDefaultPrevented: () => true }));
+
+ expect(setOpen).not.toHaveBeenCalled();
+ });
+
+ it('focuses the first focusable inside the popover on ArrowRight when submenu is already open', () => {
+ const popoverEl = document.createElement('div');
+ const popoverRef = makeRef(popoverEl) as MenuContextValue['menuPopoverRef'];
+ const focusableEl = document.createElement('button');
+ const focusSpy = jest.spyOn(focusableEl, 'focus');
+ findFirstFocusableMock.mockReturnValue(focusableEl);
+
+ const { wrapper } = makeWrapper({ menu: { open: true, isSubmenu: true, menuPopoverRef: popoverRef } });
+
+ const { result } = renderHook(
+ () => useMenuTrigger_unstable({ children: } as MenuTriggerProps),
+ { wrapper },
+ );
+ const props = getChildProps<{ onKeyDown: (e: React.KeyboardEvent) => void }>(result.current);
+ props.onKeyDown(buildKeyEvent({ key: 'ArrowRight' }));
+
+ expect(findFirstFocusableMock).toHaveBeenCalledWith(popoverEl);
+ expect(focusSpy).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('onContextMenu', () => {
+ it('opens the menu and prevents default when openOnContext is true', () => {
+ const setOpen = jest.fn();
+ const { wrapper } = makeWrapper({ menu: { openOnContext: true, setOpen } });
+
+ const { result } = renderHook(
+ () => useMenuTrigger_unstable({ children: } as MenuTriggerProps),
+ { wrapper },
+ );
+ const props = getChildProps<{ onContextMenu: (e: React.MouseEvent) => void }>(result.current);
+ const event = buildMouseEvent();
+ props.onContextMenu(event);
+
+ expect(event.preventDefault).toHaveBeenCalledTimes(1);
+ expect(setOpen).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ open: true, keyboard: false, type: 'menuTriggerContextMenu' }),
+ );
+ });
+
+ it('does not open the menu when openOnContext is false', () => {
+ const setOpen = jest.fn();
+ const { wrapper } = makeWrapper({ menu: { openOnContext: false, setOpen } });
+
+ const { result } = renderHook(
+ () => useMenuTrigger_unstable({ children: } as MenuTriggerProps),
+ { wrapper },
+ );
+ const props = getChildProps<{ onContextMenu: (e: React.MouseEvent) => void }>(result.current);
+ props.onContextMenu(buildMouseEvent());
+
+ expect(setOpen).not.toHaveBeenCalled();
+ });
+
+ it('does nothing when defaultPrevented is true', () => {
+ const setOpen = jest.fn();
+ const { wrapper } = makeWrapper({ menu: { openOnContext: true, setOpen } });
+
+ const { result } = renderHook(
+ () => useMenuTrigger_unstable({ children: } as MenuTriggerProps),
+ { wrapper },
+ );
+ const props = getChildProps<{ onContextMenu: (e: React.MouseEvent) => void }>(result.current);
+ props.onContextMenu(buildMouseEvent({ isDefaultPrevented: () => true }));
+
+ expect(setOpen).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('hover handlers', () => {
+ it('onMouseMove opens the menu on first move when openOnHover is true', () => {
+ const setOpen = jest.fn();
+ const { wrapper } = makeWrapper({ menu: { openOnHover: true, setOpen } });
+
+ const { result } = renderHook(
+ () => useMenuTrigger_unstable({ children: } as MenuTriggerProps),
+ { wrapper },
+ );
+ const props = getChildProps<{ onMouseMove: (e: React.MouseEvent) => void }>(result.current);
+ props.onMouseMove(buildMouseEvent());
+
+ expect(setOpen).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ open: true, type: 'menuTriggerMouseMove' }),
+ );
+ });
+
+ it('onMouseMove only opens the menu on the first move', () => {
+ const setOpen = jest.fn();
+ const { wrapper } = makeWrapper({ menu: { openOnHover: true, setOpen } });
+
+ const { result } = renderHook(
+ () => useMenuTrigger_unstable({ children: } as MenuTriggerProps),
+ { wrapper },
+ );
+ const props = getChildProps<{ onMouseMove: (e: React.MouseEvent) => void }>(result.current);
+ props.onMouseMove(buildMouseEvent());
+ props.onMouseMove(buildMouseEvent());
+
+ expect(setOpen).toHaveBeenCalledTimes(1);
+ });
+
+ it('onMouseMove does nothing when openOnHover is false', () => {
+ const setOpen = jest.fn();
+ const { wrapper } = makeWrapper({ menu: { openOnHover: false, setOpen } });
+
+ const { result } = renderHook(
+ () => useMenuTrigger_unstable({ children: } as MenuTriggerProps),
+ { wrapper },
+ );
+ const props = getChildProps<{ onMouseMove: (e: React.MouseEvent) => void }>(result.current);
+ props.onMouseMove(buildMouseEvent());
+
+ expect(setOpen).not.toHaveBeenCalled();
+ });
+
+ it('onMouseOver opens the menu when openOnHover and the mouse has already moved', () => {
+ const setOpen = jest.fn();
+ const { wrapper } = makeWrapper({ menu: { openOnHover: true, setOpen } });
+
+ const { result } = renderHook(
+ () => useMenuTrigger_unstable({ children: } as MenuTriggerProps),
+ { wrapper },
+ );
+ const props = getChildProps<{
+ onMouseMove: (e: React.MouseEvent) => void;
+ onMouseOver: (e: React.MouseEvent) => void;
+ }>(result.current);
+ // Prime the hasMouseMoved ref via onMouseMove first
+ props.onMouseMove(buildMouseEvent());
+ setOpen.mockClear();
+ props.onMouseOver(buildMouseEvent());
+
+ expect(setOpen).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ open: true, type: 'menuTriggerMouseEnter' }),
+ );
+ });
+
+ it('onMouseOver does nothing when the mouse has not moved yet', () => {
+ const setOpen = jest.fn();
+ const { wrapper } = makeWrapper({ menu: { openOnHover: true, setOpen } });
+
+ const { result } = renderHook(
+ () => useMenuTrigger_unstable({ children: } as MenuTriggerProps),
+ { wrapper },
+ );
+ const props = getChildProps<{ onMouseOver: (e: React.MouseEvent) => void }>(result.current);
+ props.onMouseOver(buildMouseEvent());
+
+ expect(setOpen).not.toHaveBeenCalled();
+ });
+
+ it('onMouseLeave closes the menu when openOnHover is true', () => {
+ const setOpen = jest.fn();
+ const { wrapper } = makeWrapper({ menu: { openOnHover: true, setOpen } });
+
+ const { result } = renderHook(
+ () => useMenuTrigger_unstable({ children: } as MenuTriggerProps),
+ { wrapper },
+ );
+ const props = getChildProps<{ onMouseLeave: (e: React.MouseEvent) => void }>(result.current);
+ props.onMouseLeave(buildMouseEvent());
+
+ expect(setOpen).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ open: false, type: 'menuTriggerMouseLeave' }),
+ );
+ });
+
+ it('onMouseLeave does nothing when openOnHover is false', () => {
+ const setOpen = jest.fn();
+ const { wrapper } = makeWrapper({ menu: { openOnHover: false, setOpen } });
+
+ const { result } = renderHook(
+ () => useMenuTrigger_unstable({ children: } as MenuTriggerProps),
+ { wrapper },
+ );
+ const props = getChildProps<{ onMouseLeave: (e: React.MouseEvent) => void }>(result.current);
+ props.onMouseLeave(buildMouseEvent());
+
+ expect(setOpen).not.toHaveBeenCalled();
+ });
+
+ it('mouse handlers no-op when target is aria-disabled', () => {
+ const setOpen = jest.fn();
+ const { wrapper } = makeWrapper({ menu: { openOnHover: true, setOpen } });
+ const disabledTarget = document.createElement('button');
+ disabledTarget.setAttribute('aria-disabled', 'true');
+
+ const { result } = renderHook(
+ () => useMenuTrigger_unstable({ children: } as MenuTriggerProps),
+ { wrapper },
+ );
+ const props = getChildProps<{
+ onMouseMove: (e: React.MouseEvent) => void;
+ onMouseLeave: (e: React.MouseEvent) => void;
+ }>(result.current);
+ const event = buildMouseEvent({
+ target: disabledTarget,
+ currentTarget: disabledTarget,
+ } as Partial);
+ props.onMouseMove(event);
+ props.onMouseLeave(event);
+
+ expect(setOpen).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('cleanup', () => {
+ it('does not throw when unmounted', () => {
+ const { wrapper } = makeWrapper();
+
+ const { unmount } = renderHook(
+ () => useMenuTrigger_unstable({ children: } as MenuTriggerProps),
+ { wrapper },
+ );
+
+ expect(() => unmount()).not.toThrow();
+ });
+ });
+});