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. +
+ + + + Products + + + + + + Extensibility + + + + + + + + + + + + Security + + + + + + + + + + + Authentication + + + + + + + + + + + + + + + + + + + Company + + + + + + Customers + + + + + + + + + + + Partners + + + + + + + + + + Enterprise + + + + + + + + + + + + + + + + + + Developers + + + + + + + + + Link + + + + + + +
+ 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;