diff --git a/lib/rules/template-no-redundant-role.js b/lib/rules/template-no-redundant-role.js index abaf75cf2d..527dc484bd 100644 --- a/lib/rules/template-no-redundant-role.js +++ b/lib/rules/template-no-redundant-role.js @@ -37,6 +37,34 @@ const ALLOWED_ELEMENT_ROLES = [ { name: 'input', role: 'combobox' }, ]; +// Per HTML-AAM, is a combobox by default per HTML-AAM (section 4). When + // `multiple` is present or `size > 1`, it maps to "listbox" instead; + // that case is handled at the call site via getSelectImplicitRole. + combobox: ['select'], columnheader: ['th'], complementary: ['aside'], contentinfo: ['footer'], @@ -125,7 +157,14 @@ module.exports = { let roleValue; if (roleAttr.value && roleAttr.value.type === 'GlimmerTextNode') { - roleValue = roleAttr.value.chars || ''; + // ARIA role tokens are compared ASCII-case-insensitively, and the + // attribute is a space-separated fallback list — only the first + // supported token is honored as the effective role. + const firstToken = (roleAttr.value.chars || '').trim().toLowerCase().split(/\s+/u)[0]; + if (!firstToken) { + return; + } + roleValue = firstToken; } else { // Skip dynamic role values return; @@ -141,6 +180,18 @@ module.exports = { return; } + // 's implicit + // role actually is "combobox" (no `multiple`, and `size` absent or <= 1). + // Otherwise the implicit role is "listbox", so the explicit "combobox" + // is not redundant and this rule should not flag it. When `size` is + // dynamic we bail ('unknown') rather than risk a false positive. + if (node.tag === 'select' && roleValue === 'combobox') { + const implicit = getSelectImplicitRole(node); + if (implicit !== 'combobox') { + return; + } + } + const isRedundant = elementsWithRole.includes(node.tag) && !ALLOWED_ELEMENT_ROLES.some((e) => e.name === node.tag && e.role === roleValue); diff --git a/tests/audit/no-redundant-roles/peer-parity.js b/tests/audit/no-redundant-roles/peer-parity.js new file mode 100644 index 0000000000..8f0288567f --- /dev/null +++ b/tests/audit/no-redundant-roles/peer-parity.js @@ -0,0 +1,214 @@ +// Audit fixture — translates peer-plugin test cases into assertions against +// our rule (`ember/template-no-redundant-role`). 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/no-redundant-roles.js +// - eslint-plugin-jsx-a11y-main/__tests__/src/rules/no-redundant-roles-test.js +// - eslint-plugin-vuejs-accessibility-main/src/rules/no-redundant-roles.ts +// - eslint-plugin-lit-a11y/lib/rules/no-redundant-role.js + +'use strict'; + +const rule = require('../../../lib/rules/template-no-redundant-role'); +const RuleTester = require('eslint').RuleTester; + +const ruleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser'), + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, +}); + +ruleTester.run('audit:no-redundant-roles (gts)', rule, { + valid: [ + // === Upstream parity (valid in all plugins + ours) === + // No role attribute. + '', + // Role differs from implicit. + '', + '', + // jsx-a11y/lit-a11y default exception: nav[role="navigation"] is allowed. + // Our ALLOWED_ELEMENT_ROLES also permits this. + '', + // form[role="search"] — different from implicit "form" role. + '', + // Dynamic role — we skip. + '', + + // === DIVERGENCE —