Skip to content

Commit

Permalink
[EuiToolTip] Enforce only one visible tooltip at a time (#6520)
Browse files Browse the repository at this point in the history
* [misc cleanup] Group relative imports by general concept

- keep parent services together, keep tooltip-specific imports together

* Add tooltip manager that hides all other tooltips when a new tooltip is shown

* Write Cypress E2E tests for multiple tooltip behavior

* Changelog
  • Loading branch information
cee-chen authored Jan 12, 2023
1 parent e0ac6a2 commit 02e31d0
Show file tree
Hide file tree
Showing 5 changed files with 149 additions and 6 deletions.
60 changes: 60 additions & 0 deletions src/components/tool_tip/tool_tip.spec.tsx
Original file line number Diff line number Diff line change
@@ -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.
*/

/// <reference types="../../../cypress/support"/>

import React from 'react';

import { EuiButton } from '../../components';
import { EuiToolTip } from './tool_tip';

describe('EuiToolTip', () => {
it('shows the tooltip on hover', () => {
cy.mount(
<EuiToolTip content="Tooltip text here" data-test-subj="tooltip">
<EuiButton data-test-subj="toggleToolTip">Show tooltip</EuiButton>
</EuiToolTip>
);
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(
<EuiToolTip content="Tooltip text here" data-test-subj="tooltip">
<EuiButton data-test-subj="toggleToolTip">Show tooltip</EuiButton>
</EuiToolTip>
);
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(
<>
<EuiToolTip content="Tooltip A" data-test-subj="tooltipA">
<EuiButton data-test-subj="toggleToolTipA">Show tooltip A</EuiButton>
</EuiToolTip>
<EuiToolTip content="Tooltip B" data-test-subj="tooltipB">
<EuiButton data-test-subj="toggleToolTipB">Show tooltip B</EuiButton>
</EuiToolTip>
</>
);
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');
});
});
17 changes: 11 additions & 6 deletions src/components/tool_tip/tool_tip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -209,7 +210,10 @@ export class EuiToolTip extends Component<EuiToolTipProps, State> {
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]);
}
};
Expand Down Expand Up @@ -267,6 +271,7 @@ export class EuiToolTip extends Component<EuiToolTipProps, State> {
toolTipStyles: DEFAULT_TOOLTIP_STYLES,
arrowStyles: undefined,
});
toolTipManager.deregisterToolTip(this.hideToolTip);
}
});
};
Expand Down
43 changes: 43 additions & 0 deletions src/components/tool_tip/tool_tip_manager.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
32 changes: 32 additions & 0 deletions src/components/tool_tip/tool_tip_manager.ts
Original file line number Diff line number Diff line change
@@ -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<Function>();

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();
3 changes: 3 additions & 0 deletions upcoming_changelogs/6520.md
Original file line number Diff line number Diff line change
@@ -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.

0 comments on commit 02e31d0

Please sign in to comment.