Skip to content

Commit c034e44

Browse files
Merge pull request #2427 from NullVoxPopuli/nvp/template-lint-extract-rule-template-no-duplicate-landmark-elements
Extract rule: template-no-duplicate-landmark-elements
2 parents 1c9a858 + 91c766e commit c034e44

4 files changed

Lines changed: 458 additions & 17 deletions

File tree

README.md

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -178,23 +178,24 @@ rules in templates can be disabled with eslint directives with mustache or html
178178

179179
### Accessibility
180180

181-
| Name                                   | Description | 💼 | 🔧 | 💡 |
182-
| :--------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------- | :- | :- | :- |
183-
| [template-link-href-attributes](docs/rules/template-link-href-attributes.md) | require href attribute on link elements | | | |
184-
| [template-no-abstract-roles](docs/rules/template-no-abstract-roles.md) | disallow abstract ARIA roles | | | |
185-
| [template-no-accesskey-attribute](docs/rules/template-no-accesskey-attribute.md) | disallow accesskey attribute | | 🔧 | |
186-
| [template-no-aria-hidden-body](docs/rules/template-no-aria-hidden-body.md) | disallow aria-hidden on body element | | 🔧 | |
187-
| [template-no-aria-unsupported-elements](docs/rules/template-no-aria-unsupported-elements.md) | disallow ARIA roles, states, and properties on elements that do not support them | | | |
188-
| [template-no-autofocus-attribute](docs/rules/template-no-autofocus-attribute.md) | disallow autofocus attribute | | 🔧 | |
189-
| [template-no-empty-headings](docs/rules/template-no-empty-headings.md) | disallow empty heading elements | | | |
190-
| [template-no-heading-inside-button](docs/rules/template-no-heading-inside-button.md) | disallow heading elements inside button elements | | | |
191-
| [template-no-invalid-aria-attributes](docs/rules/template-no-invalid-aria-attributes.md) | disallow invalid aria-* attributes | | | |
192-
| [template-no-invalid-interactive](docs/rules/template-no-invalid-interactive.md) | disallow non-interactive elements with interactive handlers | | | |
193-
| [template-no-invalid-link-title](docs/rules/template-no-invalid-link-title.md) | disallow invalid title attributes on link elements | | | |
194-
| [template-no-invalid-role](docs/rules/template-no-invalid-role.md) | disallow invalid ARIA roles | | | |
195-
| [template-no-nested-interactive](docs/rules/template-no-nested-interactive.md) | disallow nested interactive elements | | | |
196-
| [template-no-nested-landmark](docs/rules/template-no-nested-landmark.md) | disallow nested landmark elements | | | |
197-
| [template-no-pointer-down-event-binding](docs/rules/template-no-pointer-down-event-binding.md) | disallow pointer down event bindings | | | |
181+
| Name                                    | Description | 💼 | 🔧 | 💡 |
182+
| :----------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------- | :- | :- | :- |
183+
| [template-link-href-attributes](docs/rules/template-link-href-attributes.md) | require href attribute on link elements | | | |
184+
| [template-no-abstract-roles](docs/rules/template-no-abstract-roles.md) | disallow abstract ARIA roles | | | |
185+
| [template-no-accesskey-attribute](docs/rules/template-no-accesskey-attribute.md) | disallow accesskey attribute | | 🔧 | |
186+
| [template-no-aria-hidden-body](docs/rules/template-no-aria-hidden-body.md) | disallow aria-hidden on body element | | 🔧 | |
187+
| [template-no-aria-unsupported-elements](docs/rules/template-no-aria-unsupported-elements.md) | disallow ARIA roles, states, and properties on elements that do not support them | | | |
188+
| [template-no-autofocus-attribute](docs/rules/template-no-autofocus-attribute.md) | disallow autofocus attribute | | 🔧 | |
189+
| [template-no-duplicate-landmark-elements](docs/rules/template-no-duplicate-landmark-elements.md) | disallow duplicate landmark elements without unique labels | | | |
190+
| [template-no-empty-headings](docs/rules/template-no-empty-headings.md) | disallow empty heading elements | | | |
191+
| [template-no-heading-inside-button](docs/rules/template-no-heading-inside-button.md) | disallow heading elements inside button elements | | | |
192+
| [template-no-invalid-aria-attributes](docs/rules/template-no-invalid-aria-attributes.md) | disallow invalid aria-* attributes | | | |
193+
| [template-no-invalid-interactive](docs/rules/template-no-invalid-interactive.md) | disallow non-interactive elements with interactive handlers | | | |
194+
| [template-no-invalid-link-title](docs/rules/template-no-invalid-link-title.md) | disallow invalid title attributes on link elements | | | |
195+
| [template-no-invalid-role](docs/rules/template-no-invalid-role.md) | disallow invalid ARIA roles | | | |
196+
| [template-no-nested-interactive](docs/rules/template-no-nested-interactive.md) | disallow nested interactive elements | | | |
197+
| [template-no-nested-landmark](docs/rules/template-no-nested-landmark.md) | disallow nested landmark elements | | | |
198+
| [template-no-pointer-down-event-binding](docs/rules/template-no-pointer-down-event-binding.md) | disallow pointer down event bindings | | | |
198199

