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 d01c3ac63611..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 ff5204cb6d46..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/button/button_group/button_group.test.tsx b/packages/eui/src/components/button/button_group/button_group.test.tsx index 49318d1ac742..19eed7b70fea 100644 --- a/packages/eui/src/components/button/button_group/button_group.test.tsx +++ b/packages/eui/src/components/button/button_group/button_group.test.tsx @@ -11,6 +11,7 @@ import { css } from '@emotion/react'; import { fireEvent } from '@testing-library/react'; import { render, + simulateFocusVisible, waitForEuiToolTipHidden, waitForEuiToolTipVisible, } from '../../../test/rtl'; @@ -245,6 +246,7 @@ describe('EuiButtonGroup', () => { }); describe('tooltips', () => { + afterEach(() => jest.restoreAllMocks()); it('shows a tooltip on hover and focus', async () => { const { getByTestSubject, getByRole } = render( { fireEvent.mouseOut(getByTestSubject('buttonWithTooltip')); await waitForEuiToolTipHidden(); + simulateFocusVisible(getByTestSubject('buttonWithTooltip')); fireEvent.focus(getByTestSubject('buttonWithTooltip')); await waitForEuiToolTipVisible(); fireEvent.blur(getByTestSubject('buttonWithTooltip')); @@ -302,6 +305,7 @@ describe('EuiButtonGroup', () => { fireEvent.mouseOut(getByTestSubject('buttonWithTooltip').parentElement!); await waitForEuiToolTipHidden(); + simulateFocusVisible(getByTestSubject('buttonWithTooltip')); fireEvent.focus(getByTestSubject('buttonWithTooltip')); await waitForEuiToolTipVisible(); fireEvent.blur(getByTestSubject('buttonWithTooltip')); 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/color_picker/color_picker_swatch.test.tsx b/packages/eui/src/components/color_picker/color_picker_swatch.test.tsx index 317be86a46cf..766d997ffd93 100644 --- a/packages/eui/src/components/color_picker/color_picker_swatch.test.tsx +++ b/packages/eui/src/components/color_picker/color_picker_swatch.test.tsx @@ -12,6 +12,7 @@ import { requiredProps } from '../../test'; import { shouldRenderCustomStyles } from '../../test/internal'; import { render, + simulateFocusVisible, waitForEuiToolTipHidden, waitForEuiToolTipVisible, } from '../../test/rtl'; @@ -19,6 +20,7 @@ import { import { EuiColorPickerSwatch } from './color_picker_swatch'; describe('EuiColorPickerSwatch', () => { + afterEach(() => jest.restoreAllMocks()); shouldRenderCustomStyles(); test('is rendered', () => { @@ -61,6 +63,7 @@ describe('EuiColorPickerSwatch', () => { const swatchElement = getByTestSubject('color-picker-swatch'); + simulateFocusVisible(swatchElement); fireEvent.focus(swatchElement); await waitForEuiToolTipVisible(); diff --git a/packages/eui/src/components/color_picker/hue.test.tsx b/packages/eui/src/components/color_picker/hue.test.tsx index bbd3d05213e3..a18aa3db16d0 100644 --- a/packages/eui/src/components/color_picker/hue.test.tsx +++ b/packages/eui/src/components/color_picker/hue.test.tsx @@ -11,6 +11,7 @@ import { requiredProps } from '../../test/required_props'; import { shouldRenderCustomStyles } from '../../test/internal'; import { render, + simulateFocusVisible, waitForEuiToolTipHidden, waitForEuiToolTipVisible, } from '../../test/rtl'; @@ -23,6 +24,7 @@ const onChange = () => { }; describe('EuiHue', () => { + afterEach(() => jest.restoreAllMocks()); shouldRenderCustomStyles(, { skip: { style: true }, }); @@ -81,6 +83,7 @@ describe('EuiHue', () => { const thumbElement = document.querySelector('.euiHue__range')!; + simulateFocusVisible(thumbElement); fireEvent.focus(thumbElement); await waitForEuiToolTipVisible(); diff --git a/packages/eui/src/components/color_picker/saturation.test.tsx b/packages/eui/src/components/color_picker/saturation.test.tsx index 94ee7c19f2a3..59ac1e7495f2 100644 --- a/packages/eui/src/components/color_picker/saturation.test.tsx +++ b/packages/eui/src/components/color_picker/saturation.test.tsx @@ -11,6 +11,7 @@ import { requiredProps } from '../../test/required_props'; import { shouldRenderCustomStyles } from '../../test/internal'; import { render, + simulateFocusVisible, waitForEuiToolTipHidden, waitForEuiToolTipVisible, } from '../../test/rtl'; @@ -23,6 +24,7 @@ const onChange = () => { }; describe('EuiSaturation', () => { + afterEach(() => jest.restoreAllMocks()); shouldRenderCustomStyles(); test('is rendered', () => { @@ -70,6 +72,7 @@ describe('EuiSaturation', () => { const thumbElement = document.querySelector('.euiSaturation__indicator')!; + simulateFocusVisible(thumbElement); fireEvent.focus(thumbElement); await waitForEuiToolTipVisible(); 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..e6eab8a8f941 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, @@ -97,6 +98,12 @@ export class EuiFilterSelectItemClass extends Component< if (this.props.isFocused && !prevProps.isFocused) { this.buttonRef?.scrollIntoView?.({ block: 'nearest' }); } + if ( + this.props.isFocused !== prevProps.isFocused && + this.props.toolTipContent + ) { + this.toggleToolTip(this.props.isFocused ?? false); + } } render() { @@ -142,8 +149,6 @@ export class EuiFilterSelectItemClass extends Component< style: anchorStyles, } : { style }; - - this.toggleToolTip(isFocused ?? false); } let iconNode; 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/table/table_header_cell.test.tsx b/packages/eui/src/components/table/table_header_cell.test.tsx index 08fcef7f2af9..f606ea40d811 100644 --- a/packages/eui/src/components/table/table_header_cell.test.tsx +++ b/packages/eui/src/components/table/table_header_cell.test.tsx @@ -8,7 +8,11 @@ import React, { PropsWithChildren, ReactElement } from 'react'; import { requiredProps } from '../../test/required_props'; -import { render, waitForEuiToolTipVisible } from '../../test/rtl'; +import { + render, + simulateFocusVisible, + waitForEuiToolTipVisible, +} from '../../test/rtl'; import { fireEvent } from '@testing-library/react'; import { RIGHT_ALIGNMENT, CENTER_ALIGNMENT } from '../../services'; @@ -241,6 +245,7 @@ describe('EuiTableHeaderCell', () => { }); describe('tooltip', () => { + afterEach(() => jest.restoreAllMocks()); it('renders an icon with tooltip', async () => { const { getByTestSubject } = renderInTableHeader( { 'info' ); + simulateFocusVisible(getByTestSubject('icon')); fireEvent.focus(getByTestSubject('icon')); await waitForEuiToolTipVisible(); @@ -296,6 +302,7 @@ describe('EuiTableHeaderCell', () => { 'info' ); + simulateFocusVisible(getByTestSubject('tableHeaderSortButton')); fireEvent.focus(getByTestSubject('tableHeaderSortButton')); await waitForEuiToolTipVisible(); 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.spec.tsx b/packages/eui/src/components/tool_tip/tool_tip.spec.tsx index 47c8d9f07726..c6a2b630fb09 100644 --- a/packages/eui/src/components/tool_tip/tool_tip.spec.tsx +++ b/packages/eui/src/components/tool_tip/tool_tip.spec.tsx @@ -65,10 +65,10 @@ describe('EuiToolTip', () => { ); cy.get('[data-test-subj="tooltip"]').should('not.exist'); - cy.get('[data-test-subj="toggleToolTip"]').focus(); + cy.realPress('Tab'); cy.get('[data-test-subj="tooltip"]').should('exist'); - cy.get('[data-test-subj="toggleToolTip"]').blur(); + cy.realPress('Tab'); cy.get('[data-test-subj="tooltip"]').should('not.exist'); }); diff --git a/packages/eui/src/components/tool_tip/tool_tip.stories.tsx b/packages/eui/src/components/tool_tip/tool_tip.stories.tsx index 68146874f99d..9453412ed759 100644 --- a/packages/eui/src/components/tool_tip/tool_tip.stories.tsx +++ b/packages/eui/src/components/tool_tip/tool_tip.stories.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React from 'react'; +import React, { useRef, useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import { enableFunctionToggleControls } from '../../../.storybook/utils'; @@ -14,6 +14,16 @@ import { LOKI_SELECTORS, lokiPlayDecorator } from '../../../.storybook/loki'; import { sleep } from '../../test'; import { EuiFlexGroup } from '../flex'; import { EuiButton } from '../button'; +import { EuiFlyout, EuiFlyoutHeader, EuiFlyoutBody } from '../flyout'; +import { + EuiModal, + EuiModalHeader, + EuiModalHeaderTitle, + EuiModalBody, +} from '../modal'; +import { EuiPopover } from '../popover'; +import { EuiText } from '../text'; +import { EuiTitle } from '../title'; import { EuiToolTip, EuiToolTipProps, @@ -72,6 +82,338 @@ export const Playground: Story = { }), }; +/** + * TODO: REMOVE BEFORE MERGING + * + * Click-focus vs keyboard-focus (#9520) + * + * QA: + * - Hover the button → tooltip appears. Move mouse away → tooltip hides. + * - Click the button → tooltip may appear on hover, but once the mouse leaves, + * the tooltip hides (click focus does not persist it). + * - Tab to the button → tooltip appears and stays visible while the element is + * focused. Tab away or press Escape → tooltip hides. + */ + +const FocusClickVsKeyboardStory = () => ( + + +

    + Hover → tooltip appears, hides when mouse leaves. +

    +

    + Click → tooltip appears on hover, but hides when mouse + leaves even with focus on the button (no persistence). +

    +

    + Tab to button → tooltip appears and{' '} + stays visible until Tab away or Escape. +

    +
    + + Hover or click me + + + Tab to me + +
    +); + +export const FocusClickVsKeyboard: Story = { + render: () => , +}; + +/** + * TODO: REMOVE BEFORE MERGING + * + * Composite widget arrow-key navigation (#9580) + * + * QA: Tab into the toolbar, then press ← → to move between buttons. + * The tooltip should appear on each focused button, proving that arrow keys + * (not just Tab) trigger the tooltip — relevant for toolbars, menus, tab lists, etc. + */ + +const TOOLBAR_BUTTONS = [ + { label: 'Bold', tooltip: 'Bold (Ctrl+B)' }, + { label: 'Italic', tooltip: 'Italic (Ctrl+I)' }, + { label: 'Underline', tooltip: 'Underline (Ctrl+U)' }, +]; + +const ArrowKeyNavigationStory = () => { + const [activeIndex, setActiveIndex] = useState(0); + const buttonRefs = useRef>([]); + + const onKeyDown = ( + e: React.KeyboardEvent, + index: number + ) => { + let next = index; + if (e.key === 'ArrowRight') next = (index + 1) % TOOLBAR_BUTTONS.length; + else if (e.key === 'ArrowLeft') + next = (index - 1 + TOOLBAR_BUTTONS.length) % TOOLBAR_BUTTONS.length; + else return; + + e.preventDefault(); + setActiveIndex(next); + buttonRefs.current[next]?.focus(); + }; + + return ( + + +

    + Tab into the toolbar, then use {' '} + to navigate between buttons. The tooltip should appear on + each button as it receives focus via arrow keys. +

    +
    +
    + + {TOOLBAR_BUTTONS.map((btn, i) => ( + + { + buttonRefs.current[i] = el; + }} + tabIndex={i === activeIndex ? 0 : -1} + onKeyDown={(e: React.KeyboardEvent) => + onKeyDown(e, i) + } + > + {btn.label} + + + ))} + +
    +
    + ); +}; + +export const ArrowKeyNavigation: Story = { + render: () => , +}; + +/** + * TODO: REMOVE BEFORE MERGING + * + * Programmatic focus return stories (#9580) + * + * QA: click the trigger button to open the overlay, then close it (Escape or close button). + * Focus returns to the trigger — the tooltip should NOT appear. + * Then Tab to the trigger from elsewhere — the tooltip SHOULD appear. + */ + +const FocusReturnFlyoutStory = () => { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> + + setIsOpen(true)}>Tooltip trigger + + {isOpen && ( + setIsOpen(false)} size="s"> + + +

    Flyout

    +
    +
    + + +

    + Close this flyout (press Escape or the × button). + Focus returns to the trigger button — the tooltip should{' '} + not appear. Then Tab to the trigger — the + tooltip should appear. +

    +
    +
    +
    + )} + + ); +}; + +export const FocusReturnFlyout: Story = { + render: () => , +}; + +const FocusReturnModalStory = () => { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> + + setIsOpen(true)}>Tooltip trigger + + {isOpen && ( + setIsOpen(false)}> + + Modal + + + +

    + Close this modal (press Escape or the × button). + Focus returns to the trigger button — the tooltip should{' '} + not appear. Then Tab to the trigger — the + tooltip should appear. +

    +
    +
    +
    + )} + + ); +}; + +export const FocusReturnModal: Story = { + render: () => , +}; + +const FocusReturnPopoverStory = () => { + const [isOpen, setIsOpen] = useState(false); + + return ( + setIsOpen(false)} + button={ + + setIsOpen((open) => !open)}> + Tooltip trigger + + + } + > + +

    + Close this popover (press Escape or click outside). Focus + returns to the trigger button — the tooltip should{' '} + not appear. Then Tab to the trigger — the tooltip{' '} + should appear. +

    +
    +
    + ); +}; + +export const FocusReturnPopover: Story = { + render: () => , +}; + +/** + * TODO: REMOVE BEFORE MERGING + * + * `Escape` key propagation stories + * + * QA: hover or focus the tooltip trigger, press `Escape` and the tooltip should close + * but the overlay should stay open. Pressing `Escape` again (no tooltip) closes the overlay. + */ + +const InsideFlyoutStory = () => { + const [isOpen, setIsOpen] = useState(true); + + return ( + <> + setIsOpen(true)}>Open flyout + {isOpen && ( + setIsOpen(false)} size="s"> + + +

    Flyout

    +
    +
    + + +

    + Hover or focus the button to show the tooltip, then press{' '} + Escape. The tooltip should close but the flyout + should stay open. +

    +
    +
    + + Tooltip trigger + +
    +
    + )} + + ); +}; + +export const InsideFlyout: Story = { + render: () => , +}; + +const InsideModalStory = () => { + const [isOpen, setIsOpen] = useState(true); + + return ( + <> + setIsOpen(true)}>Open modal + {isOpen && ( + setIsOpen(false)}> + + Modal + + + +

    + Hover or focus the button to show the tooltip, then press{' '} + Escape. The tooltip should close but the modal should + stay open. +

    +
    +
    + + Tooltip trigger + +
    +
    + )} + + ); +}; + +export const InsideModal: Story = { + render: () => , +}; + +const InsidePopoverStory = () => { + const [isOpen, setIsOpen] = useState(true); + + return ( + setIsOpen(false)} + button={ + setIsOpen((open) => !open)}> + Toggle popover + + } + > + +

    + Hover or focus the button to show the tooltip, then press{' '} + Escape. The tooltip should close but the popover should + stay open. +

    +
    +
    + + Tooltip trigger + +
    + ); +}; + +export const InsidePopover: Story = { + render: () => , +}; + /** * VRT only stories */ 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..d10cc73ce0a8 100644 --- a/packages/eui/src/components/tool_tip/tool_tip.test.tsx +++ b/packages/eui/src/components/tool_tip/tool_tip.test.tsx @@ -6,11 +6,11 @@ * Side Public License, v 1. */ -import React, { useRef } from 'react'; +import React, { createRef, useRef } from 'react'; import { fireEvent } from '@testing-library/react'; -import { userEvent } from '@storybook/test'; import { render, + simulateFocusVisible, waitForEuiToolTipVisible, waitForEuiToolTipHidden, } from '../../test/rtl'; @@ -18,6 +18,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 +44,170 @@ describe('EuiToolTip', () => { expect(baseElement).toMatchSnapshot(); }); - it('shows tooltip on mouseover and focus', async () => { - const { baseElement, getByTestSubject } = render( - - - - ); + describe('visibility', () => { + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); - 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 keyboard focus and hides on blur', async () => { + const { getByTestSubject, queryByRole } = render( + + + + ); - await userEvent.hover(trigger); - await waitForEuiToolTipVisible(); - expect(baseElement).toMatchSnapshot(); - }); + expect(queryByRole('tooltip')).not.toBeInTheDocument(); - test('anchor props are rendered', () => { - const { baseElement } = render( - - - - ); + simulateFocusVisible(getByTestSubject('trigger')); + fireEvent.focus(getByTestSubject('trigger')); + await waitForEuiToolTipVisible(); + expect(queryByRole('tooltip')).toBeInTheDocument(); - expect(baseElement).toMatchSnapshot(); + fireEvent.blur(getByTestSubject('trigger')); + await waitForEuiToolTipHidden(); + expect(queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + it('does not show on mouse-click focus', () => { + const { getByTestSubject, queryByRole } = render( + + + + ); + + // `fireEvent.focus` without `simulateFocusVisible` → `:focus-visible` is `false` (mouse-click focus) + fireEvent.focus(getByTestSubject('trigger')); + expect(queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + it('persists tooltip on mouseout when trigger was keyboard-focused', async () => { + const { getByTestSubject, queryByRole } = render( + + + + ); + + simulateFocusVisible(getByTestSubject('trigger')); + fireEvent.focus(getByTestSubject('trigger')); + await waitForEuiToolTipVisible(); + + fireEvent.mouseOut(getByTestSubject('trigger')); + // Tooltip stays visible because `hasFocus=true` (keyboard focus) + expect(queryByRole('tooltip')).toBeInTheDocument(); + }); + + it('hides tooltip on mouseout when trigger was mouse-click focused', async () => { + const { getByTestSubject, queryByRole } = render( + + + + ); + + // Show on hover first, then click-focus (no `:focus-visible`) + fireEvent.mouseOver(getByTestSubject('trigger')); + await waitForEuiToolTipVisible(); + fireEvent.focus(getByTestSubject('trigger')); + + fireEvent.mouseOut(getByTestSubject('trigger')); + // Tooltip hides because `hasFocus` was not set (click focus, not keyboard) + await waitForEuiToolTipHidden(); + expect(queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + it('does not render when neither content nor title are provided', () => { + const { queryByRole, getByTestSubject } = render( + + + + ); + + fireEvent.mouseOver(getByTestSubject('trigger')); + + 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( + +
    + ); - const showToolTip = () => { - toolTipRef.current?.showToolTip(); - }; + simulateFocusVisible(getByTestSubject('trigger')); + fireEvent.focus(getByTestSubject('trigger')); + await waitForEuiToolTipVisible(); - // 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(); + fireEvent.keyDown(getByTestSubject('trigger'), { key: 'Escape' }); + await waitForEuiToolTipHidden(); + + 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( +
    + +
    + ); + + simulateFocusVisible(getByTestSubject('trigger')); + 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 { 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 ( + <> + + + + + ); + }; + 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( + + + + ); + expect(ref.current?.id).toBe('custom-id'); + }); + + it('exposes a generated id when no id prop is provided', () => { + const ref = createRef(); + render( + + + + ); + 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..909e210f38f6 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,12 +23,8 @@ 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'; import { EuiPortal } from '../portal'; import { EuiComponentDefaultsContext } from '../provider/component_defaults'; @@ -39,11 +40,6 @@ const DISPLAYS = ['inlineBlock', 'block'] as const; export type ToolTipDelay = 'regular' | 'long'; export const DEFAULT_TOOLTIP_OFFSET = 16; -const delayToMsMap: { [key in ToolTipDelay]: number } = { - regular: 250, - long: 250 * 5, -}; - interface ToolTipStyles { top: number; left: number | 'auto'; @@ -91,9 +87,10 @@ export interface EuiToolTipProps extends CommonProps { */ display?: (typeof DISPLAYS)[number]; /** - * Delay before showing tooltip. Good for repeatable items. + * TODO: remove. Breaking change. + * @deprecated No-op. The delay is zero. Kept for no Kibana churn while testing. */ - delay: ToolTipDelay; + delay?: ToolTipDelay; /** * An optional title for your tooltip. */ @@ -105,7 +102,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 +132,245 @@ 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, + // eslint-disable-line @typescript-eslint/no-unused-vars + delay: _delay, + 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 isShowingRef = useRef(false); + 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); - } - }); - }; - - 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(); + [positionToolTip] + ); + + const hideToolTip = useCallback(() => { + isShowingRef.current = false; + if (isMounted.current) { + setVisible(false); + setToolTipStyles(DEFAULT_TOOLTIP_STYLES); + setArrowStyles(undefined); + toolTipManager.deregisterToolTip(hideToolTip); } - 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(); + }, []); + + const showToolTip = useCallback(() => { + if (!isShowingRef.current) { + isShowingRef.current = true; + if (isMounted.current) { + setVisible(true); + toolTipManager.registerTooltip(hideToolTip); + } } - } + }, [hideToolTip]); + + useImperativeHandle(ref, () => ({ showToolTip, hideToolTip, id }), [ + showToolTip, + hideToolTip, + id, + ]); + + useEffect(() => { + isMounted.current = true; + return () => { + isMounted.current = false; + 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( + (e: React.FocusEvent) => { + /** + * Only show on intentional keyboard focus, not on mouse-click focus. + * + * `:focus-visible` tracks the browser's input modality: it is `true` after + * keyboard navigation and `false` after a mouse click. Programmatic focus return + * (e.g. after a modal/flyout/popover closes) happen asynchronously + * in React effects, at which point the browser no longer considers the + * focus keyboard-initiated, so `:focus-visible` is `false` and the tooltip + * stays hidden. + */ + const isFocusVisible = (e.target as Element).matches?.( + ':focus-visible' + ); + if (isFocusVisible) { + setHasFocus(true); + showToolTip(); + } + }, + [showToolTip] + ); - const { arrowStyles, id, toolTipStyles, visible, calculatedPosition } = - this.state; + 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 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 +379,16 @@ export class EuiToolTip extends Component { <> {children} @@ -381,8 +398,8 @@ export class EuiToolTip extends Component { { className="euiToolTip__arrow" position={calculatedPosition} /> - + {(resizeRef) =>
    {content}
    }
    @@ -403,4 +420,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..94ffcbb2b8d9 100644 --- a/packages/eui/src/components/tool_tip/tool_tip_anchor.tsx +++ b/packages/eui/src/components/tool_tip/tool_tip_anchor.tsx @@ -7,9 +7,10 @@ */ import React, { cloneElement, HTMLAttributes, forwardRef } from 'react'; +import type { FocusEvent } 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'; @@ -19,7 +20,7 @@ export type EuiToolTipAnchorProps = Omit< > & Required> & { onBlur: () => void; - onFocus: () => void; + onFocus: (e: FocusEvent) => void; isVisible: boolean; }; @@ -42,7 +43,7 @@ export const EuiToolTipAnchor = forwardRef< }, ref ) => { - const anchorCss = euiToolTipAnchorStyles(); + const anchorCss = useEuiMemoizedStyles(euiToolTipAnchorStyles); const cssStyles = [anchorCss.euiToolTipAnchor, anchorCss[display]]; const classes = classNames('euiToolTipAnchor', className); @@ -75,7 +76,7 @@ export const EuiToolTipAnchor = forwardRef< */} {cloneElement(children, { onFocus: (e: React.FocusEvent) => { - onFocus(); + onFocus(e); children.props.onFocus && children.props.onFocus(e); }, onBlur: (e: React.FocusEvent) => { 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..974d0cc39378 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'); diff --git a/packages/eui/src/test/rtl/component_helpers.ts b/packages/eui/src/test/rtl/component_helpers.ts index 4351f43e2a62..dbb3bedb59a3 100644 --- a/packages/eui/src/test/rtl/component_helpers.ts +++ b/packages/eui/src/test/rtl/component_helpers.ts @@ -27,6 +27,21 @@ export const waitForEuiPopoverClose = async () => expect(openPopover).toBeFalsy(); }); +/** + * jsdom's CSS engine does not track keyboard vs. mouse input modality, + * so `element.matches(':focus-visible')` always returns false. + * Use this helper in tests that assert keyboard-focus tooltip behavior. + * Requires `jest.restoreAllMocks()` in `afterEach`. + */ +export const simulateFocusVisible = (element: Element) => { + const originalMatches = Element.prototype.matches.bind(element); + jest + .spyOn(element, 'matches') + .mockImplementation((selector: string) => + selector === ':focus-visible' ? true : originalMatches(selector) + ); +}; + /** * Ensure the EuiToolTip being tested is open and visible before continuing */