Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
0c1d701
fix(template-no-invalid-role): source valid roles from aria-query; su…
johanrd Apr 21, 2026
8ec2054
fix: template-no-autofocus-attribute — value-aware + <dialog> exception
johanrd Apr 21, 2026
bcabf34
chore: drop temporal 'previously flagged' comment
johanrd Apr 21, 2026
6588862
docs: document <dialog> and falsy-value exceptions
johanrd Apr 21, 2026
b99708a
test: add Phase 3 audit fixture translating aria-role peer cases
johanrd Apr 21, 2026
823631f
fix(template-no-invalid-role): drop unsupported ARIA 1.3 allowlist to…
johanrd Apr 21, 2026
1670293
fix(template-no-autofocus-attribute): align value-aware check with HT…
johanrd Apr 21, 2026
6d4fcda
docs: cite Glimmer VM source verifying autofocus={{false}} omits attr…
johanrd Apr 22, 2026
d0844e1
fix: apply <dialog> exemption to mustache form + update comment (Copi…
johanrd Apr 22, 2026
39d2d27
fix: add 3 missing ARIA 1.3 roles + report specific offending token (…
johanrd Apr 22, 2026
7e45da3
docs: correct audit-fixture CI-run claim (Copilot review)
johanrd Apr 22, 2026
c2a4128
docs: address Copilot review wording (PR #32)
johanrd Apr 23, 2026
f4b5b49
fix(template-no-invalid-role): flag presentation/none only when it's …
johanrd Apr 24, 2026
0a3d1da
lint: reorder test-case properties to [code, output, options, errors]
johanrd Apr 24, 2026
c97a131
fix(#32): address round-2 Copilot review (document hash-pair opt-out;…
johanrd Apr 24, 2026
a3a3884
fix(#55): address round-2 Copilot review (correct audit-fixture heade…
johanrd Apr 24, 2026
57f55fc
fix(template-no-autofocus-attribute): narrow mustache check to native…
johanrd Apr 24, 2026
7ca1edc
lint: normalize string quotes to single per quotes rule
johanrd Apr 24, 2026
909e56d
fix(template-no-invalid-role): preserve author casing in invalid-role…
johanrd Apr 24, 2026
5919767
test(template-no-invalid-role): absorb audit-fixture cases, drop audi…
johanrd Apr 25, 2026
41087e0
fix(template-no-invalid-role): flag empty / whitespace-only role attr…
johanrd Apr 25, 2026
30ce5df
Merge pull request #2743 from johanrd/fix/autofocus-value-aware-and-d…
NullVoxPopuli Apr 25, 2026
f25c34e
chore(deps): update node.js to v24.15.0 (#2740)
renovate[bot] Apr 25, 2026
e34533b
Merge branch 'master' into fix/invalid-role-aria-query
johanrd Apr 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 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,34 @@ Examples of **correct** code for this rule:
</template>
```

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
<template>
<input autofocus={{false}} />
{{!-- element syntax: the mustache-boolean form --}}

{{input autofocus=false}}
{{!-- mustache syntax: the hash-pair form --}}
</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
175 changes: 152 additions & 23 deletions lib/rules/template-no-autofocus-attribute.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,80 @@
/**
* `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 boolean-literal `false` — in element syntax
* written as `autofocus={{false}}`, and in mustache hash syntax written as
* `{{input autofocus=false}}`. Both forms express intent to omit the
* attribute conditionally, and the rendered HTML will have no autofocus
* attribute. Treat both literal-false cases as opt-out.
*
* 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 node (a GlimmerElementNode OR a
* GlimmerMustacheStatement, e.g. `{{input autofocus=true}}`) 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.
*
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog
*/
// Returns true for `{{input ...}}` and `{{component "input" ...}}` mustache
// invocations — the only built-ins that deterministically render a native
// <input> with forwarded attributes.
function isNativeInputHelper(node) {
const path = node.path;
if (!path) {
return false;
}
// Direct invocation: `{{input ...}}`.
if (path.type === 'GlimmerPathExpression' && path.original === 'input') {
return true;
}
// Contextual component: `{{component "input" ...}}`.
if (path.type === 'GlimmerPathExpression' && path.original === 'component') {
const firstParam = node.params && node.params[0];
if (firstParam && firstParam.type === 'GlimmerStringLiteral' && firstParam.value === 'input') {
return true;
}
}
return false;
}

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 +104,90 @@ 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;
}

// 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;
}

// 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;
}

// Narrow to helpers that deterministically render a native `autofocus`
// attribute. The rule's purpose is the HTML attribute; arbitrary
// components taking an `autofocus` prop are opaque — we can't tell
// statically whether that prop forwards to HTML or is used for
// something else.
// - `{{input ...}}` — Ember's classic input helper renders a native
// <input> with forwarded attributes.
// - `{{component "input" ...}}` — contextual component resolution
// points at the same helper.
//
// FUTURE: when type-aware linting lands (e.g., Glint integration or
// a template-type-check step), we can resolve custom components that
// forward `autofocus` to a native <input> and flag those too. For now
// we stay conservative to avoid false positives on unrelated helpers
// that happen to take an `autofocus` prop.
if (!isNativeInputHelper(node)) {
return;
}

// 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;
}

// MDN dialog exception: autofocus on a mustache component/helper
// nested inside a <dialog> is recommended behavior, not a defect.
if (isInsideDialog(node)) {
return;
}

context.report({
node: autofocusPair,
messageId: 'noAutofocus',
});
},
};
},
Expand Down
Loading
Loading