From 8ec2054eb22817832eaf8770f1397dd1511293c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 21 Apr 2026 12:43:05 +0200 Subject: [PATCH 1/9] =?UTF-8?q?fix:=20template-no-autofocus-attribute=20?= =?UTF-8?q?=E2=80=94=20value-aware=20+=20=20exception?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 — `` exception. The rule now skips reporting when the element carrying `autofocus` is a `` itself OR is nested at any depth inside a ``. Per MDN's `` 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 ``, 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) --- lib/rules/template-no-autofocus-attribute.js | 135 +++++++++++++++--- .../rules/template-no-autofocus-attribute.js | 109 ++++++++++++++ 2 files changed, 221 insertions(+), 23 deletions(-) diff --git a/lib/rules/template-no-autofocus-attribute.js b/lib/rules/template-no-autofocus-attribute.js index d01c000e18..ef3bc28cf4 100644 --- a/lib/rules/template-no-autofocus-attribute.js +++ b/lib/rules/template-no-autofocus-attribute.js @@ -1,3 +1,64 @@ +/** + * Returns true when the attribute value is statically known to be falsy + * (i.e. the developer has written `autofocus="false"`, `autofocus={{false}}`, + * or `autofocus={{"false"}}`). These forms are aligned with jsx-a11y's + * `no-autofocus` rule, which reads the attribute value via `getPropValue` and + * skips reporting when the value is falsy (e.g. `
`). + * + * Valueless attributes (``) are TRUTHY per the HTML spec + * (boolean attribute present == "on") and must still be flagged. + */ +function isExplicitlyFalsy(value) { + if (!value) { + // No value property at all — treat as bare boolean attribute (truthy). + return false; + } + + if (value.type === 'GlimmerTextNode') { + // `autofocus="false"` → chars === "false". Bare `autofocus` has chars === "". + return value.chars === 'false'; + } + + if (value.type === 'GlimmerMustacheStatement') { + const expr = value.path; + if (!expr) { + return false; + } + // `autofocus={{false}}` → BooleanLiteral(false). + if (expr.type === 'GlimmerBooleanLiteral' && expr.value === false) { + return true; + } + // `autofocus={{"false"}}` → StringLiteral("false"). + if (expr.type === 'GlimmerStringLiteral' && expr.value === 'false') { + return true; + } + } + + return false; +} + +/** + * Returns true when the given GlimmerElementNode is a `` element + * or is nested (at any depth) inside a `` element. Per MDN, + * autofocus on (or within) a dialog is recommended because a dialog should + * focus its initial element when opened. + * + * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog + */ +function isInsideDialog(node) { + if (node.type === 'GlimmerElementNode' && node.tag === 'dialog') { + return true; + } + let ancestor = node.parent; + while (ancestor) { + if (ancestor.type === 'GlimmerElementNode' && ancestor.tag === 'dialog') { + return true; + } + ancestor = ancestor.parent; + } + return false; +} + /** @type {import('eslint').Rule.RuleModule} */ module.exports = { meta: { @@ -27,25 +88,39 @@ module.exports = { GlimmerElementNode(node) { const autofocusAttr = node.attributes?.find((attr) => attr.name === 'autofocus'); - if (autofocusAttr) { - context.report({ - node: autofocusAttr, - messageId: 'noAutofocus', - fix(fixer) { - const sourceCode = context.sourceCode; - const text = sourceCode.getText(); - const attrStart = autofocusAttr.range[0]; - const attrEnd = autofocusAttr.range[1]; - - let removeStart = attrStart; - while (removeStart > 0 && /\s/.test(text[removeStart - 1])) { - removeStart--; - } - - return fixer.removeRange([removeStart, attrEnd]); - }, - }); + if (!autofocusAttr) { + return; } + + // jsx-a11y parity: `autofocus="false"` / `={{false}}` / `={{"false"}}` + // explicitly opt out and should not be flagged. + if (isExplicitlyFalsy(autofocusAttr.value)) { + return; + } + + // MDN dialog exception: autofocus on a or inside a + // is recommended behavior, not an accessibility defect. + if (isInsideDialog(node)) { + return; + } + + context.report({ + node: autofocusAttr, + messageId: 'noAutofocus', + fix(fixer) { + const sourceCode = context.sourceCode; + const text = sourceCode.getText(); + const attrStart = autofocusAttr.range[0]; + const attrEnd = autofocusAttr.range[1]; + + let removeStart = attrStart; + while (removeStart > 0 && /\s/.test(text[removeStart - 1])) { + removeStart--; + } + + return fixer.removeRange([removeStart, attrEnd]); + }, + }); }, GlimmerMustacheStatement(node) { @@ -53,12 +128,26 @@ module.exports = { return; } const autofocusPair = node.hash.pairs.find((pair) => pair.key === 'autofocus'); - if (autofocusPair) { - context.report({ - node: autofocusPair, - messageId: 'noAutofocus', - }); + if (!autofocusPair) { + return; + } + + // Value-aware check for component/helper invocations such as + // `{{input autofocus=false}}` or `{{input autofocus="false"}}`. + const pairValue = autofocusPair.value; + if (pairValue) { + if (pairValue.type === 'GlimmerBooleanLiteral' && pairValue.value === false) { + return; + } + if (pairValue.type === 'GlimmerStringLiteral' && pairValue.value === 'false') { + return; + } } + + context.report({ + node: autofocusPair, + messageId: 'noAutofocus', + }); }, }; }, diff --git a/tests/lib/rules/template-no-autofocus-attribute.js b/tests/lib/rules/template-no-autofocus-attribute.js index ad67f03943..9709506c3a 100644 --- a/tests/lib/rules/template-no-autofocus-attribute.js +++ b/tests/lib/rules/template-no-autofocus-attribute.js @@ -22,6 +22,39 @@ ruleTester.run('template-no-autofocus-attribute', rule, { ``, + // Value-aware: explicit falsy values opt out (jsx-a11y parity). + ``, + ``, + ``, + ``, + ``, + // Dialog exception (MDN): autofocus on is recommended. + ``, + // Dialog descendants are also exempt (angular-eslint parity). + ``, + ``, ], invalid: [ @@ -123,5 +156,81 @@ ruleTester.run('template-no-autofocus-attribute', rule, { }, ], }, + // Value-aware: truthy literals and any dynamic value still flag. + { + code: ``, + output: ``, + errors: [ + { + messageId: 'noAutofocus', + type: 'GlimmerAttrNode', + }, + ], + }, + { + code: ``, + output: ``, + errors: [ + { + messageId: 'noAutofocus', + type: 'GlimmerAttrNode', + }, + ], + }, + { + code: ``, + output: ``, + errors: [ + { + messageId: 'noAutofocus', + type: 'GlimmerAttrNode', + }, + ], + }, + { + code: ``, + output: ``, + errors: [ + { + messageId: 'noAutofocus', + type: 'GlimmerAttrNode', + }, + ], + }, + // Dialog exception only applies within ; siblings elsewhere still flag. + { + code: ``, + output: ``, + errors: [ + { + messageId: 'noAutofocus', + type: 'GlimmerAttrNode', + }, + ], + }, ], }); From 65888627ae67bf1e11c8a680aa07e8e32d904615 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 21 Apr 2026 16:26:01 +0200 Subject: [PATCH 2/9] docs: document and falsy-value exceptions --- docs/rules/template-no-autofocus-attribute.md | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/rules/template-no-autofocus-attribute.md b/docs/rules/template-no-autofocus-attribute.md index b3bc9bd425..b049b29668 100644 --- a/docs/rules/template-no-autofocus-attribute.md +++ b/docs/rules/template-no-autofocus-attribute.md @@ -40,6 +40,28 @@ Examples of **correct** code for this rule: ``` +Explicit opt-out via a falsy value is allowed (parity with +[`jsx-a11y/no-autofocus`](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/no-autofocus.md)): + +```gjs + +``` + +`` and its descendants are exempt. A dialog is expected to focus its +initial element on open, per +[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog): + +```gjs + +``` + ## When Not To Use It If you need to autofocus for specific accessibility or UX requirements and have thoroughly tested with assistive technologies, you may disable this rule for those specific cases. From 1670293a9fec990aa1414be1c3dadacdcc4739fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 21 Apr 2026 20:06:14 +0200 Subject: [PATCH 3/9] fix(template-no-autofocus-attribute): align value-aware check with HTML boolean semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 . Follows the spec-first direction established in #2717 (aria-hidden), #19, #33. --- lib/rules/template-no-autofocus-attribute.js | 72 ++++++++----------- .../rules/template-no-autofocus-attribute.js | 42 ++++++++--- 2 files changed, 60 insertions(+), 54 deletions(-) diff --git a/lib/rules/template-no-autofocus-attribute.js b/lib/rules/template-no-autofocus-attribute.js index ef3bc28cf4..a5c0cdbfb5 100644 --- a/lib/rules/template-no-autofocus-attribute.js +++ b/lib/rules/template-no-autofocus-attribute.js @@ -1,40 +1,25 @@ /** - * Returns true when the attribute value is statically known to be falsy - * (i.e. the developer has written `autofocus="false"`, `autofocus={{false}}`, - * or `autofocus={{"false"}}`). These forms are aligned with jsx-a11y's - * `no-autofocus` rule, which reads the attribute value via `getPropValue` and - * skips reporting when the value is falsy (e.g. `
`). + * `autofocus` is a boolean HTML attribute. Per the HTML spec, any presence + * (including `autofocus="false"`, `autofocus=""`, `autofocus="autofocus"`) + * means the element will auto-focus. Only the genuine absence of the + * attribute turns off auto-focus. * - * Valueless attributes (``) are TRUTHY per the HTML spec - * (boolean attribute present == "on") and must still be flagged. + * jsx-a11y's `no-autofocus` treats `autofocus={false}` / `autofocus="false"` + * as opt-outs — that is a peer-plugin convention that diverges from HTML + * boolean-attribute semantics. vue-a11y and lit-a11y are presence-based, + * consistent with the spec. We follow the spec. + * + * The only exception is a mustache boolean-literal `{{false}}` in element + * syntax — Glimmer authors writing `autofocus={{false}}` are expressing + * intent to omit the attribute conditionally. Treat that narrow literal + * case as opt-out (the rendered HTML will have no autofocus attr). */ -function isExplicitlyFalsy(value) { - if (!value) { - // No value property at all — treat as bare boolean attribute (truthy). +function isMustacheBooleanFalse(value) { + if (value?.type !== 'GlimmerMustacheStatement') { return false; } - - if (value.type === 'GlimmerTextNode') { - // `autofocus="false"` → chars === "false". Bare `autofocus` has chars === "". - return value.chars === 'false'; - } - - if (value.type === 'GlimmerMustacheStatement') { - const expr = value.path; - if (!expr) { - return false; - } - // `autofocus={{false}}` → BooleanLiteral(false). - if (expr.type === 'GlimmerBooleanLiteral' && expr.value === false) { - return true; - } - // `autofocus={{"false"}}` → StringLiteral("false"). - if (expr.type === 'GlimmerStringLiteral' && expr.value === 'false') { - return true; - } - } - - return false; + const expr = value.path; + return expr?.type === 'GlimmerBooleanLiteral' && expr.value === false; } /** @@ -92,9 +77,10 @@ module.exports = { return; } - // jsx-a11y parity: `autofocus="false"` / `={{false}}` / `={{"false"}}` - // explicitly opt out and should not be flagged. - if (isExplicitlyFalsy(autofocusAttr.value)) { + // Mustache boolean-literal `autofocus={{false}}` renders no attribute + // at all — the only statically-known opt-out consistent with HTML + // boolean-attribute semantics. + if (isMustacheBooleanFalse(autofocusAttr.value)) { return; } @@ -132,16 +118,14 @@ module.exports = { return; } - // Value-aware check for component/helper invocations such as - // `{{input autofocus=false}}` or `{{input autofocus="false"}}`. + // Mustache hash-pair `{{input autofocus=false}}` — boolean literal + // false at the hash-pair level is unambiguous and renders no attr. + // Note: `autofocus="false"` in mustache syntax IS still flagged — per + // HTML boolean-attribute semantics the string "false" is truthy; it + // is only jsx-a11y that carves that form out. const pairValue = autofocusPair.value; - if (pairValue) { - if (pairValue.type === 'GlimmerBooleanLiteral' && pairValue.value === false) { - return; - } - if (pairValue.type === 'GlimmerStringLiteral' && pairValue.value === 'false') { - return; - } + if (pairValue?.type === 'GlimmerBooleanLiteral' && pairValue.value === false) { + return; } context.report({ diff --git a/tests/lib/rules/template-no-autofocus-attribute.js b/tests/lib/rules/template-no-autofocus-attribute.js index 9709506c3a..db021179a4 100644 --- a/tests/lib/rules/template-no-autofocus-attribute.js +++ b/tests/lib/rules/template-no-autofocus-attribute.js @@ -22,22 +22,15 @@ ruleTester.run('template-no-autofocus-attribute', rule, { ``, - // Value-aware: explicit falsy values opt out (jsx-a11y parity). - ``, + // Mustache boolean-literal forms render NO attribute when the literal + // is false — these are the statically-known opt-outs that align with + // HTML boolean-attribute semantics. ``, - ``, ``, - ``, // Dialog exception (MDN): autofocus on is recommended. ``, + // Dialog exception also applies to the mustache form of the `` + // helper (`{{input autofocus=true}}`) — whether direct child or nested. + ``, + ``, ], invalid: [ From c2a4128947d4759c1400cf6f0aa79ca91f435e31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Thu, 23 Apr 2026 21:39:52 +0200 Subject: [PATCH 6/9] docs: address Copilot review wording (PR #32) --- docs/rules/template-no-autofocus-attribute.md | 9 ++++++--- lib/rules/template-no-autofocus-attribute.js | 5 +++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/rules/template-no-autofocus-attribute.md b/docs/rules/template-no-autofocus-attribute.md index b049b29668..f09fc774ea 100644 --- a/docs/rules/template-no-autofocus-attribute.md +++ b/docs/rules/template-no-autofocus-attribute.md @@ -40,13 +40,16 @@ Examples of **correct** code for this rule: ``` -Explicit opt-out via a falsy value is allowed (parity with -[`jsx-a11y/no-autofocus`](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/no-autofocus.md)): +Explicit opt-out via a mustache boolean `false` is allowed — this is the +only form that statically guarantees no rendered `autofocus` attribute +(Glimmer VM normalizes `{{false}}` to attribute removal). The string +`autofocus="false"` is still flagged per HTML boolean-attribute semantics +(any attribute presence, including the string `"false"`, enables autofocus). ```gjs ``` diff --git a/lib/rules/template-no-autofocus-attribute.js b/lib/rules/template-no-autofocus-attribute.js index 3613222b1b..3c4271b47b 100644 --- a/lib/rules/template-no-autofocus-attribute.js +++ b/lib/rules/template-no-autofocus-attribute.js @@ -31,8 +31,9 @@ function isMustacheBooleanFalse(value) { } /** - * Returns true when the given GlimmerElementNode is a `` element - * or is nested (at any depth) inside a `` element. Per MDN, + * Returns true when the given node (a GlimmerElementNode OR a + * GlimmerMustacheStatement, e.g. `{{input autofocus=true}}`) is a `` + * element or is nested (at any depth) inside a `` element. Per MDN, * autofocus on (or within) a dialog is recommended because a dialog should * focus its initial element when opened. * From c97a13138daef54fda44c26c92557600dd3e1b44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Fri, 24 Apr 2026 13:25:52 +0200 Subject: [PATCH 7/9] fix(#32): address round-2 Copilot review (document hash-pair opt-out; reword {{input}} comment) --- docs/rules/template-no-autofocus-attribute.md | 3 +++ tests/lib/rules/template-no-autofocus-attribute.js | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/rules/template-no-autofocus-attribute.md b/docs/rules/template-no-autofocus-attribute.md index f09fc774ea..b257beac98 100644 --- a/docs/rules/template-no-autofocus-attribute.md +++ b/docs/rules/template-no-autofocus-attribute.md @@ -50,6 +50,9 @@ only form that statically guarantees no rendered `autofocus` attribute ``` diff --git a/tests/lib/rules/template-no-autofocus-attribute.js b/tests/lib/rules/template-no-autofocus-attribute.js index 8f0eda40e0..67557133c0 100644 --- a/tests/lib/rules/template-no-autofocus-attribute.js +++ b/tests/lib/rules/template-no-autofocus-attribute.js @@ -48,8 +48,8 @@ ruleTester.run('template-no-autofocus-attribute', rule, {
`, - // Dialog exception also applies to the mustache form of the `` - // helper (`{{input autofocus=true}}`) — whether direct child or nested. + // Dialog exception also applies to the classic mustache form + // (`{{input autofocus=true}}`) — whether direct child or nested. ``, + + // Custom helpers / components taking an `autofocus` prop are opaque — + // we can't know whether the prop forwards to a native + // or is used for something else. Narrow to {{input}} / {{component + // "input"}} which deterministically render native inputs. + ``, + ``, + ``, ], invalid: [ From 7ca1edc7371c89f7f40c26be6b02c2459d186d4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Fri, 24 Apr 2026 15:31:15 +0200 Subject: [PATCH 9/9] lint: normalize string quotes to single per quotes rule --- tests/lib/rules/template-no-autofocus-attribute.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/lib/rules/template-no-autofocus-attribute.js b/tests/lib/rules/template-no-autofocus-attribute.js index 6c81282117..08571b62a0 100644 --- a/tests/lib/rules/template-no-autofocus-attribute.js +++ b/tests/lib/rules/template-no-autofocus-attribute.js @@ -67,9 +67,9 @@ ruleTester.run('template-no-autofocus-attribute', rule, { // we can't know whether the prop forwards to a native // or is used for something else. Narrow to {{input}} / {{component // "input"}} which deterministically render native inputs. - ``, - ``, - ``, + '', + '', + '', ], invalid: [