Skip to content

feat: add template-no-role-presentation-on-focusable#2756

Draft
johanrd wants to merge 12 commits intoember-cli:masterfrom
johanrd:feat/template-no-role-presentation-on-focusable
Draft

feat: add template-no-role-presentation-on-focusable#2756
johanrd wants to merge 12 commits intoember-cli:masterfrom
johanrd:feat/template-no-role-presentation-on-focusable

Conversation

@johanrd
Copy link
Copy Markdown
Contributor

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

Per WAI-ARIA 1.2 "Presentational Roles Conflict Resolution":

"User agents MUST NOT expose elements having explicit or inherited presentational role in the accessibility tree, with these exceptions: If an element is focusable, or otherwise interactive, user agents MUST ignore the presentation role and expose the element with its implicit role, in order to ensure that the element is operable."

That is a UA-directed conflict-resolution procedure. The practical consequence for authors: role="presentation" on a focusable element is silently ignored by the UA. The author said "decorative," the UA exposes the element anyway with its implicit role. This rule flags the author-side anti-pattern before it reaches the browser.

role="none" is handled identically. WAI-ARIA 1.2 explicitly defines none as a synonym for presentation.

Shares focusable-detection with template-no-aria-hidden-on-focusable — both target the same anti-pattern shape via different attributes.

Flags

<button role="presentation">Click</button>
<a href="/x" role="presentation">Link</a>
<a href="/x" role="none">Link</a>
<input type="text" role="presentation" />
<div tabindex="0" role="presentation"></div>
<button tabindex="-1" role="presentation">…</button>              {{! tabindex="-1" is still focusable per HTML §6.6.3 (programmatic focus target, exposed in a11y tree); WAI-ARIA conflict resolution applies — flagged. jsx-a11y / vue-a11y exempt it; we follow the spec. }}

Allows

<div role="presentation"><button tabindex="-1">…</button></div>   {{! role on non-focusable; children handled recursively }}
<CustomBtn role="presentation" />                                 {{! component, opaque }}

Prior art

Plugin Rule Notes
vuejs-accessibility no-role-presentation-on-focusable Checks role === "presentation" only (not "none"). Our rule extends to "none" per spec synonymy.
jsx-a11y No equivalent rule.
lit-a11y No equivalent rule.
@angular-eslint/template No equivalent rule.

johanrd added 12 commits April 28, 2026 10:33
Captures empirically-verified Glimmer rendering behavior for HTML
attributes with mustache values, so rule authors classifying
GlimmerBooleanLiteral / GlimmerStringLiteral / GlimmerConcatStatement
have a ground-truth reference instead of intuition.

Notable findings the doc pins down:
- attr={{"false"}} (bare string "false") renders as attr="false" — TRUTHY,
  not falsy as the literal suggests.
- attr="{{false}}" (concat) sets the IDL property to true regardless of
  the literal value inside, even when HTML serialization shows nothing.
  Verified against <video muted="{{false}}"> → videoEl.muted === true.
- Non-reflecting boolean attrs (muted, autoplay) and reflecting ones
  (disabled, hidden) diverge in HTML serialization but agree at the IDL
  property layer.

Includes a copy-pasteable reproduction template so future readers can
re-verify if Glimmer behavior changes.

Adds a pointer in README's "Creating a New Rule" section.
Replaces the prior tables (which mixed verified data with extrapolations
marked "(assumed)") with strictly-verified per-attribute tables. Every cell
populated from rendering and IDL inspection in ember-source 6.12.

Structure:
- One "Reference table" section, five per-attribute sub-tables
  (muted, disabled, aria-hidden, tabindex, autocomplete)
- One "To reproduce the reference table" section with the exact template
  and JS console snippet, inline (no separate fixture file)
- Cross-attribute observations summarizing the rules the data reveals

Findings the new tables make explicit:
- Bare-mustache falsy set is {{false}}/{{null}}/{{undefined}}/{{0}} for
  boolean-coerced attrs (boolean HTML, ARIA, numeric); {{""}} is kept as
  attr="".
- Bare-mustache string literals never coerce — attr={{"false"}} renders
  as attr="false".
- Concat-mustache for boolean HTML attrs sets the IDL property to true
  regardless of the literal value inside (verified for both reflecting
  and non-reflecting attrs).
- Concat-mustache for ARIA/string attrs renders the stringified value
  literally — no boolean coercion. aria-hidden="{{false}}" is visible.
- Plain string attrs (autocomplete) skip Glimmer's boolean coercion
  entirely; autocomplete={{false}} renders as autocomplete="false".

