Skip to content

Commit a0c4d1a

Browse files
Merge pull request #2589 from NullVoxPopuli/nvp/template-lint-extract-rule-template-no-unsupported-role-attributes
Extract rule: template-no-unsupported-role-attributes
2 parents f271c55 + 5991e40 commit a0c4d1a

4 files changed

Lines changed: 339 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: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# ember/template-no-unsupported-role-attributes
2+
3+
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
4+
5+
<!-- end auto-generated rule header -->
6+
7+
Many ARIA states and properties are only available to elements with particular roles. This ensures that the appropriate information gets exposed to a browser's accessibility API for the given element.
8+
9+
This rule disallows the use of ARIA properties unsupported by an element's defined role. An element's role may either be explicitly set by the `role` attribute, or it may be implicitly defined through the use of HTML elements with inherent roles. For example, `<input type="checkbox"` has the implicit role of `checkbox`.
10+
11+
## Examples
12+
13+
This rule **forbids** the following:
14+
15+
```gjs
16+
<template>
17+
<div role="link" href="#" aria-checked />
18+
<input type="checkbox" aria-invalid="grammar" />
19+
<CustomComponent role="listbox" aria-level="2" />
20+
</template>
21+
```
22+
23+
This rule **allows** the following:
24+
25+
```gjs
26+
<template>
27+
<div role="heading" aria-level="1" />
28+
<input type="image" aria-atomic />
29+
<CustomComponent role="textbox" aria-required="true" />
30+
</template>
31+
```
32+
33+
## References
34+
35+
- [Using ARIA, Roles, States, and Properties](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques)
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
const { roles, elementRoles } = require('aria-query');
2+
3+
function createUnsupportedAttributeErrorMessage(attribute, role, element) {
4+
if (element) {
5+
return `The attribute ${attribute} is not supported by the element ${element} with the implicit role of ${role}`;
6+
}
7+
8+
return `The attribute ${attribute} is not supported by the role ${role}`;
9+
}
10+
11+
function getImplicitRole(tagName, typeAttribute) {
12+
if (tagName === 'input') {
13+
for (const key of elementRoles.keys()) {
14+
if (key.name === tagName && key.attributes) {
15+
for (const attribute of key.attributes) {
16+
if (attribute.name === 'type' && attribute.value === typeAttribute) {
17+
return elementRoles.get(key)[0];
18+
}
19+
}
20+
}
21+
}
22+
}
23+
24+
const key = [...elementRoles.keys()].find((entry) => entry.name === tagName);
25+
const implicitRoles = key && elementRoles.get(key);
26+
27+
return implicitRoles && implicitRoles[0];
28+
}
29+
30+
function getExplicitRole(node) {
31+
const roleAttr = node.attributes?.find((attr) => attr.name === 'role');
32+
if (roleAttr && roleAttr.value?.type === 'GlimmerTextNode') {
33+
return roleAttr.value.chars.trim();
34+
}
35+
return null;
36+
}
37+
38+
function getTypeAttribute(node) {
39+
const typeAttr = node.attributes?.find((attr) => attr.name === 'type');
40+
if (typeAttr && typeAttr.value?.type === 'GlimmerTextNode') {
41+
return typeAttr.value.chars.trim();
42+
}
43+
return null;
44+
}
45+
46+
function removeRangeWithAdjacentWhitespace(sourceText, range) {
47+
let [start, end] = range;
48+
49+
if (sourceText[end - 1] === ' ') {
50+
return [start, end];
51+
}
52+
53+
if (sourceText[start - 1] === ' ') {
54+
start -= 1;
55+
} else if (sourceText[end] === ' ') {
56+
end += 1;
57+
}
58+
59+
return [start, end];
60+
}
61+
62+
/** @type {import('eslint').Rule.RuleModule} */
63+
module.exports = {
64+
meta: {
65+
type: 'problem',
66+
docs: {
67+
description: 'disallow ARIA attributes that are not supported by the element role',
68+
category: 'Accessibility',
69+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-unsupported-role-attributes.md',
70+
templateMode: 'both',
71+
},
72+
fixable: 'code',
73+
schema: [],
74+
messages: {
75+
unsupportedExplicit: 'The attribute {{attribute}} is not supported by the role {{role}}',
76+
unsupportedImplicit:
77+
'The attribute {{attribute}} is not supported by the element {{element}} with the implicit role of {{role}}',
78+
},
79+
originallyFrom: {
80+
name: 'ember-template-lint',
81+
rule: 'lib/rules/no-unsupported-role-attributes.js',
82+
docs: 'docs/rule/no-unsupported-role-attributes.md',
83+
tests: 'test/unit/rules/no-unsupported-role-attributes-test.js',
84+
},
85+
},
86+
87+
create(context) {
88+
const sourceCode = context.sourceCode || context.getSourceCode();
89+
90+
function reportUnsupported(node, invalidNode, attribute, role, element) {
91+
const messageId = element ? 'unsupportedImplicit' : 'unsupportedExplicit';
92+
93+
context.report({
94+
node,
95+
messageId,
96+
data: element ? { attribute, role, element } : { attribute, role },
97+
fix(fixer) {
98+
const [start, end] = removeRangeWithAdjacentWhitespace(
99+
sourceCode.getText(),
100+
invalidNode.range
101+
);
102+
return fixer.removeRange([start, end]);
103+
},
104+
});
105+
}
106+
107+
return {
108+
GlimmerElementNode(node) {
109+
let role = getExplicitRole(node);
110+
let element;
111+
112+
if (!role) {
113+
element = node.tag;
114+
const typeAttribute = getTypeAttribute(node);
115+
role = getImplicitRole(element, typeAttribute);
116+
}
117+
118+
if (!role) {
119+
return;
120+
}
121+
122+
const roleDefinition = roles.get(role);
123+
if (!roleDefinition) {
124+
return;
125+
}
126+
127+
const supportedProps = Object.keys(roleDefinition.props);
128+
129+
for (const attr of node.attributes || []) {
130+
if (attr.type !== 'GlimmerAttrNode' || !attr.name?.startsWith('aria-')) {
131+
continue;
132+
}
133+
134+
if (!supportedProps.includes(attr.name)) {
135+
reportUnsupported(node, attr, attr.name, role, element);
136+
}
137+
}
138+
},
139+
140+
GlimmerMustacheStatement(node) {
141+
if (!node.hash || !node.hash.pairs) {
142+
return;
143+
}
144+
145+
const rolePair = node.hash.pairs.find((pair) => pair.key === 'role');
146+
if (!rolePair || rolePair.value?.type !== 'GlimmerStringLiteral') {
147+
return;
148+
}
149+
150+
const role = rolePair.value.value;
151+
if (!role) {
152+
return;
153+
}
154+
155+
const roleDefinition = roles.get(role);
156+
if (!roleDefinition) {
157+
return;
158+
}
159+
160+
const supportedProps = Object.keys(roleDefinition.props);
161+
const ariaPairs = node.hash.pairs.filter((pair) => pair.key.startsWith('aria-'));
162+
163+
for (const pair of ariaPairs) {
164+
if (!supportedProps.includes(pair.key)) {
165+
reportUnsupported(node, pair, pair.key, role);
166+
}
167+
}
168+
},
169+
};
170+
},
171+
};
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
const rule = require('../../../lib/rules/template-no-unsupported-role-attributes');
2+
const RuleTester = require('eslint').RuleTester;
3+
4+
const validHbs = [
5+
'<div role="button" aria-disabled="true"></div>',
6+
'<div role="heading" aria-level="1" />',
7+
'<span role="checkbox" aria-checked={{this.checked}}></span>',
8+
'<CustomComponent role="banner" />',
9+
'<div role="textbox" aria-required={{this.required}} aria-errormessage={{this.error}}></div>',
10+
'<div role="heading" foo="true" />',
11+
'<dialog />',
12+
'<a href="#" aria-describedby=""></a>',
13+
'<menu type="toolbar" aria-hidden="true" />',
14+
'<a role="menuitem" aria-labelledby={{this.label}} />',
15+
'<input type="image" aria-atomic />',
16+
'<input type="submit" aria-disabled="true" />',
17+
'<select aria-expanded="false" aria-controls="ctrlID" />',
18+
'<div type="button" foo="true" />',
19+
'{{some-component role="heading" aria-level="2"}}',
20+
'{{other-component role=this.role aria-bogus="true"}}',
21+
'<ItemCheckbox @model={{@model}} @checkable={{@checkable}} />',
22+
'<some-custom-element />',
23+
'<input type="password">',
24+
];
25+
26+
const invalidHbs = [
27+
{
28+
code: '<div role="link" href="#" aria-checked />',
29+
output: '<div role="link" href="#" />',
30+
errors: [{ message: 'The attribute aria-checked is not supported by the role link' }],
31+
},
32+
{
33+
code: '<CustomComponent role="listbox" aria-level="2" />',
34+
output: '<CustomComponent role="listbox" />',
35+
errors: [{ message: 'The attribute aria-level is not supported by the role listbox' }],
36+
},
37+
{
38+
code: '<div role="option" aria-notreal="bogus" aria-selected="false" />',
39+
output: '<div role="option" aria-selected="false" />',
40+
errors: [{ message: 'The attribute aria-notreal is not supported by the role option' }],
41+
},
42+
{
43+
code: '<div role="combobox" aria-multiline="true" aria-expanded="false" aria-controls="someId" />',
44+
output: '<div role="combobox" aria-expanded="false" aria-controls="someId" />',
45+
errors: [{ message: 'The attribute aria-multiline is not supported by the role combobox' }],
46+
},
47+
{
48+
code: '<button type="submit" aria-valuetext="woosh"></button>',
49+
output: '<button type="submit"></button>',
50+
errors: [
51+
{
52+
message:
53+
'The attribute aria-valuetext is not supported by the element button with the implicit role of button',
54+
},
55+
],
56+
},
57+
{
58+
code: '<menu type="toolbar" aria-expanded="true" />',
59+
output: '<menu type="toolbar" />',
60+
errors: [
61+
{
62+
message:
63+
'The attribute aria-expanded is not supported by the element menu with the implicit role of list',
64+
},
65+
],
66+
},
67+
{
68+
code: '<a role="menuitem" aria-checked={{this.checked}} />',
69+
output: '<a role="menuitem" />',
70+
errors: [{ message: 'The attribute aria-checked is not supported by the role menuitem' }],
71+
},
72+
{
73+
code: '<input type="button" aria-invalid="grammar" />',
74+
output: '<input type="button" />',
75+
errors: [
76+
{
77+
message:
78+
'The attribute aria-invalid is not supported by the element input with the implicit role of button',
79+
},
80+
],
81+
},
82+
{
83+
code: '<input type="email" aria-level={{this.level}} />',
84+
output: '<input type="email" />',
85+
errors: [
86+
{
87+
message:
88+
'The attribute aria-level is not supported by the element input with the implicit role of combobox',
89+
},
90+
],
91+
},
92+
{
93+
code: '{{foo-component role="button" aria-valuetext="blahblahblah"}}',
94+
output: '{{foo-component role="button"}}',
95+
errors: [{ message: 'The attribute aria-valuetext is not supported by the role button' }],
96+
},
97+
];
98+
99+
function wrapTemplate(entry) {
100+
if (typeof entry === 'string') {
101+
return `<template>${entry}</template>`;
102+
}
103+
104+
return {
105+
...entry,
106+
code: `<template>${entry.code}</template>`,
107+
output: entry.output ? `<template>${entry.output}</template>` : entry.output,
108+
};
109+
}
110+
111+
const gjsRuleTester = new RuleTester({
112+
parser: require.resolve('ember-eslint-parser'),
113+
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
114+
});
115+
116+
gjsRuleTester.run('template-no-unsupported-role-attributes', rule, {
117+
valid: validHbs.map(wrapTemplate),
118+
invalid: invalidHbs.map(wrapTemplate),
119+
});
120+
121+
const hbsRuleTester = new RuleTester({
122+
parser: require.resolve('ember-eslint-parser/hbs'),
123+
parserOptions: {
124+
ecmaVersion: 2022,
125+
sourceType: 'module',
126+
},
127+
});
128+
129+
hbsRuleTester.run('template-no-unsupported-role-attributes', rule, {
130+
valid: validHbs,
131+
invalid: invalidHbs,
132+
});

0 commit comments

Comments
 (0)