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, { '', '', '', + + // aria-hidden variants are exempt from the empty-heading check to avoid + // false positives when headings are intentionally hidden from assistive tech. + '', + '', + '', + '', + '', + '', + '', + '', ], 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' }], + }, ], });