@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.
@@ -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