Skip to content

Commit a769f4d

Browse files
committed
Extract rule: template-require-presentational-children
1 parent 97c7d56 commit a769f4d

4 files changed

Lines changed: 517 additions & 22 deletions

File tree

README.md

Lines changed: 23 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -178,28 +178,29 @@ rules in templates can be disabled with eslint directives with mustache or html
178178

179179
### Accessibility
180180

181-
| Name                                    | Description | 💼 | 🔧 | 💡 |
182-
| :----------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------- | :- | :- | :- |
183-
| [template-link-href-attributes](docs/rules/template-link-href-attributes.md) | require href attribute on link elements | | | |
184-
| [template-no-abstract-roles](docs/rules/template-no-abstract-roles.md) | disallow abstract ARIA roles | | | |
185-
| [template-no-accesskey-attribute](docs/rules/template-no-accesskey-attribute.md) | disallow accesskey attribute | | 🔧 | |
186-
| [template-no-aria-hidden-body](docs/rules/template-no-aria-hidden-body.md) | disallow aria-hidden on body element | | 🔧 | |
187-
| [template-no-aria-unsupported-elements](docs/rules/template-no-aria-unsupported-elements.md) | disallow ARIA roles, states, and properties on elements that do not support them | | | |
188-
| [template-no-autofocus-attribute](docs/rules/template-no-autofocus-attribute.md) | disallow autofocus attribute | | 🔧 | |
189-
| [template-no-duplicate-landmark-elements](docs/rules/template-no-duplicate-landmark-elements.md) | disallow duplicate landmark elements without unique labels | | | |
190-
| [template-no-empty-headings](docs/rules/template-no-empty-headings.md) | disallow empty heading elements | | | |
191-
| [template-no-heading-inside-button](docs/rules/template-no-heading-inside-button.md) | disallow heading elements inside button elements | | | |
192-
| [template-no-invalid-aria-attributes](docs/rules/template-no-invalid-aria-attributes.md) | disallow invalid aria-* attributes | | | |
193-
| [template-no-invalid-interactive](docs/rules/template-no-invalid-interactive.md) | disallow non-interactive elements with interactive handlers | | | |
194-
| [template-no-invalid-link-text](docs/rules/template-no-invalid-link-text.md) | disallow invalid or uninformative link text content | | | |
195-
| [template-no-invalid-link-title](docs/rules/template-no-invalid-link-title.md) | disallow invalid title attributes on link elements | | | |
196-
| [template-no-invalid-role](docs/rules/template-no-invalid-role.md) | disallow invalid ARIA roles | | | |
197-
| [template-no-nested-interactive](docs/rules/template-no-nested-interactive.md) | disallow nested interactive elements | | | |
198-
| [template-no-nested-landmark](docs/rules/template-no-nested-landmark.md) | disallow nested landmark elements | | | |
199-
| [template-no-pointer-down-event-binding](docs/rules/template-no-pointer-down-event-binding.md) | disallow pointer down event bindings | | | |
200-
| [template-require-valid-alt-text](docs/rules/template-require-valid-alt-text.md) | require valid alt text for images and other elements | | | |
201-
| [template-require-valid-form-groups](docs/rules/template-require-valid-form-groups.md) | require grouped form controls to have fieldset/legend or WAI-ARIA group labeling | | | |
202-
| [template-table-groups](docs/rules/template-table-groups.md) | require table elements to use table grouping elements | | | |
181+
| Name                                     | Description | 💼 | 🔧 | 💡 |
182+
| :------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------- | :- | :- | :- |
183+
| [template-link-href-attributes](docs/rules/template-link-href-attributes.md) | require href attribute on link elements | | | |
184+
| [template-no-abstract-roles](docs/rules/template-no-abstract-roles.md) | disallow abstract ARIA roles | | | |
185+
| [template-no-accesskey-attribute](docs/rules/template-no-accesskey-attribute.md) | disallow accesskey attribute | | 🔧 | |
186+
| [template-no-aria-hidden-body](docs/rules/template-no-aria-hidden-body.md) | disallow aria-hidden on body element | | 🔧 | |
187+
| [template-no-aria-unsupported-elements](docs/rules/template-no-aria-unsupported-elements.md) | disallow ARIA roles, states, and properties on elements that do not support them | | | |
188+
| [template-no-autofocus-attribute](docs/rules/template-no-autofocus-attribute.md) | disallow autofocus attribute | | 🔧 | |
189+
| [template-no-duplicate-landmark-elements](docs/rules/template-no-duplicate-landmark-elements.md) | disallow duplicate landmark elements without unique labels | | | |
190+
| [template-no-empty-headings](docs/rules/template-no-empty-headings.md) | disallow empty heading elements | | | |
191+
| [template-no-heading-inside-button](docs/rules/template-no-heading-inside-button.md) | disallow heading elements inside button elements | | | |
192+
| [template-no-invalid-aria-attributes](docs/rules/template-no-invalid-aria-attributes.md) | disallow invalid aria-* attributes | | | |
193+
| [template-no-invalid-interactive](docs/rules/template-no-invalid-interactive.md) | disallow non-interactive elements with interactive handlers | | | |
194+
| [template-no-invalid-link-text](docs/rules/template-no-invalid-link-text.md) | disallow invalid or uninformative link text content | | | |
195+
| [template-no-invalid-link-title](docs/rules/template-no-invalid-link-title.md) | disallow invalid title attributes on link elements | | | |
196+
| [template-no-invalid-role](docs/rules/template-no-invalid-role.md) | disallow invalid ARIA roles | | | |
197+
| [template-no-nested-interactive](docs/rules/template-no-nested-interactive.md) | disallow nested interactive elements | | | |
198+
| [template-no-nested-landmark](docs/rules/template-no-nested-landmark.md) | disallow nested landmark elements | | | |
199+
| [template-no-pointer-down-event-binding](docs/rules/template-no-pointer-down-event-binding.md) | disallow pointer down event bindings | | | |
200+
| [template-require-presentational-children](docs/rules/template-require-presentational-children.md) | require presentational elements to only contain presentational children | | | |
201+
| [template-require-valid-alt-text](docs/rules/template-require-valid-alt-text.md) | require valid alt text for images and other elements | | | |
202+
| [template-require-valid-form-groups](docs/rules/template-require-valid-form-groups.md) | require grouped form controls to have fieldset/legend or WAI-ARIA group labeling | | | |
203+
| [template-table-groups](docs/rules/template-table-groups.md) | require table elements to use table grouping elements | | | |
203204

