Skip to content

Commit 96a4705

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 24882a3 commit 96a4705

2 files changed

Lines changed: 71 additions & 25 deletions

File tree

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

Lines changed: 45 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,39 +8,59 @@ function createRequiredAttributeErrorMessage(attrs, role) {
88
return `The attributes ${attrs.join(', ')} are required by the role ${role}`;
99
}
1010

11-
function getStaticRoleFromElement(node) {
11+
// ARIA role values are whitespace-separated tokens compared ASCII-case-insensitively.
12+
// Returns the list of normalised tokens, or undefined when the attribute is
13+
// missing or dynamic.
14+
function getStaticRolesFromElement(node) {
1215
const roleAttr = node.attributes?.find((attr) => attr.name === 'role');
1316

1417
if (roleAttr?.value?.type === 'GlimmerTextNode') {
15-
return roleAttr.value.chars || undefined;
18+
return splitRoleTokens(roleAttr.value.chars);
1619
}
1720

1821
return undefined;
1922
}
2023

21-
function getStaticRoleFromMustache(node) {
24+
function getStaticRolesFromMustache(node) {
2225
const rolePair = node.hash?.pairs?.find((pair) => pair.key === 'role');
2326

2427
if (rolePair?.value?.type === 'GlimmerStringLiteral') {
25-
return rolePair.value.value;
28+
return splitRoleTokens(rolePair.value.value);
2629
}
2730

2831
return undefined;
2932
}
3033

31-
function getMissingRequiredAttributes(role, foundAriaAttributes) {
32-
const roleDefinition = roles.get(role);
33-
34-
if (!roleDefinition) {
35-
return null;
34+
function splitRoleTokens(value) {
35+
if (!value) {
36+
return undefined;
3637
}
38+
const tokens = value
39+
.trim()
40+
.toLowerCase()
41+
.split(/\s+/u)
42+
.filter(Boolean);
43+
return tokens.length > 0 ? tokens : undefined;
44+
}
3745

38-
const requiredAttributes = Object.keys(roleDefinition.requiredProps);
39-
const missingRequiredAttributes = requiredAttributes.filter(
40-
(requiredAttribute) => !foundAriaAttributes.includes(requiredAttribute)
41-
);
42-
43-
return missingRequiredAttributes.length > 0 ? missingRequiredAttributes : null;
46+
// For an ARIA role-fallback list like "combobox listbox", check required
47+
// attributes against the FIRST recognised role (the primary) — matches jsx-a11y.
48+
function getMissingRequiredAttributes(roleTokens, foundAriaAttributes) {
49+
for (const role of roleTokens) {
50+
const roleDefinition = roles.get(role);
51+
if (!roleDefinition) {
52+
continue;
53+
}
54+
const requiredAttributes = Object.keys(roleDefinition.requiredProps);
55+
const missingRequiredAttributes = requiredAttributes.filter(
56+
(requiredAttribute) => !foundAriaAttributes.includes(requiredAttribute)
57+
);
58+
return {
59+
role,
60+
missing: missingRequiredAttributes.length > 0 ? missingRequiredAttributes : null,
61+
};
62+
}
63+
return null;
4464
}
4565

4666
/** @type {import('eslint').Rule.RuleModule} */
@@ -83,38 +103,38 @@ module.exports = {
83103

84104
return {
85105
GlimmerElementNode(node) {
86-
const role = getStaticRoleFromElement(node);
106+
const roleTokens = getStaticRolesFromElement(node);
87107

88-
if (!role) {
108+
if (!roleTokens) {
89109
return;
90110
}
91111

92112
const foundAriaAttributes = (node.attributes ?? [])
93113
.filter((attribute) => attribute.name?.startsWith('aria-'))
94114
.map((attribute) => attribute.name);
95115

96-
const missingRequiredAttributes = getMissingRequiredAttributes(role, foundAriaAttributes);
116+
const result = getMissingRequiredAttributes(roleTokens, foundAriaAttributes);
97117

98-
if (missingRequiredAttributes) {
99-
reportMissingAttributes(node, role, missingRequiredAttributes);
118+
if (result?.missing) {
119+
reportMissingAttributes(node, result.role, result.missing);
100120
}
101121
},
102122

103123
GlimmerMustacheStatement(node) {
104-
const role = getStaticRoleFromMustache(node);
124+
const roleTokens = getStaticRolesFromMustache(node);
105125

106-
if (!role) {
126+
if (!roleTokens) {
107127
return;
108128
}
109129

110130
const foundAriaAttributes = (node.hash?.pairs ?? [])
111131
.filter((pair) => pair.key.startsWith('aria-'))
112132
.map((pair) => pair.key);
113133

114-
const missingRequiredAttributes = getMissingRequiredAttributes(role, foundAriaAttributes);
134+
const result = getMissingRequiredAttributes(roleTokens, foundAriaAttributes);
115135

116-
if (missingRequiredAttributes) {
117-
reportMissingAttributes(node, role, missingRequiredAttributes);
136+
if (result?.missing) {
137+
reportMissingAttributes(node, result.role, result.missing);
118138
}
119139
},
120140
};

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ ruleTester.run('template-require-mandatory-role-attributes', rule, {
2525
'<template>{{foo-component role="button"}}</template>',
2626
'<template>{{foo-component role="unknown"}}</template>',
2727
'<template>{{foo-component role=role}}</template>',
28+
29+
// Case-insensitive role matching — ARIA role tokens compare as ASCII-case-insensitive.
30+
'<template><div role="COMBOBOX" aria-expanded="false" aria-controls="ctrl" /></template>',
31+
// Role fallback list — primary role's required attributes are satisfied.
32+
'<template><div role="combobox listbox" aria-expanded="false" aria-controls="ctrl" /></template>',
2833
],
2934

3035
invalid: [
@@ -75,6 +80,27 @@ ruleTester.run('template-require-mandatory-role-attributes', rule, {
7580
output: null,
7681
errors: [{ message: 'The attribute aria-checked is required by the role checkbox' }],
7782
},
83+
84+
// Case-insensitivity surfaces previously-unflagged mistakes.
85+
{
86+
code: '<template><div role="COMBOBOX"></div></template>',
87+
output: null,
88+
errors: [
89+
{
90+
message: 'The attributes aria-controls, aria-expanded are required by the role combobox',
91+
},
92+
],
93+
},
94+
// Role-fallback list: when the primary role is missing required props, flag it.
95+
{
96+
code: '<template><div role="combobox listbox"></div></template>',
97+
output: null,
98+
errors: [
99+
{
100+
message: 'The attributes aria-controls, aria-expanded are required by the role combobox',
101+
},
102+
],
103+
},
78104
],
79105
});
80106

0 commit comments

Comments
 (0)