From 0c1d7015878fd24c721745d921aeb31ab69dc332 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 21 Apr 2026 07:47:54 +0200 Subject: [PATCH 01/22] fix(template-no-invalid-role): source valid roles from aria-query; support DPUB-/Graphics-ARIA and role-fallback lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related fixes, shared rewrite. 1. Replace the hand-maintained VALID_ROLES (~90 WAI-ARIA 1.2 tokens) with a derived list from aria-query (concrete — non-abstract — role keys), plus a small ARIA 1.3 draft-role allowlist that aria-query doesn't yet ship. Effect: DPUB-ARIA roles (doc-abstract, doc-chapter, …) and Graphics-ARIA roles (graphics-document, graphics-object, graphics-symbol) are no longer flagged as invalid. 2. Split the role value on whitespace before validating. A role attribute is a list of tokens per ARIA 1.2 §5.4 (role fallback). Each token must individually be valid. Effect: role="tabpanel row", role="doc-appendix doc-bibliography", and role="graphics-document document" now pass; role="tabpanel row foobar" flags the first invalid token ("foobar") instead of rejecting the whole string as one opaque role name. Error message now names the specific offending token. Three existing invalid tests updated accordingly (previously expected the whole string; now the specific token). Ten new valid tests cover DPUB/Graphics and the fallback-list shape. --- lib/rules/template-no-invalid-role.js | 125 +++++--------------- tests/lib/rules/template-no-invalid-role.js | 36 +++++- 2 files changed, 58 insertions(+), 103 deletions(-) diff --git a/lib/rules/template-no-invalid-role.js b/lib/rules/template-no-invalid-role.js index 9c82372821..c48ce874c8 100644 --- a/lib/rules/template-no-invalid-role.js +++ b/lib/rules/template-no-invalid-role.js @@ -1,92 +1,19 @@ -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 a +// small set of WAI-ARIA 1.3 draft roles that aria-query doesn't yet ship. The +// ARIA 1.2 base roles, DPUB-ARIA (doc-*), and Graphics-ARIA (graphics-*) all +// come from aria-query. +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 +152,38 @@ module.exports = { return; } - const role = roleAttr.value.chars.trim(); - if (!role) { + const raw = roleAttr.value.chars.trim(); + if (!raw) { return; } - const roleLower = role.toLowerCase(); + // ARIA role attribute is a whitespace-separated list of tokens + // (role-fallback pattern per ARIA 1.2 §5.4). Validate each token. + const tokens = raw.split(/\s+/u).map((t) => t.toLowerCase()); - // Check for nonexistent roles - if (catchNonexistentRoles && !VALID_ROLES.has(roleLower)) { - context.report({ - node: roleAttr, - messageId: 'invalid', - data: { role }, - }); - return; + if (catchNonexistentRoles) { + const invalidToken = tokens.find((token) => !VALID_ROLES.has(token)); + if (invalidToken) { + context.report({ + node: roleAttr, + messageId: 'invalid', + data: { role: invalidToken }, + }); + 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) if ( - (roleLower === 'presentation' || roleLower === 'none') && + tokens.some((t) => t === 'presentation' || t === 'none') && SEMANTIC_ELEMENTS.has(node.tag) ) { context.report({ node: roleAttr, messageId: 'presentationOnSemantic', - data: { role, tag: node.tag }, + data: { role: raw, tag: node.tag }, }); } }, diff --git a/tests/lib/rules/template-no-invalid-role.js b/tests/lib/rules/template-no-invalid-role.js index efca5e138a..71627e8e88 100644 --- a/tests/lib/rules/template-no-invalid-role.js +++ b/tests/lib/rules/template-no-invalid-role.js @@ -71,6 +71,20 @@ ruleTester.run('template-no-invalid-role', rule, { code: '', options: [{ catchNonexistentRoles: false }], }, + + // DPUB-ARIA (doc-*) and Graphics-ARIA (graphics-*) are in the WAI-ARIA + // ecosystem via aria-query; previously flagged because our hand-maintained + // VALID_ROLES didn't include them. + '', + '', + '', + '', + + // Whitespace-separated role fallback list — ARIA 1.2 §5.4. Each token + // must individually be valid. + '', + '', + '', ], invalid: [ @@ -164,18 +178,18 @@ ruleTester.run('template-no-invalid-role', rule, { { code: '', output: null, - errors: [{ message: "Invalid ARIA role 'command interface'. Must be a valid ARIA role." }], + errors: [{ message: "Invalid ARIA role 'command'. Must be a valid ARIA role." }], }, { code: '', output: null, - errors: [{ message: "Invalid ARIA role 'COMMAND INTERFACE'. Must be a valid ARIA role." }], + errors: [{ message: "Invalid ARIA role 'command'. Must be a valid ARIA role." }], }, { code: '', output: null, options: [{ catchNonexistentRoles: true }], - errors: [{ message: "Invalid ARIA role 'command interface'. Must be a valid ARIA role." }], + errors: [{ message: "Invalid ARIA role 'command'. Must be a valid ARIA role." }], }, // Newly added SEMANTIC_ELEMENTS: presentation/none on iframe, video, audio @@ -247,6 +261,16 @@ hbsRuleTester.run('template-no-invalid-role', rule, { code: '
', options: [{ catchNonexistentRoles: false }], }, + + // DPUB-ARIA (doc-*) and Graphics-ARIA (graphics-*) roles. + '
Abstract
', + '
', + '', + + // Whitespace-separated role fallback list. + '
', + '', + '
', ], invalid: [ { @@ -302,18 +326,18 @@ hbsRuleTester.run('template-no-invalid-role', rule, { { code: '
', output: null, - errors: [{ message: "Invalid ARIA role 'command interface'. Must be a valid ARIA role." }], + errors: [{ message: "Invalid ARIA role 'command'. Must be a valid ARIA role." }], }, { code: '
', output: null, options: [{ catchNonexistentRoles: true }], - errors: [{ message: "Invalid ARIA role 'command interface'. Must be a valid ARIA role." }], + errors: [{ message: "Invalid ARIA role 'command'. Must be a valid ARIA role." }], }, { code: '
', output: null, - errors: [{ message: "Invalid ARIA role 'COMMAND INTERFACE'. Must be a valid ARIA role." }], + errors: [{ message: "Invalid ARIA role 'command'. Must be a valid ARIA role." }], }, // Newly added SEMANTIC_ELEMENTS: presentation/none on iframe, video, audio, embed { From 8ec2054eb22817832eaf8770f1397dd1511293c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 21 Apr 2026 12:43:05 +0200 Subject: [PATCH 02/22] =?UTF-8?q?fix:=20template-no-autofocus-attribute=20?= =?UTF-8?q?=E2=80=94=20value-aware=20+=20=20exception?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes two gaps identified in PR #28 Phase 3 audit (B10 fixture on `audit/phase3/no-autofocus`): G3 — value-aware detection. The rule previously flagged `autofocus` purely on presence, producing false positives for explicit opt-out forms: - `autofocus="false"` (GlimmerTextNode chars === "false") - `autofocus={{false}}` (BooleanLiteral false) - `autofocus={{"false"}}` (StringLiteral "false") These are now treated as valid. jsx-a11y's `no-autofocus` reads the value via `getPropValue` and exits early on falsy results, which is the behavior encoded here. Truthy literals (`="true"`, `="autofocus"`, `={{true}}`, `={{"true"}}`) and any dynamic expression (`={{this.x}}`) still flag — the rule cannot prove a dynamic value is safe. Mustache-hash-pair forms (`{{input autofocus=false}}`, `{{input autofocus="false"}}`) receive the same value-aware treatment so `autofocus=true` and `autofocus=false` do not behave inconsistently between syntaxes. G4 — `` exception. The rule now skips reporting when the element carrying `autofocus` is a `` itself OR is nested at any depth inside a ``. Per MDN's `` documentation, a dialog is expected to move focus to its initial control on open, so `autofocus` on or within a dialog is the recommended pattern rather than an accessibility defect. This matches `@angular-eslint/template/no-autofocus`, which exempts the same subtree. The descendant walk is a full parent-chain traversal via `node.parent`; the Glimmer AST in this codebase exposes `parent` on every element node, so no scope narrowing was required. Behavior unchanged for: bare ``, truthy string/mustache values, dynamic mustache values, and any `autofocus` outside a dialog subtree. 22 rule tests (10 valid, 12 invalid) pass; full suite (9089 tests) green. Audit fixture `tests/audit/no-autofocus/peer-parity.js` lives on branch `audit/phase3/no-autofocus` and will need separate updating to reflect the new parity status for G3 and G4. Refs: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog Refs: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/src/rules/no-autofocus.js Refs: #28 (G3, G4) --- lib/rules/template-no-autofocus-attribute.js | 135 +++++++++++++++--- .../rules/template-no-autofocus-attribute.js | 109 ++++++++++++++ 2 files changed, 221 insertions(+), 23 deletions(-) diff --git a/lib/rules/template-no-autofocus-attribute.js b/lib/rules/template-no-autofocus-attribute.js index d01c000e18..ef3bc28cf4 100644 --- a/lib/rules/template-no-autofocus-attribute.js +++ b/lib/rules/template-no-autofocus-attribute.js @@ -1,3 +1,64 @@ +/** + * Returns true when the attribute value is statically known to be falsy + * (i.e. the developer has written `autofocus="false"`, `autofocus={{false}}`, + * or `autofocus={{"false"}}`). These forms are aligned with jsx-a11y's + * `no-autofocus` rule, which reads the attribute value via `getPropValue` and + * skips reporting when the value is falsy (e.g. `
`). + * + * Valueless attributes (``) are TRUTHY per the HTML spec + * (boolean attribute present == "on") and must still be flagged. + */ +function isExplicitlyFalsy(value) { + if (!value) { + // No value property at all — treat as bare boolean attribute (truthy). + return false; + } + + if (value.type === 'GlimmerTextNode') { + // `autofocus="false"` → chars === "false". Bare `autofocus` has chars === "". + return value.chars === 'false'; + } + + if (value.type === 'GlimmerMustacheStatement') { + const expr = value.path; + if (!expr) { + return false; + } + // `autofocus={{false}}` → BooleanLiteral(false). + if (expr.type === 'GlimmerBooleanLiteral' && expr.value === false) { + return true; + } + // `autofocus={{"false"}}` → StringLiteral("false"). + if (expr.type === 'GlimmerStringLiteral' && expr.value === 'false') { + return true; + } + } + + return false; +} + +/** + * Returns true when the given GlimmerElementNode 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 + */ +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 +88,39 @@ 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; } + + // jsx-a11y parity: `autofocus="false"` / `={{false}}` / `={{"false"}}` + // explicitly opt out and should not be flagged. + if (isExplicitlyFalsy(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 +128,26 @@ module.exports = { return; } const autofocusPair = node.hash.pairs.find((pair) => pair.key === 'autofocus'); - if (autofocusPair) { - context.report({ - node: autofocusPair, - messageId: 'noAutofocus', - }); + if (!autofocusPair) { + return; + } + + // Value-aware check for component/helper invocations such as + // `{{input autofocus=false}}` or `{{input autofocus="false"}}`. + const pairValue = autofocusPair.value; + if (pairValue) { + if (pairValue.type === 'GlimmerBooleanLiteral' && pairValue.value === false) { + return; + } + if (pairValue.type === 'GlimmerStringLiteral' && pairValue.value === 'false') { + return; + } } + + context.report({ + node: autofocusPair, + messageId: 'noAutofocus', + }); }, }; }, diff --git a/tests/lib/rules/template-no-autofocus-attribute.js b/tests/lib/rules/template-no-autofocus-attribute.js index ad67f03943..9709506c3a 100644 --- a/tests/lib/rules/template-no-autofocus-attribute.js +++ b/tests/lib/rules/template-no-autofocus-attribute.js @@ -22,6 +22,39 @@ ruleTester.run('template-no-autofocus-attribute', rule, { ``, + // Value-aware: explicit falsy values opt out (jsx-a11y parity). + ``, + ``, + ``, + ``, + ``, + // Dialog exception (MDN): autofocus on is recommended. + ``, + // Dialog descendants are also exempt (angular-eslint parity). + ``, + ``, ], invalid: [ @@ -123,5 +156,81 @@ 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', + }, + ], + }, ], }); From bcabf346c43a5c6409a815359a6bd1a8f5a6f812 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 21 Apr 2026 16:24:35 +0200 Subject: [PATCH 03/22] chore: drop temporal 'previously flagged' comment --- tests/lib/rules/template-no-invalid-role.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/lib/rules/template-no-invalid-role.js b/tests/lib/rules/template-no-invalid-role.js index 71627e8e88..c24c7d7090 100644 --- a/tests/lib/rules/template-no-invalid-role.js +++ b/tests/lib/rules/template-no-invalid-role.js @@ -72,9 +72,7 @@ ruleTester.run('template-no-invalid-role', rule, { options: [{ catchNonexistentRoles: false }], }, - // DPUB-ARIA (doc-*) and Graphics-ARIA (graphics-*) are in the WAI-ARIA - // ecosystem via aria-query; previously flagged because our hand-maintained - // VALID_ROLES didn't include them. + // DPUB-ARIA (doc-*) and Graphics-ARIA (graphics-*) are valid per aria-query. '', '', '', From 65888627ae67bf1e11c8a680aa07e8e32d904615 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 21 Apr 2026 16:26:01 +0200 Subject: [PATCH 04/22] docs: document and falsy-value exceptions --- docs/rules/template-no-autofocus-attribute.md | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/rules/template-no-autofocus-attribute.md b/docs/rules/template-no-autofocus-attribute.md index b3bc9bd425..b049b29668 100644 --- a/docs/rules/template-no-autofocus-attribute.md +++ b/docs/rules/template-no-autofocus-attribute.md @@ -40,6 +40,28 @@ Examples of **correct** code for this rule: ``` +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 + +``` + +`` 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. From b99708af5328d1dc8bde717ccf7588ad2567f014 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 21 Apr 2026 17:50:53 +0200 Subject: [PATCH 05/22] test: add Phase 3 audit fixture translating aria-role peer cases Translates 32 cases from peer-plugin rules: - jsx-a11y aria-role - vuejs-accessibility aria-role - lit-a11y aria-role Fixture documents parity after this fix: - DPUB-ARIA and Graphics-ARIA roles accepted (via aria-query). - Space-separated role tokens accepted when all are valid, and the invalid-token variant names the specific offending token. Remaining divergences (case-insensitive comparison, empty-string role not flagged) are annotated inline. --- tests/audit/aria-role/peer-parity.js | 154 +++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 tests/audit/aria-role/peer-parity.js diff --git a/tests/audit/aria-role/peer-parity.js b/tests/audit/aria-role/peer-parity.js new file mode 100644 index 0000000000..2937d5b821 --- /dev/null +++ b/tests/audit/aria-role/peer-parity.js @@ -0,0 +1,154 @@ +// Audit fixture — translated test cases from peer plugins to measure +// behavioral parity of `ember/template-no-invalid-role` (+ `ember/template-no-abstract-roles`) +// against jsx-a11y/aria-role, vuejs-accessibility/aria-role, lit-a11y/aria-role. +// +// These tests are NOT part of the main suite and do not run in CI. They encode +// the CURRENT behavior of our rule so that running this file reports pass. +// Each divergence from an upstream plugin is annotated as "DIVERGENCE —". +// +// Source files (context/ checkouts): +// - eslint-plugin-jsx-a11y-main/__tests__/src/rules/aria-role-test.js +// - eslint-plugin-vuejs-accessibility-main/src/rules/__tests__/aria-role.test.ts +// - eslint-plugin-lit-a11y/tests/lib/rules/aria-role.js + +'use strict'; + +const rule = require('../../../lib/rules/template-no-invalid-role'); +const RuleTester = require('eslint').RuleTester; + +const ruleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser'), + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, +}); + +ruleTester.run('audit:aria-role (gts)', rule, { + valid: [ + // === Upstream parity (valid in both jsx-a11y and us) === + // jsx-a11y: valid (base case, no role) + '', + '', + + // jsx-a11y / vue-a11y / lit-a11y: valid (concrete, non-abstract, single role) + '', + '', + '', + '', + '', + + // Dynamic role — both plugins and we skip + '', + '', + + // === DIVERGENCE — case-insensitivity === + // jsx-a11y: INVALID (`
` is rejected, case-sensitive). + // Our rule lowercases the role before lookup; we allow this. Intentional: + // HTML attribute values are case-insensitive in many contexts, and the + // existing test suite encodes this as an explicit design choice. + '', + '', + + // === Parity — space-separated multiple roles === + // jsx-a11y / vuejs-accessibility: VALID — splits on whitespace, each + // token must be a valid role. Our rule now does the same. + '', + '', + + // === Parity — DPUB-ARIA (doc-*) roles === + // jsx-a11y / vuejs-accessibility: VALID via aria-query. Our rule now + // derives VALID_ROLES from aria-query's concrete role keys, covering + // all 40+ doc-* roles. + '', + '', + + // === Parity — Graphics-ARIA (graphics-*) roles on === + // jsx-a11y: VALID. Our rule: VALID via aria-query. + '', + '', + ], + + invalid: [ + // === Upstream parity (invalid in both jsx-a11y and us) === + { + code: '
', + output: null, + errors: [{ messageId: 'invalid' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'invalid' }], + }, + // jsx-a11y: invalid (`range` is an abstract role). + // Ours: `range` is not in VALID_ROLES so we flag it as "not a valid ARIA role". + // Upstream says "abstract role"; we conflate. Message wording differs. + { + code: '', + output: null, + errors: [{ messageId: 'invalid' }], + }, + + // === DIVERGENCE — empty role string === + // jsx-a11y: INVALID — `
` flagged. + // vue-a11y: INVALID — same. + // Our rule: early-return on empty/whitespace role (line 229 of rule). NO FLAG. + // So this case reflects OUR (non-flagging) behavior with an explicit note. + // (No invalid assertion possible here — we'd need to move this to valid, + // or fix the rule to flag.) + + // === Parity — space-separated with at least one invalid token === + // jsx-a11y: INVALID — splits and flags the token `foobar`. + // Our rule: splits on whitespace and now names the offending token + // specifically (`'foobar'`) rather than the whole compound string. + { + code: '', + output: null, + errors: [{ messageId: 'invalid' }], + }, + ], +}); + +// === DIVERGENCE — empty role string (captured as valid because we don't flag) === +// Intentionally isolated so the intent is clear. +ruleTester.run('audit:aria-role empty string (gts)', rule, { + valid: [ + // jsx-a11y + vue-a11y both flag this. We don't. This captures OUR behavior. + '', + ], + invalid: [], +}); + +const hbsRuleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser/hbs'), + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, +}); + +hbsRuleTester.run('audit:aria-role (hbs)', rule, { + valid: [ + '
', + '
', + '
', + // DIVERGENCE case-insensitivity (see gts section). + '
', + // DIVERGENCE empty string (we don't flag). + '
', + // Parity — space-separated all-valid tokens. + '
', + // Parity — DPUB-ARIA. + '
', + // Parity — Graphics-ARIA on . + '', + ], + invalid: [ + { + code: '
', + output: null, + errors: [{ messageId: 'invalid' }], + }, + // Parity — compound with at least one invalid token. + { + code: '
', + output: null, + errors: [{ messageId: 'invalid' }], + }, + ], +}); From 823631f1a04bf904d86c92f32069e4145522cbb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 21 Apr 2026 19:36:54 +0200 Subject: [PATCH 06/22] fix(template-no-invalid-role): drop unsupported ARIA 1.3 allowlist tokens `associationlist`, `associationlistitemkey`, and `associationlistitemvalue` are not present in the current WAI-ARIA 1.3 editor's draft (https://w3c.github.io/aria/). Earlier commit listed all five as draft tokens; only `comment` and `suggestion` are actually proposed. Drop the three phantom tokens and the tests that accepted them as valid. With this change the rule now correctly flags `role='associationlist'` and siblings as invalid, matching peer behavior (jsx-a11y, vue-a11y, lit-a11y all reject them). --- lib/rules/template-no-invalid-role.js | 13 ++++--------- tests/lib/rules/template-no-invalid-role.js | 6 ------ 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/lib/rules/template-no-invalid-role.js b/lib/rules/template-no-invalid-role.js index c48ce874c8..a5c618d4ca 100644 --- a/lib/rules/template-no-invalid-role.js +++ b/lib/rules/template-no-invalid-role.js @@ -1,16 +1,11 @@ const { roles } = require('aria-query'); // Valid ARIA roles = concrete (non-abstract) entries from aria-query, plus a -// small set of WAI-ARIA 1.3 draft roles that aria-query doesn't yet ship. The +// couple of WAI-ARIA 1.3 draft roles that aria-query doesn't yet ship. The // ARIA 1.2 base roles, DPUB-ARIA (doc-*), and Graphics-ARIA (graphics-*) all -// come from aria-query. -const ARIA_13_DRAFT_ROLES = [ - 'associationlist', - 'associationlistitemkey', - 'associationlistitemvalue', - 'comment', - 'suggestion', -]; +// come from aria-query. Both `comment` and `suggestion` are present in the +// current ARIA 1.3 editor's draft (https://w3c.github.io/aria/). +const ARIA_13_DRAFT_ROLES = ['comment', 'suggestion']; const VALID_ROLES = new Set([ ...[...roles.keys()].filter((role) => !roles.get(role).abstract), ...ARIA_13_DRAFT_ROLES, diff --git a/tests/lib/rules/template-no-invalid-role.js b/tests/lib/rules/template-no-invalid-role.js index c24c7d7090..466db55268 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 @@ -246,9 +243,6 @@ hbsRuleTester.run('template-no-invalid-role', rule, { '', '
', '
', - // Missing VALID_ROLES entries: associationlistitemkey, associationlistitemvalue, cell - '
Key
', - '
Value
', 'Data', // Case-insensitive role matching '
Click
', From 1670293a9fec990aa1414be1c3dadacdcc4739fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 21 Apr 2026 20:06:14 +0200 Subject: [PATCH 07/22] fix(template-no-autofocus-attribute): align value-aware check with HTML boolean semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per HTML Living Standard on boolean attributes, the presence of `autofocus` indicates TRUE regardless of value — `autofocus="false"` and `autofocus="autofocus"` are equally truthy. jsx-a11y's `no-autofocus` treats the literal string `"false"` as an opt-out (via `getPropValue`), but that's a peer-plugin convention that diverges from HTML semantics; vue-a11y and lit-a11y are presence-based, consistent with the spec. Narrow opt-out to the only case that is spec-consistent: - `autofocus={{false}}` in angle-bracket syntax — renders no attribute. - `{{input autofocus=false}}` in mustache hash-pair syntax — no attribute. Revert peer-parity opt-outs for `autofocus="false"`, `autofocus={{"false"}}`, and `{{input autofocus="false"}}` — these are now flagged per HTML spec semantics. Moved from valid → invalid in the test suite. Dialog exemption unchanged — keeps MDN-backed behavior for autofocus on and within . Follows the spec-first direction established in #2717 (aria-hidden), #19, #33. --- lib/rules/template-no-autofocus-attribute.js | 72 ++++++++----------- .../rules/template-no-autofocus-attribute.js | 42 ++++++++--- 2 files changed, 60 insertions(+), 54 deletions(-) diff --git a/lib/rules/template-no-autofocus-attribute.js b/lib/rules/template-no-autofocus-attribute.js index ef3bc28cf4..a5c0cdbfb5 100644 --- a/lib/rules/template-no-autofocus-attribute.js +++ b/lib/rules/template-no-autofocus-attribute.js @@ -1,40 +1,25 @@ /** - * Returns true when the attribute value is statically known to be falsy - * (i.e. the developer has written `autofocus="false"`, `autofocus={{false}}`, - * or `autofocus={{"false"}}`). These forms are aligned with jsx-a11y's - * `no-autofocus` rule, which reads the attribute value via `getPropValue` and - * skips reporting when the value is falsy (e.g. `
`). + * `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. * - * Valueless attributes (``) are TRUTHY per the HTML spec - * (boolean attribute present == "on") and must still be flagged. + * 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). */ -function isExplicitlyFalsy(value) { - if (!value) { - // No value property at all — treat as bare boolean attribute (truthy). +function isMustacheBooleanFalse(value) { + if (value?.type !== 'GlimmerMustacheStatement') { return false; } - - if (value.type === 'GlimmerTextNode') { - // `autofocus="false"` → chars === "false". Bare `autofocus` has chars === "". - return value.chars === 'false'; - } - - if (value.type === 'GlimmerMustacheStatement') { - const expr = value.path; - if (!expr) { - return false; - } - // `autofocus={{false}}` → BooleanLiteral(false). - if (expr.type === 'GlimmerBooleanLiteral' && expr.value === false) { - return true; - } - // `autofocus={{"false"}}` → StringLiteral("false"). - if (expr.type === 'GlimmerStringLiteral' && expr.value === 'false') { - return true; - } - } - - return false; + const expr = value.path; + return expr?.type === 'GlimmerBooleanLiteral' && expr.value === false; } /** @@ -92,9 +77,10 @@ module.exports = { return; } - // jsx-a11y parity: `autofocus="false"` / `={{false}}` / `={{"false"}}` - // explicitly opt out and should not be flagged. - if (isExplicitlyFalsy(autofocusAttr.value)) { + // 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; } @@ -132,16 +118,14 @@ module.exports = { return; } - // Value-aware check for component/helper invocations such as - // `{{input autofocus=false}}` or `{{input autofocus="false"}}`. + // 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) { - if (pairValue.type === 'GlimmerBooleanLiteral' && pairValue.value === false) { - return; - } - if (pairValue.type === 'GlimmerStringLiteral' && pairValue.value === 'false') { - return; - } + if (pairValue?.type === 'GlimmerBooleanLiteral' && pairValue.value === false) { + return; } context.report({ diff --git a/tests/lib/rules/template-no-autofocus-attribute.js b/tests/lib/rules/template-no-autofocus-attribute.js index 9709506c3a..db021179a4 100644 --- a/tests/lib/rules/template-no-autofocus-attribute.js +++ b/tests/lib/rules/template-no-autofocus-attribute.js @@ -22,22 +22,15 @@ ruleTester.run('template-no-autofocus-attribute', rule, { ``, - // Value-aware: explicit falsy values opt out (jsx-a11y parity). - ``, + // 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 exception also applies to the mustache form of the `` + // helper (`{{input autofocus=true}}`) — whether direct child or nested. + ``, + ``, ], invalid: [ From 39d2d27b2e8d87b4f6a3023712f76d32cac10247 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Wed, 22 Apr 2026 14:22:30 +0200 Subject: [PATCH 10/22] fix: add 3 missing ARIA 1.3 roles + report specific offending token (Copilot review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR body claimed the ARIA 1.3 draft allowlist covers 5 roles (associationlist, associationlistitemkey, associationlistitemvalue, comment, suggestion). The code only listed 2 ('comment', 'suggestion'); the gap was invisible because no tests exercised any of them. Verified against aria-query 5.3.2: roles.has() returns false for all 5, so all 5 belong in the inline allowlist until aria-query catches up. Also: when reporting presentation/none on a semantic element, include the offending token in the message data instead of the raw role attribute string — avoids surfacing e.g. 'presentation listbox' when only 'presentation' is the issue. Tests: add 5 valid cases in each of the gts and hbs blocks covering all ARIA 1.3 draft roles. --- lib/rules/template-no-invalid-role.js | 29 ++++++++++++++------- tests/lib/rules/template-no-invalid-role.js | 16 ++++++++++++ 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/lib/rules/template-no-invalid-role.js b/lib/rules/template-no-invalid-role.js index a5c618d4ca..192c0899a0 100644 --- a/lib/rules/template-no-invalid-role.js +++ b/lib/rules/template-no-invalid-role.js @@ -1,11 +1,19 @@ const { roles } = require('aria-query'); -// Valid ARIA roles = concrete (non-abstract) entries from aria-query, plus a -// couple of WAI-ARIA 1.3 draft roles that aria-query doesn't yet ship. The +// 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. Both `comment` and `suggestion` are present in the -// current ARIA 1.3 editor's draft (https://w3c.github.io/aria/). -const ARIA_13_DRAFT_ROLES = ['comment', 'suggestion']; +// 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', + 'comment', + 'suggestion', +]; const VALID_ROLES = new Set([ ...[...roles.keys()].filter((role) => !roles.get(role).abstract), ...ARIA_13_DRAFT_ROLES, @@ -171,14 +179,15 @@ module.exports = { // 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) - if ( - tokens.some((t) => t === 'presentation' || t === 'none') && - SEMANTIC_ELEMENTS.has(node.tag) - ) { + const offendingToken = tokens.find((t) => t === 'presentation' || t === 'none'); + if (offendingToken && SEMANTIC_ELEMENTS.has(node.tag)) { context.report({ node: roleAttr, messageId: 'presentationOnSemantic', - data: { role: raw, tag: node.tag }, + // Report the specific offending token, not the whole raw role + // string — e.g. for role="presentation foo" we point at + // 'presentation' rather than the full attribute value. + data: { role: offendingToken, tag: node.tag }, }); } }, diff --git a/tests/lib/rules/template-no-invalid-role.js b/tests/lib/rules/template-no-invalid-role.js index 466db55268..303e8ef66f 100644 --- a/tests/lib/rules/template-no-invalid-role.js +++ b/tests/lib/rules/template-no-invalid-role.js @@ -80,6 +80,14 @@ ruleTester.run('template-no-invalid-role', rule, { '', '', '', + + // 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: [ @@ -263,6 +271,14 @@ hbsRuleTester.run('template-no-invalid-role', rule, { '
', '', '
', + + // 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: [ { From 7e45da3e54442e21bd085d9adf5975588e8af3d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Wed, 22 Apr 2026 16:51:54 +0200 Subject: [PATCH 11/22] docs: correct audit-fixture CI-run claim (Copilot review) --- tests/audit/aria-role/peer-parity.js | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/audit/aria-role/peer-parity.js b/tests/audit/aria-role/peer-parity.js index 2937d5b821..7ec82dd815 100644 --- a/tests/audit/aria-role/peer-parity.js +++ b/tests/audit/aria-role/peer-parity.js @@ -1,10 +1,15 @@ -// Audit fixture — translated test cases from peer plugins to measure -// behavioral parity of `ember/template-no-invalid-role` (+ `ember/template-no-abstract-roles`) -// against jsx-a11y/aria-role, vuejs-accessibility/aria-role, lit-a11y/aria-role. +// Audit fixture — translates peer-plugin test cases into assertions against +// our rule (`ember/template-no-invalid-role` + `ember/template-no-abstract-roles`). +// Runs as part of the default Vitest suite (via the `tests/**/*.js` include +// glob) and serves double-duty: (1) auditable record of peer-parity +// divergences, (2) regression coverage pinning CURRENT behavior. Each case +// encodes what OUR rule does today; divergences from upstream plugins are +// annotated as `DIVERGENCE —`. Peer-only constructs that can't be translated +// to Ember templates (JSX spread props, Vue v-bind, Angular `$event`, +// undefined-handler expression analysis) are marked `AUDIT-SKIP`. // -// These tests are NOT part of the main suite and do not run in CI. They encode -// the CURRENT behavior of our rule so that running this file reports pass. -// Each divergence from an upstream plugin is annotated as "DIVERGENCE —". +// Peers covered: jsx-a11y/aria-role, vuejs-accessibility/aria-role, +// lit-a11y/aria-role. // // Source files (context/ checkouts): // - eslint-plugin-jsx-a11y-main/__tests__/src/rules/aria-role-test.js From c2a4128947d4759c1400cf6f0aa79ca91f435e31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Thu, 23 Apr 2026 21:39:52 +0200 Subject: [PATCH 12/22] docs: address Copilot review wording (PR #32) --- docs/rules/template-no-autofocus-attribute.md | 9 ++++++--- lib/rules/template-no-autofocus-attribute.js | 5 +++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/rules/template-no-autofocus-attribute.md b/docs/rules/template-no-autofocus-attribute.md index b049b29668..f09fc774ea 100644 --- a/docs/rules/template-no-autofocus-attribute.md +++ b/docs/rules/template-no-autofocus-attribute.md @@ -40,13 +40,16 @@ Examples of **correct** code for this rule: ``` -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)): +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 ``` diff --git a/lib/rules/template-no-autofocus-attribute.js b/lib/rules/template-no-autofocus-attribute.js index 3613222b1b..3c4271b47b 100644 --- a/lib/rules/template-no-autofocus-attribute.js +++ b/lib/rules/template-no-autofocus-attribute.js @@ -31,8 +31,9 @@ function isMustacheBooleanFalse(value) { } /** - * Returns true when the given GlimmerElementNode is a `` element - * or is nested (at any depth) inside a `` element. Per MDN, + * 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. * From f4b5b498f81a96205beae8d46cce98564cc5c9dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Fri, 24 Apr 2026 10:30:53 +0200 Subject: [PATCH 13/22] fix(template-no-invalid-role): flag presentation/none only when it's the first recognised role (Copilot review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundle with the Q7/Q10/Q18/Q30 cross-rule first-valid-token pattern. Per WAI-ARIA §4.1, UAs walk the role-token list for the first role they recognise; subsequent tokens are author-provided fallbacks that never take effect. So `role="button presentation"` on a semantic element resolves to `button` at runtime and must NOT flag. Previously we flagged on any occurrence of presentation/none anywhere in the list. Unknown tokens are skipped per the same section, so `role="xxyxyz presentation"` correctly resolves to presentation and still flags (covered by a new regression test). --- lib/rules/template-no-invalid-role.js | 24 +++++++++++++-------- tests/lib/rules/template-no-invalid-role.js | 18 ++++++++++++++++ 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/lib/rules/template-no-invalid-role.js b/lib/rules/template-no-invalid-role.js index 192c0899a0..bb34dd2ea7 100644 --- a/lib/rules/template-no-invalid-role.js +++ b/lib/rules/template-no-invalid-role.js @@ -176,18 +176,24 @@ module.exports = { } } - // 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) - const offendingToken = tokens.find((t) => t === 'presentation' || t === 'none'); - if (offendingToken && SEMANTIC_ELEMENTS.has(node.tag)) { + // 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 ( + (firstRecognisedRole === 'presentation' || firstRecognisedRole === 'none') && + SEMANTIC_ELEMENTS.has(node.tag) + ) { context.report({ node: roleAttr, messageId: 'presentationOnSemantic', - // Report the specific offending token, not the whole raw role - // string — e.g. for role="presentation foo" we point at - // 'presentation' rather than the full attribute value. - data: { role: offendingToken, tag: node.tag }, + data: { role: firstRecognisedRole, tag: node.tag }, }); } }, diff --git a/tests/lib/rules/template-no-invalid-role.js b/tests/lib/rules/template-no-invalid-role.js index 303e8ef66f..f75125e6b3 100644 --- a/tests/lib/rules/template-no-invalid-role.js +++ b/tests/lib/rules/template-no-invalid-role.js @@ -81,6 +81,12 @@ ruleTester.run('template-no-invalid-role', rule, { '', '', + // 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. '', @@ -161,6 +167,18 @@ ruleTester.run('template-no-invalid-role', rule, { { message: 'The role "presentation" should not be used on the semantic element
`, - // Dialog exception also applies to the mustache form of the `` - // helper (`{{input autofocus=true}}`) — whether direct child or nested. + // 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: [ From 7ca1edc7371c89f7f40c26be6b02c2459d186d4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Fri, 24 Apr 2026 15:31:15 +0200 Subject: [PATCH 18/22] lint: normalize string quotes to single per quotes rule --- tests/lib/rules/template-no-autofocus-attribute.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/lib/rules/template-no-autofocus-attribute.js b/tests/lib/rules/template-no-autofocus-attribute.js index 6c81282117..08571b62a0 100644 --- a/tests/lib/rules/template-no-autofocus-attribute.js +++ b/tests/lib/rules/template-no-autofocus-attribute.js @@ -67,9 +67,9 @@ ruleTester.run('template-no-autofocus-attribute', rule, { // 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: [ From 909e56dcd16c86050a8852acc6ae5914174d2390 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Fri, 24 Apr 2026 19:04:58 +0200 Subject: [PATCH 19/22] fix(template-no-invalid-role): preserve author casing in invalid-role message (Copilot review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep the original token alongside the lowercased one; validation stays case- insensitive (lowercases against VALID_ROLES) but the reported-back token uses the author's raw casing so the error surfaces their input verbatim rather than the normalized form. Also drop a 'line 229 of rule' hard-coded reference from the aria-role peer-parity fixture — line numbers rot on every refactor. --- lib/rules/template-no-invalid-role.js | 12 ++++++++---- tests/audit/aria-role/peer-parity.js | 2 +- tests/lib/rules/template-no-invalid-role.js | 8 ++++++-- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/lib/rules/template-no-invalid-role.js b/lib/rules/template-no-invalid-role.js index bb34dd2ea7..a113b9c11e 100644 --- a/lib/rules/template-no-invalid-role.js +++ b/lib/rules/template-no-invalid-role.js @@ -162,15 +162,19 @@ module.exports = { // ARIA role attribute is a whitespace-separated list of tokens // (role-fallback pattern per ARIA 1.2 §5.4). Validate each token. - const tokens = raw.split(/\s+/u).map((t) => t.toLowerCase()); + // Keep the original casing alongside the normalized (lowercase) form + // so reported-back tokens preserve author intent — the 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 invalidToken = tokens.find((token) => !VALID_ROLES.has(token)); - if (invalidToken) { + const invalidIdx = tokens.findIndex((token) => !VALID_ROLES.has(token)); + if (invalidIdx !== -1) { context.report({ node: roleAttr, messageId: 'invalid', - data: { role: invalidToken }, + data: { role: rawTokens[invalidIdx] }, }); return; } diff --git a/tests/audit/aria-role/peer-parity.js b/tests/audit/aria-role/peer-parity.js index 073849d31f..c9e08c8022 100644 --- a/tests/audit/aria-role/peer-parity.js +++ b/tests/audit/aria-role/peer-parity.js @@ -95,7 +95,7 @@ ruleTester.run('audit:aria-role (gts)', rule, { // === DIVERGENCE — empty role string === // jsx-a11y: INVALID — `
` flagged. // vue-a11y: INVALID — same. - // Our rule: early-return on empty/whitespace role (line 229 of rule). NO FLAG. + // Our rule: early-return on empty/whitespace role. NO FLAG. // So this case reflects OUR (non-flagging) behavior with an explicit note. // (No invalid assertion possible here — we'd need to move this to valid, // or fix the rule to flag.) diff --git a/tests/lib/rules/template-no-invalid-role.js b/tests/lib/rules/template-no-invalid-role.js index dd812e746f..bda6ae1c91 100644 --- a/tests/lib/rules/template-no-invalid-role.js +++ b/tests/lib/rules/template-no-invalid-role.js @@ -204,7 +204,9 @@ ruleTester.run('template-no-invalid-role', rule, { { code: '', output: null, - errors: [{ message: "Invalid ARIA role 'command'. Must be a valid ARIA role." }], + // Validation is case-insensitive, but the error message echoes the + // author-provided token verbatim so authors see their own text. + errors: [{ message: "Invalid ARIA role 'COMMAND'. Must be a valid ARIA role." }], }, { code: '', @@ -363,7 +365,9 @@ hbsRuleTester.run('template-no-invalid-role', rule, { { code: '
', output: null, - errors: [{ message: "Invalid ARIA role 'command'. Must be a valid ARIA role." }], + // Validation is case-insensitive, but the error message echoes the + // author-provided token verbatim so authors see their own text. + errors: [{ message: "Invalid ARIA role 'COMMAND'. Must be a valid ARIA role." }], }, // Newly added SEMANTIC_ELEMENTS: presentation/none on iframe, video, audio, embed { From 5919767e95d5f434d3d4422f59266f8c0e028e22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Sat, 25 Apr 2026 06:44:12 +0200 Subject: [PATCH 20/22] test(template-no-invalid-role): absorb audit-fixture cases, drop audit fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream maintainers don't want the per-PR `tests/audit/peer-parity` pattern. Port two cases that pinned distinct behavior: - `role=""` as VALID — documented divergence; we early-return on empty/whitespace role values where jsx-a11y / vue-a11y flag. - `role="datepicker"` as INVALID — common authoring confusion that exercises the unknown-role path with a more likely real-world typo than `role="invalid"`. Other audit cases were already covered by the regular tests. --- tests/audit/aria-role/peer-parity.js | 159 -------------------- tests/lib/rules/template-no-invalid-role.js | 13 ++ 2 files changed, 13 insertions(+), 159 deletions(-) delete mode 100644 tests/audit/aria-role/peer-parity.js diff --git a/tests/audit/aria-role/peer-parity.js b/tests/audit/aria-role/peer-parity.js deleted file mode 100644 index c9e08c8022..0000000000 --- a/tests/audit/aria-role/peer-parity.js +++ /dev/null @@ -1,159 +0,0 @@ -// Audit fixture — translates peer-plugin test cases into assertions against -// our rule (`ember/template-no-invalid-role`). -// Runs as part of the default Vitest suite (via the `tests/**/*.js` include -// glob) and serves double-duty: (1) auditable record of peer-parity -// divergences, (2) regression coverage pinning CURRENT behavior. Each case -// encodes what OUR rule does today; divergences from upstream plugins are -// annotated as `DIVERGENCE —`. Peer-only constructs that can't be translated -// to Ember templates (JSX spread props, Vue v-bind, Angular `$event`, -// undefined-handler expression analysis) are marked `AUDIT-SKIP`. -// -// Peers covered: jsx-a11y/aria-role, vuejs-accessibility/aria-role, -// lit-a11y/aria-role. -// -// Source files (context/ checkouts): -// - eslint-plugin-jsx-a11y-main/__tests__/src/rules/aria-role-test.js -// - eslint-plugin-vuejs-accessibility-main/src/rules/__tests__/aria-role.test.ts -// - eslint-plugin-lit-a11y/tests/lib/rules/aria-role.js - -'use strict'; - -const rule = require('../../../lib/rules/template-no-invalid-role'); -const RuleTester = require('eslint').RuleTester; - -const ruleTester = new RuleTester({ - parser: require.resolve('ember-eslint-parser'), - parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, -}); - -ruleTester.run('audit:aria-role (gts)', rule, { - valid: [ - // === Upstream parity (valid in both jsx-a11y and us) === - // jsx-a11y: valid (base case, no role) - '', - '', - - // jsx-a11y / vue-a11y / lit-a11y: valid (concrete, non-abstract, single role) - '', - '', - '', - '', - '', - - // Dynamic role — both plugins and we skip - '', - '', - - // === DIVERGENCE — case-insensitivity === - // jsx-a11y: INVALID (`
` is rejected, case-sensitive). - // Our rule lowercases the role before lookup; we allow this. Intentional: - // HTML attribute values are case-insensitive in many contexts, and the - // existing test suite encodes this as an explicit design choice. - '', - '', - - // === Parity — space-separated multiple roles === - // jsx-a11y / vuejs-accessibility: VALID — splits on whitespace, each - // token must be a valid role. Our rule now does the same. - '', - '', - - // === Parity — DPUB-ARIA (doc-*) roles === - // jsx-a11y / vuejs-accessibility: VALID via aria-query. Our rule now - // derives VALID_ROLES from aria-query's concrete role keys, covering - // all 40+ doc-* roles. - '', - '', - - // === Parity — Graphics-ARIA (graphics-*) roles on === - // jsx-a11y: VALID. Our rule: VALID via aria-query. - '', - '', - ], - - invalid: [ - // === Upstream parity (invalid in both jsx-a11y and us) === - { - code: '
', - output: null, - errors: [{ messageId: 'invalid' }], - }, - { - code: '', - output: null, - errors: [{ messageId: 'invalid' }], - }, - // jsx-a11y: invalid (`range` is an abstract role). - // Ours: `range` is not in VALID_ROLES so we flag it as "not a valid ARIA role". - // Upstream says "abstract role"; we conflate. Message wording differs. - { - code: '', - output: null, - errors: [{ messageId: 'invalid' }], - }, - - // === DIVERGENCE — empty role string === - // jsx-a11y: INVALID — `
` flagged. - // vue-a11y: INVALID — same. - // Our rule: early-return on empty/whitespace role. NO FLAG. - // So this case reflects OUR (non-flagging) behavior with an explicit note. - // (No invalid assertion possible here — we'd need to move this to valid, - // or fix the rule to flag.) - - // === Parity — space-separated with at least one invalid token === - // jsx-a11y: INVALID — splits and flags the token `foobar`. - // Our rule: splits on whitespace and now names the offending token - // specifically (`'foobar'`) rather than the whole compound string. - { - code: '', - output: null, - errors: [{ messageId: 'invalid' }], - }, - ], -}); - -// === DIVERGENCE — empty role string (captured as valid because we don't flag) === -// Intentionally isolated so the intent is clear. -ruleTester.run('audit:aria-role empty string (gts)', rule, { - valid: [ - // jsx-a11y + vue-a11y both flag this. We don't. This captures OUR behavior. - '', - ], - invalid: [], -}); - -const hbsRuleTester = new RuleTester({ - parser: require.resolve('ember-eslint-parser/hbs'), - parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, -}); - -hbsRuleTester.run('audit:aria-role (hbs)', rule, { - valid: [ - '
', - '
', - '
', - // DIVERGENCE case-insensitivity (see gts section). - '
', - // DIVERGENCE empty string (we don't flag). - '
', - // Parity — space-separated all-valid tokens. - '
', - // Parity — DPUB-ARIA. - '
', - // Parity — Graphics-ARIA on . - '', - ], - invalid: [ - { - code: '
', - output: null, - errors: [{ messageId: 'invalid' }], - }, - // Parity — compound with at least one invalid token. - { - code: '
', - output: null, - errors: [{ messageId: 'invalid' }], - }, - ], -}); diff --git a/tests/lib/rules/template-no-invalid-role.js b/tests/lib/rules/template-no-invalid-role.js index bda6ae1c91..698b4e5923 100644 --- a/tests/lib/rules/template-no-invalid-role.js +++ b/tests/lib/rules/template-no-invalid-role.js @@ -94,9 +94,22 @@ ruleTester.run('template-no-invalid-role', rule, { '', '', '', + + // Documented divergence — empty role string. jsx-a11y / vue-a11y flag + // it; our rule early-returns on empty/whitespace role values, so we + // don't. Pin our current behavior. + '', ], 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' }], + }, { code: `