Skip to content

Commit 69a9832

Browse files
committed
Extract rule: template-no-unsupported-role-attributes
1 parent f271c55 commit 69a9832

4 files changed

Lines changed: 444 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ rules in templates can be disabled with eslint directives with mustache or html
197197
| [template-no-nested-interactive](docs/rules/template-no-nested-interactive.md) | disallow nested interactive elements | | | |
198198
| [template-no-nested-landmark](docs/rules/template-no-nested-landmark.md) | disallow nested landmark elements | | | |
199199
| [template-no-pointer-down-event-binding](docs/rules/template-no-pointer-down-event-binding.md) | disallow pointer down event bindings | | | |
200+
| [template-no-unsupported-role-attributes](docs/rules/template-no-unsupported-role-attributes.md) | disallow ARIA attributes that are not supported by the element role | | | |
200201
| [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") | | | |
201202
| [template-require-aria-activedescendant-tabindex](docs/rules/template-require-aria-activedescendant-tabindex.md) | require non-interactive elements with aria-activedescendant to have tabindex | | 🔧 | |
202203
| [template-require-iframe-title](docs/rules/template-require-iframe-title.md) | require iframe elements to have a title attribute | | | |
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# ember/template-no-unsupported-role-attributes
2+
3+
<!-- end auto-generated rule header -->
4+
5+
Disallows ARIA attributes that are not supported by the element's role.
6+
7+
Different ARIA roles support different sets of ARIA attributes. Using unsupported attributes can cause confusion and doesn't provide the intended accessibility benefits.
8+
9+
## Rule Details
10+
11+
This rule checks elements with specific ARIA roles and ensures they only use supported ARIA attributes for that role.
12+
13+
## Examples
14+
15+
Examples of **incorrect** code for this rule:
16+
17+
```gjs
18+
<template>
19+
<div role="button" aria-checked="true">Button</div>
20+
</template>
21+
```
22+
23+
```gjs
24+
<template>
25+
<div role="checkbox" aria-pressed="false">Checkbox</div>
26+
</template>
27+
```
28+
29+
```gjs
30+
<template>
31+
<div role="tab" aria-valuenow="1">Tab</div>
32+
</template>
33+
```
34+
35+
Examples of **correct** code for this rule:
36+
37+
```gjs
38+
<template>
39+
<div role="button" aria-pressed="true">Toggle Button</div>
40+
</template>
41+
```
42+
43+
```gjs
44+
<template>
45+
<div role="checkbox" aria-checked="false">Accept Terms</div>
46+
</template>
47+
```
48+
49+
```gjs
50+
<template>
51+
<div role="tab" aria-selected="true">Home Tab</div>
52+
</template>
53+
```
54+
55+
## References
56+
57+
- [ARIA Roles](https://www.w3.org/TR/wai-aria-1.2/#role_definitions)
58+
- [ARIA States and Properties](https://www.w3.org/TR/wai-aria-1.2/#state_prop_def)
59+
- [eslint-plugin-ember template-no-unsupported-role-attributes](https://github.com/ember-cli/eslint-plugin-ember/blob/master/docs/rules/template-no-unsupported-role-attributes.md)
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
const { roles, elementRoles } = require('aria-query');
2+
3+
/**
4+
* Get the implicit ARIA role for an HTML element based on its tag name and type attribute.
5+
* Uses the aria-query elementRoles mapping.
6+
*/
7+
function getImplicitRole(tagName, typeAttribute) {
8+
// For input elements, match against entries with the specific type attribute
9+
if (tagName === 'input') {
10+
for (const key of elementRoles.keys()) {
11+
if (key.name === tagName && key.attributes) {
12+
for (const attribute of key.attributes) {
13+
if (attribute.name === 'type' && attribute.value === typeAttribute) {
14+
return elementRoles.get(key)[0];
15+
}
16+
}
17+
}
18+
}
19+
}
20+
// For all elements, fall back to the first matching entry by tag name
21+
for (const key of elementRoles.keys()) {
22+
if (key.name === tagName) {
23+
return elementRoles.get(key)[0];
24+
}
25+
}
26+
return null;
27+
}
28+
29+
function getExplicitRole(node) {
30+
const roleAttr = node.attributes?.find((attr) => attr.name === 'role');
31+
if (roleAttr && roleAttr.value?.type === 'GlimmerTextNode') {
32+
return roleAttr.value.chars.trim();
33+
}
34+
return null;
35+
}
36+
37+
function getTypeAttribute(node) {
38+
const typeAttr = node.attributes?.find((attr) => attr.name === 'type');
39+
if (typeAttr && typeAttr.value?.type === 'GlimmerTextNode') {
40+
return typeAttr.value.chars.trim();
41+
}
42+
return null;
43+
}
44+
45+
/** @type {import('eslint').Rule.RuleModule} */
46+
module.exports = {
47+
meta: {
48+
type: 'problem',
49+
docs: {
50+
description: 'disallow ARIA attributes that are not supported by the element role',
51+
category: 'Accessibility',
52+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-unsupported-role-attributes.md',
53+
templateMode: 'both',
54+
},
55+
fixable: null,
56+
schema: [],
57+
messages: {
58+
unsupported:
59+
'ARIA attribute "{{attribute}}" is not supported for role "{{role}}". Remove the attribute or change the role.',
60+
},
61+
originallyFrom: {
62+
name: 'ember-template-lint',
63+
rule: 'lib/rules/no-unsupported-role-attributes.js',
64+
docs: 'docs/rule/no-unsupported-role-attributes.md',
65+
tests: 'test/unit/rules/no-unsupported-role-attributes-test.js',
66+
},
67+
},
68+
69+
create(context) {
70+
return {
71+
GlimmerElementNode(node) {
72+
// Determine the role: explicit first, then implicit
73+
let role = getExplicitRole(node);
74+
if (!role) {
75+
const tagName = node.tag;
76+
const typeAttribute = getTypeAttribute(node);
77+
role = getImplicitRole(tagName, typeAttribute);
78+
}
79+
80+
if (!role) {
81+
return;
82+
}
83+
84+
const roleDefinition = roles.get(role);
85+
if (!roleDefinition) {
86+
return;
87+
}
88+
89+
const supportedProps = Object.keys(roleDefinition.props);
90+
const ariaAttributes = node.attributes?.filter(
91+
(attr) => attr.type === 'GlimmerAttrNode' && attr.name && attr.name.startsWith('aria-')
92+
);
93+
94+
for (const attr of ariaAttributes || []) {
95+
if (!supportedProps.includes(attr.name)) {
96+
context.report({
97+
node: attr,
98+
messageId: 'unsupported',
99+
data: {
100+
attribute: attr.name,
101+
role,
102+
},
103+
});
104+
}
105+
}
106+
},
107+
108+
GlimmerMustacheStatement(node) {
109+
if (!node.hash || !node.hash.pairs) {
110+
return;
111+
}
112+
113+
const rolePair = node.hash.pairs.find((pair) => pair.key === 'role');
114+
if (!rolePair || rolePair.value?.type !== 'GlimmerStringLiteral') {
115+
return;
116+
}
117+
118+
const role = rolePair.value.original;
119+
if (!role) {
120+
return;
121+
}
122+
123+
const roleDefinition = roles.get(role);
124+
if (!roleDefinition) {
125+
return;
126+
}
127+
128+
const supportedProps = Object.keys(roleDefinition.props);
129+
const ariaPairs = node.hash.pairs.filter((pair) => pair.key.startsWith('aria-'));
130+
131+
for (const pair of ariaPairs) {
132+
if (!supportedProps.includes(pair.key)) {
133+
context.report({
134+
node: pair,
135+
messageId: 'unsupported',
136+
data: {
137+
attribute: pair.key,
138+
role,
139+
},
140+
});
141+
}
142+
}
143+
},
144+
};
145+
},
146+
};

0 commit comments

Comments
 (0)