From 92ba9e6d7dd09c58cb5f8c446549e795d4aa7b49 Mon Sep 17 00:00:00 2001 From: Kian Bazarjani Date: Wed, 18 Feb 2026 19:14:39 -0500 Subject: [PATCH 1/3] feat(popup-menu): continuously monitor submenu pointer intent after leave --- .../submenu-trigger/submenu-trigger.tsx | 162 +++++++++++++++++- .../internal/popup-menu/popup-menu.test.tsx | 100 +++++++++++ 2 files changed, 259 insertions(+), 3 deletions(-) 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 c6a35a2a..78aab260 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 @@ -19,6 +19,7 @@ import { useSubmenuContext } from '../../contexts/submenu-context.js' import { useAimGuard } from '../../hooks/use-aim-guard.js' import { usePopupMenuItem } from '../../hooks/use-popup-menu-item.js' import { + type AnchorSide, getSmoothedHeading, resolveAnchorSide, willHitSubmenu, @@ -203,6 +204,10 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< const openTimerRef = React.useRef | null>(null) // Timer for delayed close on pointer leave misses const closeTimerRef = React.useRef | null>(null) + const leaveMonitorCleanupRef = React.useRef<(() => void) | null>(null) + const leaveMonitorTimerRef = React.useRef | null>(null) const clearOpenTimer = React.useCallback(() => { if (openTimerRef.current !== null) { @@ -232,13 +237,26 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< }, closeDelay) }, [clearCloseTimer, closeDelay, setOpen]) + const clearLeaveMonitor = React.useCallback(() => { + if (leaveMonitorCleanupRef.current) { + leaveMonitorCleanupRef.current() + leaveMonitorCleanupRef.current = null + } + + if (leaveMonitorTimerRef.current !== null) { + clearTimeout(leaveMonitorTimerRef.current) + leaveMonitorTimerRef.current = null + } + }, []) + // Cleanup timer on unmount React.useEffect(() => { return () => { clearOpenTimer() clearCloseTimer() + clearLeaveMonitor() } - }, [clearOpenTimer, clearCloseTimer]) + }, [clearOpenTimer, clearCloseTimer, clearLeaveMonitor]) // Track if submenu was just closed while highlighted (e.g. ArrowLeft back) // to suppress the keyboard auto-open until highlight leaves and returns @@ -360,6 +378,127 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< ], ) + const startLeaveMonitor = React.useCallback( + ( + anchor: AnchorSide, + triggerRect: DOMRect | null, + initialHit: boolean, + timeoutMs: number, + ) => { + clearLeaveMonitor() + + if (timeoutMs <= 0) { + return + } + + let lastHit = initialHit + + const onWindowPointerMove = (pointerEvent: PointerEvent) => { + if (!isMouseLikePointerType(pointerEvent.pointerType)) { + return + } + + const contentRect = contentRef.current?.getBoundingClientRect() + if (!contentRect) { + clearLeaveMonitor() + return + } + + const { clientX, clientY } = pointerEvent + const isInsidePopup = + clientX >= contentRect.left && + clientX <= contentRect.right && + clientY >= contentRect.top && + clientY <= contentRect.bottom + + if (isInsidePopup) { + clearCloseTimer() + clearLeaveMonitor() + return + } + + const heading = getSmoothedHeading( + mouseTrailRef.current, + clientX, + clientY, + anchor, + triggerRect, + contentRect, + ) + + const hit = willHitSubmenu( + clientX, + clientY, + heading, + contentRect, + anchor, + triggerRect, + ) + + if (hit === lastHit) { + return + } + + const debugSnapshot: SubmenuSafeTriangleDebugSnapshot = { + contentRect, + triggerRect, + pointerX: clientX, + pointerY: clientY, + } + + lastHit = hit + + if (hit) { + showActivatedSafeTriangle(debugSnapshot) + clearCloseTimer() + activateAimGuard(item.id, parentDepth, childSurfaceId, 600) + parentStore.setHighlightedId(item.storeId) + setOpen(true) + return + } + + showMissedSafeTriangle(debugSnapshot) + clearAimGuard() + scheduleClose() + + if (closeDelay <= 0) { + clearLeaveMonitor() + } + } + + window.addEventListener('pointermove', onWindowPointerMove, { + passive: true, + }) + + leaveMonitorCleanupRef.current = () => { + window.removeEventListener('pointermove', onWindowPointerMove) + } + + leaveMonitorTimerRef.current = setTimeout(() => { + leaveMonitorTimerRef.current = null + clearLeaveMonitor() + }, timeoutMs) + }, + [ + clearLeaveMonitor, + contentRef, + clearCloseTimer, + mouseTrailRef, + showActivatedSafeTriangle, + activateAimGuard, + item.id, + parentDepth, + childSurfaceId, + parentStore, + item.storeId, + setOpen, + showMissedSafeTriangle, + clearAimGuard, + scheduleClose, + closeDelay, + ], + ) + React.useEffect(() => { if (showSafeTriangleAreaEnabled) { return @@ -414,8 +553,9 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< React.useEffect(() => { if (!open) { clearCloseTimer() + clearLeaveMonitor() } - }, [open, clearCloseTimer]) + }, [open, clearCloseTimer, clearLeaveMonitor]) React.useEffect(() => { if ( @@ -490,10 +630,12 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< const handlePointerEnterContent = () => { clearCloseTimer() + clearLeaveMonitor() } const handlePointerMoveContent = () => { clearCloseTimer() + clearLeaveMonitor() } contentEl.addEventListener('pointerenter', handlePointerEnterContent) @@ -505,7 +647,7 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< contentEl.removeEventListener('pointerenter', handlePointerEnterContent) contentEl.removeEventListener('pointermove', handlePointerMoveContent) } - }, [open, contentRef, clearCloseTimer]) + }, [open, contentRef, clearCloseTimer, clearLeaveMonitor]) // Register submenu open callback with parent store // When submenu is opened via keyboard (ArrowRight/Ctrl+L), transfer focus ownership @@ -699,6 +841,7 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< if (!openOnHighlight) return // Clear any existing aim guard and schedule open with delay + clearLeaveMonitor() clearAimGuard() clearOpenTimer() clearCloseTimer() @@ -722,8 +865,10 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< item.storeId, parentStore, openOnHighlight, + clearLeaveMonitor, clearAimGuard, clearOpenTimer, + clearCloseTimer, clearMissSafeTriangleTimer, showSafeTriangleAreaEnabled, delay.pointer, @@ -749,6 +894,7 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< // Get the submenu content rect for safe polygon calculation const contentRect = contentRef.current?.getBoundingClientRect() if (!contentRect) { + clearLeaveMonitor() clearMissSafeTriangleTimer() setSubmenuSafeTriangleDebugState('hidden') setSubmenuSafeTriangleDebugSnapshot(null) @@ -779,6 +925,7 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< // Pointer is already in the popup, clear guard and keep open showActivatedSafeTriangle(debugSnapshot) clearCloseTimer() + clearLeaveMonitor() clearAimGuard() return } @@ -809,11 +956,17 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< activateAimGuard(item.id, parentDepth, childSurfaceId, 600) parentStore.setHighlightedId(item.storeId) setOpen(true) + startLeaveMonitor(anchor, tRect, true, 600) } else { // User is not aiming at submenu - close it showMissedSafeTriangle(debugSnapshot) clearAimGuard() scheduleClose() + if (closeDelay > 0) { + startLeaveMonitor(anchor, tRect, false, closeDelay) + } else { + clearLeaveMonitor() + } } }, [ @@ -824,7 +977,9 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< guardedTriggerIdRef, item.id, item.storeId, + closeDelay, contentRef, + clearLeaveMonitor, clearMissSafeTriangleTimer, clearAimGuard, showActivatedSafeTriangle, @@ -832,6 +987,7 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< setOpen, clearCloseTimer, scheduleClose, + startLeaveMonitor, triggerRef, mouseTrailRef, activateAimGuard, 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 cac85edd..09d1757d 100644 --- a/packages/react/src/internal/popup-menu/popup-menu.test.tsx +++ b/packages/react/src/internal/popup-menu/popup-menu.test.tsx @@ -450,6 +450,10 @@ function createRect({ } as DOMRect } +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + // ============================================================================ // Tests // ============================================================================ @@ -1253,6 +1257,102 @@ describe('PopupMenu', () => { }) }) + describe('continuous aim monitoring', () => { + const setupAimMonitoringScenario = async (closeDelay: number) => { + 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() + }) + + const submenuPopup = screen.getByTestId('popup-submenu-1') + const triggerRectSpy = vi + .spyOn(submenuTrigger, 'getBoundingClientRect') + .mockImplementation(() => + createRect({ top: 60, left: 80, width: 120, height: 30 }), + ) + const popupRectSpy = vi + .spyOn(submenuPopup, 'getBoundingClientRect') + .mockImplementation(() => + createRect({ top: 40, left: 240, width: 180, height: 160 }), + ) + + return { + submenuTrigger, + cleanup: () => { + triggerRectSpy.mockRestore() + popupRectSpy.mockRestore() + }, + } + } + + it('closes when pointer intent changes away after an initial hit', async () => { + const scenario = await setupAimMonitoringScenario(0) + + try { + fireEvent.pointerMove(window, { clientX: 120, clientY: 90 }) + fireEvent.pointerMove(window, { clientX: 150, clientY: 92 }) + fireEvent.pointerMove(window, { clientX: 180, clientY: 94 }) + + fireEvent.pointerLeave(scenario.submenuTrigger, { + clientX: 190, + clientY: 94, + }) + + expect(screen.getByTestId('popup-submenu-1')).toBeInTheDocument() + + fireEvent.pointerMove(window, { clientX: 140, clientY: 250 }) + fireEvent.pointerMove(window, { clientX: 110, clientY: 275 }) + + await waitFor( + () => { + expect( + screen.queryByTestId('popup-submenu-1'), + ).not.toBeInTheDocument() + }, + { timeout: 350 }, + ) + } finally { + scenario.cleanup() + } + }) + + it('keeps submenu open when intent switches toward submenu before delayed close', async () => { + const scenario = await setupAimMonitoringScenario(240) + + try { + fireEvent.pointerMove(window, { clientX: 180, clientY: 220 }) + fireEvent.pointerMove(window, { clientX: 160, clientY: 230 }) + fireEvent.pointerMove(window, { clientX: 140, clientY: 240 }) + + fireEvent.pointerLeave(scenario.submenuTrigger, { + clientX: 130, + clientY: 260, + }) + + fireEvent.pointerMove(window, { clientX: 170, clientY: 200 }) + fireEvent.pointerMove(window, { clientX: 210, clientY: 170 }) + fireEvent.pointerMove(window, { clientX: 250, clientY: 140 }) + + await sleep(300) + + expect(screen.getByTestId('popup-submenu-1')).toBeInTheDocument() + } finally { + scenario.cleanup() + } + }) + }) + describe('search and filtering', () => { it('filters items by keyword search', async () => { const user = userEvent.setup() From b502ce7f012901132d3531da8252aa242ec26eeb Mon Sep 17 00:00:00 2001 From: Kian Bazarjani Date: Wed, 18 Feb 2026 20:16:47 -0500 Subject: [PATCH 2/3] fix(popup-menu): drop aim guard on immediate reversal --- .../submenu-trigger/submenu-trigger.tsx | 42 ++++++++++++++++++- .../internal/popup-menu/popup-menu.test.tsx | 31 ++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) 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 78aab260..f4d9f1ee 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 @@ -384,6 +384,8 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< triggerRect: DOMRect | null, initialHit: boolean, timeoutMs: number, + initialPointerX: number, + initialPointerY: number, ) => { clearLeaveMonitor() @@ -392,6 +394,8 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< } let lastHit = initialHit + let previousPointerX = initialPointerX + let previousPointerY = initialPointerY const onWindowPointerMove = (pointerEvent: PointerEvent) => { if (!isMouseLikePointerType(pointerEvent.pointerType)) { @@ -411,12 +415,46 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< clientY >= contentRect.top && clientY <= contentRect.bottom + const axisDelta = + anchor === 'left' || anchor === 'right' + ? clientX - previousPointerX + : clientY - previousPointerY + + previousPointerX = clientX + previousPointerY = clientY + if (isInsidePopup) { clearCloseTimer() clearLeaveMonitor() return } + const movedAwayFromSubmenu = + (anchor === 'left' && axisDelta <= -2) || + (anchor === 'right' && axisDelta >= 2) || + (anchor === 'top' && axisDelta <= -2) || + (anchor === 'bottom' && axisDelta >= 2) + + if (lastHit && movedAwayFromSubmenu) { + const debugSnapshot: SubmenuSafeTriangleDebugSnapshot = { + contentRect, + triggerRect, + pointerX: clientX, + pointerY: clientY, + } + + lastHit = false + showMissedSafeTriangle(debugSnapshot) + clearAimGuard() + scheduleClose() + + if (closeDelay <= 0) { + clearLeaveMonitor() + } + + return + } + const heading = getSmoothedHeading( mouseTrailRef.current, clientX, @@ -956,14 +994,14 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< activateAimGuard(item.id, parentDepth, childSurfaceId, 600) parentStore.setHighlightedId(item.storeId) setOpen(true) - startLeaveMonitor(anchor, tRect, true, 600) + startLeaveMonitor(anchor, tRect, true, 600, clientX, clientY) } else { // User is not aiming at submenu - close it showMissedSafeTriangle(debugSnapshot) clearAimGuard() scheduleClose() if (closeDelay > 0) { - startLeaveMonitor(anchor, tRect, false, closeDelay) + startLeaveMonitor(anchor, tRect, false, closeDelay, clientX, clientY) } else { clearLeaveMonitor() } 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 09d1757d..5b6ba27e 100644 --- a/packages/react/src/internal/popup-menu/popup-menu.test.tsx +++ b/packages/react/src/internal/popup-menu/popup-menu.test.tsx @@ -1351,6 +1351,37 @@ describe('PopupMenu', () => { scenario.cleanup() } }) + + it('drops aim guard immediately when pointer reverses direction after a hit', async () => { + const scenario = await setupAimMonitoringScenario(0) + + try { + fireEvent.pointerMove(window, { clientX: 120, clientY: 90 }) + fireEvent.pointerMove(window, { clientX: 150, clientY: 92 }) + fireEvent.pointerMove(window, { clientX: 180, clientY: 94 }) + + fireEvent.pointerLeave(scenario.submenuTrigger, { + clientX: 190, + clientY: 94, + }) + + fireEvent.pointerMove(window, { clientX: 178, clientY: 94 }) + fireEvent.pointerMove(window, { clientX: 164, clientY: 94 }) + + const rootItem = screen.getByTestId('root-item-1') + fireEvent.pointerMove(rootItem, { clientX: 96, clientY: 74 }) + fireEvent.pointerMove(rootItem, { clientX: 100, clientY: 78 }) + + await waitFor( + () => { + expect(rootItem).toHaveAttribute('data-highlighted', '') + }, + { timeout: 250 }, + ) + } finally { + scenario.cleanup() + } + }) }) describe('search and filtering', () => { From 834b305f414505dc9b65f83c6d28a3dcb1a270ae Mon Sep 17 00:00:00 2001 From: Kian Bazarjani Date: Wed, 18 Feb 2026 20:23:53 -0500 Subject: [PATCH 3/3] add logs --- .../submenu-trigger/submenu-trigger.tsx | 137 +++++++++++++++++- .../popup-menu/hooks/use-aim-guard.tsx | 42 +++++- 2 files changed, 175 insertions(+), 4 deletions(-) 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 f4d9f1ee..5ae36c00 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 @@ -309,6 +309,33 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< const disabled = item.disabled + const logAimTrace = React.useCallback( + (eventName: string, details?: Record) => { + if (!showSafeTriangleAreaEnabled) { + return + } + + console.log(`[PopupMenu][AimGuard] ${eventName}`, { + triggerId: item.id, + triggerStoreId: item.storeId, + parentDepth, + aimGuardActive: aimGuardActiveRef.current, + guardedTriggerId: guardedTriggerIdRef.current, + guardedDepth: guardedDepthRef.current, + ...details, + }) + }, + [ + showSafeTriangleAreaEnabled, + item.id, + item.storeId, + parentDepth, + aimGuardActiveRef, + guardedTriggerIdRef, + guardedDepthRef, + ], + ) + const clearMissSafeTriangleTimer = React.useCallback(() => { if (missSafeTriangleTimerRef.current !== null) { clearTimeout(missSafeTriangleTimerRef.current) @@ -389,7 +416,18 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< ) => { clearLeaveMonitor() + logAimTrace('leave-monitor-start', { + anchor, + initialHit, + timeoutMs, + closeDelay, + triggerRect, + initialPointerX, + initialPointerY, + }) + if (timeoutMs <= 0) { + logAimTrace('leave-monitor-skip-timeout', { timeoutMs }) return } @@ -404,6 +442,7 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< const contentRect = contentRef.current?.getBoundingClientRect() if (!contentRect) { + logAimTrace('leave-monitor-stop-no-content-rect') clearLeaveMonitor() return } @@ -424,6 +463,10 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< previousPointerY = clientY if (isInsidePopup) { + logAimTrace('leave-monitor-inside-popup', { + clientX, + clientY, + }) clearCloseTimer() clearLeaveMonitor() return @@ -435,6 +478,15 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< (anchor === 'top' && axisDelta <= -2) || (anchor === 'bottom' && axisDelta >= 2) + logAimTrace('leave-monitor-pointermove', { + anchor, + clientX, + clientY, + axisDelta, + movedAwayFromSubmenu, + lastHit, + }) + if (lastHit && movedAwayFromSubmenu) { const debugSnapshot: SubmenuSafeTriangleDebugSnapshot = { contentRect, @@ -444,6 +496,12 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< } lastHit = false + logAimTrace('leave-monitor-reversal-close', { + anchor, + clientX, + clientY, + axisDelta, + }) showMissedSafeTriangle(debugSnapshot) clearAimGuard() scheduleClose() @@ -473,6 +531,15 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< triggerRect, ) + logAimTrace('leave-monitor-hit-eval', { + anchor, + clientX, + clientY, + hit, + lastHit, + heading, + }) + if (hit === lastHit) { return } @@ -487,6 +554,11 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< lastHit = hit if (hit) { + logAimTrace('leave-monitor-hit-transition', { + anchor, + clientX, + clientY, + }) showActivatedSafeTriangle(debugSnapshot) clearCloseTimer() activateAimGuard(item.id, parentDepth, childSurfaceId, 600) @@ -495,6 +567,11 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< return } + logAimTrace('leave-monitor-miss-transition', { + anchor, + clientX, + clientY, + }) showMissedSafeTriangle(debugSnapshot) clearAimGuard() scheduleClose() @@ -509,16 +586,19 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< }) leaveMonitorCleanupRef.current = () => { + logAimTrace('leave-monitor-cleanup-remove-listener') window.removeEventListener('pointermove', onWindowPointerMove) } leaveMonitorTimerRef.current = setTimeout(() => { leaveMonitorTimerRef.current = null + logAimTrace('leave-monitor-timeout-expired', { timeoutMs }) clearLeaveMonitor() }, timeoutMs) }, [ clearLeaveMonitor, + logAimTrace, contentRef, clearCloseTimer, mouseTrailRef, @@ -831,6 +911,11 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< guardedDepthRef.current === parentDepth && guardedTriggerIdRef.current !== item.id ) { + logAimTrace('pointermove-blocked-by-guard', { + clientX: event.clientX, + clientY: event.clientY, + blockedByTriggerId: guardedTriggerIdRef.current, + }) return } @@ -846,6 +931,7 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< guardedTriggerIdRef, item.id, item.storeId, + logAimTrace, parentStore, ], ) @@ -863,9 +949,19 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< aimGuardActiveRef.current && guardedTriggerIdRef.current !== item.id ) { + logAimTrace('pointerenter-blocked-by-guard', { + clientX: event.clientX, + clientY: event.clientY, + blockedByTriggerId: guardedTriggerIdRef.current, + }) return } + logAimTrace('pointerenter-submenu-trigger', { + clientX: event.clientX, + clientY: event.clientY, + }) + if (showSafeTriangleAreaEnabled) { clearMissSafeTriangleTimer() setSubmenuSafeTriangleDebugSnapshot(null) @@ -911,6 +1007,7 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< showSafeTriangleAreaEnabled, delay.pointer, setOpen, + logAimTrace, ], ) @@ -925,13 +1022,28 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< // Cancel any pending open timer clearOpenTimer() + logAimTrace('pointerleave-submenu-trigger', { + clientX: event.clientX, + clientY: event.clientY, + }) + // Check if aim guard is blocking this trigger - if (aimGuardActiveRef.current && guardedTriggerIdRef.current !== item.id) + if ( + aimGuardActiveRef.current && + guardedTriggerIdRef.current !== item.id + ) { + logAimTrace('pointerleave-blocked-by-guard', { + clientX: event.clientX, + clientY: event.clientY, + blockedByTriggerId: guardedTriggerIdRef.current, + }) return + } // Get the submenu content rect for safe polygon calculation const contentRect = contentRef.current?.getBoundingClientRect() if (!contentRect) { + logAimTrace('pointerleave-no-content-rect') clearLeaveMonitor() clearMissSafeTriangleTimer() setSubmenuSafeTriangleDebugState('hidden') @@ -961,6 +1073,10 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< if (isInsidePopup) { // Pointer is already in the popup, clear guard and keep open + logAimTrace('pointerleave-inside-popup-success', { + clientX, + clientY, + }) showActivatedSafeTriangle(debugSnapshot) clearCloseTimer() clearLeaveMonitor() @@ -987,9 +1103,22 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< tRect, ) + logAimTrace('pointerleave-hit-eval', { + anchor, + hit, + clientX, + clientY, + heading, + }) + if (hit) { // User is aiming at submenu - activate aim guard for 600ms // Guard is activated at parentDepth to block highlighting in the parent menu only + logAimTrace('pointerleave-hit-activate-guard', { + anchor, + clientX, + clientY, + }) showActivatedSafeTriangle(debugSnapshot) activateAimGuard(item.id, parentDepth, childSurfaceId, 600) parentStore.setHighlightedId(item.storeId) @@ -997,6 +1126,11 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< startLeaveMonitor(anchor, tRect, true, 600, clientX, clientY) } else { // User is not aiming at submenu - close it + logAimTrace('pointerleave-miss-close', { + anchor, + clientX, + clientY, + }) showMissedSafeTriangle(debugSnapshot) clearAimGuard() scheduleClose() @@ -1032,6 +1166,7 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< parentDepth, childSurfaceId, parentStore, + logAimTrace, ], ) diff --git a/packages/react/src/internal/popup-menu/hooks/use-aim-guard.tsx b/packages/react/src/internal/popup-menu/hooks/use-aim-guard.tsx index af463c6f..442cc377 100644 --- a/packages/react/src/internal/popup-menu/hooks/use-aim-guard.tsx +++ b/packages/react/src/internal/popup-menu/hooks/use-aim-guard.tsx @@ -77,6 +77,28 @@ export function AimGuardProvider({ children }: AimGuardProviderProps) { const guardTimerRef = React.useRef(null) + const logAimGuard = React.useCallback( + (eventName: string, details?: Record) => { + if (typeof window === 'undefined') { + return + } + + console.log(`[PopupMenu][AimGuardProvider] ${eventName}`, { + aimGuardActive: aimGuardActiveRef.current, + guardedTriggerId: guardedTriggerIdRef.current, + guardedDepth: guardedDepthRef.current, + guardedSubmenuSurfaceId: guardedSubmenuSurfaceIdRef.current, + ...details, + }) + }, + [ + aimGuardActiveRef, + guardedTriggerIdRef, + guardedDepthRef, + guardedSubmenuSurfaceIdRef, + ], + ) + const resetAimGuardState = React.useCallback(() => { aimGuardActiveRef.current = false guardedTriggerIdRef.current = null @@ -93,8 +115,9 @@ export function AimGuardProvider({ children }: AimGuardProviderProps) { window.clearTimeout(guardTimerRef.current) guardTimerRef.current = null } + logAimGuard('clear') resetAimGuardState() - }, [resetAimGuardState]) + }, [resetAimGuardState, logAimGuard]) const activateAimGuard = React.useCallback( ( @@ -103,6 +126,12 @@ export function AimGuardProvider({ children }: AimGuardProviderProps) { submenuSurfaceId: string, timeoutMs = 450, ) => { + logAimGuard('activate', { + triggerId, + depth, + submenuSurfaceId, + timeoutMs, + }) aimGuardActiveRef.current = true guardedTriggerIdRef.current = triggerId guardedDepthRef.current = depth @@ -115,11 +144,16 @@ export function AimGuardProvider({ children }: AimGuardProviderProps) { window.clearTimeout(guardTimerRef.current) } guardTimerRef.current = window.setTimeout(() => { + logAimGuard('timeout-expired', { + triggerId, + depth, + submenuSurfaceId, + }) resetAimGuardState() guardTimerRef.current = null }, timeoutMs) as unknown as number }, - [resetAimGuardState], + [resetAimGuardState, logAimGuard], ) React.useEffect(() => { @@ -128,8 +162,10 @@ export function AimGuardProvider({ children }: AimGuardProviderProps) { window.clearTimeout(guardTimerRef.current) guardTimerRef.current = null } + + logAimGuard('provider-unmount-clear') } - }, []) + }, [logAimGuard]) const isGuardBlocking = React.useCallback( (rowId: string) =>