@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/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.comrule 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 targetadd 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 clicksonClick 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 useddisables 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 usedalmost 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 usedtriggers 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-roledescriptionalmost 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-readonlyvirtually 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 contentAT 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 ita phantom control. Remove tabIndex/interactivity or remove the role. (Roselli / Lauke / O\'HaraWCAG 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 / GrovesWCAG 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 treerole="{{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 meaninglessvoid 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 usedprompt 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 specaria-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 orderkeyboard 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 itaria-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 focusit 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 nameAT 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\'HaraWCAG 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 excludeddrag/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 treemouse-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 accessskip 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 staticallyensure 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 valuewarn 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 errorEscape 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 configjsx-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 textskip
1254
- if (h.hasAttr(node, 'aria-label') || h.hasAttr(node, 'aria-labelledby')) return
1255
- // We can only check static className/text at lint timeskip 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 namephantom 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-labelAT 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 ATthey 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 contentAT 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 nameAT 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 redundantAT 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 wordAT 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 rolekeyboard 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/noneno 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 htmlForAT 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 matchduplicate 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 availableJSX 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 skipcorrect 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-operablea 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-operablea 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 nullcannot 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 itdead 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 rolesvalid
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 typedefaults to text, valid
2007
- const val = h.getAttrStringValue(typeAttr)
2008
- if (val === null) return // dynamic expressioncannot 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 itselfa 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 Angulardegrades 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 themthe 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 labelsduplicates 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 labelsthe 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 texteither 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 namescreen 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-hiddenif 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 elementnot 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 rulesincluded 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
- // errorsdefinite 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
- // warningsstrong guidance, occasional legitimate overrides
2378
- [`${ns}/no-tooltip-role-misuse`]: 'warn',
2379
- [`${ns}/no-menu-role-on-nav`]: 'warn',
2380
- // off by defaultavailable 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
- }
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
+ }