From ef0de2d5289ffd3d2676a0c822d067ad2e2219b5 Mon Sep 17 00:00:00 2001 From: Kian Bazarjani Date: Wed, 18 Feb 2026 19:11:14 -0500 Subject: [PATCH] feat(popup-menu): support side-aware safe polygon geometry --- .../submenu-safe-triangle-area.tsx | 6 +- .../submenu-trigger/submenu-trigger.tsx | 2 +- .../popup-menu/utils/aim-guard.test.ts | 105 ++++++++++++++ .../internal/popup-menu/utils/aim-guard.ts | 129 +++++++++++++++--- 4 files changed, 221 insertions(+), 21 deletions(-) diff --git a/packages/react/src/internal/popup-menu/components/submenu-trigger/submenu-safe-triangle-area.tsx b/packages/react/src/internal/popup-menu/components/submenu-trigger/submenu-safe-triangle-area.tsx index aafd9b89..15e08fe5 100644 --- a/packages/react/src/internal/popup-menu/components/submenu-trigger/submenu-safe-triangle-area.tsx +++ b/packages/react/src/internal/popup-menu/components/submenu-trigger/submenu-safe-triangle-area.tsx @@ -65,7 +65,11 @@ export function PopupMenuSubmenuSafeTriangleArea( return null } - const anchor = resolveAnchorSide(rect, triggerRect, mouseX) + const anchor = resolveAnchorSide(rect, triggerRect, mouseX, mouseY) + + if (anchor === 'top' || anchor === 'bottom') { + return null + } if (anchor === 'left' && mouseX >= x) { return null 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 5ebc210f..c6a35a2a 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 @@ -784,7 +784,7 @@ export const PopupMenuSubmenuTrigger = React.forwardRef< } // Calculate safe polygon and check if user is aiming toward submenu - const anchor = resolveAnchorSide(contentRect, tRect, clientX) + const anchor = resolveAnchorSide(contentRect, tRect, clientX, clientY) const heading = getSmoothedHeading( mouseTrailRef.current, clientX, diff --git a/packages/react/src/internal/popup-menu/utils/aim-guard.test.ts b/packages/react/src/internal/popup-menu/utils/aim-guard.test.ts index 5a118b6a..6e25863b 100644 --- a/packages/react/src/internal/popup-menu/utils/aim-guard.test.ts +++ b/packages/react/src/internal/popup-menu/utils/aim-guard.test.ts @@ -75,6 +75,31 @@ describe('aim-guard', () => { expect(result).toBe('left') }) + + it('returns "top" when trigger is above submenu', () => { + const rect = createDOMRect(200, 200, 200, 160) + const triggerRect = createDOMRect(230, 100, 80, 40) + + const result = resolveAnchorSide(rect, triggerRect, 0) + + expect(result).toBe('top') + }) + + it('returns "bottom" when trigger is below submenu', () => { + const rect = createDOMRect(200, 120, 200, 160) + const triggerRect = createDOMRect(230, 320, 80, 40) + + const result = resolveAnchorSide(rect, triggerRect, 0) + + expect(result).toBe('bottom') + }) + + it('uses both x and y mouse fallback when no trigger rect is provided', () => { + const rect = createDOMRect(200, 120, 200, 160) + + expect(resolveAnchorSide(rect, null, 260, 80)).toBe('top') + expect(resolveAnchorSide(rect, null, 260, 340)).toBe('bottom') + }) }) describe('getSmoothedHeading', () => { @@ -137,6 +162,26 @@ describe('aim-guard', () => { expect(result).toHaveProperty('dx') expect(result).toHaveProperty('dy') }) + + it('infers vertical heading for top anchored submenu when movement is slow', () => { + const trail: [number, number][] = [ + [220, 120], + [220.05, 120.05], + ] + const rect = createDOMRect(160, 200, 180, 140) + const triggerRect = createDOMRect(210, 120, 40, 40) + + const result = getSmoothedHeading( + trail, + 220, + 120, + 'top', + triggerRect, + rect, + ) + + expect(result.dy).toBeGreaterThan(0) + }) }) describe('willHitSubmenu', () => { @@ -243,6 +288,66 @@ describe('aim-guard', () => { }) }) + describe('with anchor="top" (trigger above, submenu below)', () => { + const belowSubmenuRect = createDOMRect(120, 200, 220, 160) + + it('returns true when heading down toward submenu', () => { + const result = willHitSubmenu( + 220, + 120, + { dx: 0, dy: 12 }, + belowSubmenuRect, + 'top', + null, + ) + + expect(result).toBe(true) + }) + + it('returns false when heading away from submenu', () => { + const result = willHitSubmenu( + 220, + 120, + { dx: 0, dy: -12 }, + belowSubmenuRect, + 'top', + null, + ) + + expect(result).toBe(false) + }) + }) + + describe('with anchor="bottom" (trigger below, submenu above)', () => { + const aboveSubmenuRect = createDOMRect(120, 60, 220, 160) + + it('returns true when heading up toward submenu', () => { + const result = willHitSubmenu( + 220, + 280, + { dx: 0, dy: -10 }, + aboveSubmenuRect, + 'bottom', + null, + ) + + expect(result).toBe(true) + }) + + it('returns false when heading away from submenu', () => { + const result = willHitSubmenu( + 220, + 280, + { dx: 0, dy: 10 }, + aboveSubmenuRect, + 'bottom', + null, + ) + + expect(result).toBe(false) + }) + }) + describe('edge cases', () => { it('returns false when dx is near zero (no horizontal movement)', () => { const result = willHitSubmenu( diff --git a/packages/react/src/internal/popup-menu/utils/aim-guard.ts b/packages/react/src/internal/popup-menu/utils/aim-guard.ts index 5bc9a16f..a85b1032 100644 --- a/packages/react/src/internal/popup-menu/utils/aim-guard.ts +++ b/packages/react/src/internal/popup-menu/utils/aim-guard.ts @@ -1,4 +1,43 @@ -export type AnchorSide = 'left' | 'right' +export type AnchorSide = 'left' | 'right' | 'top' | 'bottom' + +function resolveSideFromPoint(rect: DOMRect, x: number, y: number): AnchorSide { + const outsideCandidates: Array<{ side: AnchorSide; gap: number }> = [] + + const leftGap = rect.left - x + if (leftGap > 0) { + outsideCandidates.push({ side: 'left', gap: leftGap }) + } + + const rightGap = x - rect.right + if (rightGap > 0) { + outsideCandidates.push({ side: 'right', gap: rightGap }) + } + + const topGap = rect.top - y + if (topGap > 0) { + outsideCandidates.push({ side: 'top', gap: topGap }) + } + + const bottomGap = y - rect.bottom + if (bottomGap > 0) { + outsideCandidates.push({ side: 'bottom', gap: bottomGap }) + } + + if (outsideCandidates.length > 0) { + outsideCandidates.sort((a, b) => b.gap - a.gap) + return outsideCandidates[0]!.side + } + + const insideDistances: Array<{ side: AnchorSide; value: number }> = [ + { side: 'left', value: Math.abs(x - rect.left) }, + { side: 'right', value: Math.abs(x - rect.right) }, + { side: 'top', value: Math.abs(y - rect.top) }, + { side: 'bottom', value: Math.abs(y - rect.bottom) }, + ] + + insideDistances.sort((a, b) => a.value - b.value) + return insideDistances[0]!.side +} /** * Determines which side of the submenu the trigger is anchored to. @@ -8,14 +47,28 @@ export function resolveAnchorSide( rect: DOMRect, tRect: DOMRect | null, mx: number, + my?: number, ): AnchorSide { if (tRect) { const tx = (tRect.left + tRect.right) / 2 - const dL = Math.abs(tx - rect.left) - const dR = Math.abs(tx - rect.right) - return dL <= dR ? 'left' : 'right' + const ty = (tRect.top + tRect.bottom) / 2 + + return resolveSideFromPoint(rect, tx, ty) } - return mx < rect.left ? 'left' : 'right' + + if (my === undefined) { + if (mx < rect.left) { + return 'left' + } + if (mx > rect.right) { + return 'right' + } + + const centerX = (rect.left + rect.right) / 2 + return mx <= centerX ? 'left' : 'right' + } + + return resolveSideFromPoint(rect, mx, my) } /** @@ -44,10 +97,18 @@ export function getSmoothedHeading( if (mag < 0.5) { const tx = tRect ? (tRect.left + tRect.right) / 2 : exitX const ty = tRect ? (tRect.top + tRect.bottom) / 2 : exitY - const edgeX = anchor === 'right' ? rect.left : rect.right - const edgeCy = (rect.top + rect.bottom) / 2 - dx = edgeX - tx - dy = edgeCy - ty + + if (anchor === 'left' || anchor === 'right') { + const edgeX = anchor === 'right' ? rect.right : rect.left + const edgeCy = (rect.top + rect.bottom) / 2 + dx = edgeX - tx + dy = edgeCy - ty + } else { + const edgeY = anchor === 'top' ? rect.top : rect.bottom + const edgeCx = (rect.left + rect.right) / 2 + dx = edgeCx - tx + dy = edgeY - ty + } } return { dx, dy } } @@ -65,16 +126,46 @@ export function willHitSubmenu( triggerRect: DOMRect | null, ): boolean { const { dx, dy } = heading - if (Math.abs(dx) < 0.01) return false - if (anchor === 'left' && dx <= 0) return false - if (anchor === 'right' && dx >= 0) return false - const edgeX = anchor === 'left' ? rect.left : rect.right - const t = (edgeX - exitX) / dx + + const isHorizontal = anchor === 'left' || anchor === 'right' + const primary = isHorizontal ? dx : dy + if (Math.abs(primary) < 0.01) { + return false + } + + if ( + (anchor === 'left' && dx <= 0) || + (anchor === 'right' && dx >= 0) || + (anchor === 'top' && dy <= 0) || + (anchor === 'bottom' && dy >= 0) + ) { + return false + } + + const edge = + anchor === 'left' + ? rect.left + : anchor === 'right' + ? rect.right + : anchor === 'top' + ? rect.top + : rect.bottom + + const t = (edge - (isHorizontal ? exitX : exitY)) / primary if (t <= 0) return false - const yAtEdge = exitY + t * dy - const baseBand = triggerRect ? triggerRect.height * 0.75 : 28 + + const projected = isHorizontal ? exitY + t * dy : exitX + t * dx + + const triggerCrossSize = triggerRect + ? isHorizontal + ? triggerRect.height + : triggerRect.width + : null + const baseBand = triggerCrossSize ? triggerCrossSize * 0.75 : 28 const extra = Math.max(12, Math.min(36, baseBand)) - const top = rect.top - extra * 0.25 - const bottom = rect.bottom + extra * 0.25 - return yAtEdge >= top && yAtEdge <= bottom + + const min = (isHorizontal ? rect.top : rect.left) - extra * 0.25 + const max = (isHorizontal ? rect.bottom : rect.right) + extra * 0.25 + + return projected >= min && projected <= max }