From 3ba3fcbd5923e34f9c80f0222d56a0e31b090f4c Mon Sep 17 00:00:00 2001 From: iu Date: Thu, 3 Sep 2026 13:27:34 +0000 Subject: [PATCH] fix: cancel tooltip close on hover re-entry --- src/@next/Tooltip/Tooltip.test.tsx | 69 ++++++++++++++++++++++++++++++ src/@next/Tooltip/Tooltip.tsx | 21 +++++++-- 2 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 src/@next/Tooltip/Tooltip.test.tsx diff --git a/src/@next/Tooltip/Tooltip.test.tsx b/src/@next/Tooltip/Tooltip.test.tsx new file mode 100644 index 000000000..9379214ae --- /dev/null +++ b/src/@next/Tooltip/Tooltip.test.tsx @@ -0,0 +1,69 @@ +import * as React from 'react'; +import '@testing-library/jest-dom/extend-expect'; +import { act, fireEvent, render } from '@testing-library/react'; + +import { Tooltip } from './Tooltip'; + +beforeAll(() => { + Object.defineProperty(global, 'DOMRect', { + configurable: true, + value: class { + bottom: number; + height: number; + left: number; + right: number; + top: number; + width: number; + x: number; + y: number; + + constructor(x = 0, y = 0, width = 0, height = 0) { + this.bottom = y + height; + this.height = height; + this.left = x; + this.right = x + width; + this.top = y; + this.width = width; + this.x = x; + this.y = y; + } + }, + }); +}); + +beforeEach(() => { + jest.useFakeTimers(); + const portalContainer = document.createElement('div'); + portalContainer.id = 'glints-portal-container'; + document.body.appendChild(portalContainer); + jest + .spyOn(Element.prototype, 'getClientRects') + .mockReturnValue([new DOMRect(0, 0, 20, 20)] as unknown as DOMRectList); +}); + +afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + document.getElementById('glints-portal-container')?.remove(); +}); + +describe('', () => { + test('stays open after a rapid leave and re-entry', () => { + const { getByText, queryByText } = render( + + mock content + + ); + const trigger = getByText('mock content'); + + fireEvent.mouseEnter(trigger); + fireEvent.mouseLeave(trigger); + fireEvent.mouseEnter(trigger); + + act(() => { + jest.advanceTimersByTime(370); + }); + + expect(queryByText('mock text')).toBeInTheDocument(); + }); +}); diff --git a/src/@next/Tooltip/Tooltip.tsx b/src/@next/Tooltip/Tooltip.tsx index c63a1b989..8fa0b1987 100644 --- a/src/@next/Tooltip/Tooltip.tsx +++ b/src/@next/Tooltip/Tooltip.tsx @@ -254,8 +254,25 @@ export const Tooltip = ({ content ); + const timeoutRef = useRef(null); + const [animate, setAnimate] = useState(false); + + useEffect( + () => () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }, + [] + ); + const handleMouseEnter = () => { - if (!clickable) setIsActive(true); + if (!clickable) { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + setAnimate(false); + setIsActive(true); + } }; const handleMouseLeave = () => { if (!clickable) { @@ -270,8 +287,6 @@ export const Tooltip = ({ } }; - const timeoutRef = useRef(null); - const [animate, setAnimate] = useState(false); const handleAnimation = () => { // if you click during the tooltip's lifespan, it should reset the timeout if (timeoutRef.current) {