diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md
index dcfa0d04439..e23f2b3e98d 100644
--- a/packages/eslint-plugin/README.md
+++ b/packages/eslint-plugin/README.md
@@ -147,9 +147,13 @@ 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/callout-announce-on-mount`
+
+Ensures that `EuiCallOut` components rendered conditionally have the `announceOnMount` prop for better accessibility. When callouts appear dynamically (e.g., after user interactions, form validation errors, or status changes), screen readers may not announce their content to users. The `announceOnMount` prop ensures these messages are properly announced to users with 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/9005.md b/packages/eslint-plugin/changelogs/upcoming/9005.md
new file mode 100644
index 00000000000..6ac3484cc65
--- /dev/null
+++ b/packages/eslint-plugin/changelogs/upcoming/9005.md
@@ -0,0 +1 @@
+- Added new `callout-announce-on-mount` rule.
\ No newline at end of file
diff --git a/packages/eslint-plugin/src/index.ts b/packages/eslint-plugin/src/index.ts
index 0362ee5e0fb..430d2bddbbb 100644
--- a/packages/eslint-plugin/src/index.ts
+++ b/packages/eslint-plugin/src/index.ts
@@ -17,6 +17,7 @@
* under the License.
*/
+import { CallOutAnnounceOnMount } from './rules/a11y/callout_announce_on_mount';
import { HrefOnClick } from './rules/href_or_on_click';
import { NoRestrictedEuiImports } from './rules/no_restricted_eui_imports';
import { NoCssColor } from './rules/no_css_color';
@@ -39,6 +40,7 @@ const config = {
'sr-output-disabled-tooltip': ScreenReaderOutputDisabledTooltip,
'prefer-eui-icon-tip': PreferEuiIconTip,
'no-unnamed-radio-group' : NoUnnamedRadioGroup,
+ 'callout-announce-on-mount': CallOutAnnounceOnMount,
'no-unnamed-interactive-element': NoUnnamedInteractiveElement,
},
configs: {
@@ -53,6 +55,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/callout-announce-on-mount': 'warn',
'@elastic/eui/no-unnamed-interactive-element': 'warn',
},
},
diff --git a/packages/eslint-plugin/src/rules/a11y/callout_announce_on_mount.test.ts b/packages/eslint-plugin/src/rules/a11y/callout_announce_on_mount.test.ts
new file mode 100644
index 00000000000..5d66c1b4fd1
--- /dev/null
+++ b/packages/eslint-plugin/src/rules/a11y/callout_announce_on_mount.test.ts
@@ -0,0 +1,225 @@
+/*
+ * 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 { CallOutAnnounceOnMount } from './callout_announce_on_mount';
+import { RuleTester } from '@typescript-eslint/rule-tester';
+import dedent from 'dedent';
+
+const languageOptions = {
+ parserOptions: {
+ ecmaFeatures: {
+ jsx: true,
+ },
+ },
+};
+
+const ruleTester = new RuleTester();
+
+ruleTester.run('callout-announce-on-mount', CallOutAnnounceOnMount, {
+ valid: [
+ {
+ code: dedent`
+ const MyComponent = () => (
+
+ This callout is always rendered
+
+ )
+ `,
+ languageOptions,
+ },
+ {
+ code: dedent`
+ const MyComponent = ({ condition }) => (
+ condition &&
+ Something went wrong
+
+ )
+ `,
+ languageOptions,
+ },
+ {
+ code: dedent`
+ const MyComponent = ({ condition }) => (
+ condition ?
+ Operation completed
+ : null
+ )
+ `,
+ languageOptions,
+ },
+ {
+ code: dedent`
+ const MyComponent = ({ condition }) => {
+ if (condition) {
+ return
+ Please check your input
+
+ }
+ return null;
+ }
+ `,
+ languageOptions,
+ },
+ {
+ code: dedent`
+ const MyComponent = () => (
+
+
+ This is not conditionally rendered
+
+
+ )
+ `,
+ languageOptions,
+ },
+ ],
+ invalid: [
+ {
+ code: dedent`
+ const MyComponent = ({ condition }) => (
+ condition &&
+ Something went wrong
+
+ )
+ `,
+ output: dedent`
+ const MyComponent = ({ condition }) => (
+ condition &&
+ Something went wrong
+
+ )
+ `,
+ languageOptions,
+ errors: [{ messageId: 'missingAnnounceOnMount' }],
+ },
+ {
+ code: dedent`
+ const MyComponent = ({ condition }) => (
+ condition ?
+ Operation completed
+ : null
+ )
+ `,
+ output: dedent`
+ const MyComponent = ({ condition }) => (
+ condition ?
+ Operation completed
+ : null
+ )
+ `,
+ languageOptions,
+ errors: [{ messageId: 'missingAnnounceOnMount' }],
+ },
+ {
+ code: dedent`
+ const MyComponent = ({ condition }) => {
+ if (condition) {
+ return
+ Please check your input
+
+ }
+ return null;
+ }
+ `,
+ output: dedent`
+ const MyComponent = ({ condition }) => {
+ if (condition) {
+ return
+ Please check your input
+
+ }
+ return null;
+ }
+ `,
+ languageOptions,
+ errors: [{ messageId: 'missingAnnounceOnMount' }],
+ },
+ {
+ code: dedent`
+ const MyComponent = ({ condition }) => (
+
+ {!condition &&
+ Form contains errors
+ }
+
+ )
+ `,
+ output: dedent`
+ const MyComponent = ({ condition }) => (
+
+ {!condition &&
+ Form contains errors
+ }
+
+ )
+ `,
+ languageOptions,
+ errors: [{ messageId: 'missingAnnounceOnMount' }],
+ },
+ {
+ code: dedent`
+ const MyComponent = ({ status }) => {
+ let notification;
+
+ if (status === 'success') {
+ notification = (
+
+ );
+ } else if (status === 'error') {
+ notification = (
+
+ );
+ }
+
+ return {notification}
;
+ }
+ `,
+ output: dedent`
+ const MyComponent = ({ status }) => {
+ let notification;
+
+ if (status === 'success') {
+ notification = (
+
+ );
+ } else if (status === 'error') {
+ notification = (
+
+ );
+ }
+
+ return {notification}
;
+ }
+ `,
+ languageOptions,
+ errors: [
+ { messageId: 'missingAnnounceOnMount' },
+ { messageId: 'missingAnnounceOnMount' },
+ ],
+ },
+ ],
+});
diff --git a/packages/eslint-plugin/src/rules/a11y/callout_announce_on_mount.ts b/packages/eslint-plugin/src/rules/a11y/callout_announce_on_mount.ts
new file mode 100644
index 00000000000..8d4f9b55a48
--- /dev/null
+++ b/packages/eslint-plugin/src/rules/a11y/callout_announce_on_mount.ts
@@ -0,0 +1,80 @@
+/*
+ * 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';
+import { isInConditionalRendering } from '../../utils/is_in_conditional_rendering';
+import { hasSpread } from '../../utils/has_spread';
+
+const CALLOUT_COMPONENT = 'EuiCallOut';
+
+export const CallOutAnnounceOnMount = ESLintUtils.RuleCreator.withoutDocs({
+ create(context) {
+ return {
+ JSXElement(node) {
+ const { openingElement } = node;
+ if (openingElement.name.type !== 'JSXIdentifier' ||
+ openingElement.name.name !== CALLOUT_COMPONENT) {
+ return;
+ }
+ if (openingElement.attributes.some(attr =>
+ attr.type === 'JSXAttribute' &&
+ attr.name.type === 'JSXIdentifier' &&
+ attr.name.name === 'announceOnMount'
+ )) {
+ return;
+ }
+ if (isInConditionalRendering(node)) {
+ context.report({
+ node: openingElement,
+ messageId: 'missingAnnounceOnMount',
+ fix: hasSpread(openingElement.attributes) ? undefined : (fixer) => {
+ return fixer.insertTextAfterRange(
+ [openingElement.name.range[1], openingElement.name.range[1]],
+ ' announceOnMount'
+ );
+ },
+ });
+ }
+ },
+ };
+ },
+ meta: {
+ type: 'problem',
+ docs: {
+ description: `Ensure ${CALLOUT_COMPONENT} components that are conditionally rendered have announceOnMount prop for better accessibility`
+ },
+ fixable: 'code',
+ schema: [],
+ messages: {
+ missingAnnounceOnMount: [
+ `${CALLOUT_COMPONENT} should have \`announceOnMount\` prop when conditionally rendered for better accessibility.`,
+ '\n',
+ `When ${CALLOUT_COMPONENT} appears dynamically (e.g., after user interaction, form validation, etc.),`,
+ 'screen readers may not announce its content. Adding `announceOnMount` ensures the callout',
+ 'is properly announced to users with assistive technologies.',
+ '\n',
+ 'Example:',
+ ` <${CALLOUT_COMPONENT} announceOnMount title="Error" color="danger">`,
+ ' This message will be announced when it appears',
+ ` ${CALLOUT_COMPONENT}>`,
+ ].join('\n'),
+ },
+ },
+ defaultOptions: [],
+});
diff --git a/packages/eslint-plugin/src/utils/is_in_conditional_rendering.ts b/packages/eslint-plugin/src/utils/is_in_conditional_rendering.ts
new file mode 100644
index 00000000000..b10c6541b1c
--- /dev/null
+++ b/packages/eslint-plugin/src/utils/is_in_conditional_rendering.ts
@@ -0,0 +1,33 @@
+/*
+ * 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 } from '@typescript-eslint/utils';
+
+export function isInConditionalRendering(node: TSESTree.JSXElement): boolean {
+ let parent: TSESTree.Node | undefined = node.parent;
+ while (parent) {
+ if (parent.type === 'ConditionalExpression' ||
+ parent.type === 'IfStatement' ||
+ (parent.type === 'LogicalExpression' && parent.operator === '&&')) {
+ return true;
+ }
+ parent = parent.parent;
+ }
+ return false;
+}
\ No newline at end of file