diff --git a/src/components/tool_tip/tool_tip.spec.tsx b/src/components/tool_tip/tool_tip.spec.tsx
new file mode 100644
index 00000000000..7ad209eee70
--- /dev/null
+++ b/src/components/tool_tip/tool_tip.spec.tsx
@@ -0,0 +1,60 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+///
+
+import React from 'react';
+
+import { EuiButton } from '../../components';
+import { EuiToolTip } from './tool_tip';
+
+describe('EuiToolTip', () => {
+ it('shows the tooltip on hover', () => {
+ cy.mount(
+
+ Show tooltip
+
+ );
+ cy.get('[data-test-subj="tooltip"]').should('not.exist');
+ cy.get('[data-test-subj="toggleToolTip"]').trigger('mouseover');
+ cy.get('[data-test-subj="tooltip"]').should('exist');
+ });
+
+ it('shows the tooltip on keyboard focus', () => {
+ cy.mount(
+
+ Show tooltip
+
+ );
+ cy.get('[data-test-subj="tooltip"]').should('not.exist');
+ cy.get('[data-test-subj="toggleToolTip"]').focus();
+ cy.get('[data-test-subj="tooltip"]').should('exist');
+ });
+
+ it('does not show multiple tooltips if one tooltip toggle is focused and another tooltip toggle is hovered', () => {
+ cy.mount(
+ <>
+
+ Show tooltip A
+
+
+ Show tooltip B
+
+ >
+ );
+ cy.get('[data-test-subj="tooltip"]').should('not.exist');
+
+ cy.get('[data-test-subj="toggleToolTipA"]').focus();
+ cy.contains('Tooltip A').should('exist');
+ cy.contains('Tooltip B').should('not.exist');
+
+ cy.get('[data-test-subj="toggleToolTipB"]').trigger('mouseover');
+ cy.contains('Tooltip B').should('exist');
+ cy.contains('Tooltip A').should('not.exist');
+ });
+});
diff --git a/src/components/tool_tip/tool_tip.tsx b/src/components/tool_tip/tool_tip.tsx
index e385271e2c2..26fbcb0e84c 100644
--- a/src/components/tool_tip/tool_tip.tsx
+++ b/src/components/tool_tip/tool_tip.tsx
@@ -16,14 +16,15 @@ import React, {
import classNames from 'classnames';
import { CommonProps, keysOf } from '../common';
+import { findPopoverPosition, htmlIdGenerator } from '../../services';
+import { enqueueStateChange } from '../../services/react';
+import { EuiResizeObserver } from '../observer/resize_observer';
import { EuiPortal } from '../portal';
+
+import { EuiToolTipPopover, ToolTipPositions } from './tool_tip_popover';
import { EuiToolTipAnchor } from './tool_tip_anchor';
import { EuiToolTipArrow } from './tool_tip_arrow';
-import { EuiToolTipPopover, ToolTipPositions } from './tool_tip_popover';
-import { enqueueStateChange } from '../../services/react';
-import { findPopoverPosition, htmlIdGenerator } from '../../services';
-
-import { EuiResizeObserver } from '../observer/resize_observer';
+import { toolTipManager } from './tool_tip_manager';
const positionsToClassNameMap: { [key in ToolTipPositions]: string } = {
top: 'euiToolTip--top',
@@ -209,7 +210,10 @@ export class EuiToolTip extends Component {
showToolTip = () => {
if (!this.timeoutId) {
this.timeoutId = setTimeout(() => {
- enqueueStateChange(() => this.setState({ visible: true }));
+ enqueueStateChange(() => {
+ this.setState({ visible: true });
+ toolTipManager.registerTooltip(this.hideToolTip);
+ });
}, delayToMsMap[this.props.delay]);
}
};
@@ -267,6 +271,7 @@ export class EuiToolTip extends Component {
toolTipStyles: DEFAULT_TOOLTIP_STYLES,
arrowStyles: undefined,
});
+ toolTipManager.deregisterToolTip(this.hideToolTip);
}
});
};
diff --git a/src/components/tool_tip/tool_tip_manager.test.ts b/src/components/tool_tip/tool_tip_manager.test.ts
new file mode 100644
index 00000000000..2f32aa74ba2
--- /dev/null
+++ b/src/components/tool_tip/tool_tip_manager.test.ts
@@ -0,0 +1,43 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { toolTipManager } from './tool_tip_manager';
+
+describe('ToolTipManager', () => {
+ describe('registerToolTip', () => {
+ const hideToolTip = jest.fn();
+
+ it('stores the passed hideToolTip callback', () => {
+ toolTipManager.registerTooltip(hideToolTip);
+
+ expect(toolTipManager.toolTipsToHide.has(hideToolTip)).toBeTruthy();
+ });
+
+ it('calls the previously stored hideToolTip callback and removes it from storage', () => {
+ toolTipManager.registerTooltip(() => {});
+
+ expect(hideToolTip).toHaveBeenCalledTimes(1);
+ expect(toolTipManager.toolTipsToHide.has(hideToolTip)).toBeFalsy();
+ });
+ });
+
+ 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('removes the hide callback from storage', () => {
+ toolTipManager.registerTooltip(deregisteredHide);
+ toolTipManager.deregisterToolTip(deregisteredHide);
+ toolTipManager.registerTooltip(() => {});
+
+ expect(deregisteredHide).toHaveBeenCalledTimes(0);
+ expect(toolTipManager.toolTipsToHide.has(deregisteredHide)).toBeFalsy();
+ });
+ });
+});
diff --git a/src/components/tool_tip/tool_tip_manager.ts b/src/components/tool_tip/tool_tip_manager.ts
new file mode 100644
index 00000000000..be522e71c57
--- /dev/null
+++ b/src/components/tool_tip/tool_tip_manager.ts
@@ -0,0 +1,32 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+/**
+ * Manager utility that ensures only one tooltip is visible at a time
+ *
+ * UX rationale (primarily for mouse-only users):
+ * @see https://github.com/elastic/kibana/issues/144482
+ * @see https://github.com/elastic/eui/issues/5883
+ */
+class ToolTipManager {
+ // We use a set instead of a single var just in case
+ // multiple tooltips are registered via async shenanigans
+ toolTipsToHide = new Set();
+
+ registerTooltip = (hideCallback: Function) => {
+ this.toolTipsToHide.forEach((hide) => hide());
+ this.toolTipsToHide.clear();
+ this.toolTipsToHide.add(hideCallback);
+ };
+
+ deregisterToolTip = (hideCallback: Function) => {
+ this.toolTipsToHide.delete(hideCallback);
+ };
+}
+
+export const toolTipManager = new ToolTipManager();
diff --git a/upcoming_changelogs/6520.md b/upcoming_changelogs/6520.md
new file mode 100644
index 00000000000..22e93fe4611
--- /dev/null
+++ b/upcoming_changelogs/6520.md
@@ -0,0 +1,3 @@
+**Breaking changes**
+
+- `EuiToolTip`s now internally enforce only showing **one** tooltip at a time (the most recently triggered tooltip). This primarily affects scenarios where users are focused on a tooltip toggle via click, and then hover onto another tooltip toggle.