Skip to content

feat: add template-interactive-supports-focus#36

Open
johanrd wants to merge 19 commits intomasterfrom
feat/template-interactive-supports-focus
Open

feat: add template-interactive-supports-focus#36
johanrd wants to merge 19 commits intomasterfrom
feat/template-interactive-supports-focus

Conversation

@johanrd
Copy link
Copy Markdown
Owner

@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.

Summary

⚠️ Stricter than all three peer plugins. This rule flags <div role="button"> on role alone, regardless of whether an event handler is present. jsx-a11y, vuejs-accessibility, and @angular-eslint/template all gate on handler presence (handler + role + no focus → flag). Shapes like <div role="button">x</div> with no handler pass in all three peers; this rule flags them. Details in "Divergence from peer plugins" below — if that isn't the behavior you want, use template-no-invalid-interactive + #33 instead (peer-parity, handler-gated).

Adds template-interactive-supports-focus — flags elements that declare an interactive ARIA role on a host that is not focusable (the canonical <div role="button"> anti-pattern).

Premise: If an author writes role="button", they promise keyboard and screen-reader users that the element behaves like a button. That promise only holds if the element is reachable by keyboard. Normative basis: WCAG 2.1 SC 2.1.1 Keyboard (Level A).

Conclusion: flag the element unless the host is inherently focusable, has a tabindex (any value, static or dynamic), or carries a truthy contenteditable. Skip component invocations and dynamic role values.

Divergence from peer plugins — important

This rule is role-gated: it flags the shape <div role="button"> regardless of whether an event handler is attached. All three peer plugins (jsx-a11y, vuejs-accessibility, @angular-eslint/template) implement the equivalent rule as handler-gated — they require an interactive handler (onClick/@click/(click)) to be present before flagging. A handler-less <div role="button">x</div> passes in all three peers; our rule flags it.

Role-gated is a deliberate, stricter choice. The canonical anti-pattern (authored role without keyboard support) is bugged whether a handler is wired up or not — the role alone promises operability. Users who want peer-parity behavior should prefer the existing template-no-invalid-interactive + #33 escape-hatch handling.

Other differences from peers:

  • progressbar treatment — jsx-a11y explicitly excludes progressbar from interactive roles (its isInteractiveRole.js carves it out because progressbar.value is always readonly). Our rule flags <div role="progressbar"> without a tabindex. This is a deliberate consequence of deriving INTERACTIVE_ROLES from aria-query's widget-ancestor chain without jsx-a11y's manual exclusion.
  • contenteditable truthiness — we treat any value except the literal string "false" as truthy. The HTML spec actually defines contenteditable as an enumerated attribute with keywords true/false/plaintext-only whose invalid value default is the Inherit state and whose empty-value default is True; we deliberately accept any non-"false" value as a focusability hint, which is more permissive than the spec. @angular-eslint is stricter: only "" and "true" count as truthy.
  • No allowList option@angular-eslint's rule exempts form by default; we don't.

Flags

<div role="button">x</div>
<span role="link">x</span>
<a role="button">x</a>              {{! anchor without href is non-focusable }}
<area role="link" />
<input type="hidden" role="button" />  {{! loses focusability via type=hidden }}

Allows

<div role="button" tabindex="0">x</div>
<div role="button" contenteditable="true">x</div>
<button role="button">x</button>    {{! button is inherently focusable }}
<CustomButton role="button">x</CustomButton>  {{! component, opaque }}

Addresses M2 in tracking PR #28 (Phase 3 audit). Opt-in — not added to template-lint-migration.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 21, 2026

🏎️ Benchmark Comparison

Benchmark Control (p50) Experiment (p50) Δ
🟢 js small 10.27 ms 9.83 ms -4.3%
🟢 js medium 4.88 ms 4.77 ms -2.3%
js large 1.91 ms 1.90 ms -0.5%
gjs small 865.41 µs 860.51 µs -0.6%
gjs medium 434.32 µs 434.69 µs +0.1%
gjs large 172.27 µs 171.54 µs -0.4%
gts small 861.86 µs 868.92 µs +0.8%
gts medium 432.91 µs 433.85 µs +0.2%
gts large 171.12 µs 171.19 µs +0.0%

🟢 faster · 🔴 slower · 🟠 slightly slower · ⚪ within 2%

Full mitata output
clk: ~3.57 GHz
cpu: AMD EPYC 9V74 80-Core Processor
runtime: node 24.15.0 (x64-linux)

