Skip to content

Commit 75b1904

Browse files
Merge pull request #2485 from NullVoxPopuli/nvp/template-lint-extract-rule-template-no-nested-landmark
Extract rule: template-no-nested-landmark
2 parents 17664dd + f50004f commit 75b1904

4 files changed

Lines changed: 405 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ rules in templates can be disabled with eslint directives with mustache or html
189189
| [template-no-empty-headings](docs/rules/template-no-empty-headings.md) | disallow empty heading elements | | | |
190190
| [template-no-heading-inside-button](docs/rules/template-no-heading-inside-button.md) | disallow heading elements inside button elements | | | |
191191
| [template-no-invalid-interactive](docs/rules/template-no-invalid-interactive.md) | disallow non-interactive elements with interactive handlers | | | |
192+
| [template-no-nested-landmark](docs/rules/template-no-nested-landmark.md) | disallow nested landmark elements | | | |
192193

193194
### Best Practices
194195

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# ember/template-no-nested-landmark
2+
3+
<!-- end auto-generated rule header -->
4+
5+
Disallows nesting landmark elements of the same type.
6+
7+
Landmark elements should not be nested within other landmarks of the same name. This creates confusion for screen reader users navigating by landmarks.
8+
9+
## Rule Details
10+
11+
This rule disallows nesting landmark elements or roles within other landmark elements or roles of the same type.
12+
13+
Landmark elements include:
14+
15+
- `<header>` (banner)
16+
- `<nav>` (navigation)
17+
- `<main>` (main)
18+
- `<aside>` (complementary)
19+
- `<footer>` (contentinfo)
20+
- `<section>` (region)
21+
- `<form>` (form)
22+
- Elements with landmark roles
23+
24+
## List of elements & their corresponding roles
25+
26+
- header (banner)
27+
- main (main)
28+
- aside (complementary)
29+
- form (form, search)
30+
- main (main)
31+
- nav (navigation)
32+
- footer (contentinfo)
33+
34+
## Examples
35+
36+
Examples of **incorrect** code for this rule:
37+
38+
```gjs
39+
<template>
40+
<nav>
41+
<nav>Nested navigation</nav>
42+
</nav>
43+
</template>
44+
```
45+
46+
```gjs
47+
<template>
48+
<main>
49+
<main>Nested main</main>
50+
</main>
51+
</template>
52+
```
53+
54+
```gjs
55+
<template>
56+
<div role="navigation">
57+
<nav>Nested nav</nav>
58+
</div>
59+
</template>
60+
```
61+
62+
Examples of **correct** code for this rule:
63+
64+
```gjs
65+
<template>
66+
<nav>Navigation</nav>
67+
<main>Content</main>
68+
</template>
69+
```
70+
71+
```gjs
72+
<template>
73+
<main>
74+
<nav>Navigation inside main</nav>
75+
</main>
76+
</template>
77+
```
78+
79+
```gjs
80+
<template>
81+
<main>
82+
<div>Regular content</div>
83+
</main>
84+
</template>
85+
```
86+
87+
## References
88+
89+
- [eslint-plugin-ember template-no-nested-landmark](https://github.com/ember-cli/eslint-plugin-ember/blob/master/docs/rules/template-no-nested-landmark.md)
90+
- [WAI-ARIA Landmarks](https://www.w3.org/WAI/ARIA/apg/practices/landmark-regions/)
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
const LANDMARK_ROLES = new Set([
2+
'banner',
3+
'complementary',
4+
'contentinfo',
5+
'form',
6+
'main',
7+
'navigation',
8+
'region',
9+
'search',
10+
]);
11+
12+
const LANDMARK_ELEMENTS = new Set(['header', 'aside', 'footer', 'form', 'main', 'nav', 'section']);
13+
14+
const EQUIVALENT_ROLE = {
15+
aside: 'complementary',
16+
footer: 'contentinfo',
17+
header: 'banner',
18+
main: 'main',
19+
nav: 'navigation',
20+
section: 'region',
21+
};
22+
23+
function isLandmark(node) {
24+
// Check if element is inherently a landmark
25+
if (LANDMARK_ELEMENTS.has(node.tag)) {
26+
return true;
27+
}
28+
29+
// Check if element has a landmark role
30+
const roleAttr = node.attributes?.find((a) => a.name === 'role');
31+
if (roleAttr?.value?.type === 'GlimmerTextNode') {
32+
return LANDMARK_ROLES.has(roleAttr.value.chars);
33+
}
34+
35+
return false;
36+
}
37+
38+
function getLandmarkType(node) {
39+
// If node has an explicit role attribute, use that
40+
const roleAttr = node.attributes?.find((a) => a.name === 'role');
41+
if (roleAttr?.value?.type === 'GlimmerTextNode') {
42+
return roleAttr.value.chars;
43+
}
44+
// Otherwise, use the equivalent role for the tag
45+
return EQUIVALENT_ROLE[node.tag] || node.tag;
46+
}
47+
48+
/** @type {import('eslint').Rule.RuleModule} */
49+
module.exports = {
50+
meta: {
51+
type: 'problem',
52+
docs: {
53+
description: 'disallow nested landmark elements',
54+
category: 'Accessibility',
55+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-nested-landmark.md',
56+
templateMode: 'both',
57+
},
58+
fixable: null,
59+
schema: [],
60+
messages: {
61+
nested: 'Landmark elements should not be nested within other landmarks.',
62+
},
63+
originallyFrom: {
64+
name: 'ember-template-lint',
65+
rule: 'lib/rules/no-nested-landmark.js',
66+
docs: 'docs/rule/no-nested-landmark.md',
67+
tests: 'test/unit/rules/no-nested-landmark-test.js',
68+
},
69+
},
70+
71+
create(context) {
72+
const landmarkStack = [];
73+
74+
return {
75+
GlimmerElementNode(node) {
76+
const isCurrentLandmark = isLandmark(node);
77+
78+
if (isCurrentLandmark) {
79+
const currentType = getLandmarkType(node);
80+
// Check if any ancestor landmark has the same type
81+
for (const ancestor of landmarkStack) {
82+
if (getLandmarkType(ancestor) === currentType) {
83+
context.report({
84+
node,
85+
messageId: 'nested',
86+
});
87+
break;
88+
}
89+
}
90+
landmarkStack.push(node);
91+
}
92+
},
93+
94+
'GlimmerElementNode:exit'(node) {
95+
if (isLandmark(node)) {
96+
landmarkStack.pop();
97+
}
98+
},
99+
};
100+
},
101+
};

0 commit comments

Comments
 (0)