Skip to content

Commit 3b7ed56

Browse files
Merge pull request #2430 from NullVoxPopuli/nvp/template-lint-extract-rule-template-no-empty-headings
Extract rule: template-no-empty-headings
2 parents a072f87 + ef9d1f3 commit 3b7ed56

File tree

4 files changed

+300
-0
lines changed

4 files changed

+300
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ rules in templates can be disabled with eslint directives with mustache or html
186186
| [template-no-aria-hidden-body](docs/rules/template-no-aria-hidden-body.md) | disallow aria-hidden on body element | | 🔧 | |
187187
| [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 | | | |
188188
| [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 | | | |
189190
| [template-no-heading-inside-button](docs/rules/template-no-heading-inside-button.md) | disallow heading elements inside button elements | | | |
190191

191192
### Best Practices
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# ember/template-no-empty-headings
2+
3+
<!-- end auto-generated rule header -->
4+
5+
Headings relay the structure of a webpage and provide a meaningful, hierarchical order of its content. If headings are empty or its text contents are inaccessible, this could confuse users or prevent them accessing sections of interest.
6+
7+
Disallow headings (h1, h2, etc.) with no accessible text content.
8+
9+
## Examples
10+
11+
This rule **forbids** the following:
12+
13+
```gjs
14+
<template><h*></h*></template>
15+
```
16+
17+
```gjs
18+
<template><div role='heading' aria-level='1'></div></template>
19+
```
20+
21+
```gjs
22+
<template><h*><span aria-hidden='true'>Inaccessible text</span></h*></template>
23+
```
24+
25+
This rule **allows** the following:
26+
27+
```gjs
28+
<template><h*>Heading Content</h*></template>
29+
```
30+
31+
```gjs
32+
<template><h*><span>Text</span><h*></template>
33+
```
34+
35+
```gjs
36+
<template><div role='heading' aria-level='1'>Heading Content</div></template>
37+
```
38+
39+
```gjs
40+
<template><h* aria-hidden='true'>Heading Content</h*></template>
41+
```
42+
43+
```gjs
44+
<template><h* hidden>Heading Content</h*></template>
45+
```
46+
47+
## Migration
48+
49+
If violations are found, remediation should be planned to ensure text content is present and visible and/or screen-reader accessible. Setting `aria-hidden="false"` or removing `hidden` attributes from the element(s) containing heading text may serve as a quickfix.
50+
51+
## References
52+
53+
- [WCAG SC 2.4.6 Headings and Labels](https://www.w3.org/TR/UNDERSTANDING-WCAG20/navigation-mechanisms-descriptive.html)
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
const HEADINGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
2+
3+
function isHidden(node) {
4+
if (!node.attributes) {
5+
return false;
6+
}
7+
if (node.attributes.some((a) => a.name === 'hidden')) {
8+
return true;
9+
}
10+
const ariaHidden = node.attributes.find((a) => a.name === 'aria-hidden');
11+
if (ariaHidden?.value?.type === 'GlimmerTextNode' && ariaHidden.value.chars === 'true') {
12+
return true;
13+
}
14+
return false;
15+
}
16+
17+
function isComponent(node) {
18+
if (node.type !== 'GlimmerElementNode') {
19+
return false;
20+
}
21+
const tag = node.tag;
22+
return /^[A-Z]/.test(tag) || tag.includes('::');
23+
}
24+
25+
function isTextEmpty(text) {
26+
// Treat &nbsp; (U+00A0) and regular whitespace as empty
27+
return text.replaceAll(/\s/g, '').replaceAll('&nbsp;', '').length === 0;
28+
}
29+
30+
function hasAccessibleContent(node) {
31+
if (!node.children || node.children.length === 0) {
32+
return false;
33+
}
34+
35+
for (const child of node.children) {
36+
// Text nodes — only counts if it has real visible characters
37+
if (child.type === 'GlimmerTextNode') {
38+
if (!isTextEmpty(child.chars)) {
39+
return true;
40+
}
41+
continue;
42+
}
43+
44+
// Mustache/block statements are dynamic content
45+
if (child.type === 'GlimmerMustacheStatement' || child.type === 'GlimmerBlockStatement') {
46+
return true;
47+
}
48+
49+
// Element nodes
50+
if (child.type === 'GlimmerElementNode') {
51+
// Skip hidden elements entirely
52+
if (isHidden(child)) {
53+
continue;
54+
}
55+
56+
// Component invocations count as content (they may render text)
57+
if (isComponent(child)) {
58+
return true;
59+
}
60+
61+
// Recurse into non-hidden, non-component elements
62+
if (hasAccessibleContent(child)) {
63+
return true;
64+
}
65+
}
66+
}
67+
return false;
68+
}
69+
70+
function isHeadingElement(node) {
71+
if (HEADINGS.has(node.tag)) {
72+
return true;
73+
}
74+
// Also detect <div role="heading" ...>
75+
const roleAttr = node.attributes?.find((a) => a.name === 'role');
76+
if (roleAttr?.value?.type === 'GlimmerTextNode' && roleAttr.value.chars === 'heading') {
77+
return true;
78+
}
79+
return false;
80+
}
81+
82+
/** @type {import('eslint').Rule.RuleModule} */
83+
module.exports = {
84+
meta: {
85+
type: 'problem',
86+
docs: {
87+
description: 'disallow empty heading elements',
88+
category: 'Accessibility',
89+
strictGjs: true,
90+
strictGts: true,
91+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-empty-headings.md',
92+
},
93+
schema: [],
94+
messages: {
95+
emptyHeading:
96+
'Headings must contain accessible text content (or helper/component that provides text).',
97+
},
98+
},
99+
create(context) {
100+
return {
101+
GlimmerElementNode(node) {
102+
if (isHeadingElement(node)) {
103+
// Skip if the heading itself is hidden
104+
if (isHidden(node)) {
105+
return;
106+
}
107+
108+
if (!hasAccessibleContent(node)) {
109+
context.report({ node, messageId: 'emptyHeading' });
110+
}
111+
}
112+
},
113+
};
114+
},
115+
};
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
const rule = require('../../../lib/rules/template-no-empty-headings');
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-empty-headings', rule, {
10+
valid: [
11+
'<template><h1>Title</h1></template>',
12+
'<template><h2>{{this.title}}</h2></template>',
13+
'<template><h3><span>Text</span></h3></template>',
14+
'<template><h4 hidden></h4></template>',
15+
'<template><h1>Accessible Heading</h1></template>',
16+
'<template><h1>Accessible&nbsp;Heading</h1></template>',
17+
'<template><h1 aria-hidden="true">Valid Heading</h1></template>',
18+
'<template><h1 aria-hidden="true"><span>Valid Heading</span></h1></template>',
19+
'<template><h1 aria-hidden="false">Accessible Heading</h1></template>',
20+
'<template><h1 hidden>Valid Heading</h1></template>',
21+
'<template><h1 hidden><span>Valid Heading</span></h1></template>',
22+
'<template><h1><span aria-hidden="true">Hidden text</span><span>Visible text</span></h1></template>',
23+
'<template><h1><span aria-hidden="true">Hidden text</span>Visible text</h1></template>',
24+
'<template><div role="heading" aria-level="1">Accessible Text</div></template>',
25+
'<template><div role="heading" aria-level="1"><span>Accessible Text</span></div></template>',
26+
'<template><div role="heading" aria-level="1"><span aria-hidden="true">Hidden text</span><span>Visible text</span></div></template>',
27+
'<template><div role="heading" aria-level="1"><span aria-hidden="true">Hidden text</span>Visible text</div></template>',
28+
'<template><div></div></template>',
29+
'<template><p></p></template>',
30+
'<template><span></span></template>',
31+
'<template><header></header></template>',
32+
'<template><h2><CustomComponent /></h2></template>',
33+
'<template><h2>{{@title}}</h2></template>',
34+
'<template><h2>{{#component}}{{/component}}</h2></template>',
35+
'<template><h2><span>{{@title}}</span></h2></template>',
36+
'<template><h2><div><CustomComponent /></div></h2></template>',
37+
'<template><h2><div></div><CustomComponent /></h2></template>',
38+
'<template><h2><div><span>{{@title}}</span></div></h2></template>',
39+
'<template><h2><span>Some text{{@title}}</span></h2></template>',
40+
'<template><h2><span><div></div>{{@title}}</span></h2></template>',
41+
],
42+
invalid: [
43+
{
44+
code: '<template><h1></h1></template>',
45+
output: null,
46+
errors: [{ messageId: 'emptyHeading' }],
47+
},
48+
{
49+
code: '<template><h2> </h2></template>',
50+
output: null,
51+
errors: [{ messageId: 'emptyHeading' }],
52+
},
53+
{
54+
code: `<template><h1>
55+
&nbsp;</h1></template>`,
56+
output: null,
57+
errors: [{ messageId: 'emptyHeading' }],
58+
},
59+
{
60+
code: '<template><h1><span></span></h1></template>',
61+
output: null,
62+
errors: [{ messageId: 'emptyHeading' }],
63+
},
64+
{
65+
code: `<template><h1><span>
66+
&nbsp;</span></h1></template>`,
67+
output: null,
68+
errors: [{ messageId: 'emptyHeading' }],
69+
},
70+
{
71+
code: '<template><h1><div><span></span></div></h1></template>',
72+
output: null,
73+
errors: [{ messageId: 'emptyHeading' }],
74+
},
75+
{
76+
code: '<template><h1><span></span><span></span></h1></template>',
77+
output: null,
78+
errors: [{ messageId: 'emptyHeading' }],
79+
},
80+
{
81+
code: '<template><h1> &nbsp; <div aria-hidden="true">Some hidden text</div></h1></template>',
82+
output: null,
83+
errors: [{ messageId: 'emptyHeading' }],
84+
},
85+
{
86+
code: '<template><h1><span aria-hidden="true">Inaccessible text</span></h1></template>',
87+
output: null,
88+
errors: [{ messageId: 'emptyHeading' }],
89+
},
90+
{
91+
code: '<template><h1><span hidden>Inaccessible text</span></h1></template>',
92+
output: null,
93+
errors: [{ messageId: 'emptyHeading' }],
94+
},
95+
{
96+
code: '<template><h1><span hidden>{{@title}}</span></h1></template>',
97+
output: null,
98+
errors: [{ messageId: 'emptyHeading' }],
99+
},
100+
{
101+
code: '<template><h1><span hidden>{{#component}}Inaccessible text{{/component}}</span></h1></template>',
102+
output: null,
103+
errors: [{ messageId: 'emptyHeading' }],
104+
},
105+
{
106+
code: '<template><h1><span hidden><CustomComponent>Inaccessible text</CustomComponent></span></h1></template>',
107+
output: null,
108+
errors: [{ messageId: 'emptyHeading' }],
109+
},
110+
{
111+
code: '<template><h1><span aria-hidden="true">Hidden text</span><span aria-hidden="true">Hidden text</span></h1></template>',
112+
output: null,
113+
errors: [{ messageId: 'emptyHeading' }],
114+
},
115+
{
116+
code: '<template><div role="heading" aria-level="1"></div></template>',
117+
output: null,
118+
errors: [{ messageId: 'emptyHeading' }],
119+
},
120+
{
121+
code: '<template><div role="heading" aria-level="1"><span aria-hidden="true">Inaccessible text</span></div></template>',
122+
output: null,
123+
errors: [{ messageId: 'emptyHeading' }],
124+
},
125+
{
126+
code: '<template><div role="heading" aria-level="1"><span hidden>Inaccessible text</span></div></template>',
127+
output: null,
128+
errors: [{ messageId: 'emptyHeading' }],
129+
},
130+
],
131+
});

0 commit comments

Comments
 (0)