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