Skip to content

Commit 57cdaf9

Browse files
Merge pull request ember-cli#2725 from johanrd/fix/role-required-aria-checkbox-switch
fix(template-require-mandatory-role-attributes): use axobject-query for semantic-role exemptions
2 parents b6e78e8 + c04f35f commit 57cdaf9

5 files changed

Lines changed: 320 additions & 12 deletions

File tree

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

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,40 @@ 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+
Undocumented 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+
- [HTML-AAM — `<input type="checkbox">``checkbox` role mapping](https://www.w3.org/TR/html-aam-1.0/#el-input-checkbox)
66+
— primary-spec source: HTML-AAM maps the native element to the
67+
`checkbox` role and derives `aria-checked` from the element's
68+
checkedness (and `indeterminate` for `mixed`). axobject-query
69+
encodes that mapping for tooling.
70+
- [axobject-query](https://github.com/A11yance/axobject-query) — AX-tree data source for the exemption lookup (secondary, encodes HTML-AAM)

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

Lines changed: 127 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,128 @@ 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+
//
52+
// Caveat: in strict GJS/GTS mode, `{{input}}` is whatever was imported
53+
// under the name `input` — it could be the classic helper (still renders
54+
// native <input>) or some user-defined component. We assume the classic
55+
// helper; the false-positive rate in practice is low because strict-mode
56+
// authors rarely use `{{input}}` at all (idiomatic is <input> or
57+
// <Input>), and when they do, it's almost always the imported built-in.
58+
return 'input';
59+
}
60+
return null;
61+
}
62+
63+
// Does this {element, role} pair match one of axobject-query's elementAXObjects
64+
// concepts? If so, the native element exposes the role's required ARIA state
65+
// automatically (e.g., <input type=checkbox> exposes aria-checked via the
66+
// `checked` attribute for both role=checkbox and role=switch).
67+
//
68+
// Mirrors jsx-a11y's `isSemanticRoleElement` util
69+
// (https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/src/util/isSemanticRoleElement.js).
70+
//
71+
// Pre-indexed at module load: elementAXObjects is static data, so we resolve
72+
// each concept's exposed-role set once (walking axObjectNames → AXObjectRoles
73+
// → role names) and bucket concepts by tag. That turns the per-call hot path
74+
// into O(concepts-for-this-tag × attrs-on-that-concept), which in practice
75+
// is a handful of entries. Benchmarked at ~12.5× faster than the naive full-
76+
// map walk on a realistic 200k-call workload.
77+
const AX_CONCEPTS_BY_TAG = buildAxConceptsByTag();
78+
79+
function buildAxConceptsByTag() {
80+
const index = new Map();
81+
for (const [concept, axObjectNames] of elementAXObjects) {
82+
const roles = new Set();
83+
for (const axName of axObjectNames) {
84+
const axRoles = AXObjectRoles.get(axName);
85+
if (!axRoles) {
86+
continue;
87+
}
88+
for (const axRole of axRoles) {
89+
roles.add(axRole.name);
90+
}
91+
}
92+
const entry = { attributes: concept.attributes || [], roles };
93+
if (!index.has(concept.name)) {
94+
index.set(concept.name, []);
95+
}
96+
index.get(concept.name).push(entry);
97+
}
98+
return index;
99+
}
100+
101+
function isSemanticRoleElement(node, role) {
102+
const tag = getTagName(node);
103+
if (!tag || typeof role !== 'string') {
104+
return false;
105+
}
106+
const entries = AX_CONCEPTS_BY_TAG.get(tag);
107+
if (!entries) {
108+
return false;
109+
}
110+
const targetRole = role.toLowerCase();
111+
for (const { attributes, roles } of entries) {
112+
if (!roles.has(targetRole)) {
113+
continue;
114+
}
115+
const allMatch = attributes.every((cAttr) => {
116+
const nodeVal = getStaticAttrValue(node, cAttr.name);
117+
if (nodeVal === undefined) {
118+
return false;
119+
}
120+
if (cAttr.value === undefined) {
121+
return true;
122+
}
123+
return nodeVal === String(cAttr.value).toLowerCase();
124+
});
125+
if (allMatch) {
126+
return true;
127+
}
128+
}
129+
return false;
130+
}
131+
132+
function getMissingRequiredAttributes(role, foundAriaAttributes, node) {
32133
const roleDefinition = roles.get(role);
33134

34135
if (!roleDefinition) {
35136
return null;
36137
}
37138

139+
// If axobject-query classifies this {element, role} pair as a semantic role
140+
// element, the native element provides all required ARIA state — skip the
141+
// missing-attribute check entirely (matches jsx-a11y's approach).
142+
if (isSemanticRoleElement(node, role)) {
143+
return null;
144+
}
145+
38146
const requiredAttributes = Object.keys(roleDefinition.requiredProps);
39147
const missingRequiredAttributes = requiredAttributes.filter(
40148
(requiredAttribute) => !foundAriaAttributes.includes(requiredAttribute)
@@ -93,7 +201,11 @@ module.exports = {
93201
.filter((attribute) => attribute.name?.startsWith('aria-'))
94202
.map((attribute) => attribute.name);
95203

96-
const missingRequiredAttributes = getMissingRequiredAttributes(role, foundAriaAttributes);
204+
const missingRequiredAttributes = getMissingRequiredAttributes(
205+
role,
206+
foundAriaAttributes,
207+
node
208+
);
97209

98210
if (missingRequiredAttributes) {
99211
reportMissingAttributes(node, role, missingRequiredAttributes);
@@ -111,7 +223,11 @@ module.exports = {
111223
.filter((pair) => pair.key.startsWith('aria-'))
112224
.map((pair) => pair.key);
113225

114-
const missingRequiredAttributes = getMissingRequiredAttributes(role, foundAriaAttributes);
226+
const missingRequiredAttributes = getMissingRequiredAttributes(
227+
role,
228+
foundAriaAttributes,
229+
node
230+
);
115231

116232
if (missingRequiredAttributes) {
117233
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.11.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)