-
Notifications
You must be signed in to change notification settings - Fork 860
Adding aria attributes required for interactive elements eslint rule #8973
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
bhavyarm
merged 9 commits into
elastic:main
from
bhavyarm:addingAriaRequiredInteractiveRule
Sep 8, 2025
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
8daf53b
adding eslint a11y rule for interactive elements needing aria labels
bhavyarm 986e5ec
fixing type errors
bhavyarm 4f204f5
addressing review comments
bhavyarm d506bb7
learning and incorporating review comments
bhavyarm a4cbdc2
addressing review comments about components with label prop and compo…
bhavyarm 8f1548e
making review comment changes
bhavyarm 27a115c
addressing review comments
bhavyarm e7fa987
adding jsdocs and fixing linting errors
bhavyarm 92054d7
Merge branch 'main' into addingAriaRequiredInteractiveRule
bhavyarm File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| **Accessibility** | ||
bhavyarm marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| - Added new `no-unnamed-interactive-element` rule. | ||
bhavyarm marked this conversation as resolved.
Show resolved
Hide resolved
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
98 changes: 98 additions & 0 deletions
98
packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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: '<EuiBetaBadge aria-label="Beta badge" />' }, | ||
| { code: '<EuiButtonEmpty aria-labelledby="btnLabel" />' }, | ||
| { code: '<EuiButtonIcon aria-label="Icon" />' }, | ||
| { code: '<EuiComboBox label="Combo label" />' }, | ||
| { code: '<EuiSelect aria-label="Select label" />' }, | ||
| { code: '<EuiSelectWithWidth label="SelectWithWidth label" />' }, | ||
| { code: '<EuiSuperSelect aria-labelledby="superLabel" />' }, | ||
| { code: '<EuiPagination label="Pagination label" />' }, | ||
| { code: '<EuiTreeView label="TreeView label" />' }, | ||
| { code: '<EuiBreadcrumbs aria-label="Breadcrumbs label" />' }, | ||
| // Wrapped in EuiFormRow with label | ||
| { code: '<EuiFormRow label="Row label"><EuiComboBox /></EuiFormRow>' }, | ||
| { code: '<EuiFormRow label="Row label"><EuiSelect /></EuiFormRow>' }, | ||
| ], | ||
| invalid: [ | ||
| // Missing a11y prop for interactive components | ||
| { | ||
| code: '<EuiBetaBadge />', | ||
| errors: [{ messageId: 'missingA11y' }], | ||
| }, | ||
| { | ||
| code: '<EuiButtonEmpty />', | ||
| errors: [{ messageId: 'missingA11y' }], | ||
| }, | ||
| { | ||
| code: '<EuiButtonIcon />', | ||
| errors: [{ messageId: 'missingA11y' }], | ||
| }, | ||
| { | ||
| code: '<EuiComboBox />', | ||
| errors: [{ messageId: 'missingA11y' }], | ||
| }, | ||
| { | ||
| code: '<EuiSelect />', | ||
| errors: [{ messageId: 'missingA11y' }], | ||
| }, | ||
| { | ||
| code: '<EuiSelectWithWidth />', | ||
| errors: [{ messageId: 'missingA11y' }], | ||
| }, | ||
| { | ||
| code: '<EuiSuperSelect />', | ||
| errors: [{ messageId: 'missingA11y' }], | ||
| }, | ||
| { | ||
| code: '<EuiPagination />', | ||
| errors: [{ messageId: 'missingA11y' }], | ||
| }, | ||
| { | ||
| code: '<EuiTreeView />', | ||
| errors: [{ messageId: 'missingA11y' }], | ||
| }, | ||
| { | ||
| code: '<EuiBreadcrumbs />', | ||
| errors: [{ messageId: 'missingA11y' }], | ||
| }, | ||
| // Wrapped but missing label | ||
| { | ||
| code: '<EuiFormRow><EuiComboBox /></EuiFormRow>', | ||
| errors: [{ messageId: 'missingA11y' }], | ||
| }, | ||
| { | ||
| code: '<EuiFormRow><EuiSelect /></EuiFormRow>', | ||
| errors: [{ messageId: 'missingA11y' }], | ||
| }, | ||
| ], | ||
| }); |
125 changes: 125 additions & 0 deletions
125
packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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}}', | ||
| }, | ||
bhavyarm marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }, | ||
| 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', | ||
bhavyarm marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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); | ||
| } | ||
| }, | ||
| }; | ||
| }, | ||
| }); | ||
56 changes: 56 additions & 0 deletions
56
packages/eslint-plugin/src/utils/get_allowed_a11y_prop_names_for_component.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string>; | ||
| wrappingComponents: ReadonlyArray<string>; | ||
| baseA11yProps: ReadonlyArray<string>; | ||
| }; | ||
|
|
||
| /** | ||
| * 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<string>([ | ||
| ...cfg.interactiveComponentsWithLabel, | ||
| ...cfg.wrappingComponents, | ||
| ]); | ||
|
|
||
| if (componentsWithLabel.has(componentName)) { | ||
| return [...cfg.baseA11yProps, 'label']; | ||
| } | ||
| return [...cfg.baseA11yProps]; | ||
| } |
57 changes: 57 additions & 0 deletions
57
packages/eslint-plugin/src/utils/has_a11y_prop_for_component.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| ); | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.