diff --git a/change/@fluentui-react-headless-components-preview-d14be094-ae2a-4f19-9dcb-1b6c49a089cb.json b/change/@fluentui-react-headless-components-preview-d14be094-ae2a-4f19-9dcb-1b6c49a089cb.json new file mode 100644 index 00000000000000..dd6842962f7794 --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-d14be094-ae2a-4f19-9dcb-1b6c49a089cb.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: add Menu component (Menu, MenuTrigger, MenuList, MenuItem, MenuPopover, MenuDivider) — wraps v9 useMenu*Base_unstable hooks; uses native popover='auto' for top-layer rendering instead of Portal", + "packageName": "@fluentui/react-headless-components-preview", + "email": "viktorgenaev@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-menu-d76677a3-6018-4f05-a403-e59905ec8e5f.json b/change/@fluentui-react-menu-d76677a3-6018-4f05-a403-e59905ec8e5f.json new file mode 100644 index 00000000000000..3f806a34a920eb --- /dev/null +++ b/change/@fluentui-react-menu-d76677a3-6018-4f05-a403-e59905ec8e5f.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: expose react-menu base hooks", + "packageName": "@fluentui/react-menu", + "email": "vgenaev@gmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-headless-components-preview/library/bundle-size/AllComponents.fixture.js b/packages/react-components/react-headless-components-preview/library/bundle-size/AllComponents.fixture.js index 0084b681e53e3e..fe4986979fadd3 100644 --- a/packages/react-components/react-headless-components-preview/library/bundle-size/AllComponents.fixture.js +++ b/packages/react-components/react-headless-components-preview/library/bundle-size/AllComponents.fixture.js @@ -11,6 +11,7 @@ import * as Drawer from '@fluentui/react-headless-components-preview/drawer'; import * as Field from '@fluentui/react-headless-components-preview/field'; import * as Input from '@fluentui/react-headless-components-preview/input'; import * as Link from '@fluentui/react-headless-components-preview/link'; +import * as Menu from '@fluentui/react-headless-components-preview/menu'; import * as MessageBar from '@fluentui/react-headless-components-preview/message-bar'; import * as ProgressBar from '@fluentui/react-headless-components-preview/progress-bar'; import * as Popover from '@fluentui/react-headless-components-preview/popover'; @@ -45,6 +46,7 @@ console.log({ Field, Input, Link, + Menu, MessageBar, ProgressBar, Popover, diff --git a/packages/react-components/react-headless-components-preview/library/etc/menu.api.md b/packages/react-components/react-headless-components-preview/library/etc/menu.api.md new file mode 100644 index 00000000000000..3256e88ae48495 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/etc/menu.api.md @@ -0,0 +1,279 @@ +## API Report File for "@fluentui/react-headless-components-preview" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { ARIAButtonElement } from '@fluentui/react-aria'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { JSXElement } from '@fluentui/react-utilities'; +import type { MenuBaseProps } from '@fluentui/react-menu'; +import type { MenuBaseState } from '@fluentui/react-menu'; +import type { MenuContextValue } from '@fluentui/react-menu'; +import { MenuDividerProps } from '@fluentui/react-menu'; +import { MenuDividerSlots } from '@fluentui/react-menu'; +import { MenuDividerState } from '@fluentui/react-menu'; +import { MenuGroupContextValues } from '@fluentui/react-menu'; +import { MenuGroupHeaderProps } from '@fluentui/react-menu'; +import { MenuGroupHeaderSlots } from '@fluentui/react-menu'; +import { MenuGroupHeaderState } from '@fluentui/react-menu'; +import { MenuGroupProps } from '@fluentui/react-menu'; +import { MenuGroupSlots } from '@fluentui/react-menu'; +import { MenuGroupState } from '@fluentui/react-menu'; +import { MenuItemCheckboxProps } from '@fluentui/react-menu'; +import { MenuItemCheckboxState } from '@fluentui/react-menu'; +import { MenuItemLinkProps } from '@fluentui/react-menu'; +import { MenuItemLinkSlots } from '@fluentui/react-menu'; +import { MenuItemLinkState } from '@fluentui/react-menu'; +import { MenuItemProps } from '@fluentui/react-menu'; +import { MenuItemRadioBaseProps as MenuItemRadioProps } from '@fluentui/react-menu'; +import { MenuItemRadioBaseState as MenuItemRadioState } from '@fluentui/react-menu'; +import { MenuItemRadioState as MenuItemRadioState_2 } from '@fluentui/react-menu'; +import { MenuItemSlots } from '@fluentui/react-menu'; +import { MenuItemState } from '@fluentui/react-menu'; +import { MenuItemSwitchProps } from '@fluentui/react-menu'; +import { MenuItemSwitchSlots } from '@fluentui/react-menu'; +import { MenuItemSwitchState } from '@fluentui/react-menu'; +import { MenuListContextValues } from '@fluentui/react-menu'; +import type { MenuListProps as MenuListProps_2 } from '@fluentui/react-menu'; +import type { MenuListSlots } from '@fluentui/react-menu'; +import { MenuListState as MenuListState_2 } from '@fluentui/react-menu'; +import type { MenuOpenChangeData } from '@fluentui/react-menu'; +import type { MenuOpenEvent } from '@fluentui/react-menu'; +import { MenuPopoverProps } from '@fluentui/react-menu'; +import { MenuPopoverSlots } from '@fluentui/react-menu'; +import { MenuPopoverState } from '@fluentui/react-menu'; +import { MenuSplitGroupProps } from '@fluentui/react-menu'; +import { MenuSplitGroupSlots } from '@fluentui/react-menu'; +import { MenuSplitGroupState } from '@fluentui/react-menu'; +import { MenuTriggerChildProps } from '@fluentui/react-menu'; +import { MenuTriggerProps } from '@fluentui/react-menu'; +import { MenuTriggerState } from '@fluentui/react-menu'; +import * as React_2 from 'react'; +import { useMenuContext_unstable as useMenuContext } from '@fluentui/react-menu'; + +// @public +export const Menu: React_2.FC; + +export { MenuContextValue } + +// @public (undocumented) +export type MenuContextValues = { + menu: MenuContextValue; +}; + +// @public +export const MenuDivider: ForwardRefComponent; + +export { MenuDividerProps } + +export { MenuDividerSlots } + +export { MenuDividerState } + +// @public +export const MenuGroup: ForwardRefComponent; + +export { MenuGroupContextValues } + +// @public +export const MenuGroupHeader: ForwardRefComponent; + +export { MenuGroupHeaderProps } + +export { MenuGroupHeaderSlots } + +export { MenuGroupHeaderState } + +export { MenuGroupProps } + +export { MenuGroupSlots } + +export { MenuGroupState } + +// @public +export const MenuItem: ForwardRefComponent; + +// @public +export const MenuItemCheckbox: ForwardRefComponent; + +export { MenuItemCheckboxProps } + +export { MenuItemCheckboxState } + +// @public +export const MenuItemLink: ForwardRefComponent; + +export { MenuItemLinkProps } + +export { MenuItemLinkSlots } + +export { MenuItemLinkState } + +export { MenuItemProps } + +// @public +export const MenuItemRadio: ForwardRefComponent; + +export { MenuItemRadioProps } + +export { MenuItemRadioState } + +export { MenuItemSlots } + +export { MenuItemState } + +// @public +export const MenuItemSwitch: ForwardRefComponent; + +export { MenuItemSwitchProps } + +export { MenuItemSwitchSlots } + +export { MenuItemSwitchState } + +// @public +export const MenuList: ForwardRefComponent; + +// @public (undocumented) +export type MenuListProps = MenuListProps_2; + +export { MenuListSlots } + +// @public (undocumented) +export type MenuListState = MenuListState_2 & { + root: { + focusgroup?: string; + }; +}; + +export { MenuOpenChangeData } + +export { MenuOpenEvent } + +// @public (undocumented) +export const MenuPopover: ForwardRefComponent; + +export { MenuPopoverProps } + +export { MenuPopoverSlots } + +export { MenuPopoverState } + +// @public (undocumented) +export type MenuProps = MenuBaseProps; + +// @public (undocumented) +export const MenuSplitGroup: ForwardRefComponent; + +export { MenuSplitGroupProps } + +export { MenuSplitGroupSlots } + +export { MenuSplitGroupState } + +// @public (undocumented) +export type MenuState = MenuBaseState; + +// @public +export const MenuTrigger: React_2.FC; + +export { MenuTriggerChildProps } + +export { MenuTriggerProps } + +export { MenuTriggerState } + +// @public (undocumented) +export const renderMenu: (state: MenuState, contextValues: MenuContextValues) => JSXElement; + +// @public (undocumented) +export const renderMenuDivider: (state: MenuDividerState) => JSXElement; + +// @public (undocumented) +export const renderMenuGroup: (state: MenuGroupState, contextValues: MenuGroupContextValues) => JSXElement; + +// @public (undocumented) +export const renderMenuGroupHeader: (state: MenuGroupHeaderState) => JSXElement; + +// @public (undocumented) +export const renderMenuItem: (state: MenuItemState) => JSXElement; + +// @public (undocumented) +export const renderMenuItemCheckbox: (state: MenuItemCheckboxState) => JSXElement; + +// @public (undocumented) +export const renderMenuItemLink: (state: MenuItemLinkState) => JSXElement; + +// @public (undocumented) +export const renderMenuItemRadio: (state: MenuItemRadioState_2) => JSXElement; + +// @public (undocumented) +export const renderMenuItemSwitch: (state: MenuItemSwitchState) => JSXElement; + +// @public (undocumented) +export const renderMenuList: (state: MenuListState_2, contextValues: MenuListContextValues) => JSXElement; + +// @public (undocumented) +export const renderMenuPopover: (state: MenuPopoverState) => JSXElement; + +// @public (undocumented) +export const renderMenuSplitGroup: (state: MenuSplitGroupState, contexts?: { + menuSplitGroup: { + setMultiline: (multiline: boolean) => void; + }; +}) => JSXElement; + +// @public +export const renderMenuTrigger: (state: MenuTriggerState) => JSXElement; + +// @public (undocumented) +export const useMenu: (props: MenuProps) => MenuState; + +export { useMenuContext } + +// @public (undocumented) +export const useMenuContextValues: (state: MenuState) => MenuContextValues; + +// @public +export const useMenuDivider: (props: MenuDividerProps, ref: React_2.Ref) => MenuDividerState; + +// @public +export const useMenuGroup: (props: MenuGroupProps, ref: React_2.Ref) => MenuGroupState; + +// @public (undocumented) +export const useMenuGroupContextValues: (state: MenuGroupState) => MenuGroupContextValues; + +// @public +export const useMenuGroupHeader: (props: MenuGroupHeaderProps, ref: React_2.Ref) => MenuGroupHeaderState; + +// @public +export const useMenuItem: (props: MenuItemProps, ref: React_2.Ref>) => MenuItemState; + +// @public +export const useMenuItemCheckbox: (props: MenuItemCheckboxProps, ref: React_2.Ref>) => MenuItemCheckboxState; + +// @public +export const useMenuItemLink: (props: MenuItemLinkProps, ref: React_2.Ref) => MenuItemLinkState; + +// @public +export const useMenuItemRadio: (props: MenuItemRadioProps, ref: React_2.Ref>) => MenuItemRadioState; + +// @public +export const useMenuItemSwitch: (props: MenuItemSwitchProps, ref: React_2.Ref) => MenuItemSwitchState; + +// @public +export const useMenuList: (props: MenuListProps, ref: React_2.Ref) => MenuListState; + +// @public (undocumented) +export const useMenuPopover: (props: MenuPopoverProps, ref: React_2.Ref) => MenuPopoverState; + +// @public (undocumented) +export const useMenuSplitGroup: (props: MenuSplitGroupProps, ref: React_2.Ref) => MenuSplitGroupState; + +// @public +export const useMenuTrigger: (props: MenuTriggerProps) => MenuTriggerState; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-headless-components-preview/library/package.json b/packages/react-components/react-headless-components-preview/library/package.json index 11ad6ce8169218..650ef00d245251 100644 --- a/packages/react-components/react-headless-components-preview/library/package.json +++ b/packages/react-components/react-headless-components-preview/library/package.json @@ -35,6 +35,7 @@ "@fluentui/react-input": "^9.8.2", "@fluentui/react-label": "^9.4.1", "@fluentui/react-link": "^9.8.1", + "@fluentui/react-menu": "^9.24.1", "@fluentui/react-message-bar": "^9.7.0", "@fluentui/react-persona": "^9.7.3", "@fluentui/react-popover": "^9.14.2", @@ -147,6 +148,12 @@ "import": "./lib/link.js", "require": "./lib-commonjs/link.js" }, + "./menu": { + "types": "./dist/menu.d.ts", + "node": "./lib-commonjs/menu.js", + "import": "./lib/menu.js", + "require": "./lib-commonjs/menu.js" + }, "./message-bar": { "types": "./dist/message-bar.d.ts", "node": "./lib-commonjs/message-bar.js", diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/Menu.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Menu/Menu.test.tsx new file mode 100644 index 00000000000000..d749f6c41c0258 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/Menu.test.tsx @@ -0,0 +1,506 @@ +import * as React from 'react'; +import { render, fireEvent, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Menu } from './Menu'; +import { MenuTrigger } from './MenuTrigger/MenuTrigger'; +import { MenuPopover } from './MenuPopover/MenuPopover'; +import { MenuList } from './MenuList/MenuList'; +import { MenuItem } from './MenuItem/MenuItem'; +import { MenuItemCheckbox } from './MenuItemCheckbox/MenuItemCheckbox'; +import { MenuItemRadio } from './MenuItemRadio/MenuItemRadio'; +import { MenuItemLink } from './MenuItemLink/MenuItemLink'; +import { MenuItemSwitch } from './MenuItemSwitch/MenuItemSwitch'; +import { MenuDivider } from './MenuDivider/MenuDivider'; +import { MenuGroup } from './MenuGroup/MenuGroup'; +import { MenuGroupHeader } from './MenuGroupHeader/MenuGroupHeader'; + +describe('Menu', () => { + it('renders trigger and surface children when open', () => { + const { getByText } = render( + + + + + + + Item 1 + Item 2 + + + , + ); + + expect(getByText('Trigger')).toBeInTheDocument(); + expect(getByText('Item 1')).toBeInTheDocument(); + expect(getByText('Item 2')).toBeInTheDocument(); + }); + + it('opens on trigger click (uncontrolled)', () => { + const { getByText, queryByText } = render( + + + + + + + Item 1 + + + , + ); + + expect(queryByText('Item 1')).not.toBeInTheDocument(); + + userEvent.click(getByText('Trigger')); + + expect(getByText('Item 1')).toBeInTheDocument(); + }); + + it('closes on trigger click when open', () => { + const { getByText, queryByText } = render( + + + + + + + Item 1 + + + , + ); + + expect(getByText('Item 1')).toBeInTheDocument(); + + userEvent.click(getByText('Trigger')); + + expect(queryByText('Item 1')).not.toBeInTheDocument(); + }); + + it('fires onOpenChange callback', () => { + const onOpenChange = jest.fn(); + + const { getByText } = render( + + + + + + + Item 1 + + + , + ); + + userEvent.click(getByText('Trigger')); + + expect(onOpenChange).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ open: true })); + }); + + it('supports controlled open state', () => { + const { getByText, queryByText, rerender } = render( + + + + + + + Item 1 + + + , + ); + + expect(queryByText('Item 1')).not.toBeInTheDocument(); + + rerender( + + + + + + + Item 1 + + + , + ); + + expect(getByText('Item 1')).toBeInTheDocument(); + }); + + it('sets aria-haspopup="menu" and aria-expanded on trigger', () => { + const { getByText } = render( + + + + + + + Item 1 + + + , + ); + + const trigger = getByText('Trigger'); + expect(trigger).toHaveAttribute('aria-haspopup', 'menu'); + expect(trigger).not.toHaveAttribute('aria-expanded'); + + userEvent.click(trigger); + + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + }); + + it('sets role="menu" on MenuList and links aria-labelledby to trigger id', () => { + const { getByText, getByRole } = render( + + + + + + + Item 1 + + + , + ); + + const list = getByRole('menu'); + const trigger = getByText('Trigger'); + + expect(list).toHaveAttribute('aria-labelledby', trigger.getAttribute('id') ?? ''); + }); + + it('sets role="menuitem" on MenuItem', () => { + const { getAllByRole } = render( + + + + + + + Item 1 + Item 2 + + + , + ); + + expect(getAllByRole('menuitem')).toHaveLength(2); + }); + + it('renders MenuDivider with role="presentation" and aria-hidden', () => { + const { container } = render( + + + + + + + Item 1 + + Item 2 + + + , + ); + + const divider = container.querySelector('[data-testid="divider"]')!; + expect(divider).toHaveAttribute('role', 'presentation'); + expect(divider).toHaveAttribute('aria-hidden', 'true'); + }); + + it('closes the menu when a MenuItem is clicked', () => { + const { getByText, queryByText } = render( + + + + + + + Item 1 + + + , + ); + + expect(getByText('Item 1')).toBeInTheDocument(); + userEvent.click(getByText('Item 1')); + expect(queryByText('Item 1')).not.toBeInTheDocument(); + }); + + it('keeps the menu open when persistOnItemClick is set', () => { + const { getByText } = render( + + + + + + + Item 1 + + + , + ); + + userEvent.click(getByText('Item 1')); + expect(getByText('Item 1')).toBeInTheDocument(); + }); + + it('renders MenuPopover without a Portal (inline)', () => { + const { container, getByRole } = render( + + + + + + + Item 1 + + + , + ); + + // The popover surface should be a descendant of the test container, not in document.body via a Portal. + expect(container.contains(getByRole('menu'))).toBe(true); + }); + + describe('arrow-key navigation in MenuList', () => { + const renderMenu = () => + render( + + + + + + + Apple + Banana + Cherry + + + , + ); + + it('ArrowDown moves focus across MenuItems', () => { + const { getAllByRole } = renderMenu(); + const [apple, banana] = getAllByRole('menuitem'); + act(() => apple.focus()); + fireEvent.keyDown(apple, { key: 'ArrowDown' }); + expect(apple.ownerDocument.activeElement).toBe(banana); + }); + + it('ArrowUp wraps to the last item from the first', () => { + const { getAllByRole } = renderMenu(); + const items = getAllByRole('menuitem'); + const [apple] = items; + const cherry = items[items.length - 1]; + act(() => apple.focus()); + fireEvent.keyDown(apple, { key: 'ArrowUp' }); + expect(apple.ownerDocument.activeElement).toBe(cherry); + }); + + it('Home jumps to first item', () => { + const { getAllByRole } = renderMenu(); + const items = getAllByRole('menuitem'); + const apple = items[0]; + const cherry = items[items.length - 1]; + act(() => cherry.focus()); + fireEvent.keyDown(cherry, { key: 'Home' }); + expect(cherry.ownerDocument.activeElement).toBe(apple); + }); + + it('stamps focusgroup attribute on MenuList root for forward-compat', () => { + const { getByRole } = renderMenu(); + expect(getByRole('menu')).toHaveAttribute('focusgroup', 'block wrap'); + }); + }); + + describe('MenuItemCheckbox', () => { + it('renders role="menuitemcheckbox"', () => { + const { getByRole } = render( + + + + + + + + Bold + + + + , + ); + + expect(getByRole('menuitemcheckbox')).toBeInTheDocument(); + }); + + it('toggles aria-checked on click via controlled checkedValues', () => { + const onCheckedValueChange = jest.fn(); + const { getByRole, rerender } = render( + + + + + + + + Bold + + + + , + ); + + const item = getByRole('menuitemcheckbox'); + expect(item).toHaveAttribute('aria-checked', 'false'); + + userEvent.click(item); + expect(onCheckedValueChange).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ name: 'filters', checkedItems: ['bold'] }), + ); + + rerender( + + + + + + + + Bold + + + + , + ); + + expect(getByRole('menuitemcheckbox')).toHaveAttribute('aria-checked', 'true'); + }); + }); + + describe('MenuItemRadio', () => { + it('renders role="menuitemradio"', () => { + const { getAllByRole } = render( + + + + + + + + Name + + + Size + + + + , + ); + + expect(getAllByRole('menuitemradio')).toHaveLength(2); + }); + + it('enforces single-select per group', () => { + const { getAllByRole } = render( + + + + + + + + Name + + + Size + + + + , + ); + + const [name, size] = getAllByRole('menuitemradio'); + expect(name).toHaveAttribute('aria-checked', 'true'); + expect(size).toHaveAttribute('aria-checked', 'false'); + + userEvent.click(size); + + const [nameAfter, sizeAfter] = getAllByRole('menuitemradio'); + expect(nameAfter).toHaveAttribute('aria-checked', 'false'); + expect(sizeAfter).toHaveAttribute('aria-checked', 'true'); + }); + }); + + describe('MenuItemLink', () => { + it('renders an element with role="menuitem" and href', () => { + const { getByRole } = render( + + + + + + + Docs + + + , + ); + + const link = getByRole('menuitem'); + expect(link.tagName).toBe('A'); + expect(link).toHaveAttribute('href', 'https://example.com'); + }); + }); + + describe('MenuItemSwitch', () => { + it('renders an item with switchIndicator slot', () => { + const { getByText, container } = render( + + + + + + + }> + Grid view + + + + , + ); + + expect(getByText('Grid view')).toBeInTheDocument(); + expect(container.querySelector('[data-testid="switch"]')).toBeInTheDocument(); + }); + }); + + describe('MenuGroup + MenuGroupHeader', () => { + it('renders header inside group with a generated id', () => { + const { getByText } = render( + + + + + + + + Document + Page + + + + , + ); + + const header = getByText('Document'); + expect(header).toBeInTheDocument(); + expect(header.getAttribute('id')).toBeTruthy(); + }); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/Menu.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Menu/Menu.tsx new file mode 100644 index 00000000000000..d68d1c8620a8ac --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/Menu.tsx @@ -0,0 +1,19 @@ +'use client'; + +import type * as React from 'react'; +import { useMenu } from './useMenu'; +import { useMenuContextValues } from './useMenuContextValues'; +import { renderMenu } from './renderMenu'; +import type { MenuProps } from './Menu.types'; + +/** + * Headless Menu component. + */ +export const Menu: React.FC = props => { + const state = useMenu(props); + const contextValues = useMenuContextValues(state); + + return renderMenu(state, contextValues); +}; + +Menu.displayName = 'Menu'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/Menu.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/Menu.types.ts new file mode 100644 index 00000000000000..308073916845c7 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/Menu.types.ts @@ -0,0 +1,16 @@ +import type { + MenuBaseProps, + MenuBaseState, + MenuContextValue, + MenuOpenChangeData, + MenuOpenEvent, +} from '@fluentui/react-menu'; + +export type MenuProps = MenuBaseProps; +export type MenuState = MenuBaseState; + +export type MenuContextValues = { + menu: MenuContextValue; +}; + +export type { MenuContextValue, MenuOpenChangeData, MenuOpenEvent }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuDivider/MenuDivider.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuDivider/MenuDivider.tsx new file mode 100644 index 00000000000000..7ef4eaea9a135d --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuDivider/MenuDivider.tsx @@ -0,0 +1,19 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { useMenuDivider } from './useMenuDivider'; +import { renderMenuDivider } from './renderMenuDivider'; +import type { MenuDividerProps } from './MenuDivider.types'; + +/** + * Headless MenuDivider component. + * + * Renders an `aria-hidden` `role="presentation"` separator inside a MenuList. + */ +export const MenuDivider: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useMenuDivider(props, ref); + return renderMenuDivider(state); +}); + +MenuDivider.displayName = 'MenuDivider'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuDivider/MenuDivider.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuDivider/MenuDivider.types.ts new file mode 100644 index 00000000000000..62b88bbd054b3b --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuDivider/MenuDivider.types.ts @@ -0,0 +1 @@ +export type { MenuDividerProps, MenuDividerSlots, MenuDividerState } from '@fluentui/react-menu'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuDivider/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuDivider/index.ts new file mode 100644 index 00000000000000..cfcd70397772db --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuDivider/index.ts @@ -0,0 +1,4 @@ +export { MenuDivider } from './MenuDivider'; +export { useMenuDivider } from './useMenuDivider'; +export { renderMenuDivider } from './renderMenuDivider'; +export type { MenuDividerProps, MenuDividerSlots, MenuDividerState } from './MenuDivider.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuDivider/renderMenuDivider.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuDivider/renderMenuDivider.tsx new file mode 100644 index 00000000000000..a5f0395a76dd22 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuDivider/renderMenuDivider.tsx @@ -0,0 +1,3 @@ +import { renderMenuDivider_unstable } from '@fluentui/react-menu'; + +export const renderMenuDivider = renderMenuDivider_unstable; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuDivider/useMenuDivider.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuDivider/useMenuDivider.ts new file mode 100644 index 00000000000000..73833f3643bdfe --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuDivider/useMenuDivider.ts @@ -0,0 +1,16 @@ +'use client'; + +import type * as React from 'react'; +import { useMenuDivider_unstable } from '@fluentui/react-menu'; +import type { MenuDividerProps, MenuDividerState } from './MenuDivider.types'; + +/** + * Returns the state for a MenuDivider. + * + * Delegates to v9's `useMenuDivider_unstable`, which produces a + * `role="presentation"` `aria-hidden` container suitable for visual + * separators between MenuItem groups. + */ +export const useMenuDivider = (props: MenuDividerProps, ref: React.Ref): MenuDividerState => { + return useMenuDivider_unstable(props, ref); +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuGroup/MenuGroup.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuGroup/MenuGroup.tsx new file mode 100644 index 00000000000000..8d1b4fecf37970 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuGroup/MenuGroup.tsx @@ -0,0 +1,22 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { useMenuGroup } from './useMenuGroup'; +import { useMenuGroupContextValues } from './useMenuGroupContextValues'; +import { renderMenuGroup } from './renderMenuGroup'; +import type { MenuGroupProps } from './MenuGroup.types'; + +/** + * Headless MenuGroup component. + * + * Wraps a logical group of menu items, exposing a `headerId` through context + * so that an optional `MenuGroupHeader` child can wire `aria-labelledby`. + */ +export const MenuGroup: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useMenuGroup(props, ref); + const contextValues = useMenuGroupContextValues(state); + return renderMenuGroup(state, contextValues); +}); + +MenuGroup.displayName = 'MenuGroup'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuGroup/MenuGroup.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuGroup/MenuGroup.types.ts new file mode 100644 index 00000000000000..f3efa9bfdc4c2f --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuGroup/MenuGroup.types.ts @@ -0,0 +1 @@ +export type { MenuGroupProps, MenuGroupSlots, MenuGroupState, MenuGroupContextValues } from '@fluentui/react-menu'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuGroup/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuGroup/index.ts new file mode 100644 index 00000000000000..e9ed490c70a50e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuGroup/index.ts @@ -0,0 +1,5 @@ +export { MenuGroup } from './MenuGroup'; +export { useMenuGroup } from './useMenuGroup'; +export { useMenuGroupContextValues } from './useMenuGroupContextValues'; +export { renderMenuGroup } from './renderMenuGroup'; +export type { MenuGroupProps, MenuGroupSlots, MenuGroupState, MenuGroupContextValues } from './MenuGroup.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuGroup/renderMenuGroup.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuGroup/renderMenuGroup.tsx new file mode 100644 index 00000000000000..ad1d6ab0e8db90 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuGroup/renderMenuGroup.tsx @@ -0,0 +1,3 @@ +import { renderMenuGroup_unstable } from '@fluentui/react-menu'; + +export const renderMenuGroup = renderMenuGroup_unstable; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuGroup/useMenuGroup.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuGroup/useMenuGroup.ts new file mode 100644 index 00000000000000..e3ee9ba4c5f16c --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuGroup/useMenuGroup.ts @@ -0,0 +1,13 @@ +'use client'; + +import type * as React from 'react'; +import { useMenuGroup_unstable } from '@fluentui/react-menu'; +import type { MenuGroupProps, MenuGroupState } from './MenuGroup.types'; + +/** + * Returns the state for a MenuGroup. + * + */ +export const useMenuGroup = (props: MenuGroupProps, ref: React.Ref): MenuGroupState => { + return useMenuGroup_unstable(props, ref); +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuGroup/useMenuGroupContextValues.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuGroup/useMenuGroupContextValues.ts new file mode 100644 index 00000000000000..69e0f3fea1fa43 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuGroup/useMenuGroupContextValues.ts @@ -0,0 +1,8 @@ +'use client'; + +import { useMenuGroupContextValues_unstable } from '@fluentui/react-menu'; +import type { MenuGroupContextValues, MenuGroupState } from './MenuGroup.types'; + +export const useMenuGroupContextValues = (state: MenuGroupState): MenuGroupContextValues => { + return useMenuGroupContextValues_unstable(state); +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuGroupHeader/MenuGroupHeader.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuGroupHeader/MenuGroupHeader.tsx new file mode 100644 index 00000000000000..8947edfe2fcfc9 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuGroupHeader/MenuGroupHeader.tsx @@ -0,0 +1,20 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { useMenuGroupHeader } from './useMenuGroupHeader'; +import { renderMenuGroupHeader } from './renderMenuGroupHeader'; +import type { MenuGroupHeaderProps } from './MenuGroupHeader.types'; + +/** + * Headless MenuGroupHeader component. + * + * Renders the labelled header for a `MenuGroup` and stamps the group's + * `headerId` on its root. + */ +export const MenuGroupHeader: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useMenuGroupHeader(props, ref); + return renderMenuGroupHeader(state); +}); + +MenuGroupHeader.displayName = 'MenuGroupHeader'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuGroupHeader/MenuGroupHeader.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuGroupHeader/MenuGroupHeader.types.ts new file mode 100644 index 00000000000000..10fbf8cd12ba1a --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuGroupHeader/MenuGroupHeader.types.ts @@ -0,0 +1 @@ +export type { MenuGroupHeaderProps, MenuGroupHeaderSlots, MenuGroupHeaderState } from '@fluentui/react-menu'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuGroupHeader/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuGroupHeader/index.ts new file mode 100644 index 00000000000000..a97660059205a3 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuGroupHeader/index.ts @@ -0,0 +1,4 @@ +export { MenuGroupHeader } from './MenuGroupHeader'; +export { useMenuGroupHeader } from './useMenuGroupHeader'; +export { renderMenuGroupHeader } from './renderMenuGroupHeader'; +export type { MenuGroupHeaderProps, MenuGroupHeaderSlots, MenuGroupHeaderState } from './MenuGroupHeader.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuGroupHeader/renderMenuGroupHeader.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuGroupHeader/renderMenuGroupHeader.tsx new file mode 100644 index 00000000000000..2418b70ec52496 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuGroupHeader/renderMenuGroupHeader.tsx @@ -0,0 +1,3 @@ +import { renderMenuGroupHeader_unstable } from '@fluentui/react-menu'; + +export const renderMenuGroupHeader = renderMenuGroupHeader_unstable; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuGroupHeader/useMenuGroupHeader.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuGroupHeader/useMenuGroupHeader.ts new file mode 100644 index 00000000000000..81e86af8d88b2e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuGroupHeader/useMenuGroupHeader.ts @@ -0,0 +1,15 @@ +'use client'; + +import type * as React from 'react'; +import { useMenuGroupHeader_unstable } from '@fluentui/react-menu'; +import type { MenuGroupHeaderProps, MenuGroupHeaderState } from './MenuGroupHeader.types'; + +/** + * Returns the state for a MenuGroupHeader. + * + * Delegates to v9's `useMenuGroupHeader_unstable`, which reads the parent + * MenuGroup's `headerId` from context and stamps it on the rendered root. + */ +export const useMenuGroupHeader = (props: MenuGroupHeaderProps, ref: React.Ref): MenuGroupHeaderState => { + return useMenuGroupHeader_unstable(props, ref); +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItem/MenuItem.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItem/MenuItem.tsx new file mode 100644 index 00000000000000..131dd40f6c192f --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItem/MenuItem.tsx @@ -0,0 +1,24 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { ARIAButtonElement } from '@fluentui/react-aria'; +import { useMenuItem } from './useMenuItem'; +import { renderMenuItem } from './renderMenuItem'; +import type { MenuItemProps } from './MenuItem.types'; + +/** + * Headless MenuItem component. + * + * Renders a `role="menuitem"` element with ARIA-button semantics, character + * search wiring, and click-to-dismiss handling. Submenu indicator is opt-in + * via the `submenuIndicator` slot — no default icon is injected. + */ +export const MenuItem: ForwardRefComponent = React.forwardRef, MenuItemProps>( + (props, ref) => { + const state = useMenuItem(props, ref); + return renderMenuItem(state); + }, +) as ForwardRefComponent; + +MenuItem.displayName = 'MenuItem'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItem/MenuItem.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItem/MenuItem.types.ts new file mode 100644 index 00000000000000..55ed06231fbb9d --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItem/MenuItem.types.ts @@ -0,0 +1 @@ +export type { MenuItemProps, MenuItemSlots, MenuItemState } from '@fluentui/react-menu'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItem/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItem/index.ts new file mode 100644 index 00000000000000..e0718ffd3ae326 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItem/index.ts @@ -0,0 +1,4 @@ +export { MenuItem } from './MenuItem'; +export { useMenuItem } from './useMenuItem'; +export { renderMenuItem } from './renderMenuItem'; +export type { MenuItemProps, MenuItemSlots, MenuItemState } from './MenuItem.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItem/renderMenuItem.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItem/renderMenuItem.tsx new file mode 100644 index 00000000000000..4675e4e65ccdb9 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItem/renderMenuItem.tsx @@ -0,0 +1,3 @@ +import { renderMenuItem_unstable } from '@fluentui/react-menu'; + +export const renderMenuItem = renderMenuItem_unstable; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItem/useMenuItem.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItem/useMenuItem.ts new file mode 100644 index 00000000000000..dee27afaca9591 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItem/useMenuItem.ts @@ -0,0 +1,19 @@ +'use client'; + +import type * as React from 'react'; +import { useMenuItemBase_unstable } from '@fluentui/react-menu'; +import type { ARIAButtonElement } from '@fluentui/react-aria'; +import type { MenuItemProps, MenuItemState } from './MenuItem.types'; + +/** + * Returns the state for a MenuItem. + * + * Delegates to v9's `useMenuItemBase_unstable`. The base hook applies + * `role="menuitem"`, ARIA-button enhancement, character-search wiring, and + * the click handler that closes the parent Menu. It does NOT inject the + * default chevron icon (that lives in the styled `useMenuItem_unstable`), + * so headless consumers can supply their own submenu indicator slot. + */ +export const useMenuItem = (props: MenuItemProps, ref: React.Ref>): MenuItemState => { + return useMenuItemBase_unstable(props, ref); +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemCheckbox/MenuItemCheckbox.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemCheckbox/MenuItemCheckbox.tsx new file mode 100644 index 00000000000000..4caaf672ae7584 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemCheckbox/MenuItemCheckbox.tsx @@ -0,0 +1,24 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { ARIAButtonElement } from '@fluentui/react-aria'; +import { useMenuItemCheckbox } from './useMenuItemCheckbox'; +import { renderMenuItemCheckbox } from './renderMenuItemCheckbox'; +import type { MenuItemCheckboxProps } from './MenuItemCheckbox.types'; + +/** + * Headless MenuItemCheckbox component. + * + * Renders a multi-select item with `role="menuitemcheckbox"` and ARIA + * `aria-checked` driven by the parent MenuList's controlled `checkedValues`. + */ +export const MenuItemCheckbox: ForwardRefComponent = React.forwardRef< + ARIAButtonElement<'div'>, + MenuItemCheckboxProps +>((props, ref) => { + const state = useMenuItemCheckbox(props, ref); + return renderMenuItemCheckbox(state); +}) as ForwardRefComponent; + +MenuItemCheckbox.displayName = 'MenuItemCheckbox'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemCheckbox/MenuItemCheckbox.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemCheckbox/MenuItemCheckbox.types.ts new file mode 100644 index 00000000000000..68fd4a2b5b571a --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemCheckbox/MenuItemCheckbox.types.ts @@ -0,0 +1 @@ +export type { MenuItemCheckboxProps, MenuItemCheckboxState } from '@fluentui/react-menu'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemCheckbox/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemCheckbox/index.ts new file mode 100644 index 00000000000000..478f0000cfe32a --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemCheckbox/index.ts @@ -0,0 +1,4 @@ +export { MenuItemCheckbox } from './MenuItemCheckbox'; +export { useMenuItemCheckbox } from './useMenuItemCheckbox'; +export { renderMenuItemCheckbox } from './renderMenuItemCheckbox'; +export type { MenuItemCheckboxProps, MenuItemCheckboxState } from './MenuItemCheckbox.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemCheckbox/renderMenuItemCheckbox.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemCheckbox/renderMenuItemCheckbox.tsx new file mode 100644 index 00000000000000..b3f30768cc2e87 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemCheckbox/renderMenuItemCheckbox.tsx @@ -0,0 +1,3 @@ +import { renderMenuItemCheckbox_unstable } from '@fluentui/react-menu'; + +export const renderMenuItemCheckbox = renderMenuItemCheckbox_unstable; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemCheckbox/useMenuItemCheckbox.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemCheckbox/useMenuItemCheckbox.ts new file mode 100644 index 00000000000000..a71a04f2d51030 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemCheckbox/useMenuItemCheckbox.ts @@ -0,0 +1,22 @@ +'use client'; + +import type * as React from 'react'; +import { useMenuItemCheckboxBase_unstable } from '@fluentui/react-menu'; +import type { ARIAButtonElement } from '@fluentui/react-aria'; +import type { MenuItemCheckboxProps, MenuItemCheckboxState } from './MenuItemCheckbox.types'; + +/** + * Returns the state for a MenuItemCheckbox. + * + * Delegates to v9's `useMenuItemCheckboxBase_unstable`. The base hook applies + * `role="menuitemcheckbox"`, wires `aria-checked` from MenuList's + * `checkedValues`, and toggles selection on click. It does NOT inject the + * default `Checkmark16Filled` icon — headless consumers supply their own + * checkmark via the `checkmark` slot. + */ +export const useMenuItemCheckbox = ( + props: MenuItemCheckboxProps, + ref: React.Ref>, +): MenuItemCheckboxState => { + return useMenuItemCheckboxBase_unstable(props, ref); +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemLink/MenuItemLink.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemLink/MenuItemLink.tsx new file mode 100644 index 00000000000000..4ba4e1f38b2ce4 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemLink/MenuItemLink.tsx @@ -0,0 +1,23 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { useMenuItemLink } from './useMenuItemLink'; +import { renderMenuItemLink } from './renderMenuItemLink'; +import type { MenuItemLinkProps } from './MenuItemLink.types'; + +/** + * Headless MenuItemLink component. + * + * Renders an anchor (``) with `role="menuitem"` for navigational menu + * items. Click follows the supplied `href`. + */ +export const MenuItemLink: ForwardRefComponent = React.forwardRef< + HTMLAnchorElement, + MenuItemLinkProps +>((props, ref) => { + const state = useMenuItemLink(props, ref); + return renderMenuItemLink(state); +}) as ForwardRefComponent; + +MenuItemLink.displayName = 'MenuItemLink'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemLink/MenuItemLink.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemLink/MenuItemLink.types.ts new file mode 100644 index 00000000000000..37a9f121bb4524 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemLink/MenuItemLink.types.ts @@ -0,0 +1 @@ +export type { MenuItemLinkProps, MenuItemLinkSlots, MenuItemLinkState } from '@fluentui/react-menu'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemLink/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemLink/index.ts new file mode 100644 index 00000000000000..fed47417f06539 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemLink/index.ts @@ -0,0 +1,4 @@ +export { MenuItemLink } from './MenuItemLink'; +export { useMenuItemLink } from './useMenuItemLink'; +export { renderMenuItemLink } from './renderMenuItemLink'; +export type { MenuItemLinkProps, MenuItemLinkSlots, MenuItemLinkState } from './MenuItemLink.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemLink/renderMenuItemLink.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemLink/renderMenuItemLink.tsx new file mode 100644 index 00000000000000..ac177b2988c48a --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemLink/renderMenuItemLink.tsx @@ -0,0 +1,3 @@ +import { renderMenuItemLink_unstable } from '@fluentui/react-menu'; + +export const renderMenuItemLink = renderMenuItemLink_unstable; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemLink/useMenuItemLink.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemLink/useMenuItemLink.ts new file mode 100644 index 00000000000000..ed94fd14be8853 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemLink/useMenuItemLink.ts @@ -0,0 +1,15 @@ +'use client'; + +import type * as React from 'react'; +import { useMenuItemLinkBase_unstable } from '@fluentui/react-menu'; +import type { MenuItemLinkProps, MenuItemLinkState } from './MenuItemLink.types'; + +/** + * Returns the state for a MenuItemLink. + * + * Delegates to v9's `useMenuItemLinkBase_unstable`. Renders an `` root + * with `role="menuitem"` and the supplied `href`. No default icon injection. + */ +export const useMenuItemLink = (props: MenuItemLinkProps, ref: React.Ref): MenuItemLinkState => { + return useMenuItemLinkBase_unstable(props, ref); +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemRadio/MenuItemRadio.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemRadio/MenuItemRadio.tsx new file mode 100644 index 00000000000000..1765dbb1e81e04 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemRadio/MenuItemRadio.tsx @@ -0,0 +1,21 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { ARIAButtonElement } from '@fluentui/react-aria'; +import { useMenuItemRadio } from './useMenuItemRadio'; +import { renderMenuItemRadio } from './renderMenuItemRadio'; +import type { MenuItemRadioProps } from './MenuItemRadio.types'; + +/** + * Headless MenuItemRadio component. + */ +export const MenuItemRadio: ForwardRefComponent = React.forwardRef< + ARIAButtonElement<'div'>, + MenuItemRadioProps +>((props, ref) => { + const state = useMenuItemRadio(props, ref); + return renderMenuItemRadio(state); +}) as ForwardRefComponent; + +MenuItemRadio.displayName = 'MenuItemRadio'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemRadio/MenuItemRadio.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemRadio/MenuItemRadio.types.ts new file mode 100644 index 00000000000000..c3773613f1beef --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemRadio/MenuItemRadio.types.ts @@ -0,0 +1,4 @@ +export type { + MenuItemRadioBaseProps as MenuItemRadioProps, + MenuItemRadioBaseState as MenuItemRadioState, +} from '@fluentui/react-menu'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemRadio/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemRadio/index.ts new file mode 100644 index 00000000000000..36449d583ad730 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemRadio/index.ts @@ -0,0 +1,4 @@ +export { MenuItemRadio } from './MenuItemRadio'; +export { useMenuItemRadio } from './useMenuItemRadio'; +export { renderMenuItemRadio } from './renderMenuItemRadio'; +export type { MenuItemRadioProps, MenuItemRadioState } from './MenuItemRadio.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemRadio/renderMenuItemRadio.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemRadio/renderMenuItemRadio.tsx new file mode 100644 index 00000000000000..e4442ecd5eb989 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemRadio/renderMenuItemRadio.tsx @@ -0,0 +1,3 @@ +import { renderMenuItemRadio_unstable } from '@fluentui/react-menu'; + +export const renderMenuItemRadio = renderMenuItemRadio_unstable; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemRadio/useMenuItemRadio.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemRadio/useMenuItemRadio.ts new file mode 100644 index 00000000000000..12af12a58dbcfb --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemRadio/useMenuItemRadio.ts @@ -0,0 +1,20 @@ +'use client'; + +import type * as React from 'react'; +import { useMenuItemRadioBase_unstable } from '@fluentui/react-menu'; +import type { ARIAButtonElement } from '@fluentui/react-aria'; +import type { MenuItemRadioProps, MenuItemRadioState } from './MenuItemRadio.types'; + +/** + * Returns the state for a MenuItemRadio. + * + * Delegates to v9's `useMenuItemRadioBase_unstable`. The base hook applies + * `role="menuitemradio"`, enforces single-selection per `name` group via the + * parent MenuList's `checkedValues`, and skips the default checkmark icon. + */ +export const useMenuItemRadio = ( + props: MenuItemRadioProps, + ref: React.Ref>, +): MenuItemRadioState => { + return useMenuItemRadioBase_unstable(props, ref); +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemSwitch/MenuItemSwitch.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemSwitch/MenuItemSwitch.tsx new file mode 100644 index 00000000000000..5ed4b247be41bf --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemSwitch/MenuItemSwitch.tsx @@ -0,0 +1,20 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { useMenuItemSwitch } from './useMenuItemSwitch'; +import { renderMenuItemSwitch } from './renderMenuItemSwitch'; +import type { MenuItemSwitchProps } from './MenuItemSwitch.types'; + +/** + * Headless MenuItemSwitch component. + * + * Renders a toggle-style menu item with a `switchIndicator` slot. Selection + * state is driven by the parent MenuList's controlled `checkedValues`. + */ +export const MenuItemSwitch: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useMenuItemSwitch(props, ref); + return renderMenuItemSwitch(state); +}); + +MenuItemSwitch.displayName = 'MenuItemSwitch'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemSwitch/MenuItemSwitch.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemSwitch/MenuItemSwitch.types.ts new file mode 100644 index 00000000000000..cd7532090c0bf6 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemSwitch/MenuItemSwitch.types.ts @@ -0,0 +1 @@ +export type { MenuItemSwitchProps, MenuItemSwitchSlots, MenuItemSwitchState } from '@fluentui/react-menu'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemSwitch/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemSwitch/index.ts new file mode 100644 index 00000000000000..278937b65d6028 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemSwitch/index.ts @@ -0,0 +1,4 @@ +export { MenuItemSwitch } from './MenuItemSwitch'; +export { useMenuItemSwitch } from './useMenuItemSwitch'; +export { renderMenuItemSwitch } from './renderMenuItemSwitch'; +export type { MenuItemSwitchProps, MenuItemSwitchSlots, MenuItemSwitchState } from './MenuItemSwitch.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemSwitch/renderMenuItemSwitch.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemSwitch/renderMenuItemSwitch.tsx new file mode 100644 index 00000000000000..489d0b410e118a --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemSwitch/renderMenuItemSwitch.tsx @@ -0,0 +1,3 @@ +import { renderMenuItemSwitch_unstable } from '@fluentui/react-menu'; + +export const renderMenuItemSwitch = renderMenuItemSwitch_unstable; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemSwitch/useMenuItemSwitch.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemSwitch/useMenuItemSwitch.ts new file mode 100644 index 00000000000000..cdcf81bc40a851 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuItemSwitch/useMenuItemSwitch.ts @@ -0,0 +1,16 @@ +'use client'; + +import type * as React from 'react'; +import { useMenuItemSwitchBase_unstable } from '@fluentui/react-menu'; +import type { MenuItemSwitchProps, MenuItemSwitchState } from './MenuItemSwitch.types'; + +/** + * Returns the state for a MenuItemSwitch. + * + * Delegates to v9's `useMenuItemSwitchBase_unstable`. Renders a toggle-style + * item with a `switchIndicator` slot. The base hook does not inject the + * default `CircleFilled` indicator — headless consumers supply their own. + */ +export const useMenuItemSwitch = (props: MenuItemSwitchProps, ref: React.Ref): MenuItemSwitchState => { + return useMenuItemSwitchBase_unstable(props, ref); +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuList/MenuList.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuList/MenuList.tsx new file mode 100644 index 00000000000000..5d98aac81c7e87 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuList/MenuList.tsx @@ -0,0 +1,19 @@ +'use client'; + +import * as React from 'react'; +import { useMenuListContextValues_unstable } from '@fluentui/react-menu'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { useMenuList } from './useMenuList'; +import { renderMenuList } from './renderMenuList'; +import type { MenuListProps } from './MenuList.types'; + +/** + * Headless MenuList component. + */ +export const MenuList: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useMenuList(props, ref); + const contextValues = useMenuListContextValues_unstable(state); + return renderMenuList(state, contextValues); +}); + +MenuList.displayName = 'MenuList'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuList/MenuList.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuList/MenuList.types.ts new file mode 100644 index 00000000000000..73b5582435a1df --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuList/MenuList.types.ts @@ -0,0 +1,22 @@ +import type { + MenuListProps as MenuListBaseProps, + MenuListSlots, + MenuListState as MenuListBaseState, + MenuCheckedValueChangeData, + MenuCheckedValueChangeEvent, +} from '@fluentui/react-menu'; + +export type MenuListProps = MenuListBaseProps; + +export type MenuListState = MenuListBaseState & { + root: { + /** + * Forward-compat hint for the WICG `focusgroup` HTML attribute. Currently + * a no-op in shipping browsers; arrow-key navigation is provided by + * `useArrowNavigation`. + */ + focusgroup?: string; + }; +}; + +export type { MenuListSlots, MenuCheckedValueChangeData, MenuCheckedValueChangeEvent }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuList/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuList/index.ts new file mode 100644 index 00000000000000..d001a86ace96bf --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuList/index.ts @@ -0,0 +1,10 @@ +export { MenuList } from './MenuList'; +export { useMenuList } from './useMenuList'; +export { renderMenuList } from './renderMenuList'; +export type { + MenuListProps, + MenuListSlots, + MenuListState, + MenuCheckedValueChangeData, + MenuCheckedValueChangeEvent, +} from './MenuList.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuList/renderMenuList.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuList/renderMenuList.tsx new file mode 100644 index 00000000000000..cd77bdb03978ac --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuList/renderMenuList.tsx @@ -0,0 +1,3 @@ +import { renderMenuList_unstable } from '@fluentui/react-menu'; + +export const renderMenuList = renderMenuList_unstable; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuList/useArrowNavigation.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuList/useArrowNavigation.test.tsx new file mode 100644 index 00000000000000..c62dfb19587e73 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuList/useArrowNavigation.test.tsx @@ -0,0 +1,85 @@ +import * as React from 'react'; +import { render, fireEvent, act } from '@testing-library/react'; +import { useArrowNavigation } from './useArrowNavigation'; + +const Harness = ({ disabled }: { disabled?: number[] } = { disabled: [] }) => { + const containerRef = React.useRef(null); + const { onKeyDown } = useArrowNavigation(containerRef, { + itemSelector: '[role="menuitem"]', + circular: true, + }); + return ( +
+ {[0, 1, 2, 3].map(i => ( +
+ Item {i} +
+ ))} +
+ ); +}; + +describe('useArrowNavigation', () => { + const focused = (container: HTMLElement) => container.ownerDocument.activeElement?.getAttribute('data-testid'); + + it('ArrowDown moves focus from item 0 to item 1', () => { + const { getByTestId, container } = render(); + act(() => getByTestId('item-0').focus()); + fireEvent.keyDown(getByTestId('item-0'), { key: 'ArrowDown' }); + expect(focused(container)).toBe('item-1'); + }); + + it('ArrowDown wraps from last item to first (circular)', () => { + const { getByTestId, container } = render(); + act(() => getByTestId('item-3').focus()); + fireEvent.keyDown(getByTestId('item-3'), { key: 'ArrowDown' }); + expect(focused(container)).toBe('item-0'); + }); + + it('ArrowUp moves focus from item 2 to item 1', () => { + const { getByTestId, container } = render(); + act(() => getByTestId('item-2').focus()); + fireEvent.keyDown(getByTestId('item-2'), { key: 'ArrowUp' }); + expect(focused(container)).toBe('item-1'); + }); + + it('ArrowUp wraps from first item to last (circular)', () => { + const { getByTestId, container } = render(); + act(() => getByTestId('item-0').focus()); + fireEvent.keyDown(getByTestId('item-0'), { key: 'ArrowUp' }); + expect(focused(container)).toBe('item-3'); + }); + + it('Home jumps to the first item', () => { + const { getByTestId, container } = render(); + act(() => getByTestId('item-2').focus()); + fireEvent.keyDown(getByTestId('item-2'), { key: 'Home' }); + expect(focused(container)).toBe('item-0'); + }); + + it('End jumps to the last item', () => { + const { getByTestId, container } = render(); + act(() => getByTestId('item-0').focus()); + fireEvent.keyDown(getByTestId('item-0'), { key: 'End' }); + expect(focused(container)).toBe('item-3'); + }); + + it('ArrowDown from no-active-element starts at item 0', () => { + const { getByTestId, container } = render(); + fireEvent.keyDown(getByTestId('item-0'), { key: 'ArrowDown' }); + expect(focused(container)).toBe('item-0'); + }); + + it('ignores unrelated keys', () => { + const { getByTestId, container } = render(); + act(() => getByTestId('item-1').focus()); + fireEvent.keyDown(getByTestId('item-1'), { key: 'a' }); + expect(focused(container)).toBe('item-1'); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuList/useArrowNavigation.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuList/useArrowNavigation.ts new file mode 100644 index 00000000000000..9ca4e1c37bf56b --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuList/useArrowNavigation.ts @@ -0,0 +1,81 @@ +'use client'; + +import type * as React from 'react'; +import { isHTMLElement, useEventCallback } from '@fluentui/react-utilities'; +import { ArrowDown, ArrowUp, Home, End } from '@fluentui/keyboard-keys'; + +export type ArrowNavigationOptions = { + /** + * CSS selector for focusable items inside the container, e.g. + * `'[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'`. + */ + itemSelector: string; + + /** + * When true, ArrowDown past the last item wraps to the first, and ArrowUp + * past the first item wraps to the last. + * @default true + */ + circular?: boolean; +}; + +/** + * Roving-tabindex-free arrow-key navigation for `role="menu"`-style + * containers. Handles ArrowDown / ArrowUp / Home / End and calls `.focus()` + * on the next item; siblings keep their default tab-order so the consumer + * does not need to manage `tabIndex` per item. + * + */ +export const useArrowNavigation = ( + containerRef: React.RefObject, + options: ArrowNavigationOptions, +): { onKeyDown: (event: React.KeyboardEvent) => void } => { + const { itemSelector, circular = true } = options; + + const onKeyDown = useEventCallback((event: React.KeyboardEvent) => { + const { key } = event; + if (key !== ArrowDown && key !== ArrowUp && key !== Home && key !== End) { + return; + } + + const container = containerRef.current; + if (!container) { + return; + } + + const items = Array.from(container.querySelectorAll(itemSelector)); + if (items.length === 0) { + return; + } + + const active = container.ownerDocument.activeElement; + const currentIndex = isHTMLElement(active) ? items.indexOf(active) : -1; + + let nextIndex: number; + if (key === Home) { + nextIndex = 0; + } else if (key === End) { + nextIndex = items.length - 1; + } else if (key === ArrowDown) { + nextIndex = + currentIndex === -1 + ? 0 + : circular + ? (currentIndex + 1) % items.length + : Math.min(currentIndex + 1, items.length - 1); + } else { + // ArrowUp + nextIndex = + currentIndex === -1 + ? items.length - 1 + : circular + ? (currentIndex - 1 + items.length) % items.length + : Math.max(currentIndex - 1, 0); + } + + items[nextIndex]?.focus(); + event.preventDefault(); + }); + + return { onKeyDown }; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuList/useMenuList.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuList/useMenuList.ts new file mode 100644 index 00000000000000..e092453bc6949d --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuList/useMenuList.ts @@ -0,0 +1,41 @@ +'use client'; + +import * as React from 'react'; +import { useMenuListBase_unstable } from '@fluentui/react-menu'; +import { useEventCallback, useMergedRefs } from '@fluentui/react-utilities'; +import { useArrowNavigation } from './useArrowNavigation'; +import type { MenuListProps, MenuListState } from './MenuList.types'; + +const MENU_ITEM_SELECTOR = ['[role="menuitem"]', '[role="menuitemcheckbox"]', '[role="menuitemradio"]'].join(', '); + +/** + * Returns the state for a MenuList. + * + * Builds on v9's `useMenuListBase_unstable` and layers on: + * - **Arrow-key navigation** (ArrowDown / ArrowUp / Home / End) via a + * tabster-free roving-focus implementation. + * - **`focusgroup` attribute** as a forward-compat hint for the WICG draft; + * it is a no-op in shipping browsers. + * + * Type-ahead (`setFocusByFirstCharacter`) is already wired by the base + * hook through `useMenuItemBase_unstable` → `useCharacterSearch`. + */ +export const useMenuList = (props: MenuListProps, ref: React.Ref): MenuListState => { + const baseState = useMenuListBase_unstable(props, ref) as MenuListState; + const containerRef = React.useRef(null); + const { onKeyDown: onArrowKeyDown } = useArrowNavigation(containerRef, { + itemSelector: MENU_ITEM_SELECTOR, + circular: true, + }); + + const { onKeyDown: baseOnKeyDown } = baseState.root; + baseState.root.onKeyDown = useEventCallback((event: React.KeyboardEvent) => { + onArrowKeyDown(event); + baseOnKeyDown?.(event); + }); + + baseState.root.ref = useMergedRefs(baseState.root.ref, containerRef) as React.Ref; + baseState.root.focusgroup ??= 'block wrap'; + + return baseState; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuPopover/MenuPopover.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuPopover/MenuPopover.tsx new file mode 100644 index 00000000000000..8f07a8261e4484 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuPopover/MenuPopover.tsx @@ -0,0 +1,14 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { useMenuPopover } from './useMenuPopover'; +import { renderMenuPopover } from './renderMenuPopover'; +import type { MenuPopoverProps } from './MenuPopover.types'; + +export const MenuPopover: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useMenuPopover(props, ref); + return renderMenuPopover(state); +}); + +MenuPopover.displayName = 'MenuPopover'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuPopover/MenuPopover.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuPopover/MenuPopover.types.ts new file mode 100644 index 00000000000000..fbc077a423e921 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuPopover/MenuPopover.types.ts @@ -0,0 +1 @@ +export type { MenuPopoverProps, MenuPopoverSlots, MenuPopoverState } from '@fluentui/react-menu'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuPopover/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuPopover/index.ts new file mode 100644 index 00000000000000..2b2e7945df67b0 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuPopover/index.ts @@ -0,0 +1,4 @@ +export { MenuPopover } from './MenuPopover'; +export { useMenuPopover } from './useMenuPopover'; +export { renderMenuPopover } from './renderMenuPopover'; +export type { MenuPopoverProps, MenuPopoverSlots, MenuPopoverState } from './MenuPopover.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuPopover/renderMenuPopover.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuPopover/renderMenuPopover.tsx new file mode 100644 index 00000000000000..f6be0911b7795f --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuPopover/renderMenuPopover.tsx @@ -0,0 +1,17 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource @fluentui/react-jsx-runtime */ + +import { assertSlots } from '@fluentui/react-utilities'; +import type { JSXElement } from '@fluentui/react-utilities'; +import type { MenuPopoverSlots, MenuPopoverState } from './MenuPopover.types'; + +export const renderMenuPopover = (state: MenuPopoverState): JSXElement => { + assertSlots(state); + + return ( + <> + + {state.safeZone} + + ); +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuPopover/useMenuPopover.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuPopover/useMenuPopover.ts new file mode 100644 index 00000000000000..96d79d1fe47632 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuPopover/useMenuPopover.ts @@ -0,0 +1,56 @@ +'use client'; + +import * as React from 'react'; +import { useMenuPopoverBase_unstable } from '@fluentui/react-menu'; +import { useMenuContext } from '../menuContext'; +import type { MenuPopoverProps, MenuPopoverState } from './MenuPopover.types'; + +const SUPPORTS_POPOVER_OPEN_SELECTOR = + typeof CSS !== 'undefined' && typeof CSS.supports === 'function' && CSS.supports('selector(:popover-open)'); + +type ToggleEvent = Event & { newState?: 'open' | 'closed' }; + +export const useMenuPopover = (props: MenuPopoverProps, ref: React.Ref): MenuPopoverState => { + const baseState = useMenuPopoverBase_unstable(props, ref); + + const state: MenuPopoverState = { + ...baseState, + root: { ...baseState.root, popover: 'auto' } as MenuPopoverState['root'], + }; + + const open = useMenuContext(ctx => ctx.open); + const setOpen = useMenuContext(ctx => ctx.setOpen); + const menuPopoverRef = useMenuContext(ctx => ctx.menuPopoverRef); + + React.useEffect(() => { + const surface = menuPopoverRef.current as HTMLElement | null; + + if (!surface || !open) { + return; + } + + if (typeof surface.showPopover !== 'function') { + return; + } + + if (!(SUPPORTS_POPOVER_OPEN_SELECTOR && surface.matches(':popover-open'))) { + surface.showPopover(); + } + + const onSurfaceToggle = (event: Event) => { + const next = (event as ToggleEvent).newState; + if (next === 'closed' && open) { + setOpen(event as unknown as MouseEvent, { + open: false, + type: 'clickOutside', + event: event as unknown as MouseEvent, + }); + } + }; + + surface.addEventListener('toggle', onSurfaceToggle); + return () => surface.removeEventListener('toggle', onSurfaceToggle); + }, [menuPopoverRef, open, setOpen]); + + return state; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuSplitGroup/MenuSplitGroup.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuSplitGroup/MenuSplitGroup.tsx new file mode 100644 index 00000000000000..a25ea11d8d078e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuSplitGroup/MenuSplitGroup.tsx @@ -0,0 +1,14 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { useMenuSplitGroup } from './useMenuSplitGroup'; +import { renderMenuSplitGroup } from './renderMenuSplitGroup'; +import type { MenuSplitGroupProps } from './MenuSplitGroup.types'; + +export const MenuSplitGroup: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useMenuSplitGroup(props, ref); + return renderMenuSplitGroup(state); +}); + +MenuSplitGroup.displayName = 'MenuSplitGroup'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuSplitGroup/MenuSplitGroup.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuSplitGroup/MenuSplitGroup.types.ts new file mode 100644 index 00000000000000..b4c57d26fc861b --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuSplitGroup/MenuSplitGroup.types.ts @@ -0,0 +1 @@ +export type { MenuSplitGroupProps, MenuSplitGroupSlots, MenuSplitGroupState } from '@fluentui/react-menu'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuSplitGroup/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuSplitGroup/index.ts new file mode 100644 index 00000000000000..6738241b599cd2 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuSplitGroup/index.ts @@ -0,0 +1,4 @@ +export { MenuSplitGroup } from './MenuSplitGroup'; +export { useMenuSplitGroup } from './useMenuSplitGroup'; +export { renderMenuSplitGroup } from './renderMenuSplitGroup'; +export type { MenuSplitGroupProps, MenuSplitGroupSlots, MenuSplitGroupState } from './MenuSplitGroup.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuSplitGroup/renderMenuSplitGroup.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuSplitGroup/renderMenuSplitGroup.tsx new file mode 100644 index 00000000000000..2885509fa0c5c4 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuSplitGroup/renderMenuSplitGroup.tsx @@ -0,0 +1,3 @@ +import { renderMenuSplitGroup_unstable } from '@fluentui/react-menu'; + +export const renderMenuSplitGroup = renderMenuSplitGroup_unstable; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuSplitGroup/useMenuSplitGroup.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuSplitGroup/useMenuSplitGroup.ts new file mode 100644 index 00000000000000..0d1f0520227b61 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuSplitGroup/useMenuSplitGroup.ts @@ -0,0 +1,9 @@ +'use client'; + +import type * as React from 'react'; +import { useMenuSplitGroup_unstable } from '@fluentui/react-menu'; +import type { MenuSplitGroupProps, MenuSplitGroupState } from './MenuSplitGroup.types'; + +export const useMenuSplitGroup = (props: MenuSplitGroupProps, ref: React.Ref): MenuSplitGroupState => { + return useMenuSplitGroup_unstable(props, ref); +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuTrigger/MenuTrigger.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuTrigger/MenuTrigger.tsx new file mode 100644 index 00000000000000..2066b698b80b1c --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuTrigger/MenuTrigger.tsx @@ -0,0 +1,19 @@ +'use client'; + +import type * as React from 'react'; +import { useMenuTrigger } from './useMenuTrigger'; +import { renderMenuTrigger } from './renderMenuTrigger'; +import type { MenuTriggerProps } from './MenuTrigger.types'; + +/** + * Headless MenuTrigger component. + * + * Clones its single child and applies the menu trigger props (ARIA, event + * handlers, ref merging) needed to drive the parent Menu's open state. + */ +export const MenuTrigger: React.FC = props => { + const state = useMenuTrigger(props); + return renderMenuTrigger(state); +}; + +MenuTrigger.displayName = 'MenuTrigger'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuTrigger/MenuTrigger.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuTrigger/MenuTrigger.types.ts new file mode 100644 index 00000000000000..8bbd77d2b226bd --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuTrigger/MenuTrigger.types.ts @@ -0,0 +1 @@ +export type { MenuTriggerProps, MenuTriggerState, MenuTriggerChildProps } from '@fluentui/react-menu'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuTrigger/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuTrigger/index.ts new file mode 100644 index 00000000000000..b239966441fb22 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuTrigger/index.ts @@ -0,0 +1,4 @@ +export { MenuTrigger } from './MenuTrigger'; +export { useMenuTrigger } from './useMenuTrigger'; +export { renderMenuTrigger } from './renderMenuTrigger'; +export type { MenuTriggerProps, MenuTriggerState, MenuTriggerChildProps } from './MenuTrigger.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuTrigger/renderMenuTrigger.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuTrigger/renderMenuTrigger.tsx new file mode 100644 index 00000000000000..38efc75b69cd08 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuTrigger/renderMenuTrigger.tsx @@ -0,0 +1,8 @@ +import { renderMenuTrigger_unstable } from '@fluentui/react-menu'; + +/** + * Renders the MenuTrigger by reusing v9's render function, which clones the + * single child element with the trigger's props and wraps it in + * `MenuTriggerContextProvider`. + */ +export const renderMenuTrigger = renderMenuTrigger_unstable; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuTrigger/useMenuTrigger.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuTrigger/useMenuTrigger.ts new file mode 100644 index 00000000000000..b651fbb44e6a95 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/MenuTrigger/useMenuTrigger.ts @@ -0,0 +1,69 @@ +'use client'; + +import * as React from 'react'; +import { useMenuTriggerBase_unstable } from '@fluentui/react-menu'; +import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; +import { useMenuContext } from '../menuContext'; +import type { MenuTriggerProps, MenuTriggerState } from './MenuTrigger.types'; + +/** + * Returns the state for a MenuTrigger. + * + * Delegates to v9's `useMenuTriggerBase_unstable` — the tabster-free variant + * of `useMenuTrigger_unstable`. The base hook leaves the + * "submenu-already-open arrow-key focuses first item" path as a no-op when + * no `focusFirst` callback is supplied; the headless package does not pull + * `@fluentui/react-tabster` for this, so consumers who need that behavior + * wire their own focus discovery. + * + * **Right-click / openOnContext**: when the parent Menu opens on context + * menu, the base hook's `onContextMenu` handler calls `setOpen(true)` + * synchronously. That doesn't cooperate well with native `popover="auto"` — + * the trailing `pointerup` of the right-click sequence is treated by the + * browser as an outside click and immediately dismisses the just-opened + * menu. To match the headless Popover's fix, we override the cloned child's + * `onContextMenu` to defer `setOpen(true)` until after the trailing + * `pointerup`, by which point the popover is already in the top layer and + * the pointerup is no longer "outside". + */ +export const useMenuTrigger = (props: MenuTriggerProps): MenuTriggerState => { + const baseState = useMenuTriggerBase_unstable(props); + const openOnContext = useMenuContext(ctx => ctx.openOnContext); + const setOpen = useMenuContext(ctx => ctx.setOpen); + const { targetDocument } = useFluent(); + + if (!openOnContext || !baseState.children || !targetDocument) { + return baseState; + } + + const child = baseState.children; + + const onContextMenuDeferred = (event: React.MouseEvent) => { + event.preventDefault(); + const nativeEvent = event.nativeEvent; + targetDocument.addEventListener( + 'pointerup', + () => { + // Pass the original contextmenu native event through so that + // `useMenuOpenState`'s `trySetOpen` recognises `e.type === 'contextmenu'` + // and sets `contextTarget` for positioning. + setOpen(nativeEvent as unknown as React.MouseEvent, { + open: true, + type: 'menuTriggerContextMenu', + event: nativeEvent as unknown as React.MouseEvent, + }); + }, + { once: true, capture: true }, + ); + }; + + return { + ...baseState, + children: React.cloneElement( + child as React.ReactElement<{ onContextMenu?: React.MouseEventHandler }>, + { + onContextMenu: onContextMenuDeferred, + }, + ), + }; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/index.ts new file mode 100644 index 00000000000000..a6480300e93689 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/index.ts @@ -0,0 +1,49 @@ +export { Menu } from './Menu'; +export { renderMenu } from './renderMenu'; +export { useMenu } from './useMenu'; +export { useMenuContextValues } from './useMenuContextValues'; +export { useMenuContext, MenuProvider } from './menuContext'; +export type { + MenuProps, + MenuState, + MenuContextValues, + MenuContextValue, + MenuOpenChangeData, + MenuOpenEvent, +} from './Menu.types'; + +export { MenuTrigger, useMenuTrigger, renderMenuTrigger } from './MenuTrigger'; +export type { MenuTriggerProps, MenuTriggerState, MenuTriggerChildProps } from './MenuTrigger'; + +export { MenuList, useMenuList, renderMenuList } from './MenuList'; +export type { MenuListProps, MenuListState, MenuListSlots } from './MenuList'; + +export { MenuItem, useMenuItem, renderMenuItem } from './MenuItem'; +export type { MenuItemProps, MenuItemState, MenuItemSlots } from './MenuItem'; + +export { MenuItemCheckbox, useMenuItemCheckbox, renderMenuItemCheckbox } from './MenuItemCheckbox'; +export type { MenuItemCheckboxProps, MenuItemCheckboxState } from './MenuItemCheckbox'; + +export { MenuItemRadio, useMenuItemRadio, renderMenuItemRadio } from './MenuItemRadio'; +export type { MenuItemRadioProps, MenuItemRadioState } from './MenuItemRadio'; + +export { MenuItemLink, useMenuItemLink, renderMenuItemLink } from './MenuItemLink'; +export type { MenuItemLinkProps, MenuItemLinkSlots, MenuItemLinkState } from './MenuItemLink'; + +export { MenuItemSwitch, useMenuItemSwitch, renderMenuItemSwitch } from './MenuItemSwitch'; +export type { MenuItemSwitchProps, MenuItemSwitchSlots, MenuItemSwitchState } from './MenuItemSwitch'; + +export { MenuDivider, useMenuDivider, renderMenuDivider } from './MenuDivider'; +export type { MenuDividerProps, MenuDividerState, MenuDividerSlots } from './MenuDivider'; + +export { MenuPopover, useMenuPopover, renderMenuPopover } from './MenuPopover'; +export type { MenuPopoverProps, MenuPopoverState, MenuPopoverSlots } from './MenuPopover'; + +export { MenuGroup, useMenuGroup, useMenuGroupContextValues, renderMenuGroup } from './MenuGroup'; +export type { MenuGroupProps, MenuGroupSlots, MenuGroupState, MenuGroupContextValues } from './MenuGroup'; + +export { MenuGroupHeader, useMenuGroupHeader, renderMenuGroupHeader } from './MenuGroupHeader'; +export type { MenuGroupHeaderProps, MenuGroupHeaderSlots, MenuGroupHeaderState } from './MenuGroupHeader'; + +export { MenuSplitGroup, useMenuSplitGroup, renderMenuSplitGroup } from './MenuSplitGroup'; +export type { MenuSplitGroupProps, MenuSplitGroupSlots, MenuSplitGroupState } from './MenuSplitGroup'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/menuContext.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/menuContext.ts new file mode 100644 index 00000000000000..705dbbed4082bb --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/menuContext.ts @@ -0,0 +1,2 @@ +export { MenuProvider, useMenuContext_unstable as useMenuContext } from '@fluentui/react-menu'; +export type { MenuContextValue } from '@fluentui/react-menu'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/renderMenu.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Menu/renderMenu.tsx new file mode 100644 index 00000000000000..35fcc80036eb31 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/renderMenu.tsx @@ -0,0 +1,12 @@ +import * as React from 'react'; +import type { JSXElement } from '@fluentui/react-utilities'; +import { MenuProvider } from './menuContext'; +import type { MenuState, MenuContextValues } from './Menu.types'; + +export const renderMenu = (state: MenuState, contextValues: MenuContextValues): JSXElement => ( + + {state.menuTrigger} + {state.open ? (state.menuPopover as React.ReactNode) : null} + {state.safeZone} + +); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/useMenu.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/useMenu.ts new file mode 100644 index 00000000000000..c359cff1e7c9da --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/useMenu.ts @@ -0,0 +1,8 @@ +'use client'; + +import { useMenuBase_unstable } from '@fluentui/react-menu'; +import type { MenuProps, MenuState } from './Menu.types'; + +export const useMenu = (props: MenuProps): MenuState => { + return useMenuBase_unstable(props); +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Menu/useMenuContextValues.ts b/packages/react-components/react-headless-components-preview/library/src/components/Menu/useMenuContextValues.ts new file mode 100644 index 00000000000000..176b2b1c05bee8 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Menu/useMenuContextValues.ts @@ -0,0 +1,8 @@ +'use client'; + +import { useMenuContextValues_unstable } from '@fluentui/react-menu'; +import type { MenuState, MenuContextValues } from './Menu.types'; + +export const useMenuContextValues = (state: MenuState): MenuContextValues => { + return useMenuContextValues_unstable(state as Parameters[0]); +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.types.ts index 7e95aa5f70cb32..32a13b170b4898 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.types.ts @@ -62,6 +62,9 @@ export type PopoverProps = { /** * Positioning configuration. Accepts either a full `PositioningProps` * object or a shorthand string such as `'below'` / `'above-end'`. + * + * See the {@link https://react.fluentui.dev/?path=/docs/headless-concepts-positioning--docs Positioning concept} + * for the full list of options and live examples. */ positioning?: PositioningShorthand; diff --git a/packages/react-components/react-headless-components-preview/library/src/menu.ts b/packages/react-components/react-headless-components-preview/library/src/menu.ts new file mode 100644 index 00000000000000..e84ab16496cd21 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/menu.ts @@ -0,0 +1,87 @@ +export { + Menu, + renderMenu, + useMenu, + useMenuContext, + useMenuContextValues, + MenuTrigger, + useMenuTrigger, + renderMenuTrigger, + MenuList, + useMenuList, + renderMenuList, + MenuItem, + useMenuItem, + renderMenuItem, + MenuItemCheckbox, + useMenuItemCheckbox, + renderMenuItemCheckbox, + MenuItemRadio, + useMenuItemRadio, + renderMenuItemRadio, + MenuItemLink, + useMenuItemLink, + renderMenuItemLink, + MenuItemSwitch, + useMenuItemSwitch, + renderMenuItemSwitch, + MenuDivider, + useMenuDivider, + renderMenuDivider, + MenuPopover, + useMenuPopover, + renderMenuPopover, + MenuGroup, + useMenuGroup, + useMenuGroupContextValues, + renderMenuGroup, + MenuGroupHeader, + useMenuGroupHeader, + renderMenuGroupHeader, + MenuSplitGroup, + useMenuSplitGroup, + renderMenuSplitGroup, +} from './components/Menu'; +export type { + MenuProps, + MenuState, + MenuContextValues, + MenuContextValue, + MenuOpenChangeData, + MenuOpenEvent, + MenuTriggerProps, + MenuTriggerState, + MenuTriggerChildProps, + MenuListProps, + MenuListSlots, + MenuListState, + MenuItemProps, + MenuItemSlots, + MenuItemState, + MenuItemCheckboxProps, + MenuItemCheckboxState, + MenuItemRadioProps, + MenuItemRadioState, + MenuItemLinkProps, + MenuItemLinkSlots, + MenuItemLinkState, + MenuItemSwitchProps, + MenuItemSwitchSlots, + MenuItemSwitchState, + MenuDividerProps, + MenuDividerSlots, + MenuDividerState, + MenuPopoverProps, + MenuPopoverSlots, + MenuPopoverState, + MenuGroupProps, + MenuGroupSlots, + MenuGroupState, + MenuGroupContextValues, + MenuGroupHeaderProps, + MenuGroupHeaderSlots, + MenuGroupHeaderState, + MenuSplitGroupProps, + MenuSplitGroupSlots, + MenuSplitGroupState, +} from './components/Menu'; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuBestPractices.md b/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuBestPractices.md new file mode 100644 index 00000000000000..8d60b94c56f582 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuBestPractices.md @@ -0,0 +1,5 @@ +**Slots and roles**: `MenuTrigger` clones its single child and stamps `aria-haspopup="menu"` and `aria-expanded`. `MenuList` renders `role="menu"` and links `aria-labelledby` back to the trigger. Each `MenuItem` renders `role="menuitem"`. `MenuDivider` is `role="presentation"` and `aria-hidden`. + +**Open paths flow through React; close paths defer to the browser**: opening fires through `onOpenChange`. Closing happens via React (item click, controlled prop, Escape via the `useMenuPopoverBase_unstable` handler) or via the browser's native `popover="auto"` light dismiss; both converge on the same `setOpen`. + +**No tabster / no Portal**: the headless package does not pull in `@fluentui/react-tabster` for arrow-key trapping or `@fluentui/react-portal` for top-layer rendering. If you need arrow-key navigation, layer it on top of the exposed `setFocusByFirstCharacter`. If you need a portal-style mount node for legacy reasons, the consumer can always wrap `MenuPopover` in their own portal. diff --git a/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuCheckboxItems.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuCheckboxItems.stories.tsx new file mode 100644 index 00000000000000..0a9817f2308558 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuCheckboxItems.stories.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { + Menu, + MenuTrigger, + MenuPopover, + MenuList, + MenuItemCheckbox, +} from '@fluentui/react-headless-components-preview/menu'; + +import styles from './menu.module.css'; + +export const CheckboxItems = (): React.ReactNode => ( + + + + + + + + Bold + + + Italic + + + Underline + + + + +); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuControlled.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuControlled.stories.tsx new file mode 100644 index 00000000000000..94fbe8113da1d7 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuControlled.stories.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { Menu, MenuTrigger, MenuPopover, MenuList, MenuItem } from '@fluentui/react-headless-components-preview/menu'; + +import styles from './menu.module.css'; + +export const Controlled = (): React.ReactNode => { + const [open, setOpen] = React.useState(false); + + return ( +
+ + setOpen(data.open)}> + + + + + + One + Two + Three + + + +
+ ); +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuDefault.stories.tsx new file mode 100644 index 00000000000000..95ddb489e623f5 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuDefault.stories.tsx @@ -0,0 +1,19 @@ +import * as React from 'react'; +import { Menu, MenuTrigger, MenuPopover, MenuList, MenuItem } from '@fluentui/react-headless-components-preview/menu'; + +import styles from './menu.module.css'; + +export const Default = (): React.ReactNode => ( + + + + + + + New + Open + Save + + + +); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuDescription.md b/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuDescription.md new file mode 100644 index 00000000000000..15980afda7400c --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuDescription.md @@ -0,0 +1,11 @@ +Headless `Menu` for the `react-headless-components-preview` package. + +Composes the new v9 `useMenu*Base_unstable` hooks (introduced in #36087) without +Tabster, Griffel, or motion. The popover surface is rendered in the browser top +layer using the native HTML `popover="auto"` attribute, so no React Portal is +required and light dismiss (Escape, click-outside, popover-stack peer dismissal) +is owned by the platform. + +Consumers bring their own styles and — if needed — their own arrow-key +navigation; the hook surface exposes ARIA wiring, controlled `open` state, +positioning, character-search, and selection state. diff --git a/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuGroupingItems.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuGroupingItems.stories.tsx new file mode 100644 index 00000000000000..e93831e7ab060b --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuGroupingItems.stories.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { + Menu, + MenuTrigger, + MenuPopover, + MenuList, + MenuItem, + MenuGroup, + MenuGroupHeader, + MenuDivider, +} from '@fluentui/react-headless-components-preview/menu'; + +import styles from './menu.module.css'; + +export const GroupingItems = (): React.ReactNode => ( + + + + + + + + Document + Page + Section + + + + Media + Image + Video + + + + +); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuItemLink.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuItemLink.stories.tsx new file mode 100644 index 00000000000000..dd72015ec547c1 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuItemLink.stories.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +import { + Menu, + MenuTrigger, + MenuPopover, + MenuList, + MenuItemLink, + MenuItem, + MenuDivider, +} from '@fluentui/react-headless-components-preview/menu'; + +import styles from './menu.module.css'; + +export const ItemLink = (): React.ReactNode => ( + + + + + + + + Documentation + + + GitHub + + + About + + + +); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuItemsWithIcons.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuItemsWithIcons.stories.tsx new file mode 100644 index 00000000000000..64135063216291 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuItemsWithIcons.stories.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { Menu, MenuTrigger, MenuPopover, MenuList, MenuItem } from '@fluentui/react-headless-components-preview/menu'; + +import styles from './menu.module.css'; + +const Icon = ({ children }: { children: React.ReactNode }): React.ReactElement => ( + + {children} + +); + +export const ItemsWithIcons = (): React.ReactNode => ( + + + + + + + 📄}> + New + + 📂}> + Open + + 💾}> + Save + + + + +); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuNestedSubmenus.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuNestedSubmenus.stories.tsx new file mode 100644 index 00000000000000..d986be1b1b1445 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuNestedSubmenus.stories.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import { Menu, MenuTrigger, MenuPopover, MenuList, MenuItem } from '@fluentui/react-headless-components-preview/menu'; + +import styles from './menu.module.css'; + +const Submenu = ({ label, children }: { label: string; children: React.ReactNode }) => ( + + + + › + + } + > + {label} + + + + {children} + + +); + +export const NestedSubmenus = (): React.ReactNode => ( + + + + + + + New + + Document.docx + Spreadsheet.xlsx + + 2024 archive + 2023 archive + + + Save + + + +); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuOpenOnContext.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuOpenOnContext.stories.tsx new file mode 100644 index 00000000000000..dc8a5153037c51 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuOpenOnContext.stories.tsx @@ -0,0 +1,19 @@ +import * as React from 'react'; +import { Menu, MenuTrigger, MenuPopover, MenuList, MenuItem } from '@fluentui/react-headless-components-preview/menu'; + +import styles from './menu.module.css'; + +export const OpenOnContext = (): React.ReactNode => ( + + +
Right-click me
+
+ + + Cut + Copy + Paste + + +
+); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuOpenOnHover.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuOpenOnHover.stories.tsx new file mode 100644 index 00000000000000..5e37dd301682d2 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuOpenOnHover.stories.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import { Menu, MenuTrigger, MenuPopover, MenuList, MenuItem } from '@fluentui/react-headless-components-preview/menu'; + +import styles from './menu.module.css'; + +export const OpenOnHover = (): React.ReactNode => ( + + + + + + + Item 1 + Item 2 + + + +); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuRadioItems.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuRadioItems.stories.tsx new file mode 100644 index 00000000000000..d1b1131e55c411 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuRadioItems.stories.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { + Menu, + MenuTrigger, + MenuPopover, + MenuList, + MenuItemRadio, +} from '@fluentui/react-headless-components-preview/menu'; + +import styles from './menu.module.css'; + +export const RadioItems = (): React.ReactNode => ( + + + + + + + + Name + + + Size + + + Last modified + + + + +); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuSecondaryContent.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuSecondaryContent.stories.tsx new file mode 100644 index 00000000000000..e513c62523c392 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuSecondaryContent.stories.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import { Menu, MenuTrigger, MenuPopover, MenuList, MenuItem } from '@fluentui/react-headless-components-preview/menu'; + +import styles from './menu.module.css'; + +export const SecondaryContent = (): React.ReactNode => ( + + + + + + + ⌘X} + > + Cut + + ⌘C} + > + Copy + + ⌘V} + > + Paste + + + + +); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuSplitMenuItem.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuSplitMenuItem.stories.tsx new file mode 100644 index 00000000000000..cc6e7a3ff4fd46 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuSplitMenuItem.stories.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { + Menu, + MenuTrigger, + MenuPopover, + MenuList, + MenuItem, + MenuSplitGroup, +} from '@fluentui/react-headless-components-preview/menu'; + +import styles from './menu.module.css'; + +const SubmenuTrigger = (): React.ReactElement => ( + + + ›} + /> + + + + Save as draft + Save as template + + + +); + +export const SplitMenuItem = (): React.ReactNode => ( + + + + + + + + Save + + + Discard + + + +); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuSwitchItem.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuSwitchItem.stories.tsx new file mode 100644 index 00000000000000..a5f08190a0d271 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuSwitchItem.stories.tsx @@ -0,0 +1,38 @@ +import * as React from 'react'; +import { + Menu, + MenuTrigger, + MenuPopover, + MenuList, + MenuItemSwitch, +} from '@fluentui/react-headless-components-preview/menu'; + +import styles from './menu.module.css'; + +export const SwitchItem = (): React.ReactNode => ( + + + + + + + + Grid view + + + Show hidden files + + + + +); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuWithDivider.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuWithDivider.stories.tsx new file mode 100644 index 00000000000000..82a3e3a90616aa --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Menu/MenuWithDivider.stories.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { + Menu, + MenuTrigger, + MenuPopover, + MenuList, + MenuItem, + MenuDivider, +} from '@fluentui/react-headless-components-preview/menu'; + +import styles from './menu.module.css'; + +export const WithDivider = (): React.ReactNode => ( + + + + + + + Cut + Copy + Paste + + Delete + + + +); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Menu/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Menu/index.stories.tsx new file mode 100644 index 00000000000000..e63686ddf4dec5 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Menu/index.stories.tsx @@ -0,0 +1,59 @@ +import { + Menu, + MenuTrigger, + MenuPopover, + MenuList, + MenuItem, + MenuItemCheckbox, + MenuItemRadio, + MenuItemSwitch, + MenuItemLink, + MenuDivider, + MenuGroup, + MenuGroupHeader, + MenuSplitGroup, +} from '@fluentui/react-headless-components-preview/menu'; + +import descriptionMd from './MenuDescription.md'; +import bestPracticesMd from './MenuBestPractices.md'; + +export { Default } from './MenuDefault.stories'; +export { Controlled } from './MenuControlled.stories'; +export { WithDivider } from './MenuWithDivider.stories'; +export { OpenOnHover } from './MenuOpenOnHover.stories'; +export { OpenOnContext } from './MenuOpenOnContext.stories'; +export { ItemsWithIcons } from './MenuItemsWithIcons.stories'; +export { SecondaryContent } from './MenuSecondaryContent.stories'; +export { NestedSubmenus } from './MenuNestedSubmenus.stories'; +export { CheckboxItems } from './MenuCheckboxItems.stories'; +export { RadioItems } from './MenuRadioItems.stories'; +export { SwitchItem } from './MenuSwitchItem.stories'; +export { ItemLink } from './MenuItemLink.stories'; +export { GroupingItems } from './MenuGroupingItems.stories'; +export { SplitMenuItem } from './MenuSplitMenuItem.stories'; + +export default { + title: 'Headless Components/Menu', + component: Menu, + subcomponents: { + MenuTrigger, + MenuPopover, + MenuList, + MenuItem, + MenuItemCheckbox, + MenuItemRadio, + MenuItemSwitch, + MenuItemLink, + MenuDivider, + MenuGroup, + MenuGroupHeader, + MenuSplitGroup, + }, + parameters: { + docs: { + description: { + component: [descriptionMd, bestPracticesMd].join('\n'), + }, + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Menu/menu.module.css b/packages/react-components/react-headless-components-preview/stories/src/Menu/menu.module.css new file mode 100644 index 00000000000000..f4d6703d6d1f87 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Menu/menu.module.css @@ -0,0 +1,268 @@ +/* + * Story-level styles for the headless Menu. Headless components do not ship + * their own CSS; consumers compose visuals using design tokens. The classes + * below mirror the patterns established by Popover's story module so that + * every Menu story has a coherent look without one-off Tailwind utilities. + */ + +.trigger { + display: inline-flex; + align-items: center; + height: 36px; + padding: 0 var(--space-4); + border: 0; + border-radius: var(--radius-md); + background: var(--text); + color: var(--text-on-accent); + font-size: 13.5px; + font-weight: 500; + cursor: pointer; +} + +.trigger:hover, +.trigger[data-open] { + background: var(--text-muted); +} + +.trigger:focus-visible { + outline: var(--stroke-thick) solid var(--text); + outline-offset: 2px; +} + +.triggerSecondary { + height: 28px; + padding: 0 var(--space-3); + border: var(--stroke-thin) solid var(--border-strong); + border-radius: var(--radius-md); + background: var(--bg-elev); + color: var(--text); + font-size: 12.5px; + font-weight: 500; + cursor: pointer; +} + +.triggerSecondary:hover { + background: var(--surface-muted); +} + +.contextTarget { + display: inline-block; + width: 288px; + height: 128px; + border-radius: var(--radius-md); + border: var(--stroke-thin) dashed var(--border-stronger); + background: var(--surface-muted); + color: var(--text-muted); + display: grid; + place-items: center; + font-size: 13px; + cursor: context-menu; + user-select: none; +} + +.surface { + background: var(--bg-elev); + border-radius: var(--radius-md); + border: var(--stroke-thin) solid var(--border); + box-shadow: var(--shadow-3); + padding: var(--space-1) 0; + min-width: 180px; +} + +.list { + display: flex; + flex-direction: column; + outline: none; +} + +/* Base item — sized to match v9 Menu's item height (~32px) so the headless + demo reads as the same affordance, while still using headless tokens. */ +.item { + display: flex; + align-items: center; + gap: var(--space-2); + width: 100%; + padding: 6px var(--space-3); + border: 0; + background: transparent; + color: var(--text); + font-size: 13.5px; + line-height: 1.4; + text-align: left; + cursor: pointer; + outline: none; +} + +.item:hover, +.item:focus-visible { + background: var(--surface-muted); +} + +.item:focus-visible { + outline: var(--stroke-thin) solid var(--border-stronger); + outline-offset: -1px; +} + +.item[aria-disabled='true'] { + color: var(--text-faint); + cursor: default; +} + +.item[aria-disabled='true']:hover { + background: transparent; +} + +/* Item layouts */ +.itemSpread { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); +} + +.itemLink { + display: block; + text-decoration: none; +} + +/* Decoration slots */ +.icon { + display: inline-grid; + place-items: center; + width: 16px; + height: 16px; + color: var(--text-muted); +} + +.shortcut { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-faint); + margin-inline-start: var(--space-4); +} + +.subText { + display: block; + font-size: 11.5px; + color: var(--text-faint); + margin-top: 2px; +} + +.chevron { + margin-inline-start: auto; + color: var(--text-faint); +} + +/* Divider */ +.divider { + height: var(--stroke-thin); + margin: var(--space-1) var(--space-2); + background: var(--border); +} + +/* Group + header */ +.group { + display: flex; + flex-direction: column; + gap: 2px; +} + +.groupHeader { + padding: var(--space-2) var(--space-3) var(--space-1); + font-size: 11px; + font-weight: 600; + color: var(--text-soft); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* Selectable indicators (checkmark / radio dot) */ +.checkmark { + display: inline-grid; + place-items: center; + width: 16px; + height: 16px; + border-radius: var(--radius-pill); + border: var(--stroke-thin) solid var(--border-strong); + background: transparent; + color: transparent; + font-size: 12px; + font-weight: 700; + line-height: 1; + transition: background var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), + border-color var(--duration-fast) var(--ease-standard); +} + +.item[aria-checked='true'] .checkmark { + border-color: var(--accent); + background: var(--accent-soft); + color: var(--accent); +} + +/* Switch indicator — slider track + knob */ +.switchTrack { + position: relative; + flex-shrink: 0; + width: 28px; + height: 16px; + border-radius: var(--radius-pill); + background: var(--border-strong); + transition: background-color var(--duration-fast) var(--ease-standard); +} + +.switchTrack::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 12px; + height: 12px; + border-radius: var(--radius-pill); + background: var(--bg-elev); + transition: transform var(--duration-fast) var(--ease-standard); +} + +.item[aria-checked='true'] .switchTrack { + background: var(--accent); +} + +.item[aria-checked='true'] .switchTrack::after { + transform: translateX(12px); +} + +/* Composite layouts */ +.row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); +} + +.column { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-3); +} + +/* SplitGroup layout — two items side by side that share a row */ +.splitGroup { + display: flex; + align-items: stretch; + gap: var(--stroke-thin); +} + +.splitPrimary { + flex: 1 1 auto; + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.splitSecondary { + flex: 0 0 auto; + max-width: 50px; + justify-content: center; + padding-inline: var(--space-2); + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} diff --git a/packages/react-components/react-menu/library/etc/react-menu.api.md b/packages/react-components/react-menu/library/etc/react-menu.api.md index 9814f2ee71551c..2591eb518b8e2b 100644 --- a/packages/react-components/react-menu/library/etc/react-menu.api.md +++ b/packages/react-components/react-menu/library/etc/react-menu.api.md @@ -32,6 +32,12 @@ export const Menu: React_2.FC; // @public export const MENU_ENTER_EVENT = "fuimenuenter"; +// @public (undocumented) +export type MenuBaseProps = Omit; + +// @public (undocumented) +export type MenuBaseState = Omit; + // @public (undocumented) export type MenuCheckedValueChangeData = { checkedItems: string[]; @@ -167,6 +173,12 @@ export type MenuItemProps = Omit>, 'conten // @public export const MenuItemRadio: ForwardRefComponent; +// @public +export type MenuItemRadioBaseProps = MenuItemRadioProps; + +// @public +export type MenuItemRadioBaseState = MenuItemRadioState; + // @public (undocumented) export const menuItemRadioClassNames: SlotClassNames>; @@ -482,6 +494,13 @@ export const useMenu_unstable: (props: MenuProps & { }; }) => MenuState; +// @public +export const useMenuBase_unstable: (props: MenuBaseProps & { + safeZone?: boolean | { + timeout?: number; + }; +}) => MenuBaseState; + // @public (undocumented) export const useMenuContext_unstable: (selector: ContextSelector) => T; @@ -515,21 +534,33 @@ export const useMenuGroupStyles_unstable: (state: MenuGroupState) => MenuGroupSt // @public export const useMenuItem_unstable: (props: MenuItemProps, ref: React_2.Ref>) => MenuItemState; +// @public +export const useMenuItemBase_unstable: (props: MenuItemProps, ref: React_2.Ref>) => MenuItemState; + // @public export const useMenuItemCheckbox_unstable: (props: MenuItemCheckboxProps, ref: React_2.Ref>) => MenuItemCheckboxState; +// @public +export const useMenuItemCheckboxBase_unstable: (props: MenuItemCheckboxProps, ref: React_2.Ref>) => MenuItemCheckboxState; + // @public (undocumented) export const useMenuItemCheckboxStyles_unstable: (state: MenuItemCheckboxState) => MenuItemCheckboxState; // @public export const useMenuItemLink_unstable: (props: MenuItemLinkProps, ref: React_2.Ref) => MenuItemLinkState; +// @public +export const useMenuItemLinkBase_unstable: (props: MenuItemLinkProps, ref: React_2.Ref) => MenuItemLinkState; + // @public export const useMenuItemLinkStyles_unstable: (state: MenuItemLinkState) => MenuItemLinkState; // @public export const useMenuItemRadio_unstable: (props: MenuItemRadioProps, ref: React_2.Ref>) => MenuItemRadioState; +// @public +export const useMenuItemRadioBase_unstable: (props: MenuItemRadioBaseProps, ref: React_2.Ref>) => MenuItemRadioBaseState; + // @public (undocumented) export const useMenuItemRadioStyles_unstable: (state: MenuItemRadioState) => void; @@ -539,12 +570,18 @@ export const useMenuItemStyles_unstable: (state: MenuItemState) => MenuItemState // @public export const useMenuItemSwitch_unstable: (props: MenuItemSwitchProps, ref: React_2.Ref) => MenuItemSwitchState; +// @public +export const useMenuItemSwitchBase_unstable: (props: MenuItemSwitchProps, ref: React_2.Ref) => MenuItemSwitchState; + // @public export const useMenuItemSwitchStyles_unstable: (state: MenuItemSwitchState) => MenuItemSwitchState; // @public export const useMenuList_unstable: (props: MenuListProps, ref: React_2.Ref) => MenuListState; +// @public +export const useMenuListBase_unstable: (props: MenuListProps, ref: React_2.Ref) => MenuListState; + // @public (undocumented) export const useMenuListContext_unstable: (selector: ContextSelector) => T; @@ -557,6 +594,9 @@ export const useMenuListStyles_unstable: (state: MenuListState) => MenuListState // @public export const useMenuPopover_unstable: (props: MenuPopoverProps, ref: React_2.Ref) => MenuPopoverState; +// @public +export const useMenuPopoverBase_unstable: (props: MenuPopoverProps, ref: React_2.Ref) => MenuPopoverState; + // @public export const useMenuPopoverStyles_unstable: (state: MenuPopoverState) => MenuPopoverState; @@ -569,6 +609,14 @@ export const useMenuSplitGroupStyles_unstable: (state: MenuSplitGroupState) => M // @public export const useMenuTrigger_unstable: (props: MenuTriggerProps) => MenuTriggerState; +// @internal +export const useMenuTriggerBase_unstable: (props: MenuTriggerProps, options?: UseMenuTriggerBaseOptions) => MenuTriggerState; + +// @public +export type UseMenuTriggerBaseOptions = { + focusFirst?: () => void; +}; + // @public (undocumented) export const useMenuTriggerContext_unstable: () => boolean; diff --git a/packages/react-components/react-menu/library/src/Menu.ts b/packages/react-components/react-menu/library/src/Menu.ts index 4a6cc2cb8481fd..5a5589befb9cc0 100644 --- a/packages/react-components/react-menu/library/src/Menu.ts +++ b/packages/react-components/react-menu/library/src/Menu.ts @@ -1,4 +1,6 @@ export type { + MenuBaseProps, + MenuBaseState, MenuContextValues, MenuOpenChangeData, MenuOpenEvent, @@ -8,4 +10,10 @@ export type { MenuSlots, MenuState, } from './components/Menu/index'; -export { Menu, renderMenu_unstable, useMenuContextValues_unstable, useMenu_unstable } from './components/Menu/index'; +export { + Menu, + renderMenu_unstable, + useMenuContextValues_unstable, + useMenuBase_unstable, + useMenu_unstable, +} from './components/Menu/index'; diff --git a/packages/react-components/react-menu/library/src/MenuItemLink.ts b/packages/react-components/react-menu/library/src/MenuItemLink.ts index 3fe714228eda6e..8dc273c4cf011d 100644 --- a/packages/react-components/react-menu/library/src/MenuItemLink.ts +++ b/packages/react-components/react-menu/library/src/MenuItemLink.ts @@ -3,6 +3,7 @@ export { MenuItemLink, menuItemLinkClassNames, renderMenuItemLink_unstable, + useMenuItemLinkBase_unstable, useMenuItemLinkStyles_unstable, useMenuItemLink_unstable, } from './components/MenuItemLink/index'; diff --git a/packages/react-components/react-menu/library/src/MenuItemRadio.ts b/packages/react-components/react-menu/library/src/MenuItemRadio.ts index b34a0459617a4f..885718903a94e0 100644 --- a/packages/react-components/react-menu/library/src/MenuItemRadio.ts +++ b/packages/react-components/react-menu/library/src/MenuItemRadio.ts @@ -1,9 +1,14 @@ -export type { MenuItemRadioProps, MenuItemRadioState } from './components/MenuItemRadio/index'; +export type { + MenuItemRadioBaseProps, + MenuItemRadioBaseState, + MenuItemRadioProps, + MenuItemRadioState, +} from './components/MenuItemRadio/index'; export { MenuItemRadio, menuItemRadioClassNames, renderMenuItemRadio_unstable, + useMenuItemRadioBase_unstable, useMenuItemRadioStyles_unstable, useMenuItemRadio_unstable, - useMenuItemRadioBase_unstable, } from './components/MenuItemRadio/index'; diff --git a/packages/react-components/react-menu/library/src/MenuList.ts b/packages/react-components/react-menu/library/src/MenuList.ts index bdf7061fb1a954..329d8a22f455b3 100644 --- a/packages/react-components/react-menu/library/src/MenuList.ts +++ b/packages/react-components/react-menu/library/src/MenuList.ts @@ -12,6 +12,7 @@ export { MenuList, menuListClassNames, renderMenuList_unstable, + useMenuListBase_unstable, useMenuListContextValues_unstable, useMenuListStyles_unstable, useMenuList_unstable, diff --git a/packages/react-components/react-menu/library/src/MenuPopover.ts b/packages/react-components/react-menu/library/src/MenuPopover.ts index 5e5ca3d44f3cdb..149421500fc622 100644 --- a/packages/react-components/react-menu/library/src/MenuPopover.ts +++ b/packages/react-components/react-menu/library/src/MenuPopover.ts @@ -3,6 +3,7 @@ export { MenuPopover, menuPopoverClassNames, renderMenuPopover_unstable, + useMenuPopoverBase_unstable, useMenuPopoverStyles_unstable, useMenuPopover_unstable, } from './components/MenuPopover/index'; diff --git a/packages/react-components/react-menu/library/src/MenuTrigger.ts b/packages/react-components/react-menu/library/src/MenuTrigger.ts index eb982a7695a6fc..aabd7fa221051e 100644 --- a/packages/react-components/react-menu/library/src/MenuTrigger.ts +++ b/packages/react-components/react-menu/library/src/MenuTrigger.ts @@ -1,2 +1,12 @@ -export type { MenuTriggerChildProps, MenuTriggerProps, MenuTriggerState } from './components/MenuTrigger/index'; -export { MenuTrigger, renderMenuTrigger_unstable, useMenuTrigger_unstable } from './components/MenuTrigger/index'; +export type { + MenuTriggerChildProps, + MenuTriggerProps, + MenuTriggerState, + UseMenuTriggerBaseOptions, +} from './components/MenuTrigger/index'; +export { + MenuTrigger, + renderMenuTrigger_unstable, + useMenuTrigger_unstable, + useMenuTriggerBase_unstable, +} from './components/MenuTrigger/index'; diff --git a/packages/react-components/react-menu/library/src/components/Menu/Menu.types.ts b/packages/react-components/react-menu/library/src/components/Menu/Menu.types.ts index 6cdb0b25a438ee..d0a69fe14e4179 100644 --- a/packages/react-components/react-menu/library/src/components/Menu/Menu.types.ts +++ b/packages/react-components/react-menu/library/src/components/Menu/Menu.types.ts @@ -187,6 +187,10 @@ export type MenuState = ComponentState & safeZone?: React.ReactElement | null; }; +export type MenuBaseProps = Omit; + +export type MenuBaseState = Omit; + export type MenuContextValues = { menu: MenuContextValue; }; diff --git a/packages/react-components/react-menu/library/src/components/Menu/index.ts b/packages/react-components/react-menu/library/src/components/Menu/index.ts index f8f52e39aa00d2..7d311530cfb429 100644 --- a/packages/react-components/react-menu/library/src/components/Menu/index.ts +++ b/packages/react-components/react-menu/library/src/components/Menu/index.ts @@ -1,5 +1,7 @@ export { Menu } from './Menu'; export type { + MenuBaseProps, + MenuBaseState, MenuContextValues, MenuOpenChangeData, MenuOpenEvent, @@ -10,5 +12,5 @@ export type { MenuState, } from './Menu.types'; export { renderMenu_unstable } from './renderMenu'; -export { useMenu_unstable } from './useMenu'; +export { useMenu_unstable, useMenuBase_unstable } from './useMenu'; export { useMenuContextValues_unstable } from './useMenuContextValues'; 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 00000000000000..525ad438c81552 --- /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/Menu/useMenu.tsx b/packages/react-components/react-menu/library/src/components/Menu/useMenu.tsx index d644f06c6e7d69..fb171f96e968a8 100644 --- a/packages/react-components/react-menu/library/src/components/Menu/useMenu.tsx +++ b/packages/react-components/react-menu/library/src/components/Menu/useMenu.tsx @@ -27,7 +27,14 @@ import { useFocusFinders } from '@fluentui/react-tabster'; import { useMenuContext_unstable } from '../../contexts/menuContext'; import { MENU_SAFEZONE_TIMEOUT_EVENT, MENU_ENTER_EVENT, useOnMenuMouseEnter, useIsSubmenu } from '../../utils'; import { menuItemClassNames } from '../MenuItem/useMenuItemStyles.styles'; -import type { MenuOpenChangeData, MenuOpenEvent, MenuProps, MenuState } from './Menu.types'; +import type { + MenuBaseProps, + MenuBaseState, + MenuOpenChangeData, + MenuOpenEvent, + MenuProps, + MenuState, +} from './Menu.types'; import { MenuSurfaceMotion } from './MenuSurfaceMotion'; // If it's not possible to position the submenu in smaller viewports, try @@ -50,6 +57,34 @@ const submenuFallbackPositions: PositioningShorthandValue[] = [ * @param props - props from this instance of Menu */ export const useMenu_unstable = (props: MenuProps & { safeZone?: boolean | { timeout?: number } }): MenuState => { + const { surfaceMotion, ...baseProps } = props; + const baseState = useMenuBase_unstable(baseProps); + + return { + ...baseState, + components: { + surfaceMotion: MenuSurfaceMotion, + }, + surfaceMotion: presenceMotionSlot(surfaceMotion, { + elementType: MenuSurfaceMotion, + defaultProps: { + visible: baseState.open, + appear: true, + unmountOnExit: true, + }, + }), + }; +}; + +/** + * Base hook for Menu component, produces state required to render the component. + * It doesn't set any design-related slots specific to Menu such as `surfaceMotion`. + * + * @param props - props from this instance of Menu + */ +export const useMenuBase_unstable = ( + props: MenuBaseProps & { safeZone?: boolean | { timeout?: number } }, +): MenuBaseState => { const isSubmenu = useIsSubmenu(); const { hoverDelay = 500, @@ -190,9 +225,6 @@ export const useMenu_unstable = (props: MenuProps & { safeZone?: boolean | { tim mountNode, triggerRef, menuPopoverRef, - components: { - surfaceMotion: MenuSurfaceMotion, - }, openOnContext, open, setOpen, @@ -200,14 +232,6 @@ export const useMenu_unstable = (props: MenuProps & { safeZone?: boolean | { tim onCheckedValueChange, persistOnItemClick, safeZone: safeZoneHandle.elementToRender, - surfaceMotion: presenceMotionSlot(props.surfaceMotion, { - elementType: MenuSurfaceMotion, - defaultProps: { - visible: open, - appear: true, - unmountOnExit: 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 00000000000000..8503963a2976cc --- /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/MenuItem/useMenuItem.tsx b/packages/react-components/react-menu/library/src/components/MenuItem/useMenuItem.tsx index 0b4a63b7180ec3..06b2ac1b55cddf 100644 --- a/packages/react-components/react-menu/library/src/components/MenuItem/useMenuItem.tsx +++ b/packages/react-components/react-menu/library/src/components/MenuItem/useMenuItem.tsx @@ -48,8 +48,6 @@ export const useMenuItem_unstable = (props: MenuItemProps, ref: React.Ref 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/MenuItemCheckbox/useMenuItemCheckbox.tsx b/packages/react-components/react-menu/library/src/components/MenuItemCheckbox/useMenuItemCheckbox.tsx index a2d3bda15d5766..30108d6db1471d 100644 --- a/packages/react-components/react-menu/library/src/components/MenuItemCheckbox/useMenuItemCheckbox.tsx +++ b/packages/react-components/react-menu/library/src/components/MenuItemCheckbox/useMenuItemCheckbox.tsx @@ -25,8 +25,6 @@ export const useMenuItemCheckbox_unstable = ( /** * Base hook for MenuItemCheckbox component, produces state required to render the component - * - * @internal */ export const useMenuItemCheckboxBase_unstable = ( props: MenuItemCheckboxProps, diff --git a/packages/react-components/react-menu/library/src/components/MenuItemLink/index.ts b/packages/react-components/react-menu/library/src/components/MenuItemLink/index.ts index 6927417b8a011d..282b954761c0ef 100644 --- a/packages/react-components/react-menu/library/src/components/MenuItemLink/index.ts +++ b/packages/react-components/react-menu/library/src/components/MenuItemLink/index.ts @@ -1,5 +1,5 @@ export { MenuItemLink } from './MenuItemLink'; export type { MenuItemLinkProps, MenuItemLinkSlots, MenuItemLinkState } from './MenuItemLink.types'; export { renderMenuItemLink_unstable } from './renderMenuItemLink'; -export { useMenuItemLink_unstable } from './useMenuItemLink'; +export { useMenuItemLinkBase_unstable, useMenuItemLink_unstable } from './useMenuItemLink'; export { menuItemLinkClassNames, useMenuItemLinkStyles_unstable } from './useMenuItemLinkStyles.styles'; 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 00000000000000..7b108dcdc428d6 --- /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/MenuItemLink/useMenuItemLink.ts b/packages/react-components/react-menu/library/src/components/MenuItemLink/useMenuItemLink.ts index 7eb9152a2abe4f..4609e6207a692e 100644 --- a/packages/react-components/react-menu/library/src/components/MenuItemLink/useMenuItemLink.ts +++ b/packages/react-components/react-menu/library/src/components/MenuItemLink/useMenuItemLink.ts @@ -4,7 +4,7 @@ import type * as React from 'react'; import type { ExtractSlotProps, Slot } from '@fluentui/react-utilities'; import { getIntrinsicElementProps, slot } from '@fluentui/react-utilities'; import type { MenuItemLinkProps, MenuItemLinkState } from './MenuItemLink.types'; -import { useMenuItem_unstable } from '../MenuItem/useMenuItem'; +import { useMenuItemBase_unstable, useMenuItem_unstable } from '../MenuItem/useMenuItem'; import type { MenuItemProps } from '../MenuItem/MenuItem.types'; /** @@ -43,3 +43,34 @@ export const useMenuItemLink_unstable = ( ), }; }; + +/** + * Base hook for MenuItemLink, mirrors `useMenuItemLink_unstable` but composes with + * `useMenuItemBase_unstable`. + * + * @param props - props from this instance of MenuItemLink + * @param ref - reference to root HTMLElement of MenuItemLink + */ +export const useMenuItemLinkBase_unstable = ( + props: MenuItemLinkProps, + ref: React.Ref, +): MenuItemLinkState => { + const baseState = useMenuItemBase_unstable(props as MenuItemProps, null); + const _props = { ...props, ...(baseState.root as ExtractSlotProps>), ref, tabIndex: props.tabIndex }; + + return { + ...baseState, + components: { + // eslint-disable-next-line @typescript-eslint/no-deprecated + ...baseState.components, + root: 'a', + }, + root: slot.always( + getIntrinsicElementProps('a', { + role: 'menuitem', + ..._props, + }), + { elementType: 'a' }, + ), + }; +}; 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 00000000000000..78ec9c8d03a89c --- /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/MenuItemRadio/useMenuItemRadio.tsx b/packages/react-components/react-menu/library/src/components/MenuItemRadio/useMenuItemRadio.tsx index f3c202078cc0d8..84549c96f60dfb 100644 --- a/packages/react-components/react-menu/library/src/components/MenuItemRadio/useMenuItemRadio.tsx +++ b/packages/react-components/react-menu/library/src/components/MenuItemRadio/useMenuItemRadio.tsx @@ -33,8 +33,6 @@ export const useMenuItemRadio_unstable = ( /** * Base hook for MenuItemRadio component, produces state required to render the component. * It doesn't set any design-related props specific to MenuItemRadio. - * - * @internal */ export const useMenuItemRadioBase_unstable = ( props: MenuItemRadioBaseProps, 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 00000000000000..d50a39836d6dcb --- /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/MenuItemSwitch/useMenuItemSwitch.tsx b/packages/react-components/react-menu/library/src/components/MenuItemSwitch/useMenuItemSwitch.tsx index 04d6aa81b47a21..7b7a4097f1a7be 100644 --- a/packages/react-components/react-menu/library/src/components/MenuItemSwitch/useMenuItemSwitch.tsx +++ b/packages/react-components/react-menu/library/src/components/MenuItemSwitch/useMenuItemSwitch.tsx @@ -34,7 +34,6 @@ export const useMenuItemSwitch_unstable = ( * Base hook for MenuItemSwitch component, produces state required to render the component. * It doesn't set any design-related props specific to MenuItemSwitch. * - * @internal * @param props - props from this instance of MenuItemSwitch * @param ref - reference to root HTMLDivElement of MenuItemSwitch */ diff --git a/packages/react-components/react-menu/library/src/components/MenuList/index.ts b/packages/react-components/react-menu/library/src/components/MenuList/index.ts index eb018a4ed49ead..c3ab47a74f6bd7 100644 --- a/packages/react-components/react-menu/library/src/components/MenuList/index.ts +++ b/packages/react-components/react-menu/library/src/components/MenuList/index.ts @@ -10,6 +10,6 @@ export type { UninitializedMenuListState, } from './MenuList.types'; export { renderMenuList_unstable } from './renderMenuList'; -export { useMenuList_unstable } from './useMenuList'; +export { useMenuList_unstable, useMenuListBase_unstable } from './useMenuList'; export { menuListClassNames, useMenuListStyles_unstable } from './useMenuListStyles.styles'; export { useMenuListContextValues_unstable } from './useMenuListContextValues'; 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 fb8d0422846f2d..00000000000000 --- 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 00000000000000..23a0cf903c84bd --- /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/MenuList/useMenuList.ts b/packages/react-components/react-menu/library/src/components/MenuList/useMenuList.ts index 059387d90ff042..66a155c31ebbbe 100644 --- a/packages/react-components/react-menu/library/src/components/MenuList/useMenuList.ts +++ b/packages/react-components/react-menu/library/src/components/MenuList/useMenuList.ts @@ -21,15 +21,21 @@ import { MenuContext } from '../../contexts/menuContext'; import type { MenuListProps, MenuListState } from './MenuList.types'; import { useValidateNesting } from '../../utils/useValidateNesting'; +const MENU_ITEM_ROLES = ['menuitem', 'menuitemcheckbox', 'menuitemradio']; +const MENU_ITEM_ROLES_SELECTOR = MENU_ITEM_ROLES.map(role => `[role="${role}"]`).join(','); + /** - * Returns the props and state required to render the component + * Returns the props and state required to render the component. + * + * Composes with `useMenuListBase_unstable` and adds Tabster-driven keyboard + * navigation: circular arrow-key focus, a `TabsterMoveFocusEvent` listener + * that lets `useMenuPopover_unstable` handle Tab key presses, a focus-aware + * `setFocusByFirstCharacter`, and the `hasIcons` / `hasCheckmarks` slot + * alignment hints sourced from the parent `MenuContext`. */ export const useMenuList_unstable = (props: MenuListProps, ref: React.Ref): MenuListState => { - const { findAllFocusable } = useFocusFinders(); - const { targetDocument } = useFluent(); const menuContext = useMenuContextSelectors(); const hasMenuContext = useHasParentContext(MenuContext); - const focusAttributes = useArrowNavigationGroup({ circular: true }); if (usingPropsAndMenuContext(props, menuContext, hasMenuContext)) { // TODO throw warnings in development safely @@ -37,84 +43,101 @@ export const useMenuList_unstable = (props: MenuListProps, ref: React.Ref(null); - const validateNestingRef = useValidateNesting('MenuList'); + const wrapperRef = React.useRef(null); + const { findAllFocusable } = useFocusFinders(); + const { targetDocument } = useFluent(); + const focusAttributes = useArrowNavigationGroup({ circular: true }); - React.useEffect(() => { - const element = innerRef.current; + const baseState = useMenuListBase_unstable(props, ref); + // recreate root non-mutatively: merge wrapperRef so the effect below can + // observe the rendered DOM element, and add Tabster arrow-nav attributes + const mergedRootRef = useMergedRefs(baseState.root.ref, wrapperRef) as React.Ref; - if (hasMenuContext && targetDocument && element) { - const onTabsterMoveFocus = (e: TabsterMoveFocusEvent) => { - const nextElement = e.detail.next; + React.useEffect(() => { + const element = wrapperRef.current; - if (nextElement && element.contains(targetDocument.activeElement) && !element.contains(nextElement)) { - // Preventing Tabster from handling Tab press, useMenuPopover will handle it. - e.preventDefault(); - } - }; + if (!hasMenuContext || !targetDocument || !element) { + return; + } - targetDocument.addEventListener(TabsterMoveFocusEventName, onTabsterMoveFocus); + const onTabsterMoveFocus = (e: TabsterMoveFocusEvent) => { + const nextElement = e.detail.next; + if (nextElement && element.contains(targetDocument.activeElement) && !element.contains(nextElement)) { + // Preventing Tabster from handling Tab press, useMenuPopover will handle it. + e.preventDefault(); + } + }; - return () => { - targetDocument.removeEventListener(TabsterMoveFocusEventName, onTabsterMoveFocus); - }; - } - }, [innerRef, targetDocument, hasMenuContext]); + targetDocument.addEventListener(TabsterMoveFocusEventName, onTabsterMoveFocus); + return () => { + targetDocument.removeEventListener(TabsterMoveFocusEventName, onTabsterMoveFocus); + }; + }, [hasMenuContext, targetDocument]); const setFocusByFirstCharacter = React.useCallback( (e: React.KeyboardEvent, itemEl: HTMLElement) => { - // TODO use some kind of children registration to reduce dependency on DOM roles - const acceptedRoles = ['menuitem', 'menuitemcheckbox', 'menuitemradio']; - if (!innerRef.current) { + if (!wrapperRef.current) { return; } - const menuItems = findAllFocusable( - innerRef.current, - (el: HTMLElement) => el.hasAttribute('role') && acceptedRoles.indexOf(el.getAttribute('role')!) !== -1, + wrapperRef.current, + (el: HTMLElement) => el.hasAttribute('role') && MENU_ITEM_ROLES.indexOf(el.getAttribute('role')!) !== -1, ); + focusItemMatchingFirstCharacter(menuItems, e.key, itemEl); + }, + [findAllFocusable], + ); - let startIndex = menuItems.indexOf(itemEl) + 1; - if (startIndex === menuItems.length) { - startIndex = 0; - } - - const firstChars = menuItems.map(menuItem => menuItem.textContent?.charAt(0).toLowerCase()); - const char = e.key.toLowerCase(); - - const getIndexFirstChars = (start: number, firstChar: string) => { - for (let i = start; i < firstChars.length; i++) { - if (char === firstChars[i]) { - return i; - } - } - return -1; - }; + return { + ...baseState, + root: { + ...focusAttributes, + ...baseState.root, + ref: mergedRootRef, + }, + setFocusByFirstCharacter, + }; +}; - // Check remaining slots in the menu - let index = getIndexFirstChars(startIndex, char); +/** + * Base hook for MenuList component, produces state required to render the component. + * + * Does not invoke any Tabster APIs internally: arrow-key navigation and the + * focus-aware `setFocusByFirstCharacter` are added by the wrapper + * `useMenuList_unstable`. The base's `setFocusByFirstCharacter` walks the DOM + * via `querySelectorAll` and does not filter by Tabster's focusability rules, + * so consumers integrating their own focus management should layer that on top. + * + * @param props - props from this instance of MenuList + * @param ref - reference to root HTMLElement of MenuList + */ +export const useMenuListBase_unstable = (props: MenuListProps, ref: React.Ref): MenuListState => { + const triggerId = useMenuContext_unstable(context => context.triggerId); + const checkedValuesContext = useMenuContext_unstable(context => context.checkedValues); + const onCheckedValueChangeContext = useMenuContext_unstable(context => context.onCheckedValueChange); + const hasIconsContext = useMenuContext_unstable(context => context.hasIcons); + const hasCheckmarksContext = useMenuContext_unstable(context => context.hasCheckmarks); + const hasMenuContext = useHasParentContext(MenuContext); - // If not found in remaining slots, check from beginning - if (index === -1) { - index = getIndexFirstChars(0, char); - } + const innerRef = React.useRef(null); + const validateNestingRef = useValidateNesting('MenuList'); - // If match was found... - if (index > -1) { - menuItems[index].focus(); - } - }, - [findAllFocusable], - ); + const setFocusByFirstCharacter = React.useCallback((e: React.KeyboardEvent, itemEl: HTMLElement) => { + if (!innerRef.current) { + return; + } + const menuItems = Array.from(innerRef.current.querySelectorAll(MENU_ITEM_ROLES_SELECTOR)); + focusItemMatchingFirstCharacter(menuItems, e.key, itemEl); + }, []); const [checkedValues, setCheckedValues] = useControllableState({ - state: props.checkedValues ?? (hasMenuContext ? menuContext.checkedValues : undefined), + state: props.checkedValues ?? (hasMenuContext ? checkedValuesContext : undefined), defaultState: props.defaultCheckedValues, initialState: {}, }); const handleCheckedValueChange = - props.onCheckedValueChange ?? (hasMenuContext ? menuContext.onCheckedValueChange : undefined); + props.onCheckedValueChange ?? (hasMenuContext ? onCheckedValueChangeContext : undefined); const toggleCheckbox = useEventCallback( (e: React.MouseEvent | React.KeyboardEvent, name: string, value: string, checked: boolean) => { @@ -148,15 +171,14 @@ export const useMenuList_unstable = (props: MenuListProps, ref: React.Ref, role: 'menu', - 'aria-labelledby': menuContext.triggerId, - ...focusAttributes, + 'aria-labelledby': triggerId, ...props, }), { elementType: 'div' }, ), - hasIcons: menuContext.hasIcons || false, - hasCheckmarks: menuContext.hasCheckmarks || false, checkedValues, + hasIcons: props.hasIcons ?? hasIconsContext ?? false, + hasCheckmarks: props.hasCheckmarks ?? hasCheckmarksContext ?? false, hasMenuContext, setFocusByFirstCharacter, selectRadio, @@ -164,6 +186,38 @@ export const useMenuList_unstable = (props: MenuListProps, ref: React.Ref { + let startIndex = menuItems.indexOf(current) + 1; + if (startIndex === menuItems.length) { + startIndex = 0; + } + + const firstChars = menuItems.map(menuItem => menuItem.textContent?.charAt(0).toLowerCase()); + const char = key.toLowerCase(); + + const getIndexFirstChars = (start: number) => { + for (let i = start; i < firstChars.length; i++) { + if (char === firstChars[i]) { + return i; + } + } + return -1; + }; + + let index = getIndexFirstChars(startIndex); + if (index === -1) { + index = getIndexFirstChars(0); + } + if (index > -1) { + menuItems[index].focus(); + } +}; + /** * Adds some sugar to fetching multiple context selector values */ diff --git a/packages/react-components/react-menu/library/src/components/MenuPopover/index.ts b/packages/react-components/react-menu/library/src/components/MenuPopover/index.ts index b35443895f1cf3..ade82b0723b4f1 100644 --- a/packages/react-components/react-menu/library/src/components/MenuPopover/index.ts +++ b/packages/react-components/react-menu/library/src/components/MenuPopover/index.ts @@ -1,5 +1,5 @@ export { MenuPopover } from './MenuPopover'; export type { MenuPopoverProps, MenuPopoverSlots, MenuPopoverState } from './MenuPopover.types'; export { renderMenuPopover_unstable } from './renderMenuPopover'; -export { useMenuPopover_unstable } from './useMenuPopover'; +export { useMenuPopover_unstable, useMenuPopoverBase_unstable } from './useMenuPopover'; export { menuPopoverClassNames, useMenuPopoverStyles_unstable } from './useMenuPopoverStyles.styles'; 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 00000000000000..178ff21ca3081b --- /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/MenuPopover/useMenuPopover.ts b/packages/react-components/react-menu/library/src/components/MenuPopover/useMenuPopover.ts index 7b690b64123dc5..d225d6e66ca78e 100644 --- a/packages/react-components/react-menu/library/src/components/MenuPopover/useMenuPopover.ts +++ b/packages/react-components/react-menu/library/src/components/MenuPopover/useMenuPopover.ts @@ -13,15 +13,16 @@ import { dispatchMenuEnterEvent, useIsSubmenu } from '../../utils/index'; import type { MenuPopoverProps, MenuPopoverState } from './MenuPopover.types'; /** - * Create the state required to render MenuPopover. + * Base hook for MenuPopover, produces state required to render the component. * - * The returned state can be modified with hooks such as useMenuPopoverStyles_unstable, - * before being passed to renderMenuPopover_unstable. + * Does not invoke `@fluentui/react-tabster` focus-restoration or + * `@fluentui/react-motion` ref-forwarding APIs internally; the wrapper + * `useMenuPopover_unstable` layers those on top. * * @param props - props from this instance of MenuPopover * @param ref - reference to root HTMLElement of MenuPopover */ -export const useMenuPopover_unstable = (props: MenuPopoverProps, ref: React.Ref): MenuPopoverState => { +export const useMenuPopoverBase_unstable = (props: MenuPopoverProps, ref: React.Ref): MenuPopoverState => { 'use no memo'; const safeZone = useMenuContext_unstable(context => context.safeZone); @@ -35,7 +36,6 @@ export const useMenuPopover_unstable = (props: MenuPopoverProps, ref: React.Ref< const shouldCloseOnArrowLeft = useMenuListContext_unstable(ctx => ctx.shouldCloseOnArrowLeft ?? true); const canDispatchCustomEventRef = React.useRef(true); - const restoreFocusSourceAttributes = useRestoreFocusSource(); const [setThrottleTimeout, clearThrottleTimeout] = useTimeout(); const { dir } = useFluent(); @@ -73,20 +73,15 @@ export const useMenuPopover_unstable = (props: MenuPopoverProps, ref: React.Ref< const rootProps = slot.always( getIntrinsicElementProps('div', { role: 'presentation', - ...restoreFocusSourceAttributes, ...props, // FIXME: // `ref` is wrongly assigned to be `HTMLElement` instead of `HTMLDivElement` // but since it would be a breaking change to fix it, we are casting ref to it's proper type - ref: useMergedRefs( - ref, - popoverRef, - mouseOverListenerCallbackRef, - useMotionForwardedRef(), - ) as React.Ref, + ref: useMergedRefs(ref, popoverRef, mouseOverListenerCallbackRef) as React.Ref, }), { elementType: 'div' }, ); + const { onMouseEnter: onMouseEnterOriginal, onKeyDown: onKeyDownOriginal } = rootProps; rootProps.onMouseEnter = useEventCallback((event: React.MouseEvent) => { if (openOnHover || isSubmenu) { @@ -94,6 +89,7 @@ export const useMenuPopover_unstable = (props: MenuPopoverProps, ref: React.Ref< } onMouseEnterOriginal?.(event); }); + rootProps.onKeyDown = useEventCallback((event: React.KeyboardEvent) => { const key = event.key; if (key === Escape || (isSubmenu && shouldCloseOnArrowLeft && key === CloseArrowKey)) { @@ -121,3 +117,27 @@ export const useMenuPopover_unstable = (props: MenuPopoverProps, ref: React.Ref< root: rootProps, }; }; + +/** + * Create the state required to render MenuPopover. + * + * The returned state can be modified with hooks such as useMenuPopoverStyles_unstable, + * before being passed to renderMenuPopover_unstable. + * + * @param props - props from this instance of MenuPopover + * @param ref - reference to root HTMLElement of MenuPopover + */ +export const useMenuPopover_unstable = (props: MenuPopoverProps, ref: React.Ref): MenuPopoverState => { + const restoreFocusSourceAttributes = useRestoreFocusSource(); + const motionRef = useMotionForwardedRef(); + const baseState = useMenuPopoverBase_unstable(props, ref); + + return { + ...baseState, + root: { + ...restoreFocusSourceAttributes, + ...baseState.root, + ref: useMergedRefs(baseState.root.ref, motionRef) as React.Ref, + }, + }; +}; diff --git a/packages/react-components/react-menu/library/src/components/MenuTrigger/index.ts b/packages/react-components/react-menu/library/src/components/MenuTrigger/index.ts index 9b1124f7ee6a47..40b0220ee18f85 100644 --- a/packages/react-components/react-menu/library/src/components/MenuTrigger/index.ts +++ b/packages/react-components/react-menu/library/src/components/MenuTrigger/index.ts @@ -1,4 +1,5 @@ export { MenuTrigger } from './MenuTrigger'; export type { MenuTriggerChildProps, MenuTriggerProps, MenuTriggerState } from './MenuTrigger.types'; export { renderMenuTrigger_unstable } from './renderMenuTrigger'; -export { useMenuTrigger_unstable } from './useMenuTrigger'; +export { useMenuTrigger_unstable, useMenuTriggerBase_unstable } from './useMenuTrigger'; +export type { UseMenuTriggerBaseOptions } from './useMenuTrigger'; diff --git a/packages/react-components/react-menu/library/src/components/MenuTrigger/useMenuTrigger.ts b/packages/react-components/react-menu/library/src/components/MenuTrigger/useMenuTrigger.ts index 152c45a0f09ae7..7fb007b0959f21 100644 --- a/packages/react-components/react-menu/library/src/components/MenuTrigger/useMenuTrigger.ts +++ b/packages/react-components/react-menu/library/src/components/MenuTrigger/useMenuTrigger.ts @@ -26,15 +26,62 @@ function noop() { /** * Create the state required to render MenuTrigger. - * Clones the only child component and adds necessary event handling behaviours to open a popup menu + * Clones the only child component and adds necessary event handling behaviours to open a popup menu. + * + * Composes with `useMenuTriggerBase_unstable` and supplies Tabster's `findFirstFocusable` + * as the `focusFirst` callback so that submenu-trigger keyboard navigation honours + * Tabster movers, focus traps, and other Tabster-managed state. * * @param props - props from this instance of MenuTrigger */ export const useMenuTrigger_unstable = (props: MenuTriggerProps): MenuTriggerState => { + const menuPopoverRef = useMenuContext_unstable(context => context.menuPopoverRef); + + const { findFirstFocusable } = useFocusFinders(); + + const focusFirst = React.useCallback(() => { + const firstFocusable = findFirstFocusable(menuPopoverRef.current); + firstFocusable?.focus(); + }, [findFirstFocusable, menuPopoverRef]); + + return useMenuTriggerBase_unstable(props, { focusFirst }); +}; + +/** + * Options accepted by `useMenuTriggerBase_unstable`. + */ +export type UseMenuTriggerBaseOptions = { + /** + * Pluggable "focus the first focusable element in the menu popover" callback, + * invoked when an already-open submenu trigger receives the open arrow key. + * + * Not provided by the base hook itself - the base hook is intentionally headless + * and leaves focus discovery to the caller. `useMenuTrigger_unstable` plugs in a + * Tabster-aware implementation; a headless consumer is expected to supply its own. + * If omitted, the keyboard handler is a no-op for that case. + */ + focusFirst?: () => void; +}; + +/** + * Base hook for MenuTrigger component, produces state required to render the component. + * + * Headless: this hook does not import from `@fluentui/react-tabster` and does not + * perform any focus discovery on its own. The submenu-already-open arrow-key path + * delegates to the optional `options.focusFirst` callback, which lets consumers wire + * up whichever focus-finding strategy fits their environment (Tabster, a native DOM + * query, a virtual focus manager, etc.). When `focusFirst` is not provided that path + * becomes a no-op. + * + * @internal + */ +export const useMenuTriggerBase_unstable = ( + props: MenuTriggerProps, + options?: UseMenuTriggerBaseOptions, +): MenuTriggerState => { const { children, disableButtonEnhancement = false } = props; const triggerRef = useMenuContext_unstable(context => context.triggerRef); - const menuPopoverRef = useMenuContext_unstable(context => context.menuPopoverRef); const setOpen = useMenuContext_unstable(context => context.setOpen); const open = useMenuContext_unstable(context => context.open); const triggerId = useMenuContext_unstable(context => context.triggerId); @@ -44,11 +91,7 @@ export const useMenuTrigger_unstable = (props: MenuTriggerProps): MenuTriggerSta const isSubmenu = useIsSubmenu(); const shouldOpenOnArrowRight = useMenuListContext_unstable(ctx => ctx.shouldOpenOnArrowRight ?? true); - const { findFirstFocusable } = useFocusFinders(); - const focusFirst = React.useCallback(() => { - const firstFocusable = findFirstFocusable(menuPopoverRef.current); - firstFocusable?.focus(); - }, [findFirstFocusable, menuPopoverRef]); + const focusFirst = options?.focusFirst; const openedWithKeyboardRef = React.useRef(false); const openedViaSafeZoneRef = React.useRef(false); @@ -113,7 +156,7 @@ export const useMenuTrigger_unstable = (props: MenuTriggerProps): MenuTriggerSta // if menu is already open, can't rely on effects to focus if (open && key === OpenArrowKey && isSubmenu && shouldOpenOnArrowRight) { - focusFirst(); + focusFirst?.(); } }; diff --git a/packages/react-components/react-menu/library/src/index.ts b/packages/react-components/react-menu/library/src/index.ts index 056aa6df4e1b00..ff5dd5dab0a4fb 100644 --- a/packages/react-components/react-menu/library/src/index.ts +++ b/packages/react-components/react-menu/library/src/index.ts @@ -6,8 +6,16 @@ export type { MenuGroupContextValue } from './contexts/menuGroupContext'; export { MenuListProvider, useMenuListContext_unstable } from './contexts/menuListContext'; export type { MenuListContextValue } from './contexts/menuListContext'; -export { Menu, renderMenu_unstable, useMenuContextValues_unstable, useMenu_unstable } from './Menu'; +export { + Menu, + renderMenu_unstable, + useMenuBase_unstable, + useMenuContextValues_unstable, + useMenu_unstable, +} from './Menu'; export type { + MenuBaseProps, + MenuBaseState, MenuContextValues, MenuOpenChangeData, MenuOpenEvent, @@ -47,6 +55,7 @@ export { MenuItem, menuItemClassNames, renderMenuItem_unstable, + useMenuItemBase_unstable, useMenuItemStyles_unstable, useMenuItem_unstable, } from './MenuItem'; @@ -55,6 +64,7 @@ export { MenuItemCheckbox, menuItemCheckboxClassNames, renderMenuItemCheckbox_unstable, + useMenuItemCheckboxBase_unstable, useMenuItemCheckboxStyles_unstable, useMenuItemCheckbox_unstable, } from './MenuItemCheckbox'; @@ -63,14 +73,21 @@ export { MenuItemRadio, menuItemRadioClassNames, renderMenuItemRadio_unstable, + useMenuItemRadioBase_unstable, useMenuItemRadioStyles_unstable, useMenuItemRadio_unstable, } from './MenuItemRadio'; -export type { MenuItemRadioProps, MenuItemRadioState } from './MenuItemRadio'; +export type { + MenuItemRadioBaseProps, + MenuItemRadioBaseState, + MenuItemRadioProps, + MenuItemRadioState, +} from './MenuItemRadio'; export { MenuList, menuListClassNames, renderMenuList_unstable, + useMenuListBase_unstable, useMenuListContextValues_unstable, useMenuListStyles_unstable, useMenuList_unstable, @@ -90,6 +107,7 @@ export { MenuPopover, menuPopoverClassNames, renderMenuPopover_unstable, + useMenuPopoverBase_unstable, useMenuPopoverStyles_unstable, useMenuPopover_unstable, } from './MenuPopover'; @@ -102,8 +120,18 @@ export { useMenuSplitGroup_unstable, } from './MenuSplitGroup'; export type { MenuSplitGroupProps, MenuSplitGroupSlots, MenuSplitGroupState } from './MenuSplitGroup'; -export { MenuTrigger, renderMenuTrigger_unstable, useMenuTrigger_unstable } from './MenuTrigger'; -export type { MenuTriggerChildProps, MenuTriggerProps, MenuTriggerState } from './MenuTrigger'; +export { + MenuTrigger, + renderMenuTrigger_unstable, + useMenuTrigger_unstable, + useMenuTriggerBase_unstable, +} from './MenuTrigger'; +export type { + MenuTriggerChildProps, + MenuTriggerProps, + MenuTriggerState, + UseMenuTriggerBaseOptions, +} from './MenuTrigger'; export { useCheckmarkStyles_unstable } from './selectable/index'; export type { MenuItemSelectableProps, MenuItemSelectableState, SelectableHandler } from './selectable/index'; @@ -112,6 +140,7 @@ export { MenuItemLink, menuItemLinkClassNames, renderMenuItemLink_unstable, + useMenuItemLinkBase_unstable, useMenuItemLinkStyles_unstable, useMenuItemLink_unstable, } from './MenuItemLink'; @@ -120,16 +149,11 @@ export type { MenuItemLinkProps, MenuItemLinkSlots, MenuItemLinkState } from './ export { MENU_ENTER_EVENT, dispatchMenuEnterEvent, useOnMenuMouseEnter } from './utils'; export { MenuItemSwitch, - useMenuItemSwitch_unstable, - useMenuItemSwitchStyles_unstable, - renderMenuItemSwitch_unstable, menuItemSwitchClassNames, + renderMenuItemSwitch_unstable, + useMenuItemSwitchBase_unstable, + useMenuItemSwitchStyles_unstable, + useMenuItemSwitch_unstable, } from './MenuItemSwitch'; -export type { MenuItemSwitchProps, MenuItemSwitchState, MenuItemSwitchSlots } from './MenuItemSwitch'; - -// Experimental: Base hooks - will be enabled in the experimental release branch -// export { useMenuItemBase_unstable } from './MenuItem'; -// export { useMenuItemCheckboxBase_unstable } from './MenuItemCheckbox'; -// export { useMenuItemRadioBase_unstable } from './MenuItemRadio'; -// export { useMenuItemSwitchBase_unstable } from './MenuItemSwitch'; +export type { MenuItemSwitchProps, MenuItemSwitchSlots, MenuItemSwitchState } from './MenuItemSwitch';