benchmark                   avg (min … max) p75 / p99    (min … top 1%)
------------------------------------------- -------------------------------
js small (control)            11.63 ms/iter  12.86 ms  █                   
                       (8.32 ms … 25.19 ms)  21.66 ms ██   ▄ ▂             
                    (  5.49 mb …  10.13 mb)   7.10 mb ██▆▆▆█▅█▅▃▅▁▃▁▁▁▁▁▃▃▅

js small (experiment)         10.56 ms/iter  11.55 ms  █                   
                       (8.86 ms … 15.93 ms)  15.40 ms  █                   
                    (  6.26 mb …   8.36 mb)   6.80 mb ▆█▇▄█▂▃▃▄▆▂▂▃▂▁▂▁▄▁▁▂

                             ┌                                            ┐
                             ╷ ┌────────┬───┐                             ╷
          js small (control) ├─┤        │   ├─────────────────────────────┤
                             ╵ └────────┴───┘                             ╵
                               ╷┌────┬──┐            ╷
       js small (experiment)   ├┤    │  ├────────────┤
                               ╵└────┴──┘            ╵
                             └                                            ┘
                             8.32 ms           14.99 ms            21.66 ms

summary
  js small (experiment)
   1.1x faster than js small (control)

------------------------------------------- -------------------------------
js medium (control)            5.30 ms/iter   5.58 ms ▇█▅                  
                       (4.56 ms … 11.00 ms)   8.12 ms ███                  
                    (  2.95 mb …   4.02 mb)   3.53 mb ████▅▃▃█▃▄▃▃▂▁▂▄▂▃▁▁▄

js medium (experiment)         5.38 ms/iter   5.63 ms █                    
                       (4.20 ms … 12.41 ms)  12.03 ms █ ▄                  
                    (  1.57 mb …   4.55 mb)   3.47 mb ███▄▃▂▄▂▂▂▁▂▂▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                               ╷┌──┬─┐              ╷
         js medium (control)   ├┤  │ ├──────────────┤
                               ╵└──┴─┘              ╵
                             ┌──────┬┐                                    ╷
      js medium (experiment) │      │├────────────────────────────────────┤
                             └──────┴┘                                    ╵
                             └                                            ┘
                             4.20 ms            8.11 ms            12.03 ms

summary
  js medium (control)
   1.02x faster than js medium (experiment)

------------------------------------------- -------------------------------
js large (control)             2.11 ms/iter   2.00 ms ██                   
                        (1.81 ms … 5.35 ms)   4.76 ms ██                   
                    (259.82 kb …   3.08 mb)   1.43 mb ██▃▂▂▂▂▂▂▁▂▂▁▁▁▁▂▁▁▁▁

js large (experiment)          2.11 ms/iter   1.96 ms ▄█                   
                        (1.78 ms … 6.77 ms)   4.86 ms ██                   
                    (330.05 kb …   2.25 mb)   1.42 mb ██▃▂▂▁▂▂▂▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ╷┌───┬                                      ╷
          js large (control) ├┤   │──────────────────────────────────────┤
                             ╵└───┴                                      ╵
                             ╷┌───┬                                       ╷
       js large (experiment) ├┤   │───────────────────────────────────────┤
                             ╵└───┴                                       ╵
                             └                                            ┘
                             1.78 ms            3.32 ms             4.86 ms

summary
  js large (experiment)
   1x faster than js large (control)

------------------------------------------- -------------------------------
gjs small (control)          950.35 µs/iter 873.62 µs █                    
                      (838.36 µs … 5.40 ms)   4.35 ms █                    
                    (259.63 kb …   1.87 mb)   1.06 mb █▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

gjs small (experiment)       948.35 µs/iter 870.22 µs █                    
                      (839.74 µs … 5.41 ms)   4.55 ms █                    
                    (550.78 kb …   2.12 mb)   1.06 mb █▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ┌┬                                         ╷
         gjs small (control) ││─────────────────────────────────────────┤
                             └┴                                         ╵
                             ┌┬                                           ╷
      gjs small (experiment) ││───────────────────────────────────────────┤
                             └┴                                           ╵
                             └                                            ┘
                             838.36 µs           2.69 ms            4.55 ms

summary
  gjs small (experiment)
   1x faster than gjs small (control)

------------------------------------------- -------------------------------
gjs medium (control)         481.43 µs/iter 440.50 µs █                    
                      (419.65 µs … 4.93 ms)   1.46 ms █                    
                    (162.92 kb …   1.30 mb) 540.70 kb █▄▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

