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
9 changes: 8 additions & 1 deletion packages/eslint-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/eslint-plugin/changelogs/upcoming/9357.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Added new `icon-accessibility-rules` rule.
3 changes: 3 additions & 0 deletions packages/eslint-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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: {
Expand All @@ -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',
},
},
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = () => (
<EuiIcon title="Search" type="search" />
)
`,
languageOptions,
},
{
code: dedent`
const MyComponent = () => (
<EuiIcon aria-label="Close" type="cross" />
)
`,
languageOptions,
},
{
code: dedent`
const MyComponent = () => (
<EuiIcon aria-labelledby="iconDesc" type="alert" />
)
`,
languageOptions,
},
{
code: dedent`
const MyComponent = () => (
<EuiIcon aria-hidden={true} type="logoElastic" />
)
`,
languageOptions,
},
{
code: dedent`
const MyComponent = () => (
<EuiIcon tabIndex={0} aria-label="Focusable icon" type="user" />
)
`,
languageOptions,
},
],
invalid: [
// Missing accessible name -> autofix adds aria-hidden={true}
{
code: dedent`
const MyComponent = () => (<EuiIcon type="search" />)
`,
output: dedent`
const MyComponent = () => (<EuiIcon type="search" aria-hidden={true}/>)
`,
languageOptions,
errors: [
{
messageId: 'missingTitleOrAriaHidden',
},
],
},
// tabIndex with aria-hidden={true} -> error and autofix removes aria-hidden
{
code: dedent`
const MyComponent = () => (
<EuiIcon tabIndex={0} aria-hidden={true} type="cross" />
)
`,
output: dedent`
const MyComponent = () => (
<EuiIcon tabIndex={0} type="cross" />
)
`,
languageOptions,
errors: [
{
messageId: 'tabIndexWithAriaHidden',
},
],
},
// tabIndex without accessible name and without aria-hidden -> error
{
code: dedent`
const MyComponent = () => (<EuiIcon tabIndex={0} type="alert" />)
`,
output: null, // no autofix
languageOptions,
errors: [
{
messageId: 'missingTitleOrAriaHidden',
},
],
},
],
});
Original file line number Diff line number Diff line change
@@ -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: []
});
38 changes: 38 additions & 0 deletions packages/eslint-plugin/src/utils/remove_attr.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown[]>
>(
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;
}