diff --git a/packages/eui/.loki/reference/chrome_desktop_Display_EuiImage_Playground.png b/packages/eui/.loki/reference/chrome_desktop_Display_EuiImage_Playground.png
index fa78d8996175..f24c67633f97 100644
Binary files a/packages/eui/.loki/reference/chrome_desktop_Display_EuiImage_Playground.png and b/packages/eui/.loki/reference/chrome_desktop_Display_EuiImage_Playground.png differ
diff --git a/packages/eui/.loki/reference/chrome_desktop_Forms_EuiFilePicker_Controlled_With_Files.png b/packages/eui/.loki/reference/chrome_desktop_Forms_EuiFilePicker_Controlled_With_Files.png
new file mode 100644
index 000000000000..52a152ef56eb
Binary files /dev/null and b/packages/eui/.loki/reference/chrome_desktop_Forms_EuiFilePicker_Controlled_With_Files.png differ
diff --git a/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlButton_High_Contrast_Dark_Mode.png b/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlButton_High_Contrast_Dark_Mode.png
index c99d5eb9da5a..40d5af29a389 100644
Binary files a/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlButton_High_Contrast_Dark_Mode.png and b/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlButton_High_Contrast_Dark_Mode.png differ
diff --git a/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlButton_Kitchensink.png b/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlButton_Kitchensink.png
index b706d9a91a6d..a4653c7c96da 100644
Binary files a/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlButton_Kitchensink.png and b/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlButton_Kitchensink.png differ
diff --git a/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlLayoutDelimited_High_Contrast.png b/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlLayoutDelimited_High_Contrast.png
index e53a0b405654..f64933d70276 100644
Binary files a/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlLayoutDelimited_High_Contrast.png and b/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlLayoutDelimited_High_Contrast.png differ
diff --git a/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlLayoutDelimited_Kitchen_Sink.png b/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlLayoutDelimited_Kitchen_Sink.png
index 87c8443cfcb2..016041e088af 100644
Binary files a/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlLayoutDelimited_Kitchen_Sink.png and b/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlLayoutDelimited_Kitchen_Sink.png differ
diff --git a/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlLayout_High_Contrast_Dark_Mode.png b/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlLayout_High_Contrast_Dark_Mode.png
index ee0a30927a83..81e664e81d31 100644
Binary files a/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlLayout_High_Contrast_Dark_Mode.png and b/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlLayout_High_Contrast_Dark_Mode.png differ
diff --git a/packages/eui/.loki/reference/chrome_mobile_Display_EuiImage_Playground.png b/packages/eui/.loki/reference/chrome_mobile_Display_EuiImage_Playground.png
index d3a0f5f98762..46439be9ada0 100644
Binary files a/packages/eui/.loki/reference/chrome_mobile_Display_EuiImage_Playground.png and b/packages/eui/.loki/reference/chrome_mobile_Display_EuiImage_Playground.png differ
diff --git a/packages/eui/.loki/reference/chrome_mobile_Forms_EuiFilePicker_Controlled_With_Files.png b/packages/eui/.loki/reference/chrome_mobile_Forms_EuiFilePicker_Controlled_With_Files.png
new file mode 100644
index 000000000000..b4325f29f902
Binary files /dev/null and b/packages/eui/.loki/reference/chrome_mobile_Forms_EuiFilePicker_Controlled_With_Files.png differ
diff --git a/packages/eui/src/components/collapsible_nav_beta/collapsible_nav_item/collapsed/__snapshots__/collapsed_nav_button.test.tsx.snap b/packages/eui/src/components/collapsible_nav_beta/collapsible_nav_item/collapsed/__snapshots__/collapsed_nav_button.test.tsx.snap
index c37dbf7fdedb..f93593dd4f69 100644
--- a/packages/eui/src/components/collapsible_nav_beta/collapsible_nav_item/collapsed/__snapshots__/collapsed_nav_button.test.tsx.snap
+++ b/packages/eui/src/components/collapsible_nav_beta/collapsible_nav_item/collapsed/__snapshots__/collapsed_nav_button.test.tsx.snap
@@ -34,7 +34,6 @@ exports[`EuiCollapsedNavButton renders a tooltip around the icon button 1`] = `
class="euiToolTipPopover euiToolTip emotion-euiToolTip-left-euiCollapsedNavItemTooltip-left"
data-position="left"
id="generated-id"
- position="left"
role="tooltip"
style="top: -10px; left: -16px;"
>
diff --git a/packages/eui/src/components/context_menu/__snapshots__/context_menu_item.test.tsx.snap b/packages/eui/src/components/context_menu/__snapshots__/context_menu_item.test.tsx.snap
index a68d93a36b1b..7ab793031cd4 100644
--- a/packages/eui/src/components/context_menu/__snapshots__/context_menu_item.test.tsx.snap
+++ b/packages/eui/src/components/context_menu/__snapshots__/context_menu_item.test.tsx.snap
@@ -117,7 +117,6 @@ exports[`EuiContextMenuItem tooltip behavior 1`] = `
class="euiToolTipPopover euiToolTip emotion-euiToolTip-top"
data-position="top"
id="generated-id"
- position="top"
role="tooltip"
style="top: -16px; left: -10px;"
>
diff --git a/packages/eui/src/components/filter_group/filter_select_item.test.tsx b/packages/eui/src/components/filter_group/filter_select_item.test.tsx
index 62764f096087..b606cc20309e 100644
--- a/packages/eui/src/components/filter_group/filter_select_item.test.tsx
+++ b/packages/eui/src/components/filter_group/filter_select_item.test.tsx
@@ -7,7 +7,11 @@
*/
import React from 'react';
-import { render } from '../../test/rtl';
+import {
+ render,
+ waitForEuiToolTipVisible,
+ waitForEuiToolTipHidden,
+} from '../../test/rtl';
import { requiredProps } from '../../test';
import { shouldRenderCustomStyles } from '../../test/internal';
@@ -21,4 +25,57 @@ describe('EuiFilterSelectItem', () => {
expect(container.firstChild).toMatchSnapshot();
});
+
+ describe('tooltip behavior', () => {
+ const tooltipProps = {
+ toolTipContent: 'Filter item tooltip',
+ toolTipProps: { 'data-test-subj': 'filterItemToolTip' },
+ };
+
+ // `toggleToolTip` is called in `componentDidUpdate`; on initial mount `tooltipRef.current`
+ // is null because `EuiToolTip` hasn't committed its ref yet, so a re-render is required
+ // to trigger `showToolTip`/`hideToolTip`.
+ it('shows tooltip when `isFocused` becomes true', async () => {
+ const { rerender, getByTestSubject } = render(
+
+ Item
+
+ );
+
+ rerender(
+
+ Item
+
+ );
+
+ await waitForEuiToolTipVisible();
+ expect(getByTestSubject('filterItemToolTip')).toBeInTheDocument();
+ });
+
+ it('hides tooltip when `isFocused` becomes false', async () => {
+ const { rerender, queryByRole } = render(
+
+ Item
+
+ );
+
+ rerender(
+
+ Item
+
+ );
+
+ await waitForEuiToolTipVisible();
+ expect(queryByRole('tooltip')).toBeInTheDocument();
+
+ rerender(
+
+ Item
+
+ );
+
+ await waitForEuiToolTipHidden();
+ expect(queryByRole('tooltip')).not.toBeInTheDocument();
+ });
+ });
});
diff --git a/packages/eui/src/components/filter_group/filter_select_item.tsx b/packages/eui/src/components/filter_group/filter_select_item.tsx
index a93b10837290..11f5639fa186 100644
--- a/packages/eui/src/components/filter_group/filter_select_item.tsx
+++ b/packages/eui/src/components/filter_group/filter_select_item.tsx
@@ -14,6 +14,7 @@ import { CommonProps } from '../common';
import { EuiFlexGroup, EuiFlexItem } from '../flex';
import { EuiToolTip } from '../tool_tip';
+import type { EuiToolTipRef } from '../tool_tip';
import { EuiIcon } from '../icon';
import { EuiComboBoxOptionOption } from '../combo_box';
@@ -62,7 +63,7 @@ export class EuiFilterSelectItemClass extends Component<
};
buttonRef: HTMLButtonElement | null = null;
- tooltipRef = createRef();
+ tooltipRef = createRef();
state = {
hasFocus: false,
diff --git a/packages/eui/src/components/selectable/selectable_list/__snapshots__/selectable_list_item.test.tsx.snap b/packages/eui/src/components/selectable/selectable_list/__snapshots__/selectable_list_item.test.tsx.snap
index 68e2c598f113..7b0d7c5b20be 100644
--- a/packages/eui/src/components/selectable/selectable_list/__snapshots__/selectable_list_item.test.tsx.snap
+++ b/packages/eui/src/components/selectable/selectable_list/__snapshots__/selectable_list_item.test.tsx.snap
@@ -547,7 +547,6 @@ exports[`EuiSelectableListItem props tooltip behavior on mouseover 1`] = `
data-position="top"
data-test-subj="listItemToolTip"
id="generated-id"
- position="top"
role="tooltip"
style="top: -16px; left: -10px;"
>
@@ -628,7 +627,6 @@ exports[`EuiSelectableListItem props tooltip behavior when isFocused 1`] = `
data-position="top"
data-test-subj="listItemToolTip"
id="generated-id"
- position="top"
role="tooltip"
style="top: -16px; left: -10px;"
>
diff --git a/packages/eui/src/components/selectable/selectable_list/selectable_list_item.tsx b/packages/eui/src/components/selectable/selectable_list/selectable_list_item.tsx
index b2aa9e497ce1..77c21b2beb1e 100644
--- a/packages/eui/src/components/selectable/selectable_list/selectable_list_item.tsx
+++ b/packages/eui/src/components/selectable/selectable_list/selectable_list_item.tsx
@@ -23,6 +23,7 @@ import { EuiIcon, IconColor, IconType } from '../../icon';
import { EuiScreenReaderOnly } from '../../accessibility';
import { EuiBadge, EuiBadgeProps } from '../../badge';
import { EuiToolTip } from '../../tool_tip';
+import type { EuiToolTipRef } from '../../tool_tip';
import type {
EuiSelectableOption,
@@ -358,7 +359,7 @@ export const EuiSelectableListItem: FunctionComponent<
}, [role, checked]);
const hasToolTip = !!toolTipContent && !disabled;
- const [tooltipRef, setTooltipRef] = useState(null); // Needs to be state and not a ref to trigger useEffect
+ const [tooltipRef, setTooltipRef] = useState(null); // Needs to be state and not a ref to trigger useEffect
const [ariaDescribedBy, setAriaDescribedBy] = useState(_ariaDescribedBy);
// Manually trigger the tooltip on keyboard focus
@@ -375,7 +376,7 @@ export const EuiSelectableListItem: FunctionComponent<
// Manually set the `aria-describedby` id on the wrapper
useEffect(() => {
if (tooltipRef) {
- const tooltipId = tooltipRef.state.id;
+ const tooltipId = tooltipRef.id;
setAriaDescribedBy(classNames(tooltipId, _ariaDescribedBy));
}
}, [tooltipRef, _ariaDescribedBy]);
diff --git a/packages/eui/src/components/tool_tip/__snapshots__/icon_tip.test.tsx.snap b/packages/eui/src/components/tool_tip/__snapshots__/icon_tip.test.tsx.snap
index f7dfa157febb..2729fa5b0a2d 100644
--- a/packages/eui/src/components/tool_tip/__snapshots__/icon_tip.test.tsx.snap
+++ b/packages/eui/src/components/tool_tip/__snapshots__/icon_tip.test.tsx.snap
@@ -13,46 +13,3 @@ exports[`EuiIconTip is rendered 1`] = `
`;
-
-exports[`EuiIconTip props color is rendered as the icon color 1`] = `
-
-
- Info
-
-
-`;
-
-exports[`EuiIconTip props size is rendered as the icon size 1`] = `
-
-
- Info
-
-
-`;
-
-exports[`EuiIconTip props type is rendered as the icon 1`] = `
-
-
- Info
-
-
-`;
diff --git a/packages/eui/src/components/tool_tip/__snapshots__/tool_tip.test.tsx.snap b/packages/eui/src/components/tool_tip/__snapshots__/tool_tip.test.tsx.snap
index 3686e08ae943..db8eb066621c 100644
--- a/packages/eui/src/components/tool_tip/__snapshots__/tool_tip.test.tsx.snap
+++ b/packages/eui/src/components/tool_tip/__snapshots__/tool_tip.test.tsx.snap
@@ -1,36 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`EuiToolTip anchor props are rendered 1`] = `
-
-
-
-
-
-
-
-`;
-
-exports[`EuiToolTip display prop renders block 1`] = `
-
-
-
-
-
-`;
-
exports[`EuiToolTip is rendered 1`] = `
`;
-
-exports[`EuiToolTip shows tooltip on mouseover and focus 1`] = `
-
-
-
-
-
-
-
-
-`;
-
-exports[`EuiToolTip uses custom offset prop value 1`] = `
-
-
-
-
-
-
-
-
-`;
diff --git a/packages/eui/src/components/tool_tip/icon_tip.test.tsx b/packages/eui/src/components/tool_tip/icon_tip.test.tsx
index 9ef82589c2b5..86fe8e3d2745 100644
--- a/packages/eui/src/components/tool_tip/icon_tip.test.tsx
+++ b/packages/eui/src/components/tool_tip/icon_tip.test.tsx
@@ -26,7 +26,7 @@ describe('EuiIconTip', () => {
}
);
- test('is rendered', () => {
+ it('is rendered', () => {
const { container } = render(
);
@@ -35,34 +35,68 @@ describe('EuiIconTip', () => {
});
describe('props', () => {
- describe('type', () => {
- test('is rendered as the icon', () => {
- const { container } = render(
-
- );
-
- expect(container.firstChild).toMatchSnapshot();
- });
+ it('renders a different icon for each type', () => {
+ const { container: defaultContainer } = render(
+
+ );
+ const { container: warningContainer } = render(
+
+ );
+
+ expect(
+ defaultContainer.querySelector('[data-euiicon-type]')
+ ).toHaveAttribute('data-euiicon-type', 'question');
+ expect(
+ warningContainer.querySelector('[data-euiicon-type]')
+ ).toHaveAttribute('data-euiicon-type', 'warning');
});
- describe('color', () => {
- test('is rendered as the icon color', () => {
- const { container } = render(
-
- );
+ it('renders a different icon for each color', () => {
+ const { container: defaultContainer } = render(
+
+ );
+ const { container: colorContainer } = render(
+
+ );
+
+ expect(
+ defaultContainer.querySelector('[data-euiicon-type]')
+ ).not.toHaveAttribute('color');
+ expect(
+ colorContainer.querySelector('[data-euiicon-type]')
+ ).toHaveAttribute('color', 'warning');
+ });
+ });
+
+ describe('aria-label', () => {
+ // In the test environment EuiIcon renders aria-label as its text content
+ it('uses "Info" as the default aria-label', () => {
+ const { container } = render();
- expect(container.firstChild).toMatchSnapshot();
- });
+ expect(container.querySelector('[data-euiicon-type]')).toHaveTextContent(
+ 'Info'
+ );
});
- describe('size', () => {
- test('is rendered as the icon size', () => {
- const { container } = render(
-
- );
+ it('uses a custom aria-label when provided', () => {
+ const { container } = render(
+
+ );
- expect(container.firstChild).toMatchSnapshot();
- });
+ expect(container.querySelector('[data-euiicon-type]')).toHaveTextContent(
+ 'More information'
+ );
});
});
+
+ it('shows tooltip content on hover', async () => {
+ const { container, getByRole } = render(
+
+ );
+
+ fireEvent.mouseOver(container.querySelector('[data-euiicon-type]')!);
+ await waitForEuiToolTipVisible();
+
+ expect(getByRole('tooltip')).toHaveTextContent('Tooltip content');
+ });
});
diff --git a/packages/eui/src/components/tool_tip/index.ts b/packages/eui/src/components/tool_tip/index.ts
index f5e6d71619da..9a0c9b269ee2 100644
--- a/packages/eui/src/components/tool_tip/index.ts
+++ b/packages/eui/src/components/tool_tip/index.ts
@@ -7,7 +7,7 @@
*/
export type { ToolTipPositions } from './tool_tip_popover';
-export type { EuiToolTipProps } from './tool_tip';
+export type { EuiToolTipProps, EuiToolTipRef } from './tool_tip';
export { EuiToolTip } from './tool_tip';
export type { EuiIconTipProps } from './icon_tip';
diff --git a/packages/eui/src/components/tool_tip/tool_tip.test.tsx b/packages/eui/src/components/tool_tip/tool_tip.test.tsx
index 9b401fa5ead5..1ef7b5ac8170 100644
--- a/packages/eui/src/components/tool_tip/tool_tip.test.tsx
+++ b/packages/eui/src/components/tool_tip/tool_tip.test.tsx
@@ -6,9 +6,8 @@
* Side Public License, v 1.
*/
-import React, { useRef } from 'react';
-import { fireEvent } from '@testing-library/react';
-import { userEvent } from '@storybook/test';
+import React, { createRef, StrictMode, useRef } from 'react';
+import { act, fireEvent } from '@testing-library/react';
import {
render,
waitForEuiToolTipVisible,
@@ -18,6 +17,7 @@ import { requiredProps } from '../../test';
import { shouldRenderCustomStyles } from '../../test/internal';
import { EuiToolTip } from './tool_tip';
+import type { EuiToolTipRef } from './tool_tip';
describe('EuiToolTip', () => {
shouldRenderCustomStyles(
@@ -43,72 +43,157 @@ describe('EuiToolTip', () => {
expect(baseElement).toMatchSnapshot();
});
- it('shows tooltip on mouseover and focus', async () => {
- const { baseElement, getByTestSubject } = render(
-
-
-
- );
+ describe('visibility', () => {
+ afterEach(() => jest.useRealTimers());
- fireEvent.mouseOver(getByTestSubject('trigger'));
- await waitForEuiToolTipVisible();
+ it('shows on mouseover and hides on mouseout', async () => {
+ const { getByTestSubject, queryByRole } = render(
+
+
+
+ );
- expect(baseElement).toMatchSnapshot();
+ expect(queryByRole('tooltip')).not.toBeInTheDocument();
- fireEvent.mouseOut(getByTestSubject('trigger'));
- await waitForEuiToolTipHidden();
+ fireEvent.mouseOver(getByTestSubject('trigger'));
+ await waitForEuiToolTipVisible();
+ expect(queryByRole('tooltip')).toBeInTheDocument();
- fireEvent.focus(getByTestSubject('trigger'));
- await waitForEuiToolTipVisible();
- });
+ fireEvent.mouseOut(getByTestSubject('trigger'));
+ await waitForEuiToolTipHidden();
+ expect(queryByRole('tooltip')).not.toBeInTheDocument();
+ });
- it('uses custom offset prop value', async () => {
- const offsetValue = 32;
- const { baseElement, getByRole } = render(
-
-
-
- );
- const trigger = getByRole('button');
+ it('shows on initial autoFocus in StrictMode', async () => {
+ const { getByTestSubject, queryByRole } = render(
+
+
+
+
+
+ );
- await userEvent.hover(trigger);
- await waitForEuiToolTipVisible();
- expect(baseElement).toMatchSnapshot();
- });
+ await waitForEuiToolTipVisible();
+ expect(queryByRole('tooltip')).toBeInTheDocument();
- test('anchor props are rendered', () => {
- const { baseElement } = render(
-
-
-
- );
+ fireEvent.blur(getByTestSubject('trigger'));
+ await waitForEuiToolTipHidden();
+ expect(queryByRole('tooltip')).not.toBeInTheDocument();
+ });
- expect(baseElement).toMatchSnapshot();
+ it('shows on focus and hides on blur', async () => {
+ const { getByTestSubject, queryByRole } = render(
+
+
+
+ );
+
+ expect(queryByRole('tooltip')).not.toBeInTheDocument();
+
+ fireEvent.focus(getByTestSubject('trigger'));
+ await waitForEuiToolTipVisible();
+ expect(queryByRole('tooltip')).toBeInTheDocument();
+
+ fireEvent.blur(getByTestSubject('trigger'));
+ await waitForEuiToolTipHidden();
+ expect(queryByRole('tooltip')).not.toBeInTheDocument();
+ });
+
+ it('keeps tooltip visible on mouseout when the trigger has focus', async () => {
+ const { getByTestSubject, queryByRole } = render(
+
+
+
+ );
+
+ fireEvent.focus(getByTestSubject('trigger'));
+ await waitForEuiToolTipVisible();
+
+ fireEvent.mouseOut(getByTestSubject('trigger'));
+ // Tooltip stays visible because hasFocus=true
+ expect(queryByRole('tooltip')).toBeInTheDocument();
+ });
+
+ it('does not render when neither content nor title are provided', () => {
+ jest.useFakeTimers();
+ const { queryByRole, getByTestSubject } = render(
+
+
+
+ );
+
+ fireEvent.mouseOver(getByTestSubject('trigger'));
+ // Flush tooltip delay and state queue
+ act(() => jest.runAllTimers());
+
+ expect(queryByRole('tooltip')).not.toBeInTheDocument();
+ });
+
+ it('renders with title only and no content', async () => {
+ const { getByTestSubject, getByRole } = render(
+
+
+
+ );
+
+ fireEvent.mouseOver(getByTestSubject('trigger'));
+ await waitForEuiToolTipVisible();
+
+ expect(getByRole('tooltip')).toHaveTextContent('Tooltip title');
+ });
});
- test('display prop renders block', () => {
- const { container } = render(
-
-
-
- );
+ describe('props', () => {
+ it('applies anchorClassName and anchorProps to the anchor wrapper', () => {
+ const { container } = render(
+
+
+
+ );
+
+ const anchor = container.querySelector('.euiToolTipAnchor');
+ expect(anchor).toHaveClass('customAnchorClass');
+ expect(anchor).toHaveAttribute('data-test-subj', 'anchor');
+ });
- expect(container).toMatchSnapshot();
+ it('display="block" applies a different CSS class than display="inlineBlock"', () => {
+ const { container: blockContainer } = render(
+
+
+
+ );
+ const { container: inlineBlockContainer } = render(
+
+
+
+ );
+
+ const blockAnchor = blockContainer.querySelector('.euiToolTipAnchor')!;
+ const inlineBlockAnchor =
+ inlineBlockContainer.querySelector('.euiToolTipAnchor')!;
+ expect(blockAnchor.className).not.toEqual(inlineBlockAnchor.className);
+ });
+
+ it('calls the onMouseOut prop callback on mouseout', async () => {
+ const onMouseOut = jest.fn();
+ const { getByTestSubject } = render(
+
+
+
+ );
+
+ fireEvent.mouseOver(getByTestSubject('trigger'));
+ await waitForEuiToolTipVisible();
+
+ fireEvent.mouseOut(getByTestSubject('trigger'));
+ expect(onMouseOut).toHaveBeenCalledTimes(1);
+ });
});
describe('aria-describedby', () => {
@@ -121,9 +206,31 @@ describe('EuiToolTip', () => {
fireEvent.mouseOver(getByTestSubject('anchor'));
await waitForEuiToolTipVisible();
- expect(
- getByTestSubject('anchor').getAttribute('aria-describedby')
- ).toEqual('toolTipId');
+ expect(getByTestSubject('anchor')).toHaveAttribute(
+ 'aria-describedby',
+ 'toolTipId'
+ );
+ });
+
+ it('removes `aria-describedby` when the tooltip is hidden', async () => {
+ const { getByTestSubject } = render(
+
+
+
+ );
+
+ fireEvent.mouseOver(getByTestSubject('anchor'));
+ await waitForEuiToolTipVisible();
+ expect(getByTestSubject('anchor')).toHaveAttribute(
+ 'aria-describedby',
+ 'toolTipId'
+ );
+
+ fireEvent.mouseOut(getByTestSubject('anchor'));
+ await waitForEuiToolTipHidden();
+ expect(getByTestSubject('anchor')).not.toHaveAttribute(
+ 'aria-describedby'
+ );
});
it('does not add `aria-describedby` when `disableScreenReaderOutput` is `true`', async () => {
@@ -139,9 +246,9 @@ describe('EuiToolTip', () => {
fireEvent.mouseOver(getByTestSubject('anchor'));
await waitForEuiToolTipVisible();
- expect(
- getByTestSubject('anchor').getAttribute('aria-describedby')
- ).toEqual(null);
+ expect(getByTestSubject('anchor')).not.toHaveAttribute(
+ 'aria-describedby'
+ );
});
it('merges with custom consumer `aria-describedby`s', async () => {
@@ -153,9 +260,10 @@ describe('EuiToolTip', () => {
fireEvent.mouseOver(getByTestSubject('anchor'));
await waitForEuiToolTipVisible();
- expect(
- getByTestSubject('anchor').getAttribute('aria-describedby')
- ).toEqual('toolTipId customId');
+ expect(getByTestSubject('anchor')).toHaveAttribute(
+ 'aria-describedby',
+ 'toolTipId customId'
+ );
});
it('adds custom consumer `aria-describedby` when `disableScreenReaderOutput` is `true`', async () => {
@@ -171,74 +279,159 @@ describe('EuiToolTip', () => {
fireEvent.mouseOver(getByTestSubject('anchor'));
await waitForEuiToolTipVisible();
- expect(
- getByTestSubject('anchor').getAttribute('aria-describedby')
- ).toEqual('customId');
+ expect(getByTestSubject('anchor')).toHaveAttribute(
+ 'aria-describedby',
+ 'customId'
+ );
});
});
- describe('ref methods', () => {
- // Although we don't publicly recommend it, consumers may need to reach into EuiToolTip
- // class methods to manually control visibility state via `show/hideToolTip`.
- // If we switch EuiToolTip to a function component, we'll need to use
- // `useImperativeHandle` to continue exposing these APIs
+ describe('disableScreenReaderOutput', () => {
+ it('when false (default), Escape stops event propagation while tooltip is visible', async () => {
+ const parentKeyDown = jest.fn();
+ const { getByTestSubject } = render(
+
+
+
+
+
+ );
- test('showToolTip', async () => {
- const ConsumerToolTip = () => {
- const toolTipRef = useRef(null);
+ fireEvent.focus(getByTestSubject('trigger'));
+ await waitForEuiToolTipVisible();
- const showToolTip = () => {
- toolTipRef.current?.showToolTip();
- };
+ fireEvent.keyDown(getByTestSubject('trigger'), { key: 'Escape' });
+ await waitForEuiToolTipHidden();
- // NOTE: KEEP IN MIND THAT THIS IS BAD ACCESSIBILITY PRACTICE AND ONLY HERE FOR TESTING
- // Because focus is on separate item from the tooltip, aria-describedby does not trigger
- // and the tooltip contents are not read out to screen readers
- return (
- <>
-
- Not focusable
-
-
- >
- );
- };
- const { getByTestSubject } = render();
+ expect(parentKeyDown).not.toHaveBeenCalled();
+ });
- fireEvent.click(getByTestSubject('trigger'));
+ it('when true, Escape does not stop event propagation', async () => {
+ const parentKeyDown = jest.fn();
+ const { getByTestSubject } = render(
+
+
+
+
+
+ );
+
+ fireEvent.focus(getByTestSubject('trigger'));
await waitForEuiToolTipVisible();
+
+ fireEvent.keyDown(getByTestSubject('trigger'), { key: 'Escape' });
+ await waitForEuiToolTipHidden();
+
+ expect(parentKeyDown).toHaveBeenCalledTimes(1);
});
- test('hideToolTip', async () => {
- // Consumers appear to mostly want this after modal/flyout/focus trap close, when
- // focus is returned to toggling buttons with a tooltip, & said tooltip blocks UI
- // @see https://github.com/elastic/eui/issues/5883#issuecomment-1120908605 for example
- const ConsumerToolTip = () => {
- const toolTipRef = useRef(null);
+ it('when true, tooltip still renders visually', async () => {
+ const { getByTestSubject, getByRole } = render(
+
+
+
+ );
- const hideToolTip = () => {
- toolTipRef.current?.hideToolTip();
- };
+ fireEvent.mouseOver(getByTestSubject('trigger'));
+ await waitForEuiToolTipVisible();
+
+ expect(getByRole('tooltip')).toBeInTheDocument();
+ });
+ });
- return (
- <>
-
-
- >
+ >
+ );
+ };
+ const { getByTestSubject, getByRole, queryByRole } = render(
+
);
- };
- const { getByTestSubject } = render();
- fireEvent.mouseOver(getByTestSubject('trigger'));
- await waitForEuiToolTipVisible();
+ expect(queryByRole('tooltip')).not.toBeInTheDocument();
- fireEvent.click(getByTestSubject('trigger'));
- await waitForEuiToolTipHidden();
+ fireEvent.click(getByTestSubject('trigger'));
+ await waitForEuiToolTipVisible();
+
+ expect(getByRole('tooltip')).toBeInTheDocument();
+ });
+
+ test('hideToolTip', async () => {
+ // Consumers appear to mostly want this after modal/flyout/focus trap close, when
+ // focus is returned to toggling buttons with a tooltip, & said tooltip blocks UI
+ // @see https://github.com/elastic/eui/issues/5883#issuecomment-1120908605 for example
+ const ConsumerToolTip = () => {
+ const toolTipRef = useRef(null);
+
+ const hideToolTip = () => {
+ toolTipRef.current?.hideToolTip();
+ };
+
+ return (
+ <>
+
+
+ Closes tooltip on click
+
+
+ >
+ );
+ };
+ const { getByTestSubject, queryByRole } = render();
+
+ fireEvent.mouseOver(getByTestSubject('trigger'));
+ await waitForEuiToolTipVisible();
+ expect(queryByRole('tooltip')).toBeInTheDocument();
+
+ fireEvent.click(getByTestSubject('trigger'));
+ await waitForEuiToolTipHidden();
+
+ expect(queryByRole('tooltip')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('id', () => {
+ it('exposes the id prop value', () => {
+ const ref = createRef();
+ render(
+
+ Trigger
+
+ );
+ expect(ref.current?.id).toBe('custom-id');
+ });
+
+ it('exposes a generated id when no id prop is provided', () => {
+ const ref = createRef();
+ render(
+
+ Trigger
+
+ );
+ expect(ref.current?.id).toBeTruthy();
+ });
});
});
});
diff --git a/packages/eui/src/components/tool_tip/tool_tip.tsx b/packages/eui/src/components/tool_tip/tool_tip.tsx
index 6655d9f368d9..fd8c7c1ad5c1 100644
--- a/packages/eui/src/components/tool_tip/tool_tip.tsx
+++ b/packages/eui/src/components/tool_tip/tool_tip.tsx
@@ -7,8 +7,13 @@
*/
import React, {
- Component,
- ContextType,
+ forwardRef,
+ useCallback,
+ useContext,
+ useEffect,
+ useImperativeHandle,
+ useRef,
+ useState,
ReactElement,
ReactNode,
MouseEvent as ReactMouseEvent,
@@ -18,10 +23,7 @@ import classNames from 'classnames';
import { CommonProps } from '../common';
import { findPopoverPosition, htmlIdGenerator, keys } from '../../services';
-import {
- createRepositionOnScroll,
- type CreateRepositionOnScrollReturnType,
-} from '../../services/popover/reposition_on_scroll';
+import { getRepositionOnScroll } from '../../services/popover/reposition_on_scroll';
import { type EuiPopoverPosition } from '../../services/popover';
import { enqueueStateChange } from '../../services/react';
import { EuiResizeObserver } from '../observer/resize_observer';
@@ -93,7 +95,7 @@ export interface EuiToolTipProps extends CommonProps {
/**
* Delay before showing tooltip. Good for repeatable items.
*/
- delay: ToolTipDelay;
+ delay?: ToolTipDelay;
/**
* An optional title for your tooltip.
*/
@@ -105,7 +107,7 @@ export interface EuiToolTipProps extends CommonProps {
/**
* Suggested position. If there is not enough room for it this will be changed.
*/
- position: ToolTipPositions;
+ position?: ToolTipPositions;
/**
* When `true`, the tooltip's position is re-calculated when the user
* scrolls. This supports having fixed-position tooltip anchors.
@@ -135,225 +137,251 @@ export interface EuiToolTipProps extends CommonProps {
offset?: number;
}
-interface State {
- visible: boolean;
- hasFocus: boolean;
- calculatedPosition: ToolTipPositions;
- toolTipStyles: ToolTipStyles;
- arrowStyles?: Record;
+export interface EuiToolTipRef {
+ showToolTip: () => void;
+ hideToolTip: () => void;
id: string;
}
-export class EuiToolTip extends Component {
- static contextType = EuiComponentDefaultsContext;
- declare context: ContextType;
- private repositionOnScroll: CreateRepositionOnScrollReturnType;
-
- _isMounted = false;
- anchor: null | HTMLElement = null;
- popover: null | HTMLElement = null;
- private timeoutId?: ReturnType;
-
- constructor(props: EuiToolTipProps) {
- super(props);
- this.state = {
- visible: false,
- hasFocus: false,
- calculatedPosition: this.props.position,
- toolTipStyles: DEFAULT_TOOLTIP_STYLES,
- arrowStyles: undefined,
- id: this.props.id || htmlIdGenerator()(),
- };
-
- this.repositionOnScroll = createRepositionOnScroll(() => ({
- repositionOnScroll: this.props.repositionOnScroll,
- componentDefaults: this.context.EuiToolTip,
- repositionFn: this.positionToolTip,
- }));
- }
-
- static defaultProps: Partial = {
- position: 'top',
- delay: 'regular',
- display: 'inlineBlock',
- disableScreenReaderOutput: false,
- };
-
- clearAnimationTimeout = () => {
- if (this.timeoutId) {
- this.timeoutId = clearTimeout(this.timeoutId) as undefined;
- }
- };
-
- componentDidMount() {
- this._isMounted = true;
- this.repositionOnScroll.subscribe();
- }
-
- componentWillUnmount() {
- this.clearAnimationTimeout();
- this._isMounted = false;
- this.repositionOnScroll.cleanup();
- }
+export const EuiToolTip = forwardRef(
+ (
+ {
+ children,
+ className,
+ anchorClassName,
+ anchorProps,
+ content,
+ title,
+ delay = 'regular',
+ display = 'inlineBlock',
+ repositionOnScroll,
+ disableScreenReaderOutput = false,
+ position: positionProp = 'top',
+ offset,
+ id: idProp,
+ onMouseOut: onMouseOutProp,
+ ...rest
+ },
+ ref
+ ) => {
+ const componentDefaultsContext = useContext(EuiComponentDefaultsContext);
+
+ const [visible, setVisible] = useState(false);
+ const [hasFocus, setHasFocus] = useState(false);
+ const [calculatedPosition, setCalculatedPosition] =
+ useState(positionProp);
+ const [toolTipStyles, setToolTipStyles] = useState(
+ DEFAULT_TOOLTIP_STYLES
+ );
+ const [arrowStyles, setArrowStyles] = useState<
+ Record | undefined
+ >(undefined);
- componentDidUpdate(prevProps: EuiToolTipProps, prevState: State) {
- if (prevState.visible === false && this.state.visible === true) {
- requestAnimationFrame(this.testAnchor);
- }
+ const generatedId = useRef(htmlIdGenerator()());
+ const id = idProp ?? generatedId.current;
- // update scroll listener
- this.repositionOnScroll.update();
- }
+ const anchorRef = useRef(null);
+ const popoverRef = useRef(null);
+ const timeoutRef = useRef | undefined>(
+ undefined
+ );
+ const isMounted = useRef(false);
- testAnchor = () => {
- // when the tooltip is visible, this checks if the anchor is still part of document
- // this fixes when the react root is removed from the dom without unmounting
- // https://github.com/elastic/eui/issues/1105
- if (document.body.contains(this.anchor) === false) {
- // the anchor is no longer part of `document`
- this.hideToolTip();
- } else {
- if (this.state.visible) {
- // if still visible, keep checking
- requestAnimationFrame(this.testAnchor);
+ const positionToolTip = useCallback(() => {
+ if (!anchorRef.current || !popoverRef.current) {
+ return;
}
- }
- };
-
- setAnchorRef = (ref: HTMLElement) => (this.anchor = ref);
-
- setPopoverRef = (ref: HTMLElement) => (this.popover = ref);
-
- showToolTip = () => {
- if (!this.timeoutId) {
- this.timeoutId = setTimeout(() => {
- enqueueStateChange(() => {
- this.setState({ visible: true });
- toolTipManager.registerTooltip(this.hideToolTip);
- });
- }, delayToMsMap[this.props.delay]);
- }
- };
-
- positionToolTip = () => {
- const requestedPosition = this.props.position;
- const offset = this.props.offset ?? DEFAULT_TOOLTIP_OFFSET;
-
- if (!this.anchor || !this.popover) {
- return;
- }
-
- const { position, left, top, arrow } = findPopoverPosition({
- anchor: this.anchor,
- popover: this.popover,
- position: requestedPosition,
- offset,
- arrowConfig: {
- arrowWidth: 12,
- arrowBuffer: 4,
+
+ const { position, left, top, arrow } = findPopoverPosition({
+ anchor: anchorRef.current,
+ popover: popoverRef.current,
+ position: positionProp,
+ offset: offset ?? DEFAULT_TOOLTIP_OFFSET,
+ arrowConfig: {
+ arrowWidth: 12,
+ arrowBuffer: 4,
+ },
+ });
+
+ // If encroaching the right edge of the window:
+ // When `props.content` changes and is longer than `prevProps.content`, the tooltip width remains and
+ // the resizeObserver callback will fire twice (once for vertical resize caused by text line wrapping,
+ // once for a subsequent position correction) and cause a flash rerender and reposition.
+ // To prevent this, we can orient from the right so that text line wrapping does not occur, negating
+ // the second resizeObserver callback call.
+ const windowWidth =
+ document.documentElement.clientWidth || window.innerWidth;
+ const useRightValue = windowWidth / 2 < left;
+
+ const newToolTipStyles: ToolTipStyles = {
+ top,
+ left: useRightValue ? 'auto' : left,
+ right: useRightValue
+ ? windowWidth - left - popoverRef.current.offsetWidth
+ : 'auto',
+ };
+
+ setVisible(true);
+ setCalculatedPosition(position);
+ setToolTipStyles(newToolTipStyles);
+ setArrowStyles(arrow);
+ }, [positionProp, offset]);
+
+ const setAnchorRef = useCallback((el: HTMLSpanElement | null) => {
+ anchorRef.current = el;
+ }, []);
+
+ const setPopoverRef = useCallback(
+ (el: HTMLDivElement | null) => {
+ popoverRef.current = el;
+ if (el) positionToolTip();
},
- });
-
- // If encroaching the right edge of the window:
- // When `props.content` changes and is longer than `prevProps.content`, the tooltip width remains and
- // the resizeObserver callback will fire twice (once for vertical resize caused by text line wrapping,
- // once for a subsequent position correction) and cause a flash rerender and reposition.
- // To prevent this, we can orient from the right so that text line wrapping does not occur, negating
- // the second resizeObserver callback call.
- const windowWidth =
- document.documentElement.clientWidth || window.innerWidth;
- const useRightValue = windowWidth / 2 < left;
-
- const toolTipStyles: ToolTipStyles = {
- top,
- left: useRightValue ? 'auto' : left,
- right: useRightValue
- ? windowWidth - left - this.popover.offsetWidth
- : 'auto',
- };
-
- this.setState({
- visible: true,
- calculatedPosition: position,
- toolTipStyles,
- arrowStyles: arrow,
- });
- };
-
- hideToolTip = () => {
- this.clearAnimationTimeout();
- enqueueStateChange(() => {
- if (this._isMounted) {
- this.setState({
- visible: false,
- toolTipStyles: DEFAULT_TOOLTIP_STYLES,
- arrowStyles: undefined,
- });
- toolTipManager.deregisterToolTip(this.hideToolTip);
+ [positionToolTip]
+ );
+
+ const hideToolTip = useCallback(() => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ timeoutRef.current = undefined;
}
- });
- };
-
- onFocus = () => {
- this.setState({
- hasFocus: true,
- });
- this.showToolTip();
- };
-
- onBlur = () => {
- this.setState({
- hasFocus: false,
- });
- this.hideToolTip();
- };
-
- onEscapeKey = (event: React.KeyboardEvent) => {
- if (event.key === keys.ESCAPE) {
- // when the tooltip is only visual, we don't want it to add an additional key stop
- if (!this.props.disableScreenReaderOutput) {
- if (this.state.visible) event.stopPropagation();
+
+ enqueueStateChange(() => {
+ if (isMounted.current) {
+ setVisible(false);
+ setToolTipStyles(DEFAULT_TOOLTIP_STYLES);
+ setArrowStyles(undefined);
+ toolTipManager.deregisterToolTip(hideToolTip);
+ }
+ });
+ }, []);
+
+ const showToolTip = useCallback(() => {
+ if (!timeoutRef.current) {
+ timeoutRef.current = setTimeout(() => {
+ enqueueStateChange(() => {
+ if (isMounted.current) {
+ setVisible(true);
+ toolTipManager.registerTooltip(hideToolTip);
+ }
+ });
+ }, delayToMsMap[delay]);
}
- this.setState({ hasFocus: false }); // Allows mousing over back into the tooltip to work correctly
- this.hideToolTip();
- }
- };
-
- onMouseOut = (event: ReactMouseEvent) => {
- // Prevent mousing over children from hiding the tooltip by testing for whether the mouse has
- // left the anchor for a non-child.
- if (
- this.anchor === event.relatedTarget ||
- (this.anchor != null &&
- !this.anchor.contains(event.relatedTarget as Node))
- ) {
- if (!this.state.hasFocus) {
- this.hideToolTip();
+ }, [delay, hideToolTip]);
+
+ useImperativeHandle(ref, () => ({ showToolTip, hideToolTip, id }), [
+ showToolTip,
+ hideToolTip,
+ id,
+ ]);
+
+ // If the anchor already has focus on mount (e.g. `autoFocus`), show the tooltip.
+ // Important for StrictMode double-mount.
+ useEffect(() => {
+ if (anchorRef.current?.contains(document.activeElement)) {
+ setHasFocus(true);
+ showToolTip();
}
- }
+ }, [showToolTip]);
+
+ useEffect(() => {
+ isMounted.current = true;
+ return () => {
+ isMounted.current = false;
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ timeoutRef.current = undefined;
+ }
+ toolTipManager.deregisterToolTip(hideToolTip);
+ };
+ }, [hideToolTip]);
+
+ // When the tooltip is visible, this checks if the anchor is still part of document.
+ // This fixes when the react root is removed from the DOM without unmounting
+ // See: https://github.com/elastic/eui/issues/1105
+ useEffect(() => {
+ if (!visible) return;
+
+ let rafId: number;
+ const testAnchor = () => {
+ if (document.body.contains(anchorRef.current) === false) {
+ // the anchor is no longer part of `document`
+ hideToolTip();
+ } else {
+ rafId = requestAnimationFrame(testAnchor);
+ }
+ };
+ rafId = requestAnimationFrame(testAnchor);
+
+ return () => {
+ cancelAnimationFrame(rafId);
+ };
+ }, [visible, hideToolTip]);
- if (this.props.onMouseOut) {
- this.props.onMouseOut(event);
- }
- };
+ // update scroll listener
+ useEffect(() => {
+ const shouldReposition = getRepositionOnScroll({
+ repositionOnScroll,
+ repositionFn: positionToolTip,
+ componentDefaults: componentDefaultsContext.EuiToolTip,
+ });
+
+ if (shouldReposition) {
+ window.addEventListener('scroll', positionToolTip, true);
+ }
- render() {
- const {
- children,
- className,
- anchorClassName,
- anchorProps,
- content,
- title,
- delay,
- display,
+ return () => {
+ window.removeEventListener('scroll', positionToolTip, true);
+ };
+ }, [
repositionOnScroll,
- disableScreenReaderOutput = false,
- ...rest
- } = this.props;
+ positionToolTip,
+ componentDefaultsContext.EuiToolTip,
+ ]);
+
+ const onFocus = useCallback(() => {
+ setHasFocus(true);
+ showToolTip();
+ }, [showToolTip]);
+
+ const onBlur = useCallback(() => {
+ setHasFocus(false);
+ hideToolTip();
+ }, [hideToolTip]);
+
+ const onEscapeKey = useCallback(
+ (event: React.KeyboardEvent) => {
+ if (event.key === keys.ESCAPE) {
+ // when the tooltip is only visual, we don't want it to add an additional key stop
+ if (!disableScreenReaderOutput) {
+ if (visible) event.stopPropagation();
+ }
+ setHasFocus(false); // Allows mousing over back into the tooltip to work correctly
+ hideToolTip();
+ }
+ },
+ [disableScreenReaderOutput, visible, hideToolTip]
+ );
- const { arrowStyles, id, toolTipStyles, visible, calculatedPosition } =
- this.state;
+ const onMouseOut = useCallback(
+ (event: ReactMouseEvent) => {
+ // Prevent mousing over children from hiding the tooltip by testing for whether the mouse has
+ // left the anchor for a non-child.
+ if (
+ anchorRef.current === event.relatedTarget ||
+ (anchorRef.current != null &&
+ !anchorRef.current.contains(event.relatedTarget as Node))
+ ) {
+ if (!hasFocus) {
+ hideToolTip();
+ }
+ }
+
+ if (onMouseOutProp) {
+ onMouseOutProp(event);
+ }
+ },
+ [hasFocus, hideToolTip, onMouseOutProp]
+ );
const classes = classNames('euiToolTip', className);
const anchorClasses = classNames(anchorClassName, anchorProps?.className);
@@ -362,16 +390,16 @@ export class EuiToolTip extends Component {
<>
{children}
@@ -381,8 +409,8 @@ export class EuiToolTip extends Component {
{
className="euiToolTip__arrow"
position={calculatedPosition}
/>
-
+
{(resizeRef) => {content}
}
@@ -403,4 +431,6 @@ export class EuiToolTip extends Component {
>
);
}
-}
+);
+
+EuiToolTip.displayName = 'EuiToolTip';
diff --git a/packages/eui/src/components/tool_tip/tool_tip_anchor.tsx b/packages/eui/src/components/tool_tip/tool_tip_anchor.tsx
index 27a9dae9cefc..63a2797586aa 100644
--- a/packages/eui/src/components/tool_tip/tool_tip_anchor.tsx
+++ b/packages/eui/src/components/tool_tip/tool_tip_anchor.tsx
@@ -9,7 +9,7 @@
import React, { cloneElement, HTMLAttributes, forwardRef } from 'react';
import classNames from 'classnames';
-import { useGeneratedHtmlId } from '../../services';
+import { useGeneratedHtmlId, useEuiMemoizedStyles } from '../../services';
import type { EuiToolTipProps } from './tool_tip';
import { euiToolTipAnchorStyles } from './tool_tip.styles';
@@ -42,7 +42,7 @@ export const EuiToolTipAnchor = forwardRef<
},
ref
) => {
- const anchorCss = euiToolTipAnchorStyles();
+ const anchorCss = useEuiMemoizedStyles(euiToolTipAnchorStyles);
const cssStyles = [anchorCss.euiToolTipAnchor, anchorCss[display]];
const classes = classNames('euiToolTipAnchor', className);
diff --git a/packages/eui/src/components/tool_tip/tool_tip_manager.test.ts b/packages/eui/src/components/tool_tip/tool_tip_manager.test.ts
index 2f32aa74ba28..502d4ff15358 100644
--- a/packages/eui/src/components/tool_tip/tool_tip_manager.test.ts
+++ b/packages/eui/src/components/tool_tip/tool_tip_manager.test.ts
@@ -9,35 +9,44 @@
import { toolTipManager } from './tool_tip_manager';
describe('ToolTipManager', () => {
- describe('registerToolTip', () => {
- const hideToolTip = jest.fn();
+ afterEach(() => {
+ // Reset the singleton between tests to prevent cross-test contamination
+ toolTipManager.toolTipsToHide.clear();
+ });
+
+ describe('registerTooltip', () => {
+ it('does not call the newly registered callback', () => {
+ const hide = jest.fn();
- it('stores the passed hideToolTip callback', () => {
- toolTipManager.registerTooltip(hideToolTip);
+ toolTipManager.registerTooltip(hide);
- expect(toolTipManager.toolTipsToHide.has(hideToolTip)).toBeTruthy();
+ expect(hide).not.toHaveBeenCalled();
});
- it('calls the previously stored hideToolTip callback and removes it from storage', () => {
- toolTipManager.registerTooltip(() => {});
+ it('calls and removes any previously registered callback when a new tooltip registers', () => {
+ const hide1 = jest.fn();
+ const hide2 = jest.fn();
+
+ toolTipManager.registerTooltip(hide1);
+ toolTipManager.registerTooltip(hide2);
- expect(hideToolTip).toHaveBeenCalledTimes(1);
- expect(toolTipManager.toolTipsToHide.has(hideToolTip)).toBeFalsy();
+ expect(hide1).toHaveBeenCalledTimes(1);
+ expect(hide2).not.toHaveBeenCalled();
});
});
describe('deregisterToolTip', () => {
- // If the current tooltip is already hidden before the next tooltip is visible,
- // there's no need to re-hide it, so we deregister the callback
- const deregisteredHide = jest.fn();
+ it('prevents a deregistered callback from being called when a new tooltip registers', () => {
+ // If the current tooltip is already hidden before the next tooltip is visible,
+ // there's no need to re-hide it, so we deregister the callback
+ const hide1 = jest.fn();
+ const hide2 = jest.fn();
- it('removes the hide callback from storage', () => {
- toolTipManager.registerTooltip(deregisteredHide);
- toolTipManager.deregisterToolTip(deregisteredHide);
- toolTipManager.registerTooltip(() => {});
+ toolTipManager.registerTooltip(hide1);
+ toolTipManager.deregisterToolTip(hide1);
+ toolTipManager.registerTooltip(hide2);
- expect(deregisteredHide).toHaveBeenCalledTimes(0);
- expect(toolTipManager.toolTipsToHide.has(deregisteredHide)).toBeFalsy();
+ expect(hide1).not.toHaveBeenCalled();
});
});
});
diff --git a/packages/eui/src/components/tool_tip/tool_tip_popover.tsx b/packages/eui/src/components/tool_tip/tool_tip_popover.tsx
index a561b30b7c14..e9a56c879dac 100644
--- a/packages/eui/src/components/tool_tip/tool_tip_popover.tsx
+++ b/packages/eui/src/components/tool_tip/tool_tip_popover.tsx
@@ -16,7 +16,7 @@ import React, {
} from 'react';
import classNames from 'classnames';
import { CommonProps } from '../common';
-import { useEuiTheme } from '../../services';
+import { useEuiMemoizedStyles } from '../../services';
import { euiToolTipStyles } from './tool_tip.styles';
export type ToolTipPositions = 'top' | 'right' | 'bottom' | 'left';
@@ -26,7 +26,7 @@ type Props = CommonProps &
positionToolTip: () => void;
children?: ReactNode;
title?: ReactNode;
- popoverRef?: (ref: HTMLDivElement) => void;
+ popoverRef?: (ref: HTMLDivElement | null) => void;
calculatedPosition?: ToolTipPositions;
};
@@ -39,10 +39,9 @@ export const EuiToolTipPopover: FunctionComponent = ({
calculatedPosition,
...rest
}) => {
- const popover = useRef();
+ const popover = useRef(null);
- const euiTheme = useEuiTheme();
- const styles = euiToolTipStyles(euiTheme);
+ const styles = useEuiMemoizedStyles(euiToolTipStyles);
const cssStyles = [
styles.euiToolTip,
calculatedPosition && styles[calculatedPosition],
@@ -50,18 +49,22 @@ export const EuiToolTipPopover: FunctionComponent = ({
const updateDimensions = useCallback(() => {
requestAnimationFrame(() => {
- // Because of this delay, sometimes `positionToolTip` becomes unavailable.
+ // Because of this delay, the popover may have unmounted by the time the RAF fires.
if (popover.current) {
positionToolTip();
}
});
}, [positionToolTip]);
- const setPopoverRef = (ref: HTMLDivElement) => {
- if (popoverRef) {
- popoverRef(ref);
- }
- };
+ const setPopoverRef = useCallback(
+ (ref: HTMLDivElement | null) => {
+ popover.current = ref;
+ if (popoverRef) {
+ popoverRef(ref);
+ }
+ },
+ [popoverRef]
+ );
useEffect(() => {
document.body.classList.add('euiBody-hasPortalContent');