The video.muted snapshot reads IDL muted=false for static attribute forms
(m1-m4, m7-m8, m11) because the test runs before media load — the doc
explains how defaultMuted reflects to muted at load time, so the rule's
"At play time" column is the lint-truth column rule authors should use.
…les" guide

Adds a practical-implementation section between the reference table and
the reproduction template. It maps each AST shape (GlimmerTextNode /
GlimmerMustacheStatement with each path type / GlimmerConcatStatement)
to a verdict, citing the row IDs from the reference table so rule
authors can implement classification correctly without re-deriving the
model.

Includes:
- AST-shape verdict table — direct mapping rule authors can copy from
- Six common mistakes section, each tied to specific row IDs
- Pointer to the (forthcoming) lib/utils/glimmer-attr-presence.js
  utility that will encode the verdict table once and let rules consume
  the resolved kind + value rather than re-walking the AST

The audit of master rules and the open feature PRs found 18 REAL_BUG
findings (12 in PRs, 6 in master) — all classifiable into the bullet-1
through bullet-4 footguns this guide enumerates.
…ng model

Adds lib/utils/glimmer-attr-presence.js exporting:

- classifyAttribute(attrNode, options?) → { presence, value }
  Maps every AST shape (valueless / GlimmerTextNode / GlimmerMustacheStatement
  with each path type / GlimmerConcatStatement) to a (presence, value) pair
  per the verified model in docs/glimmer-attribute-behavior.md. Each branch
  cites the relevant doc row IDs (m1–m19, h1–h15, d1–d10, t1–t7, i1–i5).
- inferAttrKind(name) → 'boolean' | 'aria' | 'numeric' | 'plain-string'
  Used when classifyAttribute callers don't pass options.kind explicitly.
- BOOLEAN_HTML_ATTRS, NUMERIC_ATTRS — exported sets, useful for callers
  that want to extend the kind model.

Key empirical asymmetries this util encodes correctly (and that audit
findings show several rules currently misclassify):

- Bare {{false}} / {{null}} / {{undefined}} on falsy-coerced kinds
  (boolean / aria / numeric) → presence='absent' (Glimmer omits attribute).
  Same forms on plain-string → presence='present', value='false' / etc.
- Bare {{"false"}} (StringLiteral) is JS-truthy, never coerced — renders
  the literal value across all attribute kinds.
- aria-hidden={{true}} renders aria-hidden="" (h5, contested), not
  aria-hidden="true" — the util surfaces value='' here so callers
  comparing value === 'true' don't false-match.
- Concat is never falsy: any concat form is presence='present'; the
  resolved value comes from the existing getStaticAttrValue helper.

Tests: 35 unit tests covering every doc row + the kind-override option.

Updates docs/glimmer-attribute-behavior.md to reference the actual file
and replaces the "(forthcoming)" sketch with a working example.
…ation / html-void-elements

Correctness fixes from PR ember-cli#2769 review:

- Boolean concat now returns canonical `value: 'true'` instead of leaking
  the inner literal. Per doc rows m13-m19, d7-d10 the IDL is set true
  regardless of inner value, so callers checking `value === 'false'` to
  detect "off" no longer get a wrong answer for `<video muted="{{false}}">`.
- {{true}} on numeric / plain-string now returns `unknown` (untested in
  doc; was previously claiming `value: 'true'` by extrapolation).
- `inferAttrKind` is now case-insensitive (`Disabled`, `ARIA-Hidden`, etc.).

Drop hand-rolled spec lists in favor of upstream packages:

- `property-information` for boolean / overloadedBoolean / number attribute
  classification, replacing the 24-entry BOOLEAN_HTML_ATTRS and 3-entry
  NUMERIC_ATTRS Sets. `colspan` is added as a small NUMERIC_OVERRIDES Set
  to compensate for an upstream gap in property-information 7.1.0 (rowspan
  and cols are marked `number: true`, colspan isn't).
- `html-void-elements` in template-block-indentation.js and
  template-self-closing-void-elements.js, deduplicating two parallel
  16-entry VOID_TAGS Sets.

Internal API change: BOOLEAN_HTML_ATTRS and NUMERIC_ATTRS are no longer
exported from glimmer-attr-presence. The util's public surface is now
`classifyAttribute` and `inferAttrKind`. Callers wanting the underlying
classification can use property-information directly.
…L_FOCUSABLE_TAGS to HTML §6.6.3 default-focusable list — drop datalist, details, option (none are own focusable areas)
@johanrd johanrd force-pushed the feat/template-no-role-presentation-on-focusable branch from e8ea857 to c7e12f8 Compare April 28, 2026 12:17
@johanrd johanrd marked this pull request as draft April 30, 2026 21:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant