Skip to content

BUGFIX: template-no-empty-headings — recognize boolean aria-hidden#2717

Merged
NullVoxPopuli merged 14 commits into
ember-cli:masterfrom
johanrd:fix/heading-accept-boolean-aria-hidden
Apr 25, 2026
Merged

BUGFIX: template-no-empty-headings — recognize boolean aria-hidden#2717
NullVoxPopuli merged 14 commits into
ember-cli:masterfrom
johanrd:fix/heading-accept-boolean-aria-hidden

Conversation

@johanrd
Copy link
Copy Markdown
Contributor

@johanrd johanrd commented Apr 21, 2026

Note

This is part of a series where Claude has audited eslint-plugin-ember against jsx-a11y, vuejs-accessibility, angular-eslint, lit-a11y and html-validate, ember-template-lint, and the HTML and WCAG specs.

This PR is part of a Phase 3 a11y-parity audit against jsx-a11y / vue-a11y / angular-eslint-template / lit-a11y.

  • Premise: A heading that's marked as decorative via aria-hidden is intentionally invisible to assistive technology — requiring text content in that heading is a false positive.
  • Problem: Our isHidden check only matched aria-hidden="true" as a case-sensitive string literal, so <h1 aria-hidden>, <h1 aria-hidden="">, <h1 aria-hidden="TRUE">, <h1 aria-hidden={{true}}>, etc. were treated as visible and flagged for missing content.

Fix: extract isAriaHiddenTruthy() that recognizes every plausible "hide-this" form (valueless, empty-string, "true" case-insensitive, mustache boolean/string-literal true/case-variants).

Prior art

