Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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 — 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.
Comment thread
johanrd marked this conversation as resolved.
Outdated

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)
48 changes: 43 additions & 5 deletions lib/rules/template-no-empty-headings.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,55 @@
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.
Comment thread
johanrd marked this conversation as resolved.
Outdated
//
// 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';
}
Comment thread
johanrd marked this conversation as resolved.
Outdated
if (value.type === 'GlimmerMustacheStatement' && value.path) {
if (value.path.type === 'GlimmerBooleanLiteral') {
return value.path.value === true;
}
if (value.path.type === 'GlimmerStringLiteral') {
Comment thread
johanrd marked this conversation as resolved.
Outdated
return value.path.value.toLowerCase() === 'true';
}
Comment thread
johanrd marked this conversation as resolved.
Outdated
}
Comment thread
johanrd marked this conversation as resolved.
Outdated
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 — exempt from empty-heading check (fewer-false-
// positives policy; see PR body for the four ecosystem positions).
Comment thread
johanrd marked this conversation as resolved.
Outdated
'<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>',
Comment thread
johanrd marked this conversation as resolved.
'<template><h1 aria-hidden={{"TRUE"}}></h1></template>',
'<template><h1 aria-hidden={{"True"}}></h1></template>',
Comment thread
johanrd marked this conversation as resolved.
Outdated
],
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