@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.
- package/LICENSE +21 -0
- package/dist/esm/core/components/IssueCard.d.ts +10 -0
- package/dist/esm/core/components/IssueCard.js +83 -0
- package/dist/esm/core/components/IssueCard.js.map +1 -0
- package/dist/esm/core/components/IssueList.d.ts +6 -0
- package/dist/esm/core/components/IssueList.js +134 -0
- package/dist/esm/core/components/IssueList.js.map +1 -0
- package/dist/esm/core/components/Settings.d.ts +6 -0
- package/dist/esm/core/components/Settings.js +251 -0
- package/dist/esm/core/components/Settings.js.map +1 -0
- package/dist/esm/core/components/Shell.d.ts +2 -0
- package/dist/esm/core/components/Shell.js +214 -0
- package/dist/esm/core/components/Shell.js.map +1 -0
- package/dist/esm/core/components/index.d.ts +2 -0
- package/dist/esm/core/components/index.js +14 -0
- package/dist/esm/core/components/index.js.map +1 -0
- package/dist/esm/core/contexts/allyContext.d.ts +17 -0
- package/dist/esm/core/contexts/allyContext.js +66 -0
- package/dist/esm/core/contexts/allyContext.js.map +1 -0
- package/dist/esm/core/core.d.ts +19 -0
- package/dist/esm/core/core.js +8 -0
- package/dist/esm/core/core.js.map +1 -0
- package/dist/esm/core/index.d.ts +9 -0
- package/dist/esm/core/index.js +9 -0
- package/dist/esm/core/index.js.map +1 -0
- package/dist/esm/core/production.d.ts +2 -0
- package/dist/esm/core/production.js +4 -0
- package/dist/esm/core/styles/styles.d.ts +85 -0
- package/dist/esm/core/styles/styles.js +547 -0
- package/dist/esm/core/styles/styles.js.map +1 -0
- package/dist/esm/core/types/types.d.ts +141 -0
- package/dist/esm/core/utils/ally-audit.utils.d.ts +19 -0
- package/dist/esm/core/utils/ally-audit.utils.js +226 -0
- package/dist/esm/core/utils/ally-audit.utils.js.map +1 -0
- package/dist/esm/core/utils/config.utils.d.ts +17 -0
- package/dist/esm/core/utils/config.utils.js +63 -0
- package/dist/esm/core/utils/config.utils.js.map +1 -0
- package/dist/esm/core/utils/custom-audit.utils.d.ts +13 -0
- package/dist/esm/core/utils/custom-audit.utils.js +426 -0
- package/dist/esm/core/utils/custom-audit.utils.js.map +1 -0
- package/dist/esm/core/utils/export-audit.uitls.d.ts +17 -0
- package/dist/esm/core/utils/export-audit.uitls.js +83 -0
- package/dist/esm/core/utils/export-audit.uitls.js.map +1 -0
- package/dist/esm/core/utils/ui.utils.d.ts +24 -0
- package/dist/esm/core/utils/ui.utils.js +330 -0
- package/dist/esm/core/utils/ui.utils.js.map +1 -0
- package/dist/esm/react/A11yDevtools.d.ts +5 -0
- package/dist/esm/react/A11yDevtools.js +8 -0
- package/dist/esm/react/A11yDevtools.js.map +1 -0
- package/dist/esm/react/index.d.ts +8 -0
- package/dist/esm/react/index.js +11 -0
- package/dist/esm/react/index.js.map +1 -0
- package/dist/esm/react/plugin.d.ts +12 -0
- package/dist/esm/react/plugin.js +11 -0
- package/dist/esm/react/plugin.js.map +1 -0
- package/dist/esm/react/production/A11yDevtools.d.ts +5 -0
- package/dist/esm/react/production/A11yDevtools.js +8 -0
- package/dist/esm/react/production/A11yDevtools.js.map +1 -0
- package/dist/esm/react/production/plugin.d.ts +7 -0
- package/dist/esm/react/production/plugin.js +11 -0
- package/dist/esm/react/production/plugin.js.map +1 -0
- package/dist/esm/react/production.d.ts +3 -0
- package/dist/esm/react/production.js +5 -0
- package/dist/esm/solid/A11yDevtools.d.ts +5 -0
- package/dist/esm/solid/A11yDevtools.js +8 -0
- package/dist/esm/solid/A11yDevtools.js.map +1 -0
- package/dist/esm/solid/index.d.ts +8 -0
- package/dist/esm/solid/index.js +9 -0
- package/dist/esm/solid/index.js.map +1 -0
- package/dist/esm/solid/plugin.d.ts +12 -0
- package/dist/esm/solid/plugin.js +11 -0
- package/dist/esm/solid/plugin.js.map +1 -0
- package/dist/esm/solid/production/A11yDevtools.d.ts +5 -0
- package/dist/esm/solid/production/A11yDevtools.js +8 -0
- package/dist/esm/solid/production/A11yDevtools.js.map +1 -0
- package/dist/esm/solid/production/plugin.d.ts +7 -0
- package/dist/esm/solid/production/plugin.js +11 -0
- package/dist/esm/solid/production/plugin.js.map +1 -0
- package/dist/esm/solid/production.d.ts +3 -0
- package/dist/esm/solid/production.js +3 -0
- package/package.json +110 -7
- package/src/core/components/IssueCard.tsx +75 -0
- package/src/core/components/IssueList.tsx +155 -0
- package/src/core/components/Settings.tsx +221 -0
- package/src/core/components/Shell.tsx +154 -0
- package/src/core/components/index.tsx +12 -0
- package/src/core/contexts/allyContext.tsx +118 -0
- package/src/core/core.tsx +11 -0
- package/src/core/index.ts +10 -0
- package/src/core/production.ts +5 -0
- package/src/core/styles/styles.ts +556 -0
- package/src/core/types/types.ts +177 -0
- package/src/core/utils/ally-audit.utils.ts +345 -0
- package/src/core/utils/config.utils.ts +68 -0
- package/src/core/utils/custom-audit.utils.ts +643 -0
- package/src/core/utils/export-audit.uitls.ts +180 -0
- package/src/core/utils/ui.utils.ts +483 -0
- package/src/react/A11yDevtools.ts +12 -0
- package/src/react/index.ts +16 -0
- package/src/react/plugin.ts +9 -0
- package/src/react/production/A11yDevtools.ts +11 -0
- package/src/react/production/plugin.ts +9 -0
- package/src/react/production.ts +7 -0
- package/src/solid/A11yDevtools.ts +11 -0
- package/src/solid/index.ts +14 -0
- package/src/solid/plugin.ts +9 -0
- package/src/solid/production/A11yDevtools.ts +10 -0
- package/src/solid/production/plugin.ts +9 -0
- package/src/solid/production.ts +5 -0
- 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
|
+
}
|