feat: add template-click-events-have-key-events#17
Conversation
🏎️ Benchmark Comparison
Full mitata output |
There was a problem hiding this comment.
Pull request overview
Adds a new accessibility rule to enforce keyboard parity when non-interactive template elements use {{on "click" …}}, aligning behavior with peer a11y plugins.
Changes:
- Introduces
ember/template-click-events-have-key-eventsrule with exemptions for presentation/hidden cases and inherently interactive HTML. - Adds supporting utilities (
isComponentInvocation,isHtmlInteractiveContent) with dedicated unit tests. - Documents the new rule and lists it in the README rules table; includes a peer-parity audit fixture.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/lib/utils/is-component-invocation-test.js | Adds test coverage for component-invocation tag detection. |
| tests/lib/utils/html-interactive-content-test.js | Adds spec-oriented tests for HTML “interactive content” classification. |
| tests/lib/rules/template-click-events-have-key-events.js | Adds primary RuleTester coverage for the new rule in GJS/GTS + HBS modes. |
| tests/audit/click-events-have-key-events/peer-parity.js | Adds an audit/peer-parity fixture for comparing behavior against other plugins. |
| lib/utils/is-component-invocation.js | Adds helper to distinguish component invocations from native DOM tags. |
| lib/utils/html-interactive-content.js | Adds helper for HTML interactive-content classification used by rules. |
| lib/rules/template-click-events-have-key-events.js | Implements the new rule logic (click requires key handler on non-interactive DOM elements). |
| docs/rules/template-click-events-have-key-events.md | Adds end-user documentation and examples for the new rule. |
| README.md | Adds the new rule to the auto-generated rules list table. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
aaa3605 to
8e4d319
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
259835d to
432ba40
Compare
432ba40 to
0e728eb
Compare
…pt-in requireExplicit
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| schema: [], | ||
| messages: { | ||
| needsKeyEvent: | ||
| 'Visible, non-interactive elements with click handlers must have at least one keyboard listener (keydown/keyup/keypress).', |
There was a problem hiding this comment.
The needsKeyEvent message starts with "Visible," but this rule doesn’t (and can’t) determine visual visibility; it only skips based on aria-hidden, hidden, and presentation roles. Consider rewording the message to avoid implying a visibility check (e.g., refer to "non-hidden" or just "non-interactive elements with click handlers").
| 'Visible, non-interactive elements with click handlers must have at least one keyboard listener (keydown/keyup/keypress).', | |
| 'Non-interactive elements with click handlers must have at least one keyboard listener (keydown/keyup/keypress).', |
| // Hidden from AT. | ||
| '<template><div aria-hidden="true" {{on "click" this.noop}}></div></template>', | ||
| // Mustache-literal boolean `true` — explicit static opt-out. | ||
| '<template><div aria-hidden={{true}} {{on "click" this.noop}}></div></template>', |
There was a problem hiding this comment.
Test coverage gap: there are valid cases for aria-hidden="true" and aria-hidden={{true}}, but no cases for other static equivalents already used elsewhere in the repo (e.g. aria-hidden={{"TRUE"}} and aria-hidden="{{true}}" / other GlimmerConcatStatement shapes). Adding these would prevent regressions in the rule’s aria-hidden exemption logic.
| '<template><div aria-hidden={{true}} {{on "click" this.noop}}></div></template>', | |
| '<template><div aria-hidden={{true}} {{on "click" this.noop}}></div></template>', | |
| // Static string literal equivalents should also exempt. | |
| '<template><div aria-hidden={{"TRUE"}} {{on "click" this.noop}}></div></template>', | |
| '<template><div aria-hidden="{{true}}" {{on "click" this.noop}}></div></template>', |
| '<template><summary {{on "click" this.noop}}>More</summary></template>', | ||
| '<template><option {{on "click" this.h}}>Foo</option></template>', | ||
| '<template><datalist {{on "click" this.h}}></datalist></template>', | ||
|
|
There was a problem hiding this comment.
The comment grouping <option> and <datalist> under “Inherently-interactive elements — keyboard is already built in” is misleading: those elements aren’t inherently keyboard-activatable on their own (and the rule exempts them via special-casing later). Consider adjusting the comment to reflect that these are explicit exemptions rather than native interactive controls.
…-require-input-type feat: add template-require-input-type
Prepare Release v13.2.0
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.
…oss-attr m6/d3 analog)
`controls` on `<audio>`/`<video>` is an HTML boolean attribute, so per the
cross-attribute observation in docs/glimmer-attribute-behavior.md, bare
`{{false}}` / `{{null}}` / `{{undefined}}` cause Glimmer to omit the
attribute at runtime. The helper's previous AST-presence check
(hasAttribute) treated `<video controls={{false}}>` as still having
controls — a false positive that propagated to every rule using
isHtmlInteractiveContent: nested-interactive, no-noninteractive-tabindex,
interactive-supports-focus, click-events-have-key-events, etc.
After:
- `controls` flows through classifyAttribute. Bare-mustache falsy literals
now correctly classify as 'absent' → element is NOT interactive at
runtime.
- `href` and `usemap` continue to use AST-presence — those are plain
string attributes that don't falsy-coerce (i4 analog: bare `{{false}}`
on a plain string renders as the literal `"false"`, attribute kept).
Concat forms (`controls="{{X}}"`) still classify as 'present' because
concat is never falsy at runtime — so the existing pass-through behavior
for concat is preserved.
Tests:
- New: `<video controls={{false}}>` → not interactive (regression-locking).
- New: `<audio controls={{null}}>` → not interactive.
- New: `<video controls="{{false}}">` → IS interactive (concat sets IDL
true regardless of inner literal).
- All 9555 existing tests pass — no rule was relying on the buggy
behavior, so this fix lands cleanly across all consumers.
… message word; add aria-hidden mustache-string and concat tests; clarify option/datalist comment
…elper-level fix in ember-cli#2769)
a51c130 to
03ddced
Compare
Note
This is part of a series where Claude has audited
eslint-plugin-emberagainst jsx-a11y, vuejs-accessibility, angular-eslint, lit-a11y and html-validate,ember-template-lint, and the HTML and WCAG specs.template-no-invalid-interactivetakes a stricter stance ("don't use{{on "click"}}on non-interactive elements at all"); this rule is the softer, more permissive check.template-click-events-have-key-eventsthat flags a non-interactive DOM element with{{on "click" …}}when no{{on "keydown" …}}/{{on "keyup" …}}/{{on "keypress" …}}modifier is present.Flags
Allows
The rule exempts elements carrying
role="presentation"/role="none"andaria-hiddenviahasPresentationRole-style handling. It does not separately walk interactive ARIA roles (role="button",role="link", etc.) — in practice those cases are handled upstream bytemplate-no-invalid-interactive.Prior art
Verified each peer in source:
click-events-have-key-events.js:22-69click-events-have-key-events.ts:14-42click-events-have-key-events.js:19-109allowList/allowCustomElementsoptions; usesisNonInteractiveElementclick-events-have-key-events.ts:22-101role === 'button'; acknowledged TODO at line 69 to extend to all interactive roles