diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md
index eb9c1a2f016..608991c2173 100644
--- a/packages/eslint-plugin/README.md
+++ b/packages/eslint-plugin/README.md
@@ -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
diff --git a/packages/eslint-plugin/changelogs/upcoming/9093.md b/packages/eslint-plugin/changelogs/upcoming/9093.md
new file mode 100644
index 00000000000..415b49efedb
--- /dev/null
+++ b/packages/eslint-plugin/changelogs/upcoming/9093.md
@@ -0,0 +1 @@
+- Added new `accessible-interactive-element` rule.
diff --git a/packages/eslint-plugin/src/index.ts b/packages/eslint-plugin/src/index.ts
index 8600d2b4391..34ecddb5451 100644
--- a/packages/eslint-plugin/src/index.ts
+++ b/packages/eslint-plugin/src/index.ts
@@ -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';
@@ -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: {
@@ -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: {
@@ -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',
},
},
},
diff --git a/packages/eslint-plugin/src/rules/a11y/accessible_interactive_element.ts b/packages/eslint-plugin/src/rules/a11y/accessible_interactive_element.ts
new file mode 100644
index 00000000000..786623c1f70
--- /dev/null
+++ b/packages/eslint-plugin/src/rules/a11y/accessible_interactive_element.ts
@@ -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),
+ });
+ }
+ },
+ };
+ },
+ 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: [],
+});
diff --git a/packages/eslint-plugin/src/rules/a11y/accessible_interactive_elements.test.ts b/packages/eslint-plugin/src/rules/a11y/accessible_interactive_elements.test.ts
new file mode 100644
index 00000000000..48cf45277ef
--- /dev/null
+++ b/packages/eslint-plugin/src/rules/a11y/accessible_interactive_elements.test.ts
@@ -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 = () => (
+ Click me
+ )
+ `,
+ languageOptions,
+ },
+ {
+ code: dedent`
+ const MyComponent = () => (
+ Focusable
+ )
+ `,
+ languageOptions,
+ },
+ {
+ code: dedent`
+ const MyComponent = () => (
+ Link
+ )
+ `,
+ languageOptions,
+ },
+ {
+ code: dedent`
+ const MyComponent = () => (
+ Not interactive EUI
+ )
+ `,
+ languageOptions,
+ },
+ {
+ code: dedent`
+ const MyComponent = () => (
+ Custom tab order
+ )
+ `,
+ languageOptions,
+ },
+ ],
+ invalid: [
+ {
+ code: dedent`
+ const MyComponent = () => (
+ Should not be focusable
+ )
+ `,
+ output: dedent`
+ const MyComponent = () => (
+ Should not be focusable
+ )
+ `,
+ languageOptions,
+ errors: [
+ {
+ messageId: 'disallowTabIndex',
+ data: { component: 'EuiButton' },
+ },
+ ],
+ },
+ {
+ code: dedent`
+ const MyComponent = () => (
+ Link
+ )
+ `,
+ output: dedent`
+ const MyComponent = () => (
+ Link
+ )
+ `,
+ languageOptions,
+ errors: [
+ {
+ messageId: 'disallowTabIndex',
+ data: { component: 'EuiLink' },
+ },
+ ],
+ },
+ {
+ code: dedent`
+ const MyComponent = () => (
+ Primary
+ )
+ `,
+ output: dedent`
+ const MyComponent = () => (
+ Primary
+ )
+ `,
+ languageOptions,
+ errors: [
+ {
+ messageId: 'disallowTabIndex',
+ data: { component: 'EuiButton' },
+ },
+ ],
+ },
+ {
+ code: dedent`
+ const MyComponent = () => (
+
+ )
+ `,
+ output: dedent`
+ const MyComponent = () => (
+
+ )
+ `,
+ languageOptions,
+ errors: [
+ {
+ messageId: 'disallowTabIndex',
+ data: { component: 'EuiBadge' },
+ },
+ ],
+ },
+ ],
+});
diff --git a/packages/eslint-plugin/src/rules/a11y/consistent_is_invalid_props.ts b/packages/eslint-plugin/src/rules/a11y/consistent_is_invalid_props.ts
index d3c95b9a5bf..683addc0650 100644
--- a/packages/eslint-plugin/src/rules/a11y/consistent_is_invalid_props.ts
+++ b/packages/eslint-plugin/src/rules/a11y/consistent_is_invalid_props.ts
@@ -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';
@@ -37,7 +37,7 @@ export const ConsistentIsInvalidProps = ESLintUtils.RuleCreator.withoutDocs({
return;
}
- const formRowIsInvalid = getAttrValue(
+ const formRowIsInvalid = findAttrValue(
context,
openingElement.attributes,
'isInvalid'
@@ -58,7 +58,7 @@ export const ConsistentIsInvalidProps = ESLintUtils.RuleCreator.withoutDocs({
return;
}
- const childIsInvalid = getAttrValue(
+ const childIsInvalid = findAttrValue(
context,
childElement.openingElement.attributes,
'isInvalid'
diff --git a/packages/eslint-plugin/src/rules/a11y/sr_output_disabled_tooltip.ts b/packages/eslint-plugin/src/rules/a11y/sr_output_disabled_tooltip.ts
index eb53cd77608..4800c8f8599 100644
--- a/packages/eslint-plugin/src/rules/a11y/sr_output_disabled_tooltip.ts
+++ b/packages/eslint-plugin/src/rules/a11y/sr_output_disabled_tooltip.ts
@@ -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';
@@ -28,7 +28,7 @@ export const ScreenReaderOutputDisabledTooltip =
return;
}
- const tooltipContent = getAttrValue(
+ const tooltipContent = findAttrValue(
context,
openingElement.attributes,
'content'
@@ -56,7 +56,7 @@ export const ScreenReaderOutputDisabledTooltip =
return;
}
- const ariaLabel = getAttrValue(
+ const ariaLabel = findAttrValue(
context,
buttonElement.openingElement.attributes,
'aria-label'
diff --git a/packages/eslint-plugin/src/utils/constants.ts b/packages/eslint-plugin/src/utils/constants.ts
index 2299c27549b..02013a93239 100644
--- a/packages/eslint-plugin/src/utils/constants.ts
+++ b/packages/eslint-plugin/src/utils/constants.ts
@@ -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'
+];
diff --git a/packages/eslint-plugin/src/utils/get_attr_value.ts b/packages/eslint-plugin/src/utils/get_attr_value.ts
index 2b49666b984..07dbef8d7f5 100644
--- a/packages/eslint-plugin/src/utils/get_attr_value.ts
+++ b/packages/eslint-plugin/src/utils/get_attr_value.ts
@@ -8,13 +8,13 @@
import { type TSESTree, type TSESLint} from '@typescript-eslint/utils';
-export function getAttrValue<
+export function findAttrValue<
TContext extends TSESLint.RuleContext
>(
context: TContext,
attributes: TSESTree.JSXOpeningElement['attributes'],
attrName: string
-): string | undefined {
+) {
const attr = attributes.find(
(attr): attr is TSESTree.JSXAttribute =>
attr.type === 'JSXAttribute' &&
@@ -22,6 +22,16 @@ export function getAttrValue<
attr.name.name === attrName
);
+ return extractAttrValue(context, attr);
+}
+
+export function extractAttrValue<
+ TContext extends TSESLint.RuleContext
+>(
+ context: TContext,
+ attr: TSESTree.JSXAttribute | undefined,
+): string | undefined {
+
if (!attr?.value) {
return undefined;
}