Skip to content

Commit c85b421

Browse files
committed
fix(template-require-mandatory-role-attributes): lowercase role; split whitespace-separated role lists
Two changes, shared root cause (both deal with role-token normalisation). 1. Case-insensitive role matching. Per HTML-AAM, ARIA role tokens compare as ASCII-case-insensitive. Before: <div role="COMBOBOX"> was silently accepted because "COMBOBOX" didn't match "combobox" in aria-query. Now: lowercase before lookup. 2. Whitespace-separated role fallback lists. Per ARIA 1.2 §5.4, a role attribute may list multiple tokens to express a fallback; a UA picks the first one it recognises. Before: role="combobox listbox" was treated as one opaque string, aria-query returned undefined, and the rule skipped silently. Now: split on whitespace, check against the first recognised role's required attributes (matching jsx-a11y). Helpers renamed to plural forms (getStaticRolesFromElement, getStaticRolesFromMustache) and getMissingRequiredAttributes now returns { role, missing } so the reporter can use the actually-checked role in the error message. Four new tests cover the two cases (valid + invalid of each).
1 parent 5addb56 commit c85b421

2 files changed

Lines changed: 105 additions & 46 deletions

File tree

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

Lines changed: 62 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,37 @@
11
const { roles } = require('aria-query');
22
const { AXObjectRoles, elementAXObjects } = require('axobject-query');
33

