Skip to content

Commit 0e691b3

Browse files
iviojeyeonjuan
andauthored
feat: add no-empty-headings rule to enforce accessible heading content (#373)
* feat: add no-empty-headings rule to enforce accessible heading content * refactor: improve type definitions and clean up parameter annotations in no-empty-headings rule * refactor: remove unused getHeadingText function from no-empty-headings rule * fix * Update no-empty-headings.js --------- Co-authored-by: YeonJuan <[email protected]>
1 parent 8a547c1 commit 0e691b3

File tree

5 files changed

+259
-0
lines changed

5 files changed

+259
-0
lines changed

docs/rules.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
| [no-accesskey-attrs](rules/no-accesskey-attrs) | Disallow to use of accesskey attribute | |
4949
| [no-aria-hidden-body](rules/no-aria-hidden-body) | Disallow to use aria-hidden attributes on the `body` element. | |
5050
| [no-aria-hidden-on-focusable](rules/no-aria-hidden-on-focusable) | Disallow aria-hidden="true" on focusable elements | |
51+
| [no-empty-headings](rules/no-empty-headings) | Disallow empty or inaccessible headings. | |
5152
| [no-heading-inside-button](rules/no-heading-inside-button) | Disallows the use of heading elements inside <button>. | |
5253
| [no-invalid-role](rules/no-invalid-role) | Disallows use of invalid role. | |
5354
| [no-non-scalable-viewport](rules/no-non-scalable-viewport) | Disallow use of `user-scalable=no` in `<meta name="viewport">`. | |

docs/rules/no-empty-headings.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# no-empty-headings
2+
3+
This rule enforces that all heading elements (h1–h6) and elements with `role="heading"` must have accessible text content.
4+
5+
### Why?
6+
7+
Headings relay the structure of a webpage and provide a meaningful, hierarchical order of its content. If headings are empty or their text contents are inaccessible, this could confuse users or prevent them from accessing sections of interest, especially for those using assistive technology.
8+
9+
## How to use
10+
11+
```js,.eslintrc.js
12+
module.exports = {
13+
rules: {
14+
"@html-eslint/no-empty-headings": "error",
15+
},
16+
};
17+
```
18+
19+
## Rule Details
20+
21+
Headings that are empty or whose text is only present in elements with `aria-hidden="true"` are not allowed, as this can confuse users or prevent them from accessing sections of interest.
22+
23+
### ❌ Incorrect
24+
25+
```html
26+
<h1></h1>
27+
<div role="heading" aria-level="1"></div>
28+
<h2><span aria-hidden="true">Inaccessible text</span></h2>
29+
```
30+
31+
### ✅ Correct
32+
33+
```html
34+
<h1>Heading Content</h1>
35+
<h2><span>Text</span></h2>
36+
<div role="heading" aria-level="1">Heading Content</div>
37+
<h3 aria-hidden="true">Heading Content</h3>
38+
<h4 hidden>Heading Content</h4>
39+
```
40+
41+
### Resources
42+
43+
- [ember-template-lint: no-empty-headings](https://github.com/ember-template-lint/ember-template-lint/blob/master/docs/rule/no-empty-headings.md)

packages/eslint-plugin/lib/rules/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ const maxElementDepth = require("./max-element-depth");
4848
const requireExplicitSize = require("./require-explicit-size");
4949
const useBaseLine = require("./use-baseline");
5050
const noDuplicateClass = require("./no-duplicate-class");
51+
const noEmptyHeadings = require("./no-empty-headings");
5152
// import new rule here ↑
5253
// DO NOT REMOVE THIS COMMENT
5354

@@ -102,6 +103,7 @@ const rules = {
102103
"require-explicit-size": requireExplicitSize,
103104
"use-baseline": useBaseLine,
104105
"no-duplicate-class": noDuplicateClass,
106+
"no-empty-headings": noEmptyHeadings,
105107
// export new rule here ↑
106108
// DO NOT REMOVE THIS COMMENT
107109
};
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/**
2+
* @typedef { import("../types").RuleModule<[]> } RuleModule
3+
* @typedef { import("@html-eslint/types").Tag } Tag
4+
* @typedef { import("@html-eslint/types").Text } Text
5+
*/
6+
7+
const { RULE_CATEGORY } = require("../constants");
8+
const { findAttr, isTag, isText } = require("./utils/node");
9+
const { createVisitors } = require("./utils/visitors");
10+
const { getRuleUrl } = require("./utils/rule");
11+
12+
const MESSAGE_IDS = {
13+
EMPTY_HEADING: "emptyHeading",
14+
INACCESSIBLE_HEADING: "inaccessibleHeading",
15+
};
16+
17+
const HEADING_NAMES = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
18+
19+
/**
20+
* @param {Tag} node
21+
*/
22+
function isAriaHidden(node) {
23+
const ariaHiddenAttr = findAttr(node, "aria-hidden");
24+
return (
25+
ariaHiddenAttr &&
26+
ariaHiddenAttr.value &&
27+
ariaHiddenAttr.value.value === "true"
28+
);
29+
}
30+
31+
/**
32+
* @param {Tag} node
33+
* @returns {boolean}
34+
*/
35+
function isRoleHeading(node) {
36+
const roleAttr = findAttr(node, "role");
37+
return !!roleAttr && !!roleAttr.value && roleAttr.value.value === "heading";
38+
}
39+
40+
/**
41+
* @param {Text | Tag} node
42+
* @returns {string}
43+
*/
44+
function getAllText(node) {
45+
if (!isTag(node) || !node.children.length) return "";
46+
let text = "";
47+
for (const child of node.children) {
48+
if (isText(child)) {
49+
text += child.value.trim();
50+
} else if (isTag(child)) {
51+
text += getAllText(child);
52+
}
53+
}
54+
return text;
55+
}
56+
57+
/**
58+
* @param {Text | Tag} node
59+
* @returns {string}
60+
*/
61+
function getAccessibleText(node) {
62+
if (!isTag(node) || !node.children.length) return "";
63+
let text = "";
64+
for (const child of node.children) {
65+
if (isText(child)) {
66+
text += child.value.trim();
67+
} else if (isTag(child) && !isAriaHidden(child)) {
68+
text += getAccessibleText(child);
69+
}
70+
}
71+
return text;
72+
}
73+
74+
/**
75+
* @type {RuleModule}
76+
*/
77+
module.exports = {
78+
meta: {
79+
type: "code",
80+
docs: {
81+
description: "Disallow empty or inaccessible headings.",
82+
category: RULE_CATEGORY.ACCESSIBILITY,
83+
recommended: false,
84+
url: getRuleUrl("no-empty-headings"),
85+
},
86+
fixable: null,
87+
schema: [],
88+
messages: {
89+
[MESSAGE_IDS.EMPTY_HEADING]: "Headings must not be empty.",
90+
[MESSAGE_IDS.INACCESSIBLE_HEADING]:
91+
"Heading text is inaccessible to assistive technology.",
92+
},
93+
},
94+
create(context) {
95+
return createVisitors(context, {
96+
Tag(node) {
97+
const tagName = node.name.toLowerCase();
98+
const isHeadingTag = HEADING_NAMES.has(tagName);
99+
const isRoleHeadingEl = isRoleHeading(node);
100+
if (!isHeadingTag && !isRoleHeadingEl) return;
101+
102+
// Gather all text (including aria-hidden)
103+
const allText = getAllText(node);
104+
if (!allText) {
105+
context.report({
106+
node,
107+
messageId: MESSAGE_IDS.EMPTY_HEADING,
108+
});
109+
return;
110+
}
111+
// Gather accessible text (not aria-hidden)
112+
const accessibleText = getAccessibleText(node);
113+
if (!accessibleText) {
114+
context.report({
115+
node,
116+
messageId: MESSAGE_IDS.INACCESSIBLE_HEADING,
117+
});
118+
}
119+
},
120+
});
121+
},
122+
};
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
const createRuleTester = require("../rule-tester");
2+
const rule = require("../../lib/rules/no-empty-headings");
3+
4+
const ruleTester = createRuleTester();
5+
const templateRuleTester = createRuleTester("espree");
6+
7+
ruleTester.run("no-empty-headings", rule, {
8+
valid: [
9+
{ code: "<h1>Heading Content</h1>" },
10+
{ code: "<h2><span>Text</span></h2>" },
11+
{ code: '<div role="heading" aria-level="1">Heading Content</div>' },
12+
{ code: '<h3 aria-hidden="true">Heading Content</h3>' },
13+
{ code: "<h4 hidden>Heading Content</h4>" },
14+
{ code: '<h2><span aria-hidden="true"></span>Visible</h2>' },
15+
{
16+
code: '<h2><span aria-hidden="true">Hidden</span><span>Visible</span></h2>',
17+
},
18+
],
19+
invalid: [
20+
{
21+
code: "<h1></h1>",
22+
errors: [{ messageId: "emptyHeading" }],
23+
},
24+
{
25+
code: "<h1> <span></span> </h1>",
26+
errors: [{ messageId: "emptyHeading" }],
27+
},
28+
{
29+
code: "<h1><!-- comment --></h1>",
30+
errors: [{ messageId: "emptyHeading" }],
31+
},
32+
{
33+
code: '<div role="heading" aria-level="1"></div>',
34+
errors: [{ messageId: "emptyHeading" }],
35+
},
36+
{
37+
code: '<h2><span aria-hidden="true">Inaccessible text</span></h2>',
38+
errors: [{ messageId: "inaccessibleHeading" }],
39+
},
40+
{
41+
code: "<h3> </h3>",
42+
errors: [{ messageId: "emptyHeading" }],
43+
},
44+
{
45+
code: '<h4><span aria-hidden="true"></span></h4>',
46+
errors: [{ messageId: "emptyHeading" }],
47+
},
48+
{
49+
code: `<h4>
50+
</h4>`,
51+
errors: [{ messageId: "emptyHeading" }],
52+
},
53+
],
54+
});
55+
56+
templateRuleTester.run("[template] no-empty-headings", rule, {
57+
valid: [
58+
{ code: "html`<h1>Heading Content</h1>`" },
59+
{ code: "html`<h1>${foo}</h1>`" },
60+
{ code: "html`<h2><span>Text</span></h2>`" },
61+
{ code: 'html`<div role="heading" aria-level="1">Heading Content</div>`' },
62+
{ code: 'html`<h3 aria-hidden="true">Heading Content</h3>`' },
63+
{ code: "html`<h4 hidden>Heading Content</h4>`" },
64+
{ code: 'html`<h2><span aria-hidden="true"></span>Visible</h2>`' },
65+
{
66+
code: 'html`<h2><span aria-hidden="true">Hidden</span><span>Visible</span></h2>`',
67+
},
68+
],
69+
invalid: [
70+
{
71+
code: "html`<h1></h1>`",
72+
errors: [{ messageId: "emptyHeading" }],
73+
},
74+
{
75+
code: 'html`<div role="heading" aria-level="1"></div>`',
76+
errors: [{ messageId: "emptyHeading" }],
77+
},
78+
{
79+
code: 'html`<h2><span aria-hidden="true">Inaccessible text</span></h2>`',
80+
errors: [{ messageId: "inaccessibleHeading" }],
81+
},
82+
{
83+
code: "html`<h3> </h3>`",
84+
errors: [{ messageId: "emptyHeading" }],
85+
},
86+
{
87+
code: 'html`<h4><span aria-hidden="true"></span></h4>`',
88+
errors: [{ messageId: "emptyHeading" }],
89+
},
90+
],
91+
});

0 commit comments

Comments
 (0)