diff --git a/.changeset/fine-kids-jam.md b/.changeset/fine-kids-jam.md
new file mode 100644
index 0000000000..025634f3c3
--- /dev/null
+++ b/.changeset/fine-kids-jam.md
@@ -0,0 +1,5 @@
+---
+"@radix-ui/react-navigation-menu": patch
+---
+
+Fixed a bug where a submenu's `defaultValue` was reset when external element was focused before menu is opened.
diff --git a/.changeset/navigation-menu-focus-outside.md b/.changeset/navigation-menu-focus-outside.md
new file mode 100644
index 0000000000..f033de9fc8
--- /dev/null
+++ b/.changeset/navigation-menu-focus-outside.md
@@ -0,0 +1,5 @@
+---
+'@radix-ui/react-navigation-menu': patch
+---
+
+Fixed an open `NavigationMenu` (e.g. via `defaultValue`) being dismissed when focus moved between two elements outside the menu, such as a `Dialog` auto-focusing its close button on open.
diff --git a/apps/storybook/stories/navigation-menu.stories.tsx b/apps/storybook/stories/navigation-menu.stories.tsx
index 8493dfb42f..70511eaf5f 100644
--- a/apps/storybook/stories/navigation-menu.stories.tsx
+++ b/apps/storybook/stories/navigation-menu.stories.tsx
@@ -1,6 +1,7 @@
import * as React from 'react';
-import { NavigationMenu, Direction } from 'radix-ui';
+import { Dialog, NavigationMenu, Direction } from 'radix-ui';
import styles from './navigation-menu.stories.module.css';
+import dialogStyles from './dialog.stories.module.css';
export default { title: 'Components/NavigationMenu' };
@@ -368,6 +369,203 @@ export const Submenus = () => {
);
};
+export const InsideDialog = () => {
+ return (
+
+ open
+
+
+
+ Booking info
+ Please enter the info for your booking below.
+
+ close
+
+
+
+ );
+};
+
/* -----------------------------------------------------------------------------------------------*/
const StoryFrame: React.FC<{ children: React.ReactNode }> = ({ children }) => {
diff --git a/packages/react/navigation-menu/src/navigation-menu.test.tsx b/packages/react/navigation-menu/src/navigation-menu.test.tsx
index 3bbffd07bf..cc40867836 100644
--- a/packages/react/navigation-menu/src/navigation-menu.test.tsx
+++ b/packages/react/navigation-menu/src/navigation-menu.test.tsx
@@ -1,6 +1,6 @@
import * as React from 'react';
-import { cleanup, render, screen, waitFor } from '@testing-library/react';
-import { afterEach, describe, it, expect } from 'vitest';
+import { cleanup, render, screen, waitFor, fireEvent } from '@testing-library/react';
+import { afterEach, describe, it, expect, vi } from 'vitest';
import * as NavigationMenu from './navigation-menu';
const TRIGGER_TEXT = 'Item One';
@@ -44,3 +44,50 @@ describe('aria-controls', () => {
expect(content).toContainElement(screen.getByText(CONTENT_TEXT));
});
});
+
+// See: https://github.com/radix-ui/primitives/issues/3473
+describe('focus outside', () => {
+ afterEach(cleanup);
+
+ it('should not dismiss an open menu when focus moves between elements outside the menu', async () => {
+ const onValueChange = vi.fn();
+ render(
+
+
+
+
,
+ );
+ await waitFor(() => expect(screen.getByText(CONTENT_TEXT)).toBeInTheDocument());
+
+ // Mimics an external layer (e.g. a Dialog) auto-focusing an element on open.
+ // Focus never originated from within the menu, so it should stay open.
+ const outside = screen.getByTestId('outside');
+ outside.focus();
+ fireEvent.focusIn(outside);
+
+ expect(onValueChange).not.toHaveBeenCalledWith('');
+ expect(screen.getByText(CONTENT_TEXT)).toBeInTheDocument();
+ });
+
+ it('should dismiss an open menu when focus actually leaves the menu', async () => {
+ const onValueChange = vi.fn();
+ render(
+
+
+
+
,
+ );
+ await waitFor(() => expect(screen.getByText(CONTENT_TEXT)).toBeInTheDocument());
+
+ // Focus leaving the menu content for an outside element should dismiss it.
+ const link = screen.getByText(CONTENT_TEXT);
+ const outside = screen.getByTestId('outside');
+ fireEvent.focusIn(outside, { relatedTarget: link });
+
+ expect(onValueChange).toHaveBeenCalledWith('');
+ });
+});
diff --git a/packages/react/navigation-menu/src/navigation-menu.tsx b/packages/react/navigation-menu/src/navigation-menu.tsx
index b9911bd24d..e40d909a4b 100644
--- a/packages/react/navigation-menu/src/navigation-menu.tsx
+++ b/packages/react/navigation-menu/src/navigation-menu.tsx
@@ -936,9 +936,18 @@ const NavigationMenuContentImpl = React.forwardRef<
}}
onFocusOutside={composeEventHandlers(props.onFocusOutside, (event) => {
onContentFocusOutside();
- const target = event.target as HTMLElement;
- // Only dismiss content when focus moves outside of the menu
- if (context.rootNavigationMenu?.contains(target)) event.preventDefault();
+ const target = event.target;
+ const relatedTarget = event.detail.originalEvent.relatedTarget;
+ const focusMovedIntoMenu = context.rootNavigationMenu?.contains(target as Node);
+ const focusCameFromMenu = context.rootNavigationMenu?.contains(relatedTarget as Node);
+ // Only dismiss content when focus actually leaves the menu. If focus
+ // moves into the menu, or it never originated from within the menu
+ // (e.g. an external layer such as a Dialog auto-focusing on open),
+ // keep the content open.
+ // See https://github.com/radix-ui/primitives/issues/3473
+ if (focusMovedIntoMenu || !focusCameFromMenu) {
+ event.preventDefault();
+ }
})}
onPointerDownOutside={composeEventHandlers(props.onPointerDownOutside, (event) => {
const target = event.target as HTMLElement;