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; +}