Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/eslint-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(<MyComponent />);
const cleanup = focusEuiToolTipTrigger(getByRole('button'));
expect(getByRole('tooltip')).toBeInTheDocument();
Comment thread
weronikaolejniczak marked this conversation as resolved.
cleanup();
Copy link
Copy Markdown
Contributor

@mgadewoll mgadewoll Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on #9624 (comment) - We should probably highlight using try...finally here to ensure the cleanup is always called (independent of potential assertion failures).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We agreed to update this after merging.

});
```


## Testing

### Running unit tests
Expand Down
1 change: 1 addition & 0 deletions packages/eslint-plugin/changelogs/upcoming/9624.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 6 additions & 3 deletions packages/eslint-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand All @@ -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: {
Expand All @@ -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',
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<ExtraActionsButton onClick={() => {}} showTooltip />);
expect(container.querySelector('.euiToolTipAnchor')).not.toBeNull();
});
it('focuses the button', () => {
const { getByTestId } = render(<ExtraActionsButton onClick={() => {}} />);
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' }],
},
],
}
);
Loading