From 8daf53b995ecf2424aaa5ac6e550de2bd0dc7cfa Mon Sep 17 00:00:00 2001 From: bhavyarm Date: Wed, 20 Aug 2025 11:12:32 -0400 Subject: [PATCH 1/8] adding eslint a11y rule for interactive elements needing aria labels --- packages/eslint-plugin/src/index.ts | 5 + .../no_unnamed_interactive_element.test.ts | 103 +++++++++++++++++ .../a11y/no_unnamed_interactive_element.ts | 109 ++++++++++++++++++ 3 files changed, 217 insertions(+) create mode 100644 packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.test.ts create mode 100644 packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.ts diff --git a/packages/eslint-plugin/src/index.ts b/packages/eslint-plugin/src/index.ts index ca7d25bf06c..d9b8daa034f 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,8 @@ const config = { '@elastic/eui/sr-output-disabled-tooltip': 'warn', '@elastic/eui/prefer-eui-icon-tip': 'warn', '@elastic/eui/no-unnamed-radio-group': '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..8e918dc3820 --- /dev/null +++ b/packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.test.ts @@ -0,0 +1,103 @@ +/* + * 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 languageOptions = { + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, +}; + +const ruleTester = new RuleTester(); + +ruleTester.run('no-unnamed-interactive-element', NoUnnamedInteractiveElement, { + valid: [ + { + code: dedent` + const MyComponent = () => ( + + ) + `, + languageOptions, + }, + { + code: dedent` + const MyComponent = () => ( + + + + ) + `, + languageOptions, + }, + { + code: dedent` + const MyComponent = () => ( +
Not an EUI element
+ ) + `, + languageOptions, + }, + { + code: dedent` + const MyComponent = () => ( + + ) + `, + languageOptions, + }, + ], + invalid: [ + { + code: dedent` + const MyComponent = () => ( + + ) + `, + languageOptions, + errors: [ + { + messageId: 'missingA11y', + data: { component: 'EuiButtonEmpty' }, + }, + ], + }, + { + code: dedent` + const MyComponent = () => ( + + + + ) + `, + languageOptions, + errors: [ + { + messageId: 'missingA11y', + data: { component: 'EuiFormRow' }, + }, + ], + }, + ], +}); \ 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..72e8ce03e18 --- /dev/null +++ b/packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.ts @@ -0,0 +1,109 @@ +/* + * 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 } from '@typescript-eslint/utils'; + +const interactiveComponents = [ + 'EuiBetaBadge', + 'EuiButtonEmpty', + 'EuiButtonIcon', + 'EuiComboBox', + 'EuiSelect', + 'EuiSelectWithWidth', + 'EuiSuperSelect', +]; + +const wrappingComponents = ['EuiFormRow']; +const a11yProps = ['aria-label', 'aria-labelledby', 'label']; + +export const NoUnnamedInteractiveElement = ESLintUtils.RuleCreator.withoutDocs({ + create(context) { + return { + JSXOpeningElement(node) { + if ( + node.name.type === 'JSXIdentifier' && + interactiveComponents.includes(node.name.name) + ) { + // Check if wrapped in a wrapping component + const parent = context.getAncestors().reverse().find( + (ancestor) => + ancestor.type === 'JSXElement' && + ancestor.openingElement && + ancestor.openingElement.name.type === 'JSXIdentifier' && + wrappingComponents.includes(ancestor.openingElement.name.name) + ); + if (parent) { + const hasA11yProp = parent.openingElement.attributes.some( + (attr) => + attr.type === 'JSXAttribute' && + attr.name.type === 'JSXIdentifier' && + a11yProps.includes(attr.name.name) + ); + if (hasA11yProp) return; + context.report({ + node: parent.openingElement, + messageId: 'missingA11y', + data: { component: parent.openingElement.name.name }, + fix(fixer) { + return fixer.insertTextAfter( + parent.openingElement.name, + ` aria-label="${parent.openingElement.name.name}"` + ); + }, + }); + return; + } + + // Check props on the interactive element itself + const hasA11yProp = node.attributes.some( + (attr) => + attr.type === 'JSXAttribute' && + attr.name.type === 'JSXIdentifier' && + a11yProps.includes(attr.name.name) + ); + if (hasA11yProp) return; + context.report({ + node, + messageId: 'missingA11y', + data: { component: node.name.name }, + fix(fixer) { + return fixer.insertTextAfter( + node.name, + ` aria-label="${node.name.name}"` + ); + }, + }); + } + }, + }; + }, + meta: { + type: 'suggestion', + docs: { + description: + 'Ensure interactive EUI components have an accessible name via aria-label, aria-labelledby, or label.', + }, + schema: [], + messages: { + missingA11y: + '{{ component }} should have a `aria-label` for accessibility.', + }, + }, + defaultOptions: [], +}); \ No newline at end of file From 986e5ecec35740e4a1554b0d2e82ccb93150fd5a Mon Sep 17 00:00:00 2001 From: bhavyarm Date: Thu, 21 Aug 2025 08:48:10 -0400 Subject: [PATCH 2/8] fixing type errors --- .../a11y/no_unnamed_interactive_element.ts | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) 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 index 72e8ce03e18..54a85b4fe67 100644 --- a/packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.ts +++ b/packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.ts @@ -27,6 +27,10 @@ const interactiveComponents = [ 'EuiSelect', 'EuiSelectWithWidth', 'EuiSuperSelect', + 'EuiPagination', + 'EuiTreeView', + 'EuiBreadcrumbs', + ]; const wrappingComponents = ['EuiFormRow']; @@ -47,10 +51,10 @@ export const NoUnnamedInteractiveElement = ESLintUtils.RuleCreator.withoutDocs({ ancestor.openingElement && ancestor.openingElement.name.type === 'JSXIdentifier' && wrappingComponents.includes(ancestor.openingElement.name.name) - ); - if (parent) { + ) as import('@typescript-eslint/utils').TSESTree.JSXElement | undefined; + if (parent && parent.openingElement && parent.openingElement.name.type === 'JSXIdentifier') { const hasA11yProp = parent.openingElement.attributes.some( - (attr) => + (attr: any) => attr.type === 'JSXAttribute' && attr.name.type === 'JSXIdentifier' && a11yProps.includes(attr.name.name) @@ -61,9 +65,16 @@ export const NoUnnamedInteractiveElement = ESLintUtils.RuleCreator.withoutDocs({ messageId: 'missingA11y', data: { component: parent.openingElement.name.name }, fix(fixer) { + let name = ''; + if (parent.openingElement.name.type === 'JSXIdentifier') { + name = parent.openingElement.name.name; + } else if (parent.openingElement.name.type === 'JSXMemberExpression') { + // For member expressions, fallback to string '[MemberExpression]' + name = '[MemberExpression]'; + } return fixer.insertTextAfter( parent.openingElement.name, - ` aria-label="${parent.openingElement.name.name}"` + ` aria-label="${name}"` ); }, }); @@ -72,7 +83,7 @@ export const NoUnnamedInteractiveElement = ESLintUtils.RuleCreator.withoutDocs({ // Check props on the interactive element itself const hasA11yProp = node.attributes.some( - (attr) => + (attr: any) => attr.type === 'JSXAttribute' && attr.name.type === 'JSXIdentifier' && a11yProps.includes(attr.name.name) @@ -83,9 +94,15 @@ export const NoUnnamedInteractiveElement = ESLintUtils.RuleCreator.withoutDocs({ messageId: 'missingA11y', data: { component: node.name.name }, fix(fixer) { + let name = ''; + if (node.name.type === 'JSXIdentifier') { + name = node.name.name; + } else if (node.name.type === 'JSXMemberExpression') { + name = '[MemberExpression]'; + } return fixer.insertTextAfter( node.name, - ` aria-label="${node.name.name}"` + ` aria-label="${name}"` ); }, }); From 4f204f5a78335e7afcdf38115dbf2dc5fa4b9fad Mon Sep 17 00:00:00 2001 From: bhavyarm Date: Thu, 21 Aug 2025 16:17:03 -0400 Subject: [PATCH 3/8] addressing review comments --- packages/eslint-plugin/src/index.ts | 3 +- .../a11y/no_unnamed_interactive_element.ts | 154 +++++++++--------- 2 files changed, 75 insertions(+), 82 deletions(-) diff --git a/packages/eslint-plugin/src/index.ts b/packages/eslint-plugin/src/index.ts index d9b8daa034f..0362ee5e0fb 100644 --- a/packages/eslint-plugin/src/index.ts +++ b/packages/eslint-plugin/src/index.ts @@ -53,8 +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.ts b/packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.ts index 54a85b4fe67..810d86856ce 100644 --- a/packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.ts +++ b/packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.ts @@ -17,7 +17,8 @@ * under the License. */ -import { ESLintUtils } from '@typescript-eslint/utils'; + +import { ESLintUtils, type TSESTree } from '@typescript-eslint/utils'; const interactiveComponents = [ 'EuiBetaBadge', @@ -30,97 +31,90 @@ const interactiveComponents = [ 'EuiPagination', 'EuiTreeView', 'EuiBreadcrumbs', +] as const; + +const wrappingComponents = ['EuiFormRow'] as const; +const a11yProps = ['aria-label', 'aria-labelledby', 'label'] as const; + +type JSXOpeningElement = TSESTree.JSXOpeningElement; + +function hasSpread(attrs: JSXOpeningElement['attributes']): boolean { + return attrs.some((a) => a.type === 'JSXSpreadAttribute'); +} -]; +function hasA11yProp(attrs: JSXOpeningElement['attributes']): boolean { + return attrs.some( + (attr): attr is TSESTree.JSXAttribute => + attr.type === 'JSXAttribute' && + attr.name.type === 'JSXIdentifier' && + a11yProps.includes(attr.name.name as (typeof a11yProps)[number]), + ); +} -const wrappingComponents = ['EuiFormRow']; -const a11yProps = ['aria-label', 'aria-labelledby', 'label']; +function getReadableComponentName(name: JSXOpeningElement['name']): string { + return name.type === 'JSXIdentifier' ? name.name : 'this component'; +} export const NoUnnamedInteractiveElement = ESLintUtils.RuleCreator.withoutDocs({ + meta: { + type: 'suggestion', + hasSuggestions: false, + schema: [], + messages: { + missingA11y: + '{{component}} should have an accessible name via `aria-label`, `aria-labelledby`, or `label`.', + }, + }, + defaultOptions: [], create(context) { + const sourceCode = context.sourceCode; + return { JSXOpeningElement(node) { - if ( - node.name.type === 'JSXIdentifier' && - interactiveComponents.includes(node.name.name) - ) { - // Check if wrapped in a wrapping component - const parent = context.getAncestors().reverse().find( - (ancestor) => - ancestor.type === 'JSXElement' && - ancestor.openingElement && - ancestor.openingElement.name.type === 'JSXIdentifier' && - wrappingComponents.includes(ancestor.openingElement.name.name) - ) as import('@typescript-eslint/utils').TSESTree.JSXElement | undefined; - if (parent && parent.openingElement && parent.openingElement.name.type === 'JSXIdentifier') { - const hasA11yProp = parent.openingElement.attributes.some( - (attr: any) => - attr.type === 'JSXAttribute' && - attr.name.type === 'JSXIdentifier' && - a11yProps.includes(attr.name.name) - ); - if (hasA11yProp) return; + + if (node.name.type !== 'JSXIdentifier') return; + + const isInteractive = interactiveComponents.includes( + node.name.name as (typeof interactiveComponents)[number], + ); + if (!isInteractive) return; + + + if (hasSpread(node.attributes) || hasA11yProp(node.attributes)) 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.includes( + a.openingElement.name.name as (typeof wrappingComponents)[number], + ), + ); + + if (wrapper) { + const open = wrapper.openingElement; + + if (!hasSpread(open.attributes) && !hasA11yProp(open.attributes)) { context.report({ - node: parent.openingElement, + node: open, messageId: 'missingA11y', - data: { component: parent.openingElement.name.name }, - fix(fixer) { - let name = ''; - if (parent.openingElement.name.type === 'JSXIdentifier') { - name = parent.openingElement.name.name; - } else if (parent.openingElement.name.type === 'JSXMemberExpression') { - // For member expressions, fallback to string '[MemberExpression]' - name = '[MemberExpression]'; - } - return fixer.insertTextAfter( - parent.openingElement.name, - ` aria-label="${name}"` - ); - }, + data: { component: getReadableComponentName(open.name) }, }); - return; } - - // Check props on the interactive element itself - const hasA11yProp = node.attributes.some( - (attr: any) => - attr.type === 'JSXAttribute' && - attr.name.type === 'JSXIdentifier' && - a11yProps.includes(attr.name.name) - ); - if (hasA11yProp) return; - context.report({ - node, - messageId: 'missingA11y', - data: { component: node.name.name }, - fix(fixer) { - let name = ''; - if (node.name.type === 'JSXIdentifier') { - name = node.name.name; - } else if (node.name.type === 'JSXMemberExpression') { - name = '[MemberExpression]'; - } - return fixer.insertTextAfter( - node.name, - ` aria-label="${name}"` - ); - }, - }); + return; } + + + context.report({ + node, + messageId: 'missingA11y', + data: { component: getReadableComponentName(node.name) }, + }); }, }; }, - meta: { - type: 'suggestion', - docs: { - description: - 'Ensure interactive EUI components have an accessible name via aria-label, aria-labelledby, or label.', - }, - schema: [], - messages: { - missingA11y: - '{{ component }} should have a `aria-label` for accessibility.', - }, - }, - defaultOptions: [], -}); \ No newline at end of file +}); From d506bb73b524636567d563d5cf966615ccd3eb07 Mon Sep 17 00:00:00 2001 From: bhavyarm Date: Wed, 27 Aug 2025 15:41:16 -0400 Subject: [PATCH 4/8] learning and incorporating review comments --- packages/eslint-plugin/README.md | 4 ++ .../eslint-plugin/changelogs/upcoming/8973.md | 3 ++ .../no_unnamed_interactive_element.test.ts | 14 +++++-- .../a11y/no_unnamed_interactive_element.ts | 39 ++++++++----------- 4 files changed, 34 insertions(+), 26 deletions(-) create mode 100644 packages/eslint-plugin/changelogs/upcoming/8973.md diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index e3a3b76d9d2..a7c707979c2 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..72437f054b6 --- /dev/null +++ b/packages/eslint-plugin/changelogs/upcoming/8973.md @@ -0,0 +1,3 @@ +**Accessibility** + +- Adding aria attributes required for interactive elements eslint rule so that appropriate aria rules are set for EuiBetaBadge, EuiButtonIcon, EuiComboBox. EuiSelect. EuiSelectWithWidth,EuiSuperSelect,EuiPagination, EuiTreeView, EuiBreadcrumbs. 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 index 8e918dc3820..57e31e860a1 100644 --- 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 @@ -69,6 +69,7 @@ ruleTester.run('no-unnamed-interactive-element', NoUnnamedInteractiveElement, { }, ], invalid: [ + // Unwrapped interactive element with no a11y name { code: dedent` const MyComponent = () => ( @@ -79,10 +80,14 @@ ruleTester.run('no-unnamed-interactive-element', NoUnnamedInteractiveElement, { errors: [ { messageId: 'missingA11y', - data: { component: 'EuiButtonEmpty' }, + data: { + component: 'EuiButtonEmpty', + how: '`aria-label` or `aria-labelledby`', + }, }, ], }, + // Wrapped interactive element; suggest wrapper's label in addition { code: dedent` const MyComponent = () => ( @@ -95,9 +100,12 @@ ruleTester.run('no-unnamed-interactive-element', NoUnnamedInteractiveElement, { errors: [ { messageId: 'missingA11y', - data: { component: 'EuiFormRow' }, + data: { + component: 'EuiFormRow', + how: '`aria-label` or `aria-labelledby` or the wrapper\'s \`label\` (e.g., \`EuiFormRow\`)', + }, }, ], }, ], -}); \ 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 index 810d86856ce..65efc9df9e8 100644 --- a/packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.ts +++ b/packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.ts @@ -17,7 +17,6 @@ * under the License. */ - import { ESLintUtils, type TSESTree } from '@typescript-eslint/utils'; const interactiveComponents = [ @@ -36,13 +35,11 @@ const interactiveComponents = [ const wrappingComponents = ['EuiFormRow'] as const; const a11yProps = ['aria-label', 'aria-labelledby', 'label'] as const; -type JSXOpeningElement = TSESTree.JSXOpeningElement; - -function hasSpread(attrs: JSXOpeningElement['attributes']): boolean { +function hasSpread(attrs: TSESTree.JSXOpeningElement['attributes']): boolean { return attrs.some((a) => a.type === 'JSXSpreadAttribute'); } -function hasA11yProp(attrs: JSXOpeningElement['attributes']): boolean { +function hasA11yProp(attrs: TSESTree.JSXOpeningElement['attributes']): boolean { return attrs.some( (attr): attr is TSESTree.JSXAttribute => attr.type === 'JSXAttribute' && @@ -51,7 +48,7 @@ function hasA11yProp(attrs: JSXOpeningElement['attributes']): boolean { ); } -function getReadableComponentName(name: JSXOpeningElement['name']): string { +function getReadableComponentName(name: TSESTree.JSXOpeningElement['name']): string { return name.type === 'JSXIdentifier' ? name.name : 'this component'; } @@ -69,9 +66,18 @@ export const NoUnnamedInteractiveElement = ESLintUtils.RuleCreator.withoutDocs({ create(context) { const sourceCode = context.sourceCode; + const report = (n: TSESTree.JSXOpeningElement) => { + if (n.name.type === 'JSXIdentifier') { + context.report({ + node: n, + messageId: 'missingA11y', + data: { component: n.name.name }, + }); + } + }; + return { JSXOpeningElement(node) { - if (node.name.type !== 'JSXIdentifier') return; const isInteractive = interactiveComponents.includes( @@ -79,10 +85,8 @@ export const NoUnnamedInteractiveElement = ESLintUtils.RuleCreator.withoutDocs({ ); if (!isInteractive) return; - if (hasSpread(node.attributes) || hasA11yProp(node.attributes)) return; - const ancestors = sourceCode.getAncestors(node); const wrapper = [...ancestors] .reverse() @@ -97,23 +101,12 @@ export const NoUnnamedInteractiveElement = ESLintUtils.RuleCreator.withoutDocs({ if (wrapper) { const open = wrapper.openingElement; - if (!hasSpread(open.attributes) && !hasA11yProp(open.attributes)) { - context.report({ - node: open, - messageId: 'missingA11y', - data: { component: getReadableComponentName(open.name) }, - }); + report(open); } - return; + } else { + report(node); } - - - context.report({ - node, - messageId: 'missingA11y', - data: { component: getReadableComponentName(node.name) }, - }); }, }; }, From a4cbdc2dcefba66df07e2f2ba029340de5b3726e Mon Sep 17 00:00:00 2001 From: bhavyarm Date: Fri, 29 Aug 2025 12:40:28 -0400 Subject: [PATCH 5/8] addressing review comments about components with label prop and components without label prop --- packages/eslint-plugin/README.md | 2 +- .../eslint-plugin/changelogs/upcoming/8973.md | 2 +- .../no_unnamed_interactive_element.test.ts | 2 +- .../a11y/no_unnamed_interactive_element.ts | 47 +++++++++++++------ 4 files changed, 36 insertions(+), 17 deletions(-) diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index a7c707979c2..dcfa0d04439 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -148,7 +148,7 @@ 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. +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 diff --git a/packages/eslint-plugin/changelogs/upcoming/8973.md b/packages/eslint-plugin/changelogs/upcoming/8973.md index 72437f054b6..04bd2703604 100644 --- a/packages/eslint-plugin/changelogs/upcoming/8973.md +++ b/packages/eslint-plugin/changelogs/upcoming/8973.md @@ -1,3 +1,3 @@ **Accessibility** -- Adding aria attributes required for interactive elements eslint rule so that appropriate aria rules are set for EuiBetaBadge, EuiButtonIcon, EuiComboBox. EuiSelect. EuiSelectWithWidth,EuiSuperSelect,EuiPagination, EuiTreeView, EuiBreadcrumbs. +- Added new `no-unnamed-interactive-element` rule. 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 index 57e31e860a1..864adac5336 100644 --- 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 @@ -44,7 +44,7 @@ ruleTester.run('no-unnamed-interactive-element', NoUnnamedInteractiveElement, { { code: dedent` const MyComponent = () => ( - + ) 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 index 65efc9df9e8..c1544e925af 100644 --- a/packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.ts +++ b/packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.ts @@ -32,6 +32,7 @@ const interactiveComponents = [ 'EuiBreadcrumbs', ] as const; + const wrappingComponents = ['EuiFormRow'] as const; const a11yProps = ['aria-label', 'aria-labelledby', 'label'] as const; @@ -39,6 +40,23 @@ function hasSpread(attrs: TSESTree.JSXOpeningElement['attributes']): boolean { return attrs.some((a) => a.type === 'JSXSpreadAttribute'); } +const interactiveComponentsWithoutLabel = [ + 'EuiBetaBadge', + 'EuiButtonIcon', + 'EuiButtonEmpty', + 'EuiBreadcrumbs', +]; + + +function getAllowedA11yPropNamesForComponent( + componentName: string, +): string[] { + if (interactiveComponentsWithoutLabel.includes(componentName)) { + return a11yProps.filter((p) => p !== 'label'); + } + return [...a11yProps]; +} + function hasA11yProp(attrs: TSESTree.JSXOpeningElement['attributes']): boolean { return attrs.some( (attr): attr is TSESTree.JSXAttribute => @@ -48,33 +66,34 @@ function hasA11yProp(attrs: TSESTree.JSXOpeningElement['attributes']): boolean { ); } -function getReadableComponentName(name: TSESTree.JSXOpeningElement['name']): string { - return name.type === 'JSXIdentifier' ? name.name : 'this component'; -} export const NoUnnamedInteractiveElement = ESLintUtils.RuleCreator.withoutDocs({ meta: { - type: 'suggestion', + type: 'problem', hasSuggestions: false, schema: [], messages: { missingA11y: - '{{component}} should have an accessible name via `aria-label`, `aria-labelledby`, or `label`.', + '{{component}} must include an accessible label. Use one of: {{a11yProps}}' }, }, defaultOptions: [], create(context) { const sourceCode = context.sourceCode; - const report = (n: TSESTree.JSXOpeningElement) => { - if (n.name.type === 'JSXIdentifier') { - context.report({ - node: n, - messageId: 'missingA11y', - data: { component: n.name.name }, - }); - } - }; +function report(opening: TSESTree.JSXOpeningElement) { + if (opening.name.type !== 'JSXIdentifier') return; + const component = opening.name.name; + const allowed = getAllowedA11yPropNamesForComponent(component).join(', '); + context.report({ + node: opening, + messageId: 'missingA11y', + data: { + component, + a11yProps: allowed, + }, + }); +} return { JSXOpeningElement(node) { From 8f1548ea830d890b10f3c20cf4f68975ec965d6e Mon Sep 17 00:00:00 2001 From: bhavyarm Date: Fri, 29 Aug 2025 15:18:55 -0400 Subject: [PATCH 6/8] making review comment changes --- .../no_unnamed_interactive_element.test.ts | 82 ++++++++++++---- .../a11y/no_unnamed_interactive_element.ts | 98 ++++++++++--------- 2 files changed, 119 insertions(+), 61 deletions(-) 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 index 864adac5336..2433660d9b6 100644 --- 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 @@ -21,15 +21,16 @@ import dedent from 'dedent'; import { RuleTester } from '@typescript-eslint/rule-tester'; import { NoUnnamedInteractiveElement } from './no_unnamed_interactive_element'; -const languageOptions = { - parserOptions: { - ecmaFeatures: { - jsx: true, +const ruleTester = new RuleTester({ + languageOptions: { + parser: require.resolve('@typescript-eslint/parser'), + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + ecmaFeatures: { jsx: true }, }, }, -}; - -const ruleTester = new RuleTester(); +}); ruleTester.run('no-unnamed-interactive-element', NoUnnamedInteractiveElement, { valid: [ @@ -39,8 +40,8 @@ ruleTester.run('no-unnamed-interactive-element', NoUnnamedInteractiveElement, { ) `, - languageOptions, }, + { code: dedent` const MyComponent = () => ( @@ -49,45 +50,59 @@ ruleTester.run('no-unnamed-interactive-element', NoUnnamedInteractiveElement, { ) `, - languageOptions, }, + { code: dedent` const MyComponent = () => (
Not an EUI element
) `, - languageOptions, }, + { code: dedent` const MyComponent = () => ( ) `, - languageOptions, + }, + + { + code: dedent` + const MyComponent = (props) => ( + + ) + `, + }, + + { + code: dedent` + const MyComponent = () => ( + + ) + `, }, ], + invalid: [ - // Unwrapped interactive element with no a11y name { code: dedent` const MyComponent = () => ( ) `, - languageOptions, errors: [ { messageId: 'missingA11y', data: { component: 'EuiButtonEmpty', - how: '`aria-label` or `aria-labelledby`', + a11yProps: 'aria-label, aria-labelledby', }, }, ], }, - // Wrapped interactive element; suggest wrapper's label in addition + { code: dedent` const MyComponent = () => ( @@ -96,13 +111,46 @@ ruleTester.run('no-unnamed-interactive-element', NoUnnamedInteractiveElement, { ) `, - languageOptions, errors: [ { messageId: 'missingA11y', data: { component: 'EuiFormRow', - how: '`aria-label` or `aria-labelledby` or the wrapper\'s \`label\` (e.g., \`EuiFormRow\`)', + a11yProps: 'aria-label, aria-labelledby, label', + }, + }, + ], + }, + + { + code: dedent` + const MyComponent = () => ( + + ) + `, + errors: [ + { + messageId: 'missingA11y', + data: { + component: 'EuiBetaBadge', + a11yProps: 'aria-label, aria-labelledby, label', + }, + }, + ], + }, + + { + code: dedent` + const MyComponent = () => ( + + ) + `, + errors: [ + { + messageId: 'missingA11y', + data: { + component: 'EuiButtonEmpty', + a11yProps: 'aria-label, aria-labelledby', }, }, ], 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 index c1544e925af..43eef3e1ced 100644 --- a/packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.ts +++ b/packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.ts @@ -32,79 +32,84 @@ const interactiveComponents = [ 'EuiBreadcrumbs', ] as const; - const wrappingComponents = ['EuiFormRow'] as const; -const a11yProps = ['aria-label', 'aria-labelledby', 'label'] as const; + +const interactiveComponentsWithLabel = ['EuiBetaBadge'] as const; + +const baseA11yProps = ['aria-label', 'aria-labelledby'] as const; function hasSpread(attrs: TSESTree.JSXOpeningElement['attributes']): boolean { return attrs.some((a) => a.type === 'JSXSpreadAttribute'); } -const interactiveComponentsWithoutLabel = [ - 'EuiBetaBadge', - 'EuiButtonIcon', - 'EuiButtonEmpty', - 'EuiBreadcrumbs', -]; - - -function getAllowedA11yPropNamesForComponent( - componentName: string, -): string[] { - if (interactiveComponentsWithoutLabel.includes(componentName)) { - return a11yProps.filter((p) => p !== 'label'); +function getAllowedA11yPropNamesForComponent(componentName: string): string[] { + const componentsWithLabel = new Set([ + ...interactiveComponentsWithLabel, + ...wrappingComponents, + ]); + if (componentsWithLabel.has(componentName)) { + return [...baseA11yProps, 'label']; } - return [...a11yProps]; + return [...baseA11yProps]; } -function hasA11yProp(attrs: TSESTree.JSXOpeningElement['attributes']): boolean { +function hasA11yPropForComponent( + componentName: string, + attrs: TSESTree.JSXOpeningElement['attributes'] +): boolean { + const allowed = new Set(getAllowedA11yPropNamesForComponent(componentName)); return attrs.some( (attr): attr is TSESTree.JSXAttribute => attr.type === 'JSXAttribute' && attr.name.type === 'JSXIdentifier' && - a11yProps.includes(attr.name.name as (typeof a11yProps)[number]), + allowed.has(attr.name.name) ); } - export const NoUnnamedInteractiveElement = ESLintUtils.RuleCreator.withoutDocs({ meta: { type: 'problem', - hasSuggestions: false, + hasSuggestions: false, schema: [], messages: { missingA11y: - '{{component}} must include an accessible label. Use one of: {{a11yProps}}' + '{{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).join(', '); - context.report({ - node: opening, - messageId: 'missingA11y', - data: { - component, - a11yProps: allowed, - }, - }); -} + const sourceCode = context.sourceCode; + + function report(opening: TSESTree.JSXOpeningElement) { + if (opening.name.type !== 'JSXIdentifier') return; + const component = opening.name.name; + const allowed = getAllowedA11yPropNamesForComponent(component).join(', '); + context.report({ + node: opening, + messageId: 'missingA11y', + data: { + component, + a11yProps: allowed, + }, + }); + } return { JSXOpeningElement(node) { if (node.name.type !== 'JSXIdentifier') return; - const isInteractive = interactiveComponents.includes( - node.name.name as (typeof interactiveComponents)[number], - ); + const componentName = node.name.name; + const isInteractive = ( + interactiveComponents as readonly string[] + ).includes(componentName); if (!isInteractive) return; - if (hasSpread(node.attributes) || hasA11yProp(node.attributes)) return; + if ( + hasSpread(node.attributes) || + hasA11yPropForComponent(componentName, node.attributes) + ) { + return; + } const ancestors = sourceCode.getAncestors(node); const wrapper = [...ancestors] @@ -113,14 +118,19 @@ function report(opening: TSESTree.JSXOpeningElement) { (a): a is TSESTree.JSXElement => a.type === 'JSXElement' && a.openingElement.name.type === 'JSXIdentifier' && - wrappingComponents.includes( - a.openingElement.name.name as (typeof wrappingComponents)[number], - ), + (wrappingComponents as readonly string[]).includes( + a.openingElement.name.name + ) ); if (wrapper) { const open = wrapper.openingElement; - if (!hasSpread(open.attributes) && !hasA11yProp(open.attributes)) { + const wrapperName = + open.name.type === 'JSXIdentifier' ? open.name.name : ''; + if ( + !hasSpread(open.attributes) && + !hasA11yPropForComponent(wrapperName, open.attributes) + ) { report(open); } } else { From 27a115c2df829e38dbe6aa14d7544277e145c591 Mon Sep 17 00:00:00 2001 From: bhavyarm Date: Thu, 4 Sep 2025 17:01:44 -0400 Subject: [PATCH 7/8] addressing review comments --- .../no_unnamed_interactive_element.test.ts | 165 ++++++------------ .../a11y/no_unnamed_interactive_element.ts | 49 ++---- ...t_allowed_a11y_prop_names_for_component.ts | 39 +++++ .../src/utils/has_a11y_prop_for_component.ts | 39 +++++ .../eslint-plugin/src/utils/has_spread.ts | 27 +++ 5 files changed, 173 insertions(+), 146 deletions(-) create mode 100644 packages/eslint-plugin/src/utils/get_allowed_a11y_prop_names_for_component.ts create mode 100644 packages/eslint-plugin/src/utils/has_a11y_prop_for_component.ts create mode 100644 packages/eslint-plugin/src/utils/has_spread.ts 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 index 2433660d9b6..527decf183c 100644 --- 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 @@ -21,139 +21,78 @@ import dedent from 'dedent'; import { RuleTester } from '@typescript-eslint/rule-tester'; import { NoUnnamedInteractiveElement } from './no_unnamed_interactive_element'; -const ruleTester = new RuleTester({ - languageOptions: { - parser: require.resolve('@typescript-eslint/parser'), - parserOptions: { - ecmaVersion: 2022, - sourceType: 'module', - ecmaFeatures: { jsx: true }, - }, - }, -}); +const ruleTester = new RuleTester({}); +// Set the parser for RuleTester +// @ts-ignore +ruleTester.parser = require.resolve('@typescript-eslint/parser'); -ruleTester.run('no-unnamed-interactive-element', NoUnnamedInteractiveElement, { +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: dedent` - const MyComponent = () => ( - - ) - `, + code: '', + errors: [{ messageId: 'missingA11y' }], }, - { - code: dedent` - const MyComponent = () => ( - - - - ) - `, + code: '', + errors: [{ messageId: 'missingA11y' }], }, - { - code: dedent` - const MyComponent = () => ( -
Not an EUI element
- ) - `, + code: '', + errors: [{ messageId: 'missingA11y' }], }, - { - code: dedent` - const MyComponent = () => ( - - ) - `, + code: '', + errors: [{ messageId: 'missingA11y' }], }, - { - code: dedent` - const MyComponent = (props) => ( - - ) - `, + code: '', + errors: [{ messageId: 'missingA11y' }], }, - { - code: dedent` - const MyComponent = () => ( - - ) - `, + code: '', + errors: [{ messageId: 'missingA11y' }], }, - ], - - invalid: [ { - code: dedent` - const MyComponent = () => ( - - ) - `, - errors: [ - { - messageId: 'missingA11y', - data: { - component: 'EuiButtonEmpty', - a11yProps: 'aria-label, aria-labelledby', - }, - }, - ], + code: '', + errors: [{ messageId: 'missingA11y' }], }, - { - code: dedent` - const MyComponent = () => ( - - - - ) - `, - errors: [ - { - messageId: 'missingA11y', - data: { - component: 'EuiFormRow', - a11yProps: 'aria-label, aria-labelledby, label', - }, - }, - ], + code: '', + errors: [{ messageId: 'missingA11y' }], }, - { - code: dedent` - const MyComponent = () => ( - - ) - `, - errors: [ - { - messageId: 'missingA11y', - data: { - component: 'EuiBetaBadge', - a11yProps: 'aria-label, aria-labelledby, label', - }, - }, - ], + code: '', + errors: [{ messageId: 'missingA11y' }], + }, + { + code: '', + errors: [{ messageId: 'missingA11y' }], + }, + // Wrapped but missing label + { + code: '', + errors: [{ messageId: 'missingA11y' }], }, - { - code: dedent` - const MyComponent = () => ( - - ) - `, - errors: [ - { - messageId: 'missingA11y', - data: { - component: 'EuiButtonEmpty', - a11yProps: 'aria-label, aria-labelledby', - }, - }, - ], + 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 index 43eef3e1ced..7e686fa2837 100644 --- a/packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.ts +++ b/packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.ts @@ -18,6 +18,12 @@ */ 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', @@ -33,38 +39,15 @@ const interactiveComponents = [ ] as const; const wrappingComponents = ['EuiFormRow'] as const; - const interactiveComponentsWithLabel = ['EuiBetaBadge'] as const; - const baseA11yProps = ['aria-label', 'aria-labelledby'] as const; -function hasSpread(attrs: TSESTree.JSXOpeningElement['attributes']): boolean { - return attrs.some((a) => a.type === 'JSXSpreadAttribute'); -} - -function getAllowedA11yPropNamesForComponent(componentName: string): string[] { - const componentsWithLabel = new Set([ - ...interactiveComponentsWithLabel, - ...wrappingComponents, - ]); - if (componentsWithLabel.has(componentName)) { - return [...baseA11yProps, 'label']; - } - return [...baseA11yProps]; -} - -function hasA11yPropForComponent( - componentName: string, - attrs: TSESTree.JSXOpeningElement['attributes'] -): boolean { - const allowed = new Set(getAllowedA11yPropNamesForComponent(componentName)); - return attrs.some( - (attr): attr is TSESTree.JSXAttribute => - attr.type === 'JSXAttribute' && - attr.name.type === 'JSXIdentifier' && - allowed.has(attr.name.name) - ); -} +// 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: { @@ -83,7 +66,7 @@ export const NoUnnamedInteractiveElement = ESLintUtils.RuleCreator.withoutDocs({ function report(opening: TSESTree.JSXOpeningElement) { if (opening.name.type !== 'JSXIdentifier') return; const component = opening.name.name; - const allowed = getAllowedA11yPropNamesForComponent(component).join(', '); + const allowed = getAllowedA11yPropNamesForComponent(component, a11yConfig).join(', '); context.report({ node: opening, messageId: 'missingA11y', @@ -106,7 +89,7 @@ export const NoUnnamedInteractiveElement = ESLintUtils.RuleCreator.withoutDocs({ if ( hasSpread(node.attributes) || - hasA11yPropForComponent(componentName, node.attributes) + hasA11yPropForComponent(componentName, node.attributes, a11yConfig) ) { return; } @@ -129,7 +112,7 @@ export const NoUnnamedInteractiveElement = ESLintUtils.RuleCreator.withoutDocs({ open.name.type === 'JSXIdentifier' ? open.name.name : ''; if ( !hasSpread(open.attributes) && - !hasA11yPropForComponent(wrapperName, open.attributes) + !hasA11yPropForComponent(wrapperName, open.attributes, a11yConfig) ) { report(open); } @@ -139,4 +122,4 @@ export const NoUnnamedInteractiveElement = ESLintUtils.RuleCreator.withoutDocs({ }, }; }, -}); +}); \ 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..7fd7a8fc1c2 --- /dev/null +++ b/packages/eslint-plugin/src/utils/get_allowed_a11y_prop_names_for_component.ts @@ -0,0 +1,39 @@ +/* + * 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. + */ + +export type A11yConfig = { + interactiveComponentsWithLabel: ReadonlyArray; + wrappingComponents: ReadonlyArray; + baseA11yProps: ReadonlyArray; +}; + +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]; +} \ No newline at end of file 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..c50e6982e88 --- /dev/null +++ b/packages/eslint-plugin/src/utils/has_a11y_prop_for_component.ts @@ -0,0 +1,39 @@ +/* + * 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'; + +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) + ); +} \ No newline at end of file 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..9460b13ec04 --- /dev/null +++ b/packages/eslint-plugin/src/utils/has_spread.ts @@ -0,0 +1,27 @@ + +/* + * 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'; + +export function hasSpread( + attrs: TSESTree.JSXOpeningElement['attributes'] +): boolean { + return attrs.some((a) => a.type === 'JSXSpreadAttribute'); +} \ No newline at end of file From e7fa987dfec7b5a7e7db1f785b7eb9a88faf25be Mon Sep 17 00:00:00 2001 From: bhavyarm Date: Mon, 8 Sep 2025 15:57:29 -0400 Subject: [PATCH 8/8] adding jsdocs and fixing linting errors --- ...t_allowed_a11y_prop_names_for_component.ts | 19 ++++++++++++++- .../src/utils/has_a11y_prop_for_component.ts | 24 ++++++++++++++++--- .../eslint-plugin/src/utils/has_spread.ts | 13 ++++++++-- 3 files changed, 50 insertions(+), 6 deletions(-) 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 index 7fd7a8fc1c2..5df706a485d 100644 --- 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 @@ -17,12 +17,29 @@ * 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 @@ -36,4 +53,4 @@ export function getAllowedA11yPropNamesForComponent( return [...cfg.baseA11yProps, 'label']; } return [...cfg.baseA11yProps]; -} \ No newline at end of file +} 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 index c50e6982e88..e3461b2094e 100644 --- a/packages/eslint-plugin/src/utils/has_a11y_prop_for_component.ts +++ b/packages/eslint-plugin/src/utils/has_a11y_prop_for_component.ts @@ -17,23 +17,41 @@ * 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)); + 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) ); -} \ No newline at end of file +} diff --git a/packages/eslint-plugin/src/utils/has_spread.ts b/packages/eslint-plugin/src/utils/has_spread.ts index 9460b13ec04..973274f956e 100644 --- a/packages/eslint-plugin/src/utils/has_spread.ts +++ b/packages/eslint-plugin/src/utils/has_spread.ts @@ -1,4 +1,3 @@ - /* * Licensed to Elasticsearch B.V. under one or more contributor * license agreements. See the NOTICE file distributed with @@ -20,8 +19,18 @@ 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'); -} \ No newline at end of file +}