From 23e41c116cabf6bf9a99675a1556bd0133b567f0 Mon Sep 17 00:00:00 2001 From: "paulina.shakirova" Date: Wed, 10 Sep 2025 21:34:18 +0200 Subject: [PATCH 1/5] [a11y] Add a rule for conditionally rendered EuiCallOut --- packages/eslint-plugin/README.md | 4 + packages/eslint-plugin/src/index.ts | 3 + .../a11y/callout_announce_on_mount.test.ts | 225 ++++++++++++++++++ .../rules/a11y/callout_announce_on_mount.ts | 94 ++++++++ 4 files changed, 326 insertions(+) create mode 100644 packages/eslint-plugin/src/rules/a11y/callout_announce_on_mount.test.ts create mode 100644 packages/eslint-plugin/src/rules/a11y/callout_announce_on_mount.ts diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index e3a3b76d9d2..ba368f49a1b 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -147,6 +147,10 @@ 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. + ## Testing ### Running unit tests diff --git a/packages/eslint-plugin/src/index.ts b/packages/eslint-plugin/src/index.ts index ca7d25bf06c..0220b5a4ef2 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'; @@ -37,6 +38,7 @@ const config = { 'sr-output-disabled-tooltip': ScreenReaderOutputDisabledTooltip, 'prefer-eui-icon-tip': PreferEuiIconTip, 'no-unnamed-radio-group' : NoUnnamedRadioGroup, + 'callout-announce-on-mount': CallOutAnnounceOnMount }, configs: { recommended: { @@ -50,6 +52,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', }, }, }, 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..526f80e5440 --- /dev/null +++ b/packages/eslint-plugin/src/rules/a11y/callout_announce_on_mount.ts @@ -0,0 +1,94 @@ +/* + * 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 { type TSESTree, ESLintUtils } from '@typescript-eslint/utils'; + +const CALLOUT_COMPONENT = 'EuiCallOut'; + +export const CallOutAnnounceOnMount = ESLintUtils.RuleCreator.withoutDocs({ + create(context) { + 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; + } + + 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)) { + const hasSpread = openingElement.attributes.some(a => a.type === 'JSXSpreadAttribute'); + + context.report({ + node: openingElement, + messageId: 'missingAnnounceOnMount', + fix: hasSpread ? undefined : (fixer) => { + return fixer.insertTextAfterRange( + [openingElement.name.range[1], openingElement.name.range[1]], + ' announceOnMount' + ); + }, + }); + } + }, + }; + }, + meta: { + type: 'suggestion', + docs: { + description: 'Ensure EuiCallout components that are conditionally rendered have announceOnMount prop for better accessibility' + }, + fixable: 'code', + schema: [], + messages: { + missingAnnounceOnMount: [ + 'EuiCallout should have "announceOnMount" prop when conditionally rendered for better accessibility.', + '\n', + 'When EuiCallout 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:', + ' ', + ' This message will be announced when it appears', + ' ', + ].join('\n'), + }, + }, + defaultOptions: [], +}); From fcf010fe092dc21ad0b5bcb1ae91cebba3ac83c2 Mon Sep 17 00:00:00 2001 From: "paulina.shakirova" Date: Wed, 10 Sep 2025 22:06:08 +0200 Subject: [PATCH 2/5] add changelog file --- packages/eslint-plugin/changelogs/upcoming/9005.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 packages/eslint-plugin/changelogs/upcoming/9005.md diff --git a/packages/eslint-plugin/changelogs/upcoming/9005.md b/packages/eslint-plugin/changelogs/upcoming/9005.md new file mode 100644 index 00000000000..0ae1921808f --- /dev/null +++ b/packages/eslint-plugin/changelogs/upcoming/9005.md @@ -0,0 +1,3 @@ +**Accessibility** + +- Added new `callout-announce-on-mount` rule. \ No newline at end of file From 7ddbcfb68e599b9c3fbedafdf03e3a83c17df124 Mon Sep 17 00:00:00 2001 From: "paulina.shakirova" Date: Fri, 12 Sep 2025 14:18:15 +0200 Subject: [PATCH 3/5] resolve comments --- .../rules/a11y/callout_announce_on_mount.ts | 36 ++++++------------- .../src/utils/is_in_conditional_rendering.ts | 33 +++++++++++++++++ 2 files changed, 44 insertions(+), 25 deletions(-) create mode 100644 packages/eslint-plugin/src/utils/is_in_conditional_rendering.ts 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 index 526f80e5440..8d4f9b55a48 100644 --- a/packages/eslint-plugin/src/rules/a11y/callout_announce_on_mount.ts +++ b/packages/eslint-plugin/src/rules/a11y/callout_announce_on_mount.ts @@ -17,26 +17,14 @@ * under the License. */ -import { type TSESTree, ESLintUtils } from '@typescript-eslint/utils'; +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) { - 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; - } - return { JSXElement(node) { const { openingElement } = node; @@ -52,12 +40,10 @@ export const CallOutAnnounceOnMount = ESLintUtils.RuleCreator.withoutDocs({ return; } if (isInConditionalRendering(node)) { - const hasSpread = openingElement.attributes.some(a => a.type === 'JSXSpreadAttribute'); - context.report({ node: openingElement, messageId: 'missingAnnounceOnMount', - fix: hasSpread ? undefined : (fixer) => { + fix: hasSpread(openingElement.attributes) ? undefined : (fixer) => { return fixer.insertTextAfterRange( [openingElement.name.range[1], openingElement.name.range[1]], ' announceOnMount' @@ -69,24 +55,24 @@ export const CallOutAnnounceOnMount = ESLintUtils.RuleCreator.withoutDocs({ }; }, meta: { - type: 'suggestion', + type: 'problem', docs: { - description: 'Ensure EuiCallout components that are conditionally rendered have announceOnMount prop for better accessibility' + description: `Ensure ${CALLOUT_COMPONENT} components that are conditionally rendered have announceOnMount prop for better accessibility` }, fixable: 'code', schema: [], messages: { missingAnnounceOnMount: [ - 'EuiCallout should have "announceOnMount" prop when conditionally rendered for better accessibility.', + `${CALLOUT_COMPONENT} should have \`announceOnMount\` prop when conditionally rendered for better accessibility.`, '\n', - 'When EuiCallout appears dynamically (e.g., after user interaction, form validation, etc.),', - 'screen readers may not announce its content. Adding "announceOnMount" ensures the callout', + `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'), }, }, 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 From 9e3652c152509564cef5bac632d1ba61c789399c Mon Sep 17 00:00:00 2001 From: "paulina.shakirova" Date: Fri, 12 Sep 2025 14:33:38 +0200 Subject: [PATCH 4/5] style: add comma because of merge conflict --- packages/eslint-plugin/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/eslint-plugin/src/index.ts b/packages/eslint-plugin/src/index.ts index efa51b0381c..430d2bddbbb 100644 --- a/packages/eslint-plugin/src/index.ts +++ b/packages/eslint-plugin/src/index.ts @@ -40,7 +40,7 @@ const config = { 'sr-output-disabled-tooltip': ScreenReaderOutputDisabledTooltip, 'prefer-eui-icon-tip': PreferEuiIconTip, 'no-unnamed-radio-group' : NoUnnamedRadioGroup, - 'callout-announce-on-mount': CallOutAnnounceOnMount + 'callout-announce-on-mount': CallOutAnnounceOnMount, 'no-unnamed-interactive-element': NoUnnamedInteractiveElement, }, configs: { From 7c054addfaa9e44c9c266deddc04dc02a5ef3fa6 Mon Sep 17 00:00:00 2001 From: Paulina Shakirova Date: Fri, 12 Sep 2025 19:14:26 +0200 Subject: [PATCH 5/5] Update packages/eslint-plugin/changelogs/upcoming/9005.md Co-authored-by: Weronika Olejniczak <32842468+weronikaolejniczak@users.noreply.github.com> --- packages/eslint-plugin/changelogs/upcoming/9005.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/eslint-plugin/changelogs/upcoming/9005.md b/packages/eslint-plugin/changelogs/upcoming/9005.md index 0ae1921808f..6ac3484cc65 100644 --- a/packages/eslint-plugin/changelogs/upcoming/9005.md +++ b/packages/eslint-plugin/changelogs/upcoming/9005.md @@ -1,3 +1 @@ -**Accessibility** - - Added new `callout-announce-on-mount` rule. \ No newline at end of file