Skip to content

Commit 958d1a0

Browse files
fix(tooltip): update refs (#3258)
1 parent 71bf42a commit 958d1a0

2 files changed

Lines changed: 50 additions & 4 deletions

File tree

.changeset/fiery-times-read.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+
Fixes broken refs in the Tooltip component.

packages/components/tooltip/src/tooltip.tsx

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,11 @@ const Tooltip = ({
257257
leaveTimer.current = setTimeout(() => {
258258
setState('exiting');
259259
}, closeAfter);
260-
} else {
260+
} else if (state !== 'exiting') {
261+
// Don't interrupt growOut if the animation is already in progress.
262+
// onPopperLeave and a delayed synthetic mouseleave can both reach here
263+
// while exiting; calling handleClose() would abort the animation and
264+
// risk a stray mouseover re-opening the tooltip immediately after.
261265
handleClose(event);
262266
}
263267
},
@@ -318,19 +322,56 @@ const Tooltip = ({
318322
// delivered (React 19 event-delegation timing), the tooltip would stay in
319323
// 'opened' indefinitely. A direct DOM listener on the wrapper catches the
320324
// same browser event that React missed and triggers the normal close path.
325+
//
326+
// When the tooltip body is rendered in a portal (outside the React root),
327+
// moving the mouse from the wrapper onto the portal fires mouseleave on the
328+
// wrapper. We must not start the close timer in that case — otherwise the
329+
// tooltip closes, the portal disappears, the browser re-fires mouseenter on
330+
// the wrapper, and the tooltip reopens in a continuous flicker loop.
331+
//
332+
// use-popper uses callback refs (functions, not {current} objects), so
333+
// popper.ref.current / reference.ref.current are always undefined at runtime.
334+
// The DOM nodes are accessible via the Popper.js instance instead.
321335
useEffect(() => {
322336
if (state !== 'opened' || off || isControlled) return;
323337

324-
const wrapperEl = reference.ref.current as HTMLElement | null;
338+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
339+
const inst = popperInstance as any;
340+
const wrapperEl = inst?.reference as HTMLElement | null;
325341
if (!wrapperEl) return;
342+
const popperEl = inst?.popper as HTMLElement | null;
326343

327344
const onNativeLeave = (event: MouseEvent) => {
345+
// If the mouse moved into the popper/tooltip body, suppress the close so
346+
// the tooltip stays open while the user hovers over the preview.
347+
if (
348+
popperEl &&
349+
event.relatedTarget instanceof Node &&
350+
popperEl.contains(event.relatedTarget)
351+
) {
352+
return;
353+
}
354+
handleLeave(event as unknown as ChangeEvent);
355+
};
356+
357+
// Mirror: close when the mouse leaves the tooltip body itself.
358+
const onPopperLeave = (event: MouseEvent) => {
359+
if (
360+
wrapperEl.contains(event.relatedTarget as Node | null) ||
361+
popperEl?.contains(event.relatedTarget as Node | null)
362+
) {
363+
return;
364+
}
328365
handleLeave(event as unknown as ChangeEvent);
329366
};
330367

331368
wrapperEl.addEventListener('mouseleave', onNativeLeave);
332-
return () => wrapperEl.removeEventListener('mouseleave', onNativeLeave);
333-
}, [state, off, isControlled, reference.ref, handleLeave]);
369+
popperEl?.addEventListener('mouseleave', onPopperLeave);
370+
return () => {
371+
wrapperEl.removeEventListener('mouseleave', onNativeLeave);
372+
popperEl?.removeEventListener('mouseleave', onPopperLeave);
373+
};
374+
}, [state, off, isControlled, popperInstance, handleLeave]);
334375

335376
const childrenProps = {
336377
// don't pass event listeners to children

0 commit comments

Comments
 (0)