Repository: mui/base-ui (2025-07-11T12:32:10Z)

Issue:
[menu] Unexpected behavior when pressing Shift + Tab in submenu
# Bug report

## Current behavior

This is subtle, but I believe there is a bug when using the keyboard to navigate a menu that contains a submenu: pressing Shift + Tab when the focus is on a subitem keeps the submenu open even though the focus returns to the root menu. Furthermore, when pressing Shift + Tab again to move the focus to the root menu's trigger and pressing Space to close the menu, the root menu runs its exit animation while closing, but the submenu disappears abruptly:

https://github.com/user-attachments/assets/6581d3f1-b2bc-4744-a096-76f126701568

## Expected behavior

I would expect the first Shift + Tab to behave exactly like the left arrow key, closing the submenu:

https://github.com/user-attachments/assets/3f2d84c0-ac45-488a-bbf4-768e365cc253

## Reproducible example

https://codesandbox.io/p/sandbox/nc65cv

## Base UI version

1.0.0-alpha.8

## Which browser are you using?

Chrome, Safari, and Firefox

## Which OS are you using?

macOS

## Which assistive tech are you using (if applicable)?

None


Discussion:
This fix will be available in the next npm release of Base UI.

In the meantime, you can try it out on our Canary release channel:

```sh
npm i https://pkg.pr.new/@base-ui-components/react@2290
```



