Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 110 additions & 1 deletion packages/components/popup/__tests__/popup.test.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -9,6 +9,34 @@ describe('Popup 组件测试', () => {
const popupTestId = 'popup-test-id';
const triggerElement = '触发元素';

const waitForHoverClose = async () => {
await act(async () => {
await new Promise<void>((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(
<Popup placement="top" content={<div data-testid={popupTestId}>{popupText}</div>}>
Expand Down Expand Up @@ -36,13 +64,94 @@ describe('Popup 组件测试', () => {
act(() => {
fireEvent.mouseLeave(getByText(triggerElement));
});
await waitForHoverClose();

// 鼠标离开,style 的 display 应该为 none
const popupElement3 = await waitFor(() => queryByTestId(popupTestId));
expect(popupElement3).not.toBeNull();
expect(popupElement3.parentNode.parentNode).toHaveClass('t-popup--animation-leave-active');
});

test('hover 快速从触发器移入浮层时不关闭', async () => {
const { getByText, queryByTestId } = render(
<Popup placement="top" content={<div data-testid={popupTestId}>{popupText}</div>}>
{triggerElement}
</Popup>,
);

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(
<Popup placement="top" content={<div data-testid={popupTestId}>{popupText}</div>}>
{triggerElement}
</Popup>,
);

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(
<Popup trigger="click" placement="top" content={<div data-testid={popupTestId}>{popupText}</div>}>
Expand Down
144 changes: 134 additions & 10 deletions packages/components/popup/hooks/useTrigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,8 +29,15 @@ export default function useTrigger({
const triggerElementIsString = typeof triggerElement === 'string';

const triggerRef = useRef<HTMLElement>(null);
const popupElementRef = useRef<HTMLElement>(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(() => {
Expand All @@ -52,15 +60,73 @@ 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);
const element = getRefDom(triggerRef);
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' }),
Expand All @@ -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 = () => {
Expand All @@ -91,7 +205,13 @@ export default function useTrigger({
});
};

useEffect(() => clearTimeout(visibleTimer.current), []);
useEffect(
() => () => {
clearTimeout(visibleTimer.current);
clearHoverCloseTimer();
},
[clearHoverCloseTimer],
);

useEffect(() => {
if (!shouldToggle) return;
Expand All @@ -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',
}),
});
}
};
Expand Down
5 changes: 3 additions & 2 deletions packages/components/tooltip/__tests__/tooltip.test.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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);
Expand Down
Loading