@a13y/devtools 0.1.0
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/README.md +278 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +1620 -0
- package/dist/index.js.map +1 -0
- package/dist/runtime/index.d.ts +3 -0
- package/dist/runtime/index.js +1510 -0
- package/dist/runtime/index.js.map +1 -0
- package/dist/runtime/invariants/index.d.ts +30 -0
- package/dist/runtime/invariants/index.js +452 -0
- package/dist/runtime/invariants/index.js.map +1 -0
- package/dist/runtime/validators/index.d.ts +140 -0
- package/dist/runtime/validators/index.js +1201 -0
- package/dist/runtime/validators/index.js.map +1 -0
- package/dist/runtime/warnings/index.d.ts +206 -0
- package/dist/runtime/warnings/index.js +194 -0
- package/dist/runtime/warnings/index.js.map +1 -0
- package/package.json +88 -0
|
@@ -0,0 +1,1510 @@
|
|
|
1
|
+
import { isDevelopment } from '@a13y/core/runtime/env';
|
|
2
|
+
|
|
3
|
+
// @a13y/devtools - Development-time validators (tree-shakeable in production)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
// src/runtime/warnings/warning-system.ts
|
|
7
|
+
var Styles = {
|
|
8
|
+
reset: "\x1B[0m",
|
|
9
|
+
bold: "\x1B[1m",
|
|
10
|
+
dim: "\x1B[2m",
|
|
11
|
+
blue: "\x1B[34m",
|
|
12
|
+
cyan: "\x1B[36m",
|
|
13
|
+
gray: "\x1B[90m",
|
|
14
|
+
// Backgrounds
|
|
15
|
+
bgRed: "\x1B[41m",
|
|
16
|
+
bgYellow: "\x1B[43m",
|
|
17
|
+
bgBlue: "\x1B[44m"
|
|
18
|
+
};
|
|
19
|
+
var style = (text, ...styles) => {
|
|
20
|
+
return `${styles.join("")}${text}${Styles.reset}`;
|
|
21
|
+
};
|
|
22
|
+
var WarningSystemClass = class {
|
|
23
|
+
constructor() {
|
|
24
|
+
this.config = {
|
|
25
|
+
enabled: true,
|
|
26
|
+
minSeverity: "warn",
|
|
27
|
+
showElement: true,
|
|
28
|
+
showStackTrace: true,
|
|
29
|
+
deduplicate: true,
|
|
30
|
+
onWarning: () => {
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
this.warningCache = /* @__PURE__ */ new Set();
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Configure the warning system
|
|
37
|
+
*/
|
|
38
|
+
configure(config) {
|
|
39
|
+
this.config = { ...this.config, ...config };
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Emit a warning
|
|
43
|
+
*/
|
|
44
|
+
warn(warning) {
|
|
45
|
+
if (!this.config.enabled) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const severityLevel = { info: 0, warn: 1, error: 2 };
|
|
49
|
+
if (severityLevel[warning.severity] < severityLevel[this.config.minSeverity]) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (this.config.deduplicate) {
|
|
53
|
+
const key = this.getWarningKey(warning);
|
|
54
|
+
if (this.warningCache.has(key)) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
this.warningCache.add(key);
|
|
58
|
+
}
|
|
59
|
+
if (this.config.onWarning) {
|
|
60
|
+
this.config.onWarning(warning);
|
|
61
|
+
}
|
|
62
|
+
this.printWarning(warning);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Clear warning cache
|
|
66
|
+
*/
|
|
67
|
+
clearCache() {
|
|
68
|
+
this.warningCache.clear();
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Generate a unique key for a warning
|
|
72
|
+
*/
|
|
73
|
+
getWarningKey(warning) {
|
|
74
|
+
const parts = [warning.code, warning.message];
|
|
75
|
+
if (warning.element) {
|
|
76
|
+
const tag = warning.element.tagName.toLowerCase();
|
|
77
|
+
const id = warning.element.id;
|
|
78
|
+
const classes = Array.from(warning.element.classList).join(".");
|
|
79
|
+
parts.push(`${tag}#${id}.${classes}`);
|
|
80
|
+
}
|
|
81
|
+
return parts.join("|");
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Print warning to console
|
|
85
|
+
*/
|
|
86
|
+
printWarning(warning) {
|
|
87
|
+
const { severity, code, category, message, element, wcag, fixes } = warning;
|
|
88
|
+
const badge = this.getSeverityBadge(severity);
|
|
89
|
+
console.group(
|
|
90
|
+
`${badge} ${style(code, Styles.bold, Styles.cyan)} ${style(category, Styles.dim)}`
|
|
91
|
+
);
|
|
92
|
+
console.log(style(message, Styles.bold));
|
|
93
|
+
if (element && this.config.showElement) {
|
|
94
|
+
console.log(style("\nElement:", Styles.bold));
|
|
95
|
+
console.log(element);
|
|
96
|
+
}
|
|
97
|
+
if (wcag) {
|
|
98
|
+
console.log(style("\nWCAG:", Styles.bold), `${wcag.criterion} (Level ${wcag.level})`);
|
|
99
|
+
console.log(style("Learn more:", Styles.blue), wcag.url);
|
|
100
|
+
}
|
|
101
|
+
if (fixes.length > 0) {
|
|
102
|
+
console.log(style("\nHow to fix:", Styles.bold, Styles.cyan));
|
|
103
|
+
fixes.forEach((fix, index) => {
|
|
104
|
+
console.log(`${index + 1}. ${fix.description}`);
|
|
105
|
+
if (fix.example) {
|
|
106
|
+
console.log(style("\n Example:", Styles.dim));
|
|
107
|
+
console.log(style(` ${fix.example}`, Styles.dim, Styles.gray));
|
|
108
|
+
}
|
|
109
|
+
if (fix.learnMoreUrl) {
|
|
110
|
+
console.log(style(" Learn more:", Styles.blue), fix.learnMoreUrl);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
if (severity === "error" && this.config.showStackTrace) {
|
|
115
|
+
console.log(style("\nStack trace:", Styles.dim));
|
|
116
|
+
console.trace();
|
|
117
|
+
}
|
|
118
|
+
console.groupEnd();
|
|
119
|
+
console.log("");
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Get severity badge for console
|
|
123
|
+
*/
|
|
124
|
+
getSeverityBadge(severity) {
|
|
125
|
+
switch (severity) {
|
|
126
|
+
case "error":
|
|
127
|
+
return style(" ERROR ", Styles.bold, Styles.bgRed);
|
|
128
|
+
case "warn":
|
|
129
|
+
return style(" WARN ", Styles.bold, Styles.bgYellow);
|
|
130
|
+
case "info":
|
|
131
|
+
return style(" INFO ", Styles.bold, Styles.bgBlue);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
var WarningSystem = new WarningSystemClass();
|
|
136
|
+
var createWarning = (partial) => {
|
|
137
|
+
return {
|
|
138
|
+
fixes: [],
|
|
139
|
+
...partial
|
|
140
|
+
};
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// src/runtime/warnings/warning-types.ts
|
|
144
|
+
var WarningCodes = {
|
|
145
|
+
// Focus Management (001-099)
|
|
146
|
+
FOCUS_LOST: "A13Y001",
|
|
147
|
+
FOCUS_NOT_VISIBLE: "A13Y002",
|
|
148
|
+
FOCUS_TRAP_BROKEN: "A13Y003",
|
|
149
|
+
FOCUS_ORDER_INVALID: "A13Y004",
|
|
150
|
+
FOCUS_NOT_RESTORED: "A13Y005",
|
|
151
|
+
// Keyboard Navigation (100-199)
|
|
152
|
+
NOT_KEYBOARD_ACCESSIBLE: "A13Y100",
|
|
153
|
+
MISSING_KEYBOARD_HANDLER: "A13Y101",
|
|
154
|
+
INVALID_TABINDEX: "A13Y102",
|
|
155
|
+
ROVING_TABINDEX_BROKEN: "A13Y103",
|
|
156
|
+
MISSING_ESC_HANDLER: "A13Y104",
|
|
157
|
+
// Accessible Name (200-299)
|
|
158
|
+
MISSING_ACCESSIBLE_NAME: "A13Y200",
|
|
159
|
+
EMPTY_ACCESSIBLE_NAME: "A13Y201",
|
|
160
|
+
DUPLICATE_ID: "A13Y202",
|
|
161
|
+
INVALID_LABELLEDBY: "A13Y203",
|
|
162
|
+
PLACEHOLDER_AS_LABEL: "A13Y204",
|
|
163
|
+
// ARIA Usage (300-399)
|
|
164
|
+
INVALID_ARIA_ROLE: "A13Y300",
|
|
165
|
+
INVALID_ARIA_ATTRIBUTE: "A13Y301",
|
|
166
|
+
CONFLICTING_ARIA: "A13Y302",
|
|
167
|
+
REDUNDANT_ARIA: "A13Y303",
|
|
168
|
+
MISSING_REQUIRED_ARIA: "A13Y304",
|
|
169
|
+
INVALID_ARIA_VALUE: "A13Y305",
|
|
170
|
+
// Semantic HTML (400-499)
|
|
171
|
+
DIV_BUTTON: "A13Y400",
|
|
172
|
+
DIV_LINK: "A13Y401",
|
|
173
|
+
MISSING_LANDMARK: "A13Y402",
|
|
174
|
+
INVALID_NESTING: "A13Y403",
|
|
175
|
+
// WCAG Compliance (500-599)
|
|
176
|
+
CONTRAST_INSUFFICIENT: "A13Y500",
|
|
177
|
+
TARGET_SIZE_TOO_SMALL: "A13Y501",
|
|
178
|
+
ANIMATION_NO_REDUCE_MOTION: "A13Y502",
|
|
179
|
+
// Performance (600-699)
|
|
180
|
+
TOO_MANY_LIVE_REGIONS: "A13Y600",
|
|
181
|
+
EXCESSIVE_ANNOUNCEMENTS: "A13Y601"
|
|
182
|
+
};
|
|
183
|
+
var WCAGUrls = {
|
|
184
|
+
"1.3.1": "https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html",
|
|
185
|
+
"1.4.3": "https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html",
|
|
186
|
+
"2.1.1": "https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html",
|
|
187
|
+
"2.1.2": "https://www.w3.org/WAI/WCAG22/Understanding/no-keyboard-trap.html",
|
|
188
|
+
"2.4.3": "https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html",
|
|
189
|
+
"2.4.7": "https://www.w3.org/WAI/WCAG22/Understanding/focus-visible.html",
|
|
190
|
+
"2.5.5": "https://www.w3.org/WAI/WCAG22/Understanding/target-size.html",
|
|
191
|
+
"4.1.2": "https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html",
|
|
192
|
+
"4.1.3": "https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html"
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// src/runtime/invariants/invariants.ts
|
|
196
|
+
var invariant = (condition, message) => {
|
|
197
|
+
if (!isDevelopment()) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (!condition) {
|
|
201
|
+
throw new Error(`[@a13y/devtools] Invariant violation: ${message}`);
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
var assertHasAccessibleName = (element, context) => {
|
|
205
|
+
if (!isDevelopment()) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
import('@a13y/core/runtime/aria').then(({ getAccessibleName }) => {
|
|
209
|
+
const name = getAccessibleName(element);
|
|
210
|
+
if (!name || name.trim().length === 0) {
|
|
211
|
+
WarningSystem.warn(
|
|
212
|
+
createWarning({
|
|
213
|
+
code: WarningCodes.MISSING_ACCESSIBLE_NAME,
|
|
214
|
+
severity: "error",
|
|
215
|
+
category: "accessible-name",
|
|
216
|
+
message: `Element is missing an accessible name${context ? ` in ${context}` : ""}`,
|
|
217
|
+
element,
|
|
218
|
+
wcag: {
|
|
219
|
+
criterion: "4.1.2",
|
|
220
|
+
level: "A",
|
|
221
|
+
url: WCAGUrls["4.1.2"]
|
|
222
|
+
},
|
|
223
|
+
fixes: [
|
|
224
|
+
{
|
|
225
|
+
description: "Add an aria-label attribute",
|
|
226
|
+
example: `<${element.tagName.toLowerCase()} aria-label="Descriptive name">`
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
description: "Add text content",
|
|
230
|
+
example: `<${element.tagName.toLowerCase()}>Click me</${element.tagName.toLowerCase()}>`
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
description: "Use aria-labelledby to reference another element",
|
|
234
|
+
example: `<${element.tagName.toLowerCase()} aria-labelledby="label-id">`
|
|
235
|
+
}
|
|
236
|
+
]
|
|
237
|
+
})
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
}).catch(() => {
|
|
241
|
+
});
|
|
242
|
+
};
|
|
243
|
+
var assertKeyboardAccessible = (element, context) => {
|
|
244
|
+
if (!isDevelopment()) {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const isButton = element instanceof HTMLButtonElement;
|
|
248
|
+
const isLink = element instanceof HTMLAnchorElement && element.hasAttribute("href");
|
|
249
|
+
const isInput = element instanceof HTMLInputElement;
|
|
250
|
+
const hasTabindex = element.hasAttribute("tabindex");
|
|
251
|
+
const tabindex = hasTabindex ? parseInt(element.getAttribute("tabindex") || "0", 10) : -1;
|
|
252
|
+
const isAccessible = isButton || isLink || isInput || hasTabindex && tabindex >= 0;
|
|
253
|
+
if (!isAccessible) {
|
|
254
|
+
WarningSystem.warn(
|
|
255
|
+
createWarning({
|
|
256
|
+
code: WarningCodes.NOT_KEYBOARD_ACCESSIBLE,
|
|
257
|
+
severity: "error",
|
|
258
|
+
category: "keyboard-navigation",
|
|
259
|
+
message: `Element is not keyboard accessible${context ? ` in ${context}` : ""}`,
|
|
260
|
+
element,
|
|
261
|
+
wcag: {
|
|
262
|
+
criterion: "2.1.1",
|
|
263
|
+
level: "A",
|
|
264
|
+
url: WCAGUrls["2.1.1"]
|
|
265
|
+
},
|
|
266
|
+
fixes: [
|
|
267
|
+
{
|
|
268
|
+
description: "Use a semantic HTML element (button, a, input)",
|
|
269
|
+
example: `<button type="button">Click me</button>`
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
description: 'Add tabindex="0" to make it focusable',
|
|
273
|
+
example: `<${element.tagName.toLowerCase()} tabindex="0">`
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
description: "Add keyboard event handlers (Enter, Space)",
|
|
277
|
+
example: `element.addEventListener('keydown', (e) => {
|
|
278
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
279
|
+
e.preventDefault();
|
|
280
|
+
// Handle activation
|
|
281
|
+
}
|
|
282
|
+
});`
|
|
283
|
+
}
|
|
284
|
+
]
|
|
285
|
+
})
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
const hasClickHandler = element.getAttribute("onclick") !== null;
|
|
289
|
+
if (hasClickHandler && !isButton && !isLink) {
|
|
290
|
+
WarningSystem.warn(
|
|
291
|
+
createWarning({
|
|
292
|
+
code: WarningCodes.MISSING_KEYBOARD_HANDLER,
|
|
293
|
+
severity: "warn",
|
|
294
|
+
category: "keyboard-navigation",
|
|
295
|
+
message: `Element has click handler but no keyboard handlers${context ? ` in ${context}` : ""}`,
|
|
296
|
+
element,
|
|
297
|
+
wcag: {
|
|
298
|
+
criterion: "2.1.1",
|
|
299
|
+
level: "A",
|
|
300
|
+
url: WCAGUrls["2.1.1"]
|
|
301
|
+
},
|
|
302
|
+
fixes: [
|
|
303
|
+
{
|
|
304
|
+
description: "Add onKeyDown handler for Enter and Space keys",
|
|
305
|
+
example: `const handleKeyDown = (e: KeyboardEvent) => {
|
|
306
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
307
|
+
e.preventDefault();
|
|
308
|
+
onClick(e);
|
|
309
|
+
}
|
|
310
|
+
};`
|
|
311
|
+
}
|
|
312
|
+
]
|
|
313
|
+
})
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
var assertValidTabindex = (element) => {
|
|
318
|
+
if (!isDevelopment()) {
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const tabindex = element.getAttribute("tabindex");
|
|
322
|
+
if (tabindex === null) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const value = parseInt(tabindex, 10);
|
|
326
|
+
if (value > 0) {
|
|
327
|
+
WarningSystem.warn(
|
|
328
|
+
createWarning({
|
|
329
|
+
code: WarningCodes.INVALID_TABINDEX,
|
|
330
|
+
severity: "warn",
|
|
331
|
+
category: "keyboard-navigation",
|
|
332
|
+
message: `Positive tabindex (${value}) creates confusing tab order`,
|
|
333
|
+
element,
|
|
334
|
+
wcag: {
|
|
335
|
+
criterion: "2.4.3",
|
|
336
|
+
level: "A",
|
|
337
|
+
url: WCAGUrls["2.4.3"]
|
|
338
|
+
},
|
|
339
|
+
fixes: [
|
|
340
|
+
{
|
|
341
|
+
description: 'Use tabindex="0" to add element to natural tab order',
|
|
342
|
+
example: `<div tabindex="0">Focusable in natural order</div>`
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
description: 'Use tabindex="-1" to remove from tab order (programmatic focus only)',
|
|
346
|
+
example: `<div tabindex="-1">Not in tab order</div>`
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
description: "Restructure DOM to achieve desired focus order",
|
|
350
|
+
learnMoreUrl: "https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html"
|
|
351
|
+
}
|
|
352
|
+
]
|
|
353
|
+
})
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
var assertValidAriaAttributes = (element) => {
|
|
358
|
+
if (!isDevelopment()) {
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const role = element.getAttribute("role");
|
|
362
|
+
const ariaAttributes = Array.from(element.attributes).filter(
|
|
363
|
+
(attr) => attr.name.startsWith("aria-")
|
|
364
|
+
);
|
|
365
|
+
if (!role && ariaAttributes.length > 0 && !isSemanticElement(element)) {
|
|
366
|
+
WarningSystem.warn(
|
|
367
|
+
createWarning({
|
|
368
|
+
code: WarningCodes.MISSING_REQUIRED_ARIA,
|
|
369
|
+
severity: "warn",
|
|
370
|
+
category: "aria-usage",
|
|
371
|
+
message: "Element has ARIA attributes but no explicit role",
|
|
372
|
+
element,
|
|
373
|
+
wcag: {
|
|
374
|
+
criterion: "4.1.2",
|
|
375
|
+
level: "A",
|
|
376
|
+
url: WCAGUrls["4.1.2"]
|
|
377
|
+
},
|
|
378
|
+
fixes: [
|
|
379
|
+
{
|
|
380
|
+
description: "Add an appropriate role attribute",
|
|
381
|
+
example: `<div role="button" aria-pressed="false">Toggle</div>`
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
description: "Use a semantic HTML element instead",
|
|
385
|
+
example: `<button aria-pressed="false">Toggle</button>`
|
|
386
|
+
}
|
|
387
|
+
]
|
|
388
|
+
})
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
if (role && isSemanticElement(element)) {
|
|
392
|
+
const semanticRole = getImplicitRole(element);
|
|
393
|
+
if (role === semanticRole) {
|
|
394
|
+
WarningSystem.warn(
|
|
395
|
+
createWarning({
|
|
396
|
+
code: WarningCodes.REDUNDANT_ARIA,
|
|
397
|
+
severity: "info",
|
|
398
|
+
category: "aria-usage",
|
|
399
|
+
message: `Role "${role}" is redundant on <${element.tagName.toLowerCase()}>`,
|
|
400
|
+
element,
|
|
401
|
+
fixes: [
|
|
402
|
+
{
|
|
403
|
+
description: "Remove the redundant role attribute",
|
|
404
|
+
example: `<${element.tagName.toLowerCase()}> (role="${role}" is implicit)`
|
|
405
|
+
}
|
|
406
|
+
]
|
|
407
|
+
})
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
var assertFocusVisible = (context) => {
|
|
413
|
+
if (!isDevelopment()) {
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
setTimeout(() => {
|
|
417
|
+
const activeElement = document.activeElement;
|
|
418
|
+
if (!activeElement || activeElement === document.body) {
|
|
419
|
+
WarningSystem.warn(
|
|
420
|
+
createWarning({
|
|
421
|
+
code: WarningCodes.FOCUS_LOST,
|
|
422
|
+
severity: "warn",
|
|
423
|
+
category: "focus-management",
|
|
424
|
+
message: `Focus was lost${context ? ` after ${context}` : ""}`,
|
|
425
|
+
wcag: {
|
|
426
|
+
criterion: "2.4.3",
|
|
427
|
+
level: "A",
|
|
428
|
+
url: WCAGUrls["2.4.3"]
|
|
429
|
+
},
|
|
430
|
+
fixes: [
|
|
431
|
+
{
|
|
432
|
+
description: "Ensure focus is moved to an appropriate element",
|
|
433
|
+
example: `// After closing dialog
|
|
434
|
+
const returnElement = document.getElementById('trigger-button');
|
|
435
|
+
returnElement?.focus();`
|
|
436
|
+
},
|
|
437
|
+
{
|
|
438
|
+
description: "Use FocusManager to save and restore focus",
|
|
439
|
+
example: `import { FocusManager } from '@a13y/core/runtime/focus';
|
|
440
|
+
|
|
441
|
+
const restore = FocusManager.saveFocus();
|
|
442
|
+
// ... perform action ...
|
|
443
|
+
restore();`
|
|
444
|
+
}
|
|
445
|
+
]
|
|
446
|
+
})
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
}, 100);
|
|
450
|
+
};
|
|
451
|
+
var isSemanticElement = (element) => {
|
|
452
|
+
const tag = element.tagName.toLowerCase();
|
|
453
|
+
const semanticTags = [
|
|
454
|
+
"button",
|
|
455
|
+
"a",
|
|
456
|
+
"input",
|
|
457
|
+
"select",
|
|
458
|
+
"textarea",
|
|
459
|
+
"nav",
|
|
460
|
+
"main",
|
|
461
|
+
"article",
|
|
462
|
+
"section",
|
|
463
|
+
"header",
|
|
464
|
+
"footer",
|
|
465
|
+
"aside"
|
|
466
|
+
];
|
|
467
|
+
return semanticTags.includes(tag);
|
|
468
|
+
};
|
|
469
|
+
var getImplicitRole = (element) => {
|
|
470
|
+
const tag = element.tagName.toLowerCase();
|
|
471
|
+
const roleMap = {
|
|
472
|
+
button: "button",
|
|
473
|
+
a: "link",
|
|
474
|
+
nav: "navigation",
|
|
475
|
+
main: "main",
|
|
476
|
+
article: "article",
|
|
477
|
+
section: "region",
|
|
478
|
+
header: "banner",
|
|
479
|
+
footer: "contentinfo",
|
|
480
|
+
aside: "complementary"
|
|
481
|
+
};
|
|
482
|
+
return roleMap[tag] || null;
|
|
483
|
+
};
|
|
484
|
+
var VALID_ROLES = /* @__PURE__ */ new Set([
|
|
485
|
+
"alert",
|
|
486
|
+
"alertdialog",
|
|
487
|
+
"application",
|
|
488
|
+
"article",
|
|
489
|
+
"banner",
|
|
490
|
+
"button",
|
|
491
|
+
"cell",
|
|
492
|
+
"checkbox",
|
|
493
|
+
"columnheader",
|
|
494
|
+
"combobox",
|
|
495
|
+
"complementary",
|
|
496
|
+
"contentinfo",
|
|
497
|
+
"definition",
|
|
498
|
+
"dialog",
|
|
499
|
+
"directory",
|
|
500
|
+
"document",
|
|
501
|
+
"feed",
|
|
502
|
+
"figure",
|
|
503
|
+
"form",
|
|
504
|
+
"grid",
|
|
505
|
+
"gridcell",
|
|
506
|
+
"group",
|
|
507
|
+
"heading",
|
|
508
|
+
"img",
|
|
509
|
+
"link",
|
|
510
|
+
"list",
|
|
511
|
+
"listbox",
|
|
512
|
+
"listitem",
|
|
513
|
+
"log",
|
|
514
|
+
"main",
|
|
515
|
+
"marquee",
|
|
516
|
+
"math",
|
|
517
|
+
"menu",
|
|
518
|
+
"menubar",
|
|
519
|
+
"menuitem",
|
|
520
|
+
"menuitemcheckbox",
|
|
521
|
+
"menuitemradio",
|
|
522
|
+
"navigation",
|
|
523
|
+
"none",
|
|
524
|
+
"note",
|
|
525
|
+
"option",
|
|
526
|
+
"presentation",
|
|
527
|
+
"progressbar",
|
|
528
|
+
"radio",
|
|
529
|
+
"radiogroup",
|
|
530
|
+
"region",
|
|
531
|
+
"row",
|
|
532
|
+
"rowgroup",
|
|
533
|
+
"rowheader",
|
|
534
|
+
"scrollbar",
|
|
535
|
+
"search",
|
|
536
|
+
"searchbox",
|
|
537
|
+
"separator",
|
|
538
|
+
"slider",
|
|
539
|
+
"spinbutton",
|
|
540
|
+
"status",
|
|
541
|
+
"switch",
|
|
542
|
+
"tab",
|
|
543
|
+
"table",
|
|
544
|
+
"tablist",
|
|
545
|
+
"tabpanel",
|
|
546
|
+
"term",
|
|
547
|
+
"textbox",
|
|
548
|
+
"timer",
|
|
549
|
+
"toolbar",
|
|
550
|
+
"tooltip",
|
|
551
|
+
"tree",
|
|
552
|
+
"treegrid",
|
|
553
|
+
"treeitem"
|
|
554
|
+
]);
|
|
555
|
+
var REQUIRED_ARIA_PROPS = {
|
|
556
|
+
checkbox: ["aria-checked"],
|
|
557
|
+
combobox: ["aria-expanded", "aria-controls"],
|
|
558
|
+
gridcell: ["aria-colindex"],
|
|
559
|
+
heading: ["aria-level"],
|
|
560
|
+
listbox: ["aria-orientation"],
|
|
561
|
+
option: ["aria-selected"],
|
|
562
|
+
progressbar: ["aria-valuenow", "aria-valuemin", "aria-valuemax"],
|
|
563
|
+
radio: ["aria-checked"],
|
|
564
|
+
scrollbar: ["aria-valuenow", "aria-valuemin", "aria-valuemax", "aria-controls"],
|
|
565
|
+
separator: ["aria-valuenow", "aria-valuemin", "aria-valuemax"],
|
|
566
|
+
slider: ["aria-valuenow", "aria-valuemin", "aria-valuemax"],
|
|
567
|
+
spinbutton: ["aria-valuenow", "aria-valuemin", "aria-valuemax"],
|
|
568
|
+
switch: ["aria-checked"],
|
|
569
|
+
tab: ["aria-selected"],
|
|
570
|
+
tabpanel: ["aria-labelledby"],
|
|
571
|
+
textbox: ["aria-multiline"],
|
|
572
|
+
treegrid: ["aria-multiselectable"]
|
|
573
|
+
};
|
|
574
|
+
var GLOBAL_ARIA_ATTRS = /* @__PURE__ */ new Set([
|
|
575
|
+
"aria-atomic",
|
|
576
|
+
"aria-busy",
|
|
577
|
+
"aria-controls",
|
|
578
|
+
"aria-current",
|
|
579
|
+
"aria-describedby",
|
|
580
|
+
"aria-details",
|
|
581
|
+
"aria-disabled",
|
|
582
|
+
"aria-dropeffect",
|
|
583
|
+
"aria-errormessage",
|
|
584
|
+
"aria-flowto",
|
|
585
|
+
"aria-grabbed",
|
|
586
|
+
"aria-haspopup",
|
|
587
|
+
"aria-hidden",
|
|
588
|
+
"aria-invalid",
|
|
589
|
+
"aria-keyshortcuts",
|
|
590
|
+
"aria-label",
|
|
591
|
+
"aria-labelledby",
|
|
592
|
+
"aria-live",
|
|
593
|
+
"aria-owns",
|
|
594
|
+
"aria-relevant",
|
|
595
|
+
"aria-roledescription"
|
|
596
|
+
]);
|
|
597
|
+
var AriaValidator = class {
|
|
598
|
+
/**
|
|
599
|
+
* Validate ARIA attributes on an element
|
|
600
|
+
*/
|
|
601
|
+
validateElement(element) {
|
|
602
|
+
if (!isDevelopment()) {
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
const role = element.getAttribute("role");
|
|
606
|
+
const ariaAttrs = this.getAriaAttributes(element);
|
|
607
|
+
if (role) {
|
|
608
|
+
this.validateRole(element, role);
|
|
609
|
+
}
|
|
610
|
+
ariaAttrs.forEach((attr) => {
|
|
611
|
+
this.validateAriaAttribute(element, attr, role);
|
|
612
|
+
});
|
|
613
|
+
if (role && REQUIRED_ARIA_PROPS[role]) {
|
|
614
|
+
this.validateRequiredProps(element, role);
|
|
615
|
+
}
|
|
616
|
+
this.checkRedundantAria(element, role);
|
|
617
|
+
this.checkConflictingAria(element, ariaAttrs);
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Validate accessible name
|
|
621
|
+
*/
|
|
622
|
+
validateAccessibleName(element, context) {
|
|
623
|
+
if (!isDevelopment()) {
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
import('@a13y/core/runtime/aria').then(({ getAccessibleName }) => {
|
|
627
|
+
const name = getAccessibleName(element);
|
|
628
|
+
if (!name || name.trim().length === 0) {
|
|
629
|
+
WarningSystem.warn(
|
|
630
|
+
createWarning({
|
|
631
|
+
code: WarningCodes.MISSING_ACCESSIBLE_NAME,
|
|
632
|
+
severity: "error",
|
|
633
|
+
category: "accessible-name",
|
|
634
|
+
message: `Element is missing an accessible name${context ? ` in ${context}` : ""}`,
|
|
635
|
+
element,
|
|
636
|
+
wcag: {
|
|
637
|
+
criterion: "4.1.2",
|
|
638
|
+
level: "A",
|
|
639
|
+
url: WCAGUrls["4.1.2"]
|
|
640
|
+
},
|
|
641
|
+
fixes: [
|
|
642
|
+
{
|
|
643
|
+
description: "Add aria-label",
|
|
644
|
+
example: `<${element.tagName.toLowerCase()} aria-label="Description">`
|
|
645
|
+
},
|
|
646
|
+
{
|
|
647
|
+
description: "Add text content",
|
|
648
|
+
example: `<${element.tagName.toLowerCase()}>Button text</${element.tagName.toLowerCase()}>`
|
|
649
|
+
},
|
|
650
|
+
{
|
|
651
|
+
description: "Use aria-labelledby",
|
|
652
|
+
example: `<${element.tagName.toLowerCase()} aria-labelledby="label-id">`
|
|
653
|
+
}
|
|
654
|
+
]
|
|
655
|
+
})
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
|
|
659
|
+
const placeholder = element.placeholder;
|
|
660
|
+
if (placeholder && (!name || name === placeholder)) {
|
|
661
|
+
WarningSystem.warn(
|
|
662
|
+
createWarning({
|
|
663
|
+
code: WarningCodes.PLACEHOLDER_AS_LABEL,
|
|
664
|
+
severity: "warn",
|
|
665
|
+
category: "accessible-name",
|
|
666
|
+
message: "Using placeholder as accessible name is not recommended",
|
|
667
|
+
element,
|
|
668
|
+
wcag: {
|
|
669
|
+
criterion: "4.1.2",
|
|
670
|
+
level: "A",
|
|
671
|
+
url: WCAGUrls["4.1.2"]
|
|
672
|
+
},
|
|
673
|
+
fixes: [
|
|
674
|
+
{
|
|
675
|
+
description: "Add a visible label",
|
|
676
|
+
example: `<label for="input-id">Label text</label>
|
|
677
|
+
<input id="input-id" placeholder="Example">`
|
|
678
|
+
},
|
|
679
|
+
{
|
|
680
|
+
description: "Add aria-label",
|
|
681
|
+
example: `<input aria-label="Label text" placeholder="Example">`
|
|
682
|
+
}
|
|
683
|
+
]
|
|
684
|
+
})
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}).catch(() => {
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Validate role attribute
|
|
693
|
+
*/
|
|
694
|
+
validateRole(element, role) {
|
|
695
|
+
if (!VALID_ROLES.has(role)) {
|
|
696
|
+
WarningSystem.warn(
|
|
697
|
+
createWarning({
|
|
698
|
+
code: WarningCodes.INVALID_ARIA_ROLE,
|
|
699
|
+
severity: "error",
|
|
700
|
+
category: "aria-usage",
|
|
701
|
+
message: `Invalid ARIA role: "${role}"`,
|
|
702
|
+
element,
|
|
703
|
+
wcag: {
|
|
704
|
+
criterion: "4.1.2",
|
|
705
|
+
level: "A",
|
|
706
|
+
url: WCAGUrls["4.1.2"]
|
|
707
|
+
},
|
|
708
|
+
fixes: [
|
|
709
|
+
{
|
|
710
|
+
description: "Use a valid ARIA role from the specification",
|
|
711
|
+
learnMoreUrl: "https://www.w3.org/TR/wai-aria-1.2/#role_definitions"
|
|
712
|
+
},
|
|
713
|
+
{
|
|
714
|
+
description: "Remove the role attribute if not needed"
|
|
715
|
+
}
|
|
716
|
+
]
|
|
717
|
+
})
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Validate ARIA attribute
|
|
723
|
+
*/
|
|
724
|
+
validateAriaAttribute(element, attr, role) {
|
|
725
|
+
if (!attr.startsWith("aria-")) {
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
const value = element.getAttribute(attr);
|
|
729
|
+
if ([
|
|
730
|
+
"aria-atomic",
|
|
731
|
+
"aria-busy",
|
|
732
|
+
"aria-disabled",
|
|
733
|
+
"aria-hidden",
|
|
734
|
+
"aria-multiline",
|
|
735
|
+
"aria-multiselectable",
|
|
736
|
+
"aria-readonly",
|
|
737
|
+
"aria-required"
|
|
738
|
+
].includes(attr)) {
|
|
739
|
+
if (value !== "true" && value !== "false") {
|
|
740
|
+
WarningSystem.warn(
|
|
741
|
+
createWarning({
|
|
742
|
+
code: WarningCodes.INVALID_ARIA_VALUE,
|
|
743
|
+
severity: "warn",
|
|
744
|
+
category: "aria-usage",
|
|
745
|
+
message: `ARIA attribute "${attr}" must be "true" or "false", got "${value}"`,
|
|
746
|
+
element,
|
|
747
|
+
wcag: {
|
|
748
|
+
criterion: "4.1.2",
|
|
749
|
+
level: "A",
|
|
750
|
+
url: WCAGUrls["4.1.2"]
|
|
751
|
+
},
|
|
752
|
+
fixes: [
|
|
753
|
+
{
|
|
754
|
+
description: 'Use "true" or "false"',
|
|
755
|
+
example: `<element ${attr}="true">`
|
|
756
|
+
}
|
|
757
|
+
]
|
|
758
|
+
})
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
if (["aria-labelledby", "aria-describedby", "aria-controls", "aria-owns"].includes(attr)) {
|
|
763
|
+
this.validateIdReferences(element, attr, value);
|
|
764
|
+
}
|
|
765
|
+
if (role && !GLOBAL_ARIA_ATTRS.has(attr)) ;
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* Validate ID references in ARIA attributes
|
|
769
|
+
*/
|
|
770
|
+
validateIdReferences(element, attr, value) {
|
|
771
|
+
if (!value) return;
|
|
772
|
+
const ids = value.split(/\s+/);
|
|
773
|
+
ids.forEach((id) => {
|
|
774
|
+
const referencedElement = document.getElementById(id);
|
|
775
|
+
if (!referencedElement) {
|
|
776
|
+
WarningSystem.warn(
|
|
777
|
+
createWarning({
|
|
778
|
+
code: WarningCodes.INVALID_LABELLEDBY,
|
|
779
|
+
severity: "warn",
|
|
780
|
+
category: "aria-usage",
|
|
781
|
+
message: `${attr} references non-existent element with id="${id}"`,
|
|
782
|
+
element,
|
|
783
|
+
wcag: {
|
|
784
|
+
criterion: "4.1.2",
|
|
785
|
+
level: "A",
|
|
786
|
+
url: WCAGUrls["4.1.2"]
|
|
787
|
+
},
|
|
788
|
+
fixes: [
|
|
789
|
+
{
|
|
790
|
+
description: "Ensure the referenced element exists",
|
|
791
|
+
example: `<div id="${id}">Label text</div>
|
|
792
|
+
<button ${attr}="${id}">Button</button>`
|
|
793
|
+
},
|
|
794
|
+
{
|
|
795
|
+
description: "Use aria-label instead",
|
|
796
|
+
example: `<button aria-label="Button text">Button</button>`
|
|
797
|
+
}
|
|
798
|
+
]
|
|
799
|
+
})
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
const allElements = document.querySelectorAll(`[id]`);
|
|
804
|
+
const idCounts = /* @__PURE__ */ new Map();
|
|
805
|
+
allElements.forEach((el) => {
|
|
806
|
+
const id = el.id;
|
|
807
|
+
idCounts.set(id, (idCounts.get(id) || 0) + 1);
|
|
808
|
+
});
|
|
809
|
+
ids.forEach((id) => {
|
|
810
|
+
if ((idCounts.get(id) || 0) > 1) {
|
|
811
|
+
WarningSystem.warn(
|
|
812
|
+
createWarning({
|
|
813
|
+
code: WarningCodes.DUPLICATE_ID,
|
|
814
|
+
severity: "error",
|
|
815
|
+
category: "aria-usage",
|
|
816
|
+
message: `Duplicate id="${id}" found in document`,
|
|
817
|
+
element,
|
|
818
|
+
wcag: {
|
|
819
|
+
criterion: "4.1.1",
|
|
820
|
+
level: "A",
|
|
821
|
+
url: "https://www.w3.org/WAI/WCAG22/Understanding/parsing.html"
|
|
822
|
+
},
|
|
823
|
+
fixes: [
|
|
824
|
+
{
|
|
825
|
+
description: "Ensure all IDs are unique",
|
|
826
|
+
example: `<!-- Make sure each ID is used only once -->
|
|
827
|
+
<div id="unique-id-1">First</div>
|
|
828
|
+
<div id="unique-id-2">Second</div>`
|
|
829
|
+
}
|
|
830
|
+
]
|
|
831
|
+
})
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Validate required ARIA props for a role
|
|
838
|
+
*/
|
|
839
|
+
validateRequiredProps(element, role) {
|
|
840
|
+
const requiredProps = REQUIRED_ARIA_PROPS[role] || [];
|
|
841
|
+
requiredProps.forEach((prop) => {
|
|
842
|
+
if (!element.hasAttribute(prop)) {
|
|
843
|
+
WarningSystem.warn(
|
|
844
|
+
createWarning({
|
|
845
|
+
code: WarningCodes.MISSING_REQUIRED_ARIA,
|
|
846
|
+
severity: "error",
|
|
847
|
+
category: "aria-usage",
|
|
848
|
+
message: `Role "${role}" requires "${prop}" attribute`,
|
|
849
|
+
element,
|
|
850
|
+
wcag: {
|
|
851
|
+
criterion: "4.1.2",
|
|
852
|
+
level: "A",
|
|
853
|
+
url: WCAGUrls["4.1.2"]
|
|
854
|
+
},
|
|
855
|
+
fixes: [
|
|
856
|
+
{
|
|
857
|
+
description: `Add the required ${prop} attribute`,
|
|
858
|
+
example: `<${element.tagName.toLowerCase()} role="${role}" ${prop}="value">`,
|
|
859
|
+
learnMoreUrl: `https://www.w3.org/TR/wai-aria-1.2/#${role}`
|
|
860
|
+
}
|
|
861
|
+
]
|
|
862
|
+
})
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* Check for redundant ARIA
|
|
869
|
+
*/
|
|
870
|
+
checkRedundantAria(element, role) {
|
|
871
|
+
const tagName = element.tagName.toLowerCase();
|
|
872
|
+
const implicitRoles = {
|
|
873
|
+
button: "button",
|
|
874
|
+
a: "link",
|
|
875
|
+
nav: "navigation",
|
|
876
|
+
main: "main",
|
|
877
|
+
header: "banner",
|
|
878
|
+
footer: "contentinfo",
|
|
879
|
+
article: "article",
|
|
880
|
+
aside: "complementary",
|
|
881
|
+
section: "region"
|
|
882
|
+
};
|
|
883
|
+
if (role && implicitRoles[tagName] === role) {
|
|
884
|
+
WarningSystem.warn(
|
|
885
|
+
createWarning({
|
|
886
|
+
code: WarningCodes.REDUNDANT_ARIA,
|
|
887
|
+
severity: "info",
|
|
888
|
+
category: "aria-usage",
|
|
889
|
+
message: `Role "${role}" is redundant on <${tagName}>`,
|
|
890
|
+
element,
|
|
891
|
+
fixes: [
|
|
892
|
+
{
|
|
893
|
+
description: "Remove the redundant role attribute",
|
|
894
|
+
example: `<${tagName}> (role="${role}" is implicit)`
|
|
895
|
+
}
|
|
896
|
+
]
|
|
897
|
+
})
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* Check for conflicting ARIA attributes
|
|
903
|
+
*/
|
|
904
|
+
checkConflictingAria(element, ariaAttrs) {
|
|
905
|
+
if (ariaAttrs.includes("aria-label") && ariaAttrs.includes("aria-labelledby")) {
|
|
906
|
+
WarningSystem.warn(
|
|
907
|
+
createWarning({
|
|
908
|
+
code: WarningCodes.CONFLICTING_ARIA,
|
|
909
|
+
severity: "warn",
|
|
910
|
+
category: "aria-usage",
|
|
911
|
+
message: "Element has both aria-label and aria-labelledby (labelledby takes precedence)",
|
|
912
|
+
element,
|
|
913
|
+
fixes: [
|
|
914
|
+
{
|
|
915
|
+
description: "Remove aria-label and keep aria-labelledby",
|
|
916
|
+
example: `<element aria-labelledby="label-id">`
|
|
917
|
+
},
|
|
918
|
+
{
|
|
919
|
+
description: "Remove aria-labelledby and keep aria-label",
|
|
920
|
+
example: `<element aria-label="Label text">`
|
|
921
|
+
}
|
|
922
|
+
]
|
|
923
|
+
})
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
if (element.getAttribute("aria-hidden") === "true") {
|
|
927
|
+
const isFocusable = element instanceof HTMLButtonElement || element instanceof HTMLAnchorElement || element instanceof HTMLInputElement || element.hasAttribute("tabindex") && parseInt(element.getAttribute("tabindex") || "0", 10) >= 0;
|
|
928
|
+
if (isFocusable) {
|
|
929
|
+
WarningSystem.warn(
|
|
930
|
+
createWarning({
|
|
931
|
+
code: WarningCodes.CONFLICTING_ARIA,
|
|
932
|
+
severity: "error",
|
|
933
|
+
category: "aria-usage",
|
|
934
|
+
message: 'Focusable element has aria-hidden="true"',
|
|
935
|
+
element,
|
|
936
|
+
wcag: {
|
|
937
|
+
criterion: "4.1.2",
|
|
938
|
+
level: "A",
|
|
939
|
+
url: WCAGUrls["4.1.2"]
|
|
940
|
+
},
|
|
941
|
+
fixes: [
|
|
942
|
+
{
|
|
943
|
+
description: "Remove aria-hidden",
|
|
944
|
+
example: `<button>Visible button</button>`
|
|
945
|
+
},
|
|
946
|
+
{
|
|
947
|
+
description: "Make element non-focusable",
|
|
948
|
+
example: `<div aria-hidden="true" tabindex="-1">Hidden content</div>`
|
|
949
|
+
}
|
|
950
|
+
]
|
|
951
|
+
})
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* Get all ARIA attributes from an element
|
|
958
|
+
*/
|
|
959
|
+
getAriaAttributes(element) {
|
|
960
|
+
return Array.from(element.attributes).filter((attr) => attr.name.startsWith("aria-")).map((attr) => attr.name);
|
|
961
|
+
}
|
|
962
|
+
};
|
|
963
|
+
var ariaValidator = new AriaValidator();
|
|
964
|
+
var FocusValidator = class {
|
|
965
|
+
constructor() {
|
|
966
|
+
this.focusHistory = [];
|
|
967
|
+
this.isActive = false;
|
|
968
|
+
this.handleFocusIn = (event) => {
|
|
969
|
+
const target = event.target;
|
|
970
|
+
if (target && target !== document.body) {
|
|
971
|
+
this.focusHistory.push(target);
|
|
972
|
+
if (this.focusHistory.length > 10) {
|
|
973
|
+
this.focusHistory.shift();
|
|
974
|
+
}
|
|
975
|
+
this.validateFocusVisible(target);
|
|
976
|
+
}
|
|
977
|
+
};
|
|
978
|
+
this.handleFocusOut = (_event) => {
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* Start monitoring focus changes
|
|
983
|
+
*/
|
|
984
|
+
start() {
|
|
985
|
+
if (!isDevelopment() || this.isActive) {
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
document.addEventListener("focusin", this.handleFocusIn);
|
|
989
|
+
document.addEventListener("focusout", this.handleFocusOut);
|
|
990
|
+
this.isActive = true;
|
|
991
|
+
}
|
|
992
|
+
/**
|
|
993
|
+
* Stop monitoring focus changes
|
|
994
|
+
*/
|
|
995
|
+
stop() {
|
|
996
|
+
if (!this.isActive) {
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
document.removeEventListener("focusin", this.handleFocusIn);
|
|
1000
|
+
document.removeEventListener("focusout", this.handleFocusOut);
|
|
1001
|
+
this.isActive = false;
|
|
1002
|
+
this.focusHistory = [];
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Validate that focus is visible
|
|
1006
|
+
*/
|
|
1007
|
+
validateFocusVisible(element) {
|
|
1008
|
+
if (!isDevelopment()) {
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
if (!(element instanceof HTMLElement)) {
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
const computedStyle = window.getComputedStyle(element);
|
|
1015
|
+
const outline = computedStyle.outline;
|
|
1016
|
+
const outlineWidth = computedStyle.outlineWidth;
|
|
1017
|
+
const hasFocusIndicator = outline !== "none" && outlineWidth !== "0px" && outlineWidth !== "0";
|
|
1018
|
+
if (!hasFocusIndicator) {
|
|
1019
|
+
const hasCustomFocus = element.hasAttribute("data-focus-visible-added") || element.matches(":focus-visible");
|
|
1020
|
+
if (!hasCustomFocus) {
|
|
1021
|
+
WarningSystem.warn(
|
|
1022
|
+
createWarning({
|
|
1023
|
+
code: WarningCodes.FOCUS_NOT_VISIBLE,
|
|
1024
|
+
severity: "warn",
|
|
1025
|
+
category: "focus-management",
|
|
1026
|
+
message: "Focused element has no visible focus indicator",
|
|
1027
|
+
element,
|
|
1028
|
+
wcag: {
|
|
1029
|
+
criterion: "2.4.7",
|
|
1030
|
+
level: "AA",
|
|
1031
|
+
url: WCAGUrls["2.4.7"]
|
|
1032
|
+
},
|
|
1033
|
+
fixes: [
|
|
1034
|
+
{
|
|
1035
|
+
description: "Add a visible outline or border on focus",
|
|
1036
|
+
example: `.my-element:focus {
|
|
1037
|
+
outline: 2px solid blue;
|
|
1038
|
+
outline-offset: 2px;
|
|
1039
|
+
}`
|
|
1040
|
+
},
|
|
1041
|
+
{
|
|
1042
|
+
description: "Use :focus-visible for keyboard-only focus indicators",
|
|
1043
|
+
example: `.my-element:focus-visible {
|
|
1044
|
+
outline: 2px solid blue;
|
|
1045
|
+
}`
|
|
1046
|
+
},
|
|
1047
|
+
{
|
|
1048
|
+
description: "Use FocusVisible from @a13y/core",
|
|
1049
|
+
example: `import { FocusVisible } from '@a13y/core/runtime/focus';
|
|
1050
|
+
FocusVisible.init();
|
|
1051
|
+
|
|
1052
|
+
// Then in CSS:
|
|
1053
|
+
[data-focus-visible-added] {
|
|
1054
|
+
outline: 2px solid blue;
|
|
1055
|
+
}`
|
|
1056
|
+
}
|
|
1057
|
+
]
|
|
1058
|
+
})
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* Validate focus trap
|
|
1065
|
+
*/
|
|
1066
|
+
validateFocusTrap(container, expectedTrapped) {
|
|
1067
|
+
if (!isDevelopment()) {
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
const focusableElements = this.getFocusableElements(container);
|
|
1071
|
+
if (focusableElements.length === 0) {
|
|
1072
|
+
WarningSystem.warn(
|
|
1073
|
+
createWarning({
|
|
1074
|
+
code: WarningCodes.FOCUS_TRAP_BROKEN,
|
|
1075
|
+
severity: "error",
|
|
1076
|
+
category: "focus-management",
|
|
1077
|
+
message: "Focus trap container has no focusable elements",
|
|
1078
|
+
element: container,
|
|
1079
|
+
wcag: {
|
|
1080
|
+
criterion: "2.1.2",
|
|
1081
|
+
level: "A",
|
|
1082
|
+
url: WCAGUrls["2.1.2"]
|
|
1083
|
+
},
|
|
1084
|
+
fixes: [
|
|
1085
|
+
{
|
|
1086
|
+
description: "Add at least one focusable element inside the container",
|
|
1087
|
+
example: `<div role="dialog">
|
|
1088
|
+
<button>Close</button>
|
|
1089
|
+
</div>`
|
|
1090
|
+
},
|
|
1091
|
+
{
|
|
1092
|
+
description: 'Make container focusable with tabindex="-1"',
|
|
1093
|
+
example: `<div role="dialog" tabindex="-1">
|
|
1094
|
+
Content
|
|
1095
|
+
</div>`
|
|
1096
|
+
}
|
|
1097
|
+
]
|
|
1098
|
+
})
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
1101
|
+
if (expectedTrapped && focusableElements.length > 0) {
|
|
1102
|
+
const lastElement = focusableElements[focusableElements.length - 1];
|
|
1103
|
+
if (document.activeElement === lastElement) {
|
|
1104
|
+
const hasTabHandler = container.getAttribute("data-focus-trap") === "true";
|
|
1105
|
+
if (!hasTabHandler) {
|
|
1106
|
+
WarningSystem.warn(
|
|
1107
|
+
createWarning({
|
|
1108
|
+
code: WarningCodes.FOCUS_TRAP_BROKEN,
|
|
1109
|
+
severity: "warn",
|
|
1110
|
+
category: "focus-management",
|
|
1111
|
+
message: "Focus trap may not be working correctly",
|
|
1112
|
+
element: container,
|
|
1113
|
+
wcag: {
|
|
1114
|
+
criterion: "2.1.2",
|
|
1115
|
+
level: "A",
|
|
1116
|
+
url: WCAGUrls["2.1.2"]
|
|
1117
|
+
},
|
|
1118
|
+
fixes: [
|
|
1119
|
+
{
|
|
1120
|
+
description: "Use createFocusTrap from @a13y/core",
|
|
1121
|
+
example: `import { createFocusTrap } from '@a13y/core/runtime/focus';
|
|
1122
|
+
|
|
1123
|
+
const trap = createFocusTrap(container);
|
|
1124
|
+
trap.activate();`
|
|
1125
|
+
}
|
|
1126
|
+
]
|
|
1127
|
+
})
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* Validate focus order
|
|
1135
|
+
*/
|
|
1136
|
+
validateFocusOrder(container) {
|
|
1137
|
+
if (!isDevelopment()) {
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
const focusableElements = this.getFocusableElements(container);
|
|
1141
|
+
focusableElements.forEach((element) => {
|
|
1142
|
+
const tabindex = element.getAttribute("tabindex");
|
|
1143
|
+
if (tabindex && parseInt(tabindex, 10) > 0) {
|
|
1144
|
+
WarningSystem.warn(
|
|
1145
|
+
createWarning({
|
|
1146
|
+
code: WarningCodes.FOCUS_ORDER_INVALID,
|
|
1147
|
+
severity: "warn",
|
|
1148
|
+
category: "focus-management",
|
|
1149
|
+
message: `Positive tabindex (${tabindex}) creates confusing focus order`,
|
|
1150
|
+
element,
|
|
1151
|
+
wcag: {
|
|
1152
|
+
criterion: "2.4.3",
|
|
1153
|
+
level: "A",
|
|
1154
|
+
url: WCAGUrls["2.4.3"]
|
|
1155
|
+
},
|
|
1156
|
+
fixes: [
|
|
1157
|
+
{
|
|
1158
|
+
description: "Remove positive tabindex and restructure DOM",
|
|
1159
|
+
example: `<!-- Instead of using tabindex to change order -->
|
|
1160
|
+
<div tabindex="2">Second</div>
|
|
1161
|
+
<div tabindex="1">First</div>
|
|
1162
|
+
|
|
1163
|
+
<!-- Restructure DOM to match desired order -->
|
|
1164
|
+
<div tabindex="0">First</div>
|
|
1165
|
+
<div tabindex="0">Second</div>`
|
|
1166
|
+
}
|
|
1167
|
+
]
|
|
1168
|
+
})
|
|
1169
|
+
);
|
|
1170
|
+
}
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
/**
|
|
1174
|
+
* Track focus restoration after actions
|
|
1175
|
+
*/
|
|
1176
|
+
expectFocusRestoration(expectedElement, action) {
|
|
1177
|
+
if (!isDevelopment()) {
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
setTimeout(() => {
|
|
1181
|
+
if (document.activeElement !== expectedElement) {
|
|
1182
|
+
WarningSystem.warn(
|
|
1183
|
+
createWarning({
|
|
1184
|
+
code: WarningCodes.FOCUS_NOT_RESTORED,
|
|
1185
|
+
severity: "warn",
|
|
1186
|
+
category: "focus-management",
|
|
1187
|
+
message: `Focus was not restored after ${action}`,
|
|
1188
|
+
element: expectedElement,
|
|
1189
|
+
wcag: {
|
|
1190
|
+
criterion: "2.4.3",
|
|
1191
|
+
level: "A",
|
|
1192
|
+
url: WCAGUrls["2.4.3"]
|
|
1193
|
+
},
|
|
1194
|
+
fixes: [
|
|
1195
|
+
{
|
|
1196
|
+
description: "Restore focus to the expected element",
|
|
1197
|
+
example: `// Save focus before action
|
|
1198
|
+
const returnElement = document.activeElement;
|
|
1199
|
+
|
|
1200
|
+
// Perform action
|
|
1201
|
+
performAction();
|
|
1202
|
+
|
|
1203
|
+
// Restore focus
|
|
1204
|
+
returnElement?.focus();`
|
|
1205
|
+
},
|
|
1206
|
+
{
|
|
1207
|
+
description: "Use FocusManager.saveFocus()",
|
|
1208
|
+
example: `import { FocusManager } from '@a13y/core/runtime/focus';
|
|
1209
|
+
|
|
1210
|
+
const restore = FocusManager.saveFocus();
|
|
1211
|
+
performAction();
|
|
1212
|
+
restore();`
|
|
1213
|
+
}
|
|
1214
|
+
]
|
|
1215
|
+
})
|
|
1216
|
+
);
|
|
1217
|
+
}
|
|
1218
|
+
}, 100);
|
|
1219
|
+
}
|
|
1220
|
+
getFocusableElements(container) {
|
|
1221
|
+
const selector = [
|
|
1222
|
+
"a[href]",
|
|
1223
|
+
"button:not([disabled])",
|
|
1224
|
+
"input:not([disabled])",
|
|
1225
|
+
"select:not([disabled])",
|
|
1226
|
+
"textarea:not([disabled])",
|
|
1227
|
+
'[tabindex]:not([tabindex="-1"])'
|
|
1228
|
+
].join(",");
|
|
1229
|
+
return Array.from(container.querySelectorAll(selector));
|
|
1230
|
+
}
|
|
1231
|
+
};
|
|
1232
|
+
var focusValidator = new FocusValidator();
|
|
1233
|
+
var KeyboardValidator = class {
|
|
1234
|
+
/**
|
|
1235
|
+
* Validate that interactive elements are keyboard accessible
|
|
1236
|
+
*/
|
|
1237
|
+
validateInteractiveElement(element) {
|
|
1238
|
+
if (!isDevelopment()) {
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
const info = this.analyzeElement(element);
|
|
1242
|
+
if (info.hasClickHandler && !info.isFocusable) {
|
|
1243
|
+
WarningSystem.warn(
|
|
1244
|
+
createWarning({
|
|
1245
|
+
code: WarningCodes.NOT_KEYBOARD_ACCESSIBLE,
|
|
1246
|
+
severity: "error",
|
|
1247
|
+
category: "keyboard-navigation",
|
|
1248
|
+
message: "Interactive element is not keyboard accessible",
|
|
1249
|
+
element,
|
|
1250
|
+
wcag: {
|
|
1251
|
+
criterion: "2.1.1",
|
|
1252
|
+
level: "A",
|
|
1253
|
+
url: WCAGUrls["2.1.1"]
|
|
1254
|
+
},
|
|
1255
|
+
fixes: [
|
|
1256
|
+
{
|
|
1257
|
+
description: "Use a semantic button element",
|
|
1258
|
+
example: `<button onClick={handleClick}>Click me</button>`
|
|
1259
|
+
},
|
|
1260
|
+
{
|
|
1261
|
+
description: 'Add tabindex="0" and keyboard handlers',
|
|
1262
|
+
example: `<div
|
|
1263
|
+
tabindex="0"
|
|
1264
|
+
onClick={handleClick}
|
|
1265
|
+
onKeyDown={(e) => {
|
|
1266
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
1267
|
+
e.preventDefault();
|
|
1268
|
+
handleClick();
|
|
1269
|
+
}
|
|
1270
|
+
}}
|
|
1271
|
+
>
|
|
1272
|
+
Click me
|
|
1273
|
+
</div>`
|
|
1274
|
+
}
|
|
1275
|
+
]
|
|
1276
|
+
})
|
|
1277
|
+
);
|
|
1278
|
+
}
|
|
1279
|
+
if (info.hasClickHandler && info.isFocusable && !info.hasKeyHandler) {
|
|
1280
|
+
const isSemanticInteractive = element instanceof HTMLButtonElement || element instanceof HTMLAnchorElement && element.hasAttribute("href") || element instanceof HTMLInputElement;
|
|
1281
|
+
if (!isSemanticInteractive) {
|
|
1282
|
+
WarningSystem.warn(
|
|
1283
|
+
createWarning({
|
|
1284
|
+
code: WarningCodes.MISSING_KEYBOARD_HANDLER,
|
|
1285
|
+
severity: "warn",
|
|
1286
|
+
category: "keyboard-navigation",
|
|
1287
|
+
message: "Element has click handler but no keyboard event handler",
|
|
1288
|
+
element,
|
|
1289
|
+
wcag: {
|
|
1290
|
+
criterion: "2.1.1",
|
|
1291
|
+
level: "A",
|
|
1292
|
+
url: WCAGUrls["2.1.1"]
|
|
1293
|
+
},
|
|
1294
|
+
fixes: [
|
|
1295
|
+
{
|
|
1296
|
+
description: "Add onKeyDown handler for Enter and Space keys",
|
|
1297
|
+
example: `element.addEventListener('keydown', (e) => {
|
|
1298
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
1299
|
+
e.preventDefault();
|
|
1300
|
+
// Trigger click or custom action
|
|
1301
|
+
}
|
|
1302
|
+
});`
|
|
1303
|
+
}
|
|
1304
|
+
]
|
|
1305
|
+
})
|
|
1306
|
+
);
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
this.checkForDivButton(element);
|
|
1310
|
+
}
|
|
1311
|
+
/**
|
|
1312
|
+
* Validate that a container's children are reachable via keyboard
|
|
1313
|
+
*/
|
|
1314
|
+
validateContainer(container) {
|
|
1315
|
+
if (!isDevelopment()) {
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
const interactiveElements = this.findInteractiveElements(container);
|
|
1319
|
+
interactiveElements.forEach((info) => {
|
|
1320
|
+
if (info.hasClickHandler && !info.isFocusable) {
|
|
1321
|
+
this.validateInteractiveElement(info.element);
|
|
1322
|
+
}
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Validate roving tabindex implementation
|
|
1327
|
+
*/
|
|
1328
|
+
validateRovingTabindex(container) {
|
|
1329
|
+
if (!isDevelopment()) {
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
const items = Array.from(container.children);
|
|
1333
|
+
const tabindexValues = items.map(
|
|
1334
|
+
(item) => item.hasAttribute("tabindex") ? parseInt(item.getAttribute("tabindex") || "0", 10) : null
|
|
1335
|
+
);
|
|
1336
|
+
const zeroCount = tabindexValues.filter((v) => v === 0).length;
|
|
1337
|
+
const negativeOneCount = tabindexValues.filter((v) => v === -1).length;
|
|
1338
|
+
if (zeroCount !== 1 || negativeOneCount !== items.length - 1) {
|
|
1339
|
+
WarningSystem.warn(
|
|
1340
|
+
createWarning({
|
|
1341
|
+
code: WarningCodes.ROVING_TABINDEX_BROKEN,
|
|
1342
|
+
severity: "warn",
|
|
1343
|
+
category: "keyboard-navigation",
|
|
1344
|
+
message: "Roving tabindex pattern is not correctly implemented",
|
|
1345
|
+
element: container,
|
|
1346
|
+
wcag: {
|
|
1347
|
+
criterion: "2.1.1",
|
|
1348
|
+
level: "A",
|
|
1349
|
+
url: WCAGUrls["2.1.1"]
|
|
1350
|
+
},
|
|
1351
|
+
fixes: [
|
|
1352
|
+
{
|
|
1353
|
+
description: 'Set exactly one item to tabindex="0" and all others to tabindex="-1"',
|
|
1354
|
+
example: `<!-- Correct roving tabindex -->
|
|
1355
|
+
<div role="toolbar">
|
|
1356
|
+
<button tabindex="0">First (active)</button>
|
|
1357
|
+
<button tabindex="-1">Second</button>
|
|
1358
|
+
<button tabindex="-1">Third</button>
|
|
1359
|
+
</div>`
|
|
1360
|
+
},
|
|
1361
|
+
{
|
|
1362
|
+
description: "Use RovingTabindexManager from @a13y/core",
|
|
1363
|
+
example: `import { RovingTabindexManager } from '@a13y/core/runtime/keyboard';
|
|
1364
|
+
|
|
1365
|
+
const manager = new RovingTabindexManager(toolbar, {
|
|
1366
|
+
orientation: 'horizontal',
|
|
1367
|
+
});
|
|
1368
|
+
manager.init();`
|
|
1369
|
+
}
|
|
1370
|
+
]
|
|
1371
|
+
})
|
|
1372
|
+
);
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
/**
|
|
1376
|
+
* Check for escape key handler in dialogs/modals
|
|
1377
|
+
*/
|
|
1378
|
+
validateEscapeHandler(container, shouldHaveEscape) {
|
|
1379
|
+
if (!isDevelopment() || !shouldHaveEscape) {
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
const hasEscapeAttr = container.hasAttribute("data-escape-closes");
|
|
1383
|
+
if (!hasEscapeAttr) {
|
|
1384
|
+
WarningSystem.warn(
|
|
1385
|
+
createWarning({
|
|
1386
|
+
code: WarningCodes.MISSING_ESC_HANDLER,
|
|
1387
|
+
severity: "warn",
|
|
1388
|
+
category: "keyboard-navigation",
|
|
1389
|
+
message: "Dialog/Modal should close on Escape key",
|
|
1390
|
+
element: container,
|
|
1391
|
+
wcag: {
|
|
1392
|
+
criterion: "2.1.2",
|
|
1393
|
+
level: "A",
|
|
1394
|
+
url: WCAGUrls["2.1.2"]
|
|
1395
|
+
},
|
|
1396
|
+
fixes: [
|
|
1397
|
+
{
|
|
1398
|
+
description: "Add Escape key handler",
|
|
1399
|
+
example: `container.addEventListener('keydown', (e) => {
|
|
1400
|
+
if (e.key === 'Escape') {
|
|
1401
|
+
closeDialog();
|
|
1402
|
+
}
|
|
1403
|
+
});`
|
|
1404
|
+
},
|
|
1405
|
+
{
|
|
1406
|
+
description: "Use createFocusTrap with onEscape callback",
|
|
1407
|
+
example: `import { createFocusTrap } from '@a13y/core/runtime/focus';
|
|
1408
|
+
|
|
1409
|
+
const trap = createFocusTrap(dialog, {
|
|
1410
|
+
onEscape: () => closeDialog(),
|
|
1411
|
+
});`
|
|
1412
|
+
}
|
|
1413
|
+
]
|
|
1414
|
+
})
|
|
1415
|
+
);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
/**
|
|
1419
|
+
* Analyze an element for keyboard accessibility
|
|
1420
|
+
*/
|
|
1421
|
+
analyzeElement(element) {
|
|
1422
|
+
const hasClickHandler = element.hasAttribute("onclick") || element.hasAttribute("@click") || element.hasAttribute("v-on:click") || // Check for React synthetic events (harder to detect)
|
|
1423
|
+
Object.keys(element).some((key) => key.startsWith("__react"));
|
|
1424
|
+
const hasKeyHandler = element.hasAttribute("onkeydown") || element.hasAttribute("onkeyup") || element.hasAttribute("onkeypress") || element.hasAttribute("@keydown") || element.hasAttribute("v-on:keydown");
|
|
1425
|
+
const isFocusable = this.isFocusable(element);
|
|
1426
|
+
return {
|
|
1427
|
+
element,
|
|
1428
|
+
hasClickHandler,
|
|
1429
|
+
hasKeyHandler,
|
|
1430
|
+
isFocusable
|
|
1431
|
+
};
|
|
1432
|
+
}
|
|
1433
|
+
/**
|
|
1434
|
+
* Check if element is focusable
|
|
1435
|
+
*/
|
|
1436
|
+
isFocusable(element) {
|
|
1437
|
+
if (element instanceof HTMLButtonElement || element instanceof HTMLInputElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement || element instanceof HTMLAnchorElement && element.hasAttribute("href")) {
|
|
1438
|
+
return true;
|
|
1439
|
+
}
|
|
1440
|
+
const tabindex = element.getAttribute("tabindex");
|
|
1441
|
+
if (tabindex !== null && parseInt(tabindex, 10) >= 0) {
|
|
1442
|
+
return true;
|
|
1443
|
+
}
|
|
1444
|
+
return false;
|
|
1445
|
+
}
|
|
1446
|
+
/**
|
|
1447
|
+
* Find all interactive elements in a container
|
|
1448
|
+
*/
|
|
1449
|
+
findInteractiveElements(container) {
|
|
1450
|
+
const elements = [];
|
|
1451
|
+
const clickableSelector = "[onclick], [data-clickable]";
|
|
1452
|
+
const clickables = container.querySelectorAll(clickableSelector);
|
|
1453
|
+
clickables.forEach((element) => {
|
|
1454
|
+
elements.push(this.analyzeElement(element));
|
|
1455
|
+
});
|
|
1456
|
+
return elements;
|
|
1457
|
+
}
|
|
1458
|
+
/**
|
|
1459
|
+
* Check for div/span styled as button (antipattern)
|
|
1460
|
+
*/
|
|
1461
|
+
checkForDivButton(element) {
|
|
1462
|
+
const tagName = element.tagName.toLowerCase();
|
|
1463
|
+
if (tagName === "div" || tagName === "span") {
|
|
1464
|
+
const role = element.getAttribute("role");
|
|
1465
|
+
const hasClickHandler = element.hasAttribute("onclick") || Object.keys(element).some((key) => key.startsWith("__react"));
|
|
1466
|
+
if ((role === "button" || hasClickHandler) && !this.isFocusable(element)) {
|
|
1467
|
+
WarningSystem.warn(
|
|
1468
|
+
createWarning({
|
|
1469
|
+
code: WarningCodes.DIV_BUTTON,
|
|
1470
|
+
severity: "warn",
|
|
1471
|
+
category: "semantic-html",
|
|
1472
|
+
message: `<${tagName}> used as button - use <button> instead`,
|
|
1473
|
+
element,
|
|
1474
|
+
fixes: [
|
|
1475
|
+
{
|
|
1476
|
+
description: "Use a semantic <button> element",
|
|
1477
|
+
example: `<!-- Instead of -->
|
|
1478
|
+
<div role="button" onClick={handleClick}>Click me</div>
|
|
1479
|
+
|
|
1480
|
+
<!-- Use -->
|
|
1481
|
+
<button onClick={handleClick}>Click me</button>`
|
|
1482
|
+
},
|
|
1483
|
+
{
|
|
1484
|
+
description: "If you must use a div, add tabindex and keyboard handlers",
|
|
1485
|
+
example: `<div
|
|
1486
|
+
role="button"
|
|
1487
|
+
tabindex="0"
|
|
1488
|
+
onClick={handleClick}
|
|
1489
|
+
onKeyDown={(e) => {
|
|
1490
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
1491
|
+
e.preventDefault();
|
|
1492
|
+
handleClick();
|
|
1493
|
+
}
|
|
1494
|
+
}}
|
|
1495
|
+
>
|
|
1496
|
+
Click me
|
|
1497
|
+
</div>`
|
|
1498
|
+
}
|
|
1499
|
+
]
|
|
1500
|
+
})
|
|
1501
|
+
);
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
};
|
|
1506
|
+
var keyboardValidator = new KeyboardValidator();
|
|
1507
|
+
|
|
1508
|
+
export { AriaValidator, FocusValidator, KeyboardValidator, WCAGUrls, WarningCodes, WarningSystem, ariaValidator, assertFocusVisible, assertHasAccessibleName, assertKeyboardAccessible, assertValidAriaAttributes, assertValidTabindex, createWarning, focusValidator, invariant, keyboardValidator };
|
|
1509
|
+
//# sourceMappingURL=index.js.map
|
|
1510
|
+
//# sourceMappingURL=index.js.map
|