Patch:
diff --git a/packages/react/src/floating-ui-react/hooks/useListNavigation.ts b/packages/react/src/floating-ui-react/hooks/useListNavigation.ts
index 8550e39206..567e3aaac6 100644
--- a/packages/react/src/floating-ui-react/hooks/useListNavigation.ts
+++ b/packages/react/src/floating-ui-react/hooks/useListNavigation.ts
@@ -785,12 +785,35 @@ export function useListNavigation(
     return {
       'aria-orientation': orientation === 'both' ? undefined : orientation,
       ...(!typeableComboboxReference ? ariaActiveDescendantProp : {}),
-      onKeyDown: commonOnKeyDown,
+      onKeyDown(event: React.KeyboardEvent) {
+        // Close submenu on Shift+Tab
+        if (event.key === 'Tab' && event.shiftKey && open && !virtual) {
+          stopEvent(event);
+          onOpenChange(false, event.nativeEvent, 'list-navigation');
+
+          if (isHTMLElement(elements.domReference)) {
+            elements.domReference.focus();
+          }
+
+          return;
+        }
+
+        commonOnKeyDown(event);
+      },
       onPointerMove() {
         isPointerModalityRef.current = true;
       },
     };
-  }, [ariaActiveDescendantProp, commonOnKeyDown, orientation, typeableComboboxReference]);
+  }, [
+    ariaActiveDescendantProp,
+    commonOnKeyDown,
+    orientation,
+    typeableComboboxReference,
+    onOpenChange,
+    open,
+    virtual,
+    elements.domReference,
+  ]);
 
   const reference: ElementProps['reference'] = React.useMemo(() => {
     function checkVirtualMouse(event: React.PointerEvent) {
diff --git a/packages/react/src/menu/root/MenuRoot.tsx b/packages/react/src/menu/root/MenuRoot.tsx
index eb80da5605..4205e3a59f 100644
--- a/packages/react/src/menu/root/MenuRoot.tsx
+++ b/packages/react/src/menu/root/MenuRoot.tsx
@@ -81,6 +81,9 @@ export const MenuRoot: React.FC<MenuRoot.Props> = function MenuRoot(props) {
   const popupRef = React.useRef<HTMLElement>(null);
   const positionerRef = React.useRef<HTMLElement | null>(null);
 
+  const itemDomElements = React.useRef<(HTMLElement | null)[]>([]);
+  const itemLabels = React.useRef<(string | null)[]>([]);
+
   const stickIfOpenTimeout = useTimeout();
   const contextMenuContext = useContextMenuRootContext(true);
   const isSubmenu = useMenuSubmenuRootContext();
@@ -237,6 +240,19 @@ export const MenuRoot: React.FC<MenuRoot.Props> = function MenuRoot(props) {
         return;
       }
 
+      // Workaround `enableFocusInside` in Floating UI setting `tabindex=0` of a non-highlighted
+      // option upon close when tabbing out due to `keepMounted=true`:
+      // https://github.com/floating-ui/floating-ui/pull/3004/files#diff-962a7439cdeb09ea98d4b622a45d517bce07ad8c3f866e089bda05f4b0bbd875R194-R199
+      // This otherwise causes options to retain `tabindex=0` incorrectly when the popup is closed
+      // when tabbing outside.
+      if (!nextOpen && activeIndex !== null) {
+        const activeOption = itemDomElements.current[activeIndex];
+        // Wait for Floating UI's focus effect to have fired
+        queueMicrotask(() => {
+          activeOption?.setAttribute('tabindex', '-1');
+        });
+      }
+
       // Prevent the menu from closing on mobile devices that have a delayed click event.
       // In some cases the menu, when tapped, will fire the focus event first and then the click event.
       // Without this guard, the menu will close immediately after opening.
@@ -376,9 +392,6 @@ export const MenuRoot: React.FC<MenuRoot.Props> = function MenuRoot(props) {
     role: 'menu',
   });
 
-  const itemDomElements = React.useRef<(HTMLElement | null)[]>([]);
-  const itemLabels = React.useRef<(string | null)[]>([]);
-
   const direction = useDirection();
 
   const listNavigation = useListNavigation(floatingRootContext, {


Test patch:
diff --git a/packages/react/src/menu/root/MenuRoot.test.tsx b/packages/react/src/menu/root/MenuRoot.test.tsx
index d03dad9ba9..d419a6f99c 100644
--- a/packages/react/src/menu/root/MenuRoot.test.tsx
+++ b/packages/react/src/menu/root/MenuRoot.test.tsx
@@ -535,6 +535,62 @@ describe('<Menu.Root />', () => {
       expect(submenu).not.to.equal(null);
       expect(await findByTestId('submenu-item')).to.have.text('Submenu Item');
     });
+
+    it('closes submenus when focus is lost by shift-tabbing from a nested menu', async () => {
+      const { getByRole, queryByTestId, findByTestId, user } = await render(
+        <Menu.Root>
+          <Menu.Trigger>Open Main</Menu.Trigger>
+          <Menu.Portal>
+            <Menu.Positioner data-testid="menu">
+              <Menu.Popup>
+                <Menu.Item>Item 1</Menu.Item>
+                <Menu.SubmenuRoot>
+                  <Menu.SubmenuTrigger data-testid="submenu-trigger">Submenu</Menu.SubmenuTrigger>
+                  <Menu.Portal>
+                    <Menu.Positioner data-testid="submenu">
+                      <Menu.Popup>
+                        <Menu.Item data-testid="submenu-item">Submenu Item</Menu.Item>
+                      </Menu.Popup>
+                    </Menu.Positioner>
+                  </Menu.Portal>
+                </Menu.SubmenuRoot>
+              </Menu.Popup>
+            </Menu.Positioner>
+          </Menu.Portal>
+        </Menu.Root>,
+      );
+
+      const mainTrigger = getByRole('button', { name: 'Open Main' });
+      await user.click(mainTrigger);
+
+      await findByTestId('menu');
+      expect(queryByTestId('submenu')).to.equal(null);
+
+      const submenuTrigger = await findByTestId('submenu-trigger');
+      await user.hover(submenuTrigger);
+
+      await waitFor(() => {
+        expect(queryByTestId('submenu')).not.to.equal(null);
+      });
+
+      const submenuItem = await findByTestId('submenu-item');
+      await act(async () => {
+        submenuItem.focus();
+      });
+
+      await waitFor(() => {
+        expect(submenuItem).toHaveFocus();
+      });
+
+      // Shift+Tab should close the submenu and focus should return to the submenu trigger
+      await user.keyboard('{Shift>}{Tab}{/Shift}');
+
+      await waitFor(() => {
+        expect(queryByTestId('submenu')).to.equal(null);
+      });
+
+      expect(submenuTrigger).toHaveFocus();
+    });
   });
 
   describe('focus management', () => {