199200
### Best Practices
200201

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# ember/template-no-duplicate-landmark-elements
2+
3+
<!-- end auto-generated rule header -->
4+
5+
If multiple landmark elements of the same type are found on a page, they must each have a unique label (provided by `aria-label` or `aria-labelledby`).
6+
7+
## Rule Details
8+
9+
List of elements & their corresponding roles:
10+
11+
- `header` (banner)
12+
- `main` (main)
13+
- `aside` (complementary)
14+
- `form` (form, search)
15+
- `nav` (navigation)
16+
- `footer` (contentinfo)
17+
18+
## Examples
19+
20+
This rule **forbids** the following:
21+
22+
```hbs
23+
<nav></nav>
24+
<nav></nav>
25+
```
26+
27+
```hbs
28+
<nav></nav>
29+
<div role='navigation'></div>
30+
```
31+
32+
```hbs
33+
<nav aria-label='site navigation'></nav>
34+
<nav aria-label='site navigation'></nav>
35+
```
36+
37+
```hbs
38+
<form aria-label='search-form'></form>
39+
<form aria-label='search-form'></form>
40+
```
41+
42+
This rule **allows** the following:
43+
44+
```hbs
45+
<nav aria-label='primary site navigation'></nav>
46+
<nav aria-label='secondary site navigation within home page'></nav>
47+
```
48+
49+
```hbs
50+
<nav aria-label='primary site navigation'></nav>
51+
<div role='navigation' aria-label='secondary site navigation within home page'></div>
52+
```
53+
54+
```hbs
55+
<form aria-label='shipping address'></form>
56+
<form aria-label='billing address'></form>
57+
```
58+
59+
```hbs
60+
<form role='search' aria-label='search'></form>
61+
<form aria-labelledby='form-title'><div id='form-title'>Meaningful Form Title</div></form>
62+
```
63+
64+
## References
65+
66+
- [WAI-ARIA specification: Landmark Roles](https://www.w3.org/WAI/PF/aria/roles#landmark_roles)
67+
- [Understanding Success Criterion 1.3.1: Info and Relationships](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html)
68+
- [Using aria-labelledby to name regions and landmarks](https://www.w3.org/WAI/WCAG21/Techniques/aria/ARIA13.html)
69+
- [Using aria-label to provide labels for objects](https://www.w3.org/WAI/WCAG21/Techniques/aria/ARIA6)
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
/** @type {import('eslint').Rule.RuleModule} */
2+
module.exports = {
3+
meta: {
4+
type: 'problem',
5+
docs: {
6+
description: 'disallow duplicate landmark elements without unique labels',
7+
category: 'Accessibility',
8+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-duplicate-landmark-elements.md',
9+
templateMode: 'both',
10+
},
11+
fixable: null,
12+
schema: [],
13+
messages: {
14+
duplicate:
15+
'If multiple landmark elements (or elements with an equivalent role) of the same type are found on a page, they must each have a unique label.',
16+
},
17+
originallyFrom: {
18+
name: 'ember-template-lint',
19+
rule: 'lib/rules/no-duplicate-landmark-elements.js',
20+
docs: 'docs/rule/no-duplicate-landmark-elements.md',
21+
tests: 'test/unit/rules/no-duplicate-landmark-elements-test.js',
22+
},
23+
},
24+
25+
create(context) {
26+
// Map HTML5 landmark elements to their implicit ARIA roles
27+
const ELEMENT_TO_ROLE = {
28+
header: 'banner',
29+
footer: 'contentinfo',
30+
main: 'main',
31+
nav: 'navigation',
32+
aside: 'complementary',
33+
form: 'form',
34+
};
35+
36+
// Landmark ARIA roles
37+
const LANDMARK_ROLES = new Set([
38+
'banner',
39+
'complementary',
40+
'contentinfo',
41+
'form',
42+
'main',
43+
'navigation',
44+
'region',
45+
'search',
46+
]);
47+
48+
// Sectioning elements that strip banner/contentinfo roles from header/footer
49+
const SECTIONING_ELEMENTS = new Set(['article', 'aside', 'main', 'nav', 'section']);
50+
const elementStack = [];
51+
52+
// Stack-based scoping for landmarks to handle conditional branches.
53+
// Each scope is a Map<role, [{node, tag}]>.
54+
// When entering a GlimmerBlock that is a branch of an if/unless,
55+
// we push a copy of the current scope so that each branch starts
56+
// from the same baseline and landmarks from one branch don't
57+
// leak into another.
58+
const landmarksStack = [new Map()];
59+
60+
function currentLandmarks() {
61+
return landmarksStack.at(-1);
62+
}
63+
64+
function cloneLandmarks(landmarks) {
65+
const clone = new Map();
66+
for (const [role, entries] of landmarks) {
67+
clone.set(role, [...entries]);
68+
}
69+
return clone;
70+
}
71+
72+
function isInsideSectioningElement() {
73+
return elementStack.some((tag) => SECTIONING_ELEMENTS.has(tag));
74+
}
75+
76+
function checkDuplicates(landmarks) {
77+
for (const [, entries] of landmarks) {
78+
if (entries.length > 1) {
79+
const labeled = [];
80+
const unlabeled = [];
81+
82+
for (const entry of entries) {
83+
const label = getLabel(entry.node);
84+
if (label) {
85+
labeled.push({ node: entry.node, tag: entry.tag, label });
86+
} else {
87+
unlabeled.push({ node: entry.node, tag: entry.tag });
88+
}
89+
}
90+
91+
// When multiple landmarks of same type exist, unlabeled ones are violations
92+
if (unlabeled.length > 0) {
93+
if (unlabeled.length === entries.length) {
94+
// All unlabeled — report all but the first
95+
for (let i = 1; i < unlabeled.length; i++) {
96+
context.report({
97+
node: unlabeled[i].node,
98+
messageId: 'duplicate',
99+
});
100+
}
101+
} else {
102+
// Some are labeled, some aren't — report the unlabeled ones
103+
for (const entry of unlabeled) {
104+
context.report({
105+
node: entry.node,
106+
messageId: 'duplicate',
107+
});
108+
}
109+
}
110+
}
111+
112+
// Report same-label duplicates among labeled landmarks
113+
const labelGroups = new Map();
114+
for (const entry of labeled) {
115+
if (!labelGroups.has(entry.label)) {
116+
labelGroups.set(entry.label, []);
117+
}
118+
labelGroups.get(entry.label).push(entry);
119+
}
120+
for (const [, groupEntries] of labelGroups) {
121+
if (groupEntries.length > 1) {
122+
for (let i = 1; i < groupEntries.length; i++) {
123+
context.report({
124+
node: groupEntries[i].node,
125+
messageId: 'duplicate',
126+
});
127+
}
128+
}
129+
}
130+
}
131+
}
132+
}
133+
134+
return {
135+
GlimmerElementNode(node) {
136+
elementStack.push(node.tag);
137+
138+
const landmarkRole = getLandmarkRole(
139+
node,
140+
LANDMARK_ROLES,
141+
ELEMENT_TO_ROLE,
142+
isInsideSectioningElement()
143+
);
144+
if (landmarkRole) {
145+
const landmarks = currentLandmarks();
146+
if (!landmarks.has(landmarkRole)) {
147+
landmarks.set(landmarkRole, []);
148+
}
149+
landmarks.get(landmarkRole).push({ node, tag: node.tag });
150+
}
151+
},
152+
153+
'GlimmerElementNode:exit'() {
154+
elementStack.pop();
155+
},
156+
157+
GlimmerBlock(node) {
158+
const parent = node.parent;
159+
if (parent && isIfUnless(parent)) {
160+
// Entering a branch of an if/unless block.
161+
// Push a copy of the scope from BEFORE the conditional
162+
// (which is the second-to-last on the stack, since the
163+
// conditional's own scope was pushed on enter).
164+
const parentScope = landmarksStack.at(-2) || landmarksStack.at(-1);
165+
landmarksStack.push(cloneLandmarks(parentScope));
166+
}
167+
},
168+
169+
'GlimmerBlock:exit'(node) {
170+
const parent = node.parent;
171+
if (parent && isIfUnless(parent)) {
172+
landmarksStack.pop();
173+
}
174+
},
175+
176+
GlimmerBlockStatement(node) {
177+
if (isIfUnless(node)) {
178+
// Push a scope for the conditional block itself.
179+
// Each branch (GlimmerBlock) will push its own scope on top.
180+
landmarksStack.push(cloneLandmarks(currentLandmarks()));
181+
}
182+
},
183+
184+
'GlimmerBlockStatement:exit'(node) {
185+
if (isIfUnless(node)) {
186+
landmarksStack.pop();
187+
}
188+
},
189+
190+
'Program:exit'() {
191+
checkDuplicates(currentLandmarks());
192+
},
193+
};
194+
},
195+
};
196+
197+
function isIfUnless(node) {
198+
if (node.type === 'GlimmerBlockStatement' && node.path?.type === 'GlimmerPathExpression') {
199+
return ['if', 'unless'].includes(node.path.original);
200+
}
201+
return false;
202+
}
203+
204+
function getLabel(node) {
205+
// Check aria-label
206+
const ariaLabel = node.attributes?.find((attr) => attr.name === 'aria-label');
207+
if (ariaLabel) {
208+
if (ariaLabel.value?.type === 'GlimmerTextNode') {
209+
return ariaLabel.value.chars.trim();
210+
}
211+
// Dynamic aria-label — treat as a unique label (can't statically determine duplicates)
212+
return `__dynamic:${ariaLabel.range?.[0] || Math.random()}`;
213+
}
214+
215+
// Check aria-labelledby - extract the ID value
216+
const ariaLabelledby = node.attributes?.find((attr) => attr.name === 'aria-labelledby');
217+
if (ariaLabelledby) {
218+
if (ariaLabelledby.value?.type === 'GlimmerTextNode') {
219+
return `__labelledby:${ariaLabelledby.value.chars.trim()}`;
220+
}
221+
return `__dynamic:${ariaLabelledby.range?.[0] || Math.random()}`;
222+
}
223+
224+
return null;
225+
}
226+
227+
function getRoleValue(node) {
228+
const roleAttr = node.attributes?.find((attr) => attr.name === 'role');
229+
if (roleAttr && roleAttr.value?.type === 'GlimmerTextNode') {
230+
return roleAttr.value.chars.trim();
231+
}
232+
return null;
233+
}
234+
235+
function getLandmarkRole(node, LANDMARK_ROLES, ELEMENT_TO_ROLE, insideSectioning) {
236+
const role = getRoleValue(node);
237+
if (role && LANDMARK_ROLES.has(role)) {
238+
return role;
239+
}
240+
const implicitRole = ELEMENT_TO_ROLE[node.tag];
241+
if (implicitRole) {
242+
// header and footer lose their landmark role when inside sectioning elements
243+
if (insideSectioning && (node.tag === 'header' || node.tag === 'footer')) {
244+
return null;
245+
}
246+
return implicitRole;
247+
}
248+
return null;
249+
}

0 commit comments

Comments
 (0)