Skip to content

Commit 861603e

Browse files
committed
Extract rule: template-no-duplicate-landmark-elements
1 parent 3e664f5 commit 861603e

4 files changed

Lines changed: 231 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: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
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+
// HTML5 landmark elements
22+
const LANDMARK_ELEMENTS = new Set(['header', 'footer', 'main', 'nav', 'aside', 'section']);
23+
24+
// Landmark ARIA roles
25+
const LANDMARK_ROLES = new Set([
26+
'banner',
27+
'complementary',
28+
'contentinfo',
29+
'form',
30+
'main',
31+
'navigation',
32+
'region',
33+
'search',
34+
]);
35+
36+
const landmarks = new Map();
37+
38+
return {
39+
'Program:exit'() {
40+
// Check for duplicates
41+
for (const [key, nodes] of landmarks) {
42+
if (nodes.length > 1) {
43+
// Group nodes by label
44+
const labelGroups = new Map();
45+
46+
for (const node of nodes) {
47+
const label = getLabel(node);
48+
const labelKey = label || '__no_label__';
49+
50+
if (!labelGroups.has(labelKey)) {
51+
labelGroups.set(labelKey, []);
52+
}
53+
labelGroups.get(labelKey).push(node);
54+
}
55+
56+
// Report duplicates - only report if there are multiple with same label
57+
for (const [labelKey, groupNodes] of labelGroups) {
58+
if (groupNodes.length > 1) {
59+
// Report all but the first in each duplicate group
60+
for (let i = 1; i < groupNodes.length; i++) {
61+
context.report({
62+
node: groupNodes[i],
63+
messageId: 'duplicate',
64+
data: { element: key },
65+
});
66+
}
67+
}
68+
}
69+
}
70+
}
71+
},
72+
73+
GlimmerElementNode(node) {
74+
const landmarkKey = getLandmarkKey(node, LANDMARK_ROLES, LANDMARK_ELEMENTS);
75+
if (landmarkKey) {
76+
if (!landmarks.has(landmarkKey)) {
77+
landmarks.set(landmarkKey, []);
78+
}
79+
landmarks.get(landmarkKey).push(node);
80+
}
81+
},
82+
};
83+
},
84+
};
85+
86+
function getLabel(node) {
87+
// Check aria-label
88+
const ariaLabel = node.attributes?.find((attr) => attr.name === 'aria-label');
89+
if (ariaLabel && ariaLabel.value?.type === 'GlimmerTextNode') {
90+
return ariaLabel.value.chars.trim();
91+
}
92+
93+
// Check aria-labelledby - extract the ID value
94+
const ariaLabelledby = node.attributes?.find((attr) => attr.name === 'aria-labelledby');
95+
if (ariaLabelledby && ariaLabelledby.value?.type === 'GlimmerTextNode') {
96+
return `__labelledby:${ariaLabelledby.value.chars.trim()}`;
97+
}
98+
99+
return null;
100+
}
101+
102+
function getRoleValue(node) {
103+
const roleAttr = node.attributes?.find((attr) => attr.name === 'role');
104+
if (roleAttr && roleAttr.value?.type === 'GlimmerTextNode') {
105+
return roleAttr.value.chars.trim();
106+
}
107+
return null;
108+
}
109+
110+
function getLandmarkKey(node, LANDMARK_ROLES, LANDMARK_ELEMENTS) {
111+
const role = getRoleValue(node);
112+
if (role && LANDMARK_ROLES.has(role)) {
113+
return role;
114+
}
115+
if (LANDMARK_ELEMENTS.has(node.tag)) {
116+
return node.tag;
117+
}
118+
return null;
119+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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+
});

0 commit comments

Comments
 (0)