From 9d274fb5544f3b26fe6d7d9743f5f6a80859d0e8 Mon Sep 17 00:00:00 2001
From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com>
Date: Mon, 23 Feb 2026 14:49:33 -0500
Subject: [PATCH 1/2] Extract rule: template-no-empty-headings
---
README.md | 1 +
docs/rules/template-no-empty-headings.md | 53 +++++++
lib/rules/template-no-empty-headings.js | 115 +++++++++++++++
tests/lib/rules/template-no-empty-headings.js | 135 ++++++++++++++++++
4 files changed, 304 insertions(+)
create mode 100644 docs/rules/template-no-empty-headings.md
create mode 100644 lib/rules/template-no-empty-headings.js
create mode 100644 tests/lib/rules/template-no-empty-headings.js
diff --git a/README.md b/README.md
index bdfe0d5050..e424d8009f 100644
--- a/README.md
+++ b/README.md
@@ -186,6 +186,7 @@ rules in templates can be disabled with eslint directives with mustache or html
| [template-no-aria-hidden-body](docs/rules/template-no-aria-hidden-body.md) | disallow aria-hidden on body element | | 🔧 | |
| [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 | | | |
| [template-no-autofocus-attribute](docs/rules/template-no-autofocus-attribute.md) | disallow autofocus attribute | | 🔧 | |
+| [template-no-empty-headings](docs/rules/template-no-empty-headings.md) | disallow empty heading elements | | | |
| [template-no-heading-inside-button](docs/rules/template-no-heading-inside-button.md) | disallow heading elements inside button elements | | | |
### Best Practices
diff --git a/docs/rules/template-no-empty-headings.md b/docs/rules/template-no-empty-headings.md
new file mode 100644
index 0000000000..065875381b
--- /dev/null
+++ b/docs/rules/template-no-empty-headings.md
@@ -0,0 +1,53 @@
+# ember/template-no-empty-headings
+
+
+
+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.
+
+Disallow headings (h1, h2, etc.) with no accessible text content.
+
+## Examples
+
+This rule **forbids** the following:
+
+```gjs
+
+```
+
+```gjs
+
+```
+
+```gjs
+Inaccessible text
+```
+
+This rule **allows** the following:
+
+```gjs
+Heading Content
+```
+
+```gjs
+Text
+```
+
+```gjs
+Heading Content
+```
+
+```gjs
+Heading Content
+```
+
+```gjs
+Heading Content
+```
+
+## Migration
+
+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.
+
+## References
+
+- [WCAG SC 2.4.6 Headings and Labels](https://www.w3.org/TR/UNDERSTANDING-WCAG20/navigation-mechanisms-descriptive.html)
diff --git a/lib/rules/template-no-empty-headings.js b/lib/rules/template-no-empty-headings.js
new file mode 100644
index 0000000000..4511d137e7
--- /dev/null
+++ b/lib/rules/template-no-empty-headings.js
@@ -0,0 +1,115 @@
+const HEADINGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
+
+function isHidden(node) {
+ if (!node.attributes) {
+ return false;
+ }
+ if (node.attributes.some((a) => a.name === 'hidden')) {
+ return true;
+ }
+ const ariaHidden = node.attributes.find((a) => a.name === 'aria-hidden');
+ if (ariaHidden?.value?.type === 'GlimmerTextNode' && ariaHidden.value.chars === 'true') {
+ return true;
+ }
+ return false;
+}
+
+function isComponent(node) {
+ if (node.type !== 'GlimmerElementNode') {
+ return false;
+ }
+ const tag = node.tag;
+ return /^[A-Z]/.test(tag) || tag.includes('::');
+}
+
+function isTextEmpty(text) {
+ // Treat (U+00A0) and regular whitespace as empty
+ return text.replaceAll(/\s/g, '').replaceAll(' ', '').length === 0;
+}
+
+function hasAccessibleContent(node) {
+ if (!node.children || node.children.length === 0) {
+ return false;
+ }
+
+ for (const child of node.children) {
+ // Text nodes — only counts if it has real visible characters
+ if (child.type === 'GlimmerTextNode') {
+ if (!isTextEmpty(child.chars)) {
+ return true;
+ }
+ continue;
+ }
+
+ // Mustache/block statements are dynamic content
+ if (child.type === 'GlimmerMustacheStatement' || child.type === 'GlimmerBlockStatement') {
+ return true;
+ }
+
+ // Element nodes
+ if (child.type === 'GlimmerElementNode') {
+ // Skip hidden elements entirely
+ if (isHidden(child)) {
+ continue;
+ }
+
+ // Component invocations count as content (they may render text)
+ if (isComponent(child)) {
+ return true;
+ }
+
+ // Recurse into non-hidden, non-component elements
+ if (hasAccessibleContent(child)) {
+ return true;
+ }
+ }
+ }
+ return false;
+}
+
+function isHeadingElement(node) {
+ if (HEADINGS.has(node.tag)) {
+ return true;
+ }
+ // Also detect
+ const roleAttr = node.attributes?.find((a) => a.name === 'role');
+ if (roleAttr?.value?.type === 'GlimmerTextNode' && roleAttr.value.chars === 'heading') {
+ return true;
+ }
+ return false;
+}
+
+/** @type {import('eslint').Rule.RuleModule} */
+module.exports = {
+ meta: {
+ type: 'problem',
+ docs: {
+ description: 'disallow empty heading elements',
+ category: 'Accessibility',
+ strictGjs: true,
+ strictGts: true,
+ url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-empty-headings.md',
+ },
+ schema: [],
+ messages: {
+ emptyHeading:
+ 'Headings must contain accessible text content (or helper/component that provides text).',
+ },
+ },
+ create(context) {
+ return {
+ GlimmerElementNode(node) {
+ if (isHeadingElement(node)) {
+ // Skip if the heading itself is hidden
+ if (isHidden(node)) {
+ return;
+ }
+
+ if (!hasAccessibleContent(node)) {
+ context.report({ node, messageId: 'emptyHeading' });
+ }
+ }
+ },
+ };
+ },
+};
diff --git a/tests/lib/rules/template-no-empty-headings.js b/tests/lib/rules/template-no-empty-headings.js
new file mode 100644
index 0000000000..060215e4bd
--- /dev/null
+++ b/tests/lib/rules/template-no-empty-headings.js
@@ -0,0 +1,135 @@
+const rule = require('../../../lib/rules/template-no-empty-headings');
+const RuleTester = require('eslint').RuleTester;
+
+const ruleTester = new RuleTester({
+ parser: require.resolve('ember-eslint-parser'),
+ parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
+});
+
+ruleTester.run('template-no-empty-headings', rule, {
+ valid: [
+ '
Title
',
+ '
{{this.title}}
',
+ '
Text
',
+ '
',
+
+ // Test cases ported from ember-template-lint
+ '
Accessible Heading
',
+ '
Accessible Heading
',
+ '
Valid Heading
',
+ '
Valid Heading
',
+ '
Accessible Heading
',
+ '
Valid Heading
',
+ '
Valid Heading
',
+ '
Hidden textVisible text
',
+ '
Hidden textVisible text
',
+ '
Accessible Text
',
+ '
Accessible Text
',
+ '
Hidden textVisible text
',
+ '
Hidden textVisible text
',
+ '
',
+ '
',
+ '
',
+ '
',
+ '
',
+ '
{{@title}}
',
+ '
{{#component}}{{/component}}
',
+ '
{{@title}}
',
+ '
',
+ '
',
+ '
{{@title}}
',
+ '
Some text{{@title}}
',
+ '
{{@title}}
',
+ ],
+ invalid: [
+ {
+ code: '
',
+ output: null,
+ errors: [{ messageId: 'emptyHeading' }],
+ },
+ {
+ code: '
',
+ output: null,
+ errors: [{ messageId: 'emptyHeading' }],
+ },
+
+ // Test cases ported from ember-template-lint
+ {
+ code: `
+
`,
+ output: null,
+ errors: [{ messageId: 'emptyHeading' }],
+ },
+ {
+ code: '
',
+ output: null,
+ errors: [{ messageId: 'emptyHeading' }],
+ },
+ {
+ code: `
+
`,
+ output: null,
+ errors: [{ messageId: 'emptyHeading' }],
+ },
+ {
+ code: '
',
+ output: null,
+ errors: [{ messageId: 'emptyHeading' }],
+ },
+ {
+ code: '
',
+ output: null,
+ errors: [{ messageId: 'emptyHeading' }],
+ },
+ {
+ code: '
Some hidden text
',
+ output: null,
+ errors: [{ messageId: 'emptyHeading' }],
+ },
+ {
+ code: '
Inaccessible text
',
+ output: null,
+ errors: [{ messageId: 'emptyHeading' }],
+ },
+ {
+ code: '
Inaccessible text
',
+ output: null,
+ errors: [{ messageId: 'emptyHeading' }],
+ },
+ {
+ code: '
{{@title}}
',
+ output: null,
+ errors: [{ messageId: 'emptyHeading' }],
+ },
+ {
+ code: '
{{#component}}Inaccessible text{{/component}}
',
+ output: null,
+ errors: [{ messageId: 'emptyHeading' }],
+ },
+ {
+ code: '
Inaccessible text
',
+ output: null,
+ errors: [{ messageId: 'emptyHeading' }],
+ },
+ {
+ code: '
Hidden textHidden text
',
+ output: null,
+ errors: [{ messageId: 'emptyHeading' }],
+ },
+ {
+ code: '
',
+ output: null,
+ errors: [{ messageId: 'emptyHeading' }],
+ },
+ {
+ code: '
Inaccessible text
',
+ output: null,
+ errors: [{ messageId: 'emptyHeading' }],
+ },
+ {
+ code: '
Inaccessible text
',
+ output: null,
+ errors: [{ messageId: 'emptyHeading' }],
+ },
+ ],
+});
From ef9d1f33c60ad1ee9e48685975d0a9920f3f2328 Mon Sep 17 00:00:00 2001
From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com>
Date: Wed, 25 Feb 2026 18:47:23 -0500
Subject: [PATCH 2/2] Remove unneeded comment
---
tests/lib/rules/template-no-empty-headings.js | 8 ++------
1 file changed, 2 insertions(+), 6 deletions(-)
diff --git a/tests/lib/rules/template-no-empty-headings.js b/tests/lib/rules/template-no-empty-headings.js
index 060215e4bd..4d1a911fb4 100644
--- a/tests/lib/rules/template-no-empty-headings.js
+++ b/tests/lib/rules/template-no-empty-headings.js
@@ -12,8 +12,6 @@ ruleTester.run('template-no-empty-headings', rule, {
'
{{this.title}}
',
'
Text
',
'
',
-
- // Test cases ported from ember-template-lint
'
Accessible Heading
',
'
Accessible Heading
',
'
Valid Heading
',
@@ -52,10 +50,8 @@ ruleTester.run('template-no-empty-headings', rule, {
output: null,
errors: [{ messageId: 'emptyHeading' }],
},
-
- // Test cases ported from ember-template-lint
{
- code: `
+ code: `
`,
output: null,
errors: [{ messageId: 'emptyHeading' }],
@@ -66,7 +62,7 @@ ruleTester.run('template-no-empty-headings', rule, {
errors: [{ messageId: 'emptyHeading' }],
},
{
- code: `
+ code: `
`,
output: null,
errors: [{ messageId: 'emptyHeading' }],