@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,1201 @@
|
|
|
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_NOT_VISIBLE: "A13Y002",
|
|
146
|
+
FOCUS_TRAP_BROKEN: "A13Y003",
|
|
147
|
+
FOCUS_ORDER_INVALID: "A13Y004",
|
|
148
|
+
FOCUS_NOT_RESTORED: "A13Y005",
|
|
149
|
+
// Keyboard Navigation (100-199)
|
|
150
|
+
NOT_KEYBOARD_ACCESSIBLE: "A13Y100",
|
|
151
|
+
MISSING_KEYBOARD_HANDLER: "A13Y101",
|
|
152
|
+
ROVING_TABINDEX_BROKEN: "A13Y103",
|
|
153
|
+
MISSING_ESC_HANDLER: "A13Y104",
|
|
154
|
+
// Accessible Name (200-299)
|
|
155
|
+
MISSING_ACCESSIBLE_NAME: "A13Y200",
|
|
156
|
+
DUPLICATE_ID: "A13Y202",
|
|
157
|
+
INVALID_LABELLEDBY: "A13Y203",
|
|
158
|
+
PLACEHOLDER_AS_LABEL: "A13Y204",
|
|
159
|
+
// ARIA Usage (300-399)
|
|
160
|
+
INVALID_ARIA_ROLE: "A13Y300",
|
|
161
|
+
CONFLICTING_ARIA: "A13Y302",
|
|
162
|
+
REDUNDANT_ARIA: "A13Y303",
|
|
163
|
+
MISSING_REQUIRED_ARIA: "A13Y304",
|
|
164
|
+
INVALID_ARIA_VALUE: "A13Y305",
|
|
165
|
+
// Semantic HTML (400-499)
|
|
166
|
+
DIV_BUTTON: "A13Y400"};
|
|
167
|
+
var WCAGUrls = {
|
|
168
|
+
"2.1.1": "https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html",
|
|
169
|
+
"2.1.2": "https://www.w3.org/WAI/WCAG22/Understanding/no-keyboard-trap.html",
|
|
170
|
+
"2.4.3": "https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html",
|
|
171
|
+
"2.4.7": "https://www.w3.org/WAI/WCAG22/Understanding/focus-visible.html",
|
|
172
|
+
"4.1.2": "https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html"};
|
|
173
|
+
|
|
174
|
+
// src/runtime/validators/aria-validator.ts
|
|
175
|
+
var VALID_ROLES = /* @__PURE__ */ new Set([
|
|
176
|
+
"alert",
|
|
177
|
+
"alertdialog",
|
|
178
|
+
"application",
|
|
179
|
+
"article",
|
|
180
|
+
"banner",
|
|
181
|
+
"button",
|
|
182
|
+
"cell",
|
|
183
|
+
"checkbox",
|
|
184
|
+
"columnheader",
|
|
185
|
+
"combobox",
|
|
186
|
+
"complementary",
|
|
187
|
+
"contentinfo",
|
|
188
|
+
"definition",
|
|
189
|
+
"dialog",
|
|
190
|
+
"directory",
|
|
191
|
+
"document",
|
|
192
|
+
"feed",
|
|
193
|
+
"figure",
|
|
194
|
+
"form",
|
|
195
|
+
"grid",
|
|
196
|
+
"gridcell",
|
|
197
|
+
"group",
|
|
198
|
+
"heading",
|
|
199
|
+
"img",
|
|
200
|
+
"link",
|
|
201
|
+
"list",
|
|
202
|
+
"listbox",
|
|
203
|
+
"listitem",
|
|
204
|
+
"log",
|
|
205
|
+
"main",
|
|
206
|
+
"marquee",
|
|
207
|
+
"math",
|
|
208
|
+
"menu",
|
|
209
|
+
"menubar",
|
|
210
|
+
"menuitem",
|
|
211
|
+
"menuitemcheckbox",
|
|
212
|
+
"menuitemradio",
|
|
213
|
+
"navigation",
|
|
214
|
+
"none",
|
|
215
|
+
"note",
|
|
216
|
+
"option",
|
|
217
|
+
"presentation",
|
|
218
|
+
"progressbar",
|
|
219
|
+
"radio",
|
|
220
|
+
"radiogroup",
|
|
221
|
+
"region",
|
|
222
|
+
"row",
|
|
223
|
+
"rowgroup",
|
|
224
|
+
"rowheader",
|
|
225
|
+
"scrollbar",
|
|
226
|
+
"search",
|
|
227
|
+
"searchbox",
|
|
228
|
+
"separator",
|
|
229
|
+
"slider",
|
|
230
|
+
"spinbutton",
|
|
231
|
+
"status",
|
|
232
|
+
"switch",
|
|
233
|
+
"tab",
|
|
234
|
+
"table",
|
|
235
|
+
"tablist",
|
|
236
|
+
"tabpanel",
|
|
237
|
+
"term",
|
|
238
|
+
"textbox",
|
|
239
|
+
"timer",
|
|
240
|
+
"toolbar",
|
|
241
|
+
"tooltip",
|
|
242
|
+
"tree",
|
|
243
|
+
"treegrid",
|
|
244
|
+
"treeitem"
|
|
245
|
+
]);
|
|
246
|
+
var REQUIRED_ARIA_PROPS = {
|
|
247
|
+
checkbox: ["aria-checked"],
|
|
248
|
+
combobox: ["aria-expanded", "aria-controls"],
|
|
249
|
+
gridcell: ["aria-colindex"],
|
|
250
|
+
heading: ["aria-level"],
|
|
251
|
+
listbox: ["aria-orientation"],
|
|
252
|
+
option: ["aria-selected"],
|
|
253
|
+
progressbar: ["aria-valuenow", "aria-valuemin", "aria-valuemax"],
|
|
254
|
+
radio: ["aria-checked"],
|
|
255
|
+
scrollbar: ["aria-valuenow", "aria-valuemin", "aria-valuemax", "aria-controls"],
|
|
256
|
+
separator: ["aria-valuenow", "aria-valuemin", "aria-valuemax"],
|
|
257
|
+
slider: ["aria-valuenow", "aria-valuemin", "aria-valuemax"],
|
|
258
|
+
spinbutton: ["aria-valuenow", "aria-valuemin", "aria-valuemax"],
|
|
259
|
+
switch: ["aria-checked"],
|
|
260
|
+
tab: ["aria-selected"],
|
|
261
|
+
tabpanel: ["aria-labelledby"],
|
|
262
|
+
textbox: ["aria-multiline"],
|
|
263
|
+
treegrid: ["aria-multiselectable"]
|
|
264
|
+
};
|
|
265
|
+
var GLOBAL_ARIA_ATTRS = /* @__PURE__ */ new Set([
|
|
266
|
+
"aria-atomic",
|
|
267
|
+
"aria-busy",
|
|
268
|
+
"aria-controls",
|
|
269
|
+
"aria-current",
|
|
270
|
+
"aria-describedby",
|
|
271
|
+
"aria-details",
|
|
272
|
+
"aria-disabled",
|
|
273
|
+
"aria-dropeffect",
|
|
274
|
+
"aria-errormessage",
|
|
275
|
+
"aria-flowto",
|
|
276
|
+
"aria-grabbed",
|
|
277
|
+
"aria-haspopup",
|
|
278
|
+
"aria-hidden",
|
|
279
|
+
"aria-invalid",
|
|
280
|
+
"aria-keyshortcuts",
|
|
281
|
+
"aria-label",
|
|
282
|
+
"aria-labelledby",
|
|
283
|
+
"aria-live",
|
|
284
|
+
"aria-owns",
|
|
285
|
+
"aria-relevant",
|
|
286
|
+
"aria-roledescription"
|
|
287
|
+
]);
|
|
288
|
+
var AriaValidator = class {
|
|
289
|
+
/**
|
|
290
|
+
* Validate ARIA attributes on an element
|
|
291
|
+
*/
|
|
292
|
+
validateElement(element) {
|
|
293
|
+
if (!isDevelopment()) {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const role = element.getAttribute("role");
|
|
297
|
+
const ariaAttrs = this.getAriaAttributes(element);
|
|
298
|
+
if (role) {
|
|
299
|
+
this.validateRole(element, role);
|
|
300
|
+
}
|
|
301
|
+
ariaAttrs.forEach((attr) => {
|
|
302
|
+
this.validateAriaAttribute(element, attr, role);
|
|
303
|
+
});
|
|
304
|
+
if (role && REQUIRED_ARIA_PROPS[role]) {
|
|
305
|
+
this.validateRequiredProps(element, role);
|
|
306
|
+
}
|
|
307
|
+
this.checkRedundantAria(element, role);
|
|
308
|
+
this.checkConflictingAria(element, ariaAttrs);
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Validate accessible name
|
|
312
|
+
*/
|
|
313
|
+
validateAccessibleName(element, context) {
|
|
314
|
+
if (!isDevelopment()) {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
import('@a13y/core/runtime/aria').then(({ getAccessibleName }) => {
|
|
318
|
+
const name = getAccessibleName(element);
|
|
319
|
+
if (!name || name.trim().length === 0) {
|
|
320
|
+
WarningSystem.warn(
|
|
321
|
+
createWarning({
|
|
322
|
+
code: WarningCodes.MISSING_ACCESSIBLE_NAME,
|
|
323
|
+
severity: "error",
|
|
324
|
+
category: "accessible-name",
|
|
325
|
+
message: `Element is missing an accessible name${context ? ` in ${context}` : ""}`,
|
|
326
|
+
element,
|
|
327
|
+
wcag: {
|
|
328
|
+
criterion: "4.1.2",
|
|
329
|
+
level: "A",
|
|
330
|
+
url: WCAGUrls["4.1.2"]
|
|
331
|
+
},
|
|
332
|
+
fixes: [
|
|
333
|
+
{
|
|
334
|
+
description: "Add aria-label",
|
|
335
|
+
example: `<${element.tagName.toLowerCase()} aria-label="Description">`
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
description: "Add text content",
|
|
339
|
+
example: `<${element.tagName.toLowerCase()}>Button text</${element.tagName.toLowerCase()}>`
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
description: "Use aria-labelledby",
|
|
343
|
+
example: `<${element.tagName.toLowerCase()} aria-labelledby="label-id">`
|
|
344
|
+
}
|
|
345
|
+
]
|
|
346
|
+
})
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
|
|
350
|
+
const placeholder = element.placeholder;
|
|
351
|
+
if (placeholder && (!name || name === placeholder)) {
|
|
352
|
+
WarningSystem.warn(
|
|
353
|
+
createWarning({
|
|
354
|
+
code: WarningCodes.PLACEHOLDER_AS_LABEL,
|
|
355
|
+
severity: "warn",
|
|
356
|
+
category: "accessible-name",
|
|
357
|
+
message: "Using placeholder as accessible name is not recommended",
|
|
358
|
+
element,
|
|
359
|
+
wcag: {
|
|
360
|
+
criterion: "4.1.2",
|
|
361
|
+
level: "A",
|
|
362
|
+
url: WCAGUrls["4.1.2"]
|
|
363
|
+
},
|
|
364
|
+
fixes: [
|
|
365
|
+
{
|
|
366
|
+
description: "Add a visible label",
|
|
367
|
+
example: `<label for="input-id">Label text</label>
|
|
368
|
+
<input id="input-id" placeholder="Example">`
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
description: "Add aria-label",
|
|
372
|
+
example: `<input aria-label="Label text" placeholder="Example">`
|
|
373
|
+
}
|
|
374
|
+
]
|
|
375
|
+
})
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}).catch(() => {
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Validate role attribute
|
|
384
|
+
*/
|
|
385
|
+
validateRole(element, role) {
|
|
386
|
+
if (!VALID_ROLES.has(role)) {
|
|
387
|
+
WarningSystem.warn(
|
|
388
|
+
createWarning({
|
|
389
|
+
code: WarningCodes.INVALID_ARIA_ROLE,
|
|
390
|
+
severity: "error",
|
|
391
|
+
category: "aria-usage",
|
|
392
|
+
message: `Invalid ARIA role: "${role}"`,
|
|
393
|
+
element,
|
|
394
|
+
wcag: {
|
|
395
|
+
criterion: "4.1.2",
|
|
396
|
+
level: "A",
|
|
397
|
+
url: WCAGUrls["4.1.2"]
|
|
398
|
+
},
|
|
399
|
+
fixes: [
|
|
400
|
+
{
|
|
401
|
+
description: "Use a valid ARIA role from the specification",
|
|
402
|
+
learnMoreUrl: "https://www.w3.org/TR/wai-aria-1.2/#role_definitions"
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
description: "Remove the role attribute if not needed"
|
|
406
|
+
}
|
|
407
|
+
]
|
|
408
|
+
})
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Validate ARIA attribute
|
|
414
|
+
*/
|
|
415
|
+
validateAriaAttribute(element, attr, role) {
|
|
416
|
+
if (!attr.startsWith("aria-")) {
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
const value = element.getAttribute(attr);
|
|
420
|
+
if ([
|
|
421
|
+
"aria-atomic",
|
|
422
|
+
"aria-busy",
|
|
423
|
+
"aria-disabled",
|
|
424
|
+
"aria-hidden",
|
|
425
|
+
"aria-multiline",
|
|
426
|
+
"aria-multiselectable",
|
|
427
|
+
"aria-readonly",
|
|
428
|
+
"aria-required"
|
|
429
|
+
].includes(attr)) {
|
|
430
|
+
if (value !== "true" && value !== "false") {
|
|
431
|
+
WarningSystem.warn(
|
|
432
|
+
createWarning({
|
|
433
|
+
code: WarningCodes.INVALID_ARIA_VALUE,
|
|
434
|
+
severity: "warn",
|
|
435
|
+
category: "aria-usage",
|
|
436
|
+
message: `ARIA attribute "${attr}" must be "true" or "false", got "${value}"`,
|
|
437
|
+
element,
|
|
438
|
+
wcag: {
|
|
439
|
+
criterion: "4.1.2",
|
|
440
|
+
level: "A",
|
|
441
|
+
url: WCAGUrls["4.1.2"]
|
|
442
|
+
},
|
|
443
|
+
fixes: [
|
|
444
|
+
{
|
|
445
|
+
description: 'Use "true" or "false"',
|
|
446
|
+
example: `<element ${attr}="true">`
|
|
447
|
+
}
|
|
448
|
+
]
|
|
449
|
+
})
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (["aria-labelledby", "aria-describedby", "aria-controls", "aria-owns"].includes(attr)) {
|
|
454
|
+
this.validateIdReferences(element, attr, value);
|
|
455
|
+
}
|
|
456
|
+
if (role && !GLOBAL_ARIA_ATTRS.has(attr)) ;
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Validate ID references in ARIA attributes
|
|
460
|
+
*/
|
|
461
|
+
validateIdReferences(element, attr, value) {
|
|
462
|
+
if (!value) return;
|
|
463
|
+
const ids = value.split(/\s+/);
|
|
464
|
+
ids.forEach((id) => {
|
|
465
|
+
const referencedElement = document.getElementById(id);
|
|
466
|
+
if (!referencedElement) {
|
|
467
|
+
WarningSystem.warn(
|
|
468
|
+
createWarning({
|
|
469
|
+
code: WarningCodes.INVALID_LABELLEDBY,
|
|
470
|
+
severity: "warn",
|
|
471
|
+
category: "aria-usage",
|
|
472
|
+
message: `${attr} references non-existent element with id="${id}"`,
|
|
473
|
+
element,
|
|
474
|
+
wcag: {
|
|
475
|
+
criterion: "4.1.2",
|
|
476
|
+
level: "A",
|
|
477
|
+
url: WCAGUrls["4.1.2"]
|
|
478
|
+
},
|
|
479
|
+
fixes: [
|
|
480
|
+
{
|
|
481
|
+
description: "Ensure the referenced element exists",
|
|
482
|
+
example: `<div id="${id}">Label text</div>
|
|
483
|
+
<button ${attr}="${id}">Button</button>`
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
description: "Use aria-label instead",
|
|
487
|
+
example: `<button aria-label="Button text">Button</button>`
|
|
488
|
+
}
|
|
489
|
+
]
|
|
490
|
+
})
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
const allElements = document.querySelectorAll(`[id]`);
|
|
495
|
+
const idCounts = /* @__PURE__ */ new Map();
|
|
496
|
+
allElements.forEach((el) => {
|
|
497
|
+
const id = el.id;
|
|
498
|
+
idCounts.set(id, (idCounts.get(id) || 0) + 1);
|
|
499
|
+
});
|
|
500
|
+
ids.forEach((id) => {
|
|
501
|
+
if ((idCounts.get(id) || 0) > 1) {
|
|
502
|
+
WarningSystem.warn(
|
|
503
|
+
createWarning({
|
|
504
|
+
code: WarningCodes.DUPLICATE_ID,
|
|
505
|
+
severity: "error",
|
|
506
|
+
category: "aria-usage",
|
|
507
|
+
message: `Duplicate id="${id}" found in document`,
|
|
508
|
+
element,
|
|
509
|
+
wcag: {
|
|
510
|
+
criterion: "4.1.1",
|
|
511
|
+
level: "A",
|
|
512
|
+
url: "https://www.w3.org/WAI/WCAG22/Understanding/parsing.html"
|
|
513
|
+
},
|
|
514
|
+
fixes: [
|
|
515
|
+
{
|
|
516
|
+
description: "Ensure all IDs are unique",
|
|
517
|
+
example: `<!-- Make sure each ID is used only once -->
|
|
518
|
+
<div id="unique-id-1">First</div>
|
|
519
|
+
<div id="unique-id-2">Second</div>`
|
|
520
|
+
}
|
|
521
|
+
]
|
|
522
|
+
})
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Validate required ARIA props for a role
|
|
529
|
+
*/
|
|
530
|
+
validateRequiredProps(element, role) {
|
|
531
|
+
const requiredProps = REQUIRED_ARIA_PROPS[role] || [];
|
|
532
|
+
requiredProps.forEach((prop) => {
|
|
533
|
+
if (!element.hasAttribute(prop)) {
|
|
534
|
+
WarningSystem.warn(
|
|
535
|
+
createWarning({
|
|
536
|
+
code: WarningCodes.MISSING_REQUIRED_ARIA,
|
|
537
|
+
severity: "error",
|
|
538
|
+
category: "aria-usage",
|
|
539
|
+
message: `Role "${role}" requires "${prop}" attribute`,
|
|
540
|
+
element,
|
|
541
|
+
wcag: {
|
|
542
|
+
criterion: "4.1.2",
|
|
543
|
+
level: "A",
|
|
544
|
+
url: WCAGUrls["4.1.2"]
|
|
545
|
+
},
|
|
546
|
+
fixes: [
|
|
547
|
+
{
|
|
548
|
+
description: `Add the required ${prop} attribute`,
|
|
549
|
+
example: `<${element.tagName.toLowerCase()} role="${role}" ${prop}="value">`,
|
|
550
|
+
learnMoreUrl: `https://www.w3.org/TR/wai-aria-1.2/#${role}`
|
|
551
|
+
}
|
|
552
|
+
]
|
|
553
|
+
})
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Check for redundant ARIA
|
|
560
|
+
*/
|
|
561
|
+
checkRedundantAria(element, role) {
|
|
562
|
+
const tagName = element.tagName.toLowerCase();
|
|
563
|
+
const implicitRoles = {
|
|
564
|
+
button: "button",
|
|
565
|
+
a: "link",
|
|
566
|
+
nav: "navigation",
|
|
567
|
+
main: "main",
|
|
568
|
+
header: "banner",
|
|
569
|
+
footer: "contentinfo",
|
|
570
|
+
article: "article",
|
|
571
|
+
aside: "complementary",
|
|
572
|
+
section: "region"
|
|
573
|
+
};
|
|
574
|
+
if (role && implicitRoles[tagName] === role) {
|
|
575
|
+
WarningSystem.warn(
|
|
576
|
+
createWarning({
|
|
577
|
+
code: WarningCodes.REDUNDANT_ARIA,
|
|
578
|
+
severity: "info",
|
|
579
|
+
category: "aria-usage",
|
|
580
|
+
message: `Role "${role}" is redundant on <${tagName}>`,
|
|
581
|
+
element,
|
|
582
|
+
fixes: [
|
|
583
|
+
{
|
|
584
|
+
description: "Remove the redundant role attribute",
|
|
585
|
+
example: `<${tagName}> (role="${role}" is implicit)`
|
|
586
|
+
}
|
|
587
|
+
]
|
|
588
|
+
})
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Check for conflicting ARIA attributes
|
|
594
|
+
*/
|
|
595
|
+
checkConflictingAria(element, ariaAttrs) {
|
|
596
|
+
if (ariaAttrs.includes("aria-label") && ariaAttrs.includes("aria-labelledby")) {
|
|
597
|
+
WarningSystem.warn(
|
|
598
|
+
createWarning({
|
|
599
|
+
code: WarningCodes.CONFLICTING_ARIA,
|
|
600
|
+
severity: "warn",
|
|
601
|
+
category: "aria-usage",
|
|
602
|
+
message: "Element has both aria-label and aria-labelledby (labelledby takes precedence)",
|
|
603
|
+
element,
|
|
604
|
+
fixes: [
|
|
605
|
+
{
|
|
606
|
+
description: "Remove aria-label and keep aria-labelledby",
|
|
607
|
+
example: `<element aria-labelledby="label-id">`
|
|
608
|
+
},
|
|
609
|
+
{
|
|
610
|
+
description: "Remove aria-labelledby and keep aria-label",
|
|
611
|
+
example: `<element aria-label="Label text">`
|
|
612
|
+
}
|
|
613
|
+
]
|
|
614
|
+
})
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
if (element.getAttribute("aria-hidden") === "true") {
|
|
618
|
+
const isFocusable = element instanceof HTMLButtonElement || element instanceof HTMLAnchorElement || element instanceof HTMLInputElement || element.hasAttribute("tabindex") && parseInt(element.getAttribute("tabindex") || "0", 10) >= 0;
|
|
619
|
+
if (isFocusable) {
|
|
620
|
+
WarningSystem.warn(
|
|
621
|
+
createWarning({
|
|
622
|
+
code: WarningCodes.CONFLICTING_ARIA,
|
|
623
|
+
severity: "error",
|
|
624
|
+
category: "aria-usage",
|
|
625
|
+
message: 'Focusable element has aria-hidden="true"',
|
|
626
|
+
element,
|
|
627
|
+
wcag: {
|
|
628
|
+
criterion: "4.1.2",
|
|
629
|
+
level: "A",
|
|
630
|
+
url: WCAGUrls["4.1.2"]
|
|
631
|
+
},
|
|
632
|
+
fixes: [
|
|
633
|
+
{
|
|
634
|
+
description: "Remove aria-hidden",
|
|
635
|
+
example: `<button>Visible button</button>`
|
|
636
|
+
},
|
|
637
|
+
{
|
|
638
|
+
description: "Make element non-focusable",
|
|
639
|
+
example: `<div aria-hidden="true" tabindex="-1">Hidden content</div>`
|
|
640
|
+
}
|
|
641
|
+
]
|
|
642
|
+
})
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Get all ARIA attributes from an element
|
|
649
|
+
*/
|
|
650
|
+
getAriaAttributes(element) {
|
|
651
|
+
return Array.from(element.attributes).filter((attr) => attr.name.startsWith("aria-")).map((attr) => attr.name);
|
|
652
|
+
}
|
|
653
|
+
};
|
|
654
|
+
var ariaValidator = new AriaValidator();
|
|
655
|
+
var FocusValidator = class {
|
|
656
|
+
constructor() {
|
|
657
|
+
this.focusHistory = [];
|
|
658
|
+
this.isActive = false;
|
|
659
|
+
this.handleFocusIn = (event) => {
|
|
660
|
+
const target = event.target;
|
|
661
|
+
if (target && target !== document.body) {
|
|
662
|
+
this.focusHistory.push(target);
|
|
663
|
+
if (this.focusHistory.length > 10) {
|
|
664
|
+
this.focusHistory.shift();
|
|
665
|
+
}
|
|
666
|
+
this.validateFocusVisible(target);
|
|
667
|
+
}
|
|
668
|
+
};
|
|
669
|
+
this.handleFocusOut = (_event) => {
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Start monitoring focus changes
|
|
674
|
+
*/
|
|
675
|
+
start() {
|
|
676
|
+
if (!isDevelopment() || this.isActive) {
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
document.addEventListener("focusin", this.handleFocusIn);
|
|
680
|
+
document.addEventListener("focusout", this.handleFocusOut);
|
|
681
|
+
this.isActive = true;
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Stop monitoring focus changes
|
|
685
|
+
*/
|
|
686
|
+
stop() {
|
|
687
|
+
if (!this.isActive) {
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
document.removeEventListener("focusin", this.handleFocusIn);
|
|
691
|
+
document.removeEventListener("focusout", this.handleFocusOut);
|
|
692
|
+
this.isActive = false;
|
|
693
|
+
this.focusHistory = [];
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Validate that focus is visible
|
|
697
|
+
*/
|
|
698
|
+
validateFocusVisible(element) {
|
|
699
|
+
if (!isDevelopment()) {
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
if (!(element instanceof HTMLElement)) {
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
const computedStyle = window.getComputedStyle(element);
|
|
706
|
+
const outline = computedStyle.outline;
|
|
707
|
+
const outlineWidth = computedStyle.outlineWidth;
|
|
708
|
+
const hasFocusIndicator = outline !== "none" && outlineWidth !== "0px" && outlineWidth !== "0";
|
|
709
|
+
if (!hasFocusIndicator) {
|
|
710
|
+
const hasCustomFocus = element.hasAttribute("data-focus-visible-added") || element.matches(":focus-visible");
|
|
711
|
+
if (!hasCustomFocus) {
|
|
712
|
+
WarningSystem.warn(
|
|
713
|
+
createWarning({
|
|
714
|
+
code: WarningCodes.FOCUS_NOT_VISIBLE,
|
|
715
|
+
severity: "warn",
|
|
716
|
+
category: "focus-management",
|
|
717
|
+
message: "Focused element has no visible focus indicator",
|
|
718
|
+
element,
|
|
719
|
+
wcag: {
|
|
720
|
+
criterion: "2.4.7",
|
|
721
|
+
level: "AA",
|
|
722
|
+
url: WCAGUrls["2.4.7"]
|
|
723
|
+
},
|
|
724
|
+
fixes: [
|
|
725
|
+
{
|
|
726
|
+
description: "Add a visible outline or border on focus",
|
|
727
|
+
example: `.my-element:focus {
|
|
728
|
+
outline: 2px solid blue;
|
|
729
|
+
outline-offset: 2px;
|
|
730
|
+
}`
|
|
731
|
+
},
|
|
732
|
+
{
|
|
733
|
+
description: "Use :focus-visible for keyboard-only focus indicators",
|
|
734
|
+
example: `.my-element:focus-visible {
|
|
735
|
+
outline: 2px solid blue;
|
|
736
|
+
}`
|
|
737
|
+
},
|
|
738
|
+
{
|
|
739
|
+
description: "Use FocusVisible from @a13y/core",
|
|
740
|
+
example: `import { FocusVisible } from '@a13y/core/runtime/focus';
|
|
741
|
+
FocusVisible.init();
|
|
742
|
+
|
|
743
|
+
// Then in CSS:
|
|
744
|
+
[data-focus-visible-added] {
|
|
745
|
+
outline: 2px solid blue;
|
|
746
|
+
}`
|
|
747
|
+
}
|
|
748
|
+
]
|
|
749
|
+
})
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* Validate focus trap
|
|
756
|
+
*/
|
|
757
|
+
validateFocusTrap(container, expectedTrapped) {
|
|
758
|
+
if (!isDevelopment()) {
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
const focusableElements = this.getFocusableElements(container);
|
|
762
|
+
if (focusableElements.length === 0) {
|
|
763
|
+
WarningSystem.warn(
|
|
764
|
+
createWarning({
|
|
765
|
+
code: WarningCodes.FOCUS_TRAP_BROKEN,
|
|
766
|
+
severity: "error",
|
|
767
|
+
category: "focus-management",
|
|
768
|
+
message: "Focus trap container has no focusable elements",
|
|
769
|
+
element: container,
|
|
770
|
+
wcag: {
|
|
771
|
+
criterion: "2.1.2",
|
|
772
|
+
level: "A",
|
|
773
|
+
url: WCAGUrls["2.1.2"]
|
|
774
|
+
},
|
|
775
|
+
fixes: [
|
|
776
|
+
{
|
|
777
|
+
description: "Add at least one focusable element inside the container",
|
|
778
|
+
example: `<div role="dialog">
|
|
779
|
+
<button>Close</button>
|
|
780
|
+
</div>`
|
|
781
|
+
},
|
|
782
|
+
{
|
|
783
|
+
description: 'Make container focusable with tabindex="-1"',
|
|
784
|
+
example: `<div role="dialog" tabindex="-1">
|
|
785
|
+
Content
|
|
786
|
+
</div>`
|
|
787
|
+
}
|
|
788
|
+
]
|
|
789
|
+
})
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
if (expectedTrapped && focusableElements.length > 0) {
|
|
793
|
+
const lastElement = focusableElements[focusableElements.length - 1];
|
|
794
|
+
if (document.activeElement === lastElement) {
|
|
795
|
+
const hasTabHandler = container.getAttribute("data-focus-trap") === "true";
|
|
796
|
+
if (!hasTabHandler) {
|
|
797
|
+
WarningSystem.warn(
|
|
798
|
+
createWarning({
|
|
799
|
+
code: WarningCodes.FOCUS_TRAP_BROKEN,
|
|
800
|
+
severity: "warn",
|
|
801
|
+
category: "focus-management",
|
|
802
|
+
message: "Focus trap may not be working correctly",
|
|
803
|
+
element: container,
|
|
804
|
+
wcag: {
|
|
805
|
+
criterion: "2.1.2",
|
|
806
|
+
level: "A",
|
|
807
|
+
url: WCAGUrls["2.1.2"]
|
|
808
|
+
},
|
|
809
|
+
fixes: [
|
|
810
|
+
{
|
|
811
|
+
description: "Use createFocusTrap from @a13y/core",
|
|
812
|
+
example: `import { createFocusTrap } from '@a13y/core/runtime/focus';
|
|
813
|
+
|
|
814
|
+
const trap = createFocusTrap(container);
|
|
815
|
+
trap.activate();`
|
|
816
|
+
}
|
|
817
|
+
]
|
|
818
|
+
})
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Validate focus order
|
|
826
|
+
*/
|
|
827
|
+
validateFocusOrder(container) {
|
|
828
|
+
if (!isDevelopment()) {
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
const focusableElements = this.getFocusableElements(container);
|
|
832
|
+
focusableElements.forEach((element) => {
|
|
833
|
+
const tabindex = element.getAttribute("tabindex");
|
|
834
|
+
if (tabindex && parseInt(tabindex, 10) > 0) {
|
|
835
|
+
WarningSystem.warn(
|
|
836
|
+
createWarning({
|
|
837
|
+
code: WarningCodes.FOCUS_ORDER_INVALID,
|
|
838
|
+
severity: "warn",
|
|
839
|
+
category: "focus-management",
|
|
840
|
+
message: `Positive tabindex (${tabindex}) creates confusing focus order`,
|
|
841
|
+
element,
|
|
842
|
+
wcag: {
|
|
843
|
+
criterion: "2.4.3",
|
|
844
|
+
level: "A",
|
|
845
|
+
url: WCAGUrls["2.4.3"]
|
|
846
|
+
},
|
|
847
|
+
fixes: [
|
|
848
|
+
{
|
|
849
|
+
description: "Remove positive tabindex and restructure DOM",
|
|
850
|
+
example: `<!-- Instead of using tabindex to change order -->
|
|
851
|
+
<div tabindex="2">Second</div>
|
|
852
|
+
<div tabindex="1">First</div>
|
|
853
|
+
|
|
854
|
+
<!-- Restructure DOM to match desired order -->
|
|
855
|
+
<div tabindex="0">First</div>
|
|
856
|
+
<div tabindex="0">Second</div>`
|
|
857
|
+
}
|
|
858
|
+
]
|
|
859
|
+
})
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* Track focus restoration after actions
|
|
866
|
+
*/
|
|
867
|
+
expectFocusRestoration(expectedElement, action) {
|
|
868
|
+
if (!isDevelopment()) {
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
setTimeout(() => {
|
|
872
|
+
if (document.activeElement !== expectedElement) {
|
|
873
|
+
WarningSystem.warn(
|
|
874
|
+
createWarning({
|
|
875
|
+
code: WarningCodes.FOCUS_NOT_RESTORED,
|
|
876
|
+
severity: "warn",
|
|
877
|
+
category: "focus-management",
|
|
878
|
+
message: `Focus was not restored after ${action}`,
|
|
879
|
+
element: expectedElement,
|
|
880
|
+
wcag: {
|
|
881
|
+
criterion: "2.4.3",
|
|
882
|
+
level: "A",
|
|
883
|
+
url: WCAGUrls["2.4.3"]
|
|
884
|
+
},
|
|
885
|
+
fixes: [
|
|
886
|
+
{
|
|
887
|
+
description: "Restore focus to the expected element",
|
|
888
|
+
example: `// Save focus before action
|
|
889
|
+
const returnElement = document.activeElement;
|
|
890
|
+
|
|
891
|
+
// Perform action
|
|
892
|
+
performAction();
|
|
893
|
+
|
|
894
|
+
// Restore focus
|
|
895
|
+
returnElement?.focus();`
|
|
896
|
+
},
|
|
897
|
+
{
|
|
898
|
+
description: "Use FocusManager.saveFocus()",
|
|
899
|
+
example: `import { FocusManager } from '@a13y/core/runtime/focus';
|
|
900
|
+
|
|
901
|
+
const restore = FocusManager.saveFocus();
|
|
902
|
+
performAction();
|
|
903
|
+
restore();`
|
|
904
|
+
}
|
|
905
|
+
]
|
|
906
|
+
})
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
}, 100);
|
|
910
|
+
}
|
|
911
|
+
getFocusableElements(container) {
|
|
912
|
+
const selector = [
|
|
913
|
+
"a[href]",
|
|
914
|
+
"button:not([disabled])",
|
|
915
|
+
"input:not([disabled])",
|
|
916
|
+
"select:not([disabled])",
|
|
917
|
+
"textarea:not([disabled])",
|
|
918
|
+
'[tabindex]:not([tabindex="-1"])'
|
|
919
|
+
].join(",");
|
|
920
|
+
return Array.from(container.querySelectorAll(selector));
|
|
921
|
+
}
|
|
922
|
+
};
|
|
923
|
+
var focusValidator = new FocusValidator();
|
|
924
|
+
var KeyboardValidator = class {
|
|
925
|
+
/**
|
|
926
|
+
* Validate that interactive elements are keyboard accessible
|
|
927
|
+
*/
|
|
928
|
+
validateInteractiveElement(element) {
|
|
929
|
+
if (!isDevelopment()) {
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
const info = this.analyzeElement(element);
|
|
933
|
+
if (info.hasClickHandler && !info.isFocusable) {
|
|
934
|
+
WarningSystem.warn(
|
|
935
|
+
createWarning({
|
|
936
|
+
code: WarningCodes.NOT_KEYBOARD_ACCESSIBLE,
|
|
937
|
+
severity: "error",
|
|
938
|
+
category: "keyboard-navigation",
|
|
939
|
+
message: "Interactive element is not keyboard accessible",
|
|
940
|
+
element,
|
|
941
|
+
wcag: {
|
|
942
|
+
criterion: "2.1.1",
|
|
943
|
+
level: "A",
|
|
944
|
+
url: WCAGUrls["2.1.1"]
|
|
945
|
+
},
|
|
946
|
+
fixes: [
|
|
947
|
+
{
|
|
948
|
+
description: "Use a semantic button element",
|
|
949
|
+
example: `<button onClick={handleClick}>Click me</button>`
|
|
950
|
+
},
|
|
951
|
+
{
|
|
952
|
+
description: 'Add tabindex="0" and keyboard handlers',
|
|
953
|
+
example: `<div
|
|
954
|
+
tabindex="0"
|
|
955
|
+
onClick={handleClick}
|
|
956
|
+
onKeyDown={(e) => {
|
|
957
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
958
|
+
e.preventDefault();
|
|
959
|
+
handleClick();
|
|
960
|
+
}
|
|
961
|
+
}}
|
|
962
|
+
>
|
|
963
|
+
Click me
|
|
964
|
+
</div>`
|
|
965
|
+
}
|
|
966
|
+
]
|
|
967
|
+
})
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
if (info.hasClickHandler && info.isFocusable && !info.hasKeyHandler) {
|
|
971
|
+
const isSemanticInteractive = element instanceof HTMLButtonElement || element instanceof HTMLAnchorElement && element.hasAttribute("href") || element instanceof HTMLInputElement;
|
|
972
|
+
if (!isSemanticInteractive) {
|
|
973
|
+
WarningSystem.warn(
|
|
974
|
+
createWarning({
|
|
975
|
+
code: WarningCodes.MISSING_KEYBOARD_HANDLER,
|
|
976
|
+
severity: "warn",
|
|
977
|
+
category: "keyboard-navigation",
|
|
978
|
+
message: "Element has click handler but no keyboard event handler",
|
|
979
|
+
element,
|
|
980
|
+
wcag: {
|
|
981
|
+
criterion: "2.1.1",
|
|
982
|
+
level: "A",
|
|
983
|
+
url: WCAGUrls["2.1.1"]
|
|
984
|
+
},
|
|
985
|
+
fixes: [
|
|
986
|
+
{
|
|
987
|
+
description: "Add onKeyDown handler for Enter and Space keys",
|
|
988
|
+
example: `element.addEventListener('keydown', (e) => {
|
|
989
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
990
|
+
e.preventDefault();
|
|
991
|
+
// Trigger click or custom action
|
|
992
|
+
}
|
|
993
|
+
});`
|
|
994
|
+
}
|
|
995
|
+
]
|
|
996
|
+
})
|
|
997
|
+
);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
this.checkForDivButton(element);
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* Validate that a container's children are reachable via keyboard
|
|
1004
|
+
*/
|
|
1005
|
+
validateContainer(container) {
|
|
1006
|
+
if (!isDevelopment()) {
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
const interactiveElements = this.findInteractiveElements(container);
|
|
1010
|
+
interactiveElements.forEach((info) => {
|
|
1011
|
+
if (info.hasClickHandler && !info.isFocusable) {
|
|
1012
|
+
this.validateInteractiveElement(info.element);
|
|
1013
|
+
}
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* Validate roving tabindex implementation
|
|
1018
|
+
*/
|
|
1019
|
+
validateRovingTabindex(container) {
|
|
1020
|
+
if (!isDevelopment()) {
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
const items = Array.from(container.children);
|
|
1024
|
+
const tabindexValues = items.map(
|
|
1025
|
+
(item) => item.hasAttribute("tabindex") ? parseInt(item.getAttribute("tabindex") || "0", 10) : null
|
|
1026
|
+
);
|
|
1027
|
+
const zeroCount = tabindexValues.filter((v) => v === 0).length;
|
|
1028
|
+
const negativeOneCount = tabindexValues.filter((v) => v === -1).length;
|
|
1029
|
+
if (zeroCount !== 1 || negativeOneCount !== items.length - 1) {
|
|
1030
|
+
WarningSystem.warn(
|
|
1031
|
+
createWarning({
|
|
1032
|
+
code: WarningCodes.ROVING_TABINDEX_BROKEN,
|
|
1033
|
+
severity: "warn",
|
|
1034
|
+
category: "keyboard-navigation",
|
|
1035
|
+
message: "Roving tabindex pattern is not correctly implemented",
|
|
1036
|
+
element: container,
|
|
1037
|
+
wcag: {
|
|
1038
|
+
criterion: "2.1.1",
|
|
1039
|
+
level: "A",
|
|
1040
|
+
url: WCAGUrls["2.1.1"]
|
|
1041
|
+
},
|
|
1042
|
+
fixes: [
|
|
1043
|
+
{
|
|
1044
|
+
description: 'Set exactly one item to tabindex="0" and all others to tabindex="-1"',
|
|
1045
|
+
example: `<!-- Correct roving tabindex -->
|
|
1046
|
+
<div role="toolbar">
|
|
1047
|
+
<button tabindex="0">First (active)</button>
|
|
1048
|
+
<button tabindex="-1">Second</button>
|
|
1049
|
+
<button tabindex="-1">Third</button>
|
|
1050
|
+
</div>`
|
|
1051
|
+
},
|
|
1052
|
+
{
|
|
1053
|
+
description: "Use RovingTabindexManager from @a13y/core",
|
|
1054
|
+
example: `import { RovingTabindexManager } from '@a13y/core/runtime/keyboard';
|
|
1055
|
+
|
|
1056
|
+
const manager = new RovingTabindexManager(toolbar, {
|
|
1057
|
+
orientation: 'horizontal',
|
|
1058
|
+
});
|
|
1059
|
+
manager.init();`
|
|
1060
|
+
}
|
|
1061
|
+
]
|
|
1062
|
+
})
|
|
1063
|
+
);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
/**
|
|
1067
|
+
* Check for escape key handler in dialogs/modals
|
|
1068
|
+
*/
|
|
1069
|
+
validateEscapeHandler(container, shouldHaveEscape) {
|
|
1070
|
+
if (!isDevelopment() || !shouldHaveEscape) {
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
const hasEscapeAttr = container.hasAttribute("data-escape-closes");
|
|
1074
|
+
if (!hasEscapeAttr) {
|
|
1075
|
+
WarningSystem.warn(
|
|
1076
|
+
createWarning({
|
|
1077
|
+
code: WarningCodes.MISSING_ESC_HANDLER,
|
|
1078
|
+
severity: "warn",
|
|
1079
|
+
category: "keyboard-navigation",
|
|
1080
|
+
message: "Dialog/Modal should close on Escape key",
|
|
1081
|
+
element: container,
|
|
1082
|
+
wcag: {
|
|
1083
|
+
criterion: "2.1.2",
|
|
1084
|
+
level: "A",
|
|
1085
|
+
url: WCAGUrls["2.1.2"]
|
|
1086
|
+
},
|
|
1087
|
+
fixes: [
|
|
1088
|
+
{
|
|
1089
|
+
description: "Add Escape key handler",
|
|
1090
|
+
example: `container.addEventListener('keydown', (e) => {
|
|
1091
|
+
if (e.key === 'Escape') {
|
|
1092
|
+
closeDialog();
|
|
1093
|
+
}
|
|
1094
|
+
});`
|
|
1095
|
+
},
|
|
1096
|
+
{
|
|
1097
|
+
description: "Use createFocusTrap with onEscape callback",
|
|
1098
|
+
example: `import { createFocusTrap } from '@a13y/core/runtime/focus';
|
|
1099
|
+
|
|
1100
|
+
const trap = createFocusTrap(dialog, {
|
|
1101
|
+
onEscape: () => closeDialog(),
|
|
1102
|
+
});`
|
|
1103
|
+
}
|
|
1104
|
+
]
|
|
1105
|
+
})
|
|
1106
|
+
);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* Analyze an element for keyboard accessibility
|
|
1111
|
+
*/
|
|
1112
|
+
analyzeElement(element) {
|
|
1113
|
+
const hasClickHandler = element.hasAttribute("onclick") || element.hasAttribute("@click") || element.hasAttribute("v-on:click") || // Check for React synthetic events (harder to detect)
|
|
1114
|
+
Object.keys(element).some((key) => key.startsWith("__react"));
|
|
1115
|
+
const hasKeyHandler = element.hasAttribute("onkeydown") || element.hasAttribute("onkeyup") || element.hasAttribute("onkeypress") || element.hasAttribute("@keydown") || element.hasAttribute("v-on:keydown");
|
|
1116
|
+
const isFocusable = this.isFocusable(element);
|
|
1117
|
+
return {
|
|
1118
|
+
element,
|
|
1119
|
+
hasClickHandler,
|
|
1120
|
+
hasKeyHandler,
|
|
1121
|
+
isFocusable
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
/**
|
|
1125
|
+
* Check if element is focusable
|
|
1126
|
+
*/
|
|
1127
|
+
isFocusable(element) {
|
|
1128
|
+
if (element instanceof HTMLButtonElement || element instanceof HTMLInputElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement || element instanceof HTMLAnchorElement && element.hasAttribute("href")) {
|
|
1129
|
+
return true;
|
|
1130
|
+
}
|
|
1131
|
+
const tabindex = element.getAttribute("tabindex");
|
|
1132
|
+
if (tabindex !== null && parseInt(tabindex, 10) >= 0) {
|
|
1133
|
+
return true;
|
|
1134
|
+
}
|
|
1135
|
+
return false;
|
|
1136
|
+
}
|
|
1137
|
+
/**
|
|
1138
|
+
* Find all interactive elements in a container
|
|
1139
|
+
*/
|
|
1140
|
+
findInteractiveElements(container) {
|
|
1141
|
+
const elements = [];
|
|
1142
|
+
const clickableSelector = "[onclick], [data-clickable]";
|
|
1143
|
+
const clickables = container.querySelectorAll(clickableSelector);
|
|
1144
|
+
clickables.forEach((element) => {
|
|
1145
|
+
elements.push(this.analyzeElement(element));
|
|
1146
|
+
});
|
|
1147
|
+
return elements;
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* Check for div/span styled as button (antipattern)
|
|
1151
|
+
*/
|
|
1152
|
+
checkForDivButton(element) {
|
|
1153
|
+
const tagName = element.tagName.toLowerCase();
|
|
1154
|
+
if (tagName === "div" || tagName === "span") {
|
|
1155
|
+
const role = element.getAttribute("role");
|
|
1156
|
+
const hasClickHandler = element.hasAttribute("onclick") || Object.keys(element).some((key) => key.startsWith("__react"));
|
|
1157
|
+
if ((role === "button" || hasClickHandler) && !this.isFocusable(element)) {
|
|
1158
|
+
WarningSystem.warn(
|
|
1159
|
+
createWarning({
|
|
1160
|
+
code: WarningCodes.DIV_BUTTON,
|
|
1161
|
+
severity: "warn",
|
|
1162
|
+
category: "semantic-html",
|
|
1163
|
+
message: `<${tagName}> used as button - use <button> instead`,
|
|
1164
|
+
element,
|
|
1165
|
+
fixes: [
|
|
1166
|
+
{
|
|
1167
|
+
description: "Use a semantic <button> element",
|
|
1168
|
+
example: `<!-- Instead of -->
|
|
1169
|
+
<div role="button" onClick={handleClick}>Click me</div>
|
|
1170
|
+
|
|
1171
|
+
<!-- Use -->
|
|
1172
|
+
<button onClick={handleClick}>Click me</button>`
|
|
1173
|
+
},
|
|
1174
|
+
{
|
|
1175
|
+
description: "If you must use a div, add tabindex and keyboard handlers",
|
|
1176
|
+
example: `<div
|
|
1177
|
+
role="button"
|
|
1178
|
+
tabindex="0"
|
|
1179
|
+
onClick={handleClick}
|
|
1180
|
+
onKeyDown={(e) => {
|
|
1181
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
1182
|
+
e.preventDefault();
|
|
1183
|
+
handleClick();
|
|
1184
|
+
}
|
|
1185
|
+
}}
|
|
1186
|
+
>
|
|
1187
|
+
Click me
|
|
1188
|
+
</div>`
|
|
1189
|
+
}
|
|
1190
|
+
]
|
|
1191
|
+
})
|
|
1192
|
+
);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
};
|
|
1197
|
+
var keyboardValidator = new KeyboardValidator();
|
|
1198
|
+
|
|
1199
|
+
export { AriaValidator, FocusValidator, KeyboardValidator, ariaValidator, focusValidator, keyboardValidator };
|
|
1200
|
+
//# sourceMappingURL=index.js.map
|
|
1201
|
+
//# sourceMappingURL=index.js.map
|