Skip to content

Commit f1a7b30

Browse files
committed
fix(template-no-empty-headings): lean toward fewer positives on valueless aria-hidden
The valueless / empty-string aria-hidden case is genuinely contested in the ecosystem — four positions exist (jsx-a11y / vue-a11y / axe-core / WAI-ARIA spec), and no single authoritative source is decisive. Rather than pick one interpretation and live with its false positives, this rule leans toward fewer-false-positives: any aria-hidden form that could plausibly mean "hide this" exempts the heading from the empty-content check. Truthy (exempt heading): - valueless `<h1 aria-hidden>` — undefined-default per spec, but authors who write bare aria-hidden plausibly intend to hide. - empty `<h1 aria-hidden="">` — same. - `aria-hidden="true"` (ASCII case-insensitive) — unambiguous. - `aria-hidden={{true}}` / `{{"true"}}` (case-insensitive) — unambiguous. Falsy (still flag empty heading): - `aria-hidden="false"`, `{{false}}`, `{{"false"}}` — explicit opt-out. This reverses the previous spec-first direction on the valueless/empty case. Rationale: a linter that flags intentional decorative markup creates friction and loss of trust; a linter that misses some genuinely- empty headings is preferable when the signal is ambiguous. The explicit `aria-hidden="true"` cases, which ARE clearly hidden per spec, remain exempt.
1 parent e713bdd commit f1a7b30

2 files changed

Lines changed: 33 additions & 17 deletions

File tree

lib/rules/template-no-empty-headings.js

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,35 @@
11
const HEADINGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
22

3-
// Per WAI-ARIA 1.2 §6.6 and §8.5 (https://www.w3.org/TR/wai-aria-1.2/#aria-hidden),
4-
// aria-hidden has value type true/false/undefined with DEFAULT `undefined`, and
5-
// missing / empty-string values resolve to that default. So a valueless
6-
// `aria-hidden` is NOT hidden per spec — only an explicit `"true"` (ASCII
7-
// case-insensitive, per the enumerated-attribute rules) hides the element.
3+
// aria-hidden semantics for valueless / empty / "false" are genuinely
4+
// contested — four ecosystem positions exist (jsx-a11y / vue-a11y / axe /
5+
// WAI-ARIA spec), see PR body. This rule leans toward FEWER false positives:
6+
// when the author has written `aria-hidden` in any form that could plausibly
7+
// mean "hide this", we exempt the heading from the empty-content check. The
8+
// downside (missing some genuinely-empty headings) is preferable to flagging
9+
// correctly-authored headings the developer intentionally decorated.
10+
//
11+
// Truthy:
12+
// - valueless attr (`<h1 aria-hidden>`) — default-undefined per spec, but
13+
// authors who write bare `aria-hidden` plausibly intend hidden.
14+
// - empty string `aria-hidden=""` — same.
15+
// - `aria-hidden="true"` / "TRUE" / "True" (ASCII case-insensitive).
16+
// - `aria-hidden={{true}}` mustache boolean literal.
17+
// - `aria-hidden={{"true"}}` / case-variants as mustache string literal.
18+
// Not truthy (falls through):
19+
// - `aria-hidden="false"` / `{{false}}` / `{{"false"}}` — explicit opt-out.
820
function isAriaHiddenTruthy(attr) {
9-
const value = attr?.value;
10-
if (!value) {
21+
if (!attr) {
1122
return false;
1223
}
24+
const value = attr.value;
25+
// Valueless attribute — no `value` property at all.
26+
if (!value) {
27+
return true;
28+
}
1329
if (value.type === 'GlimmerTextNode') {
14-
return value.chars.toLowerCase() === 'true';
30+
const chars = value.chars.toLowerCase();
31+
// Empty string is exempted (lean toward fewer false positives).
32+
return chars === '' || chars === 'true';
1533
}
1634
if (value.type === 'GlimmerMustacheStatement' && value.path) {
1735
if (value.path.type === 'GlimmerBooleanLiteral') {

tests/lib/rules/template-no-empty-headings.js

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ ruleTester.run('template-no-empty-headings', rule, {
4444
'<template><h2><@heading /></h2></template>',
4545
'<template><h3><ns.Heading /></h3></template>',
4646

47+
// aria-hidden variants — exempt from empty-heading check (fewer-false-
48+
// positives policy; see PR body for the four ecosystem positions).
49+
'<template><h1 aria-hidden></h1></template>',
50+
'<template><h1 aria-hidden=""></h1></template>',
4751
'<template><h1 aria-hidden={{true}}></h1></template>',
4852
'<template><h1 aria-hidden="true">Visible to sighted only</h1></template>',
4953
'<template><h1 aria-hidden="TRUE"></h1></template>',
@@ -140,16 +144,10 @@ ruleTester.run('template-no-empty-headings', rule, {
140144
errors: [{ messageId: 'emptyHeading' }],
141145
},
142146

143-
// Valueless / empty aria-hidden resolves to the default `undefined` per
144-
// WAI-ARIA §6.6 — the element is NOT hidden, so an otherwise-empty heading
145-
// still flags.
147+
// Explicit falsy aria-hidden does NOT exempt the empty-heading check —
148+
// this is the unambiguous opt-out, no ecosystem position disagrees.
146149
{
147-
code: '<template><h1 aria-hidden></h1></template>',
148-
output: null,
149-
errors: [{ messageId: 'emptyHeading' }],
150-
},
151-
{
152-
code: '<template><h1 aria-hidden=""></h1></template>',
150+
code: '<template><h1 aria-hidden="false"></h1></template>',
153151
output: null,
154152
errors: [{ messageId: 'emptyHeading' }],
155153
},

0 commit comments

Comments
 (0)