diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md
index d6401af226f..c01521666e3 100644
--- a/packages/eslint-plugin/README.md
+++ b/packages/eslint-plugin/README.md
@@ -135,6 +135,10 @@ Ensures that EUI modal components (`EuiModal`, `EuiFlyout`, `EuiConfirmModal`) h
Ensures that form control components within `EuiFormRow` components have matching `isInvalid` prop values. This maintains consistent validation state between parent form rows and their child form controls, leading to a more predictable and accessible user experience.
+### `@elastic/eui/sr_output_disabled_tooltip`
+
+Ensures `disableScreenReaderOutput` is set when `EuiToolTip` content matches `EuiButtonIcon` "aria-label".
+
## Testing
### Running unit tests
diff --git a/packages/eslint-plugin/changelogs/upcoming/8848.md b/packages/eslint-plugin/changelogs/upcoming/8848.md
new file mode 100644
index 00000000000..c0bbcf08a43
--- /dev/null
+++ b/packages/eslint-plugin/changelogs/upcoming/8848.md
@@ -0,0 +1 @@
+- Added new `sr_output_disabled_tooltip` rule.
diff --git a/packages/eslint-plugin/src/index.ts b/packages/eslint-plugin/src/index.ts
index cf8844f012f..60e792fc4d9 100644
--- a/packages/eslint-plugin/src/index.ts
+++ b/packages/eslint-plugin/src/index.ts
@@ -23,6 +23,7 @@ import { NoCssColor } from './rules/no_css_color';
import { RequireAriaLabelForModals } from './rules/a11y/require_aria_label_for_modals';
import { ConsistentIsInvalidProps } from './rules/a11y/consistent_is_invalid_props';
+import { ScreenReaderOutputDisabledTooltip } from './rules/a11y/sr_output_disabled_tooltip';
const config = {
rules: {
@@ -31,6 +32,7 @@ const config = {
'no-css-color': NoCssColor,
'require-aria-label-for-modals': RequireAriaLabelForModals,
'consistent-is-invalid-props': ConsistentIsInvalidProps,
+ 'sr_output_disabled_tooltip': ScreenReaderOutputDisabledTooltip,
},
configs: {
recommended: {
@@ -41,6 +43,7 @@ const config = {
'@elastic/eui/no-css-color': 'warn',
'@elastic/eui/require-aria-label-for-modals': 'warn',
'@elastic/eui/consistent-is-invalid-props': 'warn',
+ '@elastic/eui/sr_output_disabled_tooltip': 'warn',
},
},
},
diff --git a/packages/eslint-plugin/src/rules/a11y/sr_output_disabled_tooltip.test.ts b/packages/eslint-plugin/src/rules/a11y/sr_output_disabled_tooltip.test.ts
new file mode 100644
index 00000000000..1503b5c64b2
--- /dev/null
+++ b/packages/eslint-plugin/src/rules/a11y/sr_output_disabled_tooltip.test.ts
@@ -0,0 +1,135 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import dedent from 'dedent';
+import { RuleTester } from '@typescript-eslint/rule-tester';
+import { ScreenReaderOutputDisabledTooltip } from './sr_output_disabled_tooltip';
+
+const languageOptions = {
+ parserOptions: {
+ ecmaFeatures: {
+ jsx: true,
+ },
+ },
+};
+
+const ruleTester = new RuleTester();
+
+ruleTester.run(
+ 'screen-reader-output-disabled-tooltip',
+ ScreenReaderOutputDisabledTooltip,
+ {
+ valid: [
+ {
+ code: dedent`
+ const MyComponent = () => (
+
+
+
+ )
+ `,
+ languageOptions,
+ },
+ {
+ code: dedent`
+ const MyComponent = () => (
+
+
+
+ )
+ `,
+ languageOptions,
+ },
+ {
+ code: dedent`
+ const MyComponent = () => (
+
+ Not a button component
+
+ )
+ `,
+ languageOptions,
+ },
+ {
+ code: dedent`
+ const MyComponent = () => (
+
+ )
+ `,
+ languageOptions,
+ },
+ ],
+ invalid: [
+ {
+ code: dedent`
+ const MyComponent = () => (
+
+
+
+ )
+ `,
+ output: dedent`
+ const MyComponent = () => (
+
+
+
+ )
+ `,
+ languageOptions,
+ errors: [{ messageId: 'requireDisableScreenReader' }],
+ },
+ {
+ code: dedent`
+ const MyComponent = () => {
+ const label = "Add filter";
+ return (
+
+
+
+ );
+ }
+ `,
+ output: dedent`
+ const MyComponent = () => {
+ const label = "Add filter";
+ return (
+
+
+
+ );
+ }
+ `,
+ languageOptions,
+ errors: [{ messageId: 'requireDisableScreenReader' }],
+ },
+ ],
+ }
+);
diff --git a/packages/eslint-plugin/src/rules/a11y/sr_output_disabled_tooltip.ts b/packages/eslint-plugin/src/rules/a11y/sr_output_disabled_tooltip.ts
new file mode 100644
index 00000000000..fb0c64445bf
--- /dev/null
+++ b/packages/eslint-plugin/src/rules/a11y/sr_output_disabled_tooltip.ts
@@ -0,0 +1,111 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { type TSESTree, ESLintUtils } from '@typescript-eslint/utils';
+import { getAttrValue } from '../../utils/get_attr_value';
+
+const tooltipComponent = 'EuiToolTip';
+const disabledTooltipComponentProp = 'disableScreenReaderOutput';
+const buttonComponents = ['EuiButtonIcon'];
+
+const normalizeAttrString = (str?: string) => str?.trim().replace(/\s+/g, ' ');
+
+export const ScreenReaderOutputDisabledTooltip =
+ ESLintUtils.RuleCreator.withoutDocs({
+ create(context) {
+ return {
+ JSXElement(node) {
+ const openingElement = node.openingElement;
+
+ if (
+ openingElement?.name.type !== 'JSXIdentifier' ||
+ openingElement.name.name !== tooltipComponent
+ ) {
+ return;
+ }
+
+ const tooltipContent = getAttrValue(
+ context,
+ openingElement.attributes,
+ 'content'
+ );
+
+ const hasDisableScreenReader = openingElement.attributes.some(
+ (attr): attr is TSESTree.JSXAttribute =>
+ attr.type === 'JSXAttribute' &&
+ attr.name.type === 'JSXIdentifier' &&
+ attr.name.name === disabledTooltipComponentProp
+ );
+
+ if (hasDisableScreenReader) {
+ return;
+ }
+
+ const buttonElement = node.children.find(
+ (child): child is TSESTree.JSXElement =>
+ child.type === 'JSXElement' &&
+ child.openingElement?.name.type === 'JSXIdentifier' &&
+ buttonComponents.includes(child.openingElement.name.name)
+ );
+
+ if (!buttonElement) {
+ return;
+ }
+
+ const ariaLabel = getAttrValue(
+ context,
+ buttonElement.openingElement.attributes,
+ 'aria-label'
+ );
+
+ if (
+ tooltipContent &&
+ ariaLabel &&
+ normalizeAttrString(tooltipContent) === normalizeAttrString(ariaLabel)
+ ) {
+ const buttonElementName = (
+ buttonElement.openingElement.name as TSESTree.JSXIdentifier
+ ).name;
+
+ context.report({
+ node: openingElement,
+ messageId: 'requireDisableScreenReader',
+ fix: (fixer) => {
+ const lastAttr = openingElement.attributes[openingElement.attributes.length - 1];
+ const insertPosition = lastAttr ? lastAttr.range[1] : openingElement.name.range[1];
+
+ return fixer.insertTextAfterRange(
+ [insertPosition, insertPosition],
+ ` ${disabledTooltipComponentProp}`
+ );
+ },
+ data: {
+ tooltipComponent,
+ disabledTooltipComponentProp,
+ buttonElementName,
+ },
+ });
+ }
+ },
+ };
+ },
+ meta: {
+ type: 'problem',
+ docs: {
+ description:
+ 'Ensure "{{disabledTooltipComponentProp}}" attribute is set when {{tooltipComponent}} content matches {{buttonElementName}} "aria-label"',
+ },
+ fixable: 'code',
+ schema: [],
+ messages: {
+ requireDisableScreenReader:
+ '{{tooltipComponent}} should include "{{disabledTooltipComponentProp}}" attribute when its content matches the "aria-label" of {{buttonElementName}} to avoid redundant announcements.',
+ },
+ },
+ defaultOptions: [],
+ });