diff --git a/docs/src/app/(docs)/react/components/context-menu/types.md b/docs/src/app/(docs)/react/components/context-menu/types.md index 7428df5075a..9d486d73744 100644 --- a/docs/src/app/(docs)/react/components/context-menu/types.md +++ b/docs/src/app/(docs)/react/components/context-menu/types.md @@ -11,22 +11,22 @@ Doesn't render its own HTML element. **Root Props:** -| Prop | Type | Default | Description | -| :------------------- | :----------------------------------------------------------------------------- | :----------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| defaultOpen | `boolean` | `false` | Whether the menu is initially open. To render a controlled menu, use the `open` prop instead. | -| open | `boolean` | - | Whether the menu is currently open. | -| onOpenChange | `((open: boolean, eventDetails: ContextMenu.Root.ChangeEventDetails) => void)` | - | Event handler called when the menu is opened or closed. | -| highlightItemOnHover | `boolean` | `true` | Whether moving the pointer over items should highlight them. Disabling this prop allows CSS `:hover` to be differentiated from the `:focus` (`data-highlighted`) state. | -| actionsRef | `React.RefObject` | - | A ref to imperative actions. `unmount`: When specified, the menu will not be unmounted when closed. Instead, the `unmount` function must be called to unmount the menu manually. Useful when the menu's animation is controlled by an external library.`close`: When specified, the menu can be closed imperatively. | -| closeParentOnEsc | `boolean` | `false` | When in a submenu, determines whether pressing the Escape key closes the entire menu, or only the current child menu. | -| defaultTriggerId | `string \| null` | - | ID of the trigger that the popover is associated with. This is useful in conjunction with the `defaultOpen` prop to create an initially open popover. | -| handle | `MenuHandle` | - | A handle to associate the menu with a trigger. If specified, allows external triggers to control the menu's open state. | -| loopFocus | `boolean` | `true` | Whether to loop keyboard focus back to the first item when the end of the list is reached while using the arrow keys. | -| onOpenChangeComplete | `((open: boolean) => void)` | - | Event handler called after any animations complete when the menu is closed. | -| triggerId | `string \| null` | - | ID of the trigger that the popover is associated with. This is useful in conjunction with the `open` prop to create a controlled popover. There's no need to specify this prop when the popover is uncontrolled (that is, when the `open` prop is not set). | -| disabled | `boolean` | `false` | Whether the component should ignore user interaction. | -| orientation | `MenuRoot.Orientation` | `'vertical'` | The visual orientation of the menu. Controls whether roving focus uses up/down or left/right arrow keys. | -| children | `React.ReactNode \| PayloadChildRenderFunction` | - | The content of the popover. This can be a regular React node or a render function that receives the `payload` of the active trigger. | +| Prop | Type | Default | Description | +| :------------------- | :----------------------------------------------------------------------------- | :----------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| defaultOpen | `boolean` | `false` | Whether the menu is initially open. To render a controlled menu, use the `open` prop instead. | +| open | `boolean` | - | Whether the menu is currently open. | +| onOpenChange | `((open: boolean, eventDetails: ContextMenu.Root.ChangeEventDetails) => void)` | - | Event handler called when the menu is opened or closed. | +| highlightItemOnHover | `boolean` | `true` | Whether moving the pointer over items should highlight them. Disabling this prop allows CSS `:hover` to be differentiated from the `:focus` (`data-highlighted`) state. | +| actionsRef | `React.RefObject` | - | A ref to imperative actions. `unmount`: When specified, the menu will not be unmounted when closed. Instead, the `unmount` function must be called to unmount the menu manually. Useful when the menu's animation is controlled by an external library.`close`: When specified, the menu can be closed imperatively.`focusItem`: Move focus to the `'first'` or `'last'` item, or `'none'` to clear the highlight. Useful when opening the menu programmatically from a custom interaction so that keyboard focus lands on an item instead of the popup container. | +| closeParentOnEsc | `boolean` | `false` | When in a submenu, determines whether pressing the Escape key closes the entire menu, or only the current child menu. | +| defaultTriggerId | `string \| null` | - | ID of the trigger that the popover is associated with. This is useful in conjunction with the `defaultOpen` prop to create an initially open popover. | +| handle | `MenuHandle` | - | A handle to associate the menu with a trigger. If specified, allows external triggers to control the menu's open state. | +| loopFocus | `boolean` | `true` | Whether to loop keyboard focus back to the first item when the end of the list is reached while using the arrow keys. | +| onOpenChangeComplete | `((open: boolean) => void)` | - | Event handler called after any animations complete when the menu is closed. | +| triggerId | `string \| null` | - | ID of the trigger that the popover is associated with. This is useful in conjunction with the `open` prop to create a controlled popover. There's no need to specify this prop when the popover is uncontrolled (that is, when the `open` prop is not set). | +| disabled | `boolean` | `false` | Whether the component should ignore user interaction. | +| orientation | `MenuRoot.Orientation` | `'vertical'` | The visual orientation of the menu. Controls whether roving focus uses up/down or left/right arrow keys. | +| children | `React.ReactNode \| PayloadChildRenderFunction` | - | The content of the popover. This can be a regular React node or a render function that receives the `payload` of the active trigger. | ### Root.Props @@ -41,7 +41,11 @@ type ContextMenuRootState = {}; ### Root.Actions ```typescript -type ContextMenuRootActions = { unmount: () => void; close: () => void }; +type ContextMenuRootActions = { + unmount: () => void; + close: () => void; + focusItem: (target: MenuRoot.FocusItem) => void; +}; ``` ### Root.ChangeEventReason @@ -503,22 +507,22 @@ Doesn't render its own HTML element. **SubmenuRoot Props:** -| Prop | Type | Default | Description | -| :------------------- | :------------------------------------------------------------------------------------ | :----------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| defaultOpen | `boolean` | `false` | Whether the menu is initially open. To render a controlled menu, use the `open` prop instead. | -| open | `boolean` | - | Whether the menu is currently open. | -| onOpenChange | `((open: boolean, eventDetails: ContextMenu.SubmenuRoot.ChangeEventDetails) => void)` | - | Event handler called when the menu is opened or closed. | -| highlightItemOnHover | `boolean` | `true` | Whether moving the pointer over items should highlight them. Disabling this prop allows CSS `:hover` to be differentiated from the `:focus` (`data-highlighted`) state. | -| actionsRef | `React.RefObject` | - | A ref to imperative actions. `unmount`: When specified, the menu will not be unmounted when closed. Instead, the `unmount` function must be called to unmount the menu manually. Useful when the menu's animation is controlled by an external library.`close`: When specified, the menu can be closed imperatively. | -| closeParentOnEsc | `boolean` | `false` | When in a submenu, determines whether pressing the Escape key closes the entire menu, or only the current child menu. | -| defaultTriggerId | `string \| null` | - | ID of the trigger that the popover is associated with. This is useful in conjunction with the `defaultOpen` prop to create an initially open popover. | -| handle | `MenuHandle` | - | A handle to associate the menu with a trigger. If specified, allows external triggers to control the menu's open state. | -| loopFocus | `boolean` | `true` | Whether to loop keyboard focus back to the first item when the end of the list is reached while using the arrow keys. | -| onOpenChangeComplete | `((open: boolean) => void)` | - | Event handler called after any animations complete when the menu is closed. | -| triggerId | `string \| null` | - | ID of the trigger that the popover is associated with. This is useful in conjunction with the `open` prop to create a controlled popover. There's no need to specify this prop when the popover is uncontrolled (that is, when the `open` prop is not set). | -| disabled | `boolean` | `false` | Whether the component should ignore user interaction. | -| orientation | `MenuRoot.Orientation` | `'vertical'` | The visual orientation of the menu. Controls whether roving focus uses up/down or left/right arrow keys. | -| children | `React.ReactNode \| PayloadChildRenderFunction` | - | The content of the popover. This can be a regular React node or a render function that receives the `payload` of the active trigger. | +| Prop | Type | Default | Description | +| :------------------- | :------------------------------------------------------------------------------------ | :----------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| defaultOpen | `boolean` | `false` | Whether the menu is initially open. To render a controlled menu, use the `open` prop instead. | +| open | `boolean` | - | Whether the menu is currently open. | +| onOpenChange | `((open: boolean, eventDetails: ContextMenu.SubmenuRoot.ChangeEventDetails) => void)` | - | Event handler called when the menu is opened or closed. | +| highlightItemOnHover | `boolean` | `true` | Whether moving the pointer over items should highlight them. Disabling this prop allows CSS `:hover` to be differentiated from the `:focus` (`data-highlighted`) state. | +| actionsRef | `React.RefObject` | - | A ref to imperative actions. `unmount`: When specified, the menu will not be unmounted when closed. Instead, the `unmount` function must be called to unmount the menu manually. Useful when the menu's animation is controlled by an external library.`close`: When specified, the menu can be closed imperatively.`focusItem`: Move focus to the `'first'` or `'last'` item, or `'none'` to clear the highlight. Useful when opening the menu programmatically from a custom interaction so that keyboard focus lands on an item instead of the popup container. | +| closeParentOnEsc | `boolean` | `false` | When in a submenu, determines whether pressing the Escape key closes the entire menu, or only the current child menu. | +| defaultTriggerId | `string \| null` | - | ID of the trigger that the popover is associated with. This is useful in conjunction with the `defaultOpen` prop to create an initially open popover. | +| handle | `MenuHandle` | - | A handle to associate the menu with a trigger. If specified, allows external triggers to control the menu's open state. | +| loopFocus | `boolean` | `true` | Whether to loop keyboard focus back to the first item when the end of the list is reached while using the arrow keys. | +| onOpenChangeComplete | `((open: boolean) => void)` | - | Event handler called after any animations complete when the menu is closed. | +| triggerId | `string \| null` | - | ID of the trigger that the popover is associated with. This is useful in conjunction with the `open` prop to create a controlled popover. There's no need to specify this prop when the popover is uncontrolled (that is, when the `open` prop is not set). | +| disabled | `boolean` | `false` | Whether the component should ignore user interaction. | +| orientation | `MenuRoot.Orientation` | `'vertical'` | The visual orientation of the menu. Controls whether roving focus uses up/down or left/right arrow keys. | +| children | `React.ReactNode \| PayloadChildRenderFunction` | - | The content of the popover. This can be a regular React node or a render function that receives the `payload` of the active trigger. | ### SubmenuRoot.Props @@ -985,6 +989,12 @@ type Orientation = 'horizontal' | 'vertical'; type PayloadChildRenderFunction = (arg: { payload: unknown }) => ReactNode; ``` +### FocusItem + +```typescript +type FocusItem = 'first' | 'last' | 'none'; +``` + ### Side ```typescript diff --git a/docs/src/app/(docs)/react/components/menu/types.md b/docs/src/app/(docs)/react/components/menu/types.md index 3b3803fcc52..b2aa3a7525a 100644 --- a/docs/src/app/(docs)/react/components/menu/types.md +++ b/docs/src/app/(docs)/react/components/menu/types.md @@ -11,23 +11,23 @@ Doesn't render its own HTML element. **Root Props:** -| Prop | Type | Default | Description | -| :------------------- | :---------------------------------------------------------------------- | :----------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| defaultOpen | `boolean` | `false` | Whether the menu is initially open. To render a controlled menu, use the `open` prop instead. | -| open | `boolean` | - | Whether the menu is currently open. | -| onOpenChange | `((open: boolean, eventDetails: Menu.Root.ChangeEventDetails) => void)` | - | Event handler called when the menu is opened or closed. | -| highlightItemOnHover | `boolean` | `true` | Whether moving the pointer over items should highlight them. Disabling this prop allows CSS `:hover` to be differentiated from the `:focus` (`data-highlighted`) state. | -| actionsRef | `React.RefObject` | - | A ref to imperative actions. `unmount`: When specified, the menu will not be unmounted when closed. Instead, the `unmount` function must be called to unmount the menu manually. Useful when the menu's animation is controlled by an external library.`close`: When specified, the menu can be closed imperatively. | -| closeParentOnEsc | `boolean` | `false` | When in a submenu, determines whether pressing the Escape key closes the entire menu, or only the current child menu. | -| defaultTriggerId | `string \| null` | - | ID of the trigger that the popover is associated with. This is useful in conjunction with the `defaultOpen` prop to create an initially open popover. | -| handle | `Menu.Handle` | - | A handle to associate the menu with a trigger. If specified, allows external triggers to control the menu's open state. | -| loopFocus | `boolean` | `true` | Whether to loop keyboard focus back to the first item when the end of the list is reached while using the arrow keys. | -| modal | `boolean` | `true` | Determines if the menu enters a modal state when open. `true`: user interaction is limited to the menu: document page scroll is locked and pointer interactions on outside elements are disabled.`false`: user interaction with the rest of the document is allowed. | -| onOpenChangeComplete | `((open: boolean) => void)` | - | Event handler called after any animations complete when the menu is closed. | -| triggerId | `string \| null` | - | ID of the trigger that the popover is associated with. This is useful in conjunction with the `open` prop to create a controlled popover. There's no need to specify this prop when the popover is uncontrolled (that is, when the `open` prop is not set). | -| disabled | `boolean` | `false` | Whether the component should ignore user interaction. | -| orientation | `Menu.Root.Orientation` | `'vertical'` | The visual orientation of the menu. Controls whether roving focus uses up/down or left/right arrow keys. | -| children | `React.ReactNode \| PayloadChildRenderFunction` | - | The content of the popover. This can be a regular React node or a render function that receives the `payload` of the active trigger. | +| Prop | Type | Default | Description | +| :------------------- | :---------------------------------------------------------------------- | :----------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| defaultOpen | `boolean` | `false` | Whether the menu is initially open. To render a controlled menu, use the `open` prop instead. | +| open | `boolean` | - | Whether the menu is currently open. | +| onOpenChange | `((open: boolean, eventDetails: Menu.Root.ChangeEventDetails) => void)` | - | Event handler called when the menu is opened or closed. | +| highlightItemOnHover | `boolean` | `true` | Whether moving the pointer over items should highlight them. Disabling this prop allows CSS `:hover` to be differentiated from the `:focus` (`data-highlighted`) state. | +| actionsRef | `React.RefObject` | - | A ref to imperative actions. `unmount`: When specified, the menu will not be unmounted when closed. Instead, the `unmount` function must be called to unmount the menu manually. Useful when the menu's animation is controlled by an external library.`close`: When specified, the menu can be closed imperatively.`focusItem`: Move focus to the `'first'` or `'last'` item, or `'none'` to clear the highlight. Useful when opening the menu programmatically from a custom interaction so that keyboard focus lands on an item instead of the popup container. | +| closeParentOnEsc | `boolean` | `false` | When in a submenu, determines whether pressing the Escape key closes the entire menu, or only the current child menu. | +| defaultTriggerId | `string \| null` | - | ID of the trigger that the popover is associated with. This is useful in conjunction with the `defaultOpen` prop to create an initially open popover. | +| handle | `Menu.Handle` | - | A handle to associate the menu with a trigger. If specified, allows external triggers to control the menu's open state. | +| loopFocus | `boolean` | `true` | Whether to loop keyboard focus back to the first item when the end of the list is reached while using the arrow keys. | +| modal | `boolean` | `true` | Determines if the menu enters a modal state when open. `true`: user interaction is limited to the menu: document page scroll is locked and pointer interactions on outside elements are disabled.`false`: user interaction with the rest of the document is allowed. | +| onOpenChangeComplete | `((open: boolean) => void)` | - | Event handler called after any animations complete when the menu is closed. | +| triggerId | `string \| null` | - | ID of the trigger that the popover is associated with. This is useful in conjunction with the `open` prop to create a controlled popover. There's no need to specify this prop when the popover is uncontrolled (that is, when the `open` prop is not set). | +| disabled | `boolean` | `false` | Whether the component should ignore user interaction. | +| orientation | `Menu.Root.Orientation` | `'vertical'` | The visual orientation of the menu. Controls whether roving focus uses up/down or left/right arrow keys. | +| children | `React.ReactNode \| PayloadChildRenderFunction` | - | The content of the popover. This can be a regular React node or a render function that receives the `payload` of the active trigger. | ### Root.Props @@ -42,7 +42,11 @@ type MenuRootState = {}; ### Root.Actions ```typescript -type MenuRootActions = { unmount: () => void; close: () => void }; +type MenuRootActions = { + unmount: () => void; + close: () => void; + focusItem: (target: Menu.Root.FocusItem) => void; +}; ``` ### Root.ChangeEventReason @@ -102,6 +106,12 @@ type MenuRootChangeEventDetails = ( type MenuRootOrientation = 'horizontal' | 'vertical'; ``` +### Root.FocusItem + +```typescript +type MenuRootFocusItem = 'first' | 'last' | 'none'; +``` + ### Trigger A button that opens the menu. @@ -570,22 +580,22 @@ Doesn't render its own HTML element. **SubmenuRoot Props:** -| Prop | Type | Default | Description | -| :------------------- | :----------------------------------------------------------------------------- | :----------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| defaultOpen | `boolean` | `false` | Whether the menu is initially open. To render a controlled menu, use the `open` prop instead. | -| open | `boolean` | - | Whether the menu is currently open. | -| onOpenChange | `((open: boolean, eventDetails: Menu.SubmenuRoot.ChangeEventDetails) => void)` | - | Event handler called when the menu is opened or closed. | -| highlightItemOnHover | `boolean` | `true` | Whether moving the pointer over items should highlight them. Disabling this prop allows CSS `:hover` to be differentiated from the `:focus` (`data-highlighted`) state. | -| actionsRef | `React.RefObject` | - | A ref to imperative actions. `unmount`: When specified, the menu will not be unmounted when closed. Instead, the `unmount` function must be called to unmount the menu manually. Useful when the menu's animation is controlled by an external library.`close`: When specified, the menu can be closed imperatively. | -| closeParentOnEsc | `boolean` | `false` | When in a submenu, determines whether pressing the Escape key closes the entire menu, or only the current child menu. | -| defaultTriggerId | `string \| null` | - | ID of the trigger that the popover is associated with. This is useful in conjunction with the `defaultOpen` prop to create an initially open popover. | -| handle | `Menu.Handle` | - | A handle to associate the menu with a trigger. If specified, allows external triggers to control the menu's open state. | -| loopFocus | `boolean` | `true` | Whether to loop keyboard focus back to the first item when the end of the list is reached while using the arrow keys. | -| onOpenChangeComplete | `((open: boolean) => void)` | - | Event handler called after any animations complete when the menu is closed. | -| triggerId | `string \| null` | - | ID of the trigger that the popover is associated with. This is useful in conjunction with the `open` prop to create a controlled popover. There's no need to specify this prop when the popover is uncontrolled (that is, when the `open` prop is not set). | -| disabled | `boolean` | `false` | Whether the component should ignore user interaction. | -| orientation | `Menu.Root.Orientation` | `'vertical'` | The visual orientation of the menu. Controls whether roving focus uses up/down or left/right arrow keys. | -| children | `React.ReactNode \| PayloadChildRenderFunction` | - | The content of the popover. This can be a regular React node or a render function that receives the `payload` of the active trigger. | +| Prop | Type | Default | Description | +| :------------------- | :----------------------------------------------------------------------------- | :----------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| defaultOpen | `boolean` | `false` | Whether the menu is initially open. To render a controlled menu, use the `open` prop instead. | +| open | `boolean` | - | Whether the menu is currently open. | +| onOpenChange | `((open: boolean, eventDetails: Menu.SubmenuRoot.ChangeEventDetails) => void)` | - | Event handler called when the menu is opened or closed. | +| highlightItemOnHover | `boolean` | `true` | Whether moving the pointer over items should highlight them. Disabling this prop allows CSS `:hover` to be differentiated from the `:focus` (`data-highlighted`) state. | +| actionsRef | `React.RefObject` | - | A ref to imperative actions. `unmount`: When specified, the menu will not be unmounted when closed. Instead, the `unmount` function must be called to unmount the menu manually. Useful when the menu's animation is controlled by an external library.`close`: When specified, the menu can be closed imperatively.`focusItem`: Move focus to the `'first'` or `'last'` item, or `'none'` to clear the highlight. Useful when opening the menu programmatically from a custom interaction so that keyboard focus lands on an item instead of the popup container. | +| closeParentOnEsc | `boolean` | `false` | When in a submenu, determines whether pressing the Escape key closes the entire menu, or only the current child menu. | +| defaultTriggerId | `string \| null` | - | ID of the trigger that the popover is associated with. This is useful in conjunction with the `defaultOpen` prop to create an initially open popover. | +| handle | `Menu.Handle` | - | A handle to associate the menu with a trigger. If specified, allows external triggers to control the menu's open state. | +| loopFocus | `boolean` | `true` | Whether to loop keyboard focus back to the first item when the end of the list is reached while using the arrow keys. | +| onOpenChangeComplete | `((open: boolean) => void)` | - | Event handler called after any animations complete when the menu is closed. | +| triggerId | `string \| null` | - | ID of the trigger that the popover is associated with. This is useful in conjunction with the `open` prop to create a controlled popover. There's no need to specify this prop when the popover is uncontrolled (that is, when the `open` prop is not set). | +| disabled | `boolean` | `false` | Whether the component should ignore user interaction. | +| orientation | `Menu.Root.Orientation` | `'vertical'` | The visual orientation of the menu. Controls whether roving focus uses up/down or left/right arrow keys. | +| children | `React.ReactNode \| PayloadChildRenderFunction` | - | The content of the popover. This can be a regular React node or a render function that receives the `payload` of the active trigger. | ### SubmenuRoot.Props @@ -1025,7 +1035,7 @@ type ReturnValue = Menu.Handle; **Methods:** ```typescript -function open(triggerId: string): void; +function open(triggerId: string, focusItem?: Menu.Root.FocusItem | undefined): void; ``` Opens the menu and associates it with the trigger with the given id. @@ -1143,7 +1153,7 @@ type PayloadChildRenderFunction = (arg: { payload: unknown | undefined }) => Rea - `Menu.RadioGroup`: `Menu.RadioGroup`, `Menu.RadioGroup.Props`, `Menu.RadioGroup.State`, `Menu.RadioGroup.ChangeEventReason`, `Menu.RadioGroup.ChangeEventDetails` - `Menu.RadioItem`: `Menu.RadioItem`, `Menu.RadioItem.State`, `Menu.RadioItem.Props` - `Menu.RadioItemIndicator`: `Menu.RadioItemIndicator`, `Menu.RadioItemIndicator.Props`, `Menu.RadioItemIndicator.State` -- `Menu.Root`: `Menu.Root`, `Menu.Root.State`, `Menu.Root.Props`, `Menu.Root.Actions`, `Menu.Root.ChangeEventReason`, `Menu.Root.ChangeEventDetails`, `Menu.Root.Orientation` +- `Menu.Root`: `Menu.Root`, `Menu.Root.State`, `Menu.Root.Props`, `Menu.Root.Actions`, `Menu.Root.FocusItem`, `Menu.Root.ChangeEventReason`, `Menu.Root.ChangeEventDetails`, `Menu.Root.Orientation` - `Menu.SubmenuRoot`: `Menu.SubmenuRoot`, `Menu.SubmenuRoot.Props`, `Menu.SubmenuRoot.State`, `Menu.SubmenuRoot.ChangeEventReason`, `Menu.SubmenuRoot.ChangeEventDetails` - `Menu.Trigger`: `Menu.Trigger`, `Menu.Trigger.Props`, `Menu.Trigger.State` - `Menu.Viewport`: `Menu.Viewport`, `Menu.Viewport.Props`, `Menu.Viewport.State` @@ -1151,7 +1161,7 @@ type PayloadChildRenderFunction = (arg: { payload: unknown | undefined }) => Rea - `Menu.SubmenuTrigger`: `Menu.SubmenuTrigger`, `Menu.SubmenuTrigger.Props`, `Menu.SubmenuTrigger.State` - `Menu.Handle` - `Menu.createHandle` -- `Default`: `MenuRootState`, `MenuRootProps`, `MenuRootActions`, `MenuRootChangeEventReason`, `MenuRootChangeEventDetails`, `MenuRootOrientation`, `MenuParent`, `MenuArrowState`, `MenuArrowProps`, `MenuBackdropState`, `MenuBackdropProps`, `MenuCheckboxItemState`, `MenuCheckboxItemProps`, `MenuCheckboxItemChangeEventReason`, `MenuCheckboxItemChangeEventDetails`, `MenuCheckboxItemIndicatorProps`, `MenuCheckboxItemIndicatorState`, `MenuGroupLabelProps`, `MenuGroupLabelState`, `MenuGroupProps`, `MenuGroupState`, `MenuItemState`, `MenuItemProps`, `MenuLinkItemState`, `MenuLinkItemProps`, `MenuPopupProps`, `MenuPopupState`, `MenuPortalState`, `MenuPortalProps`, `MenuPositionerState`, `MenuPositionerProps`, `MenuRadioGroupProps`, `MenuRadioGroupState`, `MenuRadioGroupChangeEventReason`, `MenuRadioGroupChangeEventDetails`, `MenuRadioItemState`, `MenuRadioItemProps`, `MenuRadioItemIndicatorProps`, `MenuRadioItemIndicatorState`, `MenuSubmenuRootProps`, `MenuSubmenuRootState`, `MenuSubmenuRootChangeEventReason`, `MenuSubmenuRootChangeEventDetails`, `MenuTriggerProps`, `MenuTriggerState`, `MenuSubmenuTriggerState`, `MenuSubmenuTriggerProps` +- `Default`: `MenuRootState`, `MenuRootProps`, `MenuRootActions`, `MenuRootFocusItem`, `MenuRootChangeEventReason`, `MenuRootChangeEventDetails`, `MenuRootOrientation`, `MenuParent`, `MenuArrowState`, `MenuArrowProps`, `MenuBackdropState`, `MenuBackdropProps`, `MenuCheckboxItemState`, `MenuCheckboxItemProps`, `MenuCheckboxItemChangeEventReason`, `MenuCheckboxItemChangeEventDetails`, `MenuCheckboxItemIndicatorProps`, `MenuCheckboxItemIndicatorState`, `MenuGroupLabelProps`, `MenuGroupLabelState`, `MenuGroupProps`, `MenuGroupState`, `MenuItemState`, `MenuItemProps`, `MenuLinkItemState`, `MenuLinkItemProps`, `MenuPopupProps`, `MenuPopupState`, `MenuPortalState`, `MenuPortalProps`, `MenuPositionerState`, `MenuPositionerProps`, `MenuRadioGroupProps`, `MenuRadioGroupState`, `MenuRadioGroupChangeEventReason`, `MenuRadioGroupChangeEventDetails`, `MenuRadioItemState`, `MenuRadioItemProps`, `MenuRadioItemIndicatorProps`, `MenuRadioItemIndicatorState`, `MenuSubmenuRootProps`, `MenuSubmenuRootState`, `MenuSubmenuRootChangeEventReason`, `MenuSubmenuRootChangeEventDetails`, `MenuTriggerProps`, `MenuTriggerState`, `MenuSubmenuTriggerState`, `MenuSubmenuTriggerProps` ## Canonical Types @@ -1192,6 +1202,7 @@ Maps `Canonical`: `Alias` — Use Canonical when its namespace is already import - `Menu.Root.State`: `MenuRootState` - `Menu.Root.Props`: `MenuRootProps` - `Menu.Root.Actions`: `MenuRootActions` +- `Menu.Root.FocusItem`: `MenuRootFocusItem` - `Menu.Root.ChangeEventReason`: `MenuRootChangeEventReason` - `Menu.Root.ChangeEventDetails`: `MenuRootChangeEventDetails` - `Menu.Root.Orientation`: `MenuRootOrientation` diff --git a/docs/src/app/(docs)/react/components/page.mdx b/docs/src/app/(docs)/react/components/page.mdx index d8165f073f2..63d7a87d6a4 100644 --- a/docs/src/app/(docs)/react/components/page.mdx +++ b/docs/src/app/(docs)/react/components/page.mdx @@ -999,7 +999,7 @@ A list of actions in a dropdown, enhanced with keyboard navigation. - Menu - LinkItem - Props: className, closeOnClick, label, render, style - Data Attributes: data-highlighted -- Types: Menu.Arrow.Props, Menu.Arrow.State, Menu.Backdrop.Props, Menu.Backdrop.State, Menu.CheckboxItem.ChangeEventDetails, Menu.CheckboxItem.ChangeEventReason, Menu.CheckboxItem.Props, Menu.CheckboxItem.State, Menu.CheckboxItemIndicator.Props, Menu.CheckboxItemIndicator.State, Menu.Group.Props, Menu.Group.State, Menu.GroupLabel.Props, Menu.GroupLabel.State, Menu.Item.Props, Menu.Item.State, Menu.LinkItem.Props, Menu.LinkItem.State, Menu.Popup.Props, Menu.Popup.State, Menu.Portal.Props, Menu.Portal.State, Menu.Positioner.Props, Menu.Positioner.State, Menu.RadioGroup.ChangeEventDetails, Menu.RadioGroup.ChangeEventReason, Menu.RadioGroup.Props, Menu.RadioGroup.State, Menu.RadioItem.Props, Menu.RadioItem.State, Menu.RadioItemIndicator.Props, Menu.RadioItemIndicator.State, Menu.Root.Actions, Menu.Root.ChangeEventDetails, Menu.Root.ChangeEventReason, Menu.Root.Orientation, Menu.Root.Props, Menu.Root.State, Menu.Separator.Props, Menu.Separator.State, Menu.SubmenuRoot.ChangeEventDetails, Menu.SubmenuRoot.ChangeEventReason, Menu.SubmenuRoot.Props, Menu.SubmenuRoot.State, Menu.SubmenuTrigger.Props, Menu.SubmenuTrigger.State, Menu.Trigger.Props, Menu.Trigger.State, Menu.Viewport.Props, Menu.Viewport.State +- Types: Menu.Arrow.Props, Menu.Arrow.State, Menu.Backdrop.Props, Menu.Backdrop.State, Menu.CheckboxItem.ChangeEventDetails, Menu.CheckboxItem.ChangeEventReason, Menu.CheckboxItem.Props, Menu.CheckboxItem.State, Menu.CheckboxItemIndicator.Props, Menu.CheckboxItemIndicator.State, Menu.Group.Props, Menu.Group.State, Menu.GroupLabel.Props, Menu.GroupLabel.State, Menu.Item.Props, Menu.Item.State, Menu.LinkItem.Props, Menu.LinkItem.State, Menu.Popup.Props, Menu.Popup.State, Menu.Portal.Props, Menu.Portal.State, Menu.Positioner.Props, Menu.Positioner.State, Menu.RadioGroup.ChangeEventDetails, Menu.RadioGroup.ChangeEventReason, Menu.RadioGroup.Props, Menu.RadioGroup.State, Menu.RadioItem.Props, Menu.RadioItem.State, Menu.RadioItemIndicator.Props, Menu.RadioItemIndicator.State, Menu.Root.Actions, Menu.Root.ChangeEventDetails, Menu.Root.ChangeEventReason, Menu.Root.FocusItem, Menu.Root.Orientation, Menu.Root.Props, Menu.Root.State, Menu.Separator.Props, Menu.Separator.State, Menu.SubmenuRoot.ChangeEventDetails, Menu.SubmenuRoot.ChangeEventReason, Menu.SubmenuRoot.Props, Menu.SubmenuRoot.State, Menu.SubmenuTrigger.Props, Menu.SubmenuTrigger.State, Menu.Trigger.Props, Menu.Trigger.State, Menu.Viewport.Props, Menu.Viewport.State diff --git a/packages/react/src/floating-ui-react/hooks/useListNavigation.ts b/packages/react/src/floating-ui-react/hooks/useListNavigation.ts index f826878758e..6165f27874a 100644 --- a/packages/react/src/floating-ui-react/hooks/useListNavigation.ts +++ b/packages/react/src/floating-ui-react/hooks/useListNavigation.ts @@ -134,6 +134,15 @@ export interface UseListNavigationProps { * @default true */ focusItemOnHover?: boolean | undefined; + /** + * A pending imperative item focus request to resolve against the list. + * @default null + */ + pendingFocusItem?: UseListNavigationFocusItem | null | undefined; + /** + * Callback fired when a pending imperative item focus request has been consumed. + */ + onPendingFocusItemClear?: (() => void) | undefined; /** * Whether pressing an arrow key on the navigation's main axis opens the * floating element. @@ -223,6 +232,8 @@ export interface UseListNavigationProps { externalTree?: FloatingTreeStore | undefined; } +export type UseListNavigationFocusItem = 'first' | 'last' | 'none'; + /** * Adds arrow key-based navigation of a list of items, either using real DOM * focus or virtual focus. @@ -245,6 +256,8 @@ export function useListNavigation( virtual = false, focusItemOnOpen = 'auto', focusItemOnHover = true, + pendingFocusItem, + onPendingFocusItemClear, openOnArrowKeyDown = true, disabledIndices = undefined, orientation = 'vertical', @@ -298,6 +311,10 @@ export function useListNavigation( onNavigateProp(indexRef.current === -1 ? null : indexRef.current, event); }); + const clearPendingFocusItem = useStableCallback(() => { + onPendingFocusItemClear?.(); + }); + const previousOnNavigateRef = React.useRef(onNavigate); const previousMountedRef = React.useRef(!!floatingElement); const previousOpenRef = React.useRef(open); @@ -311,7 +328,6 @@ export function useListNavigation( const resetOnPointerLeaveRef = useValueAsRef(resetOnPointerLeave); const focusFrame = useAnimationFrame(); - const waitForListPopulatedFrame = useAnimationFrame(); const focusItem = useStableCallback(() => { function runFocus(item: HTMLElement) { @@ -391,21 +407,67 @@ export function useListNavigation( // open. useIsoLayoutEffect(() => { if (!enabled) { - return; + return undefined; } if (!open) { forceSyncFocusRef.current = false; - return; + return undefined; } if (!floatingElement) { - return; + return undefined; + } + + const resolveFocusItem = (focusLast: boolean, onDone?: () => void) => { + let cancelled = false; + + const waitForListPopulated = () => { + if (cancelled) { + return; + } + + if (listRef.current[0] == null) { + onDone?.(); + return; + } + + indexRef.current = focusLast ? getMaxListIndex(listRef) : getMinListIndex(listRef); + keyRef.current = null; + onNavigate(); + onDone?.(); + }; + + if (listRef.current[0] == null) { + // Some composed items register after their indexes are assigned by + // CompositeList, which can land one layout-effect flush after the + // popup opens. Retry once before clearing the pending request. + queueMicrotask(waitForListPopulated); + } else { + waitForListPopulated(); + } + + return () => { + cancelled = true; + }; + }; + + if (pendingFocusItem != null) { + forceSyncFocusRef.current = false; + + if (pendingFocusItem === 'none') { + indexRef.current = -1; + onNavigate(); + clearPendingFocusItem(); + return undefined; + } + + return resolveFocusItem(pendingFocusItem === 'last', clearPendingFocusItem); } if (activeIndex == null) { forceSyncFocusRef.current = false; if (selectedIndexRef.current != null) { - return; + return undefined; } // Reset while the floating element was open (e.g. the list changed). @@ -420,52 +482,33 @@ export function useListNavigation( focusItemOnOpenRef.current && (keyRef.current != null || (focusItemOnOpenRef.current === true && keyRef.current == null)) ) { - let runs = 0; - const waitForListPopulated = () => { - if (listRef.current[0] == null) { - // Avoid letting the browser paint if possible on the first try, - // otherwise use rAF. Don't try more than twice, since something - // is wrong otherwise. - if (runs < 2) { - const scheduler = runs - ? (callback: () => void) => waitForListPopulatedFrame.request(callback) - : queueMicrotask; - scheduler(waitForListPopulated); - } - runs += 1; - } else { - // initially focus the first non-disabled item - indexRef.current = - keyRef.current == null || - isMainOrientationToEndKey(keyRef.current, orientation, rtl) || - nested - ? getMinListIndex(listRef) - : getMaxListIndex(listRef); - keyRef.current = null; - onNavigate(); - } - }; - - waitForListPopulated(); + return resolveFocusItem( + keyRef.current != null && + !isMainOrientationToEndKey(keyRef.current, orientation, rtl) && + !nested, + ); } } else if (!isIndexOutOfListBounds(listRef.current, activeIndex)) { indexRef.current = activeIndex; focusItem(); forceScrollIntoViewRef.current = false; } + + return undefined; }, [ enabled, open, floatingElement, activeIndex, + pendingFocusItem, selectedIndexRef, nested, listRef, orientation, rtl, onNavigate, + clearPendingFocusItem, focusItem, - waitForListPopulatedFrame, ]); // Ensure the parent floating element has focus when a nested child closes diff --git a/packages/react/src/menu/root/MenuRoot.detached-triggers.test.tsx b/packages/react/src/menu/root/MenuRoot.detached-triggers.test.tsx index 6f01b3fa2ef..23505459e4d 100644 --- a/packages/react/src/menu/root/MenuRoot.detached-triggers.test.tsx +++ b/packages/react/src/menu/root/MenuRoot.detached-triggers.test.tsx @@ -1122,5 +1122,69 @@ describe('', () => { expect(trigger2).toHaveAttribute('aria-expanded', 'false'); }); + + it('focuses the first item when opening with `focusItem: first`', async () => { + const menuHandle = Menu.createHandle(); + await render( +
+ + Trigger + + + + + + One + Two + + + + +
, + ); + + await act(async () => { + menuHandle.open('trigger', 'first'); + }); + + const firstItem = await screen.findByTestId('item-1'); + await waitFor(() => { + expect(firstItem).toHaveFocus(); + }); + expect(firstItem).toHaveAttribute('tabindex', '0'); + }); + + it('focuses the popup when opening with `focusItem: none`', async () => { + const menuHandle = Menu.createHandle(); + await render( +
+ + Trigger + + + + + + One + Two + + + + +
, + ); + + await act(async () => { + menuHandle.open('trigger', 'none'); + }); + + const popup = await screen.findByRole('menu'); + await waitFor(() => { + expect(popup).toHaveFocus(); + }); + screen.getAllByRole('menuitem').forEach((item) => { + expect(item).toHaveAttribute('tabindex', '-1'); + }); + }); }); }); diff --git a/packages/react/src/menu/root/MenuRoot.test.tsx b/packages/react/src/menu/root/MenuRoot.test.tsx index 9d01b4a7b16..7155216b324 100644 --- a/packages/react/src/menu/root/MenuRoot.test.tsx +++ b/packages/react/src/menu/root/MenuRoot.test.tsx @@ -10,6 +10,7 @@ import { } from '@mui/internal-test-utils'; import { DirectionProvider } from '@base-ui/react/direction-provider'; import { useRefWithInit } from '@base-ui/utils/useRefWithInit'; +import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; import { Menu } from '@base-ui/react/menu'; import { Dialog } from '@base-ui/react/dialog'; import { AlertDialog } from '@base-ui/react/alert-dialog'; @@ -1345,6 +1346,7 @@ describe('', () => { current: { unmount: vi.fn(), close: vi.fn(), + focusItem: vi.fn(), }, }; @@ -1388,6 +1390,125 @@ describe('', () => { expect(screen.queryByRole('menu')).toBe(null); }); }); + + describe('focusItem', () => { + function createApp(target: Menu.Root.FocusItem) { + const actionsRef: React.RefObject = { current: null }; + + return function App() { + const [open, setOpen] = React.useState(false); + return ( +
+ + +
+ ); + }; + } + + it('focuses the first item when called with `first` while opening programmatically', async () => { + const App = createApp('first'); + const { user } = await render(); + + await user.click(screen.getByRole('button', { name: 'external' })); + + const firstItem = await screen.findByTestId('item-1'); + await waitFor(() => { + expect(firstItem).toHaveFocus(); + }); + expect(firstItem).toHaveAttribute('tabindex', '0'); + }); + + it('focuses the last item when called with `last` while opening programmatically', async () => { + const App = createApp('last'); + const { user } = await render(); + + await user.click(screen.getByRole('button', { name: 'external' })); + + const lastItem = await screen.findByTestId('item-5'); + await waitFor(() => { + expect(lastItem).toHaveFocus(); + }); + expect(lastItem).toHaveAttribute('tabindex', '0'); + }); + + it('focuses the popup when called with `none`', async () => { + const App = createApp('none'); + const { user } = await render(); + + await user.click(screen.getByRole('button', { name: 'external' })); + + const popup = await screen.findByRole('menu'); + await waitFor(() => { + expect(popup).toHaveFocus(); + }); + screen.getAllByRole('menuitem').forEach((item) => { + expect(item).toHaveAttribute('tabindex', '-1'); + }); + }); + + it('waits for composed items that register after a layout effect', async () => { + const actionsRef: React.RefObject = { current: null }; + + function DelayedItem(props: { children: React.ReactNode; testId: string }) { + const [mounted, setMounted] = React.useState(false); + useIsoLayoutEffect(() => { + setMounted(true); + }, []); + + if (!mounted) { + return null; + } + + return {props.children}; + } + + function App() { + const [open, setOpen] = React.useState(false); + return ( +
+ + + One + Two + + ), + }} + /> +
+ ); + } + + const { user } = await render(); + + await user.click(screen.getByRole('button', { name: 'external' })); + + const firstItem = await screen.findByTestId('delayed-1'); + await waitFor(() => { + expect(firstItem).toHaveFocus(); + }); + }); + }); }); describe.skipIf(isJSDOM)('prop: onOpenChangeComplete', () => { diff --git a/packages/react/src/menu/root/MenuRoot.tsx b/packages/react/src/menu/root/MenuRoot.tsx index ead61132115..0efa9191ee6 100644 --- a/packages/react/src/menu/root/MenuRoot.tsx +++ b/packages/react/src/menu/root/MenuRoot.tsx @@ -365,10 +365,31 @@ export const MenuRoot = fastComponent(function MenuRoot(props: MenuRoot store.setOpen(false, createChangeEventDetails(REASONS.imperativeAction)); }, [store]); + const setActiveIndex = React.useCallback( + (index: number | null) => { + if (store.select('activeIndex') === index) { + return; + } + store.set('activeIndex', index); + }, + [store], + ); + + const handleImperativeFocusItem = React.useCallback( + (target: MenuRoot.FocusItem) => { + store.set('pendingFocusItem', target); + }, + [store], + ); + React.useImperativeHandle( actionsRef, - () => ({ unmount: forceUnmount, close: handleImperativeClose }), - [forceUnmount, handleImperativeClose], + () => ({ + unmount: forceUnmount, + close: handleImperativeClose, + focusItem: handleImperativeFocusItem, + }), + [forceUnmount, handleImperativeClose, handleImperativeFocusItem], ); let ctx: ContextMenuRootContext | undefined; @@ -398,16 +419,7 @@ export const MenuRoot = fastComponent(function MenuRoot(props: MenuRoot }); const direction = useDirection(); - - const setActiveIndex = React.useCallback( - (index: number | null) => { - if (store.select('activeIndex') === index) { - return; - } - store.set('activeIndex', index); - }, - [store], - ); + const pendingFocusItem = store.useState('pendingFocusItem'); const listNavigation = useListNavigation(floatingRootContext, { enabled: !disabled, @@ -423,6 +435,10 @@ export const MenuRoot = fastComponent(function MenuRoot(props: MenuRoot openOnArrowKeyDown: parent.type !== 'context-menu', externalTree: nested ? floatingTreeRoot : undefined, focusItemOnHover: highlightItemOnHover, + pendingFocusItem, + onPendingFocusItemClear() { + store.set('pendingFocusItem', null); + }, }); const onTyping = React.useCallback( @@ -618,6 +634,9 @@ export interface MenuRootProps { * Instead, the `unmount` function must be called to unmount the menu manually. * Useful when the menu's animation is controlled by an external library. * - `close`: When specified, the menu can be closed imperatively. + * - `focusItem`: Move focus to the `'first'` or `'last'` item, or `'none'` to clear the highlight. + * Useful when opening the menu programmatically from a custom interaction + * so that keyboard focus lands on an item instead of the popup container. */ actionsRef?: React.RefObject | undefined; /** @@ -646,8 +665,11 @@ export interface MenuRootProps { export interface MenuRootActions { unmount: () => void; close: () => void; + focusItem: (target: MenuRoot.FocusItem) => void; } +export type MenuRootFocusItem = 'first' | 'last' | 'none'; + export type MenuRootChangeEventReason = | typeof REASONS.triggerHover | typeof REASONS.triggerFocus @@ -695,6 +717,7 @@ export namespace MenuRoot { export type State = MenuRootState; export type Props = MenuRootProps; export type Actions = MenuRootActions; + export type FocusItem = MenuRootFocusItem; export type ChangeEventReason = MenuRootChangeEventReason; export type ChangeEventDetails = MenuRootChangeEventDetails; export type Orientation = MenuRootOrientation; diff --git a/packages/react/src/menu/store/MenuHandle.ts b/packages/react/src/menu/store/MenuHandle.ts index 33449dc1171..9146d632ffc 100644 --- a/packages/react/src/menu/store/MenuHandle.ts +++ b/packages/react/src/menu/store/MenuHandle.ts @@ -1,4 +1,5 @@ import { createChangeEventDetails } from '../../internals/createBaseUIEventDetails'; +import type { MenuRoot } from '../root/MenuRoot'; import { MenuStore } from './MenuStore'; export class MenuHandle { @@ -17,8 +18,9 @@ export class MenuHandle { * The trigger must be a Menu.Trigger component with this handle passed as a prop. * * @param triggerId ID of the trigger to associate with the menu. + * @param focusItem Optional item to focus once the menu is open: `'first'`, `'last'`, or `'none'`. */ - open(triggerId: string) { + open(triggerId: string, focusItem?: MenuRoot.FocusItem) { const triggerElement = triggerId ? (this.store.context.triggerElements.getById(triggerId) as HTMLElement | undefined) : undefined; @@ -27,6 +29,10 @@ export class MenuHandle { throw new Error(`Base UI: MenuHandle.open: No trigger found with id "${triggerId}".`); } + if (focusItem != null) { + this.store.set('pendingFocusItem', focusItem); + } + this.store.setOpen( true, createChangeEventDetails('imperative-action', undefined, triggerElement), diff --git a/packages/react/src/menu/store/MenuStore.ts b/packages/react/src/menu/store/MenuStore.ts index 6c7e6177339..5ab4b21f7cb 100644 --- a/packages/react/src/menu/store/MenuStore.ts +++ b/packages/react/src/menu/store/MenuStore.ts @@ -22,6 +22,7 @@ export type State = PopupStoreState & { parent: MenuParent; rootId: string | undefined; activeIndex: number | null; + pendingFocusItem: MenuRoot.FocusItem | null; hoverEnabled: boolean; stickIfOpen: boolean; instantType: 'dismiss' | 'click' | 'group' | 'trigger-change' | undefined; @@ -71,6 +72,7 @@ const selectors = { return state.parent.type !== undefined ? state.parent.context.rootId : state.rootId; }), activeIndex: createSelector((state: State) => state.activeIndex), + pendingFocusItem: createSelector((state: State) => state.pendingFocusItem), isActive: createSelector( (state: State, itemIndex: number) => state.activeIndex === itemIndex, ), @@ -199,6 +201,7 @@ function createInitialState(): State { }, rootId: undefined, activeIndex: null, + pendingFocusItem: null, hoverEnabled: true, instantType: undefined, openChangeReason: null,