From 86c3957a2128d81b2a69f47e51814aa6faed2359 Mon Sep 17 00:00:00 2001 From: DaZuiZui Date: Tue, 23 Jun 2026 15:37:30 +0800 Subject: [PATCH 1/2] fix(popup): stabilize hover close when moving to popup --- .../components/popup/__tests__/popup.test.tsx | 111 +++++++++++++- .../components/popup/hooks/useTrigger.tsx | 144 ++++++++++++++++-- 2 files changed, 244 insertions(+), 11 deletions(-) diff --git a/packages/components/popup/__tests__/popup.test.tsx b/packages/components/popup/__tests__/popup.test.tsx index 7ee7c7338c..355924d0f4 100644 --- a/packages/components/popup/__tests__/popup.test.tsx +++ b/packages/components/popup/__tests__/popup.test.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { act, fireEvent, mockTimeout, render, waitFor } from '@test/utils'; +import { act, fireEvent, mockTimeout, render, vi, waitFor } from '@test/utils'; import Input from '../../input'; import Popup from '../Popup'; @@ -9,6 +9,34 @@ describe('Popup 组件测试', () => { const popupTestId = 'popup-test-id'; const triggerElement = '触发元素'; + const waitForHoverClose = async () => { + await act(async () => { + await new Promise((resolve) => { + setTimeout(() => resolve(), 150); + }); + }); + }; + + const mockElementFromPoint = (element: Element | ((clientX: number, clientY: number) => Element)) => { + const originalElementFromPoint = document.elementFromPoint; + const elementFromPointMock = typeof element === 'function' ? vi.fn(element) : vi.fn().mockReturnValue(element); + + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: elementFromPointMock, + }); + + return { + elementFromPointMock, + restore: () => { + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: originalElementFromPoint, + }); + }, + }; + }; + test('hover 触发测试', async () => { const { getByText, queryByTestId } = render( {popupText}}> @@ -36,6 +64,7 @@ describe('Popup 组件测试', () => { act(() => { fireEvent.mouseLeave(getByText(triggerElement)); }); + await waitForHoverClose(); // 鼠标离开,style 的 display 应该为 none const popupElement3 = await waitFor(() => queryByTestId(popupTestId)); @@ -43,6 +72,86 @@ describe('Popup 组件测试', () => { expect(popupElement3.parentNode.parentNode).toHaveClass('t-popup--animation-leave-active'); }); + test('hover 快速从触发器移入浮层时不关闭', async () => { + const { getByText, queryByTestId } = render( + {popupText}}> + {triggerElement} + , + ); + + act(() => { + fireEvent.mouseEnter(getByText(triggerElement)); + }); + + const popupElement = await waitFor(() => queryByTestId(popupTestId)); + expect(popupElement).not.toBeNull(); + + const outsideElement = document.createElement('div'); + document.body.appendChild(outsideElement); + const { elementFromPointMock, restore } = mockElementFromPoint((clientX, clientY) => + clientX === 30 && clientY === 40 ? popupElement : outsideElement, + ); + + try { + act(() => { + fireEvent.mouseLeave(getByText(triggerElement), { + relatedTarget: outsideElement, + clientX: 10, + clientY: 20, + }); + fireEvent.mouseMove(document, { + clientX: 30, + clientY: 40, + }); + }); + await waitForHoverClose(); + + expect(elementFromPointMock).toHaveBeenCalledWith(10, 20); + expect(elementFromPointMock).toHaveBeenCalledWith(30, 40); + expect(popupElement.parentNode.parentNode).toHaveClass('t-popup--animation-enter-active'); + expect(popupElement.parentNode.parentNode).not.toHaveClass('t-popup--animation-leave-active'); + } finally { + restore(); + outsideElement.remove(); + } + }); + + test('hover 从触发器移到外部时正常关闭', async () => { + const { getByText, queryByTestId } = render( + {popupText}}> + {triggerElement} + , + ); + + act(() => { + fireEvent.mouseEnter(getByText(triggerElement)); + }); + + const popupElement = await waitFor(() => queryByTestId(popupTestId)); + expect(popupElement).not.toBeNull(); + + const outsideElement = document.createElement('div'); + document.body.appendChild(outsideElement); + const { elementFromPointMock, restore } = mockElementFromPoint(outsideElement); + + try { + act(() => { + fireEvent.mouseLeave(getByText(triggerElement), { + relatedTarget: outsideElement, + clientX: 10, + clientY: 20, + }); + }); + await waitForHoverClose(); + + expect(elementFromPointMock).toHaveBeenCalledWith(10, 20); + expect(popupElement.parentNode.parentNode).toHaveClass('t-popup--animation-leave-active'); + } finally { + restore(); + outsideElement.remove(); + } + }); + test('click 触发测试', async () => { const { getByText, queryByTestId } = render( {popupText}}> diff --git a/packages/components/popup/hooks/useTrigger.tsx b/packages/components/popup/hooks/useTrigger.tsx index c5cb5ae478..216880cec9 100644 --- a/packages/components/popup/hooks/useTrigger.tsx +++ b/packages/components/popup/hooks/useTrigger.tsx @@ -6,6 +6,7 @@ import { composeRefs, getNodeRef, getRefDom, supportNodeRef } from '../../_util/ import useConfig from '../../hooks/useConfig'; const ESC_KEY = 'Escape'; +const HOVER_CLOSE_DELAY = 120; const isEventFromDisabledElement = (e: Event | React.SyntheticEvent, container: Element) => { const target = e.target as Element; @@ -28,8 +29,15 @@ export default function useTrigger({ const triggerElementIsString = typeof triggerElement === 'string'; const triggerRef = useRef(null); + const popupElementRef = useRef(null); const hasPopupMouseDown = useRef(false); const visibleTimer = useRef(null); + const hoverCloseTimer = useRef(null); + const hoverCloseRaf = useRef(null); + const hasDocumentMouseMove = useRef(false); + const lastMousePosition = useRef({ clientX: 0, clientY: 0 }); + + popupElementRef.current = popupElement; // 禁用和无内容时不展示 const shouldToggle = useMemo(() => { @@ -52,6 +60,36 @@ export default function useTrigger({ } } + const updateLastMousePosition = useCallback((e: MouseEvent | React.MouseEvent) => { + lastMousePosition.current = { + clientX: e.clientX, + clientY: e.clientY, + }; + }, []); + + const handleDocumentMouseMove = useCallback( + (e: MouseEvent) => { + hasDocumentMouseMove.current = true; + updateLastMousePosition(e); + }, + [updateLastMousePosition], + ); + + const clearHoverCloseTimer = useCallback(() => { + if (hoverCloseRaf.current !== null) { + cancelAnimationFrame(hoverCloseRaf.current); + hoverCloseRaf.current = null; + } + if (hoverCloseTimer.current !== null) { + clearTimeout(hoverCloseTimer.current); + hoverCloseTimer.current = null; + } + hasDocumentMouseMove.current = false; + if (canUseDocument) { + off(document, 'mousemove', handleDocumentMouseMove); + } + }, [handleDocumentMouseMove]); + const getTriggerElement = useCallback(() => { if (!canUseDocument) return null; if (triggerElementIsString) return document.querySelector(triggerElement); @@ -59,8 +97,36 @@ export default function useTrigger({ return element instanceof Element ? element : null; }, [triggerElementIsString, triggerElement]); + const isElementInPopupOrTrigger = useCallback( + (element: EventTarget | null | undefined) => { + if (!(element instanceof Element)) return false; + + const triggerElement = getTriggerElement(); + if (triggerElement?.contains(element)) return true; + + const popupElement = popupElementRef.current; + if (popupElement?.contains(element)) return true; + + const closestPopup = element.closest?.(`.${classPrefix}-popup`); + return !!(closestPopup && (popupElement ? popupElement.isEqualNode(closestPopup) : true)); + }, + [classPrefix, getTriggerElement], + ); + + const isMovingToPopupOrTrigger = useCallback( + ({ relatedTarget, clientX, clientY }: { relatedTarget: EventTarget | null; clientX: number; clientY: number }) => { + if (isElementInPopupOrTrigger(relatedTarget)) return true; + + const pointElement = document.elementFromPoint?.(clientX, clientY); + return isElementInPopupOrTrigger(pointElement); + }, + [isElementInPopupOrTrigger], + ); + const handleMouseEnter = (e: MouseEvent | React.MouseEvent) => { if (trigger === 'hover') { + updateLastMousePosition(e); + clearHoverCloseTimer(); callFuncWithDelay({ delay: appearDelay, callback: () => onVisibleChange(true, { e, trigger: 'trigger-element-hover' }), @@ -70,15 +136,63 @@ export default function useTrigger({ const handleMouseLeave = (e: MouseEvent | React.MouseEvent) => { if (trigger !== 'hover' || hasPopupMouseDown.current) return; - const relatedTarget = e.relatedTarget as HTMLElement; - const closestPopup = relatedTarget?.closest?.(`.${classPrefix}-popup`); - - const isMovingToCurrentPopup = popupElement ? popupElement?.isEqualNode?.(closestPopup) : closestPopup; - if (isMovingToCurrentPopup) return; - callFuncWithDelay({ - delay: exitDelay, - callback: () => onVisibleChange(false, { e, trigger: 'trigger-element-hover' }), + (e as React.MouseEvent).persist?.(); + + const mouseEvent = e as MouseEvent; + const { clientX, clientY, relatedTarget } = mouseEvent; + + updateLastMousePosition(mouseEvent); + clearHoverCloseTimer(); + if (isMovingToPopupOrTrigger({ relatedTarget, clientX, clientY })) return; + + const shouldKeepVisible = () => + isMovingToPopupOrTrigger({ + relatedTarget: null, + ...lastMousePosition.current, + }); + + const closePopup = () => { + if (canUseDocument) { + off(document, 'mousemove', handleDocumentMouseMove); + } + + callFuncWithDelay({ + delay: exitDelay, + callback: () => onVisibleChange(false, { e, trigger: 'trigger-element-hover' }), + }); + }; + + if (canUseDocument) { + on(document, 'mousemove', handleDocumentMouseMove); + } + + hoverCloseRaf.current = requestAnimationFrame(() => { + hoverCloseRaf.current = null; + + if (shouldKeepVisible()) { + clearHoverCloseTimer(); + return; + } + + if (!hasDocumentMouseMove.current) { + clearHoverCloseTimer(); + closePopup(); + } }); + + hoverCloseTimer.current = setTimeout(() => { + hoverCloseTimer.current = null; + if (hoverCloseRaf.current !== null) { + cancelAnimationFrame(hoverCloseRaf.current); + hoverCloseRaf.current = null; + } + if (canUseDocument) { + off(document, 'mousemove', handleDocumentMouseMove); + } + + if (shouldKeepVisible()) return; + closePopup(); + }, HOVER_CLOSE_DELAY); }; const handlePopupMouseDown = () => { @@ -91,7 +205,13 @@ export default function useTrigger({ }); }; - useEffect(() => clearTimeout(visibleTimer.current), []); + useEffect( + () => () => { + clearTimeout(visibleTimer.current); + clearHoverCloseTimer(); + }, + [clearHoverCloseTimer], + ); useEffect(() => { if (!shouldToggle) return; @@ -114,7 +234,11 @@ export default function useTrigger({ if (trigger === 'mousedown') { callFuncWithDelay({ delay: visible ? appearDelay : exitDelay, - callback: () => onVisibleChange(!visible, { e, trigger: 'trigger-element-mousedown' }), + callback: () => + onVisibleChange(!visible, { + e, + trigger: 'trigger-element-mousedown', + }), }); } }; From 43ce633194d430805460d7ba5aeb35449986e49b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=E8=8F=9C=20Cai?= Date: Wed, 24 Jun 2026 03:36:27 +0800 Subject: [PATCH 2/2] test(tooltip): update mouseLeave event handling and introduce mockDelay for improved timing --- packages/components/tooltip/__tests__/tooltip.test.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/components/tooltip/__tests__/tooltip.test.tsx b/packages/components/tooltip/__tests__/tooltip.test.tsx index 90eb27c6e4..e0c2b6934e 100644 --- a/packages/components/tooltip/__tests__/tooltip.test.tsx +++ b/packages/components/tooltip/__tests__/tooltip.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { fireEvent, mockTimeout, render, screen, waitFor } from '@test/utils'; +import { fireEvent, mockDelay, mockTimeout, render, screen, waitFor } from '@test/utils'; import Tooltip from '../Tooltip'; @@ -36,7 +36,8 @@ describe('Tooltip 组件测试', () => { }); // 模拟鼠标离开 - await fireEvent.mouseLeave(getByText(triggerElement)); + fireEvent.mouseLeave(getByText(triggerElement)); + await mockDelay(150); // 鼠标离开,style 的 display 应该为 none const popupElement2 = queryByTestId(tooltipTestId);