diff --git a/docs/rules/template-require-mandatory-role-attributes.md b/docs/rules/template-require-mandatory-role-attributes.md index 534c9189b8..e7b7521dc9 100644 --- a/docs/rules/template-require-mandatory-role-attributes.md +++ b/docs/rules/template-require-mandatory-role-attributes.md @@ -31,9 +31,39 @@ 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/) +- [HTML-AAM — `` → `checkbox` role mapping](https://www.w3.org/TR/html-aam-1.1/#el-input-checkbox) + — primary-spec source: the native element's accessibility mapping supplies + the required ARIA state via the `checked` IDL attribute, which is what + axobject-query encodes. +- [axobject-query](https://github.com/A11yance/axobject-query) — AX-tree data source for the exemption lookup (secondary, encodes HTML-AAM) diff --git a/lib/rules/template-require-mandatory-role-attributes.js b/lib/rules/template-require-mandatory-role-attributes.js index f399da3229..e958ddc9de 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,106 @@ 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). +// +// Perf note: this walks the full `elementAXObjects` map for every call, giving +// an O(n·m) scan per node (n = concepts, m = axObject→roles). In practice the +// map is small (~dozens of entries) and callers only invoke this after a role +// attribute has already been matched, so it hasn't shown up as a hotspot. +// A future optimization could precompute a `{tag,role} → boolean` lookup. +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 +179,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 +201,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..9a2f6c6214 --- /dev/null +++ b/tests/audit/role-has-required-aria/peer-parity.js @@ -0,0 +1,213 @@ +// Audit fixture — translates peer-plugin test cases into assertions against +// our rule (`ember/template-require-mandatory-role-attributes`). 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/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 + +'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' }], + }, + + // === Pairings NOT exempt — axobject-query does not list them === + // Semantic-role exemption is driven by axobject-query's `elementAXObjects` + // + `AXObjectRoles` maps — see `isSemanticRoleElement()` in the rule + // source. Pairings the AX-tree data does not list (such as + // `input[type=checkbox] role=radio` or `input[type=radio] role=switch`) + // fall through to the normal required-attribute check and are flagged + // for missing `aria-checked`. + { + code: '', + output: null, + errors: [{ messageId: 'missingAttributes' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'missingAttributes' }], + }, + // Bare `` (no `type`) has no exempt pairing either — + // the element defaults to `type=text`, which axobject-query does not map + // to the switch role. + { + 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: [ + '
', + '
', + '
', + '