From 27add92e6308fbfbc27a780981b1a49a7fabe731 Mon Sep 17 00:00:00 2001 From: Bnaya Zilberfarb Date: Sat, 30 May 2026 23:23:44 +0300 Subject: [PATCH 1/2] fix(navigation-menu): keep dropdown open on click after hover-open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, hovering a trigger opened the dropdown and a subsequent click on the same trigger closed it, because onItemSelect toggled based solely on whether the current open value matched the trigger's value — with no notion of how the menu was opened. Swallow the click when the dropdown was just opened by hover (using the existing hasPointerMoveOpenedRef signal), matching Base UI's stickIfOpen behavior. A click on an already-click-opened trigger still toggles closed; a click on a closed trigger still opens it. --- ...vigation-menu-stick-on-hover-then-click.md | 5 +++ cypress/e2e/NavigationMenu.cy.ts | 44 +++++++++++++++++++ .../navigation-menu/src/navigation-menu.tsx | 4 ++ 3 files changed, 53 insertions(+) create mode 100644 .changeset/navigation-menu-stick-on-hover-then-click.md create mode 100644 cypress/e2e/NavigationMenu.cy.ts diff --git a/.changeset/navigation-menu-stick-on-hover-then-click.md b/.changeset/navigation-menu-stick-on-hover-then-click.md new file mode 100644 index 0000000000..cf9308a038 --- /dev/null +++ b/.changeset/navigation-menu-stick-on-hover-then-click.md @@ -0,0 +1,5 @@ +--- +'@radix-ui/react-navigation-menu': patch +--- + +Keep dropdown open on click after hover-open. Previously a hover-opened trigger would close on the first click; now it stays open (matching Base UI's `stickIfOpen` behavior). Subsequent clicks still toggle closed; click-to-open is unchanged. diff --git a/cypress/e2e/NavigationMenu.cy.ts b/cypress/e2e/NavigationMenu.cy.ts new file mode 100644 index 0000000000..803d5c5056 --- /dev/null +++ b/cypress/e2e/NavigationMenu.cy.ts @@ -0,0 +1,44 @@ +describe('NavigationMenu — click-after-hover sticks open', () => { + beforeEach(() => { + cy.visitStory('navigationmenu--basic'); + }); + + it('hover → click on same trigger keeps dropdown open', () => { + cy.findByText('Products').realHover(); + cy.findByText('Products').should('have.attr', 'data-state', 'open'); + cy.findByText('Products').realClick(); + cy.findByText('Products').should('have.attr', 'data-state', 'open'); + }); + + it('click on closed trigger opens it (regression)', () => { + cy.findByText('Products').should('have.attr', 'data-state', 'closed'); + cy.findByText('Products').realClick(); + cy.findByText('Products').should('have.attr', 'data-state', 'open'); + }); + + it('click on click-opened trigger toggles closed (regression)', () => { + cy.findByText('Products').realClick(); + cy.findByText('Products').should('have.attr', 'data-state', 'open'); + cy.findByText('Products').realClick(); + cy.findByText('Products').should('have.attr', 'data-state', 'closed'); + }); + + it('hover A → hover B opens B and closes A (regression)', () => { + cy.findByText('Products').realHover(); + cy.findByText('Products').should('have.attr', 'data-state', 'open'); + cy.findByText('Company').realHover(); + cy.findByText('Company').should('have.attr', 'data-state', 'open'); + cy.findByText('Products').should('have.attr', 'data-state', 'closed'); + }); + + it('hover → leave → re-hover → click sticks open (ref reset)', () => { + cy.findByText('Products').realHover(); + cy.findByText('Products').should('have.attr', 'data-state', 'open'); + cy.findByText('Link').realHover(); + cy.findByText('Products').should('have.attr', 'data-state', 'closed'); + cy.findByText('Products').realHover(); + cy.findByText('Products').should('have.attr', 'data-state', 'open'); + cy.findByText('Products').realClick(); + cy.findByText('Products').should('have.attr', 'data-state', 'open'); + }); +}); diff --git a/packages/react/navigation-menu/src/navigation-menu.tsx b/packages/react/navigation-menu/src/navigation-menu.tsx index f4e3db8028..9ccad977fc 100644 --- a/packages/react/navigation-menu/src/navigation-menu.tsx +++ b/packages/react/navigation-menu/src/navigation-menu.tsx @@ -528,6 +528,10 @@ const NavigationMenuTrigger = React.forwardRef< }), )} onClick={composeEventHandlers(props.onClick, () => { + if (open && hasPointerMoveOpenedRef.current) { + hasPointerMoveOpenedRef.current = false; + return; + } context.onItemSelect(itemContext.value); wasClickCloseRef.current = open; })} From 0c18635cb5d3ec5ea476ede6e76246c860abc48c Mon Sep 17 00:00:00 2001 From: Bnaya Zilberfarb Date: Sat, 30 May 2026 23:57:36 +0300 Subject: [PATCH 2/2] docs(changeset): expand rationale and scope Base UI comparison Spell out the why behind the fix (the toggle had no notion of how the menu was opened) and frame the Base UI reference as relevant to folks comparing primitives in the shadcn ecosystem, rather than as a generic appeal to Base UI's behavior. --- .changeset/navigation-menu-stick-on-hover-then-click.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.changeset/navigation-menu-stick-on-hover-then-click.md b/.changeset/navigation-menu-stick-on-hover-then-click.md index cf9308a038..f52a0fa275 100644 --- a/.changeset/navigation-menu-stick-on-hover-then-click.md +++ b/.changeset/navigation-menu-stick-on-hover-then-click.md @@ -2,4 +2,8 @@ '@radix-ui/react-navigation-menu': patch --- -Keep dropdown open on click after hover-open. Previously a hover-opened trigger would close on the first click; now it stays open (matching Base UI's `stickIfOpen` behavior). Subsequent clicks still toggle closed; click-to-open is unchanged. +Keep dropdown open on click after hover-open. + +Previously, hovering a trigger opened the dropdown but a subsequent click on the same trigger closed it, because `onItemSelect` toggled state based purely on whether the current open value matched the trigger — with no notion of how the menu was opened. The trigger now swallows a click when it was just opened by hover, so the dropdown sticks open. Subsequent clicks still toggle closed; click-to-open on a closed trigger is unchanged. + +For folks comparing primitives in the shadcn ecosystem, Base UI's `NavigationMenuTrigger` gets the same behavior via Floating UI's `useClick({ stickIfOpen })`.