diff --git a/docs/rules/template-no-empty-headings.md b/docs/rules/template-no-empty-headings.md
index 96dbc2a50e..69c07cd083 100644
--- a/docs/rules/template-no-empty-headings.md
+++ b/docs/rules/template-no-empty-headings.md
@@ -50,6 +50,14 @@ 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 follows [WAI-ARIA 1.2 §`aria-hidden`](https://www.w3.org/TR/wai-aria-1.2/#aria-hidden) verbatim: only an explicit truthy value hides the element. Ambiguous shapes — valueless `aria-hidden`, empty string, and mustache literals that resolve to an empty / whitespace-only string — all resolve to the default `undefined` and do NOT exempt the heading from the empty-content check.
+
+- `aria-hidden="true"` / `aria-hidden={{true}}` / `aria-hidden={{"true"}}` (any case, whitespace-trimmed) → hidden, exempts the heading.
+- `aria-hidden="false"` / `aria-hidden={{false}}` / `aria-hidden={{"false"}}` → not hidden, the empty-content check applies.
+- `
` / `aria-hidden=""` / `aria-hidden={{""}}` / `aria-hidden={{" "}}` → spec-default `undefined`, the empty-content check 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..456c7a67ee 100644
--- a/lib/rules/template-no-empty-headings.js
+++ b/lib/rules/template-no-empty-headings.js
@@ -1,5 +1,26 @@
+const { getStaticAttrValue } = require('../utils/static-attr-value');
+
const HEADINGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
+// Aligned with the WAI-ARIA 1.2 [`aria-hidden`](https://www.w3.org/TR/wai-aria-1.2/#aria-hidden)
+// value table (`true | false | undefined (default)`): treat only an explicit
+// "true" (ASCII case-insensitive, whitespace-trimmed) as hiding the element.
+// Valueless `
`, empty-string `aria-hidden=""`, and
+// `aria-hidden="false"` all resolve to the default `undefined` / explicit
+// false — so the empty-content check still applies. All shape-unwrapping
+// (mustache/concat) goes through the shared `getStaticAttrValue` helper.
+function isAriaHiddenTruthy(attr) {
+ if (!attr) {
+ return false;
+ }
+ const resolved = getStaticAttrValue(attr.value);
+ if (resolved === undefined) {
+ // Dynamic — can't prove truthy.
+ return false;
+ }
+ return resolved.trim().toLowerCase() === 'true';
+}
+
function isHidden(node) {
if (!node.attributes) {
return false;
@@ -7,11 +28,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/lib/utils/static-attr-value.js b/lib/utils/static-attr-value.js
new file mode 100644
index 0000000000..6499c782a9
--- /dev/null
+++ b/lib/utils/static-attr-value.js
@@ -0,0 +1,64 @@
+'use strict';
+
+/**
+ * Return the statically-known string value of a Glimmer attribute value node,
+ * or `undefined` when the value is dynamic (cannot be resolved at lint time).
+ *
+ * Unwraps:
+ * - GlimmerTextNode → chars
+ * - GlimmerMustacheStatement with a literal path (boolean/string/number) → stringified value
+ * - GlimmerConcatStatement whose parts are all statically resolvable → joined string
+ *
+ * A missing/undefined value (valueless attribute, e.g. ``)
+ * returns the empty string. Pass `attr.value` — not the attribute itself.
+ */
+function getStaticAttrValue(value) {
+ if (value === null || value === undefined) {
+ return '';
+ }
+ if (value.type === 'GlimmerTextNode') {
+ return value.chars;
+ }
+ if (value.type === 'GlimmerMustacheStatement') {
+ return extractLiteral(value.path);
+ }
+ if (value.type === 'GlimmerConcatStatement') {
+ const parts = value.parts || [];
+ let out = '';
+ for (const part of parts) {
+ if (part.type === 'GlimmerTextNode') {
+ out += part.chars;
+ continue;
+ }
+ if (part.type === 'GlimmerMustacheStatement') {
+ const literal = extractLiteral(part.path);
+ if (literal === undefined) {
+ return undefined;
+ }
+ out += literal;
+ continue;
+ }
+ return undefined;
+ }
+ return out;
+ }
+ return undefined;
+}
+
+function extractLiteral(path) {
+ if (!path) {
+ return undefined;
+ }
+ if (path.type === 'GlimmerBooleanLiteral') {
+ return path.value ? 'true' : 'false';
+ }
+ if (path.type === 'GlimmerStringLiteral') {
+ return path.value;
+ }
+ if (path.type === 'GlimmerNumberLiteral') {
+ return String(path.value);
+ }
+ return undefined;
+}
+
+module.exports = { getStaticAttrValue };
diff --git a/tests/lib/rules/template-no-empty-headings.js b/tests/lib/rules/template-no-empty-headings.js
index cce6b806da..bc708a9093 100644
--- a/tests/lib/rules/template-no-empty-headings.js
+++ b/tests/lib/rules/template-no-empty-headings.js
@@ -43,6 +43,24 @@ ruleTester.run('template-no-empty-headings', rule, {
'
',
'
<@heading />
',
'
',
+
+ // Explicit "true" exempts the empty-heading check — author has
+ // signalled the heading is intentionally hidden from assistive tech.
+ '',
+ '