204205
### Best Practices
205206

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# ember/template-require-presentational-children
2+
3+
<!-- end auto-generated rule header -->
4+
5+
Requires presentational elements to only contain presentational children.
6+
7+
When an element is marked as presentational (with `role="none"` or `role="presentation"`), its semantic children should not be present as they would be confusing to assistive technology users.
8+
9+
## Rule Details
10+
11+
This rule checks that elements with `role="presentation"` or `role="none"` don't contain semantic children that expect the parent's semantic structure.
12+
13+
## Examples
14+
15+
Examples of **incorrect** code for this rule:
16+
17+
```gjs
18+
<template>
19+
<ul role="presentation">
20+
<li>Item</li>
21+
</ul>
22+
</template>
23+
```
24+
25+
```gjs
26+
<template>
27+
<table role="none">
28+
<tr><td>Data</td></tr>
29+
</table>
30+
</template>
31+
```
32+
33+
```gjs
34+
<template>
35+
<ol role="presentation">
36+
<li>Item</li>
37+
</ol>
38+
</template>
39+
```
40+
41+
Examples of **correct** code for this rule:
42+
43+
```gjs
44+
<template>
45+
<ul role="presentation">
46+
<div>Content</div>
47+
</ul>
48+
</template>
49+
```
50+
51+
```gjs
52+
<template>
53+
<ul>
54+
<li>Item</li>
55+
</ul>
56+
</template>
57+
```
58+
59+
```gjs
60+
<template>
61+
<table>
62+
<tbody>
63+
<tr><td>Data</td></tr>
64+
</tbody>
65+
</table>
66+
</template>
67+
```
68+
69+
## Configuration
70+
71+
The following values are valid configuration:
72+
73+
- object -- An object with the following keys:
74+
- `additionalNonSemanticTags` -- An array of additional tags that should be considered presentational (non-semantic). Elements matching these tags will not be flagged as semantic children violations.
75+
76+
Example:
77+
78+
```json
79+
{
80+
"ember/template-require-presentational-children": [
81+
"error",
82+
{
83+
"additionalNonSemanticTags": ["my-custom-element"]
84+
}
85+
]
86+
}
87+
```
88+
89+
## Migration
90+
91+
If violations are found, remediation should be planned to either add `role="presentation"` to the descendants as a quickfix. A better fix is to not use semantic descendants.
92+
93+
## References
94+
95+
- [eslint-plugin-ember template-require-presentational-children](https://github.com/ember-cli/eslint-plugin-ember/blob/master/docs/rules/template-require-presentational-children.md)
96+
- [WAI-ARIA - Presentational Roles](https://www.w3.org/TR/wai-aria-1.2/#presentation)
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
const PRESENTATIONAL_ROLES = new Set(['none', 'presentation']);
2+
3+
const PRESENTATIONAL_CHILDREN = {
4+
table: ['tr', 'td', 'th', 'thead', 'tbody', 'tfoot'],
5+
select: ['option', 'optgroup'],
6+
ol: ['li'],
7+
ul: ['li'],
8+
dl: ['dt', 'dd'],
9+
};
10+
11+
// Roles that require all descendants to be presentational
12+
// https://w3c.github.io/aria-practices/#children_presentational
13+
const ROLES_REQUIRING_PRESENTATIONAL_CHILDREN = new Set([
14+
'button',
15+
'checkbox',
16+
'img',
17+
'meter',
18+
'menuitemcheckbox',
19+
'menuitemradio',
20+
'option',
21+
'progressbar',
22+
'radio',
23+
'scrollbar',
24+
'separator',
25+
'slider',
26+
'switch',
27+
'tab',
28+
]);
29+
30+
// Tags that do not have semantic meaning
31+
const NON_SEMANTIC_TAGS = new Set([
32+
'span',
33+
'div',
34+
'basefont',
35+
'big',
36+
'blink',
37+
'center',
38+
'font',
39+
'marquee',
40+
's',
41+
'spacer',
42+
'strike',
43+
'tt',
44+
'u',
45+
]);
46+
47+
const SKIPPED_TAGS = new Set([
48+
// SVG tags can contain a lot of special child tags
49+
// Instead of marking all possible SVG child tags as NON_SEMANTIC_TAG,
50+
// we skip checking this rule for presentational SVGs
51+
'svg',
52+
]);
53+
54+
function getRoleValue(node) {
55+
const roleAttr = node.attributes?.find((a) => a.name === 'role');
56+
if (!roleAttr || roleAttr.value?.type !== 'GlimmerTextNode') {
57+
return null;
58+
}
59+
return roleAttr.value.chars;
60+
}
61+
62+
function hasPresentationalRole(node) {
63+
const role = getRoleValue(node);
64+
return role !== null && PRESENTATIONAL_ROLES.has(role);
65+
}
66+
67+
function findAllSemanticDescendants(children, nonSemanticTags, results) {
68+
for (const child of children || []) {
69+
if (child.type === 'GlimmerElementNode') {
70+
// If child tag starts with ':', it's a named block — skip it but recurse into its children
71+
if (child.tag.startsWith(':')) {
72+
findAllSemanticDescendants(child.children, nonSemanticTags, results);
73+
continue;
74+
}
75+
76+
const isPresentational = hasPresentationalRole(child);
77+
78+
// Include this node in results if it's not non-semantic and not presentational
79+
if (!nonSemanticTags.has(child.tag) && !isPresentational) {
80+
results.push(child);
81+
}
82+
83+
// Always recurse into children — even if the current node is presentational,
84+
// its descendants may still be semantic and need to be reported
85+
findAllSemanticDescendants(child.children, nonSemanticTags, results);
86+
}
87+
}
88+
return results;
89+
}
90+
91+
/** @type {import('eslint').Rule.RuleModule} */
92+
module.exports = {
93+
meta: {
94+
type: 'problem',
95+
docs: {
96+
description: 'require presentational elements to only contain presentational children',
97+
category: 'Accessibility',
98+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-require-presentational-children.md',
99+
templateMode: 'both',
100+
},
101+
fixable: null,
102+
schema: [
103+
{
104+
type: 'object',
105+
properties: {
106+
additionalNonSemanticTags: {
107+
type: 'array',
108+
items: { type: 'string' },
109+
uniqueItems: true,
110+
},
111+
},
112+
additionalProperties: false,
113+
},
114+
],
115+
messages: {
116+
invalid:
117+
'Element <{{parent}}> has role="{{role}}" but contains semantic child <{{child}}>. Presentational elements should only contain presentational children.',
118+
},
119+
originallyFrom: {
120+
name: 'ember-template-lint',
121+
rule: 'lib/rules/require-presentational-children.js',
122+
docs: 'docs/rule/require-presentational-children.md',
123+
tests: 'test/unit/rules/require-presentational-children-test.js',
124+
},
125+
},
126+
127+
create(context) {
128+
const options = context.options[0] || {};
129+
const nonSemanticTags = new Set([
130+
...NON_SEMANTIC_TAGS,
131+
...(options.additionalNonSemanticTags || []),
132+
]);
133+
134+
return {
135+
GlimmerElementNode(node) {
136+
const roleAttr = node.attributes?.find((a) => a.name === 'role');
137+
if (!roleAttr || roleAttr.value?.type !== 'GlimmerTextNode') {
138+
return;
139+
}
140+
141+
const role = roleAttr.value.chars;
142+
143+
// Case 1: Presentational role (none/presentation) on specific parent elements
144+
if (PRESENTATIONAL_ROLES.has(role)) {
145+
const semanticChildren = PRESENTATIONAL_CHILDREN[node.tag];
146+
if (!semanticChildren) {
147+
return;
148+
}
149+
150+
if (node.children) {
151+
for (const child of node.children) {
152+
if (
153+
child.type === 'GlimmerElementNode' &&
154+
semanticChildren.includes(child.tag) &&
155+
!nonSemanticTags.has(child.tag)
156+
) {
157+
context.report({
158+
node: child,
159+
messageId: 'invalid',
160+
data: {
161+
parent: node.tag,
162+
role,
163+
child: child.tag,
164+
},
165+
});
166+
}
167+
}
168+
}
169+
return;
170+
}
171+
172+
// Case 2: Roles that require all descendants to be presentational
173+
if (ROLES_REQUIRING_PRESENTATIONAL_CHILDREN.has(role)) {
174+
// Skip SVG and similar tags
175+
if (SKIPPED_TAGS.has(node.tag)) {
176+
return;
177+
}
178+
179+
const semanticDescendants = findAllSemanticDescendants(
180+
node.children,
181+
nonSemanticTags,
182+
[]
183+
);
184+
for (const semanticChild of semanticDescendants) {
185+
context.report({
186+
node: semanticChild,
187+
messageId: 'invalid',
188+
data: {
189+
parent: node.tag,
190+
role,
191+
child: semanticChild.tag,
192+
},
193+
});
194+
}
195+
}
196+
},
197+
};
198+
},
199+
};

0 commit comments

Comments
 (0)