Skip to content

Commit ff35fa6

Browse files
committed
fix(template-require-mandatory-role-attributes): use axobject-query for semantic-role exemptions
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., `<input type=range>` 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 (`<input type=range role=slider>` 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.
1 parent 24882a3 commit ff35fa6

6 files changed

Lines changed: 484 additions & 12 deletions

File tree

docs/rules/template-require-mandatory-role-attributes.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,35 @@ This rule **allows** the following:
3131
<div role="option" aria-selected="false" />
3232
<CustomComponent role="checkbox" aria-required="true" aria-checked="false" />
3333
{{some-component role="heading" aria-level="2"}}
34+
35+
{{! Native inputs supply required ARIA state for matching roles. Lookup is
36+
based on axobject-query's elementAXObjects + AXObjectRoles (see below). }}
37+
<input type="checkbox" role="switch" />
38+
<input type="checkbox" role="checkbox" />
39+
<input type="radio" role="radio" />
40+
<input type="range" role="slider" />
3441
</template>
3542
```
3643

44+
## Semantic-role exemptions
45+
46+
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`.
47+
48+
Exempt pairings include (non-exhaustive):
49+
50+
| Element | Role | Required ARIA state supplied by |
51+
| ------------------------- | -------------------- | ------------------------------------------------ |
52+
| `<input type="checkbox">` | `checkbox`, `switch` | native `checked` state |
53+
| `<input type="radio">` | `radio` | native `checked` state |
54+
| `<input type="range">` | `slider` | native `value` / `min` / `max` |
55+
| `<input type="number">` | `spinbutton` | native `value` (spinbutton has no required ARIA) |
56+
| `<input type="text">` | `textbox` | no required ARIA |
57+
| `<input type="search">` | `searchbox` | no required ARIA |
58+
59+
Un-documented pairings (e.g. `<input type="checkbox" role="menuitemcheckbox">` — axobject-query does not list this) remain flagged.
60+
3761
## References
3862

39-
- [WAI-ARIA Roles - Accessibility \_ MDN](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles)
63+
- [WAI-ARIA Roles](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles)
64+
- [WAI-ARIA APG — Switch pattern](https://www.w3.org/WAI/ARIA/apg/patterns/switch/)
65+
- [axobject-query](https://github.com/A11yance/axobject-query) — AX-tree data source for the exemption lookup

lib/rules/template-require-mandatory-role-attributes.js

Lines changed: 99 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,5 @@
11
const { roles } = require('aria-query');
2-
3-
function createRequiredAttributeErrorMessage(attrs, role) {
4-
if (attrs.length < 2) {
5-
return `The attribute ${attrs[0]} is required by the role ${role}`;
6-
}
7-
8-
return `The attributes ${attrs.join(', ')} are required by the role ${role}`;
9-
}
2+
const { AXObjectRoles, elementAXObjects } = require('axobject-query');
103

114
function getStaticRoleFromElement(node) {
125
const roleAttr = node.attributes?.find((attr) => attr.name === 'role');
@@ -28,13 +21,100 @@ function getStaticRoleFromMustache(node) {
2821
return undefined;
2922
}
3023

31-
function getMissingRequiredAttributes(role, foundAriaAttributes) {
24+
// Reads the static lowercase value of `name` from either a GlimmerElementNode
25+
// (angle-bracket attributes) or a GlimmerMustacheStatement (hash pairs).
26+
// Returns undefined for dynamic values or missing attributes.
27+
function getStaticAttrValue(node, name) {
28+
if (node?.type === 'GlimmerElementNode') {
29+
const attr = node.attributes?.find((a) => a.name === name);
30+
if (attr?.value?.type === 'GlimmerTextNode') {
31+
return attr.value.chars?.toLowerCase();
32+
}
33+
return undefined;
34+
}
35+
if (node?.type === 'GlimmerMustacheStatement') {
36+
const pair = node.hash?.pairs?.find((p) => p.key === name);
37+
if (pair?.value?.type === 'GlimmerStringLiteral') {
38+
return pair.value.value?.toLowerCase();
39+
}
40+
return undefined;
41+
}
42+
return undefined;
43+
}
44+
45+
function getTagName(node) {
46+
if (node?.type === 'GlimmerElementNode') {
47+
return node.tag;
48+
}
49+
if (node?.type === 'GlimmerMustacheStatement' && node.path?.original === 'input') {
50+
// The classic `{{input}}` helper renders a native <input>.
51+
return 'input';
52+
}
53+
return null;
54+
}
55+
56+
// Does this {element, role} pair match one of axobject-query's elementAXObjects
57+
// concepts? If so, the native element exposes the role's required ARIA state
58+
// automatically (e.g., <input type=checkbox> exposes aria-checked via the
59+
// `checked` attribute for both role=checkbox and role=switch).
60+
//
61+
// Mirrors jsx-a11y's `isSemanticRoleElement` util
62+
// (https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/src/util/isSemanticRoleElement.js).
63+
function isSemanticRoleElement(node, role) {
64+
const tag = getTagName(node);
65+
if (!tag || typeof role !== 'string') {
66+
return false;
67+
}
68+
const targetRole = role.toLowerCase();
69+
70+
for (const [concept, axObjectNames] of elementAXObjects) {
71+
if (concept.name !== tag) {
72+
continue;
73+
}
74+
const conceptAttrs = concept.attributes || [];
75+
const allMatch = conceptAttrs.every((cAttr) => {
76+
const nodeVal = getStaticAttrValue(node, cAttr.name);
77+
if (nodeVal === undefined) {
78+
return false;
79+
}
80+
if (cAttr.value === undefined) {
81+
return true;
82+
}
83+
return nodeVal === String(cAttr.value).toLowerCase();
84+
});
85+
if (!allMatch) {
86+
continue;
87+
}
88+
89+
for (const axName of axObjectNames) {
90+
const axRoles = AXObjectRoles.get(axName);
91+
if (!axRoles) {
92+
continue;
93+
}
94+
for (const axRole of axRoles) {
95+
if (axRole.name === targetRole) {
96+
return true;
97+
}
98+
}
99+
}
100+
}
101+
return false;
102+
}
103+
104+
function getMissingRequiredAttributes(role, foundAriaAttributes, node) {
32105
const roleDefinition = roles.get(role);
33106

34107
if (!roleDefinition) {
35108
return null;
36109
}
37110

111+
// If axobject-query classifies this {element, role} pair as a semantic role
112+
// element, the native element provides all required ARIA state — skip the
113+
// missing-attribute check entirely (matches jsx-a11y's approach).
114+
if (isSemanticRoleElement(node, role)) {
115+
return null;
116+
}
117+
38118
const requiredAttributes = Object.keys(roleDefinition.requiredProps);
39119
const missingRequiredAttributes = requiredAttributes.filter(
40120
(requiredAttribute) => !foundAriaAttributes.includes(requiredAttribute)
@@ -93,7 +173,11 @@ module.exports = {
93173
.filter((attribute) => attribute.name?.startsWith('aria-'))
94174
.map((attribute) => attribute.name);
95175

96-
const missingRequiredAttributes = getMissingRequiredAttributes(role, foundAriaAttributes);
176+
const missingRequiredAttributes = getMissingRequiredAttributes(
177+
role,
178+
foundAriaAttributes,
179+
node
180+
);
97181

98182
if (missingRequiredAttributes) {
99183
reportMissingAttributes(node, role, missingRequiredAttributes);
@@ -111,7 +195,11 @@ module.exports = {
111195
.filter((pair) => pair.key.startsWith('aria-'))
112196
.map((pair) => pair.key);
113197

114-
const missingRequiredAttributes = getMissingRequiredAttributes(role, foundAriaAttributes);
198+
const missingRequiredAttributes = getMissingRequiredAttributes(
199+
role,
200+
foundAriaAttributes,
201+
node
202+
);
115203

116204
if (missingRequiredAttributes) {
117205
reportMissingAttributes(node, role, missingRequiredAttributes);

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
"dependencies": {
6565
"@ember-data/rfc395-data": "^0.0.4",
6666
"aria-query": "^5.3.2",
67+
"axobject-query": "^4.1.0",
6768
"css-tree": "^3.0.1",
6869
"editorconfig": "^3.0.2",
6970
"ember-eslint-parser": "^0.10.0",

pnpm-lock.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)