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/README.md +80 -8
- package/cli/docs.js +712 -0
- package/cli/doctor.js +702 -0
- package/cli/index.js +338 -65
- package/cli/scaffold.js +1037 -0
- package/cli/test.js +455 -0
- package/package.json +19 -2
- package/runtime/a11y.js +824 -1
- package/runtime/context.js +374 -0
- package/runtime/graphql.js +1356 -0
- package/runtime/index.js +6 -0
- package/runtime/logger.js +2 -1
- package/runtime/websocket.js +874 -0
- package/types/context.d.ts +171 -0
- package/types/graphql.d.ts +490 -0
- package/types/index.d.ts +15 -0
- package/types/websocket.d.ts +347 -0
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
|