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
3 changes: 3 additions & 0 deletions packages/eslint-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,9 @@ Ensure that appropriate aria-attributes are set for `EuiBetaBadge`, `EuiButtonIc

Ensure `EuiTooltip` components are anchored to elements that can receive keyboard focus, making them accessible to all users. When using non-interactive elements (like `span`or `EuiText`) as tooltip anchors, they must include `tabIndex={0}` to be keyboard-focusable. For better accessibility, prefer using semantic interactive components (like `EuiButton` or `EuiLink`) which are focusable by default.

### `@elastic/eui/accessible-interactive-element`
Ensure interactive EUI components (like e.g. `EuiLink`, `EuiButton`, `EuiRadio`) remain accessible by prohibiting `tabIndex={-1}`, which removes them from keyboard navigation.

## Testing

### Running unit tests
Expand Down
1 change: 1 addition & 0 deletions packages/eslint-plugin/changelogs/upcoming/9093.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Added new `accessible-interactive-element` rule.
6 changes: 5 additions & 1 deletion packages/eslint-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* Side Public License, v 1.
*/

import { CallOutAnnounceOnMount } from './rules/a11y/callout_announce_on_mount';

import { HrefOnClick } from './rules/href_or_on_click';
import { NoRestrictedEuiImports } from './rules/no_restricted_eui_imports';
import { NoCssColor } from './rules/no_css_color';
Expand All @@ -18,6 +18,8 @@ import { PreferEuiIconTip } from './rules/a11y/prefer_eui_icon_tip';
import { NoUnnamedRadioGroup } from './rules/a11y/no_unnamed_radio_group';
import { NoUnnamedInteractiveElement } from './rules/a11y/no_unnamed_interactive_element';
import { TooltipFocusableAnchor } from './rules/a11y/tooltip_focusable_anchor';
import { CallOutAnnounceOnMount } from './rules/a11y/callout_announce_on_mount';
import { AccessibleInteractiveElements } from './rules/a11y/accessible_interactive_element';

const config = {
rules: {
Expand All @@ -32,6 +34,7 @@ const config = {
'callout-announce-on-mount': CallOutAnnounceOnMount,
'no-unnamed-interactive-element': NoUnnamedInteractiveElement,
'tooltip-focusable-anchor': TooltipFocusableAnchor,
'accessible-interactive-element': AccessibleInteractiveElements,
},
configs: {
recommended: {
Expand All @@ -48,6 +51,7 @@ const config = {
'@elastic/eui/callout-announce-on-mount': 'warn',
'@elastic/eui/no-unnamed-interactive-element': 'warn',
'@elastic/eui/tooltip-focusable-anchor': 'warn',
'@elastic/eui/accessible-interactive-element': 'warn',
},
},
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* 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 { ESLintUtils, type TSESTree } from '@typescript-eslint/utils';
import { INTERACTIVE_EUI_COMPONENTS } from '../../utils/constants';
import { extractAttrValue } from "../../utils/get_attr_value";

export const AccessibleInteractiveElements = ESLintUtils.RuleCreator.withoutDocs({
create(context) {
return {
JSXOpeningElement(node) {
if (node.name.type !== 'JSXIdentifier') return;
const componentName = node.name.name;
if (!INTERACTIVE_EUI_COMPONENTS.includes(componentName)) return;

const tabIndexAttribute = node.attributes.find(
(attr): attr is TSESTree.JSXAttribute =>
attr.type === 'JSXAttribute' &&
attr.name.type === 'JSXIdentifier' &&
attr.name.name === 'tabIndex'
);


if (tabIndexAttribute && (Number(extractAttrValue(context, tabIndexAttribute)) || 0) === -1) {
context.report({
node: node,
messageId: 'disallowTabIndex',
data: { component: componentName },
fix: fixer => fixer.remove(tabIndexAttribute),
});
Comment on lines +21 to +35
Copy link
Contributor

Choose a reason for hiding this comment

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

doubt:

So we cannot reuse findAttrValue here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hi, maybe name not so clear but here for fix method we need ref to tabIndexAttribute

}
},
};
},
meta: {
type: 'problem',
docs: {
description: 'Ensure interactive EUI components remain accessible by prohibiting tabIndex={-1}, which removes them from keyboard navigation.',
},
fixable: 'code',
schema: [],
messages: {
disallowTabIndex: '{{component}} is an interactive EUI component and must not use tabIndex={-1}, as this removes it from keyboard navigation and impairs accessibility.',
},
},
defaultOptions: [],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/*
* 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 { AccessibleInteractiveElements } from './accessible_interactive_element';

const languageOptions = {
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
};

const ruleTester = new RuleTester();

ruleTester.run('accessible-interactive-element', AccessibleInteractiveElements, {
valid: [
{
code: dedent`
const MyComponent = () => (
<EuiButton>Click me</EuiButton>
)
`,
languageOptions,
},
{
code: dedent`
const MyComponent = () => (
<EuiButton tabIndex={0}>Focusable</EuiButton>
)
`,
languageOptions,
},
{
code: dedent`
const MyComponent = () => (
<EuiLink href="#">Link</EuiLink>
)
`,
languageOptions,
},
{
code: dedent`
const MyComponent = () => (
<span tabIndex={-1}>Not interactive EUI</span>
)
`,
languageOptions,
},
{
code: dedent`
const MyComponent = () => (
<EuiButton tabIndex={1}>Custom tab order</EuiButton>
)
`,
languageOptions,
},
],
invalid: [
{
code: dedent`
const MyComponent = () => (
<EuiButton tabIndex={-1}>Should not be focusable</EuiButton>
)
`,
output: dedent`
const MyComponent = () => (
<EuiButton >Should not be focusable</EuiButton>
)
`,
languageOptions,
errors: [
{
messageId: 'disallowTabIndex',
data: { component: 'EuiButton' },
},
],
},
{
code: dedent`
const MyComponent = () => (
<EuiLink tabIndex={-1} href="#">Link</EuiLink>
)
`,
output: dedent`
const MyComponent = () => (
<EuiLink href="#">Link</EuiLink>
)
`,
languageOptions,
errors: [
{
messageId: 'disallowTabIndex',
data: { component: 'EuiLink' },
},
],
},
{
code: dedent`
const MyComponent = () => (
<EuiButton tabIndex={-1} color="primary">Primary</EuiButton>
)
`,
output: dedent`
const MyComponent = () => (
<EuiButton color="primary">Primary</EuiButton>
)
`,
languageOptions,
errors: [
{
messageId: 'disallowTabIndex',
data: { component: 'EuiButton' },
},
],
},
{
code: dedent`
const MyComponent = () => (
<EuiBadge tabIndex={-1} />
)
`,
output: dedent`
const MyComponent = () => (
<EuiBadge />
)
`,
languageOptions,
errors: [
{
messageId: 'disallowTabIndex',
data: { component: 'EuiBadge' },
},
],
},
],
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import { type TSESTree, ESLintUtils } from '@typescript-eslint/utils';
import { getAttrValue } from '../../utils/get_attr_value';
import { findAttrValue } from '../../utils/get_attr_value';
import { areAttrsEqual } from '../../utils/are_attrs_equal';

const formControlComponent = 'EuiFormRow';
Expand Down Expand Up @@ -37,7 +37,7 @@ export const ConsistentIsInvalidProps = ESLintUtils.RuleCreator.withoutDocs({
return;
}

const formRowIsInvalid = getAttrValue(
const formRowIsInvalid = findAttrValue(
context,
openingElement.attributes,
'isInvalid'
Expand All @@ -58,7 +58,7 @@ export const ConsistentIsInvalidProps = ESLintUtils.RuleCreator.withoutDocs({
return;
}

const childIsInvalid = getAttrValue(
const childIsInvalid = findAttrValue(
context,
childElement.openingElement.attributes,
'isInvalid'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import { type TSESTree, ESLintUtils } from '@typescript-eslint/utils';
import { getAttrValue } from '../../utils/get_attr_value';
import { findAttrValue } from '../../utils/get_attr_value';
import { areAttrsEqual } from '../../utils/are_attrs_equal';

const tooltipComponent = 'EuiToolTip';
Expand All @@ -28,7 +28,7 @@ export const ScreenReaderOutputDisabledTooltip =
return;
}

const tooltipContent = getAttrValue(
const tooltipContent = findAttrValue(
context,
openingElement.attributes,
'content'
Expand Down Expand Up @@ -56,7 +56,7 @@ export const ScreenReaderOutputDisabledTooltip =
return;
}

const ariaLabel = getAttrValue(
const ariaLabel = findAttrValue(
context,
buttonElement.openingElement.attributes,
'aria-label'
Expand Down
54 changes: 54 additions & 0 deletions packages/eslint-plugin/src/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,57 @@ export const NON_INTERACTIVE_HTML_TAGS = [
'tr',
'ul'
];

/**
* A list of Elastic UI (EUI) React components that are considered **interactive**.
*
* These components are designed to be focusable and respond to user actions
* such as clicks, keyboard events, or other interactions. Use this constant
* when you need to determine if a given EUI component is inherently interactive,
* for example, when enforcing accessibility rules or filtering components
* for focus management.
*
* This list should be kept up to date with EUI's interactive component offerings.
*/
export const INTERACTIVE_EUI_COMPONENTS = [
'EuiLink',
'EuiButton',
'EuiButtonEmpty',
'EuiButtonIcon',
'EuiFacetButton',
'EuiHeaderLink',
'EuiHeaderSectionItemButton',
'EuiHeaderLogo',
'EuiListGroupItem',
'EuiPinnableListGroup',
'EuiSideNav',
'EuiBreadcrumbs',
'EuiTab',
'EuiContextMenuItem',
'EuiKeyPadMenuItem',
'EuiPagination',
'EuiTreeView',
'EuiStepHorizontal',
'EuiCard',
'EuiCheckableCard',
'EuiBasicTable',
'EuiInMemoryTable',
'EuiFilterButton',
'EuiFilterSelectItem',
'EuiFilterSelectable',
'EuiBadge',
'EuiBetaBadge',
'EuiSelectable',
'EuiComboBox',
'EuiSuperSelect',
'EuiSelect',
'EuiCheckbox',
'EuiRadio',
'EuiSwitch',
'EuiButtonGroup',
'EuiRange',
'EuiDualRange',
'EuiColorPicker',
'EuiDatePicker',
'EuiSuperDatePicker'
];
14 changes: 12 additions & 2 deletions packages/eslint-plugin/src/utils/get_attr_value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,30 @@

import { type TSESTree, type TSESLint} from '@typescript-eslint/utils';

export function getAttrValue<
export function findAttrValue<
TContext extends TSESLint.RuleContext<string, unknown[]>
>(
context: TContext,
attributes: TSESTree.JSXOpeningElement['attributes'],
attrName: string
): string | undefined {
) {
const attr = attributes.find(
(attr): attr is TSESTree.JSXAttribute =>
attr.type === 'JSXAttribute' &&
attr.name.type === 'JSXIdentifier' &&
attr.name.name === attrName
);

return extractAttrValue(context, attr);
}

export function extractAttrValue<
TContext extends TSESLint.RuleContext<string, unknown[]>
>(
context: TContext,
attr: TSESTree.JSXAttribute | undefined,
): string | undefined {

if (!attr?.value) {
return undefined;
}
Expand Down