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.