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 + +``` + +This rule **allows** the following: + +```gjs + +``` + +```gjs + +``` + +```gjs + +``` + +```gjs + +``` + +```gjs + +``` + +## 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: [ + '', + '', + '', + '', + + // Test cases ported from ember-template-lint + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ], + 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: '', + 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: '', + 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' }], + }, + ], +}); 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, { '', '', '', - - // Test cases ported from ember-template-lint '', '', '', @@ -52,10 +50,8 @@ ruleTester.run('template-no-empty-headings', rule, { output: null, errors: [{ messageId: 'emptyHeading' }], }, - - // Test cases ported from ember-template-lint { - code: `