Skip to content

Commit 300da43

Browse files
Merge pull request #2601 from NullVoxPopuli/nvp/template-lint-extract-rule-template-require-context-role
Extract rule: template-require-context-role
2 parents b2ea10e + d6dc426 commit 300da43

4 files changed

Lines changed: 696 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@ rules in templates can be disabled with eslint directives with mustache or html
202202
| [template-no-unsupported-role-attributes](docs/rules/template-no-unsupported-role-attributes.md) | disallow ARIA attributes that are not supported by the element role | | 🔧 | |
203203
| [template-no-whitespace-within-word](docs/rules/template-no-whitespace-within-word.md) | disallow excess whitespace within words (e.g. "W e l c o m e") | | | |
204204
| [template-require-aria-activedescendant-tabindex](docs/rules/template-require-aria-activedescendant-tabindex.md) | require non-interactive elements with aria-activedescendant to have tabindex | | 🔧 | |
205+
| [template-require-context-role](docs/rules/template-require-context-role.md) | require ARIA roles to be used in appropriate context | | | |
205206
| [template-require-iframe-title](docs/rules/template-require-iframe-title.md) | require iframe elements to have a title attribute | | | |
206207
| [template-require-input-label](docs/rules/template-require-input-label.md) | require label for form input elements | | | |
207208
| [template-require-lang-attribute](docs/rules/template-require-lang-attribute.md) | require lang attribute on html element | | | |
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# ember/template-require-context-role
2+
3+
<!-- end auto-generated rule header -->
4+
5+
## `<* role><* role /></*>`
6+
7+
The required context role defines the owning container where this role is allowed. If a role has a required context, authors MUST ensure that an element with the role is contained inside (or owned by) an element with the required context role. For example, an element with `role="listitem"` is only meaningful when contained inside (or owned by) an element with `role="list"`. You may place intermediate elements with `role="presentation"` or `role="none"` to remove their semantic meaning.
8+
9+
## Roles to check
10+
11+
Format: role | required context role
12+
13+
- columnheader | row
14+
- gridcell | row
15+
- listitem | group or list
16+
- menuitem | group, menu, or menubar
17+
- menuitemcheckbox | menu or menubar
18+
- menuitemradio | group, menu, or menubar
19+
- option | listbox
20+
- row | grid, rowgroup, or treegrid
21+
- rowgroup | grid
22+
- rowheader | row
23+
- tab | tablist
24+
- treeitem | group or tree
25+
26+
## Examples
27+
28+
This rule **allows** the following:
29+
30+
```hbs
31+
<div role='list'>
32+
<div role='listitem'>Item One</div>
33+
<div role='listitem'>Item Two</div>
34+
</div>
35+
```
36+
37+
```hbs
38+
<div role='menu'>
39+
<div role='presentation'>
40+
<a role='menuitem'>Item One</a>
41+
</div>
42+
<div role='presentation'>
43+
<a role='menuitem'>Item Two</a>
44+
</div>
45+
</div>
46+
```
47+
48+
This rule **forbids** the following:
49+
50+
```hbs
51+
<div>
52+
<div role='listitem'>Item One</div>
53+
<div role='listitem'>Item Two</div>
54+
</div>
55+
```
56+
57+
```hbs
58+
<div role='menu'>
59+
<div role='button'>
60+
<a role='menuitem'>Item One</a>
61+
</div>
62+
<div>
63+
<a role='menuitem'>Item Two</a>
64+
</div>
65+
</div>
66+
```
67+
68+
### References
69+
70+
1. <https://www.w3.org/TR/wai-aria-1.1/#scope>
71+
1. <https://www.w3.org/TR/wai-aria-1.1/#columnheader>
72+
1. <https://www.w3.org/TR/wai-aria-1.1/#gridcell>
73+
1. <https://www.w3.org/TR/wai-aria-1.1/#listitem>
74+
1. <https://www.w3.org/TR/wai-aria-1.1/#menuitem>
75+
1. <https://www.w3.org/TR/wai-aria-1.1/#menuitemcheckbox>
76+
1. <https://www.w3.org/TR/wai-aria-1.1/#menuitemradio>
77+
1. <https://www.w3.org/TR/wai-aria-1.1/#option>
78+
1. <https://www.w3.org/TR/wai-aria-1.1/#row>
79+
1. <https://www.w3.org/TR/wai-aria-1.1/#rowgroup>
80+
1. <https://www.w3.org/TR/wai-aria-1.1/#rowheader>
81+
1. <https://www.w3.org/TR/wai-aria-1.1/#tab>
82+
1. <https://www.w3.org/TR/wai-aria-1.1/#treeitem>
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
const ROLES_REQUIRING_CONTEXT = {
2+
cell: ['row'],
3+
listitem: ['group', 'list'],
4+
option: ['listbox'],
5+
tab: ['tablist'],
6+
menuitem: ['group', 'menu', 'menubar'],
7+
menuitemcheckbox: ['menu', 'menubar'],
8+
menuitemradio: ['group', 'menu', 'menubar'],
9+
treeitem: ['group', 'tree'],
10+
row: ['grid', 'rowgroup', 'table', 'treegrid'],
11+
rowgroup: ['grid', 'table', 'treegrid'],
12+
rowheader: ['grid', 'row'],
13+
columnheader: ['row'],
14+
gridcell: ['row'],
15+
};
16+
17+
/** @type {import('eslint').Rule.RuleModule} */
18+
module.exports = {
19+
meta: {
20+
type: 'problem',
21+
docs: {
22+
description: 'require ARIA roles to be used in appropriate context',
23+
category: 'Accessibility',
24+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-require-context-role.md',
25+
templateMode: 'both',
26+
},
27+
fixable: null,
28+
schema: [],
29+
messages: {
30+
missingContext:
31+
'Role "{{role}}" must be contained in an element with one of these roles: {{requiredRoles}}',
32+
},
33+
originallyFrom: {
34+
name: 'ember-template-lint',
35+
rule: 'lib/rules/require-context-role.js',
36+
docs: 'docs/rule/require-context-role.md',
37+
tests: 'test/unit/rules/require-context-role-test.js',
38+
},
39+
},
40+
41+
create(context) {
42+
const elementStack = [];
43+
44+
return {
45+
GlimmerElementNode(node) {
46+
elementStack.push(node);
47+
48+
const role = getRoleFromNode(node);
49+
50+
if (role && ROLES_REQUIRING_CONTEXT[role]) {
51+
// Skip check if at root level (no parent elements — context may be external)
52+
if (elementStack.length > 1 && !isInsideAriaHidden(elementStack)) {
53+
const parentRole = getAccessibleParentRole(elementStack);
54+
if (parentRole === undefined) {
55+
// No non-transparent parent found (effectively root) — skip
56+
} else if (!parentRole || !ROLES_REQUIRING_CONTEXT[role].includes(parentRole)) {
57+
context.report({
58+
node,
59+
messageId: 'missingContext',
60+
data: {
61+
role,
62+
requiredRoles: ROLES_REQUIRING_CONTEXT[role].join(', '),
63+
},
64+
});
65+
}
66+
}
67+
}
68+
},
69+
70+
'GlimmerElementNode:exit'() {
71+
elementStack.pop();
72+
},
73+
};
74+
},
75+
};
76+
77+
function getRoleFromNode(node) {
78+
const roleAttr = node.attributes?.find((a) => a.name === 'role');
79+
if (roleAttr?.value?.type === 'GlimmerTextNode') {
80+
return roleAttr.value.chars;
81+
}
82+
return null;
83+
}
84+
85+
/**
86+
* Check if any ancestor element in the stack has aria-hidden="true".
87+
*/
88+
function isInsideAriaHidden(elementStack) {
89+
// Check ancestors (all elements except the current one)
90+
for (let i = elementStack.length - 2; i >= 0; i--) {
91+
const node = elementStack[i];
92+
const ariaHidden = node.attributes?.find((a) => a.name === 'aria-hidden');
93+
if (ariaHidden?.value?.type === 'GlimmerTextNode' && ariaHidden.value.chars === 'true') {
94+
return true;
95+
}
96+
}
97+
return false;
98+
}
99+
100+
/**
101+
* Get the role of the nearest non-transparent ancestor element.
102+
* Transparent elements are those with role="presentation"/"none" or named blocks (tag starts with ':').
103+
* Returns:
104+
* - a role string if a non-transparent ancestor with a role is found
105+
* - null if a non-transparent ancestor WITHOUT a role is found (breaks context)
106+
* - undefined if no non-transparent ancestor exists (root level)
107+
*/
108+
function getAccessibleParentRole(elementStack) {
109+
for (let i = elementStack.length - 2; i >= 0; i--) {
110+
const node = elementStack[i];
111+
112+
// Named blocks (e.g. <:content>) and <template> wrapper are transparent
113+
if (node.tag && (node.tag.startsWith(':') || node.tag === 'template')) {
114+
continue;
115+
}
116+
117+
const role = getRoleFromNode(node);
118+
119+
// Presentation/none roles are transparent in the accessibility tree
120+
if (role === 'presentation' || role === 'none') {
121+
continue;
122+
}
123+
124+
return role; // could be null (element with no role) or a role string
125+
}
126+
return undefined; // no non-transparent ancestor found
127+
}

0 commit comments

Comments
 (0)