From 252982891b4629c58e4e2f9383e9151805d5a53b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 21 Apr 2026 07:27:08 +0200 Subject: [PATCH] fix(template-no-empty-headings): recognize boolean aria-hidden as hidden Before: isHidden only matched aria-hidden="true" as a string literal. Boolean / valueless / empty / mustache forms (

,

,

) slipped past as "not hidden", so empty headings in those forms were flagged as empty even when the author had intentionally hidden them from AT. Fix: extract isAriaHiddenTruthy(). Recognize: - valueless attribute (HBS AST has value=null or empty-string TextNode) - "true" string literal (preserved) - "" empty string - {{true}} boolean mustache literal - {{"true"}} string mustache literal Per HTML boolean-attribute semantics (and jsx-a11y/vue-a11y convention), presence of aria-hidden without an explicit "false" value is treated as truthy. The strict ARIA spec treats bare aria-hidden as "undefined" rather than "true", but every major linter in the ecosystem (and most screen readers) treats it as true. Four new test cases covering each of the recognized forms. --- lib/rules/template-no-empty-headings.js | 30 +++++++++++++++---- tests/lib/rules/template-no-empty-headings.js | 8 +++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/lib/rules/template-no-empty-headings.js b/lib/rules/template-no-empty-headings.js index edb71caae6..ea4cb2790f 100644 --- a/lib/rules/template-no-empty-headings.js +++ b/lib/rules/template-no-empty-headings.js @@ -1,5 +1,29 @@ const HEADINGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']); +function isAriaHiddenTruthy(attr) { + if (!attr) { + return false; + } + const value = attr.value; + // Valueless or empty-string attribute —

. Per HTML boolean + // attribute semantics (and jsx-a11y/vue-a11y convention), presence = truthy. + if (!value || (value.type === 'GlimmerTextNode' && value.chars === '')) { + return true; + } + if (value.type === 'GlimmerTextNode') { + return value.chars === 'true'; + } + if (value.type === 'GlimmerMustacheStatement' && value.path) { + if (value.path.type === 'GlimmerBooleanLiteral') { + return value.path.value === true; + } + if (value.path.type === 'GlimmerStringLiteral') { + return value.path.value === 'true'; + } + } + return false; +} + function isHidden(node) { if (!node.attributes) { return false; @@ -7,11 +31,7 @@ function isHidden(node) { 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; + return isAriaHiddenTruthy(node.attributes.find((a) => a.name === 'aria-hidden')); } function isComponent(node) { diff --git a/tests/lib/rules/template-no-empty-headings.js b/tests/lib/rules/template-no-empty-headings.js index cce6b806da..375d5ee84d 100644 --- a/tests/lib/rules/template-no-empty-headings.js +++ b/tests/lib/rules/template-no-empty-headings.js @@ -43,6 +43,14 @@ ruleTester.run('template-no-empty-headings', rule, { '', '', '', + + // aria-hidden as a boolean / valueless / empty / mustache attribute — all + // should exempt the heading (aligns with jsx-a11y / vue-a11y treatment of + // boolean HTML attributes). + '', + '', + '', + '', ], invalid: [ {