@a11yfred/neighbor 0.3.0 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -7
- package/CONTRIBUTING.md +10 -10
- package/README.md +196 -32
- package/RULES-CONTENT.md +296 -0
- package/RULES-CSS.md +61 -0
- package/RULES-MARKUP.md +156 -0
- package/RULES.md +55 -0
- package/lib/content-rules.js +858 -0
- package/lib/helpers-angular.js +146 -146
- package/lib/helpers-jsx.js +193 -193
- package/lib/helpers-vue.js +151 -151
- package/lib/helpers.js +37 -37
- package/lib/rules.js +2413 -2413
- package/lib/ulam-rules.js +301 -301
- package/neighbor-content.mjs +80 -0
- package/neighbor-eslint-angular.mjs +68 -68
- package/neighbor-eslint-vue.mjs +48 -48
- package/neighbor-eslint.mjs +56 -56
- package/neighbor-stylelint.mjs +282 -256
- package/package.json +30 -5
package/lib/rules.js
CHANGED
|
@@ -1,2413 +1,2413 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* neighbor/lib/rules.js
|
|
3
|
-
* Framework-agnostic rule factories.
|
|
4
|
-
*
|
|
5
|
-
* Every factory is called as makeXxx(h) where h is the framework-specific
|
|
6
|
-
* helpers object from helpers-jsx.js / helpers-vue.js / helpers-angular.js.
|
|
7
|
-
* Each factory returns a complete ESLint rule object { meta, create }.
|
|
8
|
-
*
|
|
9
|
-
* Sources and credits:
|
|
10
|
-
* Adrian Roselli adrianroselli.com
|
|
11
|
-
* Heydon Pickering heydonworks.com, inclusive-components.design
|
|
12
|
-
* Scott O'Hara scottohara.me
|
|
13
|
-
* Patrick Lauke splintered.co.uk, patrickhlauke.github.io/aria
|
|
14
|
-
* Karl Groves karlgroves.com
|
|
15
|
-
* Marcy Sutton marcysutton.com
|
|
16
|
-
* Eric Eggert yatil.net
|
|
17
|
-
* WAI-ARIA APG w3.org/WAI/ARIA/apg
|
|
18
|
-
* ARIA 1.2 spec w3.org/TR/wai-aria-1.2
|
|
19
|
-
* WebAIM Million webaim.org/projects/million
|
|
20
|
-
* Deque / axe-core deque.com
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import {
|
|
24
|
-
INTERACTIVE_ELEMENTS,
|
|
25
|
-
INTERACTIVE_ROLES,
|
|
26
|
-
GENERIC_CONTAINERS,
|
|
27
|
-
VOID_ELEMENTS,
|
|
28
|
-
HEADING_ELEMENTS,
|
|
29
|
-
NAV_MENU_ROLES,
|
|
30
|
-
ROLES_REQUIRING_NAME,
|
|
31
|
-
FORM_ELEMENTS,
|
|
32
|
-
} from './helpers.js'
|
|
33
|
-
|
|
34
|
-
// ─── no-aria-label-on-generic ────────────────────────────────────────────────
|
|
35
|
-
|
|
36
|
-
export function makeNoAriaLabelOnGeneric(h) {
|
|
37
|
-
return {
|
|
38
|
-
meta: {
|
|
39
|
-
type: 'suggestion',
|
|
40
|
-
docs: { description: 'Disallow aria-label / aria-labelledby on generic elements with no role' },
|
|
41
|
-
messages: {
|
|
42
|
-
noLabel:
|
|
43
|
-
'{{attr}} on <{{el}}> has no semantic target
|
|
44
|
-
},
|
|
45
|
-
schema: [],
|
|
46
|
-
},
|
|
47
|
-
create(context) {
|
|
48
|
-
return {
|
|
49
|
-
[h.elementVisitor](node) {
|
|
50
|
-
const el = h.getElementName(node)
|
|
51
|
-
if (!el || !GENERIC_CONTAINERS.has(el)) return
|
|
52
|
-
const labelAttr = h.getAttr(node, 'aria-label') ?? h.getAttr(node, 'aria-labelledby')
|
|
53
|
-
if (!labelAttr) return
|
|
54
|
-
if (h.hasAttr(node, 'role')) return
|
|
55
|
-
const attrName = labelAttr.name?.name ?? labelAttr.key?.name ?? labelAttr.name
|
|
56
|
-
context.report({ node: labelAttr, messageId: 'noLabel', data: { attr: attrName, el } })
|
|
57
|
-
},
|
|
58
|
-
}
|
|
59
|
-
},
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// ─── no-assertive-live-overuse ───────────────────────────────────────────────
|
|
64
|
-
|
|
65
|
-
export function makeNoAssertiveLiveOveruse(h) {
|
|
66
|
-
return {
|
|
67
|
-
meta: {
|
|
68
|
-
type: 'suggestion',
|
|
69
|
-
docs: { description: 'Disallow aria-live="assertive" outside role="alert" elements' },
|
|
70
|
-
messages: {
|
|
71
|
-
assertiveWithoutAlert:
|
|
72
|
-
'aria-live="assertive" without role="alert" interrupts the user unexpectedly. Use aria-live="polite" for status/progress, or add role="alert" only for genuine errors or time-critical messages. (APG / Sutton / Eggert)',
|
|
73
|
-
},
|
|
74
|
-
schema: [],
|
|
75
|
-
},
|
|
76
|
-
create(context) {
|
|
77
|
-
return {
|
|
78
|
-
[h.elementVisitor](node) {
|
|
79
|
-
const liveVal = h.getAttrStringValue(h.getAttr(node, 'aria-live'))
|
|
80
|
-
if (liveVal !== 'assertive') return
|
|
81
|
-
if (h.getRoleValue(node) === 'alert') return
|
|
82
|
-
if (h.getElementName(node) === 'dialog') return
|
|
83
|
-
context.report({ node: h.getAttr(node, 'aria-live'), messageId: 'assertiveWithoutAlert' })
|
|
84
|
-
},
|
|
85
|
-
}
|
|
86
|
-
},
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// ─── no-unblocked-aria-disabled ──────────────────────────────────────────────
|
|
91
|
-
|
|
92
|
-
export function makeNoUnblockedAriaDisabled(h) {
|
|
93
|
-
return {
|
|
94
|
-
meta: {
|
|
95
|
-
type: 'problem',
|
|
96
|
-
docs: { description: 'Disallow aria-disabled="true" on interactive elements that still have an active onClick' },
|
|
97
|
-
messages: {
|
|
98
|
-
unblocked:
|
|
99
|
-
'aria-disabled="true" does not block clicks
|
|
100
|
-
},
|
|
101
|
-
schema: [],
|
|
102
|
-
},
|
|
103
|
-
create(context) {
|
|
104
|
-
return {
|
|
105
|
-
[h.elementVisitor](node) {
|
|
106
|
-
if (h.getAttrStringValue(h.getAttr(node, 'aria-disabled')) !== 'true') return
|
|
107
|
-
if (!h.isInteractiveElement(node)) return
|
|
108
|
-
// onClick is JSX-specific; Vue/Angular use @click / (click)
|
|
109
|
-
if (!h.hasAttr(node, 'onClick') && !h.hasAttr(node, '@click') && !h.hasAttr(node, '(click)')) return
|
|
110
|
-
context.report({ node: h.getAttr(node, 'aria-disabled'), messageId: 'unblocked' })
|
|
111
|
-
},
|
|
112
|
-
}
|
|
113
|
-
},
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// ─── no-tooltip-role-misuse ──────────────────────────────────────────────────
|
|
118
|
-
|
|
119
|
-
export function makeNoTooltipRoleMisuse(h) {
|
|
120
|
-
return {
|
|
121
|
-
meta: {
|
|
122
|
-
type: 'suggestion',
|
|
123
|
-
docs: { description: 'Disallow role="tooltip" with no id or on interactive elements' },
|
|
124
|
-
messages: {
|
|
125
|
-
noId:
|
|
126
|
-
'role="tooltip" requires an `id` so an interactive element can reference it via aria-describedby. Without an id no AT can associate this tooltip with its trigger. (APG: Tooltip Pattern)',
|
|
127
|
-
onInteractive:
|
|
128
|
-
'role="tooltip" belongs on the tooltip container, not the trigger. The trigger should have aria-describedby pointing to the tooltip\'s id. (APG: Tooltip Pattern)',
|
|
129
|
-
},
|
|
130
|
-
schema: [],
|
|
131
|
-
},
|
|
132
|
-
create(context) {
|
|
133
|
-
return {
|
|
134
|
-
[h.elementVisitor](node) {
|
|
135
|
-
if (h.getRoleValue(node) !== 'tooltip') return
|
|
136
|
-
const el = h.getElementName(node)
|
|
137
|
-
if (el && INTERACTIVE_ELEMENTS.has(el)) {
|
|
138
|
-
context.report({ node: h.getAttr(node, 'role'), messageId: 'onInteractive' })
|
|
139
|
-
return
|
|
140
|
-
}
|
|
141
|
-
if (!h.hasAttr(node, 'id'))
|
|
142
|
-
context.report({ node: h.getAttr(node, 'role'), messageId: 'noId' })
|
|
143
|
-
},
|
|
144
|
-
}
|
|
145
|
-
},
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// ─── no-roles-without-name ───────────────────────────────────────────────────
|
|
150
|
-
|
|
151
|
-
const ROLE_REASONS = {
|
|
152
|
-
region: 'browsers do not expose it as a landmark without a name',
|
|
153
|
-
dialog: 'users cannot identify what the dialog is for',
|
|
154
|
-
alertdialog: 'users cannot identify what the alert dialog is for',
|
|
155
|
-
application: 'users have no context for the application region',
|
|
156
|
-
marquee: 'name required per ARIA 1.2',
|
|
157
|
-
searchbox: 'name required per ARIA 1.2',
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
export function makeNoRolesWithoutName(h) {
|
|
161
|
-
return {
|
|
162
|
-
meta: {
|
|
163
|
-
type: 'problem',
|
|
164
|
-
docs: { description: 'Require accessible names on roles that need them to be usable' },
|
|
165
|
-
messages: {
|
|
166
|
-
missingName:
|
|
167
|
-
'role="{{role}}" requires an accessible name (aria-label or aria-labelledby) to be meaningful: {{reason}}. (APG / ARIA 1.2)',
|
|
168
|
-
},
|
|
169
|
-
schema: [],
|
|
170
|
-
},
|
|
171
|
-
create(context) {
|
|
172
|
-
return {
|
|
173
|
-
[h.elementVisitor](node) {
|
|
174
|
-
const role = h.getRoleValue(node)
|
|
175
|
-
if (!role || !ROLES_REQUIRING_NAME.has(role)) return
|
|
176
|
-
if (h.hasAccessibleName(node)) return
|
|
177
|
-
context.report({
|
|
178
|
-
node: h.getAttr(node, 'role'),
|
|
179
|
-
messageId: 'missingName',
|
|
180
|
-
data: { role, reason: ROLE_REASONS[role] },
|
|
181
|
-
})
|
|
182
|
-
},
|
|
183
|
-
}
|
|
184
|
-
},
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// ─── no-application-role ─────────────────────────────────────────────────────
|
|
189
|
-
|
|
190
|
-
export function makeNoApplicationRole(h) {
|
|
191
|
-
return {
|
|
192
|
-
meta: {
|
|
193
|
-
type: 'suggestion',
|
|
194
|
-
docs: { description: 'Warn when role="application" is used
|
|
195
|
-
messages: {
|
|
196
|
-
application:
|
|
197
|
-
'role="application" disables AT browse/reading mode and requires the author to implement ALL keyboard interaction. Only use it for genuine application-like widgets (spreadsheets, code editors). (Roselli / Sutton / Lauke / APG)',
|
|
198
|
-
},
|
|
199
|
-
schema: [],
|
|
200
|
-
},
|
|
201
|
-
create(context) {
|
|
202
|
-
return {
|
|
203
|
-
[h.elementVisitor](node) {
|
|
204
|
-
if (h.getRoleValue(node) === 'application')
|
|
205
|
-
context.report({ node: h.getAttr(node, 'role'), messageId: 'application' })
|
|
206
|
-
},
|
|
207
|
-
}
|
|
208
|
-
},
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// ─── no-grid-role ─────────────────────────────────────────────────────────────
|
|
213
|
-
|
|
214
|
-
export function makeNoGridRole(h) {
|
|
215
|
-
return {
|
|
216
|
-
meta: {
|
|
217
|
-
type: 'suggestion',
|
|
218
|
-
docs: { description: 'Warn when role="grid" is used
|
|
219
|
-
messages: {
|
|
220
|
-
grid:
|
|
221
|
-
'role="grid" is for spreadsheet-like widgets with arrow-key cell navigation. Using it on data tables or result lists breaks natural table navigation. Use a native <table> instead. (Roselli: ARIA Grid As an Anti-Pattern)',
|
|
222
|
-
},
|
|
223
|
-
schema: [],
|
|
224
|
-
},
|
|
225
|
-
create(context) {
|
|
226
|
-
return {
|
|
227
|
-
[h.elementVisitor](node) {
|
|
228
|
-
if (h.getRoleValue(node) === 'grid')
|
|
229
|
-
context.report({ node: h.getAttr(node, 'role'), messageId: 'grid' })
|
|
230
|
-
},
|
|
231
|
-
}
|
|
232
|
-
},
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// ─── no-menu-role-on-nav ──────────────────────────────────────────────────────
|
|
237
|
-
|
|
238
|
-
export function makeNoMenuRoleOnNav(h) {
|
|
239
|
-
return {
|
|
240
|
-
meta: {
|
|
241
|
-
type: 'suggestion',
|
|
242
|
-
docs: { description: 'Warn when menu/menubar/menuitem roles are used
|
|
243
|
-
messages: {
|
|
244
|
-
navMenu:
|
|
245
|
-
'role="{{role}}" on a <nav> triggers AT application-mode keyboard expectations (arrow keys, not Tab). Use <nav><ul><li><a> for site navigation. (Roselli / Lauke)',
|
|
246
|
-
anyMenu:
|
|
247
|
-
'role="{{role}}" triggers AT application-mode keyboard handling. Only use menu roles for true app menus (File > Edit > View). For nav use <nav>, for disclosure use <button aria-expanded>. (Roselli / Lauke / Groves)',
|
|
248
|
-
},
|
|
249
|
-
schema: [],
|
|
250
|
-
},
|
|
251
|
-
create(context) {
|
|
252
|
-
return {
|
|
253
|
-
[h.elementVisitor](node) {
|
|
254
|
-
const role = h.getRoleValue(node)
|
|
255
|
-
if (!role || !NAV_MENU_ROLES.has(role)) return
|
|
256
|
-
const el = h.getElementName(node)
|
|
257
|
-
if (el === 'nav') {
|
|
258
|
-
context.report({ node: h.getAttr(node, 'role'), messageId: 'navMenu', data: { role } })
|
|
259
|
-
return
|
|
260
|
-
}
|
|
261
|
-
for (const ancestor of h.getAncestors(node)) {
|
|
262
|
-
if (h.getElementName(ancestor) === 'nav') {
|
|
263
|
-
context.report({ node: h.getAttr(node, 'role'), messageId: 'navMenu', data: { role } })
|
|
264
|
-
return
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
context.report({ node: h.getAttr(node, 'role'), messageId: 'anyMenu', data: { role } })
|
|
268
|
-
},
|
|
269
|
-
}
|
|
270
|
-
},
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// ─── no-aria-roledescription ──────────────────────────────────────────────────
|
|
275
|
-
|
|
276
|
-
export function makeNoAriaRoledescription(h) {
|
|
277
|
-
return {
|
|
278
|
-
meta: {
|
|
279
|
-
type: 'suggestion',
|
|
280
|
-
docs: { description: 'Disallow aria-roledescription
|
|
281
|
-
messages: {
|
|
282
|
-
roledescription:
|
|
283
|
-
'aria-roledescription overrides the AT role label and does not auto-translate. Use semantic HTML, visually-hidden text, or aria-labelledby instead. (Roselli: Avoid aria-roledescription)',
|
|
284
|
-
},
|
|
285
|
-
schema: [],
|
|
286
|
-
},
|
|
287
|
-
create(context) {
|
|
288
|
-
return {
|
|
289
|
-
[h.elementVisitor](node) {
|
|
290
|
-
const attr = h.getAttr(node, 'aria-roledescription')
|
|
291
|
-
if (attr) context.report({ node: attr, messageId: 'roledescription' })
|
|
292
|
-
},
|
|
293
|
-
}
|
|
294
|
-
},
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// ─── no-aria-readonly ────────────────────────────────────────────────────────
|
|
299
|
-
|
|
300
|
-
export function makeNoAriaReadonly(h) {
|
|
301
|
-
return {
|
|
302
|
-
meta: {
|
|
303
|
-
type: 'suggestion',
|
|
304
|
-
docs: { description: 'Disallow aria-readonly
|
|
305
|
-
messages: {
|
|
306
|
-
readonly:
|
|
307
|
-
'aria-readonly has limited and inconsistent AT support. TalkBack has been known to misread it as "disabled". Prefer displaying read-only values as plain text, or use a visually-distinct disabled state with a visible explanation. (Roselli)',
|
|
308
|
-
},
|
|
309
|
-
schema: [],
|
|
310
|
-
},
|
|
311
|
-
create(context) {
|
|
312
|
-
return {
|
|
313
|
-
[h.elementVisitor](node) {
|
|
314
|
-
const attr = h.getAttr(node, 'aria-readonly')
|
|
315
|
-
if (attr) context.report({ node: attr, messageId: 'readonly' })
|
|
316
|
-
},
|
|
317
|
-
}
|
|
318
|
-
},
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
// ─── no-aria-hidden-in-link ──────────────────────────────────────────────────
|
|
324
|
-
|
|
325
|
-
export function makeNoAriaHiddenInLink(h) {
|
|
326
|
-
return {
|
|
327
|
-
meta: {
|
|
328
|
-
type: 'problem',
|
|
329
|
-
docs: { description: 'Disallow <a> elements whose only content is aria-hidden (phantom link)' },
|
|
330
|
-
messages: {
|
|
331
|
-
hiddenInLink:
|
|
332
|
-
'This <a> contains only aria-hidden content
|
|
333
|
-
},
|
|
334
|
-
schema: [],
|
|
335
|
-
},
|
|
336
|
-
create(context) {
|
|
337
|
-
return {
|
|
338
|
-
[h.elementVisitor](node) {
|
|
339
|
-
if (h.getElementName(node) !== 'a') return
|
|
340
|
-
if (h.hasAccessibleName(node)) return
|
|
341
|
-
if (h.hasOnlyHiddenChildren(node))
|
|
342
|
-
context.report({ node, messageId: 'hiddenInLink' })
|
|
343
|
-
},
|
|
344
|
-
}
|
|
345
|
-
},
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
// ─── no-log-with-interactive-children ────────────────────────────────────────
|
|
350
|
-
|
|
351
|
-
const INTERACTIVE_JSX_ELEMENTS = new Set(['button', 'input', 'select', 'textarea', 'a'])
|
|
352
|
-
|
|
353
|
-
export function makeNoLogWithInteractiveChildren(h) {
|
|
354
|
-
return {
|
|
355
|
-
meta: {
|
|
356
|
-
type: 'suggestion',
|
|
357
|
-
docs: { description: 'Disallow interactive elements inside role="log"' },
|
|
358
|
-
messages: {
|
|
359
|
-
interactiveChild:
|
|
360
|
-
'<{{el}}> inside role="log" breaks AT expectations. role="log" is for read-only async content (chat history, server logs). Move interactive controls outside the log region. (APG: Log Role)',
|
|
361
|
-
},
|
|
362
|
-
schema: [],
|
|
363
|
-
},
|
|
364
|
-
create(context) {
|
|
365
|
-
return {
|
|
366
|
-
[h.elementVisitor](node) {
|
|
367
|
-
const el = h.getElementName(node)
|
|
368
|
-
if (!el || !INTERACTIVE_JSX_ELEMENTS.has(el)) return
|
|
369
|
-
for (const ancestor of h.getAncestors(node)) {
|
|
370
|
-
if (h.getRoleValue(ancestor) === 'log') {
|
|
371
|
-
context.report({ node, messageId: 'interactiveChild', data: { el } })
|
|
372
|
-
return
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
},
|
|
376
|
-
}
|
|
377
|
-
},
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
// ─── no-presentation-on-focusable ────────────────────────────────────────────
|
|
382
|
-
|
|
383
|
-
export function makeNoPresentationOnFocusable(h) {
|
|
384
|
-
return {
|
|
385
|
-
meta: {
|
|
386
|
-
type: 'problem',
|
|
387
|
-
docs: { description: 'Disallow role="presentation" or role="none" on focusable elements' },
|
|
388
|
-
messages: {
|
|
389
|
-
presentationFocusable:
|
|
390
|
-
'role="{{role}}" removes semantics but NOT focus. Keyboard users reach this element but AT users cannot identify it
|
|
391
|
-
},
|
|
392
|
-
schema: [],
|
|
393
|
-
},
|
|
394
|
-
create(context) {
|
|
395
|
-
return {
|
|
396
|
-
[h.elementVisitor](node) {
|
|
397
|
-
const role = h.getRoleValue(node)
|
|
398
|
-
if (role !== 'presentation' && role !== 'none') return
|
|
399
|
-
const isFocusable =
|
|
400
|
-
h.hasAttr(node, 'tabIndex') || h.hasAttr(node, 'tabindex') ||
|
|
401
|
-
h.hasAttr(node, 'onClick') || h.hasAttr(node, 'onKeyDown') || h.hasAttr(node, 'onKeyPress') ||
|
|
402
|
-
h.hasAttr(node, '@click') || h.hasAttr(node, '(click)') ||
|
|
403
|
-
(h.getElementName(node) === 'a' && h.hasAttr(node, 'href'))
|
|
404
|
-
if (isFocusable)
|
|
405
|
-
context.report({ node: h.getAttr(node, 'role'), messageId: 'presentationFocusable', data: { role } })
|
|
406
|
-
},
|
|
407
|
-
}
|
|
408
|
-
},
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
// ─── no-group-without-name ───────────────────────────────────────────────────
|
|
413
|
-
|
|
414
|
-
export function makeNoGroupWithoutName(h) {
|
|
415
|
-
return {
|
|
416
|
-
meta: {
|
|
417
|
-
type: 'suggestion',
|
|
418
|
-
docs: { description: 'Require accessible name on role="group" that contains form controls' },
|
|
419
|
-
messages: {
|
|
420
|
-
missingName:
|
|
421
|
-
'role="group" containing form controls must have aria-label or aria-labelledby. Without a name the grouping is invisible to AT. Use <fieldset>/<legend> for form groups where possible. (APG / Groves
|
|
422
|
-
},
|
|
423
|
-
schema: [],
|
|
424
|
-
},
|
|
425
|
-
create(context) {
|
|
426
|
-
return {
|
|
427
|
-
[h.elementWithChildrenVisitor](node) {
|
|
428
|
-
const opening = h.getOpeningElement(node)
|
|
429
|
-
if (h.getRoleValue(opening) !== 'group') return
|
|
430
|
-
if (h.hasAccessibleName(opening)) return
|
|
431
|
-
const hasFormChild = h.getChildOpeningElementsFromWrapper(node).some(childEl => {
|
|
432
|
-
const name = h.getElementName(childEl)
|
|
433
|
-
return name && FORM_ELEMENTS.has(name)
|
|
434
|
-
})
|
|
435
|
-
if (hasFormChild)
|
|
436
|
-
context.report({ node: opening, messageId: 'missingName' })
|
|
437
|
-
},
|
|
438
|
-
}
|
|
439
|
-
},
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
// ─── no-redundant-aria-hidden-with-presentation ──────────────────────────────
|
|
444
|
-
|
|
445
|
-
export function makeNoRedundantAriaHiddenWithPresentation(h) {
|
|
446
|
-
return {
|
|
447
|
-
meta: {
|
|
448
|
-
type: 'suggestion',
|
|
449
|
-
docs: { description: 'Disallow redundant aria-hidden="true" combined with role="none" or role="presentation"' },
|
|
450
|
-
messages: {
|
|
451
|
-
redundant:
|
|
452
|
-
'aria-hidden="true" already removes this element from the accessibility tree
|
|
453
|
-
},
|
|
454
|
-
schema: [],
|
|
455
|
-
},
|
|
456
|
-
create(context) {
|
|
457
|
-
return {
|
|
458
|
-
[h.elementVisitor](node) {
|
|
459
|
-
const role = h.getRoleValue(node)
|
|
460
|
-
if (role !== 'none' && role !== 'presentation') return
|
|
461
|
-
const hiddenVal = h.getAttrStringValue(h.getAttr(node, 'aria-hidden'))
|
|
462
|
-
if (hiddenVal === 'true')
|
|
463
|
-
context.report({ node: h.getAttr(node, 'role'), messageId: 'redundant', data: { role } })
|
|
464
|
-
},
|
|
465
|
-
}
|
|
466
|
-
},
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
// ─── no-title-as-label ───────────────────────────────────────────────────────
|
|
471
|
-
|
|
472
|
-
const INPUT_TYPES_NEEDING_LABEL = new Set(['text', 'email', 'password', 'search', 'tel', 'url', 'number'])
|
|
473
|
-
|
|
474
|
-
export function makeNoTitleAsLabel(h) {
|
|
475
|
-
return {
|
|
476
|
-
meta: {
|
|
477
|
-
type: 'problem',
|
|
478
|
-
docs: { description: 'Disallow title attribute as the only accessible name on interactive elements' },
|
|
479
|
-
messages: {
|
|
480
|
-
titleOnly:
|
|
481
|
-
'The `title` attribute is not keyboard accessible (requires hover) and has inconsistent AT support. Interactive elements need a visible label, aria-label, or aria-labelledby. (Groves / O\'Hara)',
|
|
482
|
-
},
|
|
483
|
-
schema: [],
|
|
484
|
-
},
|
|
485
|
-
create(context) {
|
|
486
|
-
return {
|
|
487
|
-
[h.elementVisitor](node) {
|
|
488
|
-
if (!h.isInteractiveElement(node)) return
|
|
489
|
-
if (!h.hasAttr(node, 'title')) return
|
|
490
|
-
if (h.hasAccessibleName(node)) return
|
|
491
|
-
const el = h.getElementName(node)
|
|
492
|
-
if (el === 'input') {
|
|
493
|
-
const typeAttr = h.getAttrStringValue(h.getAttr(node, 'type')) ?? 'text'
|
|
494
|
-
if (INPUT_TYPES_NEEDING_LABEL.has(typeAttr))
|
|
495
|
-
context.report({ node: h.getAttr(node, 'title'), messageId: 'titleOnly' })
|
|
496
|
-
}
|
|
497
|
-
},
|
|
498
|
-
}
|
|
499
|
-
},
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
// ─── no-aria-owns-on-void ────────────────────────────────────────────────────
|
|
504
|
-
|
|
505
|
-
export function makeNoAriaOwnsOnVoid(h) {
|
|
506
|
-
return {
|
|
507
|
-
meta: {
|
|
508
|
-
type: 'problem',
|
|
509
|
-
docs: { description: 'Disallow aria-owns on void elements that cannot have children' },
|
|
510
|
-
messages: {
|
|
511
|
-
voidOwns:
|
|
512
|
-
'aria-owns on <{{el}}> is meaningless
|
|
513
|
-
},
|
|
514
|
-
schema: [],
|
|
515
|
-
},
|
|
516
|
-
create(context) {
|
|
517
|
-
return {
|
|
518
|
-
[h.elementVisitor](node) {
|
|
519
|
-
if (!h.hasAttr(node, 'aria-owns')) return
|
|
520
|
-
const el = h.getElementName(node)
|
|
521
|
-
if (el && VOID_ELEMENTS.has(el))
|
|
522
|
-
context.report({ node: h.getAttr(node, 'aria-owns'), messageId: 'voidOwns', data: { el } })
|
|
523
|
-
},
|
|
524
|
-
}
|
|
525
|
-
},
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
// ─── no-href-hash ─────────────────────────────────────────────────────────────
|
|
530
|
-
|
|
531
|
-
export function makeNoHrefHash(h) {
|
|
532
|
-
return {
|
|
533
|
-
meta: {
|
|
534
|
-
type: 'suggestion',
|
|
535
|
-
docs: { description: 'Disallow <a href="#">
|
|
536
|
-
messages: {
|
|
537
|
-
hrefHash:
|
|
538
|
-
'<a href="#"> is a link used as a button. Links navigate, buttons perform actions. Use <button> for click handlers. If you need a hash link, use a real fragment id. (Sutton: Links vs Buttons)',
|
|
539
|
-
},
|
|
540
|
-
schema: [],
|
|
541
|
-
},
|
|
542
|
-
create(context) {
|
|
543
|
-
return {
|
|
544
|
-
[h.elementVisitor](node) {
|
|
545
|
-
if (h.getElementName(node) !== 'a') return
|
|
546
|
-
const hrefVal = h.getAttrStringValue(h.getAttr(node, 'href'))
|
|
547
|
-
if (hrefVal === '#' || hrefVal === '#/')
|
|
548
|
-
context.report({ node: h.getAttr(node, 'href'), messageId: 'hrefHash' })
|
|
549
|
-
},
|
|
550
|
-
}
|
|
551
|
-
},
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
// ─── warn-role-alert ─────────────────────────────────────────────────────────
|
|
557
|
-
|
|
558
|
-
export function makeWarnRoleAlert(h) {
|
|
559
|
-
return {
|
|
560
|
-
meta: {
|
|
561
|
-
type: 'suggestion',
|
|
562
|
-
docs: { description: 'Warn when role="alert" is used
|
|
563
|
-
messages: {
|
|
564
|
-
alert:
|
|
565
|
-
'role="alert" immediately interrupts the user. Confirm this is a genuine error or time-critical message. For status updates use role="status" (polite). For progress use aria-live="polite". (APG / Roselli / Sutton)',
|
|
566
|
-
},
|
|
567
|
-
schema: [],
|
|
568
|
-
},
|
|
569
|
-
create(context) {
|
|
570
|
-
return {
|
|
571
|
-
[h.elementVisitor](node) {
|
|
572
|
-
if (h.getRoleValue(node) === 'alert')
|
|
573
|
-
context.report({ node: h.getAttr(node, 'role'), messageId: 'alert' })
|
|
574
|
-
},
|
|
575
|
-
}
|
|
576
|
-
},
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
// ─── prefer-aria-disabled ────────────────────────────────────────────────────
|
|
581
|
-
|
|
582
|
-
// Native form controls that support HTML disabled per spec
|
|
583
|
-
// not the right substitute for these; native disabled is correct and expected.
|
|
584
|
-
const NATIVE_DISABLED_ELEMENTS = new Set(['input', 'select', 'textarea', 'option', 'optgroup', 'fieldset'])
|
|
585
|
-
|
|
586
|
-
export function makePreferAriaDisabled(h) {
|
|
587
|
-
return {
|
|
588
|
-
meta: {
|
|
589
|
-
type: 'suggestion',
|
|
590
|
-
docs: { description: 'Suggest aria-disabled over the HTML disabled attribute for better AT discoverability' },
|
|
591
|
-
messages: {
|
|
592
|
-
disabled:
|
|
593
|
-
'`disabled` removes the element from the tab order
|
|
594
|
-
},
|
|
595
|
-
schema: [],
|
|
596
|
-
},
|
|
597
|
-
create(context) {
|
|
598
|
-
return {
|
|
599
|
-
[h.elementVisitor](node) {
|
|
600
|
-
if (!h.isInteractiveElement(node)) return
|
|
601
|
-
// Native form controls: HTML disabled is correct per spec, not aria-disabled
|
|
602
|
-
const elName = h.getElementName(node)
|
|
603
|
-
if (elName && NATIVE_DISABLED_ELEMENTS.has(elName)) return
|
|
604
|
-
const attr = h.getAttr(node, 'disabled')
|
|
605
|
-
if (!attr) return
|
|
606
|
-
// Only flag boolean disabled (not disabled={false})
|
|
607
|
-
const val = attr.value
|
|
608
|
-
// JSX: val === null is boolean true; val.type=JSXExpressionContainer with false literal is false
|
|
609
|
-
if (val === null) {
|
|
610
|
-
context.report({ node: attr, messageId: 'disabled' })
|
|
611
|
-
return
|
|
612
|
-
}
|
|
613
|
-
if (val.type === 'JSXExpressionContainer' && val.expression?.value === false) return
|
|
614
|
-
// Vue/Angular: empty string value means boolean true
|
|
615
|
-
if (typeof val === 'string' && val === '') {
|
|
616
|
-
context.report({ node: attr, messageId: 'disabled' })
|
|
617
|
-
return
|
|
618
|
-
}
|
|
619
|
-
// Generic: string value of "true" or empty
|
|
620
|
-
const strVal = h.getAttrStringValue(attr)
|
|
621
|
-
if (strVal === null || strVal === 'true' || strVal === '')
|
|
622
|
-
context.report({ node: attr, messageId: 'disabled' })
|
|
623
|
-
},
|
|
624
|
-
}
|
|
625
|
-
},
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
// ─── no-tabs-without-structure ───────────────────────────────────────────────
|
|
630
|
-
|
|
631
|
-
export function makeNoTabsWithoutStructure(h) {
|
|
632
|
-
return {
|
|
633
|
-
meta: {
|
|
634
|
-
type: 'problem',
|
|
635
|
-
docs: { description: 'Enforce required ARIA attributes on tab/tablist/tabpanel roles' },
|
|
636
|
-
messages: {
|
|
637
|
-
tabMissingSelected:
|
|
638
|
-
'role="tab" requires aria-selected="true" or aria-selected="false". Without it AT cannot determine which tab is active. (APG: Tabs Pattern)',
|
|
639
|
-
tabpanelMissingLabel:
|
|
640
|
-
'role="tabpanel" requires aria-labelledby="TAB_ID" pointing to its controlling tab. Without it the panel has no accessible name. (APG: Tabs Pattern)',
|
|
641
|
-
tablistMissingName:
|
|
642
|
-
'role="tablist" with multiple tab sets on the page needs aria-label or aria-labelledby to distinguish them. (APG: Tabs Pattern)',
|
|
643
|
-
},
|
|
644
|
-
schema: [],
|
|
645
|
-
},
|
|
646
|
-
create(context) {
|
|
647
|
-
return {
|
|
648
|
-
[h.elementVisitor](node) {
|
|
649
|
-
const role = h.getRoleValue(node)
|
|
650
|
-
|
|
651
|
-
if (role === 'tab') {
|
|
652
|
-
if (!h.hasAttr(node, 'aria-selected'))
|
|
653
|
-
context.report({ node: h.getAttr(node, 'role'), messageId: 'tabMissingSelected' })
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
if (role === 'tabpanel') {
|
|
657
|
-
if (!h.hasAttr(node, 'aria-labelledby'))
|
|
658
|
-
context.report({ node: h.getAttr(node, 'role'), messageId: 'tabpanelMissingLabel' })
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
if (role === 'tablist') {
|
|
662
|
-
if (!h.hasAccessibleName(node))
|
|
663
|
-
context.report({ node: h.getAttr(node, 'role'), messageId: 'tablistMissingName' })
|
|
664
|
-
}
|
|
665
|
-
},
|
|
666
|
-
}
|
|
667
|
-
},
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
// ─── no-tab-without-controls ─────────────────────────────────────────────────
|
|
672
|
-
// Separate warn-level rule for aria-controls on tabs. The APG recommends it but
|
|
673
|
-
// does not require it
|
|
674
|
-
// production implementations omit aria-controls without breaking AT.
|
|
675
|
-
// Ref: APG Tabs Pattern
|
|
676
|
-
|
|
677
|
-
export function makeNoTabWithoutControls(h) {
|
|
678
|
-
return {
|
|
679
|
-
meta: {
|
|
680
|
-
type: 'suggestion',
|
|
681
|
-
docs: { description: 'Warn when role="tab" lacks aria-controls pointing to its tabpanel' },
|
|
682
|
-
messages: {
|
|
683
|
-
tabMissingControls:
|
|
684
|
-
'role="tab" should have aria-controls="PANEL_ID" pointing to its tabpanel. The explicit relationship helps JAWS users; aria-labelledby on the panel is the minimum required. (APG: Tabs Pattern)',
|
|
685
|
-
},
|
|
686
|
-
schema: [],
|
|
687
|
-
},
|
|
688
|
-
create(context) {
|
|
689
|
-
return {
|
|
690
|
-
[h.elementVisitor](node) {
|
|
691
|
-
if (h.getRoleValue(node) !== 'tab') return
|
|
692
|
-
if (!h.hasAttr(node, 'aria-controls'))
|
|
693
|
-
context.report({ node: h.getAttr(node, 'role'), messageId: 'tabMissingControls' })
|
|
694
|
-
},
|
|
695
|
-
}
|
|
696
|
-
},
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
// ─── no-positive-tabindex ────────────────────────────────────────────────────
|
|
701
|
-
|
|
702
|
-
export function makeNoPositiveTabindex(h) {
|
|
703
|
-
return {
|
|
704
|
-
meta: {
|
|
705
|
-
type: 'problem',
|
|
706
|
-
docs: { description: 'Disallow tabIndex values greater than 0' },
|
|
707
|
-
messages: {
|
|
708
|
-
positive:
|
|
709
|
-
'tabIndex={{value}} creates an artificial tab order that overrides natural DOM flow, breaking keyboard and AT navigation. Use tabIndex={0} to add to the flow, or tabIndex={-1} to remove from it. (WebAIM / Lauke)',
|
|
710
|
-
},
|
|
711
|
-
schema: [],
|
|
712
|
-
},
|
|
713
|
-
create(context) {
|
|
714
|
-
return {
|
|
715
|
-
[h.elementVisitor](node) {
|
|
716
|
-
const attr = h.getAttr(node, 'tabIndex') ?? h.getAttr(node, 'tabindex')
|
|
717
|
-
if (!attr) return
|
|
718
|
-
const val = attr.value
|
|
719
|
-
let num = null
|
|
720
|
-
if (val?.type === 'JSXExpressionContainer' && val.expression.type === 'Literal')
|
|
721
|
-
num = Number(val.expression.value)
|
|
722
|
-
else if (val?.type === 'Literal')
|
|
723
|
-
num = Number(val.value)
|
|
724
|
-
else {
|
|
725
|
-
// Vue/Angular: plain string value
|
|
726
|
-
const strVal = h.getAttrStringValue(attr)
|
|
727
|
-
if (strVal !== null) num = Number(strVal)
|
|
728
|
-
}
|
|
729
|
-
if (num !== null && num > 0)
|
|
730
|
-
context.report({ node: attr, messageId: 'positive', data: { value: num } })
|
|
731
|
-
},
|
|
732
|
-
}
|
|
733
|
-
},
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
// ─── no-target-blank-without-label ───────────────────────────────────────────
|
|
738
|
-
|
|
739
|
-
export function makeNoTargetBlankWithoutLabel(h) {
|
|
740
|
-
return {
|
|
741
|
-
meta: {
|
|
742
|
-
type: 'suggestion',
|
|
743
|
-
docs: { description: 'Warn when target="_blank" is used without communicating the new-tab behaviour' },
|
|
744
|
-
messages: {
|
|
745
|
-
targetBlank:
|
|
746
|
-
'target="_blank" opens a new tab without warning AT users. Add visually-hidden text "(opens in new tab)" or include it in aria-label/the link text so users can anticipate the context switch. (WebAIM / WCAG 3.2.2)',
|
|
747
|
-
},
|
|
748
|
-
schema: [],
|
|
749
|
-
},
|
|
750
|
-
create(context) {
|
|
751
|
-
return {
|
|
752
|
-
[h.elementVisitor](node) {
|
|
753
|
-
if (h.getElementName(node) !== 'a') return
|
|
754
|
-
const targetVal = h.getAttrStringValue(h.getAttr(node, 'target'))
|
|
755
|
-
if (targetVal !== '_blank') return
|
|
756
|
-
if (h.hasNewTabWarning?.(node)) return
|
|
757
|
-
context.report({ node: h.getAttr(node, 'target'), messageId: 'targetBlank' })
|
|
758
|
-
},
|
|
759
|
-
}
|
|
760
|
-
},
|
|
761
|
-
}
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
// ─── no-autoplay-without-controls ────────────────────────────────────────────
|
|
765
|
-
|
|
766
|
-
export function makeNoAutoplayWithoutControls(h) {
|
|
767
|
-
return {
|
|
768
|
-
meta: {
|
|
769
|
-
type: 'problem',
|
|
770
|
-
docs: { description: 'Disallow autoPlay on media elements without controls' },
|
|
771
|
-
messages: {
|
|
772
|
-
autoplay:
|
|
773
|
-
'<{{el}} autoPlay> without controls violates WCAG 1.4.2. Users cannot pause or mute it; screen reader audio is disrupted. Add the controls attribute or a custom control UI. (WCAG 1.4.2 / WebAIM)',
|
|
774
|
-
},
|
|
775
|
-
schema: [],
|
|
776
|
-
},
|
|
777
|
-
create(context) {
|
|
778
|
-
return {
|
|
779
|
-
[h.elementVisitor](node) {
|
|
780
|
-
const el = h.getElementName(node)
|
|
781
|
-
if (el !== 'video' && el !== 'audio') return
|
|
782
|
-
if (!h.hasAttr(node, 'autoPlay') && !h.hasAttr(node, 'autoplay')) return
|
|
783
|
-
if (h.hasAttr(node, 'controls')) return
|
|
784
|
-
const autoPlayAttr = h.getAttr(node, 'autoPlay') ?? h.getAttr(node, 'autoplay')
|
|
785
|
-
context.report({ node: autoPlayAttr, messageId: 'autoplay', data: { el } })
|
|
786
|
-
},
|
|
787
|
-
}
|
|
788
|
-
},
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
// ─── no-heading-inside-interactive ───────────────────────────────────────────
|
|
793
|
-
|
|
794
|
-
export function makeNoHeadingInsideInteractive(h) {
|
|
795
|
-
return {
|
|
796
|
-
meta: {
|
|
797
|
-
type: 'problem',
|
|
798
|
-
docs: { description: 'Disallow heading elements nested inside interactive elements' },
|
|
799
|
-
messages: {
|
|
800
|
-
headingInInteractive:
|
|
801
|
-
'<{{heading}}> inside <{{parent}}> breaks AT heading navigation and causes double-announcement. Move the heading outside the interactive element, or use CSS to style text without a heading tag. (Roselli / Pickering)',
|
|
802
|
-
},
|
|
803
|
-
schema: [],
|
|
804
|
-
},
|
|
805
|
-
create(context) {
|
|
806
|
-
return {
|
|
807
|
-
[h.elementVisitor](node) {
|
|
808
|
-
const el = h.getElementName(node)
|
|
809
|
-
if (!el || !HEADING_ELEMENTS.has(el)) return
|
|
810
|
-
for (const ancestor of h.getAncestors(node)) {
|
|
811
|
-
const parentEl = h.getElementName(ancestor)
|
|
812
|
-
const parentRole = h.getRoleValue(ancestor)
|
|
813
|
-
if ((parentEl && INTERACTIVE_ELEMENTS.has(parentEl)) ||
|
|
814
|
-
(parentRole && INTERACTIVE_ROLES.has(parentRole))) {
|
|
815
|
-
context.report({
|
|
816
|
-
node,
|
|
817
|
-
messageId: 'headingInInteractive',
|
|
818
|
-
data: { heading: el, parent: parentEl ?? `[role="${parentRole}"]` },
|
|
819
|
-
})
|
|
820
|
-
return
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
},
|
|
824
|
-
}
|
|
825
|
-
},
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
// ─── no-placeholder-only ─────────────────────────────────────────────────────
|
|
830
|
-
|
|
831
|
-
export function makeNoPlaceholderOnly(h) {
|
|
832
|
-
return {
|
|
833
|
-
meta: {
|
|
834
|
-
type: 'problem',
|
|
835
|
-
docs: { description: 'Disallow form inputs that rely solely on placeholder as their accessible label' },
|
|
836
|
-
messages: {
|
|
837
|
-
placeholderOnly:
|
|
838
|
-
'placeholder disappears on focus
|
|
839
|
-
},
|
|
840
|
-
schema: [],
|
|
841
|
-
},
|
|
842
|
-
create(context) {
|
|
843
|
-
return {
|
|
844
|
-
[h.elementVisitor](node) {
|
|
845
|
-
if (h.getElementName(node) !== 'input') return
|
|
846
|
-
if (!h.hasAttr(node, 'placeholder')) return
|
|
847
|
-
if (h.hasAccessibleName(node)) return
|
|
848
|
-
context.report({ node: h.getAttr(node, 'placeholder'), messageId: 'placeholderOnly' })
|
|
849
|
-
},
|
|
850
|
-
}
|
|
851
|
-
},
|
|
852
|
-
}
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
// ─── no-empty-button ─────────────────────────────────────────────────────────
|
|
856
|
-
// WebAIM Million #2 failure: empty or icon-only buttons with no accessible name.
|
|
857
|
-
// An icon <button> with only aria-hidden children has no accessible name.
|
|
858
|
-
// Ref: WebAIM Million 2024; WCAG 4.1.2; axe-core (MPL-2.0, reimplemented)
|
|
859
|
-
|
|
860
|
-
export function makeNoEmptyButton(h) {
|
|
861
|
-
return {
|
|
862
|
-
meta: {
|
|
863
|
-
type: 'problem',
|
|
864
|
-
docs: { description: 'Disallow <button> elements with no accessible name' },
|
|
865
|
-
messages: {
|
|
866
|
-
emptyButton:
|
|
867
|
-
'This <button> has no accessible name
|
|
868
|
-
},
|
|
869
|
-
schema: [],
|
|
870
|
-
},
|
|
871
|
-
create(context) {
|
|
872
|
-
return {
|
|
873
|
-
[h.elementVisitor](node) {
|
|
874
|
-
if (h.getElementName(node) !== 'button') return
|
|
875
|
-
if (h.hasAccessibleName(node)) return
|
|
876
|
-
if (!h.hasOnlyHiddenChildren(node)) return
|
|
877
|
-
context.report({ node, messageId: 'emptyButton' })
|
|
878
|
-
},
|
|
879
|
-
}
|
|
880
|
-
},
|
|
881
|
-
}
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
// ─── no-image-role-without-name ──────────────────────────────────────────────
|
|
885
|
-
// role="img" marks a container as an image. Without an accessible name the image
|
|
886
|
-
// is meaningless to AT. Particularly common with SVG composed of multiple shapes.
|
|
887
|
-
// Ref: APG; ARIA 1.2; O'Hara scottohara.me/blog/2019/05/22/contextual-images-svgs-and-a11y.html
|
|
888
|
-
|
|
889
|
-
export function makeNoImageRoleWithoutName(h) {
|
|
890
|
-
return {
|
|
891
|
-
meta: {
|
|
892
|
-
type: 'problem',
|
|
893
|
-
docs: { description: 'Require accessible name on role="img"' },
|
|
894
|
-
messages: {
|
|
895
|
-
missingName:
|
|
896
|
-
'role="img" requires an accessible name (aria-label or aria-labelledby) to convey what the image depicts. (APG / O\'Hara
|
|
897
|
-
},
|
|
898
|
-
schema: [],
|
|
899
|
-
},
|
|
900
|
-
create(context) {
|
|
901
|
-
return {
|
|
902
|
-
[h.elementVisitor](node) {
|
|
903
|
-
if (h.getRoleValue(node) !== 'img') return
|
|
904
|
-
if (h.hasAccessibleName(node)) return
|
|
905
|
-
context.report({ node: h.getAttr(node, 'role'), messageId: 'missingName' })
|
|
906
|
-
},
|
|
907
|
-
}
|
|
908
|
-
},
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
// ─── no-spinbutton-without-range ─────────────────────────────────────────────
|
|
914
|
-
// role="spinbutton" requires aria-valuenow, aria-valuemin, and aria-valuemax.
|
|
915
|
-
// Without these the widget is incomplete and AT cannot convey the value.
|
|
916
|
-
// Ref: ARIA 1.2 §5.3.21; APG Spinbutton Pattern
|
|
917
|
-
|
|
918
|
-
export function makeNoSpinbuttonWithoutRange(h) {
|
|
919
|
-
return {
|
|
920
|
-
meta: {
|
|
921
|
-
type: 'problem',
|
|
922
|
-
docs: { description: 'Require aria-valuenow/min/max on role="spinbutton"' },
|
|
923
|
-
messages: {
|
|
924
|
-
missingValueNow:
|
|
925
|
-
'role="spinbutton" requires aria-valuenow so AT can announce the current value. (ARIA 1.2 / APG: Spinbutton)',
|
|
926
|
-
missingValueRange:
|
|
927
|
-
'role="spinbutton" requires aria-valuemin and aria-valuemax to define the valid range. (ARIA 1.2 / APG: Spinbutton)',
|
|
928
|
-
},
|
|
929
|
-
schema: [],
|
|
930
|
-
},
|
|
931
|
-
create(context) {
|
|
932
|
-
return {
|
|
933
|
-
[h.elementVisitor](node) {
|
|
934
|
-
if (h.getRoleValue(node) !== 'spinbutton') return
|
|
935
|
-
if (!h.hasAttr(node, 'aria-valuenow'))
|
|
936
|
-
context.report({ node: h.getAttr(node, 'role'), messageId: 'missingValueNow' })
|
|
937
|
-
if (!h.hasAttr(node, 'aria-valuemin') || !h.hasAttr(node, 'aria-valuemax'))
|
|
938
|
-
context.report({ node: h.getAttr(node, 'role'), messageId: 'missingValueRange' })
|
|
939
|
-
},
|
|
940
|
-
}
|
|
941
|
-
},
|
|
942
|
-
}
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
// ─── no-slider-without-range ─────────────────────────────────────────────────
|
|
946
|
-
// role="slider" requires aria-valuenow, aria-valuemin, aria-valuemax.
|
|
947
|
-
// Ref: ARIA 1.2 §5.3.20; APG Slider Pattern
|
|
948
|
-
|
|
949
|
-
export function makeNoSliderWithoutRange(h) {
|
|
950
|
-
return {
|
|
951
|
-
meta: {
|
|
952
|
-
type: 'problem',
|
|
953
|
-
docs: { description: 'Require aria-valuenow/min/max on role="slider"' },
|
|
954
|
-
messages: {
|
|
955
|
-
missingRange:
|
|
956
|
-
'role="slider" requires aria-valuenow, aria-valuemin, and aria-valuemax. Without them AT cannot announce the current value or valid range. (ARIA 1.2 / APG: Slider)',
|
|
957
|
-
},
|
|
958
|
-
schema: [],
|
|
959
|
-
},
|
|
960
|
-
create(context) {
|
|
961
|
-
return {
|
|
962
|
-
[h.elementVisitor](node) {
|
|
963
|
-
if (h.getRoleValue(node) !== 'slider') return
|
|
964
|
-
const missing = ['aria-valuenow', 'aria-valuemin', 'aria-valuemax'].filter(a => !h.hasAttr(node, a))
|
|
965
|
-
if (missing.length)
|
|
966
|
-
context.report({ node: h.getAttr(node, 'role'), messageId: 'missingRange' })
|
|
967
|
-
},
|
|
968
|
-
}
|
|
969
|
-
},
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
// ─── no-combobox-without-expanded ────────────────────────────────────────────
|
|
974
|
-
// role="combobox" requires aria-expanded to convey open/closed state to AT.
|
|
975
|
-
// Ref: ARIA 1.2 §5.3.3; APG Combobox Pattern
|
|
976
|
-
|
|
977
|
-
export function makeNoComboboxWithoutExpanded(h) {
|
|
978
|
-
return {
|
|
979
|
-
meta: {
|
|
980
|
-
type: 'problem',
|
|
981
|
-
docs: { description: 'Require aria-expanded on role="combobox"' },
|
|
982
|
-
messages: {
|
|
983
|
-
missingExpanded:
|
|
984
|
-
'role="combobox" requires aria-expanded to communicate open/closed state to AT. (ARIA 1.2 / APG: Combobox)',
|
|
985
|
-
},
|
|
986
|
-
schema: [],
|
|
987
|
-
},
|
|
988
|
-
create(context) {
|
|
989
|
-
return {
|
|
990
|
-
[h.elementVisitor](node) {
|
|
991
|
-
if (h.getRoleValue(node) !== 'combobox') return
|
|
992
|
-
if (!h.hasAttr(node, 'aria-expanded'))
|
|
993
|
-
context.report({ node: h.getAttr(node, 'role'), messageId: 'missingExpanded' })
|
|
994
|
-
},
|
|
995
|
-
}
|
|
996
|
-
},
|
|
997
|
-
}
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
// ─── no-mouse-only-events ────────────────────────────────────────────────────
|
|
1002
|
-
// onMouseEnter/onMouseLeave/onMouseOver without keyboard equivalents (onFocus/
|
|
1003
|
-
// onBlur) leaves those interactions unreachable for keyboard and switch users.
|
|
1004
|
-
// This is a direct WCAG 2.1.1 (Keyboard) failure.
|
|
1005
|
-
// Note: onMouseMove is intentionally excluded
|
|
1006
|
-
// have no keyboard equivalent by nature and should be handled separately.
|
|
1007
|
-
// Ref: WCAG 2.1.1; MDN Accessibility; cross-practitioner consensus
|
|
1008
|
-
|
|
1009
|
-
const MOUSE_ONLY_PAIRS = [
|
|
1010
|
-
{ mouse: 'onMouseEnter', keyboard: 'onFocus' },
|
|
1011
|
-
{ mouse: 'onMouseLeave', keyboard: 'onBlur' },
|
|
1012
|
-
{ mouse: 'onMouseOver', keyboard: 'onFocus' },
|
|
1013
|
-
{ mouse: 'onMouseOut', keyboard: 'onBlur' },
|
|
1014
|
-
]
|
|
1015
|
-
|
|
1016
|
-
export function makeNoMouseOnlyEvents(h) {
|
|
1017
|
-
return {
|
|
1018
|
-
meta: {
|
|
1019
|
-
type: 'problem',
|
|
1020
|
-
docs: { description: 'Disallow mouse-only event handlers without keyboard equivalents' },
|
|
1021
|
-
messages: {
|
|
1022
|
-
missingKeyboard:
|
|
1023
|
-
'{{mouse}} without {{keyboard}} leaves this interaction unreachable by keyboard. Add {{keyboard}} (and {{blur}} for cleanup if needed) to support keyboard and switch users. (WCAG 2.1.1)',
|
|
1024
|
-
},
|
|
1025
|
-
schema: [],
|
|
1026
|
-
},
|
|
1027
|
-
create(context) {
|
|
1028
|
-
return {
|
|
1029
|
-
[h.elementVisitor](node) {
|
|
1030
|
-
// aria-hidden elements are removed from the AT tree
|
|
1031
|
-
if (h.getAttrStringValue(h.getAttr(node, 'aria-hidden')) === 'true') return
|
|
1032
|
-
for (const { mouse, keyboard } of MOUSE_ONLY_PAIRS) {
|
|
1033
|
-
if (!h.hasAttr(node, mouse)) continue
|
|
1034
|
-
if (h.hasAttr(node, keyboard)) continue
|
|
1035
|
-
// onClick already implies keyboard access
|
|
1036
|
-
if (h.hasAttr(node, 'onClick')) continue
|
|
1037
|
-
context.report({
|
|
1038
|
-
node: h.getAttr(node, mouse),
|
|
1039
|
-
messageId: 'missingKeyboard',
|
|
1040
|
-
data: { mouse, keyboard, blur: keyboard === 'onFocus' ? ' and onBlur' : '' },
|
|
1041
|
-
})
|
|
1042
|
-
}
|
|
1043
|
-
},
|
|
1044
|
-
}
|
|
1045
|
-
},
|
|
1046
|
-
}
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
// ─── no-listbox-without-option ───────────────────────────────────────────────
|
|
1050
|
-
// ARIA 1.2: listbox required owned elements = option (or group > option).
|
|
1051
|
-
// A listbox with no option children is an empty, non-functional widget.
|
|
1052
|
-
// Ref: ARIA 1.2 §5.3.13; APG Listbox Pattern
|
|
1053
|
-
|
|
1054
|
-
export function makeNoListboxWithoutOption(h) {
|
|
1055
|
-
return {
|
|
1056
|
-
meta: {
|
|
1057
|
-
type: 'problem',
|
|
1058
|
-
docs: { description: 'Require role="option" children inside role="listbox"' },
|
|
1059
|
-
messages: {
|
|
1060
|
-
missingOption:
|
|
1061
|
-
'role="listbox" must contain elements with role="option" (directly or via role="group"). Without options the listbox is empty and non-functional for AT. (ARIA 1.2 §5.3.13 / APG: Listbox Pattern)',
|
|
1062
|
-
},
|
|
1063
|
-
schema: [],
|
|
1064
|
-
},
|
|
1065
|
-
create(context) {
|
|
1066
|
-
return {
|
|
1067
|
-
[h.elementWithChildrenVisitor](node) {
|
|
1068
|
-
const opening = h.getOpeningElement(node)
|
|
1069
|
-
if (h.getRoleValue(opening) !== 'listbox') return
|
|
1070
|
-
const hasOption = h.getChildOpeningElementsFromWrapper(node).some(
|
|
1071
|
-
child => h.getRoleValue(child) === 'option' || h.getRoleValue(child) === 'group'
|
|
1072
|
-
)
|
|
1073
|
-
if (!hasOption)
|
|
1074
|
-
context.report({ node: opening, messageId: 'missingOption' })
|
|
1075
|
-
},
|
|
1076
|
-
}
|
|
1077
|
-
},
|
|
1078
|
-
}
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
// ─── no-tree-without-treeitem ─────────────────────────────────────────────────
|
|
1082
|
-
// APG: "Each element serving as a tree node has role treeitem."
|
|
1083
|
-
// A tree with no treeitem children is structurally broken.
|
|
1084
|
-
// Ref: ARIA 1.2 §5.3.25; APG Tree View Pattern
|
|
1085
|
-
|
|
1086
|
-
export function makeNoTreeWithoutTreeitem(h) {
|
|
1087
|
-
return {
|
|
1088
|
-
meta: {
|
|
1089
|
-
type: 'problem',
|
|
1090
|
-
docs: { description: 'Require role="treeitem" children inside role="tree"' },
|
|
1091
|
-
messages: {
|
|
1092
|
-
missingTreeitem:
|
|
1093
|
-
'role="tree" must contain elements with role="treeitem". Without treeitems the tree is structurally broken and non-functional for AT. (ARIA 1.2 §5.3.25 / APG: Tree View Pattern)',
|
|
1094
|
-
},
|
|
1095
|
-
schema: [],
|
|
1096
|
-
},
|
|
1097
|
-
create(context) {
|
|
1098
|
-
return {
|
|
1099
|
-
[h.elementWithChildrenVisitor](node) {
|
|
1100
|
-
const opening = h.getOpeningElement(node)
|
|
1101
|
-
if (h.getRoleValue(opening) !== 'tree') return
|
|
1102
|
-
const hasTreeitem = h.getChildOpeningElementsFromWrapper(node).some(
|
|
1103
|
-
child => h.getRoleValue(child) === 'treeitem' || h.getRoleValue(child) === 'group'
|
|
1104
|
-
)
|
|
1105
|
-
if (!hasTreeitem)
|
|
1106
|
-
context.report({ node: opening, messageId: 'missingTreeitem' })
|
|
1107
|
-
},
|
|
1108
|
-
}
|
|
1109
|
-
},
|
|
1110
|
-
}
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
// ─── no-feed-without-article ──────────────────────────────────────────────────
|
|
1114
|
-
// APG: "Each unit of content in a feed is contained in an element with role article."
|
|
1115
|
-
// A feed with no article children violates the required owned elements contract.
|
|
1116
|
-
// Ref: ARIA 1.2 feed role; APG Feed Pattern
|
|
1117
|
-
|
|
1118
|
-
export function makeNoFeedWithoutArticle(h) {
|
|
1119
|
-
return {
|
|
1120
|
-
meta: {
|
|
1121
|
-
type: 'problem',
|
|
1122
|
-
docs: { description: 'Require role="article" children inside role="feed"' },
|
|
1123
|
-
messages: {
|
|
1124
|
-
missingArticle:
|
|
1125
|
-
'role="feed" must contain elements with role="article". The APG requires all feed content to be in article elements so AT can navigate between items. (ARIA 1.2 / APG: Feed Pattern)',
|
|
1126
|
-
},
|
|
1127
|
-
schema: [],
|
|
1128
|
-
},
|
|
1129
|
-
create(context) {
|
|
1130
|
-
return {
|
|
1131
|
-
[h.elementWithChildrenVisitor](node) {
|
|
1132
|
-
const opening = h.getOpeningElement(node)
|
|
1133
|
-
if (h.getRoleValue(opening) !== 'feed') return
|
|
1134
|
-
const hasArticle = h.getChildOpeningElementsFromWrapper(node).some(
|
|
1135
|
-
child => h.getRoleValue(child) === 'article' || h.getElementName(child) === 'article'
|
|
1136
|
-
)
|
|
1137
|
-
if (!hasArticle)
|
|
1138
|
-
context.report({ node: opening, messageId: 'missingArticle' })
|
|
1139
|
-
},
|
|
1140
|
-
}
|
|
1141
|
-
},
|
|
1142
|
-
}
|
|
1143
|
-
}
|
|
1144
|
-
|
|
1145
|
-
// ─── no-aria-activedescendant-without-id ─────────────────────────────────────
|
|
1146
|
-
// ARIA 1.2: aria-activedescendant must reference a valid ID.
|
|
1147
|
-
// At lint time we can verify the value is a non-empty static string ID
|
|
1148
|
-
// (not empty, not a dynamic expression we can't resolve).
|
|
1149
|
-
// Ref: ARIA 1.2 §6.6.3
|
|
1150
|
-
|
|
1151
|
-
export function makeNoAriaActivedescendantWithoutId(h) {
|
|
1152
|
-
return {
|
|
1153
|
-
meta: {
|
|
1154
|
-
type: 'problem',
|
|
1155
|
-
docs: { description: 'Require aria-activedescendant to have a non-empty static ID value' },
|
|
1156
|
-
messages: {
|
|
1157
|
-
emptyId:
|
|
1158
|
-
'aria-activedescendant must reference a non-empty element ID. An empty or missing value means no descendant is active, which confuses AT. (ARIA 1.2 §6.6.3)',
|
|
1159
|
-
dynamicOnly:
|
|
1160
|
-
'aria-activedescendant value cannot be verified statically
|
|
1161
|
-
},
|
|
1162
|
-
schema: [],
|
|
1163
|
-
},
|
|
1164
|
-
create(context) {
|
|
1165
|
-
return {
|
|
1166
|
-
[h.elementVisitor](node) {
|
|
1167
|
-
const attr = h.getAttr(node, 'aria-activedescendant')
|
|
1168
|
-
if (!attr) return
|
|
1169
|
-
const val = h.getAttrStringValue(attr)
|
|
1170
|
-
if (val === null) {
|
|
1171
|
-
// Dynamic value
|
|
1172
|
-
context.report({ node: attr, messageId: 'dynamicOnly' })
|
|
1173
|
-
return
|
|
1174
|
-
}
|
|
1175
|
-
if (val.trim() === '')
|
|
1176
|
-
context.report({ node: attr, messageId: 'emptyId' })
|
|
1177
|
-
},
|
|
1178
|
-
}
|
|
1179
|
-
},
|
|
1180
|
-
}
|
|
1181
|
-
}
|
|
1182
|
-
|
|
1183
|
-
// ─── no-dialog-without-close ─────────────────────────────────────────────────
|
|
1184
|
-
// APG: "It is strongly recommended that the tab sequence of all dialogs include
|
|
1185
|
-
// a visible element with role button that closes the dialog."
|
|
1186
|
-
// We can only detect a close button statically by looking for a button with
|
|
1187
|
-
// a close-like aria-label or text content. Warn rather than error
|
|
1188
|
-
// alone satisfies the keyboard requirement even without a visible close button.
|
|
1189
|
-
// Ref: APG Dialog (Modal) Pattern; WCAG 2.1.2
|
|
1190
|
-
|
|
1191
|
-
export function makeNoDialogWithoutClose(h) {
|
|
1192
|
-
const CLOSE_PATTERN = /\b(close|dismiss|cancel|✕|×|x)\b/i
|
|
1193
|
-
|
|
1194
|
-
return {
|
|
1195
|
-
meta: {
|
|
1196
|
-
type: 'suggestion',
|
|
1197
|
-
docs: { description: 'Warn when role="dialog" has no detectable close button' },
|
|
1198
|
-
messages: {
|
|
1199
|
-
missingClose:
|
|
1200
|
-
'role="dialog" has no detectable close button. The APG strongly recommends a visible close button inside every dialog. Escape key alone is insufficient for pointer-only users. (APG: Dialog Pattern / WCAG 2.1.2)',
|
|
1201
|
-
},
|
|
1202
|
-
schema: [],
|
|
1203
|
-
},
|
|
1204
|
-
create(context) {
|
|
1205
|
-
return {
|
|
1206
|
-
[h.elementWithChildrenVisitor](node) {
|
|
1207
|
-
const opening = h.getOpeningElement(node)
|
|
1208
|
-
if (h.getRoleValue(opening) !== 'dialog') return
|
|
1209
|
-
const hasClose = h.getChildOpeningElementsFromWrapper(node).some(child => {
|
|
1210
|
-
const el = h.getElementName(child)
|
|
1211
|
-
const role = h.getRoleValue(child)
|
|
1212
|
-
if (el !== 'button' && role !== 'button') return false
|
|
1213
|
-
const label = h.getAttrStringValue(h.getAttr(child, 'aria-label')) ?? ''
|
|
1214
|
-
return CLOSE_PATTERN.test(label)
|
|
1215
|
-
})
|
|
1216
|
-
if (!hasClose)
|
|
1217
|
-
context.report({ node: opening, messageId: 'missingClose' })
|
|
1218
|
-
},
|
|
1219
|
-
}
|
|
1220
|
-
},
|
|
1221
|
-
}
|
|
1222
|
-
}
|
|
1223
|
-
|
|
1224
|
-
// ═══ Rules that fill jsx-a11y gaps for Vue/Angular consumers ═════════════════
|
|
1225
|
-
// These are NOT included in the JSX config
|
|
1226
|
-
// They are included in neighbor-eslint-vue.mjs and neighbor-eslint-angular.mjs.
|
|
1227
|
-
|
|
1228
|
-
// ─── no-anchor-ambiguous-text ────────────────────────────────────────────────
|
|
1229
|
-
// Links with generic text like "click here", "read more" are meaningless out of
|
|
1230
|
-
// context for AT users navigating by links. WCAG 2.4.4 Link Purpose (In Context).
|
|
1231
|
-
// Ref: WCAG 2.4.4; WebAIM: Links and Hypertext
|
|
1232
|
-
|
|
1233
|
-
const AMBIGUOUS_LINK_TEXT = new Set([
|
|
1234
|
-
'click here', 'here', 'read more', 'more', 'learn more', 'this',
|
|
1235
|
-
'link', 'button', 'details', 'info', 'information', 'click', 'tap',
|
|
1236
|
-
])
|
|
1237
|
-
|
|
1238
|
-
export function makeNoAnchorAmbiguousText(h) {
|
|
1239
|
-
return {
|
|
1240
|
-
meta: {
|
|
1241
|
-
type: 'suggestion',
|
|
1242
|
-
docs: { description: 'Disallow ambiguous link text like "click here" or "read more"' },
|
|
1243
|
-
messages: {
|
|
1244
|
-
ambiguous:
|
|
1245
|
-
'Link text "{{text}}" is ambiguous out of context. AT users navigating by links cannot determine the link destination. Use descriptive text or supplement with aria-label. (WCAG 2.4.4)',
|
|
1246
|
-
},
|
|
1247
|
-
schema: [],
|
|
1248
|
-
},
|
|
1249
|
-
create(context) {
|
|
1250
|
-
return {
|
|
1251
|
-
[h.elementVisitor](node) {
|
|
1252
|
-
if (h.getElementName(node) !== 'a') return
|
|
1253
|
-
// If there's an aria-label it overrides visible text
|
|
1254
|
-
if (h.hasAttr(node, 'aria-label') || h.hasAttr(node, 'aria-labelledby')) return
|
|
1255
|
-
// We can only check static className/text at lint time
|
|
1256
|
-
const role = h.getRoleValue(node)
|
|
1257
|
-
if (role && role !== 'link') return
|
|
1258
|
-
// Check aria-label value if present
|
|
1259
|
-
const label = (h.getAttrStringValue(h.getAttr(node, 'aria-label')) ?? '').trim().toLowerCase()
|
|
1260
|
-
if (label && AMBIGUOUS_LINK_TEXT.has(label))
|
|
1261
|
-
context.report({ node, messageId: 'ambiguous', data: { text: label } })
|
|
1262
|
-
},
|
|
1263
|
-
}
|
|
1264
|
-
},
|
|
1265
|
-
}
|
|
1266
|
-
}
|
|
1267
|
-
|
|
1268
|
-
// ─── no-anchor-no-content ─────────────────────────────────────────────────────
|
|
1269
|
-
// <a> with no children and no aria-label has no accessible name
|
|
1270
|
-
// Ref: WCAG 4.1.2 Name, Role, Value; WCAG 2.4.4
|
|
1271
|
-
|
|
1272
|
-
export function makeNoAnchorNoContent(h) {
|
|
1273
|
-
return {
|
|
1274
|
-
meta: {
|
|
1275
|
-
type: 'problem',
|
|
1276
|
-
docs: { description: 'Disallow <a> elements with no content and no accessible name' },
|
|
1277
|
-
messages: {
|
|
1278
|
-
noContent:
|
|
1279
|
-
'This <a> has no content and no aria-label
|
|
1280
|
-
},
|
|
1281
|
-
schema: [],
|
|
1282
|
-
},
|
|
1283
|
-
create(context) {
|
|
1284
|
-
return {
|
|
1285
|
-
[h.elementVisitor](node) {
|
|
1286
|
-
if (h.getElementName(node) !== 'a') return
|
|
1287
|
-
if (h.hasAccessibleName(node)) return
|
|
1288
|
-
// hasOnlyHiddenChildren returns false for truly empty elements too
|
|
1289
|
-
if (!h.hasOnlyHiddenChildren(node)) return
|
|
1290
|
-
context.report({ node, messageId: 'noContent' })
|
|
1291
|
-
},
|
|
1292
|
-
}
|
|
1293
|
-
},
|
|
1294
|
-
}
|
|
1295
|
-
}
|
|
1296
|
-
|
|
1297
|
-
// ─── no-aria-activedescendant-no-tabindex ────────────────────────────────────
|
|
1298
|
-
// Elements using aria-activedescendant to manage focus must themselves be
|
|
1299
|
-
// focusable (tabIndex >= 0) so AT can reach the composite widget.
|
|
1300
|
-
// Ref: ARIA 1.2 §6.6.3; APG Composite Widget Pattern
|
|
1301
|
-
|
|
1302
|
-
export function makeNoAriaActivedescendantNoTabindex(h) {
|
|
1303
|
-
return {
|
|
1304
|
-
meta: {
|
|
1305
|
-
type: 'problem',
|
|
1306
|
-
docs: { description: 'Require tabIndex on elements using aria-activedescendant' },
|
|
1307
|
-
messages: {
|
|
1308
|
-
missingTabindex:
|
|
1309
|
-
'Elements using aria-activedescendant must have tabIndex (0 or -1) so they can receive DOM focus and manage keyboard interaction. (ARIA 1.2 §6.6.3)',
|
|
1310
|
-
},
|
|
1311
|
-
schema: [],
|
|
1312
|
-
},
|
|
1313
|
-
create(context) {
|
|
1314
|
-
return {
|
|
1315
|
-
[h.elementVisitor](node) {
|
|
1316
|
-
if (!h.hasAttr(node, 'aria-activedescendant')) return
|
|
1317
|
-
if (h.hasAttr(node, 'tabIndex') || h.hasAttr(node, 'tabindex')) return
|
|
1318
|
-
// Native interactive elements are already focusable
|
|
1319
|
-
const el = h.getElementName(node)
|
|
1320
|
-
if (el && INTERACTIVE_ELEMENTS.has(el)) return
|
|
1321
|
-
context.report({ node: h.getAttr(node, 'aria-activedescendant'), messageId: 'missingTabindex' })
|
|
1322
|
-
},
|
|
1323
|
-
}
|
|
1324
|
-
},
|
|
1325
|
-
}
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
// ─── no-invalid-aria-prop-value ───────────────────────────────────────────────
|
|
1329
|
-
// ARIA attributes have defined value types. Boolean props must be "true"/"false",
|
|
1330
|
-
// tristate props "true"/"false"/"mixed", token props must use valid tokens.
|
|
1331
|
-
// Ref: ARIA 1.2 §6.6 State and Property Attribute Processing
|
|
1332
|
-
|
|
1333
|
-
const ARIA_BOOLEAN_PROPS = new Set([
|
|
1334
|
-
'aria-atomic', 'aria-busy', 'aria-disabled', 'aria-grabbed',
|
|
1335
|
-
'aria-hidden', 'aria-modal', 'aria-multiline', 'aria-multiselectable',
|
|
1336
|
-
'aria-pressed', 'aria-readonly', 'aria-required', 'aria-selected',
|
|
1337
|
-
])
|
|
1338
|
-
const ARIA_TRISTATE_PROPS = new Set(['aria-checked', 'aria-pressed'])
|
|
1339
|
-
const ARIA_TRISTATE_VALUES = new Set(['true', 'false', 'mixed'])
|
|
1340
|
-
const ARIA_BOOLEAN_VALUES = new Set(['true', 'false'])
|
|
1341
|
-
|
|
1342
|
-
const ARIA_TOKEN_PROPS = {
|
|
1343
|
-
'aria-autocomplete': new Set(['inline', 'list', 'both', 'none']),
|
|
1344
|
-
'aria-current': new Set(['page', 'step', 'location', 'date', 'time', 'true', 'false']),
|
|
1345
|
-
'aria-dropeffect': new Set(['copy', 'execute', 'link', 'move', 'none', 'popup']),
|
|
1346
|
-
'aria-haspopup': new Set(['false', 'true', 'menu', 'listbox', 'tree', 'grid', 'dialog']),
|
|
1347
|
-
'aria-invalid': new Set(['grammar', 'false', 'spelling', 'true']),
|
|
1348
|
-
'aria-live': new Set(['assertive', 'off', 'polite']),
|
|
1349
|
-
'aria-orientation': new Set(['horizontal', 'undefined', 'vertical']),
|
|
1350
|
-
'aria-relevant': new Set(['additions', 'all', 'removals', 'text', 'additions text']),
|
|
1351
|
-
'aria-sort': new Set(['ascending', 'descending', 'none', 'other']),
|
|
1352
|
-
}
|
|
1353
|
-
|
|
1354
|
-
export function makeNoInvalidAriaPropValue(h) {
|
|
1355
|
-
return {
|
|
1356
|
-
meta: {
|
|
1357
|
-
type: 'problem',
|
|
1358
|
-
docs: { description: 'Disallow invalid ARIA attribute values' },
|
|
1359
|
-
messages: {
|
|
1360
|
-
invalidBoolean:
|
|
1361
|
-
'"{{attr}}" must be "true" or "false", got "{{value}}". AT may misinterpret invalid values. (ARIA 1.2)',
|
|
1362
|
-
invalidTristate:
|
|
1363
|
-
'"{{attr}}" must be "true", "false", or "mixed", got "{{value}}". (ARIA 1.2)',
|
|
1364
|
-
invalidToken:
|
|
1365
|
-
'"{{attr}}" must be one of [{{valid}}], got "{{value}}". (ARIA 1.2)',
|
|
1366
|
-
},
|
|
1367
|
-
schema: [],
|
|
1368
|
-
},
|
|
1369
|
-
create(context) {
|
|
1370
|
-
return {
|
|
1371
|
-
[h.elementVisitor](node) {
|
|
1372
|
-
for (const [prop, validValues] of Object.entries(ARIA_TOKEN_PROPS)) {
|
|
1373
|
-
const attr = h.getAttr(node, prop)
|
|
1374
|
-
if (!attr) continue
|
|
1375
|
-
const val = h.getAttrStringValue(attr)
|
|
1376
|
-
if (val === null) continue
|
|
1377
|
-
if (!validValues.has(val.toLowerCase()))
|
|
1378
|
-
context.report({ node: attr, messageId: 'invalidToken', data: { attr: prop, value: val, valid: [...validValues].join(', ') } })
|
|
1379
|
-
}
|
|
1380
|
-
for (const prop of ARIA_TRISTATE_PROPS) {
|
|
1381
|
-
const attr = h.getAttr(node, prop)
|
|
1382
|
-
if (!attr) continue
|
|
1383
|
-
const val = h.getAttrStringValue(attr)
|
|
1384
|
-
if (val === null) continue
|
|
1385
|
-
if (!ARIA_TRISTATE_VALUES.has(val.toLowerCase()))
|
|
1386
|
-
context.report({ node: attr, messageId: 'invalidTristate', data: { attr: prop, value: val } })
|
|
1387
|
-
}
|
|
1388
|
-
for (const prop of ARIA_BOOLEAN_PROPS) {
|
|
1389
|
-
if (ARIA_TRISTATE_PROPS.has(prop)) continue
|
|
1390
|
-
const attr = h.getAttr(node, prop)
|
|
1391
|
-
if (!attr) continue
|
|
1392
|
-
const val = h.getAttrStringValue(attr)
|
|
1393
|
-
if (val === null) continue
|
|
1394
|
-
if (!ARIA_BOOLEAN_VALUES.has(val.toLowerCase()))
|
|
1395
|
-
context.report({ node: attr, messageId: 'invalidBoolean', data: { attr: prop, value: val } })
|
|
1396
|
-
}
|
|
1397
|
-
},
|
|
1398
|
-
}
|
|
1399
|
-
},
|
|
1400
|
-
}
|
|
1401
|
-
}
|
|
1402
|
-
|
|
1403
|
-
// ─── no-autocomplete-invalid ──────────────────────────────────────────────────
|
|
1404
|
-
// autocomplete must use valid HTML spec token values. Invalid values are ignored
|
|
1405
|
-
// by browsers, breaking autofill for AT users. WCAG 1.3.5 Identify Input Purpose.
|
|
1406
|
-
// Ref: WCAG 1.3.5; HTML Living Standard autocomplete attribute
|
|
1407
|
-
|
|
1408
|
-
const VALID_AUTOCOMPLETE_TOKENS = new Set([
|
|
1409
|
-
'off', 'on', 'name', 'honorific-prefix', 'given-name', 'additional-name',
|
|
1410
|
-
'family-name', 'honorific-suffix', 'nickname', 'email', 'username',
|
|
1411
|
-
'new-password', 'current-password', 'one-time-code', 'organization-title',
|
|
1412
|
-
'organization', 'street-address', 'address-line1', 'address-line2',
|
|
1413
|
-
'address-line3', 'address-level4', 'address-level3', 'address-level2',
|
|
1414
|
-
'address-level1', 'country', 'country-name', 'postal-code',
|
|
1415
|
-
'cc-name', 'cc-given-name', 'cc-additional-name', 'cc-family-name',
|
|
1416
|
-
'cc-number', 'cc-exp', 'cc-exp-month', 'cc-exp-year', 'cc-csc', 'cc-type',
|
|
1417
|
-
'transaction-currency', 'transaction-amount', 'language', 'bday',
|
|
1418
|
-
'bday-day', 'bday-month', 'bday-year', 'sex', 'tel', 'tel-country-code',
|
|
1419
|
-
'tel-national', 'tel-area-code', 'tel-local', 'tel-extension',
|
|
1420
|
-
'impp', 'url', 'photo', 'webauthn',
|
|
1421
|
-
])
|
|
1422
|
-
|
|
1423
|
-
export function makeNoAutocompleteInvalid(h) {
|
|
1424
|
-
return {
|
|
1425
|
-
meta: {
|
|
1426
|
-
type: 'problem',
|
|
1427
|
-
docs: { description: 'Require valid autocomplete attribute values' },
|
|
1428
|
-
messages: {
|
|
1429
|
-
invalid:
|
|
1430
|
-
'"{{value}}" is not a valid autocomplete token. Invalid values are ignored by browsers, breaking autofill for AT users. (WCAG 1.3.5 / HTML spec)',
|
|
1431
|
-
},
|
|
1432
|
-
schema: [],
|
|
1433
|
-
},
|
|
1434
|
-
create(context) {
|
|
1435
|
-
return {
|
|
1436
|
-
[h.elementVisitor](node) {
|
|
1437
|
-
const el = h.getElementName(node)
|
|
1438
|
-
if (el !== 'input' && el !== 'select' && el !== 'textarea') return
|
|
1439
|
-
const attr = h.getAttr(node, 'autocomplete')
|
|
1440
|
-
if (!attr) return
|
|
1441
|
-
const val = h.getAttrStringValue(attr)
|
|
1442
|
-
if (val === null) return
|
|
1443
|
-
const tokens = val.trim().toLowerCase().split(/\s+/)
|
|
1444
|
-
for (const token of tokens) {
|
|
1445
|
-
if (!VALID_AUTOCOMPLETE_TOKENS.has(token))
|
|
1446
|
-
context.report({ node: attr, messageId: 'invalid', data: { value: token } })
|
|
1447
|
-
}
|
|
1448
|
-
},
|
|
1449
|
-
}
|
|
1450
|
-
},
|
|
1451
|
-
}
|
|
1452
|
-
}
|
|
1453
|
-
|
|
1454
|
-
// ─── no-heading-no-content ────────────────────────────────────────────────────
|
|
1455
|
-
// Headings with no text content are meaningless to AT
|
|
1456
|
-
// heading tree but convey nothing. WCAG 2.4.6 Headings and Labels.
|
|
1457
|
-
// Ref: WCAG 2.4.6; WebAIM: Headings
|
|
1458
|
-
|
|
1459
|
-
export function makeNoHeadingNoContent(h) {
|
|
1460
|
-
return {
|
|
1461
|
-
meta: {
|
|
1462
|
-
type: 'problem',
|
|
1463
|
-
docs: { description: 'Disallow heading elements with no content' },
|
|
1464
|
-
messages: {
|
|
1465
|
-
noContent:
|
|
1466
|
-
'<{{el}}> has no content
|
|
1467
|
-
},
|
|
1468
|
-
schema: [],
|
|
1469
|
-
},
|
|
1470
|
-
create(context) {
|
|
1471
|
-
return {
|
|
1472
|
-
[h.elementVisitor](node) {
|
|
1473
|
-
const el = h.getElementName(node)
|
|
1474
|
-
if (!el || !HEADING_ELEMENTS.has(el)) return
|
|
1475
|
-
if (h.hasAccessibleName(node)) return
|
|
1476
|
-
if (h.hasOnlyHiddenChildren(node))
|
|
1477
|
-
context.report({ node, messageId: 'noContent', data: { el } })
|
|
1478
|
-
},
|
|
1479
|
-
}
|
|
1480
|
-
},
|
|
1481
|
-
}
|
|
1482
|
-
}
|
|
1483
|
-
|
|
1484
|
-
// ─── no-iframe-no-title ───────────────────────────────────────────────────────
|
|
1485
|
-
// <iframe> without a title has no accessible name
|
|
1486
|
-
// the purpose of the embedded content. WCAG 4.1.2 Name, Role, Value.
|
|
1487
|
-
// Ref: WCAG 4.1.2; HTML spec
|
|
1488
|
-
|
|
1489
|
-
export function makeNoIframeNoTitle(h) {
|
|
1490
|
-
return {
|
|
1491
|
-
meta: {
|
|
1492
|
-
type: 'problem',
|
|
1493
|
-
docs: { description: 'Require title attribute on <iframe> elements' },
|
|
1494
|
-
messages: {
|
|
1495
|
-
missingTitle:
|
|
1496
|
-
'<iframe> must have a title attribute describing its content. Without it AT users cannot identify the embedded content. (WCAG 4.1.2)',
|
|
1497
|
-
},
|
|
1498
|
-
schema: [],
|
|
1499
|
-
},
|
|
1500
|
-
create(context) {
|
|
1501
|
-
return {
|
|
1502
|
-
[h.elementVisitor](node) {
|
|
1503
|
-
if (h.getElementName(node) !== 'iframe') return
|
|
1504
|
-
const title = h.getAttrStringValue(h.getAttr(node, 'title'))
|
|
1505
|
-
if (!title || title.trim() === '')
|
|
1506
|
-
context.report({ node, messageId: 'missingTitle' })
|
|
1507
|
-
},
|
|
1508
|
-
}
|
|
1509
|
-
},
|
|
1510
|
-
}
|
|
1511
|
-
}
|
|
1512
|
-
|
|
1513
|
-
// ─── no-img-redundant-alt ─────────────────────────────────────────────────────
|
|
1514
|
-
// Alt text saying "image of", "photo of", "picture of" is redundant
|
|
1515
|
-
// announces the element is an image. WCAG 1.1.1 Non-text Content.
|
|
1516
|
-
// Ref: WCAG 1.1.1; WebAIM: Alternative Text
|
|
1517
|
-
|
|
1518
|
-
const REDUNDANT_ALT_PATTERN = /\b(image|photo|photograph|picture|graphic|icon|thumbnail)\b/i
|
|
1519
|
-
|
|
1520
|
-
export function makeNoImgRedundantAlt(h) {
|
|
1521
|
-
return {
|
|
1522
|
-
meta: {
|
|
1523
|
-
type: 'suggestion',
|
|
1524
|
-
docs: { description: 'Disallow redundant words like "image" or "photo" in alt text' },
|
|
1525
|
-
messages: {
|
|
1526
|
-
redundant:
|
|
1527
|
-
'Alt text "{{alt}}" contains a redundant word
|
|
1528
|
-
},
|
|
1529
|
-
schema: [],
|
|
1530
|
-
},
|
|
1531
|
-
create(context) {
|
|
1532
|
-
return {
|
|
1533
|
-
[h.elementVisitor](node) {
|
|
1534
|
-
if (h.getElementName(node) !== 'img') return
|
|
1535
|
-
const alt = h.getAttrStringValue(h.getAttr(node, 'alt'))
|
|
1536
|
-
if (!alt) return
|
|
1537
|
-
const match = alt.match(REDUNDANT_ALT_PATTERN)
|
|
1538
|
-
if (match)
|
|
1539
|
-
context.report({ node: h.getAttr(node, 'alt'), messageId: 'redundant', data: { alt, word: match[0] } })
|
|
1540
|
-
},
|
|
1541
|
-
}
|
|
1542
|
-
},
|
|
1543
|
-
}
|
|
1544
|
-
}
|
|
1545
|
-
|
|
1546
|
-
// ─── no-access-key ────────────────────────────────────────────────────────────
|
|
1547
|
-
// accessKey creates keyboard shortcuts that conflict with browser and AT shortcuts.
|
|
1548
|
-
// No WCAG SC directly bans it but it causes 2.1.4 Character Key Shortcuts failures
|
|
1549
|
-
// and is universally discouraged. Ref: WCAG 2.1.4; WebAIM
|
|
1550
|
-
|
|
1551
|
-
export function makeNoAccessKey(h) {
|
|
1552
|
-
return {
|
|
1553
|
-
meta: {
|
|
1554
|
-
type: 'suggestion',
|
|
1555
|
-
docs: { description: 'Disallow accessKey attribute' },
|
|
1556
|
-
messages: {
|
|
1557
|
-
accessKey:
|
|
1558
|
-
'accessKey creates keyboard shortcuts that conflict with browser and AT shortcuts, breaking keyboard navigation for many users. Remove it. (WCAG 2.1.4)',
|
|
1559
|
-
},
|
|
1560
|
-
schema: [],
|
|
1561
|
-
},
|
|
1562
|
-
create(context) {
|
|
1563
|
-
return {
|
|
1564
|
-
[h.elementVisitor](node) {
|
|
1565
|
-
const attr = h.getAttr(node, 'accessKey') ?? h.getAttr(node, 'accesskey')
|
|
1566
|
-
if (attr) context.report({ node: attr, messageId: 'accessKey' })
|
|
1567
|
-
},
|
|
1568
|
-
}
|
|
1569
|
-
},
|
|
1570
|
-
}
|
|
1571
|
-
}
|
|
1572
|
-
|
|
1573
|
-
// ─── no-noninteractive-to-interactive-role ────────────────────────────────────
|
|
1574
|
-
// Adding interactive roles to non-interactive elements (li, div, span, p, etc.)
|
|
1575
|
-
// without the required keyboard handlers is a WCAG 4.1.2 failure.
|
|
1576
|
-
// Ref: WCAG 4.1.2; ARIA 1.2
|
|
1577
|
-
|
|
1578
|
-
const NON_INTERACTIVE_ELEMENTS = new Set([
|
|
1579
|
-
'li', 'ul', 'ol', 'dl', 'dt', 'dd', 'table', 'tr', 'td', 'th',
|
|
1580
|
-
'thead', 'tbody', 'tfoot', 'caption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
1581
|
-
'article', 'aside', 'footer', 'header', 'main', 'nav', 'section',
|
|
1582
|
-
'blockquote', 'figure', 'figcaption', 'address', 'p', 'pre',
|
|
1583
|
-
])
|
|
1584
|
-
|
|
1585
|
-
export function makeNoNoninteractiveToInteractiveRole(h) {
|
|
1586
|
-
return {
|
|
1587
|
-
meta: {
|
|
1588
|
-
type: 'problem',
|
|
1589
|
-
docs: { description: 'Disallow interactive roles on non-interactive elements without keyboard handlers' },
|
|
1590
|
-
messages: {
|
|
1591
|
-
missingHandlers:
|
|
1592
|
-
'<{{el}} role="{{role}}"> makes a non-interactive element interactive but has no keyboard handler. Add onKeyDown/onKeyPress or use a native interactive element instead. (WCAG 4.1.2 / 2.1.1)',
|
|
1593
|
-
},
|
|
1594
|
-
schema: [],
|
|
1595
|
-
},
|
|
1596
|
-
create(context) {
|
|
1597
|
-
return {
|
|
1598
|
-
[h.elementVisitor](node) {
|
|
1599
|
-
const el = h.getElementName(node)
|
|
1600
|
-
if (!el || !NON_INTERACTIVE_ELEMENTS.has(el)) return
|
|
1601
|
-
const role = h.getRoleValue(node)
|
|
1602
|
-
if (!role || !INTERACTIVE_ROLES.has(role)) return
|
|
1603
|
-
const hasKeyHandler =
|
|
1604
|
-
h.hasAttr(node, 'onKeyDown') || h.hasAttr(node, 'onKeyPress') ||
|
|
1605
|
-
h.hasAttr(node, 'onKeyUp') || h.hasAttr(node, 'tabIndex') ||
|
|
1606
|
-
h.hasAttr(node, 'tabindex') || h.hasAttr(node, '@keydown') ||
|
|
1607
|
-
h.hasAttr(node, '(keydown)') || h.hasAttr(node, '@keyup') ||
|
|
1608
|
-
h.hasAttr(node, '(keyup)')
|
|
1609
|
-
if (!hasKeyHandler)
|
|
1610
|
-
context.report({ node: h.getAttr(node, 'role'), messageId: 'missingHandlers', data: { el, role } })
|
|
1611
|
-
},
|
|
1612
|
-
}
|
|
1613
|
-
},
|
|
1614
|
-
}
|
|
1615
|
-
}
|
|
1616
|
-
|
|
1617
|
-
// ─── no-noninteractive-tabindex ───────────────────────────────────────────────
|
|
1618
|
-
// tabIndex on non-interactive elements without a role puts them in the tab order
|
|
1619
|
-
// but AT users have no semantic context for what they are. WCAG 4.1.2.
|
|
1620
|
-
// Ref: WCAG 4.1.2; Roselli: Stop Giving Control Hints to Non-Interactive Elements
|
|
1621
|
-
|
|
1622
|
-
export function makeNoNoninteractiveTabindex(h) {
|
|
1623
|
-
return {
|
|
1624
|
-
meta: {
|
|
1625
|
-
type: 'problem',
|
|
1626
|
-
docs: { description: 'Disallow tabIndex >= 0 on non-interactive elements without a role' },
|
|
1627
|
-
messages: {
|
|
1628
|
-
noninteractiveTabindex:
|
|
1629
|
-
'tabIndex on <{{el}}> puts it in the tab order but it has no interactive role
|
|
1630
|
-
},
|
|
1631
|
-
schema: [],
|
|
1632
|
-
},
|
|
1633
|
-
create(context) {
|
|
1634
|
-
return {
|
|
1635
|
-
[h.elementVisitor](node) {
|
|
1636
|
-
const el = h.getElementName(node)
|
|
1637
|
-
if (!el || !NON_INTERACTIVE_ELEMENTS.has(el)) return
|
|
1638
|
-
if (h.getRoleValue(node)) return
|
|
1639
|
-
const attr = h.getAttr(node, 'tabIndex') ?? h.getAttr(node, 'tabindex')
|
|
1640
|
-
if (!attr) return
|
|
1641
|
-
const val = h.getAttrStringValue(attr)
|
|
1642
|
-
if (val === null) return
|
|
1643
|
-
const num = Number(val)
|
|
1644
|
-
if (!isNaN(num) && num >= 0)
|
|
1645
|
-
context.report({ node: attr, messageId: 'noninteractiveTabindex', data: { el } })
|
|
1646
|
-
},
|
|
1647
|
-
}
|
|
1648
|
-
},
|
|
1649
|
-
}
|
|
1650
|
-
}
|
|
1651
|
-
|
|
1652
|
-
// ─── prefer-semantic-element ──────────────────────────────────────────────────
|
|
1653
|
-
// When a native HTML element exists for a role, prefer it over role=.
|
|
1654
|
-
// Native elements have built-in keyboard handling and better AT support.
|
|
1655
|
-
// Ref: WCAG 4.1.2; ARIA in HTML spec "first rule of ARIA"
|
|
1656
|
-
|
|
1657
|
-
const ROLE_TO_ELEMENT = {
|
|
1658
|
-
button: 'button',
|
|
1659
|
-
link: 'a',
|
|
1660
|
-
heading: 'h1–h6',
|
|
1661
|
-
checkbox: 'input[type=checkbox]',
|
|
1662
|
-
radio: 'input[type=radio]',
|
|
1663
|
-
textbox: 'input or textarea',
|
|
1664
|
-
searchbox: 'input[type=search]',
|
|
1665
|
-
spinbutton: 'input[type=number]',
|
|
1666
|
-
slider: 'input[type=range]',
|
|
1667
|
-
img: 'img',
|
|
1668
|
-
list: 'ul or ol',
|
|
1669
|
-
listitem: 'li',
|
|
1670
|
-
table: 'table',
|
|
1671
|
-
row: 'tr',
|
|
1672
|
-
cell: 'td',
|
|
1673
|
-
columnheader:'th',
|
|
1674
|
-
rowheader: 'th',
|
|
1675
|
-
form: 'form',
|
|
1676
|
-
navigation: 'nav',
|
|
1677
|
-
main: 'main',
|
|
1678
|
-
banner: 'header',
|
|
1679
|
-
contentinfo: 'footer',
|
|
1680
|
-
complementary: 'aside',
|
|
1681
|
-
region: 'section',
|
|
1682
|
-
article: 'article',
|
|
1683
|
-
separator: 'hr',
|
|
1684
|
-
}
|
|
1685
|
-
|
|
1686
|
-
export function makePreferSemanticElement(h) {
|
|
1687
|
-
return {
|
|
1688
|
-
meta: {
|
|
1689
|
-
type: 'suggestion',
|
|
1690
|
-
docs: { description: 'Prefer native HTML elements over ARIA role equivalents' },
|
|
1691
|
-
messages: {
|
|
1692
|
-
preferNative:
|
|
1693
|
-
'Use <{{element}}> instead of role="{{role}}"
|
|
1694
|
-
},
|
|
1695
|
-
schema: [],
|
|
1696
|
-
},
|
|
1697
|
-
create(context) {
|
|
1698
|
-
return {
|
|
1699
|
-
[h.elementVisitor](node) {
|
|
1700
|
-
const el = h.getElementName(node)
|
|
1701
|
-
const role = h.getRoleValue(node)
|
|
1702
|
-
if (!role) return
|
|
1703
|
-
const native = ROLE_TO_ELEMENT[role]
|
|
1704
|
-
if (!native) return
|
|
1705
|
-
// Don't flag if they're already using a semantic element with a redundant role
|
|
1706
|
-
if (el && el === role) return
|
|
1707
|
-
context.report({ node: h.getAttr(node, 'role'), messageId: 'preferNative', data: { role, element: native } })
|
|
1708
|
-
},
|
|
1709
|
-
}
|
|
1710
|
-
},
|
|
1711
|
-
}
|
|
1712
|
-
}
|
|
1713
|
-
|
|
1714
|
-
// ─── no-role-supports-aria-props ─────────────────────────────────────────────
|
|
1715
|
-
// ARIA attributes must be valid for the element's role. Using unsupported
|
|
1716
|
-
// properties on a role is ignored or misread by AT. WCAG 4.1.2.
|
|
1717
|
-
// Only catches the most common mismatches statically.
|
|
1718
|
-
// Ref: ARIA 1.2 §6.6; ARIA in HTML
|
|
1719
|
-
|
|
1720
|
-
const ROLE_FORBIDDEN_PROPS = {
|
|
1721
|
-
// presentation/none
|
|
1722
|
-
presentation: new Set(['aria-label', 'aria-labelledby', 'aria-describedby', 'aria-hidden']),
|
|
1723
|
-
none: new Set(['aria-label', 'aria-labelledby', 'aria-describedby', 'aria-hidden']),
|
|
1724
|
-
// separator (non-focusable)
|
|
1725
|
-
separator: new Set(['aria-checked', 'aria-selected', 'aria-expanded']),
|
|
1726
|
-
}
|
|
1727
|
-
|
|
1728
|
-
export function makeNoRoleSupportsAriaProps(h) {
|
|
1729
|
-
return {
|
|
1730
|
-
meta: {
|
|
1731
|
-
type: 'problem',
|
|
1732
|
-
docs: { description: 'Disallow ARIA attributes that are not supported by the element\'s role' },
|
|
1733
|
-
messages: {
|
|
1734
|
-
unsupported:
|
|
1735
|
-
'"{{attr}}" is not supported on role="{{role}}" and will be ignored or misread by AT. Remove it or change the role. (ARIA 1.2 / WCAG 4.1.2)',
|
|
1736
|
-
},
|
|
1737
|
-
schema: [],
|
|
1738
|
-
},
|
|
1739
|
-
create(context) {
|
|
1740
|
-
return {
|
|
1741
|
-
[h.elementVisitor](node) {
|
|
1742
|
-
const role = h.getRoleValue(node)
|
|
1743
|
-
if (!role) return
|
|
1744
|
-
const forbidden = ROLE_FORBIDDEN_PROPS[role]
|
|
1745
|
-
if (!forbidden) return
|
|
1746
|
-
for (const prop of forbidden) {
|
|
1747
|
-
const attr = h.getAttr(node, prop)
|
|
1748
|
-
if (attr)
|
|
1749
|
-
context.report({ node: attr, messageId: 'unsupported', data: { attr: prop, role } })
|
|
1750
|
-
}
|
|
1751
|
-
},
|
|
1752
|
-
}
|
|
1753
|
-
},
|
|
1754
|
-
}
|
|
1755
|
-
}
|
|
1756
|
-
|
|
1757
|
-
// ─── no-scope-on-td ───────────────────────────────────────────────────────────
|
|
1758
|
-
// scope attribute is only valid on <th>, not <td>. Using it on <td> is invalid
|
|
1759
|
-
// HTML and ignored by browsers. WCAG 1.3.1 Info and Relationships.
|
|
1760
|
-
// Ref: WCAG 1.3.1; HTML spec
|
|
1761
|
-
|
|
1762
|
-
export function makeNoScopeOnTd(h) {
|
|
1763
|
-
return {
|
|
1764
|
-
meta: {
|
|
1765
|
-
type: 'problem',
|
|
1766
|
-
docs: { description: 'Disallow scope attribute on <td>
|
|
1767
|
-
messages: {
|
|
1768
|
-
invalidScope:
|
|
1769
|
-
'scope is only valid on <th>, not <td>. Using it on <td> is invalid HTML and is ignored by browsers and AT. (WCAG 1.3.1 / HTML spec)',
|
|
1770
|
-
},
|
|
1771
|
-
schema: [],
|
|
1772
|
-
},
|
|
1773
|
-
create(context) {
|
|
1774
|
-
return {
|
|
1775
|
-
[h.elementVisitor](node) {
|
|
1776
|
-
if (h.getElementName(node) !== 'td') return
|
|
1777
|
-
const attr = h.getAttr(node, 'scope')
|
|
1778
|
-
if (attr) context.report({ node: attr, messageId: 'invalidScope' })
|
|
1779
|
-
},
|
|
1780
|
-
}
|
|
1781
|
-
},
|
|
1782
|
-
}
|
|
1783
|
-
}
|
|
1784
|
-
|
|
1785
|
-
// ─── no-duplicate-id ──────────────────────────────────────────────────────────
|
|
1786
|
-
// Duplicate IDs break aria-labelledby, aria-describedby, aria-controls,
|
|
1787
|
-
// aria-owns, aria-activedescendant, and htmlFor
|
|
1788
|
-
// WCAG 4.1.1 was removed in WCAG 2.2; failures now map to SC 1.3.1 / 4.1.2.
|
|
1789
|
-
// We only flag duplicates that are actually referenced by an ARIA relation,
|
|
1790
|
-
// to avoid noise from IDs used purely for styling or scripting.
|
|
1791
|
-
// Ref: axe-core duplicate-id-aria (MPL-2.0, reimplemented); SC 1.3.1 / 4.1.2
|
|
1792
|
-
|
|
1793
|
-
const ARIA_ID_ATTRS = ['aria-labelledby', 'aria-describedby', 'aria-controls', 'aria-owns', 'aria-activedescendant']
|
|
1794
|
-
|
|
1795
|
-
export function makeNoDuplicateId(h) {
|
|
1796
|
-
return {
|
|
1797
|
-
meta: {
|
|
1798
|
-
type: 'problem',
|
|
1799
|
-
docs: { description: 'Disallow duplicate id values on elements referenced by ARIA attributes' },
|
|
1800
|
-
messages: {
|
|
1801
|
-
duplicate:
|
|
1802
|
-
'id="{{id}}" appears more than once. AT resolves aria-labelledby/describedby/controls/owns/activedescendant by first match
|
|
1803
|
-
},
|
|
1804
|
-
schema: [],
|
|
1805
|
-
},
|
|
1806
|
-
create(context) {
|
|
1807
|
-
const idNodes = new Map() // id string → first node with that id
|
|
1808
|
-
const idDups = new Map() // id string → subsequent nodes (to report)
|
|
1809
|
-
const ariaRefs = new Set() // all id values referenced by ARIA attrs or htmlFor
|
|
1810
|
-
|
|
1811
|
-
return {
|
|
1812
|
-
[h.elementVisitor](node) {
|
|
1813
|
-
const id = h.getAttrStringValue(h.getAttr(node, 'id'))
|
|
1814
|
-
if (id) {
|
|
1815
|
-
if (!idNodes.has(id)) {
|
|
1816
|
-
idNodes.set(id, node)
|
|
1817
|
-
} else {
|
|
1818
|
-
if (!idDups.has(id)) idDups.set(id, [])
|
|
1819
|
-
idDups.get(id).push(node)
|
|
1820
|
-
}
|
|
1821
|
-
}
|
|
1822
|
-
|
|
1823
|
-
for (const attr of ARIA_ID_ATTRS) {
|
|
1824
|
-
const val = h.getAttrStringValue(h.getAttr(node, attr))
|
|
1825
|
-
if (val) val.trim().split(/\s+/).forEach(ref => ariaRefs.add(ref))
|
|
1826
|
-
}
|
|
1827
|
-
// htmlFor (JSX) and for (Vue/Angular)
|
|
1828
|
-
const forVal = h.getAttrStringValue(h.getAttr(node, 'htmlFor') ?? h.getAttr(node, 'for'))
|
|
1829
|
-
if (forVal) ariaRefs.add(forVal.trim())
|
|
1830
|
-
},
|
|
1831
|
-
|
|
1832
|
-
'Program:exit'() {
|
|
1833
|
-
for (const [id, nodes] of idDups) {
|
|
1834
|
-
if (!ariaRefs.has(id)) continue
|
|
1835
|
-
for (const node of nodes) {
|
|
1836
|
-
context.report({
|
|
1837
|
-
node: h.getAttr(node, 'id'),
|
|
1838
|
-
messageId: 'duplicate',
|
|
1839
|
-
data: { id },
|
|
1840
|
-
})
|
|
1841
|
-
}
|
|
1842
|
-
}
|
|
1843
|
-
},
|
|
1844
|
-
}
|
|
1845
|
-
},
|
|
1846
|
-
}
|
|
1847
|
-
}
|
|
1848
|
-
|
|
1849
|
-
// ─── no-button-type-missing ───────────────────────────────────────────────────
|
|
1850
|
-
// <button> without an explicit type attribute defaults to type="submit" when
|
|
1851
|
-
// inside a <form>, causing accidental form submission. This is an HTML spec
|
|
1852
|
-
// issue (not a WCAG SC), but it is the root cause of unexpected navigation and
|
|
1853
|
-
// double-submit bugs. We only flag when the button is inside a <form> ancestor
|
|
1854
|
-
// (where getAncestors is available
|
|
1855
|
-
// because getParent returns null and ancestor walking is unavailable there.
|
|
1856
|
-
// Ref: HTML Living Standard §4.10.18.5; H32 Technique
|
|
1857
|
-
|
|
1858
|
-
export function makeNoButtonTypeMissing(h) {
|
|
1859
|
-
return {
|
|
1860
|
-
meta: {
|
|
1861
|
-
type: 'suggestion',
|
|
1862
|
-
docs: { description: 'Require explicit type attribute on <button> elements inside forms' },
|
|
1863
|
-
messages: {
|
|
1864
|
-
missingType:
|
|
1865
|
-
'<button> without an explicit type defaults to type="submit" inside a <form>, which can cause accidental form submission. Add type="button", type="submit", or type="reset". (HTML spec §4.10.18.5)',
|
|
1866
|
-
},
|
|
1867
|
-
schema: [],
|
|
1868
|
-
},
|
|
1869
|
-
create(context) {
|
|
1870
|
-
return {
|
|
1871
|
-
[h.elementVisitor](node) {
|
|
1872
|
-
if (h.getElementName(node) !== 'button') return
|
|
1873
|
-
if (h.hasAttr(node, 'type')) return
|
|
1874
|
-
|
|
1875
|
-
// Only flag when inside a <form>
|
|
1876
|
-
// Angular's getAncestors yields nothing (parent is null), so the loop
|
|
1877
|
-
// completes without finding 'form' and we silently skip
|
|
1878
|
-
let insideForm = false
|
|
1879
|
-
for (const ancestor of h.getAncestors(node)) {
|
|
1880
|
-
if (h.getElementName(ancestor) === 'form') { insideForm = true; break }
|
|
1881
|
-
}
|
|
1882
|
-
if (!insideForm) return
|
|
1883
|
-
|
|
1884
|
-
context.report({ node, messageId: 'missingType' })
|
|
1885
|
-
},
|
|
1886
|
-
}
|
|
1887
|
-
},
|
|
1888
|
-
}
|
|
1889
|
-
}
|
|
1890
|
-
|
|
1891
|
-
// ─── no-summary-without-details ───────────────────────────────────────────────
|
|
1892
|
-
// <summary> must be the first child of <details>. Orphaned <summary> elements
|
|
1893
|
-
// are still exposed as interactive by Firefox and Safari even though they are
|
|
1894
|
-
// not keyboard-operable
|
|
1895
|
-
// Angular skips silently because getParent returns null there.
|
|
1896
|
-
// Ref: HTML Living Standard §4.11.1; O'Hara scottohara.me/blog/2022/09/12/details-summary.html
|
|
1897
|
-
|
|
1898
|
-
export function makeNoSummaryWithoutDetails(h) {
|
|
1899
|
-
return {
|
|
1900
|
-
meta: {
|
|
1901
|
-
type: 'problem',
|
|
1902
|
-
docs: { description: 'Require <summary> to be a child of <details>' },
|
|
1903
|
-
messages: {
|
|
1904
|
-
orphaned:
|
|
1905
|
-
'<summary> outside <details> is invalid HTML. Firefox and Safari still expose it as an interactive element, but it is not keyboard-operable
|
|
1906
|
-
},
|
|
1907
|
-
schema: [],
|
|
1908
|
-
},
|
|
1909
|
-
create(context) {
|
|
1910
|
-
return {
|
|
1911
|
-
[h.elementVisitor](node) {
|
|
1912
|
-
if (h.getElementName(node) !== 'summary') return
|
|
1913
|
-
const parent = h.getParent(node)
|
|
1914
|
-
// Angular returns null
|
|
1915
|
-
if (parent === null) return
|
|
1916
|
-
if (h.getElementName(parent) !== 'details')
|
|
1917
|
-
context.report({ node, messageId: 'orphaned' })
|
|
1918
|
-
},
|
|
1919
|
-
}
|
|
1920
|
-
},
|
|
1921
|
-
}
|
|
1922
|
-
}
|
|
1923
|
-
|
|
1924
|
-
// ─── no-aria-required-on-non-form ────────────────────────────────────────────
|
|
1925
|
-
// aria-required is only meaningful on 8 ARIA roles per the ARIA 1.2 spec:
|
|
1926
|
-
// checkbox, combobox, gridcell, listbox, radiogroup, spinbutton, textbox, tree.
|
|
1927
|
-
// On any other element or role AT ignores it
|
|
1928
|
-
// Ref: ARIA 1.2 §6.6.9 aria-required; SC 4.1.2
|
|
1929
|
-
|
|
1930
|
-
const ARIA_REQUIRED_VALID_ROLES = new Set([
|
|
1931
|
-
'checkbox', 'combobox', 'gridcell', 'listbox', 'radiogroup', 'spinbutton', 'textbox', 'tree',
|
|
1932
|
-
])
|
|
1933
|
-
|
|
1934
|
-
// Input types whose implicit ARIA role supports aria-required
|
|
1935
|
-
const ARIA_REQUIRED_VALID_INPUT_TYPES = new Set([
|
|
1936
|
-
'text', 'email', 'password', 'search', 'tel', 'url', 'number', 'checkbox',
|
|
1937
|
-
])
|
|
1938
|
-
|
|
1939
|
-
export function makeNoAriaRequiredOnNonForm(h) {
|
|
1940
|
-
return {
|
|
1941
|
-
meta: {
|
|
1942
|
-
type: 'problem',
|
|
1943
|
-
docs: { description: 'Disallow aria-required on elements whose role does not support it' },
|
|
1944
|
-
messages: {
|
|
1945
|
-
invalid:
|
|
1946
|
-
'aria-required is only valid on roles: checkbox, combobox, gridcell, listbox, radiogroup, spinbutton, textbox, tree. On <{{el}}> with no matching role, AT ignores it. Use a native required attribute or apply aria-required to a control with a valid role. (ARIA 1.2 §6.6.9 / SC 4.1.2)',
|
|
1947
|
-
},
|
|
1948
|
-
schema: [],
|
|
1949
|
-
},
|
|
1950
|
-
create(context) {
|
|
1951
|
-
return {
|
|
1952
|
-
[h.elementVisitor](node) {
|
|
1953
|
-
const attr = h.getAttr(node, 'aria-required')
|
|
1954
|
-
if (!attr) return
|
|
1955
|
-
|
|
1956
|
-
const role = h.getRoleValue(node)
|
|
1957
|
-
if (role && ARIA_REQUIRED_VALID_ROLES.has(role)) return
|
|
1958
|
-
|
|
1959
|
-
const el = h.getElementName(node)
|
|
1960
|
-
|
|
1961
|
-
// select and textarea have implicit listbox/textbox roles
|
|
1962
|
-
if (el === 'select' || el === 'textarea') return
|
|
1963
|
-
|
|
1964
|
-
if (el === 'input') {
|
|
1965
|
-
const type = (h.getAttrStringValue(h.getAttr(node, 'type')) ?? 'text').toLowerCase()
|
|
1966
|
-
if (ARIA_REQUIRED_VALID_INPUT_TYPES.has(type)) return
|
|
1967
|
-
}
|
|
1968
|
-
|
|
1969
|
-
context.report({ node: attr, messageId: 'invalid', data: { el: el ?? 'unknown' } })
|
|
1970
|
-
},
|
|
1971
|
-
}
|
|
1972
|
-
},
|
|
1973
|
-
}
|
|
1974
|
-
}
|
|
1975
|
-
|
|
1976
|
-
// ─── no-input-type-invalid ────────────────────────────────────────────────────
|
|
1977
|
-
// <input type="X"> with an invalid type silently falls back to type="text",
|
|
1978
|
-
// losing mobile keyboard hints, native pickers, format validation, and browser
|
|
1979
|
-
// autofill matching. WCAG 1.3.5 Identify Input Purpose.
|
|
1980
|
-
// Dynamic type values (JSX expression, v-bind, Angular binding) are skipped
|
|
1981
|
-
// getAttrStringValue returns null for those and we cannot validate at lint time.
|
|
1982
|
-
// Ref: HTML Living Standard §4.10.18.5; SC 1.3.5
|
|
1983
|
-
|
|
1984
|
-
const VALID_INPUT_TYPES = new Set([
|
|
1985
|
-
'button', 'checkbox', 'color', 'date', 'datetime-local', 'email', 'file',
|
|
1986
|
-
'hidden', 'image', 'month', 'number', 'password', 'radio', 'range', 'reset',
|
|
1987
|
-
'search', 'submit', 'tel', 'text', 'time', 'url', 'week',
|
|
1988
|
-
])
|
|
1989
|
-
|
|
1990
|
-
export function makeNoInputTypeInvalid(h) {
|
|
1991
|
-
return {
|
|
1992
|
-
meta: {
|
|
1993
|
-
type: 'problem',
|
|
1994
|
-
docs: { description: 'Require valid HTML type attribute values on <input> elements' },
|
|
1995
|
-
messages: {
|
|
1996
|
-
invalid:
|
|
1997
|
-
'type="{{type}}" is not a valid HTML input type and silently falls back to type="text", losing mobile keyboard hints, native pickers, and autofill matching. Use a valid type value. (HTML spec / SC 1.3.5)',
|
|
1998
|
-
},
|
|
1999
|
-
schema: [],
|
|
2000
|
-
},
|
|
2001
|
-
create(context) {
|
|
2002
|
-
return {
|
|
2003
|
-
[h.elementVisitor](node) {
|
|
2004
|
-
if (h.getElementName(node) !== 'input') return
|
|
2005
|
-
const typeAttr = h.getAttr(node, 'type')
|
|
2006
|
-
if (!typeAttr) return // missing type
|
|
2007
|
-
const val = h.getAttrStringValue(typeAttr)
|
|
2008
|
-
if (val === null) return // dynamic expression
|
|
2009
|
-
if (!VALID_INPUT_TYPES.has(val.toLowerCase()))
|
|
2010
|
-
context.report({ node: typeAttr, messageId: 'invalid', data: { type: val } })
|
|
2011
|
-
},
|
|
2012
|
-
}
|
|
2013
|
-
},
|
|
2014
|
-
}
|
|
2015
|
-
}
|
|
2016
|
-
|
|
2017
|
-
// ─── no-labelledby-missing-target ────────────────────────────────────────────
|
|
2018
|
-
// aria-labelledby and aria-describedby accept a space-separated list of id refs.
|
|
2019
|
-
// If any referenced id does not exist in the same file the association is broken
|
|
2020
|
-
// AT silently computes an empty name. axe-core catches this at runtime; we can
|
|
2021
|
-
// catch the static case (same file) at lint time.
|
|
2022
|
-
// Ref: axe-core aria-labelledby (reimplemented); ARIA 1.2 §6.2.4; SC 4.1.2
|
|
2023
|
-
|
|
2024
|
-
const LABELLEDBY_ATTRS = ['aria-labelledby', 'aria-describedby', 'aria-controls', 'aria-owns', 'aria-activedescendant']
|
|
2025
|
-
|
|
2026
|
-
export function makeNoLabelledbyMissingTarget(h) {
|
|
2027
|
-
return {
|
|
2028
|
-
meta: {
|
|
2029
|
-
type: 'problem',
|
|
2030
|
-
docs: { description: 'Disallow aria-labelledby/describedby/controls/owns/activedescendant referencing an id that does not exist in the file' },
|
|
2031
|
-
messages: {
|
|
2032
|
-
missingTarget:
|
|
2033
|
-
'{{attr}}="{{ids}}" references id "{{id}}" which does not exist in this file. ' +
|
|
2034
|
-
'AT will compute an empty name for this element. Add an element with id="{{id}}" ' +
|
|
2035
|
-
'or correct the reference. (axe-core aria-labelledby / SC 4.1.2)',
|
|
2036
|
-
},
|
|
2037
|
-
schema: [],
|
|
2038
|
-
},
|
|
2039
|
-
create(context) {
|
|
2040
|
-
const definedIds = new Set()
|
|
2041
|
-
// attr node → { attr, tokens }
|
|
2042
|
-
const refs = []
|
|
2043
|
-
|
|
2044
|
-
return {
|
|
2045
|
-
[h.elementVisitor](node) {
|
|
2046
|
-
const idVal = h.getAttrStringValue(h.getAttr(node, 'id'))
|
|
2047
|
-
if (idVal) definedIds.add(idVal.trim())
|
|
2048
|
-
|
|
2049
|
-
for (const attrName of LABELLEDBY_ATTRS) {
|
|
2050
|
-
const attrNode = h.getAttr(node, attrName)
|
|
2051
|
-
if (!attrNode) continue
|
|
2052
|
-
const val = h.getAttrStringValue(attrNode)
|
|
2053
|
-
if (!val) continue
|
|
2054
|
-
const tokens = val.trim().split(/\s+/).filter(Boolean)
|
|
2055
|
-
if (tokens.length) refs.push({ attrNode, attrName, tokens })
|
|
2056
|
-
}
|
|
2057
|
-
},
|
|
2058
|
-
|
|
2059
|
-
'Program:exit'() {
|
|
2060
|
-
for (const { attrNode, attrName, tokens } of refs) {
|
|
2061
|
-
for (const id of tokens) {
|
|
2062
|
-
if (!definedIds.has(id)) {
|
|
2063
|
-
context.report({
|
|
2064
|
-
node: attrNode,
|
|
2065
|
-
messageId: 'missingTarget',
|
|
2066
|
-
data: { attr: attrName, ids: tokens.join(' '), id },
|
|
2067
|
-
})
|
|
2068
|
-
break // one report per attribute is enough
|
|
2069
|
-
}
|
|
2070
|
-
}
|
|
2071
|
-
}
|
|
2072
|
-
},
|
|
2073
|
-
}
|
|
2074
|
-
},
|
|
2075
|
-
}
|
|
2076
|
-
}
|
|
2077
|
-
|
|
2078
|
-
// ─── no-dynamic-content-without-live ─────────────────────────────────────────
|
|
2079
|
-
// Injecting HTML dynamically (dangerouslySetInnerHTML / v-html / [innerHTML])
|
|
2080
|
-
// replaces the subtree after load. Screen readers do not re-read replaced
|
|
2081
|
-
// content unless a live region wraps it. axe-core catches this at runtime as
|
|
2082
|
-
// "content-changes" violations; we can catch the static pattern at lint time.
|
|
2083
|
-
//
|
|
2084
|
-
// The check: the element using the inject-HTML attribute, or one of its
|
|
2085
|
-
// ancestors, must have aria-live (or role="alert"/"status"/"log"/"marquee"
|
|
2086
|
-
// which carry implicit live region semantics).
|
|
2087
|
-
//
|
|
2088
|
-
// Angular ancestor walking is unavailable (getParent returns null) so for
|
|
2089
|
-
// Angular we only check the element itself
|
|
2090
|
-
//
|
|
2091
|
-
// Ref: axe-core (content-changes); WCAG SC 4.1.3 Status Messages
|
|
2092
|
-
|
|
2093
|
-
const IMPLICIT_LIVE_ROLES = new Set(['alert', 'status', 'log', 'marquee', 'timer'])
|
|
2094
|
-
|
|
2095
|
-
function hasLiveRegion(node, h) {
|
|
2096
|
-
if (h.hasAttr(node, 'aria-live')) return true
|
|
2097
|
-
const role = h.getRoleValue(node)
|
|
2098
|
-
if (role && IMPLICIT_LIVE_ROLES.has(role)) return true
|
|
2099
|
-
return false
|
|
2100
|
-
}
|
|
2101
|
-
|
|
2102
|
-
export function makeNoDynamicContentWithoutLive(h) {
|
|
2103
|
-
return {
|
|
2104
|
-
meta: {
|
|
2105
|
-
type: 'problem',
|
|
2106
|
-
docs: { description: 'Require aria-live on elements that inject dynamic HTML content' },
|
|
2107
|
-
messages: {
|
|
2108
|
-
missingLive:
|
|
2109
|
-
'{{attr}} replaces element content after load. Screen readers will not re-read ' +
|
|
2110
|
-
'the new content unless this element or an ancestor has aria-live (or an implicit ' +
|
|
2111
|
-
'live role like role="alert"). Add aria-live="polite" (or role="status") to the ' +
|
|
2112
|
-
'container, or move the inject into an existing live region. (axe-core content-changes / SC 4.1.3)',
|
|
2113
|
-
},
|
|
2114
|
-
schema: [],
|
|
2115
|
-
},
|
|
2116
|
-
create(context) {
|
|
2117
|
-
return {
|
|
2118
|
-
[h.elementVisitor](node) {
|
|
2119
|
-
const injectAttr = h.getInnerHtmlAttr(node)
|
|
2120
|
-
if (!injectAttr) return
|
|
2121
|
-
|
|
2122
|
-
// Check the element itself first
|
|
2123
|
-
if (hasLiveRegion(node, h)) return
|
|
2124
|
-
|
|
2125
|
-
// Walk ancestors (returns nothing for Angular
|
|
2126
|
-
for (const ancestor of h.getAncestors(node)) {
|
|
2127
|
-
if (hasLiveRegion(ancestor, h)) return
|
|
2128
|
-
}
|
|
2129
|
-
|
|
2130
|
-
const attrName = h.getInnerHtmlAttrName(node)
|
|
2131
|
-
context.report({ node: injectAttr, messageId: 'missingLive', data: { attr: attrName } })
|
|
2132
|
-
},
|
|
2133
|
-
}
|
|
2134
|
-
},
|
|
2135
|
-
}
|
|
2136
|
-
}
|
|
2137
|
-
|
|
2138
|
-
// ─── form-field-multiple-labels ───────────────────────────────────────────────
|
|
2139
|
-
// A form control should have exactly one label. When multiple <label for="X">
|
|
2140
|
-
// elements point to the same input, screen readers read all of them
|
|
2141
|
-
// is verbose, repetitive, or confusing depending on the AT.
|
|
2142
|
-
// We only flag the case where more than one *static* <label for="id"> targets
|
|
2143
|
-
// the same input in the same file. Dynamic labels (v-bind:for, [for]) are skipped.
|
|
2144
|
-
// Ref: axe-core form-field-multiple-labels (reimplemented); SC 1.3.1
|
|
2145
|
-
|
|
2146
|
-
export function makeFormFieldMultipleLabels(h) {
|
|
2147
|
-
return {
|
|
2148
|
-
meta: {
|
|
2149
|
-
type: 'problem',
|
|
2150
|
-
docs: { description: 'Disallow multiple <label> elements associated with the same form control' },
|
|
2151
|
-
messages: {
|
|
2152
|
-
multipleLabels:
|
|
2153
|
-
'id="{{id}}" is referenced by more than one <label for="...">. Screen readers read all ' +
|
|
2154
|
-
'associated labels
|
|
2155
|
-
'(axe-core form-field-multiple-labels / SC 1.3.1)',
|
|
2156
|
-
},
|
|
2157
|
-
schema: [],
|
|
2158
|
-
},
|
|
2159
|
-
create(context) {
|
|
2160
|
-
// Map from id value → array of <label for="id"> attribute nodes
|
|
2161
|
-
const labelForRefs = new Map()
|
|
2162
|
-
|
|
2163
|
-
return {
|
|
2164
|
-
[h.elementVisitor](node) {
|
|
2165
|
-
if (h.getElementName(node) !== 'label') return
|
|
2166
|
-
// Support both htmlFor (JSX) and for (Vue/Angular)
|
|
2167
|
-
const forAttr = h.getAttr(node, 'htmlFor') ?? h.getAttr(node, 'for')
|
|
2168
|
-
if (!forAttr) return
|
|
2169
|
-
const forVal = h.getAttrStringValue(forAttr)
|
|
2170
|
-
if (!forVal) return
|
|
2171
|
-
const id = forVal.trim()
|
|
2172
|
-
if (!labelForRefs.has(id)) labelForRefs.set(id, [])
|
|
2173
|
-
labelForRefs.get(id).push(forAttr)
|
|
2174
|
-
},
|
|
2175
|
-
|
|
2176
|
-
'Program:exit'() {
|
|
2177
|
-
for (const [id, nodes] of labelForRefs) {
|
|
2178
|
-
if (nodes.length < 2) continue
|
|
2179
|
-
// Report the second and subsequent labels
|
|
2180
|
-
for (const node of nodes.slice(1)) {
|
|
2181
|
-
context.report({ node, messageId: 'multipleLabels', data: { id } })
|
|
2182
|
-
}
|
|
2183
|
-
}
|
|
2184
|
-
},
|
|
2185
|
-
}
|
|
2186
|
-
},
|
|
2187
|
-
}
|
|
2188
|
-
}
|
|
2189
|
-
|
|
2190
|
-
// ─── no-empty-table-header ────────────────────────────────────────────────────
|
|
2191
|
-
// <th> elements (and elements with role="columnheader" or role="rowheader") must
|
|
2192
|
-
// have accessible text
|
|
2193
|
-
// An empty table header is invisible to screen reader users; they cannot navigate
|
|
2194
|
-
// or understand the table structure.
|
|
2195
|
-
// Ref: axe-core empty-table-header (reimplemented); SC 1.3.1
|
|
2196
|
-
|
|
2197
|
-
const TABLE_HEADER_ROLES = new Set(['columnheader', 'rowheader'])
|
|
2198
|
-
|
|
2199
|
-
export function makeNoEmptyTableHeader(h) {
|
|
2200
|
-
return {
|
|
2201
|
-
meta: {
|
|
2202
|
-
type: 'problem',
|
|
2203
|
-
docs: { description: 'Require accessible text on <th> elements and header role elements' },
|
|
2204
|
-
messages: {
|
|
2205
|
-
emptyHeader:
|
|
2206
|
-
'This table header has no accessible name
|
|
2207
|
-
'or row to users. Add visible text, aria-label, or aria-labelledby. ' +
|
|
2208
|
-
'(axe-core empty-table-header / SC 1.3.1)',
|
|
2209
|
-
},
|
|
2210
|
-
schema: [],
|
|
2211
|
-
},
|
|
2212
|
-
create(context) {
|
|
2213
|
-
return {
|
|
2214
|
-
[h.elementWithChildrenVisitor](node) {
|
|
2215
|
-
const opening = h.getOpeningElement(node)
|
|
2216
|
-
const el = h.getElementName(opening)
|
|
2217
|
-
const role = h.getRoleValue(opening)
|
|
2218
|
-
const isTh = el === 'th'
|
|
2219
|
-
const isHeaderRole = role && TABLE_HEADER_ROLES.has(role)
|
|
2220
|
-
if (!isTh && !isHeaderRole) return
|
|
2221
|
-
if (h.hasAccessibleName(opening)) return
|
|
2222
|
-
// Check for visible text children
|
|
2223
|
-
const children = h.getChildOpeningElementsFromWrapper(node)
|
|
2224
|
-
// hasOnlyHiddenChildren checks if ALL children are aria-hidden
|
|
2225
|
-
// returns true on a childless node it returns false, so we also check
|
|
2226
|
-
// whether the element has zero children with text.
|
|
2227
|
-
// Use the wrapper-level text check available for JSX/Vue.
|
|
2228
|
-
if (!h.hasOnlyHiddenChildren(opening) && !isEffectivelyEmpty(node, h)) return
|
|
2229
|
-
context.report({ node: opening, messageId: 'emptyHeader' })
|
|
2230
|
-
},
|
|
2231
|
-
}
|
|
2232
|
-
},
|
|
2233
|
-
}
|
|
2234
|
-
}
|
|
2235
|
-
|
|
2236
|
-
/**
|
|
2237
|
-
* Returns true if the element wrapper has no visible text content.
|
|
2238
|
-
* Works for JSX (JSXElement.children) and Vue (VElement.children).
|
|
2239
|
-
* For Angular, elementWithChildrenVisitor === elementVisitor and children
|
|
2240
|
-
* are on tmplElement.children directly.
|
|
2241
|
-
*/
|
|
2242
|
-
function isEffectivelyEmpty(wrapperNode, h) {
|
|
2243
|
-
// For frameworks where wrapper === opening (Vue, Angular), use children directly
|
|
2244
|
-
const children = wrapperNode.children ?? wrapperNode.parent?.children ?? []
|
|
2245
|
-
if (children.length === 0) return true
|
|
2246
|
-
return children.every(child => {
|
|
2247
|
-
// JSX
|
|
2248
|
-
if (child.type === 'JSXText') return child.value.trim() === ''
|
|
2249
|
-
if (child.type === 'JSXExpressionContainer') {
|
|
2250
|
-
const ex = child.expression
|
|
2251
|
-
return ex.type === 'Literal' && String(ex.value).trim() === ''
|
|
2252
|
-
}
|
|
2253
|
-
// Vue
|
|
2254
|
-
if (child.type === 'VText') return (child.value ?? '').trim() === ''
|
|
2255
|
-
// Angular
|
|
2256
|
-
if (child.constructor?.name === 'TmplAstText') return (child.value ?? '').trim() === ''
|
|
2257
|
-
// Child element
|
|
2258
|
-
return false
|
|
2259
|
-
})
|
|
2260
|
-
}
|
|
2261
|
-
|
|
2262
|
-
// ─── All rules map ────────────────────────────────────────────────────────────
|
|
2263
|
-
|
|
2264
|
-
export const RULE_FACTORIES = {
|
|
2265
|
-
'no-aria-label-on-generic': makeNoAriaLabelOnGeneric,
|
|
2266
|
-
'no-assertive-live-overuse': makeNoAssertiveLiveOveruse,
|
|
2267
|
-
'warn-role-alert': makeWarnRoleAlert,
|
|
2268
|
-
'no-unblocked-aria-disabled': makeNoUnblockedAriaDisabled,
|
|
2269
|
-
'prefer-aria-disabled': makePreferAriaDisabled,
|
|
2270
|
-
'no-tooltip-role-misuse': makeNoTooltipRoleMisuse,
|
|
2271
|
-
'no-roles-without-name': makeNoRolesWithoutName,
|
|
2272
|
-
'no-group-without-name': makeNoGroupWithoutName,
|
|
2273
|
-
'no-tabs-without-structure': makeNoTabsWithoutStructure,
|
|
2274
|
-
'no-tab-without-controls': makeNoTabWithoutControls,
|
|
2275
|
-
'no-application-role': makeNoApplicationRole,
|
|
2276
|
-
'no-grid-role': makeNoGridRole,
|
|
2277
|
-
'no-menu-role-on-nav': makeNoMenuRoleOnNav,
|
|
2278
|
-
'no-presentation-on-focusable': makeNoPresentationOnFocusable,
|
|
2279
|
-
'no-log-with-interactive-children': makeNoLogWithInteractiveChildren,
|
|
2280
|
-
'no-redundant-aria-hidden-with-presentation': makeNoRedundantAriaHiddenWithPresentation,
|
|
2281
|
-
'no-aria-roledescription': makeNoAriaRoledescription,
|
|
2282
|
-
'no-aria-readonly': makeNoAriaReadonly,
|
|
2283
|
-
'no-aria-hidden-in-link': makeNoAriaHiddenInLink,
|
|
2284
|
-
'no-title-as-label': makeNoTitleAsLabel,
|
|
2285
|
-
'no-href-hash': makeNoHrefHash,
|
|
2286
|
-
'no-target-blank-without-label': makeNoTargetBlankWithoutLabel,
|
|
2287
|
-
'no-autoplay-without-controls': makeNoAutoplayWithoutControls,
|
|
2288
|
-
'no-heading-inside-interactive': makeNoHeadingInsideInteractive,
|
|
2289
|
-
'no-placeholder-only': makeNoPlaceholderOnly,
|
|
2290
|
-
'no-positive-tabindex': makeNoPositiveTabindex,
|
|
2291
|
-
'no-aria-owns-on-void': makeNoAriaOwnsOnVoid,
|
|
2292
|
-
'no-empty-button': makeNoEmptyButton,
|
|
2293
|
-
'no-image-role-without-name': makeNoImageRoleWithoutName,
|
|
2294
|
-
'no-spinbutton-without-range': makeNoSpinbuttonWithoutRange,
|
|
2295
|
-
'no-slider-without-range': makeNoSliderWithoutRange,
|
|
2296
|
-
'no-combobox-without-expanded': makeNoComboboxWithoutExpanded,
|
|
2297
|
-
'no-mouse-only-events': makeNoMouseOnlyEvents,
|
|
2298
|
-
'no-listbox-without-option': makeNoListboxWithoutOption,
|
|
2299
|
-
'no-tree-without-treeitem': makeNoTreeWithoutTreeitem,
|
|
2300
|
-
'no-feed-without-article': makeNoFeedWithoutArticle,
|
|
2301
|
-
'no-aria-activedescendant-without-id': makeNoAriaActivedescendantWithoutId,
|
|
2302
|
-
'no-dialog-without-close': makeNoDialogWithoutClose,
|
|
2303
|
-
// jsx-a11y portability rules
|
|
2304
|
-
'no-anchor-ambiguous-text': makeNoAnchorAmbiguousText,
|
|
2305
|
-
'no-anchor-no-content': makeNoAnchorNoContent,
|
|
2306
|
-
'no-aria-activedescendant-no-tabindex': makeNoAriaActivedescendantNoTabindex,
|
|
2307
|
-
'no-invalid-aria-prop-value': makeNoInvalidAriaPropValue,
|
|
2308
|
-
'no-autocomplete-invalid': makeNoAutocompleteInvalid,
|
|
2309
|
-
'no-heading-no-content': makeNoHeadingNoContent,
|
|
2310
|
-
'no-iframe-no-title': makeNoIframeNoTitle,
|
|
2311
|
-
'no-img-redundant-alt': makeNoImgRedundantAlt,
|
|
2312
|
-
'no-access-key': makeNoAccessKey,
|
|
2313
|
-
'no-noninteractive-to-interactive-role': makeNoNoninteractiveToInteractiveRole,
|
|
2314
|
-
'no-noninteractive-tabindex': makeNoNoninteractiveTabindex,
|
|
2315
|
-
'prefer-semantic-element': makePreferSemanticElement,
|
|
2316
|
-
'no-role-supports-aria-props': makeNoRoleSupportsAriaProps,
|
|
2317
|
-
'no-scope-on-td': makeNoScopeOnTd,
|
|
2318
|
-
'no-duplicate-id': makeNoDuplicateId,
|
|
2319
|
-
'no-button-type-missing': makeNoButtonTypeMissing,
|
|
2320
|
-
'no-summary-without-details': makeNoSummaryWithoutDetails,
|
|
2321
|
-
'no-aria-required-on-non-form': makeNoAriaRequiredOnNonForm,
|
|
2322
|
-
'no-input-type-invalid': makeNoInputTypeInvalid,
|
|
2323
|
-
'no-labelledby-missing-target': makeNoLabelledbyMissingTarget,
|
|
2324
|
-
'no-dynamic-content-without-live': makeNoDynamicContentWithoutLive,
|
|
2325
|
-
'form-field-multiple-labels': makeFormFieldMultipleLabels,
|
|
2326
|
-
'no-empty-table-header': makeNoEmptyTableHeader,
|
|
2327
|
-
}
|
|
2328
|
-
|
|
2329
|
-
/** Build the rules map for a plugin by applying helpers to all factories. */
|
|
2330
|
-
export function buildRules(h) {
|
|
2331
|
-
const rules = {}
|
|
2332
|
-
for (const [name, factory] of Object.entries(RULE_FACTORIES)) {
|
|
2333
|
-
rules[name] = factory(h)
|
|
2334
|
-
}
|
|
2335
|
-
return rules
|
|
2336
|
-
}
|
|
2337
|
-
|
|
2338
|
-
/** Build the recommended config rules object for a given plugin namespace. */
|
|
2339
|
-
export function buildRecommendedRules(ns) {
|
|
2340
|
-
return {
|
|
2341
|
-
// errors
|
|
2342
|
-
[`${ns}/no-aria-label-on-generic`]: 'error',
|
|
2343
|
-
[`${ns}/no-assertive-live-overuse`]: 'error',
|
|
2344
|
-
[`${ns}/no-unblocked-aria-disabled`]: 'error',
|
|
2345
|
-
[`${ns}/no-roles-without-name`]: 'error',
|
|
2346
|
-
[`${ns}/no-group-without-name`]: 'error',
|
|
2347
|
-
[`${ns}/no-presentation-on-focusable`]: 'error',
|
|
2348
|
-
[`${ns}/no-log-with-interactive-children`]: 'error',
|
|
2349
|
-
[`${ns}/no-aria-hidden-in-link`]: 'error',
|
|
2350
|
-
[`${ns}/no-redundant-aria-hidden-with-presentation`]: 'error',
|
|
2351
|
-
[`${ns}/no-aria-owns-on-void`]: 'error',
|
|
2352
|
-
[`${ns}/no-title-as-label`]: 'error',
|
|
2353
|
-
[`${ns}/no-tabs-without-structure`]: 'error',
|
|
2354
|
-
[`${ns}/no-positive-tabindex`]: 'error',
|
|
2355
|
-
[`${ns}/no-autoplay-without-controls`]: 'error',
|
|
2356
|
-
[`${ns}/no-heading-inside-interactive`]: 'error',
|
|
2357
|
-
[`${ns}/no-placeholder-only`]: 'error',
|
|
2358
|
-
[`${ns}/no-empty-button`]: 'error',
|
|
2359
|
-
[`${ns}/no-image-role-without-name`]: 'error',
|
|
2360
|
-
[`${ns}/no-spinbutton-without-range`]: 'error',
|
|
2361
|
-
[`${ns}/no-slider-without-range`]: 'error',
|
|
2362
|
-
[`${ns}/no-combobox-without-expanded`]: 'error',
|
|
2363
|
-
[`${ns}/no-mouse-only-events`]: 'error',
|
|
2364
|
-
[`${ns}/no-listbox-without-option`]: 'error',
|
|
2365
|
-
[`${ns}/no-tree-without-treeitem`]: 'error',
|
|
2366
|
-
[`${ns}/no-feed-without-article`]: 'error',
|
|
2367
|
-
[`${ns}/no-aria-activedescendant-without-id`]: 'error',
|
|
2368
|
-
[`${ns}/no-duplicate-id`]: 'error',
|
|
2369
|
-
[`${ns}/no-summary-without-details`]: 'error',
|
|
2370
|
-
[`${ns}/no-aria-required-on-non-form`]: 'error',
|
|
2371
|
-
[`${ns}/no-input-type-invalid`]: 'error',
|
|
2372
|
-
[`${ns}/no-labelledby-missing-target`]: 'error',
|
|
2373
|
-
[`${ns}/no-dynamic-content-without-live`]: 'error',
|
|
2374
|
-
[`${ns}/form-field-multiple-labels`]: 'error',
|
|
2375
|
-
[`${ns}/no-empty-table-header`]: 'error',
|
|
2376
|
-
[`${ns}/no-button-type-missing`]: 'warn',
|
|
2377
|
-
// warnings
|
|
2378
|
-
[`${ns}/no-tooltip-role-misuse`]: 'warn',
|
|
2379
|
-
[`${ns}/no-menu-role-on-nav`]: 'warn',
|
|
2380
|
-
// off by default
|
|
2381
|
-
// enable individually if the pattern applies to your project
|
|
2382
|
-
[`${ns}/no-application-role`]: 'off',
|
|
2383
|
-
[`${ns}/no-grid-role`]: 'off',
|
|
2384
|
-
[`${ns}/no-aria-roledescription`]: 'off',
|
|
2385
|
-
[`${ns}/no-aria-readonly`]: 'off',
|
|
2386
|
-
[`${ns}/no-tab-without-controls`]: 'off',
|
|
2387
|
-
[`${ns}/no-href-hash`]: 'off',
|
|
2388
|
-
[`${ns}/warn-role-alert`]: 'off',
|
|
2389
|
-
[`${ns}/prefer-aria-disabled`]: 'off',
|
|
2390
|
-
[`${ns}/no-target-blank-without-label`]: 'off',
|
|
2391
|
-
[`${ns}/no-dialog-without-close`]: 'off',
|
|
2392
|
-
}
|
|
2393
|
-
}
|
|
2394
|
-
|
|
2395
|
-
/** Build portability rules for Vue/Angular configs (jsx-a11y gap rules). */
|
|
2396
|
-
export function buildPortabilityRules(ns) {
|
|
2397
|
-
return {
|
|
2398
|
-
[`${ns}/no-anchor-ambiguous-text`]: 'error',
|
|
2399
|
-
[`${ns}/no-anchor-no-content`]: 'error',
|
|
2400
|
-
[`${ns}/no-aria-activedescendant-no-tabindex`]: 'error',
|
|
2401
|
-
[`${ns}/no-invalid-aria-prop-value`]: 'error',
|
|
2402
|
-
[`${ns}/no-autocomplete-invalid`]: 'error',
|
|
2403
|
-
[`${ns}/no-heading-no-content`]: 'error',
|
|
2404
|
-
[`${ns}/no-iframe-no-title`]: 'error',
|
|
2405
|
-
[`${ns}/no-img-redundant-alt`]: 'warn',
|
|
2406
|
-
[`${ns}/no-access-key`]: 'warn',
|
|
2407
|
-
[`${ns}/no-noninteractive-to-interactive-role`]: 'error',
|
|
2408
|
-
[`${ns}/no-noninteractive-tabindex`]: 'error',
|
|
2409
|
-
[`${ns}/prefer-semantic-element`]: 'warn',
|
|
2410
|
-
[`${ns}/no-role-supports-aria-props`]: 'error',
|
|
2411
|
-
[`${ns}/no-scope-on-td`]: 'error',
|
|
2412
|
-
}
|
|
2413
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* neighbor/lib/rules.js
|
|
3
|
+
* Framework-agnostic rule factories.
|
|
4
|
+
*
|
|
5
|
+
* Every factory is called as makeXxx(h) where h is the framework-specific
|
|
6
|
+
* helpers object from helpers-jsx.js / helpers-vue.js / helpers-angular.js.
|
|
7
|
+
* Each factory returns a complete ESLint rule object { meta, create }.
|
|
8
|
+
*
|
|
9
|
+
* Sources and credits:
|
|
10
|
+
* Adrian Roselli adrianroselli.com
|
|
11
|
+
* Heydon Pickering heydonworks.com, inclusive-components.design
|
|
12
|
+
* Scott O'Hara scottohara.me
|
|
13
|
+
* Patrick Lauke splintered.co.uk, patrickhlauke.github.io/aria
|
|
14
|
+
* Karl Groves karlgroves.com
|
|
15
|
+
* Marcy Sutton marcysutton.com
|
|
16
|
+
* Eric Eggert yatil.net
|
|
17
|
+
* WAI-ARIA APG w3.org/WAI/ARIA/apg
|
|
18
|
+
* ARIA 1.2 spec w3.org/TR/wai-aria-1.2
|
|
19
|
+
* WebAIM Million webaim.org/projects/million
|
|
20
|
+
* Deque / axe-core deque.com - rule concepts reimplemented under MPL-2.0
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
INTERACTIVE_ELEMENTS,
|
|
25
|
+
INTERACTIVE_ROLES,
|
|
26
|
+
GENERIC_CONTAINERS,
|
|
27
|
+
VOID_ELEMENTS,
|
|
28
|
+
HEADING_ELEMENTS,
|
|
29
|
+
NAV_MENU_ROLES,
|
|
30
|
+
ROLES_REQUIRING_NAME,
|
|
31
|
+
FORM_ELEMENTS,
|
|
32
|
+
} from './helpers.js'
|
|
33
|
+
|
|
34
|
+
// ─── no-aria-label-on-generic ────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
export function makeNoAriaLabelOnGeneric(h) {
|
|
37
|
+
return {
|
|
38
|
+
meta: {
|
|
39
|
+
type: 'suggestion',
|
|
40
|
+
docs: { description: 'Disallow aria-label / aria-labelledby on generic elements with no role' },
|
|
41
|
+
messages: {
|
|
42
|
+
noLabel:
|
|
43
|
+
'{{attr}} on <{{el}}> has no semantic target - add a role, or move the label to a landmark or interactive element. (Roselli / O\'Hara)',
|
|
44
|
+
},
|
|
45
|
+
schema: [],
|
|
46
|
+
},
|
|
47
|
+
create(context) {
|
|
48
|
+
return {
|
|
49
|
+
[h.elementVisitor](node) {
|
|
50
|
+
const el = h.getElementName(node)
|
|
51
|
+
if (!el || !GENERIC_CONTAINERS.has(el)) return
|
|
52
|
+
const labelAttr = h.getAttr(node, 'aria-label') ?? h.getAttr(node, 'aria-labelledby')
|
|
53
|
+
if (!labelAttr) return
|
|
54
|
+
if (h.hasAttr(node, 'role')) return
|
|
55
|
+
const attrName = labelAttr.name?.name ?? labelAttr.key?.name ?? labelAttr.name
|
|
56
|
+
context.report({ node: labelAttr, messageId: 'noLabel', data: { attr: attrName, el } })
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ─── no-assertive-live-overuse ───────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
export function makeNoAssertiveLiveOveruse(h) {
|
|
66
|
+
return {
|
|
67
|
+
meta: {
|
|
68
|
+
type: 'suggestion',
|
|
69
|
+
docs: { description: 'Disallow aria-live="assertive" outside role="alert" elements' },
|
|
70
|
+
messages: {
|
|
71
|
+
assertiveWithoutAlert:
|
|
72
|
+
'aria-live="assertive" without role="alert" interrupts the user unexpectedly. Use aria-live="polite" for status/progress, or add role="alert" only for genuine errors or time-critical messages. (APG / Sutton / Eggert)',
|
|
73
|
+
},
|
|
74
|
+
schema: [],
|
|
75
|
+
},
|
|
76
|
+
create(context) {
|
|
77
|
+
return {
|
|
78
|
+
[h.elementVisitor](node) {
|
|
79
|
+
const liveVal = h.getAttrStringValue(h.getAttr(node, 'aria-live'))
|
|
80
|
+
if (liveVal !== 'assertive') return
|
|
81
|
+
if (h.getRoleValue(node) === 'alert') return
|
|
82
|
+
if (h.getElementName(node) === 'dialog') return
|
|
83
|
+
context.report({ node: h.getAttr(node, 'aria-live'), messageId: 'assertiveWithoutAlert' })
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ─── no-unblocked-aria-disabled ──────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
export function makeNoUnblockedAriaDisabled(h) {
|
|
93
|
+
return {
|
|
94
|
+
meta: {
|
|
95
|
+
type: 'problem',
|
|
96
|
+
docs: { description: 'Disallow aria-disabled="true" on interactive elements that still have an active onClick' },
|
|
97
|
+
messages: {
|
|
98
|
+
unblocked:
|
|
99
|
+
'aria-disabled="true" does not block clicks - onClick still fires. Guard the handler, remove it when disabled, or use the native `disabled` attribute. (ARIA 1.2)',
|
|
100
|
+
},
|
|
101
|
+
schema: [],
|
|
102
|
+
},
|
|
103
|
+
create(context) {
|
|
104
|
+
return {
|
|
105
|
+
[h.elementVisitor](node) {
|
|
106
|
+
if (h.getAttrStringValue(h.getAttr(node, 'aria-disabled')) !== 'true') return
|
|
107
|
+
if (!h.isInteractiveElement(node)) return
|
|
108
|
+
// onClick is JSX-specific; Vue/Angular use @click / (click) - check both
|
|
109
|
+
if (!h.hasAttr(node, 'onClick') && !h.hasAttr(node, '@click') && !h.hasAttr(node, '(click)')) return
|
|
110
|
+
context.report({ node: h.getAttr(node, 'aria-disabled'), messageId: 'unblocked' })
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ─── no-tooltip-role-misuse ──────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
export function makeNoTooltipRoleMisuse(h) {
|
|
120
|
+
return {
|
|
121
|
+
meta: {
|
|
122
|
+
type: 'suggestion',
|
|
123
|
+
docs: { description: 'Disallow role="tooltip" with no id or on interactive elements' },
|
|
124
|
+
messages: {
|
|
125
|
+
noId:
|
|
126
|
+
'role="tooltip" requires an `id` so an interactive element can reference it via aria-describedby. Without an id no AT can associate this tooltip with its trigger. (APG: Tooltip Pattern)',
|
|
127
|
+
onInteractive:
|
|
128
|
+
'role="tooltip" belongs on the tooltip container, not the trigger. The trigger should have aria-describedby pointing to the tooltip\'s id. (APG: Tooltip Pattern)',
|
|
129
|
+
},
|
|
130
|
+
schema: [],
|
|
131
|
+
},
|
|
132
|
+
create(context) {
|
|
133
|
+
return {
|
|
134
|
+
[h.elementVisitor](node) {
|
|
135
|
+
if (h.getRoleValue(node) !== 'tooltip') return
|
|
136
|
+
const el = h.getElementName(node)
|
|
137
|
+
if (el && INTERACTIVE_ELEMENTS.has(el)) {
|
|
138
|
+
context.report({ node: h.getAttr(node, 'role'), messageId: 'onInteractive' })
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
if (!h.hasAttr(node, 'id'))
|
|
142
|
+
context.report({ node: h.getAttr(node, 'role'), messageId: 'noId' })
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ─── no-roles-without-name ───────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
const ROLE_REASONS = {
|
|
152
|
+
region: 'browsers do not expose it as a landmark without a name',
|
|
153
|
+
dialog: 'users cannot identify what the dialog is for',
|
|
154
|
+
alertdialog: 'users cannot identify what the alert dialog is for',
|
|
155
|
+
application: 'users have no context for the application region',
|
|
156
|
+
marquee: 'name required per ARIA 1.2',
|
|
157
|
+
searchbox: 'name required per ARIA 1.2',
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function makeNoRolesWithoutName(h) {
|
|
161
|
+
return {
|
|
162
|
+
meta: {
|
|
163
|
+
type: 'problem',
|
|
164
|
+
docs: { description: 'Require accessible names on roles that need them to be usable' },
|
|
165
|
+
messages: {
|
|
166
|
+
missingName:
|
|
167
|
+
'role="{{role}}" requires an accessible name (aria-label or aria-labelledby) to be meaningful: {{reason}}. (APG / ARIA 1.2)',
|
|
168
|
+
},
|
|
169
|
+
schema: [],
|
|
170
|
+
},
|
|
171
|
+
create(context) {
|
|
172
|
+
return {
|
|
173
|
+
[h.elementVisitor](node) {
|
|
174
|
+
const role = h.getRoleValue(node)
|
|
175
|
+
if (!role || !ROLES_REQUIRING_NAME.has(role)) return
|
|
176
|
+
if (h.hasAccessibleName(node)) return
|
|
177
|
+
context.report({
|
|
178
|
+
node: h.getAttr(node, 'role'),
|
|
179
|
+
messageId: 'missingName',
|
|
180
|
+
data: { role, reason: ROLE_REASONS[role] },
|
|
181
|
+
})
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ─── no-application-role ─────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
export function makeNoApplicationRole(h) {
|
|
191
|
+
return {
|
|
192
|
+
meta: {
|
|
193
|
+
type: 'suggestion',
|
|
194
|
+
docs: { description: 'Warn when role="application" is used - disables AT browse mode' },
|
|
195
|
+
messages: {
|
|
196
|
+
application:
|
|
197
|
+
'role="application" disables AT browse/reading mode and requires the author to implement ALL keyboard interaction. Only use it for genuine application-like widgets (spreadsheets, code editors). (Roselli / Sutton / Lauke / APG)',
|
|
198
|
+
},
|
|
199
|
+
schema: [],
|
|
200
|
+
},
|
|
201
|
+
create(context) {
|
|
202
|
+
return {
|
|
203
|
+
[h.elementVisitor](node) {
|
|
204
|
+
if (h.getRoleValue(node) === 'application')
|
|
205
|
+
context.report({ node: h.getAttr(node, 'role'), messageId: 'application' })
|
|
206
|
+
},
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ─── no-grid-role ─────────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
export function makeNoGridRole(h) {
|
|
215
|
+
return {
|
|
216
|
+
meta: {
|
|
217
|
+
type: 'suggestion',
|
|
218
|
+
docs: { description: 'Warn when role="grid" is used - almost always wrong outside spreadsheet widgets' },
|
|
219
|
+
messages: {
|
|
220
|
+
grid:
|
|
221
|
+
'role="grid" is for spreadsheet-like widgets with arrow-key cell navigation. Using it on data tables or result lists breaks natural table navigation. Use a native <table> instead. (Roselli: ARIA Grid As an Anti-Pattern)',
|
|
222
|
+
},
|
|
223
|
+
schema: [],
|
|
224
|
+
},
|
|
225
|
+
create(context) {
|
|
226
|
+
return {
|
|
227
|
+
[h.elementVisitor](node) {
|
|
228
|
+
if (h.getRoleValue(node) === 'grid')
|
|
229
|
+
context.report({ node: h.getAttr(node, 'role'), messageId: 'grid' })
|
|
230
|
+
},
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ─── no-menu-role-on-nav ──────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
export function makeNoMenuRoleOnNav(h) {
|
|
239
|
+
return {
|
|
240
|
+
meta: {
|
|
241
|
+
type: 'suggestion',
|
|
242
|
+
docs: { description: 'Warn when menu/menubar/menuitem roles are used - triggers AT application-mode keyboard handling' },
|
|
243
|
+
messages: {
|
|
244
|
+
navMenu:
|
|
245
|
+
'role="{{role}}" on a <nav> triggers AT application-mode keyboard expectations (arrow keys, not Tab). Use <nav><ul><li><a> for site navigation. (Roselli / Lauke)',
|
|
246
|
+
anyMenu:
|
|
247
|
+
'role="{{role}}" triggers AT application-mode keyboard handling. Only use menu roles for true app menus (File > Edit > View). For nav use <nav>, for disclosure use <button aria-expanded>. (Roselli / Lauke / Groves)',
|
|
248
|
+
},
|
|
249
|
+
schema: [],
|
|
250
|
+
},
|
|
251
|
+
create(context) {
|
|
252
|
+
return {
|
|
253
|
+
[h.elementVisitor](node) {
|
|
254
|
+
const role = h.getRoleValue(node)
|
|
255
|
+
if (!role || !NAV_MENU_ROLES.has(role)) return
|
|
256
|
+
const el = h.getElementName(node)
|
|
257
|
+
if (el === 'nav') {
|
|
258
|
+
context.report({ node: h.getAttr(node, 'role'), messageId: 'navMenu', data: { role } })
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
for (const ancestor of h.getAncestors(node)) {
|
|
262
|
+
if (h.getElementName(ancestor) === 'nav') {
|
|
263
|
+
context.report({ node: h.getAttr(node, 'role'), messageId: 'navMenu', data: { role } })
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
context.report({ node: h.getAttr(node, 'role'), messageId: 'anyMenu', data: { role } })
|
|
268
|
+
},
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ─── no-aria-roledescription ──────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
export function makeNoAriaRoledescription(h) {
|
|
277
|
+
return {
|
|
278
|
+
meta: {
|
|
279
|
+
type: 'suggestion',
|
|
280
|
+
docs: { description: 'Disallow aria-roledescription - almost always misused and does not translate' },
|
|
281
|
+
messages: {
|
|
282
|
+
roledescription:
|
|
283
|
+
'aria-roledescription overrides the AT role label and does not auto-translate. Use semantic HTML, visually-hidden text, or aria-labelledby instead. (Roselli: Avoid aria-roledescription)',
|
|
284
|
+
},
|
|
285
|
+
schema: [],
|
|
286
|
+
},
|
|
287
|
+
create(context) {
|
|
288
|
+
return {
|
|
289
|
+
[h.elementVisitor](node) {
|
|
290
|
+
const attr = h.getAttr(node, 'aria-roledescription')
|
|
291
|
+
if (attr) context.report({ node: attr, messageId: 'roledescription' })
|
|
292
|
+
},
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ─── no-aria-readonly ────────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
export function makeNoAriaReadonly(h) {
|
|
301
|
+
return {
|
|
302
|
+
meta: {
|
|
303
|
+
type: 'suggestion',
|
|
304
|
+
docs: { description: 'Disallow aria-readonly - virtually unsupported across AT' },
|
|
305
|
+
messages: {
|
|
306
|
+
readonly:
|
|
307
|
+
'aria-readonly has limited and inconsistent AT support. TalkBack has been known to misread it as "disabled". Prefer displaying read-only values as plain text, or use a visually-distinct disabled state with a visible explanation. (Roselli)',
|
|
308
|
+
},
|
|
309
|
+
schema: [],
|
|
310
|
+
},
|
|
311
|
+
create(context) {
|
|
312
|
+
return {
|
|
313
|
+
[h.elementVisitor](node) {
|
|
314
|
+
const attr = h.getAttr(node, 'aria-readonly')
|
|
315
|
+
if (attr) context.report({ node: attr, messageId: 'readonly' })
|
|
316
|
+
},
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
// ─── no-aria-hidden-in-link ──────────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
export function makeNoAriaHiddenInLink(h) {
|
|
326
|
+
return {
|
|
327
|
+
meta: {
|
|
328
|
+
type: 'problem',
|
|
329
|
+
docs: { description: 'Disallow <a> elements whose only content is aria-hidden (phantom link)' },
|
|
330
|
+
messages: {
|
|
331
|
+
hiddenInLink:
|
|
332
|
+
'This <a> contains only aria-hidden content - AT users encounter a link with no name. Add visible text, a visually-hidden <span>, or an SVG <title> inside the link. (Roselli)',
|
|
333
|
+
},
|
|
334
|
+
schema: [],
|
|
335
|
+
},
|
|
336
|
+
create(context) {
|
|
337
|
+
return {
|
|
338
|
+
[h.elementVisitor](node) {
|
|
339
|
+
if (h.getElementName(node) !== 'a') return
|
|
340
|
+
if (h.hasAccessibleName(node)) return
|
|
341
|
+
if (h.hasOnlyHiddenChildren(node))
|
|
342
|
+
context.report({ node, messageId: 'hiddenInLink' })
|
|
343
|
+
},
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ─── no-log-with-interactive-children ────────────────────────────────────────
|
|
350
|
+
|
|
351
|
+
const INTERACTIVE_JSX_ELEMENTS = new Set(['button', 'input', 'select', 'textarea', 'a'])
|
|
352
|
+
|
|
353
|
+
export function makeNoLogWithInteractiveChildren(h) {
|
|
354
|
+
return {
|
|
355
|
+
meta: {
|
|
356
|
+
type: 'suggestion',
|
|
357
|
+
docs: { description: 'Disallow interactive elements inside role="log"' },
|
|
358
|
+
messages: {
|
|
359
|
+
interactiveChild:
|
|
360
|
+
'<{{el}}> inside role="log" breaks AT expectations. role="log" is for read-only async content (chat history, server logs). Move interactive controls outside the log region. (APG: Log Role)',
|
|
361
|
+
},
|
|
362
|
+
schema: [],
|
|
363
|
+
},
|
|
364
|
+
create(context) {
|
|
365
|
+
return {
|
|
366
|
+
[h.elementVisitor](node) {
|
|
367
|
+
const el = h.getElementName(node)
|
|
368
|
+
if (!el || !INTERACTIVE_JSX_ELEMENTS.has(el)) return
|
|
369
|
+
for (const ancestor of h.getAncestors(node)) {
|
|
370
|
+
if (h.getRoleValue(ancestor) === 'log') {
|
|
371
|
+
context.report({ node, messageId: 'interactiveChild', data: { el } })
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
},
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ─── no-presentation-on-focusable ────────────────────────────────────────────
|
|
382
|
+
|
|
383
|
+
export function makeNoPresentationOnFocusable(h) {
|
|
384
|
+
return {
|
|
385
|
+
meta: {
|
|
386
|
+
type: 'problem',
|
|
387
|
+
docs: { description: 'Disallow role="presentation" or role="none" on focusable elements' },
|
|
388
|
+
messages: {
|
|
389
|
+
presentationFocusable:
|
|
390
|
+
'role="{{role}}" removes semantics but NOT focus. Keyboard users reach this element but AT users cannot identify it - a phantom control. Remove tabIndex/interactivity or remove the role. (Roselli / Lauke / O\'Hara - WCAG 2.1 SC 2.1.1)',
|
|
391
|
+
},
|
|
392
|
+
schema: [],
|
|
393
|
+
},
|
|
394
|
+
create(context) {
|
|
395
|
+
return {
|
|
396
|
+
[h.elementVisitor](node) {
|
|
397
|
+
const role = h.getRoleValue(node)
|
|
398
|
+
if (role !== 'presentation' && role !== 'none') return
|
|
399
|
+
const isFocusable =
|
|
400
|
+
h.hasAttr(node, 'tabIndex') || h.hasAttr(node, 'tabindex') ||
|
|
401
|
+
h.hasAttr(node, 'onClick') || h.hasAttr(node, 'onKeyDown') || h.hasAttr(node, 'onKeyPress') ||
|
|
402
|
+
h.hasAttr(node, '@click') || h.hasAttr(node, '(click)') ||
|
|
403
|
+
(h.getElementName(node) === 'a' && h.hasAttr(node, 'href'))
|
|
404
|
+
if (isFocusable)
|
|
405
|
+
context.report({ node: h.getAttr(node, 'role'), messageId: 'presentationFocusable', data: { role } })
|
|
406
|
+
},
|
|
407
|
+
}
|
|
408
|
+
},
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ─── no-group-without-name ───────────────────────────────────────────────────
|
|
413
|
+
|
|
414
|
+
export function makeNoGroupWithoutName(h) {
|
|
415
|
+
return {
|
|
416
|
+
meta: {
|
|
417
|
+
type: 'suggestion',
|
|
418
|
+
docs: { description: 'Require accessible name on role="group" that contains form controls' },
|
|
419
|
+
messages: {
|
|
420
|
+
missingName:
|
|
421
|
+
'role="group" containing form controls must have aria-label or aria-labelledby. Without a name the grouping is invisible to AT. Use <fieldset>/<legend> for form groups where possible. (APG / Groves - WCAG 1.3.1)',
|
|
422
|
+
},
|
|
423
|
+
schema: [],
|
|
424
|
+
},
|
|
425
|
+
create(context) {
|
|
426
|
+
return {
|
|
427
|
+
[h.elementWithChildrenVisitor](node) {
|
|
428
|
+
const opening = h.getOpeningElement(node)
|
|
429
|
+
if (h.getRoleValue(opening) !== 'group') return
|
|
430
|
+
if (h.hasAccessibleName(opening)) return
|
|
431
|
+
const hasFormChild = h.getChildOpeningElementsFromWrapper(node).some(childEl => {
|
|
432
|
+
const name = h.getElementName(childEl)
|
|
433
|
+
return name && FORM_ELEMENTS.has(name)
|
|
434
|
+
})
|
|
435
|
+
if (hasFormChild)
|
|
436
|
+
context.report({ node: opening, messageId: 'missingName' })
|
|
437
|
+
},
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ─── no-redundant-aria-hidden-with-presentation ──────────────────────────────
|
|
444
|
+
|
|
445
|
+
export function makeNoRedundantAriaHiddenWithPresentation(h) {
|
|
446
|
+
return {
|
|
447
|
+
meta: {
|
|
448
|
+
type: 'suggestion',
|
|
449
|
+
docs: { description: 'Disallow redundant aria-hidden="true" combined with role="none" or role="presentation"' },
|
|
450
|
+
messages: {
|
|
451
|
+
redundant:
|
|
452
|
+
'aria-hidden="true" already removes this element from the accessibility tree - role="{{role}}" is redundant. Use one or the other, not both. (O\'Hara)',
|
|
453
|
+
},
|
|
454
|
+
schema: [],
|
|
455
|
+
},
|
|
456
|
+
create(context) {
|
|
457
|
+
return {
|
|
458
|
+
[h.elementVisitor](node) {
|
|
459
|
+
const role = h.getRoleValue(node)
|
|
460
|
+
if (role !== 'none' && role !== 'presentation') return
|
|
461
|
+
const hiddenVal = h.getAttrStringValue(h.getAttr(node, 'aria-hidden'))
|
|
462
|
+
if (hiddenVal === 'true')
|
|
463
|
+
context.report({ node: h.getAttr(node, 'role'), messageId: 'redundant', data: { role } })
|
|
464
|
+
},
|
|
465
|
+
}
|
|
466
|
+
},
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ─── no-title-as-label ───────────────────────────────────────────────────────
|
|
471
|
+
|
|
472
|
+
const INPUT_TYPES_NEEDING_LABEL = new Set(['text', 'email', 'password', 'search', 'tel', 'url', 'number'])
|
|
473
|
+
|
|
474
|
+
export function makeNoTitleAsLabel(h) {
|
|
475
|
+
return {
|
|
476
|
+
meta: {
|
|
477
|
+
type: 'problem',
|
|
478
|
+
docs: { description: 'Disallow title attribute as the only accessible name on interactive elements' },
|
|
479
|
+
messages: {
|
|
480
|
+
titleOnly:
|
|
481
|
+
'The `title` attribute is not keyboard accessible (requires hover) and has inconsistent AT support. Interactive elements need a visible label, aria-label, or aria-labelledby. (Groves / O\'Hara)',
|
|
482
|
+
},
|
|
483
|
+
schema: [],
|
|
484
|
+
},
|
|
485
|
+
create(context) {
|
|
486
|
+
return {
|
|
487
|
+
[h.elementVisitor](node) {
|
|
488
|
+
if (!h.isInteractiveElement(node)) return
|
|
489
|
+
if (!h.hasAttr(node, 'title')) return
|
|
490
|
+
if (h.hasAccessibleName(node)) return
|
|
491
|
+
const el = h.getElementName(node)
|
|
492
|
+
if (el === 'input') {
|
|
493
|
+
const typeAttr = h.getAttrStringValue(h.getAttr(node, 'type')) ?? 'text'
|
|
494
|
+
if (INPUT_TYPES_NEEDING_LABEL.has(typeAttr))
|
|
495
|
+
context.report({ node: h.getAttr(node, 'title'), messageId: 'titleOnly' })
|
|
496
|
+
}
|
|
497
|
+
},
|
|
498
|
+
}
|
|
499
|
+
},
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ─── no-aria-owns-on-void ────────────────────────────────────────────────────
|
|
504
|
+
|
|
505
|
+
export function makeNoAriaOwnsOnVoid(h) {
|
|
506
|
+
return {
|
|
507
|
+
meta: {
|
|
508
|
+
type: 'problem',
|
|
509
|
+
docs: { description: 'Disallow aria-owns on void elements that cannot have children' },
|
|
510
|
+
messages: {
|
|
511
|
+
voidOwns:
|
|
512
|
+
'aria-owns on <{{el}}> is meaningless - void elements cannot have children. If you need to associate elements, use aria-controls (for widget relationships) or restructure the DOM. (O\'Hara / ARIA 1.2)',
|
|
513
|
+
},
|
|
514
|
+
schema: [],
|
|
515
|
+
},
|
|
516
|
+
create(context) {
|
|
517
|
+
return {
|
|
518
|
+
[h.elementVisitor](node) {
|
|
519
|
+
if (!h.hasAttr(node, 'aria-owns')) return
|
|
520
|
+
const el = h.getElementName(node)
|
|
521
|
+
if (el && VOID_ELEMENTS.has(el))
|
|
522
|
+
context.report({ node: h.getAttr(node, 'aria-owns'), messageId: 'voidOwns', data: { el } })
|
|
523
|
+
},
|
|
524
|
+
}
|
|
525
|
+
},
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ─── no-href-hash ─────────────────────────────────────────────────────────────
|
|
530
|
+
|
|
531
|
+
export function makeNoHrefHash(h) {
|
|
532
|
+
return {
|
|
533
|
+
meta: {
|
|
534
|
+
type: 'suggestion',
|
|
535
|
+
docs: { description: 'Disallow <a href="#"> - use <button> for actions' },
|
|
536
|
+
messages: {
|
|
537
|
+
hrefHash:
|
|
538
|
+
'<a href="#"> is a link used as a button. Links navigate, buttons perform actions. Use <button> for click handlers. If you need a hash link, use a real fragment id. (Sutton: Links vs Buttons)',
|
|
539
|
+
},
|
|
540
|
+
schema: [],
|
|
541
|
+
},
|
|
542
|
+
create(context) {
|
|
543
|
+
return {
|
|
544
|
+
[h.elementVisitor](node) {
|
|
545
|
+
if (h.getElementName(node) !== 'a') return
|
|
546
|
+
const hrefVal = h.getAttrStringValue(h.getAttr(node, 'href'))
|
|
547
|
+
if (hrefVal === '#' || hrefVal === '#/')
|
|
548
|
+
context.report({ node: h.getAttr(node, 'href'), messageId: 'hrefHash' })
|
|
549
|
+
},
|
|
550
|
+
}
|
|
551
|
+
},
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
// ─── warn-role-alert ─────────────────────────────────────────────────────────
|
|
557
|
+
|
|
558
|
+
export function makeWarnRoleAlert(h) {
|
|
559
|
+
return {
|
|
560
|
+
meta: {
|
|
561
|
+
type: 'suggestion',
|
|
562
|
+
docs: { description: 'Warn when role="alert" is used - prompt developer to confirm the interruption is warranted' },
|
|
563
|
+
messages: {
|
|
564
|
+
alert:
|
|
565
|
+
'role="alert" immediately interrupts the user. Confirm this is a genuine error or time-critical message. For status updates use role="status" (polite). For progress use aria-live="polite". (APG / Roselli / Sutton)',
|
|
566
|
+
},
|
|
567
|
+
schema: [],
|
|
568
|
+
},
|
|
569
|
+
create(context) {
|
|
570
|
+
return {
|
|
571
|
+
[h.elementVisitor](node) {
|
|
572
|
+
if (h.getRoleValue(node) === 'alert')
|
|
573
|
+
context.report({ node: h.getAttr(node, 'role'), messageId: 'alert' })
|
|
574
|
+
},
|
|
575
|
+
}
|
|
576
|
+
},
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ─── prefer-aria-disabled ────────────────────────────────────────────────────
|
|
581
|
+
|
|
582
|
+
// Native form controls that support HTML disabled per spec - aria-disabled is
|
|
583
|
+
// not the right substitute for these; native disabled is correct and expected.
|
|
584
|
+
const NATIVE_DISABLED_ELEMENTS = new Set(['input', 'select', 'textarea', 'option', 'optgroup', 'fieldset'])
|
|
585
|
+
|
|
586
|
+
export function makePreferAriaDisabled(h) {
|
|
587
|
+
return {
|
|
588
|
+
meta: {
|
|
589
|
+
type: 'suggestion',
|
|
590
|
+
docs: { description: 'Suggest aria-disabled over the HTML disabled attribute for better AT discoverability' },
|
|
591
|
+
messages: {
|
|
592
|
+
disabled:
|
|
593
|
+
'`disabled` removes the element from the tab order - keyboard and AT users cannot discover it or learn why it\'s unavailable. Consider aria-disabled="true" instead, which keeps the element reachable and lets you explain the reason. Guard the onClick handler when using aria-disabled. (Roselli: Don\'t Disable Form Controls)',
|
|
594
|
+
},
|
|
595
|
+
schema: [],
|
|
596
|
+
},
|
|
597
|
+
create(context) {
|
|
598
|
+
return {
|
|
599
|
+
[h.elementVisitor](node) {
|
|
600
|
+
if (!h.isInteractiveElement(node)) return
|
|
601
|
+
// Native form controls: HTML disabled is correct per spec, not aria-disabled
|
|
602
|
+
const elName = h.getElementName(node)
|
|
603
|
+
if (elName && NATIVE_DISABLED_ELEMENTS.has(elName)) return
|
|
604
|
+
const attr = h.getAttr(node, 'disabled')
|
|
605
|
+
if (!attr) return
|
|
606
|
+
// Only flag boolean disabled (not disabled={false})
|
|
607
|
+
const val = attr.value
|
|
608
|
+
// JSX: val === null is boolean true; val.type=JSXExpressionContainer with false literal is false
|
|
609
|
+
if (val === null) {
|
|
610
|
+
context.report({ node: attr, messageId: 'disabled' })
|
|
611
|
+
return
|
|
612
|
+
}
|
|
613
|
+
if (val.type === 'JSXExpressionContainer' && val.expression?.value === false) return
|
|
614
|
+
// Vue/Angular: empty string value means boolean true
|
|
615
|
+
if (typeof val === 'string' && val === '') {
|
|
616
|
+
context.report({ node: attr, messageId: 'disabled' })
|
|
617
|
+
return
|
|
618
|
+
}
|
|
619
|
+
// Generic: string value of "true" or empty
|
|
620
|
+
const strVal = h.getAttrStringValue(attr)
|
|
621
|
+
if (strVal === null || strVal === 'true' || strVal === '')
|
|
622
|
+
context.report({ node: attr, messageId: 'disabled' })
|
|
623
|
+
},
|
|
624
|
+
}
|
|
625
|
+
},
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// ─── no-tabs-without-structure ───────────────────────────────────────────────
|
|
630
|
+
|
|
631
|
+
export function makeNoTabsWithoutStructure(h) {
|
|
632
|
+
return {
|
|
633
|
+
meta: {
|
|
634
|
+
type: 'problem',
|
|
635
|
+
docs: { description: 'Enforce required ARIA attributes on tab/tablist/tabpanel roles' },
|
|
636
|
+
messages: {
|
|
637
|
+
tabMissingSelected:
|
|
638
|
+
'role="tab" requires aria-selected="true" or aria-selected="false". Without it AT cannot determine which tab is active. (APG: Tabs Pattern)',
|
|
639
|
+
tabpanelMissingLabel:
|
|
640
|
+
'role="tabpanel" requires aria-labelledby="TAB_ID" pointing to its controlling tab. Without it the panel has no accessible name. (APG: Tabs Pattern)',
|
|
641
|
+
tablistMissingName:
|
|
642
|
+
'role="tablist" with multiple tab sets on the page needs aria-label or aria-labelledby to distinguish them. (APG: Tabs Pattern)',
|
|
643
|
+
},
|
|
644
|
+
schema: [],
|
|
645
|
+
},
|
|
646
|
+
create(context) {
|
|
647
|
+
return {
|
|
648
|
+
[h.elementVisitor](node) {
|
|
649
|
+
const role = h.getRoleValue(node)
|
|
650
|
+
|
|
651
|
+
if (role === 'tab') {
|
|
652
|
+
if (!h.hasAttr(node, 'aria-selected'))
|
|
653
|
+
context.report({ node: h.getAttr(node, 'role'), messageId: 'tabMissingSelected' })
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if (role === 'tabpanel') {
|
|
657
|
+
if (!h.hasAttr(node, 'aria-labelledby'))
|
|
658
|
+
context.report({ node: h.getAttr(node, 'role'), messageId: 'tabpanelMissingLabel' })
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (role === 'tablist') {
|
|
662
|
+
if (!h.hasAccessibleName(node))
|
|
663
|
+
context.report({ node: h.getAttr(node, 'role'), messageId: 'tablistMissingName' })
|
|
664
|
+
}
|
|
665
|
+
},
|
|
666
|
+
}
|
|
667
|
+
},
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// ─── no-tab-without-controls ─────────────────────────────────────────────────
|
|
672
|
+
// Separate warn-level rule for aria-controls on tabs. The APG recommends it but
|
|
673
|
+
// does not require it - aria-labelledby on the panel is sufficient. Many solid
|
|
674
|
+
// production implementations omit aria-controls without breaking AT.
|
|
675
|
+
// Ref: APG Tabs Pattern
|
|
676
|
+
|
|
677
|
+
export function makeNoTabWithoutControls(h) {
|
|
678
|
+
return {
|
|
679
|
+
meta: {
|
|
680
|
+
type: 'suggestion',
|
|
681
|
+
docs: { description: 'Warn when role="tab" lacks aria-controls pointing to its tabpanel' },
|
|
682
|
+
messages: {
|
|
683
|
+
tabMissingControls:
|
|
684
|
+
'role="tab" should have aria-controls="PANEL_ID" pointing to its tabpanel. The explicit relationship helps JAWS users; aria-labelledby on the panel is the minimum required. (APG: Tabs Pattern)',
|
|
685
|
+
},
|
|
686
|
+
schema: [],
|
|
687
|
+
},
|
|
688
|
+
create(context) {
|
|
689
|
+
return {
|
|
690
|
+
[h.elementVisitor](node) {
|
|
691
|
+
if (h.getRoleValue(node) !== 'tab') return
|
|
692
|
+
if (!h.hasAttr(node, 'aria-controls'))
|
|
693
|
+
context.report({ node: h.getAttr(node, 'role'), messageId: 'tabMissingControls' })
|
|
694
|
+
},
|
|
695
|
+
}
|
|
696
|
+
},
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// ─── no-positive-tabindex ────────────────────────────────────────────────────
|
|
701
|
+
|
|
702
|
+
export function makeNoPositiveTabindex(h) {
|
|
703
|
+
return {
|
|
704
|
+
meta: {
|
|
705
|
+
type: 'problem',
|
|
706
|
+
docs: { description: 'Disallow tabIndex values greater than 0' },
|
|
707
|
+
messages: {
|
|
708
|
+
positive:
|
|
709
|
+
'tabIndex={{value}} creates an artificial tab order that overrides natural DOM flow, breaking keyboard and AT navigation. Use tabIndex={0} to add to the flow, or tabIndex={-1} to remove from it. (WebAIM / Lauke)',
|
|
710
|
+
},
|
|
711
|
+
schema: [],
|
|
712
|
+
},
|
|
713
|
+
create(context) {
|
|
714
|
+
return {
|
|
715
|
+
[h.elementVisitor](node) {
|
|
716
|
+
const attr = h.getAttr(node, 'tabIndex') ?? h.getAttr(node, 'tabindex')
|
|
717
|
+
if (!attr) return
|
|
718
|
+
const val = attr.value
|
|
719
|
+
let num = null
|
|
720
|
+
if (val?.type === 'JSXExpressionContainer' && val.expression.type === 'Literal')
|
|
721
|
+
num = Number(val.expression.value)
|
|
722
|
+
else if (val?.type === 'Literal')
|
|
723
|
+
num = Number(val.value)
|
|
724
|
+
else {
|
|
725
|
+
// Vue/Angular: plain string value
|
|
726
|
+
const strVal = h.getAttrStringValue(attr)
|
|
727
|
+
if (strVal !== null) num = Number(strVal)
|
|
728
|
+
}
|
|
729
|
+
if (num !== null && num > 0)
|
|
730
|
+
context.report({ node: attr, messageId: 'positive', data: { value: num } })
|
|
731
|
+
},
|
|
732
|
+
}
|
|
733
|
+
},
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// ─── no-target-blank-without-label ───────────────────────────────────────────
|
|
738
|
+
|
|
739
|
+
export function makeNoTargetBlankWithoutLabel(h) {
|
|
740
|
+
return {
|
|
741
|
+
meta: {
|
|
742
|
+
type: 'suggestion',
|
|
743
|
+
docs: { description: 'Warn when target="_blank" is used without communicating the new-tab behaviour' },
|
|
744
|
+
messages: {
|
|
745
|
+
targetBlank:
|
|
746
|
+
'target="_blank" opens a new tab without warning AT users. Add visually-hidden text "(opens in new tab)" or include it in aria-label/the link text so users can anticipate the context switch. (WebAIM / WCAG 3.2.2)',
|
|
747
|
+
},
|
|
748
|
+
schema: [],
|
|
749
|
+
},
|
|
750
|
+
create(context) {
|
|
751
|
+
return {
|
|
752
|
+
[h.elementVisitor](node) {
|
|
753
|
+
if (h.getElementName(node) !== 'a') return
|
|
754
|
+
const targetVal = h.getAttrStringValue(h.getAttr(node, 'target'))
|
|
755
|
+
if (targetVal !== '_blank') return
|
|
756
|
+
if (h.hasNewTabWarning?.(node)) return
|
|
757
|
+
context.report({ node: h.getAttr(node, 'target'), messageId: 'targetBlank' })
|
|
758
|
+
},
|
|
759
|
+
}
|
|
760
|
+
},
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// ─── no-autoplay-without-controls ────────────────────────────────────────────
|
|
765
|
+
|
|
766
|
+
export function makeNoAutoplayWithoutControls(h) {
|
|
767
|
+
return {
|
|
768
|
+
meta: {
|
|
769
|
+
type: 'problem',
|
|
770
|
+
docs: { description: 'Disallow autoPlay on media elements without controls' },
|
|
771
|
+
messages: {
|
|
772
|
+
autoplay:
|
|
773
|
+
'<{{el}} autoPlay> without controls violates WCAG 1.4.2. Users cannot pause or mute it; screen reader audio is disrupted. Add the controls attribute or a custom control UI. (WCAG 1.4.2 / WebAIM)',
|
|
774
|
+
},
|
|
775
|
+
schema: [],
|
|
776
|
+
},
|
|
777
|
+
create(context) {
|
|
778
|
+
return {
|
|
779
|
+
[h.elementVisitor](node) {
|
|
780
|
+
const el = h.getElementName(node)
|
|
781
|
+
if (el !== 'video' && el !== 'audio') return
|
|
782
|
+
if (!h.hasAttr(node, 'autoPlay') && !h.hasAttr(node, 'autoplay')) return
|
|
783
|
+
if (h.hasAttr(node, 'controls')) return
|
|
784
|
+
const autoPlayAttr = h.getAttr(node, 'autoPlay') ?? h.getAttr(node, 'autoplay')
|
|
785
|
+
context.report({ node: autoPlayAttr, messageId: 'autoplay', data: { el } })
|
|
786
|
+
},
|
|
787
|
+
}
|
|
788
|
+
},
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// ─── no-heading-inside-interactive ───────────────────────────────────────────
|
|
793
|
+
|
|
794
|
+
export function makeNoHeadingInsideInteractive(h) {
|
|
795
|
+
return {
|
|
796
|
+
meta: {
|
|
797
|
+
type: 'problem',
|
|
798
|
+
docs: { description: 'Disallow heading elements nested inside interactive elements' },
|
|
799
|
+
messages: {
|
|
800
|
+
headingInInteractive:
|
|
801
|
+
'<{{heading}}> inside <{{parent}}> breaks AT heading navigation and causes double-announcement. Move the heading outside the interactive element, or use CSS to style text without a heading tag. (Roselli / Pickering)',
|
|
802
|
+
},
|
|
803
|
+
schema: [],
|
|
804
|
+
},
|
|
805
|
+
create(context) {
|
|
806
|
+
return {
|
|
807
|
+
[h.elementVisitor](node) {
|
|
808
|
+
const el = h.getElementName(node)
|
|
809
|
+
if (!el || !HEADING_ELEMENTS.has(el)) return
|
|
810
|
+
for (const ancestor of h.getAncestors(node)) {
|
|
811
|
+
const parentEl = h.getElementName(ancestor)
|
|
812
|
+
const parentRole = h.getRoleValue(ancestor)
|
|
813
|
+
if ((parentEl && INTERACTIVE_ELEMENTS.has(parentEl)) ||
|
|
814
|
+
(parentRole && INTERACTIVE_ROLES.has(parentRole))) {
|
|
815
|
+
context.report({
|
|
816
|
+
node,
|
|
817
|
+
messageId: 'headingInInteractive',
|
|
818
|
+
data: { heading: el, parent: parentEl ?? `[role="${parentRole}"]` },
|
|
819
|
+
})
|
|
820
|
+
return
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
},
|
|
824
|
+
}
|
|
825
|
+
},
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// ─── no-placeholder-only ─────────────────────────────────────────────────────
|
|
830
|
+
|
|
831
|
+
export function makeNoPlaceholderOnly(h) {
|
|
832
|
+
return {
|
|
833
|
+
meta: {
|
|
834
|
+
type: 'problem',
|
|
835
|
+
docs: { description: 'Disallow form inputs that rely solely on placeholder as their accessible label' },
|
|
836
|
+
messages: {
|
|
837
|
+
placeholderOnly:
|
|
838
|
+
'placeholder disappears on focus - it cannot be the sole label for this input. Add a <label>, aria-label, or aria-labelledby. Placeholder may remain as supplemental hint text. (WebAIM Million #3 / WCAG 1.3.1)',
|
|
839
|
+
},
|
|
840
|
+
schema: [],
|
|
841
|
+
},
|
|
842
|
+
create(context) {
|
|
843
|
+
return {
|
|
844
|
+
[h.elementVisitor](node) {
|
|
845
|
+
if (h.getElementName(node) !== 'input') return
|
|
846
|
+
if (!h.hasAttr(node, 'placeholder')) return
|
|
847
|
+
if (h.hasAccessibleName(node)) return
|
|
848
|
+
context.report({ node: h.getAttr(node, 'placeholder'), messageId: 'placeholderOnly' })
|
|
849
|
+
},
|
|
850
|
+
}
|
|
851
|
+
},
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// ─── no-empty-button ─────────────────────────────────────────────────────────
|
|
856
|
+
// WebAIM Million #2 failure: empty or icon-only buttons with no accessible name.
|
|
857
|
+
// An icon <button> with only aria-hidden children has no accessible name.
|
|
858
|
+
// Ref: WebAIM Million 2024; WCAG 4.1.2; axe-core (MPL-2.0, reimplemented)
|
|
859
|
+
|
|
860
|
+
export function makeNoEmptyButton(h) {
|
|
861
|
+
return {
|
|
862
|
+
meta: {
|
|
863
|
+
type: 'problem',
|
|
864
|
+
docs: { description: 'Disallow <button> elements with no accessible name' },
|
|
865
|
+
messages: {
|
|
866
|
+
emptyButton:
|
|
867
|
+
'This <button> has no accessible name - AT users encounter a nameless control. Add visible text, aria-label, or aria-labelledby. For icon-only buttons, add aria-label or a visually-hidden <span>. (WebAIM Million #2 / WCAG 4.1.2)',
|
|
868
|
+
},
|
|
869
|
+
schema: [],
|
|
870
|
+
},
|
|
871
|
+
create(context) {
|
|
872
|
+
return {
|
|
873
|
+
[h.elementVisitor](node) {
|
|
874
|
+
if (h.getElementName(node) !== 'button') return
|
|
875
|
+
if (h.hasAccessibleName(node)) return
|
|
876
|
+
if (!h.hasOnlyHiddenChildren(node)) return
|
|
877
|
+
context.report({ node, messageId: 'emptyButton' })
|
|
878
|
+
},
|
|
879
|
+
}
|
|
880
|
+
},
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// ─── no-image-role-without-name ──────────────────────────────────────────────
|
|
885
|
+
// role="img" marks a container as an image. Without an accessible name the image
|
|
886
|
+
// is meaningless to AT. Particularly common with SVG composed of multiple shapes.
|
|
887
|
+
// Ref: APG; ARIA 1.2; O'Hara scottohara.me/blog/2019/05/22/contextual-images-svgs-and-a11y.html
|
|
888
|
+
|
|
889
|
+
export function makeNoImageRoleWithoutName(h) {
|
|
890
|
+
return {
|
|
891
|
+
meta: {
|
|
892
|
+
type: 'problem',
|
|
893
|
+
docs: { description: 'Require accessible name on role="img"' },
|
|
894
|
+
messages: {
|
|
895
|
+
missingName:
|
|
896
|
+
'role="img" requires an accessible name (aria-label or aria-labelledby) to convey what the image depicts. (APG / O\'Hara - WCAG 4.1.2)',
|
|
897
|
+
},
|
|
898
|
+
schema: [],
|
|
899
|
+
},
|
|
900
|
+
create(context) {
|
|
901
|
+
return {
|
|
902
|
+
[h.elementVisitor](node) {
|
|
903
|
+
if (h.getRoleValue(node) !== 'img') return
|
|
904
|
+
if (h.hasAccessibleName(node)) return
|
|
905
|
+
context.report({ node: h.getAttr(node, 'role'), messageId: 'missingName' })
|
|
906
|
+
},
|
|
907
|
+
}
|
|
908
|
+
},
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
// ─── no-spinbutton-without-range ─────────────────────────────────────────────
|
|
914
|
+
// role="spinbutton" requires aria-valuenow, aria-valuemin, and aria-valuemax.
|
|
915
|
+
// Without these the widget is incomplete and AT cannot convey the value.
|
|
916
|
+
// Ref: ARIA 1.2 §5.3.21; APG Spinbutton Pattern
|
|
917
|
+
|
|
918
|
+
export function makeNoSpinbuttonWithoutRange(h) {
|
|
919
|
+
return {
|
|
920
|
+
meta: {
|
|
921
|
+
type: 'problem',
|
|
922
|
+
docs: { description: 'Require aria-valuenow/min/max on role="spinbutton"' },
|
|
923
|
+
messages: {
|
|
924
|
+
missingValueNow:
|
|
925
|
+
'role="spinbutton" requires aria-valuenow so AT can announce the current value. (ARIA 1.2 / APG: Spinbutton)',
|
|
926
|
+
missingValueRange:
|
|
927
|
+
'role="spinbutton" requires aria-valuemin and aria-valuemax to define the valid range. (ARIA 1.2 / APG: Spinbutton)',
|
|
928
|
+
},
|
|
929
|
+
schema: [],
|
|
930
|
+
},
|
|
931
|
+
create(context) {
|
|
932
|
+
return {
|
|
933
|
+
[h.elementVisitor](node) {
|
|
934
|
+
if (h.getRoleValue(node) !== 'spinbutton') return
|
|
935
|
+
if (!h.hasAttr(node, 'aria-valuenow'))
|
|
936
|
+
context.report({ node: h.getAttr(node, 'role'), messageId: 'missingValueNow' })
|
|
937
|
+
if (!h.hasAttr(node, 'aria-valuemin') || !h.hasAttr(node, 'aria-valuemax'))
|
|
938
|
+
context.report({ node: h.getAttr(node, 'role'), messageId: 'missingValueRange' })
|
|
939
|
+
},
|
|
940
|
+
}
|
|
941
|
+
},
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// ─── no-slider-without-range ─────────────────────────────────────────────────
|
|
946
|
+
// role="slider" requires aria-valuenow, aria-valuemin, aria-valuemax.
|
|
947
|
+
// Ref: ARIA 1.2 §5.3.20; APG Slider Pattern
|
|
948
|
+
|
|
949
|
+
export function makeNoSliderWithoutRange(h) {
|
|
950
|
+
return {
|
|
951
|
+
meta: {
|
|
952
|
+
type: 'problem',
|
|
953
|
+
docs: { description: 'Require aria-valuenow/min/max on role="slider"' },
|
|
954
|
+
messages: {
|
|
955
|
+
missingRange:
|
|
956
|
+
'role="slider" requires aria-valuenow, aria-valuemin, and aria-valuemax. Without them AT cannot announce the current value or valid range. (ARIA 1.2 / APG: Slider)',
|
|
957
|
+
},
|
|
958
|
+
schema: [],
|
|
959
|
+
},
|
|
960
|
+
create(context) {
|
|
961
|
+
return {
|
|
962
|
+
[h.elementVisitor](node) {
|
|
963
|
+
if (h.getRoleValue(node) !== 'slider') return
|
|
964
|
+
const missing = ['aria-valuenow', 'aria-valuemin', 'aria-valuemax'].filter(a => !h.hasAttr(node, a))
|
|
965
|
+
if (missing.length)
|
|
966
|
+
context.report({ node: h.getAttr(node, 'role'), messageId: 'missingRange' })
|
|
967
|
+
},
|
|
968
|
+
}
|
|
969
|
+
},
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// ─── no-combobox-without-expanded ────────────────────────────────────────────
|
|
974
|
+
// role="combobox" requires aria-expanded to convey open/closed state to AT.
|
|
975
|
+
// Ref: ARIA 1.2 §5.3.3; APG Combobox Pattern
|
|
976
|
+
|
|
977
|
+
export function makeNoComboboxWithoutExpanded(h) {
|
|
978
|
+
return {
|
|
979
|
+
meta: {
|
|
980
|
+
type: 'problem',
|
|
981
|
+
docs: { description: 'Require aria-expanded on role="combobox"' },
|
|
982
|
+
messages: {
|
|
983
|
+
missingExpanded:
|
|
984
|
+
'role="combobox" requires aria-expanded to communicate open/closed state to AT. (ARIA 1.2 / APG: Combobox)',
|
|
985
|
+
},
|
|
986
|
+
schema: [],
|
|
987
|
+
},
|
|
988
|
+
create(context) {
|
|
989
|
+
return {
|
|
990
|
+
[h.elementVisitor](node) {
|
|
991
|
+
if (h.getRoleValue(node) !== 'combobox') return
|
|
992
|
+
if (!h.hasAttr(node, 'aria-expanded'))
|
|
993
|
+
context.report({ node: h.getAttr(node, 'role'), messageId: 'missingExpanded' })
|
|
994
|
+
},
|
|
995
|
+
}
|
|
996
|
+
},
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
// ─── no-mouse-only-events ────────────────────────────────────────────────────
|
|
1002
|
+
// onMouseEnter/onMouseLeave/onMouseOver without keyboard equivalents (onFocus/
|
|
1003
|
+
// onBlur) leaves those interactions unreachable for keyboard and switch users.
|
|
1004
|
+
// This is a direct WCAG 2.1.1 (Keyboard) failure.
|
|
1005
|
+
// Note: onMouseMove is intentionally excluded - drag/drawing interactions
|
|
1006
|
+
// have no keyboard equivalent by nature and should be handled separately.
|
|
1007
|
+
// Ref: WCAG 2.1.1; MDN Accessibility; cross-practitioner consensus
|
|
1008
|
+
|
|
1009
|
+
const MOUSE_ONLY_PAIRS = [
|
|
1010
|
+
{ mouse: 'onMouseEnter', keyboard: 'onFocus' },
|
|
1011
|
+
{ mouse: 'onMouseLeave', keyboard: 'onBlur' },
|
|
1012
|
+
{ mouse: 'onMouseOver', keyboard: 'onFocus' },
|
|
1013
|
+
{ mouse: 'onMouseOut', keyboard: 'onBlur' },
|
|
1014
|
+
]
|
|
1015
|
+
|
|
1016
|
+
export function makeNoMouseOnlyEvents(h) {
|
|
1017
|
+
return {
|
|
1018
|
+
meta: {
|
|
1019
|
+
type: 'problem',
|
|
1020
|
+
docs: { description: 'Disallow mouse-only event handlers without keyboard equivalents' },
|
|
1021
|
+
messages: {
|
|
1022
|
+
missingKeyboard:
|
|
1023
|
+
'{{mouse}} without {{keyboard}} leaves this interaction unreachable by keyboard. Add {{keyboard}} (and {{blur}} for cleanup if needed) to support keyboard and switch users. (WCAG 2.1.1)',
|
|
1024
|
+
},
|
|
1025
|
+
schema: [],
|
|
1026
|
+
},
|
|
1027
|
+
create(context) {
|
|
1028
|
+
return {
|
|
1029
|
+
[h.elementVisitor](node) {
|
|
1030
|
+
// aria-hidden elements are removed from the AT tree - mouse-only events can't harm keyboard users there
|
|
1031
|
+
if (h.getAttrStringValue(h.getAttr(node, 'aria-hidden')) === 'true') return
|
|
1032
|
+
for (const { mouse, keyboard } of MOUSE_ONLY_PAIRS) {
|
|
1033
|
+
if (!h.hasAttr(node, mouse)) continue
|
|
1034
|
+
if (h.hasAttr(node, keyboard)) continue
|
|
1035
|
+
// onClick already implies keyboard access - skip if onClick present
|
|
1036
|
+
if (h.hasAttr(node, 'onClick')) continue
|
|
1037
|
+
context.report({
|
|
1038
|
+
node: h.getAttr(node, mouse),
|
|
1039
|
+
messageId: 'missingKeyboard',
|
|
1040
|
+
data: { mouse, keyboard, blur: keyboard === 'onFocus' ? ' and onBlur' : '' },
|
|
1041
|
+
})
|
|
1042
|
+
}
|
|
1043
|
+
},
|
|
1044
|
+
}
|
|
1045
|
+
},
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// ─── no-listbox-without-option ───────────────────────────────────────────────
|
|
1050
|
+
// ARIA 1.2: listbox required owned elements = option (or group > option).
|
|
1051
|
+
// A listbox with no option children is an empty, non-functional widget.
|
|
1052
|
+
// Ref: ARIA 1.2 §5.3.13; APG Listbox Pattern
|
|
1053
|
+
|
|
1054
|
+
export function makeNoListboxWithoutOption(h) {
|
|
1055
|
+
return {
|
|
1056
|
+
meta: {
|
|
1057
|
+
type: 'problem',
|
|
1058
|
+
docs: { description: 'Require role="option" children inside role="listbox"' },
|
|
1059
|
+
messages: {
|
|
1060
|
+
missingOption:
|
|
1061
|
+
'role="listbox" must contain elements with role="option" (directly or via role="group"). Without options the listbox is empty and non-functional for AT. (ARIA 1.2 §5.3.13 / APG: Listbox Pattern)',
|
|
1062
|
+
},
|
|
1063
|
+
schema: [],
|
|
1064
|
+
},
|
|
1065
|
+
create(context) {
|
|
1066
|
+
return {
|
|
1067
|
+
[h.elementWithChildrenVisitor](node) {
|
|
1068
|
+
const opening = h.getOpeningElement(node)
|
|
1069
|
+
if (h.getRoleValue(opening) !== 'listbox') return
|
|
1070
|
+
const hasOption = h.getChildOpeningElementsFromWrapper(node).some(
|
|
1071
|
+
child => h.getRoleValue(child) === 'option' || h.getRoleValue(child) === 'group'
|
|
1072
|
+
)
|
|
1073
|
+
if (!hasOption)
|
|
1074
|
+
context.report({ node: opening, messageId: 'missingOption' })
|
|
1075
|
+
},
|
|
1076
|
+
}
|
|
1077
|
+
},
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// ─── no-tree-without-treeitem ─────────────────────────────────────────────────
|
|
1082
|
+
// APG: "Each element serving as a tree node has role treeitem."
|
|
1083
|
+
// A tree with no treeitem children is structurally broken.
|
|
1084
|
+
// Ref: ARIA 1.2 §5.3.25; APG Tree View Pattern
|
|
1085
|
+
|
|
1086
|
+
export function makeNoTreeWithoutTreeitem(h) {
|
|
1087
|
+
return {
|
|
1088
|
+
meta: {
|
|
1089
|
+
type: 'problem',
|
|
1090
|
+
docs: { description: 'Require role="treeitem" children inside role="tree"' },
|
|
1091
|
+
messages: {
|
|
1092
|
+
missingTreeitem:
|
|
1093
|
+
'role="tree" must contain elements with role="treeitem". Without treeitems the tree is structurally broken and non-functional for AT. (ARIA 1.2 §5.3.25 / APG: Tree View Pattern)',
|
|
1094
|
+
},
|
|
1095
|
+
schema: [],
|
|
1096
|
+
},
|
|
1097
|
+
create(context) {
|
|
1098
|
+
return {
|
|
1099
|
+
[h.elementWithChildrenVisitor](node) {
|
|
1100
|
+
const opening = h.getOpeningElement(node)
|
|
1101
|
+
if (h.getRoleValue(opening) !== 'tree') return
|
|
1102
|
+
const hasTreeitem = h.getChildOpeningElementsFromWrapper(node).some(
|
|
1103
|
+
child => h.getRoleValue(child) === 'treeitem' || h.getRoleValue(child) === 'group'
|
|
1104
|
+
)
|
|
1105
|
+
if (!hasTreeitem)
|
|
1106
|
+
context.report({ node: opening, messageId: 'missingTreeitem' })
|
|
1107
|
+
},
|
|
1108
|
+
}
|
|
1109
|
+
},
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// ─── no-feed-without-article ──────────────────────────────────────────────────
|
|
1114
|
+
// APG: "Each unit of content in a feed is contained in an element with role article."
|
|
1115
|
+
// A feed with no article children violates the required owned elements contract.
|
|
1116
|
+
// Ref: ARIA 1.2 feed role; APG Feed Pattern
|
|
1117
|
+
|
|
1118
|
+
export function makeNoFeedWithoutArticle(h) {
|
|
1119
|
+
return {
|
|
1120
|
+
meta: {
|
|
1121
|
+
type: 'problem',
|
|
1122
|
+
docs: { description: 'Require role="article" children inside role="feed"' },
|
|
1123
|
+
messages: {
|
|
1124
|
+
missingArticle:
|
|
1125
|
+
'role="feed" must contain elements with role="article". The APG requires all feed content to be in article elements so AT can navigate between items. (ARIA 1.2 / APG: Feed Pattern)',
|
|
1126
|
+
},
|
|
1127
|
+
schema: [],
|
|
1128
|
+
},
|
|
1129
|
+
create(context) {
|
|
1130
|
+
return {
|
|
1131
|
+
[h.elementWithChildrenVisitor](node) {
|
|
1132
|
+
const opening = h.getOpeningElement(node)
|
|
1133
|
+
if (h.getRoleValue(opening) !== 'feed') return
|
|
1134
|
+
const hasArticle = h.getChildOpeningElementsFromWrapper(node).some(
|
|
1135
|
+
child => h.getRoleValue(child) === 'article' || h.getElementName(child) === 'article'
|
|
1136
|
+
)
|
|
1137
|
+
if (!hasArticle)
|
|
1138
|
+
context.report({ node: opening, messageId: 'missingArticle' })
|
|
1139
|
+
},
|
|
1140
|
+
}
|
|
1141
|
+
},
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// ─── no-aria-activedescendant-without-id ─────────────────────────────────────
|
|
1146
|
+
// ARIA 1.2: aria-activedescendant must reference a valid ID.
|
|
1147
|
+
// At lint time we can verify the value is a non-empty static string ID
|
|
1148
|
+
// (not empty, not a dynamic expression we can't resolve).
|
|
1149
|
+
// Ref: ARIA 1.2 §6.6.3
|
|
1150
|
+
|
|
1151
|
+
export function makeNoAriaActivedescendantWithoutId(h) {
|
|
1152
|
+
return {
|
|
1153
|
+
meta: {
|
|
1154
|
+
type: 'problem',
|
|
1155
|
+
docs: { description: 'Require aria-activedescendant to have a non-empty static ID value' },
|
|
1156
|
+
messages: {
|
|
1157
|
+
emptyId:
|
|
1158
|
+
'aria-activedescendant must reference a non-empty element ID. An empty or missing value means no descendant is active, which confuses AT. (ARIA 1.2 §6.6.3)',
|
|
1159
|
+
dynamicOnly:
|
|
1160
|
+
'aria-activedescendant value cannot be verified statically - ensure it always resolves to a valid element ID at runtime. (ARIA 1.2 §6.6.3)',
|
|
1161
|
+
},
|
|
1162
|
+
schema: [],
|
|
1163
|
+
},
|
|
1164
|
+
create(context) {
|
|
1165
|
+
return {
|
|
1166
|
+
[h.elementVisitor](node) {
|
|
1167
|
+
const attr = h.getAttr(node, 'aria-activedescendant')
|
|
1168
|
+
if (!attr) return
|
|
1169
|
+
const val = h.getAttrStringValue(attr)
|
|
1170
|
+
if (val === null) {
|
|
1171
|
+
// Dynamic value - warn but don't error, we can't resolve it
|
|
1172
|
+
context.report({ node: attr, messageId: 'dynamicOnly' })
|
|
1173
|
+
return
|
|
1174
|
+
}
|
|
1175
|
+
if (val.trim() === '')
|
|
1176
|
+
context.report({ node: attr, messageId: 'emptyId' })
|
|
1177
|
+
},
|
|
1178
|
+
}
|
|
1179
|
+
},
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// ─── no-dialog-without-close ─────────────────────────────────────────────────
|
|
1184
|
+
// APG: "It is strongly recommended that the tab sequence of all dialogs include
|
|
1185
|
+
// a visible element with role button that closes the dialog."
|
|
1186
|
+
// We can only detect a close button statically by looking for a button with
|
|
1187
|
+
// a close-like aria-label or text content. Warn rather than error - Escape key
|
|
1188
|
+
// alone satisfies the keyboard requirement even without a visible close button.
|
|
1189
|
+
// Ref: APG Dialog (Modal) Pattern; WCAG 2.1.2
|
|
1190
|
+
|
|
1191
|
+
export function makeNoDialogWithoutClose(h) {
|
|
1192
|
+
const CLOSE_PATTERN = /\b(close|dismiss|cancel|✕|×|x)\b/i
|
|
1193
|
+
|
|
1194
|
+
return {
|
|
1195
|
+
meta: {
|
|
1196
|
+
type: 'suggestion',
|
|
1197
|
+
docs: { description: 'Warn when role="dialog" has no detectable close button' },
|
|
1198
|
+
messages: {
|
|
1199
|
+
missingClose:
|
|
1200
|
+
'role="dialog" has no detectable close button. The APG strongly recommends a visible close button inside every dialog. Escape key alone is insufficient for pointer-only users. (APG: Dialog Pattern / WCAG 2.1.2)',
|
|
1201
|
+
},
|
|
1202
|
+
schema: [],
|
|
1203
|
+
},
|
|
1204
|
+
create(context) {
|
|
1205
|
+
return {
|
|
1206
|
+
[h.elementWithChildrenVisitor](node) {
|
|
1207
|
+
const opening = h.getOpeningElement(node)
|
|
1208
|
+
if (h.getRoleValue(opening) !== 'dialog') return
|
|
1209
|
+
const hasClose = h.getChildOpeningElementsFromWrapper(node).some(child => {
|
|
1210
|
+
const el = h.getElementName(child)
|
|
1211
|
+
const role = h.getRoleValue(child)
|
|
1212
|
+
if (el !== 'button' && role !== 'button') return false
|
|
1213
|
+
const label = h.getAttrStringValue(h.getAttr(child, 'aria-label')) ?? ''
|
|
1214
|
+
return CLOSE_PATTERN.test(label)
|
|
1215
|
+
})
|
|
1216
|
+
if (!hasClose)
|
|
1217
|
+
context.report({ node: opening, messageId: 'missingClose' })
|
|
1218
|
+
},
|
|
1219
|
+
}
|
|
1220
|
+
},
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// ═══ Rules that fill jsx-a11y gaps for Vue/Angular consumers ═════════════════
|
|
1225
|
+
// These are NOT included in the JSX config - jsx-a11y already covers them there.
|
|
1226
|
+
// They are included in neighbor-eslint-vue.mjs and neighbor-eslint-angular.mjs.
|
|
1227
|
+
|
|
1228
|
+
// ─── no-anchor-ambiguous-text ────────────────────────────────────────────────
|
|
1229
|
+
// Links with generic text like "click here", "read more" are meaningless out of
|
|
1230
|
+
// context for AT users navigating by links. WCAG 2.4.4 Link Purpose (In Context).
|
|
1231
|
+
// Ref: WCAG 2.4.4; WebAIM: Links and Hypertext
|
|
1232
|
+
|
|
1233
|
+
const AMBIGUOUS_LINK_TEXT = new Set([
|
|
1234
|
+
'click here', 'here', 'read more', 'more', 'learn more', 'this',
|
|
1235
|
+
'link', 'button', 'details', 'info', 'information', 'click', 'tap',
|
|
1236
|
+
])
|
|
1237
|
+
|
|
1238
|
+
export function makeNoAnchorAmbiguousText(h) {
|
|
1239
|
+
return {
|
|
1240
|
+
meta: {
|
|
1241
|
+
type: 'suggestion',
|
|
1242
|
+
docs: { description: 'Disallow ambiguous link text like "click here" or "read more"' },
|
|
1243
|
+
messages: {
|
|
1244
|
+
ambiguous:
|
|
1245
|
+
'Link text "{{text}}" is ambiguous out of context. AT users navigating by links cannot determine the link destination. Use descriptive text or supplement with aria-label. (WCAG 2.4.4)',
|
|
1246
|
+
},
|
|
1247
|
+
schema: [],
|
|
1248
|
+
},
|
|
1249
|
+
create(context) {
|
|
1250
|
+
return {
|
|
1251
|
+
[h.elementVisitor](node) {
|
|
1252
|
+
if (h.getElementName(node) !== 'a') return
|
|
1253
|
+
// If there's an aria-label it overrides visible text - skip
|
|
1254
|
+
if (h.hasAttr(node, 'aria-label') || h.hasAttr(node, 'aria-labelledby')) return
|
|
1255
|
+
// We can only check static className/text at lint time - skip dynamic content
|
|
1256
|
+
const role = h.getRoleValue(node)
|
|
1257
|
+
if (role && role !== 'link') return
|
|
1258
|
+
// Check aria-label value if present
|
|
1259
|
+
const label = (h.getAttrStringValue(h.getAttr(node, 'aria-label')) ?? '').trim().toLowerCase()
|
|
1260
|
+
if (label && AMBIGUOUS_LINK_TEXT.has(label))
|
|
1261
|
+
context.report({ node, messageId: 'ambiguous', data: { text: label } })
|
|
1262
|
+
},
|
|
1263
|
+
}
|
|
1264
|
+
},
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// ─── no-anchor-no-content ─────────────────────────────────────────────────────
|
|
1269
|
+
// <a> with no children and no aria-label has no accessible name - phantom link.
|
|
1270
|
+
// Ref: WCAG 4.1.2 Name, Role, Value; WCAG 2.4.4
|
|
1271
|
+
|
|
1272
|
+
export function makeNoAnchorNoContent(h) {
|
|
1273
|
+
return {
|
|
1274
|
+
meta: {
|
|
1275
|
+
type: 'problem',
|
|
1276
|
+
docs: { description: 'Disallow <a> elements with no content and no accessible name' },
|
|
1277
|
+
messages: {
|
|
1278
|
+
noContent:
|
|
1279
|
+
'This <a> has no content and no aria-label - AT users encounter a nameless link. Add visible text, aria-label, or a visually-hidden <span>. (WCAG 4.1.2 / 2.4.4)',
|
|
1280
|
+
},
|
|
1281
|
+
schema: [],
|
|
1282
|
+
},
|
|
1283
|
+
create(context) {
|
|
1284
|
+
return {
|
|
1285
|
+
[h.elementVisitor](node) {
|
|
1286
|
+
if (h.getElementName(node) !== 'a') return
|
|
1287
|
+
if (h.hasAccessibleName(node)) return
|
|
1288
|
+
// hasOnlyHiddenChildren returns false for truly empty elements too
|
|
1289
|
+
if (!h.hasOnlyHiddenChildren(node)) return
|
|
1290
|
+
context.report({ node, messageId: 'noContent' })
|
|
1291
|
+
},
|
|
1292
|
+
}
|
|
1293
|
+
},
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
// ─── no-aria-activedescendant-no-tabindex ────────────────────────────────────
|
|
1298
|
+
// Elements using aria-activedescendant to manage focus must themselves be
|
|
1299
|
+
// focusable (tabIndex >= 0) so AT can reach the composite widget.
|
|
1300
|
+
// Ref: ARIA 1.2 §6.6.3; APG Composite Widget Pattern
|
|
1301
|
+
|
|
1302
|
+
export function makeNoAriaActivedescendantNoTabindex(h) {
|
|
1303
|
+
return {
|
|
1304
|
+
meta: {
|
|
1305
|
+
type: 'problem',
|
|
1306
|
+
docs: { description: 'Require tabIndex on elements using aria-activedescendant' },
|
|
1307
|
+
messages: {
|
|
1308
|
+
missingTabindex:
|
|
1309
|
+
'Elements using aria-activedescendant must have tabIndex (0 or -1) so they can receive DOM focus and manage keyboard interaction. (ARIA 1.2 §6.6.3)',
|
|
1310
|
+
},
|
|
1311
|
+
schema: [],
|
|
1312
|
+
},
|
|
1313
|
+
create(context) {
|
|
1314
|
+
return {
|
|
1315
|
+
[h.elementVisitor](node) {
|
|
1316
|
+
if (!h.hasAttr(node, 'aria-activedescendant')) return
|
|
1317
|
+
if (h.hasAttr(node, 'tabIndex') || h.hasAttr(node, 'tabindex')) return
|
|
1318
|
+
// Native interactive elements are already focusable
|
|
1319
|
+
const el = h.getElementName(node)
|
|
1320
|
+
if (el && INTERACTIVE_ELEMENTS.has(el)) return
|
|
1321
|
+
context.report({ node: h.getAttr(node, 'aria-activedescendant'), messageId: 'missingTabindex' })
|
|
1322
|
+
},
|
|
1323
|
+
}
|
|
1324
|
+
},
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
// ─── no-invalid-aria-prop-value ───────────────────────────────────────────────
|
|
1329
|
+
// ARIA attributes have defined value types. Boolean props must be "true"/"false",
|
|
1330
|
+
// tristate props "true"/"false"/"mixed", token props must use valid tokens.
|
|
1331
|
+
// Ref: ARIA 1.2 §6.6 State and Property Attribute Processing
|
|
1332
|
+
|
|
1333
|
+
const ARIA_BOOLEAN_PROPS = new Set([
|
|
1334
|
+
'aria-atomic', 'aria-busy', 'aria-disabled', 'aria-grabbed',
|
|
1335
|
+
'aria-hidden', 'aria-modal', 'aria-multiline', 'aria-multiselectable',
|
|
1336
|
+
'aria-pressed', 'aria-readonly', 'aria-required', 'aria-selected',
|
|
1337
|
+
])
|
|
1338
|
+
const ARIA_TRISTATE_PROPS = new Set(['aria-checked', 'aria-pressed'])
|
|
1339
|
+
const ARIA_TRISTATE_VALUES = new Set(['true', 'false', 'mixed'])
|
|
1340
|
+
const ARIA_BOOLEAN_VALUES = new Set(['true', 'false'])
|
|
1341
|
+
|
|
1342
|
+
const ARIA_TOKEN_PROPS = {
|
|
1343
|
+
'aria-autocomplete': new Set(['inline', 'list', 'both', 'none']),
|
|
1344
|
+
'aria-current': new Set(['page', 'step', 'location', 'date', 'time', 'true', 'false']),
|
|
1345
|
+
'aria-dropeffect': new Set(['copy', 'execute', 'link', 'move', 'none', 'popup']),
|
|
1346
|
+
'aria-haspopup': new Set(['false', 'true', 'menu', 'listbox', 'tree', 'grid', 'dialog']),
|
|
1347
|
+
'aria-invalid': new Set(['grammar', 'false', 'spelling', 'true']),
|
|
1348
|
+
'aria-live': new Set(['assertive', 'off', 'polite']),
|
|
1349
|
+
'aria-orientation': new Set(['horizontal', 'undefined', 'vertical']),
|
|
1350
|
+
'aria-relevant': new Set(['additions', 'all', 'removals', 'text', 'additions text']),
|
|
1351
|
+
'aria-sort': new Set(['ascending', 'descending', 'none', 'other']),
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
export function makeNoInvalidAriaPropValue(h) {
|
|
1355
|
+
return {
|
|
1356
|
+
meta: {
|
|
1357
|
+
type: 'problem',
|
|
1358
|
+
docs: { description: 'Disallow invalid ARIA attribute values' },
|
|
1359
|
+
messages: {
|
|
1360
|
+
invalidBoolean:
|
|
1361
|
+
'"{{attr}}" must be "true" or "false", got "{{value}}". AT may misinterpret invalid values. (ARIA 1.2)',
|
|
1362
|
+
invalidTristate:
|
|
1363
|
+
'"{{attr}}" must be "true", "false", or "mixed", got "{{value}}". (ARIA 1.2)',
|
|
1364
|
+
invalidToken:
|
|
1365
|
+
'"{{attr}}" must be one of [{{valid}}], got "{{value}}". (ARIA 1.2)',
|
|
1366
|
+
},
|
|
1367
|
+
schema: [],
|
|
1368
|
+
},
|
|
1369
|
+
create(context) {
|
|
1370
|
+
return {
|
|
1371
|
+
[h.elementVisitor](node) {
|
|
1372
|
+
for (const [prop, validValues] of Object.entries(ARIA_TOKEN_PROPS)) {
|
|
1373
|
+
const attr = h.getAttr(node, prop)
|
|
1374
|
+
if (!attr) continue
|
|
1375
|
+
const val = h.getAttrStringValue(attr)
|
|
1376
|
+
if (val === null) continue
|
|
1377
|
+
if (!validValues.has(val.toLowerCase()))
|
|
1378
|
+
context.report({ node: attr, messageId: 'invalidToken', data: { attr: prop, value: val, valid: [...validValues].join(', ') } })
|
|
1379
|
+
}
|
|
1380
|
+
for (const prop of ARIA_TRISTATE_PROPS) {
|
|
1381
|
+
const attr = h.getAttr(node, prop)
|
|
1382
|
+
if (!attr) continue
|
|
1383
|
+
const val = h.getAttrStringValue(attr)
|
|
1384
|
+
if (val === null) continue
|
|
1385
|
+
if (!ARIA_TRISTATE_VALUES.has(val.toLowerCase()))
|
|
1386
|
+
context.report({ node: attr, messageId: 'invalidTristate', data: { attr: prop, value: val } })
|
|
1387
|
+
}
|
|
1388
|
+
for (const prop of ARIA_BOOLEAN_PROPS) {
|
|
1389
|
+
if (ARIA_TRISTATE_PROPS.has(prop)) continue
|
|
1390
|
+
const attr = h.getAttr(node, prop)
|
|
1391
|
+
if (!attr) continue
|
|
1392
|
+
const val = h.getAttrStringValue(attr)
|
|
1393
|
+
if (val === null) continue
|
|
1394
|
+
if (!ARIA_BOOLEAN_VALUES.has(val.toLowerCase()))
|
|
1395
|
+
context.report({ node: attr, messageId: 'invalidBoolean', data: { attr: prop, value: val } })
|
|
1396
|
+
}
|
|
1397
|
+
},
|
|
1398
|
+
}
|
|
1399
|
+
},
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
// ─── no-autocomplete-invalid ──────────────────────────────────────────────────
|
|
1404
|
+
// autocomplete must use valid HTML spec token values. Invalid values are ignored
|
|
1405
|
+
// by browsers, breaking autofill for AT users. WCAG 1.3.5 Identify Input Purpose.
|
|
1406
|
+
// Ref: WCAG 1.3.5; HTML Living Standard autocomplete attribute
|
|
1407
|
+
|
|
1408
|
+
const VALID_AUTOCOMPLETE_TOKENS = new Set([
|
|
1409
|
+
'off', 'on', 'name', 'honorific-prefix', 'given-name', 'additional-name',
|
|
1410
|
+
'family-name', 'honorific-suffix', 'nickname', 'email', 'username',
|
|
1411
|
+
'new-password', 'current-password', 'one-time-code', 'organization-title',
|
|
1412
|
+
'organization', 'street-address', 'address-line1', 'address-line2',
|
|
1413
|
+
'address-line3', 'address-level4', 'address-level3', 'address-level2',
|
|
1414
|
+
'address-level1', 'country', 'country-name', 'postal-code',
|
|
1415
|
+
'cc-name', 'cc-given-name', 'cc-additional-name', 'cc-family-name',
|
|
1416
|
+
'cc-number', 'cc-exp', 'cc-exp-month', 'cc-exp-year', 'cc-csc', 'cc-type',
|
|
1417
|
+
'transaction-currency', 'transaction-amount', 'language', 'bday',
|
|
1418
|
+
'bday-day', 'bday-month', 'bday-year', 'sex', 'tel', 'tel-country-code',
|
|
1419
|
+
'tel-national', 'tel-area-code', 'tel-local', 'tel-extension',
|
|
1420
|
+
'impp', 'url', 'photo', 'webauthn',
|
|
1421
|
+
])
|
|
1422
|
+
|
|
1423
|
+
export function makeNoAutocompleteInvalid(h) {
|
|
1424
|
+
return {
|
|
1425
|
+
meta: {
|
|
1426
|
+
type: 'problem',
|
|
1427
|
+
docs: { description: 'Require valid autocomplete attribute values' },
|
|
1428
|
+
messages: {
|
|
1429
|
+
invalid:
|
|
1430
|
+
'"{{value}}" is not a valid autocomplete token. Invalid values are ignored by browsers, breaking autofill for AT users. (WCAG 1.3.5 / HTML spec)',
|
|
1431
|
+
},
|
|
1432
|
+
schema: [],
|
|
1433
|
+
},
|
|
1434
|
+
create(context) {
|
|
1435
|
+
return {
|
|
1436
|
+
[h.elementVisitor](node) {
|
|
1437
|
+
const el = h.getElementName(node)
|
|
1438
|
+
if (el !== 'input' && el !== 'select' && el !== 'textarea') return
|
|
1439
|
+
const attr = h.getAttr(node, 'autocomplete')
|
|
1440
|
+
if (!attr) return
|
|
1441
|
+
const val = h.getAttrStringValue(attr)
|
|
1442
|
+
if (val === null) return
|
|
1443
|
+
const tokens = val.trim().toLowerCase().split(/\s+/)
|
|
1444
|
+
for (const token of tokens) {
|
|
1445
|
+
if (!VALID_AUTOCOMPLETE_TOKENS.has(token))
|
|
1446
|
+
context.report({ node: attr, messageId: 'invalid', data: { value: token } })
|
|
1447
|
+
}
|
|
1448
|
+
},
|
|
1449
|
+
}
|
|
1450
|
+
},
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// ─── no-heading-no-content ────────────────────────────────────────────────────
|
|
1455
|
+
// Headings with no text content are meaningless to AT - they appear in the
|
|
1456
|
+
// heading tree but convey nothing. WCAG 2.4.6 Headings and Labels.
|
|
1457
|
+
// Ref: WCAG 2.4.6; WebAIM: Headings
|
|
1458
|
+
|
|
1459
|
+
export function makeNoHeadingNoContent(h) {
|
|
1460
|
+
return {
|
|
1461
|
+
meta: {
|
|
1462
|
+
type: 'problem',
|
|
1463
|
+
docs: { description: 'Disallow heading elements with no content' },
|
|
1464
|
+
messages: {
|
|
1465
|
+
noContent:
|
|
1466
|
+
'<{{el}}> has no content - AT users encounter an empty heading in the page outline. Add visible text or aria-label, or remove the heading. (WCAG 2.4.6)',
|
|
1467
|
+
},
|
|
1468
|
+
schema: [],
|
|
1469
|
+
},
|
|
1470
|
+
create(context) {
|
|
1471
|
+
return {
|
|
1472
|
+
[h.elementVisitor](node) {
|
|
1473
|
+
const el = h.getElementName(node)
|
|
1474
|
+
if (!el || !HEADING_ELEMENTS.has(el)) return
|
|
1475
|
+
if (h.hasAccessibleName(node)) return
|
|
1476
|
+
if (h.hasOnlyHiddenChildren(node))
|
|
1477
|
+
context.report({ node, messageId: 'noContent', data: { el } })
|
|
1478
|
+
},
|
|
1479
|
+
}
|
|
1480
|
+
},
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// ─── no-iframe-no-title ───────────────────────────────────────────────────────
|
|
1485
|
+
// <iframe> without a title has no accessible name - AT users cannot determine
|
|
1486
|
+
// the purpose of the embedded content. WCAG 4.1.2 Name, Role, Value.
|
|
1487
|
+
// Ref: WCAG 4.1.2; HTML spec
|
|
1488
|
+
|
|
1489
|
+
export function makeNoIframeNoTitle(h) {
|
|
1490
|
+
return {
|
|
1491
|
+
meta: {
|
|
1492
|
+
type: 'problem',
|
|
1493
|
+
docs: { description: 'Require title attribute on <iframe> elements' },
|
|
1494
|
+
messages: {
|
|
1495
|
+
missingTitle:
|
|
1496
|
+
'<iframe> must have a title attribute describing its content. Without it AT users cannot identify the embedded content. (WCAG 4.1.2)',
|
|
1497
|
+
},
|
|
1498
|
+
schema: [],
|
|
1499
|
+
},
|
|
1500
|
+
create(context) {
|
|
1501
|
+
return {
|
|
1502
|
+
[h.elementVisitor](node) {
|
|
1503
|
+
if (h.getElementName(node) !== 'iframe') return
|
|
1504
|
+
const title = h.getAttrStringValue(h.getAttr(node, 'title'))
|
|
1505
|
+
if (!title || title.trim() === '')
|
|
1506
|
+
context.report({ node, messageId: 'missingTitle' })
|
|
1507
|
+
},
|
|
1508
|
+
}
|
|
1509
|
+
},
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
// ─── no-img-redundant-alt ─────────────────────────────────────────────────────
|
|
1514
|
+
// Alt text saying "image of", "photo of", "picture of" is redundant - AT already
|
|
1515
|
+
// announces the element is an image. WCAG 1.1.1 Non-text Content.
|
|
1516
|
+
// Ref: WCAG 1.1.1; WebAIM: Alternative Text
|
|
1517
|
+
|
|
1518
|
+
const REDUNDANT_ALT_PATTERN = /\b(image|photo|photograph|picture|graphic|icon|thumbnail)\b/i
|
|
1519
|
+
|
|
1520
|
+
export function makeNoImgRedundantAlt(h) {
|
|
1521
|
+
return {
|
|
1522
|
+
meta: {
|
|
1523
|
+
type: 'suggestion',
|
|
1524
|
+
docs: { description: 'Disallow redundant words like "image" or "photo" in alt text' },
|
|
1525
|
+
messages: {
|
|
1526
|
+
redundant:
|
|
1527
|
+
'Alt text "{{alt}}" contains a redundant word - AT already announces this is an image. Remove "{{word}}" from the alt text. (WCAG 1.1.1)',
|
|
1528
|
+
},
|
|
1529
|
+
schema: [],
|
|
1530
|
+
},
|
|
1531
|
+
create(context) {
|
|
1532
|
+
return {
|
|
1533
|
+
[h.elementVisitor](node) {
|
|
1534
|
+
if (h.getElementName(node) !== 'img') return
|
|
1535
|
+
const alt = h.getAttrStringValue(h.getAttr(node, 'alt'))
|
|
1536
|
+
if (!alt) return
|
|
1537
|
+
const match = alt.match(REDUNDANT_ALT_PATTERN)
|
|
1538
|
+
if (match)
|
|
1539
|
+
context.report({ node: h.getAttr(node, 'alt'), messageId: 'redundant', data: { alt, word: match[0] } })
|
|
1540
|
+
},
|
|
1541
|
+
}
|
|
1542
|
+
},
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
// ─── no-access-key ────────────────────────────────────────────────────────────
|
|
1547
|
+
// accessKey creates keyboard shortcuts that conflict with browser and AT shortcuts.
|
|
1548
|
+
// No WCAG SC directly bans it but it causes 2.1.4 Character Key Shortcuts failures
|
|
1549
|
+
// and is universally discouraged. Ref: WCAG 2.1.4; WebAIM
|
|
1550
|
+
|
|
1551
|
+
export function makeNoAccessKey(h) {
|
|
1552
|
+
return {
|
|
1553
|
+
meta: {
|
|
1554
|
+
type: 'suggestion',
|
|
1555
|
+
docs: { description: 'Disallow accessKey attribute' },
|
|
1556
|
+
messages: {
|
|
1557
|
+
accessKey:
|
|
1558
|
+
'accessKey creates keyboard shortcuts that conflict with browser and AT shortcuts, breaking keyboard navigation for many users. Remove it. (WCAG 2.1.4)',
|
|
1559
|
+
},
|
|
1560
|
+
schema: [],
|
|
1561
|
+
},
|
|
1562
|
+
create(context) {
|
|
1563
|
+
return {
|
|
1564
|
+
[h.elementVisitor](node) {
|
|
1565
|
+
const attr = h.getAttr(node, 'accessKey') ?? h.getAttr(node, 'accesskey')
|
|
1566
|
+
if (attr) context.report({ node: attr, messageId: 'accessKey' })
|
|
1567
|
+
},
|
|
1568
|
+
}
|
|
1569
|
+
},
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// ─── no-noninteractive-to-interactive-role ────────────────────────────────────
|
|
1574
|
+
// Adding interactive roles to non-interactive elements (li, div, span, p, etc.)
|
|
1575
|
+
// without the required keyboard handlers is a WCAG 4.1.2 failure.
|
|
1576
|
+
// Ref: WCAG 4.1.2; ARIA 1.2
|
|
1577
|
+
|
|
1578
|
+
const NON_INTERACTIVE_ELEMENTS = new Set([
|
|
1579
|
+
'li', 'ul', 'ol', 'dl', 'dt', 'dd', 'table', 'tr', 'td', 'th',
|
|
1580
|
+
'thead', 'tbody', 'tfoot', 'caption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
1581
|
+
'article', 'aside', 'footer', 'header', 'main', 'nav', 'section',
|
|
1582
|
+
'blockquote', 'figure', 'figcaption', 'address', 'p', 'pre',
|
|
1583
|
+
])
|
|
1584
|
+
|
|
1585
|
+
export function makeNoNoninteractiveToInteractiveRole(h) {
|
|
1586
|
+
return {
|
|
1587
|
+
meta: {
|
|
1588
|
+
type: 'problem',
|
|
1589
|
+
docs: { description: 'Disallow interactive roles on non-interactive elements without keyboard handlers' },
|
|
1590
|
+
messages: {
|
|
1591
|
+
missingHandlers:
|
|
1592
|
+
'<{{el}} role="{{role}}"> makes a non-interactive element interactive but has no keyboard handler. Add onKeyDown/onKeyPress or use a native interactive element instead. (WCAG 4.1.2 / 2.1.1)',
|
|
1593
|
+
},
|
|
1594
|
+
schema: [],
|
|
1595
|
+
},
|
|
1596
|
+
create(context) {
|
|
1597
|
+
return {
|
|
1598
|
+
[h.elementVisitor](node) {
|
|
1599
|
+
const el = h.getElementName(node)
|
|
1600
|
+
if (!el || !NON_INTERACTIVE_ELEMENTS.has(el)) return
|
|
1601
|
+
const role = h.getRoleValue(node)
|
|
1602
|
+
if (!role || !INTERACTIVE_ROLES.has(role)) return
|
|
1603
|
+
const hasKeyHandler =
|
|
1604
|
+
h.hasAttr(node, 'onKeyDown') || h.hasAttr(node, 'onKeyPress') ||
|
|
1605
|
+
h.hasAttr(node, 'onKeyUp') || h.hasAttr(node, 'tabIndex') ||
|
|
1606
|
+
h.hasAttr(node, 'tabindex') || h.hasAttr(node, '@keydown') ||
|
|
1607
|
+
h.hasAttr(node, '(keydown)') || h.hasAttr(node, '@keyup') ||
|
|
1608
|
+
h.hasAttr(node, '(keyup)')
|
|
1609
|
+
if (!hasKeyHandler)
|
|
1610
|
+
context.report({ node: h.getAttr(node, 'role'), messageId: 'missingHandlers', data: { el, role } })
|
|
1611
|
+
},
|
|
1612
|
+
}
|
|
1613
|
+
},
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
// ─── no-noninteractive-tabindex ───────────────────────────────────────────────
|
|
1618
|
+
// tabIndex on non-interactive elements without a role puts them in the tab order
|
|
1619
|
+
// but AT users have no semantic context for what they are. WCAG 4.1.2.
|
|
1620
|
+
// Ref: WCAG 4.1.2; Roselli: Stop Giving Control Hints to Non-Interactive Elements
|
|
1621
|
+
|
|
1622
|
+
export function makeNoNoninteractiveTabindex(h) {
|
|
1623
|
+
return {
|
|
1624
|
+
meta: {
|
|
1625
|
+
type: 'problem',
|
|
1626
|
+
docs: { description: 'Disallow tabIndex >= 0 on non-interactive elements without a role' },
|
|
1627
|
+
messages: {
|
|
1628
|
+
noninteractiveTabindex:
|
|
1629
|
+
'tabIndex on <{{el}}> puts it in the tab order but it has no interactive role - keyboard users reach it but AT cannot identify what it is. Add a role or use a native interactive element. (WCAG 4.1.2)',
|
|
1630
|
+
},
|
|
1631
|
+
schema: [],
|
|
1632
|
+
},
|
|
1633
|
+
create(context) {
|
|
1634
|
+
return {
|
|
1635
|
+
[h.elementVisitor](node) {
|
|
1636
|
+
const el = h.getElementName(node)
|
|
1637
|
+
if (!el || !NON_INTERACTIVE_ELEMENTS.has(el)) return
|
|
1638
|
+
if (h.getRoleValue(node)) return
|
|
1639
|
+
const attr = h.getAttr(node, 'tabIndex') ?? h.getAttr(node, 'tabindex')
|
|
1640
|
+
if (!attr) return
|
|
1641
|
+
const val = h.getAttrStringValue(attr)
|
|
1642
|
+
if (val === null) return
|
|
1643
|
+
const num = Number(val)
|
|
1644
|
+
if (!isNaN(num) && num >= 0)
|
|
1645
|
+
context.report({ node: attr, messageId: 'noninteractiveTabindex', data: { el } })
|
|
1646
|
+
},
|
|
1647
|
+
}
|
|
1648
|
+
},
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
// ─── prefer-semantic-element ──────────────────────────────────────────────────
|
|
1653
|
+
// When a native HTML element exists for a role, prefer it over role=.
|
|
1654
|
+
// Native elements have built-in keyboard handling and better AT support.
|
|
1655
|
+
// Ref: WCAG 4.1.2; ARIA in HTML spec "first rule of ARIA"
|
|
1656
|
+
|
|
1657
|
+
const ROLE_TO_ELEMENT = {
|
|
1658
|
+
button: 'button',
|
|
1659
|
+
link: 'a',
|
|
1660
|
+
heading: 'h1–h6',
|
|
1661
|
+
checkbox: 'input[type=checkbox]',
|
|
1662
|
+
radio: 'input[type=radio]',
|
|
1663
|
+
textbox: 'input or textarea',
|
|
1664
|
+
searchbox: 'input[type=search]',
|
|
1665
|
+
spinbutton: 'input[type=number]',
|
|
1666
|
+
slider: 'input[type=range]',
|
|
1667
|
+
img: 'img',
|
|
1668
|
+
list: 'ul or ol',
|
|
1669
|
+
listitem: 'li',
|
|
1670
|
+
table: 'table',
|
|
1671
|
+
row: 'tr',
|
|
1672
|
+
cell: 'td',
|
|
1673
|
+
columnheader:'th',
|
|
1674
|
+
rowheader: 'th',
|
|
1675
|
+
form: 'form',
|
|
1676
|
+
navigation: 'nav',
|
|
1677
|
+
main: 'main',
|
|
1678
|
+
banner: 'header',
|
|
1679
|
+
contentinfo: 'footer',
|
|
1680
|
+
complementary: 'aside',
|
|
1681
|
+
region: 'section',
|
|
1682
|
+
article: 'article',
|
|
1683
|
+
separator: 'hr',
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
export function makePreferSemanticElement(h) {
|
|
1687
|
+
return {
|
|
1688
|
+
meta: {
|
|
1689
|
+
type: 'suggestion',
|
|
1690
|
+
docs: { description: 'Prefer native HTML elements over ARIA role equivalents' },
|
|
1691
|
+
messages: {
|
|
1692
|
+
preferNative:
|
|
1693
|
+
'Use <{{element}}> instead of role="{{role}}" - native elements have built-in keyboard handling and better AT support. (ARIA first rule / WCAG 4.1.2)',
|
|
1694
|
+
},
|
|
1695
|
+
schema: [],
|
|
1696
|
+
},
|
|
1697
|
+
create(context) {
|
|
1698
|
+
return {
|
|
1699
|
+
[h.elementVisitor](node) {
|
|
1700
|
+
const el = h.getElementName(node)
|
|
1701
|
+
const role = h.getRoleValue(node)
|
|
1702
|
+
if (!role) return
|
|
1703
|
+
const native = ROLE_TO_ELEMENT[role]
|
|
1704
|
+
if (!native) return
|
|
1705
|
+
// Don't flag if they're already using a semantic element with a redundant role
|
|
1706
|
+
if (el && el === role) return
|
|
1707
|
+
context.report({ node: h.getAttr(node, 'role'), messageId: 'preferNative', data: { role, element: native } })
|
|
1708
|
+
},
|
|
1709
|
+
}
|
|
1710
|
+
},
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
// ─── no-role-supports-aria-props ─────────────────────────────────────────────
|
|
1715
|
+
// ARIA attributes must be valid for the element's role. Using unsupported
|
|
1716
|
+
// properties on a role is ignored or misread by AT. WCAG 4.1.2.
|
|
1717
|
+
// Only catches the most common mismatches statically.
|
|
1718
|
+
// Ref: ARIA 1.2 §6.6; ARIA in HTML
|
|
1719
|
+
|
|
1720
|
+
const ROLE_FORBIDDEN_PROPS = {
|
|
1721
|
+
// presentation/none - no aria props valid (element is hidden from AT tree)
|
|
1722
|
+
presentation: new Set(['aria-label', 'aria-labelledby', 'aria-describedby', 'aria-hidden']),
|
|
1723
|
+
none: new Set(['aria-label', 'aria-labelledby', 'aria-describedby', 'aria-hidden']),
|
|
1724
|
+
// separator (non-focusable) - value props don't apply
|
|
1725
|
+
separator: new Set(['aria-checked', 'aria-selected', 'aria-expanded']),
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
export function makeNoRoleSupportsAriaProps(h) {
|
|
1729
|
+
return {
|
|
1730
|
+
meta: {
|
|
1731
|
+
type: 'problem',
|
|
1732
|
+
docs: { description: 'Disallow ARIA attributes that are not supported by the element\'s role' },
|
|
1733
|
+
messages: {
|
|
1734
|
+
unsupported:
|
|
1735
|
+
'"{{attr}}" is not supported on role="{{role}}" and will be ignored or misread by AT. Remove it or change the role. (ARIA 1.2 / WCAG 4.1.2)',
|
|
1736
|
+
},
|
|
1737
|
+
schema: [],
|
|
1738
|
+
},
|
|
1739
|
+
create(context) {
|
|
1740
|
+
return {
|
|
1741
|
+
[h.elementVisitor](node) {
|
|
1742
|
+
const role = h.getRoleValue(node)
|
|
1743
|
+
if (!role) return
|
|
1744
|
+
const forbidden = ROLE_FORBIDDEN_PROPS[role]
|
|
1745
|
+
if (!forbidden) return
|
|
1746
|
+
for (const prop of forbidden) {
|
|
1747
|
+
const attr = h.getAttr(node, prop)
|
|
1748
|
+
if (attr)
|
|
1749
|
+
context.report({ node: attr, messageId: 'unsupported', data: { attr: prop, role } })
|
|
1750
|
+
}
|
|
1751
|
+
},
|
|
1752
|
+
}
|
|
1753
|
+
},
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
// ─── no-scope-on-td ───────────────────────────────────────────────────────────
|
|
1758
|
+
// scope attribute is only valid on <th>, not <td>. Using it on <td> is invalid
|
|
1759
|
+
// HTML and ignored by browsers. WCAG 1.3.1 Info and Relationships.
|
|
1760
|
+
// Ref: WCAG 1.3.1; HTML spec
|
|
1761
|
+
|
|
1762
|
+
export function makeNoScopeOnTd(h) {
|
|
1763
|
+
return {
|
|
1764
|
+
meta: {
|
|
1765
|
+
type: 'problem',
|
|
1766
|
+
docs: { description: 'Disallow scope attribute on <td> - only valid on <th>' },
|
|
1767
|
+
messages: {
|
|
1768
|
+
invalidScope:
|
|
1769
|
+
'scope is only valid on <th>, not <td>. Using it on <td> is invalid HTML and is ignored by browsers and AT. (WCAG 1.3.1 / HTML spec)',
|
|
1770
|
+
},
|
|
1771
|
+
schema: [],
|
|
1772
|
+
},
|
|
1773
|
+
create(context) {
|
|
1774
|
+
return {
|
|
1775
|
+
[h.elementVisitor](node) {
|
|
1776
|
+
if (h.getElementName(node) !== 'td') return
|
|
1777
|
+
const attr = h.getAttr(node, 'scope')
|
|
1778
|
+
if (attr) context.report({ node: attr, messageId: 'invalidScope' })
|
|
1779
|
+
},
|
|
1780
|
+
}
|
|
1781
|
+
},
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
// ─── no-duplicate-id ──────────────────────────────────────────────────────────
|
|
1786
|
+
// Duplicate IDs break aria-labelledby, aria-describedby, aria-controls,
|
|
1787
|
+
// aria-owns, aria-activedescendant, and htmlFor - AT uses only the first match.
|
|
1788
|
+
// WCAG 4.1.1 was removed in WCAG 2.2; failures now map to SC 1.3.1 / 4.1.2.
|
|
1789
|
+
// We only flag duplicates that are actually referenced by an ARIA relation,
|
|
1790
|
+
// to avoid noise from IDs used purely for styling or scripting.
|
|
1791
|
+
// Ref: axe-core duplicate-id-aria (MPL-2.0, reimplemented); SC 1.3.1 / 4.1.2
|
|
1792
|
+
|
|
1793
|
+
const ARIA_ID_ATTRS = ['aria-labelledby', 'aria-describedby', 'aria-controls', 'aria-owns', 'aria-activedescendant']
|
|
1794
|
+
|
|
1795
|
+
export function makeNoDuplicateId(h) {
|
|
1796
|
+
return {
|
|
1797
|
+
meta: {
|
|
1798
|
+
type: 'problem',
|
|
1799
|
+
docs: { description: 'Disallow duplicate id values on elements referenced by ARIA attributes' },
|
|
1800
|
+
messages: {
|
|
1801
|
+
duplicate:
|
|
1802
|
+
'id="{{id}}" appears more than once. AT resolves aria-labelledby/describedby/controls/owns/activedescendant by first match - duplicate IDs silently break these associations. (SC 1.3.1 / 4.1.2)',
|
|
1803
|
+
},
|
|
1804
|
+
schema: [],
|
|
1805
|
+
},
|
|
1806
|
+
create(context) {
|
|
1807
|
+
const idNodes = new Map() // id string → first node with that id
|
|
1808
|
+
const idDups = new Map() // id string → subsequent nodes (to report)
|
|
1809
|
+
const ariaRefs = new Set() // all id values referenced by ARIA attrs or htmlFor
|
|
1810
|
+
|
|
1811
|
+
return {
|
|
1812
|
+
[h.elementVisitor](node) {
|
|
1813
|
+
const id = h.getAttrStringValue(h.getAttr(node, 'id'))
|
|
1814
|
+
if (id) {
|
|
1815
|
+
if (!idNodes.has(id)) {
|
|
1816
|
+
idNodes.set(id, node)
|
|
1817
|
+
} else {
|
|
1818
|
+
if (!idDups.has(id)) idDups.set(id, [])
|
|
1819
|
+
idDups.get(id).push(node)
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
for (const attr of ARIA_ID_ATTRS) {
|
|
1824
|
+
const val = h.getAttrStringValue(h.getAttr(node, attr))
|
|
1825
|
+
if (val) val.trim().split(/\s+/).forEach(ref => ariaRefs.add(ref))
|
|
1826
|
+
}
|
|
1827
|
+
// htmlFor (JSX) and for (Vue/Angular)
|
|
1828
|
+
const forVal = h.getAttrStringValue(h.getAttr(node, 'htmlFor') ?? h.getAttr(node, 'for'))
|
|
1829
|
+
if (forVal) ariaRefs.add(forVal.trim())
|
|
1830
|
+
},
|
|
1831
|
+
|
|
1832
|
+
'Program:exit'() {
|
|
1833
|
+
for (const [id, nodes] of idDups) {
|
|
1834
|
+
if (!ariaRefs.has(id)) continue
|
|
1835
|
+
for (const node of nodes) {
|
|
1836
|
+
context.report({
|
|
1837
|
+
node: h.getAttr(node, 'id'),
|
|
1838
|
+
messageId: 'duplicate',
|
|
1839
|
+
data: { id },
|
|
1840
|
+
})
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
},
|
|
1844
|
+
}
|
|
1845
|
+
},
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
// ─── no-button-type-missing ───────────────────────────────────────────────────
|
|
1850
|
+
// <button> without an explicit type attribute defaults to type="submit" when
|
|
1851
|
+
// inside a <form>, causing accidental form submission. This is an HTML spec
|
|
1852
|
+
// issue (not a WCAG SC), but it is the root cause of unexpected navigation and
|
|
1853
|
+
// double-submit bugs. We only flag when the button is inside a <form> ancestor
|
|
1854
|
+
// (where getAncestors is available - JSX and Vue). Angular silently passes
|
|
1855
|
+
// because getParent returns null and ancestor walking is unavailable there.
|
|
1856
|
+
// Ref: HTML Living Standard §4.10.18.5; H32 Technique
|
|
1857
|
+
|
|
1858
|
+
export function makeNoButtonTypeMissing(h) {
|
|
1859
|
+
return {
|
|
1860
|
+
meta: {
|
|
1861
|
+
type: 'suggestion',
|
|
1862
|
+
docs: { description: 'Require explicit type attribute on <button> elements inside forms' },
|
|
1863
|
+
messages: {
|
|
1864
|
+
missingType:
|
|
1865
|
+
'<button> without an explicit type defaults to type="submit" inside a <form>, which can cause accidental form submission. Add type="button", type="submit", or type="reset". (HTML spec §4.10.18.5)',
|
|
1866
|
+
},
|
|
1867
|
+
schema: [],
|
|
1868
|
+
},
|
|
1869
|
+
create(context) {
|
|
1870
|
+
return {
|
|
1871
|
+
[h.elementVisitor](node) {
|
|
1872
|
+
if (h.getElementName(node) !== 'button') return
|
|
1873
|
+
if (h.hasAttr(node, 'type')) return
|
|
1874
|
+
|
|
1875
|
+
// Only flag when inside a <form> - outside a form, the default is harmless.
|
|
1876
|
+
// Angular's getAncestors yields nothing (parent is null), so the loop
|
|
1877
|
+
// completes without finding 'form' and we silently skip - correct behaviour.
|
|
1878
|
+
let insideForm = false
|
|
1879
|
+
for (const ancestor of h.getAncestors(node)) {
|
|
1880
|
+
if (h.getElementName(ancestor) === 'form') { insideForm = true; break }
|
|
1881
|
+
}
|
|
1882
|
+
if (!insideForm) return
|
|
1883
|
+
|
|
1884
|
+
context.report({ node, messageId: 'missingType' })
|
|
1885
|
+
},
|
|
1886
|
+
}
|
|
1887
|
+
},
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
// ─── no-summary-without-details ───────────────────────────────────────────────
|
|
1892
|
+
// <summary> must be the first child of <details>. Orphaned <summary> elements
|
|
1893
|
+
// are still exposed as interactive by Firefox and Safari even though they are
|
|
1894
|
+
// not keyboard-operable - a SC 2.1.1 and 4.1.2 failure.
|
|
1895
|
+
// Angular skips silently because getParent returns null there.
|
|
1896
|
+
// Ref: HTML Living Standard §4.11.1; O'Hara scottohara.me/blog/2022/09/12/details-summary.html
|
|
1897
|
+
|
|
1898
|
+
export function makeNoSummaryWithoutDetails(h) {
|
|
1899
|
+
return {
|
|
1900
|
+
meta: {
|
|
1901
|
+
type: 'problem',
|
|
1902
|
+
docs: { description: 'Require <summary> to be a child of <details>' },
|
|
1903
|
+
messages: {
|
|
1904
|
+
orphaned:
|
|
1905
|
+
'<summary> outside <details> is invalid HTML. Firefox and Safari still expose it as an interactive element, but it is not keyboard-operable - a phantom control. Wrap it in <details> or remove it. (SC 2.1.1 / 4.1.2 / HTML spec)',
|
|
1906
|
+
},
|
|
1907
|
+
schema: [],
|
|
1908
|
+
},
|
|
1909
|
+
create(context) {
|
|
1910
|
+
return {
|
|
1911
|
+
[h.elementVisitor](node) {
|
|
1912
|
+
if (h.getElementName(node) !== 'summary') return
|
|
1913
|
+
const parent = h.getParent(node)
|
|
1914
|
+
// Angular returns null - cannot check parent, skip silently.
|
|
1915
|
+
if (parent === null) return
|
|
1916
|
+
if (h.getElementName(parent) !== 'details')
|
|
1917
|
+
context.report({ node, messageId: 'orphaned' })
|
|
1918
|
+
},
|
|
1919
|
+
}
|
|
1920
|
+
},
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
// ─── no-aria-required-on-non-form ────────────────────────────────────────────
|
|
1925
|
+
// aria-required is only meaningful on 8 ARIA roles per the ARIA 1.2 spec:
|
|
1926
|
+
// checkbox, combobox, gridcell, listbox, radiogroup, spinbutton, textbox, tree.
|
|
1927
|
+
// On any other element or role AT ignores it - dead code that misleads authors.
|
|
1928
|
+
// Ref: ARIA 1.2 §6.6.9 aria-required; SC 4.1.2
|
|
1929
|
+
|
|
1930
|
+
const ARIA_REQUIRED_VALID_ROLES = new Set([
|
|
1931
|
+
'checkbox', 'combobox', 'gridcell', 'listbox', 'radiogroup', 'spinbutton', 'textbox', 'tree',
|
|
1932
|
+
])
|
|
1933
|
+
|
|
1934
|
+
// Input types whose implicit ARIA role supports aria-required
|
|
1935
|
+
const ARIA_REQUIRED_VALID_INPUT_TYPES = new Set([
|
|
1936
|
+
'text', 'email', 'password', 'search', 'tel', 'url', 'number', 'checkbox',
|
|
1937
|
+
])
|
|
1938
|
+
|
|
1939
|
+
export function makeNoAriaRequiredOnNonForm(h) {
|
|
1940
|
+
return {
|
|
1941
|
+
meta: {
|
|
1942
|
+
type: 'problem',
|
|
1943
|
+
docs: { description: 'Disallow aria-required on elements whose role does not support it' },
|
|
1944
|
+
messages: {
|
|
1945
|
+
invalid:
|
|
1946
|
+
'aria-required is only valid on roles: checkbox, combobox, gridcell, listbox, radiogroup, spinbutton, textbox, tree. On <{{el}}> with no matching role, AT ignores it. Use a native required attribute or apply aria-required to a control with a valid role. (ARIA 1.2 §6.6.9 / SC 4.1.2)',
|
|
1947
|
+
},
|
|
1948
|
+
schema: [],
|
|
1949
|
+
},
|
|
1950
|
+
create(context) {
|
|
1951
|
+
return {
|
|
1952
|
+
[h.elementVisitor](node) {
|
|
1953
|
+
const attr = h.getAttr(node, 'aria-required')
|
|
1954
|
+
if (!attr) return
|
|
1955
|
+
|
|
1956
|
+
const role = h.getRoleValue(node)
|
|
1957
|
+
if (role && ARIA_REQUIRED_VALID_ROLES.has(role)) return
|
|
1958
|
+
|
|
1959
|
+
const el = h.getElementName(node)
|
|
1960
|
+
|
|
1961
|
+
// select and textarea have implicit listbox/textbox roles - valid
|
|
1962
|
+
if (el === 'select' || el === 'textarea') return
|
|
1963
|
+
|
|
1964
|
+
if (el === 'input') {
|
|
1965
|
+
const type = (h.getAttrStringValue(h.getAttr(node, 'type')) ?? 'text').toLowerCase()
|
|
1966
|
+
if (ARIA_REQUIRED_VALID_INPUT_TYPES.has(type)) return
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
context.report({ node: attr, messageId: 'invalid', data: { el: el ?? 'unknown' } })
|
|
1970
|
+
},
|
|
1971
|
+
}
|
|
1972
|
+
},
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
// ─── no-input-type-invalid ────────────────────────────────────────────────────
|
|
1977
|
+
// <input type="X"> with an invalid type silently falls back to type="text",
|
|
1978
|
+
// losing mobile keyboard hints, native pickers, format validation, and browser
|
|
1979
|
+
// autofill matching. WCAG 1.3.5 Identify Input Purpose.
|
|
1980
|
+
// Dynamic type values (JSX expression, v-bind, Angular binding) are skipped -
|
|
1981
|
+
// getAttrStringValue returns null for those and we cannot validate at lint time.
|
|
1982
|
+
// Ref: HTML Living Standard §4.10.18.5; SC 1.3.5
|
|
1983
|
+
|
|
1984
|
+
const VALID_INPUT_TYPES = new Set([
|
|
1985
|
+
'button', 'checkbox', 'color', 'date', 'datetime-local', 'email', 'file',
|
|
1986
|
+
'hidden', 'image', 'month', 'number', 'password', 'radio', 'range', 'reset',
|
|
1987
|
+
'search', 'submit', 'tel', 'text', 'time', 'url', 'week',
|
|
1988
|
+
])
|
|
1989
|
+
|
|
1990
|
+
export function makeNoInputTypeInvalid(h) {
|
|
1991
|
+
return {
|
|
1992
|
+
meta: {
|
|
1993
|
+
type: 'problem',
|
|
1994
|
+
docs: { description: 'Require valid HTML type attribute values on <input> elements' },
|
|
1995
|
+
messages: {
|
|
1996
|
+
invalid:
|
|
1997
|
+
'type="{{type}}" is not a valid HTML input type and silently falls back to type="text", losing mobile keyboard hints, native pickers, and autofill matching. Use a valid type value. (HTML spec / SC 1.3.5)',
|
|
1998
|
+
},
|
|
1999
|
+
schema: [],
|
|
2000
|
+
},
|
|
2001
|
+
create(context) {
|
|
2002
|
+
return {
|
|
2003
|
+
[h.elementVisitor](node) {
|
|
2004
|
+
if (h.getElementName(node) !== 'input') return
|
|
2005
|
+
const typeAttr = h.getAttr(node, 'type')
|
|
2006
|
+
if (!typeAttr) return // missing type - defaults to text, valid
|
|
2007
|
+
const val = h.getAttrStringValue(typeAttr)
|
|
2008
|
+
if (val === null) return // dynamic expression - cannot validate
|
|
2009
|
+
if (!VALID_INPUT_TYPES.has(val.toLowerCase()))
|
|
2010
|
+
context.report({ node: typeAttr, messageId: 'invalid', data: { type: val } })
|
|
2011
|
+
},
|
|
2012
|
+
}
|
|
2013
|
+
},
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
// ─── no-labelledby-missing-target ────────────────────────────────────────────
|
|
2018
|
+
// aria-labelledby and aria-describedby accept a space-separated list of id refs.
|
|
2019
|
+
// If any referenced id does not exist in the same file the association is broken -
|
|
2020
|
+
// AT silently computes an empty name. axe-core catches this at runtime; we can
|
|
2021
|
+
// catch the static case (same file) at lint time.
|
|
2022
|
+
// Ref: axe-core aria-labelledby (reimplemented); ARIA 1.2 §6.2.4; SC 4.1.2
|
|
2023
|
+
|
|
2024
|
+
const LABELLEDBY_ATTRS = ['aria-labelledby', 'aria-describedby', 'aria-controls', 'aria-owns', 'aria-activedescendant']
|
|
2025
|
+
|
|
2026
|
+
export function makeNoLabelledbyMissingTarget(h) {
|
|
2027
|
+
return {
|
|
2028
|
+
meta: {
|
|
2029
|
+
type: 'problem',
|
|
2030
|
+
docs: { description: 'Disallow aria-labelledby/describedby/controls/owns/activedescendant referencing an id that does not exist in the file' },
|
|
2031
|
+
messages: {
|
|
2032
|
+
missingTarget:
|
|
2033
|
+
'{{attr}}="{{ids}}" references id "{{id}}" which does not exist in this file. ' +
|
|
2034
|
+
'AT will compute an empty name for this element. Add an element with id="{{id}}" ' +
|
|
2035
|
+
'or correct the reference. (axe-core aria-labelledby / SC 4.1.2)',
|
|
2036
|
+
},
|
|
2037
|
+
schema: [],
|
|
2038
|
+
},
|
|
2039
|
+
create(context) {
|
|
2040
|
+
const definedIds = new Set()
|
|
2041
|
+
// attr node → { attr, tokens } - collected on first pass, checked at exit
|
|
2042
|
+
const refs = []
|
|
2043
|
+
|
|
2044
|
+
return {
|
|
2045
|
+
[h.elementVisitor](node) {
|
|
2046
|
+
const idVal = h.getAttrStringValue(h.getAttr(node, 'id'))
|
|
2047
|
+
if (idVal) definedIds.add(idVal.trim())
|
|
2048
|
+
|
|
2049
|
+
for (const attrName of LABELLEDBY_ATTRS) {
|
|
2050
|
+
const attrNode = h.getAttr(node, attrName)
|
|
2051
|
+
if (!attrNode) continue
|
|
2052
|
+
const val = h.getAttrStringValue(attrNode)
|
|
2053
|
+
if (!val) continue
|
|
2054
|
+
const tokens = val.trim().split(/\s+/).filter(Boolean)
|
|
2055
|
+
if (tokens.length) refs.push({ attrNode, attrName, tokens })
|
|
2056
|
+
}
|
|
2057
|
+
},
|
|
2058
|
+
|
|
2059
|
+
'Program:exit'() {
|
|
2060
|
+
for (const { attrNode, attrName, tokens } of refs) {
|
|
2061
|
+
for (const id of tokens) {
|
|
2062
|
+
if (!definedIds.has(id)) {
|
|
2063
|
+
context.report({
|
|
2064
|
+
node: attrNode,
|
|
2065
|
+
messageId: 'missingTarget',
|
|
2066
|
+
data: { attr: attrName, ids: tokens.join(' '), id },
|
|
2067
|
+
})
|
|
2068
|
+
break // one report per attribute is enough
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
},
|
|
2073
|
+
}
|
|
2074
|
+
},
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
// ─── no-dynamic-content-without-live ─────────────────────────────────────────
|
|
2079
|
+
// Injecting HTML dynamically (dangerouslySetInnerHTML / v-html / [innerHTML])
|
|
2080
|
+
// replaces the subtree after load. Screen readers do not re-read replaced
|
|
2081
|
+
// content unless a live region wraps it. axe-core catches this at runtime as
|
|
2082
|
+
// "content-changes" violations; we can catch the static pattern at lint time.
|
|
2083
|
+
//
|
|
2084
|
+
// The check: the element using the inject-HTML attribute, or one of its
|
|
2085
|
+
// ancestors, must have aria-live (or role="alert"/"status"/"log"/"marquee"
|
|
2086
|
+
// which carry implicit live region semantics).
|
|
2087
|
+
//
|
|
2088
|
+
// Angular ancestor walking is unavailable (getParent returns null) so for
|
|
2089
|
+
// Angular we only check the element itself - a partial but still useful signal.
|
|
2090
|
+
//
|
|
2091
|
+
// Ref: axe-core (content-changes); WCAG SC 4.1.3 Status Messages
|
|
2092
|
+
|
|
2093
|
+
const IMPLICIT_LIVE_ROLES = new Set(['alert', 'status', 'log', 'marquee', 'timer'])
|
|
2094
|
+
|
|
2095
|
+
function hasLiveRegion(node, h) {
|
|
2096
|
+
if (h.hasAttr(node, 'aria-live')) return true
|
|
2097
|
+
const role = h.getRoleValue(node)
|
|
2098
|
+
if (role && IMPLICIT_LIVE_ROLES.has(role)) return true
|
|
2099
|
+
return false
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
export function makeNoDynamicContentWithoutLive(h) {
|
|
2103
|
+
return {
|
|
2104
|
+
meta: {
|
|
2105
|
+
type: 'problem',
|
|
2106
|
+
docs: { description: 'Require aria-live on elements that inject dynamic HTML content' },
|
|
2107
|
+
messages: {
|
|
2108
|
+
missingLive:
|
|
2109
|
+
'{{attr}} replaces element content after load. Screen readers will not re-read ' +
|
|
2110
|
+
'the new content unless this element or an ancestor has aria-live (or an implicit ' +
|
|
2111
|
+
'live role like role="alert"). Add aria-live="polite" (or role="status") to the ' +
|
|
2112
|
+
'container, or move the inject into an existing live region. (axe-core content-changes / SC 4.1.3)',
|
|
2113
|
+
},
|
|
2114
|
+
schema: [],
|
|
2115
|
+
},
|
|
2116
|
+
create(context) {
|
|
2117
|
+
return {
|
|
2118
|
+
[h.elementVisitor](node) {
|
|
2119
|
+
const injectAttr = h.getInnerHtmlAttr(node)
|
|
2120
|
+
if (!injectAttr) return
|
|
2121
|
+
|
|
2122
|
+
// Check the element itself first
|
|
2123
|
+
if (hasLiveRegion(node, h)) return
|
|
2124
|
+
|
|
2125
|
+
// Walk ancestors (returns nothing for Angular - degrades to element-only check)
|
|
2126
|
+
for (const ancestor of h.getAncestors(node)) {
|
|
2127
|
+
if (hasLiveRegion(ancestor, h)) return
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
const attrName = h.getInnerHtmlAttrName(node)
|
|
2131
|
+
context.report({ node: injectAttr, messageId: 'missingLive', data: { attr: attrName } })
|
|
2132
|
+
},
|
|
2133
|
+
}
|
|
2134
|
+
},
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
// ─── form-field-multiple-labels ───────────────────────────────────────────────
|
|
2139
|
+
// A form control should have exactly one label. When multiple <label for="X">
|
|
2140
|
+
// elements point to the same input, screen readers read all of them - the result
|
|
2141
|
+
// is verbose, repetitive, or confusing depending on the AT.
|
|
2142
|
+
// We only flag the case where more than one *static* <label for="id"> targets
|
|
2143
|
+
// the same input in the same file. Dynamic labels (v-bind:for, [for]) are skipped.
|
|
2144
|
+
// Ref: axe-core form-field-multiple-labels (reimplemented); SC 1.3.1
|
|
2145
|
+
|
|
2146
|
+
export function makeFormFieldMultipleLabels(h) {
|
|
2147
|
+
return {
|
|
2148
|
+
meta: {
|
|
2149
|
+
type: 'problem',
|
|
2150
|
+
docs: { description: 'Disallow multiple <label> elements associated with the same form control' },
|
|
2151
|
+
messages: {
|
|
2152
|
+
multipleLabels:
|
|
2153
|
+
'id="{{id}}" is referenced by more than one <label for="...">. Screen readers read all ' +
|
|
2154
|
+
'associated labels - duplicates add noise or conflict. Keep exactly one label per control. ' +
|
|
2155
|
+
'(axe-core form-field-multiple-labels / SC 1.3.1)',
|
|
2156
|
+
},
|
|
2157
|
+
schema: [],
|
|
2158
|
+
},
|
|
2159
|
+
create(context) {
|
|
2160
|
+
// Map from id value → array of <label for="id"> attribute nodes
|
|
2161
|
+
const labelForRefs = new Map()
|
|
2162
|
+
|
|
2163
|
+
return {
|
|
2164
|
+
[h.elementVisitor](node) {
|
|
2165
|
+
if (h.getElementName(node) !== 'label') return
|
|
2166
|
+
// Support both htmlFor (JSX) and for (Vue/Angular)
|
|
2167
|
+
const forAttr = h.getAttr(node, 'htmlFor') ?? h.getAttr(node, 'for')
|
|
2168
|
+
if (!forAttr) return
|
|
2169
|
+
const forVal = h.getAttrStringValue(forAttr)
|
|
2170
|
+
if (!forVal) return
|
|
2171
|
+
const id = forVal.trim()
|
|
2172
|
+
if (!labelForRefs.has(id)) labelForRefs.set(id, [])
|
|
2173
|
+
labelForRefs.get(id).push(forAttr)
|
|
2174
|
+
},
|
|
2175
|
+
|
|
2176
|
+
'Program:exit'() {
|
|
2177
|
+
for (const [id, nodes] of labelForRefs) {
|
|
2178
|
+
if (nodes.length < 2) continue
|
|
2179
|
+
// Report the second and subsequent labels - the first is fine
|
|
2180
|
+
for (const node of nodes.slice(1)) {
|
|
2181
|
+
context.report({ node, messageId: 'multipleLabels', data: { id } })
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
},
|
|
2185
|
+
}
|
|
2186
|
+
},
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
// ─── no-empty-table-header ────────────────────────────────────────────────────
|
|
2191
|
+
// <th> elements (and elements with role="columnheader" or role="rowheader") must
|
|
2192
|
+
// have accessible text - either text content or aria-label / aria-labelledby.
|
|
2193
|
+
// An empty table header is invisible to screen reader users; they cannot navigate
|
|
2194
|
+
// or understand the table structure.
|
|
2195
|
+
// Ref: axe-core empty-table-header (reimplemented); SC 1.3.1
|
|
2196
|
+
|
|
2197
|
+
const TABLE_HEADER_ROLES = new Set(['columnheader', 'rowheader'])
|
|
2198
|
+
|
|
2199
|
+
export function makeNoEmptyTableHeader(h) {
|
|
2200
|
+
return {
|
|
2201
|
+
meta: {
|
|
2202
|
+
type: 'problem',
|
|
2203
|
+
docs: { description: 'Require accessible text on <th> elements and header role elements' },
|
|
2204
|
+
messages: {
|
|
2205
|
+
emptyHeader:
|
|
2206
|
+
'This table header has no accessible name - screen readers cannot describe the column ' +
|
|
2207
|
+
'or row to users. Add visible text, aria-label, or aria-labelledby. ' +
|
|
2208
|
+
'(axe-core empty-table-header / SC 1.3.1)',
|
|
2209
|
+
},
|
|
2210
|
+
schema: [],
|
|
2211
|
+
},
|
|
2212
|
+
create(context) {
|
|
2213
|
+
return {
|
|
2214
|
+
[h.elementWithChildrenVisitor](node) {
|
|
2215
|
+
const opening = h.getOpeningElement(node)
|
|
2216
|
+
const el = h.getElementName(opening)
|
|
2217
|
+
const role = h.getRoleValue(opening)
|
|
2218
|
+
const isTh = el === 'th'
|
|
2219
|
+
const isHeaderRole = role && TABLE_HEADER_ROLES.has(role)
|
|
2220
|
+
if (!isTh && !isHeaderRole) return
|
|
2221
|
+
if (h.hasAccessibleName(opening)) return
|
|
2222
|
+
// Check for visible text children
|
|
2223
|
+
const children = h.getChildOpeningElementsFromWrapper(node)
|
|
2224
|
+
// hasOnlyHiddenChildren checks if ALL children are aria-hidden - if it
|
|
2225
|
+
// returns true on a childless node it returns false, so we also check
|
|
2226
|
+
// whether the element has zero children with text.
|
|
2227
|
+
// Use the wrapper-level text check available for JSX/Vue.
|
|
2228
|
+
if (!h.hasOnlyHiddenChildren(opening) && !isEffectivelyEmpty(node, h)) return
|
|
2229
|
+
context.report({ node: opening, messageId: 'emptyHeader' })
|
|
2230
|
+
},
|
|
2231
|
+
}
|
|
2232
|
+
},
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
/**
|
|
2237
|
+
* Returns true if the element wrapper has no visible text content.
|
|
2238
|
+
* Works for JSX (JSXElement.children) and Vue (VElement.children).
|
|
2239
|
+
* For Angular, elementWithChildrenVisitor === elementVisitor and children
|
|
2240
|
+
* are on tmplElement.children directly.
|
|
2241
|
+
*/
|
|
2242
|
+
function isEffectivelyEmpty(wrapperNode, h) {
|
|
2243
|
+
// For frameworks where wrapper === opening (Vue, Angular), use children directly
|
|
2244
|
+
const children = wrapperNode.children ?? wrapperNode.parent?.children ?? []
|
|
2245
|
+
if (children.length === 0) return true
|
|
2246
|
+
return children.every(child => {
|
|
2247
|
+
// JSX
|
|
2248
|
+
if (child.type === 'JSXText') return child.value.trim() === ''
|
|
2249
|
+
if (child.type === 'JSXExpressionContainer') {
|
|
2250
|
+
const ex = child.expression
|
|
2251
|
+
return ex.type === 'Literal' && String(ex.value).trim() === ''
|
|
2252
|
+
}
|
|
2253
|
+
// Vue
|
|
2254
|
+
if (child.type === 'VText') return (child.value ?? '').trim() === ''
|
|
2255
|
+
// Angular
|
|
2256
|
+
if (child.constructor?.name === 'TmplAstText') return (child.value ?? '').trim() === ''
|
|
2257
|
+
// Child element - not text, assume non-empty (may have aria-label etc.)
|
|
2258
|
+
return false
|
|
2259
|
+
})
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
// ─── All rules map ────────────────────────────────────────────────────────────
|
|
2263
|
+
|
|
2264
|
+
export const RULE_FACTORIES = {
|
|
2265
|
+
'no-aria-label-on-generic': makeNoAriaLabelOnGeneric,
|
|
2266
|
+
'no-assertive-live-overuse': makeNoAssertiveLiveOveruse,
|
|
2267
|
+
'warn-role-alert': makeWarnRoleAlert,
|
|
2268
|
+
'no-unblocked-aria-disabled': makeNoUnblockedAriaDisabled,
|
|
2269
|
+
'prefer-aria-disabled': makePreferAriaDisabled,
|
|
2270
|
+
'no-tooltip-role-misuse': makeNoTooltipRoleMisuse,
|
|
2271
|
+
'no-roles-without-name': makeNoRolesWithoutName,
|
|
2272
|
+
'no-group-without-name': makeNoGroupWithoutName,
|
|
2273
|
+
'no-tabs-without-structure': makeNoTabsWithoutStructure,
|
|
2274
|
+
'no-tab-without-controls': makeNoTabWithoutControls,
|
|
2275
|
+
'no-application-role': makeNoApplicationRole,
|
|
2276
|
+
'no-grid-role': makeNoGridRole,
|
|
2277
|
+
'no-menu-role-on-nav': makeNoMenuRoleOnNav,
|
|
2278
|
+
'no-presentation-on-focusable': makeNoPresentationOnFocusable,
|
|
2279
|
+
'no-log-with-interactive-children': makeNoLogWithInteractiveChildren,
|
|
2280
|
+
'no-redundant-aria-hidden-with-presentation': makeNoRedundantAriaHiddenWithPresentation,
|
|
2281
|
+
'no-aria-roledescription': makeNoAriaRoledescription,
|
|
2282
|
+
'no-aria-readonly': makeNoAriaReadonly,
|
|
2283
|
+
'no-aria-hidden-in-link': makeNoAriaHiddenInLink,
|
|
2284
|
+
'no-title-as-label': makeNoTitleAsLabel,
|
|
2285
|
+
'no-href-hash': makeNoHrefHash,
|
|
2286
|
+
'no-target-blank-without-label': makeNoTargetBlankWithoutLabel,
|
|
2287
|
+
'no-autoplay-without-controls': makeNoAutoplayWithoutControls,
|
|
2288
|
+
'no-heading-inside-interactive': makeNoHeadingInsideInteractive,
|
|
2289
|
+
'no-placeholder-only': makeNoPlaceholderOnly,
|
|
2290
|
+
'no-positive-tabindex': makeNoPositiveTabindex,
|
|
2291
|
+
'no-aria-owns-on-void': makeNoAriaOwnsOnVoid,
|
|
2292
|
+
'no-empty-button': makeNoEmptyButton,
|
|
2293
|
+
'no-image-role-without-name': makeNoImageRoleWithoutName,
|
|
2294
|
+
'no-spinbutton-without-range': makeNoSpinbuttonWithoutRange,
|
|
2295
|
+
'no-slider-without-range': makeNoSliderWithoutRange,
|
|
2296
|
+
'no-combobox-without-expanded': makeNoComboboxWithoutExpanded,
|
|
2297
|
+
'no-mouse-only-events': makeNoMouseOnlyEvents,
|
|
2298
|
+
'no-listbox-without-option': makeNoListboxWithoutOption,
|
|
2299
|
+
'no-tree-without-treeitem': makeNoTreeWithoutTreeitem,
|
|
2300
|
+
'no-feed-without-article': makeNoFeedWithoutArticle,
|
|
2301
|
+
'no-aria-activedescendant-without-id': makeNoAriaActivedescendantWithoutId,
|
|
2302
|
+
'no-dialog-without-close': makeNoDialogWithoutClose,
|
|
2303
|
+
// jsx-a11y portability rules - included in Vue/Angular configs only
|
|
2304
|
+
'no-anchor-ambiguous-text': makeNoAnchorAmbiguousText,
|
|
2305
|
+
'no-anchor-no-content': makeNoAnchorNoContent,
|
|
2306
|
+
'no-aria-activedescendant-no-tabindex': makeNoAriaActivedescendantNoTabindex,
|
|
2307
|
+
'no-invalid-aria-prop-value': makeNoInvalidAriaPropValue,
|
|
2308
|
+
'no-autocomplete-invalid': makeNoAutocompleteInvalid,
|
|
2309
|
+
'no-heading-no-content': makeNoHeadingNoContent,
|
|
2310
|
+
'no-iframe-no-title': makeNoIframeNoTitle,
|
|
2311
|
+
'no-img-redundant-alt': makeNoImgRedundantAlt,
|
|
2312
|
+
'no-access-key': makeNoAccessKey,
|
|
2313
|
+
'no-noninteractive-to-interactive-role': makeNoNoninteractiveToInteractiveRole,
|
|
2314
|
+
'no-noninteractive-tabindex': makeNoNoninteractiveTabindex,
|
|
2315
|
+
'prefer-semantic-element': makePreferSemanticElement,
|
|
2316
|
+
'no-role-supports-aria-props': makeNoRoleSupportsAriaProps,
|
|
2317
|
+
'no-scope-on-td': makeNoScopeOnTd,
|
|
2318
|
+
'no-duplicate-id': makeNoDuplicateId,
|
|
2319
|
+
'no-button-type-missing': makeNoButtonTypeMissing,
|
|
2320
|
+
'no-summary-without-details': makeNoSummaryWithoutDetails,
|
|
2321
|
+
'no-aria-required-on-non-form': makeNoAriaRequiredOnNonForm,
|
|
2322
|
+
'no-input-type-invalid': makeNoInputTypeInvalid,
|
|
2323
|
+
'no-labelledby-missing-target': makeNoLabelledbyMissingTarget,
|
|
2324
|
+
'no-dynamic-content-without-live': makeNoDynamicContentWithoutLive,
|
|
2325
|
+
'form-field-multiple-labels': makeFormFieldMultipleLabels,
|
|
2326
|
+
'no-empty-table-header': makeNoEmptyTableHeader,
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
/** Build the rules map for a plugin by applying helpers to all factories. */
|
|
2330
|
+
export function buildRules(h) {
|
|
2331
|
+
const rules = {}
|
|
2332
|
+
for (const [name, factory] of Object.entries(RULE_FACTORIES)) {
|
|
2333
|
+
rules[name] = factory(h)
|
|
2334
|
+
}
|
|
2335
|
+
return rules
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
/** Build the recommended config rules object for a given plugin namespace. */
|
|
2339
|
+
export function buildRecommendedRules(ns) {
|
|
2340
|
+
return {
|
|
2341
|
+
// errors - definite breakage or phantom controls
|
|
2342
|
+
[`${ns}/no-aria-label-on-generic`]: 'error',
|
|
2343
|
+
[`${ns}/no-assertive-live-overuse`]: 'error',
|
|
2344
|
+
[`${ns}/no-unblocked-aria-disabled`]: 'error',
|
|
2345
|
+
[`${ns}/no-roles-without-name`]: 'error',
|
|
2346
|
+
[`${ns}/no-group-without-name`]: 'error',
|
|
2347
|
+
[`${ns}/no-presentation-on-focusable`]: 'error',
|
|
2348
|
+
[`${ns}/no-log-with-interactive-children`]: 'error',
|
|
2349
|
+
[`${ns}/no-aria-hidden-in-link`]: 'error',
|
|
2350
|
+
[`${ns}/no-redundant-aria-hidden-with-presentation`]: 'error',
|
|
2351
|
+
[`${ns}/no-aria-owns-on-void`]: 'error',
|
|
2352
|
+
[`${ns}/no-title-as-label`]: 'error',
|
|
2353
|
+
[`${ns}/no-tabs-without-structure`]: 'error',
|
|
2354
|
+
[`${ns}/no-positive-tabindex`]: 'error',
|
|
2355
|
+
[`${ns}/no-autoplay-without-controls`]: 'error',
|
|
2356
|
+
[`${ns}/no-heading-inside-interactive`]: 'error',
|
|
2357
|
+
[`${ns}/no-placeholder-only`]: 'error',
|
|
2358
|
+
[`${ns}/no-empty-button`]: 'error',
|
|
2359
|
+
[`${ns}/no-image-role-without-name`]: 'error',
|
|
2360
|
+
[`${ns}/no-spinbutton-without-range`]: 'error',
|
|
2361
|
+
[`${ns}/no-slider-without-range`]: 'error',
|
|
2362
|
+
[`${ns}/no-combobox-without-expanded`]: 'error',
|
|
2363
|
+
[`${ns}/no-mouse-only-events`]: 'error',
|
|
2364
|
+
[`${ns}/no-listbox-without-option`]: 'error',
|
|
2365
|
+
[`${ns}/no-tree-without-treeitem`]: 'error',
|
|
2366
|
+
[`${ns}/no-feed-without-article`]: 'error',
|
|
2367
|
+
[`${ns}/no-aria-activedescendant-without-id`]: 'error',
|
|
2368
|
+
[`${ns}/no-duplicate-id`]: 'error',
|
|
2369
|
+
[`${ns}/no-summary-without-details`]: 'error',
|
|
2370
|
+
[`${ns}/no-aria-required-on-non-form`]: 'error',
|
|
2371
|
+
[`${ns}/no-input-type-invalid`]: 'error',
|
|
2372
|
+
[`${ns}/no-labelledby-missing-target`]: 'error',
|
|
2373
|
+
[`${ns}/no-dynamic-content-without-live`]: 'error',
|
|
2374
|
+
[`${ns}/form-field-multiple-labels`]: 'error',
|
|
2375
|
+
[`${ns}/no-empty-table-header`]: 'error',
|
|
2376
|
+
[`${ns}/no-button-type-missing`]: 'warn',
|
|
2377
|
+
// warnings - strong guidance, occasional legitimate overrides
|
|
2378
|
+
[`${ns}/no-tooltip-role-misuse`]: 'warn',
|
|
2379
|
+
[`${ns}/no-menu-role-on-nav`]: 'warn',
|
|
2380
|
+
// off by default - available to opt in, but noisy in real codebases
|
|
2381
|
+
// enable individually if the pattern applies to your project
|
|
2382
|
+
[`${ns}/no-application-role`]: 'off',
|
|
2383
|
+
[`${ns}/no-grid-role`]: 'off',
|
|
2384
|
+
[`${ns}/no-aria-roledescription`]: 'off',
|
|
2385
|
+
[`${ns}/no-aria-readonly`]: 'off',
|
|
2386
|
+
[`${ns}/no-tab-without-controls`]: 'off',
|
|
2387
|
+
[`${ns}/no-href-hash`]: 'off',
|
|
2388
|
+
[`${ns}/warn-role-alert`]: 'off',
|
|
2389
|
+
[`${ns}/prefer-aria-disabled`]: 'off',
|
|
2390
|
+
[`${ns}/no-target-blank-without-label`]: 'off',
|
|
2391
|
+
[`${ns}/no-dialog-without-close`]: 'off',
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
/** Build portability rules for Vue/Angular configs (jsx-a11y gap rules). */
|
|
2396
|
+
export function buildPortabilityRules(ns) {
|
|
2397
|
+
return {
|
|
2398
|
+
[`${ns}/no-anchor-ambiguous-text`]: 'error',
|
|
2399
|
+
[`${ns}/no-anchor-no-content`]: 'error',
|
|
2400
|
+
[`${ns}/no-aria-activedescendant-no-tabindex`]: 'error',
|
|
2401
|
+
[`${ns}/no-invalid-aria-prop-value`]: 'error',
|
|
2402
|
+
[`${ns}/no-autocomplete-invalid`]: 'error',
|
|
2403
|
+
[`${ns}/no-heading-no-content`]: 'error',
|
|
2404
|
+
[`${ns}/no-iframe-no-title`]: 'error',
|
|
2405
|
+
[`${ns}/no-img-redundant-alt`]: 'warn',
|
|
2406
|
+
[`${ns}/no-access-key`]: 'warn',
|
|
2407
|
+
[`${ns}/no-noninteractive-to-interactive-role`]: 'error',
|
|
2408
|
+
[`${ns}/no-noninteractive-tabindex`]: 'error',
|
|
2409
|
+
[`${ns}/prefer-semantic-element`]: 'warn',
|
|
2410
|
+
[`${ns}/no-role-supports-aria-props`]: 'error',
|
|
2411
|
+
[`${ns}/no-scope-on-td`]: 'error',
|
|
2412
|
+
}
|
|
2413
|
+
}
|