Skip to content

Commit b41b15a

Browse files
committed
Extract rule: template-require-context-role
1 parent b2ea10e commit b41b15a

4 files changed

Lines changed: 694 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: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# ember/template-require-context-role
2+
3+
<!-- end auto-generated rule header -->
4+
5+
Requires certain ARIA roles to be used in appropriate context.
6+
7+
Some ARIA roles must be contained within specific parent roles to be meaningful and accessible. For example, a `role="listitem"` must be inside an element with `role="list"`.
8+
9+
## Rule Details
10+
11+
This rule checks that context-dependent ARIA roles are used within the appropriate parent roles.
12+
13+
Roles requiring context:
14+
15+
- `listitem` → must be in `list`
16+
- `option` → must be in `listbox`
17+
- `tab` → must be in `tablist`
18+
- `menuitem`, `menuitemcheckbox`, `menuitemradio` → must be in `menu` or `menubar`
19+
- `treeitem` → must be in `tree`
20+
- `row` → must be in `table`, `grid`, `treegrid`, or `rowgroup`
21+
- And more...
22+
23+
## Roles to check
24+
25+
Format: role | required context role
26+
27+
- columnheader | row
28+
- gridcell | row
29+
- listitem | group or list
30+
- menuitem | group, menu, or menubar
31+
- menuitemcheckbox | menu or menubar
32+
- menuitemradio | group, menu, or menubar
33+
- option | listbox
34+
- row | grid, rowgroup, or treegrid
35+
- rowgroup | grid
36+
- rowheader | row
37+
- tab | tablist
38+
- treeitem | group or tree
39+
40+
## `<* role><* role /></*>`
41+
42+
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.
43+
44+
## Examples
45+
46+
Examples of **incorrect** code for this rule:
47+
48+
```gjs
49+
<template>
50+
<div>
51+
<span role="listitem">Item</span>
52+
</div>
53+
</template>
54+
```
55+
56+
```gjs
57+
<template>
58+
<div>
59+
<span role="tab">Tab 1</span>
60+
</div>
61+
</template>
62+
```
63+
64+
```gjs
65+
<template>
66+
<div>
67+
<span role="menuitem">Item</span>
68+
</div>
69+
</template>
70+
```
71+
72+
Examples of **correct** code for this rule:
73+
74+
```gjs
75+
<template>
76+
<ul role="list">
77+
<li role="listitem">Item</li>
78+
</ul>
79+
</template>
80+
```
81+
82+
```gjs
83+
<template>
84+
<div role="tablist">
85+
<div role="tab">Tab 1</div>
86+
</div>
87+
</template>
88+
```
89+
90+
```gjs
91+
<template>
92+
<div role="menu">
93+
<div role="menuitem">Item</div>
94+
</div>
95+
</template>
96+
```
97+
98+
## References
99+
100+
- [eslint-plugin-ember template-require-context-role](https://github.com/ember-cli/eslint-plugin-ember/blob/master/docs/rules/template-require-context-role.md)
101+
- [WAI-ARIA - Required Context Roles](https://www.w3.org/TR/wai-aria-1.2/#scope)
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
const ROLES_REQUIRING_CONTEXT = {
2+
listitem: ['list', 'group', 'directory'],
3+
option: ['listbox', 'group'],
4+
tab: ['tablist'],
5+
menuitem: ['menu', 'menubar', 'group'],
6+
menuitemcheckbox: ['menu', 'menubar', 'group'],
7+
menuitemradio: ['menu', 'menubar', 'group'],
8+
treeitem: ['tree', 'group'],
9+
row: ['table', 'grid', 'treegrid', 'rowgroup'],
10+
rowgroup: ['grid', 'table', 'treegrid'],
11+
rowheader: ['row'],
12+
columnheader: ['row'],
13+
gridcell: ['row'],
14+
};
15+
16+
/** @type {import('eslint').Rule.RuleModule} */
17+
module.exports = {
18+
meta: {
19+
type: 'problem',
20+
docs: {
21+
description: 'require ARIA roles to be used in appropriate context',
22+
category: 'Accessibility',
23+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-require-context-role.md',
24+
templateMode: 'both',
25+
},
26+
fixable: null,
27+
schema: [],
28+
messages: {
29+
missingContext:
30+
'Role "{{role}}" must be contained in an element with one of these roles: {{requiredRoles}}',
31+
},
32+
originallyFrom: {
33+
name: 'ember-template-lint',
34+
rule: 'lib/rules/require-context-role.js',
35+
docs: 'docs/rule/require-context-role.md',
36+
tests: 'test/unit/rules/require-context-role-test.js',
37+
},
38+
},
39+
40+
create(context) {
41+
const elementStack = [];
42+
43+
return {
44+
GlimmerElementNode(node) {
45+
elementStack.push(node);
46+
47+
const role = getRoleFromNode(node);
48+
49+
if (role && ROLES_REQUIRING_CONTEXT[role]) {
50+
// Skip check if at root level (no parent elements — context may be external)
51+
if (elementStack.length > 1 && !isInsideAriaHidden(elementStack)) {
52+
const parentRole = getAccessibleParentRole(elementStack);
53+
if (parentRole === undefined) {
54+
// No non-transparent parent found (effectively root) — skip
55+
} else if (!parentRole || !ROLES_REQUIRING_CONTEXT[role].includes(parentRole)) {
56+
context.report({
57+
node,
58+
messageId: 'missingContext',
59+
data: {
60+
role,
61+
requiredRoles: ROLES_REQUIRING_CONTEXT[role].join(', '),
62+
},
63+
});
64+
}
65+
}
66+
}
67+
},
68+
69+
'GlimmerElementNode:exit'() {
70+
elementStack.pop();
71+
},
72+
};
73+
},
74+
};
75+
76+
function getRoleFromNode(node) {
77+
const roleAttr = node.attributes?.find((a) => a.name === 'role');
78+
if (roleAttr?.value?.type === 'GlimmerTextNode') {
79+
return roleAttr.value.chars;
80+
}
81+
return null;
82+
}
83+
84+
/**
85+
* Check if any ancestor element in the stack has aria-hidden="true".
86+
*/
87+
function isInsideAriaHidden(elementStack) {
88+
// Check ancestors (all elements except the current one)
89+
for (let i = elementStack.length - 2; i >= 0; i--) {
90+
const node = elementStack[i];
91+
const ariaHidden = node.attributes?.find((a) => a.name === 'aria-hidden');
92+
if (ariaHidden?.value?.type === 'GlimmerTextNode' && ariaHidden.value.chars === 'true') {
93+
return true;
94+
}
95+
}
96+
return false;
97+
}
98+
99+
/**
100+
* Get the role of the nearest non-transparent ancestor element.
101+
* Transparent elements are those with role="presentation"/"none" or named blocks (tag starts with ':').
102+
* Returns:
103+
* - a role string if a non-transparent ancestor with a role is found
104+
* - null if a non-transparent ancestor WITHOUT a role is found (breaks context)
105+
* - undefined if no non-transparent ancestor exists (root level)
106+
*/
107+
function getAccessibleParentRole(elementStack) {
108+
for (let i = elementStack.length - 2; i >= 0; i--) {
109+
const node = elementStack[i];
110+
111+
// Named blocks (e.g. <:content>) and <template> wrapper are transparent
112+
if (node.tag && (node.tag.startsWith(':') || node.tag === 'template')) {
113+
continue;
114+
}
115+
116+
const role = getRoleFromNode(node);
117+
118+
// Presentation/none roles are transparent in the accessibility tree
119+
if (role === 'presentation' || role === 'none') {
120+
continue;
121+
}
122+
123+
return role; // could be null (element with no role) or a role string
124+
}
125+
return undefined; // no non-transparent ancestor found
126+
}

0 commit comments

Comments
 (0)