Skip to content

Commit bd4ccb7

Browse files
FEC-1010: close reliably when mouseleave is missed or animationend never fires (#3256)
* fix(tooltip): reliable close after hover * fix(tooltip): changeset
1 parent 5d5e5d9 commit bd4ccb7

3 files changed

Lines changed: 68 additions & 23 deletions

File tree

.changeset/silver-swans-brush.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@commercetools-uikit/tooltip': patch
3+
---
4+
5+
Allows hover close events to close Tooltip.

packages/components/tooltip/src/tooltip.spec.js

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -70,22 +70,20 @@ const closeAndValidateTooltip = async ({
7070
// Move away from the trigger element
7171
fireEvent[options.eventType](options.triggerElement);
7272

73-
// We need to wait for the tooltip to be removed
74-
// after the 'closeAfter' delay
75-
await waitForTimeout(options.closeAfter);
76-
77-
// We need to fake trigger the animation we use to hide the tooltip
78-
fireEvent.animationEnd(screen.getByTestId('tooltip-message-wrapper'));
73+
// Wait for the tooltip to fully close (closeAfter delay + animation fallback).
74+
// In jsdom there is no real popper instance, so the exiting effect calls
75+
// handleClose directly without waiting for animationend.
76+
await waitFor(() => {
77+
expect(
78+
screen.queryByText('What kind of bear is best?')
79+
).not.toBeInTheDocument();
80+
});
7981

8082
// should call the exit callbacks
8183
options.exitCallbacks.forEach((callback) => {
8284
expect(callback).toHaveBeenCalled();
8385
});
8486

85-
// should hide tooltip
86-
expect(
87-
screen.queryByText('What kind of bear is best?')
88-
).not.toBeInTheDocument();
8987
// should add the title again
9088
expect(options.triggerElement).toHaveProperty(
9189
'title',

packages/components/tooltip/src/tooltip.tsx

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -255,25 +255,13 @@ const Tooltip = ({
255255

256256
if (closeAfter && state === 'opened') {
257257
leaveTimer.current = setTimeout(() => {
258-
const tooltipElement = popperInstance?.popper.querySelector(
259-
'[data-testid="tooltip-message-wrapper"]'
260-
) as HTMLElement;
261-
262-
if (tooltipElement) {
263-
tooltipElement.addEventListener('animationend', () =>
264-
handleClose()
265-
);
266-
} else {
267-
handleClose();
268-
}
269-
270258
setState('exiting');
271259
}, closeAfter);
272260
} else {
273261
handleClose(event);
274262
}
275263
},
276-
[closeAfter, onBlur, onMouseLeave, handleClose, state, popperInstance]
264+
[closeAfter, onBlur, onMouseLeave, handleClose, state]
277265
);
278266

279267
useEffect(() => {
@@ -290,6 +278,60 @@ const Tooltip = ({
290278
}
291279
}, [off, closeAfter, handleClose, state]);
292280

281+
// When entering 'exiting' state, drive the close via animationend on the
282+
// wrapper itself (scoped to event.target to ignore bubbled descendant events)
283+
// with a hard fallback so a missed/blocked animationend never wedges the tooltip.
284+
useEffect(() => {
285+
if (state !== 'exiting') return;
286+
287+
const tooltipElement = popperInstance?.popper.querySelector(
288+
'[data-testid="tooltip-message-wrapper"]'
289+
) as HTMLElement | null;
290+
291+
if (!tooltipElement) {
292+
handleClose();
293+
return;
294+
}
295+
296+
let closed = false;
297+
const close = () => {
298+
if (closed) return;
299+
closed = true;
300+
handleClose();
301+
};
302+
303+
const onAnimEnd = (event: AnimationEvent) => {
304+
if (event.target === tooltipElement) close();
305+
};
306+
307+
tooltipElement.addEventListener('animationend', onAnimEnd);
308+
// growOut runs for 80 ms; 100 ms gives a small buffer before forcing close
309+
const fallback = setTimeout(close, 100);
310+
311+
return () => {
312+
tooltipElement.removeEventListener('animationend', onAnimEnd);
313+
clearTimeout(fallback);
314+
};
315+
}, [state, popperInstance, handleClose]);
316+
317+
// Native mouseleave fallback: if React's synthetic onMouseLeave is never
318+
// delivered (React 19 event-delegation timing), the tooltip would stay in
319+
// 'opened' indefinitely. A direct DOM listener on the wrapper catches the
320+
// same browser event that React missed and triggers the normal close path.
321+
useEffect(() => {
322+
if (state !== 'opened' || off || isControlled) return;
323+
324+
const wrapperEl = reference.ref.current as HTMLElement | null;
325+
if (!wrapperEl) return;
326+
327+
const onNativeLeave = (event: MouseEvent) => {
328+
handleLeave(event as unknown as ChangeEvent);
329+
};
330+
331+
wrapperEl.addEventListener('mouseleave', onNativeLeave);
332+
return () => wrapperEl.removeEventListener('mouseleave', onNativeLeave);
333+
}, [state, off, isControlled, reference.ref, handleLeave]);
334+
293335
const childrenProps = {
294336
// don't pass event listeners to children
295337
onFocus: null,

0 commit comments

Comments
 (0)