From 0419c12202e022d9850cb5ffd00ca978cc5aa123 Mon Sep 17 00:00:00 2001 From: onehanddev Date: Sun, 5 Jan 2025 19:40:17 +0530 Subject: [PATCH 1/8] Added setActiveIndex to the MenuRoot and MenuSubmenuTrigger components, allowing for dynamic updates to the active (highlighted) item index. This fixes not updating hover out behaviour of navigation for keyboard events. --- packages/react/src/menu/root/MenuRoot.tsx | 1 + packages/react/src/menu/root/useMenuRoot.ts | 3 +++ .../submenu-trigger/MenuSubmenuTrigger.tsx | 4 +-- .../submenu-trigger/useMenuSubmenuTrigger.ts | 27 ++++++++++++++++++- 4 files changed, 32 insertions(+), 3 deletions(-) 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..700c451548 100644 --- a/packages/react/src/menu/submenu-trigger/useMenuSubmenuTrigger.ts +++ b/packages/react/src/menu/submenu-trigger/useMenuSubmenuTrigger.ts @@ -5,6 +5,10 @@ import { useMenuItem } from '../item/useMenuItem'; import { useForkRef } from '../../utils/useForkRef'; import { GenericHTMLProps } from '../../utils/types'; +type MenuKeyboardEvent = { + key: 'ArrowRight' | 'ArrowLeft' | 'ArrowUp' | 'ArrowDown'; +} & React.KeyboardEvent; + export function useMenuSubmenuTrigger( parameters: useSubmenuTrigger.Parameters, ): useSubmenuTrigger.ReturnValue { @@ -17,6 +21,8 @@ export function useMenuSubmenuTrigger( setTriggerElement, allowMouseUpTriggerRef, typingRef, + onKeyDown, + setActiveIndex, } = parameters; const { getRootProps: getMenuItemProps, rootRef: menuItemRef } = useMenuItem({ @@ -38,11 +44,20 @@ export function useMenuSubmenuTrigger( ...getMenuItemProps({ 'aria-haspopup': 'menu' as const, ...externalProps, + onKeyDown: (event: MenuKeyboardEvent) => { + if (event.key === 'ArrowRight' && highlighted) { + // Clear parent menu's highlight state when entering submenu + // This prevents multiple highlighted items across menu levels + setActiveIndex(null); + } + onKeyDown?.(event); + externalProps?.onKeyDown?.(event); + }, }), ref: menuTriggerRef, }; }, - [getMenuItemProps, menuTriggerRef], + [getMenuItemProps, menuTriggerRef, onKeyDown, highlighted, setActiveIndex], ); return React.useMemo( @@ -82,6 +97,16 @@ export namespace useSubmenuTrigger { * A ref that is set to `true` when the user is using the typeahead feature. */ typingRef: React.RefObject; + /** + * Callback function that is triggered on key down events. + * @param event - The keyboard event that triggered the callback. + */ + onKeyDown?: (event: MenuKeyboardEvent) => void; + /** + * 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 { From eb47bc24dbd5778b74ebc3a3879b65c0fd63a933 Mon Sep 17 00:00:00 2001 From: onehanddev Date: Sun, 5 Jan 2025 20:03:23 +0530 Subject: [PATCH 2/8] fix: ts errors --- packages/react/src/menu/checkbox-item/MenuCheckboxItem.test.tsx | 1 + packages/react/src/menu/item/MenuItem.test.tsx | 1 + packages/react/src/menu/positioner/MenuPositioner.test.tsx | 1 + packages/react/src/menu/radio-item/MenuRadioItem.test.tsx | 1 + packages/react/src/menu/trigger/MenuTrigger.test.tsx | 1 + 5 files changed, 5 insertions(+) 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 d9fecfcee1..7518811587 100644 --- a/packages/react/src/menu/item/MenuItem.test.tsx +++ b/packages/react/src/menu/item/MenuItem.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/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/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: () => {}, From c4aa02ccb557b128b8c5d79bec4b9edb233a322e Mon Sep 17 00:00:00 2001 From: onehanddev Date: Mon, 6 Jan 2025 13:43:44 +0530 Subject: [PATCH 3/8] Handled click event as well for setting active item for menu, added mergeReactProps to merge externalProps --- .../submenu-trigger/useMenuSubmenuTrigger.ts | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/react/src/menu/submenu-trigger/useMenuSubmenuTrigger.ts b/packages/react/src/menu/submenu-trigger/useMenuSubmenuTrigger.ts index 700c451548..57f0c8d8f9 100644 --- a/packages/react/src/menu/submenu-trigger/useMenuSubmenuTrigger.ts +++ b/packages/react/src/menu/submenu-trigger/useMenuSubmenuTrigger.ts @@ -4,6 +4,7 @@ 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'; type MenuKeyboardEvent = { key: 'ArrowRight' | 'ArrowLeft' | 'ArrowUp' | 'ArrowDown'; @@ -23,6 +24,7 @@ export function useMenuSubmenuTrigger( typingRef, onKeyDown, setActiveIndex, + onClick, } = parameters; const { getRootProps: getMenuItemProps, rootRef: menuItemRef } = useMenuItem({ @@ -40,10 +42,9 @@ export function useMenuSubmenuTrigger( const getRootProps = React.useCallback( (externalProps?: GenericHTMLProps) => { - return { + return mergeReactProps(externalProps, { ...getMenuItemProps({ 'aria-haspopup': 'menu' as const, - ...externalProps, onKeyDown: (event: MenuKeyboardEvent) => { if (event.key === 'ArrowRight' && highlighted) { // Clear parent menu's highlight state when entering submenu @@ -51,13 +52,18 @@ export function useMenuSubmenuTrigger( setActiveIndex(null); } onKeyDown?.(event); - externalProps?.onKeyDown?.(event); + }, + onClick: (event: React.MouseEvent) => { + if (highlighted) { + setActiveIndex(null); + } + onClick?.(event); }, }), ref: menuTriggerRef, - }; + }); }, - [getMenuItemProps, menuTriggerRef, onKeyDown, highlighted, setActiveIndex], + [getMenuItemProps, menuTriggerRef, onKeyDown, onClick, highlighted, setActiveIndex], ); return React.useMemo( @@ -102,6 +108,11 @@ export namespace useSubmenuTrigger { * @param event - The keyboard event that triggered the callback. */ onKeyDown?: (event: MenuKeyboardEvent) => void; + /** + * Callback function that is triggered on click events. + * @param event - The click event that triggered the callback. + */ + onClick?: (event: React.MouseEvent) => void; /** * Callback to update the active (highlighted) item index. * Set to null to remove highlighting from all items. From 1700a03aacf0234a67085c631aba0ffce25ae9a1 Mon Sep 17 00:00:00 2001 From: onehanddev Date: Mon, 6 Jan 2025 14:51:08 +0530 Subject: [PATCH 4/8] addressed PR comments --- .../submenu-trigger/useMenuSubmenuTrigger.ts | 22 ++----------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/packages/react/src/menu/submenu-trigger/useMenuSubmenuTrigger.ts b/packages/react/src/menu/submenu-trigger/useMenuSubmenuTrigger.ts index 57f0c8d8f9..5b2213479d 100644 --- a/packages/react/src/menu/submenu-trigger/useMenuSubmenuTrigger.ts +++ b/packages/react/src/menu/submenu-trigger/useMenuSubmenuTrigger.ts @@ -22,9 +22,7 @@ export function useMenuSubmenuTrigger( setTriggerElement, allowMouseUpTriggerRef, typingRef, - onKeyDown, setActiveIndex, - onClick, } = parameters; const { getRootProps: getMenuItemProps, rootRef: menuItemRef } = useMenuItem({ @@ -51,19 +49,13 @@ export function useMenuSubmenuTrigger( // This prevents multiple highlighted items across menu levels setActiveIndex(null); } - onKeyDown?.(event); - }, - onClick: (event: React.MouseEvent) => { - if (highlighted) { - setActiveIndex(null); - } - onClick?.(event); }, + onClick: () => highlighted && setActiveIndex(null), }), ref: menuTriggerRef, }); }, - [getMenuItemProps, menuTriggerRef, onKeyDown, onClick, highlighted, setActiveIndex], + [getMenuItemProps, menuTriggerRef, highlighted, setActiveIndex], ); return React.useMemo( @@ -103,16 +95,6 @@ export namespace useSubmenuTrigger { * A ref that is set to `true` when the user is using the typeahead feature. */ typingRef: React.RefObject; - /** - * Callback function that is triggered on key down events. - * @param event - The keyboard event that triggered the callback. - */ - onKeyDown?: (event: MenuKeyboardEvent) => void; - /** - * Callback function that is triggered on click events. - * @param event - The click event that triggered the callback. - */ - onClick?: (event: React.MouseEvent) => void; /** * Callback to update the active (highlighted) item index. * Set to null to remove highlighting from all items. From 03c65817fb5d84ab439f085cf40938514bbba796 Mon Sep 17 00:00:00 2001 From: onehanddev Date: Wed, 8 Jan 2025 19:06:43 +0530 Subject: [PATCH 5/8] added keyboard support for rtl direction for submenu toggling for Menu component --- .../src/menu/submenu-trigger/useMenuSubmenuTrigger.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/react/src/menu/submenu-trigger/useMenuSubmenuTrigger.ts b/packages/react/src/menu/submenu-trigger/useMenuSubmenuTrigger.ts index 5b2213479d..312881c5fb 100644 --- a/packages/react/src/menu/submenu-trigger/useMenuSubmenuTrigger.ts +++ b/packages/react/src/menu/submenu-trigger/useMenuSubmenuTrigger.ts @@ -5,6 +5,7 @@ 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'; @@ -38,13 +39,17 @@ export function useMenuSubmenuTrigger( const menuTriggerRef = useForkRef(menuItemRef, setTriggerElement); + const direction = useDirection(); + const getRootProps = React.useCallback( (externalProps?: GenericHTMLProps) => { + const openKey = direction === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; + return mergeReactProps(externalProps, { ...getMenuItemProps({ 'aria-haspopup': 'menu' as const, onKeyDown: (event: MenuKeyboardEvent) => { - if (event.key === 'ArrowRight' && highlighted) { + if (event.key === openKey && highlighted) { // Clear parent menu's highlight state when entering submenu // This prevents multiple highlighted items across menu levels setActiveIndex(null); @@ -55,7 +60,7 @@ export function useMenuSubmenuTrigger( ref: menuTriggerRef, }); }, - [getMenuItemProps, menuTriggerRef, highlighted, setActiveIndex], + [getMenuItemProps, menuTriggerRef, highlighted, setActiveIndex, direction], ); return React.useMemo( From 9ac898170b32515c418c7a648fd52dd25ad7b744 Mon Sep 17 00:00:00 2001 From: onehanddev Date: Mon, 13 Jan 2025 19:19:29 +0530 Subject: [PATCH 6/8] Added test cases to verify that the parent menu item remains inactive when a submenu item is opened. --- .../MenuSubmenuTrigger.test.tsx | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 packages/react/src/menu/submenu-trigger/MenuSubmenuTrigger.test.tsx diff --git a/packages/react/src/menu/submenu-trigger/MenuSubmenuTrigger.test.tsx b/packages/react/src/menu/submenu-trigger/MenuSubmenuTrigger.test.tsx new file mode 100644 index 0000000000..852cc054bc --- /dev/null +++ b/packages/react/src/menu/submenu-trigger/MenuSubmenuTrigger.test.tsx @@ -0,0 +1,77 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { fireEvent, waitFor, screen } from '@mui/internal-test-utils'; +import { createRenderer } from '#test-utils'; +import { DirectionProvider } from '@base-ui-components/react/direction-provider'; +import { Menu } from '@base-ui-components/react/menu'; + +type TextDirection = 'ltr' | 'rtl'; + +describe('', () => { + const { render } = createRenderer(); + + function TestComponent({ direction = 'ltr' }: { direction: TextDirection }) { + return ( + + + + + + 1 + + 2 + + + + 2.1 + 2.2 + + + + + + + + + + ); + } + + const testCases = [ + { direction: 'ltr', openKey: 'ArrowRight', closeKey: 'ArrowLeft' }, + { direction: 'rtl', openKey: 'ArrowLeft', closeKey: 'ArrowRight' }, + ]; + + testCases.forEach(({ direction, openKey }) => { + it(`opens the submenu with ${openKey} and highlights a single item in ${direction.toUpperCase()} direction`, async () => { + await render(); + const submenuTrigger = screen.getByText('2'); + + fireEvent.focus(submenuTrigger); + fireEvent.keyDown(submenuTrigger, { key: openKey }); + + const submenuItems = await screen.findAllByRole('menuitem'); + const submenuItem1 = submenuItems.find((item) => item.textContent === '2.1'); + + await waitFor(() => { + expect(submenuItem1).toHaveFocus(); + }); + + submenuItems.forEach((item) => { + if (item === submenuItem1) { + expect(item).to.have.attribute('tabindex', '0'); + } else { + expect(item).to.have.attribute('tabindex', '-1'); + } + }); + + // Check that parent menu items are not active + const parentMenuItems = screen + .getAllByRole('menuitem') + .filter((item) => item.textContent !== '2.1' && item.textContent !== '2.2'); + parentMenuItems.forEach((item) => { + expect(item).not.to.have.attribute('tabindex', '0'); + }); + }); + }); +}); From 7f69c60fbbc827d2baca27e4623ddafa6fbc92f8 Mon Sep 17 00:00:00 2001 From: onehanddev Date: Tue, 14 Jan 2025 11:02:41 +0530 Subject: [PATCH 7/8] fix: replaced tabindex with data-highlighted for tests --- .../src/menu/submenu-trigger/MenuSubmenuTrigger.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react/src/menu/submenu-trigger/MenuSubmenuTrigger.test.tsx b/packages/react/src/menu/submenu-trigger/MenuSubmenuTrigger.test.tsx index 852cc054bc..05fc9df4c9 100644 --- a/packages/react/src/menu/submenu-trigger/MenuSubmenuTrigger.test.tsx +++ b/packages/react/src/menu/submenu-trigger/MenuSubmenuTrigger.test.tsx @@ -59,9 +59,9 @@ describe('', () => { submenuItems.forEach((item) => { if (item === submenuItem1) { - expect(item).to.have.attribute('tabindex', '0'); + expect(item).to.have.attribute('data-highlighted'); } else { - expect(item).to.have.attribute('tabindex', '-1'); + expect(item).not.to.have.attribute('data-highlighted'); } }); @@ -70,7 +70,7 @@ describe('', () => { .getAllByRole('menuitem') .filter((item) => item.textContent !== '2.1' && item.textContent !== '2.2'); parentMenuItems.forEach((item) => { - expect(item).not.to.have.attribute('tabindex', '0'); + expect(item).not.to.have.attribute('data-highlighted'); }); }); }); From 79cc42c607b145cc3580ca3799992a4ef947a64d Mon Sep 17 00:00:00 2001 From: onehanddev Date: Thu, 16 Jan 2025 13:39:59 +0530 Subject: [PATCH 8/8] fix: retained tabindex of the submenutrigger once the submenu opens --- .../MenuSubmenuTrigger.test.tsx | 12 +++++++++ .../submenu-trigger/useMenuSubmenuTrigger.ts | 25 +++++++++++++------ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/packages/react/src/menu/submenu-trigger/MenuSubmenuTrigger.test.tsx b/packages/react/src/menu/submenu-trigger/MenuSubmenuTrigger.test.tsx index 05fc9df4c9..130be1fe53 100644 --- a/packages/react/src/menu/submenu-trigger/MenuSubmenuTrigger.test.tsx +++ b/packages/react/src/menu/submenu-trigger/MenuSubmenuTrigger.test.tsx @@ -74,4 +74,16 @@ describe('', () => { }); }); }); + + it('sets tabIndex to 0 on the submenu trigger after opening the submenu with a keydown event', async () => { + await render(); + const submenuTrigger = screen.getByText('2'); + + fireEvent.focus(submenuTrigger); + fireEvent.keyDown(submenuTrigger, { key: 'ArrowRight' }); + + await waitFor(() => { + expect(submenuTrigger).to.have.attribute('tabIndex', '0'); + }); + }); }); diff --git a/packages/react/src/menu/submenu-trigger/useMenuSubmenuTrigger.ts b/packages/react/src/menu/submenu-trigger/useMenuSubmenuTrigger.ts index 312881c5fb..ad93ff517c 100644 --- a/packages/react/src/menu/submenu-trigger/useMenuSubmenuTrigger.ts +++ b/packages/react/src/menu/submenu-trigger/useMenuSubmenuTrigger.ts @@ -7,8 +7,10 @@ import { GenericHTMLProps } from '../../utils/types'; import { mergeReactProps } from '../../utils/mergeReactProps'; import { useDirection } from '../../direction-provider/DirectionContext'; +const { useState } = React; + type MenuKeyboardEvent = { - key: 'ArrowRight' | 'ArrowLeft' | 'ArrowUp' | 'ArrowDown'; + key: 'ArrowRight' | 'ArrowLeft' | 'ArrowUp' | 'ArrowDown' | 'Tab'; } & React.KeyboardEvent; export function useMenuSubmenuTrigger( @@ -41,26 +43,35 @@ export function useMenuSubmenuTrigger( const direction = useDirection(); + const [isSubmenuOpen, setIsSubmenuOpen] = useState(false); + const getRootProps = React.useCallback( (externalProps?: GenericHTMLProps) => { const openKey = direction === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; - + const handleOpenSubmenu = () => { + if (highlighted) { + setActiveIndex(null); + setIsSubmenuOpen(true); + } + }; return mergeReactProps(externalProps, { ...getMenuItemProps({ + // Once the submenu is opened, retain the tab index of the trigger element + tabIndex: highlighted || isSubmenuOpen ? 0 : -1, 'aria-haspopup': 'menu' as const, 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 + if (event.key === openKey) { + handleOpenSubmenu(); + } else if (event.key === 'Tab') { setActiveIndex(null); } }, - onClick: () => highlighted && setActiveIndex(null), + onClick: handleOpenSubmenu, }), ref: menuTriggerRef, }); }, - [getMenuItemProps, menuTriggerRef, highlighted, setActiveIndex, direction], + [getMenuItemProps, menuTriggerRef, highlighted, setActiveIndex, direction, isSubmenuOpen], ); return React.useMemo(