@tanstack/devtools-a11y 0.0.1 → 0.1.1

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.
Files changed (110) hide show
  1. package/LICENSE +21 -0
  2. package/dist/esm/core/components/IssueCard.d.ts +10 -0
  3. package/dist/esm/core/components/IssueCard.js +83 -0
  4. package/dist/esm/core/components/IssueCard.js.map +1 -0
  5. package/dist/esm/core/components/IssueList.d.ts +6 -0
  6. package/dist/esm/core/components/IssueList.js +134 -0
  7. package/dist/esm/core/components/IssueList.js.map +1 -0
  8. package/dist/esm/core/components/Settings.d.ts +6 -0
  9. package/dist/esm/core/components/Settings.js +251 -0
  10. package/dist/esm/core/components/Settings.js.map +1 -0
  11. package/dist/esm/core/components/Shell.d.ts +2 -0
  12. package/dist/esm/core/components/Shell.js +214 -0
  13. package/dist/esm/core/components/Shell.js.map +1 -0
  14. package/dist/esm/core/components/index.d.ts +2 -0
  15. package/dist/esm/core/components/index.js +14 -0
  16. package/dist/esm/core/components/index.js.map +1 -0
  17. package/dist/esm/core/contexts/allyContext.d.ts +17 -0
  18. package/dist/esm/core/contexts/allyContext.js +66 -0
  19. package/dist/esm/core/contexts/allyContext.js.map +1 -0
  20. package/dist/esm/core/core.d.ts +19 -0
  21. package/dist/esm/core/core.js +8 -0
  22. package/dist/esm/core/core.js.map +1 -0
  23. package/dist/esm/core/index.d.ts +9 -0
  24. package/dist/esm/core/index.js +9 -0
  25. package/dist/esm/core/index.js.map +1 -0
  26. package/dist/esm/core/production.d.ts +2 -0
  27. package/dist/esm/core/production.js +4 -0
  28. package/dist/esm/core/styles/styles.d.ts +85 -0
  29. package/dist/esm/core/styles/styles.js +547 -0
  30. package/dist/esm/core/styles/styles.js.map +1 -0
  31. package/dist/esm/core/types/types.d.ts +141 -0
  32. package/dist/esm/core/utils/ally-audit.utils.d.ts +19 -0
  33. package/dist/esm/core/utils/ally-audit.utils.js +226 -0
  34. package/dist/esm/core/utils/ally-audit.utils.js.map +1 -0
  35. package/dist/esm/core/utils/config.utils.d.ts +17 -0
  36. package/dist/esm/core/utils/config.utils.js +63 -0
  37. package/dist/esm/core/utils/config.utils.js.map +1 -0
  38. package/dist/esm/core/utils/custom-audit.utils.d.ts +13 -0
  39. package/dist/esm/core/utils/custom-audit.utils.js +426 -0
  40. package/dist/esm/core/utils/custom-audit.utils.js.map +1 -0
  41. package/dist/esm/core/utils/export-audit.uitls.d.ts +17 -0
  42. package/dist/esm/core/utils/export-audit.uitls.js +83 -0
  43. package/dist/esm/core/utils/export-audit.uitls.js.map +1 -0
  44. package/dist/esm/core/utils/ui.utils.d.ts +24 -0
  45. package/dist/esm/core/utils/ui.utils.js +330 -0
  46. package/dist/esm/core/utils/ui.utils.js.map +1 -0
  47. package/dist/esm/react/A11yDevtools.d.ts +5 -0
  48. package/dist/esm/react/A11yDevtools.js +8 -0
  49. package/dist/esm/react/A11yDevtools.js.map +1 -0
  50. package/dist/esm/react/index.d.ts +8 -0
  51. package/dist/esm/react/index.js +11 -0
  52. package/dist/esm/react/index.js.map +1 -0
  53. package/dist/esm/react/plugin.d.ts +12 -0
  54. package/dist/esm/react/plugin.js +11 -0
  55. package/dist/esm/react/plugin.js.map +1 -0
  56. package/dist/esm/react/production/A11yDevtools.d.ts +5 -0
  57. package/dist/esm/react/production/A11yDevtools.js +8 -0
  58. package/dist/esm/react/production/A11yDevtools.js.map +1 -0
  59. package/dist/esm/react/production/plugin.d.ts +7 -0
  60. package/dist/esm/react/production/plugin.js +11 -0
  61. package/dist/esm/react/production/plugin.js.map +1 -0
  62. package/dist/esm/react/production.d.ts +3 -0
  63. package/dist/esm/react/production.js +5 -0
  64. package/dist/esm/solid/A11yDevtools.d.ts +5 -0
  65. package/dist/esm/solid/A11yDevtools.js +8 -0
  66. package/dist/esm/solid/A11yDevtools.js.map +1 -0
  67. package/dist/esm/solid/index.d.ts +8 -0
  68. package/dist/esm/solid/index.js +9 -0
  69. package/dist/esm/solid/index.js.map +1 -0
  70. package/dist/esm/solid/plugin.d.ts +12 -0
  71. package/dist/esm/solid/plugin.js +11 -0
  72. package/dist/esm/solid/plugin.js.map +1 -0
  73. package/dist/esm/solid/production/A11yDevtools.d.ts +5 -0
  74. package/dist/esm/solid/production/A11yDevtools.js +8 -0
  75. package/dist/esm/solid/production/A11yDevtools.js.map +1 -0
  76. package/dist/esm/solid/production/plugin.d.ts +7 -0
  77. package/dist/esm/solid/production/plugin.js +11 -0
  78. package/dist/esm/solid/production/plugin.js.map +1 -0
  79. package/dist/esm/solid/production.d.ts +3 -0
  80. package/dist/esm/solid/production.js +3 -0
  81. package/package.json +110 -7
  82. package/src/core/components/IssueCard.tsx +75 -0
  83. package/src/core/components/IssueList.tsx +155 -0
  84. package/src/core/components/Settings.tsx +221 -0
  85. package/src/core/components/Shell.tsx +154 -0
  86. package/src/core/components/index.tsx +12 -0
  87. package/src/core/contexts/allyContext.tsx +118 -0
  88. package/src/core/core.tsx +11 -0
  89. package/src/core/index.ts +10 -0
  90. package/src/core/production.ts +5 -0
  91. package/src/core/styles/styles.ts +556 -0
  92. package/src/core/types/types.ts +177 -0
  93. package/src/core/utils/ally-audit.utils.ts +345 -0
  94. package/src/core/utils/config.utils.ts +68 -0
  95. package/src/core/utils/custom-audit.utils.ts +643 -0
  96. package/src/core/utils/export-audit.uitls.ts +180 -0
  97. package/src/core/utils/ui.utils.ts +483 -0
  98. package/src/react/A11yDevtools.ts +12 -0
  99. package/src/react/index.ts +16 -0
  100. package/src/react/plugin.ts +9 -0
  101. package/src/react/production/A11yDevtools.ts +11 -0
  102. package/src/react/production/plugin.ts +9 -0
  103. package/src/react/production.ts +7 -0
  104. package/src/solid/A11yDevtools.ts +11 -0
  105. package/src/solid/index.ts +14 -0
  106. package/src/solid/plugin.ts +9 -0
  107. package/src/solid/production/A11yDevtools.ts +10 -0
  108. package/src/solid/production/plugin.ts +9 -0
  109. package/src/solid/production.ts +5 -0
  110. package/README.md +0 -45
