diff --git a/lib/rules/template-no-unsupported-role-attributes.js b/lib/rules/template-no-unsupported-role-attributes.js index 5f4b08fc99..c3b047fe03 100644 --- a/lib/rules/template-no-unsupported-role-attributes.js +++ b/lib/rules/template-no-unsupported-role-attributes.js @@ -1,30 +1,78 @@ const { roles, elementRoles } = require('aria-query'); -function createUnsupportedAttributeErrorMessage(attribute, role, element) { - if (element) { - return `The attribute ${attribute} is not supported by the element ${element} with the implicit role of ${role}`; +function getStaticAttrValue(node, name) { + const attr = node.attributes?.find((a) => a.name === name); + if (!attr) { + return undefined; } - - return `The attribute ${attribute} is not supported by the role ${role}`; + if (!attr.value || attr.value.type !== 'GlimmerTextNode') { + // Presence with dynamic value — treat as "set" but unknown string. + return ''; + } + return attr.value.chars.trim(); } -function getImplicitRole(tagName, typeAttribute) { - if (tagName === 'input') { - for (const key of elementRoles.keys()) { - if (key.name === tagName && key.attributes) { - for (const attribute of key.attributes) { - if (attribute.name === 'type' && attribute.value === typeAttribute) { - return elementRoles.get(key)[0]; - } - } - } - } +function nodeSatisfiesAttributeConstraint(node, attrSpec) { + const value = getStaticAttrValue(node, attrSpec.name); + const isSet = value !== undefined; + + if (attrSpec.constraints?.includes('set')) { + return isSet; + } + if (attrSpec.constraints?.includes('undefined')) { + return !isSet; + } + if (attrSpec.value !== undefined) { + // HTML enumerated attribute values are ASCII case-insensitive + // (HTML common-microsyntaxes §2.3.3). aria-query's attrSpec.value is + // already lowercase, so lowercase the node's value for comparison. + return isSet && value.toLowerCase() === attrSpec.value; } + // No constraint listed — just require presence. + return isSet; +} - const key = [...elementRoles.keys()].find((entry) => entry.name === tagName); - const implicitRoles = key && elementRoles.get(key); +function keyMatchesNode(node, key) { + if (key.name !== node.tag) { + return false; + } + if (!key.attributes || key.attributes.length === 0) { + return true; + } + return key.attributes.every((attrSpec) => nodeSatisfiesAttributeConstraint(node, attrSpec)); +} - return implicitRoles && implicitRoles[0]; +function getImplicitRole(node) { + // Honor aria-query's attribute constraints when mapping element -> implicit role. + // Each elementRoles entry lists attributes that must match (with optional + // constraints "set" / "undefined"); pick the most specific entry whose + // attribute spec is fully satisfied by the node. + // + // Heuristic: "specificity = attribute-constraint count". aria-query exports + // elementRoles as an unordered Map and does not document how consumers + // should resolve multi-match cases; this count-based tiebreak is an + // inference from the data shape. It resolves the motivating bugs: + // - without `list` → textbox, not combobox + // (the combobox entry requires `list=set`, a stricter 2-attr match; + // the textbox entry's 1-attr type=text wins when `list` is absent). + // - → no role (no elementRoles entry matches). + // If aria-query ever publishes a resolution order, switch to that. + let bestKey; + let bestSpecificity = -1; + for (const key of elementRoles.keys()) { + if (!keyMatchesNode(node, key)) { + continue; + } + const specificity = key.attributes?.length ?? 0; + if (specificity > bestSpecificity) { + bestKey = key; + bestSpecificity = specificity; + } + } + if (!bestKey) { + return undefined; + } + return elementRoles.get(bestKey)[0]; } function getExplicitRole(node) { @@ -35,14 +83,6 @@ function getExplicitRole(node) { return null; } -function getTypeAttribute(node) { - const typeAttr = node.attributes?.find((attr) => attr.name === 'type'); - if (typeAttr && typeAttr.value?.type === 'GlimmerTextNode') { - return typeAttr.value.chars.trim(); - } - return null; -} - function removeRangeWithAdjacentWhitespace(sourceText, range) { let [start, end] = range; @@ -111,8 +151,7 @@ module.exports = { if (!role) { element = node.tag; - const typeAttribute = getTypeAttribute(node); - role = getImplicitRole(element, typeAttribute); + role = getImplicitRole(node); } if (!role) { diff --git a/tests/audit/role-supports-aria-props/peer-parity.js b/tests/audit/role-supports-aria-props/peer-parity.js new file mode 100644 index 0000000000..7488143043 --- /dev/null +++ b/tests/audit/role-supports-aria-props/peer-parity.js @@ -0,0 +1,213 @@ +// Audit fixture — translates peer-plugin test cases into assertions against +// our rule (`ember/template-no-unsupported-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-supports-aria-props.js +// - eslint-plugin-jsx-a11y-main/__tests__/src/rules/role-supports-aria-props-test.js +// - eslint-plugin-lit-a11y/lib/rules/role-supports-aria-attr.js +// - eslint-plugin-lit-a11y/tests/lib/rules/role-supports-aria-attr.js + +'use strict'; + +const rule = require('../../../lib/rules/template-no-unsupported-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-supports-aria-props (gts)', rule, { + valid: [ + // === Upstream parity (valid in jsx-a11y + ours) === + '', + '', + + // Explicit role with supported attr. + '', + '', + '', + '', + + // Implicit role tests that match between jsx-a11y and aria-query + // (we rely on aria-query's elementRoles). + // a with href — aria-query gives "generic" for first match; jsx-a11y + // gives "link". Both happen to support aria-describedby etc. + '', + // input[type=submit] — implicit "button". + '', + // select — implicit "combobox". + '', + // menu[type=toolbar] — aria-query gives "list"; jsx-a11y gives "toolbar". + // aria-hidden is a global attr supported by both — valid in both. + '', + + // Components / unknown elements are skipped. + '', + '', + + // Dynamic role (mustache value) — we skip. + // jsx-a11y similarly skips non-literal role values. + + // === DIVERGENCE — without href === + // jsx-a11y: `` without href has NO implicit role → uses global aria + // set → `` is VALID. + // Our rule: aria-query's first entry for `a` has no attribute constraint + // and returns "generic". `generic` does not support aria-checked → we + // would FLAG. (Invalid section captures this.) + // Captured as the opposite: `` passes in both + // because aria-describedby is global. + '', + ], + invalid: [ + // === Upstream parity (invalid in jsx-a11y + ours) === + // Explicit role rejects unsupported attrs. + { + code: '', + output: '', + errors: [{ messageId: 'unsupportedExplicit' }], + }, + { + code: '', + output: '', + errors: [{ messageId: 'unsupportedExplicit' }], + }, + { + code: '', + output: + '', + errors: [{ messageId: 'unsupportedExplicit' }], + }, + { + code: '', + output: '', + errors: [{ messageId: 'unsupportedExplicit' }], + }, + + // Implicit role rejects unsupported attrs (parity). + { + code: '', + output: '', + errors: [{ messageId: 'unsupportedImplicit' }], + }, + { + code: '', + output: '', + errors: [{ messageId: 'unsupportedImplicit' }], + }, + + // === DIVERGENCE — role-name in message differs for === + // jsx-a11y reports role "toolbar"; we report role "list". + // Both FLAG though, so the divergence is cosmetic (message text). + { + code: '', + output: '', + errors: [ + { + message: + 'The attribute aria-expanded is not supported by the element menu with the implicit role of list', + }, + ], + }, + + // === DIVERGENCE — role-name differs for === + // jsx-a11y: implicit role = "document". + // Our rule: aria-query first match gives role "generic". + // aria-expanded is unsupported by both, so both FLAG — diff is message. + { + code: '', + output: '', + errors: [ + { + message: + 'The attribute aria-expanded is not supported by the element body with the implicit role of generic', + }, + ], + }, + + // === Parity — without `list` → textbox === + // jsx-a11y considers these to be "textbox" (since aria-query's first + // "email" entry has "list attribute not set" constraint → textbox). + // Our rule now honors aria-query attribute constraints: `type=email` + // without a `list` attribute maps to "textbox". With `list=...` it + // maps to "combobox" (sibling case below). + // aria-level is not supported by either role; still flagged. + { + code: '', + output: '', + errors: [ + { + message: + 'The attribute aria-level is not supported by the element input with the implicit role of textbox', + }, + ], + }, + // → "combobox" (aria-level unsupported there too). + { + code: '', + output: '', + errors: [ + { + message: + 'The attribute aria-level is not supported by the element input with the implicit role of combobox', + }, + ], + }, + + // === DIVERGENCE — without href, with non-global aria attr === + // jsx-a11y: VALID (no implicit role → global set). + // Our rule: role=generic, aria-checked not supported → FLAG. FALSE POSITIVE. + { + code: '', + output: '', + errors: [{ messageId: 'unsupportedImplicit' }], + }, + ], +}); + +const hbsRuleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser/hbs'), + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, +}); + +hbsRuleTester.run('audit:role-supports-aria-props (hbs)', rule, { + valid: [ + '
', + '
', + '
', + '', + '