Skip to content

Commit e702a59

Browse files
feat(eslint-plugin): add new @elastic/eui/callout-announce-on-mount rule (#9005)
Co-authored-by: Weronika Olejniczak <[email protected]>
1 parent 094b531 commit e702a59

File tree

6 files changed

+347
-1
lines changed

6 files changed

+347
-1
lines changed

packages/eslint-plugin/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,9 +147,13 @@ Ensure `EuiIconTip` is used rather than `<EuiToolTip><EuiIcon/></EuiToolTip>`, a
147147

148148
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.
149149

150+
### `@elastic/eui/callout-announce-on-mount`
151+
152+
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.
153+
150154
### `@elastic/eui/no-unnamed-interactive-element`
151-
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.
152155

156+
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.
153157

154158
## Testing
155159

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Added new `callout-announce-on-mount` rule.

packages/eslint-plugin/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
* under the License.
1818
*/
1919

20+
import { CallOutAnnounceOnMount } from './rules/a11y/callout_announce_on_mount';
2021
import { HrefOnClick } from './rules/href_or_on_click';
2122
import { NoRestrictedEuiImports } from './rules/no_restricted_eui_imports';
2223
import { NoCssColor } from './rules/no_css_color';
@@ -39,6 +40,7 @@ const config = {
3940
'sr-output-disabled-tooltip': ScreenReaderOutputDisabledTooltip,
4041
'prefer-eui-icon-tip': PreferEuiIconTip,
4142
'no-unnamed-radio-group' : NoUnnamedRadioGroup,
43+
'callout-announce-on-mount': CallOutAnnounceOnMount,
4244
'no-unnamed-interactive-element': NoUnnamedInteractiveElement,
4345
},
4446
configs: {
@@ -53,6 +55,7 @@ const config = {
5355
'@elastic/eui/sr-output-disabled-tooltip': 'warn',
5456
'@elastic/eui/prefer-eui-icon-tip': 'warn',
5557
'@elastic/eui/no-unnamed-radio-group': 'warn',
58+
'@elastic/eui/callout-announce-on-mount': 'warn',
5659
'@elastic/eui/no-unnamed-interactive-element': 'warn',
5760
},
5861
},
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
/*
2+
* Licensed to Elasticsearch B.V. under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch B.V. licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import { CallOutAnnounceOnMount } from './callout_announce_on_mount';
21+
import { RuleTester } from '@typescript-eslint/rule-tester';
22+
import dedent from 'dedent';
23+
24+
const languageOptions = {
25+
parserOptions: {
26+
ecmaFeatures: {
27+
jsx: true,
28+
},
29+
},
30+
};
31+
32+
const ruleTester = new RuleTester();
33+
34+
ruleTester.run('callout-announce-on-mount', CallOutAnnounceOnMount, {
35+
valid: [
36+
{
37+
code: dedent`
38+
const MyComponent = () => (
39+
<EuiCallOut title="Always visible callout">
40+
This callout is always rendered
41+
</EuiCallOut>
42+
)
43+
`,
44+
languageOptions,
45+
},
46+
{
47+
code: dedent`
48+
const MyComponent = ({ condition }) => (
49+
condition && <EuiCallOut announceOnMount title="Error">
50+
Something went wrong
51+
</EuiCallOut>
52+
)
53+
`,
54+
languageOptions,
55+
},
56+
{
57+
code: dedent`
58+
const MyComponent = ({ condition }) => (
59+
condition ? <EuiCallOut announceOnMount title="Success">
60+
Operation completed
61+
</EuiCallOut> : null
62+
)
63+
`,
64+
languageOptions,
65+
},
66+
{
67+
code: dedent`
68+
const MyComponent = ({ condition }) => {
69+
if (condition) {
70+
return <EuiCallOut announceOnMount title="Warning">
71+
Please check your input
72+
</EuiCallOut>
73+
}
74+
return null;
75+
}
76+
`,
77+
languageOptions,
78+
},
79+
{
80+
code: dedent`
81+
const MyComponent = () => (
82+
<div>
83+
<EuiCallOut title="Static callout">
84+
This is not conditionally rendered
85+
</EuiCallOut>
86+
</div>
87+
)
88+
`,
89+
languageOptions,
90+
},
91+
],
92+
invalid: [
93+
{
94+
code: dedent`
95+
const MyComponent = ({ condition }) => (
96+
condition && <EuiCallOut title="Error">
97+
Something went wrong
98+
</EuiCallOut>
99+
)
100+
`,
101+
output: dedent`
102+
const MyComponent = ({ condition }) => (
103+
condition && <EuiCallOut announceOnMount title="Error">
104+
Something went wrong
105+
</EuiCallOut>
106+
)
107+
`,
108+
languageOptions,
109+
errors: [{ messageId: 'missingAnnounceOnMount' }],
110+
},
111+
{
112+
code: dedent`
113+
const MyComponent = ({ condition }) => (
114+
condition ? <EuiCallOut title="Success">
115+
Operation completed
116+
</EuiCallOut> : null
117+
)
118+
`,
119+
output: dedent`
120+
const MyComponent = ({ condition }) => (
121+
condition ? <EuiCallOut announceOnMount title="Success">
122+
Operation completed
123+
</EuiCallOut> : null
124+
)
125+
`,
126+
languageOptions,
127+
errors: [{ messageId: 'missingAnnounceOnMount' }],
128+
},
129+
{
130+
code: dedent`
131+
const MyComponent = ({ condition }) => {
132+
if (condition) {
133+
return <EuiCallOut title="Warning">
134+
Please check your input
135+
</EuiCallOut>
136+
}
137+
return null;
138+
}
139+
`,
140+
output: dedent`
141+
const MyComponent = ({ condition }) => {
142+
if (condition) {
143+
return <EuiCallOut announceOnMount title="Warning">
144+
Please check your input
145+
</EuiCallOut>
146+
}
147+
return null;
148+
}
149+
`,
150+
languageOptions,
151+
errors: [{ messageId: 'missingAnnounceOnMount' }],
152+
},
153+
{
154+
code: dedent`
155+
const MyComponent = ({ condition }) => (
156+
<div>
157+
{!condition && <EuiCallOut title="Validation Error">
158+
Form contains errors
159+
</EuiCallOut>}
160+
</div>
161+
)
162+
`,
163+
output: dedent`
164+
const MyComponent = ({ condition }) => (
165+
<div>
166+
{!condition && <EuiCallOut announceOnMount title="Validation Error">
167+
Form contains errors
168+
</EuiCallOut>}
169+
</div>
170+
)
171+
`,
172+
languageOptions,
173+
errors: [{ messageId: 'missingAnnounceOnMount' }],
174+
},
175+
{
176+
code: dedent`
177+
const MyComponent = ({ status }) => {
178+
let notification;
179+
180+
if (status === 'success') {
181+
notification = (
182+
<EuiCallOut
183+
title="Task completed successfully"
184+
/>
185+
);
186+
} else if (status === 'error') {
187+
notification = (
188+
<EuiCallOut
189+
title="Something went wrong"
190+
/>
191+
);
192+
}
193+
194+
return <div>{notification}</div>;
195+
}
196+
`,
197+
output: dedent`
198+
const MyComponent = ({ status }) => {
199+
let notification;
200+
201+
if (status === 'success') {
202+
notification = (
203+
<EuiCallOut announceOnMount
204+
title="Task completed successfully"
205+
/>
206+
);
207+
} else if (status === 'error') {
208+
notification = (
209+
<EuiCallOut announceOnMount
210+
title="Something went wrong"
211+
/>
212+
);
213+
}
214+
215+
return <div>{notification}</div>;
216+
}
217+
`,
218+
languageOptions,
219+
errors: [
220+
{ messageId: 'missingAnnounceOnMount' },
221+
{ messageId: 'missingAnnounceOnMount' },
222+
],
223+
},
224+
],
225+
});
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/*
2+
* Licensed to Elasticsearch B.V. under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch B.V. licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import { ESLintUtils } from '@typescript-eslint/utils';
21+
import { isInConditionalRendering } from '../../utils/is_in_conditional_rendering';
22+
import { hasSpread } from '../../utils/has_spread';
23+
24+
const CALLOUT_COMPONENT = 'EuiCallOut';
25+
26+
export const CallOutAnnounceOnMount = ESLintUtils.RuleCreator.withoutDocs({
27+
create(context) {
28+
return {
29+
JSXElement(node) {
30+
const { openingElement } = node;
31+
if (openingElement.name.type !== 'JSXIdentifier' ||
32+
openingElement.name.name !== CALLOUT_COMPONENT) {
33+
return;
34+
}
35+
if (openingElement.attributes.some(attr =>
36+
attr.type === 'JSXAttribute' &&
37+
attr.name.type === 'JSXIdentifier' &&
38+
attr.name.name === 'announceOnMount'
39+
)) {
40+
return;
41+
}
42+
if (isInConditionalRendering(node)) {
43+
context.report({
44+
node: openingElement,
45+
messageId: 'missingAnnounceOnMount',
46+
fix: hasSpread(openingElement.attributes) ? undefined : (fixer) => {
47+
return fixer.insertTextAfterRange(
48+
[openingElement.name.range[1], openingElement.name.range[1]],
49+
' announceOnMount'
50+
);
51+
},
52+
});
53+
}
54+
},
55+
};
56+
},
57+
meta: {
58+
type: 'problem',
59+
docs: {
60+
description: `Ensure ${CALLOUT_COMPONENT} components that are conditionally rendered have announceOnMount prop for better accessibility`
61+
},
62+
fixable: 'code',
63+
schema: [],
64+
messages: {
65+
missingAnnounceOnMount: [
66+
`${CALLOUT_COMPONENT} should have \`announceOnMount\` prop when conditionally rendered for better accessibility.`,
67+
'\n',
68+
`When ${CALLOUT_COMPONENT} appears dynamically (e.g., after user interaction, form validation, etc.),`,
69+
'screen readers may not announce its content. Adding `announceOnMount` ensures the callout',
70+
'is properly announced to users with assistive technologies.',
71+
'\n',
72+
'Example:',
73+
` <${CALLOUT_COMPONENT} announceOnMount title="Error" color="danger">`,
74+
' This message will be announced when it appears',
75+
` </${CALLOUT_COMPONENT}>`,
76+
].join('\n'),
77+
},
78+
},
79+
defaultOptions: [],
80+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Licensed to Elasticsearch B.V. under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch B.V. licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import { TSESTree } from '@typescript-eslint/utils';
21+
22+
export function isInConditionalRendering(node: TSESTree.JSXElement): boolean {
23+
let parent: TSESTree.Node | undefined = node.parent;
24+
while (parent) {
25+
if (parent.type === 'ConditionalExpression' ||
26+
parent.type === 'IfStatement' ||
27+
(parent.type === 'LogicalExpression' && parent.operator === '&&')) {
28+
return true;
29+
}
30+
parent = parent.parent;
31+
}
32+
return false;
33+
}

0 commit comments

Comments
 (0)