4-
function getStaticRoleFromElement(node) {
4+
// ARIA role values are whitespace-separated tokens compared ASCII-case-insensitively.
5+
// Returns the list of normalised tokens, or undefined when the attribute is
6+
// missing or dynamic.
7+
function getStaticRolesFromElement(node) {
58
const roleAttr = node.attributes?.find((attr) => attr.name === 'role');
69

710
if (roleAttr?.value?.type === 'GlimmerTextNode') {
8-
return roleAttr.value.chars || undefined;
11+
return splitRoleTokens(roleAttr.value.chars);
912
}
1013

1114
return undefined;
1215
}
1316

14-
function getStaticRoleFromMustache(node) {
17+
function getStaticRolesFromMustache(node) {
1518
const rolePair = node.hash?.pairs?.find((pair) => pair.key === 'role');
1619

1720
if (rolePair?.value?.type === 'GlimmerStringLiteral') {
18-
return rolePair.value.value;
21+
return splitRoleTokens(rolePair.value.value);
1922
}
2023

2124
return undefined;
2225
}
2326

27+
function splitRoleTokens(value) {
28+
if (!value) {
29+
return undefined;
30+
}
31+
const tokens = value.trim().toLowerCase().split(/\s+/u).filter(Boolean);
32+
return tokens.length > 0 ? tokens : undefined;
33+
}
34+
2435
// Reads the static lowercase value of `name` from either a GlimmerElementNode
2536
// (angle-bracket attributes) or a GlimmerMustacheStatement (hash pairs).
2637
// Returns undefined for dynamic values or missing attributes.
@@ -79,15 +90,15 @@ const AX_CONCEPTS_BY_TAG = buildAxConceptsByTag();
7990
function buildAxConceptsByTag() {
8091
const index = new Map();
8192
for (const [concept, axObjectNames] of elementAXObjects) {
82-
const roles = new Set();
93+
const conceptRoles = new Set();
8394
for (const axName of axObjectNames) {
8495
const axRoles = AXObjectRoles.get(axName);
8596
if (!axRoles) continue;
8697
for (const axRole of axRoles) {
87-
roles.add(axRole.name);
98+
conceptRoles.add(axRole.name);
8899
}
89100
}
90-
const entry = { attributes: concept.attributes || [], roles };
101+
const entry = { attributes: concept.attributes || [], roles: conceptRoles };
91102
if (!index.has(concept.name)) {
92103
index.set(concept.name, []);
93104
}
@@ -106,8 +117,8 @@ function isSemanticRoleElement(node, role) {
106117
return false;
107118
}
108119
const targetRole = role.toLowerCase();
109-
for (const { attributes, roles } of entries) {
110-
if (!roles.has(targetRole)) {
120+
for (const { attributes, roles: conceptRoles } of entries) {
121+
if (!conceptRoles.has(targetRole)) {
111122
continue;
112123
}
113124
const allMatch = attributes.every((cAttr) => {
@@ -127,26 +138,39 @@ function isSemanticRoleElement(node, role) {
127138
return false;
128139
}
129140

130-
function getMissingRequiredAttributes(role, foundAriaAttributes, node) {
131-
const roleDefinition = roles.get(role);
132-
133-
if (!roleDefinition) {
134-
return null;
135-
}
136-
137-
// If axobject-query classifies this {element, role} pair as a semantic role
138-
// element, the native element provides all required ARIA state — skip the
139-
// missing-attribute check entirely (matches jsx-a11y's approach).
140-
if (isSemanticRoleElement(node, role)) {
141-
return null;
141+
// For an ARIA role-fallback list like "combobox listbox", check required
142+
// attributes against the FIRST recognised role (the primary) per ARIA 1.2
143+
// role-fallback semantics — a user agent picks the first role it recognises.
144+
// Abstract roles (widget, input, command, section, … — ARIA §5.3) are
145+
// ontology categories, not valid authoring roles, so UAs skip them too.
146+
//
147+
// When the primary role is a semantic-role element (axobject-query says the
148+
// native element provides the required ARIA state natively — e.g. <input
149+
// type=checkbox role=switch>), the element is exempt: return { role, missing: null }.
150+
//
151+
// Diverges from jsx-a11y, which validates every recognised token.
152+
function getMissingRequiredAttributes(roleTokens, foundAriaAttributes, node) {
153+
for (const role of roleTokens) {
154+
const roleDefinition = roles.get(role);
155+
if (!roleDefinition || roleDefinition.abstract) {
156+
continue;
157+
}
158+
// Semantic-role elements expose required ARIA state natively — skip.
159+
if (isSemanticRoleElement(node, role)) {
160+
return { role, missing: null };
161+
}
162+
const requiredAttributes = Object.keys(roleDefinition.requiredProps);
163+
const missingRequiredAttributes = requiredAttributes
164+
.filter((requiredAttribute) => !foundAriaAttributes.includes(requiredAttribute))
165+
// Sort for deterministic report order (aria-query's requiredProps
166+
// iteration order is not guaranteed stable across versions).
167+
.sort();
168+
return {
169+
role,
170+
missing: missingRequiredAttributes.length > 0 ? missingRequiredAttributes : null,
171+
};
142172
}
143-
144-
const requiredAttributes = Object.keys(roleDefinition.requiredProps);
145-
const missingRequiredAttributes = requiredAttributes.filter(
146-
(requiredAttribute) => !foundAriaAttributes.includes(requiredAttribute)
147-
);
148-
149-
return missingRequiredAttributes.length > 0 ? missingRequiredAttributes : null;
173+
return null;
150174
}
151175

152176
/** @type {import('eslint').Rule.RuleModule} */
@@ -189,46 +213,38 @@ module.exports = {
189213

190214
return {
191215
GlimmerElementNode(node) {
192-
const role = getStaticRoleFromElement(node);
216+
const roleTokens = getStaticRolesFromElement(node);
193217

194-
if (!role) {
218+
if (!roleTokens) {
195219
return;
196220
}
197221

198222
const foundAriaAttributes = (node.attributes ?? [])
199223
.filter((attribute) => attribute.name?.startsWith('aria-'))
200224
.map((attribute) => attribute.name);
201225

202-
const missingRequiredAttributes = getMissingRequiredAttributes(
203-
role,
204-
foundAriaAttributes,
205-
node
206-
);
226+
const result = getMissingRequiredAttributes(roleTokens, foundAriaAttributes, node);
207227

208-
if (missingRequiredAttributes) {
209-
reportMissingAttributes(node, role, missingRequiredAttributes);
228+
if (result?.missing) {
229+
reportMissingAttributes(node, result.role, result.missing);
210230
}
211231
},
212232

213233
GlimmerMustacheStatement(node) {
214-
const role = getStaticRoleFromMustache(node);
234+
const roleTokens = getStaticRolesFromMustache(node);
215235

216-
if (!role) {
236+
if (!roleTokens) {
217237
return;
218238
}
219239

220240
const foundAriaAttributes = (node.hash?.pairs ?? [])
221241
.filter((pair) => pair.key.startsWith('aria-'))
222242
.map((pair) => pair.key);
223243

224-
const missingRequiredAttributes = getMissingRequiredAttributes(
225-
role,
226-
foundAriaAttributes,
227-
node
228-
);
244+
const result = getMissingRequiredAttributes(roleTokens, foundAriaAttributes, node);
229245

230-
if (missingRequiredAttributes) {
231-
reportMissingAttributes(node, role, missingRequiredAttributes);
246+
if (result?.missing) {
247+
reportMissingAttributes(node, result.role, result.missing);
232248
}
233249
},
234250
};

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,17 @@ ruleTester.run('template-require-mandatory-role-attributes', rule, {
4444
'<template>{{input type="checkbox" role="switch"}}</template>',
4545
'<template>{{input type="Checkbox" role="switch"}}</template>',
4646
'<template>{{input type="range" role="slider"}}</template>',
47+
48+
// Case-insensitive role matching — ARIA role tokens compare as ASCII-case-insensitive.
49+
'<template><div role="COMBOBOX" aria-expanded="false" aria-controls="ctrl" /></template>',
50+
// Role fallback list — primary role's required attributes are satisfied.
51+
'<template><div role="combobox listbox" aria-expanded="false" aria-controls="ctrl" /></template>',
52+
// Abstract roles (ARIA §5.3) are skipped per §4.1 fallback semantics —
53+
// `widget` isn't an authoring role, so the UA walks past it to the next
54+
// recognised token. Here `button` has no required attrs → valid.
55+
'<template><div role="widget button" /></template>',
56+
// Abstract role followed by a concrete role that IS satisfied.
57+
'<template><div role="command slider" aria-valuenow="0" /></template>',
4758
],
4859

4960
invalid: [
@@ -148,6 +159,38 @@ ruleTester.run('template-require-mandatory-role-attributes', rule, {
148159
output: null,
149160
errors: [{ message: 'The attribute aria-checked is required by the role menuitemradio' }],
150161
},
162+
// Case-insensitivity surfaces previously-unflagged mistakes.
163+
{
164+
code: '<template><div role="COMBOBOX"></div></template>',
165+
output: null,
166+
errors: [
167+
{
168+
message: 'The attributes aria-controls, aria-expanded are required by the role combobox',
169+
},
170+
],
171+
},
172+
// Role-fallback list: when the primary role is missing required props, flag it.
173+
{
174+
code: '<template><div role="combobox listbox"></div></template>',
175+
output: null,
176+
errors: [
177+
{
178+
message: 'The attributes aria-controls, aria-expanded are required by the role combobox',
179+
},
180+
],
181+
},
182+
// Abstract role (`widget`) followed by a concrete role that's missing
183+
// required attrs — UA skips the abstract, lands on `slider`, which
184+
// requires aria-valuenow.
185+
{
186+
code: '<template><div role="widget slider"></div></template>',
187+
output: null,
188+
errors: [
189+
{
190+
message: 'The attribute aria-valuenow is required by the role slider',
191+
},
192+
],
193+
},
151194
],
152195
});
153196

0 commit comments

Comments
 (0)