pulse-js-framework 1.10.0 → 1.10.3

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.
Files changed (37) hide show
  1. package/compiler/parser/_extract.js +393 -0
  2. package/compiler/parser/blocks.js +361 -0
  3. package/compiler/parser/core.js +306 -0
  4. package/compiler/parser/expressions.js +386 -0
  5. package/compiler/parser/imports.js +108 -0
  6. package/compiler/parser/index.js +47 -0
  7. package/compiler/parser/state.js +155 -0
  8. package/compiler/parser/style.js +445 -0
  9. package/compiler/parser/view.js +632 -0
  10. package/compiler/parser.js +15 -2372
  11. package/compiler/parser.js.original +2376 -0
  12. package/package.json +2 -1
  13. package/runtime/a11y/announcements.js +213 -0
  14. package/runtime/a11y/contrast.js +125 -0
  15. package/runtime/a11y/focus.js +412 -0
  16. package/runtime/a11y/index.js +35 -0
  17. package/runtime/a11y/preferences.js +121 -0
  18. package/runtime/a11y/utils.js +164 -0
  19. package/runtime/a11y/validation.js +258 -0
  20. package/runtime/a11y/widgets.js +545 -0
  21. package/runtime/a11y.js +15 -1840
  22. package/runtime/a11y.js.original +1844 -0
  23. package/runtime/graphql/cache.js +69 -0
  24. package/runtime/graphql/client.js +563 -0
  25. package/runtime/graphql/hooks.js +492 -0
  26. package/runtime/graphql/index.js +62 -0
  27. package/runtime/graphql/subscriptions.js +241 -0
  28. package/runtime/graphql.js +12 -1322
  29. package/runtime/graphql.js.original +1326 -0
  30. package/runtime/router/core.js +956 -0
  31. package/runtime/router/guards.js +90 -0
  32. package/runtime/router/history.js +204 -0
  33. package/runtime/router/index.js +36 -0
  34. package/runtime/router/lazy.js +180 -0
  35. package/runtime/router/utils.js +226 -0
  36. package/runtime/router.js +12 -1600
  37. package/runtime/router.js.original +1605 -0
