Skip to content

Commit 414d6d5

Browse files
Merge pull request #2728 from johanrd/fix/role-required-aria-case-and-space-split
BUGFIX: template-require-mandatory-role-attributes — lowercase role + split whitespace role lists
2 parents 2c709c8 + 48deb08 commit 414d6d5

3 files changed

Lines changed: 172 additions & 77 deletions

File tree

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

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,14 @@ When the role attribute explicitly declares a role that the native element alrea
4747

4848
Exempt pairings include (non-exhaustive):
4949

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 |
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` / `min` / `max` (spinbutton's required `aria-valuenow` is derived from the native `value`) |
56+
| `<input type="text">` | `textbox` | no required ARIA |
57+
| `<input type="search">` | `searchbox` | no required ARIA |
5858

5959
Undocumented pairings (e.g. `<input type="checkbox" role="menuitemcheckbox">` — axobject-query does not list this) remain flagged.
6060

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

Lines changed: 85 additions & 59 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.
@@ -42,20 +53,30 @@ function getStaticAttrValue(node, name) {
4253
return undefined;
4354
}
4455

45-
function getTagName(node) {
56+
// In classic Handlebars (.hbs) `{{input}}` globally resolves to Ember's
57+
// built-in input helper, which renders a native <input>. In strict-mode
58+
// GJS/GTS there is no corresponding lowercase `input` export from
59+
// `@ember/component` (only the PascalCase `<Input>` component), so
60+
// `{{input}}` in strict mode is always a user-bound identifier and cannot
61+
// be assumed to render a native <input>. Treating it as native there
62+
// would silently skip required-ARIA checks on arbitrary components.
63+
function isClassicHbsFilename(context) {
64+
const filename = context.filename || context.getFilename?.() || '';
65+
return !filename.endsWith('.gjs') && !filename.endsWith('.gts');
66+
}
67+
68+
function getTagName(node, context) {
4669
if (node?.type === 'GlimmerElementNode') {
47-
return node.tag;
70+
// HTML tag names are case-insensitive; normalize so <INPUT>/<Input> match
71+
// the lowercase keys in AX_CONCEPTS_BY_TAG and the semantic-role maps.
72+
return node.tag?.toLowerCase();
4873
}
4974
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';
75+
if (!context || isClassicHbsFilename(context)) {
76+
return 'input';
77+
}
78+
// Strict-mode {{input}} — not the classic helper, can't claim native.
79+
return null;
5980
}
6081
return null;
6182
}
@@ -79,17 +100,17 @@ const AX_CONCEPTS_BY_TAG = buildAxConceptsByTag();
79100
function buildAxConceptsByTag() {
80101
const index = new Map();
81102
for (const [concept, axObjectNames] of elementAXObjects) {
82-
const roles = new Set();
103+
const conceptRoles = new Set();
83104
for (const axName of axObjectNames) {
84105
const axRoles = AXObjectRoles.get(axName);
85106
if (!axRoles) {
86107
continue;
87108
}
88109
for (const axRole of axRoles) {
89-
roles.add(axRole.name);
110+
conceptRoles.add(axRole.name);
90111
}
91112
}
92-
const entry = { attributes: concept.attributes || [], roles };
113+
const entry = { attributes: concept.attributes || [], roles: conceptRoles };
93114
if (!index.has(concept.name)) {
94115
index.set(concept.name, []);
95116
}
@@ -98,8 +119,8 @@ function buildAxConceptsByTag() {
98119
return index;
99120
}
100121

101-
function isSemanticRoleElement(node, role) {
102-
const tag = getTagName(node);
122+
function isSemanticRoleElement(node, role, context) {
123+
const tag = getTagName(node, context);
103124
if (!tag || typeof role !== 'string') {
104125
return false;
105126
}
@@ -108,8 +129,8 @@ function isSemanticRoleElement(node, role) {
108129
return false;
109130
}
110131
const targetRole = role.toLowerCase();
111-
for (const { attributes, roles } of entries) {
112-
if (!roles.has(targetRole)) {
132+
for (const { attributes, roles: conceptRoles } of entries) {
133+
if (!conceptRoles.has(targetRole)) {
113134
continue;
114135
}
115136
const allMatch = attributes.every((cAttr) => {
@@ -129,26 +150,39 @@ function isSemanticRoleElement(node, role) {
129150
return false;
130151
}
131152

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

154188
/** @type {import('eslint').Rule.RuleModule} */
@@ -191,46 +225,38 @@ module.exports = {
191225

192226
return {
193227
GlimmerElementNode(node) {
194-
const role = getStaticRoleFromElement(node);
228+
const roleTokens = getStaticRolesFromElement(node);
195229

196-
if (!role) {
230+
if (!roleTokens) {
197231
return;
198232
}
199233

200234
const foundAriaAttributes = (node.attributes ?? [])
201235
.filter((attribute) => attribute.name?.startsWith('aria-'))
202236
.map((attribute) => attribute.name);
203237

204-
const missingRequiredAttributes = getMissingRequiredAttributes(
205-
role,
206-
foundAriaAttributes,
207-
node
208-
);
238+
const result = getMissingRequiredAttributes(roleTokens, foundAriaAttributes, node, context);
209239

210-
if (missingRequiredAttributes) {
211-
reportMissingAttributes(node, role, missingRequiredAttributes);
240+
if (result?.missing) {
241+
reportMissingAttributes(node, result.role, result.missing);
212242
}
213243
},
214244

215245
GlimmerMustacheStatement(node) {
216-
const role = getStaticRoleFromMustache(node);
246+
const roleTokens = getStaticRolesFromMustache(node);
217247

218-
if (!role) {
248+
if (!roleTokens) {
219249
return;
220250
}
221251

222252
const foundAriaAttributes = (node.hash?.pairs ?? [])
223253
.filter((pair) => pair.key.startsWith('aria-'))
224254
.map((pair) => pair.key);
225255

226-
const missingRequiredAttributes = getMissingRequiredAttributes(
227-
role,
228-
foundAriaAttributes,
229-
node
230-
);
256+
const result = getMissingRequiredAttributes(roleTokens, foundAriaAttributes, node, context);
231257

232-
if (missingRequiredAttributes) {
233-
reportMissingAttributes(node, role, missingRequiredAttributes);
258+
if (result?.missing) {
259+
reportMissingAttributes(node, result.role, result.missing);
234260
}
235261
},
236262
};

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

Lines changed: 79 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,16 +45,17 @@ ruleTester.run('template-require-mandatory-role-attributes', rule, {
4545
'<template>{{input type="Checkbox" role="switch"}}</template>',
4646
'<template>{{input type="range" role="slider"}}</template>',
4747

48-
// Documented divergences from jsx-a11y / vue-a11y / angular-eslint:
49-
// - Space-separated role tokens: peers split on whitespace and require
50-
// attrs for each token. We pass the whole string to aria-query, so
51-
// `role="combobox listbox"` lookup misses → no flag. (Case below.)
52-
// - Case-folding role values: peers lowercase before lookup; we don't.
53-
// `role="COMBOBOX"` similarly misses lookup → no flag.
54-
// - Unknown role: all plugins skip — parity, captured for completeness.
55-
'<template><div role="combobox listbox" /></template>',
56-
'<template><div role="COMBOBOX" /></template>',
57-
'<template><div role="SLIDER" /></template>',
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>',
58+
// Unknown roles are skipped — rule only checks required attrs for known roles.
5859
'<template><div role="foobar" /></template>',
5960
],
6061

@@ -73,6 +74,18 @@ ruleTester.run('template-require-mandatory-role-attributes', rule, {
7374
output: null,
7475
errors: [{ message: 'The attribute aria-selected is required by the role option' }],
7576
},
77+
// Plain widget roles missing all required attrs — basic coverage that
78+
// peer plugins (jsx-a11y / vue-a11y / angular-eslint) also flag.
79+
{
80+
code: '<template><div role="slider" /></template>',
81+
output: null,
82+
errors: [{ message: 'The attribute aria-valuenow is required by the role slider' }],
83+
},
84+
{
85+
code: '<template><div role="checkbox" /></template>',
86+
output: null,
87+
errors: [{ message: 'The attribute aria-checked is required by the role checkbox' }],
88+
},
7689
{
7790
code: '<template><CustomComponent role="checkbox" aria-required="true" /></template>',
7891
output: null,
@@ -160,6 +173,62 @@ ruleTester.run('template-require-mandatory-role-attributes', rule, {
160173
output: null,
161174
errors: [{ message: 'The attribute aria-checked is required by the role menuitemradio' }],
162175
},
176+
// Case-insensitive role matching — uppercase role missing required props is flagged.
177+
{
178+
code: '<template><div role="COMBOBOX"></div></template>',
179+
output: null,
180+
errors: [
181+
{
182+
message: 'The attributes aria-controls, aria-expanded are required by the role combobox',
183+
},
184+
],
185+
},
186+
// Role-fallback list: when the primary role is missing required props, flag it.
187+
{
188+
code: '<template><div role="combobox listbox"></div></template>',
189+
output: null,
190+
errors: [
191+
{
192+
message: 'The attributes aria-controls, aria-expanded are required by the role combobox',
193+
},
194+
],
195+
},
196+
// Abstract role (`widget`) followed by a concrete role that's missing
197+
// required attrs — UA skips the abstract, lands on `slider`, which
198+
// requires aria-valuenow.
199+
{
200+
code: '<template><div role="widget slider"></div></template>',
201+
output: null,
202+
errors: [
203+
{
204+
message: 'The attribute aria-valuenow is required by the role slider',
205+
},
206+
],
207+
},
208+
// Strict-mode {{input}} is a scope binding, not Ember's classic helper
209+
// (which doesn't exist as a strict-mode export from @ember/component).
210+
// The semantic-role exemption must NOT apply — we can't prove the
211+
// imported identifier renders a native <input>. Flag the missing ARIA.
212+
{
213+
filename: 'component.gjs',
214+
code: '<template>{{input type="checkbox" role="switch"}}</template>',
215+
output: null,
216+
errors: [
217+
{
218+
message: 'The attribute aria-checked is required by the role switch',
219+
},
220+
],
221+
},
222+
{
223+
filename: 'component.gts',
224+
code: '<template>{{input type="range" role="slider"}}</template>',
225+
output: null,
226+
errors: [
227+
{
228+
message: 'The attribute aria-valuenow is required by the role slider',
229+
},
230+
],
231+
},
163232
],
164233
});
165234

0 commit comments

Comments
 (0)