Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/rules/template-no-empty-headings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` (`<h1 aria-hidden>` or `<h1 aria-hidden="">`) 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)
74 changes: 69 additions & 5 deletions lib/rules/template-no-empty-headings.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,81 @@
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 (`<h1 aria-hidden>`) — 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') {
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') {
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;
}
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) {
Expand Down
29 changes: 29 additions & 0 deletions tests/lib/rules/template-no-empty-headings.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,17 @@ ruleTester.run('template-no-empty-headings', rule, {
'<template><h1><this.Heading /></h1></template>',
'<template><h2><@heading /></h2></template>',
'<template><h3><ns.Heading /></h3></template>',

// aria-hidden variants are exempt from the empty-heading check to avoid
// false positives when headings are intentionally hidden from assistive tech.
'<template><h1 aria-hidden></h1></template>',
'<template><h1 aria-hidden=""></h1></template>',
'<template><h1 aria-hidden={{true}}></h1></template>',
'<template><h1 aria-hidden="true">Visible to sighted only</h1></template>',
'<template><h1 aria-hidden="TRUE"></h1></template>',
'<template><h1 aria-hidden="True"></h1></template>',
'<template><h1 aria-hidden={{"TRUE"}}></h1></template>',
'<template><h1 aria-hidden={{"True"}}></h1></template>',
],
invalid: [
{
Expand Down Expand Up @@ -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: '<template><h1 aria-hidden="false"></h1></template>',
output: null,
errors: [{ messageId: 'emptyHeading' }],
},
{
code: '<template><h1 aria-hidden={{false}}></h1></template>',
output: null,
errors: [{ messageId: 'emptyHeading' }],
},
{
code: '<template><h1 aria-hidden={{"false"}}></h1></template>',
output: null,
errors: [{ messageId: 'emptyHeading' }],
},
],
});
Loading