Skip to content

Commit f5e051b

Browse files
Revert "Revert "[EuiToolTip] Improve tooltip group transition" (#9748)"
This reverts commit 7e4e562.
1 parent 7e4e562 commit f5e051b

8 files changed

Lines changed: 210 additions & 29 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
- Updated `EuiToolTip` to skip the entry animation when hovering between adjacent triggers in quick succession, so a group of tooltips behaves like a single continuous tooltip following the cursor
2+
3+
**Bug fixes**
4+
5+
- Fixed `EuiToolTip` rare flicker when the cursor moved between inner children of the trigger

packages/eui/src/components/selectable/selectable_list/selectable_list_item.test.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { requiredProps } from '../../../test/required_props';
1313

1414
import { EuiSelectableListItem } from './selectable_list_item';
1515
import { fireEvent } from '@testing-library/react';
16+
import { toolTipManager } from '../../tool_tip/tool_tip_manager';
1617

1718
describe('EuiSelectableListItem', () => {
1819
shouldRenderCustomStyles(<EuiSelectableListItem />);
@@ -24,6 +25,14 @@ describe('EuiSelectableListItem', () => {
2425
});
2526

2627
describe('props', () => {
28+
// The tooltip manager is a module-level singleton that remembers when the
29+
// last tooltip closed (to skip the entry animation for grouped tooltips).
30+
// Reset it between tests so a tooltip shown by one test doesn't make a
31+
// following test's tooltip render with `animation: none`.
32+
beforeEach(() => {
33+
toolTipManager.reset();
34+
});
35+
2736
describe('checked', () => {
2837
const checkedValues = ['on', 'mixed', undefined] as const;
2938

packages/eui/src/components/tool_tip/tool_tip.stories.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { enableFunctionToggleControls } from '../../../.storybook/utils';
1313
import { VRT_SELECTORS } from '../../../.storybook/vrt';
1414
import { EuiButton } from '../button';
1515
import { EuiFlexGroup } from '../flex';
16+
import { useEuiTheme } from '../../services';
17+
1618
import {
1719
EuiToolTip,
1820
EuiToolTipProps,
@@ -65,6 +67,31 @@ export const Playground: Story = {
6567
},
6668
};
6769

70+
const TooltipGroupStory = () => {
71+
const { euiTheme } = useEuiTheme();
72+
73+
return (
74+
<EuiFlexGroup
75+
gutterSize="s"
76+
css={{
77+
alignSelf: 'flex-start',
78+
padding: euiTheme.size.m,
79+
inlineSize: '100%',
80+
}}
81+
>
82+
{['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo'].map((label) => (
83+
<EuiToolTip key={label} content={`Tooltip for ${label}`}>
84+
<EuiButton>{label}</EuiButton>
85+
</EuiToolTip>
86+
))}
87+
</EuiFlexGroup>
88+
);
89+
};
90+
91+
export const TooltipGroup: Story = {
92+
render: () => <TooltipGroupStory />,
93+
};
94+
6895
/**
6996
* VRT only stories
7097
*/

packages/eui/src/components/tool_tip/tool_tip.test.tsx

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,13 @@ import { shouldRenderCustomStyles } from '../../test/internal';
1919

2020
import { EuiToolTip } from './tool_tip';
2121
import type { EuiToolTipRef } from './tool_tip';
22+
import { toolTipManager } from './tool_tip_manager';
2223

2324
describe('EuiToolTip', () => {
25+
afterEach(() => {
26+
toolTipManager.reset();
27+
});
28+
2429
shouldRenderCustomStyles(
2530
<EuiToolTip content="test">
2631
<button data-test-subj="trigger" />
@@ -175,6 +180,29 @@ describe('EuiToolTip', () => {
175180
expect(getByRole('tooltip')).toHaveTextContent('Tooltip title');
176181
});
177182

183+
it('skips the entry animation when a tooltip opens right after another closes', () => {
184+
const { getByTestSubject, getByRole } = render(
185+
<>
186+
<EuiToolTip content="First">
187+
<button data-test-subj="first">First</button>
188+
</EuiToolTip>
189+
<EuiToolTip content="Second">
190+
<button data-test-subj="second">Second</button>
191+
</EuiToolTip>
192+
</>
193+
);
194+
195+
// First tooltip shows with entry animation
196+
fireEvent.mouseEnter(getByTestSubject('first'));
197+
expect(getByRole('tooltip').style.animation).toBe('');
198+
199+
fireEvent.mouseLeave(getByTestSubject('first'));
200+
201+
// Second tooltip should have inline `animation: none` to suppress entry animation
202+
fireEvent.mouseEnter(getByTestSubject('second'));
203+
expect(getByRole('tooltip')).toHaveStyle({ animation: 'none' });
204+
});
205+
178206
it('does not block clicks on the trigger while the tooltip is visible', () => {
179207
const onClick = jest.fn();
180208
const { getByTestSubject, getByRole } = render(
@@ -185,7 +213,7 @@ describe('EuiToolTip', () => {
185213
</EuiToolTip>
186214
);
187215

188-
fireEvent.mouseOver(getByTestSubject('trigger'));
216+
fireEvent.mouseEnter(getByTestSubject('trigger'));
189217
expect(getByRole('tooltip')).toBeInTheDocument();
190218

191219
fireEvent.click(getByTestSubject('trigger'));

packages/eui/src/components/tool_tip/tool_tip.tsx

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ interface ToolTipStyles {
5858
right?: number | 'auto';
5959
opacity?: number;
6060
visibility?: 'hidden';
61+
animation?: 'none';
6162
}
6263

6364
const DEFAULT_TOOLTIP_STYLES: ToolTipStyles = {
@@ -173,6 +174,7 @@ export const EuiToolTip = forwardRef<EuiToolTipRef, EuiToolTipProps>(
173174
const componentDefaultsContext = useContext(EuiComponentDefaultsContext);
174175

175176
const [visible, setVisible] = useState(false);
177+
const [skipAnimation, setSkipAnimation] = useState(false);
176178
const [hasFocus, setHasFocus] = useState(false);
177179
const [calculatedPosition, setCalculatedPosition] =
178180
useState<ToolTipPositions>(positionProp);
@@ -247,10 +249,20 @@ export const EuiToolTip = forwardRef<EuiToolTipRef, EuiToolTipProps>(
247249
toolTipManager.deregisterToolTip(hideToolTip);
248250
}, []);
249251

252+
/**
253+
* Show the tooltip.
254+
*
255+
* Uses the tooltip manager's `skipAnimation` signal to optionally skip the entry
256+
* animation when another tooltip is already open or was just closed.
257+
*/
250258
const showToolTip = useCallback(() => {
251259
if (!content && !title) return;
260+
261+
const result = toolTipManager.registerTooltip(hideToolTip);
262+
if (!result) return;
263+
264+
setSkipAnimation(result.skipAnimation);
252265
setVisible(true);
253-
toolTipManager.registerTooltip(hideToolTip);
254266
}, [content, title, hideToolTip]);
255267

256268
useImperativeHandle(ref, () => ({ showToolTip, hideToolTip, id }), [
@@ -351,25 +363,26 @@ export const EuiToolTip = forwardRef<EuiToolTipRef, EuiToolTipProps>(
351363
[disableScreenReaderOutput, visible, hideToolTip]
352364
);
353365

354-
const onMouseOut = useCallback(
366+
/**
367+
* Show the tooltip on enter.
368+
*/
369+
const onMouseEnter = useCallback(
355370
(event: ReactMouseEvent<HTMLSpanElement, MouseEvent>) => {
356-
// Prevent mousing over children from hiding the tooltip by testing for whether the mouse has
357-
// left the anchor for a non-child.
358-
if (
359-
anchorRef.current === event.relatedTarget ||
360-
(anchorRef.current != null &&
361-
!anchorRef.current.contains(event.relatedTarget as Node))
362-
) {
363-
if (!hasFocus) {
364-
hideToolTip();
365-
}
366-
}
371+
showToolTip();
372+
anchorProps?.onMouseEnter?.(event);
373+
},
374+
[showToolTip, anchorProps?.onMouseEnter]
375+
);
367376

368-
if (onMouseOutProp) {
369-
onMouseOutProp(event);
370-
}
377+
/**
378+
* Hide the tooltip if the mouse is not over the trigger.
379+
*/
380+
const onMouseLeave = useCallback(
381+
(event: ReactMouseEvent<HTMLSpanElement, MouseEvent>) => {
382+
if (!hasFocus) hideToolTip();
383+
anchorProps?.onMouseLeave?.(event);
371384
},
372-
[hasFocus, hideToolTip, onMouseOutProp]
385+
[hasFocus, hideToolTip, anchorProps?.onMouseLeave]
373386
);
374387

375388
const classes = classNames('euiToolTip', className);
@@ -383,8 +396,9 @@ export const EuiToolTip = forwardRef<EuiToolTipRef, EuiToolTipProps>(
383396
onBlur={onBlur}
384397
onFocus={onFocus}
385398
onKeyDown={onEscapeKey}
386-
onMouseOver={showToolTip}
387-
onMouseOut={onMouseOut}
399+
onMouseEnter={onMouseEnter}
400+
onMouseLeave={onMouseLeave}
401+
onMouseOut={onMouseOutProp}
388402
// `id` defines if the trigger and tooltip are automatically linked via `aria-describedby`.
389403
id={!disableScreenReaderOutput ? id : undefined}
390404
className={anchorClasses}
@@ -397,7 +411,13 @@ export const EuiToolTip = forwardRef<EuiToolTipRef, EuiToolTipProps>(
397411
<EuiPortal>
398412
<EuiToolTipPopover
399413
className={classes}
400-
style={toolTipStyles}
414+
style={{
415+
...toolTipStyles,
416+
// Inline `animation: none` overrides the keyframes fade-in
417+
// shorthand on the base `.euiToolTip` class, so a tooltip
418+
// shown right after another closes appears instantly.
419+
animation: skipAnimation ? 'none' : undefined,
420+
}}
401421
positionToolTip={positionToolTip}
402422
popoverRef={setPopoverRef}
403423
title={title}

packages/eui/src/components/tool_tip/tool_tip_anchor.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ export const EuiToolTipAnchor = forwardRef<
3636
{
3737
onBlur,
3838
onFocus,
39-
onMouseOver,
40-
onMouseOut,
39+
onMouseEnter,
40+
onMouseLeave,
4141
id,
4242
className,
4343
children,
@@ -67,8 +67,8 @@ export const EuiToolTipAnchor = forwardRef<
6767
id={anchorId}
6868
{...rest}
6969
className={classes}
70-
onMouseOver={onMouseOver}
71-
onMouseOut={onMouseOut}
70+
onMouseEnter={onMouseEnter}
71+
onMouseLeave={onMouseLeave}
7272
>
7373
{/**
7474
* Re: jsx-a11y/mouse-events-have-key-events

packages/eui/src/components/tool_tip/tool_tip_manager.test.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { toolTipManager } from './tool_tip_manager';
1111
describe('ToolTipManager', () => {
1212
afterEach(() => {
1313
// Reset the singleton between tests to prevent cross-test contamination
14-
toolTipManager.toolTipsToHide.clear();
14+
toolTipManager.reset();
1515
});
1616

1717
describe('registerTooltip', () => {
@@ -44,6 +44,66 @@ describe('ToolTipManager', () => {
4444
});
4545
});
4646

47+
describe('registerTooltip skipAnimation signal', () => {
48+
// The skip-animation decision is time-based, so control `Date.now()`.
49+
let nowSpy: jest.SpyInstance;
50+
51+
beforeEach(() => {
52+
nowSpy = jest.spyOn(Date, 'now').mockReturnValue(1000);
53+
});
54+
55+
afterEach(() => {
56+
nowSpy.mockRestore();
57+
});
58+
59+
it('returns skipAnimation: false for the first tooltip', () => {
60+
const hide = jest.fn();
61+
62+
expect(toolTipManager.registerTooltip(hide)).toEqual({
63+
skipAnimation: false,
64+
});
65+
});
66+
67+
it('returns skipAnimation: true when another tooltip is already open', () => {
68+
toolTipManager.registerTooltip(jest.fn());
69+
70+
expect(toolTipManager.registerTooltip(jest.fn())).toEqual({
71+
skipAnimation: true,
72+
});
73+
});
74+
75+
it('returns skipAnimation: true when a tooltip closed within the window', () => {
76+
const hide = jest.fn();
77+
toolTipManager.registerTooltip(hide);
78+
toolTipManager.deregisterToolTip(hide); // lastHiddenAt = 1000
79+
80+
nowSpy.mockReturnValue(1000 + 299); // just inside the 300ms window
81+
82+
expect(toolTipManager.registerTooltip(jest.fn())).toEqual({
83+
skipAnimation: true,
84+
});
85+
});
86+
87+
it('returns skipAnimation: false when the last close was outside the window', () => {
88+
const hide = jest.fn();
89+
toolTipManager.registerTooltip(hide);
90+
toolTipManager.deregisterToolTip(hide); // lastHiddenAt = 1000
91+
92+
nowSpy.mockReturnValue(1000 + 300); // window has elapsed
93+
94+
expect(toolTipManager.registerTooltip(jest.fn())).toEqual({
95+
skipAnimation: false,
96+
});
97+
});
98+
99+
it('returns null when re-registering the same tooltip', () => {
100+
const hide = jest.fn();
101+
toolTipManager.registerTooltip(hide);
102+
103+
expect(toolTipManager.registerTooltip(hide)).toBeNull();
104+
});
105+
});
106+
47107
describe('deregisterToolTip', () => {
48108
it('prevents a deregistered callback from being called when a new tooltip registers', () => {
49109
// If the current tooltip is already hidden before the next tooltip is visible,

packages/eui/src/components/tool_tip/tool_tip_manager.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,25 +8,57 @@
88

99
/**
1010
* Manager utility that ensures only one tooltip is visible at a time
11+
* and lets the next tooltip to open skip its entry animation if another was
12+
* just opened or just closed.
1113
*
1214
* UX rationale (primarily for mouse-only users):
1315
* @see https://github.com/elastic/kibana/issues/144482
1416
* @see https://github.com/elastic/eui/issues/5883
1517
*/
18+
19+
/**
20+
* Time window in ms after a tooltip closes during which the next one to open
21+
* skips its entry animation.
22+
*/
23+
const SKIP_ANIMATION_WINDOW = 300;
24+
1625
class ToolTipManager {
1726
// We use a set instead of a single var just in case
1827
// multiple tooltips are registered via async shenanigans
1928
toolTipsToHide = new Set<Function>();
29+
// Timestamp of the last hide; `null` means no tooltip has closed yet.
30+
lastHiddenAt: number | null = null;
31+
32+
registerTooltip = (
33+
hideCallback: Function
34+
): { skipAnimation: boolean } | null => {
35+
if (this.toolTipsToHide.has(hideCallback)) return null;
36+
37+
const hadOpen = this.toolTipsToHide.size > 0;
38+
const recentlyClosed =
39+
this.lastHiddenAt !== null &&
40+
Date.now() - this.lastHiddenAt < SKIP_ANIMATION_WINDOW;
2041

21-
registerTooltip = (hideCallback: Function) => {
22-
if (this.toolTipsToHide.has(hideCallback)) return;
2342
this.toolTipsToHide.forEach((hide) => hide());
2443
this.toolTipsToHide.clear();
2544
this.toolTipsToHide.add(hideCallback);
45+
46+
return { skipAnimation: hadOpen || recentlyClosed };
2647
};
2748

2849
deregisterToolTip = (hideCallback: Function) => {
29-
this.toolTipsToHide.delete(hideCallback);
50+
if (this.toolTipsToHide.delete(hideCallback))
51+
this.lastHiddenAt = Date.now();
52+
};
53+
54+
/**
55+
* Resets all internal state. Primarily intended for tests, so that a
56+
* tooltip shown in one test doesn't leak into the next (the manager is a
57+
* module-level singleton).
58+
*/
59+
reset = () => {
60+
this.toolTipsToHide.clear();
61+
this.lastHiddenAt = null;
3062
};
3163
}
3264

0 commit comments

Comments
 (0)