From b211cd1c6897d40433668bc06f090c0b5036dbe2 Mon Sep 17 00:00:00 2001 From: tyler ford Date: Thu, 11 Jun 2026 14:38:13 -0400 Subject: [PATCH 1/2] fix(tooltip): reliable close after hover --- .../components/tooltip/src/tooltip.spec.js | 18 +++-- packages/components/tooltip/src/tooltip.tsx | 68 +++++++++++++++---- 2 files changed, 63 insertions(+), 23 deletions(-) diff --git a/packages/components/tooltip/src/tooltip.spec.js b/packages/components/tooltip/src/tooltip.spec.js index 72abd2bfc1..ed539a6859 100644 --- a/packages/components/tooltip/src/tooltip.spec.js +++ b/packages/components/tooltip/src/tooltip.spec.js @@ -70,22 +70,20 @@ const closeAndValidateTooltip = async ({ // Move away from the trigger element fireEvent[options.eventType](options.triggerElement); - // We need to wait for the tooltip to be removed - // after the 'closeAfter' delay - await waitForTimeout(options.closeAfter); - - // We need to fake trigger the animation we use to hide the tooltip - fireEvent.animationEnd(screen.getByTestId('tooltip-message-wrapper')); + // Wait for the tooltip to fully close (closeAfter delay + animation fallback). + // In jsdom there is no real popper instance, so the exiting effect calls + // handleClose directly without waiting for animationend. + await waitFor(() => { + expect( + screen.queryByText('What kind of bear is best?') + ).not.toBeInTheDocument(); + }); // should call the exit callbacks options.exitCallbacks.forEach((callback) => { expect(callback).toHaveBeenCalled(); }); - // should hide tooltip - expect( - screen.queryByText('What kind of bear is best?') - ).not.toBeInTheDocument(); // should add the title again expect(options.triggerElement).toHaveProperty( 'title', diff --git a/packages/components/tooltip/src/tooltip.tsx b/packages/components/tooltip/src/tooltip.tsx index 394ccb61c4..752194cd46 100644 --- a/packages/components/tooltip/src/tooltip.tsx +++ b/packages/components/tooltip/src/tooltip.tsx @@ -255,25 +255,13 @@ const Tooltip = ({ if (closeAfter && state === 'opened') { leaveTimer.current = setTimeout(() => { - const tooltipElement = popperInstance?.popper.querySelector( - '[data-testid="tooltip-message-wrapper"]' - ) as HTMLElement; - - if (tooltipElement) { - tooltipElement.addEventListener('animationend', () => - handleClose() - ); - } else { - handleClose(); - } - setState('exiting'); }, closeAfter); } else { handleClose(event); } }, - [closeAfter, onBlur, onMouseLeave, handleClose, state, popperInstance] + [closeAfter, onBlur, onMouseLeave, handleClose, state] ); useEffect(() => { @@ -290,6 +278,60 @@ const Tooltip = ({ } }, [off, closeAfter, handleClose, state]); + // When entering 'exiting' state, drive the close via animationend on the + // wrapper itself (scoped to event.target to ignore bubbled descendant events) + // with a hard fallback so a missed/blocked animationend never wedges the tooltip. + useEffect(() => { + if (state !== 'exiting') return; + + const tooltipElement = popperInstance?.popper.querySelector( + '[data-testid="tooltip-message-wrapper"]' + ) as HTMLElement | null; + + if (!tooltipElement) { + handleClose(); + return; + } + + let closed = false; + const close = () => { + if (closed) return; + closed = true; + handleClose(); + }; + + const onAnimEnd = (event: AnimationEvent) => { + if (event.target === tooltipElement) close(); + }; + + tooltipElement.addEventListener('animationend', onAnimEnd); + // growOut runs for 80 ms; 100 ms gives a small buffer before forcing close + const fallback = setTimeout(close, 100); + + return () => { + tooltipElement.removeEventListener('animationend', onAnimEnd); + clearTimeout(fallback); + }; + }, [state, popperInstance, handleClose]); + + // Native mouseleave fallback: if React's synthetic onMouseLeave is never + // delivered (React 19 event-delegation timing), the tooltip would stay in + // 'opened' indefinitely. A direct DOM listener on the wrapper catches the + // same browser event that React missed and triggers the normal close path. + useEffect(() => { + if (state !== 'opened' || off || isControlled) return; + + const wrapperEl = reference.ref.current as HTMLElement | null; + if (!wrapperEl) return; + + const onNativeLeave = (event: MouseEvent) => { + handleLeave(event as unknown as ChangeEvent); + }; + + wrapperEl.addEventListener('mouseleave', onNativeLeave); + return () => wrapperEl.removeEventListener('mouseleave', onNativeLeave); + }, [state, off, isControlled, reference.ref, handleLeave]); + const childrenProps = { // don't pass event listeners to children onFocus: null, From 01f9bcd7ac3e5e99d8c2bd82d0be0f64a92c98f9 Mon Sep 17 00:00:00 2001 From: tyler ford Date: Thu, 11 Jun 2026 15:25:09 -0400 Subject: [PATCH 2/2] fix(tooltip): changeset --- .changeset/silver-swans-brush.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/silver-swans-brush.md diff --git a/.changeset/silver-swans-brush.md b/.changeset/silver-swans-brush.md new file mode 100644 index 0000000000..f582d423d0 --- /dev/null +++ b/.changeset/silver-swans-brush.md @@ -0,0 +1,5 @@ +--- +'@commercetools-uikit/tooltip': patch +--- + +Allows hover close events to close Tooltip.