Skip to content

Commit 544b9f3

Browse files
committed
Extract rule: template-no-duplicate-landmark-elements
1 parent 1c9a858 commit 544b9f3

4 files changed

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

0 commit comments

Comments
 (0)