|
| 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 | + |
1 | 78 | /** @type {import('eslint').Rule.RuleModule} */ |
2 | 79 | module.exports = { |
3 | 80 | meta: { |
@@ -27,38 +104,90 @@ module.exports = { |
27 | 104 | GlimmerElementNode(node) { |
28 | 105 | const autofocusAttr = node.attributes?.find((attr) => attr.name === 'autofocus'); |
29 | 106 |
|
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; |
48 | 122 | } |
| 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 | + }); |
49 | 141 | }, |
50 | 142 |
|
51 | 143 | GlimmerMustacheStatement(node) { |
52 | 144 | if (!node.hash || !node.hash.pairs) { |
53 | 145 | return; |
54 | 146 | } |
55 | 147 | 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; |
61 | 185 | } |
| 186 | + |
| 187 | + context.report({ |
| 188 | + node: autofocusPair, |
| 189 | + messageId: 'noAutofocus', |
| 190 | + }); |
62 | 191 | }, |
63 | 192 | }; |
64 | 193 | }, |
|
0 commit comments