From 67829e5a585bc5c229ff948b8c19e1397cef1704 Mon Sep 17 00:00:00 2001 From: lijianan <574980606@qq.com> Date: Sun, 7 Jun 2026 22:47:23 +0800 Subject: [PATCH 01/11] chore: update deps --- docs/demos/safe-hover.md | 8 ++ docs/examples/safe-hover.tsx | 61 +++++++++++ src/index.tsx | 151 ++++++++++++++++++++++++--- src/util/safeHover.ts | 193 +++++++++++++++++++++++++++++++++++ tests/basic.test.jsx | 138 +++++++++++++++++++++++++ tests/safeHover.test.ts | 141 +++++++++++++++++++++++++ 6 files changed, 678 insertions(+), 14 deletions(-) create mode 100644 docs/demos/safe-hover.md create mode 100644 docs/examples/safe-hover.tsx create mode 100644 src/util/safeHover.ts create mode 100644 tests/safeHover.test.ts diff --git a/docs/demos/safe-hover.md b/docs/demos/safe-hover.md new file mode 100644 index 00000000..5df13f22 --- /dev/null +++ b/docs/demos/safe-hover.md @@ -0,0 +1,8 @@ +--- +title: Safe Hover +nav: + title: Demo + path: /demo +--- + + diff --git a/docs/examples/safe-hover.tsx b/docs/examples/safe-hover.tsx new file mode 100644 index 00000000..2af3d387 --- /dev/null +++ b/docs/examples/safe-hover.tsx @@ -0,0 +1,61 @@ +import Trigger from '@rc-component/trigger'; +import React from 'react'; +import '../../assets/index.less'; + +const builtinPlacements = { + top: { + points: ['bc', 'tc'], + offset: [0, -56], + }, +}; + +const popupStyle: React.CSSProperties = { + width: 240, + padding: 12, + background: '#fff', + border: '1px solid #d9d9d9', + boxShadow: '0 6px 16px rgba(0, 0, 0, 0.12)', +}; + +const SafeHoverDemo = () => { + return ( +
+ + Safe hover popup +
+ Move through the gap to reach me. +
+ +
+ } + > + + + +
+ The popup is offset upward, leaving a blank hover gap. +
+ + ); +}; + +export default SafeHoverDemo; diff --git a/src/index.tsx b/src/index.tsx index bb5fb838..a95b58db 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -22,6 +22,8 @@ import useDelay from './hooks/useDelay'; import useWatch from './hooks/useWatch'; import useWinClick from './hooks/useWinClick'; import type { PortalProps } from '@rc-component/portal'; +import { isPointInSafeHoverArea } from './util/safeHover'; +import type { SafeHoverPoint } from './util/safeHover'; import type { ActionType, @@ -421,6 +423,120 @@ export function generateTrigger( }, delay); }; + const safeHoverRef = React.useRef<{ + doc: Document; + handler: (event: MouseEvent) => void; + refreshTimer: ReturnType | null; + } | null>(null); + + const clearSafeHover = useEvent(() => { + const safeHover = safeHoverRef.current; + + if (safeHover) { + safeHover.doc.removeEventListener('mousemove', safeHover.handler); + safeHover.doc.removeEventListener('pointermove', safeHover.handler); + + if (safeHover.refreshTimer) { + clearTimeout(safeHover.refreshTimer); + } + + safeHoverRef.current = null; + } + }); + + const startSafeHover = useEvent( + ( + event: React.MouseEvent | React.PointerEvent, + ) => { + if (!targetEle || !popupEle || !openRef.current) { + return false; + } + + const leavePoint: SafeHoverPoint = [event.clientX, event.clientY]; + const targetRect = targetEle.getBoundingClientRect(); + const popupRect = popupEle.getBoundingClientRect(); + + if ( + !isPointInSafeHoverArea(leavePoint, leavePoint, targetRect, popupRect) + ) { + return false; + } + + const doc = targetEle.ownerDocument; + + clearSafeHover(); + + let latestPoint = leavePoint; + + const isPointSafe = (point: SafeHoverPoint) => + isPointInSafeHoverArea( + point, + leavePoint, + targetEle.getBoundingClientRect(), + popupEle.getBoundingClientRect(), + ); + + const refreshDelay = + mouseLeaveDelay > 0 + ? Math.max(16, Math.min(mouseLeaveDelay * 500, 100)) + : 0; + const scheduleRefresh = () => { + const safeHover = safeHoverRef.current; + + if (!safeHover || !refreshDelay) { + return; + } + + safeHover.refreshTimer = setTimeout(() => { + if (isPointSafe(latestPoint)) { + triggerOpen(false, mouseLeaveDelay); + scheduleRefresh(); + } else { + clearSafeHover(); + triggerOpen(false, mouseLeaveDelay); + } + }, refreshDelay); + }; + + // Keep the existing close delay alive while the cursor crosses the gap. + const handler = (nativeEvent: MouseEvent) => { + latestPoint = [nativeEvent.clientX, nativeEvent.clientY]; + + if (isPointSafe(latestPoint)) { + triggerOpen(false, mouseLeaveDelay); + } else { + clearSafeHover(); + triggerOpen(false, mouseLeaveDelay); + } + }; + + doc.addEventListener('mousemove', handler); + doc.addEventListener('pointermove', handler); + safeHoverRef.current = { + doc, + handler, + refreshTimer: null, + }; + + triggerOpen(false, mouseLeaveDelay); + scheduleRefresh(); + + return true; + }, + ); + + React.useEffect(() => { + return () => { + clearSafeHover(); + }; + }, [clearSafeHover]); + + useLayoutEffect(() => { + if (!mergedOpen) { + clearSafeHover(); + } + }, [mergedOpen, clearSafeHover]); + function onEsc({ top }: Parameters[0]) { if (top) { triggerOpen(false); @@ -668,6 +784,7 @@ export function generateTrigger( if (hoverToShow) { const onMouseEnterCallback = (event: React.MouseEvent) => { + clearSafeHover(); setMousePosByEvent(event); }; @@ -688,6 +805,8 @@ export function generateTrigger( ); onPopupMouseEnter = (event) => { + clearSafeHover(); + // Only trigger re-open when popup is visible if ( (mergedOpen || inMotion) && @@ -706,22 +825,26 @@ export function generateTrigger( } if (hoverToHide) { - wrapperAction( - 'onMouseLeave', - false, - mouseLeaveDelay, - undefined, - ignoreMouseTrigger, - ); - wrapperAction( - 'onPointerLeave', - false, - mouseLeaveDelay, - undefined, - ignoreMouseTrigger, - ); + cloneProps.onMouseLeave = (event, ...args) => { + if (!ignoreMouseTrigger() && !startSafeHover(event)) { + triggerOpen(false, mouseLeaveDelay); + } + + // Pass to origin + originChildProps.onMouseLeave?.(event, ...args); + }; + + cloneProps.onPointerLeave = (event, ...args) => { + if (!ignoreMouseTrigger() && !startSafeHover(event)) { + triggerOpen(false, mouseLeaveDelay); + } + + // Pass to origin + originChildProps.onPointerLeave?.(event, ...args); + }; onPopupMouseLeave = () => { + clearSafeHover(); triggerOpen(false, mouseLeaveDelay); }; } diff --git a/src/util/safeHover.ts b/src/util/safeHover.ts new file mode 100644 index 00000000..a609d1c7 --- /dev/null +++ b/src/util/safeHover.ts @@ -0,0 +1,193 @@ +export type SafeHoverPoint = [x: number, y: number]; + +export type SafeHoverRect = Pick< + DOMRect, + 'left' | 'right' | 'top' | 'bottom' | 'width' | 'height' +>; + +export type SafeHoverSide = 'top' | 'bottom' | 'left' | 'right'; + +function isPointInPolygon(point: SafeHoverPoint, polygon: SafeHoverPoint[]) { + const [x, y] = point; + let isInside = false; + + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const [xi, yi] = polygon[i]; + const [xj, yj] = polygon[j]; + const intersect = + yi >= y !== yj >= y && x <= ((xj - xi) * (y - yi)) / (yj - yi) + xi; + + if (intersect) { + isInside = !isInside; + } + } + + return isInside; +} + +function isPointInRect(point: SafeHoverPoint, rect: SafeHoverRect) { + return ( + point[0] >= rect.left && + point[0] <= rect.right && + point[1] >= rect.top && + point[1] <= rect.bottom + ); +} + +export function getSafeHoverSide( + targetRect: SafeHoverRect, + popupRect: SafeHoverRect, +): SafeHoverSide | null { + const gaps: { side: SafeHoverSide; value: number }[] = [ + { side: 'top', value: targetRect.top - popupRect.bottom }, + { side: 'bottom', value: popupRect.top - targetRect.bottom }, + { side: 'left', value: targetRect.left - popupRect.right }, + { side: 'right', value: popupRect.left - targetRect.right }, + ]; + + const largestGap = gaps.reduce((prev, next) => + next.value > prev.value ? next : prev, + ); + + return largestGap.value > 0 ? largestGap.side : null; +} + +function isLeavePointTowardsPopup( + side: SafeHoverSide, + leavePoint: SafeHoverPoint, + targetRect: SafeHoverRect, +) { + const [x, y] = leavePoint; + + switch (side) { + case 'top': + return y <= targetRect.top + 1; + + case 'bottom': + return y >= targetRect.bottom - 1; + + case 'left': + return x <= targetRect.left + 1; + + case 'right': + return x >= targetRect.right - 1; + } +} + +function getSafeHoverGapPolygon( + side: SafeHoverSide, + targetRect: SafeHoverRect, + popupRect: SafeHoverRect, + buffer: number, +): SafeHoverPoint[] { + const verticalRect = + popupRect.width > targetRect.width ? targetRect : popupRect; + const horizontalRect = + popupRect.height > targetRect.height ? targetRect : popupRect; + const left = verticalRect.left - buffer; + const right = verticalRect.right + buffer; + const top = horizontalRect.top - buffer; + const bottom = horizontalRect.bottom + buffer; + + switch (side) { + case 'top': + return [ + [left, popupRect.bottom - 1], + [left, targetRect.top + 1], + [right, targetRect.top + 1], + [right, popupRect.bottom - 1], + ]; + + case 'bottom': + return [ + [left, targetRect.bottom - 1], + [left, popupRect.top + 1], + [right, popupRect.top + 1], + [right, targetRect.bottom - 1], + ]; + + case 'left': + return [ + [popupRect.right - 1, top], + [popupRect.right - 1, bottom], + [targetRect.left + 1, bottom], + [targetRect.left + 1, top], + ]; + + case 'right': + return [ + [targetRect.right - 1, top], + [targetRect.right - 1, bottom], + [popupRect.left + 1, bottom], + [popupRect.left + 1, top], + ]; + } +} + +function getSafeHoverIntentPolygon( + side: SafeHoverSide, + leavePoint: SafeHoverPoint, + popupRect: SafeHoverRect, + buffer: number, +): SafeHoverPoint[] { + switch (side) { + case 'top': + return [ + leavePoint, + [popupRect.left - buffer, popupRect.bottom + buffer], + [popupRect.right + buffer, popupRect.bottom + buffer], + ]; + + case 'bottom': + return [ + leavePoint, + [popupRect.right + buffer, popupRect.top - buffer], + [popupRect.left - buffer, popupRect.top - buffer], + ]; + + case 'left': + return [ + leavePoint, + [popupRect.right + buffer, popupRect.bottom + buffer], + [popupRect.right + buffer, popupRect.top - buffer], + ]; + + case 'right': + return [ + leavePoint, + [popupRect.left - buffer, popupRect.top - buffer], + [popupRect.left - buffer, popupRect.bottom + buffer], + ]; + } +} + +export function isPointInSafeHoverArea( + point: SafeHoverPoint, + leavePoint: SafeHoverPoint, + targetRect: SafeHoverRect, + popupRect: SafeHoverRect, + buffer = 0.5, +) { + const side = getSafeHoverSide(targetRect, popupRect); + + if (!side || !isLeavePointTowardsPopup(side, leavePoint, targetRect)) { + return false; + } + + if (isPointInRect(point, targetRect) || isPointInRect(point, popupRect)) { + return true; + } + + // The gap polygon keeps the straight corridor open; the intent polygon + // catches diagonal movement toward the popup edge. + return ( + isPointInPolygon( + point, + getSafeHoverGapPolygon(side, targetRect, popupRect, buffer), + ) || + isPointInPolygon( + point, + getSafeHoverIntentPolygon(side, leavePoint, popupRect, buffer), + ) + ); +} diff --git a/tests/basic.test.jsx b/tests/basic.test.jsx index 1da873db..6efc28c7 100644 --- a/tests/basic.test.jsx +++ b/tests/basic.test.jsx @@ -136,6 +136,17 @@ describe('Trigger.Basic', () => { }); describe('hover works', () => { + function mockRect(element, rect) { + element.getBoundingClientRect = jest.fn(() => ({ + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + })); + } + it('mouse event', () => { const { container } = render( { trigger(document, '.rc-trigger-popup', 'pointerEnter'); expect(isPopupHidden()).toBeFalsy(); }); + + it('keeps popup open while mouse moves through safe hover area', () => { + const { container } = render( + trigger} + > +
hover
+
, + ); + + const target = container.querySelector('.target'); + + fireEvent.mouseEnter(target, { clientX: 50, clientY: 10 }); + act(() => jest.runAllTimers()); + + const popup = document.querySelector('.rc-trigger-popup'); + + mockRect(target, { left: 0, top: 0, width: 100, height: 20 }); + mockRect(popup, { left: 20, top: 60, width: 60, height: 30 }); + + fireEvent.mouseLeave(target, { clientX: 50, clientY: 20 }); + act(() => jest.advanceTimersByTime(50)); + + fireEvent.mouseMove(document, { clientX: 50, clientY: 40 }); + act(() => jest.advanceTimersByTime(250)); + + expect(isPopupHidden()).toBeFalsy(); + + fireEvent.mouseEnter(popup, { clientX: 50, clientY: 60 }); + act(() => jest.runAllTimers()); + + expect(isPopupHidden()).toBeFalsy(); + }); + + it('closes popup after mouse leaves safe hover area', () => { + const { container } = render( + trigger} + > +
hover
+
, + ); + + const target = container.querySelector('.target'); + + fireEvent.mouseEnter(target, { clientX: 50, clientY: 10 }); + act(() => jest.runAllTimers()); + + const popup = document.querySelector('.rc-trigger-popup'); + + mockRect(target, { left: 0, top: 0, width: 100, height: 20 }); + mockRect(popup, { left: 20, top: 60, width: 60, height: 30 }); + + fireEvent.mouseLeave(target, { clientX: 50, clientY: 20 }); + act(() => jest.advanceTimersByTime(50)); + + fireEvent.mouseMove(document, { clientX: 50, clientY: 40 }); + act(() => jest.advanceTimersByTime(80)); + + expect(isPopupHidden()).toBeFalsy(); + + fireEvent.mouseMove(document, { clientX: 150, clientY: 40 }); + act(() => jest.advanceTimersByTime(100)); + + expect(isPopupHidden()).toBeTruthy(); + }); + + it('closes popup when safe hover area disappears while mouse is paused', () => { + const { container } = render( + trigger} + > +
hover
+
, + ); + + const target = container.querySelector('.target'); + + fireEvent.mouseEnter(target, { clientX: 50, clientY: 10 }); + act(() => jest.runAllTimers()); + + const popup = document.querySelector('.rc-trigger-popup'); + + mockRect(target, { left: 0, top: 0, width: 100, height: 20 }); + mockRect(popup, { left: 20, top: 60, width: 60, height: 30 }); + + fireEvent.mouseLeave(target, { clientX: 50, clientY: 20 }); + + mockRect(popup, { left: 10, top: 10, width: 60, height: 30 }); + act(() => jest.advanceTimersByTime(150)); + + expect(isPopupHidden()).toBeTruthy(); + }); + + it('keeps zero mouseLeaveDelay closing immediately', () => { + const { container } = render( + trigger} + > +
hover
+
, + ); + + const target = container.querySelector('.target'); + + fireEvent.mouseEnter(target, { clientX: 50, clientY: 10 }); + act(() => jest.runAllTimers()); + + const popup = document.querySelector('.rc-trigger-popup'); + + mockRect(target, { left: 0, top: 0, width: 100, height: 20 }); + mockRect(popup, { left: 20, top: 60, width: 60, height: 30 }); + + fireEvent.mouseLeave(target, { clientX: 50, clientY: 20 }); + act(() => jest.runAllTimers()); + + expect(isPopupHidden()).toBeTruthy(); + }); + }); it('contextMenu works', () => { diff --git a/tests/safeHover.test.ts b/tests/safeHover.test.ts new file mode 100644 index 00000000..664c626c --- /dev/null +++ b/tests/safeHover.test.ts @@ -0,0 +1,141 @@ +import { + getSafeHoverSide, + isPointInSafeHoverArea, + type SafeHoverRect, +} from '../src/util/safeHover'; + +function rect( + left: number, + top: number, + width: number, + height: number, +): SafeHoverRect { + return { + left, + top, + width, + height, + right: left + width, + bottom: top + height, + }; +} + +describe('safeHover util', () => { + it('detects popup side from separated rectangles', () => { + const target = rect(40, 40, 20, 20); + + expect(getSafeHoverSide(target, rect(40, 0, 20, 20))).toBe('top'); + expect(getSafeHoverSide(target, rect(40, 80, 20, 20))).toBe('bottom'); + expect(getSafeHoverSide(target, rect(0, 40, 20, 20))).toBe('left'); + expect(getSafeHoverSide(target, rect(80, 40, 20, 20))).toBe('right'); + expect(getSafeHoverSide(target, rect(45, 45, 20, 20))).toBeNull(); + }); + + it('keeps the vertical gap and diagonal intent safe', () => { + const target = rect(0, 0, 100, 20); + const popup = rect(20, 60, 60, 30); + const leavePoint: [number, number] = [50, 20]; + + expect(isPointInSafeHoverArea([50, 10], leavePoint, target, popup)).toBe( + true, + ); + expect(isPointInSafeHoverArea([50, 70], leavePoint, target, popup)).toBe( + true, + ); + expect(isPointInSafeHoverArea([50, 40], leavePoint, target, popup)).toBe( + true, + ); + expect(isPointInSafeHoverArea([30, 50], leavePoint, target, popup)).toBe( + true, + ); + expect(isPointInSafeHoverArea([150, 40], leavePoint, target, popup)).toBe( + false, + ); + }); + + it('rejects leave points moving away from the popup', () => { + expect( + isPointInSafeHoverArea( + [50, 40], + [50, 0], + rect(0, 0, 100, 20), + rect(20, 60, 60, 30), + ), + ).toBe(false); + expect( + isPointInSafeHoverArea( + [50, 60], + [50, 100], + rect(0, 80, 100, 20), + rect(20, 10, 60, 30), + ), + ).toBe(false); + expect( + isPointInSafeHoverArea( + [60, 50], + [100, 50], + rect(80, 0, 20, 100), + rect(10, 20, 30, 60), + ), + ).toBe(false); + expect( + isPointInSafeHoverArea( + [40, 50], + [0, 50], + rect(0, 0, 20, 100), + rect(60, 20, 30, 60), + ), + ).toBe(false); + }); + + it('keeps horizontal gaps safe', () => { + expect( + isPointInSafeHoverArea( + [60, 50], + [80, 50], + rect(80, 0, 20, 100), + rect(10, 20, 30, 60), + ), + ).toBe(true); + expect( + isPointInSafeHoverArea( + [40, 50], + [20, 50], + rect(0, 0, 20, 100), + rect(60, 20, 30, 60), + ), + ).toBe(true); + }); + + it('keeps top gap and diagonal intent safe', () => { + const target = rect(40, 80, 20, 20); + const popup = rect(0, 10, 100, 30); + const leavePoint: [number, number] = [50, 80]; + + expect(isPointInSafeHoverArea([50, 60], leavePoint, target, popup)).toBe( + true, + ); + expect(isPointInSafeHoverArea([25, 55], leavePoint, target, popup)).toBe( + true, + ); + }); + + it('keeps horizontal diagonal intent safe', () => { + expect( + isPointInSafeHoverArea( + [55, 25], + [80, 50], + rect(80, 40, 20, 20), + rect(10, 0, 30, 100), + ), + ).toBe(true); + expect( + isPointInSafeHoverArea( + [45, 25], + [20, 50], + rect(0, 40, 20, 20), + rect(60, 0, 30, 100), + ), + ).toBe(true); + }); +}); From 4a69bd2de01ec7419b5fa9a8d8b6c1c29e3c652f Mon Sep 17 00:00:00 2001 From: lijianan <574980606@qq.com> Date: Sun, 7 Jun 2026 23:04:15 +0800 Subject: [PATCH 02/11] chore: update deps --- docs/examples/safe-hover.tsx | 93 ++++++++++++++++++++++++++++++++++-- src/util/safeHover.ts | 38 ++++++++++----- 2 files changed, 116 insertions(+), 15 deletions(-) diff --git a/docs/examples/safe-hover.tsx b/docs/examples/safe-hover.tsx index 2af3d387..53e6dd62 100644 --- a/docs/examples/safe-hover.tsx +++ b/docs/examples/safe-hover.tsx @@ -1,7 +1,28 @@ -import Trigger from '@rc-component/trigger'; +import Trigger, { type TriggerRef } from '@rc-component/trigger'; import React from 'react'; +import { + getSafeHoverAreaPolygons, + type SafeHoverPoint, +} from '../../src/util/safeHover'; import '../../assets/index.less'; +type SafeHoverPolygon = { + points: SafeHoverPoint[]; + fill: string; + stroke: string; +}; + +const safeHoverPolygonStyles = [ + { + fill: 'rgba(255, 176, 32, 0.22)', + stroke: 'rgba(222, 121, 0, 0.6)', + }, + { + fill: 'rgba(22, 119, 255, 0.16)', + stroke: 'rgba(22, 119, 255, 0.55)', + }, +]; + const builtinPlacements = { top: { points: ['bc', 'tc'], @@ -18,16 +39,77 @@ const popupStyle: React.CSSProperties = { }; const SafeHoverDemo = () => { + const triggerRef = React.useRef(null); + + const [safeHoverPolygons, setSafeHoverPolygons] = React.useState< + SafeHoverPolygon[] + >([]); + + const updateSafeHoverPolygons = ( + event: React.MouseEvent | React.PointerEvent, + ) => { + const target = triggerRef.current?.nativeElement; + const popup = triggerRef.current?.popupElement; + + if (!target || !popup) { + setSafeHoverPolygons([]); + return; + } + + const leavePoint: SafeHoverPoint = [event.clientX, event.clientY]; + setSafeHoverPolygons( + getSafeHoverAreaPolygons( + leavePoint, + target.getBoundingClientRect(), + popup.getBoundingClientRect(), + ).map((points, index) => ({ + points, + ...safeHoverPolygonStyles[index], + })), + ); + }; + return (
+ {safeHoverPolygons.length > 0 && ( + + {safeHoverPolygons.map(({ points, fill, stroke }, index) => ( + point.join(',')).join(' ')} + fill={fill} + stroke={stroke} + strokeDasharray="4 3" + strokeWidth={1} + /> + ))} + + )} { + if (!nextOpen) { + setSafeHoverPolygons([]); + } + }} popup={ -
+
setSafeHoverPolygons([])}> Safe hover popup
Move through the gap to reach me. @@ -38,7 +120,12 @@ const SafeHoverDemo = () => {
} > - diff --git a/src/util/safeHover.ts b/src/util/safeHover.ts index a609d1c7..e67b3136 100644 --- a/src/util/safeHover.ts +++ b/src/util/safeHover.ts @@ -161,8 +161,7 @@ function getSafeHoverIntentPolygon( } } -export function isPointInSafeHoverArea( - point: SafeHoverPoint, +export function getSafeHoverAreaPolygons( leavePoint: SafeHoverPoint, targetRect: SafeHoverRect, popupRect: SafeHoverRect, @@ -171,6 +170,30 @@ export function isPointInSafeHoverArea( const side = getSafeHoverSide(targetRect, popupRect); if (!side || !isLeavePointTowardsPopup(side, leavePoint, targetRect)) { + return []; + } + + return [ + getSafeHoverGapPolygon(side, targetRect, popupRect, buffer), + getSafeHoverIntentPolygon(side, leavePoint, popupRect, buffer), + ]; +} + +export function isPointInSafeHoverArea( + point: SafeHoverPoint, + leavePoint: SafeHoverPoint, + targetRect: SafeHoverRect, + popupRect: SafeHoverRect, + buffer = 0.5, +) { + const safeHoverPolygons = getSafeHoverAreaPolygons( + leavePoint, + targetRect, + popupRect, + buffer, + ); + + if (!safeHoverPolygons.length) { return false; } @@ -180,14 +203,5 @@ export function isPointInSafeHoverArea( // The gap polygon keeps the straight corridor open; the intent polygon // catches diagonal movement toward the popup edge. - return ( - isPointInPolygon( - point, - getSafeHoverGapPolygon(side, targetRect, popupRect, buffer), - ) || - isPointInPolygon( - point, - getSafeHoverIntentPolygon(side, leavePoint, popupRect, buffer), - ) - ); + return safeHoverPolygons.some((polygon) => isPointInPolygon(point, polygon)); } From ba5359bd4535787c914f9f92a4846812532ed60c Mon Sep 17 00:00:00 2001 From: lijianan <574980606@qq.com> Date: Mon, 8 Jun 2026 01:56:37 +0800 Subject: [PATCH 03/11] update --- docs/examples/safe-hover.tsx | 65 ++++++++++++++++-------------------- src/util/safeHover.ts | 61 ++++++++++++++------------------- typings.d.ts | 1 + 3 files changed, 55 insertions(+), 72 deletions(-) create mode 100644 typings.d.ts diff --git a/docs/examples/safe-hover.tsx b/docs/examples/safe-hover.tsx index 53e6dd62..96b6c30a 100644 --- a/docs/examples/safe-hover.tsx +++ b/docs/examples/safe-hover.tsx @@ -1,16 +1,15 @@ -import Trigger, { type TriggerRef } from '@rc-component/trigger'; -import React from 'react'; -import { - getSafeHoverAreaPolygons, - type SafeHoverPoint, -} from '../../src/util/safeHover'; +import Trigger from '@rc-component/trigger'; +import type { TriggerRef } from '@rc-component/trigger'; +import React, { useState } from 'react'; +import { getSafeHoverAreaPolygons } from '../../src/util/safeHover'; +import type { SafeHoverPoint } from '../../src/util/safeHover'; import '../../assets/index.less'; -type SafeHoverPolygon = { +interface SafeHoverPolygon { points: SafeHoverPoint[]; fill: string; stroke: string; -}; +} const safeHoverPolygonStyles = [ { @@ -38,12 +37,10 @@ const popupStyle: React.CSSProperties = { boxShadow: '0 6px 16px rgba(0, 0, 0, 0.12)', }; -const SafeHoverDemo = () => { +const SafeHoverDemo: React.FC = () => { const triggerRef = React.useRef(null); - const [safeHoverPolygons, setSafeHoverPolygons] = React.useState< - SafeHoverPolygon[] - >([]); + const [safeHoverPolygons, setPolygons] = useState([]); const updateSafeHoverPolygons = ( event: React.MouseEvent | React.PointerEvent, @@ -52,20 +49,18 @@ const SafeHoverDemo = () => { const popup = triggerRef.current?.popupElement; if (!target || !popup) { - setSafeHoverPolygons([]); + setPolygons([]); return; } const leavePoint: SafeHoverPoint = [event.clientX, event.clientY]; - setSafeHoverPolygons( + + setPolygons( getSafeHoverAreaPolygons( leavePoint, target.getBoundingClientRect(), popup.getBoundingClientRect(), - ).map((points, index) => ({ - points, - ...safeHoverPolygonStyles[index], - })), + ).map((points, i) => ({ points, ...safeHoverPolygonStyles[i] })), ); }; @@ -83,17 +78,19 @@ const SafeHoverDemo = () => { zIndex: 999, }} > - {safeHoverPolygons.map(({ points, fill, stroke }, index) => ( - point.join(',')).join(' ')} - fill={fill} - stroke={stroke} - strokeDasharray="4 3" - strokeWidth={1} - /> - ))} + {safeHoverPolygons.map(({ points, fill, stroke }, index) => { + return ( + point.join(',')).join(' ')} + fill={fill} + stroke={stroke} + strokeDasharray="4 3" + strokeWidth={1} + /> + ); + })} )} { popupStyle={popupStyle} onOpenChange={(nextOpen) => { if (!nextOpen) { - setSafeHoverPolygons([]); + setPolygons([]); } }} popup={ -
setSafeHoverPolygons([])}> +
setPolygons([])}> Safe hover popup
Move through the gap to reach me.
-
} > @@ -126,10 +120,9 @@ const SafeHoverDemo = () => { onMouseLeave={updateSafeHoverPolygons} onPointerLeave={updateSafeHoverPolygons} > - Hover target + trigger -
{ const [x, y] = point; let isInside = false; @@ -23,63 +26,57 @@ function isPointInPolygon(point: SafeHoverPoint, polygon: SafeHoverPoint[]) { } return isInside; -} +}; -function isPointInRect(point: SafeHoverPoint, rect: SafeHoverRect) { +export const isPointInRect = (point: SafeHoverPoint, rect: SafeHoverRect) => { return ( point[0] >= rect.left && point[0] <= rect.right && point[1] >= rect.top && point[1] <= rect.bottom ); -} +}; -export function getSafeHoverSide( +export const getSafeHoverSide = ( targetRect: SafeHoverRect, popupRect: SafeHoverRect, -): SafeHoverSide | null { +): SafeHoverSide | null => { const gaps: { side: SafeHoverSide; value: number }[] = [ { side: 'top', value: targetRect.top - popupRect.bottom }, { side: 'bottom', value: popupRect.top - targetRect.bottom }, { side: 'left', value: targetRect.left - popupRect.right }, { side: 'right', value: popupRect.left - targetRect.right }, ]; - const largestGap = gaps.reduce((prev, next) => next.value > prev.value ? next : prev, ); - return largestGap.value > 0 ? largestGap.side : null; -} +}; -function isLeavePointTowardsPopup( +const isLeavePointTowardsPopup = ( side: SafeHoverSide, leavePoint: SafeHoverPoint, targetRect: SafeHoverRect, -) { +) => { const [x, y] = leavePoint; - switch (side) { case 'top': return y <= targetRect.top + 1; - case 'bottom': return y >= targetRect.bottom - 1; - case 'left': return x <= targetRect.left + 1; - case 'right': return x >= targetRect.right - 1; } -} +}; -function getSafeHoverGapPolygon( +const getSafeHoverGapPolygon = ( side: SafeHoverSide, targetRect: SafeHoverRect, popupRect: SafeHoverRect, buffer: number, -): SafeHoverPoint[] { +): SafeHoverPoint[] => { const verticalRect = popupRect.width > targetRect.width ? targetRect : popupRect; const horizontalRect = @@ -88,7 +85,6 @@ function getSafeHoverGapPolygon( const right = verticalRect.right + buffer; const top = horizontalRect.top - buffer; const bottom = horizontalRect.bottom + buffer; - switch (side) { case 'top': return [ @@ -97,7 +93,6 @@ function getSafeHoverGapPolygon( [right, targetRect.top + 1], [right, popupRect.bottom - 1], ]; - case 'bottom': return [ [left, targetRect.bottom - 1], @@ -105,7 +100,6 @@ function getSafeHoverGapPolygon( [right, popupRect.top + 1], [right, targetRect.bottom - 1], ]; - case 'left': return [ [popupRect.right - 1, top], @@ -113,7 +107,6 @@ function getSafeHoverGapPolygon( [targetRect.left + 1, bottom], [targetRect.left + 1, top], ]; - case 'right': return [ [targetRect.right - 1, top], @@ -122,14 +115,14 @@ function getSafeHoverGapPolygon( [popupRect.left + 1, top], ]; } -} +}; -function getSafeHoverIntentPolygon( +const getSafeHoverIntentPolygon = ( side: SafeHoverSide, leavePoint: SafeHoverPoint, popupRect: SafeHoverRect, buffer: number, -): SafeHoverPoint[] { +): SafeHoverPoint[] => { switch (side) { case 'top': return [ @@ -137,21 +130,18 @@ function getSafeHoverIntentPolygon( [popupRect.left - buffer, popupRect.bottom + buffer], [popupRect.right + buffer, popupRect.bottom + buffer], ]; - case 'bottom': return [ leavePoint, [popupRect.right + buffer, popupRect.top - buffer], [popupRect.left - buffer, popupRect.top - buffer], ]; - case 'left': return [ leavePoint, [popupRect.right + buffer, popupRect.bottom + buffer], [popupRect.right + buffer, popupRect.top - buffer], ]; - case 'right': return [ leavePoint, @@ -159,33 +149,32 @@ function getSafeHoverIntentPolygon( [popupRect.left - buffer, popupRect.bottom + buffer], ]; } -} +}; -export function getSafeHoverAreaPolygons( +export const getSafeHoverAreaPolygons = ( leavePoint: SafeHoverPoint, targetRect: SafeHoverRect, popupRect: SafeHoverRect, buffer = 0.5, -) { +) => { const side = getSafeHoverSide(targetRect, popupRect); if (!side || !isLeavePointTowardsPopup(side, leavePoint, targetRect)) { return []; } - return [ getSafeHoverGapPolygon(side, targetRect, popupRect, buffer), getSafeHoverIntentPolygon(side, leavePoint, popupRect, buffer), ]; -} +}; -export function isPointInSafeHoverArea( +export const isPointInSafeHoverArea = ( point: SafeHoverPoint, leavePoint: SafeHoverPoint, targetRect: SafeHoverRect, popupRect: SafeHoverRect, buffer = 0.5, -) { +) => { const safeHoverPolygons = getSafeHoverAreaPolygons( leavePoint, targetRect, @@ -204,4 +193,4 @@ export function isPointInSafeHoverArea( // The gap polygon keeps the straight corridor open; the intent polygon // catches diagonal movement toward the popup edge. return safeHoverPolygons.some((polygon) => isPointInPolygon(point, polygon)); -} +}; diff --git a/typings.d.ts b/typings.d.ts new file mode 100644 index 00000000..1ea39606 --- /dev/null +++ b/typings.d.ts @@ -0,0 +1 @@ +declare module '*.less'; From 6ba40acd20c99a50d6c9d4fbd7b94e584862b65b Mon Sep 17 00:00:00 2001 From: lijianan <574980606@qq.com> Date: Mon, 8 Jun 2026 17:59:15 +0800 Subject: [PATCH 04/11] update --- src/index.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index a95b58db..673d5bfa 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -434,7 +434,6 @@ export function generateTrigger( if (safeHover) { safeHover.doc.removeEventListener('mousemove', safeHover.handler); - safeHover.doc.removeEventListener('pointermove', safeHover.handler); if (safeHover.refreshTimer) { clearTimeout(safeHover.refreshTimer); @@ -448,7 +447,12 @@ export function generateTrigger( ( event: React.MouseEvent | React.PointerEvent, ) => { - if (!targetEle || !popupEle || !openRef.current) { + if ( + !targetEle || + !popupEle || + !openRef.current || + mouseLeaveDelay <= 0 + ) { return false; } @@ -511,12 +515,8 @@ export function generateTrigger( }; doc.addEventListener('mousemove', handler); - doc.addEventListener('pointermove', handler); - safeHoverRef.current = { - doc, - handler, - refreshTimer: null, - }; + + safeHoverRef.current = { doc, handler, refreshTimer: null }; triggerOpen(false, mouseLeaveDelay); scheduleRefresh(); From 098bb3cee9ed3b2a0cd10a9183c51c750891ba4e Mon Sep 17 00:00:00 2001 From: lijianan <574980606@qq.com> Date: Mon, 8 Jun 2026 18:16:04 +0800 Subject: [PATCH 05/11] Potential fix for pull request finding 'CodeQL / Useless comparison test' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- src/index.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 673d5bfa..074f0659 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -480,10 +480,10 @@ export function generateTrigger( popupEle.getBoundingClientRect(), ); - const refreshDelay = - mouseLeaveDelay > 0 - ? Math.max(16, Math.min(mouseLeaveDelay * 500, 100)) - : 0; + const refreshDelay = Math.max( + 16, + Math.min(mouseLeaveDelay * 500, 100), + ); const scheduleRefresh = () => { const safeHover = safeHoverRef.current; From e356ae1e9b64e3be59880757d50ac103b0c07a9d Mon Sep 17 00:00:00 2001 From: lijianan <574980606@qq.com> Date: Mon, 8 Jun 2026 18:19:48 +0800 Subject: [PATCH 06/11] update --- src/index.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 074f0659..aafa5cd4 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -480,10 +480,11 @@ export function generateTrigger( popupEle.getBoundingClientRect(), ); - const refreshDelay = Math.max( - 16, - Math.min(mouseLeaveDelay * 500, 100), - ); + const refreshDelay = + mouseLeaveDelay > 0 + ? Math.max(1000 / 60, Math.min(mouseLeaveDelay * 1000, 1000)) + : 0; + const scheduleRefresh = () => { const safeHover = safeHoverRef.current; From 63e2743633a68e5bfac60910e1cbc42a04a52035 Mon Sep 17 00:00:00 2001 From: lijianan <574980606@qq.com> Date: Mon, 8 Jun 2026 18:23:42 +0800 Subject: [PATCH 07/11] Potential fix for pull request finding 'CodeQL / Useless comparison test' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- src/index.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index aafa5cd4..31c38f25 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -480,10 +480,10 @@ export function generateTrigger( popupEle.getBoundingClientRect(), ); - const refreshDelay = - mouseLeaveDelay > 0 - ? Math.max(1000 / 60, Math.min(mouseLeaveDelay * 1000, 1000)) - : 0; + const refreshDelay = Math.max( + 1000 / 60, + Math.min(mouseLeaveDelay * 1000, 1000), + ); const scheduleRefresh = () => { const safeHover = safeHoverRef.current; From a05aaaf5a04de7c88596b7d1bdd71f8bcc731c85 Mon Sep 17 00:00:00 2001 From: lijianan <574980606@qq.com> Date: Mon, 8 Jun 2026 18:36:41 +0800 Subject: [PATCH 08/11] update --- tests/basic.test.jsx | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/tests/basic.test.jsx b/tests/basic.test.jsx index 6efc28c7..8fc1f59c 100644 --- a/tests/basic.test.jsx +++ b/tests/basic.test.jsx @@ -216,7 +216,7 @@ describe('Trigger.Basic', () => { act(() => jest.advanceTimersByTime(50)); fireEvent.mouseMove(document, { clientX: 50, clientY: 40 }); - act(() => jest.advanceTimersByTime(250)); + act(() => jest.advanceTimersByTime(50)); expect(isPopupHidden()).toBeFalsy(); @@ -226,6 +226,43 @@ describe('Trigger.Basic', () => { expect(isPopupHidden()).toBeFalsy(); }); + it('tracks safe hover with mousemove instead of pointermove', () => { + const { container } = render( + trigger} + > +
hover
+
, + ); + + const target = container.querySelector('.target'); + + fireEvent.mouseEnter(target, { clientX: 50, clientY: 10 }); + act(() => jest.runAllTimers()); + + const popup = document.querySelector('.rc-trigger-popup'); + + mockRect(target, { left: 0, top: 0, width: 100, height: 20 }); + mockRect(popup, { left: 20, top: 60, width: 60, height: 30 }); + + fireEvent.mouseLeave(target, { clientX: 50, clientY: 20 }); + + target.getBoundingClientRect.mockClear(); + popup.getBoundingClientRect.mockClear(); + + fireEvent.pointerMove(document, { clientX: 50, clientY: 40 }); + + expect(target.getBoundingClientRect).not.toHaveBeenCalled(); + expect(popup.getBoundingClientRect).not.toHaveBeenCalled(); + + fireEvent.mouseMove(document, { clientX: 50, clientY: 40 }); + + expect(target.getBoundingClientRect).toHaveBeenCalledTimes(1); + expect(popup.getBoundingClientRect).toHaveBeenCalledTimes(1); + }); + it('closes popup after mouse leaves safe hover area', () => { const { container } = render( Date: Mon, 8 Jun 2026 22:19:35 +0800 Subject: [PATCH 09/11] update --- src/Popup/Arrow.tsx | 2 +- src/hooks/useOffsetStyle.ts | 4 ++-- src/index.tsx | 12 +++++------- src/util.ts | 20 +++++++++++++++----- 4 files changed, 23 insertions(+), 15 deletions(-) diff --git a/src/Popup/Arrow.tsx b/src/Popup/Arrow.tsx index 1ab8239e..508ef817 100644 --- a/src/Popup/Arrow.tsx +++ b/src/Popup/Arrow.tsx @@ -4,7 +4,7 @@ import type { AlignType, ArrowPos, ArrowTypeOuter } from '../interface'; export interface ArrowProps { prefixCls: string; - align: AlignType; + align?: AlignType; arrow: ArrowTypeOuter; arrowPos: ArrowPos; } diff --git a/src/hooks/useOffsetStyle.ts b/src/hooks/useOffsetStyle.ts index 3c590041..75c2dc2e 100644 --- a/src/hooks/useOffsetStyle.ts +++ b/src/hooks/useOffsetStyle.ts @@ -28,8 +28,8 @@ export default function useOffsetStyle( const { points } = align; const dynamicInset = align.dynamicInset || (align as any)._experimental?.dynamicInset; - const alignRight = dynamicInset && points[0][1] === 'r'; - const alignBottom = dynamicInset && points[0][0] === 'b'; + const alignRight = dynamicInset && points?.[0][1] === 'r'; + const alignBottom = dynamicInset && points?.[0][0] === 'b'; if (alignRight) { offsetStyle.right = offsetR; diff --git a/src/index.tsx b/src/index.tsx index 31c38f25..f55717b4 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -13,7 +13,8 @@ import { useLayoutEffect, } from '@rc-component/util'; import * as React from 'react'; -import Popup, { type MobileConfig } from './Popup'; +import Popup from './Popup'; +import type { MobileConfig } from './Popup'; import type { TriggerContextProps } from './context'; import TriggerContext, { UniqueContext } from './context'; import useAction from './hooks/useAction'; @@ -24,7 +25,6 @@ import useWinClick from './hooks/useWinClick'; import type { PortalProps } from '@rc-component/portal'; import { isPointInSafeHoverArea } from './util/safeHover'; import type { SafeHoverPoint } from './util/safeHover'; - import type { ActionType, AlignType, @@ -32,7 +32,7 @@ import type { ArrowTypeOuter, BuildInPlacements, } from './interface'; -import { getAlignPopupClassName } from './util'; +import { clamp, getAlignPopupClassName } from './util'; export type { ActionType, @@ -480,10 +480,8 @@ export function generateTrigger( popupEle.getBoundingClientRect(), ); - const refreshDelay = Math.max( - 1000 / 60, - Math.min(mouseLeaveDelay * 1000, 1000), - ); + // Between 1 frame and 1 second + const refreshDelay = clamp(mouseLeaveDelay * 1000, 1000 / 60, 1000); const scheduleRefresh = () => { const safeHover = safeHoverRef.current; diff --git a/src/util.ts b/src/util.ts index ba05902a..e79c03f3 100644 --- a/src/util.ts +++ b/src/util.ts @@ -52,8 +52,11 @@ export function collectScroller(ele: HTMLElement) { while (current) { const { overflowX, overflowY, overflow } = - getWin(current).getComputedStyle(current); - if ([overflowX, overflowY, overflow].some((o) => scrollStyle.includes(o))) { + getWin(current)?.getComputedStyle(current) || {}; + + if ( + [overflowX, overflowY, overflow].some((o) => o && scrollStyle.includes(o)) + ) { scrollerList.push(current); } @@ -67,10 +70,17 @@ export function toNum(num: number, defaultValue = 1) { return Number.isNaN(num) ? defaultValue : num; } -function getPxValue(val: string) { - return toNum(parseFloat(val), 0); +function getPxValue(val?: string) { + if (!val) { + return 0; + } + return toNum(Number.parseFloat(val), 0); } +export const clamp = (num: number, min: number, max: number) => { + return Math.min(Math.max(num, min), max); +}; + export interface VisibleArea { left: number; top: number; @@ -119,7 +129,7 @@ export function getVisibleArea( borderBottomWidth, borderLeftWidth, borderRightWidth, - } = getWin(ele).getComputedStyle(ele); + } = getWin(ele)?.getComputedStyle(ele) || {}; const eleRect = ele.getBoundingClientRect(); const { From fac2f2d3d5bc63b16ff417cc78cf8f47c6b37083 Mon Sep 17 00:00:00 2001 From: lijianan <574980606@qq.com> Date: Mon, 8 Jun 2026 23:18:44 +0800 Subject: [PATCH 10/11] update --- src/index.tsx | 28 +++++++++++++++------------- tests/basic.test.jsx | 7 ++++++- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index f55717b4..eb9f7f4f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -483,33 +483,38 @@ export function generateTrigger( // Between 1 frame and 1 second const refreshDelay = clamp(mouseLeaveDelay * 1000, 1000 / 60, 1000); + const cancelRefresh = () => { + const safeHover = safeHoverRef.current; + if (safeHover?.refreshTimer) { + clearTimeout(safeHover.refreshTimer); + safeHover.refreshTimer = null; + } + }; + const scheduleRefresh = () => { const safeHover = safeHoverRef.current; - if (!safeHover || !refreshDelay) { + if (!safeHover || safeHover.refreshTimer) { return; } safeHover.refreshTimer = setTimeout(() => { if (isPointSafe(latestPoint)) { - triggerOpen(false, mouseLeaveDelay); - scheduleRefresh(); + safeHover.refreshTimer = null; } else { clearSafeHover(); - triggerOpen(false, mouseLeaveDelay); + triggerOpen(false); } }, refreshDelay); }; - // Keep the existing close delay alive while the cursor crosses the gap. const handler = (nativeEvent: MouseEvent) => { latestPoint = [nativeEvent.clientX, nativeEvent.clientY]; if (isPointSafe(latestPoint)) { - triggerOpen(false, mouseLeaveDelay); + cancelRefresh(); } else { - clearSafeHover(); - triggerOpen(false, mouseLeaveDelay); + scheduleRefresh(); } }; @@ -517,9 +522,6 @@ export function generateTrigger( safeHoverRef.current = { doc, handler, refreshTimer: null }; - triggerOpen(false, mouseLeaveDelay); - scheduleRefresh(); - return true; }, ); @@ -536,7 +538,7 @@ export function generateTrigger( } }, [mergedOpen, clearSafeHover]); - function onEsc({ top }: Parameters[0]) { + function onEsc({ top }: Parameters>[0]) { if (top) { triggerOpen(false); } @@ -555,7 +557,7 @@ export function generateTrigger( ); const [motionPrepareResolve, setMotionPrepareResolve] = - React.useState(null); + React.useState(null); // =========================== Align ============================ const [mousePos, setMousePos] = React.useState< diff --git a/tests/basic.test.jsx b/tests/basic.test.jsx index 8fc1f59c..0ebb9b05 100644 --- a/tests/basic.test.jsx +++ b/tests/basic.test.jsx @@ -298,7 +298,7 @@ describe('Trigger.Basic', () => { expect(isPopupHidden()).toBeTruthy(); }); - it('closes popup when safe hover area disappears while mouse is paused', () => { + it('waits for mousemove to start refresh close detection after safe hover area disappears', () => { const { container } = render( { mockRect(popup, { left: 10, top: 10, width: 60, height: 30 }); act(() => jest.advanceTimersByTime(150)); + expect(isPopupHidden()).toBeFalsy(); + + fireEvent.mouseMove(document, { clientX: 150, clientY: 40 }); + act(() => jest.advanceTimersByTime(100)); + expect(isPopupHidden()).toBeTruthy(); }); From 8e484507742c1b3901f78b292eed1539cccd5136 Mon Sep 17 00:00:00 2001 From: lijianan <574980606@qq.com> Date: Mon, 8 Jun 2026 23:37:40 +0800 Subject: [PATCH 11/11] update --- tests/arrow.test.jsx | 17 ++++++++++ tests/basic.test.jsx | 61 +++++++++++++++++++++++++++++++++++ tests/flip.test.tsx | 43 +++++++++++++++++++++++- tests/safeHover.test.ts | 19 +++++++++++ tests/useOffsetStyle.test.tsx | 29 +++++++++++++++++ 5 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 tests/useOffsetStyle.test.tsx diff --git a/tests/arrow.test.jsx b/tests/arrow.test.jsx index d49087ba..56dacad5 100644 --- a/tests/arrow.test.jsx +++ b/tests/arrow.test.jsx @@ -6,6 +6,7 @@ import { spyElementPrototypes, } from '@rc-component/util/lib/test/domHook'; import Trigger from '../src'; +import Arrow from '../src/Popup/Arrow'; describe('Trigger.Arrow', () => { beforeAll(() => { @@ -42,6 +43,22 @@ describe('Trigger.Arrow', () => { ); }); + it('uses default arrow options and position', () => { + render( + , + ); + + expect(document.querySelector('.rc-trigger-popup-arrow')).toHaveStyle({ + left: 0, + top: 0, + }); + }); + describe('direction', () => { let divSpy; let windowSpy; diff --git a/tests/basic.test.jsx b/tests/basic.test.jsx index 0ebb9b05..0241162f 100644 --- a/tests/basic.test.jsx +++ b/tests/basic.test.jsx @@ -298,6 +298,67 @@ describe('Trigger.Basic', () => { expect(isPopupHidden()).toBeTruthy(); }); + it('cancels refresh close detection when mouse returns to safe hover area', () => { + const { container } = render( + trigger} + > +
hover
+
, + ); + + const target = container.querySelector('.target'); + + fireEvent.mouseEnter(target, { clientX: 50, clientY: 10 }); + act(() => jest.runAllTimers()); + + const popup = document.querySelector('.rc-trigger-popup'); + + mockRect(target, { left: 0, top: 0, width: 100, height: 20 }); + mockRect(popup, { left: 20, top: 60, width: 60, height: 30 }); + + fireEvent.mouseLeave(target, { clientX: 50, clientY: 20 }); + fireEvent.mouseMove(document, { clientX: 150, clientY: 40 }); + fireEvent.mouseMove(document, { clientX: 50, clientY: 40 }); + + act(() => jest.advanceTimersByTime(100)); + + expect(isPopupHidden()).toBeFalsy(); + }); + + it('keeps pending refresh close detection while mouse remains unsafe', () => { + const { container } = render( + trigger} + > +
hover
+
, + ); + + const target = container.querySelector('.target'); + + fireEvent.mouseEnter(target, { clientX: 50, clientY: 10 }); + act(() => jest.runAllTimers()); + + const popup = document.querySelector('.rc-trigger-popup'); + + mockRect(target, { left: 0, top: 0, width: 100, height: 20 }); + mockRect(popup, { left: 20, top: 60, width: 60, height: 30 }); + + fireEvent.mouseLeave(target, { clientX: 50, clientY: 20 }); + fireEvent.mouseMove(document, { clientX: 50, clientY: 150 }); + fireEvent.mouseMove(document, { clientX: 50, clientY: 150 }); + + mockRect(popup, { left: 20, top: 60, width: 60, height: 200 }); + act(() => jest.advanceTimersByTime(100)); + + expect(isPopupHidden()).toBeFalsy(); + }); + it('waits for mousemove to start refresh close detection after safe hover area disappears', () => { const { container } = render( { for (let i = 0; i < 10; i += 1) { @@ -366,6 +366,47 @@ describe('Trigger.Align', () => { window.getComputedStyle = oriGetComputedStyle; }); + it('visible area skips document body and html elements', () => { + const initArea = { + left: 0, + right: 500, + top: 0, + bottom: 500, + }; + + expect(getVisibleArea(initArea)).toEqual(initArea); + expect( + getVisibleArea(initArea, [document.body, document.documentElement]), + ).toEqual(initArea); + }); + + it('handles elements without a document window', () => { + const detachedDocument = document.implementation.createHTMLDocument(); + const scroller = detachedDocument.createElement('div'); + const child = detachedDocument.createElement('div'); + + detachedDocument.body.appendChild(scroller); + scroller.appendChild(child); + + expect(collectScroller(child)).toEqual([]); + expect( + getVisibleArea( + { + left: 0, + right: 500, + top: 0, + bottom: 500, + }, + [scroller], + ), + ).toEqual({ + left: 0, + right: 100, + top: 0, + bottom: 100, + }); + }); + // e.g. adjustY + shiftX may make popup out but push back in screen // which should keep flip /* diff --git a/tests/safeHover.test.ts b/tests/safeHover.test.ts index 664c626c..7eaa4348 100644 --- a/tests/safeHover.test.ts +++ b/tests/safeHover.test.ts @@ -1,4 +1,5 @@ import { + getSafeHoverAreaPolygons, getSafeHoverSide, isPointInSafeHoverArea, type SafeHoverRect, @@ -138,4 +139,22 @@ describe('safeHover util', () => { ), ).toBe(true); }); + + it('supports custom safe hover buffer', () => { + expect( + getSafeHoverAreaPolygons( + [50, 20], + rect(0, 0, 100, 20), + rect(20, 60, 60, 30), + ), + ).toHaveLength(2); + expect( + getSafeHoverAreaPolygons( + [50, 20], + rect(0, 0, 100, 20), + rect(20, 60, 60, 30), + 4, + ), + ).toHaveLength(2); + }); }); diff --git a/tests/useOffsetStyle.test.tsx b/tests/useOffsetStyle.test.tsx new file mode 100644 index 00000000..2de789ad --- /dev/null +++ b/tests/useOffsetStyle.test.tsx @@ -0,0 +1,29 @@ +import { renderHook } from '@testing-library/react'; +import useOffsetStyle from '../src/hooks/useOffsetStyle'; + +describe('useOffsetStyle', () => { + it('uses right and bottom offsets with dynamic inset alignment', () => { + const { result } = renderHook(() => + useOffsetStyle( + false, + true, + true, + { + points: ['br', 'tr'], + dynamicInset: true, + }, + 12, + 34, + 56, + 78, + ), + ); + + expect(result.current).toEqual({ + left: 'auto', + top: 'auto', + right: 12, + bottom: 34, + }); + }); +});