@@ -0,0 +1,643 @@
1
+ /**
2
+ * Custom accessibility rules for issues not covered by axe-core
3
+ *
4
+ * These rules detect common accessibility anti-patterns like:
5
+ * - Click handlers on non-interactive elements
6
+ * - Mouse-only event handlers without keyboard equivalents
7
+ * - Static elements with interactive handlers
8
+ */
9
+
10
+ import { meetsThreshold } from './ally-audit.utils'
11
+
12
+ import type {
13
+ A11yIssue,
14
+ CustomRulesConfig,
15
+ SeverityThreshold,
16
+ } from '../types/types'
17
+
18
+ /**
19
+ * Interactive HTML elements that can receive focus and have implicit roles
20
+ */
21
+ const INTERACTIVE_ELEMENTS = new Set([
22
+ 'a',
23
+ 'button',
24
+ 'input',
25
+ 'select',
26
+ 'textarea',
27
+ 'details',
28
+ 'summary',
29
+ 'audio',
30
+ 'video',
31
+ ])
32
+
33
+ /**
34
+ * Elements that are interactive when they have an href attribute
35
+ */
36
+ const INTERACTIVE_WITH_HREF = new Set(['a', 'area'])
37
+
38
+ /**
39
+ * Interactive ARIA roles
40
+ */
41
+ const INTERACTIVE_ROLES = new Set([
42
+ 'button',
43
+ 'checkbox',
44
+ 'combobox',
45
+ 'gridcell',
46
+ 'link',
47
+ 'listbox',
48
+ 'menu',
49
+ 'menubar',
50
+ 'menuitem',
51
+ 'menuitemcheckbox',
52
+ 'menuitemradio',
53
+ 'option',
54
+ 'progressbar',
55
+ 'radio',
56
+ 'scrollbar',
57
+ 'searchbox',
58
+ 'slider',
59
+ 'spinbutton',
60
+ 'switch',
61
+ 'tab',
62
+ 'tabpanel',
63
+ 'textbox',
64
+ 'tree',
65
+ 'treeitem',
66
+ ])
67
+
68
+ /**
69
+ * Mouse-only events that should have keyboard equivalents
70
+ */
71
+ const MOUSE_ONLY_EVENTS = [
72
+ 'onclick',
73
+ 'ondblclick',
74
+ 'onmousedown',
75
+ 'onmouseup',
76
+ 'onmouseover',
77
+ 'onmouseout',
78
+ 'onmouseenter',
79
+ 'onmouseleave',
80
+ ]
81
+
82
+ /**
83
+ * Keyboard events that would make an element accessible
84
+ */
85
+ const KEYBOARD_EVENTS = ['onkeydown', 'onkeyup', 'onkeypress']
86
+
87
+ /**
88
+ * Selectors for devtools elements to exclude
89
+ */
90
+ const DEVTOOLS_SELECTORS = [
91
+ '[data-testid="tanstack_devtools"]',
92
+ '[data-devtools]',
93
+ '[data-devtools-panel]',
94
+ '[data-a11y-overlay]',
95
+ ]
96
+
97
+ /**
98
+ * Common root container element IDs used by frameworks.
99
+ * React attaches event delegation to these elements, which would
100
+ * cause false positives for click handler detection.
101
+ */
102
+ const ROOT_CONTAINER_IDS = new Set([
103
+ 'root',
104
+ 'app',
105
+ '__next', // Next.js
106
+ '__nuxt', // Nuxt
107
+ '__gatsby', // Gatsby
108
+ 'app-root', // Angular
109
+ 'svelte', // SvelteKit
110
+ 'q-app', // Qwik
111
+ ])
112
+
113
+ /**
114
+ * Check if an element is a root container (framework app mount point).
115
+ * These elements often have React internals attached for event delegation
116
+ * but don't actually have user-defined click handlers.
117
+ */
118
+ function isRootContainer(element: Element): boolean {
119
+ // Check by ID
120
+ if (element.id && ROOT_CONTAINER_IDS.has(element.id)) {
121
+ return true
122
+ }
123
+
124
+ // Check if direct child of body (common for app containers)
125
+ if (element.parentElement === document.body) {
126
+ // Only consider it a root if it has no meaningful content attributes
127
+ // that would indicate it's an interactive element
128
+ const tagName = element.tagName.toLowerCase()
129
+ if (tagName === 'div' || tagName === 'main' || tagName === 'section') {
130
+ // Check if this looks like an app container (wraps most of the page)
131
+ // by checking if it has React fiber but no explicit onClick in props
132
+ const keys = Object.keys(element)
133
+ for (const key of keys) {
134
+ if (key.startsWith('__reactProps$')) {
135
+ const props = (element as unknown as Record<string, unknown>)[key]
136
+ if (props && typeof props === 'object') {
137
+ const propsObj = props as Record<string, unknown>
138
+ // If it has children but no onClick, it's likely a container
139
+ if ('children' in propsObj && !('onClick' in propsObj)) {
140
+ return true
141
+ }
142
+ }
143
+ }
144
+ }
145
+ }
146
+ }
147
+
148
+ return false
149
+ }
150
+
151
+ /**
152
+ * Check if an element is inside devtools
153
+ */
154
+ function isInsideDevtools(element: Element): boolean {
155
+ for (const selector of DEVTOOLS_SELECTORS) {
156
+ if (element.closest(selector)) {
157
+ return true
158
+ }
159
+ }
160
+ return false
161
+ }
162
+
163
+ /**
164
+ * Check if element is interactive by nature
165
+ */
166
+ function isInteractiveElement(element: Element): boolean {
167
+ const tagName = element.tagName.toLowerCase()
168
+
169
+ // Check if it's an inherently interactive element
170
+ if (INTERACTIVE_ELEMENTS.has(tagName)) {
171
+ // Disabled elements are not interactive
172
+ return !element.hasAttribute('disabled')
173
+ }
174
+
175
+ // Check if it's an element that becomes interactive with href
176
+ return INTERACTIVE_WITH_HREF.has(tagName) && element.hasAttribute('href')
177
+ }
178
+
179
+ /**
180
+ * Check if element has an interactive ARIA role
181
+ */
182
+ function hasInteractiveRole(element: Element): boolean {
183
+ const role = element.getAttribute('role')
184
+ return role !== null && INTERACTIVE_ROLES.has(role)
185
+ }
186
+
187
+ /**
188
+ * Check if element is focusable (has tabindex)
189
+ */
190
+ function isFocusable(element: Element): boolean {
191
+ const tabindex = element.getAttribute('tabindex')
192
+ if (tabindex === null) {
193
+ return false
194
+ }
195
+ const tabindexValue = parseInt(tabindex, 10)
196
+ return !isNaN(tabindexValue) && tabindexValue >= 0
197
+ }
198
+
199
+ /**
200
+ * Check if element has click event handlers (via attribute or property)
201
+ */
202
+ function hasClickHandler(element: Element): boolean {
203
+ // Check for onclick attribute
204
+ if (element.hasAttribute('onclick')) {
205
+ return true
206
+ }
207
+
208
+ // Check for event listener via property (common in React/frameworks)
209
+ // Note: We can't detect addEventListener calls, but we can check common patterns
210
+ const htmlElement = element as HTMLElement
211
+
212
+ // Check if onclick property is set
213
+ if (typeof htmlElement.onclick === 'function') {
214
+ return true
215
+ }
216
+
217
+ // Check for React synthetic events (data attributes often indicate handlers)
218
+ // React 17+ uses __reactFiber$ and __reactProps$ prefixed properties
219
+ const keys = Object.keys(element)
220
+ for (const key of keys) {
221
+ if (
222
+ key.startsWith('__reactProps$') ||
223
+ key.startsWith('__reactFiber$') ||
224
+ key.startsWith('__reactEventHandlers$')
225
+ ) {
226
+ // Element has React internals, likely has event handlers
227
+ // We can't easily inspect these, so we'll check for common patterns
228
+ const props = (element as unknown as Record<string, unknown>)[key]
229
+ if (props && typeof props === 'object') {
230
+ const propsObj = props as Record<string, unknown>
231
+ if (
232
+ typeof propsObj.onClick === 'function' ||
233
+ typeof propsObj.onMouseDown === 'function' ||
234
+ typeof propsObj.onMouseUp === 'function'
235
+ ) {
236
+ return true
237
+ }
238
+ }
239
+ }
240
+ }
241
+
242
+ return false
243
+ }
244
+
245
+ /**
246
+ * Check if element has keyboard event handlers
247
+ */
248
+ function hasKeyboardHandler(element: Element): boolean {
249
+ // Check for keyboard event attributes
250
+ for (const event of KEYBOARD_EVENTS) {
251
+ if (element.hasAttribute(event)) {
252
+ return true
253
+ }
254
+ }
255
+
256
+ const htmlElement = element as HTMLElement
257
+ if (
258
+ typeof htmlElement.onkeydown === 'function' ||
259
+ typeof htmlElement.onkeyup === 'function' ||
260
+ typeof htmlElement.onkeypress === 'function'
261
+ ) {
262
+ return true
263
+ }
264
+
265
+ // Check React props for keyboard handlers
266
+ const keys = Object.keys(element)
267
+ for (const key of keys) {
268
+ if (key.startsWith('__reactProps$')) {
269
+ const props = (element as unknown as Record<string, unknown>)[key]
270
+ if (props && typeof props === 'object') {
271
+ const propsObj = props as Record<string, unknown>
272
+ if (
273
+ typeof propsObj.onKeyDown === 'function' ||
274
+ typeof propsObj.onKeyUp === 'function' ||
275
+ typeof propsObj.onKeyPress === 'function'
276
+ ) {
277
+ return true
278
+ }
279
+ }
280
+ }
281
+ }
282
+
283
+ return false
284
+ }
285
+
286
+ /**
287
+ * Class prefixes to exclude from selectors (devtools overlay classes)
288
+ */
289
+ const EXCLUDED_CLASS_PREFIXES = ['tsd-a11y-']
290
+
291
+ /**
292
+ * Filter out devtools-injected classes from class list
293
+ */
294
+ function filterClasses(classList: DOMTokenList): Array<string> {
295
+ return Array.from(classList).filter(
296
+ (cls) => !EXCLUDED_CLASS_PREFIXES.some((prefix) => cls.startsWith(prefix)),
297
+ )
298
+ }
299
+
300
+ /**
301
+ * Get a unique selector for an element
302
+ */
303
+ function getSelector(element: Element): string {
304
+ // Try to build a unique selector
305
+ if (element.id) {
306
+ return `#${element.id}`
307
+ }
308
+
309
+ const tagName = element.tagName.toLowerCase()
310
+ // Filter out devtools overlay classes (tsd-a11y-highlight, etc.)
311
+ const classes = filterClasses(element.classList).join('.')
312
+ const classSelector = classes ? `.${classes}` : ''
313
+
314
+ // Build path from parent
315
+ const parent = element.parentElement
316
+ if (parent && parent !== document.body) {
317
+ const parentSelector = getSelector(parent)
318
+ const siblings = Array.from(parent.children).filter(
319
+ (el) => el.tagName === element.tagName,
320
+ )
321
+ if (siblings.length > 1) {
322
+ const index = siblings.indexOf(element) + 1
323
+ return `${parentSelector} > ${tagName}${classSelector}:nth-of-type(${index})`
324
+ }
325
+ return `${parentSelector} > ${tagName}${classSelector}`
326
+ }
327
+
328
+ return `${tagName}${classSelector}`
329
+ }
330
+
331
+ /**
332
+ * Custom rule: Click handler on non-interactive element
333
+ *
334
+ * This rule detects elements that have click handlers but are not:
335
+ * - Interactive HTML elements (button, a, input, etc.)
336
+ * - Elements with interactive ARIA roles
337
+ * - Elements with tabindex for keyboard access
338
+ */
339
+ function checkClickHandlerOnNonInteractive(
340
+ context: Document | Element = document,
341
+ threshold: SeverityThreshold = 'serious',
342
+ ): Array<A11yIssue> {
343
+ const issues: Array<A11yIssue> = []
344
+ const timestamp = Date.now()
345
+
346
+ // Query all elements and check for click handlers
347
+ const allElements = context.querySelectorAll('*')
348
+
349
+ for (const element of allElements) {
350
+ // Skip devtools elements
351
+ if (isInsideDevtools(element)) {
352
+ continue
353
+ }
354
+
355
+ // Skip root container elements (e.g., #root, #app)
356
+ // These often have React event delegation attached but no actual click handlers
357
+ if (isRootContainer(element)) {
358
+ continue
359
+ }
360
+
361
+ // Skip if element is interactive
362
+ if (isInteractiveElement(element) || hasInteractiveRole(element)) {
363
+ continue
364
+ }
365
+
366
+ // Check if element has click handler
367
+ if (!hasClickHandler(element)) {
368
+ continue
369
+ }
370
+
371
+ // Element has click handler but is not interactive
372
+ // Check if it at least has keyboard access
373
+ const hasFocus = isFocusable(element)
374
+ const hasKeyboard = hasKeyboardHandler(element)
375
+
376
+ if (!hasFocus && !hasKeyboard) {
377
+ // Critical: No keyboard access at all
378
+ const selector = getSelector(element)
379
+ issues.push({
380
+ id: `click-handler-no-keyboard-${timestamp}-${issues.length}`,
381
+ ruleId: 'click-handler-on-non-interactive',
382
+ impact: 'serious',
383
+ message:
384
+ 'Element has a click handler but is not keyboard accessible. Add tabindex="0" and keyboard event handlers, or use an interactive element like <button>.',
385
+ help: 'Interactive elements must be keyboard accessible',
386
+ helpUrl:
387
+ 'https://www.w3.org/WAI/WCAG21/Understanding/keyboard-accessible',
388
+ wcagTags: ['wcag211', 'wcag21a'],
389
+ nodes: [
390
+ {
391
+ selector,
392
+ html: element.outerHTML.slice(0, 200),
393
+ },
394
+ ],
395
+ meetsThreshold: meetsThreshold('serious', threshold),
396
+ timestamp,
397
+ })
398
+ } else if (hasFocus && !hasKeyboard) {
399
+ // Moderate: Has tabindex but no keyboard handler
400
+ const selector = getSelector(element)
401
+ issues.push({
402
+ id: `click-handler-no-keyboard-handler-${timestamp}-${issues.length}`,
403
+ ruleId: 'click-handler-on-non-interactive',
404
+ impact: 'moderate',
405
+ message:
406
+ 'Element has a click handler and tabindex but no keyboard event handler. Add onKeyDown/onKeyPress to handle Enter/Space keys.',
407
+ help: 'Interactive elements should respond to keyboard events',
408
+ helpUrl:
409
+ 'https://www.w3.org/WAI/WCAG21/Understanding/keyboard-accessible',
410
+ wcagTags: ['wcag211', 'wcag21a'],
411
+ nodes: [
412
+ {
413
+ selector,
414
+ html: element.outerHTML.slice(0, 200),
415
+ },
416
+ ],
417
+ meetsThreshold: meetsThreshold('moderate', threshold),
418
+ timestamp,
419
+ })
420
+ }
421
+ }
422
+
423
+ return issues
424
+ }
425
+
426
+ /**
427
+ * Custom rule: Mouse-only event handlers
428
+ *
429
+ * Detects elements that have mouse event handlers (onmouseover, onmousedown, etc.)
430
+ * without corresponding keyboard event handlers.
431
+ */
432
+ function checkMouseOnlyEvents(
433
+ context: Document | Element = document,
434
+ threshold: SeverityThreshold = 'serious',
435
+ ): Array<A11yIssue> {
436
+ const issues: Array<A11yIssue> = []
437
+ const timestamp = Date.now()
438
+ // default threshold will be provided by runCustomRules
439
+ // We'll accept threshold by adding a parameter in the function signature
440
+
441
+ // Build selector for elements with mouse events
442
+ const mouseEventSelectors = MOUSE_ONLY_EVENTS.map(
443
+ (event) => `[${event}]`,
444
+ ).join(', ')
445
+
446
+ const elements = context.querySelectorAll(mouseEventSelectors)
447
+
448
+ for (const element of elements) {
449
+ // Skip devtools elements
450
+ if (isInsideDevtools(element)) {
451
+ continue
452
+ }
453
+
454
+ // Skip interactive elements (they handle keyboard by default)
455
+ if (isInteractiveElement(element)) {
456
+ continue
457
+ }
458
+
459
+ // Check if element has keyboard handlers
460
+ if (hasKeyboardHandler(element) || isFocusable(element)) {
461
+ continue
462
+ }
463
+
464
+ const mouseEvents: Array<string> = []
465
+ for (const event of MOUSE_ONLY_EVENTS) {
466
+ if (element.hasAttribute(event)) {
467
+ mouseEvents.push(event)
468
+ }
469
+ }
470
+
471
+ const selector = getSelector(element)
472
+ issues.push({
473
+ id: `mouse-only-events-${timestamp}-${issues.length}`,
474
+ ruleId: 'mouse-only-event-handlers',
475
+ impact: 'serious',
476
+ message: `Element has mouse-only event handlers (${mouseEvents.join(', ')}) without keyboard equivalents. Ensure functionality is available via keyboard.`,
477
+ help: 'All functionality must be operable through keyboard',
478
+ helpUrl: 'https://www.w3.org/WAI/WCAG21/Understanding/keyboard',
479
+ wcagTags: ['wcag211', 'wcag21a'],
480
+ nodes: [
481
+ {
482
+ selector,
483
+ html: element.outerHTML.slice(0, 200),
484
+ },
485
+ ],
486
+ meetsThreshold: meetsThreshold('serious', threshold),
487
+ timestamp,
488
+ })
489
+ }
490
+
491
+ return issues
492
+ }
493
+
494
+ /**
495
+ * Custom rule: Static element with interactive semantics
496
+ *
497
+ * Detects elements like <div> or <span> that have role="button" but lack
498
+ * proper keyboard handling (tabindex and key events).
499
+ */
500
+ function checkStaticElementInteraction(
501
+ context: Document | Element = document,
502
+ threshold: SeverityThreshold = 'serious',
503
+ ): Array<A11yIssue> {
504
+ const issues: Array<A11yIssue> = []
505
+ const timestamp = Date.now()
506
+
507
+ // Query elements with interactive roles
508
+ const roleSelectors = Array.from(INTERACTIVE_ROLES)
509
+ .map((role) => `[role="${role}"]`)
510
+ .join(', ')
511
+
512
+ const elements = context.querySelectorAll(roleSelectors)
513
+
514
+ for (const element of elements) {
515
+ // Skip devtools elements
516
+ if (isInsideDevtools(element)) {
517
+ continue
518
+ }
519
+
520
+ // Skip inherently interactive elements
521
+ if (isInteractiveElement(element)) {
522
+ continue
523
+ }
524
+
525
+ const role = element.getAttribute('role')
526
+ const hasFocus = isFocusable(element)
527
+ const hasKeyboard = hasKeyboardHandler(element)
528
+
529
+ // Check for missing tabindex
530
+ if (!hasFocus) {
531
+ const selector = getSelector(element)
532
+ issues.push({
533
+ id: `static-element-no-tabindex-${timestamp}-${issues.length}`,
534
+ ruleId: 'static-element-interaction',
535
+ impact: 'serious',
536
+ message: `Element with role="${role}" is not focusable. Add tabindex="0" to make it keyboard accessible.`,
537
+ help: 'Elements with interactive roles must be focusable',
538
+ helpUrl:
539
+ 'https://www.w3.org/WAI/WCAG21/Techniques/aria/ARIA4#description',
540
+ wcagTags: ['wcag211', 'wcag21a', 'wcag412'],
541
+ nodes: [
542
+ {
543
+ selector,
544
+ html: element.outerHTML.slice(0, 200),
545
+ },
546
+ ],
547
+ meetsThreshold: meetsThreshold('serious', threshold),
548
+ timestamp,
549
+ })
550
+ }
551
+
552
+ // Check for missing keyboard handlers (for button-like roles)
553
+ const requiresKeyboardActivation = ['button', 'link', 'menuitem', 'option']
554
+ if (
555
+ role &&
556
+ requiresKeyboardActivation.includes(role) &&
557
+ !hasKeyboard &&
558
+ hasClickHandler(element)
559
+ ) {
560
+ const selector = getSelector(element)
561
+ issues.push({
562
+ id: `static-element-no-keyboard-${timestamp}-${issues.length}`,
563
+ ruleId: 'static-element-interaction',
564
+ impact: 'moderate',
565
+ message: `Element with role="${role}" has click handler but no keyboard handler. Add onKeyDown to handle Enter/Space.`,
566
+ help: 'Elements with button-like roles should respond to Enter and Space keys',
567
+ helpUrl:
568
+ 'https://www.w3.org/WAI/WCAG21/Techniques/aria/ARIA4#description',
569
+ wcagTags: ['wcag211', 'wcag21a'],
570
+ nodes: [
571
+ {
572
+ selector,
573
+ html: element.outerHTML.slice(0, 200),
574
+ },
575
+ ],
576
+ meetsThreshold: meetsThreshold('moderate', threshold),
577
+ timestamp,
578
+ })
579
+ }
580
+ }
581
+
582
+ return issues
583
+ }
584
+
585
+ /**
586
+ * Run all enabled custom rules
587
+ */
588
+ export function runCustomRules(
589
+ context: Document | Element = document,
590
+ config: CustomRulesConfig = {},
591
+ threshold: SeverityThreshold = 'serious',
592
+ ): Array<A11yIssue> {
593
+ const {
594
+ clickHandlerOnNonInteractive = true,
595
+ mouseOnlyEventHandlers = true,
596
+ staticElementInteraction = true,
597
+ } = config
598
+
599
+ const issues: Array<A11yIssue> = []
600
+
601
+ if (clickHandlerOnNonInteractive) {
602
+ issues.push(...checkClickHandlerOnNonInteractive(context, threshold))
603
+ }
604
+
605
+ if (mouseOnlyEventHandlers) {
606
+ issues.push(...checkMouseOnlyEvents(context, threshold))
607
+ }
608
+
609
+ if (staticElementInteraction) {
610
+ issues.push(...checkStaticElementInteraction(context, threshold))
611
+ }
612
+
613
+ return issues
614
+ }
615
+
616
+ /**
617
+ * Get list of custom rule metadata (for UI display)
618
+ */
619
+ export function getCustomRules(): Array<{
620
+ id: string
621
+ description: string
622
+ tags: Array<string>
623
+ }> {
624
+ return [
625
+ {
626
+ id: 'click-handler-on-non-interactive',
627
+ description:
628
+ 'Ensures click handlers are only on keyboard-accessible elements',
629
+ tags: ['custom', 'cat.keyboard', 'wcag21a', 'wcag211'],
630
+ },
631
+ {
632
+ id: 'mouse-only-event-handlers',
633
+ description: 'Ensures mouse event handlers have keyboard equivalents',
634
+ tags: ['custom', 'cat.keyboard', 'wcag21a', 'wcag211'],
635
+ },
636
+ {
637
+ id: 'static-element-interaction',
638
+ description:
639
+ 'Ensures elements with interactive roles are properly keyboard accessible',
640
+ tags: ['custom', 'cat.keyboard', 'cat.aria', 'wcag21a', 'wcag211'],
641
+ },
642
+ ]
643
+ }