diff --git a/docs/rules/template-no-empty-headings.md b/docs/rules/template-no-empty-headings.md
index 96dbc2a50e..f9b7fd4706 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, 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:
+
+- `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)
diff --git a/lib/rules/template-no-empty-headings.js b/lib/rules/template-no-empty-headings.js
index edb71caae6..dd6284b3bb 100644
--- a/lib/rules/template-no-empty-headings.js
+++ b/lib/rules/template-no-empty-headings.js
@@ -1,5 +1,76 @@
const HEADINGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
+// aria-hidden semantics for valueless / empty / "false" are genuinely
+// 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
+// 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) {
+ if (!attr) {
+ return false;
+ }
+ const value = attr.value;
+ // Valueless attribute — no `value` property at all.
+ if (!value) {
+ return true;
+ }
+ if (value.type === 'GlimmerTextNode') {
+ // 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';
+ }
+ 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.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;
+}
+
function isHidden(node) {
if (!node.attributes) {
return false;
@@ -7,11 +78,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..24e20cf93d 100644
--- a/tests/lib/rules/template-no-empty-headings.js
+++ b/tests/lib/rules/template-no-empty-headings.js
@@ -43,6 +43,17 @@ ruleTester.run('template-no-empty-headings', rule, {
'
',
'<@heading />
',
'
',
+
+ // aria-hidden variants are exempt from the empty-heading check to avoid
+ // false positives when headings are intentionally hidden from assistive tech.
+ '',
+ '',
+ '',
+ 'Visible to sighted only
',
+ '',
+ '',
+ '',
+ '',
],
invalid: [
{
@@ -132,5 +143,23 @@ ruleTester.run('template-no-empty-headings', rule, {
output: null,
errors: [{ messageId: 'emptyHeading' }],
},
+
+ // 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: '',
+ output: null,
+ errors: [{ messageId: 'emptyHeading' }],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [{ messageId: 'emptyHeading' }],
+ },
],
});