Skip to content

Commit e8c212d

Browse files
committed
Extract rule: template-no-redundant-role
1 parent b054aa5 commit e8c212d

4 files changed

Lines changed: 488 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-redundant-role](docs/rules/template-no-redundant-role.md) | disallow redundant role attributes | | 🔧 | |
200201
| [template-no-unsupported-role-attributes](docs/rules/template-no-unsupported-role-attributes.md) | disallow ARIA attributes that are not supported by the element role | | 🔧 | |
201202
| [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") | | | |
202203
| [template-require-aria-activedescendant-tabindex](docs/rules/template-require-aria-activedescendant-tabindex.md) | require non-interactive elements with aria-activedescendant to have tabindex | | 🔧 | |
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# ember/template-no-redundant-role
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+
Disallows redundant role attributes on semantic HTML elements.
8+
9+
The rule checks for redundancy between any semantic HTML element with a default/implicit ARIA role and the role provided.
10+
11+
For example, if a landmark element is used, any role provided will either be redundant or incorrect. This rule ensures that no role attribute is placed on any of the landmark elements, with the following exceptions:
12+
13+
- a `nav` element with the `navigation` role to [make the structure of the page more accessible to user agents](https://www.w3.org/WAI/GL/wiki/Using_HTML5_nav_element#Example:The_.3Cnav.3E_element)
14+
- a `form` element with the `search` role to [identify the form's search functionality](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/search_role#examples)
15+
- a `input` element with `combobox` role to [identify the input as a combobox](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-autocomplete-both/)
16+
17+
## Examples
18+
19+
This rule **forbids** the following:
20+
21+
```gjs
22+
<template><header role='banner'></header></template>
23+
```
24+
25+
```gjs
26+
<template><main role='main'></main></template>
27+
```
28+
29+
```gjs
30+
<template><aside role='complementary'></aside></template>
31+
```
32+
33+
```gjs
34+
<template><footer role='contentinfo'></footer></template>
35+
```
36+
37+
```gjs
38+
<template><form role='form'></form></template>
39+
```
40+
41+
This rule **allows** the following:
42+
43+
```gjs
44+
<template><form role='search'></form></template>
45+
```
46+
47+
```gjs
48+
<template><nav role='navigation'></nav></template>
49+
```
50+
51+
```gjs
52+
<template><input role='combobox' /></template>
53+
```
54+
55+
## Configuration
56+
57+
This rule accepts an options object with the following properties:
58+
59+
- `checkAllHTMLElements` (default: `true`) - When set to `true`, checks all HTML elements for redundant roles. When `false`, only checks landmark elements.
60+
61+
```js
62+
// .eslintrc.js
63+
module.exports = {
64+
rules: {
65+
'ember/template-no-redundant-role': ['error', { checkAllHTMLElements: false }],
66+
},
67+
};
68+
```
69+
70+
## References
71+
72+
- [ARIA Roles](https://www.w3.org/TR/wai-aria-1.2/#role_definitions)
73+
- [HTML ARIA](https://www.w3.org/TR/html-aria/)
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
const DEFAULT_CONFIG = {
2+
checkAllHTMLElements: true,
3+
};
4+
5+
function parseConfig(config) {
6+
if (config === true) {
7+
return DEFAULT_CONFIG;
8+
}
9+
return { ...DEFAULT_CONFIG, ...config };
10+
}
11+
12+
function createErrorMessageLandmarkElement(element, role) {
13+
return `Use of redundant or invalid role: ${role} on <${element}> detected. If a landmark element is used, any role provided will either be redundant or incorrect.`;
14+
}
15+
16+
function createErrorMessageAnyElement(element, role) {
17+
return `Use of redundant or invalid role: ${role} on <${element}> detected.`;
18+
}
19+
20+
// https://www.w3.org/TR/html-aria/#docconformance
21+
const LANDMARK_ROLES = new Set([
22+
'banner',
23+
'main',
24+
'complementary',
25+
'search',
26+
'form',
27+
'navigation',
28+
'contentinfo',
29+
]);
30+
31+
const ALLOWED_ELEMENT_ROLES = [
32+
{ name: 'nav', role: 'navigation' },
33+
{ name: 'form', role: 'search' },
34+
{ name: 'ol', role: 'list' },
35+
{ name: 'ul', role: 'list' },
36+
{ name: 'a', role: 'link' },
37+
{ name: 'input', role: 'combobox' },
38+
];
39+
40+
// Mapping of roles to their corresponding HTML elements
41+
// From https://www.w3.org/TR/html-aria/
42+
const ROLE_TO_ELEMENTS = {
43+
article: ['article'],
44+
banner: ['header'],
45+
button: ['button'],
46+
cell: ['td'],
47+
checkbox: ['input'],
48+
columnheader: ['th'],
49+
complementary: ['aside'],
50+
contentinfo: ['footer'],
51+
definition: ['dd'],
52+
dialog: ['dialog'],
53+
document: ['body'],
54+
figure: ['figure'],
55+
form: ['form'],
56+
grid: ['table'],
57+
gridcell: ['td'],
58+
group: ['details', 'fieldset', 'optgroup'],
59+
heading: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
60+
img: ['img'],
61+
link: ['a'],
62+
list: ['ol', 'ul'],
63+
listbox: ['select'],
64+
listitem: ['li'],
65+
main: ['main'],
66+
navigation: ['nav'],
67+
option: ['option'],
68+
radio: ['input'],
69+
region: ['section'],
70+
row: ['tr'],
71+
rowgroup: ['tbody', 'tfoot', 'thead'],
72+
rowheader: ['th'],
73+
search: ['search'],
74+
searchbox: ['input'],
75+
separator: ['hr'],
76+
slider: ['input'],
77+
spinbutton: ['input'],
78+
status: ['output'],
79+
table: ['table'],
80+
term: ['dfn', 'dt'],
81+
textbox: ['input', 'textarea'],
82+
};
83+
84+
/** @type {import('eslint').Rule.RuleModule} */
85+
module.exports = {
86+
meta: {
87+
type: 'suggestion',
88+
docs: {
89+
description: 'disallow redundant role attributes',
90+
category: 'Accessibility',
91+
recommended: false,
92+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-redundant-role.md',
93+
templateMode: 'both',
94+
},
95+
fixable: 'code',
96+
schema: [
97+
{
98+
type: 'object',
99+
properties: {
100+
checkAllHTMLElements: {
101+
type: 'boolean',
102+
},
103+
},
104+
additionalProperties: false,
105+
},
106+
],
107+
messages: {},
108+
originallyFrom: {
109+
name: 'ember-template-lint',
110+
rule: 'lib/rules/no-redundant-role.js',
111+
docs: 'docs/rule/no-redundant-role.md',
112+
tests: 'test/unit/rules/no-redundant-role-test.js',
113+
},
114+
},
115+
116+
create(context) {
117+
const config = parseConfig(context.options[0]);
118+
119+
return {
120+
GlimmerElementNode(node) {
121+
const roleAttr = node.attributes?.find((attr) => attr.name === 'role');
122+
123+
if (!roleAttr) {
124+
return;
125+
}
126+
127+
let roleValue;
128+
if (roleAttr.value && roleAttr.value.type === 'GlimmerTextNode') {
129+
roleValue = roleAttr.value.chars || '';
130+
} else {
131+
// Skip dynamic role values
132+
return;
133+
}
134+
135+
const isLandmarkRole = LANDMARK_ROLES.has(roleValue);
136+
if (!config.checkAllHTMLElements && !isLandmarkRole) {
137+
return;
138+
}
139+
140+
const elementsWithRole = ROLE_TO_ELEMENTS[roleValue];
141+
if (!elementsWithRole) {
142+
return;
143+
}
144+
145+
const isRedundant =
146+
elementsWithRole.includes(node.tag) &&
147+
!ALLOWED_ELEMENT_ROLES.some((e) => e.name === node.tag && e.role === roleValue);
148+
149+
if (isRedundant) {
150+
const errorMessage = isLandmarkRole
151+
? createErrorMessageLandmarkElement(node.tag, roleValue)
152+
: createErrorMessageAnyElement(node.tag, roleValue);
153+
154+
context.report({
155+
node,
156+
message: errorMessage,
157+
fix(fixer) {
158+
const sourceCode = context.getSourceCode();
159+
const elementText = sourceCode.getText(node);
160+
const roleAttrText = sourceCode.getText(roleAttr);
161+
162+
// Find the role attribute in the element text and remove it along with preceding space
163+
const roleAttrPattern = new RegExp(
164+
`\\s+${roleAttrText.replaceAll(/[$()*+.?[\\\]^{|}]/g, '\\$&')}`
165+
);
166+
const fixedText = elementText.replace(roleAttrPattern, '');
167+
168+
return fixer.replaceText(node, fixedText);
169+
},
170+
});
171+
}
172+
},
173+
};
174+
},
175+
};

0 commit comments

Comments
 (0)