diff --git a/packages/react/src/menu/checkbox-item/MenuCheckboxItem.test.tsx b/packages/react/src/menu/checkbox-item/MenuCheckboxItem.test.tsx index d39bd9d53d..e3a5d1c542 100644 --- a/packages/react/src/menu/checkbox-item/MenuCheckboxItem.test.tsx +++ b/packages/react/src/menu/checkbox-item/MenuCheckboxItem.test.tsx @@ -12,6 +12,7 @@ const testRootContext: MenuRootContext = { getPopupProps: (p) => ({ ...p }), getTriggerProps: (p) => ({ ...p }), getItemProps: (p) => ({ ...p }), + setActiveIndex: () => {}, parentContext: undefined, nested: false, setTriggerElement: () => {}, diff --git a/packages/react/src/menu/item/MenuItem.test.tsx b/packages/react/src/menu/item/MenuItem.test.tsx index a25baf0260..99486ca53e 100644 --- a/packages/react/src/menu/item/MenuItem.test.tsx +++ b/packages/react/src/menu/item/MenuItem.test.tsx @@ -13,6 +13,7 @@ const testRootContext: MenuRootContext = { getPopupProps: (p) => ({ ...p }), getTriggerProps: (p) => ({ ...p }), getItemProps: (p) => ({ ...p }), + setActiveIndex: () => {}, parentContext: undefined, nested: false, setTriggerElement: () => {}, diff --git a/packages/react/src/menu/positioner/MenuPositioner.test.tsx b/packages/react/src/menu/positioner/MenuPositioner.test.tsx index 934fb73d2f..7b36fc00b6 100644 --- a/packages/react/src/menu/positioner/MenuPositioner.test.tsx +++ b/packages/react/src/menu/positioner/MenuPositioner.test.tsx @@ -12,6 +12,7 @@ const testRootContext: MenuRootContext = { getPopupProps: (p) => ({ ...p }), getTriggerProps: (p) => ({ ...p }), getItemProps: (p) => ({ ...p }), + setActiveIndex: () => {}, parentContext: undefined, nested: false, setTriggerElement: () => {}, diff --git a/packages/react/src/menu/radio-item/MenuRadioItem.test.tsx b/packages/react/src/menu/radio-item/MenuRadioItem.test.tsx index 012555604d..2cfdf239a5 100644 --- a/packages/react/src/menu/radio-item/MenuRadioItem.test.tsx +++ b/packages/react/src/menu/radio-item/MenuRadioItem.test.tsx @@ -13,6 +13,7 @@ const testRootContext: MenuRootContext = { getPopupProps: (p) => ({ ...p }), getTriggerProps: (p) => ({ ...p }), getItemProps: (p) => ({ ...p }), + setActiveIndex: () => {}, parentContext: undefined, nested: false, setTriggerElement: () => {}, diff --git a/packages/react/src/menu/root/MenuRoot.tsx b/packages/react/src/menu/root/MenuRoot.tsx index c56b0129e1..8d1d3bf09e 100644 --- a/packages/react/src/menu/root/MenuRoot.tsx +++ b/packages/react/src/menu/root/MenuRoot.tsx @@ -61,6 +61,7 @@ const MenuRoot: React.FC = function MenuRoot(props) { ...menuRoot, nested, parentContext, + setActiveIndex: menuRoot.setActiveIndex, disabled, allowMouseUpTriggerRef: parentContext?.allowMouseUpTriggerRef ?? menuRoot.allowMouseUpTriggerRef, diff --git a/packages/react/src/menu/root/useMenuRoot.ts b/packages/react/src/menu/root/useMenuRoot.ts index 72d0e7008d..b5750d52a6 100644 --- a/packages/react/src/menu/root/useMenuRoot.ts +++ b/packages/react/src/menu/root/useMenuRoot.ts @@ -177,6 +177,7 @@ export function useMenuRoot(parameters: useMenuRoot.Parameters): useMenuRoot.Ret () => ({ activeIndex, allowMouseUpTriggerRef, + setActiveIndex, floatingRootContext, getItemProps, getPopupProps, @@ -194,6 +195,7 @@ export function useMenuRoot(parameters: useMenuRoot.Parameters): useMenuRoot.Ret }), [ activeIndex, + setActiveIndex, floatingRootContext, getItemProps, getPopupProps, @@ -274,6 +276,7 @@ export namespace useMenuRoot { export interface ReturnValue { activeIndex: number | null; + setActiveIndex: (index: number | null) => void; floatingRootContext: FloatingRootContext; getItemProps: (externalProps?: GenericHTMLProps) => GenericHTMLProps; getPopupProps: (externalProps?: GenericHTMLProps) => GenericHTMLProps; diff --git a/packages/react/src/menu/submenu-trigger/MenuSubmenuTrigger.tsx b/packages/react/src/menu/submenu-trigger/MenuSubmenuTrigger.tsx index 8eabcd8538..a4f6bdba5c 100644 --- a/packages/react/src/menu/submenu-trigger/MenuSubmenuTrigger.tsx +++ b/packages/react/src/menu/submenu-trigger/MenuSubmenuTrigger.tsx @@ -37,7 +37,7 @@ const MenuSubmenuTrigger = React.forwardRef(function SubmenuTriggerComponent( throw new Error('Base UI: ItemTrigger must be placed in a nested Menu.'); } - const { activeIndex, getItemProps } = parentContext; + const { activeIndex, getItemProps, setActiveIndex } = parentContext; const item = useCompositeListItem(); const highlighted = activeIndex === item.index; @@ -45,7 +45,6 @@ const MenuSubmenuTrigger = React.forwardRef(function SubmenuTriggerComponent( const mergedRef = useForkRef(forwardedRef, item.ref); const { events: menuEvents } = useFloatingTree()!; - const { getRootProps } = useMenuSubmenuTrigger({ id, highlighted, @@ -55,6 +54,7 @@ const MenuSubmenuTrigger = React.forwardRef(function SubmenuTriggerComponent( setTriggerElement, allowMouseUpTriggerRef, typingRef, + setActiveIndex, }); const state: MenuSubmenuTrigger.State = React.useMemo( diff --git a/packages/react/src/menu/submenu-trigger/useMenuSubmenuTrigger.ts b/packages/react/src/menu/submenu-trigger/useMenuSubmenuTrigger.ts index 3935162902..312881c5fb 100644 --- a/packages/react/src/menu/submenu-trigger/useMenuSubmenuTrigger.ts +++ b/packages/react/src/menu/submenu-trigger/useMenuSubmenuTrigger.ts @@ -4,6 +4,12 @@ import { FloatingEvents } from '@floating-ui/react'; import { useMenuItem } from '../item/useMenuItem'; import { useForkRef } from '../../utils/useForkRef'; import { GenericHTMLProps } from '../../utils/types'; +import { mergeReactProps } from '../../utils/mergeReactProps'; +import { useDirection } from '../../direction-provider/DirectionContext'; + +type MenuKeyboardEvent = { + key: 'ArrowRight' | 'ArrowLeft' | 'ArrowUp' | 'ArrowDown'; +} & React.KeyboardEvent; export function useMenuSubmenuTrigger( parameters: useSubmenuTrigger.Parameters, @@ -17,6 +23,7 @@ export function useMenuSubmenuTrigger( setTriggerElement, allowMouseUpTriggerRef, typingRef, + setActiveIndex, } = parameters; const { getRootProps: getMenuItemProps, rootRef: menuItemRef } = useMenuItem({ @@ -32,17 +39,28 @@ export function useMenuSubmenuTrigger( const menuTriggerRef = useForkRef(menuItemRef, setTriggerElement); + const direction = useDirection(); + const getRootProps = React.useCallback( (externalProps?: GenericHTMLProps) => { - return { + const openKey = direction === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; + + return mergeReactProps(externalProps, { ...getMenuItemProps({ 'aria-haspopup': 'menu' as const, - ...externalProps, + onKeyDown: (event: MenuKeyboardEvent) => { + if (event.key === openKey && highlighted) { + // Clear parent menu's highlight state when entering submenu + // This prevents multiple highlighted items across menu levels + setActiveIndex(null); + } + }, + onClick: () => highlighted && setActiveIndex(null), }), ref: menuTriggerRef, - }; + }); }, - [getMenuItemProps, menuTriggerRef], + [getMenuItemProps, menuTriggerRef, highlighted, setActiveIndex, direction], ); return React.useMemo( @@ -82,6 +100,11 @@ export namespace useSubmenuTrigger { * A ref that is set to `true` when the user is using the typeahead feature. */ typingRef: React.RefObject; + /** + * Callback to update the active (highlighted) item index. + * Set to null to remove highlighting from all items. + */ + setActiveIndex: (index: number | null) => void; } export interface ReturnValue { diff --git a/packages/react/src/menu/trigger/MenuTrigger.test.tsx b/packages/react/src/menu/trigger/MenuTrigger.test.tsx index b994e047fd..dd7e029eae 100644 --- a/packages/react/src/menu/trigger/MenuTrigger.test.tsx +++ b/packages/react/src/menu/trigger/MenuTrigger.test.tsx @@ -12,6 +12,7 @@ const testRootContext: MenuRootContext = { getPopupProps: (p) => ({ ...p }), getTriggerProps: (p) => ({ ...p }), getItemProps: (p) => ({ ...p }), + setActiveIndex: () => {}, parentContext: undefined, nested: false, setTriggerElement: () => {},