package/runtime/a11y.js CHANGED
@@ -1,1844 +1,19 @@
1
1
  /**
2
- * Pulse A11y - Accessibility Utilities
3
- * Zero-dependency accessibility helpers for inclusive web applications
2
+ * Pulse A11y - Backward Compatibility Export
4
3
  *
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
- * Add escape key handler for dismissing modals/dialogs
316
- * @param {HTMLElement} container - Container element
317
- * @param {Function} onEscape - Callback when escape is pressed
318
- * @param {object} options - Options
319
- * @param {boolean} options.stopPropagation - Stop event propagation (default: true)
320
- * @returns {Function} Cleanup function to remove handler
321
- */
322
- export function onEscapeKey(container, onEscape, options = {}) {
323
- const { stopPropagation = true } = options;
324
-
325
- if (!container) return () => {};
326
-
327
- const handleKeyDown = (e) => {
328
- if (e.key === 'Escape' || e.key === 'Esc') {
329
- if (stopPropagation) {
330
- e.stopPropagation();
331
- }
332
- onEscape(e);
333
- }
334
- };
335
-
336
- container.addEventListener('keydown', handleKeyDown);
337
-
338
- return () => {
339
- container.removeEventListener('keydown', handleKeyDown);
340
- };
341
- }
342
-
343
- /**
344
- * Track whether the user is navigating with keyboard
345
- * Useful for implementing :focus-visible behavior
346
- * @returns {{ isKeyboardUser: object, cleanup: Function }} isKeyboardUser is a pulse
347
- */
348
- export function createFocusVisibleTracker() {
349
- const isKeyboardUser = pulse(false);
350
-
351
- if (typeof document === 'undefined') {
352
- return { isKeyboardUser, cleanup: () => {} };
353
- }
354
-
355
- const handleKeyDown = (e) => {
356
- if (e.key === 'Tab' || e.key === 'ArrowUp' || e.key === 'ArrowDown' ||
357
- e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
358
- isKeyboardUser.set(true);
359
- }
360
- };
361
-
362
- const handleMouseDown = () => {
363
- isKeyboardUser.set(false);
364
- };
365
-
366
- document.addEventListener('keydown', handleKeyDown, true);
367
- document.addEventListener('mousedown', handleMouseDown, true);
368
-
369
- return {
370
- isKeyboardUser,
371
- cleanup: () => {
372
- document.removeEventListener('keydown', handleKeyDown, true);
373
- document.removeEventListener('mousedown', handleMouseDown, true);
374
- }
375
- };
376
- }
377
-
378
- // =============================================================================
379
- // SKIP LINKS
380
- // =============================================================================
381
-
382
- /**
383
- * Create a skip link for keyboard navigation
384
- * @param {string} targetId - ID of target element to skip to
385
- * @param {string} text - Link text (default: 'Skip to main content')
386
- * @param {object} options - Options
387
- * @returns {HTMLElement} The skip link element
388
- */
389
- export function createSkipLink(targetId, text = 'Skip to main content', options = {}) {
390
- const { className = 'pulse-skip-link' } = options;
391
-
392
- const link = document.createElement('a');
393
- link.href = `#${targetId}`;
394
- link.textContent = text;
395
- link.className = className;
396
-
397
- // Visually hidden but focusable styles
398
- Object.assign(link.style, {
399
- position: 'absolute',
400
- top: '-40px',
401
- left: '0',
402
- padding: '8px 16px',
403
- background: '#000',
404
- color: '#fff',
405
- textDecoration: 'none',
406
- zIndex: '10000',
407
- transition: 'top 0.2s'
408
- });
409
-
410
- // Show on focus
411
- link.addEventListener('focus', () => {
412
- link.style.top = '0';
413
- });
414
-
415
- link.addEventListener('blur', () => {
416
- link.style.top = '-40px';
417
- });
418
-
419
- // Handle click
420
- link.addEventListener('click', (e) => {
421
- e.preventDefault();
422
- const target = document.getElementById(targetId);
423
- if (target) {
424
- target.setAttribute('tabindex', '-1');
425
- target.focus();
426
- target.removeAttribute('tabindex');
427
- }
428
- });
429
-
430
- return link;
431
- }
432
-
433
- /**
434
- * Install skip links at the beginning of the document
435
- * @param {Array<{target: string, text: string}>} links - Skip link definitions
436
- */
437
- export function installSkipLinks(links) {
438
- const container = document.createElement('nav');
439
- container.setAttribute('aria-label', 'Skip links');
440
- container.className = 'pulse-skip-links';
441
-
442
- links.forEach(({ target, text }) => {
443
- container.appendChild(createSkipLink(target, text));
444
- });
445
-
446
- document.body.insertBefore(container, document.body.firstChild);
447
- return container;
448
- }
449
-
450
- // =============================================================================
451
- // USER PREFERENCES
452
- // =============================================================================
453
-
454
- /**
455
- * Check if user prefers reduced motion
456
- * @returns {boolean}
457
- */
458
- export function prefersReducedMotion() {
459
- if (typeof window === 'undefined') return false;
460
- return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
461
- }
462
-
463
- /**
464
- * Check user's preferred color scheme
465
- * @returns {'light'|'dark'|'no-preference'}
466
- */
467
- export function prefersColorScheme() {
468
- if (typeof window === 'undefined') return 'no-preference';
469
- if (window.matchMedia('(prefers-color-scheme: dark)').matches) return 'dark';
470
- if (window.matchMedia('(prefers-color-scheme: light)').matches) return 'light';
471
- return 'no-preference';
472
- }
473
-
474
- /**
475
- * Check if user prefers high contrast
476
- * @returns {boolean}
477
- */
478
- export function prefersHighContrast() {
479
- if (typeof window === 'undefined') return false;
480
- return window.matchMedia('(prefers-contrast: more)').matches;
481
- }
482
-
483
- /**
484
- * Check if user prefers reduced transparency
485
- * @returns {boolean}
486
- */
487
- export function prefersReducedTransparency() {
488
- if (typeof window === 'undefined') return false;
489
- return window.matchMedia('(prefers-reduced-transparency: reduce)').matches;
490
- }
491
-
492
- /**
493
- * Check if forced-colors mode is active (Windows High Contrast)
494
- * @returns {'none'|'active'}
495
- */
496
- export function forcedColorsMode() {
497
- if (typeof window === 'undefined') return 'none';
498
- if (window.matchMedia('(forced-colors: active)').matches) return 'active';
499
- return 'none';
500
- }
501
-
502
- /**
503
- * Check user's contrast preference (more detailed than prefersHighContrast)
504
- * @returns {'no-preference'|'more'|'less'|'custom'}
505
- */
506
- export function prefersContrast() {
507
- if (typeof window === 'undefined') return 'no-preference';
508
- if (window.matchMedia('(prefers-contrast: more)').matches) return 'more';
509
- if (window.matchMedia('(prefers-contrast: less)').matches) return 'less';
510
- if (window.matchMedia('(prefers-contrast: custom)').matches) return 'custom';
511
- return 'no-preference';
512
- }
513
-
514
- /**
515
- * Create reactive user preferences pulse
516
- * @returns {object} Object with reactive preference pulses
517
- */
518
- export function createPreferences() {
519
- const reducedMotion = pulse(prefersReducedMotion());
520
- const colorScheme = pulse(prefersColorScheme());
521
- const highContrast = pulse(prefersHighContrast());
522
- const reducedTransparency = pulse(prefersReducedTransparency());
523
- const forcedColors = pulse(forcedColorsMode());
524
- const contrast = pulse(prefersContrast());
525
-
526
- const listeners = [];
527
-
528
- if (typeof window !== 'undefined') {
529
- const track = (query, handler) => {
530
- const mql = window.matchMedia(query);
531
- mql.addEventListener('change', handler);
532
- listeners.push({ mql, handler });
533
- };
534
-
535
- track('(prefers-reduced-motion: reduce)', (e) => reducedMotion.set(e.matches));
536
- track('(prefers-color-scheme: dark)', (e) => colorScheme.set(e.matches ? 'dark' : 'light'));
537
- track('(prefers-contrast: more)', (e) => highContrast.set(e.matches));
538
- track('(prefers-reduced-transparency: reduce)', (e) => reducedTransparency.set(e.matches));
539
- track('(forced-colors: active)', (e) => forcedColors.set(e.matches ? 'active' : 'none'));
540
- track('(prefers-contrast: more)', () => contrast.set(prefersContrast()));
541
- track('(prefers-contrast: less)', () => contrast.set(prefersContrast()));
542
- }
543
-
544
- const cleanup = () => {
545
- for (const { mql, handler } of listeners) {
546
- mql.removeEventListener('change', handler);
547
- }
548
- listeners.length = 0;
549
- };
550
-
551
- return {
552
- reducedMotion,
553
- colorScheme,
554
- highContrast,
555
- reducedTransparency,
556
- forcedColors,
557
- contrast,
558
- cleanup
559
- };
560
- }
561
-
562
- // =============================================================================
563
- // ARIA HELPERS
564
- // =============================================================================
565
-
566
- /**
567
- * Set multiple ARIA attributes on an element
568
- * @param {HTMLElement} element - Target element
569
- * @param {object} attrs - ARIA attributes (without 'aria-' prefix)
570
- */
571
- export function setAriaAttributes(element, attrs) {
572
- Object.entries(attrs).forEach(([key, value]) => {
573
- if (value === null || value === undefined) {
574
- element.removeAttribute(`aria-${key}`);
575
- } else {
576
- element.setAttribute(`aria-${key}`, String(value));
577
- }
578
- });
579
- }
580
-
581
- /**
582
- * Create an ARIA-compliant disclosure widget
583
- * @param {HTMLElement} trigger - Button that toggles disclosure
584
- * @param {HTMLElement} content - Content to show/hide
585
- * @param {object} options - Options
586
- * @returns {object} Control object with toggle, open, close methods
587
- */
588
- export function createDisclosure(trigger, content, options = {}) {
589
- const { defaultOpen = false, onToggle = null } = options;
590
-
591
- const expanded = pulse(defaultOpen);
592
- const id = content.id || `pulse-disclosure-${Date.now()}`;
593
-
594
- content.id = id;
595
- trigger.setAttribute('aria-controls', id);
596
- trigger.setAttribute('aria-expanded', String(expanded.get()));
597
-
598
- // Update visibility
599
- effect(() => {
600
- const isOpen = expanded.get();
601
- trigger.setAttribute('aria-expanded', String(isOpen));
602
- content.hidden = !isOpen;
603
- if (onToggle) onToggle(isOpen);
604
- });
605
-
606
- // Handle click
607
- trigger.addEventListener('click', () => {
608
- expanded.update(v => !v);
609
- });
610
-
611
- // Handle keyboard
612
- trigger.addEventListener('keydown', (e) => {
613
- if (e.key === 'Enter' || e.key === ' ') {
614
- e.preventDefault();
615
- expanded.update(v => !v);
616
- }
617
- });
618
-
619
- return {
620
- expanded,
621
- toggle: () => expanded.update(v => !v),
622
- open: () => expanded.set(true),
623
- close: () => expanded.set(false)
624
- };
625
- }
626
-
627
- /**
628
- * Create ARIA-compliant tabs
629
- * @param {HTMLElement} tablist - Container with role="tablist"
630
- * @param {object} options - Options
631
- * @returns {object} Control object
632
- */
633
- export function createTabs(tablist, options = {}) {
634
- const { defaultIndex = 0, orientation = 'horizontal', onSelect = null } = options;
635
-
636
- const tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
637
- const panels = tabs.map(tab => {
638
- const panelId = tab.getAttribute('aria-controls');
639
- return document.getElementById(panelId);
640
- });
641
-
642
- const selectedIndex = pulse(defaultIndex);
643
-
644
- tablist.setAttribute('aria-orientation', orientation);
645
-
646
- // Update selection
647
- effect(() => {
648
- const index = selectedIndex.get();
649
-
650
- tabs.forEach((tab, i) => {
651
- const isSelected = i === index;
652
- tab.setAttribute('aria-selected', String(isSelected));
653
- tab.setAttribute('tabindex', isSelected ? '0' : '-1');
654
- });
655
-
656
- panels.forEach((panel, i) => {
657
- if (panel) {
658
- panel.hidden = i !== index;
659
- }
660
- });
661
-
662
- if (onSelect) onSelect(index);
663
- });
664
-
665
- // Handle click
666
- tabs.forEach((tab, i) => {
667
- tab.addEventListener('click', () => {
668
- selectedIndex.set(i);
669
- tab.focus();
670
- });
671
- });
672
-
673
- // Handle keyboard navigation
674
- tablist.addEventListener('keydown', (e) => {
675
- const currentIndex = selectedIndex.get();
676
- let newIndex = currentIndex;
677
-
678
- const isHorizontal = orientation === 'horizontal';
679
- const prevKey = isHorizontal ? 'ArrowLeft' : 'ArrowUp';
680
- const nextKey = isHorizontal ? 'ArrowRight' : 'ArrowDown';
681
-
682
- switch (e.key) {
683
- case prevKey:
684
- newIndex = currentIndex > 0 ? currentIndex - 1 : tabs.length - 1;
685
- break;
686
- case nextKey:
687
- newIndex = currentIndex < tabs.length - 1 ? currentIndex + 1 : 0;
688
- break;
689
- case 'Home':
690
- newIndex = 0;
691
- break;
692
- case 'End':
693
- newIndex = tabs.length - 1;
694
- break;
695
- default:
696
- return;
697
- }
698
-
699
- e.preventDefault();
700
- selectedIndex.set(newIndex);
701
- tabs[newIndex].focus();
702
- });
703
-
704
- return {
705
- selectedIndex,
706
- select: (index) => selectedIndex.set(index),
707
- tabs,
708
- panels
709
- };
710
- }
711
-
712
- // =============================================================================
713
- // KEYBOARD NAVIGATION
714
- // =============================================================================
715
-
716
- /**
717
- * Handle arrow key navigation within a container (roving tabindex)
718
- * @param {HTMLElement} container - Container element
719
- * @param {object} options - Options
720
- * @returns {Function} Cleanup function
721
- */
722
- export function createRovingTabindex(container, options = {}) {
723
- const {
724
- selector = '[role="option"], [role="menuitem"], [role="treeitem"]',
725
- orientation = 'vertical',
726
- loop = true,
727
- onSelect = null
728
- } = options;
729
-
730
- const getItems = () => Array.from(container.querySelectorAll(selector))
731
- .filter(el => !el.hasAttribute('disabled') && el.getAttribute('aria-disabled') !== 'true');
732
-
733
- const items = getItems();
734
- if (items.length === 0) return () => {};
735
-
736
- // Initialize tabindex
737
- items.forEach((item, i) => {
738
- item.setAttribute('tabindex', i === 0 ? '0' : '-1');
739
- });
740
-
741
- const handleKeyDown = (e) => {
742
- const items = getItems();
743
- const currentIndex = items.findIndex(item => item === document.activeElement);
744
- if (currentIndex === -1) return;
745
-
746
- const isVertical = orientation === 'vertical';
747
- const prevKey = isVertical ? 'ArrowUp' : 'ArrowLeft';
748
- const nextKey = isVertical ? 'ArrowDown' : 'ArrowRight';
749
-
750
- let newIndex = currentIndex;
751
-
752
- switch (e.key) {
753
- case prevKey:
754
- if (loop) {
755
- newIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
756
- } else {
757
- newIndex = Math.max(0, currentIndex - 1);
758
- }
759
- break;
760
- case nextKey:
761
- if (loop) {
762
- newIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
763
- } else {
764
- newIndex = Math.min(items.length - 1, currentIndex + 1);
765
- }
766
- break;
767
- case 'Home':
768
- newIndex = 0;
769
- break;
770
- case 'End':
771
- newIndex = items.length - 1;
772
- break;
773
- case 'Enter':
774
- case ' ':
775
- if (onSelect) {
776
- e.preventDefault();
777
- onSelect(items[currentIndex], currentIndex);
778
- }
779
- return;
780
- default:
781
- return;
782
- }
783
-
784
- e.preventDefault();
785
-
786
- // Update tabindex
787
- items.forEach((item, i) => {
788
- item.setAttribute('tabindex', i === newIndex ? '0' : '-1');
789
- });
790
-
791
- items[newIndex].focus();
792
- };
793
-
794
- container.addEventListener('keydown', handleKeyDown);
795
-
796
- return () => {
797
- container.removeEventListener('keydown', handleKeyDown);
798
- };
799
- }
800
-
801
- // =============================================================================
802
- // ARIA WIDGETS
803
- // =============================================================================
804
-
805
- /**
806
- * Create an accessible modal dialog
807
- * Composes trapFocus, onEscapeKey, and proper ARIA attributes
808
- * @param {HTMLElement} dialog - Dialog element
809
- * @param {object} options - Options
810
- * @param {HTMLElement} options.triggerElement - Element that triggered the dialog
811
- * @param {string} options.labelledBy - ID of element labeling the dialog
812
- * @param {string} options.describedBy - ID of element describing the dialog
813
- * @param {HTMLElement} options.initialFocus - Element to focus initially
814
- * @param {Function} options.onClose - Callback when dialog should close
815
- * @param {boolean} options.closeOnBackdropClick - Close on backdrop click (default: true)
816
- * @param {boolean} options.inertBackground - Make background inert (default: true)
817
- * @returns {object} Control object with open, close methods and isOpen pulse
818
- */
819
- export function createModal(dialog, options = {}) {
820
- const {
821
- labelledBy = null,
822
- describedBy = null,
823
- initialFocus = null,
824
- onClose = null,
825
- closeOnBackdropClick = true,
826
- inertBackground = true
827
- } = options;
828
-
829
- const isOpen = pulse(false);
830
- let releaseFocusTrap = null;
831
- let removeEscapeHandler = null;
832
- let restoreInertFns = null;
833
- let backdropHandler = null;
834
-
835
- // Set ARIA attributes
836
- dialog.setAttribute('role', 'dialog');
837
- dialog.setAttribute('aria-modal', 'true');
838
- if (labelledBy) dialog.setAttribute('aria-labelledby', labelledBy);
839
- if (describedBy) dialog.setAttribute('aria-describedby', describedBy);
840
-
841
- const open = () => {
842
- if (isOpen.get()) return;
843
-
844
- dialog.hidden = false;
845
- isOpen.set(true);
846
-
847
- // Make background inert
848
- if (inertBackground && typeof document !== 'undefined') {
849
- const siblings = Array.from(document.body.children)
850
- .filter(el => el !== dialog && !el.hasAttribute('inert'));
851
- restoreInertFns = siblings.map(el => makeInert(el));
852
- }
853
-
854
- // Trap focus
855
- releaseFocusTrap = trapFocus(dialog, {
856
- autoFocus: true,
857
- returnFocus: true,
858
- initialFocus
859
- });
860
-
861
- // Handle escape key
862
- removeEscapeHandler = onEscapeKey(dialog, close);
863
-
864
- // Handle backdrop click
865
- if (closeOnBackdropClick) {
866
- backdropHandler = (e) => {
867
- if (e.target === dialog) close();
868
- };
869
- dialog.addEventListener('click', backdropHandler);
870
- }
871
-
872
- // Announce to screen readers
873
- announce('Dialog opened');
874
- };
875
-
876
- const close = () => {
877
- if (!isOpen.get()) return;
878
-
879
- dialog.hidden = true;
880
- isOpen.set(false);
881
-
882
- // Clean up
883
- if (releaseFocusTrap) {
884
- releaseFocusTrap();
885
- releaseFocusTrap = null;
886
- }
887
- if (removeEscapeHandler) {
888
- removeEscapeHandler();
889
- removeEscapeHandler = null;
890
- }
891
- if (restoreInertFns) {
892
- restoreInertFns.forEach(restore => restore());
893
- restoreInertFns = null;
894
- }
895
- if (backdropHandler) {
896
- dialog.removeEventListener('click', backdropHandler);
897
- backdropHandler = null;
898
- }
899
-
900
- if (onClose) onClose();
901
- announce('Dialog closed');
902
- };
903
-
904
- return { isOpen, open, close };
905
- }
906
-
907
- /**
908
- * Create an accessible tooltip
909
- * Manages aria-describedby and visibility
910
- * @param {HTMLElement} trigger - Element that triggers tooltip
911
- * @param {HTMLElement} tooltip - Tooltip element
912
- * @param {object} options - Options
913
- * @param {number} options.showDelay - Delay before showing (ms, default: 500)
914
- * @param {number} options.hideDelay - Delay before hiding (ms, default: 100)
915
- * @returns {object} Control object with show, hide methods and isVisible pulse
916
- */
917
- export function createTooltip(trigger, tooltip, options = {}) {
918
- const {
919
- showDelay = 500,
920
- hideDelay = 100
921
- } = options;
922
-
923
- const isVisible = pulse(false);
924
- let showTimer = null;
925
- let hideTimer = null;
926
-
927
- // Generate ID if needed
928
- const tooltipId = tooltip.id || generateId('tooltip');
929
- tooltip.id = tooltipId;
930
-
931
- // Set ARIA attributes
932
- tooltip.setAttribute('role', 'tooltip');
933
- trigger.setAttribute('aria-describedby', tooltipId);
934
- tooltip.hidden = true;
935
-
936
- const show = () => {
937
- clearTimeout(hideTimer);
938
- showTimer = setTimeout(() => {
939
- tooltip.hidden = false;
940
- isVisible.set(true);
941
- }, showDelay);
942
- };
943
-
944
- const hide = () => {
945
- clearTimeout(showTimer);
946
- hideTimer = setTimeout(() => {
947
- tooltip.hidden = true;
948
- isVisible.set(false);
949
- }, hideDelay);
950
- };
951
-
952
- const showImmediate = () => {
953
- clearTimeout(hideTimer);
954
- clearTimeout(showTimer);
955
- tooltip.hidden = false;
956
- isVisible.set(true);
957
- };
958
-
959
- const hideImmediate = () => {
960
- clearTimeout(hideTimer);
961
- clearTimeout(showTimer);
962
- tooltip.hidden = true;
963
- isVisible.set(false);
964
- };
965
-
966
- const handleEscapeKey = (e) => {
967
- if (e.key === 'Escape') hideImmediate();
968
- };
969
-
970
- // Event listeners
971
- trigger.addEventListener('mouseenter', show);
972
- trigger.addEventListener('mouseleave', hide);
973
- trigger.addEventListener('focus', showImmediate);
974
- trigger.addEventListener('blur', hideImmediate);
975
- trigger.addEventListener('keydown', handleEscapeKey);
976
-
977
- const cleanup = () => {
978
- clearTimeout(showTimer);
979
- clearTimeout(hideTimer);
980
- trigger.removeEventListener('mouseenter', show);
981
- trigger.removeEventListener('mouseleave', hide);
982
- trigger.removeEventListener('focus', showImmediate);
983
- trigger.removeEventListener('blur', hideImmediate);
984
- trigger.removeEventListener('keydown', handleEscapeKey);
985
- trigger.removeAttribute('aria-describedby');
986
- };
987
-
988
- return { isVisible, show: showImmediate, hide: hideImmediate, cleanup };
989
- }
990
-
991
- /**
992
- * Create an accessible accordion (composed of disclosures)
993
- * @param {HTMLElement} container - Accordion container
994
- * @param {object} options - Options
995
- * @param {string} options.triggerSelector - Selector for accordion triggers
996
- * @param {string} options.panelSelector - Selector for accordion panels
997
- * @param {boolean} options.allowMultiple - Allow multiple panels open (default: false)
998
- * @param {number} options.defaultOpen - Index of initially open panel (-1 for none)
999
- * @param {Function} options.onToggle - Callback (index, isOpen) => void
1000
- * @returns {object} Control object
1001
- */
1002
- export function createAccordion(container, options = {}) {
1003
- const {
1004
- triggerSelector = '[data-accordion-trigger]',
1005
- panelSelector = '[data-accordion-panel]',
1006
- allowMultiple = false,
1007
- defaultOpen = -1,
1008
- onToggle = null
1009
- } = options;
1010
-
1011
- const triggers = Array.from(container.querySelectorAll(triggerSelector));
1012
- const panels = Array.from(container.querySelectorAll(panelSelector));
1013
- const disclosures = [];
1014
- const openIndices = pulse(defaultOpen >= 0 ? [defaultOpen] : []);
1015
-
1016
- triggers.forEach((trigger, index) => {
1017
- const panel = panels[index];
1018
- if (!panel) return;
1019
-
1020
- const disclosure = createDisclosure(trigger, panel, {
1021
- defaultOpen: index === defaultOpen,
1022
- onToggle: (isExpanded) => {
1023
- if (isExpanded) {
1024
- if (allowMultiple) {
1025
- openIndices.update(arr => arr.includes(index) ? arr : [...arr, index]);
1026
- } else {
1027
- // Close other panels
1028
- disclosures.forEach((d, i) => {
1029
- if (i !== index && d.expanded.get()) d.close();
1030
- });
1031
- openIndices.set([index]);
1032
- }
1033
- } else {
1034
- openIndices.update(arr => arr.filter(i => i !== index));
1035
- }
1036
- if (onToggle) onToggle(index, isExpanded);
1037
- }
1038
- });
1039
-
1040
- disclosures.push(disclosure);
1041
- });
1042
-
1043
- return {
1044
- openIndices,
1045
- disclosures,
1046
- openAll: () => {
1047
- if (allowMultiple) {
1048
- disclosures.forEach(d => d.open());
1049
- }
1050
- },
1051
- closeAll: () => {
1052
- disclosures.forEach(d => d.close());
1053
- },
1054
- open: (index) => {
1055
- if (disclosures[index]) disclosures[index].open();
1056
- },
1057
- close: (index) => {
1058
- if (disclosures[index]) disclosures[index].close();
1059
- },
1060
- toggle: (index) => {
1061
- if (disclosures[index]) disclosures[index].toggle();
1062
- }
1063
- };
1064
- }
1065
-
1066
- /**
1067
- * Create an accessible dropdown menu
1068
- * @param {HTMLElement} button - Menu button
1069
- * @param {HTMLElement} menu - Menu container
1070
- * @param {object} options - Options
1071
- * @param {string} options.itemSelector - Selector for menu items (default: '[role="menuitem"]')
1072
- * @param {Function} options.onSelect - Callback when item is selected
1073
- * @param {boolean} options.closeOnSelect - Close menu on item selection (default: true)
1074
- * @returns {object} Control object with open, close, toggle methods and isOpen pulse
1075
- */
1076
- export function createMenu(button, menu, options = {}) {
1077
- const {
1078
- itemSelector = '[role="menuitem"]',
1079
- onSelect = null,
1080
- closeOnSelect = true
1081
- } = options;
1082
-
1083
- const isOpen = pulse(false);
1084
- const menuId = menu.id || generateId('menu');
1085
- let rovingCleanup = null;
1086
- let documentClickHandler = null;
1087
-
1088
- // Set ARIA attributes
1089
- menu.id = menuId;
1090
- menu.setAttribute('role', 'menu');
1091
- button.setAttribute('aria-haspopup', 'menu');
1092
- button.setAttribute('aria-controls', menuId);
1093
- button.setAttribute('aria-expanded', 'false');
1094
- menu.hidden = true;
1095
-
1096
- const open = () => {
1097
- if (isOpen.get()) return;
1098
-
1099
- menu.hidden = false;
1100
- button.setAttribute('aria-expanded', 'true');
1101
- isOpen.set(true);
1102
-
1103
- // Setup roving tabindex for menu items
1104
- rovingCleanup = createRovingTabindex(menu, {
1105
- selector: itemSelector,
1106
- orientation: 'vertical',
1107
- onSelect: (el, index) => {
1108
- if (onSelect) onSelect(el, index);
1109
- if (closeOnSelect) close();
1110
- }
1111
- });
1112
-
1113
- // Focus first item
1114
- const firstItem = menu.querySelector(itemSelector);
1115
- if (firstItem) firstItem.focus();
1116
-
1117
- // Close on click outside (delay to avoid immediate close)
1118
- setTimeout(() => {
1119
- documentClickHandler = (e) => {
1120
- if (!button.contains(e.target) && !menu.contains(e.target)) {
1121
- close();
1122
- }
1123
- };
1124
- document.addEventListener('click', documentClickHandler);
1125
- }, 0);
1126
- };
1127
-
1128
- const close = () => {
1129
- if (!isOpen.get()) return;
1130
-
1131
- menu.hidden = true;
1132
- button.setAttribute('aria-expanded', 'false');
1133
- isOpen.set(false);
1134
-
1135
- if (rovingCleanup) {
1136
- rovingCleanup();
1137
- rovingCleanup = null;
1138
- }
1139
-
1140
- if (documentClickHandler) {
1141
- document.removeEventListener('click', documentClickHandler);
1142
- documentClickHandler = null;
1143
- }
1144
-
1145
- button.focus();
1146
- };
1147
-
1148
- const toggle = () => isOpen.get() ? close() : open();
1149
-
1150
- // Button click
1151
- button.addEventListener('click', toggle);
1152
-
1153
- // Keyboard navigation on button
1154
- const handleButtonKeyDown = (e) => {
1155
- if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
1156
- e.preventDefault();
1157
- open();
1158
- }
1159
- };
1160
- button.addEventListener('keydown', handleButtonKeyDown);
1161
-
1162
- // Close on escape
1163
- const handleMenuKeyDown = (e) => {
1164
- if (e.key === 'Escape') {
1165
- e.stopPropagation();
1166
- close();
1167
- }
1168
- };
1169
- menu.addEventListener('keydown', handleMenuKeyDown);
1170
-
1171
- const cleanup = () => {
1172
- button.removeEventListener('click', toggle);
1173
- button.removeEventListener('keydown', handleButtonKeyDown);
1174
- menu.removeEventListener('keydown', handleMenuKeyDown);
1175
- if (documentClickHandler) {
1176
- document.removeEventListener('click', documentClickHandler);
1177
- }
1178
- if (rovingCleanup) {
1179
- rovingCleanup();
1180
- }
1181
- };
1182
-
1183
- return { isOpen, open, close, toggle, cleanup };
1184
- }
1185
-
1186
- // =============================================================================
1187
- // VALIDATION & AUDITING
1188
- // =============================================================================
1189
-
1190
- /**
1191
- * A11y issues found during validation
1192
- * @typedef {object} A11yIssue
1193
- * @property {'error'|'warning'} severity - Issue severity
1194
- * @property {string} rule - Rule identifier
1195
- * @property {string} message - Human-readable message
1196
- * @property {HTMLElement} element - The element with the issue
1197
- */
1198
-
1199
- /**
1200
- * Validate accessibility of a container
1201
- * @param {HTMLElement} container - Container to validate (default: document.body)
1202
- * @returns {A11yIssue[]} Array of issues found
1203
- */
1204
- export function validateA11y(container = document.body) {
1205
- const issues = [];
1206
-
1207
- const addIssue = (severity, rule, message, element) => {
1208
- issues.push({ severity, rule, message, element });
1209
- };
1210
-
1211
- // Check images for alt text
1212
- container.querySelectorAll('img').forEach(img => {
1213
- if (!img.hasAttribute('alt')) {
1214
- addIssue('error', 'img-alt', 'Image missing alt attribute', img);
1215
- } else if (img.alt === '') {
1216
- // Empty alt is OK for decorative images, but warn
1217
- if (!img.getAttribute('role')?.includes('presentation')) {
1218
- addIssue('warning', 'img-alt-empty', 'Image has empty alt - ensure it is decorative', img);
1219
- }
1220
- }
1221
- });
1222
-
1223
- // Check buttons for accessible names
1224
- container.querySelectorAll('button').forEach(button => {
1225
- const hasText = button.textContent.trim().length > 0;
1226
- const hasAriaLabel = button.hasAttribute('aria-label');
1227
- const hasAriaLabelledBy = button.hasAttribute('aria-labelledby');
1228
- const hasTitle = button.hasAttribute('title');
1229
-
1230
- if (!hasText && !hasAriaLabel && !hasAriaLabelledBy && !hasTitle) {
1231
- addIssue('error', 'button-name', 'Button has no accessible name', button);
1232
- }
1233
- });
1234
-
1235
- // Check links for accessible names
1236
- container.querySelectorAll('a[href]').forEach(link => {
1237
- const hasText = link.textContent.trim().length > 0;
1238
- const hasAriaLabel = link.hasAttribute('aria-label');
1239
- const hasImg = link.querySelector('img[alt]');
1240
-
1241
- if (!hasText && !hasAriaLabel && !hasImg) {
1242
- addIssue('error', 'link-name', 'Link has no accessible name', link);
1243
- }
1244
- });
1245
-
1246
- // Check form inputs for labels
1247
- container.querySelectorAll('input, select, textarea').forEach(input => {
1248
- if (input.type === 'hidden' || input.type === 'submit' || input.type === 'button') return;
1249
-
1250
- const id = input.id;
1251
- const hasLabel = id && container.querySelector(`label[for="${id}"]`);
1252
- const hasAriaLabel = input.hasAttribute('aria-label');
1253
- const hasAriaLabelledBy = input.hasAttribute('aria-labelledby');
1254
- const isWrappedByLabel = input.closest('label');
1255
- const hasPlaceholder = input.hasAttribute('placeholder');
1256
-
1257
- if (!hasLabel && !hasAriaLabel && !hasAriaLabelledBy && !isWrappedByLabel) {
1258
- const msg = hasPlaceholder
1259
- ? 'Form input uses placeholder but missing label (placeholder is not a label substitute)'
1260
- : 'Form input missing associated label';
1261
- addIssue('error', 'input-label', msg, input);
1262
- }
1263
- });
1264
-
1265
- // Check for positive tabindex (anti-pattern)
1266
- container.querySelectorAll('[tabindex]').forEach(el => {
1267
- const tabindex = parseInt(el.getAttribute('tabindex'), 10);
1268
- if (tabindex > 0) {
1269
- addIssue('warning', 'tabindex-positive', 'Avoid positive tabindex values - use DOM order instead', el);
1270
- }
1271
- });
1272
-
1273
- // Check for click handlers on non-interactive elements
1274
- container.querySelectorAll('div[onclick], span[onclick]').forEach(el => {
1275
- if (!el.hasAttribute('role') && !el.hasAttribute('tabindex')) {
1276
- addIssue('warning', 'click-non-interactive', 'Click handler on non-interactive element - consider using button', el);
1277
- }
1278
- });
1279
-
1280
- // Check headings hierarchy
1281
- const headings = container.querySelectorAll('h1, h2, h3, h4, h5, h6');
1282
- let lastLevel = 0;
1283
- headings.forEach(heading => {
1284
- const level = parseInt(heading.tagName[1], 10);
1285
- if (level > lastLevel + 1 && lastLevel !== 0) {
1286
- addIssue('warning', 'heading-order', `Heading level skipped (h${lastLevel} to h${level})`, heading);
1287
- }
1288
- lastLevel = level;
1289
- });
1290
-
1291
- // Check for autoplay media
1292
- container.querySelectorAll('video[autoplay], audio[autoplay]').forEach(media => {
1293
- if (!media.hasAttribute('muted')) {
1294
- addIssue('warning', 'media-autoplay', 'Autoplaying media should be muted', media);
1295
- }
1296
- });
1297
-
1298
- // Check for duplicate IDs
1299
- const idMap = new Map();
1300
- container.querySelectorAll('[id]').forEach(el => {
1301
- const id = el.id;
1302
- if (id) {
1303
- if (idMap.has(id)) {
1304
- addIssue('error', 'duplicate-id', `Duplicate ID "${id}" found`, el);
1305
- } else {
1306
- idMap.set(id, el);
1307
- }
1308
- }
1309
- });
1310
-
1311
- // Check for landmark regions (main, nav, etc.)
1312
- if (typeof container.querySelector === 'function' && container === document.body) {
1313
- const hasMain = container.querySelector('main, [role="main"]');
1314
- if (!hasMain) {
1315
- addIssue('warning', 'missing-main', 'Page should have a <main> landmark', document.body);
1316
- }
1317
- }
1318
-
1319
- // Check for nested interactive elements
1320
- container.querySelectorAll('a, button').forEach(el => {
1321
- if (typeof el.querySelector === 'function') {
1322
- const nestedInteractive = el.querySelector('a, button, input, select, textarea');
1323
- if (nestedInteractive) {
1324
- addIssue('error', 'nested-interactive',
1325
- 'Interactive elements should not be nested inside other interactive elements', el);
1326
- }
1327
- }
1328
- });
1329
-
1330
- // Check for missing html lang attribute
1331
- if (container === document.body && typeof document !== 'undefined' && document.documentElement) {
1332
- const lang = document.documentElement.getAttribute?.('lang');
1333
- if (!lang) {
1334
- addIssue('warning', 'missing-lang',
1335
- 'Document should have a lang attribute on <html>', document.documentElement);
1336
- }
1337
- }
1338
-
1339
- // Check for touch target sizes (WCAG 2.2 - 24x24px minimum)
1340
- if (typeof getComputedStyle === 'function') {
1341
- container.querySelectorAll('a, button, input, select, [role="button"], [role="link"]').forEach(el => {
1342
- if (typeof el.getBoundingClientRect === 'function') {
1343
- const rect = el.getBoundingClientRect();
1344
- if (rect.width > 0 && rect.height > 0 && (rect.width < 24 || rect.height < 24)) {
1345
- // Only flag if element is visible
1346
- const style = getComputedStyle(el);
1347
- if (style.display !== 'none' && style.visibility !== 'hidden') {
1348
- addIssue('warning', 'touch-target-size',
1349
- `Touch target (${Math.round(rect.width)}x${Math.round(rect.height)}px) smaller than 24x24px minimum`,
1350
- el);
1351
- }
1352
- }
1353
- }
1354
- });
1355
- }
1356
-
1357
- return issues;
1358
- }
1359
-
1360
- /**
1361
- * Log validation results to console
1362
- * @param {A11yIssue[]} issues - Issues from validateA11y
1363
- */
1364
- export function logA11yIssues(issues) {
1365
- if (issues.length === 0) {
1366
- console.log('%c✓ No accessibility issues found', 'color: green; font-weight: bold');
1367
- return;
1368
- }
1369
-
1370
- const errors = issues.filter(i => i.severity === 'error');
1371
- const warnings = issues.filter(i => i.severity === 'warning');
1372
-
1373
- console.group(`%cAccessibility Issues (${errors.length} errors, ${warnings.length} warnings)`,
1374
- 'color: red; font-weight: bold');
1375
-
1376
- issues.forEach(issue => {
1377
- const icon = issue.severity === 'error' ? '❌' : '⚠️';
1378
- const color = issue.severity === 'error' ? 'color: red' : 'color: orange';
1379
- console.log(`%c${icon} [${issue.rule}] ${issue.message}`, color, issue.element);
1380
- });
1381
-
1382
- console.groupEnd();
1383
- }
1384
-
1385
- /**
1386
- * Highlight elements with accessibility issues in the DOM
1387
- * @param {A11yIssue[]} issues - Issues from validateA11y
1388
- * @returns {Function} Cleanup function to remove highlights
1389
- */
1390
- export function highlightA11yIssues(issues) {
1391
- const highlights = [];
1392
-
1393
- issues.forEach(issue => {
1394
- const el = issue.element;
1395
- const rect = el.getBoundingClientRect();
1396
-
1397
- const highlight = document.createElement('div');
1398
- highlight.className = 'pulse-a11y-highlight';
1399
- highlight.style.cssText = `
1400
- position: fixed;
1401
- top: ${rect.top}px;
1402
- left: ${rect.left}px;
1403
- width: ${rect.width}px;
1404
- height: ${rect.height}px;
1405
- border: 2px solid ${issue.severity === 'error' ? 'red' : 'orange'};
1406
- background: ${issue.severity === 'error' ? 'rgba(255,0,0,0.1)' : 'rgba(255,165,0,0.1)'};
1407
- pointer-events: none;
1408
- z-index: 99999;
1409
- `;
1410
-
1411
- const label = document.createElement('div');
1412
- label.style.cssText = `
1413
- position: absolute;
1414
- top: -20px;
1415
- left: 0;
1416
- background: ${issue.severity === 'error' ? 'red' : 'orange'};
1417
- color: white;
1418
- font-size: 10px;
1419
- padding: 2px 4px;
1420
- border-radius: 2px;
1421
- white-space: nowrap;
1422
- `;
1423
- label.textContent = issue.rule;
1424
- highlight.appendChild(label);
1425
-
1426
- document.body.appendChild(highlight);
1427
- highlights.push(highlight);
1428
- });
1429
-
1430
- return () => {
1431
- highlights.forEach(h => h.remove());
1432
- };
1433
- }
1434
-
1435
- // =============================================================================
1436
- // COLOR CONTRAST
1437
- // =============================================================================
1438
-
1439
- /**
1440
- * Parse a color string to RGB values using canvas
1441
- * @param {string} color - CSS color string
1442
- * @returns {{r: number, g: number, b: number}|null}
1443
- */
1444
- function parseColor(color) {
1445
- if (typeof document === 'undefined') return null;
1446
-
1447
- const canvas = document.createElement('canvas');
1448
- canvas.width = canvas.height = 1;
1449
- const ctx = canvas.getContext('2d');
1450
- if (!ctx) return null;
1451
-
1452
- ctx.fillStyle = color;
1453
- ctx.fillRect(0, 0, 1, 1);
1454
- const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
1455
- return { r, g, b };
1456
- }
1457
-
1458
- /**
1459
- * Calculate relative luminance of a color
1460
- * @param {{r: number, g: number, b: number}} color - RGB color
1461
- * @returns {number} Luminance between 0 and 1
1462
- */
1463
- function relativeLuminance({ r, g, b }) {
1464
- const [rs, gs, bs] = [r, g, b].map(c => {
1465
- c = c / 255;
1466
- return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
1467
- });
1468
- return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
1469
- }
1470
-
1471
- /**
1472
- * Calculate contrast ratio between two colors
1473
- * @param {string} foreground - Foreground color (any CSS color format)
1474
- * @param {string} background - Background color (any CSS color format)
1475
- * @returns {number} Contrast ratio (1 to 21)
1476
- */
1477
- export function getContrastRatio(foreground, background) {
1478
- const fg = parseColor(foreground);
1479
- const bg = parseColor(background);
1480
-
1481
- if (!fg || !bg) return 1;
1482
-
1483
- const l1 = relativeLuminance(fg);
1484
- const l2 = relativeLuminance(bg);
1485
-
1486
- const lighter = Math.max(l1, l2);
1487
- const darker = Math.min(l1, l2);
1488
-
1489
- return (lighter + 0.05) / (darker + 0.05);
1490
- }
1491
-
1492
- /**
1493
- * Check if contrast meets WCAG requirements
1494
- * @param {number} ratio - Contrast ratio
1495
- * @param {'AA'|'AAA'} level - WCAG level (default: 'AA')
1496
- * @param {'normal'|'large'} textSize - Text size category (default: 'normal')
1497
- * @returns {boolean}
1498
- */
1499
- export function meetsContrastRequirement(ratio, level = 'AA', textSize = 'normal') {
1500
- const requirements = {
1501
- AA: { normal: 4.5, large: 3 },
1502
- AAA: { normal: 7, large: 4.5 }
1503
- };
1504
- return ratio >= (requirements[level]?.[textSize] ?? 4.5);
1505
- }
1506
-
1507
- /**
1508
- * Get the effective background color of an element (handles transparency)
1509
- * @param {HTMLElement} element - Element to check
1510
- * @returns {string} Computed background color
1511
- */
1512
- export function getEffectiveBackgroundColor(element) {
1513
- if (!element || typeof getComputedStyle === 'undefined') return 'rgb(255, 255, 255)';
1514
-
1515
- let el = element;
1516
- while (el) {
1517
- const bg = getComputedStyle(el).backgroundColor;
1518
- // Check if background is not transparent
1519
- if (bg && bg !== 'transparent' && bg !== 'rgba(0, 0, 0, 0)') {
1520
- return bg;
1521
- }
1522
- el = el.parentElement;
1523
- }
1524
- return 'rgb(255, 255, 255)'; // Default to white
1525
- }
1526
-
1527
- /**
1528
- * Check color contrast of text in an element
1529
- * @param {HTMLElement} element - Element to check
1530
- * @param {'AA'|'AAA'} level - WCAG level
1531
- * @returns {{ ratio: number, passes: boolean, foreground: string, background: string }}
1532
- */
1533
- export function checkElementContrast(element, level = 'AA') {
1534
- if (!element || typeof getComputedStyle === 'undefined') {
1535
- return { ratio: 1, passes: false, foreground: '', background: '' };
1536
- }
1537
-
1538
- const style = getComputedStyle(element);
1539
- const foreground = style.color;
1540
- const background = getEffectiveBackgroundColor(element);
1541
- const ratio = getContrastRatio(foreground, background);
1542
-
1543
- // Determine if text is "large" (14pt bold or 18pt+)
1544
- const fontSize = parseFloat(style.fontSize);
1545
- const fontWeight = parseInt(style.fontWeight, 10) || 400;
1546
- const isLarge = fontSize >= 24 || (fontSize >= 18.66 && fontWeight >= 700);
1547
-
1548
- const passes = meetsContrastRequirement(ratio, level, isLarge ? 'large' : 'normal');
1549
-
1550
- return { ratio, passes, foreground, background };
1551
- }
1552
-
1553
- // =============================================================================
1554
- // ANNOUNCEMENT QUEUE
1555
- // =============================================================================
1556
-
1557
- /**
1558
- * Create an announcement queue that handles multiple messages in sequence
1559
- * @param {object} options - Options
1560
- * @param {number} options.minDelay - Minimum delay between announcements (ms, default: 500)
1561
- * @returns {object} Queue control object
1562
- */
1563
- export function createAnnouncementQueue(options = {}) {
1564
- const { minDelay = 500 } = options;
1565
-
1566
- const queue = [];
1567
- let isProcessing = false;
1568
- let currentTimerId = null;
1569
- let aborted = false;
1570
- const queueLength = pulse(0);
1571
-
1572
- const processQueue = async () => {
1573
- if (isProcessing || queue.length === 0 || aborted) return;
1574
-
1575
- isProcessing = true;
1576
-
1577
- while (queue.length > 0 && !aborted) {
1578
- const { message, priority, clearAfter } = queue.shift();
1579
- queueLength.set(queue.length);
1580
-
1581
- announce(message, { priority, clearAfter });
1582
-
1583
- // Wait for announcement to be read
1584
- await new Promise(resolve => {
1585
- currentTimerId = setTimeout(resolve,
1586
- Math.max(minDelay, clearAfter || 1000));
1587
- });
1588
- currentTimerId = null;
1589
- }
1590
-
1591
- isProcessing = false;
1592
- };
1593
-
1594
- const dispose = () => {
1595
- aborted = true;
1596
- if (currentTimerId !== null) {
1597
- clearTimeout(currentTimerId);
1598
- currentTimerId = null;
1599
- }
1600
- queue.length = 0;
1601
- queueLength.set(0);
1602
- isProcessing = false;
1603
- };
1604
-
1605
- return {
1606
- queueLength,
1607
- /**
1608
- * Add a message to the queue
1609
- * @param {string} message - Message to announce
1610
- * @param {object} options - Announcement options (priority, clearAfter)
1611
- */
1612
- add: (message, opts = {}) => {
1613
- if (aborted) return;
1614
- queue.push({ message, ...opts });
1615
- queueLength.set(queue.length);
1616
- processQueue();
1617
- },
1618
- /**
1619
- * Clear the queue
1620
- */
1621
- clear: () => {
1622
- queue.length = 0;
1623
- queueLength.set(0);
1624
- },
1625
- /**
1626
- * Check if queue is being processed
1627
- * @returns {boolean}
1628
- */
1629
- isProcessing: () => isProcessing,
1630
- /**
1631
- * Dispose the queue, cancelling any pending timers
1632
- */
1633
- dispose
1634
- };
1635
- }
1636
-
1637
- // =============================================================================
1638
- // UTILITIES
1639
- // =============================================================================
1640
-
1641
- /**
1642
- * Generate a unique ID for ARIA relationships
1643
- * @param {string} prefix - ID prefix
1644
- * @returns {string} Unique ID
1645
- */
1646
- export function generateId(prefix = 'pulse') {
1647
- return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
1648
- }
1649
-
1650
- /**
1651
- * Compute the accessible name of an element
1652
- * Follows simplified ARIA accessible name computation algorithm
1653
- * @param {HTMLElement} element - Element to get name for
1654
- * @returns {string} The accessible name
1655
- */
1656
- export function getAccessibleName(element) {
1657
- if (!element) return '';
1658
-
1659
- // 1. aria-labelledby takes precedence
1660
- const labelledBy = element.getAttribute('aria-labelledby');
1661
- if (labelledBy) {
1662
- const ids = labelledBy.split(/\s+/);
1663
- const names = ids
1664
- .map(id => document.getElementById(id))
1665
- .filter(Boolean)
1666
- .map(el => el.textContent?.trim() || '');
1667
- if (names.length > 0) {
1668
- return names.join(' ');
1669
- }
1670
- }
1671
-
1672
- // 2. aria-label
1673
- const ariaLabel = element.getAttribute('aria-label');
1674
- if (ariaLabel && ariaLabel.trim()) {
1675
- return ariaLabel.trim();
1676
- }
1677
-
1678
- // 3. Native label association (for form controls)
1679
- if (element.labels && element.labels.length > 0) {
1680
- return Array.from(element.labels)
1681
- .map(label => label.textContent?.trim() || '')
1682
- .join(' ');
1683
- }
1684
-
1685
- // 4. title attribute
1686
- const title = element.getAttribute('title');
1687
- if (title && title.trim()) {
1688
- return title.trim();
1689
- }
1690
-
1691
- // 5. alt attribute (for images)
1692
- if (element.tagName === 'IMG') {
1693
- const alt = element.getAttribute('alt');
1694
- if (alt) return alt;
1695
- }
1696
-
1697
- // 6. Text content (for buttons, links)
1698
- const textContent = element.textContent?.trim();
1699
- if (textContent) {
1700
- return textContent;
1701
- }
1702
-
1703
- // 7. value attribute (for inputs with type=button/submit)
1704
- const type = element.getAttribute('type');
1705
- if (element.tagName === 'INPUT' && (type === 'button' || type === 'submit')) {
1706
- return element.value || '';
1707
- }
1708
-
1709
- return '';
1710
- }
1711
-
1712
- /**
1713
- * Check if an element is visible to screen readers
1714
- * @param {HTMLElement} element - Element to check
1715
- * @returns {boolean}
1716
- */
1717
- export function isAccessiblyHidden(element) {
1718
- if (!element) return true;
1719
-
1720
- // Check aria-hidden
1721
- if (element.getAttribute('aria-hidden') === 'true') return true;
1722
-
1723
- // Check CSS
1724
- const style = getComputedStyle(element);
1725
- if (style.display === 'none') return true;
1726
- if (style.visibility === 'hidden') return true;
1727
-
1728
- // Check inert
1729
- if (element.hasAttribute('inert')) return true;
1730
-
1731
- // Check ancestors
1732
- let parent = element.parentElement;
1733
- while (parent) {
1734
- if (parent.getAttribute('aria-hidden') === 'true') return true;
1735
- if (parent.hasAttribute('inert')) return true;
1736
- parent = parent.parentElement;
1737
- }
1738
-
1739
- return false;
1740
- }
1741
-
1742
- /**
1743
- * Make an element inert (non-interactive, hidden from a11y tree)
1744
- * @param {HTMLElement} element - Element to make inert
1745
- * @returns {Function} Restore function
1746
- */
1747
- export function makeInert(element) {
1748
- const wasInert = element.hasAttribute('inert');
1749
- element.setAttribute('inert', '');
1750
- element.setAttribute('aria-hidden', 'true');
1751
-
1752
- return () => {
1753
- if (!wasInert) {
1754
- element.removeAttribute('inert');
1755
- }
1756
- element.removeAttribute('aria-hidden');
1757
- };
1758
- }
1759
-
1760
- /**
1761
- * Create screen reader only text (visually hidden)
1762
- * @param {string} text - Text content
1763
- * @returns {HTMLElement} Span element
4
+ * This file maintains backward compatibility by re-exporting from a11y/
5
+ * The actual implementation has been split into focused sub-modules:
6
+ * - a11y/announcements.js - Screen reader announcements
7
+ * - a11y/focus.js - Focus management and keyboard navigation
8
+ * - a11y/preferences.js - User preference detection
9
+ * - a11y/widgets.js - ARIA widgets (modal, tabs, etc.)
10
+ * - a11y/validation.js - A11y validation and auditing
11
+ * - a11y/contrast.js - Color contrast utilities
12
+ * - a11y/utils.js - Utility functions
13
+ *
14
+ * @deprecated Import from 'pulse-js-framework/runtime/a11y/index.js' instead
15
+ * @module pulse-js-framework/runtime/a11y
1764
16
  */
