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..f52a0fa275 --- /dev/null +++ b/.changeset/navigation-menu-stick-on-hover-then-click.md @@ -0,0 +1,9 @@ +--- +'@radix-ui/react-navigation-menu': patch +--- + +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 })`. 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; })}