diff --git a/docs/rules/template-no-autofocus-attribute.md b/docs/rules/template-no-autofocus-attribute.md index b3bc9bd425..b257beac98 100644 --- a/docs/rules/template-no-autofocus-attribute.md +++ b/docs/rules/template-no-autofocus-attribute.md @@ -40,6 +40,34 @@ Examples of **correct** code for this rule: ``` +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 + +``` + +`` 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. diff --git a/lib/rules/template-no-autofocus-attribute.js b/lib/rules/template-no-autofocus-attribute.js index d01c000e18..b63768b9f1 100644 --- a/lib/rules/template-no-autofocus-attribute.js +++ b/lib/rules/template-no-autofocus-attribute.js @@ -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 `` + * 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 + */ +// Returns true for `{{input ...}}` and `{{component "input" ...}}` mustache +// invocations — the only built-ins that deterministically render a native +// 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: { @@ -27,25 +104,40 @@ 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 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 +145,49 @@ module.exports = { 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 + // 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 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 is recommended behavior, not a defect. + if (isInsideDialog(node)) { + return; } + + context.report({ + node: autofocusPair, + messageId: 'noAutofocus', + }); }, }; }, diff --git a/lib/rules/template-no-invalid-role.js b/lib/rules/template-no-invalid-role.js index 9c82372821..4c694db747 100644 --- a/lib/rules/template-no-invalid-role.js +++ b/lib/rules/template-no-invalid-role.js @@ -1,92 +1,22 @@ -const VALID_ROLES = new Set([ - 'alert', - 'alertdialog', - 'application', - 'article', +const { roles } = require('aria-query'); + +// Valid ARIA roles = concrete (non-abstract) entries from aria-query, plus the +// WAI-ARIA 1.3 draft roles that aria-query 5.3.2 doesn't yet ship. The +// ARIA 1.2 base roles, DPUB-ARIA (doc-*), and Graphics-ARIA (graphics-*) all +// come from aria-query. `associationlist*`, `comment`, and `suggestion` are in +// the current ARIA 1.3 editor's draft (https://w3c.github.io/aria/) but not +// yet in aria-query, so they're listed here until the next aria-query release +// adds them. +const ARIA_13_DRAFT_ROLES = [ 'associationlist', 'associationlistitemkey', 'associationlistitemvalue', - 'banner', - 'blockquote', - 'button', - 'caption', - 'cell', - 'checkbox', - 'code', - 'columnheader', - 'combobox', 'comment', - 'complementary', - 'contentinfo', - 'definition', - 'deletion', - 'dialog', - 'directory', - 'document', - 'emphasis', - 'feed', - 'figure', - 'form', - 'generic', - 'grid', - 'gridcell', - 'group', - 'heading', - 'img', - 'insertion', - 'link', - 'list', - 'listbox', - 'listitem', - 'log', - 'main', - 'mark', - 'marquee', - 'math', - 'menu', - 'menubar', - 'menuitem', - 'menuitemcheckbox', - 'menuitemradio', - 'meter', - 'navigation', - 'none', - 'note', - 'option', - 'paragraph', - 'presentation', - 'progressbar', - 'radio', - 'radiogroup', - 'region', - 'row', - 'rowgroup', - 'rowheader', - 'scrollbar', - 'search', - 'searchbox', - 'separator', - 'slider', - 'spinbutton', - 'status', - 'strong', - 'subscript', 'suggestion', - 'superscript', - 'switch', - 'tab', - 'table', - 'tablist', - 'tabpanel', - 'term', - 'textbox', - 'time', - 'timer', - 'toolbar', - 'tooltip', - 'tree', - 'treegrid', - 'treeitem', +]; +const VALID_ROLES = new Set([ + ...[...roles.keys()].filter((role) => !roles.get(role).abstract), + ...ARIA_13_DRAFT_ROLES, ]); // Elements with semantic meaning that should not be given role="presentation" or role="none" @@ -225,34 +155,56 @@ module.exports = { return; } - const role = roleAttr.value.chars.trim(); - if (!role) { - return; - } - - const roleLower = role.toLowerCase(); - - // Check for nonexistent roles - if (catchNonexistentRoles && !VALID_ROLES.has(roleLower)) { + // ARIA role attribute is a whitespace-separated list of tokens + // (role-fallback pattern per ARIA 1.2 §5.4). An empty / whitespace- + // only value supplies zero tokens — flag as `role=""` to catch the + // authoring mistake (matches jsx-a11y / vue-a11y). + const raw = roleAttr.value.chars.trim(); + if (!raw) { context.report({ node: roleAttr, messageId: 'invalid', - data: { role }, + data: { role: '' }, }); return; } - // Check for presentation/none role on semantic elements (case-insensitive per WAI-ARIA 1.2: - // "Case-sensitivity of the comparison inherits from the case-sensitivity of the host language" - // and HTML is case-insensitive — https://www.w3.org/TR/wai-aria-1.2/#document-handling_author-errors_roles) + // Validate each token. Keep the original casing alongside the + // normalized (lowercase) form so reported tokens preserve author + // intent — validation is case-insensitive, the ERROR MESSAGE isn't. + const rawTokens = raw.split(/\s+/u); + const tokens = rawTokens.map((t) => t.toLowerCase()); + + if (catchNonexistentRoles) { + const invalidIdx = tokens.findIndex((token) => !VALID_ROLES.has(token)); + if (invalidIdx !== -1) { + context.report({ + node: roleAttr, + messageId: 'invalid', + data: { role: rawTokens[invalidIdx] }, + }); + return; + } + } + + // Flag presentation/none only when it's the FIRST recognised role per + // WAI-ARIA §4.1 fallback semantics — UAs walk the token list and use + // the first role they recognise; subsequent tokens are author-provided + // fallbacks that never take effect if the first is recognised. So + // `role="button presentation"` resolves to `button` at runtime and + // should NOT flag. `role="xxyxyz presentation"` resolves to + // `presentation` (unknown tokens are skipped) and SHOULD flag on a + // semantic element. Case-insensitivity inherits from HTML per §4.1: + // https://www.w3.org/TR/wai-aria-1.2/#document-handling_author-errors_roles + const firstRecognisedRole = tokens.find((t) => VALID_ROLES.has(t)); if ( - (roleLower === 'presentation' || roleLower === 'none') && + (firstRecognisedRole === 'presentation' || firstRecognisedRole === 'none') && SEMANTIC_ELEMENTS.has(node.tag) ) { context.report({ node: roleAttr, messageId: 'presentationOnSemantic', - data: { role, tag: node.tag }, + data: { role: firstRecognisedRole, tag: node.tag }, }); } }, diff --git a/package.json b/package.json index fa43993f9c..c8a1881564 100644 --- a/package.json +++ b/package.json @@ -131,7 +131,7 @@ "node": ">= 20.19" }, "volta": { - "node": "24.14.1", + "node": "24.15.0", "pnpm": "10.33.1" }, "publishConfig": { diff --git a/tests/lib/rules/template-no-autofocus-attribute.js b/tests/lib/rules/template-no-autofocus-attribute.js index ad67f03943..08571b62a0 100644 --- a/tests/lib/rules/template-no-autofocus-attribute.js +++ b/tests/lib/rules/template-no-autofocus-attribute.js @@ -22,6 +22,54 @@ ruleTester.run('template-no-autofocus-attribute', rule, { ``, + // 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 descendants are also exempt (angular-eslint parity). + ``, + ``, + // 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: [ @@ -123,5 +171,110 @@ 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', + }, + ], + }, + + // 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: ``, + output: ``, + errors: [{ messageId: 'noAutofocus', type: 'GlimmerAttrNode' }], + }, + { + code: ``, + output: ``, + errors: [{ messageId: 'noAutofocus', type: 'GlimmerAttrNode' }], + }, + { + code: ``, + output: null, + errors: [{ messageId: 'noAutofocus' }], + }, ], }); diff --git a/tests/lib/rules/template-no-invalid-role.js b/tests/lib/rules/template-no-invalid-role.js index efca5e138a..1df074bdc8 100644 --- a/tests/lib/rules/template-no-invalid-role.js +++ b/tests/lib/rules/template-no-invalid-role.js @@ -57,9 +57,6 @@ ruleTester.run('template-no-invalid-role', rule, { '', '', - // Missing VALID_ROLES entries: associationlistitemkey, associationlistitemvalue, cell - '', - '', '', // Case-insensitive role matching @@ -71,9 +68,56 @@ ruleTester.run('template-no-invalid-role', rule, { code: '', options: [{ catchNonexistentRoles: false }], }, + + // DPUB-ARIA (doc-*) and Graphics-ARIA (graphics-*) are valid per aria-query. + '', + '', + '', + '', + + // Whitespace-separated role fallback list — ARIA 1.2 §5.4. Each token + // must individually be valid. + '', + '', + '', + + // Role-fallback: `presentation`/`none` in a non-first position does NOT + // flag on a semantic element — UAs pick the first recognised role per + // §4.1. Here `button` resolves, `presentation` is an unused fallback. + '', + '', + + // ARIA 1.3 draft roles — not in aria-query 5.3.2 but spec-blessed, so + // the rule accepts them via the inline allowlist. + '', + '', + '', + '', + '', ], invalid: [ + // Common authoring confusion — `datepicker` looks like it could be a + // role (it's a UI concept) but isn't in the ARIA registry. Same lookup + // path as `role="invalid"`, exercised here for the more likely typo. + { + code: '', + output: null, + errors: [{ messageId: 'invalid' }], + }, + // Empty / whitespace-only role attribute supplies no recognized role + // token — flag as an authoring mistake. Aligns with jsx-a11y and + // vue-a11y (both flag). + { + code: '', + output: null, + errors: [{ messageId: 'invalid' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'invalid' }], + }, { code: `