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. + '', + '', + + // === 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 === + // jsx-a11y: VALID. Our rule: VALID via aria-query. + '', + '', + ], + + invalid: [ + // === Upstream parity (invalid in both jsx-a11y and us) === + { + code: '
', + output: null, + errors: [{ messageId: 'invalid' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'invalid' }], + }, + // jsx-a11y: invalid (`range` is an abstract role). + // Ours: `range` is not in VALID_ROLES so we flag it as "not a valid ARIA role". + // Upstream says "abstract role"; we conflate. Message wording differs. + { + code: '', + output: null, + errors: [{ messageId: 'invalid' }], + }, + + // === DIVERGENCE — empty role string === + // jsx-a11y: INVALID — `
` flagged. + // vue-a11y: INVALID — same. + // Our rule: early-return on empty/whitespace role (line 229 of rule). NO FLAG. + // So this case reflects OUR (non-flagging) behavior with an explicit note. + // (No invalid assertion possible here — we'd need to move this to valid, + // or fix the rule to flag.) + + // === Parity — space-separated with at least one invalid token === + // jsx-a11y: INVALID — splits and flags the token `foobar`. + // Our rule: splits on whitespace and now names the offending token + // specifically (`'foobar'`) rather than the whole compound string. + { + code: '', + output: null, + errors: [{ messageId: 'invalid' }], + }, + ], +}); + +// === DIVERGENCE — empty role string (captured as valid because we don't flag) === +// Intentionally isolated so the intent is clear. +ruleTester.run('audit:aria-role empty string (gts)', rule, { + valid: [ + // jsx-a11y + vue-a11y both flag this. We don't. This captures OUR behavior. + '', + ], + invalid: [], +}); + +const hbsRuleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser/hbs'), + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, +}); + +hbsRuleTester.run('audit:aria-role (hbs)', rule, { + valid: [ + '
', + '
', + '
', + // DIVERGENCE case-insensitivity (see gts section). + '
', + // DIVERGENCE empty string (we don't flag). + '
', + // Parity — space-separated all-valid tokens. + '
', + // Parity — DPUB-ARIA. + '
', + // Parity — Graphics-ARIA on . + '', + ], + invalid: [ + { + code: '
', + output: null, + errors: [{ messageId: 'invalid' }], + }, + // Parity — compound with at least one invalid token. + { + code: '
', + output: null, + errors: [{ messageId: 'invalid' }], + }, + ], +}); diff --git a/tests/lib/rules/template-no-invalid-role.js b/tests/lib/rules/template-no-invalid-role.js index efca5e138a..303e8ef66f 100644 --- a/tests/lib/rules/template-no-invalid-role.js +++ b/tests/lib/rules/template-no-invalid-role.js @@ -57,9 +57,6 @@ ruleTester.run('template-no-invalid-role', rule, { '', '', - // Missing VALID_ROLES entries: associationlistitemkey, associationlistitemvalue, cell - '', - '', '', // Case-insensitive role matching @@ -71,6 +68,26 @@ ruleTester.run('template-no-invalid-role', rule, { code: '', options: [{ catchNonexistentRoles: false }], }, + + // DPUB-ARIA (doc-*) and Graphics-ARIA (graphics-*) are valid per aria-query. + '', + '', + '', + '', + + // Whitespace-separated role fallback list — ARIA 1.2 §5.4. Each token + // must individually be valid. + '', + '', + '', + + // ARIA 1.3 draft roles — not in aria-query 5.3.2 but spec-blessed, so + // the rule accepts them via the inline allowlist. + '', + '', + '', + '', + '', ], invalid: [ @@ -164,18 +181,18 @@ ruleTester.run('template-no-invalid-role', rule, { { code: '', output: null, - errors: [{ message: "Invalid ARIA role 'command interface'. Must be a valid ARIA role." }], + errors: [{ message: "Invalid ARIA role 'command'. Must be a valid ARIA role." }], }, { code: '', output: null, - errors: [{ message: "Invalid ARIA role 'COMMAND INTERFACE'. Must be a valid ARIA role." }], + errors: [{ message: "Invalid ARIA role 'command'. Must be a valid ARIA role." }], }, { code: '', output: null, options: [{ catchNonexistentRoles: true }], - errors: [{ message: "Invalid ARIA role 'command interface'. Must be a valid ARIA role." }], + errors: [{ message: "Invalid ARIA role 'command'. Must be a valid ARIA role." }], }, // Newly added SEMANTIC_ELEMENTS: presentation/none on iframe, video, audio @@ -234,9 +251,6 @@ hbsRuleTester.run('template-no-invalid-role', rule, { '', '
', '
', - // Missing VALID_ROLES entries: associationlistitemkey, associationlistitemvalue, cell - '
Key
', - '
Value
', 'Data', // Case-insensitive role matching '
Click
', @@ -247,6 +261,24 @@ hbsRuleTester.run('template-no-invalid-role', rule, { code: '
', options: [{ catchNonexistentRoles: false }], }, + + // DPUB-ARIA (doc-*) and Graphics-ARIA (graphics-*) roles. + '
Abstract
', + '
', + '', + + // Whitespace-separated role fallback list. + '
', + '', + '
', + + // ARIA 1.3 draft roles — not in aria-query 5.3.2 but spec-blessed, so + // the rule accepts them via the inline allowlist. + '
', + '
', + '
', + '
', + '
', ], invalid: [ { @@ -302,18 +334,18 @@ hbsRuleTester.run('template-no-invalid-role', rule, { { code: '
', output: null, - errors: [{ message: "Invalid ARIA role 'command interface'. Must be a valid ARIA role." }], + errors: [{ message: "Invalid ARIA role 'command'. Must be a valid ARIA role." }], }, { code: '
', output: null, options: [{ catchNonexistentRoles: true }], - errors: [{ message: "Invalid ARIA role 'command interface'. Must be a valid ARIA role." }], + errors: [{ message: "Invalid ARIA role 'command'. Must be a valid ARIA role." }], }, { code: '
', output: null, - errors: [{ message: "Invalid ARIA role 'COMMAND INTERFACE'. Must be a valid ARIA role." }], + errors: [{ message: "Invalid ARIA role 'command'. Must be a valid ARIA role." }], }, // Newly added SEMANTIC_ELEMENTS: presentation/none on iframe, video, audio, embed {