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 01/11] 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: [ { From 4544f39e7e869fa998592c56fde8723b57086018 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 21 Apr 2026 18:04:34 +0200 Subject: [PATCH 02/11] fix(template-no-empty-headings): treat aria-hidden values case-insensitively HTML attribute value comparison is ASCII case-insensitive per spec, so `aria-hidden="TRUE"` and `aria-hidden="True"` (and their mustache-string equivalents) should be recognised as truthy. Mirrors the same case- handling choice made in #2718 for `kind="captions"`. Tests cover `"TRUE"`, `"True"`, `{{"TRUE"}}`, `{{"True"}}`. --- lib/rules/template-no-empty-headings.js | 6 ++++-- tests/lib/rules/template-no-empty-headings.js | 7 +++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/lib/rules/template-no-empty-headings.js b/lib/rules/template-no-empty-headings.js index ea4cb2790f..1ce7175ed8 100644 --- a/lib/rules/template-no-empty-headings.js +++ b/lib/rules/template-no-empty-headings.js @@ -10,15 +10,17 @@ function isAriaHiddenTruthy(attr) { if (!value || (value.type === 'GlimmerTextNode' && value.chars === '')) { return true; } + // HTML attribute values compare ASCII-case-insensitively, so "TRUE"/"True" + // count as truthy. if (value.type === 'GlimmerTextNode') { - return value.chars === 'true'; + return value.chars.toLowerCase() === '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 value.path.value.toLowerCase() === 'true'; } } return false; diff --git a/tests/lib/rules/template-no-empty-headings.js b/tests/lib/rules/template-no-empty-headings.js index 375d5ee84d..a74e6adc3d 100644 --- a/tests/lib/rules/template-no-empty-headings.js +++ b/tests/lib/rules/template-no-empty-headings.js @@ -51,6 +51,13 @@ ruleTester.run('template-no-empty-headings', rule, { '', '', '', + + // HTML attribute values are ASCII case-insensitive — "TRUE" / "True" / + // {{"TRUE"}} / {{"True"}} all count as truthy. + '', + '', + '', + '', ], invalid: [ { From 872190f8a8a0d4a5a8959deffdb47cf8ed9fc872 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 21 Apr 2026 18:19:11 +0200 Subject: [PATCH 03/11] test(template-no-empty-headings): pin falsy mustache aria-hidden cases Adds invalid tests for `aria-hidden={{false}}` and `aria-hidden={{"false"}}` to lock down that falsy mustache values do not exempt an otherwise-empty heading. --- tests/lib/rules/template-no-empty-headings.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/lib/rules/template-no-empty-headings.js b/tests/lib/rules/template-no-empty-headings.js index a74e6adc3d..d901792f7c 100644 --- a/tests/lib/rules/template-no-empty-headings.js +++ b/tests/lib/rules/template-no-empty-headings.js @@ -147,5 +147,17 @@ ruleTester.run('template-no-empty-headings', rule, { output: null, errors: [{ messageId: 'emptyHeading' }], }, + + // Falsy mustache aria-hidden values do not exempt the heading. + { + code: '', + output: null, + errors: [{ messageId: 'emptyHeading' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'emptyHeading' }], + }, ], }); From e713bdd55ee2779e30f5362030895f2ad3b42188 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 21 Apr 2026 18:32:34 +0200 Subject: [PATCH 04/11] fix(template-no-empty-headings): align aria-hidden handling with WAI-ARIA spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per WAI-ARIA 1.2 §6.6, `aria-hidden` has value type true/false/undefined with default `undefined`. Per §8.5, missing or empty-string attribute values resolve to the default. So a valueless `aria-hidden` is NOT hidden per spec — only an explicit `"true"` (ASCII case-insensitive per HTML enumerated-attribute rules) hides the element. The earlier direction of this PR borrowed the HTML boolean-attribute intuition (presence = truthy) from jsx-a11y. That's a peer-plugin convention, not a spec mandate — aria-hidden is an enumerated ARIA attribute, not a boolean HTML one. vue-a11y's heading-has-content doesn't exempt aria-hidden headings at all; lit-a11y has the inverse rule. Behaviour now: - Exempt (hidden): `aria-hidden="true"` / "TRUE" / "True", `{{true}}`, `{{"true"}}` / case-variants. - Flag (NOT hidden per spec): valueless `

`, empty `

`, `{{false}}`, `{{"false"}}`, `"false"`. --- lib/rules/template-no-empty-headings.js | 16 ++++++-------- tests/lib/rules/template-no-empty-headings.js | 22 +++++++++++-------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/lib/rules/template-no-empty-headings.js b/lib/rules/template-no-empty-headings.js index 1ce7175ed8..8eeae79de3 100644 --- a/lib/rules/template-no-empty-headings.js +++ b/lib/rules/template-no-empty-headings.js @@ -1,17 +1,15 @@ const HEADINGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']); +// Per WAI-ARIA 1.2 §6.6 and §8.5 (https://www.w3.org/TR/wai-aria-1.2/#aria-hidden), +// aria-hidden has value type true/false/undefined with DEFAULT `undefined`, and +// missing / empty-string values resolve to that default. So a valueless +// `aria-hidden` is NOT hidden per spec — only an explicit `"true"` (ASCII +// case-insensitive, per the enumerated-attribute rules) hides the element. function isAriaHiddenTruthy(attr) { - if (!attr) { + const value = attr?.value; + if (!value) { 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; - } - // HTML attribute values compare ASCII-case-insensitively, so "TRUE"/"True" - // count as truthy. if (value.type === 'GlimmerTextNode') { return value.chars.toLowerCase() === 'true'; } diff --git a/tests/lib/rules/template-no-empty-headings.js b/tests/lib/rules/template-no-empty-headings.js index d901792f7c..1b3eca3a48 100644 --- a/tests/lib/rules/template-no-empty-headings.js +++ b/tests/lib/rules/template-no-empty-headings.js @@ -44,16 +44,8 @@ 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). - '', - '', '', '', - - // HTML attribute values are ASCII case-insensitive — "TRUE" / "True" / - // {{"TRUE"}} / {{"True"}} all count as truthy. '', '', '', @@ -148,7 +140,19 @@ ruleTester.run('template-no-empty-headings', rule, { errors: [{ messageId: 'emptyHeading' }], }, - // Falsy mustache aria-hidden values do not exempt the heading. + // Valueless / empty aria-hidden resolves to the default `undefined` per + // WAI-ARIA §6.6 — the element is NOT hidden, so an otherwise-empty heading + // still flags. + { + code: '', + output: null, + errors: [{ messageId: 'emptyHeading' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'emptyHeading' }], + }, { code: '', output: null, From f1a7b3044f32aa5d251deded88ae92ab931e6770 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 21 Apr 2026 20:23:53 +0200 Subject: [PATCH 05/11] fix(template-no-empty-headings): lean toward fewer positives on valueless aria-hidden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The valueless / empty-string aria-hidden case is genuinely contested in the ecosystem — four positions exist (jsx-a11y / vue-a11y / axe-core / WAI-ARIA spec), and no single authoritative source is decisive. Rather than pick one interpretation and live with its false positives, this rule leans toward fewer-false-positives: any aria-hidden form that could plausibly mean "hide this" exempts the heading from the empty-content check. Truthy (exempt heading): - valueless `

` — undefined-default per spec, but authors who write bare aria-hidden plausibly intend to hide. - empty `

` — same. - `aria-hidden="true"` (ASCII case-insensitive) — unambiguous. - `aria-hidden={{true}}` / `{{"true"}}` (case-insensitive) — unambiguous. Falsy (still flag empty heading): - `aria-hidden="false"`, `{{false}}`, `{{"false"}}` — explicit opt-out. This reverses the previous spec-first direction on the valueless/empty case. Rationale: a linter that flags intentional decorative markup creates friction and loss of trust; a linter that misses some genuinely- empty headings is preferable when the signal is ambiguous. The explicit `aria-hidden="true"` cases, which ARE clearly hidden per spec, remain exempt. --- lib/rules/template-no-empty-headings.js | 34 ++++++++++++++----- tests/lib/rules/template-no-empty-headings.js | 16 ++++----- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/lib/rules/template-no-empty-headings.js b/lib/rules/template-no-empty-headings.js index 8eeae79de3..a69aa6b00c 100644 --- a/lib/rules/template-no-empty-headings.js +++ b/lib/rules/template-no-empty-headings.js @@ -1,17 +1,35 @@ const HEADINGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']); -// Per WAI-ARIA 1.2 §6.6 and §8.5 (https://www.w3.org/TR/wai-aria-1.2/#aria-hidden), -// aria-hidden has value type true/false/undefined with DEFAULT `undefined`, and -// missing / empty-string values resolve to that default. So a valueless -// `aria-hidden` is NOT hidden per spec — only an explicit `"true"` (ASCII -// case-insensitive, per the enumerated-attribute rules) hides the element. +// aria-hidden semantics for valueless / empty / "false" are genuinely +// contested — four ecosystem positions exist (jsx-a11y / vue-a11y / axe / +// WAI-ARIA spec), see PR body. This rule leans toward FEWER false positives: +// when the author has written `aria-hidden` in any form that could plausibly +// mean "hide this", we exempt the heading from the empty-content check. The +// downside (missing some genuinely-empty headings) is preferable to flagging +// correctly-authored headings the developer intentionally decorated. +// +// Truthy: +// - valueless attr (`

`) — default-undefined per spec, but +// authors who write bare `aria-hidden` plausibly intend hidden. +// - empty string `aria-hidden=""` — same. +// - `aria-hidden="true"` / "TRUE" / "True" (ASCII case-insensitive). +// - `aria-hidden={{true}}` mustache boolean literal. +// - `aria-hidden={{"true"}}` / case-variants as mustache string literal. +// Not truthy (falls through): +// - `aria-hidden="false"` / `{{false}}` / `{{"false"}}` — explicit opt-out. function isAriaHiddenTruthy(attr) { - const value = attr?.value; - if (!value) { + if (!attr) { return false; } + const value = attr.value; + // Valueless attribute — no `value` property at all. + if (!value) { + return true; + } if (value.type === 'GlimmerTextNode') { - return value.chars.toLowerCase() === 'true'; + const chars = value.chars.toLowerCase(); + // Empty string is exempted (lean toward fewer false positives). + return chars === '' || chars === 'true'; } if (value.type === 'GlimmerMustacheStatement' && value.path) { if (value.path.type === 'GlimmerBooleanLiteral') { diff --git a/tests/lib/rules/template-no-empty-headings.js b/tests/lib/rules/template-no-empty-headings.js index 1b3eca3a48..0f5ed14b7a 100644 --- a/tests/lib/rules/template-no-empty-headings.js +++ b/tests/lib/rules/template-no-empty-headings.js @@ -44,6 +44,10 @@ ruleTester.run('template-no-empty-headings', rule, { '', '', + // aria-hidden variants — exempt from empty-heading check (fewer-false- + // positives policy; see PR body for the four ecosystem positions). + '', + '', '', '', '', @@ -140,16 +144,10 @@ ruleTester.run('template-no-empty-headings', rule, { errors: [{ messageId: 'emptyHeading' }], }, - // Valueless / empty aria-hidden resolves to the default `undefined` per - // WAI-ARIA §6.6 — the element is NOT hidden, so an otherwise-empty heading - // still flags. + // Explicit falsy aria-hidden does NOT exempt the empty-heading check — + // this is the unambiguous opt-out, no ecosystem position disagrees. { - code: '', - output: null, - errors: [{ messageId: 'emptyHeading' }], - }, - { - code: '', + code: '', output: null, errors: [{ messageId: 'emptyHeading' }], }, From 16ca45097ab8c4d60ce42ece2415c38b20de7742 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Wed, 22 Apr 2026 01:20:44 +0200 Subject: [PATCH 06/11] docs: document deliberate spec deviation on valueless aria-hidden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the explanation of valueless / empty-string aria-hidden handling from the PR body into the published rule docs. The rule deviates from WAI-ARIA 1.2 §aria-hidden (which resolves valueless aria-hidden to the default 'undefined', not 'true') in order to favor fewer false positives for this specific check. Also document the 'opposite-direction' split with template-no-aria-hidden-on-focusable / template-anchor-has-content (where spec-literal interpretation applies), and the unambiguous cases that always follow the spec. --- docs/rules/template-no-empty-headings.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/rules/template-no-empty-headings.md b/docs/rules/template-no-empty-headings.md index 96dbc2a50e..8954a0b63a 100644 --- a/docs/rules/template-no-empty-headings.md +++ b/docs/rules/template-no-empty-headings.md @@ -50,6 +50,21 @@ This rule **allows** the following: 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. +## Notes on `aria-hidden` semantics + +This rule treats valueless / empty-string `aria-hidden` (`

` or `

`) as exempting the heading from the empty-content check — those forms count as "hidden" for this rule. + +**This is a deliberate deviation from [WAI-ARIA 1.2 §`aria-hidden`](https://www.w3.org/TR/wai-aria-1.2/#aria-hidden)**, which resolves valueless / empty-string `aria-hidden` to the default value `undefined` — not `true` — and therefore does not hide the element per spec. The spec-literal reading would say "valueless `aria-hidden` doesn't hide, so the empty heading is still a violation." + +We lean toward fewer false positives here: if the author wrote `aria-hidden` at all, they signaled an intent to hide, and flagging the empty heading on top of what is already a malformed `aria-hidden` usage layers a second-order complaint on a first-order problem. Axe-core and the W3C ACT rules consistently treat this shape as INCOMPLETE (needs manual review) rather than a definitive failure, which is consistent with leaning away from a hard flag here. + +For rules that ask the _opposite_ question ("is this element authoritatively hidden?"), the spec-literal reading applies — see e.g. `template-no-aria-hidden-on-focusable` and `template-anchor-has-content`, which treat valueless `aria-hidden` as **not** hidden. This split is applied per-rule, picking the interpretation that produces the fewest false positives for each specific check. + +Unambiguous forms always follow the spec: + +- `aria-hidden="true"` / `aria-hidden={{true}}` / `aria-hidden={{"true"}}` (any case) → hidden, exempts the heading. +- `aria-hidden="false"` / `aria-hidden={{false}}` / `aria-hidden={{"false"}}` → not hidden, the empty-content check still applies. + ## References - [WCAG SC 2.4.6 Headings and Labels](https://www.w3.org/TR/UNDERSTANDING-WCAG20/navigation-mechanisms-descriptive.html) From a02b3e918d1d324233484dc408d98de53f1cca1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Wed, 22 Apr 2026 17:09:25 +0200 Subject: [PATCH 07/11] fix(template-no-empty-headings): recognize quoted-mustache aria-hidden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `isAriaHiddenTruthy` previously only handled raw TextNode and bare MustacheStatement attribute values. The quoted-mustache form `aria-hidden="{{true}}"` produces a `GlimmerConcatStatement` with a single mustache part — resolve that case by descending into the single static-literal part, mirroring the pattern established in template-no-aria-hidden-focusable. Leans toward "truthy" only on literal true / empty / bare-valueless to match the rule's doc-stated ethos of fewer false positives. --- lib/rules/template-no-empty-headings.js | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/lib/rules/template-no-empty-headings.js b/lib/rules/template-no-empty-headings.js index a69aa6b00c..2b6666519a 100644 --- a/lib/rules/template-no-empty-headings.js +++ b/lib/rules/template-no-empty-headings.js @@ -39,6 +39,31 @@ function isAriaHiddenTruthy(attr) { return value.path.value.toLowerCase() === 'true'; } } + if (value.type === 'GlimmerConcatStatement') { + // Quoted-mustache form like aria-hidden="{{true}}" or aria-hidden="{{x}}". + // Only resolve when the concat is a single static-literal part; any + // dynamic path makes the runtime value unknown. Lean toward "truthy" + // only on literal `true` / empty-literal / bare-valueless to stay aligned + // with the doc-stated ethos (fewer false positives — don't flag headings + // the author has intentionally decorated with aria-hidden). + const parts = value.parts || []; + if (parts.length === 1) { + const only = parts[0]; + if (only.type === 'GlimmerMustacheStatement' && only.path) { + if (only.path.type === 'GlimmerBooleanLiteral') { + return only.path.value === true; + } + if (only.path.type === 'GlimmerStringLiteral') { + return only.path.value.toLowerCase() === 'true'; + } + } + if (only.type === 'GlimmerTextNode') { + const chars = only.chars.toLowerCase(); + return chars === '' || chars === 'true'; + } + } + return false; + } return false; } From 343869db1c6aa7c3c6bfc00ea318ead16945cf87 Mon Sep 17 00:00:00 2001 From: johanrd Date: Wed, 22 Apr 2026 21:10:27 +0200 Subject: [PATCH 08/11] Update tests/lib/rules/template-no-empty-headings.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/lib/rules/template-no-empty-headings.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/lib/rules/template-no-empty-headings.js b/tests/lib/rules/template-no-empty-headings.js index 0f5ed14b7a..24e20cf93d 100644 --- a/tests/lib/rules/template-no-empty-headings.js +++ b/tests/lib/rules/template-no-empty-headings.js @@ -44,8 +44,8 @@ ruleTester.run('template-no-empty-headings', rule, { '', '', - // aria-hidden variants — exempt from empty-heading check (fewer-false- - // positives policy; see PR body for the four ecosystem positions). + // aria-hidden variants are exempt from the empty-heading check to avoid + // false positives when headings are intentionally hidden from assistive tech. '', '', '', From 28373ea8d0e73c77444b172915e298811f939da0 Mon Sep 17 00:00:00 2001 From: johanrd Date: Wed, 22 Apr 2026 21:10:36 +0200 Subject: [PATCH 09/11] Update docs/rules/template-no-empty-headings.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/rules/template-no-empty-headings.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rules/template-no-empty-headings.md b/docs/rules/template-no-empty-headings.md index 8954a0b63a..f9b7fd4706 100644 --- a/docs/rules/template-no-empty-headings.md +++ b/docs/rules/template-no-empty-headings.md @@ -58,7 +58,7 @@ This rule treats valueless / empty-string `aria-hidden` (`

` or ` We lean toward fewer false positives here: if the author wrote `aria-hidden` at all, they signaled an intent to hide, and flagging the empty heading on top of what is already a malformed `aria-hidden` usage layers a second-order complaint on a first-order problem. Axe-core and the W3C ACT rules consistently treat this shape as INCOMPLETE (needs manual review) rather than a definitive failure, which is consistent with leaning away from a hard flag here. -For rules that ask the _opposite_ question ("is this element authoritatively hidden?"), the spec-literal reading applies — see e.g. `template-no-aria-hidden-on-focusable` and `template-anchor-has-content`, which treat valueless `aria-hidden` as **not** hidden. This split is applied per-rule, picking the interpretation that produces the fewest false positives for each specific check. +For rules that ask the _opposite_ question ("is this element authoritatively hidden?"), the spec-literal reading applies, and valueless `aria-hidden` should be treated as **not** hidden. This split is applied per-rule, picking the interpretation that produces the fewest false positives for each specific check. Unambiguous forms always follow the spec: From fbf4c1b88ca0363def16d4cedea334843f6fe845 Mon Sep 17 00:00:00 2001 From: johanrd Date: Wed, 22 Apr 2026 21:11:27 +0200 Subject: [PATCH 10/11] Update lib/rules/template-no-empty-headings.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lib/rules/template-no-empty-headings.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/rules/template-no-empty-headings.js b/lib/rules/template-no-empty-headings.js index 2b6666519a..01084eb8fa 100644 --- a/lib/rules/template-no-empty-headings.js +++ b/lib/rules/template-no-empty-headings.js @@ -1,12 +1,13 @@ const HEADINGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']); // aria-hidden semantics for valueless / empty / "false" are genuinely -// contested — four ecosystem positions exist (jsx-a11y / vue-a11y / axe / -// WAI-ARIA spec), see PR body. This rule leans toward FEWER false positives: -// when the author has written `aria-hidden` in any form that could plausibly -// mean "hide this", we exempt the heading from the empty-content check. The -// downside (missing some genuinely-empty headings) is preferable to flagging -// correctly-authored headings the developer intentionally decorated. +// contested across common accessibility tooling and spec interpretations. +// For this rule, we prefer FEWER false positives: when the author has written +// `aria-hidden` in any form that could plausibly mean "hide this", we exempt +// the heading from the empty-content check. The downside (missing some +// genuinely-empty headings) is preferable to flagging correctly-authored +// headings the developer intentionally decorated. See +// docs/rules/template-no-empty-headings.md for the rule-level rationale. // // Truthy: // - valueless attr (`

`) — default-undefined per spec, but From 00a5c40f98280a28a843e3b543076da3e868443a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Thu, 23 Apr 2026 21:43:38 +0200 Subject: [PATCH 11/11] =?UTF-8?q?fix:=20normalize=20role/aria-hidden=20val?= =?UTF-8?q?ues=20=E2=80=94=20trim=20+=20lowercase=20(Copilot=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/rules/template-no-empty-headings.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/rules/template-no-empty-headings.js b/lib/rules/template-no-empty-headings.js index 01084eb8fa..dd6284b3bb 100644 --- a/lib/rules/template-no-empty-headings.js +++ b/lib/rules/template-no-empty-headings.js @@ -28,7 +28,10 @@ function isAriaHiddenTruthy(attr) { return true; } if (value.type === 'GlimmerTextNode') { - const chars = value.chars.toLowerCase(); + // Normalize like other aria-* value checks: trim incidental whitespace + // and compare case-insensitively. `aria-hidden=" true "` is semantically + // "true" per the trim step used elsewhere in this rule family. + const chars = value.chars.trim().toLowerCase(); // Empty string is exempted (lean toward fewer false positives). return chars === '' || chars === 'true'; }