From 246d6d83f629a93905077472fb3a5a854180e485 Mon Sep 17 00:00:00 2001 From: Kian Bazarjani Date: Wed, 18 Feb 2026 20:41:14 -0500 Subject: [PATCH] fix(popup-menu): suppress pointermove reopen after explicit close --- .../submenu-trigger/submenu-trigger.tsx | 29 +++++++++++++- .../internal/popup-menu/popup-menu.test.tsx | 40 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/packages/react/src/internal/popup-menu/components/submenu-trigger/submenu-trigger.tsx b/packages/react/src/internal/popup-menu/components/submenu-trigger/submenu-trigger.tsx index 2bdaf803..daf94ade 100644 --- a/packages/react/src/internal/popup-menu/components/submenu-trigger/submenu-trigger.tsx +++ b/packages/react/src/internal/popup-menu/components/submenu-trigger/submenu-trigger.tsx @@ -883,9 +883,18 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< (event: React.PointerEvent) => { // Prevent focus from leaving the input event.preventDefault() + + if (open) { + suppressAutoOpenRef.current = true + logAimTrace('pointerdown-suppress-auto-open', { + clientX: event.clientX, + clientY: event.clientY, + }) + } + onPointerDown?.(event) }, - [onPointerDown], + [open, logAimTrace, onPointerDown], ) // Custom pointer move handler for submenu triggers @@ -928,6 +937,14 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< return } + if (suppressAutoOpenRef.current) { + logAimTrace('pointermove-open-suppressed-after-explicit-close', { + clientX: event.clientX, + clientY: event.clientY, + }) + return + } + clearLeaveMonitor() clearCloseTimer() @@ -957,6 +974,7 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< item.storeId, openOnHighlight, open, + suppressAutoOpenRef, clearLeaveMonitor, clearCloseTimer, delay.pointer, @@ -1004,6 +1022,14 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< // Skip submenu opening if openOnHighlight is disabled if (!openOnHighlight) return + if (suppressAutoOpenRef.current) { + logAimTrace('pointerenter-open-suppressed-after-explicit-close', { + clientX: event.clientX, + clientY: event.clientY, + }) + return + } + // Clear any existing aim guard and schedule open with delay clearLeaveMonitor() clearAimGuard() @@ -1029,6 +1055,7 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< item.storeId, parentStore, openOnHighlight, + suppressAutoOpenRef, clearLeaveMonitor, clearAimGuard, clearOpenTimer, diff --git a/packages/react/src/internal/popup-menu/popup-menu.test.tsx b/packages/react/src/internal/popup-menu/popup-menu.test.tsx index 21a4a9e4..ef161337 100644 --- a/packages/react/src/internal/popup-menu/popup-menu.test.tsx +++ b/packages/react/src/internal/popup-menu/popup-menu.test.tsx @@ -1448,6 +1448,46 @@ describe('PopupMenu', () => { scenario.cleanup() } }) + + it('does not reopen submenu on pointermove after explicit click close until pointer leaves', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByTestId('trigger')) + + await waitFor(() => { + expect(screen.getByTestId('popup-root')).toBeInTheDocument() + }) + + const submenuTrigger = screen.getByTestId('submenu-trigger-1') + + await user.hover(submenuTrigger) + + await waitFor(() => { + expect(screen.getByTestId('popup-submenu-1')).toBeInTheDocument() + }) + + await user.click(submenuTrigger) + + await waitFor(() => { + expect(screen.queryByTestId('popup-submenu-1')).not.toBeInTheDocument() + }) + + fireEvent.pointerMove(submenuTrigger, { clientX: 182, clientY: 92 }) + fireEvent.pointerMove(submenuTrigger, { clientX: 186, clientY: 94 }) + + await sleep(120) + + expect(screen.queryByTestId('popup-submenu-1')).not.toBeInTheDocument() + + const rootItem = screen.getByTestId('root-item-1') + await user.hover(rootItem) + await user.hover(submenuTrigger) + + await waitFor(() => { + expect(screen.getByTestId('popup-submenu-1')).toBeInTheDocument() + }) + }) }) describe('search and filtering', () => {