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
*/