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(); + }); + }); +});