Skip to content

Commit 00fbc3b

Browse files
Merge pull request #2729 from johanrd/fix/invalid-role-aria-query
BUGFIX: template-no-invalid-role — support DPUB/Graphics-ARIA and role-fallback lists
2 parents 0da6be9 + e34533b commit 00fbc3b

2 files changed

Lines changed: 138 additions & 111 deletions

File tree

lib/rules/template-no-invalid-role.js

Lines changed: 51 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -1,92 +1,22 @@
1-
const VALID_ROLES = new Set([
2-
'alert',
3-
'alertdialog',
4-
'application',
5-
'article',
1+
const { roles } = require('aria-query');
2+
3+
// Valid ARIA roles = concrete (non-abstract) entries from aria-query, plus the
4+
// WAI-ARIA 1.3 draft roles that aria-query 5.3.2 doesn't yet ship. The
5+
// ARIA 1.2 base roles, DPUB-ARIA (doc-*), and Graphics-ARIA (graphics-*) all
6+
// come from aria-query. `associationlist*`, `comment`, and `suggestion` are in
7+
// the current ARIA 1.3 editor's draft (https://w3c.github.io/aria/) but not
8+
// yet in aria-query, so they're listed here until the next aria-query release
9+
// adds them.
10+
const ARIA_13_DRAFT_ROLES = [
611
'associationlist',
712
'associationlistitemkey',
813
'associationlistitemvalue',
9-
'banner',
10-
'blockquote',
11-
'button',
12-
'caption',
13-
'cell',
14-
'checkbox',
15-
'code',
16-
'columnheader',
17-
'combobox',
1814
'comment',
19-
'complementary',
20-
'contentinfo',
21-
'definition',
22-
'deletion',
23-
'dialog',
24-
'directory',
25-
'document',
26-
'emphasis',
27-
'feed',
28-
'figure',
29-
'form',
30-
'generic',
31-
'grid',
32-
'gridcell',
33-
'group',
34-
'heading',
35-
'img',
36-
'insertion',
37-
'link',
38-
'list',
39-
'listbox',
40-
'listitem',
41-
'log',
42-
'main',
43-
'mark',
44-
'marquee',
45-
'math',
46-
'menu',
47-
'menubar',
48-
'menuitem',
49-
'menuitemcheckbox',
50-
'menuitemradio',
51-
'meter',
52-
'navigation',
53-
'none',
54-
'note',
55-
'option',
56-
'paragraph',
57-
'presentation',
58-
'progressbar',
59-
'radio',
60-
'radiogroup',
61-
'region',
62-
'row',
63-
'rowgroup',
64-
'rowheader',
65-
'scrollbar',
66-
'search',
67-
'searchbox',
68-
'separator',
69-
'slider',
70-
'spinbutton',
71-
'status',
72-
'strong',
73-
'subscript',
7415
'suggestion',
75-
'superscript',
76-
'switch',
77-
'tab',
78-
'table',
79-
'tablist',
80-
'tabpanel',
81-
'term',
82-
'textbox',
83-
'time',
84-
'timer',
85-
'toolbar',
86-
'tooltip',
87-
'tree',
88-
'treegrid',
89-
'treeitem',
16+
];
17+
const VALID_ROLES = new Set([
18+
...[...roles.keys()].filter((role) => !roles.get(role).abstract),
19+
...ARIA_13_DRAFT_ROLES,
9020
]);
9121

9222
// Elements with semantic meaning that should not be given role="presentation" or role="none"
@@ -225,34 +155,56 @@ module.exports = {
225155
return;
226156
}
227157

228-
const role = roleAttr.value.chars.trim();
229-
if (!role) {
230-
return;
231-
}
232-
233-
const roleLower = role.toLowerCase();
234-
235-
// Check for nonexistent roles
236-
if (catchNonexistentRoles && !VALID_ROLES.has(roleLower)) {
158+
// ARIA role attribute is a whitespace-separated list of tokens
159+
// (role-fallback pattern per ARIA 1.2 §5.4). An empty / whitespace-
160+
// only value supplies zero tokens — flag as `role=""` to catch the
161+
// authoring mistake (matches jsx-a11y / vue-a11y).
162+
const raw = roleAttr.value.chars.trim();
163+
if (!raw) {
237164
context.report({
238165
node: roleAttr,
239166
messageId: 'invalid',
240-
data: { role },
167+
data: { role: '' },
241168
});
242169
return;
243170
}
244171

245-
// Check for presentation/none role on semantic elements (case-insensitive per WAI-ARIA 1.2:
246-
// "Case-sensitivity of the comparison inherits from the case-sensitivity of the host language"
247-
// and HTML is case-insensitive — https://www.w3.org/TR/wai-aria-1.2/#document-handling_author-errors_roles)
172+
// Validate each token. Keep the original casing alongside the
173+
// normalized (lowercase) form so reported tokens preserve author
174+
// intent — validation is case-insensitive, the ERROR MESSAGE isn't.
175+
const rawTokens = raw.split(/\s+/u);
176+
const tokens = rawTokens.map((t) => t.toLowerCase());
177+
178+
if (catchNonexistentRoles) {
179+
const invalidIdx = tokens.findIndex((token) => !VALID_ROLES.has(token));
180+
if (invalidIdx !== -1) {
181+
context.report({
182+
node: roleAttr,
183+
messageId: 'invalid',
184+
data: { role: rawTokens[invalidIdx] },
185+
});
186+
return;
187+
}
188+
}
189+
190+
// Flag presentation/none only when it's the FIRST recognised role per
191+
// WAI-ARIA §4.1 fallback semantics — UAs walk the token list and use
192+
// the first role they recognise; subsequent tokens are author-provided
193+
// fallbacks that never take effect if the first is recognised. So
194+
// `role="button presentation"` resolves to `button` at runtime and
195+
// should NOT flag. `role="xxyxyz presentation"` resolves to
196+
// `presentation` (unknown tokens are skipped) and SHOULD flag on a
197+
// semantic element. Case-insensitivity inherits from HTML per §4.1:
198+
// https://www.w3.org/TR/wai-aria-1.2/#document-handling_author-errors_roles
199+
const firstRecognisedRole = tokens.find((t) => VALID_ROLES.has(t));
248200
if (
249-
(roleLower === 'presentation' || roleLower === 'none') &&
201+
(firstRecognisedRole === 'presentation' || firstRecognisedRole === 'none') &&
250202
SEMANTIC_ELEMENTS.has(node.tag)
251203
) {
252204
context.report({
253205
node: roleAttr,
254206
messageId: 'presentationOnSemantic',
255-
data: { role, tag: node.tag },
207+
data: { role: firstRecognisedRole, tag: node.tag },
256208
});
257209
}
258210
},

tests/lib/rules/template-no-invalid-role.js

Lines changed: 87 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,6 @@ ruleTester.run('template-no-invalid-role', rule, {
5757
'<template><table role="textbox"></table></template>',
5858
'<template><div role="{{if this.inModal "dialog" "contentinfo" }}"></div></template>',
5959

60-
// Missing VALID_ROLES entries: associationlistitemkey, associationlistitemvalue, cell
61-
'<template><div role="associationlistitemkey">Key</div></template>',
62-
'<template><div role="associationlistitemvalue">Value</div></template>',
6360
'<template><td role="cell">Data</td></template>',
6461

6562
// Case-insensitive role matching
@@ -71,9 +68,56 @@ ruleTester.run('template-no-invalid-role', rule, {
7168
code: '<template><div role="command interface"></div></template>',
7269
options: [{ catchNonexistentRoles: false }],
7370
},
71+
72+
// DPUB-ARIA (doc-*) and Graphics-ARIA (graphics-*) are valid per aria-query.
73+
'<template><div role="doc-abstract">Abstract</div></template>',
74+
'<template><section role="doc-chapter"></section></template>',
75+
'<template><svg role="graphics-document"></svg></template>',
76+
'<template><svg role="graphics-object"></svg></template>',
77+
78+
// Whitespace-separated role fallback list — ARIA 1.2 §5.4. Each token
79+
// must individually be valid.
80+
'<template><div role="tabpanel row"></div></template>',
81+
'<template><svg role="graphics-document document"></svg></template>',
82+
'<template><section role="doc-appendix doc-bibliography"></section></template>',
83+
84+
// Role-fallback: `presentation`/`none` in a non-first position does NOT
85+
// flag on a semantic element — UAs pick the first recognised role per
86+
// §4.1. Here `button` resolves, `presentation` is an unused fallback.
87+
'<template><ul role="list presentation"></ul></template>',
88+
'<template><table role="grid none"></table></template>',
89+
90+
// ARIA 1.3 draft roles — not in aria-query 5.3.2 but spec-blessed, so
91+
// the rule accepts them via the inline allowlist.
92+
'<template><div role="associationlist"></div></template>',
93+
'<template><div role="associationlistitemkey"></div></template>',
94+
'<template><div role="associationlistitemvalue"></div></template>',
95+
'<template><div role="comment"></div></template>',
96+
'<template><div role="suggestion"></div></template>',
7497
],
7598

7699
invalid: [
100+
// Common authoring confusion — `datepicker` looks like it could be a
101+
// role (it's a UI concept) but isn't in the ARIA registry. Same lookup
102+
// path as `role="invalid"`, exercised here for the more likely typo.
103+
{
104+
code: '<template><div role="datepicker"></div></template>',
105+
output: null,
106+
errors: [{ messageId: 'invalid' }],
107+
},
108+
// Empty / whitespace-only role attribute supplies no recognized role
109+
// token — flag as an authoring mistake. Aligns with jsx-a11y and
110+
// vue-a11y (both flag).
111+
{
112+
code: '<template><div role=""></div></template>',
113+
output: null,
114+
errors: [{ messageId: 'invalid' }],
115+
},
116+
{
117+
code: '<template><div role=" "></div></template>',
118+
output: null,
119+
errors: [{ messageId: 'invalid' }],
120+
},
77121
{
78122
code: `<template>
79123
<div role="invalid">Content</div>
@@ -144,6 +188,18 @@ ruleTester.run('template-no-invalid-role', rule, {
144188
{ message: 'The role "presentation" should not be used on the semantic element <button>.' },
145189
],
146190
},
191+
// Role-fallback: unknown leading token is skipped per §4.1, so the
192+
// first RECOGNISED role is `presentation` → flag on semantic element.
193+
// Uses catchNonexistentRoles: false so the unknown `xxyxyz` doesn't
194+
// intercept the check via the invalid-role path.
195+
{
196+
code: '<template><ul role="xxyxyz presentation"></ul></template>',
197+
output: null,
198+
options: [{ catchNonexistentRoles: false }],
199+
errors: [
200+
{ message: 'The role "presentation" should not be used on the semantic element <ul>.' },
201+
],
202+
},
147203
{
148204
code: '<template><button role="none"></button></template>',
149205
output: null,
@@ -164,18 +220,20 @@ ruleTester.run('template-no-invalid-role', rule, {
164220
{
165221
code: '<template><div role="command interface"></div></template>',
166222
output: null,
167-
errors: [{ message: "Invalid ARIA role 'command interface'. Must be a valid ARIA role." }],
223+
errors: [{ message: "Invalid ARIA role 'command'. Must be a valid ARIA role." }],
168224
},
169225
{
170226
code: '<template><div role="COMMAND INTERFACE"></div></template>',
171227
output: null,
172-
errors: [{ message: "Invalid ARIA role 'COMMAND INTERFACE'. Must be a valid ARIA role." }],
228+
// Validation is case-insensitive, but the error message echoes the
229+
// author-provided token verbatim so authors see their own text.
230+
errors: [{ message: "Invalid ARIA role 'COMMAND'. Must be a valid ARIA role." }],
173231
},
174232
{
175233
code: '<template><div role="command interface"></div></template>',
176234
output: null,
177235
options: [{ catchNonexistentRoles: true }],
178-
errors: [{ message: "Invalid ARIA role 'command interface'. Must be a valid ARIA role." }],
236+
errors: [{ message: "Invalid ARIA role 'command'. Must be a valid ARIA role." }],
179237
},
180238

181239
// Newly added SEMANTIC_ELEMENTS: presentation/none on iframe, video, audio
@@ -234,9 +292,6 @@ hbsRuleTester.run('template-no-invalid-role', rule, {
234292
'<AwesomeThing role="presentation"></AwesomeThing>',
235293
'<table role="textbox"></table>',
236294
'<div role="{{if this.inModal "dialog" "contentinfo" }}"></div>',
237-
// Missing VALID_ROLES entries: associationlistitemkey, associationlistitemvalue, cell
238-
'<div role="associationlistitemkey">Key</div>',
239-
'<div role="associationlistitemvalue">Value</div>',
240295
'<td role="cell">Data</td>',
241296
// Case-insensitive role matching
242297
'<div role="Button">Click</div>',
@@ -247,6 +302,24 @@ hbsRuleTester.run('template-no-invalid-role', rule, {
247302
code: '<div role="command interface"></div>',
248303
options: [{ catchNonexistentRoles: false }],
249304
},
305+
306+
// DPUB-ARIA (doc-*) and Graphics-ARIA (graphics-*) roles.
307+
'<div role="doc-abstract">Abstract</div>',
308+
'<section role="doc-chapter"></section>',
309+
'<svg role="graphics-document"></svg>',
310+
311+
// Whitespace-separated role fallback list.
312+
'<div role="tabpanel row"></div>',
313+
'<svg role="graphics-document document"></svg>',
314+
'<section role="doc-appendix doc-bibliography"></section>',
315+
316+
// ARIA 1.3 draft roles — not in aria-query 5.3.2 but spec-blessed, so
317+
// the rule accepts them via the inline allowlist.
318+
'<div role="associationlist"></div>',
319+
'<div role="associationlistitemkey"></div>',
320+
'<div role="associationlistitemvalue"></div>',
321+
'<div role="comment"></div>',
322+
'<div role="suggestion"></div>',
250323
],
251324
invalid: [
252325
{
@@ -302,18 +375,20 @@ hbsRuleTester.run('template-no-invalid-role', rule, {
302375
{
303376
code: '<div role="command interface"></div>',
304377
output: null,
305-
errors: [{ message: "Invalid ARIA role 'command interface'. Must be a valid ARIA role." }],
378+
errors: [{ message: "Invalid ARIA role 'command'. Must be a valid ARIA role." }],
306379
},
307380
{
308381
code: '<div role="command interface"></div>',
309382
output: null,
310383
options: [{ catchNonexistentRoles: true }],
311-
errors: [{ message: "Invalid ARIA role 'command interface'. Must be a valid ARIA role." }],
384+
errors: [{ message: "Invalid ARIA role 'command'. Must be a valid ARIA role." }],
312385
},
313386
{
314387
code: '<div role="COMMAND INTERFACE"></div>',
315388
output: null,
316-
errors: [{ message: "Invalid ARIA role 'COMMAND INTERFACE'. Must be a valid ARIA role." }],
389+
// Validation is case-insensitive, but the error message echoes the
390+
// author-provided token verbatim so authors see their own text.
391+
errors: [{ message: "Invalid ARIA role 'COMMAND'. Must be a valid ARIA role." }],
317392
},
318393
// Newly added SEMANTIC_ELEMENTS: presentation/none on iframe, video, audio, embed
319394
{

0 commit comments

Comments
 (0)