Skip to content

Commit 30ce5df

Browse files
Merge pull request #2743 from johanrd/fix/autofocus-value-aware-and-dialog
fix: template-no-autofocus-attribute — value-aware + <dialog> exception
2 parents e170971 + 7ca1edc commit 30ce5df

3 files changed

Lines changed: 333 additions & 23 deletions

File tree

docs/rules/template-no-autofocus-attribute.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,34 @@ Examples of **correct** code for this rule:
4040
</template>
4141
```
4242

43+
Explicit opt-out via a mustache boolean `false` is allowed — this is the
44+
only form that statically guarantees no rendered `autofocus` attribute
45+
(Glimmer VM normalizes `{{false}}` to attribute removal). The string
46+
`autofocus="false"` is still flagged per HTML boolean-attribute semantics
47+
(any attribute presence, including the string `"false"`, enables autofocus).
48+
49+
```gjs
50+
<template>
51+
<input autofocus={{false}} />
52+
{{!-- element syntax: the mustache-boolean form --}}
53+
54+
{{input autofocus=false}}
55+
{{!-- mustache syntax: the hash-pair form --}}
56+
</template>
57+
```
58+
59+
`<dialog>` and its descendants are exempt. A dialog is expected to focus its
60+
initial element on open, per
61+
[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog):
62+
63+
```gjs
64+
<template>
65+
<dialog>
66+
<button autofocus>Close</button>
67+
</dialog>
68+
</template>
69+
```
70+
4371
## When Not To Use It
4472

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

lib/rules/template-no-autofocus-attribute.js

Lines changed: 152 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,80 @@
1+
/**
2+
* `autofocus` is a boolean HTML attribute. Per the HTML spec, any presence
3+
* (including `autofocus="false"`, `autofocus=""`, `autofocus="autofocus"`)
4+
* means the element will auto-focus. Only the genuine absence of the
5+
* attribute turns off auto-focus.
6+
*
7+
* jsx-a11y's `no-autofocus` treats `autofocus={false}` / `autofocus="false"`
8+
* as opt-outs — that is a peer-plugin convention that diverges from HTML
9+
* boolean-attribute semantics. vue-a11y and lit-a11y are presence-based,
10+
* consistent with the spec. We follow the spec.
11+
*
12+
* The only exception is a boolean-literal `false` — in element syntax
13+
* written as `autofocus={{false}}`, and in mustache hash syntax written as
14+
* `{{input autofocus=false}}`. Both forms express intent to omit the
15+
* attribute conditionally, and the rendered HTML will have no autofocus
16+
* attribute. Treat both literal-false cases as opt-out.
17+
*
18+
* Verified against Glimmer VM's attribute-normalization source:
19+
* glimmer-vm/packages/@glimmer/runtime/lib/vm/attributes/dynamic.ts —
20+
* `normalizeValue` returns `null` for `false | undefined | null`, and
21+
* `SimpleDynamicAttribute.update()` calls `element.removeAttribute(name)`
22+
* when the normalized value is null. So `autofocus={{false}}` renders
23+
* with the attribute entirely absent from the DOM.
24+
*/
25+
function isMustacheBooleanFalse(value) {
26+
if (value?.type !== 'GlimmerMustacheStatement') {
27+
return false;
28+
}
29+
const expr = value.path;
30+
return expr?.type === 'GlimmerBooleanLiteral' && expr.value === false;
31+
}
32+
33+
/**
34+
* Returns true when the given node (a GlimmerElementNode OR a
35+
* GlimmerMustacheStatement, e.g. `{{input autofocus=true}}`) is a `<dialog>`
36+
* element or is nested (at any depth) inside a `<dialog>` element. Per MDN,
37+
* autofocus on (or within) a dialog is recommended because a dialog should
38+
* focus its initial element when opened.
39+
*
40+
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog
41+
*/
42+
// Returns true for `{{input ...}}` and `{{component "input" ...}}` mustache
43+
// invocations — the only built-ins that deterministically render a native
44+
// <input> with forwarded attributes.
45+
function isNativeInputHelper(node) {
46+
const path = node.path;
47+
if (!path) {
48+
return false;
49+
}
50+
// Direct invocation: `{{input ...}}`.
51+
if (path.type === 'GlimmerPathExpression' && path.original === 'input') {
52+
return true;
53+
}
54+
// Contextual component: `{{component "input" ...}}`.
55+
if (path.type === 'GlimmerPathExpression' && path.original === 'component') {
56+
const firstParam = node.params && node.params[0];
57+
if (firstParam && firstParam.type === 'GlimmerStringLiteral' && firstParam.value === 'input') {
58+
return true;
59+
}
60+
}
61+
return false;
62+
}
63+
64+
function isInsideDialog(node) {
65+
if (node.type === 'GlimmerElementNode' && node.tag === 'dialog') {
66+
return true;
67+
}
68+
let ancestor = node.parent;
69+
while (ancestor) {
70+
if (ancestor.type === 'GlimmerElementNode' && ancestor.tag === 'dialog') {
71+
return true;
72+
}
73+
ancestor = ancestor.parent;
74+
}
75+
return false;
76+
}
77+
178
/** @type {import('eslint').Rule.RuleModule} */
279
module.exports = {
380
meta: {
@@ -27,38 +104,90 @@ module.exports = {
27104
GlimmerElementNode(node) {
28105
const autofocusAttr = node.attributes?.find((attr) => attr.name === 'autofocus');
29106

30-
if (autofocusAttr) {
31-
context.report({
32-
node: autofocusAttr,
33-
messageId: 'noAutofocus',
34-
fix(fixer) {
35-
const sourceCode = context.sourceCode;
36-
const text = sourceCode.getText();
37-
const attrStart = autofocusAttr.range[0];
38-
const attrEnd = autofocusAttr.range[1];
39-
40-
let removeStart = attrStart;
41-
while (removeStart > 0 && /\s/.test(text[removeStart - 1])) {
42-
removeStart--;
43-
}
44-
45-
return fixer.removeRange([removeStart, attrEnd]);
46-
},
47-
});
107+
if (!autofocusAttr) {
108+
return;
109+
}
110+
111+
// Mustache boolean-literal `autofocus={{false}}` renders no attribute
112+
// at all — the only statically-known opt-out consistent with HTML
113+
// boolean-attribute semantics.
114+
if (isMustacheBooleanFalse(autofocusAttr.value)) {
115+
return;
116+
}
117+
118+
// MDN dialog exception: autofocus on a <dialog> or inside a <dialog>
119+
// is recommended behavior, not an accessibility defect.
120+
if (isInsideDialog(node)) {
121+
return;
48122
}
123+
124+
context.report({
125+
node: autofocusAttr,
126+
messageId: 'noAutofocus',
127+
fix(fixer) {
128+
const sourceCode = context.sourceCode;
129+
const text = sourceCode.getText();
130+
const attrStart = autofocusAttr.range[0];
131+
const attrEnd = autofocusAttr.range[1];
132+
133+
let removeStart = attrStart;
134+
while (removeStart > 0 && /\s/.test(text[removeStart - 1])) {
135+
removeStart--;
136+
}
137+
138+
return fixer.removeRange([removeStart, attrEnd]);
139+
},
140+
});
49141
},
50142

51143
GlimmerMustacheStatement(node) {
52144
if (!node.hash || !node.hash.pairs) {
53145
return;
54146
}
55147
const autofocusPair = node.hash.pairs.find((pair) => pair.key === 'autofocus');
56-
if (autofocusPair) {
57-
context.report({
58-
node: autofocusPair,
59-
messageId: 'noAutofocus',
60-
});
148+
if (!autofocusPair) {
149+
return;
150+
}
151+
152+
// Narrow to helpers that deterministically render a native `autofocus`
153+
// attribute. The rule's purpose is the HTML attribute; arbitrary
154+
// components taking an `autofocus` prop are opaque — we can't tell
155+
// statically whether that prop forwards to HTML or is used for
156+
// something else.
157+
// - `{{input ...}}` — Ember's classic input helper renders a native
158+
// <input> with forwarded attributes.
159+
// - `{{component "input" ...}}` — contextual component resolution
160+
// points at the same helper.
161+
//
162+
// FUTURE: when type-aware linting lands (e.g., Glint integration or
163+
// a template-type-check step), we can resolve custom components that
164+
// forward `autofocus` to a native <input> and flag those too. For now
165+
// we stay conservative to avoid false positives on unrelated helpers
166+
// that happen to take an `autofocus` prop.
167+
if (!isNativeInputHelper(node)) {
168+
return;
169+
}
170+
171+
// Mustache hash-pair `{{input autofocus=false}}` — boolean literal
172+
// false at the hash-pair level is unambiguous and renders no attr.
173+
// Note: `autofocus="false"` in mustache syntax IS still flagged — per
174+
// HTML boolean-attribute semantics the string "false" is truthy; it
175+
// is only jsx-a11y that carves that form out.
176+
const pairValue = autofocusPair.value;
177+
if (pairValue?.type === 'GlimmerBooleanLiteral' && pairValue.value === false) {
178+
return;
179+
}
180+
181+
// MDN dialog exception: autofocus on a mustache component/helper
182+
// nested inside a <dialog> is recommended behavior, not a defect.
183+
if (isInsideDialog(node)) {
184+
return;
61185
}
186+
187+
context.report({
188+
node: autofocusPair,
189+
messageId: 'noAutofocus',
190+
});
62191
},
63192
};
64193
},

tests/lib/rules/template-no-autofocus-attribute.js

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,54 @@ ruleTester.run('template-no-autofocus-attribute', rule, {
2222
`<template>
2323
<button>Click me</button>
2424
</template>`,
25+
// Mustache boolean-literal forms render NO attribute when the literal
26+
// is false — these are the statically-known opt-outs that align with
27+
// HTML boolean-attribute semantics.
28+
`<template>
29+
<input autofocus={{false}} />
30+
</template>`,
31+
`<template>
32+
{{input autofocus=false}}
33+
</template>`,
34+
// Dialog exception (MDN): autofocus on <dialog> is recommended.
35+
`<template>
36+
<dialog autofocus></dialog>
37+
</template>`,
38+
// Dialog descendants are also exempt (angular-eslint parity).
39+
`<template>
40+
<dialog>
41+
<button autofocus>Close</button>
42+
</dialog>
43+
</template>`,
44+
`<template>
45+
<dialog>
46+
<div>
47+
<input autofocus />
48+
</div>
49+
</dialog>
50+
</template>`,
51+
// Dialog exception also applies to the classic mustache form
52+
// (`{{input autofocus=true}}`) — whether direct child or nested.
53+
`<template>
54+
<dialog>
55+
{{input autofocus=true}}
56+
</dialog>
57+
</template>`,
58+
`<template>
59+
<dialog>
60+
<div>
61+
{{input autofocus=true}}
62+
</div>
63+
</dialog>
64+
</template>`,
65+
66+
// Custom helpers / components taking an `autofocus` prop are opaque —
67+
// we can't know whether the prop forwards to a native <input autofocus>
68+
// or is used for something else. Narrow to {{input}} / {{component
69+
// "input"}} which deterministically render native inputs.
70+
'<template>{{my-wrapper autofocus=true}}</template>',
71+
'<template>{{some-component autofocus=true name="foo"}}</template>',
72+
'<template>{{component "some-other-helper" autofocus=true}}</template>',
2573
],
2674

2775
invalid: [
@@ -123,5 +171,110 @@ ruleTester.run('template-no-autofocus-attribute', rule, {
123171
},
124172
],
125173
},
174+
// Value-aware: truthy literals and any dynamic value still flag.
175+
{
176+
code: `<template>
177+
<input autofocus="true" />
178+
</template>`,
179+
output: `<template>
180+
<input />
181+
</template>`,
182+
errors: [
183+
{
184+
messageId: 'noAutofocus',
185+
type: 'GlimmerAttrNode',
186+
},
187+
],
188+
},
189+
{
190+
code: `<template>
191+
<input autofocus={{true}} />
192+
</template>`,
193+
output: `<template>
194+
<input />
195+
</template>`,
196+
errors: [
197+
{
198+
messageId: 'noAutofocus',
199+
type: 'GlimmerAttrNode',
200+
},
201+
],
202+
},
203+
{
204+
code: `<template>
205+
<input autofocus={{"true"}} />
206+
</template>`,
207+
output: `<template>
208+
<input />
209+
</template>`,
210+
errors: [
211+
{
212+
messageId: 'noAutofocus',
213+
type: 'GlimmerAttrNode',
214+
},
215+
],
216+
},
217+
{
218+
code: `<template>
219+
<input autofocus={{this.shouldFocus}} />
220+
</template>`,
221+
output: `<template>
222+
<input />
223+
</template>`,
224+
errors: [
225+
{
226+
messageId: 'noAutofocus',
227+
type: 'GlimmerAttrNode',
228+
},
229+
],
230+
},
231+
// Dialog exception only applies within <dialog>; siblings elsewhere still flag.
232+
{
233+
code: `<template>
234+
<section>
235+
<button autofocus>Focus</button>
236+
</section>
237+
</template>`,
238+
output: `<template>
239+
<section>
240+
<button>Focus</button>
241+
</section>
242+
</template>`,
243+
errors: [
244+
{
245+
messageId: 'noAutofocus',
246+
type: 'GlimmerAttrNode',
247+
},
248+
],
249+
},
250+
251+
// Per HTML boolean-attribute semantics, the string "false" / mustache
252+
// string "false" / hash-pair string "false" are all TRUTHY. Only the
253+
// mustache boolean-literal {{false}} renders no attribute.
254+
{
255+
code: `<template>
256+
<input autofocus="false" />
257+
</template>`,
258+
output: `<template>
259+
<input />
260+
</template>`,
261+
errors: [{ messageId: 'noAutofocus', type: 'GlimmerAttrNode' }],
262+
},
263+
{
264+
code: `<template>
265+
<input autofocus={{"false"}} />
266+
</template>`,
267+
output: `<template>
268+
<input />
269+
</template>`,
270+
errors: [{ messageId: 'noAutofocus', type: 'GlimmerAttrNode' }],
271+
},
272+
{
273+
code: `<template>
274+
{{input autofocus="false"}}
275+
</template>`,
276+
output: null,
277+
errors: [{ messageId: 'noAutofocus' }],
278+
},
126279
],
127280
});

0 commit comments

Comments
 (0)