pulse-js-framework 1.7.11 → 1.7.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/runtime/a11y.js CHANGED
@@ -311,6 +311,70 @@ export function clearFocusStack() {
311
311
  focusStack.length = 0;
312
312
  }
313
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
+
314
378
  // =============================================================================
315
379
  // SKIP LINKS
316
380
  // =============================================================================
@@ -416,6 +480,37 @@ export function prefersHighContrast() {
416
480
  return window.matchMedia('(prefers-contrast: more)').matches;
417
481
  }
418
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
+
419
514
  /**
420
515
  * Create reactive user preferences pulse
421
516
  * @returns {object} Object with reactive preference pulses
@@ -424,6 +519,9 @@ export function createPreferences() {
424
519
  const reducedMotion = pulse(prefersReducedMotion());
425
520
  const colorScheme = pulse(prefersColorScheme());
426
521
  const highContrast = pulse(prefersHighContrast());
522
+ const reducedTransparency = pulse(prefersReducedTransparency());
523
+ const forcedColors = pulse(forcedColorsMode());
524
+ const contrast = pulse(prefersContrast());
427
525
 
428
526
  if (typeof window !== 'undefined') {
429
527
  // Listen for preference changes
@@ -438,12 +536,31 @@ export function createPreferences() {
438
536
  window.matchMedia('(prefers-contrast: more)').addEventListener('change', (e) => {
439
537
  highContrast.set(e.matches);
440
538
  });
539
+
540
+ window.matchMedia('(prefers-reduced-transparency: reduce)').addEventListener('change', (e) => {
541
+ reducedTransparency.set(e.matches);
542
+ });
543
+
544
+ window.matchMedia('(forced-colors: active)').addEventListener('change', (e) => {
545
+ forcedColors.set(e.matches ? 'active' : 'none');
546
+ });
547
+
548
+ // More granular contrast detection
549
+ window.matchMedia('(prefers-contrast: more)').addEventListener('change', () => {
550
+ contrast.set(prefersContrast());
551
+ });
552
+ window.matchMedia('(prefers-contrast: less)').addEventListener('change', () => {
553
+ contrast.set(prefersContrast());
554
+ });
441
555
  }
442
556
 
443
557
  return {
444
558
  reducedMotion,
445
559
  colorScheme,
446
- highContrast
560
+ highContrast,
561
+ reducedTransparency,
562
+ forcedColors,
563
+ contrast
447
564
  };
448
565
  }
449
566
 
@@ -686,6 +803,391 @@ export function createRovingTabindex(container, options = {}) {
686
803
  };
687
804
  }
688
805
 
806
+ // =============================================================================
807
+ // ARIA WIDGETS
808
+ // =============================================================================
809
+
810
+ /**
811
+ * Create an accessible modal dialog
812
+ * Composes trapFocus, onEscapeKey, and proper ARIA attributes
813
+ * @param {HTMLElement} dialog - Dialog element
814
+ * @param {object} options - Options
815
+ * @param {HTMLElement} options.triggerElement - Element that triggered the dialog
816
+ * @param {string} options.labelledBy - ID of element labeling the dialog
817
+ * @param {string} options.describedBy - ID of element describing the dialog
818
+ * @param {HTMLElement} options.initialFocus - Element to focus initially
819
+ * @param {Function} options.onClose - Callback when dialog should close
820
+ * @param {boolean} options.closeOnBackdropClick - Close on backdrop click (default: true)
821
+ * @param {boolean} options.inertBackground - Make background inert (default: true)
822
+ * @returns {object} Control object with open, close methods and isOpen pulse
823
+ */
824
+ export function createModal(dialog, options = {}) {
825
+ const {
826
+ labelledBy = null,
827
+ describedBy = null,
828
+ initialFocus = null,
829
+ onClose = null,
830
+ closeOnBackdropClick = true,
831
+ inertBackground = true
832
+ } = options;
833
+
834
+ const isOpen = pulse(false);
835
+ let releaseFocusTrap = null;
836
+ let removeEscapeHandler = null;
837
+ let restoreInertFns = null;
838
+ let backdropHandler = null;
839
+
840
+ // Set ARIA attributes
841
+ dialog.setAttribute('role', 'dialog');
842
+ dialog.setAttribute('aria-modal', 'true');
843
+ if (labelledBy) dialog.setAttribute('aria-labelledby', labelledBy);
844
+ if (describedBy) dialog.setAttribute('aria-describedby', describedBy);
845
+
846
+ const open = () => {
847
+ if (isOpen.get()) return;
848
+
849
+ dialog.hidden = false;
850
+ isOpen.set(true);
851
+
852
+ // Make background inert
853
+ if (inertBackground && typeof document !== 'undefined') {
854
+ const siblings = Array.from(document.body.children)
855
+ .filter(el => el !== dialog && !el.hasAttribute('inert'));
856
+ restoreInertFns = siblings.map(el => makeInert(el));
857
+ }
858
+
859
+ // Trap focus
860
+ releaseFocusTrap = trapFocus(dialog, {
861
+ autoFocus: true,
862
+ returnFocus: true,
863
+ initialFocus
864
+ });
865
+
866
+ // Handle escape key
867
+ removeEscapeHandler = onEscapeKey(dialog, close);
868
+
869
+ // Handle backdrop click
870
+ if (closeOnBackdropClick) {
871
+ backdropHandler = (e) => {
872
+ if (e.target === dialog) close();
873
+ };
874
+ dialog.addEventListener('click', backdropHandler);
875
+ }
876
+
877
+ // Announce to screen readers
878
+ announce('Dialog opened');
879
+ };
880
+
881
+ const close = () => {
882
+ if (!isOpen.get()) return;
883
+
884
+ dialog.hidden = true;
885
+ isOpen.set(false);
886
+
887
+ // Clean up
888
+ if (releaseFocusTrap) {
889
+ releaseFocusTrap();
890
+ releaseFocusTrap = null;
891
+ }
892
+ if (removeEscapeHandler) {
893
+ removeEscapeHandler();
894
+ removeEscapeHandler = null;
895
+ }
896
+ if (restoreInertFns) {
897
+ restoreInertFns.forEach(restore => restore());
898
+ restoreInertFns = null;
899
+ }
900
+ if (backdropHandler) {
901
+ dialog.removeEventListener('click', backdropHandler);
902
+ backdropHandler = null;
903
+ }
904
+
905
+ if (onClose) onClose();
906
+ announce('Dialog closed');
907
+ };
908
+
909
+ return { isOpen, open, close };
910
+ }
911
+
912
+ /**
913
+ * Create an accessible tooltip
914
+ * Manages aria-describedby and visibility
915
+ * @param {HTMLElement} trigger - Element that triggers tooltip
916
+ * @param {HTMLElement} tooltip - Tooltip element
917
+ * @param {object} options - Options
918
+ * @param {number} options.showDelay - Delay before showing (ms, default: 500)
919
+ * @param {number} options.hideDelay - Delay before hiding (ms, default: 100)
920
+ * @returns {object} Control object with show, hide methods and isVisible pulse
921
+ */
922
+ export function createTooltip(trigger, tooltip, options = {}) {
923
+ const {
924
+ showDelay = 500,
925
+ hideDelay = 100
926
+ } = options;
927
+
928
+ const isVisible = pulse(false);
929
+ let showTimer = null;
930
+ let hideTimer = null;
931
+
932
+ // Generate ID if needed
933
+ const tooltipId = tooltip.id || generateId('tooltip');
934
+ tooltip.id = tooltipId;
935
+
936
+ // Set ARIA attributes
937
+ tooltip.setAttribute('role', 'tooltip');
938
+ trigger.setAttribute('aria-describedby', tooltipId);
939
+ tooltip.hidden = true;
940
+
941
+ const show = () => {
942
+ clearTimeout(hideTimer);
943
+ showTimer = setTimeout(() => {
944
+ tooltip.hidden = false;
945
+ isVisible.set(true);
946
+ }, showDelay);
947
+ };
948
+
949
+ const hide = () => {
950
+ clearTimeout(showTimer);
951
+ hideTimer = setTimeout(() => {
952
+ tooltip.hidden = true;
953
+ isVisible.set(false);
954
+ }, hideDelay);
955
+ };
956
+
957
+ const showImmediate = () => {
958
+ clearTimeout(hideTimer);
959
+ clearTimeout(showTimer);
960
+ tooltip.hidden = false;
961
+ isVisible.set(true);
962
+ };
963
+
964
+ const hideImmediate = () => {
965
+ clearTimeout(hideTimer);
966
+ clearTimeout(showTimer);
967
+ tooltip.hidden = true;
968
+ isVisible.set(false);
969
+ };
970
+
971
+ const handleEscapeKey = (e) => {
972
+ if (e.key === 'Escape') hideImmediate();
973
+ };
974
+
975
+ // Event listeners
976
+ trigger.addEventListener('mouseenter', show);
977
+ trigger.addEventListener('mouseleave', hide);
978
+ trigger.addEventListener('focus', showImmediate);
979
+ trigger.addEventListener('blur', hideImmediate);
980
+ trigger.addEventListener('keydown', handleEscapeKey);
981
+
982
+ const cleanup = () => {
983
+ clearTimeout(showTimer);
984
+ clearTimeout(hideTimer);
985
+ trigger.removeEventListener('mouseenter', show);
986
+ trigger.removeEventListener('mouseleave', hide);
987
+ trigger.removeEventListener('focus', showImmediate);
988
+ trigger.removeEventListener('blur', hideImmediate);
989
+ trigger.removeEventListener('keydown', handleEscapeKey);
990
+ trigger.removeAttribute('aria-describedby');
991
+ };
992
+
993
+ return { isVisible, show: showImmediate, hide: hideImmediate, cleanup };
994
+ }
995
+
996
+ /**
997
+ * Create an accessible accordion (composed of disclosures)
998
+ * @param {HTMLElement} container - Accordion container
999
+ * @param {object} options - Options
1000
+ * @param {string} options.triggerSelector - Selector for accordion triggers
1001
+ * @param {string} options.panelSelector - Selector for accordion panels
1002
+ * @param {boolean} options.allowMultiple - Allow multiple panels open (default: false)
1003
+ * @param {number} options.defaultOpen - Index of initially open panel (-1 for none)
1004
+ * @param {Function} options.onToggle - Callback (index, isOpen) => void
1005
+ * @returns {object} Control object
1006
+ */
1007
+ export function createAccordion(container, options = {}) {
1008
+ const {
1009
+ triggerSelector = '[data-accordion-trigger]',
1010
+ panelSelector = '[data-accordion-panel]',
1011
+ allowMultiple = false,
1012
+ defaultOpen = -1,
1013
+ onToggle = null
1014
+ } = options;
1015
+
1016
+ const triggers = Array.from(container.querySelectorAll(triggerSelector));
1017
+ const panels = Array.from(container.querySelectorAll(panelSelector));
1018
+ const disclosures = [];
1019
+ const openIndices = pulse(defaultOpen >= 0 ? [defaultOpen] : []);
1020
+
1021
+ triggers.forEach((trigger, index) => {
1022
+ const panel = panels[index];
1023
+ if (!panel) return;
1024
+
1025
+ const disclosure = createDisclosure(trigger, panel, {
1026
+ defaultOpen: index === defaultOpen,
1027
+ onToggle: (isExpanded) => {
1028
+ if (isExpanded) {
1029
+ if (allowMultiple) {
1030
+ openIndices.update(arr => arr.includes(index) ? arr : [...arr, index]);
1031
+ } else {
1032
+ // Close other panels
1033
+ disclosures.forEach((d, i) => {
1034
+ if (i !== index && d.expanded.get()) d.close();
1035
+ });
1036
+ openIndices.set([index]);
1037
+ }
1038
+ } else {
1039
+ openIndices.update(arr => arr.filter(i => i !== index));
1040
+ }
1041
+ if (onToggle) onToggle(index, isExpanded);
1042
+ }
1043
+ });
1044
+
1045
+ disclosures.push(disclosure);
1046
+ });
1047
+
1048
+ return {
1049
+ openIndices,
1050
+ disclosures,
1051
+ openAll: () => {
1052
+ if (allowMultiple) {
1053
+ disclosures.forEach(d => d.open());
1054
+ }
1055
+ },
1056
+ closeAll: () => {
1057
+ disclosures.forEach(d => d.close());
1058
+ },
1059
+ open: (index) => {
1060
+ if (disclosures[index]) disclosures[index].open();
1061
+ },
1062
+ close: (index) => {
1063
+ if (disclosures[index]) disclosures[index].close();
1064
+ },
1065
+ toggle: (index) => {
1066
+ if (disclosures[index]) disclosures[index].toggle();
1067
+ }
1068
+ };
1069
+ }
1070
+
1071
+ /**
1072
+ * Create an accessible dropdown menu
1073
+ * @param {HTMLElement} button - Menu button
1074
+ * @param {HTMLElement} menu - Menu container
1075
+ * @param {object} options - Options
1076
+ * @param {string} options.itemSelector - Selector for menu items (default: '[role="menuitem"]')
1077
+ * @param {Function} options.onSelect - Callback when item is selected
1078
+ * @param {boolean} options.closeOnSelect - Close menu on item selection (default: true)
1079
+ * @returns {object} Control object with open, close, toggle methods and isOpen pulse
1080
+ */
1081
+ export function createMenu(button, menu, options = {}) {
1082
+ const {
1083
+ itemSelector = '[role="menuitem"]',
1084
+ onSelect = null,
1085
+ closeOnSelect = true
1086
+ } = options;
1087
+
1088
+ const isOpen = pulse(false);
1089
+ const menuId = menu.id || generateId('menu');
1090
+ let rovingCleanup = null;
1091
+ let documentClickHandler = null;
1092
+
1093
+ // Set ARIA attributes
1094
+ menu.id = menuId;
1095
+ menu.setAttribute('role', 'menu');
1096
+ button.setAttribute('aria-haspopup', 'menu');
1097
+ button.setAttribute('aria-controls', menuId);
1098
+ button.setAttribute('aria-expanded', 'false');
1099
+ menu.hidden = true;
1100
+
1101
+ const open = () => {
1102
+ if (isOpen.get()) return;
1103
+
1104
+ menu.hidden = false;
1105
+ button.setAttribute('aria-expanded', 'true');
1106
+ isOpen.set(true);
1107
+
1108
+ // Setup roving tabindex for menu items
1109
+ rovingCleanup = createRovingTabindex(menu, {
1110
+ selector: itemSelector,
1111
+ orientation: 'vertical',
1112
+ onSelect: (el, index) => {
1113
+ if (onSelect) onSelect(el, index);
1114
+ if (closeOnSelect) close();
1115
+ }
1116
+ });
1117
+
1118
+ // Focus first item
1119
+ const firstItem = menu.querySelector(itemSelector);
1120
+ if (firstItem) firstItem.focus();
1121
+
1122
+ // Close on click outside (delay to avoid immediate close)
1123
+ setTimeout(() => {
1124
+ documentClickHandler = (e) => {
1125
+ if (!button.contains(e.target) && !menu.contains(e.target)) {
1126
+ close();
1127
+ }
1128
+ };
1129
+ document.addEventListener('click', documentClickHandler);
1130
+ }, 0);
1131
+ };
1132
+
1133
+ const close = () => {
1134
+ if (!isOpen.get()) return;
1135
+
1136
+ menu.hidden = true;
1137
+ button.setAttribute('aria-expanded', 'false');
1138
+ isOpen.set(false);
1139
+
1140
+ if (rovingCleanup) {
1141
+ rovingCleanup();
1142
+ rovingCleanup = null;
1143
+ }
1144
+
1145
+ if (documentClickHandler) {
1146
+ document.removeEventListener('click', documentClickHandler);
1147
+ documentClickHandler = null;
1148
+ }
1149
+
1150
+ button.focus();
1151
+ };
1152
+
1153
+ const toggle = () => isOpen.get() ? close() : open();
1154
+
1155
+ // Button click
1156
+ button.addEventListener('click', toggle);
1157
+
1158
+ // Keyboard navigation on button
1159
+ const handleButtonKeyDown = (e) => {
1160
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
1161
+ e.preventDefault();
1162
+ open();
1163
+ }
1164
+ };
1165
+ button.addEventListener('keydown', handleButtonKeyDown);
1166
+
1167
+ // Close on escape
1168
+ const handleMenuKeyDown = (e) => {
1169
+ if (e.key === 'Escape') {
1170
+ e.stopPropagation();
1171
+ close();
1172
+ }
1173
+ };
1174
+ menu.addEventListener('keydown', handleMenuKeyDown);
1175
+
1176
+ const cleanup = () => {
1177
+ button.removeEventListener('click', toggle);
1178
+ button.removeEventListener('keydown', handleButtonKeyDown);
1179
+ menu.removeEventListener('keydown', handleMenuKeyDown);
1180
+ if (documentClickHandler) {
1181
+ document.removeEventListener('click', documentClickHandler);
1182
+ }
1183
+ if (rovingCleanup) {
1184
+ rovingCleanup();
1185
+ }
1186
+ };
1187
+
1188
+ return { isOpen, open, close, toggle, cleanup };
1189
+ }
1190
+
689
1191
  // =============================================================================
