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 4e2bf36a..78a8f5f3 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 @@ -995,12 +995,14 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< // Check if aim guard is blocking this trigger if ( aimGuardActiveRef.current && + guardedDepthRef.current === parentDepth && guardedTriggerIdRef.current !== item.id ) { logAimTrace('pointerenter-blocked-by-guard', { clientX: event.clientX, clientY: event.clientY, blockedByTriggerId: guardedTriggerIdRef.current, + blockedByDepth: guardedDepthRef.current, }) return } @@ -1050,6 +1052,7 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< onPointerEnter, disabled, aimGuardActiveRef, + guardedDepthRef, guardedTriggerIdRef, item.id, item.storeId, @@ -1087,12 +1090,14 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< // Check if aim guard is blocking this trigger if ( aimGuardActiveRef.current && + guardedDepthRef.current === parentDepth && guardedTriggerIdRef.current !== item.id ) { logAimTrace('pointerleave-blocked-by-guard', { clientX: event.clientX, clientY: event.clientY, blockedByTriggerId: guardedTriggerIdRef.current, + blockedByDepth: guardedDepthRef.current, }) return } @@ -1203,6 +1208,7 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< disabled, clearOpenTimer, aimGuardActiveRef, + guardedDepthRef, guardedTriggerIdRef, item.id, item.storeId, 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 c06f424a..e51e1f05 100644 --- a/packages/react/src/internal/popup-menu/popup-menu.test.tsx +++ b/packages/react/src/internal/popup-menu/popup-menu.test.tsx @@ -783,6 +783,98 @@ describe('PopupMenu', () => { popupRectSpy.mockRestore() }) + it('shows hover safe triangle for nested submenu trigger while parent guard is active', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByTestId('trigger')) + + await waitFor(() => { + expect(screen.getByTestId('popup-root')).toBeInTheDocument() + }) + + const submenuTrigger1 = screen.getByTestId('submenu-trigger-1') + + await user.hover(submenuTrigger1) + + await waitFor(() => { + expect(screen.getByTestId('popup-submenu-1')).toBeInTheDocument() + }) + + const submenuPopup1 = screen.getByTestId('popup-submenu-1') + const trigger1RectSpy = vi + .spyOn(submenuTrigger1, 'getBoundingClientRect') + .mockImplementation(() => + createRect({ top: 60, left: 80, width: 120, height: 30 }), + ) + const popup1RectSpy = vi + .spyOn(submenuPopup1, 'getBoundingClientRect') + .mockImplementation(() => + createRect({ top: 40, left: 240, width: 180, height: 160 }), + ) + + try { + // Activate aim guard for the root submenu trigger. + fireEvent.pointerMove(window, { clientX: 120, clientY: 90 }) + fireEvent.pointerMove(window, { clientX: 150, clientY: 92 }) + fireEvent.pointerMove(window, { clientX: 180, clientY: 94 }) + fireEvent.pointerLeave(submenuTrigger1, { clientX: 190, clientY: 94 }) + fireEvent.pointerMove(window, { clientX: 190, clientY: 94 }) + + await waitFor(() => { + expect(getSafeTriangle('activated')).toBeInTheDocument() + }) + + const submenuTrigger2 = screen.getByTestId('submenu-trigger-2') + + fireEvent.pointerEnter(submenuTrigger2, { + pointerType: 'mouse', + clientX: 270, + clientY: 95, + }) + fireEvent.pointerMove(submenuTrigger2, { + pointerType: 'mouse', + clientX: 270, + clientY: 95, + }) + + await waitFor(() => { + expect(screen.getByTestId('popup-submenu-2')).toBeInTheDocument() + }) + + const submenuPopup2 = screen.getByTestId('popup-submenu-2') + const trigger2RectSpy = vi + .spyOn(submenuTrigger2, 'getBoundingClientRect') + .mockImplementation(() => + createRect({ top: 90, left: 250, width: 120, height: 30 }), + ) + const popup2RectSpy = vi + .spyOn(submenuPopup2, 'getBoundingClientRect') + .mockImplementation(() => + createRect({ top: 70, left: 430, width: 180, height: 160 }), + ) + + try { + // Force a position update after geometry overrides are in place. + fireEvent.pointerMove(window, { + pointerType: 'mouse', + clientX: 270, + clientY: 95, + }) + + await waitFor(() => { + expect(getSafeTriangle('hover')).toBeInTheDocument() + }) + } finally { + trigger2RectSpy.mockRestore() + popup2RectSpy.mockRestore() + } + } finally { + trigger1RectSpy.mockRestore() + popup1RectSpy.mockRestore() + } + }) + it('renders the safe triangle in green when aim guard is activated on leave', async () => { const user = userEvent.setup() render()