diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index ca2a6659bad0..5108ff9038ef 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -182,6 +182,24 @@ Ensure the `EuiIcon` includes appropriate accessibility attributes. - `EuiIcon` has an accessible name via `title`, `aria-label`, or `aria-labelledby`; otherwise mark it decorative with `aria-hidden={true}` - Do not combine `tabIndex` with `aria-hidden` +### `@elastic/eui/prefer-tooltip-trigger-focus-test-utility` + +Flags `fireEvent.focus()` inside `it`/`test` blocks that also query for a tooltip element (`getByRole('tooltip')`, `queryByRole('tooltip')`, `findByRole('tooltip')` or any selector containing `euiToolTip`). Plain `fireEvent.focus` does not simulate `:focus-visible` in jsdom and will not trigger `EuiToolTip`, so tooltip focus tests will silently pass without actually showing the tooltip. + +Use `focusEuiToolTipTrigger` from EUI's RTL test utilities instead, which correctly mocks `:focus-visible` before firing the focus event: + +```tsx +import { focusEuiToolTipTrigger } from '@elastic/eui/test/rtl'; + +it('shows tooltip on focus', async () => { + const { getByRole } = render(); + const cleanup = focusEuiToolTipTrigger(getByRole('button')); + expect(getByRole('tooltip')).toBeInTheDocument(); + cleanup(); +}); +``` + + ## Testing ### Running unit tests diff --git a/packages/eslint-plugin/changelogs/upcoming/9624.md b/packages/eslint-plugin/changelogs/upcoming/9624.md new file mode 100644 index 000000000000..101e4b9ef775 --- /dev/null +++ b/packages/eslint-plugin/changelogs/upcoming/9624.md @@ -0,0 +1 @@ +- Added a new `prefer-tooltip-trigger-focus-test-utility` rule that flags `fireEvent.focus()` inside `it`/`test` blocks that also query for a tooltip. The rule auto-fixes to `focusEuiToolTipTrigger` from EUI's RTL test utilities. \ No newline at end of file diff --git a/packages/eslint-plugin/src/index.ts b/packages/eslint-plugin/src/index.ts index 849f9951008b..0e20ad94e40d 100644 --- a/packages/eslint-plugin/src/index.ts +++ b/packages/eslint-plugin/src/index.ts @@ -6,7 +6,6 @@ * Side Public License, v 1. */ - import { AccessibleInteractiveElements } from './rules/a11y/accessible_interactive_element'; import { CallOutAnnounceOnMount } from './rules/a11y/callout_announce_on_mount'; import { ConsistentIsInvalidProps } from './rules/a11y/consistent_is_invalid_props'; @@ -21,6 +20,7 @@ import { RequireAriaLabelForModals } from './rules/a11y/require_aria_label_for_m import { RequireTableCaption } from './rules/a11y/require_table_caption'; import { ScreenReaderOutputDisabledTooltip } from './rules/a11y/sr_output_disabled_tooltip'; import { TooltipFocusableAnchor } from './rules/a11y/tooltip_focusable_anchor'; +import { PreferTooltipTriggerFocusTestUtility } from './rules/prefer_tooltip_trigger_focus_test_utility'; import { EuiBadgeAccessibilityRules } from './rules/a11y/badge_accessibility_rules'; import { EuiIconAccessibilityRules } from './rules/a11y/icon_accessibility_rules'; @@ -34,14 +34,16 @@ const config = { 'no-restricted-eui-imports': NoRestrictedEuiImports, 'no-static-z-index': NoStaticZIndex, 'no-unnamed-interactive-element': NoUnnamedInteractiveElement, - 'no-unnamed-radio-group' : NoUnnamedRadioGroup, + 'no-unnamed-radio-group': NoUnnamedRadioGroup, 'prefer-eui-icon-tip': PreferEuiIconTip, 'require-aria-label-for-modals': RequireAriaLabelForModals, 'require-table-caption': RequireTableCaption, 'sr-output-disabled-tooltip': ScreenReaderOutputDisabledTooltip, 'tooltip-focusable-anchor': TooltipFocusableAnchor, + 'prefer-tooltip-trigger-focus-test-utility': + PreferTooltipTriggerFocusTestUtility, 'badge-accessibility-rules': EuiBadgeAccessibilityRules, - 'icon-accessibility-rules': EuiIconAccessibilityRules + 'icon-accessibility-rules': EuiIconAccessibilityRules, }, configs: { recommended: { @@ -61,6 +63,7 @@ const config = { '@elastic/eui/require-table-caption': 'warn', '@elastic/eui/sr-output-disabled-tooltip': 'warn', '@elastic/eui/tooltip-focusable-anchor': 'warn', + '@elastic/eui/prefer-tooltip-trigger-focus-test-utility': 'warn', '@elastic/eui/badge-accessibility-rules': 'warn', '@elastic/eui/icon-accessibility-rules': 'warn', }, diff --git a/packages/eslint-plugin/src/rules/prefer_tooltip_trigger_focus_test_utility.test.ts b/packages/eslint-plugin/src/rules/prefer_tooltip_trigger_focus_test_utility.test.ts new file mode 100644 index 000000000000..8f94b7a83256 --- /dev/null +++ b/packages/eslint-plugin/src/rules/prefer_tooltip_trigger_focus_test_utility.test.ts @@ -0,0 +1,236 @@ +/* + * 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 dedent from 'dedent'; +import { RuleTester } from '@typescript-eslint/rule-tester'; + +import { PreferTooltipTriggerFocusTestUtility } from './prefer_tooltip_trigger_focus_test_utility'; + +const languageOptions = { + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, +}; + +const ruleTester = new RuleTester(); + +ruleTester.run( + 'prefer-tooltip-trigger-focus-test-utility', + PreferTooltipTriggerFocusTestUtility, + { + valid: [ + { + // No tooltip signal in the it block, rule does not apply + code: dedent` + it('focuses', () => { + fireEvent.focus(element); + }); + `, + languageOptions, + }, + { + // Uses the correct helper, no violation + code: dedent` + it('shows tooltip on focus', () => { + focusEuiToolTipTrigger(element); + queryByRole('tooltip'); + }); + `, + languageOptions, + }, + { + // `fireEvent.blur` is not flagged + code: dedent` + it('hides tooltip on blur', () => { + fireEvent.blur(element); + queryByRole('tooltip'); + }); + `, + languageOptions, + }, + { + // `someOtherObj.focus()` is not flagged + code: dedent` + it('shows tooltip', () => { + someOtherObj.focus(element); + queryByRole('tooltip'); + }); + `, + languageOptions, + }, + { + // `fireEvent.focus` outside any it block, not tracked + code: dedent` + queryByRole('tooltip'); + fireEvent.focus(element); + `, + languageOptions, + }, + { + // `fireEvent.focus` in an it block without tooltip signal, not flagged + code: dedent` + it('focuses an input', () => { + fireEvent.focus(input); + getByRole('textbox'); + }); + it('shows tooltip on hover', () => { + fireEvent.mouseOver(trigger); + queryByRole('tooltip'); + }); + `, + languageOptions, + }, + { + // `.euiToolTip` class selector present but no `fireEvent.focus` in that block, valid + code: dedent` + it('finds tooltip element', () => { + document.querySelector('.euiToolTip'); + }); + `, + languageOptions, + }, + { + // `.euiToolTipAnchor` in one block does not affect `fireEvent.focus` in a separate block + code: dedent` + it('renders tooltip when showTooltip=true', () => { + const { container } = render( {}} showTooltip />); + expect(container.querySelector('.euiToolTipAnchor')).not.toBeNull(); + }); + it('focuses the button', () => { + const { getByTestId } = render( {}} />); + fireEvent.focus(getByTestId('showExtraActionsButton')); + }); + `, + languageOptions, + }, + ], + + invalid: [ + { + // `queryByRole('tooltip')` in same it block, `fireEvent.focus` should be flagged + code: dedent` + it('shows tooltip on focus', () => { + fireEvent.focus(element); + queryByRole('tooltip'); + }); + `, + languageOptions, + errors: [{ messageId: 'preferTooltipTriggerFocusTestUtility' }], + }, + { + // `getByRole('tooltip')` in same it block, `fireEvent.focus` should be flagged + code: dedent` + it('shows tooltip on focus', () => { + fireEvent.focus(element); + getByRole('tooltip'); + }); + `, + languageOptions, + errors: [{ messageId: 'preferTooltipTriggerFocusTestUtility' }], + }, + { + // `screen.findByRole('tooltip')` in same it block, `fireEvent.focus` should be flagged + code: dedent` + it('shows tooltip on focus', async () => { + fireEvent.focus(element); + await screen.findByRole('tooltip'); + }); + `, + languageOptions, + errors: [{ messageId: 'preferTooltipTriggerFocusTestUtility' }], + }, + { + // test() block (alias for it), `fireEvent.focus` should be flagged + code: dedent` + test('shows tooltip on focus', () => { + fireEvent.focus(element); + queryByRole('tooltip'); + }); + `, + languageOptions, + errors: [{ messageId: 'preferTooltipTriggerFocusTestUtility' }], + }, + { + // tooltip query appears before `fireEvent.focus`, still flagged (whole-block analysis) + code: dedent` + it('shows tooltip on focus', () => { + queryByRole('tooltip'); + fireEvent.focus(element); + }); + `, + languageOptions, + errors: [{ messageId: 'preferTooltipTriggerFocusTestUtility' }], + }, + { + // Only the it block with a tooltip signal is flagged, not unrelated blocks + code: dedent` + it('focuses an input', () => { + fireEvent.focus(input); + getByRole('textbox'); + }); + it('shows tooltip on focus', () => { + fireEvent.focus(trigger); + queryByRole('tooltip'); + }); + `, + languageOptions, + errors: [{ messageId: 'preferTooltipTriggerFocusTestUtility' }], + }, + { + // Multiple `fireEvent.focus` calls in same tooltip it block, each flagged + code: dedent` + it('shows tooltip on focus', () => { + queryByRole('tooltip'); + fireEvent.focus(triggerA); + fireEvent.focus(triggerB); + }); + `, + languageOptions, + errors: [ + { messageId: 'preferTooltipTriggerFocusTestUtility' }, + { messageId: 'preferTooltipTriggerFocusTestUtility' }, + ], + }, + { + // `.euiToolTip` class selector in same it block, `fireEvent.focus` should be flagged + code: dedent` + it('shows tooltip on focus', () => { + fireEvent.focus(trigger); + document.querySelector('.euiToolTip'); + }); + `, + languageOptions, + errors: [{ messageId: 'preferTooltipTriggerFocusTestUtility' }], + }, + { + // `.euiToolTipAnchor` through `closest()` in same it block, `fireEvent.focus` should be flagged + code: dedent` + it('shows tooltip on focus', () => { + fireEvent.focus(trigger); + trigger.closest('.euiToolTipAnchor'); + }); + `, + languageOptions, + errors: [{ messageId: 'preferTooltipTriggerFocusTestUtility' }], + }, + { + // `[class*="euiToolTip"]` wildcard selector in same it block, `fireEvent.focus` should be flagged + code: dedent` + it('shows tooltip on focus', () => { + fireEvent.focus(trigger); + trigger.closest('[class*="euiToolTip"]'); + }); + `, + languageOptions, + errors: [{ messageId: 'preferTooltipTriggerFocusTestUtility' }], + }, + ], + } +); diff --git a/packages/eslint-plugin/src/rules/prefer_tooltip_trigger_focus_test_utility.ts b/packages/eslint-plugin/src/rules/prefer_tooltip_trigger_focus_test_utility.ts new file mode 100644 index 000000000000..162ec308445e --- /dev/null +++ b/packages/eslint-plugin/src/rules/prefer_tooltip_trigger_focus_test_utility.ts @@ -0,0 +1,114 @@ +/* + * 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 { TSESTree, ESLintUtils } from '@typescript-eslint/utils'; + +const TOOLTIP_ROLE_QUERIES = new Set([ + 'getByRole', + 'queryByRole', + 'findByRole', +]); + +const isTooltipRoleQuery = (node: TSESTree.CallExpression): boolean => { + const firstArg = node.arguments[0]; + + if (firstArg?.type !== 'Literal' || firstArg.value !== 'tooltip') + return false; + + const callee = node.callee; + + // `screen.getByRole('tooltip')` or destructured `getByRole('tooltip')` + if (callee.type === 'MemberExpression') + return ( + callee.property.type === 'Identifier' && + TOOLTIP_ROLE_QUERIES.has(callee.property.name) + ); + + if (callee.type === 'Identifier') + return TOOLTIP_ROLE_QUERIES.has(callee.name); + + return false; +}; + +const isFireEventFocus = (node: TSESTree.CallExpression): boolean => + node.callee.type === 'MemberExpression' && + node.callee.object.type === 'Identifier' && + node.callee.object.name === 'fireEvent' && + node.callee.property.type === 'Identifier' && + node.callee.property.name === 'focus'; + +const isEuiToolTipClassSelector = (node: TSESTree.Literal): boolean => + typeof node.value === 'string' && node.value.includes('euiToolTip'); + +const isTestBlock = (node: TSESTree.CallExpression): boolean => { + const callee = node.callee; + return ( + callee.type === 'Identifier' && + (callee.name === 'it' || callee.name === 'test') + ); +}; + +type BlockState = { + violations: TSESTree.CallExpression[]; + hasTooltipSignal: boolean; +}; + +export const PreferTooltipTriggerFocusTestUtility = + ESLintUtils.RuleCreator.withoutDocs({ + create(context) { + const blockStack: BlockState[] = []; + + const currentBlock = (): BlockState | undefined => + blockStack[blockStack.length - 1]; + + return { + CallExpression(node: TSESTree.CallExpression) { + if (isTestBlock(node)) { + blockStack.push({ violations: [], hasTooltipSignal: false }); + return; + } + + const block = currentBlock(); + if (!block) return; + if (isTooltipRoleQuery(node)) block.hasTooltipSignal = true; + if (isFireEventFocus(node)) block.violations.push(node); + }, + Literal(node: TSESTree.Literal) { + const block = currentBlock(); + if (!block) return; + if (isEuiToolTipClassSelector(node)) block.hasTooltipSignal = true; + }, + 'CallExpression:exit'(node: TSESTree.CallExpression) { + if (!isTestBlock(node)) return; + + const block = blockStack.pop(); + if (!block?.hasTooltipSignal) return; + + for (const violation of block.violations) { + context.report({ + node: violation, + messageId: 'preferTooltipTriggerFocusTestUtility', + }); + } + }, + }; + }, + meta: { + type: 'suggestion', + docs: { + description: + 'Prefer `focusEuiToolTipTrigger` over `fireEvent.focus` in tooltip tests. Plain `fireEvent.focus` does not simulate `:focus-visible` in jsdom and will not trigger the tooltip.', + }, + schema: [], + messages: { + preferTooltipTriggerFocusTestUtility: + 'Use `focusEuiToolTipTrigger` from EUI test utilities instead of `fireEvent.focus` when testing tooltip focus behavior.', + }, + }, + defaultOptions: [], + }); diff --git a/packages/eui/changelogs/upcoming/9624.md b/packages/eui/changelogs/upcoming/9624.md new file mode 100644 index 000000000000..830b9b67cb1e --- /dev/null +++ b/packages/eui/changelogs/upcoming/9624.md @@ -0,0 +1 @@ +- Updated `EuiToolTip` to respect input modality. Tooltip no longer persists on mouse-click focus or shows on programmatic focus return. 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..5d29efb68b92 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 @@ -13,6 +13,7 @@ import { render, waitForEuiToolTipHidden, waitForEuiToolTipVisible, + focusEuiToolTipTrigger, } from '../../../test/rtl'; import { requiredProps as commonProps } from '../../../test'; import { shouldRenderCustomStyles } from '../../../test/internal'; @@ -268,10 +269,13 @@ describe('EuiButtonGroup', () => { fireEvent.mouseOut(getByTestSubject('buttonWithTooltip')); await waitForEuiToolTipHidden(); - fireEvent.focus(getByTestSubject('buttonWithTooltip')); + const cleanup = focusEuiToolTipTrigger( + getByTestSubject('buttonWithTooltip') + ); await waitForEuiToolTipVisible(); fireEvent.blur(getByTestSubject('buttonWithTooltip')); await waitForEuiToolTipHidden(); + cleanup(); }); it('shows a tooltip on hover and focus when custom disabled via `hasAriaDisabled`', async () => { @@ -302,10 +306,13 @@ describe('EuiButtonGroup', () => { fireEvent.mouseOut(getByTestSubject('buttonWithTooltip').parentElement!); await waitForEuiToolTipHidden(); - fireEvent.focus(getByTestSubject('buttonWithTooltip')); + const cleanup = focusEuiToolTipTrigger( + getByTestSubject('buttonWithTooltip') + ); await waitForEuiToolTipVisible(); fireEvent.blur(getByTestSubject('buttonWithTooltip')); await waitForEuiToolTipHidden(); + cleanup(); }); it('allows customizing the tooltip via `toolTipProps`', async () => { 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..2a3b67000f6d 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 @@ -14,6 +14,7 @@ import { render, waitForEuiToolTipHidden, waitForEuiToolTipVisible, + focusEuiToolTipTrigger, } from '../../test/rtl'; import { EuiColorPickerSwatch } from './color_picker_swatch'; @@ -61,7 +62,7 @@ describe('EuiColorPickerSwatch', () => { const swatchElement = getByTestSubject('color-picker-swatch'); - fireEvent.focus(swatchElement); + const cleanup = focusEuiToolTipTrigger(swatchElement); await waitForEuiToolTipVisible(); @@ -70,6 +71,7 @@ describe('EuiColorPickerSwatch', () => { fireEvent.blur(swatchElement); await waitForEuiToolTipHidden(); + cleanup(); }); test('it does not render a color label tooltip when `showToolTip` is `false`', async () => { diff --git a/packages/eui/src/components/color_picker/hue.test.tsx b/packages/eui/src/components/color_picker/hue.test.tsx index bbd3d05213e3..55716bfefad7 100644 --- a/packages/eui/src/components/color_picker/hue.test.tsx +++ b/packages/eui/src/components/color_picker/hue.test.tsx @@ -13,6 +13,7 @@ import { render, waitForEuiToolTipHidden, waitForEuiToolTipVisible, + focusEuiToolTipTrigger, } from '../../test/rtl'; import { EuiHue } from './hue'; @@ -81,7 +82,7 @@ describe('EuiHue', () => { const thumbElement = document.querySelector('.euiHue__range')!; - fireEvent.focus(thumbElement); + const cleanup = focusEuiToolTipTrigger(thumbElement); await waitForEuiToolTipVisible(); @@ -90,5 +91,6 @@ describe('EuiHue', () => { fireEvent.blur(thumbElement); await waitForEuiToolTipHidden(); + cleanup(); }); }); diff --git a/packages/eui/src/components/color_picker/saturation.test.tsx b/packages/eui/src/components/color_picker/saturation.test.tsx index 94ee7c19f2a3..6bae379e3f7c 100644 --- a/packages/eui/src/components/color_picker/saturation.test.tsx +++ b/packages/eui/src/components/color_picker/saturation.test.tsx @@ -13,6 +13,7 @@ import { render, waitForEuiToolTipHidden, waitForEuiToolTipVisible, + focusEuiToolTipTrigger, } from '../../test/rtl'; import { EuiSaturation } from './saturation'; @@ -70,7 +71,7 @@ describe('EuiSaturation', () => { const thumbElement = document.querySelector('.euiSaturation__indicator')!; - fireEvent.focus(thumbElement); + const cleanup = focusEuiToolTipTrigger(thumbElement); await waitForEuiToolTipVisible(); @@ -79,5 +80,6 @@ describe('EuiSaturation', () => { fireEvent.blur(thumbElement); await waitForEuiToolTipHidden(); + cleanup(); }); }); 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 05e0cae93ea3..e311f3ae494b 100644 --- a/packages/eui/src/components/table/table_header_cell.test.tsx +++ b/packages/eui/src/components/table/table_header_cell.test.tsx @@ -280,7 +280,7 @@ describe('EuiTableHeaderCell', () => { 'info' ); - fireEvent.focus(getByTestSubject('icon')); + fireEvent.mouseOver(getByTestSubject('icon')); await waitForEuiToolTipVisible(); expect(getByTestSubject('tooltip')).toHaveTextContent( @@ -312,7 +312,7 @@ describe('EuiTableHeaderCell', () => { 'info' ); - fireEvent.focus(getByTestSubject('tableHeaderSortButton')); + fireEvent.mouseOver(getByTestSubject('tableHeaderSortButton')); await waitForEuiToolTipVisible(); expect(getByTestSubject('tooltip')).toHaveTextContent( 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..f1be405a2f7e 100644 --- a/packages/eui/src/components/tool_tip/tool_tip.spec.tsx +++ b/packages/eui/src/components/tool_tip/tool_tip.spec.tsx @@ -58,39 +58,45 @@ describe('EuiToolTip', () => { }); it('shows the tooltip on keyboard focus and hides it on blur', () => { - cy.mount( - - Show tooltip - + cy.realMount( + <> + + Show tooltip + + After + ); 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'); }); it('shows the tooltip on keyboard focus and hides it on blur for a custom disabled trigger button', () => { - cy.mount( - - - Show tooltip - - + cy.realMount( + <> + + + Show tooltip + + + After + ); 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'); }); it('does not show multiple tooltips if one tooltip toggle is focused and another tooltip toggle is hovered', () => { - cy.mount( + cy.realMount( <> Show tooltip A @@ -102,7 +108,7 @@ describe('EuiToolTip', () => { ); cy.get('[data-test-subj="tooltip"]').should('not.exist'); - cy.get('[data-test-subj="toggleToolTipA"]').focus(); + cy.realPress('Tab'); cy.contains('Tooltip A').should('exist'); cy.contains('Tooltip B').should('not.exist'); @@ -113,7 +119,7 @@ describe('EuiToolTip', () => { describe('Escape key', () => { it('hides the tooltip when rendered by itself', () => { - cy.mount( + cy.realMount( Show tooltip 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..52c010800b11 100644 --- a/packages/eui/src/components/tool_tip/tool_tip.stories.tsx +++ b/packages/eui/src/components/tool_tip/tool_tip.stories.tsx @@ -12,8 +12,8 @@ import type { Meta, StoryObj } from '@storybook/react'; import { enableFunctionToggleControls } from '../../../.storybook/utils'; import { LOKI_SELECTORS, lokiPlayDecorator } from '../../../.storybook/loki'; import { sleep } from '../../test'; -import { EuiFlexGroup } from '../flex'; import { EuiButton } from '../button'; +import { EuiFlexGroup } from '../flex'; import { EuiToolTip, EuiToolTipProps, 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 1ef7b5ac8170..f8543d98aaff 100644 --- a/packages/eui/src/components/tool_tip/tool_tip.test.tsx +++ b/packages/eui/src/components/tool_tip/tool_tip.test.tsx @@ -12,6 +12,7 @@ import { render, waitForEuiToolTipVisible, waitForEuiToolTipHidden, + focusEuiToolTipTrigger, } from '../../test/rtl'; import { requiredProps } from '../../test'; import { shouldRenderCustomStyles } from '../../test/internal'; @@ -44,7 +45,9 @@ describe('EuiToolTip', () => { }); describe('visibility', () => { - afterEach(() => jest.useRealTimers()); + afterEach(() => { + jest.useRealTimers(); + }); it('shows on mouseover and hides on mouseout', async () => { const { getByTestSubject, queryByRole } = render( @@ -65,55 +68,105 @@ describe('EuiToolTip', () => { }); it('shows on initial autoFocus in StrictMode', async () => { + const originalMatches = Element.prototype.matches; + const spy = jest + .spyOn(Element.prototype, 'matches') + .mockImplementation(function (this: Element, selector: string) { + return selector === ':focus-visible' + ? true + : originalMatches.call(this, selector); + }); + + try { + const { getByTestSubject, queryByRole } = render( + + + + + + ); + + await waitForEuiToolTipVisible(); + expect(queryByRole('tooltip')).toBeInTheDocument(); + + fireEvent.blur(getByTestSubject('trigger')); + await waitForEuiToolTipHidden(); + expect(queryByRole('tooltip')).not.toBeInTheDocument(); + } finally { + spy.mockRestore(); + } + }); + + it('shows on keyboard focus and hides on blur', async () => { const { getByTestSubject, queryByRole } = render( - - - - - + + + ); + expect(queryByRole('tooltip')).not.toBeInTheDocument(); + + const trigger = getByTestSubject('trigger'); + const cleanup = focusEuiToolTipTrigger(trigger); await waitForEuiToolTipVisible(); expect(queryByRole('tooltip')).toBeInTheDocument(); - fireEvent.blur(getByTestSubject('trigger')); + fireEvent.blur(trigger); await waitForEuiToolTipHidden(); expect(queryByRole('tooltip')).not.toBeInTheDocument(); + cleanup(); }); - it('shows on focus and hides on blur', async () => { + it('does not show on mouse-click focus', () => { const { getByTestSubject, queryByRole } = render( ); + // Intentionally using plain `fireEvent.focus` (no `:focus-visible`) to simulate mouse-click focus + // eslint-disable-next-line @elastic/eui/prefer-tooltip-trigger-focus-test-utility + fireEvent.focus(getByTestSubject('trigger')); expect(queryByRole('tooltip')).not.toBeInTheDocument(); + }); - fireEvent.focus(getByTestSubject('trigger')); + it('persists tooltip on mouseout when trigger was keyboard-focused', async () => { + const { getByTestSubject, queryByRole } = render( + + + + ); + + const trigger = getByTestSubject('trigger'); + const cleanup = focusEuiToolTipTrigger(trigger); await waitForEuiToolTipVisible(); - expect(queryByRole('tooltip')).toBeInTheDocument(); - fireEvent.blur(getByTestSubject('trigger')); - await waitForEuiToolTipHidden(); - expect(queryByRole('tooltip')).not.toBeInTheDocument(); + fireEvent.mouseOut(getByTestSubject('trigger')); + // Tooltip stays visible because `hasFocus=true` (keyboard focus) + expect(queryByRole('tooltip')).toBeInTheDocument(); + cleanup(); }); - it('keeps tooltip visible on mouseout when the trigger has focus', async () => { + it('hides tooltip on mouseout when trigger was mouse-click focused', async () => { const { getByTestSubject, queryByRole } = render( ); - fireEvent.focus(getByTestSubject('trigger')); + // Show on hover first, then click-focus (no `:focus-visible`) + fireEvent.mouseOver(getByTestSubject('trigger')); await waitForEuiToolTipVisible(); + // Intentionally using plain `fireEvent.focus` (no `:focus-visible`) to simulate mouse-click focus + // eslint-disable-next-line @elastic/eui/prefer-tooltip-trigger-focus-test-utility + fireEvent.focus(getByTestSubject('trigger')); fireEvent.mouseOut(getByTestSubject('trigger')); - // Tooltip stays visible because hasFocus=true - expect(queryByRole('tooltip')).toBeInTheDocument(); + // 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', () => { @@ -297,13 +350,15 @@ describe('EuiToolTip', () => { ); - fireEvent.focus(getByTestSubject('trigger')); + const trigger = getByTestSubject('trigger'); + const cleanup = focusEuiToolTipTrigger(trigger); await waitForEuiToolTipVisible(); fireEvent.keyDown(getByTestSubject('trigger'), { key: 'Escape' }); await waitForEuiToolTipHidden(); expect(parentKeyDown).not.toHaveBeenCalled(); + cleanup(); }); it('when true, Escape does not stop event propagation', async () => { @@ -316,13 +371,15 @@ describe('EuiToolTip', () => { ); - fireEvent.focus(getByTestSubject('trigger')); + const trigger = getByTestSubject('trigger'); + const cleanup = focusEuiToolTipTrigger(trigger); await waitForEuiToolTipVisible(); fireEvent.keyDown(getByTestSubject('trigger'), { key: 'Escape' }); await waitForEuiToolTipHidden(); expect(parentKeyDown).toHaveBeenCalledTimes(1); + cleanup(); }); it('when true, tooltip still renders visually', async () => { diff --git a/packages/eui/src/components/tool_tip/tool_tip.tsx b/packages/eui/src/components/tool_tip/tool_tip.tsx index fd8c7c1ad5c1..11306a205b55 100644 --- a/packages/eui/src/components/tool_tip/tool_tip.tsx +++ b/packages/eui/src/components/tool_tip/tool_tip.tsx @@ -16,7 +16,8 @@ import React, { useState, ReactElement, ReactNode, - MouseEvent as ReactMouseEvent, + type MouseEvent as ReactMouseEvent, + type FocusEvent as ReactFocusEvent, HTMLAttributes, } from 'react'; import classNames from 'classnames'; @@ -46,6 +47,18 @@ const delayToMsMap: { [key in ToolTipDelay]: number } = { long: 250 * 5, }; +/** + * `:focus-visible` may throw in browsers that don't support the selector, + * fall back to treating all focus as visible so tooltips still appear. + */ +const isFocusVisible = (element: Element): boolean => { + try { + return element.matches(':focus-visible'); + } catch { + return element.matches(':focus'); + } +}; + interface ToolTipStyles { top: number; left: number | 'auto'; @@ -277,7 +290,11 @@ export const EuiToolTip = forwardRef( // 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)) { + if ( + anchorRef.current?.contains(document.activeElement) && + document.activeElement != null && + isFocusVisible(document.activeElement) + ) { setHasFocus(true); showToolTip(); } @@ -338,10 +355,15 @@ export const EuiToolTip = forwardRef( componentDefaultsContext.EuiToolTip, ]); - const onFocus = useCallback(() => { - setHasFocus(true); - showToolTip(); - }, [showToolTip]); + const onFocus = useCallback( + (e: ReactFocusEvent) => { + if (isFocusVisible(e.target as Element)) { + setHasFocus(true); + showToolTip(); + } + }, + [showToolTip] + ); const onBlur = useCallback(() => { setHasFocus(false); 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 63a2797586aa..47444dc3a847 100644 --- a/packages/eui/src/components/tool_tip/tool_tip_anchor.tsx +++ b/packages/eui/src/components/tool_tip/tool_tip_anchor.tsx @@ -6,7 +6,12 @@ * Side Public License, v 1. */ -import React, { cloneElement, HTMLAttributes, forwardRef } from 'react'; +import React, { + cloneElement, + HTMLAttributes, + forwardRef, + type FocusEvent as ReactFocusEvent, +} from 'react'; import classNames from 'classnames'; import { useGeneratedHtmlId, useEuiMemoizedStyles } from '../../services'; @@ -19,7 +24,7 @@ export type EuiToolTipAnchorProps = Omit< > & Required> & { onBlur: () => void; - onFocus: () => void; + onFocus: (e: ReactFocusEvent) => void; isVisible: boolean; }; @@ -75,7 +80,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/test/rtl/component_helpers.ts b/packages/eui/src/test/rtl/component_helpers.ts index 4351f43e2a62..6d981d99d708 100644 --- a/packages/eui/src/test/rtl/component_helpers.ts +++ b/packages/eui/src/test/rtl/component_helpers.ts @@ -45,6 +45,38 @@ export const waitForEuiToolTipHidden = async () => expect(tooltip).toBeNull(); }); +/** + * jsdom does not track keyboard vs. mouse input modality, so `:focus-visible` + * always returns false. Call this before `fireEvent.focus()` on an element that + * should be treated as keyboard-focused. + * + * Returns a cleanup function, call it after test assertions to restore the spy. + */ +export const simulateFocusVisible = (element: Element): (() => void) => { + const originalMatches = Element.prototype.matches.bind(element); + const spy = jest + .spyOn(element, 'matches') + .mockImplementation((selector: string) => + selector === ':focus-visible' ? true : originalMatches(selector) + ); + + return () => spy.mockRestore(); +}; + +/** + * Prefer this over `fireEvent.focus()` in tooltip tests. Plain `fireEvent.focus` + * does not set `:focus-visible` in jsdom and will not trigger the tooltip. + * + * Returns a cleanup function to restore the mock after assertions. + */ +export const focusEuiToolTipTrigger = (element: Element): (() => void) => { + const cleanup = simulateFocusVisible(element); + + fireEvent.focus(element); + + return cleanup; +}; + /** * EuiComboBox */