gjs medium (experiment)      475.91 µs/iter 440.49 µs █                    
                      (421.19 µs … 4.96 ms)   1.34 ms █                    
                    (539.66 kb …   1.68 mb) 541.50 kb █▄▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ┌──┬                                         ╷
        gjs medium (control) │  │─────────────────────────────────────────┤
                             └──┴                                         ╵
                             ┌─┬                                     ╷
     gjs medium (experiment) │ │─────────────────────────────────────┤
                             └─┴                                     ╵
                             └                                            ┘
                             419.65 µs          938.38 µs           1.46 ms

summary
  gjs medium (experiment)
   1.01x faster than gjs medium (control)

------------------------------------------- -------------------------------
gjs large (control)          189.81 µs/iter 175.34 µs  █▃                  
                      (167.04 µs … 4.58 ms) 227.14 µs  ██                  
                    ( 23.63 kb …   1.28 mb) 216.23 kb ▂██▆▆▅▂▂▁▁▁▁▁▁▁▁▁▁▁▁▁

gjs large (experiment)       189.95 µs/iter 174.52 µs   █                  
                      (166.55 µs … 4.52 ms) 210.96 µs  ██▅                 
                    (128.70 kb …   1.37 mb) 216.29 kb ▂███▅▅▅▄▂▂▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ╷  ┌─────────────┬                           ╷
         gjs large (control) ├──┤             │───────────────────────────┤
                             ╵  └─────────────┴                           ╵
                             ╷  ┌─────────────┬               ╷
      gjs large (experiment) ├──┤             │───────────────┤
                             ╵  └─────────────┴               ╵
                             └                                            ┘
                             166.55 µs         196.85 µs          227.14 µs

summary
  gjs large (control)
   1x faster than gjs large (experiment)

------------------------------------------- -------------------------------
gts small (control)          947.73 µs/iter 870.76 µs █                    
                      (838.05 µs … 5.31 ms)   4.77 ms █                    
                    (801.23 kb …   2.21 mb)   1.06 mb █▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

gts small (experiment)       984.60 µs/iter 881.91 µs █                    
                      (842.00 µs … 5.36 ms)   4.91 ms █                    
                    (218.17 kb …   1.91 mb)   1.05 mb █▂▂▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ┌┬                                         ╷
         gts small (control) ││─────────────────────────────────────────┤
                             └┴                                         ╵
                             ┌─┬                                          ╷
      gts small (experiment) │ │──────────────────────────────────────────┤
                             └─┴                                          ╵
                             └                                            ┘
                             838.05 µs           2.88 ms            4.91 ms

summary
  gts small (control)
   1.04x faster than gts small (experiment)

------------------------------------------- -------------------------------
gts medium (control)         482.62 µs/iter 439.73 µs █                    
                      (419.02 µs … 4.46 ms)   1.08 ms █▄                   
                    (505.91 kb …   1.59 mb) 540.56 kb ██▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

gts medium (experiment)      489.11 µs/iter 440.53 µs █                    
                      (420.24 µs … 4.75 ms)   1.53 ms █                    
                    (454.20 kb …   1.25 mb) 540.35 kb █▃▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ┌──┬                       ╷
        gts medium (control) │  │───────────────────────┤
                             └──┴                       ╵
                             ┌──┬                                         ╷
     gts medium (experiment) │  │─────────────────────────────────────────┤
                             └──┴                                         ╵
                             └                                            ┘
                             419.02 µs          976.84 µs           1.53 ms

summary
  gts medium (control)
   1.01x faster than gts medium (experiment)

------------------------------------------- -------------------------------
gts large (control)          191.27 µs/iter 174.76 µs  █                   
                      (166.28 µs … 4.27 ms) 254.12 µs  █                   
                    (105.61 kb …   1.41 mb) 216.78 kb ▅█▆▆▃▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

gts large (experiment)       191.06 µs/iter 174.23 µs  █                   
                      (166.89 µs … 4.32 ms) 300.53 µs ██                   
                    (169.00 kb … 808.06 kb) 216.04 kb ███▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ╷┌──────┬                    ╷
         gts large (control) ├┤      │────────────────────┤
                             ╵└──────┴                    ╵
                             ╷┌──────┬                                    ╷
      gts large (experiment) ├┤      │────────────────────────────────────┤
                             ╵└──────┴                                    ╵
                             └                                            ┘
                             166.28 µs         233.41 µs          300.53 µs

summary
  gts large (experiment)
   1x faster than gts large (control)

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new opt-in accessibility rule, template-interactive-supports-focus, to flag template elements that declare an interactive ARIA role but aren’t focusable.

