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.
- package/compiler/parser/_extract.js +393 -0
- package/compiler/parser/blocks.js +361 -0
- package/compiler/parser/core.js +306 -0
- package/compiler/parser/expressions.js +386 -0
- package/compiler/parser/imports.js +108 -0
- package/compiler/parser/index.js +47 -0
- package/compiler/parser/state.js +155 -0
- package/compiler/parser/style.js +445 -0
- package/compiler/parser/view.js +632 -0
- package/compiler/parser.js +15 -2372
- package/compiler/parser.js.original +2376 -0
- package/package.json +2 -1
- package/runtime/a11y/announcements.js +213 -0
- package/runtime/a11y/contrast.js +125 -0
- package/runtime/a11y/focus.js +412 -0
- package/runtime/a11y/index.js +35 -0
- package/runtime/a11y/preferences.js +121 -0
- package/runtime/a11y/utils.js +164 -0
- package/runtime/a11y/validation.js +258 -0
- package/runtime/a11y/widgets.js +545 -0
- package/runtime/a11y.js +15 -1840
- package/runtime/a11y.js.original +1844 -0
- package/runtime/graphql/cache.js +69 -0
- package/runtime/graphql/client.js +563 -0
- package/runtime/graphql/hooks.js +492 -0
- package/runtime/graphql/index.js +62 -0
- package/runtime/graphql/subscriptions.js +241 -0
- package/runtime/graphql.js +12 -1322
- package/runtime/graphql.js.original +1326 -0
- package/runtime/router/core.js +956 -0
- package/runtime/router/guards.js +90 -0
- package/runtime/router/history.js +204 -0
- package/runtime/router/index.js +36 -0
- package/runtime/router/lazy.js +180 -0
- package/runtime/router/utils.js +226 -0
- package/runtime/router.js +12 -1600
- package/runtime/router.js.original +1605 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse A11y - Focus Management
|
|
3
|
+
*
|
|
4
|
+
* Focus management, skip links, and keyboard navigation
|
|
5
|
+
*
|
|
6
|
+
* @module pulse-js-framework/runtime/a11y/focus
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { pulse, effect } from '../pulse.js';
|
|
10
|
+
|
|
11
|
+
// =============================================================================
|
|
12
|
+
// FOCUS MANAGEMENT
|
|
13
|
+
// =============================================================================
|
|
14
|
+
|
|
15
|
+
const focusStack = [];
|
|
16
|
+
|
|
17
|
+
/** Focusable element selector */
|
|
18
|
+
const FOCUSABLE_SELECTOR = [
|
|
19
|
+
'a[href]',
|
|
20
|
+
'button:not([disabled])',
|
|
21
|
+
'input:not([disabled]):not([type="hidden"])',
|
|
22
|
+
'select:not([disabled])',
|
|
23
|
+
'textarea:not([disabled])',
|
|
24
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
25
|
+
'[contenteditable="true"]',
|
|
26
|
+
'audio[controls]',
|
|
27
|
+
'video[controls]',
|
|
28
|
+
'details > summary',
|
|
29
|
+
'iframe'
|
|
30
|
+
].join(', ');
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Get all focusable elements within a container
|
|
34
|
+
* @param {HTMLElement} container - Container element
|
|
35
|
+
* @returns {HTMLElement[]} Array of focusable elements
|
|
36
|
+
*/
|
|
37
|
+
export function getFocusableElements(container) {
|
|
38
|
+
if (!container) return [];
|
|
39
|
+
const elements = Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR));
|
|
40
|
+
return elements.filter(el => {
|
|
41
|
+
// Check visibility
|
|
42
|
+
const style = getComputedStyle(el);
|
|
43
|
+
return style.display !== 'none' &&
|
|
44
|
+
style.visibility !== 'hidden' &&
|
|
45
|
+
el.offsetParent !== null;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Focus the first focusable element in a container
|
|
51
|
+
* @param {HTMLElement} container - Container element
|
|
52
|
+
* @returns {HTMLElement|null} The focused element or null
|
|
53
|
+
*/
|
|
54
|
+
export function focusFirst(container) {
|
|
55
|
+
const focusable = getFocusableElements(container);
|
|
56
|
+
if (focusable.length > 0) {
|
|
57
|
+
focusable[0].focus();
|
|
58
|
+
return focusable[0];
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Focus the last focusable element in a container
|
|
65
|
+
* @param {HTMLElement} container - Container element
|
|
66
|
+
* @returns {HTMLElement|null} The focused element or null
|
|
67
|
+
*/
|
|
68
|
+
export function focusLast(container) {
|
|
69
|
+
const focusable = getFocusableElements(container);
|
|
70
|
+
if (focusable.length > 0) {
|
|
71
|
+
focusable[focusable.length - 1].focus();
|
|
72
|
+
return focusable[focusable.length - 1];
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Trap focus within a container (for modals, dialogs)
|
|
79
|
+
* @param {HTMLElement} container - Container to trap focus in
|
|
80
|
+
* @param {object} options - Options
|
|
81
|
+
* @param {boolean} options.autoFocus - Auto focus first element (default: true)
|
|
82
|
+
* @param {boolean} options.returnFocus - Return focus on release (default: true)
|
|
83
|
+
* @param {HTMLElement} options.initialFocus - Element to focus initially
|
|
84
|
+
* @returns {Function} Release function to remove trap
|
|
85
|
+
*/
|
|
86
|
+
export function trapFocus(container, options = {}) {
|
|
87
|
+
const { autoFocus = true, returnFocus = true, initialFocus = null } = options;
|
|
88
|
+
|
|
89
|
+
if (!container) return () => {};
|
|
90
|
+
|
|
91
|
+
// Save current focus
|
|
92
|
+
const previouslyFocused = document.activeElement;
|
|
93
|
+
if (returnFocus) {
|
|
94
|
+
focusStack.push(previouslyFocused);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Handle Tab key
|
|
98
|
+
const handleKeyDown = (e) => {
|
|
99
|
+
if (e.key !== 'Tab') return;
|
|
100
|
+
|
|
101
|
+
const focusable = getFocusableElements(container);
|
|
102
|
+
if (focusable.length === 0) {
|
|
103
|
+
e.preventDefault();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const firstFocusable = focusable[0];
|
|
108
|
+
const lastFocusable = focusable[focusable.length - 1];
|
|
109
|
+
|
|
110
|
+
if (e.shiftKey) {
|
|
111
|
+
// Shift + Tab: going backwards
|
|
112
|
+
if (document.activeElement === firstFocusable) {
|
|
113
|
+
e.preventDefault();
|
|
114
|
+
lastFocusable.focus();
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
// Tab: going forwards
|
|
118
|
+
if (document.activeElement === lastFocusable) {
|
|
119
|
+
e.preventDefault();
|
|
120
|
+
firstFocusable.focus();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// Handle focus leaving container
|
|
126
|
+
const handleFocusOut = (e) => {
|
|
127
|
+
if (!container.contains(e.relatedTarget)) {
|
|
128
|
+
// Focus is leaving container, bring it back
|
|
129
|
+
const focusable = getFocusableElements(container);
|
|
130
|
+
if (focusable.length > 0) {
|
|
131
|
+
focusable[0].focus();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
container.addEventListener('keydown', handleKeyDown);
|
|
137
|
+
container.addEventListener('focusout', handleFocusOut);
|
|
138
|
+
|
|
139
|
+
// Set initial focus
|
|
140
|
+
if (autoFocus) {
|
|
141
|
+
requestAnimationFrame(() => {
|
|
142
|
+
if (initialFocus && container.contains(initialFocus)) {
|
|
143
|
+
initialFocus.focus();
|
|
144
|
+
} else {
|
|
145
|
+
focusFirst(container);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Return release function
|
|
151
|
+
return function releaseFocusTrap() {
|
|
152
|
+
container.removeEventListener('keydown', handleKeyDown);
|
|
153
|
+
container.removeEventListener('focusout', handleFocusOut);
|
|
154
|
+
|
|
155
|
+
if (returnFocus && focusStack.length > 0) {
|
|
156
|
+
const toFocus = focusStack.pop();
|
|
157
|
+
if (toFocus && typeof toFocus.focus === 'function') {
|
|
158
|
+
toFocus.focus();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Save current focus to stack
|
|
166
|
+
*/
|
|
167
|
+
export function saveFocus() {
|
|
168
|
+
focusStack.push(document.activeElement);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Restore focus from stack
|
|
173
|
+
*/
|
|
174
|
+
export function restoreFocus() {
|
|
175
|
+
if (focusStack.length > 0) {
|
|
176
|
+
const element = focusStack.pop();
|
|
177
|
+
if (element && typeof element.focus === 'function') {
|
|
178
|
+
element.focus();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Clear focus stack
|
|
185
|
+
*/
|
|
186
|
+
export function clearFocusStack() {
|
|
187
|
+
focusStack.length = 0;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Add escape key handler for dismissing modals/dialogs
|
|
192
|
+
* @param {HTMLElement} container - Container element
|
|
193
|
+
* @param {Function} onEscape - Callback when escape is pressed
|
|
194
|
+
* @param {object} options - Options
|
|
195
|
+
* @param {boolean} options.stopPropagation - Stop event propagation (default: true)
|
|
196
|
+
* @returns {Function} Cleanup function to remove handler
|
|
197
|
+
*/
|
|
198
|
+
export function onEscapeKey(container, onEscape, options = {}) {
|
|
199
|
+
const { stopPropagation = true } = options;
|
|
200
|
+
|
|
201
|
+
if (!container) return () => {};
|
|
202
|
+
|
|
203
|
+
const handleKeyDown = (e) => {
|
|
204
|
+
if (e.key === 'Escape' || e.key === 'Esc') {
|
|
205
|
+
if (stopPropagation) {
|
|
206
|
+
e.stopPropagation();
|
|
207
|
+
}
|
|
208
|
+
onEscape(e);
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
container.addEventListener('keydown', handleKeyDown);
|
|
213
|
+
|
|
214
|
+
return () => {
|
|
215
|
+
container.removeEventListener('keydown', handleKeyDown);
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Track whether the user is navigating with keyboard
|
|
221
|
+
* Useful for implementing :focus-visible behavior
|
|
222
|
+
* @returns {{ isKeyboardUser: object, cleanup: Function }} isKeyboardUser is a pulse
|
|
223
|
+
*/
|
|
224
|
+
export function createFocusVisibleTracker() {
|
|
225
|
+
const isKeyboardUser = pulse(false);
|
|
226
|
+
|
|
227
|
+
if (typeof document === 'undefined') {
|
|
228
|
+
return { isKeyboardUser, cleanup: () => {} };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const handleKeyDown = (e) => {
|
|
232
|
+
if (e.key === 'Tab' || e.key === 'ArrowUp' || e.key === 'ArrowDown' ||
|
|
233
|
+
e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
|
234
|
+
isKeyboardUser.set(true);
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const handleMouseDown = () => {
|
|
239
|
+
isKeyboardUser.set(false);
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
document.addEventListener('keydown', handleKeyDown, true);
|
|
243
|
+
document.addEventListener('mousedown', handleMouseDown, true);
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
isKeyboardUser,
|
|
247
|
+
cleanup: () => {
|
|
248
|
+
document.removeEventListener('keydown', handleKeyDown, true);
|
|
249
|
+
document.removeEventListener('mousedown', handleMouseDown, true);
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// =============================================================================
|
|
255
|
+
// SKIP LINKS
|
|
256
|
+
// =============================================================================
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Create a skip link for keyboard navigation
|
|
260
|
+
* @param {string} targetId - ID of target element to skip to
|
|
261
|
+
* @param {string} text - Link text (default: 'Skip to main content')
|
|
262
|
+
* @param {object} options - Options
|
|
263
|
+
* @returns {HTMLElement} The skip link element
|
|
264
|
+
*/
|
|
265
|
+
export function createSkipLink(targetId, text = 'Skip to main content', options = {}) {
|
|
266
|
+
const { className = 'pulse-skip-link' } = options;
|
|
267
|
+
|
|
268
|
+
const link = document.createElement('a');
|
|
269
|
+
link.href = `#${targetId}`;
|
|
270
|
+
link.textContent = text;
|
|
271
|
+
link.className = className;
|
|
272
|
+
|
|
273
|
+
// Visually hidden but focusable styles
|
|
274
|
+
Object.assign(link.style, {
|
|
275
|
+
position: 'absolute',
|
|
276
|
+
top: '-40px',
|
|
277
|
+
left: '0',
|
|
278
|
+
padding: '8px 16px',
|
|
279
|
+
background: '#000',
|
|
280
|
+
color: '#fff',
|
|
281
|
+
textDecoration: 'none',
|
|
282
|
+
zIndex: '10000',
|
|
283
|
+
transition: 'top 0.2s'
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// Show on focus
|
|
287
|
+
link.addEventListener('focus', () => {
|
|
288
|
+
link.style.top = '0';
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
link.addEventListener('blur', () => {
|
|
292
|
+
link.style.top = '-40px';
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// Handle click
|
|
296
|
+
link.addEventListener('click', (e) => {
|
|
297
|
+
e.preventDefault();
|
|
298
|
+
const target = document.getElementById(targetId);
|
|
299
|
+
if (target) {
|
|
300
|
+
target.setAttribute('tabindex', '-1');
|
|
301
|
+
target.focus();
|
|
302
|
+
target.removeAttribute('tabindex');
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
return link;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Install skip links at the beginning of the document
|
|
311
|
+
* @param {Array<{target: string, text: string}>} links - Skip link definitions
|
|
312
|
+
*/
|
|
313
|
+
export function installSkipLinks(links) {
|
|
314
|
+
const container = document.createElement('nav');
|
|
315
|
+
container.setAttribute('aria-label', 'Skip links');
|
|
316
|
+
container.className = 'pulse-skip-links';
|
|
317
|
+
|
|
318
|
+
links.forEach(({ target, text }) => {
|
|
319
|
+
container.appendChild(createSkipLink(target, text));
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
document.body.insertBefore(container, document.body.firstChild);
|
|
323
|
+
return container;
|
|
324
|
+
}
|
|
325
|
+
// =============================================================================
|
|
326
|
+
// KEYBOARD NAVIGATION
|
|
327
|
+
// =============================================================================
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Handle arrow key navigation within a container (roving tabindex)
|
|
331
|
+
* @param {HTMLElement} container - Container element
|
|
332
|
+
* @param {object} options - Options
|
|
333
|
+
* @returns {Function} Cleanup function
|
|
334
|
+
*/
|
|
335
|
+
export function createRovingTabindex(container, options = {}) {
|
|
336
|
+
const {
|
|
337
|
+
selector = '[role="option"], [role="menuitem"], [role="treeitem"]',
|
|
338
|
+
orientation = 'vertical',
|
|
339
|
+
loop = true,
|
|
340
|
+
onSelect = null
|
|
341
|
+
} = options;
|
|
342
|
+
|
|
343
|
+
const getItems = () => Array.from(container.querySelectorAll(selector))
|
|
344
|
+
.filter(el => !el.hasAttribute('disabled') && el.getAttribute('aria-disabled') !== 'true');
|
|
345
|
+
|
|
346
|
+
const items = getItems();
|
|
347
|
+
if (items.length === 0) return () => {};
|
|
348
|
+
|
|
349
|
+
// Initialize tabindex
|
|
350
|
+
items.forEach((item, i) => {
|
|
351
|
+
item.setAttribute('tabindex', i === 0 ? '0' : '-1');
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
const handleKeyDown = (e) => {
|
|
355
|
+
const items = getItems();
|
|
356
|
+
const currentIndex = items.findIndex(item => item === document.activeElement);
|
|
357
|
+
if (currentIndex === -1) return;
|
|
358
|
+
|
|
359
|
+
const isVertical = orientation === 'vertical';
|
|
360
|
+
const prevKey = isVertical ? 'ArrowUp' : 'ArrowLeft';
|
|
361
|
+
const nextKey = isVertical ? 'ArrowDown' : 'ArrowRight';
|
|
362
|
+
|
|
363
|
+
let newIndex = currentIndex;
|
|
364
|
+
|
|
365
|
+
switch (e.key) {
|
|
366
|
+
case prevKey:
|
|
367
|
+
if (loop) {
|
|
368
|
+
newIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
|
|
369
|
+
} else {
|
|
370
|
+
newIndex = Math.max(0, currentIndex - 1);
|
|
371
|
+
}
|
|
372
|
+
break;
|
|
373
|
+
case nextKey:
|
|
374
|
+
if (loop) {
|
|
375
|
+
newIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
|
|
376
|
+
} else {
|
|
377
|
+
newIndex = Math.min(items.length - 1, currentIndex + 1);
|
|
378
|
+
}
|
|
379
|
+
break;
|
|
380
|
+
case 'Home':
|
|
381
|
+
newIndex = 0;
|
|
382
|
+
break;
|
|
383
|
+
case 'End':
|
|
384
|
+
newIndex = items.length - 1;
|
|
385
|
+
break;
|
|
386
|
+
case 'Enter':
|
|
387
|
+
case ' ':
|
|
388
|
+
if (onSelect) {
|
|
389
|
+
e.preventDefault();
|
|
390
|
+
onSelect(items[currentIndex], currentIndex);
|
|
391
|
+
}
|
|
392
|
+
return;
|
|
393
|
+
default:
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
e.preventDefault();
|
|
398
|
+
|
|
399
|
+
// Update tabindex
|
|
400
|
+
items.forEach((item, i) => {
|
|
401
|
+
item.setAttribute('tabindex', i === newIndex ? '0' : '-1');
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
items[newIndex].focus();
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
container.addEventListener('keydown', handleKeyDown);
|
|
408
|
+
|
|
409
|
+
return () => {
|
|
410
|
+
container.removeEventListener('keydown', handleKeyDown);
|
|
411
|
+
};
|
|
412
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse A11y - Main Entry Point
|
|
3
|
+
*
|
|
4
|
+
* Barrel export for all accessibility modules
|
|
5
|
+
*
|
|
6
|
+
* @module pulse-js-framework/runtime/a11y
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// Export all from sub-modules
|
|
10
|
+
export * from './announcements.js';
|
|
11
|
+
export * from './focus.js';
|
|
12
|
+
export * from './preferences.js';
|
|
13
|
+
export * from './widgets.js';
|
|
14
|
+
export * from './validation.js';
|
|
15
|
+
export * from './contrast.js';
|
|
16
|
+
export * from './utils.js';
|
|
17
|
+
|
|
18
|
+
// Default export for backward compatibility
|
|
19
|
+
import * as announcements from './announcements.js';
|
|
20
|
+
import * as focus from './focus.js';
|
|
21
|
+
import * as preferences from './preferences.js';
|
|
22
|
+
import * as widgets from './widgets.js';
|
|
23
|
+
import * as validation from './validation.js';
|
|
24
|
+
import * as contrast from './contrast.js';
|
|
25
|
+
import * as utils from './utils.js';
|
|
26
|
+
|
|
27
|
+
export default {
|
|
28
|
+
...announcements,
|
|
29
|
+
...focus,
|
|
30
|
+
...preferences,
|
|
31
|
+
...widgets,
|
|
32
|
+
...validation,
|
|
33
|
+
...contrast,
|
|
34
|
+
...utils
|
|
35
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse A11y - User Preferences
|
|
3
|
+
*
|
|
4
|
+
* User preference detection (reduced motion, color scheme, etc.)
|
|
5
|
+
*
|
|
6
|
+
* @module pulse-js-framework/runtime/a11y/preferences
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { pulse } from '../pulse.js';
|
|
10
|
+
|
|
11
|
+
// =============================================================================
|
|
12
|
+
// USER PREFERENCES
|
|
13
|
+
// =============================================================================
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Check if user prefers reduced motion
|
|
17
|
+
* @returns {boolean}
|
|
18
|
+
*/
|
|
19
|
+
export function prefersReducedMotion() {
|
|
20
|
+
if (typeof window === 'undefined') return false;
|
|
21
|
+
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Check user's preferred color scheme
|
|
26
|
+
* @returns {'light'|'dark'|'no-preference'}
|
|
27
|
+
*/
|
|
28
|
+
export function prefersColorScheme() {
|
|
29
|
+
if (typeof window === 'undefined') return 'no-preference';
|
|
30
|
+
if (window.matchMedia('(prefers-color-scheme: dark)').matches) return 'dark';
|
|
31
|
+
if (window.matchMedia('(prefers-color-scheme: light)').matches) return 'light';
|
|
32
|
+
return 'no-preference';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Check if user prefers high contrast
|
|
37
|
+
* @returns {boolean}
|
|
38
|
+
*/
|
|
39
|
+
export function prefersHighContrast() {
|
|
40
|
+
if (typeof window === 'undefined') return false;
|
|
41
|
+
return window.matchMedia('(prefers-contrast: more)').matches;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Check if user prefers reduced transparency
|
|
46
|
+
* @returns {boolean}
|
|
47
|
+
*/
|
|
48
|
+
export function prefersReducedTransparency() {
|
|
49
|
+
if (typeof window === 'undefined') return false;
|
|
50
|
+
return window.matchMedia('(prefers-reduced-transparency: reduce)').matches;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Check if forced-colors mode is active (Windows High Contrast)
|
|
55
|
+
* @returns {'none'|'active'}
|
|
56
|
+
*/
|
|
57
|
+
export function forcedColorsMode() {
|
|
58
|
+
if (typeof window === 'undefined') return 'none';
|
|
59
|
+
if (window.matchMedia('(forced-colors: active)').matches) return 'active';
|
|
60
|
+
return 'none';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Check user's contrast preference (more detailed than prefersHighContrast)
|
|
65
|
+
* @returns {'no-preference'|'more'|'less'|'custom'}
|
|
66
|
+
*/
|
|
67
|
+
export function prefersContrast() {
|
|
68
|
+
if (typeof window === 'undefined') return 'no-preference';
|
|
69
|
+
if (window.matchMedia('(prefers-contrast: more)').matches) return 'more';
|
|
70
|
+
if (window.matchMedia('(prefers-contrast: less)').matches) return 'less';
|
|
71
|
+
if (window.matchMedia('(prefers-contrast: custom)').matches) return 'custom';
|
|
72
|
+
return 'no-preference';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Create reactive user preferences pulse
|
|
77
|
+
* @returns {object} Object with reactive preference pulses
|
|
78
|
+
*/
|
|
79
|
+
export function createPreferences() {
|
|
80
|
+
const reducedMotion = pulse(prefersReducedMotion());
|
|
81
|
+
const colorScheme = pulse(prefersColorScheme());
|
|
82
|
+
const highContrast = pulse(prefersHighContrast());
|
|
83
|
+
const reducedTransparency = pulse(prefersReducedTransparency());
|
|
84
|
+
const forcedColors = pulse(forcedColorsMode());
|
|
85
|
+
const contrast = pulse(prefersContrast());
|
|
86
|
+
|
|
87
|
+
const listeners = [];
|
|
88
|
+
|
|
89
|
+
if (typeof window !== 'undefined') {
|
|
90
|
+
const track = (query, handler) => {
|
|
91
|
+
const mql = window.matchMedia(query);
|
|
92
|
+
mql.addEventListener('change', handler);
|
|
93
|
+
listeners.push({ mql, handler });
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
track('(prefers-reduced-motion: reduce)', (e) => reducedMotion.set(e.matches));
|
|
97
|
+
track('(prefers-color-scheme: dark)', (e) => colorScheme.set(e.matches ? 'dark' : 'light'));
|
|
98
|
+
track('(prefers-contrast: more)', (e) => highContrast.set(e.matches));
|
|
99
|
+
track('(prefers-reduced-transparency: reduce)', (e) => reducedTransparency.set(e.matches));
|
|
100
|
+
track('(forced-colors: active)', (e) => forcedColors.set(e.matches ? 'active' : 'none'));
|
|
101
|
+
track('(prefers-contrast: more)', () => contrast.set(prefersContrast()));
|
|
102
|
+
track('(prefers-contrast: less)', () => contrast.set(prefersContrast()));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const cleanup = () => {
|
|
106
|
+
for (const { mql, handler } of listeners) {
|
|
107
|
+
mql.removeEventListener('change', handler);
|
|
108
|
+
}
|
|
109
|
+
listeners.length = 0;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
reducedMotion,
|
|
114
|
+
colorScheme,
|
|
115
|
+
highContrast,
|
|
116
|
+
reducedTransparency,
|
|
117
|
+
forcedColors,
|
|
118
|
+
contrast,
|
|
119
|
+
cleanup
|
|
120
|
+
};
|
|
121
|
+
}
|