690
1192
  // VALIDATION & AUDITING
691
1193
  // =============================================================================
@@ -798,6 +1300,65 @@ export function validateA11y(container = document.body) {
798
1300
  }
799
1301
  });
800
1302
 
1303
+ // Check for duplicate IDs
1304
+ const idMap = new Map();
1305
+ container.querySelectorAll('[id]').forEach(el => {
1306
+ const id = el.id;
1307
+ if (id) {
1308
+ if (idMap.has(id)) {
1309
+ addIssue('error', 'duplicate-id', `Duplicate ID "${id}" found`, el);
1310
+ } else {
1311
+ idMap.set(id, el);
1312
+ }
1313
+ }
1314
+ });
1315
+
1316
+ // Check for landmark regions (main, nav, etc.)
1317
+ if (typeof container.querySelector === 'function' && container === document.body) {
1318
+ const hasMain = container.querySelector('main, [role="main"]');
1319
+ if (!hasMain) {
1320
+ addIssue('warning', 'missing-main', 'Page should have a <main> landmark', document.body);
1321
+ }
1322
+ }
1323
+
1324
+ // Check for nested interactive elements
1325
+ container.querySelectorAll('a, button').forEach(el => {
1326
+ if (typeof el.querySelector === 'function') {
1327
+ const nestedInteractive = el.querySelector('a, button, input, select, textarea');
1328
+ if (nestedInteractive) {
1329
+ addIssue('error', 'nested-interactive',
1330
+ 'Interactive elements should not be nested inside other interactive elements', el);
1331
+ }
1332
+ }
1333
+ });
1334
+
1335
+ // Check for missing html lang attribute
1336
+ if (container === document.body && typeof document !== 'undefined' && document.documentElement) {
1337
+ const lang = document.documentElement.getAttribute?.('lang');
1338
+ if (!lang) {
1339
+ addIssue('warning', 'missing-lang',
1340
+ 'Document should have a lang attribute on <html>', document.documentElement);
1341
+ }
1342
+ }
1343
+
1344
+ // Check for touch target sizes (WCAG 2.2 - 24x24px minimum)
1345
+ if (typeof getComputedStyle === 'function') {
1346
+ container.querySelectorAll('a, button, input, select, [role="button"], [role="link"]').forEach(el => {
1347
+ if (typeof el.getBoundingClientRect === 'function') {
1348
+ const rect = el.getBoundingClientRect();
1349
+ if (rect.width > 0 && rect.height > 0 && (rect.width < 24 || rect.height < 24)) {
1350
+ // Only flag if element is visible
1351
+ const style = getComputedStyle(el);
1352
+ if (style.display !== 'none' && style.visibility !== 'hidden') {
1353
+ addIssue('warning', 'touch-target-size',
1354
+ `Touch target (${Math.round(rect.width)}x${Math.round(rect.height)}px) smaller than 24x24px minimum`,
1355
+ el);
1356
+ }
1357
+ }
1358
+ }
1359
+ });
1360
+ }
1361
+
801
1362
  return issues;
