diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index e3a3b76d9d2..dcfa0d04439 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -147,6 +147,10 @@ Ensure `EuiIconTip` is used rather than ``, a Ensure that all radio input components (`EuiRadio`, `EuiRadioGroup`) have a `name` attribute. The `name` attribute is required for radio inputs to be grouped correctly, allowing users to select only one option from a set. Without a `name`, radios may not behave as expected and can cause accessibility issues for assistive technologies. +### `@elastic/eui/no-unnamed-interactive-element` +Ensure that appropriate aria-attributes are set for `EuiBetaBadge`, `EuiButtonIcon`, `EuiComboBox`, `EuiSelect`, `EuiSelectWithWidth`,`EuiSuperSelect`,`EuiPagination`, `EuiTreeView`, `EuiBreadcrumbs`. Without this rule, screen reader users lose context, keyboard navigation can be confusing. + + ## Testing ### Running unit tests diff --git a/packages/eslint-plugin/changelogs/upcoming/8973.md b/packages/eslint-plugin/changelogs/upcoming/8973.md new file mode 100644 index 00000000000..04bd2703604 --- /dev/null +++ b/packages/eslint-plugin/changelogs/upcoming/8973.md @@ -0,0 +1,3 @@ +**Accessibility** + +- Added new `no-unnamed-interactive-element` rule. diff --git a/packages/eslint-plugin/src/index.ts b/packages/eslint-plugin/src/index.ts index ca7d25bf06c..0362ee5e0fb 100644 --- a/packages/eslint-plugin/src/index.ts +++ b/packages/eslint-plugin/src/index.ts @@ -26,6 +26,8 @@ import { ConsistentIsInvalidProps } from './rules/a11y/consistent_is_invalid_pro import { ScreenReaderOutputDisabledTooltip } from './rules/a11y/sr_output_disabled_tooltip'; 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'; + const config = { rules: { @@ -37,6 +39,7 @@ const config = { 'sr-output-disabled-tooltip': ScreenReaderOutputDisabledTooltip, 'prefer-eui-icon-tip': PreferEuiIconTip, 'no-unnamed-radio-group' : NoUnnamedRadioGroup, + 'no-unnamed-interactive-element': NoUnnamedInteractiveElement, }, configs: { recommended: { @@ -50,6 +53,7 @@ const config = { '@elastic/eui/sr-output-disabled-tooltip': 'warn', '@elastic/eui/prefer-eui-icon-tip': 'warn', '@elastic/eui/no-unnamed-radio-group': 'warn', + '@elastic/eui/no-unnamed-interactive-element': 'warn', }, }, }, diff --git a/packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.test.ts b/packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.test.ts new file mode 100644 index 00000000000..527decf183c --- /dev/null +++ b/packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.test.ts @@ -0,0 +1,98 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import dedent from 'dedent'; +import { RuleTester } from '@typescript-eslint/rule-tester'; +import { NoUnnamedInteractiveElement } from './no_unnamed_interactive_element'; + +const ruleTester = new RuleTester({}); +// Set the parser for RuleTester +// @ts-ignore +ruleTester.parser = require.resolve('@typescript-eslint/parser'); + +ruleTester.run('NoUnnamedInteractiveElement', NoUnnamedInteractiveElement, { + valid: [ + // Components with allowed a11y props + { code: '' }, + { code: '' }, + { code: '' }, + { code: '' }, + { code: '' }, + { code: '' }, + { code: '' }, + { code: '' }, + { code: '' }, + { code: '' }, + // Wrapped in EuiFormRow with label + { code: '' }, + { code: '' }, + ], + invalid: [ + // Missing a11y prop for interactive components + { + code: '', + errors: [{ messageId: 'missingA11y' }], + }, + { + code: '', + errors: [{ messageId: 'missingA11y' }], + }, + { + code: '', + errors: [{ messageId: 'missingA11y' }], + }, + { + code: '', + errors: [{ messageId: 'missingA11y' }], + }, + { + code: '', + errors: [{ messageId: 'missingA11y' }], + }, + { + code: '', + errors: [{ messageId: 'missingA11y' }], + }, + { + code: '', + errors: [{ messageId: 'missingA11y' }], + }, + { + code: '', + errors: [{ messageId: 'missingA11y' }], + }, + { + code: '', + errors: [{ messageId: 'missingA11y' }], + }, + { + code: '', + errors: [{ messageId: 'missingA11y' }], + }, + // Wrapped but missing label + { + code: '', + errors: [{ messageId: 'missingA11y' }], + }, + { + code: '', + errors: [{ messageId: 'missingA11y' }], + }, + ], +}); \ No newline at end of file diff --git a/packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.ts b/packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.ts new file mode 100644 index 00000000000..7e686fa2837 --- /dev/null +++ b/packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.ts @@ -0,0 +1,125 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ESLintUtils, type TSESTree } from '@typescript-eslint/utils'; +import { hasSpread } from '../../utils/has_spread'; +import { + getAllowedA11yPropNamesForComponent, + type A11yConfig, +} from '../../utils/get_allowed_a11y_prop_names_for_component'; +import { hasA11yPropForComponent } from '../../utils/has_a11y_prop_for_component'; + +const interactiveComponents = [ + 'EuiBetaBadge', + 'EuiButtonEmpty', + 'EuiButtonIcon', + 'EuiComboBox', + 'EuiSelect', + 'EuiSelectWithWidth', + 'EuiSuperSelect', + 'EuiPagination', + 'EuiTreeView', + 'EuiBreadcrumbs', +] as const; + +const wrappingComponents = ['EuiFormRow'] as const; +const interactiveComponentsWithLabel = ['EuiBetaBadge'] as const; +const baseA11yProps = ['aria-label', 'aria-labelledby'] as const; + +// Single source of truth for the utils (keeps them reusable) +const a11yConfig: A11yConfig = { + interactiveComponentsWithLabel: [...interactiveComponentsWithLabel], + wrappingComponents: [...wrappingComponents], + baseA11yProps: [...baseA11yProps], +}; + +export const NoUnnamedInteractiveElement = ESLintUtils.RuleCreator.withoutDocs({ + meta: { + type: 'problem', + hasSuggestions: false, + schema: [], + messages: { + missingA11y: + '{{component}} must include an accessible label. Use one of: {{a11yProps}}', + }, + }, + defaultOptions: [], + create(context) { + const sourceCode = context.sourceCode; + + function report(opening: TSESTree.JSXOpeningElement) { + if (opening.name.type !== 'JSXIdentifier') return; + const component = opening.name.name; + const allowed = getAllowedA11yPropNamesForComponent(component, a11yConfig).join(', '); + context.report({ + node: opening, + messageId: 'missingA11y', + data: { + component, + a11yProps: allowed, + }, + }); + } + + return { + JSXOpeningElement(node) { + if (node.name.type !== 'JSXIdentifier') return; + + const componentName = node.name.name; + const isInteractive = ( + interactiveComponents as readonly string[] + ).includes(componentName); + if (!isInteractive) return; + + if ( + hasSpread(node.attributes) || + hasA11yPropForComponent(componentName, node.attributes, a11yConfig) + ) { + return; + } + + const ancestors = sourceCode.getAncestors(node); + const wrapper = [...ancestors] + .reverse() + .find( + (a): a is TSESTree.JSXElement => + a.type === 'JSXElement' && + a.openingElement.name.type === 'JSXIdentifier' && + (wrappingComponents as readonly string[]).includes( + a.openingElement.name.name + ) + ); + + if (wrapper) { + const open = wrapper.openingElement; + const wrapperName = + open.name.type === 'JSXIdentifier' ? open.name.name : ''; + if ( + !hasSpread(open.attributes) && + !hasA11yPropForComponent(wrapperName, open.attributes, a11yConfig) + ) { + report(open); + } + } else { + report(node); + } + }, + }; + }, +}); \ No newline at end of file diff --git a/packages/eslint-plugin/src/utils/get_allowed_a11y_prop_names_for_component.ts b/packages/eslint-plugin/src/utils/get_allowed_a11y_prop_names_for_component.ts new file mode 100644 index 00000000000..5df706a485d --- /dev/null +++ b/packages/eslint-plugin/src/utils/get_allowed_a11y_prop_names_for_component.ts @@ -0,0 +1,56 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Configuration describing which components accept a `label` prop + * and the baseline set of accessibility prop names allowed across components. + */ +export type A11yConfig = { + interactiveComponentsWithLabel: ReadonlyArray; + wrappingComponents: ReadonlyArray; + baseA11yProps: ReadonlyArray; +}; + +/** + * Compute the set of allowed accessibility prop names for a given component. + * + * - Always includes the provided `baseA11yProps`. + * - Conditionally includes `label` if the component is listed in either + * `interactiveComponentsWithLabel` or `wrappingComponents`. + * - Does **not** mutate the provided configuration; a new array is returned. + * + * @param componentName - The EUI component name (e.g., `'EuiButtonIcon'`). + * @param cfg - The accessibility configuration to use when resolving allowed props. + * @returns A new array of allowed prop names for `componentName`. + * + */ +export function getAllowedA11yPropNamesForComponent( + componentName: string, + cfg: A11yConfig +): string[] { + const componentsWithLabel = new Set([ + ...cfg.interactiveComponentsWithLabel, + ...cfg.wrappingComponents, + ]); + + if (componentsWithLabel.has(componentName)) { + return [...cfg.baseA11yProps, 'label']; + } + return [...cfg.baseA11yProps]; +} diff --git a/packages/eslint-plugin/src/utils/has_a11y_prop_for_component.ts b/packages/eslint-plugin/src/utils/has_a11y_prop_for_component.ts new file mode 100644 index 00000000000..e3461b2094e --- /dev/null +++ b/packages/eslint-plugin/src/utils/has_a11y_prop_for_component.ts @@ -0,0 +1,57 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { TSESTree } from '@typescript-eslint/utils'; +import { + getAllowedA11yPropNamesForComponent, + type A11yConfig, +} from './get_allowed_a11y_prop_names_for_component'; + +/** + * Determines whether a JSX element declares at least one **allowed** + * accessibility-related prop for a given component. + * + * Allowed prop names are resolved via {@link getAllowedA11yPropNamesForComponent}, + * which combines baseline a11y props (e.g. `aria-*`) and conditionally adds + * `label` for components that support it per the provided configuration. + * + * Only plain `JSXAttribute` nodes are considered—spread attributes are ignored here. + * + * @param componentName - The component name being checked (e.g., `"EuiButtonIcon"`). + * @param attrs - The attributes array from a `JSXOpeningElement` (ESTree). + * @param cfg - Accessibility configuration that defines base props and which + * components may accept a `label` prop. + * @returns `true` if any attribute name on the element is in the allowed set; otherwise `false`. + */ + +export function hasA11yPropForComponent( + componentName: string, + attrs: TSESTree.JSXOpeningElement['attributes'], + cfg: A11yConfig +): boolean { + const allowed = new Set( + getAllowedA11yPropNamesForComponent(componentName, cfg) + ); + return attrs.some( + (attr): attr is TSESTree.JSXAttribute => + attr.type === 'JSXAttribute' && + attr.name.type === 'JSXIdentifier' && + allowed.has(attr.name.name) + ); +} diff --git a/packages/eslint-plugin/src/utils/has_spread.ts b/packages/eslint-plugin/src/utils/has_spread.ts new file mode 100644 index 00000000000..973274f956e --- /dev/null +++ b/packages/eslint-plugin/src/utils/has_spread.ts @@ -0,0 +1,36 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { TSESTree } from '@typescript-eslint/utils'; + +/** + * Checks whether a JSX opening element contains a spread attribute + * (e.g., `...props`). Spreads make it impossible to statically know + * all props present on an element, so ESLint rules often use this as + * a quick bail-out to avoid false positives. + * + * @param attrs - The attributes array from a `JSXOpeningElement` node (ESTree). + * @returns `true` if any attribute is a `JSXSpreadAttribute`; otherwise `false`. + */ + +export function hasSpread( + attrs: TSESTree.JSXOpeningElement['attributes'] +): boolean { + return attrs.some((a) => a.type === 'JSXSpreadAttribute'); +}