From a61e9a648b6be049fa6c7da3abd20dab7aedcec4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 21 Apr 2026 07:36:32 +0200 Subject: [PATCH 1/8] fix(template-no-unsupported-role-attributes): honor aria-query attribute constraints in implicit-role lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old getImplicitRole picked the first elementRoles entry whose name matched the tag. For it tried a type-attribute match but ignored the rest of aria-query's constraint annotations. Concrete consequences of the old logic: - without a `list` attribute returned "combobox" (because aria-query's {type=text, list=set}→combobox entry appears before {type=text, list=undefined}→textbox). Correct implicit role is textbox. - behaved the same way. - isn't mapped by aria-query; the fallback branch returned the first input entry — "button". The new implementation walks every elementRoles key whose tag matches, checks each attribute spec (honoring `constraints: ["set"]` and `constraints: ["undefined"]` as well as required values), and picks the entry with the most attribute constraints satisfied. Behavioral impact: - Text-like inputs without a list attribute now correctly resolve to textbox and accept aria-required / aria-readonly / aria-placeholder. - now resolves to no implicit role, so global ARIA attrs don't trip the rule (matching jsx-a11y's treatment). - One existing test updated: now maps to textbox in the error message; added a sibling case with `list="x"` to cover the combobox path. - Eight new valid tests cover the textbox/password paths. --- ...template-no-unsupported-role-attributes.js | 84 +++++++++++++------ ...template-no-unsupported-role-attributes.js | 26 ++++++ 2 files changed, 86 insertions(+), 24 deletions(-) diff --git a/lib/rules/template-no-unsupported-role-attributes.js b/lib/rules/template-no-unsupported-role-attributes.js index 5f4b08fc99..a5d9383105 100644 --- a/lib/rules/template-no-unsupported-role-attributes.js +++ b/lib/rules/template-no-unsupported-role-attributes.js @@ -8,23 +8,68 @@ function createUnsupportedAttributeErrorMessage(attribute, role, element) { return `The attribute ${attribute} is not supported by the role ${role}`; } -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 getStaticAttrValue(node, name) { + const attr = node.attributes?.find((a) => a.name === name); + if (!attr) { + return undefined; } + if (!attr.value || attr.value.type !== 'GlimmerTextNode') { + // Presence with dynamic value — treat as "set" but unknown string. + return ''; + } + return attr.value.chars; +} + +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) { + return isSet && value === 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. + 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 +80,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 +148,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/lib/rules/template-no-unsupported-role-attributes.js b/tests/lib/rules/template-no-unsupported-role-attributes.js index d2d86948e3..ec07928998 100644 --- a/tests/lib/rules/template-no-unsupported-role-attributes.js +++ b/tests/lib/rules/template-no-unsupported-role-attributes.js @@ -21,6 +21,19 @@ const validHbs = [ '', '', '', + + // has no implicit role per aria-query (it's intentionally + // not mapped so that screen readers don't announce typed content). No role → + // no aria-supported-props check. + '', + '', + + // without a list attribute is a textbox — aria-required, + // aria-readonly, aria-placeholder are all supported. + '', + '', + '', + '', ]; const invalidHbs = [ @@ -80,8 +93,21 @@ const invalidHbs = [ ], }, { + // without a `list` attribute → implicit role "textbox" + // (per aria-query / HTML-AAM). With a `list` attribute it would be "combobox". code: '', output: '', + errors: [ + { + message: + 'The attribute aria-level is not supported by the element input with the implicit role of textbox', + }, + ], + }, + { + // With a `list` attribute, becomes a combobox. + code: '', + output: '', errors: [ { message: From 26555ddc213439d50432ac277003e4f8883eff6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 21 Apr 2026 08:35:26 +0200 Subject: [PATCH 2/8] lint: prettier format --- lib/rules/template-no-unsupported-role-attributes.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/rules/template-no-unsupported-role-attributes.js b/lib/rules/template-no-unsupported-role-attributes.js index a5d9383105..b121f223a0 100644 --- a/lib/rules/template-no-unsupported-role-attributes.js +++ b/lib/rules/template-no-unsupported-role-attributes.js @@ -44,9 +44,7 @@ function keyMatchesNode(node, key) { if (!key.attributes || key.attributes.length === 0) { return true; } - return key.attributes.every((attrSpec) => - nodeSatisfiesAttributeConstraint(node, attrSpec) - ); + return key.attributes.every((attrSpec) => nodeSatisfiesAttributeConstraint(node, attrSpec)); } function getImplicitRole(node) { From ef99ec72e090852d550a6511912884339eed08ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 21 Apr 2026 17:44:32 +0200 Subject: [PATCH 3/8] test: add Phase 3 audit fixture translating role-supports-aria-props peer cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Translates 33 cases from peer-plugin rules: - jsx-a11y role-supports-aria-props - lit-a11y role-supports-aria-attr - vuejs-accessibility (no direct equivalent; divergence noted) The fixture documents where our rule now matches jsx-a11y (notably without `list` mapping to "textbox" per the aria-query attribute constraints) plus the sibling `list=…` case mapping to "combobox". --- .../role-supports-aria-props/peer-parity.js | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 tests/audit/role-supports-aria-props/peer-parity.js 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..a762121c16 --- /dev/null +++ b/tests/audit/role-supports-aria-props/peer-parity.js @@ -0,0 +1,209 @@ +// Audit fixture — peer-plugin parity for +// `ember/template-no-unsupported-role-attributes`. +// +// 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 +// +// These tests are NOT part of the main suite and do not run in CI. They encode +// the CURRENT behavior of our rule. Each divergence from an upstream plugin is +// annotated as "DIVERGENCE —". + +'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: [ + '
', + '
', + '
', + '', + '