802
1363
  }
803
1364
 
@@ -876,6 +1437,187 @@ export function highlightA11yIssues(issues) {
876
1437
  };
877
1438
  }
878
1439
 
1440
+ // =============================================================================
1441
+ // COLOR CONTRAST
1442
+ // =============================================================================
1443
+
1444
+ /**
1445
+ * Parse a color string to RGB values using canvas
1446
+ * @param {string} color - CSS color string
1447
+ * @returns {{r: number, g: number, b: number}|null}
1448
+ */
1449
+ function parseColor(color) {
1450
+ if (typeof document === 'undefined') return null;
1451
+
1452
+ const canvas = document.createElement('canvas');
1453
+ canvas.width = canvas.height = 1;
1454
+ const ctx = canvas.getContext('2d');
1455
+ if (!ctx) return null;
1456
+
1457
+ ctx.fillStyle = color;
1458
+ ctx.fillRect(0, 0, 1, 1);
1459
+ const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
1460
+ return { r, g, b };
1461
+ }
1462
+
1463
+ /**
1464
+ * Calculate relative luminance of a color
1465
+ * @param {{r: number, g: number, b: number}} color - RGB color
1466
+ * @returns {number} Luminance between 0 and 1
1467
+ */
1468
+ function relativeLuminance({ r, g, b }) {
1469
+ const [rs, gs, bs] = [r, g, b].map(c => {
1470
+ c = c / 255;
1471
+ return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
1472
+ });
1473
+ return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
1474
+ }
1475
+
1476
+ /**
1477
+ * Calculate contrast ratio between two colors
1478
+ * @param {string} foreground - Foreground color (any CSS color format)
1479
+ * @param {string} background - Background color (any CSS color format)
1480
+ * @returns {number} Contrast ratio (1 to 21)
1481
+ */
1482
+ export function getContrastRatio(foreground, background) {
1483
+ const fg = parseColor(foreground);
1484
+ const bg = parseColor(background);
1485
+
1486
+ if (!fg || !bg) return 1;
1487
+
1488
+ const l1 = relativeLuminance(fg);
1489
+ const l2 = relativeLuminance(bg);
1490
+
1491
+ const lighter = Math.max(l1, l2);
1492
+ const darker = Math.min(l1, l2);
1493
+
1494
+ return (lighter + 0.05) / (darker + 0.05);
1495
+ }
1496
+
1497
+ /**
1498
+ * Check if contrast meets WCAG requirements
1499
+ * @param {number} ratio - Contrast ratio
1500
+ * @param {'AA'|'AAA'} level - WCAG level (default: 'AA')
1501
+ * @param {'normal'|'large'} textSize - Text size category (default: 'normal')
1502
+ * @returns {boolean}
1503
+ */
1504
+ export function meetsContrastRequirement(ratio, level = 'AA', textSize = 'normal') {
1505
+ const requirements = {
1506
+ AA: { normal: 4.5, large: 3 },
1507
+ AAA: { normal: 7, large: 4.5 }
1508
+ };
1509
+ return ratio >= (requirements[level]?.[textSize] ?? 4.5);
1510
+ }
1511
+
1512
+ /**
1513
+ * Get the effective background color of an element (handles transparency)
1514
+ * @param {HTMLElement} element - Element to check
1515
+ * @returns {string} Computed background color
1516
+ */
1517
+ export function getEffectiveBackgroundColor(element) {
1518
+ if (!element || typeof getComputedStyle === 'undefined') return 'rgb(255, 255, 255)';
1519
+
1520
+ let el = element;
1521
+ while (el) {
1522
+ const bg = getComputedStyle(el).backgroundColor;
1523
+ // Check if background is not transparent
1524
+ if (bg && bg !== 'transparent' && bg !== 'rgba(0, 0, 0, 0)') {
1525
+ return bg;
1526
+ }
1527
+ el = el.parentElement;
1528
+ }
1529
+ return 'rgb(255, 255, 255)'; // Default to white
1530
+ }
1531
+
1532
+ /**
1533
+ * Check color contrast of text in an element
1534
+ * @param {HTMLElement} element - Element to check
1535
+ * @param {'AA'|'AAA'} level - WCAG level
1536
+ * @returns {{ ratio: number, passes: boolean, foreground: string, background: string }}
1537
+ */
1538
+ export function checkElementContrast(element, level = 'AA') {
1539
+ if (!element || typeof getComputedStyle === 'undefined') {
1540
+ return { ratio: 1, passes: false, foreground: '', background: '' };
1541
+ }
1542
+
1543
+ const style = getComputedStyle(element);
1544
+ const foreground = style.color;
1545
+ const background = getEffectiveBackgroundColor(element);
1546
+ const ratio = getContrastRatio(foreground, background);
1547
+
1548
+ // Determine if text is "large" (14pt bold or 18pt+)
1549
+ const fontSize = parseFloat(style.fontSize);
1550
+ const fontWeight = parseInt(style.fontWeight, 10) || 400;
1551
+ const isLarge = fontSize >= 24 || (fontSize >= 18.66 && fontWeight >= 700);
1552
+
1553
+ const passes = meetsContrastRequirement(ratio, level, isLarge ? 'large' : 'normal');
1554
+
1555
+ return { ratio, passes, foreground, background };
1556
+ }
1557
+
1558
+ // =============================================================================
1559
+ // ANNOUNCEMENT QUEUE
1560
+ // =============================================================================
1561
+
1562
+ /**
1563
+ * Create an announcement queue that handles multiple messages in sequence
1564
+ * @param {object} options - Options
1565
+ * @param {number} options.minDelay - Minimum delay between announcements (ms, default: 500)
1566
+ * @returns {object} Queue control object
1567
+ */
1568
+ export function createAnnouncementQueue(options = {}) {
1569
+ const { minDelay = 500 } = options;
1570
+
1571
+ const queue = [];
1572
+ let isProcessing = false;
1573
+ const queueLength = pulse(0);
1574
+
1575
+ const processQueue = async () => {
1576
+ if (isProcessing || queue.length === 0) return;
1577
+
1578
+ isProcessing = true;
1579
+
1580
+ while (queue.length > 0) {
1581
+ const { message, priority, clearAfter } = queue.shift();
1582
+ queueLength.set(queue.length);
1583
+
1584
+ announce(message, { priority, clearAfter });
1585
+
1586
+ // Wait for announcement to be read
1587
+ await new Promise(resolve => setTimeout(resolve,
1588
+ Math.max(minDelay, clearAfter || 1000)));
1589
+ }
1590
+
1591
+ isProcessing = false;
1592
+ };
1593
+
1594
+ return {
1595
+ queueLength,
1596
+ /**
1597
+ * Add a message to the queue
1598
+ * @param {string} message - Message to announce
1599
+ * @param {object} options - Announcement options (priority, clearAfter)
1600
+ */
1601
+ add: (message, opts = {}) => {
1602
+ queue.push({ message, ...opts });
1603
+ queueLength.set(queue.length);
1604
+ processQueue();
1605
+ },
1606
+ /**
1607
+ * Clear the queue
1608
+ */
1609
+ clear: () => {
1610
+ queue.length = 0;
1611
+ queueLength.set(0);
1612
+ },
1613
+ /**
1614
+ * Check if queue is being processed
1615
+ * @returns {boolean}
1616
+ */
1617
+ isProcessing: () => isProcessing
1618
+ };
1619
+ }
1620
+
879
1621
  // =============================================================================
