From 96a4705efcca78af2636d7e9ae55a33c6697e12d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 21 Apr 2026 07:44:22 +0200 Subject: [PATCH 1/8] fix(template-require-mandatory-role-attributes): lowercase role; split whitespace-separated role lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes, shared root cause (both deal with role-token normalisation). 1. Case-insensitive role matching. Per HTML-AAM, ARIA role tokens compare as ASCII-case-insensitive. Before:
was silently accepted because "COMBOBOX" didn't match "combobox" in aria-query. Now: lowercase before lookup. 2. Whitespace-separated role fallback lists. Per ARIA 1.2 §5.4, a role attribute may list multiple tokens to express a fallback; a UA picks the first one it recognises. Before: role="combobox listbox" was treated as one opaque string, aria-query returned undefined, and the rule skipped silently. Now: split on whitespace, check against the first recognised role's required attributes (matching jsx-a11y). Helpers renamed to plural forms (getStaticRolesFromElement, getStaticRolesFromMustache) and getMissingRequiredAttributes now returns { role, missing } so the reporter can use the actually-checked role in the error message. Four new tests cover the two cases (valid + invalid of each). --- ...plate-require-mandatory-role-attributes.js | 70 ++++++++++++------- ...plate-require-mandatory-role-attributes.js | 26 +++++++ 2 files changed, 71 insertions(+), 25 deletions(-) diff --git a/lib/rules/template-require-mandatory-role-attributes.js b/lib/rules/template-require-mandatory-role-attributes.js index f399da3229..12ee0da056 100644 --- a/lib/rules/template-require-mandatory-role-attributes.js +++ b/lib/rules/template-require-mandatory-role-attributes.js @@ -8,39 +8,59 @@ function createRequiredAttributeErrorMessage(attrs, role) { return `The attributes ${attrs.join(', ')} are required by the role ${role}`; } -function getStaticRoleFromElement(node) { +// ARIA role values are whitespace-separated tokens compared ASCII-case-insensitively. +// Returns the list of normalised tokens, or undefined when the attribute is +// missing or dynamic. +function getStaticRolesFromElement(node) { const roleAttr = node.attributes?.find((attr) => attr.name === 'role'); if (roleAttr?.value?.type === 'GlimmerTextNode') { - return roleAttr.value.chars || undefined; + return splitRoleTokens(roleAttr.value.chars); } return undefined; } -function getStaticRoleFromMustache(node) { +function getStaticRolesFromMustache(node) { const rolePair = node.hash?.pairs?.find((pair) => pair.key === 'role'); if (rolePair?.value?.type === 'GlimmerStringLiteral') { - return rolePair.value.value; + return splitRoleTokens(rolePair.value.value); } return undefined; } -function getMissingRequiredAttributes(role, foundAriaAttributes) { - const roleDefinition = roles.get(role); - - if (!roleDefinition) { - return null; +function splitRoleTokens(value) { + if (!value) { + return undefined; } + const tokens = value + .trim() + .toLowerCase() + .split(/\s+/u) + .filter(Boolean); + return tokens.length > 0 ? tokens : undefined; +} - const requiredAttributes = Object.keys(roleDefinition.requiredProps); - const missingRequiredAttributes = requiredAttributes.filter( - (requiredAttribute) => !foundAriaAttributes.includes(requiredAttribute) - ); - - return missingRequiredAttributes.length > 0 ? missingRequiredAttributes : null; +// For an ARIA role-fallback list like "combobox listbox", check required +// attributes against the FIRST recognised role (the primary) — matches jsx-a11y. +function getMissingRequiredAttributes(roleTokens, foundAriaAttributes) { + for (const role of roleTokens) { + const roleDefinition = roles.get(role); + if (!roleDefinition) { + continue; + } + const requiredAttributes = Object.keys(roleDefinition.requiredProps); + const missingRequiredAttributes = requiredAttributes.filter( + (requiredAttribute) => !foundAriaAttributes.includes(requiredAttribute) + ); + return { + role, + missing: missingRequiredAttributes.length > 0 ? missingRequiredAttributes : null, + }; + } + return null; } /** @type {import('eslint').Rule.RuleModule} */ @@ -83,9 +103,9 @@ module.exports = { return { GlimmerElementNode(node) { - const role = getStaticRoleFromElement(node); + const roleTokens = getStaticRolesFromElement(node); - if (!role) { + if (!roleTokens) { return; } @@ -93,17 +113,17 @@ module.exports = { .filter((attribute) => attribute.name?.startsWith('aria-')) .map((attribute) => attribute.name); - const missingRequiredAttributes = getMissingRequiredAttributes(role, foundAriaAttributes); + const result = getMissingRequiredAttributes(roleTokens, foundAriaAttributes); - if (missingRequiredAttributes) { - reportMissingAttributes(node, role, missingRequiredAttributes); + if (result?.missing) { + reportMissingAttributes(node, result.role, result.missing); } }, GlimmerMustacheStatement(node) { - const role = getStaticRoleFromMustache(node); + const roleTokens = getStaticRolesFromMustache(node); - if (!role) { + if (!roleTokens) { return; } @@ -111,10 +131,10 @@ module.exports = { .filter((pair) => pair.key.startsWith('aria-')) .map((pair) => pair.key); - const missingRequiredAttributes = getMissingRequiredAttributes(role, foundAriaAttributes); + const result = getMissingRequiredAttributes(roleTokens, foundAriaAttributes); - if (missingRequiredAttributes) { - reportMissingAttributes(node, role, missingRequiredAttributes); + if (result?.missing) { + reportMissingAttributes(node, result.role, result.missing); } }, }; diff --git a/tests/lib/rules/template-require-mandatory-role-attributes.js b/tests/lib/rules/template-require-mandatory-role-attributes.js index 43af97d30a..a5417b4cbf 100644 --- a/tests/lib/rules/template-require-mandatory-role-attributes.js +++ b/tests/lib/rules/template-require-mandatory-role-attributes.js @@ -25,6 +25,11 @@ ruleTester.run('template-require-mandatory-role-attributes', rule, { '', '', '', + + // Case-insensitive role matching — ARIA role tokens compare as ASCII-case-insensitive. + '', + // Role fallback list — primary role's required attributes are satisfied. + '', ], invalid: [ @@ -75,6 +80,27 @@ ruleTester.run('template-require-mandatory-role-attributes', rule, { output: null, errors: [{ message: 'The attribute aria-checked is required by the role checkbox' }], }, + + // Case-insensitivity surfaces previously-unflagged mistakes. + { + code: '', + output: null, + errors: [ + { + message: 'The attributes aria-controls, aria-expanded are required by the role combobox', + }, + ], + }, + // Role-fallback list: when the primary role is missing required props, flag it. + { + code: '', + output: null, + errors: [ + { + message: 'The attributes aria-controls, aria-expanded are required by the role combobox', + }, + ], + }, ], }); From 721235b97b78e513830d74658be2c9925cf267fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 21 Apr 2026 08:35:46 +0200 Subject: [PATCH 2/8] lint: prettier format --- lib/rules/template-require-mandatory-role-attributes.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/rules/template-require-mandatory-role-attributes.js b/lib/rules/template-require-mandatory-role-attributes.js index 12ee0da056..6fc9ce6076 100644 --- a/lib/rules/template-require-mandatory-role-attributes.js +++ b/lib/rules/template-require-mandatory-role-attributes.js @@ -35,11 +35,7 @@ function splitRoleTokens(value) { if (!value) { return undefined; } - const tokens = value - .trim() - .toLowerCase() - .split(/\s+/u) - .filter(Boolean); + const tokens = value.trim().toLowerCase().split(/\s+/u).filter(Boolean); return tokens.length > 0 ? tokens : undefined; } From 75141976bd4138dfcf007a854ddd2877e94bd5d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 21 Apr 2026 16:25:35 +0200 Subject: [PATCH 3/8] chore: drop temporal 'previously-unflagged' comment --- tests/lib/rules/template-require-mandatory-role-attributes.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/lib/rules/template-require-mandatory-role-attributes.js b/tests/lib/rules/template-require-mandatory-role-attributes.js index a5417b4cbf..838c7f6766 100644 --- a/tests/lib/rules/template-require-mandatory-role-attributes.js +++ b/tests/lib/rules/template-require-mandatory-role-attributes.js @@ -81,7 +81,7 @@ ruleTester.run('template-require-mandatory-role-attributes', rule, { errors: [{ message: 'The attribute aria-checked is required by the role checkbox' }], }, - // Case-insensitivity surfaces previously-unflagged mistakes. + // Case-insensitive role matching — uppercase role missing required props is flagged. { code: '', output: null, From 892ec9af62c08d28c5da1e266ab1e29018f05d6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 21 Apr 2026 16:56:49 +0200 Subject: [PATCH 4/8] =?UTF-8?q?docs:=20correct=20code=20comment=20?= =?UTF-8?q?=E2=80=94=20rule=20diverges=20from=20jsx-a11y=20on=20role-fallb?= =?UTF-8?q?ack=20semantics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous comment said this matched jsx-a11y, but jsx-a11y validates every recognised role token while we check only the first recognised role per ARIA role-fallback semantics. Update the comment to reflect the PR description's stated behavior. --- lib/rules/template-require-mandatory-role-attributes.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/rules/template-require-mandatory-role-attributes.js b/lib/rules/template-require-mandatory-role-attributes.js index 6fc9ce6076..4a26e5ccc6 100644 --- a/lib/rules/template-require-mandatory-role-attributes.js +++ b/lib/rules/template-require-mandatory-role-attributes.js @@ -40,7 +40,9 @@ function splitRoleTokens(value) { } // For an ARIA role-fallback list like "combobox listbox", check required -// attributes against the FIRST recognised role (the primary) — matches jsx-a11y. +// attributes against the FIRST recognised role (the primary) per ARIA 1.2 +// role-fallback semantics — a user agent picks the first role it recognises. +// Diverges from jsx-a11y, which validates every recognised token. function getMissingRequiredAttributes(roleTokens, foundAriaAttributes) { for (const role of roleTokens) { const roleDefinition = roles.get(role); From 7ac18b28ee183b68171e837d1c20d85a6055f4a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 21 Apr 2026 17:49:05 +0200 Subject: [PATCH 5/8] test: add Phase 3 audit fixture translating role-has-required-aria peer cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Translates 33 cases from peer-plugin rules: - jsx-a11y role-has-required-aria-props - vuejs-accessibility role-has-required-aria-props - @angular-eslint/template role-has-required-aria - lit-a11y role-has-required-aria-attrs Fixture documents parity after this fix: - Case-insensitive role comparison (
flagged). - Whitespace-separated roles: first recognised token is validated (ARIA 1.2 §4.1 role-fallback semantics). Divergence from jsx-a11y, which validates every recognised token; documented inline. The semantic input+role exception (input[type=checkbox] role=switch) remains flagged here — that fix is on a separate branch. --- .../role-has-required-aria/peer-parity.js | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 tests/audit/role-has-required-aria/peer-parity.js diff --git a/tests/audit/role-has-required-aria/peer-parity.js b/tests/audit/role-has-required-aria/peer-parity.js new file mode 100644 index 0000000000..f6d47174a4 --- /dev/null +++ b/tests/audit/role-has-required-aria/peer-parity.js @@ -0,0 +1,204 @@ +// Audit fixture — peer-plugin parity for +// `ember/template-require-mandatory-role-attributes`. +// +// Source files (context/ checkouts): +// - eslint-plugin-jsx-a11y-main/src/rules/role-has-required-aria-props.js +// - eslint-plugin-jsx-a11y-main/__tests__/src/rules/role-has-required-aria-props-test.js +// - eslint-plugin-vuejs-accessibility-main/src/rules/role-has-required-aria-props.ts +// - angular-eslint-main/packages/eslint-plugin-template/src/rules/role-has-required-aria.ts +// - angular-eslint-main/packages/eslint-plugin-template/tests/rules/role-has-required-aria/cases.ts +// - eslint-plugin-lit-a11y/lib/rules/role-has-required-aria-attrs.js +// +// These tests are NOT part of the main suite and do not run in CI. They encode +// the CURRENT behavior of our rule. Each divergence from an upstream plugin is +// annotated as "DIVERGENCE —". + +'use strict'; + +const rule = require('../../../lib/rules/template-require-mandatory-role-attributes'); +const RuleTester = require('eslint').RuleTester; + +const ruleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser'), + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, +}); + +ruleTester.run('audit:role-has-required-aria (gts)', rule, { + valid: [ + // === Upstream parity (valid everywhere) === + '', + '', // no required props + '', + '', + // checkbox with aria-checked — valid in all plugins. + '', + // combobox with BOTH required props (jsx-a11y, vue, ours). + '', + // scrollbar requires aria-valuenow, aria-valuemin, aria-valuemax, aria-controls, aria-orientation. + // slider similarly — we leave the all-present case off for brevity. + + // Dynamic role — skipped by all. + '', + + // Unknown role — jsx-a11y filters out unknown, we return null. Both allow. + '', + + // === DIVERGENCE — === + // jsx-a11y: VALID via `isSemanticRoleElement` (semantic input[type=checkbox] + // counts as already-declaring aria-checked via its `checked` state). + // vue-a11y: VALID via explicit carve-out in filterRequiredPropsExceptions. + // angular: VALID via isSemanticRoleElement. + // Our rule: INVALID — we treat every element generically and `switch` has + // `aria-checked` as a required prop. Captured in invalid section below. + + // === Partial parity — space-separated role tokens === + // jsx-a11y + vue: split on whitespace, validate EACH recognised token. + // Our rule: splits on whitespace, validates only the FIRST recognised + // token (ARIA 1.2 §4.1 role-fallback semantics — UA picks the first + // recognised role). So `
` — which has + // "button" as the first recognised token (no required attrs) — + // remains valid for us but jsx-a11y would flag it for missing + // combobox attrs. + '', + // Both-token case where the first token HAS no required attrs: valid + // for us, invalid for jsx-a11y. + '', + ], + + invalid: [ + // === Upstream parity (invalid everywhere) === + { + code: '', + output: null, + errors: [{ messageId: 'missingAttributes' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'missingAttributes' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'missingAttributes' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'missingAttributes' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'missingAttributes' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'missingAttributes' }], + }, + + // === Partial parity — partial attrs present, still missing one === + // jsx-a11y flags `
` + // (missing aria-controls/aria-orientation/aria-valuenow). + // Our rule: also flags — missing-attrs list non-empty. Parity. + { + code: '', + output: null, + errors: [{ messageId: 'missingAttributes' }], + }, + + // === Parity — case-insensitive role comparison === + // jsx-a11y + vue + angular lowercase the role value before lookup. + // Our rule now does the same, so `
` → INVALID + // (missing aria-expanded / aria-controls). + { + code: '', + output: null, + errors: [{ messageId: 'missingAttributes' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'missingAttributes' }], + }, + + // === Parity — whitespace-separated roles, first recognised validated === + // `
` — both tokens are recognised roles + // with required attrs. Per ARIA role-fallback semantics we validate + // the first recognised token (combobox). jsx-a11y validates every + // token; both plugins end up flagging this same code (though our + // error names `combobox`, jsx-a11y may cite all missing attrs). + { + code: '', + output: null, + errors: [{ messageId: 'missingAttributes' }], + }, + + // === DIVERGENCE — input[type=checkbox] role="switch" === + // jsx-a11y / vue / angular: VALID (semantic exception). + // Our rule: INVALID (missing aria-checked). FALSE POSITIVE. + // (This PR does not fix the semantic-input exception; separate + // fix lives on fix/role-required-aria-checkbox-switch.) + { + code: '', + output: null, + errors: [{ messageId: 'missingAttributes' }], + }, + ], +}); + +const hbsRuleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser/hbs'), + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, +}); + +hbsRuleTester.run('audit:role-has-required-aria (hbs)', rule, { + valid: [ + '
', + '
', + '
', + '