1765
- export function srOnly(text) {
1766
- const span = document.createElement('span');
1767
- span.textContent = text;
1768
- span.className = 'sr-only';
1769
- span.style.cssText = `
1770
- position: absolute;
1771
- width: 1px;
1772
- height: 1px;
1773
- padding: 0;
1774
- margin: -1px;
1775
- overflow: hidden;
1776
- clip: rect(0, 0, 0, 0);
1777
- white-space: nowrap;
1778
- border: 0;
1779
- `;
1780
- return span;
1781
- }
1782
-
1783
- // Default export for convenience
1784
- export default {
1785
- // Announcements
1786
- announce,
1787
- announcePolite,
1788
- announceAssertive,
1789
- createLiveAnnouncer,
1790
- createAnnouncementQueue,
1791
-
1792
- // Focus
1793
- trapFocus,
1794
- focusFirst,
1795
- focusLast,
1796
- saveFocus,
1797
- restoreFocus,
1798
- getFocusableElements,
1799
- onEscapeKey,
1800
- createFocusVisibleTracker,
1801
-
1802
- // Skip links
1803
- createSkipLink,
1804
- installSkipLinks,
1805
-
1806
- // Preferences
1807
- prefersReducedMotion,
1808
- prefersColorScheme,
1809
- prefersHighContrast,
1810
- prefersReducedTransparency,
1811
- forcedColorsMode,
1812
- prefersContrast,
1813
- createPreferences,
1814
-
1815
- // ARIA helpers
1816
- setAriaAttributes,
1817
- createDisclosure,
1818
- createTabs,
1819
- createRovingTabindex,
1820
-
1821
- // ARIA widgets
1822
- createModal,
1823
- createTooltip,
1824
- createAccordion,
1825
- createMenu,
1826
-
1827
- // Color contrast
1828
- getContrastRatio,
1829
- meetsContrastRequirement,
1830
- getEffectiveBackgroundColor,
1831
- checkElementContrast,
1832
-
1833
- // Validation
1834
- validateA11y,
1835
- logA11yIssues,
1836
- highlightA11yIssues,
1837
17
 
1838
- // Utilities
1839
- generateId,
1840
- getAccessibleName,
1841
- isAccessiblyHidden,
1842
- makeInert,
1843
- srOnly
1844
- };
18
+ export * from './a11y/index.js';
19
+ export { default } from './a11y/index.js';