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 }}\'}',
+ ' ',
+ ' { component }}Header>',
+ ' ...',
+ ' {{ 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: [],
+});
+
+