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