pulse-js-framework 1.7.9 → 1.7.10

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,1005 @@
1
+ /**
2
+ * Pulse A11y - Accessibility Utilities
3
+ * Zero-dependency accessibility helpers for inclusive web applications
4
+ *
5
+ * Features:
6
+ * - Screen reader announcements (live regions)
7
+ * - Focus management (trap, restore, skip links)
8
+ * - User preferences detection (reduced motion, color scheme)
9
+ * - ARIA validation and helpers
10
+ * - Keyboard navigation utilities
11
+ */
12
+
13
+ import { pulse, effect } from './pulse.js';
14
+
15
+ // =============================================================================
16
+ // LIVE REGIONS - Screen Reader Announcements
17
+ // =============================================================================
18
+
19
+ let liveRegionPolite = null;
20
+ let liveRegionAssertive = null;
21
+
22
+ /**
23
+ * Initialize live regions for screen reader announcements
24
+ * Called automatically on first announce
25
+ */
26
+ function ensureLiveRegions() {
27
+ if (typeof document === 'undefined') return;
28
+
29
+ if (!liveRegionPolite) {
30
+ liveRegionPolite = document.createElement('div');
31
+ liveRegionPolite.setAttribute('role', 'status');
32
+ liveRegionPolite.setAttribute('aria-live', 'polite');
33
+ liveRegionPolite.setAttribute('aria-atomic', 'true');
34
+ Object.assign(liveRegionPolite.style, {
35
+ position: 'absolute',
36
+ width: '1px',
37
+ height: '1px',
38
+ padding: '0',
39
+ margin: '-1px',
40
+ overflow: 'hidden',
41
+ clip: 'rect(0, 0, 0, 0)',
42
+ whiteSpace: 'nowrap',
43
+ border: '0'
44
+ });
45
+ liveRegionPolite.id = 'pulse-a11y-polite';
46
+ document.body.appendChild(liveRegionPolite);
47
+ }
48
+
49
+ if (!liveRegionAssertive) {
50
+ liveRegionAssertive = document.createElement('div');
51
+ liveRegionAssertive.setAttribute('role', 'alert');
52
+ liveRegionAssertive.setAttribute('aria-live', 'assertive');
53
+ liveRegionAssertive.setAttribute('aria-atomic', 'true');
54
+ Object.assign(liveRegionAssertive.style, {
55
+ position: 'absolute',
56
+ width: '1px',
57
+ height: '1px',
58
+ padding: '0',
59
+ margin: '-1px',
60
+ overflow: 'hidden',
61
+ clip: 'rect(0, 0, 0, 0)',
62
+ whiteSpace: 'nowrap',
63
+ border: '0'
64
+ });
65
+ liveRegionAssertive.id = 'pulse-a11y-assertive';
66
+ document.body.appendChild(liveRegionAssertive);
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Announce a message to screen readers
72
+ * @param {string} message - Message to announce
73
+ * @param {object} options - Options
74
+ * @param {'polite'|'assertive'} options.priority - Announcement priority (default: 'polite')
75
+ * @param {number} options.clearAfter - Clear message after ms (default: 1000)
76
+ */
77
+ export function announce(message, options = {}) {
78
+ const { priority = 'polite', clearAfter = 1000 } = options;
79
+
80
+ ensureLiveRegions();
81
+
82
+ const region = priority === 'assertive' ? liveRegionAssertive : liveRegionPolite;
83
+ if (!region) return;
84
+
85
+ // Clear and set new message (needed for repeated announcements)
86
+ region.textContent = '';
87
+
88
+ // Use requestAnimationFrame to ensure the clear is processed
89
+ requestAnimationFrame(() => {
90
+ region.textContent = message;
91
+
92
+ if (clearAfter > 0) {
93
+ setTimeout(() => {
94
+ region.textContent = '';
95
+ }, clearAfter);
96
+ }
97
+ });
98
+ }
99
+
100
+ /**
101
+ * Announce politely (waits for user to finish current task)
102
+ * @param {string} message - Message to announce
103
+ */
104
+ export function announcePolite(message) {
105
+ announce(message, { priority: 'polite' });
106
+ }
107
+
108
+ /**
109
+ * Announce assertively (interrupts current announcement)
110
+ * Use sparingly - only for critical updates
111
+ * @param {string} message - Message to announce
112
+ */
113
+ export function announceAssertive(message) {
114
+ announce(message, { priority: 'assertive' });
115
+ }
116
+
117
+ /**
118
+ * Create a reactive live region that announces when value changes
119
+ * @param {Function} getter - Function that returns the message
120
+ * @param {object} options - Announce options
121
+ * @returns {Function} Cleanup function
122
+ */
123
+ export function createLiveAnnouncer(getter, options = {}) {
124
+ let lastValue = null;
125
+
126
+ return effect(() => {
127
+ const value = getter();
128
+ if (value !== lastValue && value) {
129
+ announce(value, options);
130
+ lastValue = value;
131
+ }
132
+ });
133
+ }
134
+
135
+ // =============================================================================
136
+ // FOCUS MANAGEMENT
137
+ // =============================================================================
138
+
139
+ const focusStack = [];
140
+
141
+ /** Focusable element selector */
142
+ const FOCUSABLE_SELECTOR = [
143
+ 'a[href]',
144
+ 'button:not([disabled])',
145
+ 'input:not([disabled]):not([type="hidden"])',
146
+ 'select:not([disabled])',
147
+ 'textarea:not([disabled])',
148
+ '[tabindex]:not([tabindex="-1"])',
149
+ '[contenteditable="true"]',
150
+ 'audio[controls]',
151
+ 'video[controls]',
152
+ 'details > summary',
153
+ 'iframe'
154
+ ].join(', ');
155
+
156
+ /**
157
+ * Get all focusable elements within a container
158
+ * @param {HTMLElement} container - Container element
159
+ * @returns {HTMLElement[]} Array of focusable elements
160
+ */
161
+ export function getFocusableElements(container) {
162
+ if (!container) return [];
163
+ const elements = Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR));
164
+ return elements.filter(el => {
165
+ // Check visibility
166
+ const style = getComputedStyle(el);
167
+ return style.display !== 'none' &&
168
+ style.visibility !== 'hidden' &&
169
+ el.offsetParent !== null;
170
+ });
171
+ }
172
+
173
+ /**
174
+ * Focus the first focusable element in a container
175
+ * @param {HTMLElement} container - Container element
176
+ * @returns {HTMLElement|null} The focused element or null
177
+ */
178
+ export function focusFirst(container) {
179
+ const focusable = getFocusableElements(container);
180
+ if (focusable.length > 0) {
181
+ focusable[0].focus();
182
+ return focusable[0];
183
+ }
184
+ return null;
185
+ }
186
+
187
+ /**
188
+ * Focus the last focusable element in a container
189
+ * @param {HTMLElement} container - Container element
190
+ * @returns {HTMLElement|null} The focused element or null
191
+ */
192
+ export function focusLast(container) {
193
+ const focusable = getFocusableElements(container);
194
+ if (focusable.length > 0) {
195
+ focusable[focusable.length - 1].focus();
196
+ return focusable[focusable.length - 1];
197
+ }
198
+ return null;
199
+ }
200
+
201
+ /**
202
+ * Trap focus within a container (for modals, dialogs)
203
+ * @param {HTMLElement} container - Container to trap focus in
204
+ * @param {object} options - Options
205
+ * @param {boolean} options.autoFocus - Auto focus first element (default: true)
206
+ * @param {boolean} options.returnFocus - Return focus on release (default: true)
207
+ * @param {HTMLElement} options.initialFocus - Element to focus initially
208
+ * @returns {Function} Release function to remove trap
209
+ */
210
+ export function trapFocus(container, options = {}) {
211
+ const { autoFocus = true, returnFocus = true, initialFocus = null } = options;
212
+
213
+ if (!container) return () => {};
214
+
215
+ // Save current focus
216
+ const previouslyFocused = document.activeElement;
217
+ if (returnFocus) {
218
+ focusStack.push(previouslyFocused);
219
+ }
220
+
221
+ // Handle Tab key
222
+ const handleKeyDown = (e) => {
223
+ if (e.key !== 'Tab') return;
224
+
225
+ const focusable = getFocusableElements(container);
226
+ if (focusable.length === 0) {
227
+ e.preventDefault();
228
+ return;
229
+ }
230
+
231
+ const firstFocusable = focusable[0];
232
+ const lastFocusable = focusable[focusable.length - 1];
233
+
234
+ if (e.shiftKey) {
235
+ // Shift + Tab: going backwards
236
+ if (document.activeElement === firstFocusable) {
237
+ e.preventDefault();
238
+ lastFocusable.focus();
239
+ }
240
+ } else {
241
+ // Tab: going forwards
242
+ if (document.activeElement === lastFocusable) {
243
+ e.preventDefault();
244
+ firstFocusable.focus();
245
+ }
246
+ }
247
+ };
248
+
249
+ // Handle focus leaving container
250
+ const handleFocusOut = (e) => {
251
+ if (!container.contains(e.relatedTarget)) {
252
+ // Focus is leaving container, bring it back
253
+ const focusable = getFocusableElements(container);
254
+ if (focusable.length > 0) {
255
+ focusable[0].focus();
256
+ }
257
+ }
258
+ };
259
+
260
+ container.addEventListener('keydown', handleKeyDown);
261
+ container.addEventListener('focusout', handleFocusOut);
262
+
263
+ // Set initial focus
264
+ if (autoFocus) {
265
+ requestAnimationFrame(() => {
266
+ if (initialFocus && container.contains(initialFocus)) {
267
+ initialFocus.focus();
268
+ } else {
269
+ focusFirst(container);
270
+ }
271
+ });
272
+ }
273
+
274
+ // Return release function
275
+ return function releaseFocusTrap() {
276
+ container.removeEventListener('keydown', handleKeyDown);
277
+ container.removeEventListener('focusout', handleFocusOut);
278
+
279
+ if (returnFocus && focusStack.length > 0) {
280
+ const toFocus = focusStack.pop();
281
+ if (toFocus && typeof toFocus.focus === 'function') {
282
+ toFocus.focus();
283
+ }
284
+ }
285
+ };
286
+ }
287
+
288
+ /**
289
+ * Save current focus to stack
290
+ */
291
+ export function saveFocus() {
292
+ focusStack.push(document.activeElement);
293
+ }
294
+
295
+ /**
296
+ * Restore focus from stack
297
+ */
298
+ export function restoreFocus() {
299
+ if (focusStack.length > 0) {
300
+ const element = focusStack.pop();
301
+ if (element && typeof element.focus === 'function') {
302
+ element.focus();
303
+ }
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Clear focus stack
309
+ */
310
+ export function clearFocusStack() {
311
+ focusStack.length = 0;
312
+ }
313
+
314
+ // =============================================================================
315
+ // SKIP LINKS
316
+ // =============================================================================
317
+
318
+ /**
319
+ * Create a skip link for keyboard navigation
320
+ * @param {string} targetId - ID of target element to skip to
321
+ * @param {string} text - Link text (default: 'Skip to main content')
322
+ * @param {object} options - Options
323
+ * @returns {HTMLElement} The skip link element
324
+ */
325
+ export function createSkipLink(targetId, text = 'Skip to main content', options = {}) {
326
+ const { className = 'pulse-skip-link' } = options;
327
+
328
+ const link = document.createElement('a');
329
+ link.href = `#${targetId}`;
330
+ link.textContent = text;
331
+ link.className = className;
332
+
333
+ // Visually hidden but focusable styles
334
+ Object.assign(link.style, {
335
+ position: 'absolute',
336
+ top: '-40px',
337
+ left: '0',
338
+ padding: '8px 16px',
339
+ background: '#000',
340
+ color: '#fff',
341
+ textDecoration: 'none',
342
+ zIndex: '10000',
343
+ transition: 'top 0.2s'
344
+ });
345
+
346
+ // Show on focus
347
+ link.addEventListener('focus', () => {
348
+ link.style.top = '0';
349
+ });
350
+
351
+ link.addEventListener('blur', () => {
352
+ link.style.top = '-40px';
353
+ });
354
+
355
+ // Handle click
356
+ link.addEventListener('click', (e) => {
357
+ e.preventDefault();
358
+ const target = document.getElementById(targetId);
359
+ if (target) {
360
+ target.setAttribute('tabindex', '-1');
361
+ target.focus();
362
+ target.removeAttribute('tabindex');
363
+ }
364
+ });
365
+
366
+ return link;
367
+ }
368
+
369
+ /**
370
+ * Install skip links at the beginning of the document
371
+ * @param {Array<{target: string, text: string}>} links - Skip link definitions
372
+ */
373
+ export function installSkipLinks(links) {
374
+ const container = document.createElement('nav');
375
+ container.setAttribute('aria-label', 'Skip links');
376
+ container.className = 'pulse-skip-links';
377
+
378
+ links.forEach(({ target, text }) => {
379
+ container.appendChild(createSkipLink(target, text));
380
+ });
381
+
382
+ document.body.insertBefore(container, document.body.firstChild);
383
+ return container;
384
+ }
385
+
386
+ // =============================================================================
387
+ // USER PREFERENCES
388
+ // =============================================================================
389
+
390
+ /**
391
+ * Check if user prefers reduced motion
392
+ * @returns {boolean}
393
+ */
394
+ export function prefersReducedMotion() {
395
+ if (typeof window === 'undefined') return false;
396
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
397
+ }
398
+
399
+ /**
400
+ * Check user's preferred color scheme
401
+ * @returns {'light'|'dark'|'no-preference'}
402
+ */
403
+ export function prefersColorScheme() {
404
+ if (typeof window === 'undefined') return 'no-preference';
405
+ if (window.matchMedia('(prefers-color-scheme: dark)').matches) return 'dark';
406
+ if (window.matchMedia('(prefers-color-scheme: light)').matches) return 'light';
407
+ return 'no-preference';
408
+ }
409
+
410
+ /**
411
+ * Check if user prefers high contrast
412
+ * @returns {boolean}
413
+ */
414
+ export function prefersHighContrast() {
415
+ if (typeof window === 'undefined') return false;
416
+ return window.matchMedia('(prefers-contrast: more)').matches;
417
+ }
418
+
419
+ /**
420
+ * Create reactive user preferences pulse
421
+ * @returns {object} Object with reactive preference pulses
422
+ */
423
+ export function createPreferences() {
424
+ const reducedMotion = pulse(prefersReducedMotion());
425
+ const colorScheme = pulse(prefersColorScheme());
426
+ const highContrast = pulse(prefersHighContrast());
427
+
428
+ if (typeof window !== 'undefined') {
429
+ // Listen for preference changes
430
+ window.matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', (e) => {
431
+ reducedMotion.set(e.matches);
432
+ });
433
+
434
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
435
+ colorScheme.set(e.matches ? 'dark' : 'light');
436
+ });
437
+
438
+ window.matchMedia('(prefers-contrast: more)').addEventListener('change', (e) => {
439
+ highContrast.set(e.matches);
440
+ });
441
+ }
442
+
443
+ return {
444
+ reducedMotion,
445
+ colorScheme,
446
+ highContrast
447
+ };
448
+ }
449
+
450
+ // =============================================================================
451
+ // ARIA HELPERS
452
+ // =============================================================================
453
+
454
+ /**
455
+ * Set multiple ARIA attributes on an element
456
+ * @param {HTMLElement} element - Target element
457
+ * @param {object} attrs - ARIA attributes (without 'aria-' prefix)
458
+ */
459
+ export function setAriaAttributes(element, attrs) {
460
+ Object.entries(attrs).forEach(([key, value]) => {
461
+ if (value === null || value === undefined) {
462
+ element.removeAttribute(`aria-${key}`);
463
+ } else {
464
+ element.setAttribute(`aria-${key}`, String(value));
465
+ }
466
+ });
467
+ }
468
+
469
+ /**
470
+ * Create an ARIA-compliant disclosure widget
471
+ * @param {HTMLElement} trigger - Button that toggles disclosure
472
+ * @param {HTMLElement} content - Content to show/hide
473
+ * @param {object} options - Options
474
+ * @returns {object} Control object with toggle, open, close methods
475
+ */
476
+ export function createDisclosure(trigger, content, options = {}) {
477
+ const { defaultOpen = false, onToggle = null } = options;
478
+
479
+ const expanded = pulse(defaultOpen);
480
+ const id = content.id || `pulse-disclosure-${Date.now()}`;
481
+
482
+ content.id = id;
483
+ trigger.setAttribute('aria-controls', id);
484
+ trigger.setAttribute('aria-expanded', String(expanded.get()));
485
+
486
+ // Update visibility
487
+ effect(() => {
488
+ const isOpen = expanded.get();
489
+ trigger.setAttribute('aria-expanded', String(isOpen));
490
+ content.hidden = !isOpen;
491
+ if (onToggle) onToggle(isOpen);
492
+ });
493
+
494
+ // Handle click
495
+ trigger.addEventListener('click', () => {
496
+ expanded.update(v => !v);
497
+ });
498
+
499
+ // Handle keyboard
500
+ trigger.addEventListener('keydown', (e) => {
501
+ if (e.key === 'Enter' || e.key === ' ') {
502
+ e.preventDefault();
503
+ expanded.update(v => !v);
504
+ }
505
+ });
506
+
507
+ return {
508
+ expanded,
509
+ toggle: () => expanded.update(v => !v),
510
+ open: () => expanded.set(true),
511
+ close: () => expanded.set(false)
512
+ };
513
+ }
514
+
515
+ /**
516
+ * Create ARIA-compliant tabs
517
+ * @param {HTMLElement} tablist - Container with role="tablist"
518
+ * @param {object} options - Options
519
+ * @returns {object} Control object
520
+ */
521
+ export function createTabs(tablist, options = {}) {
522
+ const { defaultIndex = 0, orientation = 'horizontal', onSelect = null } = options;
523
+
524
+ const tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
525
+ const panels = tabs.map(tab => {
526
+ const panelId = tab.getAttribute('aria-controls');
527
+ return document.getElementById(panelId);
528
+ });
529
+
530
+ const selectedIndex = pulse(defaultIndex);
531
+
532
+ tablist.setAttribute('aria-orientation', orientation);
533
+
534
+ // Update selection
535
+ effect(() => {
536
+ const index = selectedIndex.get();
537
+
538
+ tabs.forEach((tab, i) => {
539
+ const isSelected = i === index;
540
+ tab.setAttribute('aria-selected', String(isSelected));
541
+ tab.setAttribute('tabindex', isSelected ? '0' : '-1');
542
+ });
543
+
544
+ panels.forEach((panel, i) => {
545
+ if (panel) {
546
+ panel.hidden = i !== index;
547
+ }
548
+ });
549
+
550
+ if (onSelect) onSelect(index);
551
+ });
552
+
553
+ // Handle click
554
+ tabs.forEach((tab, i) => {
555
+ tab.addEventListener('click', () => {
556
+ selectedIndex.set(i);
557
+ tab.focus();
558
+ });
559
+ });
560
+
561
+ // Handle keyboard navigation
562
+ tablist.addEventListener('keydown', (e) => {
563
+ const currentIndex = selectedIndex.get();
564
+ let newIndex = currentIndex;
565
+
566
+ const isHorizontal = orientation === 'horizontal';
567
+ const prevKey = isHorizontal ? 'ArrowLeft' : 'ArrowUp';
568
+ const nextKey = isHorizontal ? 'ArrowRight' : 'ArrowDown';
569
+
570
+ switch (e.key) {
571
+ case prevKey:
572
+ newIndex = currentIndex > 0 ? currentIndex - 1 : tabs.length - 1;
573
+ break;
574
+ case nextKey:
575
+ newIndex = currentIndex < tabs.length - 1 ? currentIndex + 1 : 0;
576
+ break;
577
+ case 'Home':
578
+ newIndex = 0;
579
+ break;
580
+ case 'End':
581
+ newIndex = tabs.length - 1;
582
+ break;
583
+ default:
584
+ return;
585
+ }
586
+
587
+ e.preventDefault();
588
+ selectedIndex.set(newIndex);
589
+ tabs[newIndex].focus();
590
+ });
591
+
592
+ return {
593
+ selectedIndex,
594
+ select: (index) => selectedIndex.set(index),
595
+ tabs,
596
+ panels
597
+ };
598
+ }
599
+
600
+ // =============================================================================
601
+ // KEYBOARD NAVIGATION
602
+ // =============================================================================
603
+
604
+ /**
605
+ * Handle arrow key navigation within a container (roving tabindex)
606
+ * @param {HTMLElement} container - Container element
607
+ * @param {object} options - Options
608
+ * @returns {Function} Cleanup function
609
+ */
610
+ export function createRovingTabindex(container, options = {}) {
611
+ const {
612
+ selector = '[role="option"], [role="menuitem"], [role="treeitem"]',
613
+ orientation = 'vertical',
614
+ loop = true,
615
+ onSelect = null
616
+ } = options;
617
+
618
+ const getItems = () => Array.from(container.querySelectorAll(selector))
619
+ .filter(el => !el.hasAttribute('disabled') && el.getAttribute('aria-disabled') !== 'true');
620
+
621
+ const items = getItems();
622
+ if (items.length === 0) return () => {};
623
+
624
+ // Initialize tabindex
625
+ items.forEach((item, i) => {
626
+ item.setAttribute('tabindex', i === 0 ? '0' : '-1');
627
+ });
628
+
629
+ const handleKeyDown = (e) => {
630
+ const items = getItems();
631
+ const currentIndex = items.findIndex(item => item === document.activeElement);
632
+ if (currentIndex === -1) return;
633
+
634
+ const isVertical = orientation === 'vertical';
635
+ const prevKey = isVertical ? 'ArrowUp' : 'ArrowLeft';
636
+ const nextKey = isVertical ? 'ArrowDown' : 'ArrowRight';
637
+
638
+ let newIndex = currentIndex;
639
+
640
+ switch (e.key) {
641
+ case prevKey:
642
+ if (loop) {
643
+ newIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
644
+ } else {
645
+ newIndex = Math.max(0, currentIndex - 1);
646
+ }
647
+ break;
648
+ case nextKey:
649
+ if (loop) {
650
+ newIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
651
+ } else {
652
+ newIndex = Math.min(items.length - 1, currentIndex + 1);
653
+ }
654
+ break;
655
+ case 'Home':
656
+ newIndex = 0;
657
+ break;
658
+ case 'End':
659
+ newIndex = items.length - 1;
660
+ break;
661
+ case 'Enter':
662
+ case ' ':
663
+ if (onSelect) {
664
+ e.preventDefault();
665
+ onSelect(items[currentIndex], currentIndex);
666
+ }
667
+ return;
668
+ default:
669
+ return;
670
+ }
671
+
672
+ e.preventDefault();
673
+
674
+ // Update tabindex
675
+ items.forEach((item, i) => {
676
+ item.setAttribute('tabindex', i === newIndex ? '0' : '-1');
677
+ });
678
+
679
+ items[newIndex].focus();
680
+ };
681
+
682
+ container.addEventListener('keydown', handleKeyDown);
683
+
684
+ return () => {
685
+ container.removeEventListener('keydown', handleKeyDown);
686
+ };
687
+ }
688
+
689
+ // =============================================================================
690
+ // VALIDATION & AUDITING
691
+ // =============================================================================
692
+
693
+ /**
694
+ * A11y issues found during validation
695
+ * @typedef {object} A11yIssue
696
+ * @property {'error'|'warning'} severity - Issue severity
697
+ * @property {string} rule - Rule identifier
698
+ * @property {string} message - Human-readable message
699
+ * @property {HTMLElement} element - The element with the issue
700
+ */
701
+
702
+ /**
703
+ * Validate accessibility of a container
704
+ * @param {HTMLElement} container - Container to validate (default: document.body)
705
+ * @returns {A11yIssue[]} Array of issues found
706
+ */
707
+ export function validateA11y(container = document.body) {
708
+ const issues = [];
709
+
710
+ const addIssue = (severity, rule, message, element) => {
711
+ issues.push({ severity, rule, message, element });
712
+ };
713
+
714
+ // Check images for alt text
715
+ container.querySelectorAll('img').forEach(img => {
716
+ if (!img.hasAttribute('alt')) {
717
+ addIssue('error', 'img-alt', 'Image missing alt attribute', img);
718
+ } else if (img.alt === '') {
719
+ // Empty alt is OK for decorative images, but warn
720
+ if (!img.getAttribute('role')?.includes('presentation')) {
721
+ addIssue('warning', 'img-alt-empty', 'Image has empty alt - ensure it is decorative', img);
722
+ }
723
+ }
724
+ });
725
+
726
+ // Check buttons for accessible names
727
+ container.querySelectorAll('button').forEach(button => {
728
+ const hasText = button.textContent.trim().length > 0;
729
+ const hasAriaLabel = button.hasAttribute('aria-label');
730
+ const hasAriaLabelledBy = button.hasAttribute('aria-labelledby');
731
+ const hasTitle = button.hasAttribute('title');
732
+
733
+ if (!hasText && !hasAriaLabel && !hasAriaLabelledBy && !hasTitle) {
734
+ addIssue('error', 'button-name', 'Button has no accessible name', button);
735
+ }
736
+ });
737
+
738
+ // Check links for accessible names
739
+ container.querySelectorAll('a[href]').forEach(link => {
740
+ const hasText = link.textContent.trim().length > 0;
741
+ const hasAriaLabel = link.hasAttribute('aria-label');
742
+ const hasImg = link.querySelector('img[alt]');
743
+
744
+ if (!hasText && !hasAriaLabel && !hasImg) {
745
+ addIssue('error', 'link-name', 'Link has no accessible name', link);
746
+ }
747
+ });
748
+
749
+ // Check form inputs for labels
750
+ container.querySelectorAll('input, select, textarea').forEach(input => {
751
+ if (input.type === 'hidden' || input.type === 'submit' || input.type === 'button') return;
752
+
753
+ const id = input.id;
754
+ const hasLabel = id && container.querySelector(`label[for="${id}"]`);
755
+ const hasAriaLabel = input.hasAttribute('aria-label');
756
+ const hasAriaLabelledBy = input.hasAttribute('aria-labelledby');
757
+ const isWrappedByLabel = input.closest('label');
758
+ const hasPlaceholder = input.hasAttribute('placeholder');
759
+
760
+ if (!hasLabel && !hasAriaLabel && !hasAriaLabelledBy && !isWrappedByLabel) {
761
+ const msg = hasPlaceholder
762
+ ? 'Form input uses placeholder but missing label (placeholder is not a label substitute)'
763
+ : 'Form input missing associated label';
764
+ addIssue('error', 'input-label', msg, input);
765
+ }
766
+ });
767
+
768
+ // Check for positive tabindex (anti-pattern)
769
+ container.querySelectorAll('[tabindex]').forEach(el => {
770
+ const tabindex = parseInt(el.getAttribute('tabindex'), 10);
771
+ if (tabindex > 0) {
772
+ addIssue('warning', 'tabindex-positive', 'Avoid positive tabindex values - use DOM order instead', el);
773
+ }
774
+ });
775
+
776
+ // Check for click handlers on non-interactive elements
777
+ container.querySelectorAll('div[onclick], span[onclick]').forEach(el => {
778
+ if (!el.hasAttribute('role') && !el.hasAttribute('tabindex')) {
779
+ addIssue('warning', 'click-non-interactive', 'Click handler on non-interactive element - consider using button', el);
780
+ }
781
+ });
782
+
783
+ // Check headings hierarchy
784
+ const headings = container.querySelectorAll('h1, h2, h3, h4, h5, h6');
785
+ let lastLevel = 0;
786
+ headings.forEach(heading => {
787
+ const level = parseInt(heading.tagName[1], 10);
788
+ if (level > lastLevel + 1 && lastLevel !== 0) {
789
+ addIssue('warning', 'heading-order', `Heading level skipped (h${lastLevel} to h${level})`, heading);
790
+ }
791
+ lastLevel = level;
792
+ });
793
+
794
+ // Check for autoplay media
795
+ container.querySelectorAll('video[autoplay], audio[autoplay]').forEach(media => {
796
+ if (!media.hasAttribute('muted')) {
797
+ addIssue('warning', 'media-autoplay', 'Autoplaying media should be muted', media);
798
+ }
799
+ });
800
+
801
+ return issues;
802
+ }
803
+
804
+ /**
805
+ * Log validation results to console
806
+ * @param {A11yIssue[]} issues - Issues from validateA11y
807
+ */
808
+ export function logA11yIssues(issues) {
809
+ if (issues.length === 0) {
810
+ console.log('%c✓ No accessibility issues found', 'color: green; font-weight: bold');
811
+ return;
812
+ }
813
+
814
+ const errors = issues.filter(i => i.severity === 'error');
815
+ const warnings = issues.filter(i => i.severity === 'warning');
816
+
817
+ console.group(`%cAccessibility Issues (${errors.length} errors, ${warnings.length} warnings)`,
818
+ 'color: red; font-weight: bold');
819
+
820
+ issues.forEach(issue => {
821
+ const icon = issue.severity === 'error' ? '❌' : '⚠️';
822
+ const color = issue.severity === 'error' ? 'color: red' : 'color: orange';
823
+ console.log(`%c${icon} [${issue.rule}] ${issue.message}`, color, issue.element);
824
+ });
825
+
826
+ console.groupEnd();
827
+ }
828
+
829
+ /**
830
+ * Highlight elements with accessibility issues in the DOM
831
+ * @param {A11yIssue[]} issues - Issues from validateA11y
832
+ * @returns {Function} Cleanup function to remove highlights
833
+ */
834
+ export function highlightA11yIssues(issues) {
835
+ const highlights = [];
836
+
837
+ issues.forEach(issue => {
838
+ const el = issue.element;
839
+ const rect = el.getBoundingClientRect();
840
+
841
+ const highlight = document.createElement('div');
842
+ highlight.className = 'pulse-a11y-highlight';
843
+ highlight.style.cssText = `
844
+ position: fixed;
845
+ top: ${rect.top}px;
846
+ left: ${rect.left}px;
847
+ width: ${rect.width}px;
848
+ height: ${rect.height}px;
849
+ border: 2px solid ${issue.severity === 'error' ? 'red' : 'orange'};
850
+ background: ${issue.severity === 'error' ? 'rgba(255,0,0,0.1)' : 'rgba(255,165,0,0.1)'};
851
+ pointer-events: none;
852
+ z-index: 99999;
853
+ `;
854
+
855
+ const label = document.createElement('div');
856
+ label.style.cssText = `
857
+ position: absolute;
858
+ top: -20px;
859
+ left: 0;
860
+ background: ${issue.severity === 'error' ? 'red' : 'orange'};
861
+ color: white;
862
+ font-size: 10px;
863
+ padding: 2px 4px;
864
+ border-radius: 2px;
865
+ white-space: nowrap;
866
+ `;
867
+ label.textContent = issue.rule;
868
+ highlight.appendChild(label);
869
+
870
+ document.body.appendChild(highlight);
871
+ highlights.push(highlight);
872
+ });
873
+
874
+ return () => {
875
+ highlights.forEach(h => h.remove());
876
+ };
877
+ }
878
+
879
+ // =============================================================================
880
+ // UTILITIES
881
+ // =============================================================================
882
+
883
+ /**
884
+ * Generate a unique ID for ARIA relationships
885
+ * @param {string} prefix - ID prefix
886
+ * @returns {string} Unique ID
887
+ */
888
+ export function generateId(prefix = 'pulse') {
889
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
890
+ }
891
+
892
+ /**
893
+ * Check if an element is visible to screen readers
894
+ * @param {HTMLElement} element - Element to check
895
+ * @returns {boolean}
896
+ */
897
+ export function isAccessiblyHidden(element) {
898
+ if (!element) return true;
899
+
900
+ // Check aria-hidden
901
+ if (element.getAttribute('aria-hidden') === 'true') return true;
902
+
903
+ // Check CSS
904
+ const style = getComputedStyle(element);
905
+ if (style.display === 'none') return true;
906
+ if (style.visibility === 'hidden') return true;
907
+
908
+ // Check inert
909
+ if (element.hasAttribute('inert')) return true;
910
+
911
+ // Check ancestors
912
+ let parent = element.parentElement;
913
+ while (parent) {
914
+ if (parent.getAttribute('aria-hidden') === 'true') return true;
915
+ if (parent.hasAttribute('inert')) return true;
916
+ parent = parent.parentElement;
917
+ }
918
+
919
+ return false;
920
+ }
921
+
922
+ /**
923
+ * Make an element inert (non-interactive, hidden from a11y tree)
924
+ * @param {HTMLElement} element - Element to make inert
925
+ * @returns {Function} Restore function
926
+ */
927
+ export function makeInert(element) {
928
+ const wasInert = element.hasAttribute('inert');
929
+ element.setAttribute('inert', '');
930
+ element.setAttribute('aria-hidden', 'true');
931
+
932
+ return () => {
933
+ if (!wasInert) {
934
+ element.removeAttribute('inert');
935
+ }
936
+ element.removeAttribute('aria-hidden');
937
+ };
938
+ }
939
+
940
+ /**
941
+ * Create screen reader only text (visually hidden)
942
+ * @param {string} text - Text content
943
+ * @returns {HTMLElement} Span element
944
+ */
945
+ export function srOnly(text) {
946
+ const span = document.createElement('span');
947
+ span.textContent = text;
948
+ span.className = 'sr-only';
949
+ span.style.cssText = `
950
+ position: absolute;
951
+ width: 1px;
952
+ height: 1px;
953
+ padding: 0;
954
+ margin: -1px;
955
+ overflow: hidden;
956
+ clip: rect(0, 0, 0, 0);
957
+ white-space: nowrap;
958
+ border: 0;
959
+ `;
960
+ return span;
961
+ }
962
+
963
+ // Default export for convenience
964
+ export default {
965
+ // Announcements
966
+ announce,
967
+ announcePolite,
968
+ announceAssertive,
969
+ createLiveAnnouncer,
970
+
971
+ // Focus
972
+ trapFocus,
973
+ focusFirst,
974
+ focusLast,
975
+ saveFocus,
976
+ restoreFocus,
977
+ getFocusableElements,
978
+
979
+ // Skip links
980
+ createSkipLink,
981
+ installSkipLinks,
982
+
983
+ // Preferences
984
+ prefersReducedMotion,
985
+ prefersColorScheme,
986
+ prefersHighContrast,
987
+ createPreferences,
988
+
989
+ // ARIA helpers
990
+ setAriaAttributes,
991
+ createDisclosure,
992
+ createTabs,
993
+ createRovingTabindex,
994
+
995
+ // Validation
996
+ validateA11y,
997
+ logA11yIssues,
998
+ highlightA11yIssues,
999
+
1000
+ // Utilities
1001
+ generateId,
1002
+ isAccessiblyHidden,
1003
+ makeInert,
1004
+ srOnly
1005
+ };