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: [], + });