Skip to content

fix: template-no-autofocus-attribute — value-aware + <dialog> exception#2743

Merged
NullVoxPopuli merged 9 commits intoember-cli:masterfrom
johanrd:fix/autofocus-value-aware-and-dialog
Apr 25, 2026
Merged

fix: template-no-autofocus-attribute — value-aware + <dialog> exception#2743
NullVoxPopuli merged 9 commits intoember-cli:masterfrom
johanrd:fix/autofocus-value-aware-and-dialog

Conversation

@johanrd
Copy link
Copy Markdown
Contributor

@johanrd johanrd commented Apr 25, 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.

Premise

The Phase 3 audit on audit/phase3/no-autofocus (fixture B10 in tests/audit/no-autofocus/peer-parity.js) flagged two gaps in template-no-autofocus-attribute, tracked in PR #28 as G3 and G4:

  • G3 — The rule flagged autofocus on attribute presence only, so autofocus={{false}} (which renders no attribute at all in the final HTML) was reported even though the rendered element has no autofocus.
  • G4 — The rule flagged autofocus on any element, including <dialog> and its descendants. MDN's <dialog> reference describes autofocus-on-open as the recommended dialog behavior.

Fix

G3 — value-aware for mustache boolean-literal only

Per the HTML Living Standard on boolean attributes, the presence of autofocus indicates TRUE regardless of value — autofocus="false" and autofocus="autofocus" are equally truthy. The only statically-known opt-out consistent with HTML boolean-attribute semantics is:

  • <input autofocus={{false}} /> — Glimmer renders no autofocus attribute when the mustache-literal evaluates to false.
  • {{input autofocus=false}} — same at the hash-pair level.

Both are now exempted.

Verification of the Glimmer rendering claim: the attribute-omission behavior is authoritative per Glimmer VM's dynamic.ts: normalizeValue returns null for false | undefined | null, and SimpleDynamicAttribute.update() calls element.removeAttribute(name) when the normalized value is null. So autofocus={{false}} renders with no autofocus attribute in the DOM — not autofocus="false".

Not exempted (flagged per spec):

  • autofocus="false" — presence = truthy.
  • autofocus={{"false"}} — renders as autofocus="false" = truthy.
  • {{input autofocus="false"}} — same.
  • Dynamic mustache expressions (helpers, path references) — can't be proved safe.

This diverges from jsx-a11y's no-autofocus, which treats the literal string "false" as an opt-out (via getPropValue). vue-a11y and lit-a11y are presence-based, consistent with the HTML spec — we align with them. Matches the spec-first direction established in #2717, #19, and #33.

G4 — exemption

autofocus on a <dialog> or any descendant of a <dialog> is exempt. MDN-backed editorial guidance (not a normative spec requirement); angular-eslint's no-autofocus implements the same exemption.

Tests

22 tests total. Moved 3 cases from valid → invalid (per the G3 spec correction); 11 dialog / non-literal-falsy / peer-parity cases unchanged.

johanrd added 9 commits April 21, 2026 12:43
Closes two gaps identified in PR #28 Phase 3 audit (B10 fixture on
`audit/phase3/no-autofocus`):

G3 — value-aware detection. The rule previously flagged `autofocus` purely
on presence, producing false positives for explicit opt-out forms:
  - `autofocus="false"` (GlimmerTextNode chars === "false")
  - `autofocus={{false}}` (BooleanLiteral false)
  - `autofocus={{"false"}}` (StringLiteral "false")
These are now treated as valid. jsx-a11y's `no-autofocus` reads the value
via `getPropValue` and exits early on falsy results, which is the behavior
encoded here. Truthy literals (`="true"`, `="autofocus"`, `={{true}}`,
`={{"true"}}`) and any dynamic expression (`={{this.x}}`) still flag —
the rule cannot prove a dynamic value is safe. Mustache-hash-pair forms
(`{{input autofocus=false}}`, `{{input autofocus="false"}}`) receive the
same value-aware treatment so `autofocus=true` and `autofocus=false` do
not behave inconsistently between syntaxes.

G4 — `<dialog>` exception. The rule now skips reporting when the element
carrying `autofocus` is a `<dialog>` itself OR is nested at any depth
inside a `<dialog>`. Per MDN's `<dialog>` documentation, a dialog is
expected to move focus to its initial control on open, so
`autofocus` on or within a dialog is the recommended pattern rather
than an accessibility defect. This matches
`@angular-eslint/template/no-autofocus`, which exempts the same subtree.

The descendant walk is a full parent-chain traversal via `node.parent`;
the Glimmer AST in this codebase exposes `parent` on every element node,
so no scope narrowing was required.

Behavior unchanged for: bare `<input autofocus>`, truthy string/mustache
values, dynamic mustache values, and any `autofocus` outside a dialog
subtree. 22 rule tests (10 valid, 12 invalid) pass; full suite (9089
tests) green.

Audit fixture `tests/audit/no-autofocus/peer-parity.js` lives on branch
`audit/phase3/no-autofocus` and will need separate updating to reflect
the new parity status for G3 and G4.

Refs: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog
Refs: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/src/rules/no-autofocus.js
Refs: #28 (G3, G4)
…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.
…ibute

The G3 exemption's load-bearing premise (Glimmer renders no attribute
when given a mustache-literal false) was asserted in the PR body but
unverified in the rule's own documentation. Verified against Glimmer
VM's attribute-normalization path:

  glimmer-vm/packages/@glimmer/runtime/lib/vm/attributes/dynamic.ts

`normalizeValue` returns null for false/undefined/null; then
`SimpleDynamicAttribute.update()` calls element.removeAttribute(name)
when the value is null. So autofocus={{false}} genuinely omits the
attribute from the rendered DOM — not autofocus="false".

Inlined the citation into the rule's JSDoc so future readers don't
have to trace it.
…-input helpers (Copilot review)

Previously the GlimmerMustacheStatement visitor fired on ANY mustache
with an autofocus hash pair — but arbitrary custom components taking
'autofocus' as a prop are opaque. We can't statically tell whether the
prop forwards to a native <input autofocus> or is used for something
else. Narrow to the two built-ins that deterministically render a
native input:
  - {{input …}}
  - {{component "input" …}}

Future: when type-aware linting lands (Glint integration or template-
type-check), we can resolve custom components that forward 'autofocus'
to a native <input> and flag those too. For now we stay conservative
to avoid false positives on unrelated helpers.
@NullVoxPopuli NullVoxPopuli merged commit 30ce5df into ember-cli:master Apr 25, 2026
12 checks passed
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