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