From ff35fa68ca8dd8b1f904f4b1ba38d9a389259c10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Wed, 22 Apr 2026 00:16:34 +0200 Subject: [PATCH 1/4] fix(template-require-mandatory-role-attributes): use axobject-query for semantic-role exemptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the 3-entry hand-list (`{input type}:{role}` pairings) with a lookup against axobject-query's `elementAXObjects` + `AXObjectRoles` maps. Mirrors the approach used by eslint-plugin-jsx-a11y (its `isSemanticRoleElement` util) and @angular-eslint/template. ## Why The hand-list covered 3 pairings: `checkbox:checkbox`, `checkbox:switch`, `radio:radio`. axobject-query encodes substantially more — including `input[type=range]:slider`, `input[type=number]:spinbutton`, `input[type=text]:textbox`, `input[type=search]:searchbox`. Each of these is a case where the native element already provides the role's required ARIA state (e.g., `` provides value via its native `value` attribute, satisfying `role=slider`'s `aria-valuenow` requirement). Using axobject-query directly: - Gives us strict superset coverage of the hand-list. - Stays in sync when axobject-query updates (the hand-list had already drifted — the earlier revision incorrectly claimed menuitemcheckbox / menuitemradio pairings were in axobject-query when they aren't). - Matches jsx-a11y / angular-eslint behavior, closing a documented parity gap. Adds `axobject-query@^4.1.0` as a direct dep. It's already a transitive dep via other ecosystem packages; this elevates it to first-class. ## Changes - `lib/rules/template-require-mandatory-role-attributes.js` — replace `NATIVELY_CHECKED_INPUT_ROLE_PAIRS` + `isNativelyChecked` with `isSemanticRoleElement` that walks `elementAXObjects` and checks `AXObjectRoles`. Handles both `GlimmerElementNode` (angle-bracket syntax) and `GlimmerMustacheStatement` (classic `{{input}}` helper). - `package.json` — add `axobject-query@^4.1.0`. - `tests/lib/rules/template-require-mandatory-role-attributes.js` — add tests for the broadened coverage (`` now valid in both gts and hbs forms). - `docs/rules/template-require-mandatory-role-attributes.md` — rewrite the exemption section to describe the axobject-query-backed lookup with a table of known pairings. --- ...plate-require-mandatory-role-attributes.md | 28 ++- ...plate-require-mandatory-role-attributes.js | 110 ++++++++- package.json | 1 + pnpm-lock.yaml | 9 + .../role-has-required-aria/peer-parity.js | 209 ++++++++++++++++++ ...plate-require-mandatory-role-attributes.js | 139 ++++++++++++ 6 files changed, 484 insertions(+), 12 deletions(-) create mode 100644 tests/audit/role-has-required-aria/peer-parity.js diff --git a/docs/rules/template-require-mandatory-role-attributes.md b/docs/rules/template-require-mandatory-role-attributes.md index 534c9189b8..bb2b044684 100644 --- a/docs/rules/template-require-mandatory-role-attributes.md +++ b/docs/rules/template-require-mandatory-role-attributes.md @@ -31,9 +31,35 @@ This rule **allows** the following:
{{some-component role="heading" aria-level="2"}} + + {{! Native inputs supply required ARIA state for matching roles. Lookup is + based on axobject-query's elementAXObjects + AXObjectRoles (see below). }} + + + + ``` +## Semantic-role exemptions + +When the role attribute explicitly declares a role that the native element already provides, the native element supplies the required ARIA state and the rule does not flag missing attributes. The exemption is looked up via [axobject-query](https://github.com/A11yance/axobject-query)'s `elementAXObjects` + `AXObjectRoles` maps, matching the approach used by `eslint-plugin-jsx-a11y` and `@angular-eslint/template`. + +Exempt pairings include (non-exhaustive): + +| Element | Role | Required ARIA state supplied by | +| ------------------------- | -------------------- | ------------------------------------------------ | +| `` | `checkbox`, `switch` | native `checked` state | +| `` | `radio` | native `checked` state | +| `` | `slider` | native `value` / `min` / `max` | +| `` | `spinbutton` | native `value` (spinbutton has no required ARIA) | +| `` | `textbox` | no required ARIA | +| `` | `searchbox` | no required ARIA | + +Un-documented pairings (e.g. `` — axobject-query does not list this) remain flagged. + ## References -- [WAI-ARIA Roles - Accessibility \_ MDN](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles) +- [WAI-ARIA Roles](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles) +- [WAI-ARIA APG — Switch pattern](https://www.w3.org/WAI/ARIA/apg/patterns/switch/) +- [axobject-query](https://github.com/A11yance/axobject-query) — AX-tree data source for the exemption lookup diff --git a/lib/rules/template-require-mandatory-role-attributes.js b/lib/rules/template-require-mandatory-role-attributes.js index f399da3229..e57b2d0216 100644 --- a/lib/rules/template-require-mandatory-role-attributes.js +++ b/lib/rules/template-require-mandatory-role-attributes.js @@ -1,12 +1,5 @@ const { roles } = require('aria-query'); - -function createRequiredAttributeErrorMessage(attrs, role) { - if (attrs.length < 2) { - return `The attribute ${attrs[0]} is required by the role ${role}`; - } - - return `The attributes ${attrs.join(', ')} are required by the role ${role}`; -} +const { AXObjectRoles, elementAXObjects } = require('axobject-query'); function getStaticRoleFromElement(node) { const roleAttr = node.attributes?.find((attr) => attr.name === 'role'); @@ -28,13 +21,100 @@ function getStaticRoleFromMustache(node) { return undefined; } -function getMissingRequiredAttributes(role, foundAriaAttributes) { +// Reads the static lowercase value of `name` from either a GlimmerElementNode +// (angle-bracket attributes) or a GlimmerMustacheStatement (hash pairs). +// Returns undefined for dynamic values or missing attributes. +function getStaticAttrValue(node, name) { + if (node?.type === 'GlimmerElementNode') { + const attr = node.attributes?.find((a) => a.name === name); + if (attr?.value?.type === 'GlimmerTextNode') { + return attr.value.chars?.toLowerCase(); + } + return undefined; + } + if (node?.type === 'GlimmerMustacheStatement') { + const pair = node.hash?.pairs?.find((p) => p.key === name); + if (pair?.value?.type === 'GlimmerStringLiteral') { + return pair.value.value?.toLowerCase(); + } + return undefined; + } + return undefined; +} + +function getTagName(node) { + if (node?.type === 'GlimmerElementNode') { + return node.tag; + } + if (node?.type === 'GlimmerMustacheStatement' && node.path?.original === 'input') { + // The classic `{{input}}` helper renders a native . + return 'input'; + } + return null; +} + +// Does this {element, role} pair match one of axobject-query's elementAXObjects +// concepts? If so, the native element exposes the role's required ARIA state +// automatically (e.g., exposes aria-checked via the +// `checked` attribute for both role=checkbox and role=switch). +// +// Mirrors jsx-a11y's `isSemanticRoleElement` util +// (https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/src/util/isSemanticRoleElement.js). +function isSemanticRoleElement(node, role) { + const tag = getTagName(node); + if (!tag || typeof role !== 'string') { + return false; + } + const targetRole = role.toLowerCase(); + + for (const [concept, axObjectNames] of elementAXObjects) { + if (concept.name !== tag) { + continue; + } + const conceptAttrs = concept.attributes || []; + const allMatch = conceptAttrs.every((cAttr) => { + const nodeVal = getStaticAttrValue(node, cAttr.name); + if (nodeVal === undefined) { + return false; + } + if (cAttr.value === undefined) { + return true; + } + return nodeVal === String(cAttr.value).toLowerCase(); + }); + if (!allMatch) { + continue; + } + + for (const axName of axObjectNames) { + const axRoles = AXObjectRoles.get(axName); + if (!axRoles) { + continue; + } + for (const axRole of axRoles) { + if (axRole.name === targetRole) { + return true; + } + } + } + } + return false; +} + +function getMissingRequiredAttributes(role, foundAriaAttributes, node) { const roleDefinition = roles.get(role); if (!roleDefinition) { return null; } + // If axobject-query classifies this {element, role} pair as a semantic role + // element, the native element provides all required ARIA state — skip the + // missing-attribute check entirely (matches jsx-a11y's approach). + if (isSemanticRoleElement(node, role)) { + return null; + } + const requiredAttributes = Object.keys(roleDefinition.requiredProps); const missingRequiredAttributes = requiredAttributes.filter( (requiredAttribute) => !foundAriaAttributes.includes(requiredAttribute) @@ -93,7 +173,11 @@ module.exports = { .filter((attribute) => attribute.name?.startsWith('aria-')) .map((attribute) => attribute.name); - const missingRequiredAttributes = getMissingRequiredAttributes(role, foundAriaAttributes); + const missingRequiredAttributes = getMissingRequiredAttributes( + role, + foundAriaAttributes, + node + ); if (missingRequiredAttributes) { reportMissingAttributes(node, role, missingRequiredAttributes); @@ -111,7 +195,11 @@ module.exports = { .filter((pair) => pair.key.startsWith('aria-')) .map((pair) => pair.key); - const missingRequiredAttributes = getMissingRequiredAttributes(role, foundAriaAttributes); + const missingRequiredAttributes = getMissingRequiredAttributes( + role, + foundAriaAttributes, + node + ); if (missingRequiredAttributes) { reportMissingAttributes(node, role, missingRequiredAttributes); diff --git a/package.json b/package.json index 070c505856..c985e34356 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "dependencies": { "@ember-data/rfc395-data": "^0.0.4", "aria-query": "^5.3.2", + "axobject-query": "^4.1.0", "css-tree": "^3.0.1", "editorconfig": "^3.0.2", "ember-eslint-parser": "^0.10.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02f7d84094..d32e257bf7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: aria-query: specifier: ^5.3.2 version: 5.3.2 + axobject-query: + specifier: ^4.1.0 + version: 4.1.0 css-tree: specifier: ^3.0.1 version: 3.2.1 @@ -1377,6 +1380,10 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -5311,6 +5318,8 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + axobject-query@4.1.0: {} + balanced-match@1.0.2: {} balanced-match@4.0.4: {} diff --git a/tests/audit/role-has-required-aria/peer-parity.js b/tests/audit/role-has-required-aria/peer-parity.js new file mode 100644 index 0000000000..004756b749 --- /dev/null +++ b/tests/audit/role-has-required-aria/peer-parity.js @@ -0,0 +1,209 @@ +// Audit fixture — peer-plugin parity for +// `ember/template-require-mandatory-role-attributes`. +// +// Source files (context/ checkouts): +// - eslint-plugin-jsx-a11y-main/src/rules/role-has-required-aria-props.js +// - eslint-plugin-jsx-a11y-main/__tests__/src/rules/role-has-required-aria-props-test.js +// - eslint-plugin-vuejs-accessibility-main/src/rules/role-has-required-aria-props.ts +// - angular-eslint-main/packages/eslint-plugin-template/src/rules/role-has-required-aria.ts +// - angular-eslint-main/packages/eslint-plugin-template/tests/rules/role-has-required-aria/cases.ts +// - eslint-plugin-lit-a11y/lib/rules/role-has-required-aria-attrs.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-require-mandatory-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-has-required-aria (gts)', rule, { + valid: [ + // === Upstream parity (valid everywhere) === + '', + '', // no required props + '', + '', + // checkbox with aria-checked — valid in all plugins. + '', + // combobox with BOTH required props (jsx-a11y, vue, ours). + '', + // scrollbar requires aria-valuenow, aria-valuemin, aria-valuemax, aria-controls, aria-orientation. + // slider similarly — we leave the all-present case off for brevity. + + // Dynamic role — skipped by all. + '', + + // Unknown role — jsx-a11y filters out unknown, we return null. Both allow. + '', + + // === Parity — semantic-role exemptions via axobject-query === + // jsx-a11y, angular-eslint, and our rule all consult axobject-query's + // elementAXObjects + AXObjectRoles to determine when a native element + // implements a given ARIA role. Pairings we cover (non-exhaustive): + // input[type=checkbox] → checkbox, switch (CheckBoxRole + SwitchRole) + // input[type=radio] → radio (RadioButtonRole) + // input[type=range] → slider (SliderRole) + // input[type=number] → spinbutton (SpinButtonRole) + // input[type=text] → textbox (TextFieldRole) + // input[type=search] → searchbox (SearchBoxRole) + // vue-a11y: VALID only for {role: switch, type: checkbox} via its hardcoded + // `filterRequiredPropsExceptions`. Narrower than axobject-query coverage. + '', + '', + '', + '', + // HTML type keyword values are ASCII case-insensitive. + '', + + // === Parity — input + menuitemcheckbox/menuitemradio flagged === + // Neither axobject-query's MenuItemCheckBoxRole nor MenuItemRadioRole + // lists an HTML concept; they only have ARIA concepts. So + // jsx-a11y / angular / ours all flag these pairings (captured in the + // `invalid` section below). + + // === DIVERGENCE — space-separated role tokens === + // jsx-a11y + vue: split on whitespace, validate each token. If every token + // is a valid role, require attrs for each. + // Our rule: looks up the whole string in aria-query. `"combobox listbox"` + // is not a role → returns null → no missing attrs → NO FLAG. + // Net: jsx-a11y would flag `
` (missing attrs + // for both), we don't. Captured as valid below. + '', + + // === DIVERGENCE — case-insensitivity on role value === + // jsx-a11y + vue + angular: lowercase the role value before lookup. + // `
` → INVALID (missing aria-expanded/controls). + // Our rule: passes the raw string; aria-query lookup misses → no flag. + '', + '', + ], + + invalid: [ + // === Upstream parity (invalid everywhere) === + { + code: '', + output: null, + errors: [{ messageId: 'missingAttributes' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'missingAttributes' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'missingAttributes' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'missingAttributes' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'missingAttributes' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'missingAttributes' }], + }, + + // === DIVERGENCE — partial attrs present, still missing one === + // jsx-a11y flags `
` + // (missing aria-controls/aria-orientation/aria-valuenow). + // Our rule: also flags — missing-attrs list non-empty. Parity. + { + code: '', + output: null, + errors: [{ messageId: 'missingAttributes' }], + }, + + // === DIVERGENCE — undocumented input+role pairings === + // Pairings NOT on our whitelist remain flagged. jsx-a11y/angular defer to + // axobject-query's `elementAXObjects`, which covers a larger set; we only + // recognize the documented five pairings. Example mismatches: + // - input[type=checkbox] role=radio → we flag, jsx-a11y/angular don't + // - input[type=radio] role=switch → we flag, peer behavior varies + // These remain invalid (missing aria-checked) in our rule. + { + code: '', + output: null, + errors: [{ messageId: 'missingAttributes' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'missingAttributes' }], + }, + // Bare `` (no `type`) — not on our whitelist, stays + // flagged. The input's default `type=text` does not expose aria-checked. + { + code: '', + output: null, + errors: [{ messageId: 'missingAttributes' }], + }, + ], +}); + +const hbsRuleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser/hbs'), + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, +}); + +hbsRuleTester.run('audit:role-has-required-aria (hbs)', rule, { + valid: [ + '
', + '
', + '
', + '