Skip to content

Commit c0806c1

Browse files
Merge pull request #2611 from NullVoxPopuli/nvp/template-lint-extract-rule-template-require-presentational-children
Extract rule: template-require-presentational-children
2 parents 97c7d56 + 74de86a commit c0806c1

4 files changed

Lines changed: 423 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: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# ember/template-require-presentational-children
2+
3+
<!-- end auto-generated rule header -->
4+
5+
There are roles that require all children to be presentational. This rule checks
6+
if descendants of an element with one of those roles are presentational. By
7+
default, browsers are required to add `role="presentation"` to all descendants,
8+
but we should not rely on browsers to do this.
9+
10+
The roles that require all children to be presentational are:
11+
12+
- `button`
13+
- `checkbox`
14+
- `img`
15+
- `meter`
16+
- `menuitemcheckbox`
17+
- `menuitemradio`
18+
- `option`
19+
- `progressbar`
20+
- `radio`
21+
- `scrollbar`
22+
- `separator`
23+
- `slider`
24+
- `switch`
25+
- `tab`
26+
27+
Please note that children of `<svg>` tags will not be checked by this rule, as
28+
they have somewhat special semantics.
29+
30+
## Examples
31+
32+
This rule **forbids** the following:
33+
34+
```gjs
35+
<template>
36+
<li role="tab"><h3>Title of My Tab</h3></li>
37+
</template>
38+
```
39+
40+
```gjs
41+
<template>
42+
<div role="button">
43+
<h2 role="presentation">
44+
<button>Test <img /></button>
45+
</h2>
46+
</div>
47+
</template>
48+
```
49+
50+
This rule **allows** the following:
51+
52+
```gjs
53+
<template>
54+
<li role="tab">Title of My Tab</li>
55+
</template>
56+
```
57+
58+
```gjs
59+
<template>
60+
<li role="tab"><h3 role="presentation">Title of My Tab</h3></li>
61+
</template>
62+
```
63+
64+
## Migration
65+
66+
If violations are found, remediation should be planned to either add
67+
`role="presentation"` to the descendants as a quickfix. A better fix is to not
68+
use semantic descendants.
69+
70+
## Configuration
71+
72+
- object -- An object with the following keys:
73+
- `additionalNonSemanticTags` -- An array of additional tags that should be considered presentation
74+
75+
```json
76+
{
77+
"ember/template-require-presentational-children": [
78+
"error",
79+
{
80+
"additionalNonSemanticTags": ["my-custom-element"]
81+
}
82+
]
83+
}
84+
```
85+
86+
## References
87+
88+
- [Roles That Automatically Hide Semantics by Making Their Descendants Presentational](https://w3c.github.io/aria-practices/#children_presentational)
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
// Roles that require all descendants to be presentational
2+
// https://w3c.github.io/aria-practices/#children_presentational
3+
const ROLES_REQUIRING_PRESENTATIONAL_CHILDREN = new Set([
4+
'button',
5+
'checkbox',
6+
'img',
7+
'meter',
8+
'menuitemcheckbox',
9+
'menuitemradio',
10+
'option',
11+
'progressbar',
12+
'radio',
13+
'scrollbar',
14+
'separator',
15+
'slider',
16+
'switch',
17+
'tab',
18+
]);
19+
20+
// Tags that do not have semantic meaning
21+
const NON_SEMANTIC_TAGS = new Set([
22+
'span',
23+
'div',
24+
'basefont',
25+
'big',
26+
'blink',
27+
'center',
28+
'font',
29+
'marquee',
30+
's',
31+
'spacer',
32+
'strike',
33+
'tt',
34+
'u',
35+
]);
36+
37+
const SKIPPED_TAGS = new Set([
38+
// SVG tags can contain a lot of special child tags
39+
// Instead of marking all possible SVG child tags as NON_SEMANTIC_TAG,
40+
// we skip checking this rule for presentational SVGs
41+
'svg',
42+
]);
43+
44+
function getRoleValue(node) {
45+
const roleAttr = node.attributes?.find((a) => a.name === 'role');
46+
if (!roleAttr || roleAttr.value?.type !== 'GlimmerTextNode') {
47+
return null;
48+
}
49+
return roleAttr.value.chars;
50+
}
51+
52+
function hasPresentationalRole(node) {
53+
const role = getRoleValue(node);
54+
return role === 'presentation';
55+
}
56+
57+
function findAllSemanticDescendants(children, nonSemanticTags, results) {
58+
for (const child of children || []) {
59+
if (child.type === 'GlimmerElementNode') {
60+
// If child tag starts with ':', it's a named block — skip it but recurse into its children
61+
if (child.tag.startsWith(':')) {
62+
findAllSemanticDescendants(child.children, nonSemanticTags, results);
63+
continue;
64+
}
65+
66+
const isPresentational = hasPresentationalRole(child);
67+
68+
// Include this node in results if it's not non-semantic and not presentational
69+
if (!nonSemanticTags.has(child.tag) && !isPresentational) {
70+
results.push(child);
71+
}
72+
73+
// Always recurse into children — even if the current node is presentational,
74+
// its descendants may still be semantic and need to be reported
75+
findAllSemanticDescendants(child.children, nonSemanticTags, results);
76+
}
77+
}
78+
return results;
79+
}
80+
81+
/** @type {import('eslint').Rule.RuleModule} */
82+
module.exports = {
83+
meta: {
84+
type: 'problem',
85+
docs: {
86+
description: 'require presentational elements to only contain presentational children',
87+
category: 'Accessibility',
88+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-require-presentational-children.md',
89+
templateMode: 'both',
90+
},
91+
fixable: null,
92+
schema: [
93+
{
94+
type: 'object',
95+
properties: {
96+
additionalNonSemanticTags: {
97+
type: 'array',
98+
items: { type: 'string' },
99+
uniqueItems: true,
100+
},
101+
},
102+
additionalProperties: false,
103+
},
104+
],
105+
messages: {
106+
invalid:
107+
'<{{parent}}> has a role of {{role}}, it cannot have semantic descendants like <{{child}}>',
108+
},
109+
originallyFrom: {
110+
name: 'ember-template-lint',
111+
rule: 'lib/rules/require-presentational-children.js',
112+
docs: 'docs/rule/require-presentational-children.md',
113+
tests: 'test/unit/rules/require-presentational-children-test.js',
114+
},
115+
},
116+
117+
create(context) {
118+
const options = context.options[0] || {};
119+
const nonSemanticTags = new Set([
120+
...NON_SEMANTIC_TAGS,
121+
...(options.additionalNonSemanticTags || []),
122+
]);
123+
124+
return {
125+
GlimmerElementNode(node) {
126+
const roleAttr = node.attributes?.find((a) => a.name === 'role');
127+
if (!roleAttr || roleAttr.value?.type !== 'GlimmerTextNode') {
128+
return;
129+
}
130+
131+
const role = roleAttr.value.chars;
132+
133+
if (ROLES_REQUIRING_PRESENTATIONAL_CHILDREN.has(role)) {
134+
if (SKIPPED_TAGS.has(node.tag)) {
135+
return;
136+
}
137+
138+
const semanticDescendants = findAllSemanticDescendants(
139+
node.children,
140+
nonSemanticTags,
141+
[]
142+
);
143+
for (const semanticChild of semanticDescendants) {
144+
context.report({
145+
node: semanticChild,
146+
messageId: 'invalid',
147+
data: {
148+
parent: node.tag,
149+
role,
150+
child: semanticChild.tag,
151+
},
152+
});
153+
}
154+
}
155+
},
156+
};
157+
},
158+
};

0 commit comments

Comments
 (0)