diff --git a/docs/reference/generated/menu-item.json b/docs/reference/generated/menu-item.json index b50fb573145..159fd25453b 100644 --- a/docs/reference/generated/menu-item.json +++ b/docs/reference/generated/menu-item.json @@ -38,10 +38,6 @@ "type": "string", "detailedType": "string | undefined" }, - "children": { - "type": "ReactNode", - "detailedType": "React.ReactNode" - }, "className": { "type": "string | ((state: Menu.Item.State) => string | undefined)", "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state.", diff --git a/docs/reference/generated/menu-radio-item.json b/docs/reference/generated/menu-radio-item.json index 68a089acaea..6dc318a9296 100644 --- a/docs/reference/generated/menu-radio-item.json +++ b/docs/reference/generated/menu-radio-item.json @@ -43,10 +43,6 @@ "type": "string", "detailedType": "string | undefined" }, - "children": { - "type": "ReactNode", - "detailedType": "React.ReactNode" - }, "className": { "type": "string | ((state: Menu.RadioItem.State) => string | undefined)", "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state.", diff --git a/docs/reference/generated/menu-submenu-trigger.json b/docs/reference/generated/menu-submenu-trigger.json index 0bb313f8365..09e90907d25 100644 --- a/docs/reference/generated/menu-submenu-trigger.json +++ b/docs/reference/generated/menu-submenu-trigger.json @@ -25,10 +25,6 @@ "type": "string", "detailedType": "string | undefined" }, - "children": { - "type": "ReactNode", - "detailedType": "React.ReactNode" - }, "className": { "type": "string | ((state: Menu.SubmenuTrigger.State) => string | undefined)", "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state.", diff --git a/packages/react/src/floating-ui-react/hooks/useListNavigation.ts b/packages/react/src/floating-ui-react/hooks/useListNavigation.ts index 7c87ec8fa6f..4024bd257fe 100644 --- a/packages/react/src/floating-ui-react/hooks/useListNavigation.ts +++ b/packages/react/src/floating-ui-react/hooks/useListNavigation.ts @@ -144,7 +144,7 @@ export interface UseListNavigationProps { * navigating via arrow keys, specify an empty array. * @default undefined */ - disabledIndices?: Array | ((index: number) => boolean); + disabledIndices?: ReadonlyArray | ((index: number) => boolean); /** * Determines whether focus can escape the list, such that nothing is selected * after navigating beyond the boundary of the list. In some diff --git a/packages/react/src/floating-ui-react/utils/composite.ts b/packages/react/src/floating-ui-react/utils/composite.ts index 64914b663d2..be134c571cc 100644 --- a/packages/react/src/floating-ui-react/utils/composite.ts +++ b/packages/react/src/floating-ui-react/utils/composite.ts @@ -4,7 +4,7 @@ import type { Dimensions } from '../types'; import { stopEvent } from './event'; import { ARROW_DOWN, ARROW_LEFT, ARROW_RIGHT, ARROW_UP } from './constants'; -type DisabledIndices = Array | ((index: number) => boolean); +type DisabledIndices = ReadonlyArray | ((index: number) => boolean); export function isDifferentGridRow(index: number, cols: number, prevRow: number) { return Math.floor(index / cols) !== prevRow; @@ -18,7 +18,7 @@ export function isIndexOutOfListBounds( } export function getMinListIndex( - listRef: React.RefObject>, + listRef: React.RefObject>, disabledIndices?: DisabledIndices | undefined, ) { return findNonDisabledListIndex(listRef, { disabledIndices }); @@ -36,7 +36,7 @@ export function getMaxListIndex( } export function findNonDisabledListIndex( - listRef: React.RefObject>, + listRef: React.RefObject>, { startingIndex = -1, decrement = false, @@ -421,7 +421,7 @@ export function getGridCellIndices( } export function isListIndexDisabled( - listRef: React.RefObject>, + listRef: React.RefObject>, index: number, disabledIndices?: DisabledIndices, ) { diff --git a/packages/react/src/menu/arrow/MenuArrow.tsx b/packages/react/src/menu/arrow/MenuArrow.tsx index 8371a17cbd9..484b4a490a4 100644 --- a/packages/react/src/menu/arrow/MenuArrow.tsx +++ b/packages/react/src/menu/arrow/MenuArrow.tsx @@ -19,8 +19,9 @@ export const MenuArrow = React.forwardRef(function MenuArrow( ) { const { className, render, ...elementProps } = componentProps; - const { open } = useMenuRootContext(); + const { store } = useMenuRootContext(); const { arrowRef, side, align, arrowUncentered, arrowStyles } = useMenuPositionerContext(); + const open = store.useState('open'); const state: MenuArrow.State = React.useMemo( () => ({ diff --git a/packages/react/src/menu/backdrop/MenuBackdrop.tsx b/packages/react/src/menu/backdrop/MenuBackdrop.tsx index 57b71a97c64..d71802fbb43 100644 --- a/packages/react/src/menu/backdrop/MenuBackdrop.tsx +++ b/packages/react/src/menu/backdrop/MenuBackdrop.tsx @@ -26,7 +26,12 @@ export const MenuBackdrop = React.forwardRef(function MenuBackdrop( ) { const { className, render, ...elementProps } = componentProps; - const { open, mounted, transitionStatus, lastOpenChangeReason } = useMenuRootContext(); + const { store } = useMenuRootContext(); + const open = store.useState('open'); + const mounted = store.useState('mounted'); + const transitionStatus = store.useState('transitionStatus'); + const lastOpenChangeReason = store.useState('lastOpenChangeReason'); + const contextMenuContext = useContextMenuRootContext(); const state: MenuBackdrop.State = React.useMemo( diff --git a/packages/react/src/menu/checkbox-item/MenuCheckboxItem.tsx b/packages/react/src/menu/checkbox-item/MenuCheckboxItem.tsx index 61055a7ef0b..20fe6532dca 100644 --- a/packages/react/src/menu/checkbox-item/MenuCheckboxItem.tsx +++ b/packages/react/src/menu/checkbox-item/MenuCheckboxItem.tsx @@ -1,105 +1,20 @@ 'use client'; import * as React from 'react'; -import { useMergedRefs } from '@base-ui-components/utils/useMergedRefs'; +import { useStableCallback } from '@base-ui-components/utils/useStableCallback'; import { useControlled } from '@base-ui-components/utils/useControlled'; -import { FloatingEvents, useFloatingTree } from '../../floating-ui-react'; +import { useFloatingTree } from '../../floating-ui-react'; import { MenuCheckboxItemContext } from './MenuCheckboxItemContext'; import { REGULAR_ITEM, useMenuItem } from '../item/useMenuItem'; import { useCompositeListItem } from '../../composite/list/useCompositeListItem'; import { useMenuRootContext } from '../root/MenuRootContext'; import { useRenderElement } from '../../utils/useRenderElement'; import { useBaseUiId } from '../../utils/useBaseUiId'; -import type { BaseUIComponentProps, HTMLProps, NonNativeButtonProps } from '../../utils/types'; +import type { BaseUIComponentProps, NonNativeButtonProps } from '../../utils/types'; import { itemMapping } from '../utils/stateAttributesMapping'; import { useMenuPositionerContext } from '../positioner/MenuPositionerContext'; import { createChangeEventDetails } from '../../utils/createBaseUIEventDetails'; import type { MenuRoot } from '../root/MenuRoot'; -const InnerMenuCheckboxItem = React.memo( - React.forwardRef(function InnerMenuCheckboxItem( - componentProps: InnerMenuCheckboxItemProps, - forwardedRef: React.ForwardedRef, - ) { - const { - checked: checkedProp, - defaultChecked, - onCheckedChange, - className, - closeOnClick, - disabled = false, - highlighted, - id, - menuEvents, - itemProps, - render, - allowMouseUpTriggerRef, - typingRef, - nativeButton, - nodeId, - ...elementProps - } = componentProps; - - const [checked, setChecked] = useControlled({ - controlled: checkedProp, - default: defaultChecked ?? false, - name: 'MenuCheckboxItem', - state: 'checked', - }); - - const { getItemProps, itemRef } = useMenuItem({ - closeOnClick, - disabled, - highlighted, - id, - menuEvents, - allowMouseUpTriggerRef, - typingRef, - nativeButton, - nodeId, - itemMetadata: REGULAR_ITEM, - }); - - const state: MenuCheckboxItem.State = React.useMemo( - () => ({ - disabled, - highlighted, - checked, - }), - [disabled, highlighted, checked], - ); - - const element = useRenderElement('div', componentProps, { - state, - stateAttributesMapping: itemMapping, - props: [ - itemProps, - { - role: 'menuitemcheckbox', - 'aria-checked': checked, - onClick(event: React.MouseEvent) { - const details = createChangeEventDetails('item-press', event.nativeEvent); - - onCheckedChange?.(!checked, details); - - if (details.isCanceled) { - return; - } - - setChecked((currentlyChecked) => !currentlyChecked); - }, - }, - elementProps, - getItemProps, - ], - ref: [itemRef, forwardedRef], - }); - - return ( - {element} - ); - }), -); - /** * A menu item that toggles a setting on or off. * Renders a `
` element. @@ -107,55 +22,93 @@ const InnerMenuCheckboxItem = React.memo( * Documentation: [Base UI Menu](https://base-ui.com/react/components/menu) */ export const MenuCheckboxItem = React.forwardRef(function MenuCheckboxItem( - props: MenuCheckboxItem.Props, + componentProps: MenuCheckboxItem.Props, forwardedRef: React.ForwardedRef, ) { - const { id: idProp, label, closeOnClick = false, nativeButton = false, ...other } = props; + const { + render, + className, + id: idProp, + label, + nativeButton = false, + disabled = false, + closeOnClick = false, + checked: checkedProp, + defaultChecked, + onCheckedChange, + ...elementProps + } = componentProps; - const itemRef = React.useRef(null); const listItem = useCompositeListItem({ label }); - const mergedRef = useMergedRefs(forwardedRef, listItem.ref, itemRef); - - const { itemProps, activeIndex, allowMouseUpTriggerRef, typingRef } = useMenuRootContext(); const menuPositionerContext = useMenuPositionerContext(true); - const id = useBaseUiId(idProp); - - const highlighted = listItem.index === activeIndex; const { events: menuEvents } = useFloatingTree()!; - // This wrapper component is used as a performance optimization. - // MenuCheckboxItem reads the context and re-renders the actual MenuCheckboxItem - // only when it needs to. + const { store } = useMenuRootContext(); + const highlighted = store.useState('isActive', listItem.index); + const itemProps = store.useState('itemProps'); + + const [checked, setChecked] = useControlled({ + controlled: checkedProp, + default: defaultChecked ?? false, + name: 'MenuCheckboxItem', + state: 'checked', + }); + + const { getItemProps, itemRef } = useMenuItem({ + closeOnClick, + disabled, + highlighted, + id, + menuEvents, + store, + nativeButton, + nodeId: menuPositionerContext?.floatingContext.nodeId, + itemMetadata: REGULAR_ITEM, + }); + + const state: MenuCheckboxItem.State = React.useMemo( + () => ({ + disabled, + highlighted, + checked, + }), + [disabled, highlighted, checked], + ); + + const handleClick = useStableCallback((event: React.MouseEvent) => { + const details = createChangeEventDetails('item-press', event.nativeEvent); + + onCheckedChange?.(!checked, details); + + if (details.isCanceled) { + return; + } + + setChecked((currentlyChecked) => !currentlyChecked); + }); + + const element = useRenderElement('div', componentProps, { + state, + stateAttributesMapping: itemMapping, + props: [ + itemProps, + { + role: 'menuitemcheckbox', + 'aria-checked': checked, + onClick: handleClick, + }, + elementProps, + getItemProps, + ], + ref: [itemRef, forwardedRef, listItem.ref], + }); return ( - + {element} ); }); -interface InnerMenuCheckboxItemProps extends MenuCheckboxItem.Props { - highlighted: boolean; - itemProps: HTMLProps; - menuEvents: FloatingEvents; - allowMouseUpTriggerRef: React.RefObject; - typingRef: React.RefObject; - closeOnClick: boolean; - nativeButton: boolean; - nodeId: string | undefined; -} - export type MenuCheckboxItemState = { /** * Whether the checkbox item should ignore user interaction. diff --git a/packages/react/src/menu/item/MenuItem.tsx b/packages/react/src/menu/item/MenuItem.tsx index 2c380f1bd4c..bf24b25c4cf 100644 --- a/packages/react/src/menu/item/MenuItem.tsx +++ b/packages/react/src/menu/item/MenuItem.tsx @@ -1,65 +1,14 @@ 'use client'; import * as React from 'react'; -import { useMergedRefs } from '@base-ui-components/utils/useMergedRefs'; -import { FloatingEvents, useFloatingTree } from '../../floating-ui-react'; +import { useFloatingTree } from '../../floating-ui-react'; import { REGULAR_ITEM, useMenuItem } from './useMenuItem'; import { useMenuRootContext } from '../root/MenuRootContext'; import { useRenderElement } from '../../utils/useRenderElement'; import { useBaseUiId } from '../../utils/useBaseUiId'; -import type { BaseUIComponentProps, HTMLProps, NonNativeButtonProps } from '../../utils/types'; +import type { BaseUIComponentProps, NonNativeButtonProps } from '../../utils/types'; import { useCompositeListItem } from '../../composite/list/useCompositeListItem'; import { useMenuPositionerContext } from '../positioner/MenuPositionerContext'; -const InnerMenuItem = React.memo( - React.forwardRef(function InnerMenuItem( - componentProps: InnerMenuItemProps, - forwardedRef: React.ForwardedRef, - ) { - const { - className, - closeOnClick = true, - disabled = false, - highlighted, - id, - menuEvents, - itemProps, - render, - allowMouseUpTriggerRef, - typingRef, - nativeButton, - nodeId, - ...elementProps - } = componentProps; - - const { getItemProps, itemRef } = useMenuItem({ - closeOnClick, - disabled, - highlighted, - id, - menuEvents, - allowMouseUpTriggerRef, - typingRef, - nativeButton, - nodeId, - itemMetadata: REGULAR_ITEM, - }); - - const state: MenuItem.State = React.useMemo( - () => ({ - disabled, - highlighted, - }), - [disabled, highlighted], - ); - - return useRenderElement('div', componentProps, { - state, - ref: [itemRef, forwardedRef], - props: [itemProps, elementProps, getItemProps], - }); - }), -); - /** * An individual interactive item in the menu. * Renders a `
` element. @@ -67,51 +16,55 @@ const InnerMenuItem = React.memo( * Documentation: [Base UI Menu](https://base-ui.com/react/components/menu) */ export const MenuItem = React.forwardRef(function MenuItem( - props: MenuItem.Props, + componentProps: MenuItem.Props, forwardedRef: React.ForwardedRef, ) { - const { id: idProp, label, nativeButton = false, ...other } = props; + const { + render, + className, + id: idProp, + label, + nativeButton = false, + disabled = false, + closeOnClick = true, + ...elementProps + } = componentProps; - const itemRef = React.useRef(null); const listItem = useCompositeListItem({ label }); - const mergedRef = useMergedRefs(forwardedRef, listItem.ref, itemRef); - - const { itemProps, activeIndex, allowMouseUpTriggerRef, typingRef } = useMenuRootContext(); const menuPositionerContext = useMenuPositionerContext(true); const id = useBaseUiId(idProp); - - const highlighted = listItem.index === activeIndex; const { events: menuEvents } = useFloatingTree()!; - // This wrapper component is used as a performance optimization. - // MenuItem reads the context and re-renders the actual MenuItem - // only when it needs to. + const { store } = useMenuRootContext(); + const highlighted = store.useState('isActive', listItem.index); + const itemProps = store.useState('itemProps'); + + const { getItemProps, itemRef } = useMenuItem({ + closeOnClick, + disabled, + highlighted, + id, + menuEvents, + store, + nativeButton, + nodeId: menuPositionerContext?.floatingContext.nodeId, + itemMetadata: REGULAR_ITEM, + }); - return ( - + const state: MenuItem.State = React.useMemo( + () => ({ + disabled, + highlighted, + }), + [disabled, highlighted], ); -}); -interface InnerMenuItemProps extends MenuItem.Props { - highlighted: boolean; - itemProps: HTMLProps; - menuEvents: FloatingEvents; - allowMouseUpTriggerRef: React.RefObject; - typingRef: React.RefObject; - nativeButton: boolean; - nodeId: string | undefined; -} + return useRenderElement('div', componentProps, { + state, + props: [itemProps, elementProps, getItemProps], + ref: [itemRef, forwardedRef, listItem.ref], + }); +}); export interface MenuItemState { /** @@ -127,7 +80,6 @@ export interface MenuItemState { export interface MenuItemProps extends NonNativeButtonProps, BaseUIComponentProps<'div', MenuItem.State> { - children?: React.ReactNode; /** * The click handler for the menu item. */ diff --git a/packages/react/src/menu/item/useMenuItem.ts b/packages/react/src/menu/item/useMenuItem.ts index e70210dda4f..886ec83c771 100644 --- a/packages/react/src/menu/item/useMenuItem.ts +++ b/packages/react/src/menu/item/useMenuItem.ts @@ -6,6 +6,7 @@ import { useButton } from '../../use-button'; import { mergeProps } from '../../merge-props'; import { HTMLProps, BaseUIEvent } from '../../utils/types'; import { useContextMenuRootContext } from '../../context-menu/root/ContextMenuRootContext'; +import { MenuStore } from '../store/MenuStore'; export const REGULAR_ITEM = { type: 'regular-item' as const, @@ -17,9 +18,8 @@ export function useMenuItem(params: useMenuItem.Parameters): useMenuItem.ReturnV disabled = false, highlighted, id, + store, menuEvents, - allowMouseUpTriggerRef, - typingRef, nativeButton, itemMetadata, nodeId, @@ -62,7 +62,7 @@ export function useMenuItem(params: useMenuItem.Parameters): useMenuItem.ReturnV itemMetadata.setActive(); }, onKeyUp(event: BaseUIEvent) { - if (event.key === ' ' && typingRef.current) { + if (event.key === ' ' && store.context.typingRef.current) { event.preventBaseUIHandler(); } }, @@ -74,7 +74,7 @@ export function useMenuItem(params: useMenuItem.Parameters): useMenuItem.ReturnV onMouseUp(event) { if ( itemRef.current && - allowMouseUpTriggerRef.current && + store.context.allowMouseUpTriggerRef.current && (!isContextMenu || event.button === 2) ) { // This fires whenever the user clicks on the trigger, moves the cursor, and releases it over the item. @@ -93,10 +93,9 @@ export function useMenuItem(params: useMenuItem.Parameters): useMenuItem.ReturnV id, highlighted, getButtonProps, - typingRef, closeOnClick, menuEvents, - allowMouseUpTriggerRef, + store, isContextMenu, itemMetadata, nodeId, @@ -135,14 +134,6 @@ export interface UseMenuItemParameters { * The FloatingEvents instance of the menu's root. */ menuEvents: FloatingEvents; - /** - * Whether to treat mouseup events as clicks. - */ - allowMouseUpTriggerRef: React.RefObject; - /** - * A ref that is set to `true` when the user is using the typeahead feature. - */ - typingRef: React.RefObject; /** * Whether the component renders a native `