pulse-js-framework 1.7.9 → 1.7.10
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/cli/lint.js +442 -3
- package/compiler/lexer.js +6 -0
- package/compiler/parser.js +144 -1
- package/compiler/transformer/imports.js +15 -0
- package/compiler/transformer/index.js +46 -0
- package/compiler/transformer/view.js +180 -5
- package/package.json +9 -2
- package/runtime/a11y.js +1005 -0
- package/runtime/devtools/a11y-audit.js +442 -0
- package/runtime/devtools/diagnostics.js +403 -0
- package/runtime/devtools/index.js +53 -0
- package/runtime/devtools/time-travel.js +189 -0
- package/runtime/devtools.js +138 -497
- package/runtime/dom-binding.js +7 -4
- package/runtime/dom-element.js +192 -1
- package/runtime/dom.js +8 -2
- package/runtime/index.js +2 -0
- package/runtime/native.js +2 -2
- package/runtime/security.js +461 -0
- package/runtime/utils.js +37 -16
- package/types/a11y.d.ts +336 -0
package/runtime/a11y.js
ADDED
|
@@ -0,0 +1,1005 @@
|
|
|
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
|
+
// SKIP LINKS
|
|
316
|
+
// =============================================================================
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Create a skip link for keyboard navigation
|
|
320
|
+
* @param {string} targetId - ID of target element to skip to
|
|
321
|
+
* @param {string} text - Link text (default: 'Skip to main content')
|
|
322
|
+
* @param {object} options - Options
|
|
323
|
+
* @returns {HTMLElement} The skip link element
|
|
324
|
+
*/
|
|
325
|
+
export function createSkipLink(targetId, text = 'Skip to main content', options = {}) {
|
|
326
|
+
const { className = 'pulse-skip-link' } = options;
|
|
327
|
+
|
|
328
|
+
const link = document.createElement('a');
|
|
329
|
+
link.href = `#${targetId}`;
|
|
330
|
+
link.textContent = text;
|
|
331
|
+
link.className = className;
|
|
332
|
+
|
|
333
|
+
// Visually hidden but focusable styles
|
|
334
|
+
Object.assign(link.style, {
|
|
335
|
+
position: 'absolute',
|
|
336
|
+
top: '-40px',
|
|
337
|
+
left: '0',
|
|
338
|
+
padding: '8px 16px',
|
|
339
|
+
background: '#000',
|
|
340
|
+
color: '#fff',
|
|
341
|
+
textDecoration: 'none',
|
|
342
|
+
zIndex: '10000',
|
|
343
|
+
transition: 'top 0.2s'
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// Show on focus
|
|
347
|
+
link.addEventListener('focus', () => {
|
|
348
|
+
link.style.top = '0';
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
link.addEventListener('blur', () => {
|
|
352
|
+
link.style.top = '-40px';
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// Handle click
|
|
356
|
+
link.addEventListener('click', (e) => {
|
|
357
|
+
e.preventDefault();
|
|
358
|
+
const target = document.getElementById(targetId);
|
|
359
|
+
if (target) {
|
|
360
|
+
target.setAttribute('tabindex', '-1');
|
|
361
|
+
target.focus();
|
|
362
|
+
target.removeAttribute('tabindex');
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
return link;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Install skip links at the beginning of the document
|
|
371
|
+
* @param {Array<{target: string, text: string}>} links - Skip link definitions
|
|
372
|
+
*/
|
|
373
|
+
export function installSkipLinks(links) {
|
|
374
|
+
const container = document.createElement('nav');
|
|
375
|
+
container.setAttribute('aria-label', 'Skip links');
|
|
376
|
+
container.className = 'pulse-skip-links';
|
|
377
|
+
|
|
378
|
+
links.forEach(({ target, text }) => {
|
|
379
|
+
container.appendChild(createSkipLink(target, text));
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
document.body.insertBefore(container, document.body.firstChild);
|
|
383
|
+
return container;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// =============================================================================
|
|
387
|
+
// USER PREFERENCES
|
|
388
|
+
// =============================================================================
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Check if user prefers reduced motion
|
|
392
|
+
* @returns {boolean}
|
|
393
|
+
*/
|
|
394
|
+
export function prefersReducedMotion() {
|
|
395
|
+
if (typeof window === 'undefined') return false;
|
|
396
|
+
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Check user's preferred color scheme
|
|
401
|
+
* @returns {'light'|'dark'|'no-preference'}
|
|
402
|
+
*/
|
|
403
|
+
export function prefersColorScheme() {
|
|
404
|
+
if (typeof window === 'undefined') return 'no-preference';
|
|
405
|
+
if (window.matchMedia('(prefers-color-scheme: dark)').matches) return 'dark';
|
|
406
|
+
if (window.matchMedia('(prefers-color-scheme: light)').matches) return 'light';
|
|
407
|
+
return 'no-preference';
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Check if user prefers high contrast
|
|
412
|
+
* @returns {boolean}
|
|
413
|
+
*/
|
|
414
|
+
export function prefersHighContrast() {
|
|
415
|
+
if (typeof window === 'undefined') return false;
|
|
416
|
+
return window.matchMedia('(prefers-contrast: more)').matches;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Create reactive user preferences pulse
|
|
421
|
+
* @returns {object} Object with reactive preference pulses
|
|
422
|
+
*/
|
|
423
|
+
export function createPreferences() {
|
|
424
|
+
const reducedMotion = pulse(prefersReducedMotion());
|
|
425
|
+
const colorScheme = pulse(prefersColorScheme());
|
|
426
|
+
const highContrast = pulse(prefersHighContrast());
|
|
427
|
+
|
|
428
|
+
if (typeof window !== 'undefined') {
|
|
429
|
+
// Listen for preference changes
|
|
430
|
+
window.matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', (e) => {
|
|
431
|
+
reducedMotion.set(e.matches);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
|
435
|
+
colorScheme.set(e.matches ? 'dark' : 'light');
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
window.matchMedia('(prefers-contrast: more)').addEventListener('change', (e) => {
|
|
439
|
+
highContrast.set(e.matches);
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return {
|
|
444
|
+
reducedMotion,
|
|
445
|
+
colorScheme,
|
|
446
|
+
highContrast
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// =============================================================================
|
|
451
|
+
// ARIA HELPERS
|
|
452
|
+
// =============================================================================
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Set multiple ARIA attributes on an element
|
|
456
|
+
* @param {HTMLElement} element - Target element
|
|
457
|
+
* @param {object} attrs - ARIA attributes (without 'aria-' prefix)
|
|
458
|
+
*/
|
|
459
|
+
export function setAriaAttributes(element, attrs) {
|
|
460
|
+
Object.entries(attrs).forEach(([key, value]) => {
|
|
461
|
+
if (value === null || value === undefined) {
|
|
462
|
+
element.removeAttribute(`aria-${key}`);
|
|
463
|
+
} else {
|
|
464
|
+
element.setAttribute(`aria-${key}`, String(value));
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Create an ARIA-compliant disclosure widget
|
|
471
|
+
* @param {HTMLElement} trigger - Button that toggles disclosure
|
|
472
|
+
* @param {HTMLElement} content - Content to show/hide
|
|
473
|
+
* @param {object} options - Options
|
|
474
|
+
* @returns {object} Control object with toggle, open, close methods
|
|
475
|
+
*/
|
|
476
|
+
export function createDisclosure(trigger, content, options = {}) {
|
|
477
|
+
const { defaultOpen = false, onToggle = null } = options;
|
|
478
|
+
|
|
479
|
+
const expanded = pulse(defaultOpen);
|
|
480
|
+
const id = content.id || `pulse-disclosure-${Date.now()}`;
|
|
481
|
+
|
|
482
|
+
content.id = id;
|
|
483
|
+
trigger.setAttribute('aria-controls', id);
|
|
484
|
+
trigger.setAttribute('aria-expanded', String(expanded.get()));
|
|
485
|
+
|
|
486
|
+
// Update visibility
|
|
487
|
+
effect(() => {
|
|
488
|
+
const isOpen = expanded.get();
|
|
489
|
+
trigger.setAttribute('aria-expanded', String(isOpen));
|
|
490
|
+
content.hidden = !isOpen;
|
|
491
|
+
if (onToggle) onToggle(isOpen);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
// Handle click
|
|
495
|
+
trigger.addEventListener('click', () => {
|
|
496
|
+
expanded.update(v => !v);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
// Handle keyboard
|
|
500
|
+
trigger.addEventListener('keydown', (e) => {
|
|
501
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
502
|
+
e.preventDefault();
|
|
503
|
+
expanded.update(v => !v);
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
return {
|
|
508
|
+
expanded,
|
|
509
|
+
toggle: () => expanded.update(v => !v),
|
|
510
|
+
open: () => expanded.set(true),
|
|
511
|
+
close: () => expanded.set(false)
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Create ARIA-compliant tabs
|
|
517
|
+
* @param {HTMLElement} tablist - Container with role="tablist"
|
|
518
|
+
* @param {object} options - Options
|
|
519
|
+
* @returns {object} Control object
|
|
520
|
+
*/
|
|
521
|
+
export function createTabs(tablist, options = {}) {
|
|
522
|
+
const { defaultIndex = 0, orientation = 'horizontal', onSelect = null } = options;
|
|
523
|
+
|
|
524
|
+
const tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
|
|
525
|
+
const panels = tabs.map(tab => {
|
|
526
|
+
const panelId = tab.getAttribute('aria-controls');
|
|
527
|
+
return document.getElementById(panelId);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
const selectedIndex = pulse(defaultIndex);
|
|
531
|
+
|
|
532
|
+
tablist.setAttribute('aria-orientation', orientation);
|
|
533
|
+
|
|
534
|
+
// Update selection
|
|
535
|
+
effect(() => {
|
|
536
|
+
const index = selectedIndex.get();
|
|
537
|
+
|
|
538
|
+
tabs.forEach((tab, i) => {
|
|
539
|
+
const isSelected = i === index;
|
|
540
|
+
tab.setAttribute('aria-selected', String(isSelected));
|
|
541
|
+
tab.setAttribute('tabindex', isSelected ? '0' : '-1');
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
panels.forEach((panel, i) => {
|
|
545
|
+
if (panel) {
|
|
546
|
+
panel.hidden = i !== index;
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
if (onSelect) onSelect(index);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
// Handle click
|
|
554
|
+
tabs.forEach((tab, i) => {
|
|
555
|
+
tab.addEventListener('click', () => {
|
|
556
|
+
selectedIndex.set(i);
|
|
557
|
+
tab.focus();
|
|
558
|
+
});
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
// Handle keyboard navigation
|
|
562
|
+
tablist.addEventListener('keydown', (e) => {
|
|
563
|
+
const currentIndex = selectedIndex.get();
|
|
564
|
+
let newIndex = currentIndex;
|
|
565
|
+
|
|
566
|
+
const isHorizontal = orientation === 'horizontal';
|
|
567
|
+
const prevKey = isHorizontal ? 'ArrowLeft' : 'ArrowUp';
|
|
568
|
+
const nextKey = isHorizontal ? 'ArrowRight' : 'ArrowDown';
|
|
569
|
+
|
|
570
|
+
switch (e.key) {
|
|
571
|
+
case prevKey:
|
|
572
|
+
newIndex = currentIndex > 0 ? currentIndex - 1 : tabs.length - 1;
|
|
573
|
+
break;
|
|
574
|
+
case nextKey:
|
|
575
|
+
newIndex = currentIndex < tabs.length - 1 ? currentIndex + 1 : 0;
|
|
576
|
+
break;
|
|
577
|
+
case 'Home':
|
|
578
|
+
newIndex = 0;
|
|
579
|
+
break;
|
|
580
|
+
case 'End':
|
|
581
|
+
newIndex = tabs.length - 1;
|
|
582
|
+
break;
|
|
583
|
+
default:
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
e.preventDefault();
|
|
588
|
+
selectedIndex.set(newIndex);
|
|
589
|
+
tabs[newIndex].focus();
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
return {
|
|
593
|
+
selectedIndex,
|
|
594
|
+
select: (index) => selectedIndex.set(index),
|
|
595
|
+
tabs,
|
|
596
|
+
panels
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// =============================================================================
|
|
601
|
+
// KEYBOARD NAVIGATION
|
|
602
|
+
// =============================================================================
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Handle arrow key navigation within a container (roving tabindex)
|
|
606
|
+
* @param {HTMLElement} container - Container element
|
|
607
|
+
* @param {object} options - Options
|
|
608
|
+
* @returns {Function} Cleanup function
|
|
609
|
+
*/
|
|
610
|
+
export function createRovingTabindex(container, options = {}) {
|
|
611
|
+
const {
|
|
612
|
+
selector = '[role="option"], [role="menuitem"], [role="treeitem"]',
|
|
613
|
+
orientation = 'vertical',
|
|
614
|
+
loop = true,
|
|
615
|
+
onSelect = null
|
|
616
|
+
} = options;
|
|
617
|
+
|
|
618
|
+
const getItems = () => Array.from(container.querySelectorAll(selector))
|
|
619
|
+
.filter(el => !el.hasAttribute('disabled') && el.getAttribute('aria-disabled') !== 'true');
|
|
620
|
+
|
|
621
|
+
const items = getItems();
|
|
622
|
+
if (items.length === 0) return () => {};
|
|
623
|
+
|
|
624
|
+
// Initialize tabindex
|
|
625
|
+
items.forEach((item, i) => {
|
|
626
|
+
item.setAttribute('tabindex', i === 0 ? '0' : '-1');
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
const handleKeyDown = (e) => {
|
|
630
|
+
const items = getItems();
|
|
631
|
+
const currentIndex = items.findIndex(item => item === document.activeElement);
|
|
632
|
+
if (currentIndex === -1) return;
|
|
633
|
+
|
|
634
|
+
const isVertical = orientation === 'vertical';
|
|
635
|
+
const prevKey = isVertical ? 'ArrowUp' : 'ArrowLeft';
|
|
636
|
+
const nextKey = isVertical ? 'ArrowDown' : 'ArrowRight';
|
|
637
|
+
|
|
638
|
+
let newIndex = currentIndex;
|
|
639
|
+
|
|
640
|
+
switch (e.key) {
|
|
641
|
+
case prevKey:
|
|
642
|
+
if (loop) {
|
|
643
|
+
newIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
|
|
644
|
+
} else {
|
|
645
|
+
newIndex = Math.max(0, currentIndex - 1);
|
|
646
|
+
}
|
|
647
|
+
break;
|
|
648
|
+
case nextKey:
|
|
649
|
+
if (loop) {
|
|
650
|
+
newIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
|
|
651
|
+
} else {
|
|
652
|
+
newIndex = Math.min(items.length - 1, currentIndex + 1);
|
|
653
|
+
}
|
|
654
|
+
break;
|
|
655
|
+
case 'Home':
|
|
656
|
+
newIndex = 0;
|
|
657
|
+
break;
|
|
658
|
+
case 'End':
|
|
659
|
+
newIndex = items.length - 1;
|
|
660
|
+
break;
|
|
661
|
+
case 'Enter':
|
|
662
|
+
case ' ':
|
|
663
|
+
if (onSelect) {
|
|
664
|
+
e.preventDefault();
|
|
665
|
+
onSelect(items[currentIndex], currentIndex);
|
|
666
|
+
}
|
|
667
|
+
return;
|
|
668
|
+
default:
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
e.preventDefault();
|
|
673
|
+
|
|
674
|
+
// Update tabindex
|
|
675
|
+
items.forEach((item, i) => {
|
|
676
|
+
item.setAttribute('tabindex', i === newIndex ? '0' : '-1');
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
items[newIndex].focus();
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
container.addEventListener('keydown', handleKeyDown);
|
|
683
|
+
|
|
684
|
+
return () => {
|
|
685
|
+
container.removeEventListener('keydown', handleKeyDown);
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// =============================================================================
|
|
690
|
+
// VALIDATION & AUDITING
|
|
691
|
+
// =============================================================================
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* A11y issues found during validation
|
|
695
|
+
* @typedef {object} A11yIssue
|
|
696
|
+
* @property {'error'|'warning'} severity - Issue severity
|
|
697
|
+
* @property {string} rule - Rule identifier
|
|
698
|
+
* @property {string} message - Human-readable message
|
|
699
|
+
* @property {HTMLElement} element - The element with the issue
|
|
700
|
+
*/
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Validate accessibility of a container
|
|
704
|
+
* @param {HTMLElement} container - Container to validate (default: document.body)
|
|
705
|
+
* @returns {A11yIssue[]} Array of issues found
|
|
706
|
+
*/
|
|
707
|
+
export function validateA11y(container = document.body) {
|
|
708
|
+
const issues = [];
|
|
709
|
+
|
|
710
|
+
const addIssue = (severity, rule, message, element) => {
|
|
711
|
+
issues.push({ severity, rule, message, element });
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
// Check images for alt text
|
|
715
|
+
container.querySelectorAll('img').forEach(img => {
|
|
716
|
+
if (!img.hasAttribute('alt')) {
|
|
717
|
+
addIssue('error', 'img-alt', 'Image missing alt attribute', img);
|
|
718
|
+
} else if (img.alt === '') {
|
|
719
|
+
// Empty alt is OK for decorative images, but warn
|
|
720
|
+
if (!img.getAttribute('role')?.includes('presentation')) {
|
|
721
|
+
addIssue('warning', 'img-alt-empty', 'Image has empty alt - ensure it is decorative', img);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
// Check buttons for accessible names
|
|
727
|
+
container.querySelectorAll('button').forEach(button => {
|
|
728
|
+
const hasText = button.textContent.trim().length > 0;
|
|
729
|
+
const hasAriaLabel = button.hasAttribute('aria-label');
|
|
730
|
+
const hasAriaLabelledBy = button.hasAttribute('aria-labelledby');
|
|
731
|
+
const hasTitle = button.hasAttribute('title');
|
|
732
|
+
|
|
733
|
+
if (!hasText && !hasAriaLabel && !hasAriaLabelledBy && !hasTitle) {
|
|
734
|
+
addIssue('error', 'button-name', 'Button has no accessible name', button);
|
|
735
|
+
}
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
// Check links for accessible names
|
|
739
|
+
container.querySelectorAll('a[href]').forEach(link => {
|
|
740
|
+
const hasText = link.textContent.trim().length > 0;
|
|
741
|
+
const hasAriaLabel = link.hasAttribute('aria-label');
|
|
742
|
+
const hasImg = link.querySelector('img[alt]');
|
|
743
|
+
|
|
744
|
+
if (!hasText && !hasAriaLabel && !hasImg) {
|
|
745
|
+
addIssue('error', 'link-name', 'Link has no accessible name', link);
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
// Check form inputs for labels
|
|
750
|
+
container.querySelectorAll('input, select, textarea').forEach(input => {
|
|
751
|
+
if (input.type === 'hidden' || input.type === 'submit' || input.type === 'button') return;
|
|
752
|
+
|
|
753
|
+
const id = input.id;
|
|
754
|
+
const hasLabel = id && container.querySelector(`label[for="${id}"]`);
|
|
755
|
+
const hasAriaLabel = input.hasAttribute('aria-label');
|
|
756
|
+
const hasAriaLabelledBy = input.hasAttribute('aria-labelledby');
|
|
757
|
+
const isWrappedByLabel = input.closest('label');
|
|
758
|
+
const hasPlaceholder = input.hasAttribute('placeholder');
|
|
759
|
+
|
|
760
|
+
if (!hasLabel && !hasAriaLabel && !hasAriaLabelledBy && !isWrappedByLabel) {
|
|
761
|
+
const msg = hasPlaceholder
|
|
762
|
+
? 'Form input uses placeholder but missing label (placeholder is not a label substitute)'
|
|
763
|
+
: 'Form input missing associated label';
|
|
764
|
+
addIssue('error', 'input-label', msg, input);
|
|
765
|
+
}
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
// Check for positive tabindex (anti-pattern)
|
|
769
|
+
container.querySelectorAll('[tabindex]').forEach(el => {
|
|
770
|
+
const tabindex = parseInt(el.getAttribute('tabindex'), 10);
|
|
771
|
+
if (tabindex > 0) {
|
|
772
|
+
addIssue('warning', 'tabindex-positive', 'Avoid positive tabindex values - use DOM order instead', el);
|
|
773
|
+
}
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
// Check for click handlers on non-interactive elements
|
|
777
|
+
container.querySelectorAll('div[onclick], span[onclick]').forEach(el => {
|
|
778
|
+
if (!el.hasAttribute('role') && !el.hasAttribute('tabindex')) {
|
|
779
|
+
addIssue('warning', 'click-non-interactive', 'Click handler on non-interactive element - consider using button', el);
|
|
780
|
+
}
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
// Check headings hierarchy
|
|
784
|
+
const headings = container.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
|
785
|
+
let lastLevel = 0;
|
|
786
|
+
headings.forEach(heading => {
|
|
787
|
+
const level = parseInt(heading.tagName[1], 10);
|
|
788
|
+
if (level > lastLevel + 1 && lastLevel !== 0) {
|
|
789
|
+
addIssue('warning', 'heading-order', `Heading level skipped (h${lastLevel} to h${level})`, heading);
|
|
790
|
+
}
|
|
791
|
+
lastLevel = level;
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
// Check for autoplay media
|
|
795
|
+
container.querySelectorAll('video[autoplay], audio[autoplay]').forEach(media => {
|
|
796
|
+
if (!media.hasAttribute('muted')) {
|
|
797
|
+
addIssue('warning', 'media-autoplay', 'Autoplaying media should be muted', media);
|
|
798
|
+
}
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
return issues;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Log validation results to console
|
|
806
|
+
* @param {A11yIssue[]} issues - Issues from validateA11y
|
|
807
|
+
*/
|
|
808
|
+
export function logA11yIssues(issues) {
|
|
809
|
+
if (issues.length === 0) {
|
|
810
|
+
console.log('%c✓ No accessibility issues found', 'color: green; font-weight: bold');
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const errors = issues.filter(i => i.severity === 'error');
|
|
815
|
+
const warnings = issues.filter(i => i.severity === 'warning');
|
|
816
|
+
|
|
817
|
+
console.group(`%cAccessibility Issues (${errors.length} errors, ${warnings.length} warnings)`,
|
|
818
|
+
'color: red; font-weight: bold');
|
|
819
|
+
|
|
820
|
+
issues.forEach(issue => {
|
|
821
|
+
const icon = issue.severity === 'error' ? '❌' : '⚠️';
|
|
822
|
+
const color = issue.severity === 'error' ? 'color: red' : 'color: orange';
|
|
823
|
+
console.log(`%c${icon} [${issue.rule}] ${issue.message}`, color, issue.element);
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
console.groupEnd();
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Highlight elements with accessibility issues in the DOM
|
|
831
|
+
* @param {A11yIssue[]} issues - Issues from validateA11y
|
|
832
|
+
* @returns {Function} Cleanup function to remove highlights
|
|
833
|
+
*/
|
|
834
|
+
export function highlightA11yIssues(issues) {
|
|
835
|
+
const highlights = [];
|
|
836
|
+
|
|
837
|
+
issues.forEach(issue => {
|
|
838
|
+
const el = issue.element;
|
|
839
|
+
const rect = el.getBoundingClientRect();
|
|
840
|
+
|
|
841
|
+
const highlight = document.createElement('div');
|
|
842
|
+
highlight.className = 'pulse-a11y-highlight';
|
|
843
|
+
highlight.style.cssText = `
|
|
844
|
+
position: fixed;
|
|
845
|
+
top: ${rect.top}px;
|
|
846
|
+
left: ${rect.left}px;
|
|
847
|
+
width: ${rect.width}px;
|
|
848
|
+
height: ${rect.height}px;
|
|
849
|
+
border: 2px solid ${issue.severity === 'error' ? 'red' : 'orange'};
|
|
850
|
+
background: ${issue.severity === 'error' ? 'rgba(255,0,0,0.1)' : 'rgba(255,165,0,0.1)'};
|
|
851
|
+
pointer-events: none;
|
|
852
|
+
z-index: 99999;
|
|
853
|
+
`;
|
|
854
|
+
|
|
855
|
+
const label = document.createElement('div');
|
|
856
|
+
label.style.cssText = `
|
|
857
|
+
position: absolute;
|
|
858
|
+
top: -20px;
|
|
859
|
+
left: 0;
|
|
860
|
+
background: ${issue.severity === 'error' ? 'red' : 'orange'};
|
|
861
|
+
color: white;
|
|
862
|
+
font-size: 10px;
|
|
863
|
+
padding: 2px 4px;
|
|
864
|
+
border-radius: 2px;
|
|
865
|
+
white-space: nowrap;
|
|
866
|
+
`;
|
|
867
|
+
label.textContent = issue.rule;
|
|
868
|
+
highlight.appendChild(label);
|
|
869
|
+
|
|
870
|
+
document.body.appendChild(highlight);
|
|
871
|
+
highlights.push(highlight);
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
return () => {
|
|
875
|
+
highlights.forEach(h => h.remove());
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// =============================================================================
|
|
880
|
+
// UTILITIES
|
|
881
|
+
// =============================================================================
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Generate a unique ID for ARIA relationships
|
|
885
|
+
* @param {string} prefix - ID prefix
|
|
886
|
+
* @returns {string} Unique ID
|
|
887
|
+
*/
|
|
888
|
+
export function generateId(prefix = 'pulse') {
|
|
889
|
+
return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Check if an element is visible to screen readers
|
|
894
|
+
* @param {HTMLElement} element - Element to check
|
|
895
|
+
* @returns {boolean}
|
|
896
|
+
*/
|
|
897
|
+
export function isAccessiblyHidden(element) {
|
|
898
|
+
if (!element) return true;
|
|
899
|
+
|
|
900
|
+
// Check aria-hidden
|
|
901
|
+
if (element.getAttribute('aria-hidden') === 'true') return true;
|
|
902
|
+
|
|
903
|
+
// Check CSS
|
|
904
|
+
const style = getComputedStyle(element);
|
|
905
|
+
if (style.display === 'none') return true;
|
|
906
|
+
if (style.visibility === 'hidden') return true;
|
|
907
|
+
|
|
908
|
+
// Check inert
|
|
909
|
+
if (element.hasAttribute('inert')) return true;
|
|
910
|
+
|
|
911
|
+
// Check ancestors
|
|
912
|
+
let parent = element.parentElement;
|
|
913
|
+
while (parent) {
|
|
914
|
+
if (parent.getAttribute('aria-hidden') === 'true') return true;
|
|
915
|
+
if (parent.hasAttribute('inert')) return true;
|
|
916
|
+
parent = parent.parentElement;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
return false;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* Make an element inert (non-interactive, hidden from a11y tree)
|
|
924
|
+
* @param {HTMLElement} element - Element to make inert
|
|
925
|
+
* @returns {Function} Restore function
|
|
926
|
+
*/
|
|
927
|
+
export function makeInert(element) {
|
|
928
|
+
const wasInert = element.hasAttribute('inert');
|
|
929
|
+
element.setAttribute('inert', '');
|
|
930
|
+
element.setAttribute('aria-hidden', 'true');
|
|
931
|
+
|
|
932
|
+
return () => {
|
|
933
|
+
if (!wasInert) {
|
|
934
|
+
element.removeAttribute('inert');
|
|
935
|
+
}
|
|
936
|
+
element.removeAttribute('aria-hidden');
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* Create screen reader only text (visually hidden)
|
|
942
|
+
* @param {string} text - Text content
|
|
943
|
+
* @returns {HTMLElement} Span element
|
|
944
|
+
*/
|
|
945
|
+
export function srOnly(text) {
|
|
946
|
+
const span = document.createElement('span');
|
|
947
|
+
span.textContent = text;
|
|
948
|
+
span.className = 'sr-only';
|
|
949
|
+
span.style.cssText = `
|
|
950
|
+
position: absolute;
|
|
951
|
+
width: 1px;
|
|
952
|
+
height: 1px;
|
|
953
|
+
padding: 0;
|
|
954
|
+
margin: -1px;
|
|
955
|
+
overflow: hidden;
|
|
956
|
+
clip: rect(0, 0, 0, 0);
|
|
957
|
+
white-space: nowrap;
|
|
958
|
+
border: 0;
|
|
959
|
+
`;
|
|
960
|
+
return span;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Default export for convenience
|
|
964
|
+
export default {
|
|
965
|
+
// Announcements
|
|
966
|
+
announce,
|
|
967
|
+
announcePolite,
|
|
968
|
+
announceAssertive,
|
|
969
|
+
createLiveAnnouncer,
|
|
970
|
+
|
|
971
|
+
// Focus
|
|
972
|
+
trapFocus,
|
|
973
|
+
focusFirst,
|
|
974
|
+
focusLast,
|
|
975
|
+
saveFocus,
|
|
976
|
+
restoreFocus,
|
|
977
|
+
getFocusableElements,
|
|
978
|
+
|
|
979
|
+
// Skip links
|
|
980
|
+
createSkipLink,
|
|
981
|
+
installSkipLinks,
|
|
982
|
+
|
|
983
|
+
// Preferences
|
|
984
|
+
prefersReducedMotion,
|
|
985
|
+
prefersColorScheme,
|
|
986
|
+
prefersHighContrast,
|
|
987
|
+
createPreferences,
|
|
988
|
+
|
|
989
|
+
// ARIA helpers
|
|
990
|
+
setAriaAttributes,
|
|
991
|
+
createDisclosure,
|
|
992
|
+
createTabs,
|
|
993
|
+
createRovingTabindex,
|
|
994
|
+
|
|
995
|
+
// Validation
|
|
996
|
+
validateA11y,
|
|
997
|
+
logA11yIssues,
|
|
998
|
+
highlightA11yIssues,
|
|
999
|
+
|
|
1000
|
+
// Utilities
|
|
1001
|
+
generateId,
|
|
1002
|
+
isAccessiblyHidden,
|
|
1003
|
+
makeInert,
|
|
1004
|
+
srOnly
|
|
1005
|
+
};
|