Changes:

  • Implement template-interactive-supports-focus rule using aria-query role taxonomy + focusability heuristics.
  • Add comprehensive RuleTester coverage for both strict (<template>) and loose (.hbs) template modes.
  • Document the rule and add it to the README rules table.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
lib/rules/template-interactive-supports-focus.js New rule implementation for interactive-role focusability enforcement.
tests/lib/rules/template-interactive-supports-focus.js New test suite validating rule behavior in gjs/gts and hbs parsing modes.
docs/rules/template-interactive-supports-focus.md New rule documentation, including rationale and peer-plugin divergence notes.
README.md Adds the rule to the published rules list.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/rules/template-interactive-supports-focus.js Outdated
Comment thread lib/rules/template-interactive-supports-focus.js Outdated
Comment thread lib/rules/template-interactive-supports-focus.js Outdated
Comment thread lib/rules/template-interactive-supports-focus.js
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/rules/template-interactive-supports-focus.js Outdated
Comment thread tests/lib/rules/template-interactive-supports-focus.js Outdated
Comment thread lib/rules/template-interactive-supports-focus.js Outdated
Comment thread lib/rules/template-interactive-supports-focus.js Outdated
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/rules/template-interactive-supports-focus.js Outdated
Comment thread tests/lib/rules/template-interactive-supports-focus.js
Comment thread tests/lib/rules/template-interactive-supports-focus.js
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/rules/template-interactive-supports-focus.js
@johanrd johanrd requested a review from Copilot April 24, 2026 17:48
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@johanrd johanrd requested a review from Copilot April 24, 2026 18:15
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/lib/rules/template-interactive-supports-focus.js Outdated
@johanrd johanrd force-pushed the feat/template-interactive-supports-focus branch from a474bbb to 9782bc3 Compare April 27, 2026 14:05
@johanrd johanrd force-pushed the feat/template-interactive-supports-focus branch from 79e0d33 to d924e2a Compare April 27, 2026 19:28
@johanrd johanrd requested a review from Copilot April 27, 2026 19:34
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

if (attr.value.type !== 'GlimmerTextNode') {
return true;
}
return attr.value.chars.toLowerCase() !== 'false';
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

contenteditable values aren’t whitespace-normalized before comparison, so contenteditable=" false " (or other incidental whitespace) will be treated as truthy and incorrectly exempt elements from reporting. Trim before lowercasing (consistent with the type handling in isInherentlyFocusable) so explicit false remains false even with surrounding whitespace.

Suggested change
return attr.value.chars.toLowerCase() !== 'false';
return attr.value.chars.trim().toLowerCase() !== 'false';

Copilot uses AI. Check for mistakes.
Comment on lines +211 to +238
const hasTabindex = node.attributes?.some((a) => a.name?.toLowerCase() === 'tabindex');
if (hasTabindex) {
const disabled = DISABLABLE_FORM_CONTROLS.has(tag) && findAttr(node, 'disabled');
let hiddenInput = false;
if (tag === 'input') {
const type = getTextAttrValue(findAttr(node, 'type'));
hiddenInput = typeof type === 'string' && type.trim().toLowerCase() === 'hidden';
}
if (!disabled && !hiddenInput) {
return;
}
}

// contenteditable also makes an element focusable, with the same
// HTML-spec carve-outs as tabindex: the UA ignores it on disabled
// form controls (HTML §4.10.18.5) and on <input type="hidden">
// (no rendered element to edit), so the a11y conflict still stands.
if (isContentEditable(node)) {
const disabled = DISABLABLE_FORM_CONTROLS.has(tag) && findAttr(node, 'disabled');
let hiddenInput = false;
if (tag === 'input') {
const type = getTextAttrValue(findAttr(node, 'type'));
hiddenInput = typeof type === 'string' && type.trim().toLowerCase() === 'hidden';
}
if (!disabled && !hiddenInput) {
return;
}
}
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The “disabled/hiddenInput suppresses focusability” logic is duplicated in both the tabindex and contenteditable paths. Consider extracting a small helper (e.g., isSuppressedFromFocus({ tag, node })) returning { disabled, hiddenInput } (or a single boolean) so these two blocks can share the same source of truth and avoid drift if the carve-outs change.

Copilot uses AI. Check for mistakes.
NullVoxPopuli and others added 16 commits April 27, 2026 21:47
…-require-input-type

feat: add template-require-input-type
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.
…omFocus helper to deduplicate tabindex/contenteditable carve-outs
@johanrd johanrd force-pushed the feat/template-interactive-supports-focus branch from 04216f3 to 99774f7 Compare April 28, 2026 12:07
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.

3 participants