880
1622
  // UTILITIES
881
1623
  // =============================================================================
@@ -889,6 +1631,68 @@ export function generateId(prefix = 'pulse') {
889
1631
  return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
890
1632
  }
891
1633
 
1634
+ /**
1635
+ * Compute the accessible name of an element
1636
+ * Follows simplified ARIA accessible name computation algorithm
1637
+ * @param {HTMLElement} element - Element to get name for
1638
+ * @returns {string} The accessible name
1639
+ */
1640
+ export function getAccessibleName(element) {
1641
+ if (!element) return '';
1642
+
1643
+ // 1. aria-labelledby takes precedence
1644
+ const labelledBy = element.getAttribute('aria-labelledby');
1645
+ if (labelledBy) {
1646
+ const ids = labelledBy.split(/\s+/);
1647
+ const names = ids
1648
+ .map(id => document.getElementById(id))
1649
+ .filter(Boolean)
1650
+ .map(el => el.textContent?.trim() || '');
1651
+ if (names.length > 0) {
1652
+ return names.join(' ');
1653
+ }
1654
+ }
1655
+
1656
+ // 2. aria-label
1657
+ const ariaLabel = element.getAttribute('aria-label');
1658
+ if (ariaLabel && ariaLabel.trim()) {
1659
+ return ariaLabel.trim();
1660
+ }
1661
+
1662
+ // 3. Native label association (for form controls)
1663
+ if (element.labels && element.labels.length > 0) {
1664
+ return Array.from(element.labels)
1665
+ .map(label => label.textContent?.trim() || '')
1666
+ .join(' ');
1667
+ }
1668
+
1669
+ // 4. title attribute
1670
+ const title = element.getAttribute('title');
1671
+ if (title && title.trim()) {
1672
+ return title.trim();
1673
+ }
1674
+
1675
+ // 5. alt attribute (for images)
1676
+ if (element.tagName === 'IMG') {
1677
+ const alt = element.getAttribute('alt');
1678
+ if (alt) return alt;
1679
+ }
1680
+
1681
+ // 6. Text content (for buttons, links)
1682
+ const textContent = element.textContent?.trim();
1683
+ if (textContent) {
1684
+ return textContent;
1685
+ }
1686
+
1687
+ // 7. value attribute (for inputs with type=button/submit)
1688
+ const type = element.getAttribute('type');
1689
+ if (element.tagName === 'INPUT' && (type === 'button' || type === 'submit')) {
1690
+ return element.value || '';
1691
+ }
1692
+
1693
+ return '';
1694
+ }
1695
+
892
1696
  /**
893
1697
  * Check if an element is visible to screen readers
894
1698
  * @param {HTMLElement} element - Element to check
@@ -967,6 +1771,7 @@ export default {
967
1771
  announcePolite,
968
1772
  announceAssertive,
969
1773
  createLiveAnnouncer,
1774
+ createAnnouncementQueue,
970
1775
 
971
1776
  // Focus
972
1777
  trapFocus,
@@ -975,6 +1780,8 @@ export default {
975
1780
  saveFocus,
976
1781
  restoreFocus,
977
1782
  getFocusableElements,
1783
+ onEscapeKey,
1784
+ createFocusVisibleTracker,
978
1785
 
979
1786
  // Skip links
980
1787
  createSkipLink,
@@ -984,6 +1791,9 @@ export default {
984
1791
  prefersReducedMotion,
985
1792
  prefersColorScheme,
986
1793
  prefersHighContrast,
1794
+ prefersReducedTransparency,
1795
+ forcedColorsMode,
1796
+ prefersContrast,
987
1797
  createPreferences,
988
1798
 
989
1799
  // ARIA helpers
@@ -992,6 +1802,18 @@ export default {
992
1802
  createTabs,
993
1803
  createRovingTabindex,
994
1804
 
1805
+ // ARIA widgets
1806
+ createModal,
1807
+ createTooltip,
1808
+ createAccordion,
1809
+ createMenu,
1810
+
1811
+ // Color contrast
1812
+ getContrastRatio,
1813
+ meetsContrastRequirement,
1814
+ getEffectiveBackgroundColor,
1815
+ checkElementContrast,
1816
+
995
1817
  // Validation
996
1818
  validateA11y,
997
1819
  logA11yIssues,
@@ -999,6 +1821,7 @@ export default {
999
1821
 
1000
1822
  // Utilities
1001
1823
  generateId,
1824
+ getAccessibleName,
1002
1825
  isAccessiblyHidden,
1003
1826
  makeInert,
1004
1827
  srOnly