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', + ` `, + ].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