Skip to content

Commit 034cbe1

Browse files
committed
Extract rule: template-no-empty-headings
1 parent 3e664f5 commit 034cbe1

4 files changed

Lines changed: 145 additions & 0 deletions

File tree

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

190191
### Best Practices
191192

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+
```hbs
14+
<h*></h*>
15+
```
16+
17+
```hbs
18+
<div role='heading' aria-level='1'></div>
19+
```
20+
21+
```hbs
22+
<h*><span aria-hidden='true'>Inaccessible text</span></h*>
23+
```
24+
25+
This rule **allows** the following:
26+
27+
```hbs
28+
<h*>Heading Content</h*>
29+
```
30+
31+
```hbs
32+
<h*><span>Text</span><h*>
33+
```
34+
35+
```hbs
36+
<div role='heading' aria-level='1'>Heading Content</div>
37+
```
38+
39+
```hbs
40+
<h* aria-hidden='true'>Heading Content</h*>
41+
```
42+
43+
```hbs
44+
<h* hidden>Heading Content</h*>
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: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
const HEADINGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
2+
3+
function hasTextContent(node) {
4+
if (!node.children || node.children.length === 0) {
5+
return false;
6+
}
7+
8+
for (const child of node.children) {
9+
// Text nodes with content
10+
if (child.type === 'GlimmerTextNode' && child.chars.trim().length > 0) {
11+
return true;
12+
}
13+
// Mustache statements or helpers
14+
if (child.type === 'GlimmerMustacheStatement' || child.type === 'GlimmerBlockStatement') {
15+
return true;
16+
}
17+
// Nested elements
18+
if (child.type === 'GlimmerElementNode' && hasTextContent(child)) {
19+
return true;
20+
}
21+
}
22+
return false;
23+
}
24+
25+
/** @type {import('eslint').Rule.RuleModule} */
26+
module.exports = {
27+
meta: {
28+
type: 'problem',
29+
docs: {
30+
description: 'disallow empty heading elements',
31+
category: 'Accessibility',
32+
strictGjs: true,
33+
strictGts: true,
34+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-empty-headings.md',
35+
},
36+
schema: [],
37+
messages: {
38+
emptyHeading:
39+
'Headings must contain accessible text content (or helper/component that provides text).',
40+
},
41+
},
42+
create(context) {
43+
return {
44+
GlimmerElementNode(node) {
45+
if (HEADINGS.has(node.tag)) {
46+
// Skip if hidden
47+
const hasHidden = node.attributes?.some((a) => a.name === 'hidden');
48+
const ariaHidden = node.attributes?.find((a) => a.name === 'aria-hidden');
49+
if (
50+
hasHidden ||
51+
(ariaHidden?.value?.type === 'GlimmerTextNode' && ariaHidden.value.chars === 'true')
52+
) {
53+
return;
54+
}
55+
56+
if (!hasTextContent(node)) {
57+
context.report({ node, messageId: 'emptyHeading' });
58+
}
59+
}
60+
},
61+
};
62+
},
63+
};
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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+
],
16+
invalid: [
17+
{
18+
code: '<template><h1></h1></template>',
19+
output: null,
20+
errors: [{ messageId: 'emptyHeading' }],
21+
},
22+
{
23+
code: '<template><h2> </h2></template>',
24+
output: null,
25+
errors: [{ messageId: 'emptyHeading' }],
26+
},
27+
],
28+
});

0 commit comments

Comments
 (0)