From 331514b24c618947d0ab9fab976927ecb903a160 Mon Sep 17 00:00:00 2001 From: Rohit Singh Date: Thu, 22 Oct 2026 21:49:11 +0530 Subject: [PATCH 1/2] [context menu] Keep Shift+Tab inside the open menu Shift+Tab closed a root context menu while Tab left it open. --- .../src/context-menu/root/ContextMenuRoot.test.tsx | 12 ++++++------ packages/react/src/menu/root/MenuRoot.tsx | 3 +++ .../utils/popups/interactions/useListNavigation.ts | 12 ++++++++++-- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/react/src/context-menu/root/ContextMenuRoot.test.tsx b/packages/react/src/context-menu/root/ContextMenuRoot.test.tsx index d62e43298d4..6f1fd0dbf2f 100644 --- a/packages/react/src/context-menu/root/ContextMenuRoot.test.tsx +++ b/packages/react/src/context-menu/root/ContextMenuRoot.test.tsx @@ -283,7 +283,7 @@ describe('', () => { }); }); - it('returns focus to the focused surface when closing with Shift+Tab', async () => { + it('keeps the menu open when pressing Shift+Tab', async () => { const { user } = await render( }>Surface @@ -301,15 +301,15 @@ describe('', () => { await user.tab(); expect(surface).toHaveFocus(); fireEvent.contextMenu(surface, { clientX: 20, clientY: 20, button: 2 }); + const menu = await screen.findByRole('menu'); await waitFor(() => { - expect(screen.getByRole('menu')).toHaveFocus(); + expect(menu).toHaveFocus(); }); await user.tab({ shift: true }); - await waitFor(() => { - expect(screen.queryByRole('menu')).toBe(null); - }); - expect(surface).toHaveFocus(); + + expect(menu).toHaveFocus(); + expect(surface).not.toHaveFocus(); }); describe.skipIf(isJSDOM)('prop: collisionAvoidance', () => { diff --git a/packages/react/src/menu/root/MenuRoot.tsx b/packages/react/src/menu/root/MenuRoot.tsx index 6ba9545754f..ee07eb8097e 100644 --- a/packages/react/src/menu/root/MenuRoot.tsx +++ b/packages/react/src/menu/root/MenuRoot.tsx @@ -381,6 +381,9 @@ export const MenuRootInternal = fastComponent(function MenuRootInternal // on both menus' orientations; the generic arrow-key opening would also react to the parent's // forwarded cross-axis keys while closed. openOnArrowKeyDown: parent.type !== 'context-menu' && !(virtualFocus && isSubmenu), + // A root context menu is modal and has no tabbable items, so Tab stays inside. + // Shift+Tab stays inside too. + closeOnShiftTab: parent.type !== 'context-menu', externalTree: !virtualFocus && nested ? floatingTreeRoot : undefined, nestedReturnFocusRef: parentMenuStore?.context.virtualFocusRef, focusItemOnHover: highlightItemOnHover, diff --git a/packages/react/src/utils/popups/interactions/useListNavigation.ts b/packages/react/src/utils/popups/interactions/useListNavigation.ts index 6ea19a4d18d..7a83078e951 100644 --- a/packages/react/src/utils/popups/interactions/useListNavigation.ts +++ b/packages/react/src/utils/popups/interactions/useListNavigation.ts @@ -224,6 +224,12 @@ export interface UseListNavigationProps { * Computes two-dimensional list navigation for grid-capable consumers. */ grid?: typeof gridNavigation | null | undefined; + /** + * Whether Shift+Tab closes the open list. + * Root context menus leave this off so Shift+Tab stays inside the menu. + * @default true + */ + closeOnShiftTab?: boolean | undefined; } /** @@ -258,6 +264,7 @@ export function useListNavigation( externalTree, nestedReturnFocusRef, grid: navigateGrid, + closeOnShiftTab = true, } = props; const isGrid = navigateGrid != null; @@ -761,8 +768,8 @@ export function useListNavigation( return { ...(!typeableComboboxReference ? ariaActiveDescendantProp : {}), onKeyDown(event: React.KeyboardEvent) { - // Close submenu on Shift+Tab - if (event.key === 'Tab' && event.shiftKey && open && !virtual) { + // Close on Shift+Tab. A root context menu opts out so Shift+Tab stays inside. + if (event.key === 'Tab' && event.shiftKey && open && !virtual && closeOnShiftTab) { // If the event originated from within a nested element (e.g., a Dialog opened from // within the menu), don't close the menu. The nested element has its own focus // management and should handle the Tab key. @@ -802,6 +809,7 @@ export function useListNavigation( virtual, anchorElement, nestedReturnFocusRef, + closeOnShiftTab, ]); const trigger: ElementProps['trigger'] = React.useMemo(() => { From d3a792d6cb135075dfbb82b5d3a57481a8dac45c Mon Sep 17 00:00:00 2001 From: Rohit Singh Date: Thu, 29 Oct 2026 20:00:28 +0530 Subject: [PATCH 2/2] [context menu] Close the root menu on Tab like Shift+Tab The root context menu's modal focus manager swallowed Tab, so only Shift+Tab closed it. Tab now closes it the same way and returns focus, as a regular menu closes on both. --- .../root/ContextMenuRoot.test.tsx | 39 ++++++++++++++++--- packages/react/src/menu/root/MenuRoot.tsx | 6 +-- .../popups/interactions/useListNavigation.ts | 15 ++++--- 3 files changed, 44 insertions(+), 16 deletions(-) diff --git a/packages/react/src/context-menu/root/ContextMenuRoot.test.tsx b/packages/react/src/context-menu/root/ContextMenuRoot.test.tsx index 6f1fd0dbf2f..bb43620b18e 100644 --- a/packages/react/src/context-menu/root/ContextMenuRoot.test.tsx +++ b/packages/react/src/context-menu/root/ContextMenuRoot.test.tsx @@ -283,7 +283,7 @@ describe('', () => { }); }); - it('keeps the menu open when pressing Shift+Tab', async () => { + it('returns focus to the focused surface when closing with Shift+Tab', async () => { const { user } = await render( }>Surface @@ -301,15 +301,44 @@ describe('', () => { await user.tab(); expect(surface).toHaveFocus(); fireEvent.contextMenu(surface, { clientX: 20, clientY: 20, button: 2 }); - const menu = await screen.findByRole('menu'); await waitFor(() => { - expect(menu).toHaveFocus(); + expect(screen.getByRole('menu')).toHaveFocus(); }); await user.tab({ shift: true }); + await waitFor(() => { + expect(screen.queryByRole('menu')).toBe(null); + }); + expect(surface).toHaveFocus(); + }); + + it('returns focus to the focused surface when closing with Tab', async () => { + const { user } = await render( + + }>Surface + + + + Item + + + + , + ); + + const surface = screen.getByRole('button', { name: 'Surface' }); + await user.tab(); + expect(surface).toHaveFocus(); + fireEvent.contextMenu(surface, { clientX: 20, clientY: 20, button: 2 }); + await waitFor(() => { + expect(screen.getByRole('menu')).toHaveFocus(); + }); - expect(menu).toHaveFocus(); - expect(surface).not.toHaveFocus(); + await user.tab(); + await waitFor(() => { + expect(screen.queryByRole('menu')).toBe(null); + }); + expect(surface).toHaveFocus(); }); describe.skipIf(isJSDOM)('prop: collisionAvoidance', () => { diff --git a/packages/react/src/menu/root/MenuRoot.tsx b/packages/react/src/menu/root/MenuRoot.tsx index ee07eb8097e..fdf186442c6 100644 --- a/packages/react/src/menu/root/MenuRoot.tsx +++ b/packages/react/src/menu/root/MenuRoot.tsx @@ -381,9 +381,9 @@ export const MenuRootInternal = fastComponent(function MenuRootInternal // on both menus' orientations; the generic arrow-key opening would also react to the parent's // forwarded cross-axis keys while closed. openOnArrowKeyDown: parent.type !== 'context-menu' && !(virtualFocus && isSubmenu), - // A root context menu is modal and has no tabbable items, so Tab stays inside. - // Shift+Tab stays inside too. - closeOnShiftTab: parent.type !== 'context-menu', + // A root context menu's focus manager is modal, so its guard swallows Tab when no item is + // tabbable. Close it here instead, as a regular menu closes when Tab moves focus out. + closeOnTab: parent.type === 'context-menu', externalTree: !virtualFocus && nested ? floatingTreeRoot : undefined, nestedReturnFocusRef: parentMenuStore?.context.virtualFocusRef, focusItemOnHover: highlightItemOnHover, diff --git a/packages/react/src/utils/popups/interactions/useListNavigation.ts b/packages/react/src/utils/popups/interactions/useListNavigation.ts index 7a83078e951..de3d2bc6864 100644 --- a/packages/react/src/utils/popups/interactions/useListNavigation.ts +++ b/packages/react/src/utils/popups/interactions/useListNavigation.ts @@ -225,11 +225,10 @@ export interface UseListNavigationProps { */ grid?: typeof gridNavigation | null | undefined; /** - * Whether Shift+Tab closes the open list. - * Root context menus leave this off so Shift+Tab stays inside the menu. - * @default true + * Whether Tab closes the open list the same way Shift+Tab does. + * @default false */ - closeOnShiftTab?: boolean | undefined; + closeOnTab?: boolean | undefined; } /** @@ -264,7 +263,7 @@ export function useListNavigation( externalTree, nestedReturnFocusRef, grid: navigateGrid, - closeOnShiftTab = true, + closeOnTab = false, } = props; const isGrid = navigateGrid != null; @@ -768,8 +767,8 @@ export function useListNavigation( return { ...(!typeableComboboxReference ? ariaActiveDescendantProp : {}), onKeyDown(event: React.KeyboardEvent) { - // Close on Shift+Tab. A root context menu opts out so Shift+Tab stays inside. - if (event.key === 'Tab' && event.shiftKey && open && !virtual && closeOnShiftTab) { + // Close on Shift+Tab, and on Tab when `closeOnTab` is set. + if (event.key === 'Tab' && (event.shiftKey || closeOnTab) && open && !virtual) { // If the event originated from within a nested element (e.g., a Dialog opened from // within the menu), don't close the menu. The nested element has its own focus // management and should handle the Tab key. @@ -809,7 +808,7 @@ export function useListNavigation( virtual, anchorElement, nestedReturnFocusRef, - closeOnShiftTab, + closeOnTab, ]); const trigger: ElementProps['trigger'] = React.useMemo(() => {