Plugin Rule Verified behavior
jsx-a11y heading-has-content Flags empty <h1><h6>; skips hidden headings via isHiddenFromAT (jsx-a11y's aria-hidden interpretation — see Contested-semantics table below).
vuejs-accessibility heading-has-content Flags empty <h1><h6>; uses isHiddenFromScreenReader (vue's (value || "").toString() !== "false" — see Contested-semantics table below).
@angular-eslint/template elements-content Covers <h1><h6> together with <a> / <button> via a single regex matcher; skips hidden via isHiddenFromScreenReader. Not a dedicated heading rule.
lit-a11y no direct equivalent lit-a11y ships heading-hidden (headings must not be hidden) but no "heading has content" rule. Inverse concern.

Four ecosystem positions on valueless aria-hidden

The question "what does <el aria-hidden> (bare), aria-hidden="" (empty), or aria-hidden={{false}} mean?" has no single authoritative answer. Four defensible positions exist:

# Source Interpretation Evidence
1 jsx-a11y Valueless → hidden Side effect of jsx-ast-utils coercing valueless JSX attrs to boolean true, combined with rule check ariaHidden === true. Quirk: string aria-hidden="true" is NOT recognized because "true" !== true. Not a deliberate ARIA interpretation.
2 vue-a11y Anything not literal "false" → hidden isHiddenFromScreenReader.ts: (value || "").toString() !== "false". Catches valueless, empty, "TRUE", "anything". Non-spec shortcut.
3 axe-core / W3C ACT Rules Valueless/empty → INCOMPLETE, needs author review axe-core PR #3635: empty ARIA values reported as incomplete, "There is no real difference between an empty ARIA attribute, a null attribute, and not having the attribute at all." W3C ACT Rule 6a7281 explicitly scopes out empty values as inapplicable.
4 WAI-ARIA 1.2 spec Valueless/empty → default undefined → not hidden The aria-hidden value table lists exactly true / false / undefined (default). A missing attribute resolves to that default; an empty-string value isn't enumerated, so it isn't interpreted as true. aria-hidden is also not in HTML's boolean attributes list, so the "presence-implies-true" rule that applies to e.g. disabled does not extend here. WAI-ARIA 1.2 §9.2 further specifies that for boolean-like platform API states, values of "" or no attribute present SHOULD both be treated as false — consistent with the undefined-default interpretation. (aria-hidden has no per-attribute empty-string text, so the empty-string case remains inference backed by §9.2's SHOULD.)

Browser testing shows disagreement even on the explicit aria-hidden="true" case (see Steve Faulkner's post and Mozilla bug 948540); no documented browser testing on valueless specifically — most likely a no-op matching the spec's undefined-default.

Design choice for this rule

We lean toward fewer false positives. A linter that flags a heading the author intentionally marked decorative (via bare aria-hidden) creates friction and loss of trust; a linter that silently accepts some genuinely-empty unhidden headings is the smaller cost when the signal is ambiguous. So any aria-hidden form that could plausibly mean "hide this" exempts the heading from the empty-content check.

Exempt (don't flag empty heading):

  • <h1 aria-hidden>, <h1 aria-hidden="">, <h1 aria-hidden="true">, "TRUE", "True"
  • <h1 aria-hidden={{true}}>, {{"true"}}, {{"TRUE"}}, {{"True"}}

Still flag (explicit opt-in to the content check):

  • <h1 aria-hidden="false">, {{false}}, {{"false"}}

Tests

Valid (heading exempted):

  • Valueless, empty, "true" / "TRUE" / "True", {{true}}, {{"true"}} / case-variants

Invalid (falsy explicitly → flagged):

  • <h1 aria-hidden="false"></h1>, {{false}}, {{"false"}}

Before: isHidden only matched aria-hidden="true" as a string literal.
Boolean / valueless / empty / mustache forms (<h1 aria-hidden />,
<h1 aria-hidden="" />, <h1 aria-hidden={{true}} />) slipped past as
"not hidden", so empty headings in those forms were flagged as empty
even when the author had intentionally hidden them from AT.

Fix: extract isAriaHiddenTruthy(). Recognize:
- valueless attribute (HBS AST has value=null or empty-string TextNode)
- "true" string literal (preserved)
- "" empty string
- {{true}} boolean mustache literal
- {{"true"}} string mustache literal

Per HTML boolean-attribute semantics (and jsx-a11y/vue-a11y convention),
presence of aria-hidden without an explicit "false" value is treated as
truthy. The strict ARIA spec treats bare aria-hidden as "undefined"
rather than "true", but every major linter in the ecosystem (and most
screen readers) treats it as true.

Four new test cases covering each of the recognized forms.
johanrd added 3 commits April 21, 2026 18:04
…itively

HTML attribute value comparison is ASCII case-insensitive per spec, so
`aria-hidden="TRUE"` and `aria-hidden="True"` (and their mustache-string
equivalents) should be recognised as truthy. Mirrors the same case-
handling choice made in ember-cli#2718 for `kind="captions"`.

Tests cover `"TRUE"`, `"True"`, `{{"TRUE"}}`, `{{"True"}}`.
Adds invalid tests for `aria-hidden={{false}}` and `aria-hidden={{"false"}}`
to lock down that falsy mustache values do not exempt an otherwise-empty
heading.
…ARIA spec

Per WAI-ARIA 1.2 §6.6, `aria-hidden` has value type true/false/undefined
with default `undefined`. Per §8.5, missing or empty-string attribute
values resolve to the default. So a valueless `aria-hidden` is NOT
hidden per spec — only an explicit `"true"` (ASCII case-insensitive per
HTML enumerated-attribute rules) hides the element.

The earlier direction of this PR borrowed the HTML boolean-attribute
intuition (presence = truthy) from jsx-a11y. That's a peer-plugin
convention, not a spec mandate — aria-hidden is an enumerated ARIA
attribute, not a boolean HTML one. vue-a11y's heading-has-content
doesn't exempt aria-hidden headings at all; lit-a11y has the inverse
rule.

Behaviour now:
- Exempt (hidden): `aria-hidden="true"` / "TRUE" / "True", `{{true}}`,
  `{{"true"}}` / case-variants.
- Flag (NOT hidden per spec): valueless `<h1 aria-hidden>`, empty
  `<h1 aria-hidden="">`, `{{false}}`, `{{"false"}}`, `"false"`.
johanrd added a commit to johanrd/eslint-plugin-ember that referenced this pull request Apr 21, 2026
…idden default

Per WAI-ARIA 1.2 §6.6 + aria-hidden value table, a missing or empty-string
aria-hidden resolves to default `undefined` — NOT `true`. Valueless
`<button aria-hidden>` and empty `<button aria-hidden="">` are therefore
NOT spec-hidden; they do not create a focus-trap anti-pattern and the
rule should not flag them.

The prior behavior inherited jsx-a11y's convention (jsx-ast-utils
coerces valueless JSX attrs to boolean true) and vue-a11y's
"anything-not-literal-false" shortcut. Both are peer-plugin conventions,
not normative ARIA interpretations. Matching ember-cli#2717's spec-first
resolution.

Also corrects the rule-doc comment: the claim attributed to WAI-ARIA 1.2
("Authors SHOULD NOT use aria-hidden='true' on any element that has
focus or may receive focus") is not in the WAI-ARIA spec. The spec only
says authors MAY "with caution" use aria-hidden. The rule's concern
(keyboard trap) comes from community/axe guidance, which this comment
now accurately attributes.

Net: flagged values are now `aria-hidden="true"` (ASCII case-insensitive),
`aria-hidden={{true}}`, and `aria-hidden={{"true"}}`. Valueless, empty,
`false`, and `{{false}}` are all accepted.
johanrd added a commit to johanrd/eslint-plugin-ember that referenced this pull request Apr 21, 2026
…ec + correct peer-plugin claims

Two corrections to the previous revision:

1. Valueless / empty-string `aria-hidden` is no longer treated as a
   non-interactive escape hatch. Per WAI-ARIA 1.2 §6.6 + aria-hidden
   value table, a missing or empty-string value resolves to the default
   `undefined` — NOT `true`. Only an explicit `aria-hidden="true"`
   (ASCII case-insensitive) or mustache-literal `{{true}}` opts out.
   This matches ember-cli#2717 / #19's spec-first resolution.

2. Code comment corrections. jsx-a11y's util is named
   `isPresentationRole`, not `hasPresentationRole`. The comment also
   claimed jsx-a11y's `isPresentationRole` does "first token of a
   space-separated role list" — it does not (jsx-a11y does plain
   `presentationRoles.has(rawValue)`, no trim/lowercase/split). Our
   first-token behavior is a deliberate superset, not parity.

Moved `<div aria-hidden onclick>` and `<div aria-hidden="" onclick>` from
the valid section to invalid. Added `<div aria-hidden="TRUE">` as
additional valid coverage for the case-insensitive path.
johanrd added a commit to johanrd/eslint-plugin-ember that referenced this pull request Apr 21, 2026
…ML boolean semantics

Per HTML Living Standard on boolean attributes, the presence of `autofocus`
indicates TRUE regardless of value — `autofocus="false"` and
`autofocus="autofocus"` are equally truthy. jsx-a11y's `no-autofocus`
treats the literal string `"false"` as an opt-out (via `getPropValue`),
but that's a peer-plugin convention that diverges from HTML semantics;
vue-a11y and lit-a11y are presence-based, consistent with the spec.

Narrow opt-out to the only case that is spec-consistent:
- `autofocus={{false}}` in angle-bracket syntax — renders no attribute.
- `{{input autofocus=false}}` in mustache hash-pair syntax — no attribute.

Revert peer-parity opt-outs for `autofocus="false"`, `autofocus={{"false"}}`,
and `{{input autofocus="false"}}` — these are now flagged per HTML spec
semantics. Moved from valid → invalid in the test suite.

Dialog exemption unchanged — keeps MDN-backed behavior for autofocus on
and within <dialog>.

Follows the spec-first direction established in ember-cli#2717 (aria-hidden),
#19, #33.
johanrd added a commit to johanrd/eslint-plugin-ember that referenced this pull request Apr 21, 2026
…I-ARIA spec

Per WAI-ARIA 1.2 §6.6 + aria-hidden value table, a missing or empty-string
aria-hidden resolves to the default `undefined` — NOT `true`. So
<span aria-hidden>X</span> as a child of <a href="/x"> does NOT hide the
span; its content still contributes to the anchor's accessible name.

The prior behavior inherited jsx-a11y's JSX-coercion convention and
vue-a11y's "anything-not-literal-false" shortcut. Both are peer-plugin
conventions that diverge from normative ARIA. Matches the spec-first
resolution of ember-cli#2717, #19, and #33.

Moved valueless / empty aria-hidden cases from invalid → valid. Kept the
explicit aria-hidden="true" and {{true}} cases as invalid.
johanrd added 2 commits April 21, 2026 20:23
…less 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.
Move the explanation of valueless / empty-string aria-hidden handling
from the PR body into the published rule docs. The rule deviates from
WAI-ARIA 1.2 §aria-hidden (which resolves valueless aria-hidden to the
default 'undefined', not 'true') in order to favor fewer false
positives for this specific check.

Also document the 'opposite-direction' split with
template-no-aria-hidden-on-focusable / template-anchor-has-content
(where spec-literal interpretation applies), and the unambiguous cases
that always follow the spec.
`isAriaHiddenTruthy` previously only handled raw TextNode and bare
MustacheStatement attribute values. The quoted-mustache form
`aria-hidden="{{true}}"` produces a `GlimmerConcatStatement` with a
single mustache part — resolve that case by descending into the single
static-literal part, mirroring the pattern established in
template-no-aria-hidden-focusable.

Leans toward "truthy" only on literal true / empty / bare-valueless to
match the rule's doc-stated ethos of fewer false positives.
@johanrd johanrd force-pushed the fix/heading-accept-boolean-aria-hidden branch from 0bcced1 to a02b3e9 Compare April 22, 2026 17:11
johanrd and others added 6 commits April 22, 2026 21:10
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
… via shared helper (Copilot review)

Extract a new `getStaticAttrValue` util that resolves literal-valued
mustaches (`{{"foo"}}`, `{{true}}`, `{{-1}}`) and single-part concat
statements (`"{{true}}"`) to their static string value. `isAriaHiddenTruthy`
now delegates to the helper and compares the resolved string to `'true'`
(case-insensitive, whitespace-trimmed).

Behavior change: valueless `<h1 aria-hidden>`, `aria-hidden=""`, and the
mustache-empty-string equivalents (`aria-hidden={{""}}`, `aria-hidden="{{""}}"`,
`aria-hidden={{" "}}`) are no longer treated as hidden. Per WAI-ARIA 1.2
§6.6 value table, those shapes resolve to the default `undefined` — NOT
`true` — so the empty-content check still applies. Drops the previous
"fewer false positives" deviation rationale in favour of spec-literal
consistency with sibling rules (#19, #35, #41) that share the same
aria-hidden resolution.

Byte-identical carrier of lib/utils/static-attr-value.js across all PRs
that land it.
@johanrd johanrd marked this pull request as ready for review April 24, 2026 20:33
The rule and test comments cited "WAI-ARIA 1.2 §6.6 aria-hidden value
table". §6.6 is "Taxonomy of WAI-ARIA States and Properties" and only
categorizes attributes; the aria-hidden value table lives in §6.7
"Definitions of States and Properties". Use the bare #aria-hidden
anchor, which is unambiguous.
@NullVoxPopuli NullVoxPopuli merged commit b6e78e8 into ember-cli:master Apr 25, 2026
10 of 11 checks passed
johanrd added a commit to johanrd/eslint-plugin-ember that referenced this pull request Apr 26, 2026
…ec + correct peer-plugin claims

Two corrections to the previous revision:

1. Valueless / empty-string `aria-hidden` is no longer treated as a
   non-interactive escape hatch. Per WAI-ARIA 1.2 §6.6 + aria-hidden
   value table, a missing or empty-string value resolves to the default
   `undefined` — NOT `true`. Only an explicit `aria-hidden="true"`
   (ASCII case-insensitive) or mustache-literal `{{true}}` opts out.
   This matches ember-cli#2717 / #19's spec-first resolution.

2. Code comment corrections. jsx-a11y's util is named
   `isPresentationRole`, not `hasPresentationRole`. The comment also
   claimed jsx-a11y's `isPresentationRole` does "first token of a
   space-separated role list" — it does not (jsx-a11y does plain
   `presentationRoles.has(rawValue)`, no trim/lowercase/split). Our
   first-token behavior is a deliberate superset, not parity.

Moved `<div aria-hidden onclick>` and `<div aria-hidden="" onclick>` from
the valid section to invalid. Added `<div aria-hidden="TRUE">` as
additional valid coverage for the case-insensitive path.
johanrd added a commit to johanrd/eslint-plugin-ember that referenced this pull request Apr 26, 2026
…I-ARIA spec

Per WAI-ARIA 1.2 §6.6 + aria-hidden value table, a missing or empty-string
aria-hidden resolves to the default `undefined` — NOT `true`. So
<span aria-hidden>X</span> as a child of <a href="/x"> does NOT hide the
span; its content still contributes to the anchor's accessible name.

The prior behavior inherited jsx-a11y's JSX-coercion convention and
vue-a11y's "anything-not-literal-false" shortcut. Both are peer-plugin
conventions that diverge from normative ARIA. Matches the spec-first
resolution of ember-cli#2717, #19, and #33.

Moved valueless / empty aria-hidden cases from invalid → valid. Kept the
explicit aria-hidden="true" and {{true}} cases as invalid.
johanrd added a commit to johanrd/eslint-plugin-ember that referenced this pull request Apr 26, 2026
…idden default

Per WAI-ARIA 1.2 §6.6 + aria-hidden value table, a missing or empty-string
aria-hidden resolves to default `undefined` — NOT `true`. Valueless
`<button aria-hidden>` and empty `<button aria-hidden="">` are therefore
NOT spec-hidden; they do not create a focus-trap anti-pattern and the
rule should not flag them.

The prior behavior inherited jsx-a11y's convention (jsx-ast-utils
coerces valueless JSX attrs to boolean true) and vue-a11y's
"anything-not-literal-false" shortcut. Both are peer-plugin conventions,
not normative ARIA interpretations. Matching ember-cli#2717's spec-first
resolution.

Also corrects the rule-doc comment: the claim attributed to WAI-ARIA 1.2
("Authors SHOULD NOT use aria-hidden='true' on any element that has
focus or may receive focus") is not in the WAI-ARIA spec. The spec only
says authors MAY "with caution" use aria-hidden. The rule's concern
(keyboard trap) comes from community/axe guidance, which this comment
now accurately attributes.

Net: flagged values are now `aria-hidden="true"` (ASCII case-insensitive),
`aria-hidden={{true}}`, and `aria-hidden={{"true"}}`. Valueless, empty,
`false`, and `{{false}}` are all accepted.
johanrd added a commit to johanrd/eslint-plugin-ember that referenced this pull request Apr 27, 2026
…idden default

Per WAI-ARIA 1.2 §6.6 + aria-hidden value table, a missing or empty-string
aria-hidden resolves to default `undefined` — NOT `true`. Valueless
`<button aria-hidden>` and empty `<button aria-hidden="">` are therefore
NOT spec-hidden; they do not create a focus-trap anti-pattern and the
rule should not flag them.

The prior behavior inherited jsx-a11y's convention (jsx-ast-utils
coerces valueless JSX attrs to boolean true) and vue-a11y's
"anything-not-literal-false" shortcut. Both are peer-plugin conventions,
not normative ARIA interpretations. Matching ember-cli#2717's spec-first
resolution.

Also corrects the rule-doc comment: the claim attributed to WAI-ARIA 1.2
("Authors SHOULD NOT use aria-hidden='true' on any element that has
focus or may receive focus") is not in the WAI-ARIA spec. The spec only
says authors MAY "with caution" use aria-hidden. The rule's concern
(keyboard trap) comes from community/axe guidance, which this comment
now accurately attributes.

Net: flagged values are now `aria-hidden="true"` (ASCII case-insensitive),
`aria-hidden={{true}}`, and `aria-hidden={{"true"}}`. Valueless, empty,
`false`, and `{{false}}` are all accepted.
johanrd added a commit to johanrd/eslint-plugin-ember that referenced this pull request Apr 27, 2026
…I-ARIA spec

Per WAI-ARIA 1.2 §6.6 + aria-hidden value table, a missing or empty-string
aria-hidden resolves to the default `undefined` — NOT `true`. So
<span aria-hidden>X</span> as a child of <a href="/x"> does NOT hide the
span; its content still contributes to the anchor's accessible name.

The prior behavior inherited jsx-a11y's JSX-coercion convention and
vue-a11y's "anything-not-literal-false" shortcut. Both are peer-plugin
conventions that diverge from normative ARIA. Matches the spec-first
resolution of ember-cli#2717, #19, and #33.

Moved valueless / empty aria-hidden cases from invalid → valid. Kept the
explicit aria-hidden="true" and {{true}} cases as invalid.
johanrd added a commit to johanrd/eslint-plugin-ember that referenced this pull request Apr 27, 2026
…ec + correct peer-plugin claims

Two corrections to the previous revision:

1. Valueless / empty-string `aria-hidden` is no longer treated as a
   non-interactive escape hatch. Per WAI-ARIA 1.2 §6.6 + aria-hidden
   value table, a missing or empty-string value resolves to the default
   `undefined` — NOT `true`. Only an explicit `aria-hidden="true"`
   (ASCII case-insensitive) or mustache-literal `{{true}}` opts out.
   This matches ember-cli#2717 / #19's spec-first resolution.

2. Code comment corrections. jsx-a11y's util is named
   `isPresentationRole`, not `hasPresentationRole`. The comment also
   claimed jsx-a11y's `isPresentationRole` does "first token of a
   space-separated role list" — it does not (jsx-a11y does plain
   `presentationRoles.has(rawValue)`, no trim/lowercase/split). Our
   first-token behavior is a deliberate superset, not parity.

Moved `<div aria-hidden onclick>` and `<div aria-hidden="" onclick>` from
the valid section to invalid. Added `<div aria-hidden="TRUE">` as
additional valid coverage for the case-insensitive path.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants