Skip to content
Closed
22 changes: 22 additions & 0 deletions docs/rules/template-no-autofocus-attribute.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,28 @@ Examples of **correct** code for this rule:
</template>
```

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
<template>
<input autofocus="false" />
<input autofocus={{false}} />
Comment thread
johanrd marked this conversation as resolved.
Outdated
</template>
```

`<dialog>` 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
<template>
<dialog>
<button autofocus>Close</button>
</dialog>
</template>
```

## 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.
Expand Down
122 changes: 101 additions & 21 deletions lib/rules/template-no-autofocus-attribute.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,56 @@
/**
* `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.
*
* 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).
Comment thread
johanrd marked this conversation as resolved.
Outdated
*
* Verified against Glimmer VM's attribute-normalization source:
* glimmer-vm/packages/@glimmer/runtime/lib/vm/attributes/dynamic.ts —
* `normalizeValue` returns `null` for `false | undefined | null`, and
* `SimpleDynamicAttribute.update()` calls `element.removeAttribute(name)`
* when the normalized value is null. So `autofocus={{false}}` renders
* with the attribute entirely absent from the DOM.
*/
function isMustacheBooleanFalse(value) {
if (value?.type !== 'GlimmerMustacheStatement') {
return false;
}
const expr = value.path;
return expr?.type === 'GlimmerBooleanLiteral' && expr.value === false;
}

/**
* Returns true when the given GlimmerElementNode is a `<dialog>` element
* or is nested (at any depth) inside a `<dialog>` element. Per MDN,
* autofocus on (or within) a dialog is recommended because a dialog should
* focus its initial element when opened.
*
Comment thread
johanrd marked this conversation as resolved.
Outdated
* 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: {
Expand Down Expand Up @@ -27,38 +80,65 @@ 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];
if (!autofocusAttr) {
return;
}

let removeStart = attrStart;
while (removeStart > 0 && /\s/.test(text[removeStart - 1])) {
removeStart--;
}
// 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;
}

return fixer.removeRange([removeStart, attrEnd]);
},
});
// MDN dialog exception: autofocus on a <dialog> or inside a <dialog>
// 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) {
if (!node.hash || !node.hash.pairs) {
return;
}
const autofocusPair = node.hash.pairs.find((pair) => pair.key === 'autofocus');
if (autofocusPair) {
context.report({
node: autofocusPair,
messageId: 'noAutofocus',
});
if (!autofocusPair) {
return;
}
Comment thread
johanrd marked this conversation as resolved.

// 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?.type === 'GlimmerBooleanLiteral' && pairValue.value === false) {
return;
}

context.report({
node: autofocusPair,
messageId: 'noAutofocus',
});
Comment thread
johanrd marked this conversation as resolved.
},
};
},
Expand Down
131 changes: 131 additions & 0 deletions tests/lib/rules/template-no-autofocus-attribute.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,32 @@ ruleTester.run('template-no-autofocus-attribute', rule, {
`<template>
<button>Click me</button>
</template>`,
// 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.
`<template>
<input autofocus={{false}} />
</template>`,
`<template>
{{input autofocus=false}}
</template>`,
// Dialog exception (MDN): autofocus on <dialog> is recommended.
`<template>
<dialog autofocus></dialog>
</template>`,
// Dialog descendants are also exempt (angular-eslint parity).
`<template>
<dialog>
<button autofocus>Close</button>
</dialog>
</template>`,
`<template>
<dialog>
<div>
<input autofocus />
</div>
</dialog>
</template>`,
],

invalid: [
Expand Down Expand Up @@ -123,5 +149,110 @@ ruleTester.run('template-no-autofocus-attribute', rule, {
},
],
},
// Value-aware: truthy literals and any dynamic value still flag.
{
code: `<template>
<input autofocus="true" />
</template>`,
output: `<template>
<input />
</template>`,
errors: [
{
messageId: 'noAutofocus',
type: 'GlimmerAttrNode',
},
],
},
{
code: `<template>
<input autofocus={{true}} />
</template>`,
output: `<template>
<input />
</template>`,
errors: [
{
messageId: 'noAutofocus',
type: 'GlimmerAttrNode',
},
],
},
{
code: `<template>
<input autofocus={{"true"}} />
</template>`,
output: `<template>
<input />
</template>`,
errors: [
{
messageId: 'noAutofocus',
type: 'GlimmerAttrNode',
},
],
},
{
code: `<template>
<input autofocus={{this.shouldFocus}} />
</template>`,
output: `<template>
<input />
</template>`,
errors: [
{
messageId: 'noAutofocus',
type: 'GlimmerAttrNode',
},
],
},
// Dialog exception only applies within <dialog>; siblings elsewhere still flag.
{
code: `<template>
<section>
<button autofocus>Focus</button>
</section>
</template>`,
output: `<template>
<section>
<button>Focus</button>
</section>
</template>`,
errors: [
{
messageId: 'noAutofocus',
type: 'GlimmerAttrNode',
},
],
},

// Per HTML boolean-attribute semantics, the string "false" / mustache
// string "false" / hash-pair string "false" are all TRUTHY. Only the
// mustache boolean-literal {{false}} renders no attribute.
{
code: `<template>
<input autofocus="false" />
</template>`,
output: `<template>
<input />
</template>`,
errors: [{ messageId: 'noAutofocus', type: 'GlimmerAttrNode' }],
},
{
code: `<template>
<input autofocus={{"false"}} />
</template>`,
output: `<template>
<input />
</template>`,
errors: [{ messageId: 'noAutofocus', type: 'GlimmerAttrNode' }],
},
{
code: `<template>
{{input autofocus="false"}}
</template>`,
output: null,
errors: [{ messageId: 'noAutofocus' }],
},
],
});
Loading