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 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
+ }