diff --git a/lib/rules/template-require-mandatory-role-attributes.js b/lib/rules/template-require-mandatory-role-attributes.js index f399da3229..66153be724 100644 --- a/lib/rules/template-require-mandatory-role-attributes.js +++ b/lib/rules/template-require-mandatory-role-attributes.js @@ -1,46 +1,58 @@ const { roles } = require('aria-query'); -function createRequiredAttributeErrorMessage(attrs, role) { - if (attrs.length < 2) { - return `The attribute ${attrs[0]} is required by the role ${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) 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); + if (!roleDefinition) { + continue; + } + const requiredAttributes = Object.keys(roleDefinition.requiredProps); + const missingRequiredAttributes = requiredAttributes + .filter((requiredAttribute) => !foundAriaAttributes.includes(requiredAttribute)) + // Sort for deterministic report order (aria-query's requiredProps + // iteration order is not guaranteed stable across versions). + .sort(); + return { + role, + missing: missingRequiredAttributes.length > 0 ? missingRequiredAttributes : null, + }; + } + return null; } /** @type {import('eslint').Rule.RuleModule} */ @@ -83,9 +95,9 @@ module.exports = { return { GlimmerElementNode(node) { - const role = getStaticRoleFromElement(node); + const roleTokens = getStaticRolesFromElement(node); - if (!role) { + if (!roleTokens) { return; } @@ -93,17 +105,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 +123,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/audit/role-has-required-aria/peer-parity.js b/tests/audit/role-has-required-aria/peer-parity.js new file mode 100644 index 0000000000..98bd770056 --- /dev/null +++ b/tests/audit/role-has-required-aria/peer-parity.js @@ -0,0 +1,207 @@ +// Audit fixture — translates peer-plugin test cases into assertions against +// our rule (`ember/template-require-mandatory-role-attributes`). 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`. +// +// 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 + +'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: [ + '
', + '
', + '
', + '