Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/silver-swans-brush.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@commercetools-uikit/tooltip': patch
---

Allows hover close events to close Tooltip.
18 changes: 8 additions & 10 deletions packages/components/tooltip/src/tooltip.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
68 changes: 55 additions & 13 deletions packages/components/tooltip/src/tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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,
Expand Down
Loading