diff --git a/docs/reference/generated/context-menu-root.json b/docs/reference/generated/context-menu-root.json index a20029e69e7..2a95de1c083 100644 --- a/docs/reference/generated/context-menu-root.json +++ b/docs/reference/generated/context-menu-root.json @@ -20,7 +20,7 @@ }, "actionsRef": { "type": "RefObject", - "description": "A ref to imperative actions.\n- `unmount`: When specified, the menu will not be unmounted when closed.\nInstead, the `unmount` function must be called to unmount the menu manually.\nUseful when the menu's animation is controlled by an external library.", + "description": "A ref to imperative actions.\n- `unmount`: When specified, the menu will not be unmounted when closed.\n Instead, the `unmount` function must be called to unmount the menu manually.\n Useful when the menu's animation is controlled by an external library.\n- `close`: When specified, the menu can be closed imperatively.", "detailedType": "React.RefObject | undefined" }, "closeParentOnEsc": { @@ -29,6 +29,16 @@ "description": "When in a submenu, determines whether pressing the Escape key\ncloses the entire menu, or only the current child menu.", "detailedType": "boolean | undefined" }, + "defaultTriggerId": { + "type": "string | null", + "description": "ID of the trigger that the popover is associated with.\nThis is useful in conjuntion with the `defaultOpen` prop to create an initially open popover.", + "detailedType": "string | null | undefined" + }, + "handle": { + "type": "Menu.Handle", + "description": "A handle to associate the popover with a trigger.\nIf specified, allows external triggers to control the popover's open state.", + "detailedType": "{} | undefined" + }, "loopFocus": { "type": "boolean", "default": "true", @@ -40,6 +50,11 @@ "description": "Event handler called after any animations complete when the menu is closed.", "detailedType": "((open: boolean) => void) | undefined" }, + "triggerId": { + "type": "string | null", + "description": "ID of the trigger that the popover is associated with.\nThis is useful in conjuntion with the `open` prop to create a controlled popover.\nThere's no need to specify this prop when the popover is uncontrolled (i.e. when the `open` prop is not set).", + "detailedType": "string | null | undefined" + }, "disabled": { "type": "boolean", "default": "false", @@ -53,9 +68,9 @@ "detailedType": "'horizontal' | 'vertical' | undefined" }, "children": { - "type": "ReactNode", - "required": true, - "detailedType": "React.ReactNode" + "type": "ReactNode | PayloadChildRenderFunction", + "description": "The content of the popover.\nThis can be a regular React node or a render function that receives the `payload` of the active trigger.", + "detailedType": "| React.ReactNode\n| ((arg: { payload: unknown }) => ReactNode)" } }, "dataAttributes": {}, diff --git a/docs/reference/generated/menu-root.json b/docs/reference/generated/menu-root.json index b2d36d5f0bc..b28ae96dd1e 100644 --- a/docs/reference/generated/menu-root.json +++ b/docs/reference/generated/menu-root.json @@ -20,7 +20,7 @@ }, "actionsRef": { "type": "RefObject", - "description": "A ref to imperative actions.\n- `unmount`: When specified, the menu will not be unmounted when closed.\nInstead, the `unmount` function must be called to unmount the menu manually.\nUseful when the menu's animation is controlled by an external library.", + "description": "A ref to imperative actions.\n- `unmount`: When specified, the menu will not be unmounted when closed.\n Instead, the `unmount` function must be called to unmount the menu manually.\n Useful when the menu's animation is controlled by an external library.\n- `close`: When specified, the menu can be closed imperatively.", "detailedType": "React.RefObject | undefined" }, "closeParentOnEsc": { @@ -29,6 +29,16 @@ "description": "When in a submenu, determines whether pressing the Escape key\ncloses the entire menu, or only the current child menu.", "detailedType": "boolean | undefined" }, + "defaultTriggerId": { + "type": "string | null", + "description": "ID of the trigger that the popover is associated with.\nThis is useful in conjuntion with the `defaultOpen` prop to create an initially open popover.", + "detailedType": "string | null | undefined" + }, + "handle": { + "type": "Menu.Handle", + "description": "A handle to associate the popover with a trigger.\nIf specified, allows external triggers to control the popover's open state.", + "detailedType": "{} | undefined" + }, "loopFocus": { "type": "boolean", "default": "true", @@ -46,29 +56,17 @@ "description": "Event handler called after any animations complete when the menu is closed.", "detailedType": "((open: boolean) => void) | undefined" }, + "triggerId": { + "type": "string | null", + "description": "ID of the trigger that the popover is associated with.\nThis is useful in conjuntion with the `open` prop to create a controlled popover.\nThere's no need to specify this prop when the popover is uncontrolled (i.e. when the `open` prop is not set).", + "detailedType": "string | null | undefined" + }, "disabled": { "type": "boolean", "default": "false", "description": "Whether the component should ignore user interaction.", "detailedType": "boolean | undefined" }, - "openOnHover": { - "type": "boolean", - "description": "Whether the menu should also open when the trigger is hovered.", - "detailedType": "boolean | undefined" - }, - "delay": { - "type": "number", - "default": "100", - "description": "How long to wait before the menu may be opened on hover. Specified in milliseconds.\n\nRequires the `openOnHover` prop.", - "detailedType": "number | undefined" - }, - "closeDelay": { - "type": "number", - "default": "0", - "description": "How long to wait before closing the menu that was opened on hover.\nSpecified in milliseconds.\n\nRequires the `openOnHover` prop.", - "detailedType": "number | undefined" - }, "orientation": { "type": "Menu.Root.Orientation", "default": "'vertical'", @@ -76,9 +74,9 @@ "detailedType": "'horizontal' | 'vertical' | undefined" }, "children": { - "type": "ReactNode", - "required": true, - "detailedType": "React.ReactNode" + "type": "ReactNode | PayloadChildRenderFunction", + "description": "The content of the popover.\nThis can be a regular React node or a render function that receives the `payload` of the active trigger.", + "detailedType": "| React.ReactNode\n| ((arg: { payload: Payload | undefined }) => ReactNode)" } }, "dataAttributes": {}, diff --git a/docs/reference/generated/menu-submenu-root.json b/docs/reference/generated/menu-submenu-root.json index 0206bbf0410..42e0c239b0e 100644 --- a/docs/reference/generated/menu-submenu-root.json +++ b/docs/reference/generated/menu-submenu-root.json @@ -20,7 +20,7 @@ }, "actionsRef": { "type": "RefObject", - "description": "A ref to imperative actions.\n- `unmount`: When specified, the menu will not be unmounted when closed.\nInstead, the `unmount` function must be called to unmount the menu manually.\nUseful when the menu's animation is controlled by an external library.", + "description": "A ref to imperative actions.\n- `unmount`: When specified, the menu will not be unmounted when closed.\n Instead, the `unmount` function must be called to unmount the menu manually.\n Useful when the menu's animation is controlled by an external library.\n- `close`: When specified, the menu can be closed imperatively.", "detailedType": "React.RefObject | undefined" }, "closeParentOnEsc": { @@ -29,6 +29,16 @@ "description": "When in a submenu, determines whether pressing the Escape key\ncloses the entire menu, or only the current child menu.", "detailedType": "boolean | undefined" }, + "defaultTriggerId": { + "type": "string | null", + "description": "ID of the trigger that the popover is associated with.\nThis is useful in conjuntion with the `defaultOpen` prop to create an initially open popover.", + "detailedType": "string | null | undefined" + }, + "handle": { + "type": "Menu.Handle", + "description": "A handle to associate the popover with a trigger.\nIf specified, allows external triggers to control the popover's open state.", + "detailedType": "{} | undefined" + }, "loopFocus": { "type": "boolean", "default": "true", @@ -40,30 +50,17 @@ "description": "Event handler called after any animations complete when the menu is closed.", "detailedType": "((open: boolean) => void) | undefined" }, + "triggerId": { + "type": "string | null", + "description": "ID of the trigger that the popover is associated with.\nThis is useful in conjuntion with the `open` prop to create a controlled popover.\nThere's no need to specify this prop when the popover is uncontrolled (i.e. when the `open` prop is not set).", + "detailedType": "string | null | undefined" + }, "disabled": { "type": "boolean", "default": "false", "description": "Whether the component should ignore user interaction.", "detailedType": "boolean | undefined" }, - "openOnHover": { - "type": "boolean", - "default": "true", - "description": "Whether the submenu should open when the trigger is hovered.", - "detailedType": "boolean | undefined" - }, - "delay": { - "type": "number", - "default": "100", - "description": "How long to wait before the menu may be opened on hover. Specified in milliseconds.\n\nRequires the `openOnHover` prop.", - "detailedType": "number | undefined" - }, - "closeDelay": { - "type": "number", - "default": "0", - "description": "How long to wait before closing the menu that was opened on hover.\nSpecified in milliseconds.\n\nRequires the `openOnHover` prop.", - "detailedType": "number | undefined" - }, "orientation": { "type": "Menu.Root.Orientation", "default": "'vertical'", @@ -71,9 +68,9 @@ "detailedType": "'horizontal' | 'vertical' | undefined" }, "children": { - "type": "ReactNode", - "required": true, - "detailedType": "React.ReactNode" + "type": "ReactNode | PayloadChildRenderFunction", + "description": "The content of the popover.\nThis can be a regular React node or a render function that receives the `payload` of the active trigger.", + "detailedType": "| React.ReactNode\n| ((arg: { payload: unknown }) => ReactNode)" } }, "dataAttributes": {}, diff --git a/docs/reference/generated/menu-submenu-trigger.json b/docs/reference/generated/menu-submenu-trigger.json index 485f04be028..78a40a114ed 100644 --- a/docs/reference/generated/menu-submenu-trigger.json +++ b/docs/reference/generated/menu-submenu-trigger.json @@ -17,6 +17,29 @@ "description": "Whether the component renders a native ` + + +

Uncontrolled, multiple triggers within Root

+
+ + {({ payload }) => ( + + + + + {renderMenuContent(payload as ContentKey, settings)} + + )} + +
+ +

Controlled, multiple triggers within Root

+
+ { + setControlledWithinRootOpen(open); + setControlledWithinRootTriggerId(eventDetails.trigger?.id ?? null); + }} + triggerId={controlledWithinRootTriggerId} + > + {({ payload }) => ( + + + + + {renderMenuContent(payload as ContentKey, settings)} + + )} + + +
+ +

Uncontrolled, detached triggers

+ +
+ + + + +
+ +

Controlled, detached triggers

