Skip to content

Commit 15aa94c

Browse files
committed
Extract rule: template-no-duplicate-landmark-elements
1 parent 0149ef1 commit 15aa94c

4 files changed

Lines changed: 304 additions & 8 deletions

File tree

README.md

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -178,14 +178,15 @@ 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 | | 🔧 | |
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 | | | |
189190

190191
### Best Practices
191192

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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`
16+
- `footer`
17+
- `main`
18+
- `nav`
19+
- `aside`
20+
- `section`
21+
22+
## Examples
23+
24+
Examples of **incorrect** code for this rule:
25+
26+
```gjs
27+
<template>
28+
<nav>Primary Navigation</nav>
29+
<nav>Secondary Navigation</nav>
30+
</template>
31+
```
32+
33+
```gjs
34+
<template>
35+
<header>Site Header</header>
36+
<header>Article Header</header>
37+
</template>
38+
```
39+
40+
Examples of **correct** code for this rule:
41+
42+
```gjs
43+
<template>
44+
<nav aria-label="Primary Navigation">Links</nav>
45+
<nav aria-label="Secondary Navigation">More Links</nav>
46+
</template>
47+
```
48+
49+
```gjs
50+
<template>
51+
<header aria-label="Site Header">Site Logo</header>
52+
<header aria-label="Article Header">Article Title</header>
53+
</template>
54+
```
55+
56+
```gjs
57+
<template>
58+
<nav aria-labelledby="nav-1">
59+
<h2 id="nav-1">Main Menu</h2>
60+
</nav>
61+
<nav aria-labelledby="nav-2">
62+
<h2 id="nav-2">Side Menu</h2>
63+
</nav>
64+
</template>
65+
```
66+
67+
## References
68+
69+
- [ARIA Landmarks](https://www.w3.org/WAI/ARIA/apg/practices/landmark-regions/)
70+
- [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: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
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+
strictGjs: true,
9+
strictGts: true,
10+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-duplicate-landmark-elements.md',
11+
},
12+
fixable: null,
13+
schema: [],
14+
messages: {
15+
duplicate:
16+
'Duplicate <{{element}}> landmark element found. Each landmark must have a unique label.',
17+
},
18+
},
19+
20+
create(context) {
21+
// Map HTML5 landmark elements to their implicit ARIA roles
22+
const ELEMENT_TO_ROLE = {
23+
header: 'banner',
24+
footer: 'contentinfo',
25+
main: 'main',
26+
nav: 'navigation',
27+
aside: 'complementary',
28+
section: 'region',
29+
form: 'form',
30+
};
31+
32+
// Landmark ARIA roles
33+
const LANDMARK_ROLES = new Set([
34+
'banner',
35+
'complementary',
36+
'contentinfo',
37+
'form',
38+
'main',
39+
'navigation',
40+
'region',
41+
'search',
42+
]);
43+
44+
const landmarks = new Map();
45+
46+
return {
47+
'Program:exit'() {
48+
// Check for duplicates
49+
for (const [key, entries] of landmarks) {
50+
if (entries.length > 1) {
51+
const labeled = [];
52+
const unlabeled = [];
53+
54+
for (const entry of entries) {
55+
const label = getLabel(entry.node);
56+
if (label) {
57+
labeled.push({ node: entry.node, tag: entry.tag, label });
58+
} else {
59+
unlabeled.push({ node: entry.node, tag: entry.tag });
60+
}
61+
}
62+
63+
// When multiple landmarks of same type exist, unlabeled ones are violations
64+
if (unlabeled.length > 0) {
65+
if (unlabeled.length === entries.length) {
66+
// All unlabeled — report all but the first
67+
for (let i = 1; i < unlabeled.length; i++) {
68+
context.report({
69+
node: unlabeled[i].node,
70+
messageId: 'duplicate',
71+
data: { element: unlabeled[i].tag },
72+
});
73+
}
74+
} else {
75+
// Some are labeled, some aren't — report the unlabeled ones
76+
for (const entry of unlabeled) {
77+
context.report({
78+
node: entry.node,
79+
messageId: 'duplicate',
80+
data: { element: entry.tag },
81+
});
82+
}
83+
}
84+
}
85+
86+
// Report same-label duplicates among labeled landmarks
87+
const labelGroups = new Map();
88+
for (const entry of labeled) {
89+
if (!labelGroups.has(entry.label)) {
90+
labelGroups.set(entry.label, []);
91+
}
92+
labelGroups.get(entry.label).push(entry);
93+
}
94+
for (const [, groupEntries] of labelGroups) {
95+
if (groupEntries.length > 1) {
96+
for (let i = 1; i < groupEntries.length; i++) {
97+
context.report({
98+
node: groupEntries[i].node,
99+
messageId: 'duplicate',
100+
data: { element: groupEntries[i].tag },
101+
});
102+
}
103+
}
104+
}
105+
}
106+
}
107+
},
108+
109+
GlimmerElementNode(node) {
110+
const landmarkRole = getLandmarkRole(node, LANDMARK_ROLES, ELEMENT_TO_ROLE);
111+
if (landmarkRole) {
112+
if (!landmarks.has(landmarkRole)) {
113+
landmarks.set(landmarkRole, []);
114+
}
115+
landmarks.get(landmarkRole).push({ node, tag: node.tag });
116+
}
117+
},
118+
};
119+
},
120+
};
121+
122+
function getLabel(node) {
123+
// Check aria-label
124+
const ariaLabel = node.attributes?.find((attr) => attr.name === 'aria-label');
125+
if (ariaLabel && ariaLabel.value?.type === 'GlimmerTextNode') {
126+
return ariaLabel.value.chars.trim();
127+
}
128+
129+
// Check aria-labelledby - extract the ID value
130+
const ariaLabelledby = node.attributes?.find((attr) => attr.name === 'aria-labelledby');
131+
if (ariaLabelledby && ariaLabelledby.value?.type === 'GlimmerTextNode') {
132+
return `__labelledby:${ariaLabelledby.value.chars.trim()}`;
133+
}
134+
135+
return null;
136+
}
137+
138+
function getRoleValue(node) {
139+
const roleAttr = node.attributes?.find((attr) => attr.name === 'role');
140+
if (roleAttr && roleAttr.value?.type === 'GlimmerTextNode') {
141+
return roleAttr.value.chars.trim();
142+
}
143+
return null;
144+
}
145+
146+
function getLandmarkRole(node, LANDMARK_ROLES, ELEMENT_TO_ROLE) {
147+
const role = getRoleValue(node);
148+
if (role && LANDMARK_ROLES.has(role)) {
149+
return role;
150+
}
151+
if (ELEMENT_TO_ROLE[node.tag]) {
152+
return ELEMENT_TO_ROLE[node.tag];
153+
}
154+
return null;
155+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
const rule = require('../../../lib/rules/template-no-duplicate-landmark-elements');
2+
const RuleTester = require('eslint').RuleTester;
3+
4+
const ruleTester = new RuleTester({
5+
parser: require.resolve('ember-eslint-parser'),
6+
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
7+
});
8+
9+
ruleTester.run('template-no-duplicate-landmark-elements', rule, {
10+
valid: [
11+
'<template><header aria-label="Main">Header</header></template>',
12+
'<template><nav aria-label="Primary">Nav 1</nav><nav aria-label="Secondary">Nav 2</nav></template>',
13+
'<template><main>Content</main></template>',
14+
],
15+
16+
invalid: [
17+
{
18+
code: '<template><nav>Nav 1</nav><nav>Nav 2</nav></template>',
19+
output: null,
20+
errors: [{ messageId: 'duplicate' }],
21+
},
22+
{
23+
code: '<template><header>Header 1</header><header>Header 2</header></template>',
24+
output: null,
25+
errors: [{ messageId: 'duplicate' }],
26+
},
27+
{
28+
code: '<template><aside>Side 1</aside><aside>Side 2</aside></template>',
29+
output: null,
30+
errors: [{ messageId: 'duplicate' }],
31+
},
32+
33+
// Test cases ported from ember-template-lint
34+
{
35+
code: '<template><nav></nav><nav></nav></template>',
36+
output: null,
37+
errors: [{ messageId: 'duplicate' }],
38+
},
39+
{
40+
code: '<template><nav></nav><div role="navigation"></div></template>',
41+
output: null,
42+
errors: [{ messageId: 'duplicate' }],
43+
},
44+
{
45+
code: '<template><nav></nav><nav aria-label="secondary navigation"></nav></template>',
46+
output: null,
47+
errors: [{ messageId: 'duplicate' }],
48+
},
49+
{
50+
code: '<template><main></main><div role="main"></div></template>',
51+
output: null,
52+
errors: [{ messageId: 'duplicate' }],
53+
},
54+
{
55+
code: '<template><nav aria-label="site navigation"></nav><nav aria-label="site navigation"></nav></template>',
56+
output: null,
57+
errors: [{ messageId: 'duplicate' }],
58+
},
59+
{
60+
code: '<template><form aria-label="search-form"></form><form aria-label="search-form"></form></template>',
61+
output: null,
62+
errors: [{ messageId: 'duplicate' }],
63+
},
64+
{
65+
code: '<template><form aria-labelledby="form-title"></form><form aria-labelledby="form-title"></form></template>',
66+
output: null,
67+
errors: [{ messageId: 'duplicate' }],
68+
},
69+
],
70+
});

0 commit comments

Comments
 (0)