what-core 0.1.1 → 0.3.0
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/dist/a11y.js +425 -0
- package/dist/animation.js +540 -0
- package/dist/components.js +272 -115
- package/dist/data.js +444 -0
- package/dist/dom.js +702 -427
- package/dist/form.js +441 -0
- package/dist/h.js +191 -138
- package/dist/head.js +59 -42
- package/dist/helpers.js +125 -83
- package/dist/hooks.js +226 -124
- package/dist/index.js +2 -2
- package/dist/reactive.js +165 -108
- package/dist/scheduler.js +241 -0
- package/dist/skeleton.js +363 -0
- package/dist/store.js +114 -55
- package/dist/testing.js +367 -0
- package/dist/what.js +2 -2
- package/index.d.ts +15 -0
- package/package.json +1 -1
- package/src/animation.js +11 -2
- package/src/components.js +93 -0
- package/src/data.js +19 -9
- package/src/dom.js +181 -85
- package/src/hooks.js +22 -10
- package/src/index.js +2 -2
- package/src/reactive.js +15 -1
- package/src/store.js +24 -5
package/dist/a11y.js
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
// What Framework - Accessibility Utilities
|
|
2
|
+
// Focus management, ARIA helpers, screen reader announcements
|
|
3
|
+
|
|
4
|
+
import { signal, effect } from './reactive.js';
|
|
5
|
+
import { h } from './h.js';
|
|
6
|
+
|
|
7
|
+
// --- Focus Management ---
|
|
8
|
+
|
|
9
|
+
// Track currently focused element
|
|
10
|
+
const focusedElement = signal(null);
|
|
11
|
+
|
|
12
|
+
if (typeof document !== 'undefined') {
|
|
13
|
+
document.addEventListener('focusin', (e) => {
|
|
14
|
+
focusedElement.set(e.target);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function useFocus() {
|
|
19
|
+
return {
|
|
20
|
+
current: () => focusedElement(),
|
|
21
|
+
focus: (element) => element?.focus(),
|
|
22
|
+
blur: () => document.activeElement?.blur(),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// --- Focus Trap ---
|
|
27
|
+
// Keep focus within a container (for modals, dialogs, etc.)
|
|
28
|
+
|
|
29
|
+
export function useFocusTrap(containerRef) {
|
|
30
|
+
let previousFocus = null;
|
|
31
|
+
|
|
32
|
+
function activate() {
|
|
33
|
+
if (typeof document === 'undefined') return;
|
|
34
|
+
|
|
35
|
+
previousFocus = document.activeElement;
|
|
36
|
+
const container = containerRef.current || containerRef;
|
|
37
|
+
|
|
38
|
+
if (!container) return;
|
|
39
|
+
|
|
40
|
+
// Find all focusable elements
|
|
41
|
+
const focusables = getFocusableElements(container);
|
|
42
|
+
if (focusables.length === 0) return;
|
|
43
|
+
|
|
44
|
+
// Focus first element
|
|
45
|
+
focusables[0].focus();
|
|
46
|
+
|
|
47
|
+
// Handle Tab key
|
|
48
|
+
function handleKeydown(e) {
|
|
49
|
+
if (e.key !== 'Tab') return;
|
|
50
|
+
|
|
51
|
+
const focusables = getFocusableElements(container);
|
|
52
|
+
const first = focusables[0];
|
|
53
|
+
const last = focusables[focusables.length - 1];
|
|
54
|
+
|
|
55
|
+
if (e.shiftKey) {
|
|
56
|
+
// Shift+Tab: if on first, go to last
|
|
57
|
+
if (document.activeElement === first) {
|
|
58
|
+
e.preventDefault();
|
|
59
|
+
last.focus();
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
// Tab: if on last, go to first
|
|
63
|
+
if (document.activeElement === last) {
|
|
64
|
+
e.preventDefault();
|
|
65
|
+
first.focus();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
container.addEventListener('keydown', handleKeydown);
|
|
71
|
+
|
|
72
|
+
return () => {
|
|
73
|
+
container.removeEventListener('keydown', handleKeydown);
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function deactivate() {
|
|
78
|
+
if (previousFocus && typeof previousFocus.focus === 'function') {
|
|
79
|
+
previousFocus.focus();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { activate, deactivate };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function getFocusableElements(container) {
|
|
87
|
+
const selector = [
|
|
88
|
+
'button:not([disabled])',
|
|
89
|
+
'a[href]',
|
|
90
|
+
'input:not([disabled])',
|
|
91
|
+
'select:not([disabled])',
|
|
92
|
+
'textarea:not([disabled])',
|
|
93
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
94
|
+
].join(',');
|
|
95
|
+
|
|
96
|
+
return Array.from(container.querySelectorAll(selector)).filter(el => {
|
|
97
|
+
return el.offsetParent !== null; // Visible
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- Focus Scope ---
|
|
102
|
+
// Component wrapper that traps focus
|
|
103
|
+
|
|
104
|
+
export function FocusTrap({ children, active = true }) {
|
|
105
|
+
const containerRef = { current: null };
|
|
106
|
+
const trap = useFocusTrap(containerRef);
|
|
107
|
+
|
|
108
|
+
effect(() => {
|
|
109
|
+
if (active) {
|
|
110
|
+
const cleanup = trap.activate();
|
|
111
|
+
return () => {
|
|
112
|
+
cleanup?.();
|
|
113
|
+
trap.deactivate();
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
return h('div', { ref: containerRef }, children);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// --- Screen Reader Announcements ---
|
|
122
|
+
|
|
123
|
+
let announcer = null;
|
|
124
|
+
let announcerId = 0;
|
|
125
|
+
|
|
126
|
+
function getAnnouncer() {
|
|
127
|
+
if (typeof document === 'undefined') return null;
|
|
128
|
+
|
|
129
|
+
if (!announcer) {
|
|
130
|
+
announcer = document.createElement('div');
|
|
131
|
+
announcer.id = 'what-announcer';
|
|
132
|
+
announcer.setAttribute('aria-live', 'polite');
|
|
133
|
+
announcer.setAttribute('aria-atomic', 'true');
|
|
134
|
+
announcer.style.cssText = `
|
|
135
|
+
position: absolute;
|
|
136
|
+
width: 1px;
|
|
137
|
+
height: 1px;
|
|
138
|
+
padding: 0;
|
|
139
|
+
margin: -1px;
|
|
140
|
+
overflow: hidden;
|
|
141
|
+
clip: rect(0, 0, 0, 0);
|
|
142
|
+
white-space: nowrap;
|
|
143
|
+
border: 0;
|
|
144
|
+
`;
|
|
145
|
+
document.body.appendChild(announcer);
|
|
146
|
+
}
|
|
147
|
+
return announcer;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function announce(message, options = {}) {
|
|
151
|
+
const { priority = 'polite', timeout = 1000 } = options;
|
|
152
|
+
const announcer = getAnnouncer();
|
|
153
|
+
if (!announcer) return;
|
|
154
|
+
|
|
155
|
+
announcer.setAttribute('aria-live', priority);
|
|
156
|
+
|
|
157
|
+
// Clear and re-announce (required for some screen readers)
|
|
158
|
+
const id = ++announcerId;
|
|
159
|
+
announcer.textContent = '';
|
|
160
|
+
|
|
161
|
+
requestAnimationFrame(() => {
|
|
162
|
+
if (announcerId === id) {
|
|
163
|
+
announcer.textContent = message;
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Clear after timeout
|
|
168
|
+
setTimeout(() => {
|
|
169
|
+
if (announcerId === id) {
|
|
170
|
+
announcer.textContent = '';
|
|
171
|
+
}
|
|
172
|
+
}, timeout);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function announceAssertive(message) {
|
|
176
|
+
return announce(message, { priority: 'assertive' });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// --- Skip Link ---
|
|
180
|
+
// Accessible skip navigation
|
|
181
|
+
|
|
182
|
+
export function SkipLink({ href = '#main', children = 'Skip to content' }) {
|
|
183
|
+
return h('a', {
|
|
184
|
+
href,
|
|
185
|
+
class: 'what-skip-link',
|
|
186
|
+
onClick: (e) => {
|
|
187
|
+
e.preventDefault();
|
|
188
|
+
const target = document.querySelector(href);
|
|
189
|
+
if (target) {
|
|
190
|
+
target.focus();
|
|
191
|
+
target.scrollIntoView();
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
style: {
|
|
195
|
+
position: 'absolute',
|
|
196
|
+
top: '-40px',
|
|
197
|
+
left: '0',
|
|
198
|
+
padding: '8px',
|
|
199
|
+
background: '#000',
|
|
200
|
+
color: '#fff',
|
|
201
|
+
textDecoration: 'none',
|
|
202
|
+
zIndex: '10000',
|
|
203
|
+
},
|
|
204
|
+
onFocus: (e) => {
|
|
205
|
+
e.target.style.top = '0';
|
|
206
|
+
},
|
|
207
|
+
onBlur: (e) => {
|
|
208
|
+
e.target.style.top = '-40px';
|
|
209
|
+
},
|
|
210
|
+
}, children);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// --- ARIA Helpers ---
|
|
214
|
+
|
|
215
|
+
export function useAriaExpanded(initialExpanded = false) {
|
|
216
|
+
const expanded = signal(initialExpanded);
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
expanded: () => expanded(),
|
|
220
|
+
toggle: () => expanded.set(!expanded.peek()),
|
|
221
|
+
open: () => expanded.set(true),
|
|
222
|
+
close: () => expanded.set(false),
|
|
223
|
+
buttonProps: () => ({
|
|
224
|
+
'aria-expanded': expanded(),
|
|
225
|
+
onClick: () => expanded.set(!expanded.peek()),
|
|
226
|
+
}),
|
|
227
|
+
panelProps: () => ({
|
|
228
|
+
hidden: !expanded(),
|
|
229
|
+
}),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function useAriaSelected(initialSelected = null) {
|
|
234
|
+
const selected = signal(initialSelected);
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
selected: () => selected(),
|
|
238
|
+
select: (value) => selected.set(value),
|
|
239
|
+
isSelected: (value) => selected() === value,
|
|
240
|
+
itemProps: (value) => ({
|
|
241
|
+
'aria-selected': selected() === value,
|
|
242
|
+
onClick: () => selected.set(value),
|
|
243
|
+
}),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function useAriaChecked(initialChecked = false) {
|
|
248
|
+
const checked = signal(initialChecked);
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
checked: () => checked(),
|
|
252
|
+
toggle: () => checked.set(!checked.peek()),
|
|
253
|
+
set: (value) => checked.set(value),
|
|
254
|
+
checkboxProps: () => ({
|
|
255
|
+
role: 'checkbox',
|
|
256
|
+
'aria-checked': checked(),
|
|
257
|
+
tabIndex: 0,
|
|
258
|
+
onClick: () => checked.set(!checked.peek()),
|
|
259
|
+
onKeyDown: (e) => {
|
|
260
|
+
if (e.key === ' ' || e.key === 'Enter') {
|
|
261
|
+
e.preventDefault();
|
|
262
|
+
checked.set(!checked.peek());
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
}),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// --- Roving Tab Index ---
|
|
270
|
+
// For keyboard navigation in lists, toolbars, etc.
|
|
271
|
+
|
|
272
|
+
export function useRovingTabIndex(itemCount) {
|
|
273
|
+
const focusIndex = signal(0);
|
|
274
|
+
|
|
275
|
+
function handleKeyDown(e) {
|
|
276
|
+
switch (e.key) {
|
|
277
|
+
case 'ArrowDown':
|
|
278
|
+
case 'ArrowRight':
|
|
279
|
+
e.preventDefault();
|
|
280
|
+
focusIndex.set((focusIndex.peek() + 1) % itemCount);
|
|
281
|
+
break;
|
|
282
|
+
case 'ArrowUp':
|
|
283
|
+
case 'ArrowLeft':
|
|
284
|
+
e.preventDefault();
|
|
285
|
+
focusIndex.set((focusIndex.peek() - 1 + itemCount) % itemCount);
|
|
286
|
+
break;
|
|
287
|
+
case 'Home':
|
|
288
|
+
e.preventDefault();
|
|
289
|
+
focusIndex.set(0);
|
|
290
|
+
break;
|
|
291
|
+
case 'End':
|
|
292
|
+
e.preventDefault();
|
|
293
|
+
focusIndex.set(itemCount - 1);
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
focusIndex: () => focusIndex(),
|
|
300
|
+
setFocusIndex: (i) => focusIndex.set(i),
|
|
301
|
+
getItemProps: (index) => ({
|
|
302
|
+
tabIndex: focusIndex() === index ? 0 : -1,
|
|
303
|
+
onKeyDown: handleKeyDown,
|
|
304
|
+
onFocus: () => focusIndex.set(index),
|
|
305
|
+
}),
|
|
306
|
+
containerProps: () => ({
|
|
307
|
+
role: 'listbox',
|
|
308
|
+
}),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// --- Visually Hidden ---
|
|
313
|
+
// Hide content visually but keep accessible to screen readers
|
|
314
|
+
|
|
315
|
+
export function VisuallyHidden({ children, as = 'span' }) {
|
|
316
|
+
return h(as, {
|
|
317
|
+
style: {
|
|
318
|
+
position: 'absolute',
|
|
319
|
+
width: '1px',
|
|
320
|
+
height: '1px',
|
|
321
|
+
padding: '0',
|
|
322
|
+
margin: '-1px',
|
|
323
|
+
overflow: 'hidden',
|
|
324
|
+
clip: 'rect(0, 0, 0, 0)',
|
|
325
|
+
whiteSpace: 'nowrap',
|
|
326
|
+
border: '0',
|
|
327
|
+
},
|
|
328
|
+
}, children);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// --- Live Region Component ---
|
|
332
|
+
|
|
333
|
+
export function LiveRegion({ children, priority = 'polite', atomic = true }) {
|
|
334
|
+
return h('div', {
|
|
335
|
+
'aria-live': priority,
|
|
336
|
+
'aria-atomic': atomic,
|
|
337
|
+
}, children);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// --- ID Generator ---
|
|
341
|
+
// Generate unique IDs for ARIA attributes
|
|
342
|
+
|
|
343
|
+
let idCounter = 0;
|
|
344
|
+
|
|
345
|
+
export function useId(prefix = 'what') {
|
|
346
|
+
const id = signal(`${prefix}-${++idCounter}`);
|
|
347
|
+
return () => id();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function useIds(count, prefix = 'what') {
|
|
351
|
+
const ids = [];
|
|
352
|
+
for (let i = 0; i < count; i++) {
|
|
353
|
+
ids.push(`${prefix}-${++idCounter}`);
|
|
354
|
+
}
|
|
355
|
+
return ids;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// --- Describe ---
|
|
359
|
+
// Associate description with an element
|
|
360
|
+
|
|
361
|
+
export function useDescribedBy(description) {
|
|
362
|
+
const id = useId('desc');
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
descriptionId: id,
|
|
366
|
+
descriptionProps: () => ({
|
|
367
|
+
id: id(),
|
|
368
|
+
style: { display: 'none' },
|
|
369
|
+
}),
|
|
370
|
+
describedByProps: () => ({
|
|
371
|
+
'aria-describedby': id(),
|
|
372
|
+
}),
|
|
373
|
+
Description: () => h('div', {
|
|
374
|
+
id: id(),
|
|
375
|
+
style: { display: 'none' },
|
|
376
|
+
}, description),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// --- Labelledby ---
|
|
381
|
+
|
|
382
|
+
export function useLabelledBy(label) {
|
|
383
|
+
const id = useId('label');
|
|
384
|
+
|
|
385
|
+
return {
|
|
386
|
+
labelId: id,
|
|
387
|
+
labelProps: () => ({
|
|
388
|
+
id: id(),
|
|
389
|
+
}),
|
|
390
|
+
labelledByProps: () => ({
|
|
391
|
+
'aria-labelledby': id(),
|
|
392
|
+
}),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// --- Keyboard Navigation Helpers ---
|
|
397
|
+
|
|
398
|
+
export const Keys = {
|
|
399
|
+
Enter: 'Enter',
|
|
400
|
+
Space: ' ',
|
|
401
|
+
Escape: 'Escape',
|
|
402
|
+
ArrowUp: 'ArrowUp',
|
|
403
|
+
ArrowDown: 'ArrowDown',
|
|
404
|
+
ArrowLeft: 'ArrowLeft',
|
|
405
|
+
ArrowRight: 'ArrowRight',
|
|
406
|
+
Home: 'Home',
|
|
407
|
+
End: 'End',
|
|
408
|
+
Tab: 'Tab',
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
export function onKey(key, handler) {
|
|
412
|
+
return (e) => {
|
|
413
|
+
if (e.key === key) {
|
|
414
|
+
handler(e);
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function onKeys(keys, handler) {
|
|
420
|
+
return (e) => {
|
|
421
|
+
if (keys.includes(e.key)) {
|
|
422
|
+
handler(e);
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
}
|