+ +
+ { + setControlledDetachedOpen(open); + setControlledDetachedTriggerId(eventDetails.trigger?.id ?? null); + }} + /> + + + + + +
+ + ); +} + +type StyledMenuProps = Pick< + Menu.Root.Props, + 'handle' | 'open' | 'onOpenChange' | 'triggerId' +>; + +function StyledMenu(props: StyledMenuProps) { + const { handle, open, onOpenChange, triggerId } = props; + const { settings } = useExperimentSettings(); + + return ( + + {({ payload }) => renderMenuContent(payload as ContentKey, settings)} + + ); +} + +type MenuTriggerPropsWithFeatures = Menu.Trigger.Props & { + handle?: Menu.Handle; + payload?: Payload; +}; + +function StyledTrigger( + props: MenuTriggerPropsWithFeatures & React.RefAttributes, +) { + const { settings } = useExperimentSettings(); + const { className, children, payload, ...restProps } = props; + + const triggerProps: Menu.Trigger.Props = { + ...restProps, + className: [demoStyles.Button, styles.Trigger, className].filter(Boolean).join(' '), + openOnHover: settings.openOnHover, + delay: settings.delay, + closeDelay: settings.closeDelay, + payload, + }; + + const content = payload ?? children; + + return ( + + {content} + + + ); +} + +function renderMenuContent(contentKey: ContentKey | undefined, settings: Settings) { + const items = contentKey ? contents[contentKey] : undefined; + + return ( + + + + + + + {items ? ( + items.map((item, index) => renderMenuContentItem(item, `item-${index}`)) + ) : ( +
No content for this trigger.
+ )} +
+
+
+ ); +} + +function renderMenuContentItem(item: MenuContentItem, key: string) { + switch (item.type) { + case 'item': + return ( + console.log('Clicked on', item.label))} + key={key} + > + {item.label} + + ); + case 'separator': + return ; + case 'submenu': + return ( + + + {item.label} + + + + + + {item.menu.map((subItem, subIndex) => + renderMenuContentItem(subItem, `${key}.${subIndex}`), + )} + + + + + ); + default: + return null; + } +} + +export const settingsMetadata: SettingsMetadata = { + openOnHover: { + type: 'boolean', + label: 'Open on hover', + }, + delay: { + type: 'number', + label: 'Delay', + default: 100, + }, + closeDelay: { + type: 'number', + label: 'Close Delay', + default: 0, + }, + side: { + type: 'string', + label: 'Side', + options: ['top', 'bottom', 'inline-start', 'inline-end'], + default: 'bottom', + }, + modal: { + type: 'boolean', + label: 'Modal', + default: true, + }, + keepMounted: { + type: 'boolean', + label: 'Keep mounted', + default: false, + }, +}; + +function ArrowSvg(props: React.ComponentProps<'svg'>) { + return ( + + + + + + ); +} + +function ChevronDownIcon(props: React.ComponentProps<'svg'>) { + return ( + + + + ); +} + +function ChevronRightIcon(props: React.ComponentProps<'svg'>) { + return ( + + + + ); +} diff --git a/docs/src/app/(private)/experiments/toolbar/triggers.tsx b/docs/src/app/(private)/experiments/toolbar/triggers.tsx index beefa6a0237..ccafe9ea856 100644 --- a/docs/src/app/(private)/experiments/toolbar/triggers.tsx +++ b/docs/src/app/(private)/experiments/toolbar/triggers.tsx @@ -8,14 +8,24 @@ import { Tooltip } from '@base-ui-components/react/tooltip'; import { Popover } from '@base-ui-components/react/popover'; import { Dialog } from '@base-ui-components/react/dialog'; import { AlertDialog } from '@base-ui-components/react/alert-dialog'; +import { Menu } from '@base-ui-components/react/menu'; import toolbarClasses from './toolbar.module.css'; import triggerToolbarClasses from './triggers.module.css'; +import menuClasses from '../../../(public)/(content)/react/components/menu/demos/submenu/css-modules/index.module.css'; import tooltipClasses from '../../../(public)/(content)/react/components/tooltip/demos/hero/css-modules/index.module.css'; import switchClasses from '../../../(public)/(content)/react/components/switch/demos/hero/css-modules/index.module.css'; import dialogClasses from '../../../(public)/(content)/react/components/alert-dialog/demos/hero/css-modules/index.module.css'; import popoverClasses from '../../../(public)/(content)/react/components/popover/demos/_index.module.css'; import comboSliderClasses from './slider.module.css'; -import { SlidersIcon, TrashIcon, MessageCircleIcon, ArrowSvg, BellIcon } from './_icons'; +import { + SlidersIcon, + TrashIcon, + MessageCircleIcon, + ArrowSvg, + BellIcon, + MoreHorizontalIcon, + ChevronRightIcon, +} from './_icons'; import { SettingsMetadata, useExperimentSettings, @@ -65,6 +75,7 @@ const styles = { popover: popoverClasses, slider: comboSliderClasses, tooltip: tooltipClasses, + menu: menuClasses, }; const TEXT = `Shows toolbar buttons as various triggers: @@ -120,6 +131,7 @@ export default function App() { const POPOVER_DISABLED = settings.popoverDisabled || settings.toolbarDisabled; const INT_POPOVER_DISABLED = settings.interactivePopoverDisabled || settings.toolbarDisabled; const SWITCH_DISABLED = settings.switchDisabled || settings.toolbarDisabled; + const MENU_DISABLED = settings.menuDisabled || settings.toolbarDisabled; return ( + + {renderTriggerWithTooltip({ + render: ( + + + + ), + key: 'menu', + label: 'More actions', + disabled: MENU_DISABLED, + })} + + + + + Save + Save as... + + + + Recent + + + + + + index.tsx + index.module.css + + + + + + Close + + + + + + + {renderTriggerWithTooltip({ render: ( diff --git a/docs/src/app/(public)/(content)/react/components/menu/demos/_index.module.css b/docs/src/app/(public)/(content)/react/components/menu/demos/_index.module.css new file mode 100644 index 00000000000..887535af1a7 --- /dev/null +++ b/docs/src/app/(public)/(content)/react/components/menu/demos/_index.module.css @@ -0,0 +1,200 @@ +.IconButton { + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + padding: 0; + margin: 0; + outline: 0; + border: 1px solid var(--color-gray-200); + border-radius: 0.375rem; + background-color: var(--color-gray-50); + color: var(--color-gray-900); + user-select: none; + + @media (hover: hover) { + &:hover { + background-color: var(--color-gray-100); + } + } + + &:active { + background-color: var(--color-gray-100); + } + + &[data-popup-open] { + background-color: var(--color-gray-100); + } + + &:focus-visible { + outline: 2px solid var(--color-blue); + outline-offset: -1px; + } +} + +.Icon { + width: 1.25rem; + height: 1.25rem; +} + +.Container { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.Button { + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.375rem; + height: 2.5rem; + padding: 0 0.875rem; + margin: 0; + outline: 0; + border: 1px solid var(--color-gray-200); + border-radius: 0.375rem; + background-color: var(--color-gray-50); + font-family: inherit; + font-size: 1rem; + font-weight: 500; + line-height: 1.5rem; + color: var(--color-gray-900); + user-select: none; + + @media (hover: hover) { + &:hover { + background-color: var(--color-gray-100); + } + } + + &:active { + background-color: var(--color-gray-100); + } + + &:focus-visible { + outline: 2px solid var(--color-blue); + outline-offset: -1px; + } +} + +.Positioner { + outline: 0; +} + +.Popup { + box-sizing: border-box; + padding-block: 0.25rem; + border-radius: 0.375rem; + background-color: canvas; + color: var(--color-gray-900); + transform-origin: var(--transform-origin); + transition: + transform 150ms, + opacity 150ms; + + &[data-starting-style], + &[data-ending-style] { + opacity: 0; + transform: scale(0.9); + } + + @media (prefers-color-scheme: light) { + outline: 1px solid var(--color-gray-200); + box-shadow: + 0 10px 15px -3px var(--color-gray-200), + 0 4px 6px -4px var(--color-gray-200); + } + + @media (prefers-color-scheme: dark) { + outline: 1px solid var(--color-gray-300); + outline-offset: -1px; + } +} + +.Arrow { + display: flex; + + &[data-side='top'] { + bottom: -8px; + rotate: 180deg; + } + + &[data-side='bottom'] { + top: -8px; + rotate: 0deg; + } + + &[data-side='left'] { + right: -13px; + rotate: 90deg; + } + + &[data-side='right'] { + left: -13px; + rotate: -90deg; + } +} + +.ArrowFill { + fill: canvas; +} + +.ArrowOuterStroke { + @media (prefers-color-scheme: light) { + fill: var(--color-gray-200); + } +} + +.ArrowInnerStroke { + @media (prefers-color-scheme: dark) { + fill: var(--color-gray-300); + } +} + +.Item { + outline: 0; + cursor: default; + user-select: none; + padding-block: 0.5rem; + padding-left: 1rem; + padding-right: 2rem; + display: flex; + font-size: 0.875rem; + line-height: 1rem; + color: inherit; + + &[data-highlighted] { + z-index: 0; + position: relative; + color: var(--color-gray-50); + } + + &[data-highlighted]::before { + content: ''; + z-index: -1; + position: absolute; + inset-block: 0; + inset-inline: 0.25rem; + border-radius: 0.25rem; + background-color: var(--color-gray-900); + } +} + +.Label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-gray-500); + padding: 0.5rem 1rem; +} + +.Separator { + margin: 0.375rem 1rem; + height: 1px; + background-color: var(--color-gray-200); +} diff --git a/docs/src/app/(public)/(content)/react/components/menu/demos/detached-triggers-controlled/css-modules/index.tsx b/docs/src/app/(public)/(content)/react/components/menu/demos/detached-triggers-controlled/css-modules/index.tsx new file mode 100644 index 00000000000..6ca7343767c --- /dev/null +++ b/docs/src/app/(public)/(content)/react/components/menu/demos/detached-triggers-controlled/css-modules/index.tsx @@ -0,0 +1,126 @@ +'use client'; +import * as React from 'react'; +import { Menu } from '@base-ui-components/react/menu'; +import styles from '../../_index.module.css'; + +/* eslint-disable no-console */ +const itemGroups = { + library: [ + { label: 'Add to library', onClick: () => console.log('Adding to library') }, + { label: 'Add to favorites', onClick: () => console.log('Adding to favorites') }, + ], + playback: [ + { label: 'Play', onClick: () => console.log('Playing') }, + { label: 'Add to queue', onClick: () => console.log('Adding to queue') }, + ], + share: [ + { label: 'Share', onClick: () => console.log('Sharing') }, + { label: 'Copy link', onClick: () => console.log('Copying link') }, + ], +} as const; +/* eslint-enable no-console */ + +type MenuKey = keyof typeof itemGroups; + +const demoMenu = Menu.createHandle(); + +export default function MenuDetachedTriggersControlledDemo() { + const [open, setOpen] = React.useState(false); + const [activeTrigger, setActiveTrigger] = React.useState(null); + + const handleOpenChange = (isOpen: boolean, eventDetails: Menu.Root.ChangeEventDetails) => { + setOpen(isOpen); + if (isOpen) { + setActiveTrigger(eventDetails.trigger?.id ?? null); + } + }; + + return ( + +
+ + Library + + + + Playback + + + + Share + + + +
+ + + {({ payload }) => ( + + + + + + + + {payload && + itemGroups[payload].map((item, index) => ( + + {item.label} + + ))} + + + + )} + +
+ ); +} + +function ArrowSvg(props: React.ComponentProps<'svg'>) { + return ( + + + + + + ); +} diff --git a/docs/src/app/(public)/(content)/react/components/menu/demos/detached-triggers-controlled/index.ts b/docs/src/app/(public)/(content)/react/components/menu/demos/detached-triggers-controlled/index.ts new file mode 100644 index 00000000000..0ba85d1ae83 --- /dev/null +++ b/docs/src/app/(public)/(content)/react/components/menu/demos/detached-triggers-controlled/index.ts @@ -0,0 +1,8 @@ +import { createDemoWithVariants } from 'docs/src/utils/createDemo'; +import CssModules from './css-modules'; +import Tailwind from './tailwind'; + +export const DemoMenuDetachedTriggersControlled = createDemoWithVariants(import.meta.url, { + CssModules, + Tailwind, +}); diff --git a/docs/src/app/(public)/(content)/react/components/menu/demos/detached-triggers-controlled/tailwind/index.tsx b/docs/src/app/(public)/(content)/react/components/menu/demos/detached-triggers-controlled/tailwind/index.tsx new file mode 100644 index 00000000000..3bc78527f4c --- /dev/null +++ b/docs/src/app/(public)/(content)/react/components/menu/demos/detached-triggers-controlled/tailwind/index.tsx @@ -0,0 +1,131 @@ +'use client'; +import * as React from 'react'; +import { Menu } from '@base-ui-components/react/menu'; + +const itemClass = + 'flex cursor-default py-2 pr-8 pl-4 text-sm leading-4 outline-none select-none data-[highlighted]:relative data-[highlighted]:z-0 data-[highlighted]:text-gray-50 data-[highlighted]:before:absolute data-[highlighted]:before:inset-x-1 data-[highlighted]:before:inset-y-0 data-[highlighted]:before:z-[-1] data-[highlighted]:before:rounded-sm data-[highlighted]:before:bg-gray-900'; + +interface MenuItemDefinition { + label: string; + onClick?: () => void; +} + +/* eslint-disable no-console */ +const MENUS = { + library: [ + { label: 'Add to library', onClick: () => console.log('Adding to library') }, + { label: 'Add to favorites', onClick: () => console.log('Adding to favorites') }, + ] as MenuItemDefinition[], + playback: [ + { label: 'Play', onClick: () => console.log('Playing') }, + { label: 'Add to queue', onClick: () => console.log('Adding to queue') }, + ] as MenuItemDefinition[], + share: [ + { label: 'Share', onClick: () => console.log('Sharing') }, + { label: 'Copy link', onClick: () => console.log('Copying') }, + ] as MenuItemDefinition[], +}; +/* eslint-enable no-console */ + +type MenuKey = keyof typeof MENUS; + +const demoMenu = Menu.createHandle(); + +export default function MenuDetachedTriggersControlledDemo() { + const [open, setOpen] = React.useState(false); + const [activeTrigger, setActiveTrigger] = React.useState(null); + + const handleOpenChange = (isOpen: boolean, eventDetails: Menu.Root.ChangeEventDetails) => { + setOpen(isOpen); + if (isOpen) { + setActiveTrigger(eventDetails.trigger?.id ?? null); + } + }; + + return ( + +
+ + Library + + + Playback + + + Share + + + +
+ + + {({ payload }) => ( + + + + + + + + {payload && + MENUS[payload].map((item, index) => ( + + {item.label} + + ))} + + + + )} + +
+ ); +} + +function ArrowSvg(props: React.ComponentProps<'svg'>) { + return ( + + + + + + ); +} diff --git a/docs/src/app/(public)/(content)/react/components/menu/demos/detached-triggers-simple/css-modules/index.tsx b/docs/src/app/(public)/(content)/react/components/menu/demos/detached-triggers-simple/css-modules/index.tsx new file mode 100644 index 00000000000..27c8600c32a --- /dev/null +++ b/docs/src/app/(public)/(content)/react/components/menu/demos/detached-triggers-simple/css-modules/index.tsx @@ -0,0 +1,75 @@ +'use client'; +import * as React from 'react'; +import { Menu } from '@base-ui-components/react/menu'; +import styles from '../../_index.module.css'; + +const demoMenu = Menu.createHandle(); + +export default function MenuDetachedTriggersSimpleDemo() { + return ( + + + + + + + + + + + + + + Rename + Duplicate + Move to folder + + Archive + Delete + + + + + + ); +} + +function ArrowSvg(props: React.ComponentProps<'svg'>) { + return ( + + + + + + ); +} + +function DotsIcon(props: React.ComponentProps<'svg'>) { + return ( + + + + + + ); +} diff --git a/docs/src/app/(public)/(content)/react/components/menu/demos/detached-triggers-simple/index.ts b/docs/src/app/(public)/(content)/react/components/menu/demos/detached-triggers-simple/index.ts new file mode 100644 index 00000000000..b6e3512318a --- /dev/null +++ b/docs/src/app/(public)/(content)/react/components/menu/demos/detached-triggers-simple/index.ts @@ -0,0 +1,8 @@ +import { createDemoWithVariants } from 'docs/src/utils/createDemo'; +import CssModules from './css-modules'; +import Tailwind from './tailwind'; + +export const DemoMenuDetachedTriggersSimple = createDemoWithVariants(import.meta.url, { + CssModules, + Tailwind, +}); diff --git a/docs/src/app/(public)/(content)/react/components/menu/demos/detached-triggers-simple/tailwind/index.tsx b/docs/src/app/(public)/(content)/react/components/menu/demos/detached-triggers-simple/tailwind/index.tsx new file mode 100644 index 00000000000..fcc1350c278 --- /dev/null +++ b/docs/src/app/(public)/(content)/react/components/menu/demos/detached-triggers-simple/tailwind/index.tsx @@ -0,0 +1,83 @@ +'use client'; +import * as React from 'react'; +import { Menu } from '@base-ui-components/react/menu'; + +const demoMenu = Menu.createHandle(); +const popupClass = + 'origin-[var(--transform-origin)] rounded-md bg-[canvas] py-1 text-gray-900 shadow-lg shadow-gray-200 outline outline-1 outline-gray-200 transition-[transform,scale,opacity] data-[ending-style]:scale-90 data-[ending-style]:opacity-0 data-[starting-style]:scale-90 data-[starting-style]:opacity-0 dark:shadow-none dark:-outline-offset-1 dark:outline-gray-300'; +const itemClass = + 'flex cursor-default py-2 pr-8 pl-4 text-sm leading-4 outline-none select-none data-[highlighted]:relative data-[highlighted]:z-0 data-[highlighted]:text-gray-50 data-[highlighted]:before:absolute data-[highlighted]:before:inset-x-1 data-[highlighted]:before:inset-y-0 data-[highlighted]:before:z-[-1] data-[highlighted]:before:rounded-sm data-[highlighted]:before:bg-gray-900'; + +export default function MenuDetachedTriggersSimpleDemo() { + return ( + + + + + + + + + + + + + Rename + Duplicate + Move to folder + + Archive + Delete + + + + + + ); +} + +export function ArrowSvg(props: React.ComponentProps<'svg'>) { + return ( + + + + + + ); +} + +export function DotsIcon(props: React.ComponentProps<'svg'>) { + return ( + + + + + + ); +} diff --git a/docs/src/app/(public)/(content)/react/components/menu/demos/open-on-hover/css-modules/index.tsx b/docs/src/app/(public)/(content)/react/components/menu/demos/open-on-hover/css-modules/index.tsx index 17a191a95a0..cd19d74ed54 100644 --- a/docs/src/app/(public)/(content)/react/components/menu/demos/open-on-hover/css-modules/index.tsx +++ b/docs/src/app/(public)/(content)/react/components/menu/demos/open-on-hover/css-modules/index.tsx @@ -4,8 +4,8 @@ import styles from './index.module.css'; export default function ExampleMenu() { return ( - - + + Add to playlist diff --git a/docs/src/app/(public)/(content)/react/components/menu/demos/open-on-hover/tailwind/index.tsx b/docs/src/app/(public)/(content)/react/components/menu/demos/open-on-hover/tailwind/index.tsx index 95141673680..2ffc97be9d9 100644 --- a/docs/src/app/(public)/(content)/react/components/menu/demos/open-on-hover/tailwind/index.tsx +++ b/docs/src/app/(public)/(content)/react/components/menu/demos/open-on-hover/tailwind/index.tsx @@ -3,8 +3,11 @@ import { Menu } from '@base-ui-components/react/menu'; export default function ExampleMenu() { return ( - - + + Add to playlist diff --git a/docs/src/app/(public)/(content)/react/components/menu/page.mdx b/docs/src/app/(public)/(content)/react/components/menu/page.mdx index 0fa0d4d8429..78bc4a0c291 100644 --- a/docs/src/app/(public)/(content)/react/components/menu/page.mdx +++ b/docs/src/app/(public)/(content)/react/components/menu/page.mdx @@ -172,6 +172,107 @@ function ExampleMenu() { } ``` +### Detached triggers + +A menu can be opened by a trigger that lives either inside or outside the ``. +Keep the trigger inside `` for simple, tightly coupled layouts like the hero demo at the top of this page. +When the trigger and menu content need to live in different parts of the tree (for example, in a card list that controls a menu rendered near the document root), create a `handle` with `Menu.createHandle()` and pass it to both the trigger and the root. + +Note that only top-level menus can have detached triggers. +Submenus must have their triggers defined within the `SubmenuRoot` part. + +```jsx title="Detached triggers" {3,7} "handle={demoMenu}" +const demoMenu = Menu.createHandle(); + + + Actions + + + + + + + Edit + Share + + + + +``` + +import { DemoMenuDetachedTriggersSimple } from './demos/detached-triggers-simple'; + + + +### Multiple triggers + +One menu can be opened by several triggers. +You can either render multiple `` components inside the same ``, or attach several detached triggers to the same `handle`. + +```jsx title="Multiple triggers within the Root part" + + Row actions + Quick actions + {/* Rest of the menu */} + +``` + +```jsx title="Multiple detached triggers" +const projectMenu = Menu.createHandle(); + +Row actions +Quick actions + + + {/* Rest of the menu */} + +``` + +Menus can render different content depending on which trigger opened them. +Pass a `payload` prop to each `` and read it via a function child on ``. +Provide a type argument to `createHandle()` to strongly type the payload. + +```jsx title="Detached triggers with payload" {6,8,12,17} +const menus = { + 'file': ['New', 'Open', 'Save'], + 'edit': ['Undo', 'Redo', 'Cut', 'Copy', 'Paste'], +} + +const demoMenu = Menu.createHandle<{ items: string[] }>(); + + + File + + + + Edit + + + + {({ payload }) => ( + + + + {(payload ?? []).items.map((item) => ( + {item} + ))} + + + + )} + +``` + +### Controlled mode with multiple triggers + +Control a menu's open state externally with the `open` and `onOpenChange` props on ``. +When more than one trigger can open the menu, track the active trigger with the `triggerId` prop on `` and matching `id` props on each ``. +The `onOpenChange` callback receives `eventDetails`, which includes the DOM element that initiated the change, so you can update your `triggerId` state when the user activates a different trigger. + +import { DemoMenuDetachedTriggersControlled } from './demos/detached-triggers-controlled'; + + + ## API reference .", "4": "Base UI: DirectionContext is missing.", "5": "Base UI: MenubarContext is missing. Menubar parts must be placed within .", - "6": "Base UI: useToastManager must be used within .", "7": "Base UI: ToggleGroupContext is missing. ToggleGroup parts must be placed within .", "8": "Base UI: Render element or function are not defined.", "9": "Base UI: AccordionItemContext is missing. Accordion parts must be placed within .", @@ -81,5 +80,7 @@ "80": "Base UI: PopoverHandle.open: No trigger found with id \"%s\".", "81": "Base UI: TooltipHandle.open: No trigger found with id \"%s\".", "82": "Base UI: must be either used within a component or provided with a handle.", - "83": "Base UI: ToastPositionerContext is missing. ToastPositioner parts must be placed within ." + "83": "Base UI: MenuHandle.open: No trigger found with id \"%s\".", + "84": "Base UI: ToastPositionerContext is missing. ToastPositioner parts must be placed within .", + "85": "Base UI: must be either used within a component or provided with a handle." } diff --git a/packages/react/src/composite/root/CompositeRoot.tsx b/packages/react/src/composite/root/CompositeRoot.tsx index 64edae65096..b543ef4269f 100644 --- a/packages/react/src/composite/root/CompositeRoot.tsx +++ b/packages/react/src/composite/root/CompositeRoot.tsx @@ -49,6 +49,7 @@ export function CompositeRoot ({ highlightedIndex, onHighlightedIndexChange, highlightItemOnHover }), - [highlightedIndex, onHighlightedIndexChange, highlightItemOnHover], + () => ({ + highlightedIndex, + onHighlightedIndexChange, + highlightItemOnHover, + relayKeyboardEvent, + }), + [highlightedIndex, onHighlightedIndexChange, highlightItemOnHover, relayKeyboardEvent], ); return ( diff --git a/packages/react/src/composite/root/CompositeRootContext.ts b/packages/react/src/composite/root/CompositeRootContext.ts index be00031940a..20880b4b210 100644 --- a/packages/react/src/composite/root/CompositeRootContext.ts +++ b/packages/react/src/composite/root/CompositeRootContext.ts @@ -5,6 +5,13 @@ export interface CompositeRootContext { highlightedIndex: number; onHighlightedIndexChange: (index: number, shouldScrollIntoView?: boolean) => void; highlightItemOnHover: boolean; + /** + * Makes it possible to control composite components using events that don't originate from their children. + * For example, a Menubar with detached triggers may define its Menu.Root outside of CompositeRoot. + * Keyboard events that occur within this menu won't normally be captured by the CompositeRoot, + * so they need to be forwarded manually using this function. + */ + relayKeyboardEvent: (event: React.KeyboardEvent) => void; } export const CompositeRootContext = React.createContext( diff --git a/packages/react/src/composite/root/useCompositeRoot.ts b/packages/react/src/composite/root/useCompositeRoot.ts index 35f8ca834df..d691af09381 100644 --- a/packages/react/src/composite/root/useCompositeRoot.ts +++ b/packages/react/src/composite/root/useCompositeRoot.ts @@ -345,6 +345,7 @@ export function useCompositeRoot(params: UseCompositeRootParameters) { elementsRef, disabledIndices, onMapChange, + relayKeyboardEvent: props.onKeyDown!, }), [props, highlightedIndex, onHighlightedIndexChange, elementsRef, disabledIndices, onMapChange], ); diff --git a/packages/react/src/context-menu/root/ContextMenuRoot.test.tsx b/packages/react/src/context-menu/root/ContextMenuRoot.test.tsx index b978014a63e..ce51dbed9fc 100644 --- a/packages/react/src/context-menu/root/ContextMenuRoot.test.tsx +++ b/packages/react/src/context-menu/root/ContextMenuRoot.test.tsx @@ -29,8 +29,8 @@ describe('', () => { - - + + More options diff --git a/packages/react/src/dialog/root/useDialogRoot.ts b/packages/react/src/dialog/root/useDialogRoot.ts index e319f4facc2..ebf8e6283dd 100644 --- a/packages/react/src/dialog/root/useDialogRoot.ts +++ b/packages/react/src/dialog/root/useDialogRoot.ts @@ -144,7 +144,8 @@ export function useDialogRoot(params: useDialogRoot.Parameters): useDialogRoot.R return store.context.internalBackdropRef.current || store.context.backdropRef.current ? store.context.internalBackdropRef.current === eventTarget || store.context.backdropRef.current === eventTarget || - contains(eventTarget, popupElement) + (contains(eventTarget, popupElement) && + !eventTarget?.hasAttribute('data-base-ui-portal')) : true; } return true; diff --git a/packages/react/src/floating-ui-react/components/FloatingFocusManager.tsx b/packages/react/src/floating-ui-react/components/FloatingFocusManager.tsx index 1930cbdb7f1..8149c4469ae 100644 --- a/packages/react/src/floating-ui-react/components/FloatingFocusManager.tsx +++ b/packages/react/src/floating-ui-react/components/FloatingFocusManager.tsx @@ -35,7 +35,7 @@ import { createAttribute } from '../utils/createAttribute'; import { enqueueFocus } from '../utils/enqueueFocus'; import { markOthers } from '../utils/markOthers'; import { usePortalContext } from './FloatingPortal'; -import { useFloatingTree } from './FloatingTree'; +import { FloatingTreeStore, useFloatingTree } from './FloatingTree'; import { CLICK_TRIGGER_IDENTIFIER } from '../../utils/constants'; import { FloatingUIOpenChangeDetails } from '../../utils/types'; import { resolveRef } from '../../utils/resolveRef'; @@ -227,9 +227,13 @@ export interface FloatingFocusManagerProps { previousFocusableElement?: HTMLElement | React.RefObject | null; /** * Ref to the focus guard preceding the floating element content. - * Can be usefult to focus the popup progammatically. + * Can be useful to focus the popup progammatically. */ beforeContentFocusGuardRef?: React.RefObject; + /** + * External FlatingTree to use when the one provided by context can't be used. + */ + externalTree?: FloatingTreeStore; } /** @@ -253,6 +257,7 @@ export function FloatingFocusManager(props: FloatingFocusManagerProps): React.JS nextFocusableElement, previousFocusableElement, beforeContentFocusGuardRef, + externalTree, } = props; const { open, @@ -278,7 +283,7 @@ export function FloatingFocusManager(props: FloatingFocusManagerProps): React.JS const returnFocusRef = useValueAsRef(returnFocus); const openInteractionTypeRef = useValueAsRef(openInteractionType); - const tree = useFloatingTree(); + const tree = useFloatingTree(externalTree); const portalContext = usePortalContext(); const startDismissButtonRef = React.useRef(null); diff --git a/packages/react/src/floating-ui-react/components/FloatingTree.tsx b/packages/react/src/floating-ui-react/components/FloatingTree.tsx index 06202fb4a3f..046aa3c6298 100644 --- a/packages/react/src/floating-ui-react/components/FloatingTree.tsx +++ b/packages/react/src/floating-ui-react/components/FloatingTree.tsx @@ -2,7 +2,8 @@ import * as React from 'react'; import { useId } from '@base-ui-components/utils/useId'; import { useIsoLayoutEffect } from '@base-ui-components/utils/useIsoLayoutEffect'; -import type { FloatingNodeType, FloatingTreeType, ReferenceType } from '../types'; +import { useRefWithInit } from '@base-ui-components/utils/useRefWithInit'; +import type { FloatingEvents, FloatingNodeType, FloatingTreeType, ReferenceType } from '../types'; import { createEventEmitter } from '../utils/createEventEmitter'; const FloatingNodeContext = React.createContext(null); @@ -18,20 +19,21 @@ export const useFloatingParentNodeId = (): string | null => /** * Returns the nearest floating tree context, if available. */ -export const useFloatingTree = < - RT extends ReferenceType = ReferenceType, ->(): FloatingTreeType | null => - React.useContext(FloatingTreeContext) as FloatingTreeType | null; +export const useFloatingTree = ( + externalTree?: FloatingTreeStore, +): FloatingTreeType | null => { + const contextTree = React.useContext(FloatingTreeContext) as FloatingTreeType | null; + return externalTree ?? contextTree; +}; /** * Registers a node into the `FloatingTree`, returning its id. * @see https://floating-ui.com/docs/FloatingTree */ -export function useFloatingNodeId(customParentId?: string): string | undefined { +export function useFloatingNodeId(externalTree?: FloatingTreeStore): string | undefined { const id = useId(); - const tree = useFloatingTree(); - const reactParentId = useFloatingParentNodeId(); - const parentId = customParentId || reactParentId; + const tree = useFloatingTree(externalTree); + const parentId = useFloatingParentNodeId(); useIsoLayoutEffect(() => { if (!id) { @@ -71,6 +73,30 @@ export function FloatingNode(props: FloatingNodeProps): React.JSX.Element { export interface FloatingTreeProps { children?: React.ReactNode; + externalTree?: FloatingTreeStore; +} + +/** + * Stores and manages floating elements in a tree structure. + * This is a backing store for the `FloatingTree` component. + */ +export class FloatingTreeStore { + public readonly nodesRef: React.RefObject>> = { current: [] }; + + public readonly events: FloatingEvents = createEventEmitter(); + + private readonly _id: string = `${Math.random().toString(16).slice(2)}`; + + public addNode(node: FloatingNodeType) { + this.nodesRef.current.push(node); + } + + public removeNode(node: FloatingNodeType) { + const index = this.nodesRef.current.findIndex((n) => n === node); + if (index !== -1) { + this.nodesRef.current.splice(index, 1); + } + } } /** @@ -85,33 +111,8 @@ export interface FloatingTreeProps { * @internal */ export function FloatingTree(props: FloatingTreeProps): React.JSX.Element { - const { children } = props; - - const nodesRef = React.useRef>([]); - - const addNode = React.useCallback((node: FloatingNodeType) => { - nodesRef.current = [...nodesRef.current, node]; - }, []); - - const removeNode = React.useCallback((node: FloatingNodeType) => { - nodesRef.current = nodesRef.current.filter((n) => n !== node); - }, []); + const { children, externalTree } = props; - const [events] = React.useState(() => createEventEmitter()); - - return ( - ({ - nodesRef, - addNode, - removeNode, - events, - }), - [addNode, removeNode, events], - )} - > - {children} - - ); + const tree = useRefWithInit(() => externalTree ?? new FloatingTreeStore()).current; + return {children}; } diff --git a/packages/react/src/floating-ui-react/hooks/useClick.ts b/packages/react/src/floating-ui-react/hooks/useClick.ts index d48fd1039c7..cde75288e8f 100644 --- a/packages/react/src/floating-ui-react/hooks/useClick.ts +++ b/packages/react/src/floating-ui-react/hooks/useClick.ts @@ -119,13 +119,17 @@ export function useClick( return; } + // Capture the currentTarget before the rAF. + // as React sets it to null after the event handler completes. + const eventCurrentTarget = event.currentTarget as HTMLElement; + // Wait until focus is set on the element. This is an alternative to // `event.preventDefault()` to avoid :focus-visible from appearing when using a pointer. frame.request(() => { const details = createChangeEventDetails( REASONS.triggerPress, nativeEvent, - event.currentTarget as HTMLElement, + eventCurrentTarget, ); if (nextOpen && pointerType === 'touch' && touchOpenDelay > 0) { touchOpenTimeout.start(touchOpenDelay, () => { diff --git a/packages/react/src/floating-ui-react/hooks/useDismiss.ts b/packages/react/src/floating-ui-react/hooks/useDismiss.ts index c117c433a79..cd9fbf72e17 100644 --- a/packages/react/src/floating-ui-react/hooks/useDismiss.ts +++ b/packages/react/src/floating-ui-react/hooks/useDismiss.ts @@ -22,7 +22,7 @@ import { /* eslint-disable no-underscore-dangle */ -import { useFloatingTree } from '../components/FloatingTree'; +import { FloatingTreeStore, useFloatingTree } from '../components/FloatingTree'; import type { ElementProps, FloatingRootContext } from '../types'; import { createChangeEventDetails } from '../../utils/createBaseUIEventDetails'; import { REASONS } from '../../utils/reasons'; @@ -114,6 +114,10 @@ export interface UseDismissProps { * floating elements. */ bubbles?: boolean | { escapeKey?: boolean; outsidePress?: boolean }; + /** + * External FlatingTree to use when the one provided by context can't be used. + */ + externalTree?: FloatingTreeStore; } /** @@ -135,9 +139,10 @@ export function useDismiss( referencePressEvent = 'sloppy', ancestorScroll = false, bubbles, + externalTree, } = props; - const tree = useFloatingTree(); + const tree = useFloatingTree(externalTree); const outsidePressFn = useStableCallback( typeof outsidePressProp === 'function' ? outsidePressProp : () => false, ); diff --git a/packages/react/src/floating-ui-react/hooks/useFloating.ts b/packages/react/src/floating-ui-react/hooks/useFloating.ts index 671ef55e184..30112b3a630 100644 --- a/packages/react/src/floating-ui-react/hooks/useFloating.ts +++ b/packages/react/src/floating-ui-react/hooks/useFloating.ts @@ -20,7 +20,7 @@ import { useFloatingRootContext } from './useFloatingRootContext'; export function useFloating( options: UseFloatingOptions = {}, ): UseFloatingReturn { - const { nodeId } = options; + const { nodeId, externalTree } = options; const internalRootContext = useFloatingRootContext({ ...options, @@ -41,7 +41,7 @@ export function useFloating( const domReference = (optionDomReference || domReferenceState) as NarrowedElement; const domReferenceRef = React.useRef | null>(null); - const tree = useFloatingTree(); + const tree = useFloatingTree(externalTree); useIsoLayoutEffect(() => { if (domReference) { diff --git a/packages/react/src/floating-ui-react/hooks/useFocus.ts b/packages/react/src/floating-ui-react/hooks/useFocus.ts index 81b55efd7ad..b006e7fa371 100644 --- a/packages/react/src/floating-ui-react/hooks/useFocus.ts +++ b/packages/react/src/floating-ui-react/hooks/useFocus.ts @@ -194,5 +194,8 @@ export function useFocus(context: FloatingRootContext, props: UseFocusProps = {} [dataRef, elements.domReference, elements.triggers, onOpenChange, visibleOnly, timeout], ); - return React.useMemo(() => (enabled ? { reference } : {}), [enabled, reference]); + return React.useMemo( + () => (enabled ? { reference, trigger: reference } : {}), + [enabled, reference], + ); } diff --git a/packages/react/src/floating-ui-react/hooks/useHover.ts b/packages/react/src/floating-ui-react/hooks/useHover.ts index 5e174e0b39a..980ce5dbb98 100644 --- a/packages/react/src/floating-ui-react/hooks/useHover.ts +++ b/packages/react/src/floating-ui-react/hooks/useHover.ts @@ -6,7 +6,11 @@ import { useStableCallback } from '@base-ui-components/utils/useStableCallback'; import { useIsoLayoutEffect } from '@base-ui-components/utils/useIsoLayoutEffect'; import { contains, getDocument, getTarget, isMouseLikePointerType } from '../utils'; -import { useFloatingParentNodeId, useFloatingTree } from '../components/FloatingTree'; +import { + FloatingTreeStore, + useFloatingParentNodeId, + useFloatingTree, +} from '../components/FloatingTree'; import type { Delay, ElementProps, @@ -114,6 +118,10 @@ export interface UseHoverProps { * This allows to have multiple triggers per floating element (assuming `useHover` is called per trigger). */ triggerElement?: HTMLElement | null; + /** + * External FlatingTree to use when the one provided by context can't be used. + */ + externalTree?: FloatingTreeStore; } /** @@ -134,9 +142,10 @@ export function useHover( restMs = 0, move = true, triggerElement = null, + externalTree, } = props; - const tree = useFloatingTree(); + const tree = useFloatingTree(externalTree); const parentId = useFloatingParentNodeId(); const handleCloseRef = useValueAsRef(handleClose); const delayRef = useValueAsRef(delay); diff --git a/packages/react/src/floating-ui-react/hooks/useHoverFloatingInteraction.ts b/packages/react/src/floating-ui-react/hooks/useHoverFloatingInteraction.ts new file mode 100644 index 00000000000..a5bc1f75fa0 --- /dev/null +++ b/packages/react/src/floating-ui-react/hooks/useHoverFloatingInteraction.ts @@ -0,0 +1,299 @@ +import * as React from 'react'; +import { isElement } from '@floating-ui/utils/dom'; +import { useStableCallback } from '@base-ui-components/utils/useStableCallback'; +import { useIsoLayoutEffect } from '@base-ui-components/utils/useIsoLayoutEffect'; + +import type { FloatingRootContext } from '../types'; +import { getDocument, getTarget, isMouseLikePointerType } from '../utils'; + +import { createChangeEventDetails } from '../../utils/createBaseUIEventDetails'; +import { REASONS } from '../../utils/reasons'; +import { FloatingUIOpenChangeDetails } from '../../utils/types'; +import { + FloatingTreeStore, + useFloatingParentNodeId, + useFloatingTree, +} from '../components/FloatingTree'; +import { + isInteractiveElement, + safePolygonIdentifier, + useHoverInteractionSharedState, +} from './useHoverInteractionSharedState'; + +export type UseHoverFloatingInteractionProps = { + /** + * Whether the Hook is enabled, including all internal Effects and event + * handlers. + * @default true + */ + enabled?: boolean; + /** + * Waits for the specified time when the event listener runs before changing + * the `open` state. + * @default 0 + */ + closeDelay?: number | (() => number); + /** + * An optional external floating tree to use instead of the default context. + */ + externalTree?: FloatingTreeStore; +}; + +const clickLikeEvents = new Set(['click', 'mousedown']); + +/** + * Provides hover interactions that should be attached to the floating element. + */ +export function useHoverFloatingInteraction( + context: FloatingRootContext, + parameters: UseHoverFloatingInteractionProps = {}, +): void { + const { open, onOpenChange, dataRef, events, elements } = context; + const { enabled = true, closeDelay: closeDelayProp = 0, externalTree } = parameters; + + const { + pointerTypeRef, + interactedInsideRef, + handlerRef, + blockMouseMoveRef, + performedPointerEventsMutationRef, + unbindMouseMoveRef, + restTimeoutPendingRef, + openChangeTimeout: openChangeTimeout, + restTimeout, + handleCloseOptionsRef, + } = useHoverInteractionSharedState(context); + + const tree = useFloatingTree(externalTree); + const parentId = useFloatingParentNodeId(); + + const isClickLikeOpenEvent = useStableCallback(() => { + if (interactedInsideRef.current) { + return true; + } + + return dataRef.current.openEvent ? clickLikeEvents.has(dataRef.current.openEvent.type) : false; + }); + + const isHoverOpen = useStableCallback(() => { + const type = dataRef.current.openEvent?.type; + return type?.includes('mouse') && type !== 'mousedown'; + }); + + const closeWithDelay = React.useCallback( + (event: MouseEvent, runElseBranch = true) => { + const closeDelay = getDelay(closeDelayProp, pointerTypeRef.current); + if (closeDelay && !handlerRef.current) { + openChangeTimeout.start(closeDelay, () => + onOpenChange(false, createChangeEventDetails(REASONS.triggerHover, event)), + ); + } else if (runElseBranch) { + openChangeTimeout.clear(); + onOpenChange(false, createChangeEventDetails(REASONS.triggerHover, event)); + } + }, + [closeDelayProp, handlerRef, onOpenChange, pointerTypeRef, openChangeTimeout], + ); + + const cleanupMouseMoveHandler = useStableCallback(() => { + unbindMouseMoveRef.current(); + handlerRef.current = undefined; + }); + + const clearPointerEvents = useStableCallback(() => { + if (performedPointerEventsMutationRef.current) { + const body = getDocument(elements.floating).body; + body.style.pointerEvents = ''; + body.removeAttribute(safePolygonIdentifier); + performedPointerEventsMutationRef.current = false; + } + }); + + const handleInteractInside = useStableCallback((event: PointerEvent) => { + const target = getTarget(event) as Element | null; + if (!isInteractiveElement(target)) { + interactedInsideRef.current = false; + return; + } + + interactedInsideRef.current = true; + }); + + React.useEffect(() => { + if (!enabled) { + return undefined; + } + + function onOpenChangeLocal(details: FloatingUIOpenChangeDetails) { + if (!details.open) { + openChangeTimeout.clear(); + restTimeout.clear(); + blockMouseMoveRef.current = true; + restTimeoutPendingRef.current = false; + } + } + + events.on('openchange', onOpenChangeLocal); + return () => { + events.off('openchange', onOpenChangeLocal); + }; + }, [enabled, events, openChangeTimeout, restTimeout, blockMouseMoveRef, restTimeoutPendingRef]); + + useIsoLayoutEffect(() => { + if (!open) { + pointerTypeRef.current = undefined; + restTimeoutPendingRef.current = false; + interactedInsideRef.current = false; + cleanupMouseMoveHandler(); + clearPointerEvents(); + } + }, [ + open, + pointerTypeRef, + restTimeoutPendingRef, + interactedInsideRef, + cleanupMouseMoveHandler, + clearPointerEvents, + ]); + + React.useEffect(() => { + return () => { + cleanupMouseMoveHandler(); + openChangeTimeout.clear(); + restTimeout.clear(); + interactedInsideRef.current = false; + }; + }, [cleanupMouseMoveHandler, openChangeTimeout, restTimeout, interactedInsideRef]); + + React.useEffect(() => { + return clearPointerEvents; + }, [clearPointerEvents]); + + useIsoLayoutEffect(() => { + if (!enabled) { + return undefined; + } + + if ( + open && + handleCloseOptionsRef.current?.blockPointerEvents && + isHoverOpen() && + isElement(elements.domReference) && + elements.floating + ) { + performedPointerEventsMutationRef.current = true; + const body = getDocument(elements.floating).body; + body.setAttribute(safePolygonIdentifier, ''); + + const ref = elements.domReference as HTMLElement | SVGSVGElement; + const floatingEl = elements.floating; + + const parentFloating = tree?.nodesRef.current.find((node) => node.id === parentId)?.context + ?.elements.floating; + + if (parentFloating) { + parentFloating.style.pointerEvents = ''; + } + + body.style.pointerEvents = 'none'; + ref.style.pointerEvents = 'auto'; + floatingEl.style.pointerEvents = 'auto'; + + return () => { + body.style.pointerEvents = ''; + ref.style.pointerEvents = ''; + floatingEl.style.pointerEvents = ''; + }; + } + + return undefined; + }, [ + enabled, + open, + elements.domReference, + elements.floating, + handleCloseOptionsRef, + isHoverOpen, + tree, + parentId, + performedPointerEventsMutationRef, + ]); + + React.useEffect(() => { + if (!enabled) { + return undefined; + } + + // Ensure the floating element closes after scrolling even if the pointer + // did not move. + // https://github.com/floating-ui/floating-ui/discussions/1692 + function onScrollMouseLeave(event: MouseEvent) { + if (isClickLikeOpenEvent()) { + return; + } + if (!dataRef.current.floatingContext) { + return; + } + if ( + event.relatedTarget && + elements.triggers && + elements.triggers.includes(event.relatedTarget as Element) + ) { + // If the mouse is leaving the reference element to another trigger, don't explicitly close the popup + // as it will be moved. + return; + } + + clearPointerEvents(); + cleanupMouseMoveHandler(); + if (!isClickLikeOpenEvent()) { + closeWithDelay(event); + } + } + + function onFloatingMouseEnter(event: MouseEvent) { + openChangeTimeout.clear(); + clearPointerEvents(); + handlerRef.current?.(event); + cleanupMouseMoveHandler(); + } + + function onFloatingMouseLeave(event: MouseEvent) { + if (!isClickLikeOpenEvent()) { + closeWithDelay(event, false); + } + } + + const floating = elements.floating; + if (floating) { + floating.addEventListener('mouseleave', onScrollMouseLeave); + floating.addEventListener('mouseenter', onFloatingMouseEnter); + floating.addEventListener('mouseleave', onFloatingMouseLeave); + floating.addEventListener('pointerdown', handleInteractInside, true); + } + + return () => { + if (floating) { + floating.removeEventListener('mouseleave', onScrollMouseLeave); + floating.removeEventListener('mouseenter', onFloatingMouseEnter); + floating.removeEventListener('mouseleave', onFloatingMouseLeave); + floating.removeEventListener('pointerdown', handleInteractInside, true); + } + }; + }); +} + +export function getDelay( + value: number | (() => number), + pointerType?: PointerEvent['pointerType'], +) { + if (pointerType && !isMouseLikePointerType(pointerType)) { + return 0; + } + + if (typeof value === 'function') { + return value(); + } + + return value; +} diff --git a/packages/react/src/floating-ui-react/hooks/useHoverInteractionSharedState.ts b/packages/react/src/floating-ui-react/hooks/useHoverInteractionSharedState.ts new file mode 100644 index 00000000000..134b0a204f2 --- /dev/null +++ b/packages/react/src/floating-ui-react/hooks/useHoverInteractionSharedState.ts @@ -0,0 +1,81 @@ +import * as React from 'react'; +import { useTimeout } from '@base-ui-components/utils/useTimeout'; + +import type { ContextData, FloatingRootContext, SafePolygonOptions } from '../types'; +import { createAttribute } from '../utils/createAttribute'; +import { TYPEABLE_SELECTOR } from '../utils/constants'; +import { getEmptyContext } from './useFloatingRootContext'; + +export const safePolygonIdentifier = createAttribute('safe-polygon'); +const interactiveSelector = `button,a,[role="button"],select,[tabindex]:not([tabindex="-1"]),${TYPEABLE_SELECTOR}`; + +export function isInteractiveElement(element: Element | null) { + return element ? Boolean(element.closest(interactiveSelector)) : false; +} + +export interface HoverInteractionSharedState { + pointerTypeRef: React.RefObject; + interactedInsideRef: React.RefObject; + handlerRef: React.RefObject<((event: MouseEvent) => void) | undefined>; + blockMouseMoveRef: React.RefObject; + performedPointerEventsMutationRef: React.RefObject; + unbindMouseMoveRef: React.RefObject<() => void>; + restTimeoutPendingRef: React.RefObject; + openChangeTimeout: ReturnType; + restTimeout: ReturnType; + handleCloseOptionsRef: React.RefObject; +} + +type HoverContextData = ContextData & { + hoverInteractionState?: HoverInteractionSharedState; +}; + +export function useHoverInteractionSharedState( + context: FloatingRootContext | null, +): HoverInteractionSharedState { + const ctx = context ?? getEmptyContext(); + + const pointerTypeRef = React.useRef(undefined); + const interactedInsideRef = React.useRef(false); + const handlerRef = React.useRef<((event: MouseEvent) => void) | undefined>(undefined); + const blockMouseMoveRef = React.useRef(true); + const performedPointerEventsMutationRef = React.useRef(false); + const unbindMouseMoveRef = React.useRef<() => void>(() => {}); + const restTimeoutPendingRef = React.useRef(false); + const timeout = useTimeout(); + const restTimeout = useTimeout(); + const handleCloseOptionsRef = React.useRef(undefined); + + return React.useMemo(() => { + const data = ctx.dataRef.current as HoverContextData; + + if (!data.hoverInteractionState) { + data.hoverInteractionState = { + pointerTypeRef, + interactedInsideRef, + handlerRef, + blockMouseMoveRef, + performedPointerEventsMutationRef, + unbindMouseMoveRef, + restTimeoutPendingRef, + openChangeTimeout: timeout, + restTimeout, + handleCloseOptionsRef, + }; + } + + return data.hoverInteractionState; + }, [ + ctx.dataRef, + pointerTypeRef, + interactedInsideRef, + handlerRef, + blockMouseMoveRef, + performedPointerEventsMutationRef, + unbindMouseMoveRef, + restTimeoutPendingRef, + timeout, + restTimeout, + handleCloseOptionsRef, + ]); +} diff --git a/packages/react/src/floating-ui-react/hooks/useHoverReferenceInteraction.ts b/packages/react/src/floating-ui-react/hooks/useHoverReferenceInteraction.ts new file mode 100644 index 00000000000..053ff68a2c5 --- /dev/null +++ b/packages/react/src/floating-ui-react/hooks/useHoverReferenceInteraction.ts @@ -0,0 +1,377 @@ +import * as React from 'react'; +import { isElement } from '@floating-ui/utils/dom'; +import { useValueAsRef } from '@base-ui-components/utils/useValueAsRef'; +import { useStableCallback } from '@base-ui-components/utils/useStableCallback'; + +import type { ElementProps, FloatingRootContext } from '../types'; +import { contains, getDocument, isMouseLikePointerType } from '../utils'; +import { createChangeEventDetails } from '../../utils/createBaseUIEventDetails'; +import { REASONS } from '../../utils/reasons'; +import type { UseHoverProps } from './useHover'; +import { getDelay } from './useHover'; +import { getEmptyContext } from './useFloatingRootContext'; +import { useFloatingTree } from '../components/FloatingTree'; +import { + safePolygonIdentifier, + useHoverInteractionSharedState, +} from './useHoverInteractionSharedState'; + +export interface UseHoverReferenceInteractionProps extends UseHoverProps { + /** + * Whether the hook controls the active trigger. When false, the props are + * returned under the `trigger` key so they can be applied to inactive + * triggers via `getTriggerProps`. + * @default true + */ + isActiveTrigger?: boolean; +} + +function getRestMs(value: number | (() => number)) { + if (typeof value === 'function') { + return value(); + } + return value; +} + +/** + * Provides hover interactions that should be attached to reference or trigger + * elements. + */ +export function useHoverReferenceInteraction( + context: FloatingRootContext | null, + props: UseHoverReferenceInteractionProps = {}, +): React.HTMLProps | undefined { + const ctx = context ?? getEmptyContext(); + const { open, onOpenChange, dataRef, elements } = ctx; + const { + enabled = true, + delay = 0, + handleClose = null, + mouseOnly = false, + restMs = 0, + move = true, + triggerElement = null, + externalTree, + isActiveTrigger = true, + } = props; + + const tree = useFloatingTree(externalTree); + + const { + pointerTypeRef, + interactedInsideRef, + handlerRef: closeHandlerRef, + blockMouseMoveRef, + performedPointerEventsMutationRef, + unbindMouseMoveRef, + restTimeoutPendingRef, + openChangeTimeout, + restTimeout, + handleCloseOptionsRef, + } = useHoverInteractionSharedState(ctx); + + const handleCloseRef = useValueAsRef(handleClose); + const delayRef = useValueAsRef(delay); + const openRef = useValueAsRef(open); + const restMsRef = useValueAsRef(restMs); + + if (isActiveTrigger) { + // eslint-disable-next-line no-underscore-dangle + handleCloseOptionsRef.current = handleCloseRef.current?.__options; + } + + const isClickLikeOpenEvent = useStableCallback(() => { + if (interactedInsideRef.current) { + return true; + } + + return dataRef.current.openEvent + ? ['click', 'mousedown'].includes(dataRef.current.openEvent.type) + : false; + }); + + const closeWithDelay = React.useCallback( + (event: MouseEvent, runElseBranch = true) => { + const closeDelay = getDelay(delayRef.current, 'close', pointerTypeRef.current); + if (closeDelay && !closeHandlerRef.current) { + openChangeTimeout.start(closeDelay, () => + onOpenChange(false, createChangeEventDetails(REASONS.triggerHover, event)), + ); + } else if (runElseBranch) { + openChangeTimeout.clear(); + onOpenChange(false, createChangeEventDetails(REASONS.triggerHover, event)); + } + }, + [delayRef, closeHandlerRef, onOpenChange, pointerTypeRef, openChangeTimeout], + ); + + const cleanupMouseMoveHandler = useStableCallback(() => { + unbindMouseMoveRef.current(); + closeHandlerRef.current = undefined; + }); + + const clearPointerEvents = useStableCallback(() => { + if (performedPointerEventsMutationRef.current) { + const body = getDocument(elements.floating).body; + body.style.pointerEvents = ''; + body.removeAttribute(safePolygonIdentifier); + performedPointerEventsMutationRef.current = false; + } + }); + + const handleScrollMouseLeave = useStableCallback((event: MouseEvent) => { + if (isClickLikeOpenEvent()) { + return; + } + if (!dataRef.current.floatingContext) { + return; + } + if ( + event.relatedTarget && + elements.triggers && + elements.triggers.includes(event.relatedTarget as Element) + ) { + return; + } + + handleCloseRef.current?.({ + ...dataRef.current.floatingContext, + tree, + x: event.clientX, + y: event.clientY, + onClose() { + clearPointerEvents(); + cleanupMouseMoveHandler(); + if (!isClickLikeOpenEvent()) { + closeWithDelay(event); + } + }, + })(event); + }); + + React.useEffect(() => { + if (!enabled) { + return undefined; + } + + const trigger = + (triggerElement as HTMLElement | null) ?? + (isActiveTrigger ? (elements.domReference as HTMLElement | null) : null); + + if (!isElement(trigger)) { + return undefined; + } + + function onMouseEnter(event: MouseEvent) { + openChangeTimeout.clear(); + blockMouseMoveRef.current = false; + + if ( + (mouseOnly && !isMouseLikePointerType(pointerTypeRef.current)) || + (getRestMs(restMsRef.current) > 0 && !getDelay(delayRef.current, 'open')) + ) { + return; + } + + const openDelay = getDelay(delayRef.current, 'open', pointerTypeRef.current); + const triggerNode = (event.currentTarget as HTMLElement) ?? undefined; + + const isOverInactiveTrigger = + elements.domReference && triggerNode && !contains(elements.domReference, triggerNode); + + if (openDelay) { + openChangeTimeout.start(openDelay, () => { + if (!openRef.current) { + onOpenChange(true, createChangeEventDetails(REASONS.triggerHover, event, triggerNode)); + } + }); + } else if (!open || isOverInactiveTrigger) { + onOpenChange(true, createChangeEventDetails(REASONS.triggerHover, event, triggerNode)); + } + } + + function onMouseLeave(event: MouseEvent) { + if (isClickLikeOpenEvent()) { + clearPointerEvents(); + return; + } + + unbindMouseMoveRef.current(); + + const doc = getDocument(elements.floating); + restTimeout.clear(); + restTimeoutPendingRef.current = false; + + if ( + event.relatedTarget && + elements.triggers && + elements.triggers.includes(event.relatedTarget as Element) + ) { + return; + } + + if (handleCloseRef.current && dataRef.current.floatingContext) { + if (!open) { + openChangeTimeout.clear(); + } + + closeHandlerRef.current = handleCloseRef.current({ + ...dataRef.current.floatingContext, + tree, + x: event.clientX, + y: event.clientY, + onClose() { + clearPointerEvents(); + cleanupMouseMoveHandler(); + if (!isClickLikeOpenEvent()) { + closeWithDelay(event, true); + } + }, + }); + + const handler = closeHandlerRef.current; + + doc.addEventListener('mousemove', handler); + unbindMouseMoveRef.current = () => { + doc.removeEventListener('mousemove', handler); + }; + + return; + } + + const shouldClose = + pointerTypeRef.current === 'touch' + ? !contains(elements.floating, event.relatedTarget as Element | null) + : true; + if (shouldClose) { + closeWithDelay(event); + } + } + + function onScrollMouseLeave(event: MouseEvent) { + handleScrollMouseLeave(event); + } + + if (open) { + trigger.addEventListener('mouseleave', onScrollMouseLeave); + } + + if (move) { + trigger.addEventListener('mousemove', onMouseEnter, { + once: true, + }); + } + + trigger.addEventListener('mouseenter', onMouseEnter); + trigger.addEventListener('mouseleave', onMouseLeave); + + return () => { + if (open) { + trigger.removeEventListener('mouseleave', onScrollMouseLeave); + } + + if (move) { + trigger.removeEventListener('mousemove', onMouseEnter); + } + + trigger.removeEventListener('mouseenter', onMouseEnter); + trigger.removeEventListener('mouseleave', onMouseLeave); + }; + }, [ + cleanupMouseMoveHandler, + clearPointerEvents, + blockMouseMoveRef, + dataRef, + delayRef, + closeWithDelay, + elements.domReference, + elements.floating, + elements.triggers, + enabled, + handleCloseRef, + handleScrollMouseLeave, + isActiveTrigger, + isClickLikeOpenEvent, + mouseOnly, + move, + onOpenChange, + open, + openRef, + pointerTypeRef, + restMsRef, + restTimeout, + restTimeoutPendingRef, + openChangeTimeout, + triggerElement, + tree, + unbindMouseMoveRef, + closeHandlerRef, + ]); + + const referenceProps = React.useMemo(() => { + function setPointerRef(event: React.PointerEvent) { + pointerTypeRef.current = event.pointerType; + } + + return { + onPointerDown: setPointerRef, + onPointerEnter: setPointerRef, + onMouseMove(event) { + const { nativeEvent } = event; + const trigger = event.currentTarget as HTMLElement; + + const isOverInactiveTrigger = + elements.domReference && !contains(elements.domReference, event.target as Element); + + function handleMouseMove() { + if (!blockMouseMoveRef.current && (!openRef.current || isOverInactiveTrigger)) { + onOpenChange( + true, + createChangeEventDetails(REASONS.triggerHover, nativeEvent, trigger), + ); + } + } + + if (mouseOnly && !isMouseLikePointerType(pointerTypeRef.current)) { + return; + } + + if ((open && !isOverInactiveTrigger) || getRestMs(restMsRef.current) === 0) { + return; + } + + if ( + !isOverInactiveTrigger && + restTimeoutPendingRef.current && + event.movementX ** 2 + event.movementY ** 2 < 2 + ) { + return; + } + + restTimeout.clear(); + + if (pointerTypeRef.current === 'touch') { + handleMouseMove(); + } else if (isOverInactiveTrigger) { + handleMouseMove(); + } else { + restTimeoutPendingRef.current = true; + restTimeout.start(getRestMs(restMsRef.current), handleMouseMove); + } + }, + }; + }, [ + blockMouseMoveRef, + elements.domReference, + mouseOnly, + onOpenChange, + open, + openRef, + pointerTypeRef, + restMsRef, + restTimeout, + restTimeoutPendingRef, + ]); + + return referenceProps; +} diff --git a/packages/react/src/floating-ui-react/hooks/useListNavigation.ts b/packages/react/src/floating-ui-react/hooks/useListNavigation.ts index 20775e20bd9..6fbe8fc8f62 100644 --- a/packages/react/src/floating-ui-react/hooks/useListNavigation.ts +++ b/packages/react/src/floating-ui-react/hooks/useListNavigation.ts @@ -23,7 +23,11 @@ import { findNonDisabledListIndex, } from '../utils'; -import { useFloatingParentNodeId, useFloatingTree } from '../components/FloatingTree'; +import { + FloatingTreeStore, + useFloatingParentNodeId, + useFloatingTree, +} from '../components/FloatingTree'; import type { Dimensions, ElementProps, FloatingRootContext } from '../types'; import { createChangeEventDetails } from '../../utils/createBaseUIEventDetails'; import { REASONS } from '../../utils/reasons'; @@ -225,6 +229,10 @@ export interface UseListNavigationProps { * The id of the root component. */ id?: string | undefined; + /** + * External FlatingTree to use when the one provided by context can't be used. + */ + externalTree?: FloatingTreeStore; } /** @@ -259,6 +267,7 @@ export function useListNavigation( itemSizes, dense = false, id, + externalTree, } = props; if (process.env.NODE_ENV !== 'production') { @@ -284,7 +293,7 @@ export function useListNavigation( const floatingFocusElementRef = useValueAsRef(floatingFocusElement); const parentId = useFloatingParentNodeId(); - const tree = useFloatingTree(); + const tree = useFloatingTree(externalTree); useIsoLayoutEffect(() => { context.dataRef.current.orientation = orientation; @@ -796,7 +805,7 @@ export function useListNavigation( // Close submenu on Shift+Tab if (event.key === 'Tab' && event.shiftKey && open && !virtual) { stopEvent(event); - onOpenChange(false, createChangeEventDetails(REASONS.listNavigation, event.nativeEvent)); + onOpenChange(false, createChangeEventDetails(REASONS.focusOut, event.nativeEvent)); if (isHTMLElement(elements.domReference)) { elements.domReference.focus(); @@ -879,7 +888,11 @@ export function useListNavigation( } else { onOpenChange( true, - createChangeEventDetails(REASONS.listNavigation, event.nativeEvent), + createChangeEventDetails( + REASONS.listNavigation, + event.nativeEvent, + event.currentTarget as HTMLElement, + ), ); } } @@ -895,7 +908,14 @@ export function useListNavigation( stopEvent(event); if (!open && openOnArrowKeyDown) { - onOpenChange(true, createChangeEventDetails(REASONS.listNavigation, event.nativeEvent)); + onOpenChange( + true, + createChangeEventDetails( + REASONS.listNavigation, + event.nativeEvent, + event.currentTarget as HTMLElement, + ), + ); } else { commonOnKeyDown(event); } @@ -937,7 +957,7 @@ export function useListNavigation( ]); return React.useMemo( - () => (enabled ? { reference, floating, item } : {}), + () => (enabled ? { reference, floating, item, trigger: reference } : {}), [enabled, reference, floating, item], ); } diff --git a/packages/react/src/floating-ui-react/hooks/useRole.ts b/packages/react/src/floating-ui-react/hooks/useRole.ts index 3f672328229..73929cf71b3 100644 --- a/packages/react/src/floating-ui-react/hooks/useRole.ts +++ b/packages/react/src/floating-ui-react/hooks/useRole.ts @@ -93,7 +93,9 @@ export function useRole(context: FloatingRootContext, props: UseRoleProps = {}): return { ...floatingProps, - ...(ariaRole === 'menu' && { 'aria-labelledby': referenceId }), + ...(ariaRole === 'menu' && { + 'aria-labelledby': referenceId, + }), }; }, [ariaRole, floatingId, referenceId, role]); diff --git a/packages/react/src/floating-ui-react/index.ts b/packages/react/src/floating-ui-react/index.ts index 67c9f70febb..2da009f3fa4 100644 --- a/packages/react/src/floating-ui-react/index.ts +++ b/packages/react/src/floating-ui-react/index.ts @@ -14,6 +14,8 @@ export { useDismiss } from './hooks/useDismiss'; export { useFloating } from './hooks/useFloating'; export { useFloatingRootContext } from './hooks/useFloatingRootContext'; export { useFocus } from './hooks/useFocus'; +export { useHoverFloatingInteraction } from './hooks/useHoverFloatingInteraction'; +export { useHoverReferenceInteraction } from './hooks/useHoverReferenceInteraction'; export { useHover } from './hooks/useHover'; export { useInteractions } from './hooks/useInteractions'; export { useListNavigation } from './hooks/useListNavigation'; diff --git a/packages/react/src/floating-ui-react/types.ts b/packages/react/src/floating-ui-react/types.ts index 7f3f9d0acec..d305d07c346 100644 --- a/packages/react/src/floating-ui-react/types.ts +++ b/packages/react/src/floating-ui-react/types.ts @@ -7,6 +7,7 @@ import type * as React from 'react'; import type { BaseUIChangeEventDetails } from '../utils/createBaseUIEventDetails'; import type { ExtendedUserProps } from './hooks/useInteractions'; +import type { FloatingTreeStore } from './components/FloatingTree'; export * from '.'; export type { FloatingDelayGroupProps } from './components/FloatingDelayGroup'; @@ -16,6 +17,8 @@ export type { UseClientPointProps } from './hooks/useClientPoint'; export type { UseDismissProps } from './hooks/useDismiss'; export type { UseFocusProps } from './hooks/useFocus'; export type { UseHoverProps, HandleCloseContext, HandleClose } from './hooks/useHover'; +export type { UseHoverFloatingInteractionProps } from './hooks/useHoverFloatingInteraction'; +export type { UseHoverReferenceInteractionProps } from './hooks/useHoverReferenceInteraction'; export type { UseListNavigationProps } from './hooks/useListNavigation'; export type { UseRoleProps } from './hooks/useRole'; export type { UseTypeaheadProps } from './hooks/useTypeahead'; @@ -156,12 +159,7 @@ export interface FloatingNodeType { context?: FloatingContext; } -export interface FloatingTreeType { - nodesRef: React.MutableRefObject>>; - events: FloatingEvents; - addNode(node: FloatingNodeType): void; - removeNode(node: FloatingNodeType): void; -} +export type FloatingTreeType = FloatingTreeStore; export interface ElementProps { reference?: React.HTMLProps; @@ -215,4 +213,8 @@ export interface UseFloatingOptions * Unique node id when using `FloatingTree`. */ nodeId?: string; + /** + * External FlatingTree to use when the one provided by context can't be used. + */ + externalTree?: FloatingTreeStore; } diff --git a/packages/react/src/floating-ui-react/utils/createEventEmitter.ts b/packages/react/src/floating-ui-react/utils/createEventEmitter.ts index 5f7c4ad94cc..94e9f818731 100644 --- a/packages/react/src/floating-ui-react/utils/createEventEmitter.ts +++ b/packages/react/src/floating-ui-react/utils/createEventEmitter.ts @@ -1,4 +1,6 @@ -export function createEventEmitter() { +import { FloatingEvents } from '../types'; + +export function createEventEmitter(): FloatingEvents { const map = new Map void>>(); return { emit(event: string, data: any) { diff --git a/packages/react/src/menu/backdrop/MenuBackdrop.test.tsx b/packages/react/src/menu/backdrop/MenuBackdrop.test.tsx index 04fba5c0213..52fdcdcf54a 100644 --- a/packages/react/src/menu/backdrop/MenuBackdrop.test.tsx +++ b/packages/react/src/menu/backdrop/MenuBackdrop.test.tsx @@ -14,8 +14,10 @@ describe('', () => { it('sets `pointer-events: none` style on backdrop if opened by hover', async () => { const { user } = await render( - - Open + + + Open + @@ -32,8 +34,8 @@ describe('', () => { it('does not set `pointer-events: none` style on backdrop if opened by click', async () => { const { user } = await render( - - Open + + Open diff --git a/packages/react/src/menu/checkbox-item/MenuCheckboxItem.tsx b/packages/react/src/menu/checkbox-item/MenuCheckboxItem.tsx index bb8d0aae1bb..f83f8000e06 100644 --- a/packages/react/src/menu/checkbox-item/MenuCheckboxItem.tsx +++ b/packages/react/src/menu/checkbox-item/MenuCheckboxItem.tsx @@ -2,7 +2,6 @@ import * as React from 'react'; import { useStableCallback } from '@base-ui-components/utils/useStableCallback'; import { useControlled } from '@base-ui-components/utils/useControlled'; -import { useFloatingTree } from '../../floating-ui-react'; import { MenuCheckboxItemContext } from './MenuCheckboxItemContext'; import { REGULAR_ITEM, useMenuItem } from '../item/useMenuItem'; import { useCompositeListItem } from '../../composite/list/useCompositeListItem'; @@ -43,7 +42,6 @@ export const MenuCheckboxItem = React.forwardRef(function MenuCheckboxItem( const listItem = useCompositeListItem({ label }); const menuPositionerContext = useMenuPositionerContext(true); const id = useBaseUiId(idProp); - const { events: menuEvents } = useFloatingTree()!; const { store } = useMenuRootContext(); const highlighted = store.useState('isActive', listItem.index); @@ -61,10 +59,9 @@ export const MenuCheckboxItem = React.forwardRef(function MenuCheckboxItem( disabled, highlighted, id, - menuEvents, store, nativeButton, - nodeId: menuPositionerContext?.floatingContext.nodeId, + nodeId: menuPositionerContext?.nodeId, itemMetadata: REGULAR_ITEM, }); @@ -78,7 +75,10 @@ export const MenuCheckboxItem = React.forwardRef(function MenuCheckboxItem( ); const handleClick = useStableCallback((event: React.MouseEvent) => { - const details = createChangeEventDetails(REASONS.itemPress, event.nativeEvent); + const details = { + ...createChangeEventDetails(REASONS.itemPress, event.nativeEvent), + preventUnmountOnClose: () => {}, + }; onCheckedChange?.(!checked, details); diff --git a/packages/react/src/menu/index.parts.ts b/packages/react/src/menu/index.parts.ts index bd3a873df9f..da523acbf93 100644 --- a/packages/react/src/menu/index.parts.ts +++ b/packages/react/src/menu/index.parts.ts @@ -16,3 +16,4 @@ export { MenuSubmenuRoot as SubmenuRoot } from './submenu-root/MenuSubmenuRoot'; export { MenuTrigger as Trigger } from './trigger/MenuTrigger'; export { Separator } from '../separator/Separator'; export { MenuSubmenuTrigger as SubmenuTrigger } from './submenu-trigger/MenuSubmenuTrigger'; +export { MenuHandle as Handle, createMenuHandle as createHandle } from './store/MenuHandle'; diff --git a/packages/react/src/menu/item/MenuItem.tsx b/packages/react/src/menu/item/MenuItem.tsx index bf24b25c4cf..c4c63b51c7d 100644 --- a/packages/react/src/menu/item/MenuItem.tsx +++ b/packages/react/src/menu/item/MenuItem.tsx @@ -1,6 +1,5 @@ 'use client'; import * as React from 'react'; -import { useFloatingTree } from '../../floating-ui-react'; import { REGULAR_ITEM, useMenuItem } from './useMenuItem'; import { useMenuRootContext } from '../root/MenuRootContext'; import { useRenderElement } from '../../utils/useRenderElement'; @@ -33,7 +32,6 @@ export const MenuItem = React.forwardRef(function MenuItem( const listItem = useCompositeListItem({ label }); const menuPositionerContext = useMenuPositionerContext(true); const id = useBaseUiId(idProp); - const { events: menuEvents } = useFloatingTree()!; const { store } = useMenuRootContext(); const highlighted = store.useState('isActive', listItem.index); @@ -44,10 +42,9 @@ export const MenuItem = React.forwardRef(function MenuItem( disabled, highlighted, id, - menuEvents, store, nativeButton, - nodeId: menuPositionerContext?.floatingContext.nodeId, + nodeId: menuPositionerContext?.nodeId, itemMetadata: REGULAR_ITEM, }); diff --git a/packages/react/src/menu/item/useMenuItem.ts b/packages/react/src/menu/item/useMenuItem.ts index 4e4b252a25b..3646607938f 100644 --- a/packages/react/src/menu/item/useMenuItem.ts +++ b/packages/react/src/menu/item/useMenuItem.ts @@ -1,7 +1,6 @@ 'use client'; import * as React from 'react'; import { useMergedRefs } from '@base-ui-components/utils/useMergedRefs'; -import { FloatingEvents } from '../../floating-ui-react'; import { useButton } from '../../use-button'; import { mergeProps } from '../../merge-props'; import { HTMLProps, BaseUIEvent } from '../../utils/types'; @@ -20,7 +19,6 @@ export function useMenuItem(params: useMenuItem.Parameters): useMenuItem.ReturnV highlighted, id, store, - menuEvents, nativeButton, itemMetadata, nodeId, @@ -29,6 +27,7 @@ export function useMenuItem(params: useMenuItem.Parameters): useMenuItem.ReturnV const itemRef = React.useRef(null); const contextMenuContext = useContextMenuRootContext(true); const isContextMenu = contextMenuContext !== undefined; + const { events: menuEvents } = store.useState('floatingTreeRoot'); const { getButtonProps, buttonRef } = useButton({ disabled, @@ -131,10 +130,6 @@ export interface UseMenuItemParameters { * The id of the menu item. */ id: string | undefined; - /** - * The FloatingEvents instance of the menu's root. - */ - menuEvents: FloatingEvents; /** * Whether the component renders a native ` + + + + ); + } + + const { user } = await render(); + + await user.click(screen.getByRole('button', { name: 'Open Trigger 1' })); + await waitFor(() => { + expect(screen.getByTestId('content').textContent).to.equal('1'); + }); + + await user.click(screen.getByRole('button', { name: 'Open Trigger 2' })); + await waitFor(() => { + expect(screen.getByTestId('content').textContent).to.equal('2'); + }); + + await user.click(screen.getByRole('button', { name: 'Close' })); + await waitFor(() => { + expect(screen.queryByTestId('content')).to.equal(null); + }); + }); + + it('allows setting an initially open menu', async () => { + await render( + + {({ payload }: NumberPayload) => ( + + + Trigger 1 + + + Trigger 2 + + + + + {payload} + + + + + )} + , + ); + + expect(screen.getByTestId('popup-content').textContent).to.equal('2'); + }); + + describe('nested menus', () => { + it('supports keyboard navigation from any trigger', async () => { + const { user } = await render( + + Trigger 1 + Trigger 2 + + + + + Standalone + + More + + + + Nested + + + + + + + + , + ); + + const trigger1 = screen.getByRole('button', { name: 'Trigger 1' }); + const trigger2 = screen.getByRole('button', { name: 'Trigger 2' }); + + await user.click(trigger1); + await screen.findByTestId('menu'); + + await user.keyboard('[ArrowDown]'); + await user.keyboard('[ArrowDown]'); + + const submenuTrigger = await screen.findByTestId('submenu-trigger'); + await waitFor(() => { + expect(submenuTrigger).toHaveFocus(); + }); + + await user.keyboard('[ArrowRight]'); + + const submenuItem = await screen.findByTestId('submenu-item'); + await waitFor(() => { + expect(submenuItem).toHaveFocus(); + }); + + await user.keyboard('[ArrowLeft]'); + await waitFor(() => { + expect(screen.queryByTestId('submenu')).to.equal(null); + }); + expect(submenuTrigger).toHaveFocus(); + + await user.keyboard('[Escape]'); + await waitFor(() => { + expect(screen.queryByTestId('menu')).to.equal(null); + }); + + await user.click(trigger2); + await screen.findByTestId('menu'); + }); + + it('opens a submenu with the mouse when hover is disabled', async () => { + const { user } = await render( + + Trigger 1 + Trigger 2 + + + + + Standalone + + + More + + + + + Nested + + + + + + + + , + ); + + const trigger1 = screen.getByRole('button', { name: 'Trigger 1' }); + const trigger2 = screen.getByRole('button', { name: 'Trigger 2' }); + + await user.click(trigger1); + await screen.findByTestId('menu'); + expect(screen.queryByTestId('submenu')).to.equal(null); + + const submenuTrigger = screen.getByTestId('submenu-trigger'); + await user.click(submenuTrigger); + + const submenuItem = await screen.findByTestId('submenu-item'); + expect(submenuItem.textContent).to.equal('Nested'); + + await user.click(submenuItem); + await waitFor(() => { + expect(screen.queryByTestId('menu')).to.equal(null); + }); + + await user.click(trigger2); + await screen.findByTestId('menu'); + expect(screen.queryByTestId('submenu')).to.equal(null); + }); + + it('closes every level when clicking outside the deepest submenu', async () => { + const { user } = await render( +
+ + Trigger 1 + Trigger 2 + + + + Item 1 + + + Level 2 + + + + + Item 2 + + + Level 3 + + + + + Deep Item + + + + + + + + + + + + + +
, + ); + + const trigger = screen.getByRole('button', { name: 'Trigger 1' }); + await user.click(trigger); + await screen.findByTestId('level-1'); + + await user.keyboard('[ArrowDown]'); + await user.keyboard('[ArrowDown]'); + + const submenuTrigger1 = await screen.findByTestId('submenu-trigger-1'); + await waitFor(() => { + expect(submenuTrigger1).toHaveFocus(); + }); + + await user.keyboard('[ArrowRight]'); + await screen.findByTestId('level-2'); + + await user.keyboard('[ArrowDown]'); + const submenuTrigger2 = await screen.findByTestId('submenu-trigger-2'); + await waitFor(() => { + expect(submenuTrigger2).toHaveFocus(); + }); + + await user.keyboard('[ArrowRight]'); + await screen.findByTestId('level-3'); + + await user.click(screen.getByTestId('outside')); + await waitFor(() => { + expect(screen.queryByTestId('level-1')).to.equal(null); + expect(screen.queryByTestId('level-2')).to.equal(null); + expect(screen.queryByTestId('level-3')).to.equal(null); + }); + }); + + it('allows selecting nested items via click, drag, release', async () => { + const clickSpy = spy(); + const { user } = await render( + + Trigger 1 + Trigger 2 + + + + + Item 1 + + More + + + + + Nested Action + + + + + + + + + , + ); + + const trigger1 = screen.getByRole('button', { name: 'Trigger 1' }); + fireEvent.mouseDown(trigger1); + + await screen.findByTestId('menu'); + + const submenuTrigger = await screen.findByTestId('submenu-trigger'); + await user.hover(submenuTrigger); + await screen.findByTestId('submenu'); + + // Wait 200ms to enable mouseup on menu items + await wait(200); + + const submenuItem = await screen.findByTestId('submenu-item'); + fireEvent.mouseUp(submenuItem); + + await waitFor(() => { + expect(screen.queryByTestId('menu')).to.equal(null); + }); + expect(clickSpy.callCount).to.equal(1); + + const trigger2 = screen.getByRole('button', { name: 'Trigger 2' }); + await user.click(trigger2); + await screen.findByTestId('menu'); + }); + }); + }); + + describe.skipIf(isJSDOM)('multiple detached triggers', () => { + type NumberPayload = { payload: number | undefined }; + + it('should open the menu with any trigger', async () => { + const testMenu = Menu.createHandle(); + const { user } = await render( +
+ Trigger 1 + Trigger 2 + Trigger 3 + + + + + + Close + + + + +
, + ); + + const trigger1 = screen.getByRole('button', { name: 'Trigger 1' }); + const trigger2 = screen.getByRole('button', { name: 'Trigger 2' }); + const trigger3 = screen.getByRole('button', { name: 'Trigger 3' }); + + expect(screen.queryByRole('menu')).to.equal(null); + + await user.click(trigger1); + await screen.findByRole('menu'); + await user.click(await screen.findByRole('menuitem', { name: 'Close' })); + await waitFor(() => { + expect(screen.queryByRole('menu')).to.equal(null); + }); + + await user.click(trigger2); + await screen.findByRole('menu'); + await user.click(await screen.findByRole('menuitem', { name: 'Close' })); + await waitFor(() => { + expect(screen.queryByRole('menu')).to.equal(null); + }); + + await user.click(trigger3); + await screen.findByRole('menu'); + await user.click(await screen.findByRole('menuitem', { name: 'Close' })); + await waitFor(() => { + expect(screen.queryByRole('menu')).to.equal(null); + }); + }); + + it('should set the payload and render content based on its value', async () => { + const testMenu = Menu.createHandle(); + const { user } = await render( +
+ + Trigger 1 + + + Trigger 2 + + + + {({ payload }: NumberPayload) => ( + + + + {payload} + + + + )} + +
, + ); + + const trigger1 = screen.getByRole('button', { name: 'Trigger 1' }); + const trigger2 = screen.getByRole('button', { name: 'Trigger 2' }); + + await user.click(trigger1); + await waitFor(() => { + expect(screen.getByTestId('content').textContent).to.equal('1'); + }); + + await user.click(screen.getByTestId('content')); + await waitFor(() => { + expect(screen.queryByRole('menu')).to.equal(null); + }); + + await user.click(trigger2); + await waitFor(() => { + expect(screen.getByTestId('content').textContent).to.equal('2'); + }); + }); + + it('should reuse the popup and positioner DOM nodes when switching triggers', async () => { + const testMenu = Menu.createHandle(); + const { user } = await render( + + + Trigger 1 + + + Trigger 2 + + + + {({ payload }: NumberPayload) => ( + + + + {payload} + + + + )} + + , + ); + + const trigger1 = screen.getByRole('button', { name: 'Trigger 1' }); + const trigger2 = screen.getByRole('button', { name: 'Trigger 2' }); + + await user.click(trigger1); + await screen.findByRole('menu'); + const popupElement = screen.getByTestId('popup'); + const positionerElement = screen.getByTestId('positioner'); + + await user.click(trigger2); + await screen.findByRole('menu'); + + expect(screen.getByTestId('popup')).to.equal(popupElement); + expect(screen.getByTestId('positioner')).to.equal(positionerElement); + }); + + it('should allow controlling the menu state programmatically', async () => { + const testMenu = Menu.createHandle(); + + function Test() { + const [open, setOpen] = React.useState(false); + const [activeTrigger, setActiveTrigger] = React.useState(null); + + return ( +
+ + Trigger 1 + + + Trigger 2 + + + { + setActiveTrigger(details.trigger?.id ?? null); + setOpen(nextOpen); + }} + triggerId={activeTrigger} + handle={testMenu} + > + {({ payload }: NumberPayload) => ( + + + + {payload} + + + + )} + + + + + +
+ ); + } + + const { user } = await render(); + + const trigger1 = screen.getByRole('button', { name: 'Trigger 1' }); + const trigger2 = screen.getByRole('button', { name: 'Trigger 2' }); + + await user.click(screen.getByRole('button', { name: 'Open Trigger 1' })); + await waitFor(() => { + expect(screen.getByTestId('content').textContent).to.equal('1'); + }); + + await waitFor(() => { + const positionerLeft = screen.getByTestId('positioner').getBoundingClientRect().left; + expect(positionerLeft).to.be.closeTo(trigger1.getBoundingClientRect().left, 1); + }); + + await user.click(screen.getByRole('button', { name: 'Open Trigger 2' })); + await waitFor(() => { + expect(screen.getByTestId('content').textContent).to.equal('2'); + }); + await waitFor(() => { + const positionerLeft = screen.getByTestId('positioner').getBoundingClientRect().left; + expect(positionerLeft).to.be.closeTo(trigger2.getBoundingClientRect().left, 1); + }); + + await user.click(screen.getByRole('button', { name: 'Close' })); + await waitFor(() => { + expect(screen.queryByTestId('content')).to.equal(null); + }); + }); + + it('allows setting an initially open menu', async () => { + const testMenu = Menu.createHandle(); + await render( + + {({ payload }: NumberPayload) => ( + + + Trigger 1 + + + Trigger 2 + + + + + {payload} + + + + + )} + , + ); + + expect(screen.getByTestId('popup-content').textContent).to.equal('2'); + }); + + describe('nested menus', () => { + it('supports keyboard navigation regardless of which trigger opened the menu', async () => { + const testMenu = Menu.createHandle(); + const { user } = await render( +
+ Trigger 1 + Trigger 2 + + + + + + Standalone + + More + + + + Nested + + + + + + + + +
, + ); + + const trigger1 = screen.getByRole('button', { name: 'Trigger 1' }); + const trigger2 = screen.getByRole('button', { name: 'Trigger 2' }); + + await user.click(trigger1); + await screen.findByTestId('menu'); + + await user.keyboard('[ArrowDown]'); + await user.keyboard('[ArrowDown]'); + + const submenuTrigger = await screen.findByTestId('submenu-trigger'); + await waitFor(() => { + expect(submenuTrigger).toHaveFocus(); + }); + + await user.keyboard('[ArrowRight]'); + const submenuItem = await screen.findByTestId('submenu-item'); + await waitFor(() => expect(submenuItem).toHaveFocus()); + + await user.keyboard('[ArrowLeft]'); + await waitFor(() => { + expect(screen.queryByTestId('submenu')).to.equal(null); + }); + expect(submenuTrigger).toHaveFocus(); + + await user.keyboard('[Escape]'); + await waitFor(() => { + expect(screen.queryByTestId('menu')).to.equal(null); + }); + + await user.click(trigger2); + await screen.findByTestId('menu'); + }); + + it('opens submenus on click when hover is disabled', async () => { + const testMenu = Menu.createHandle(); + const { user } = await render( +
+ Trigger 1 + Trigger 2 + + + + + + Standalone + + + More + + + + + Nested + + + + + + + + +
, + ); + + const trigger1 = screen.getByRole('button', { name: 'Trigger 1' }); + const trigger2 = screen.getByRole('button', { name: 'Trigger 2' }); + + await user.click(trigger1); + await screen.findByTestId('menu'); + expect(screen.queryByTestId('submenu')).to.equal(null); + + const submenuTrigger = screen.getByTestId('submenu-trigger'); + await user.click(submenuTrigger); + + const submenuItem = await screen.findByTestId('submenu-item'); + expect(submenuItem.textContent).to.equal('Nested'); + + await user.click(submenuItem); + await waitFor(() => { + expect(screen.queryByTestId('menu')).to.equal(null); + }); + + await user.click(trigger2); + await screen.findByTestId('menu'); + expect(screen.queryByTestId('submenu')).to.equal(null); + }); + + it('closes the nested tree on outside click', async () => { + const testMenu = Menu.createHandle(); + const { user } = await render( +
+ Trigger 1 + Trigger 2 + + + + + + Item 1 + + + Level 2 + + + + + Item 2 + + + Level 3 + + + + + Deep Item + + + + + + + + + + + + + +
, + ); + + const trigger = screen.getByRole('button', { name: 'Trigger 1' }); + await user.click(trigger); + await screen.findByTestId('level-1'); + + await user.keyboard('[ArrowDown]'); + await user.keyboard('[ArrowDown]'); + + const submenuTrigger1 = await screen.findByTestId('submenu-trigger-1'); + await waitFor(() => expect(submenuTrigger1).toHaveFocus()); + + await user.keyboard('[ArrowRight]'); + await screen.findByTestId('level-2'); + + await user.keyboard('[ArrowDown]'); + const submenuTrigger2 = await screen.findByTestId('submenu-trigger-2'); + await waitFor(() => expect(submenuTrigger2).toHaveFocus()); + + await user.keyboard('[ArrowRight]'); + await screen.findByTestId('level-3'); + + await user.click(screen.getByTestId('outside')); + await waitFor(() => { + expect(screen.queryByTestId('level-1')).to.equal(null); + expect(screen.queryByTestId('level-2')).to.equal(null); + expect(screen.queryByTestId('level-3')).to.equal(null); + }); + }); + + it('selects nested items with click, drag, release', async () => { + const testMenu = Menu.createHandle(); + const clickSpy = spy(); + const { user } = await render( +
+ Trigger 1 + Trigger 2 + + + + + + Item 1 + + More + + + + + Nested Action + + + + + + + + + +
, + ); + + const trigger1 = screen.getByRole('button', { name: 'Trigger 1' }); + fireEvent.mouseDown(trigger1); + await screen.findByTestId('menu'); + + const submenuTrigger = await screen.findByTestId('submenu-trigger'); + await user.hover(submenuTrigger); + await screen.findByTestId('submenu'); + + // Wait 200ms to enable mouseup on menu items + await wait(200); + + const submenuItem = await screen.findByTestId('submenu-item'); + fireEvent.mouseUp(submenuItem); + + await waitFor(() => { + expect(screen.queryByTestId('menu')).to.equal(null); + }); + expect(clickSpy.callCount).to.equal(1); + + const trigger2 = screen.getByRole('button', { name: 'Trigger 2' }); + await user.click(trigger2); + await screen.findByTestId('menu'); + }); + }); + }); + + describe.skipIf(isJSDOM)('imperative actions on the handle', () => { + type NumberPayload = { payload: number | undefined }; + + it('opens and closes the menu', async () => { + const menuHandle = Menu.createHandle(); + await render( +
+ + Trigger + + + + + + Content + + + + +
, + ); + + const trigger = screen.getByRole('button', { name: 'Trigger' }); + expect(screen.queryByRole('menu')).to.equal(null); + + await act(async () => { + menuHandle.open('trigger'); + }); + await waitFor(() => { + expect(screen.queryByRole('menu')).not.to.equal(null); + }); + + expect(screen.getByTestId('content').textContent).to.equal('Content'); + expect(trigger).to.have.attribute('aria-expanded', 'true'); + + await act(async () => { + menuHandle.close(); + }); + await waitFor(() => { + expect(screen.queryByRole('menu')).to.equal(null); + }); + + expect(trigger).to.have.attribute('aria-expanded', 'false'); + }); + + it('sets the payload associated with the trigger', async () => { + const menuHandle = Menu.createHandle(); + await render( +
+ + Trigger 1 + + + Trigger 2 + + + {({ payload }: NumberPayload) => ( + + + + {payload} + + + + )} + +
, + ); + + const trigger1 = screen.getByRole('button', { name: 'Trigger 1' }); + const trigger2 = screen.getByRole('button', { name: 'Trigger 2' }); + expect(screen.queryByRole('menu')).to.equal(null); + + await act(async () => { + menuHandle.open('trigger2'); + }); + await waitFor(() => { + expect(screen.queryByRole('menu')).not.to.equal(null); + }); + + expect(screen.getByTestId('content').textContent).to.equal('2'); + expect(trigger2).to.have.attribute('aria-expanded', 'true'); + expect(trigger1).not.to.have.attribute('aria-expanded', 'true'); + + await act(async () => { + menuHandle.close(); + }); + await waitFor(() => { + expect(screen.queryByRole('menu')).to.equal(null); + }); + + expect(trigger2).to.have.attribute('aria-expanded', 'false'); + }); + }); +}); diff --git a/packages/react/src/menu/root/MenuRoot.test.tsx b/packages/react/src/menu/root/MenuRoot.test.tsx index 5d0354b8a5d..cbd6a1ad063 100644 --- a/packages/react/src/menu/root/MenuRoot.test.tsx +++ b/packages/react/src/menu/root/MenuRoot.test.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { expect } from 'chai'; import { act, flushMicrotasks, waitFor, screen, fireEvent } from '@mui/internal-test-utils'; import { DirectionProvider } from '@base-ui-components/react/direction-provider'; +import { useRefWithInit } from '@base-ui-components/utils/useRefWithInit'; import { Menu } from '@base-ui-components/react/menu'; import userEvent from '@testing-library/user-event'; import { spy } from 'sinon'; @@ -39,990 +40,815 @@ describe('', () => { expectedPopupRole: 'menu', }); - describe('BaseUIChangeEventDetails', () => { - it('onOpenChange cancel() prevents opening while uncontrolled', async () => { - await render( - { - if (nextOpen) { - eventDetails.cancel(); - } - }} - > - Open menu - - - - Item - - - - , - ); - - const trigger = screen.getByRole('button', { name: 'Open menu' }); - await userEvent.click(trigger); - - await waitFor(() => { - expect(screen.queryByRole('menu')).to.equal(null); - }); - }); - }); - - describe('keyboard navigation', () => { - it('changes the highlighted item using the arrow keys', async () => { - await render( - - Toggle - - - - 1 - 2 - 3 - - - - , - ); - - const trigger = screen.getByRole('button', { name: 'Toggle' }); - await act(async () => { - trigger.focus(); - }); - - await userEvent.keyboard('[Enter]'); - - const item1 = screen.getByTestId('item-1'); - const item2 = screen.getByTestId('item-2'); - const item3 = screen.getByTestId('item-3'); + // All these tests run for contained and detached triggers. + // The rendered menubar has the same structure in most cases. + describe.for([ + { name: 'contained triggers', Component: ContainedTriggerMenu }, + { name: 'detached triggers', Component: DetachedTriggerMenu }, + ])('when using $name', ({ Component: TestMenu }) => { + describe('keyboard navigation', () => { + it('changes the highlighted item using the arrow keys', async () => { + await render(); - await waitFor(() => { - expect(item1).toHaveFocus(); - }); - - await userEvent.keyboard('{ArrowDown}'); - await waitFor(() => { - expect(item2).toHaveFocus(); - }); + const trigger = screen.getByRole('button', { name: 'Toggle' }); + await act(async () => { + trigger.focus(); + }); - await userEvent.keyboard('{ArrowDown}'); - await waitFor(() => { - expect(item3).toHaveFocus(); - }); + await userEvent.keyboard('[Enter]'); - await userEvent.keyboard('{ArrowUp}'); - await waitFor(() => { - expect(item2).toHaveFocus(); - }); - }); + const item1 = screen.getByTestId('item-1'); + const item2 = screen.getByTestId('item-2'); + const item3 = screen.getByTestId('item-3'); - it('changes the highlighted item using the Home and End keys', async () => { - await render( - - Toggle - - - - 1 - 2 - 3 - - - - , - ); - - const trigger = screen.getByRole('button', { name: 'Toggle' }); - await act(async () => { - trigger.focus(); - }); - - await userEvent.keyboard('[Enter]'); - const item1 = screen.getByTestId('item-1'); - const item3 = screen.getByTestId('item-3'); + await waitFor(() => { + expect(item1).toHaveFocus(); + }); - await waitFor(() => { - expect(item1).toHaveFocus(); - }); + await userEvent.keyboard('{ArrowDown}'); + await waitFor(() => { + expect(item2).toHaveFocus(); + }); - await userEvent.keyboard('{End}'); - await waitFor(() => { - expect(item3).toHaveFocus(); - }); + await userEvent.keyboard('{ArrowDown}'); + await waitFor(() => { + expect(item3).toHaveFocus(); + }); - await userEvent.keyboard('{Home}'); - await waitFor(() => { - expect(item1).toHaveFocus(); + await userEvent.keyboard('{ArrowUp}'); + await waitFor(() => { + expect(item2).toHaveFocus(); + }); }); - }); - it('includes disabled items during keyboard navigation', async () => { - await render( - - Toggle - - - - 1 - - 2 - - - - - , - ); + it('changes the highlighted item using the Home and End keys', async () => { + await render(); - const trigger = screen.getByRole('button', { name: 'Toggle' }); - await act(async () => { - trigger.focus(); - }); - - await userEvent.keyboard('[Enter]'); + const trigger = screen.getByRole('button', { name: 'Toggle' }); + await act(async () => { + trigger.focus(); + }); - const item1 = screen.getByTestId('item-1'); - const item2 = screen.getByTestId('item-2'); + await userEvent.keyboard('[Enter]'); + const item1 = screen.getByTestId('item-1'); + const item5 = screen.getByTestId('item-5'); - await waitFor(() => { - expect(item1).toHaveFocus(); - }); + await waitFor(() => { + expect(item1).toHaveFocus(); + }); - await userEvent.keyboard('{ArrowDown}'); + await userEvent.keyboard('{End}'); + await waitFor(() => { + expect(item5).toHaveFocus(); + }); - await waitFor(() => { - expect(item2).toHaveFocus(); + await userEvent.keyboard('{Home}'); + await waitFor(() => { + expect(item1).toHaveFocus(); + }); }); - expect(item2).to.have.attribute('aria-disabled', 'true'); - }); + it('includes disabled items during keyboard navigation', async () => { + await render(); - describe('text navigation', () => { - it('changes the highlighted item', async ({ skip }) => { - if (isJSDOM) { - // useMenuPopup Text navigation match menu items using HTMLElement.innerText - // innerText is not supported by JSDOM - skip(); - } + const trigger = screen.getByRole('button', { name: 'Toggle' }); + await act(async () => { + trigger.focus(); + }); - const { user } = await render( - - - - - Aa - Ba - Bb - Ca - Cb - Cd - - - - , - ); + await userEvent.keyboard('[Enter]'); - const items = screen.getAllByRole('menuitem'); + const item1 = screen.getByTestId('item-1'); + const item2 = screen.getByTestId('item-2'); + const disabledItem3 = screen.getByTestId('item-3'); - await act(async () => { - items[0].focus(); + await waitFor(() => { + expect(item1).toHaveFocus(); }); - await user.keyboard('c'); + await userEvent.keyboard('{ArrowDown}'); + await waitFor(() => { - expect(screen.getByText('Ca')).toHaveFocus(); + expect(item2).toHaveFocus(); }); - expect(screen.getByText('Ca')).to.have.attribute('tabindex', '0'); + await userEvent.keyboard('{ArrowDown}'); - await user.keyboard('d'); await waitFor(() => { - expect(screen.getByText('Cd')).toHaveFocus(); + expect(disabledItem3).toHaveFocus(); }); - expect(screen.getByText('Cd')).to.have.attribute('tabindex', '0'); + expect(disabledItem3).to.have.attribute('aria-disabled', 'true'); }); - it('changes the highlighted item using text navigation on label prop', async ({ skip }) => { - if (!isJSDOM) { - // This test is very flaky in real browsers - skip(); - } + describe('text navigation', () => { + it('changes the highlighted item', async ({ skip }) => { + if (isJSDOM) { + // useMenuPopup Text navigation match menu items using HTMLElement.innerText + // innerText is not supported by JSDOM + skip(); + } - const { user } = await render( - - Toggle - - - - 1 - 2 - 3 - 4 - - - - , - ); + const itemElements = [ + Aa, + Ba, + Bb, + Ca, + Cb, + Cd, + ]; - const trigger = screen.getByRole('button', { name: 'Toggle' }); - await user.click(trigger); - const items = screen.getAllByRole('menuitem'); - await flushMicrotasks(); + const { user } = await render( + , + ); - await user.keyboard('b'); - await waitFor(() => { - expect(items[1]).toHaveFocus(); - }); + const items = screen.getAllByRole('menuitem'); - await waitFor(() => { - expect(items[1]).to.have.attribute('tabindex', '0'); - }); + await act(async () => { + items[0].focus(); + }); - await user.keyboard('b'); - await waitFor(() => { - expect(items[2]).toHaveFocus(); - }); + await user.keyboard('c'); + await waitFor(() => { + expect(screen.getByText('Ca')).toHaveFocus(); + }); - await waitFor(() => { - expect(items[2]).to.have.attribute('tabindex', '0'); - }); + expect(screen.getByText('Ca')).to.have.attribute('tabindex', '0'); - await user.keyboard('b'); - await waitFor(() => { - expect(items[2]).toHaveFocus(); - }); + await user.keyboard('d'); + await waitFor(() => { + expect(screen.getByText('Cd')).toHaveFocus(); + }); - await waitFor(() => { - expect(items[2]).to.have.attribute('tabindex', '0'); + expect(screen.getByText('Cd')).to.have.attribute('tabindex', '0'); }); - }); - it('skips the non-stringifiable items', async ({ skip }) => { - if (isJSDOM) { - // useMenuPopup Text navigation match menu items using HTMLElement.innerText - // innerText is not supported by JSDOM - skip(); - } + it('changes the highlighted item using text navigation on label prop', async ({ skip }) => { + if (!isJSDOM) { + // This test is very flaky in real browsers + skip(); + } - const { user } = await render( - - - - - Aa - Ba - - -
Nested Content
-
- {undefined} - {null} - Bc -
-
-
-
, - ); + const itemElements = [ + + 1 + , + + 2 + , + + 3 + , + + 4 + , + ]; + + const { user } = await render(); + + const trigger = screen.getByRole('button', { name: 'Toggle' }); + await user.click(trigger); + const items = screen.getAllByRole('menuitem'); + await flushMicrotasks(); + + await user.keyboard('b'); + await waitFor(() => { + expect(items[1]).toHaveFocus(); + }); - const items = screen.getAllByRole('menuitem'); + await waitFor(() => { + expect(items[1]).to.have.attribute('tabindex', '0'); + }); - await act(async () => { - items[0].focus(); - }); + await user.keyboard('b'); + await waitFor(() => { + expect(items[2]).toHaveFocus(); + }); - await user.keyboard('b'); - await waitFor(() => { - expect(screen.getByText('Ba')).toHaveFocus(); - }); - expect(screen.getByText('Ba')).to.have.attribute('tabindex', '0'); + await waitFor(() => { + expect(items[2]).to.have.attribute('tabindex', '0'); + }); - await user.keyboard('c'); - await waitFor(() => { - expect(screen.getByText('Bc')).toHaveFocus(); + await user.keyboard('b'); + await waitFor(() => { + expect(items[2]).toHaveFocus(); + }); + + await waitFor(() => { + expect(items[2]).to.have.attribute('tabindex', '0'); + }); }); - expect(screen.getByText('Bc')).to.have.attribute('tabindex', '0'); - }); - it('navigate to options with diacritic characters', async ({ skip }) => { - if (isJSDOM) { - // useMenuPopup Text navigation match menu items using HTMLElement.innerText - // innerText is not supported by JSDOM - skip(); - } + it('skips the non-stringifiable items', async ({ skip }) => { + if (isJSDOM) { + // useMenuPopup Text navigation match menu items using HTMLElement.innerText + // innerText is not supported by JSDOM + skip(); + } - const { user } = await render( - - - - - Aa - Ba - Bb - BÄ… - - - - , - ); + const itemElements = [ + Aa, + Ba, + , + +
Nested Content
+
, + {undefined}, + {null}, + Bc, + ]; - const items = screen.getAllByRole('menuitem'); + const { user } = await render( + , + ); - await act(async () => { - items[0].focus(); - }); + const items = screen.getAllByRole('menuitem'); - await user.keyboard('b'); - await waitFor(() => { - expect(screen.getByText('Ba')).toHaveFocus(); - }); - expect(screen.getByText('Ba')).to.have.attribute('tabindex', '0'); + await act(async () => { + items[0].focus(); + }); - await user.keyboard('Ä…'); - await waitFor(() => { - expect(screen.getByText('BÄ…')).toHaveFocus(); + await user.keyboard('b'); + await waitFor(() => { + expect(screen.getByText('Ba')).toHaveFocus(); + }); + expect(screen.getByText('Ba')).to.have.attribute('tabindex', '0'); + + await user.keyboard('c'); + await waitFor(() => { + expect(screen.getByText('Bc')).toHaveFocus(); + }); + expect(screen.getByText('Bc')).to.have.attribute('tabindex', '0'); }); - expect(screen.getByText('BÄ…')).to.have.attribute('tabindex', '0'); - }); - it('navigate to next options beginning with diacritic characters', async ({ skip }) => { - if (isJSDOM) { - // useMenuPopup Text navigation match menu items using HTMLElement.innerText - // innerText is not supported by JSDOM - skip(); - } + it('navigate to options with diacritic characters', async ({ skip }) => { + if (isJSDOM) { + // useMenuPopup Text navigation match menu items using HTMLElement.innerText + // innerText is not supported by JSDOM + skip(); + } - const { user } = await render( - - - - - Aa - Ä…a - Ä…b - Ä…c - - - - , - ); + const itemElements = [ + Aa, + Ba, + Bb, + BÄ…, + ]; - const items = screen.getAllByRole('menuitem'); + const { user } = await render( + , + ); - await act(async () => { - items[0].focus(); - }); + const items = screen.getAllByRole('menuitem'); - await user.keyboard('Ä…'); - await waitFor(() => { - expect(screen.getByText('Ä…a')).toHaveFocus(); + await act(async () => { + items[0].focus(); + }); + + await user.keyboard('b'); + await waitFor(() => { + expect(screen.getByText('Ba')).toHaveFocus(); + }); + expect(screen.getByText('Ba')).to.have.attribute('tabindex', '0'); + + await user.keyboard('Ä…'); + await waitFor(() => { + expect(screen.getByText('BÄ…')).toHaveFocus(); + }); + expect(screen.getByText('BÄ…')).to.have.attribute('tabindex', '0'); }); - expect(screen.getByText('Ä…a')).to.have.attribute('tabindex', '0'); - }); - it('does not trigger the onClick event when Space is pressed during text navigation', async ({ - skip, - }) => { - if (isJSDOM) { - // useMenuPopup Text navigation match menu items using HTMLElement.innerText - // innerText is not supported by JSDOM - skip(); - } + it('navigate to next options that begin with diacritic characters', async ({ skip }) => { + if (isJSDOM) { + // useMenuPopup Text navigation match menu items using HTMLElement.innerText + // innerText is not supported by JSDOM + skip(); + } - const handleClick = spy(); + const itemElements = [ + Aa, + Ä…a, + Ä…b, + Ä…c, + ]; - const { user } = await render( - - - - - handleClick()}>Item One - handleClick()}>Item Two - handleClick()}>Item Three - - - - , - ); + const { user } = await render( + , + ); - const items = screen.getAllByRole('menuitem'); + const items = screen.getAllByRole('menuitem'); - await act(async () => { - items[0].focus(); + await act(async () => { + items[0].focus(); + }); + + await user.keyboard('Ä…'); + await waitFor(() => { + expect(screen.getByText('Ä…a')).toHaveFocus(); + }); + expect(screen.getByText('Ä…a')).to.have.attribute('tabindex', '0'); }); - await user.keyboard('Item T'); + it('does not trigger the onClick event when Space is pressed during text navigation', async ({ + skip, + }) => { + if (isJSDOM) { + // useMenuPopup Text navigation match menu items using HTMLElement.innerText + // innerText is not supported by JSDOM + skip(); + } - expect(handleClick.called).to.equal(false); + const handleClick = spy(); - await waitFor(() => { - expect(items[1]).toHaveFocus(); - }); - }); - }); - }); + const itemElements = [ + handleClick()}> + Item One + , + handleClick()}> + Item Two + , + handleClick()}> + Item Three + , + ]; - describe('nested menus', () => { - ( - [ - ['vertical', 'ltr', 'ArrowRight', 'ArrowLeft'], - ['vertical', 'rtl', 'ArrowLeft', 'ArrowRight'], - ['horizontal', 'ltr', 'ArrowDown', 'ArrowUp'], - ['horizontal', 'rtl', 'ArrowDown', 'ArrowUp'], - ] as const - ).forEach(([orientation, direction, openKey, closeKey]) => { - it.skipIf(isJSDOM)( - `opens a nested menu of a ${orientation} ${direction.toUpperCase()} menu with ${openKey} key and closes it with ${closeKey}`, - async () => { const { user } = await render( - - - - - - 1 - - 2 - - - - 2.1 - 2.2 - - - - - - - - - , + , ); - const submenuTrigger = screen.getByTestId('submenu-trigger'); + const items = screen.getAllByRole('menuitem'); await act(async () => { - submenuTrigger.focus(); - }); - - // This check fails in JSDOM - await waitFor(() => { - expect(submenuTrigger).toHaveFocus(); + items[0].focus(); }); - await user.keyboard(`[${openKey}]`); + await user.keyboard('Item T'); - let submenu: HTMLElement | null = await screen.findByTestId('submenu'); + expect(handleClick.called).to.equal(false); - const submenuItem1 = screen.queryByTestId('submenu-item-1'); - expect(submenuItem1).not.to.equal(null); await waitFor(() => { - expect(submenuItem1).toHaveFocus(); + expect(items[1]).toHaveFocus(); }); + }); + }); + }); - await user.keyboard(`[${closeKey}]`); + describe('nested menus', () => { + ( + [ + ['vertical', 'ltr', 'ArrowRight', 'ArrowLeft'], + ['vertical', 'rtl', 'ArrowLeft', 'ArrowRight'], + ['horizontal', 'ltr', 'ArrowDown', 'ArrowUp'], + ['horizontal', 'rtl', 'ArrowDown', 'ArrowUp'], + ] as const + ).forEach(([orientation, direction, openKey, closeKey]) => { + it.skipIf(isJSDOM)( + `opens a nested menu of a ${orientation} ${direction.toUpperCase()} menu with ${openKey} key and closes it with ${closeKey}`, - submenu = screen.queryByTestId('submenu'); - expect(submenu).to.equal(null); + async () => { + const { user } = await render( + + + , + ); - expect(submenuTrigger).toHaveFocus(); - }, - ); - }); + const submenuTrigger = screen.getByTestId('submenu-trigger'); - it('opens submenu on click when openOnHover is false', async () => { - const { user } = await render( - - Open Main - - - - Item 1 - - Submenu - - - - Submenu Item - - - - - - - - , - ); + await act(async () => { + submenuTrigger.focus(); + }); - const mainTrigger = screen.getByRole('button', { name: 'Open Main' }); - await user.click(mainTrigger); + // This check fails in JSDOM + await waitFor(() => { + expect(submenuTrigger).toHaveFocus(); + }); - const submenu = await screen.findByTestId('menu'); - expect(screen.queryByTestId('submenu')).to.equal(null); + await user.keyboard(`[${openKey}]`); - const submenuTrigger = await screen.findByTestId('submenu-trigger'); - await user.click(submenuTrigger); + let submenu: HTMLElement | null = await screen.findByTestId('submenu'); - expect(submenu).not.to.equal(null); - expect(await screen.findByTestId('submenu-item')).to.have.text('Submenu Item'); - }); + const submenuItem1 = screen.queryByTestId('item-4_1'); + expect(submenuItem1).not.to.equal(null); + await waitFor(() => { + expect(submenuItem1).toHaveFocus(); + }); - it('closes submenus when focus is lost by shift-tabbing from a nested menu', async () => { - const { user } = await render( - - Open Main - - - - Item 1 - - Submenu - - - - Submenu Item - - - - - - - - , - ); + await user.keyboard(`[${closeKey}]`); - const mainTrigger = screen.getByRole('button', { name: 'Open Main' }); - await user.click(mainTrigger); + submenu = screen.queryByTestId('submenu'); + expect(submenu).to.equal(null); - await screen.findByTestId('menu'); - expect(screen.queryByTestId('submenu')).to.equal(null); + expect(submenuTrigger).toHaveFocus(); + }, + ); + }); - const submenuTrigger = await screen.findByTestId('submenu-trigger'); - await user.hover(submenuTrigger); + it('opens submenu on click when openOnHover is false', async () => { + const { user } = await render(); - await waitFor(() => { - expect(screen.queryByTestId('submenu')).not.to.equal(null); - }); + const mainTrigger = screen.getByRole('button', { name: 'Toggle' }); + await user.click(mainTrigger); - const submenuItem = await screen.findByTestId('submenu-item'); - await act(async () => { - submenuItem.focus(); - }); + const menu = await screen.findByTestId('menu'); + expect(screen.queryByTestId('submenu')).to.equal(null); - await waitFor(() => { - expect(submenuItem).toHaveFocus(); + const submenuTrigger = await screen.findByTestId('submenu-trigger'); + await user.click(submenuTrigger); + + expect(menu).not.to.equal(null); + expect(await screen.findByTestId('item-4_1')).to.have.text('Item 4.1'); }); - // Shift+Tab should close the submenu and focus should return to the submenu trigger - await user.keyboard('{Shift>}{Tab}{/Shift}'); + it('closes submenus when focus is lost by shift-tabbing from a nested menu', async () => { + const { user } = await render(); - await waitFor(() => { + const mainTrigger = screen.getByRole('button', { name: 'Toggle' }); + await user.click(mainTrigger); + + await screen.findByTestId('menu'); expect(screen.queryByTestId('submenu')).to.equal(null); - }); - expect(submenuTrigger).toHaveFocus(); - }); - }); + const submenuTrigger = await screen.findByTestId('submenu-trigger'); + await user.hover(submenuTrigger); - describe('focus management', () => { - function Test() { - return ( - - Toggle - - - - 1 - 2 - 3 - - - - - ); - } - - it('focuses the first item after the menu is opened by keyboard', async () => { - await render(); - - const trigger = screen.getByRole('button', { name: 'Toggle' }); - await act(async () => { - trigger.focus(); - }); + await waitFor(() => { + expect(screen.queryByTestId('submenu')).not.to.equal(null); + }); - await userEvent.keyboard('[Enter]'); + const submenuItem = await screen.findByTestId('item-4_1'); + await act(async () => { + submenuItem.focus(); + }); - const [firstItem, ...otherItems] = screen.getAllByRole('menuitem'); - await waitFor(() => { - expect(firstItem.tabIndex).to.equal(0); - }); - otherItems.forEach((item) => { - expect(item.tabIndex).to.equal(-1); - }); - }); + await waitFor(() => { + expect(submenuItem).toHaveFocus(); + }); - it('focuses the first item when down arrow key opens the menu', async () => { - const { user } = await render(); + // Shift+Tab should close the submenu and focus should return to the submenu trigger + await user.keyboard('{Shift>}{Tab}{/Shift}'); - const trigger = screen.getByRole('button', { name: 'Toggle' }); - await act(async () => { - trigger.focus(); + await waitFor(() => { + expect(screen.queryByTestId('submenu')).to.equal(null); + }); + + expect(submenuTrigger).toHaveFocus(); }); - await user.keyboard('[ArrowDown]'); + it('closes the entire tree when clicking outside the deepest submenu', async () => { + const { user } = await render( +
+ + +
, + ); + + const trigger = screen.getByRole('button', { name: 'Toggle' }); + await user.click(trigger); + + await screen.findByTestId('menu'); + + await user.keyboard('[ArrowDown]'); + await user.keyboard('[ArrowDown]'); + await user.keyboard('[ArrowDown]'); + await user.keyboard('[ArrowDown]'); + + const submenuTrigger1 = await screen.findByTestId('submenu-trigger'); + await waitFor(() => { + expect(submenuTrigger1).toHaveFocus(); + }); - const [firstItem, ...otherItems] = screen.getAllByRole('menuitem'); - await waitFor(() => expect(firstItem).toHaveFocus()); - expect(firstItem.tabIndex).to.equal(0); - otherItems.forEach((item) => { - expect(item.tabIndex).to.equal(-1); + await user.keyboard('[ArrowRight]'); + await screen.findByTestId('submenu'); + + await user.keyboard('[ArrowDown]'); + await user.keyboard('[ArrowDown]'); + + const submenuTrigger2 = await screen.findByTestId('nested-submenu-trigger'); + await waitFor(() => { + expect(submenuTrigger2).toHaveFocus(); + }); + + await user.keyboard('[ArrowRight]'); + await screen.findByTestId('nested-submenu'); + + const outside = screen.getByTestId('outside'); + await user.click(outside); + + await waitFor(() => { + expect(screen.queryByTestId('level-1')).to.equal(null); + expect(screen.queryByTestId('level-2')).to.equal(null); + expect(screen.queryByTestId('level-3')).to.equal(null); + }); }); }); - it('focuses the last item when up arrow key opens the menu', async () => { - const { user } = await render(); + describe('focus management', () => { + it('focuses the first item after the menu is opened by keyboard', async () => { + await render(); - const trigger = screen.getByRole('button', { name: 'Toggle' }); + const trigger = screen.getByRole('button', { name: 'Toggle' }); + await act(async () => { + trigger.focus(); + }); - await act(async () => { - trigger.focus(); + await userEvent.keyboard('[Enter]'); + + const [firstItem, ...otherItems] = screen.getAllByRole('menuitem'); + await waitFor(() => { + expect(firstItem.tabIndex).to.equal(0); + }); + otherItems.forEach((item) => { + expect(item.tabIndex).to.equal(-1); + }); }); - await user.keyboard('[ArrowUp]'); + it('focuses the first item when down arrow key opens the menu', async () => { + const { user } = await render(); - const [firstItem, secondItem, lastItem] = screen.getAllByRole('menuitem'); - await waitFor(() => { - expect(lastItem).toHaveFocus(); - }); + const trigger = screen.getByRole('button', { name: 'Toggle' }); + await act(async () => { + trigger.focus(); + }); - expect(lastItem.tabIndex).to.equal(0); - [firstItem, secondItem].forEach((item) => { - expect(item.tabIndex).to.equal(-1); + await user.keyboard('[ArrowDown]'); + + const [firstItem, ...otherItems] = screen.getAllByRole('menuitem'); + await waitFor(() => expect(firstItem).toHaveFocus()); + expect(firstItem.tabIndex).to.equal(0); + otherItems.forEach((item) => { + expect(item.tabIndex).to.equal(-1); + }); }); - }); - it('focuses the trigger after the menu is closed', async () => { - const { user } = await render( -
- - - Toggle - - - - Close - - - - - -
, - ); + it('focuses the last item when up arrow key opens the menu', async () => { + const { user } = await render(); - const button = screen.getByRole('button', { name: 'Toggle' }); - await user.click(button); + const trigger = screen.getByRole('button', { name: 'Toggle' }); - const menuItem = await screen.findByRole('menuitem'); - await user.click(menuItem); + await act(async () => { + trigger.focus(); + }); - expect(button).toHaveFocus(); - }); + await user.keyboard('[ArrowUp]'); - it('focuses the trigger after the menu is closed but not unmounted', async ({ skip }) => { - if (isJSDOM) { - // TODO: this stopped working in vitest JSDOM mode - skip(); - } + const items = screen.getAllByRole('menuitem'); + await waitFor(() => { + expect(items[4]).toHaveFocus(); + }); - const { user } = await render( -
- - - Toggle - - - - Close - - - - - -
, - ); + expect(items[4].tabIndex).to.equal(0); + [items[0], items[1], items[2], items[3]].forEach((item) => { + expect(item.tabIndex).to.equal(-1); + }); + }); - const button = screen.getByRole('button', { name: 'Toggle' }); - await user.click(button); + it('focuses the trigger after the menu is closed', async () => { + const { user } = await render( +
+ + + +
, + ); - const menuItem = await screen.findByRole('menuitem'); - await user.click(menuItem); + const button = screen.getByRole('button', { name: 'Toggle' }); + await user.click(button); + + const menuItem = await screen.findAllByRole('menuitem'); + await user.click(menuItem[0]); - await waitFor(() => { expect(button).toHaveFocus(); }); - }); - }); - describe('prop: closeParentOnEsc', () => { - it('does not close the parent menu when the Escape key is pressed by default', async () => { - const { user } = await render( - - Open - - - - 1 - - 2 - - - - 2.1 - 2.2 - - - - - - - - , - ); + it('focuses the trigger after the menu is closed but not unmounted', async ({ skip }) => { + if (isJSDOM) { + // TODO: this stopped working in vitest JSDOM mode + skip(); + } - const trigger = screen.getByRole('button', { name: 'Open' }); - await act(async () => { - trigger.focus(); - }); + const { user } = await render( +
+ + + +
, + ); - await user.keyboard('[ArrowDown]'); - await waitFor(() => { - expect(screen.getByRole('menuitem', { name: '1' })).toHaveFocus(); - }); + const button = screen.getByRole('button', { name: 'Toggle' }); + await user.click(button); - await user.keyboard('[ArrowDown]'); - await waitFor(() => { - expect(screen.getByRole('menuitem', { name: '2' })).toHaveFocus(); - }); + const menuItem = await screen.findAllByRole('menuitem'); + await user.click(menuItem[0]); - await user.keyboard('[ArrowRight]'); - await waitFor(() => { - expect(screen.getByRole('menuitem', { name: '2.1' })).toHaveFocus(); + await waitFor(() => { + expect(button).toHaveFocus(); + }); }); + }); - await user.keyboard('[Escape]'); + describe('prop: closeParentOnEsc', () => { + it('does not close the parent menu when the Escape key is pressed by default', async () => { + const { user } = await render(); - const menus = screen.queryAllByRole('menu', { hidden: false }); - await waitFor(() => { - expect(menus.length).to.equal(1); - }); + const trigger = screen.getByRole('button', { name: 'Toggle' }); + await act(async () => { + trigger.focus(); + }); - expect(menus[0].id).to.equal('parent-menu'); - }); + await user.keyboard('[ArrowDown]'); + await waitFor(() => { + expect(screen.getByTestId('item-1')).toHaveFocus(); + }); - it('closes the parent menu when the Escape key is pressed if `closeParentOnEsc=true`', async () => { - const { user } = await render( - - Open - - - - 1 - - 2 - - - - 2.1 - 2.2 - - - - - - - - , - ); + await user.keyboard('[ArrowDown]'); + await waitFor(() => { + expect(screen.getByTestId('item-2')).toHaveFocus(); + }); - const trigger = screen.getByRole('button', { name: 'Open' }); - await act(async () => { - trigger.focus(); - }); + await user.keyboard('[ArrowDown]'); + await waitFor(() => { + expect(screen.getByTestId('item-3')).toHaveFocus(); + }); - await user.keyboard('[ArrowDown]'); - await waitFor(() => { - expect(screen.getByRole('menuitem', { name: '1' })).toHaveFocus(); - }); + await user.keyboard('[ArrowDown]'); + await waitFor(() => { + expect(screen.getByTestId('submenu-trigger')).toHaveFocus(); + }); - await user.keyboard('[ArrowDown]'); - await waitFor(() => { - expect(screen.getByRole('menuitem', { name: '2' })).toHaveFocus(); - }); + await user.keyboard('[ArrowRight]'); + await waitFor(() => { + expect(screen.getByTestId('item-4_1')).toHaveFocus(); + }); - await user.keyboard('[ArrowRight]'); - await waitFor(() => { - expect(screen.getByRole('menuitem', { name: '2.1' })).toHaveFocus(); + await user.keyboard('[Escape]'); + + const menus = screen.queryAllByRole('menu', { hidden: false }); + await waitFor(() => { + expect(menus.length).to.equal(1); + }); + + expect(menus[0].dataset.testid).to.equal('menu'); }); - await user.keyboard('[Escape]'); - await flushMicrotasks(); + it('closes the parent menu when the Escape key is pressed if `closeParentOnEsc=true`', async () => { + const { user } = await render(); - expect(screen.queryByRole('menu', { hidden: false })).to.equal(null); - }); - }); + const trigger = screen.getByRole('button', { name: 'Toggle' }); + await act(async () => { + trigger.focus(); + }); - describe('prop: modal', () => { - it('should render an internal backdrop when `true`', async () => { - const { user } = await render( -
- - Open - - - - 1 - - - - - -
, - ); + await user.keyboard('[ArrowDown]'); + await waitFor(() => { + expect(screen.getByTestId('item-1')).toHaveFocus(); + }); - const trigger = screen.getByRole('button', { name: 'Open' }); + await user.keyboard('[ArrowDown]'); + await waitFor(() => { + expect(screen.getByTestId('item-2')).toHaveFocus(); + }); - await user.click(trigger); + await user.keyboard('[ArrowDown]'); + await waitFor(() => { + expect(screen.getByTestId('item-3')).toHaveFocus(); + }); - await waitFor(() => { - expect(screen.queryByRole('menu')).not.to.equal(null); - }); + await user.keyboard('[ArrowDown]'); + await waitFor(() => { + expect(screen.getByTestId('submenu-trigger')).toHaveFocus(); + }); + + await user.keyboard('[ArrowRight]'); + await waitFor(() => { + expect(screen.getByRole('menuitem', { name: 'Item 4.1' })).toHaveFocus(); + }); - const positioner = screen.getByTestId('positioner'); + await user.keyboard('[Escape]'); + await flushMicrotasks(); - expect(positioner.previousElementSibling).to.have.attribute('role', 'presentation'); + expect(screen.queryByRole('menu', { hidden: false })).to.equal(null); + }); }); - it('should not render an internal backdrop when `false`', async () => { - const { user } = await render( -
- - Open - - - - 1 - - - - - -
, - ); + describe('prop: modal', () => { + it('should render an internal backdrop when `true`', async () => { + const { user } = await render( +
+ + +
, + ); - const trigger = screen.getByRole('button', { name: 'Open' }); + const trigger = screen.getByRole('button', { name: 'Toggle' }); - await user.click(trigger); + await user.click(trigger); - await waitFor(() => { - expect(screen.queryByRole('menu')).not.to.equal(null); + await waitFor(() => { + expect(screen.queryByRole('menu')).not.to.equal(null); + }); + + const positioner = screen.getByTestId('menu-positioner'); + + expect(positioner.previousElementSibling).to.have.attribute('role', 'presentation'); }); - const positioner = screen.getByTestId('positioner'); + it('should not render an internal backdrop when `false`', async () => { + const { user } = await render( +
+ + +
, + ); - expect(positioner.previousElementSibling).to.equal(null); - }); - }); + const trigger = screen.getByRole('button', { name: 'Toggle' }); - describe('prop: actionsRef', () => { - it('unmounts the menu when the `unmount` method is called', async () => { - const actionsRef = { - current: { - unmount: spy(), - }, - }; - - const { user } = await render( - - Open - - - - - - , - ); - - const trigger = screen.getByRole('button', { name: 'Open' }); - await act(() => { - trigger.focus(); - }); + await user.click(trigger); - await user.keyboard('{Enter}'); + await waitFor(() => { + expect(screen.queryByRole('menu')).not.to.equal(null); + }); - await waitFor(() => { - expect(screen.queryByRole('menu')).not.to.equal(null); + const positioner = screen.getByTestId('menu-positioner'); + + expect(positioner.previousElementSibling).to.equal(null); }); + }); - await user.click(trigger); + describe('prop: actionsRef', () => { + it('unmounts the menu when the `unmount` method is called', async () => { + const actionsRef = { + current: { + unmount: spy(), + }, + }; - await waitFor(() => { - expect(screen.queryByRole('menu')).not.to.equal(null); - }); + const { user } = await render(); - await act(async () => { - await new Promise((resolve) => { - requestAnimationFrame(resolve); + const trigger = screen.getByRole('button', { name: 'Toggle' }); + await act(() => { + trigger.focus(); }); - actionsRef.current.unmount(); - }); + await user.keyboard('{Enter}'); - await waitFor(() => { - expect(screen.queryByRole('menu')).to.equal(null); + await waitFor(() => { + expect(screen.queryByRole('menu')).not.to.equal(null); + }); + + await user.click(trigger); + + await waitFor(() => { + expect(screen.queryByRole('menu')).not.to.equal(null); + }); + + await act(async () => { + await new Promise((resolve) => { + requestAnimationFrame(resolve); + }); + + actionsRef.current.unmount(); + }); + + await waitFor(() => { + expect(screen.queryByRole('menu')).to.equal(null); + }); }); }); - }); - describe.skipIf(isJSDOM)('prop: onOpenChangeComplete', () => { - it('is called on close when there is no exit animation defined', async () => { - const onOpenChangeComplete = spy(); + describe.skipIf(isJSDOM)('prop: onOpenChangeComplete', () => { + it('is called on close when there is no exit animation defined', async () => { + const onOpenChangeComplete = spy(); + + function Test() { + const [open, setOpen] = React.useState(true); + return ( +
+ + +
+ ); + } - function Test() { - const [open, setOpen] = React.useState(true); - return ( -
- - - - - - - - -
- ); - } + const { user } = await render(); - const { user } = await render(); + const closeButton = screen.getByText('Close'); + await user.click(closeButton); - const closeButton = screen.getByText('Close'); - await user.click(closeButton); + await waitFor(() => { + expect(screen.queryByTestId('menu')).to.equal(null); + }); - await waitFor(() => { - expect(screen.queryByTestId('popup')).to.equal(null); + expect(onOpenChangeComplete.firstCall.args[0]).to.equal(true); + expect(onOpenChangeComplete.lastCall.args[0]).to.equal(false); }); - expect(onOpenChangeComplete.firstCall.args[0]).to.equal(true); - expect(onOpenChangeComplete.lastCall.args[0]).to.equal(false); - }); - - it('is called on close when the exit animation finishes', async () => { - globalThis.BASE_UI_ANIMATIONS_DISABLED = false; + it('is called on close when the exit animation finishes', async () => { + globalThis.BASE_UI_ANIMATIONS_DISABLED = false; - const onOpenChangeComplete = spy(); + const onOpenChangeComplete = spy(); - function Test() { - const style = ` + function Test() { + const style = ` @keyframes test-anim { to { opacity: 0; @@ -1034,82 +860,73 @@ describe('', () => { } `; - const [open, setOpen] = React.useState(true); + const [open, setOpen] = React.useState(true); + + return ( +
+ {/* eslint-disable-next-line react/no-danger */} +