diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md
index ce9a520840c5..3015ba376493 100644
--- a/packages/eslint-plugin/README.md
+++ b/packages/eslint-plugin/README.md
@@ -169,12 +169,19 @@ Ensure `EuiInMemoryTable`, `EuiBasicTable` have a `tableCaption` property for ac
### `@elastic/eui/badge-accessibility-rules`
-Ensure the EuiBadge includes appropriate accessibility attributes.
+Ensure the `EuiBadge` includes appropriate accessibility attributes.
- `iconOnClick` and `onClick` must not reference the same callback. The rule autofixes by removing `iconOnClick`.
- `iconOnClickAriaLabel` is only valid when `iconOnClick` is present. The rule autofixes by removing `iconOnClickAriaLabel`.
- `onClickAriaLabel` is only valid when `onClick` is present. The rule autofixes by removing `onClickAriaLabel`.
+### `@elastic/eui/icon-accessibility-rules`
+
+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`
+
## Testing
### Running unit tests
diff --git a/packages/eslint-plugin/changelogs/upcoming/9357.md b/packages/eslint-plugin/changelogs/upcoming/9357.md
new file mode 100644
index 000000000000..156a16b2241c
--- /dev/null
+++ b/packages/eslint-plugin/changelogs/upcoming/9357.md
@@ -0,0 +1 @@
+- Added new `icon-accessibility-rules` rule.
diff --git a/packages/eslint-plugin/src/index.ts b/packages/eslint-plugin/src/index.ts
index 432f2904ca34..849f9951008b 100644
--- a/packages/eslint-plugin/src/index.ts
+++ b/packages/eslint-plugin/src/index.ts
@@ -22,6 +22,7 @@ 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 { EuiBadgeAccessibilityRules } from './rules/a11y/badge_accessibility_rules';
+import { EuiIconAccessibilityRules } from './rules/a11y/icon_accessibility_rules';
const config = {
rules: {
@@ -40,6 +41,7 @@ const config = {
'sr-output-disabled-tooltip': ScreenReaderOutputDisabledTooltip,
'tooltip-focusable-anchor': TooltipFocusableAnchor,
'badge-accessibility-rules': EuiBadgeAccessibilityRules,
+ 'icon-accessibility-rules': EuiIconAccessibilityRules
},
configs: {
recommended: {
@@ -60,6 +62,7 @@ const config = {
'@elastic/eui/sr-output-disabled-tooltip': 'warn',
'@elastic/eui/tooltip-focusable-anchor': 'warn',
'@elastic/eui/badge-accessibility-rules': 'warn',
+ '@elastic/eui/icon-accessibility-rules': 'warn',
},
},
},
diff --git a/packages/eslint-plugin/src/rules/a11y/icon_accessibility_rules.test.ts b/packages/eslint-plugin/src/rules/a11y/icon_accessibility_rules.test.ts
new file mode 100644
index 000000000000..e37b2c68c9e3
--- /dev/null
+++ b/packages/eslint-plugin/src/rules/a11y/icon_accessibility_rules.test.ts
@@ -0,0 +1,107 @@
+import dedent from 'dedent';
+import { RuleTester } from '@typescript-eslint/rule-tester';
+import { EuiIconAccessibilityRules } from './icon_accessibility_rules';
+
+const languageOptions = {
+ parserOptions: {
+ ecmaFeatures: {
+ jsx: true,
+ },
+ },
+};
+
+const ruleTester = new RuleTester();
+
+ruleTester.run('EuiIconAccessibilityRules', EuiIconAccessibilityRules, {
+ valid: [
+ {
+ code: dedent`
+ const MyComponent = () => (
+
+ )
+ `,
+ languageOptions,
+ },
+ {
+ code: dedent`
+ const MyComponent = () => (
+
+ )
+ `,
+ languageOptions,
+ },
+ {
+ code: dedent`
+ const MyComponent = () => (
+
+ )
+ `,
+ languageOptions,
+ },
+ {
+ code: dedent`
+ const MyComponent = () => (
+
+ )
+ `,
+ languageOptions,
+ },
+ {
+ code: dedent`
+ const MyComponent = () => (
+
+ )
+ `,
+ languageOptions,
+ },
+ ],
+ invalid: [
+ // Missing accessible name -> autofix adds aria-hidden={true}
+ {
+ code: dedent`
+ const MyComponent = () => ()
+ `,
+ output: dedent`
+ const MyComponent = () => ()
+ `,
+ languageOptions,
+ errors: [
+ {
+ messageId: 'missingTitleOrAriaHidden',
+ },
+ ],
+ },
+ // tabIndex with aria-hidden={true} -> error and autofix removes aria-hidden
+ {
+ code: dedent`
+ const MyComponent = () => (
+
+ )
+ `,
+ output: dedent`
+ const MyComponent = () => (
+
+ )
+ `,
+ languageOptions,
+ errors: [
+ {
+ messageId: 'tabIndexWithAriaHidden',
+ },
+ ],
+ },
+ // tabIndex without accessible name and without aria-hidden -> error
+ {
+ code: dedent`
+ const MyComponent = () => ()
+ `,
+ output: null, // no autofix
+ languageOptions,
+ errors: [
+ {
+ messageId: 'missingTitleOrAriaHidden',
+ },
+ ],
+ },
+ ],
+});
diff --git a/packages/eslint-plugin/src/rules/a11y/icon_accessibility_rules.ts b/packages/eslint-plugin/src/rules/a11y/icon_accessibility_rules.ts
new file mode 100644
index 000000000000..c8f2765c5d83
--- /dev/null
+++ b/packages/eslint-plugin/src/rules/a11y/icon_accessibility_rules.ts
@@ -0,0 +1,95 @@
+import { ESLintUtils, TSESTree } from '@typescript-eslint/utils';
+import { removeAttribute } from '../../utils/remove_attr';
+
+const COMPONENT = 'EuiIcon';
+
+export const EuiIconAccessibilityRules = ESLintUtils.RuleCreator.withoutDocs({
+ create(context) {
+
+ return {
+ JSXElement(node: TSESTree.JSXElement) {
+ const { openingElement } = node;
+ if (
+ openingElement.name.type !== 'JSXIdentifier' ||
+ openingElement.name.name !== COMPONENT
+ ) {
+ return;
+ }
+
+ let ariaHiddenAttr: TSESTree.JSXAttribute | undefined;
+ let tabIndexAttr: TSESTree.JSXAttribute | undefined;
+ let isIconNamed = false;
+
+ for (const attr of openingElement.attributes) {
+ if (attr.type !== 'JSXAttribute' || attr.name.type !== 'JSXIdentifier') continue;
+ const name = attr.name.name;
+ if (name === 'aria-hidden') ariaHiddenAttr = attr;
+ if (name === 'tabIndex') tabIndexAttr = attr;
+ if (['title', 'aria-labelledby', 'aria-label'].includes(name)) {
+ isIconNamed = true;
+ }
+ }
+
+ const hasAriaHiddenTrue =
+ !!ariaHiddenAttr &&
+ ariaHiddenAttr.value &&
+ (
+ // aria-hidden={true}
+ (ariaHiddenAttr.value.type === 'JSXExpressionContainer' &&
+ ariaHiddenAttr.value.expression.type === 'Literal' &&
+ ariaHiddenAttr.value.expression.value === true) ||
+ // aria-hidden='true'
+ (ariaHiddenAttr.value.type === 'Literal' &&
+ ariaHiddenAttr.value.value === 'true')
+ );
+
+ // Case: `tabIndex` and `aria-hidden` cannot be used together
+ if (tabIndexAttr && hasAriaHiddenTrue) {
+ context.report({
+ node: openingElement,
+ messageId: 'tabIndexWithAriaHidden',
+ fix: fixer => {
+ if (!ariaHiddenAttr?.range) return null;
+ const [start, end] = removeAttribute(context, ariaHiddenAttr);
+
+ return [fixer.removeRange([start, end])];
+ }
+ });
+ return;
+ }
+
+ // Require accessible name or `aria-hidden={true}`;
+ if (!isIconNamed && !hasAriaHiddenTrue) {
+ context.report({
+ node: openingElement,
+ messageId: 'missingTitleOrAriaHidden',
+ fix: fixer => {
+ if (tabIndexAttr) return null;
+
+ const end = openingElement.range[1];
+ const insertPos = openingElement.selfClosing ? end - 2 : end - 1; // before '/>' or '>'
+ const insertRange = [insertPos, insertPos] as const;
+
+ return [fixer.insertTextBeforeRange(insertRange, ' aria-hidden={true}')];
+ }
+ });
+ }
+ }
+ };
+ },
+ meta: {
+ type: 'suggestion',
+ docs: {
+ description: `Ensure the EuiIcon includes appropriate accessibility attributes`
+ },
+ fixable: 'code',
+ schema: [],
+ messages: {
+ missingTitleOrAriaHidden:
+ 'Add a `title`, `aria-label`, or `aria-labelledby` to EuiIcon, or set `aria-hidden={true}` if it is decorative.',
+ tabIndexWithAriaHidden:
+ 'Do not use `tabIndex` together with `aria-hidden`. Remove `aria-hidden` or provide an accessible name.'
+ }
+ },
+ defaultOptions: []
+});
diff --git a/packages/eslint-plugin/src/utils/remove_attr.ts b/packages/eslint-plugin/src/utils/remove_attr.ts
new file mode 100644
index 000000000000..f86c6b1c8bce
--- /dev/null
+++ b/packages/eslint-plugin/src/utils/remove_attr.ts
@@ -0,0 +1,38 @@
+import { type TSESTree, type TSESLint} from '@typescript-eslint/utils';
+
+/**
+ * Computes the removal range for a JSX attribute, including a preceding space
+ * when present, to keep formatting intact after autofix.
+ *
+ * This helper is useful in ESLint rule fixers when calling `fixer.removeRange(...)`,
+ * ensuring that the attribute and its leading space are removed cleanly.
+ *
+ * @typeParam TContext - An ESLint rule context type extending `TSESLint.RuleContext`.
+ * @param context - The current ESLint rule context providing access to `SourceCode`.
+ * @param attr - The JSX attribute node to remove.
+ * @returns A readonly tuple `[start, end]` representing the inclusive start and exclusive end indexes for removal.
+ *
+ * @example
+ * ```ts
+ * context.report({
+ * node: openingElement,
+ * messageId: 'removeAttr',
+ * fix: fixer => {
+ * const [start, end] = removeAttribute(context, ariaHiddenAttr);
+ * return fixer.removeRange([start, end]);
+ * },
+ * });
+ **/
+
+export function removeAttribute<
+ TContext extends TSESLint.RuleContext
+>(
+ context: TContext,
+ attr: TSESTree.JSXAttribute) {
+ const { sourceCode } = context;
+ const start = attr.range[0];
+ const before = sourceCode.text[start - 1];
+ const rangeStart = before === ' ' ? start - 1 : start;
+
+ return [rangeStart, attr.range[1]] as const;
+}