diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index 40af3a2eaaf..7d25d3a4aa4 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -125,9 +125,13 @@ function MyComponent() { ) } ``` - It's worth pointing out that although the examples provided are specific to EUI components, this rule applies to all JSX elements. +### `@elastic/eui/require-aria-label-for-modals` + +Ensures that EUI modal components (`EuiModal`, `EuiFlyout`, `EuiConfirmModal`) have either an `aria-label` or `aria-labelledby` prop for accessibility. This helps screen reader users understand the purpose and content of modal dialogs. + + ## Testing ### Running unit tests diff --git a/packages/eslint-plugin/changelogs/upcoming/8811.md b/packages/eslint-plugin/changelogs/upcoming/8811.md new file mode 100644 index 00000000000..3a8a031ada5 --- /dev/null +++ b/packages/eslint-plugin/changelogs/upcoming/8811.md @@ -0,0 +1 @@ +- Added new `require-aria-label-for-modals` rule. diff --git a/packages/eslint-plugin/src/index.ts b/packages/eslint-plugin/src/index.ts index f432fc01357..c0168e6a485 100644 --- a/packages/eslint-plugin/src/index.ts +++ b/packages/eslint-plugin/src/index.ts @@ -21,11 +21,14 @@ import { HrefOnClick } from './rules/href_or_on_click'; import { NoRestrictedEuiImports } from './rules/no_restricted_eui_imports'; import { NoCssColor } from './rules/no_css_color'; +import { RequireAriaLabelForModals } from './rules/a11y/require_aria_label_for_modals'; + const config = { rules: { 'href-or-on-click': HrefOnClick, 'no-restricted-eui-imports': NoRestrictedEuiImports, 'no-css-color': NoCssColor, + 'require-aria-label-for-modals': RequireAriaLabelForModals, }, configs: { recommended: { @@ -34,6 +37,7 @@ const config = { '@elastic/eui/href-or-on-click': 'warn', '@elastic/eui/no-restricted-eui-imports': 'warn', '@elastic/eui/no-css-color': 'warn', + '@elastic/eui/require-aria-label-for-modals': 'warn', }, }, }, diff --git a/packages/eslint-plugin/src/rules/a11y/require_aria_label_for_modals.test.ts b/packages/eslint-plugin/src/rules/a11y/require_aria_label_for_modals.test.ts new file mode 100644 index 00000000000..bdd0a1ebc1b --- /dev/null +++ b/packages/eslint-plugin/src/rules/a11y/require_aria_label_for_modals.test.ts @@ -0,0 +1,115 @@ +/* + * 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 { RequireAriaLabelForModals } from './require_aria_label_for_modals'; + +const languageOptions = { + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, +}; + +const ruleTester = new RuleTester(); + +ruleTester.run('require-aria-label-for-modals', RequireAriaLabelForModals, { + valid: [ + { + code: dedent` + module.export = () => ( + + ) + `, + languageOptions, + }, + { + code: dedent` + module.export = () => ( + + ) + `, + languageOptions, + }, + { + code: dedent` + module.export = () => ( + + ) + `, + languageOptions, + }, + { + code: dedent` + module.export = () => ( +
Regular component without aria
+ ) + `, + languageOptions, + }, + ], + + invalid: [ + { + code: dedent` + module.export = () => ( + + ) + `, + languageOptions, + errors: [ + { + messageId: 'modalAriaMissing', + data: { component: 'EuiModal' }, + }, + ], + }, + { + code: dedent` + module.export = () => ( + + ) + `, + languageOptions, + errors: [ + { + messageId: 'modalAriaMissing', + data: { component: 'EuiFlyout' }, + }, + ], + }, + { + code: dedent` + module.export = () => ( + + ) + `, + languageOptions, + errors: [ + { + messageId: 'confirmModalAriaMissing', + data: { component: 'EuiConfirmModal' }, + }, + ], + }, + ], +}); diff --git a/packages/eslint-plugin/src/rules/a11y/require_aria_label_for_modals.ts b/packages/eslint-plugin/src/rules/a11y/require_aria_label_for_modals.ts new file mode 100644 index 00000000000..b1aeb9ea8f1 --- /dev/null +++ b/packages/eslint-plugin/src/rules/a11y/require_aria_label_for_modals.ts @@ -0,0 +1,117 @@ +/* + * 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 { TSESTree, ESLintUtils } from '@typescript-eslint/utils'; + +const modalComponents = ['EuiModal', 'EuiFlyout']; +const confirmModalComponents = ['EuiConfirmModal']; + +export const RequireAriaLabelForModals = ESLintUtils.RuleCreator.withoutDocs({ + create(context) { + function checkAttributes(node: TSESTree.JSXOpeningElement, componentName: string, messageId: 'modalAriaMissing' | 'confirmModalAriaMissing') { + const hasAriaLabel = node.attributes.some( + (attr) => + attr.type === 'JSXAttribute' && + typeof attr.name.name === 'string' && + ['aria-label', 'aria-labelledby'].includes(attr.name.name) + ); + + if (!hasAriaLabel) { + context.report({ + node, + messageId: messageId, + data: { component: componentName }, + }); + } + } + + return { + JSXOpeningElement(node) { + if ( + node.name.type === 'JSXIdentifier' + ) { + if (modalComponents.includes(node.name.name)) { + checkAttributes(node, node.name.name, 'modalAriaMissing') + } + + if (confirmModalComponents.includes(node.name.name)) { + checkAttributes(node, node.name.name, 'confirmModalAriaMissing') + } + } + return + }, + }; + }, + meta: { + type: 'problem', + docs: { + description: 'Ensure modals have \'aria-label\' or \'aria-labelledby\'', + }, + schema: [], + messages: { + modalAriaMissing: [ + '{{ component }} must have either \'aria-label\' or \'aria-labelledby\' prop for accessibility.', + '\n', + 'Option 1: Using \'aria-labelledby\' (preferred):', + '1. Import \'useGeneratedHtmlId\':', + ' import { useGeneratedHtmlId } from \'@elastic/eui\';', + '2. Update your component:', + ' const modalTitleId = useGeneratedHtmlId();', + ' ...', + ' <{{ component }}', + ' aria-labelledby={modalTitleId}', + ' {...props} ', + ' />', + ' <{{ component }}Header>', + ' ', + ' {\'Descriptive title for the {{ component }}\'}', + ' ', + ' ', + ' ...', + ' ', + '\n', + 'Option 2: Using \'aria-label\':', + ' <{{ component }} aria-label="Descriptive title for the {{ component }}" {...props} />', + ].join('\n'), + + confirmModalAriaMissing: [ + '{{ component }} must have either \'aria-label\' or \'aria-labelledby\' prop for accessibility.', + '\n', + 'Option 1: Using \'aria-labelledby\' (preferred):', + '1. Import \'useGeneratedHtmlId\':', + ' import { useGeneratedHtmlId } from \'@elastic/eui\';', + '2. Update your component:', + ' const modalTitleId = useGeneratedHtmlId();', + ' ...', + ' <{{ component }}', + ' title="Descriptive title for the {{ component }}"', + ' aria-labelledby={modalTitleId}', + ' titleProps={({id: modalTitleId })}', + ' {...props} ', + ' />', + '\n', + 'Option 2: Using \'aria-label\':', + ' <{{ component }} aria-label="Descriptive title for the {{ component }}" {...props} />', + ].join('\n') + }, + }, + defaultOptions: [], +}); + +