diff --git a/lib/rules/template-no-invalid-role.js b/lib/rules/template-no-invalid-role.js
index 9c82372821..192c0899a0 100644
--- a/lib/rules/template-no-invalid-role.js
+++ b/lib/rules/template-no-invalid-role.js
@@ -1,92 +1,22 @@
-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 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. `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',
- '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 +155,39 @@ 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') &&
- 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, 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/audit/aria-role/peer-parity.js b/tests/audit/aria-role/peer-parity.js
new file mode 100644
index 0000000000..7ec82dd815
--- /dev/null
+++ b/tests/audit/aria-role/peer-parity.js
@@ -0,0 +1,159 @@
+// 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`.
+//
+// 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.
+ '
Click
',
+ '
Nav
',
+
+ // === 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