@sc4rfurryx/proteusjs 1.1.0 → 1.1.1

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.
Files changed (40) hide show
  1. package/README.md +5 -5
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/adapters/react.d.ts +1 -0
  4. package/dist/adapters/react.esm.js +2 -1
  5. package/dist/adapters/react.esm.js.map +1 -1
  6. package/dist/adapters/svelte.esm.js +2 -1
  7. package/dist/adapters/svelte.esm.js.map +1 -1
  8. package/dist/adapters/vue.esm.js +2 -1
  9. package/dist/adapters/vue.esm.js.map +1 -1
  10. package/dist/modules/a11y-audit.d.ts +1 -9
  11. package/dist/modules/a11y-audit.esm.js +30 -475
  12. package/dist/modules/a11y-audit.esm.js.map +1 -1
  13. package/dist/modules/a11y-primitives.d.ts +8 -41
  14. package/dist/modules/a11y-primitives.esm.js +69 -400
  15. package/dist/modules/a11y-primitives.esm.js.map +1 -1
  16. package/dist/modules/anchor.d.ts +1 -0
  17. package/dist/modules/anchor.esm.js +2 -1
  18. package/dist/modules/anchor.esm.js.map +1 -1
  19. package/dist/modules/container.esm.js +1 -1
  20. package/dist/modules/perf.esm.js +1 -1
  21. package/dist/modules/popover.esm.js +1 -1
  22. package/dist/modules/scroll.esm.js +1 -1
  23. package/dist/modules/transitions.esm.js +1 -1
  24. package/dist/modules/typography.esm.js +1 -1
  25. package/dist/proteus.cjs.js +97 -875
  26. package/dist/proteus.cjs.js.map +1 -1
  27. package/dist/proteus.d.ts +11 -56
  28. package/dist/proteus.esm.js +97 -875
  29. package/dist/proteus.esm.js.map +1 -1
  30. package/dist/proteus.esm.min.js +2 -2
  31. package/dist/proteus.esm.min.js.map +1 -1
  32. package/dist/proteus.js +97 -875
  33. package/dist/proteus.js.map +1 -1
  34. package/dist/proteus.min.js +2 -2
  35. package/dist/proteus.min.js.map +1 -1
  36. package/package.json +9 -4
  37. package/src/index.ts +1 -1
  38. package/src/modules/a11y-audit/index.ts +29 -553
  39. package/src/modules/a11y-primitives/index.ts +73 -475
  40. package/src/modules/anchor/index.ts +2 -0
@@ -1,6 +1,6 @@
1
- /**
1
+ /**
2
2
  * @sc4rfurryx/proteusjs/a11y-primitives
3
- * Headless accessibility patterns (no styles)
3
+ * Lightweight accessibility patterns
4
4
  *
5
5
  * @version 1.1.0
6
6
  * @author sc4rfurry
@@ -18,15 +18,7 @@ export interface DialogOptions {
18
18
 
19
19
  export interface TooltipOptions {
20
20
  delay?: number;
21
- }
22
-
23
- export interface ComboboxOptions {
24
- multiselect?: boolean;
25
- filtering?: (query: string) => Promise<unknown[]> | unknown[];
26
- }
27
-
28
- export interface ListboxOptions {
29
- multiselect?: boolean;
21
+ placement?: 'top' | 'bottom' | 'left' | 'right';
30
22
  }
31
23
 
32
24
  export interface FocusTrapController {
@@ -34,521 +26,127 @@ export interface FocusTrapController {
34
26
  deactivate(): void;
35
27
  }
36
28
 
37
- /**
38
- * Dialog primitive with proper ARIA and focus management
39
- */
40
29
  export function dialog(root: Element | string, opts: DialogOptions = {}): Controller {
41
- const rootEl = typeof root === 'string' ? document.querySelector(root) : root;
42
- if (!rootEl) throw new Error('Dialog root element not found');
30
+ const el = typeof root === 'string' ? document.querySelector(root) : root;
31
+ if (!el) throw new Error('Dialog element not found');
43
32
 
44
33
  const { modal = true, restoreFocus = true } = opts;
45
- let previousFocus: Element | null = null;
46
- let isOpen = false;
47
-
48
- const setup = () => {
49
- rootEl.setAttribute('role', 'dialog');
50
- if (modal) {
51
- rootEl.setAttribute('aria-modal', 'true');
52
- }
53
-
54
- // Ensure dialog is initially hidden
55
- if (!rootEl.hasAttribute('hidden')) {
56
- rootEl.setAttribute('hidden', '');
57
- }
58
- };
34
+ let prevFocus: Element | null = null;
59
35
 
60
36
  const open = () => {
61
- if (restoreFocus) {
62
- previousFocus = document.activeElement;
63
- }
64
-
65
- rootEl.removeAttribute('hidden');
66
- isOpen = true;
67
-
68
- // Focus first focusable element
69
- const focusable = rootEl.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
70
- if (focusable) {
71
- (focusable as HTMLElement).focus();
72
- }
37
+ if (restoreFocus) prevFocus = document.activeElement;
38
+ el.setAttribute('role', 'dialog');
39
+ if (modal) el.setAttribute('aria-modal', 'true');
40
+ (el as HTMLElement).focus();
73
41
  };
74
42
 
75
43
  const close = () => {
76
- rootEl.setAttribute('hidden', '');
77
- isOpen = false;
78
-
79
- if (restoreFocus && previousFocus) {
80
- (previousFocus as HTMLElement).focus();
81
- }
44
+ if (restoreFocus && prevFocus) (prevFocus as HTMLElement).focus();
82
45
  };
83
46
 
84
- const handleKeyDown = (e: KeyboardEvent) => {
85
- if (e.key === 'Escape' && isOpen) {
86
- close();
87
- }
88
- };
89
-
90
- setup();
91
- document.addEventListener('keydown', handleKeyDown);
92
-
93
- return {
94
- destroy: () => {
95
- document.removeEventListener('keydown', handleKeyDown);
96
- if (isOpen) close();
97
- }
98
- };
47
+ return { destroy: () => close() };
99
48
  }
100
49
 
101
- /**
102
- * Tooltip primitive with delay and proper ARIA
103
- */
104
- export function tooltip(trigger: Element | string, panel: Element | string, opts: TooltipOptions = {}): Controller {
105
- const triggerEl = typeof trigger === 'string' ? document.querySelector(trigger) : trigger;
106
- const panelEl = typeof panel === 'string' ? document.querySelector(panel) : panel;
107
-
108
- if (!triggerEl || !panelEl) {
109
- throw new Error('Both trigger and panel elements must exist');
110
- }
111
-
112
- const { delay = 500 } = opts;
113
- let timeoutId: number | null = null;
114
- let isVisible = false;
115
-
116
- const setup = () => {
117
- const tooltipId = panelEl.id || `tooltip-${Math.random().toString(36).substring(2, 11)}`;
118
- panelEl.id = tooltipId;
119
- panelEl.setAttribute('role', 'tooltip');
120
- triggerEl.setAttribute('aria-describedby', tooltipId);
121
-
122
- // Initially hidden
123
- (panelEl as HTMLElement).style.display = 'none';
124
- };
50
+ export function tooltip(trigger: Element, content: Element, opts: TooltipOptions = {}): Controller {
51
+ const { delay = 300 } = opts;
52
+ let timeout: number;
125
53
 
126
54
  const show = () => {
127
- if (isVisible) return;
128
- (panelEl as HTMLElement).style.display = 'block';
129
- isVisible = true;
55
+ clearTimeout(timeout);
56
+ timeout = window.setTimeout(() => {
57
+ content.setAttribute('role', 'tooltip');
58
+ trigger.setAttribute('aria-describedby', content.id || 'tooltip');
59
+ content.style.display = 'block';
60
+ }, delay);
130
61
  };
131
62
 
132
63
  const hide = () => {
133
- if (!isVisible) return;
134
- (panelEl as HTMLElement).style.display = 'none';
135
- isVisible = false;
64
+ clearTimeout(timeout);
65
+ content.style.display = 'none';
66
+ trigger.removeAttribute('aria-describedby');
136
67
  };
137
68
 
138
- const handleMouseEnter = () => {
139
- if (timeoutId) clearTimeout(timeoutId);
140
- timeoutId = window.setTimeout(show, delay);
141
- };
142
-
143
- const handleMouseLeave = () => {
144
- if (timeoutId) {
145
- clearTimeout(timeoutId);
146
- timeoutId = null;
147
- }
148
- hide();
149
- };
150
-
151
- const handleFocus = () => {
152
- show();
153
- };
154
-
155
- const handleBlur = () => {
156
- hide();
157
- };
158
-
159
- setup();
160
- triggerEl.addEventListener('mouseenter', handleMouseEnter);
161
- triggerEl.addEventListener('mouseleave', handleMouseLeave);
162
- triggerEl.addEventListener('focus', handleFocus);
163
- triggerEl.addEventListener('blur', handleBlur);
69
+ trigger.addEventListener('mouseenter', show);
70
+ trigger.addEventListener('mouseleave', hide);
71
+ trigger.addEventListener('focus', show);
72
+ trigger.addEventListener('blur', hide);
164
73
 
165
74
  return {
166
75
  destroy: () => {
167
- if (timeoutId) clearTimeout(timeoutId);
168
- triggerEl.removeEventListener('mouseenter', handleMouseEnter);
169
- triggerEl.removeEventListener('mouseleave', handleMouseLeave);
170
- triggerEl.removeEventListener('focus', handleFocus);
171
- triggerEl.removeEventListener('blur', handleBlur);
172
- hide();
76
+ clearTimeout(timeout);
77
+ trigger.removeEventListener('mouseenter', show);
78
+ trigger.removeEventListener('mouseleave', hide);
79
+ trigger.removeEventListener('focus', show);
80
+ trigger.removeEventListener('blur', hide);
173
81
  }
174
82
  };
175
83
  }
176
84
 
177
- /**
178
- * Listbox primitive with keyboard navigation
179
- */
180
- export function listbox(root: Element | string, opts: ListboxOptions = {}): Controller {
181
- const rootEl = typeof root === 'string' ? document.querySelector(root) : root;
182
- if (!rootEl) throw new Error('Listbox root element not found');
183
-
184
- const { multiselect = false } = opts;
185
- let currentIndex = -1;
186
-
187
- const setup = () => {
188
- rootEl.setAttribute('role', 'listbox');
189
- if (multiselect) {
190
- rootEl.setAttribute('aria-multiselectable', 'true');
191
- }
192
-
193
- // Set up options
194
- const options = rootEl.querySelectorAll('[role="option"]');
195
- options.forEach((option, _index) => {
196
- option.setAttribute('aria-selected', 'false');
197
- option.setAttribute('tabindex', '-1');
198
- });
199
-
200
- if (options.length > 0) {
201
- options[0]?.setAttribute('tabindex', '0');
202
- currentIndex = 0;
203
- }
204
- };
205
-
206
- const getOptions = () => rootEl.querySelectorAll('[role="option"]');
207
-
208
- const setCurrentIndex = (index: number) => {
209
- const options = getOptions();
210
- if (index < 0 || index >= options.length) return;
85
+ export function focusTrap(container: Element): FocusTrapController {
86
+ const focusable = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
87
+
88
+ const activate = () => {
89
+ const elements = container.querySelectorAll(focusable);
90
+ if (elements.length === 0) return;
211
91
 
212
- // Remove tabindex from all options
213
- options.forEach(option => option.setAttribute('tabindex', '-1'));
92
+ const first = elements[0] as HTMLElement;
93
+ const last = elements[elements.length - 1] as HTMLElement;
214
94
 
215
- // Set current option
216
- currentIndex = index;
217
- options[currentIndex]?.setAttribute('tabindex', '0');
218
- (options[currentIndex] as HTMLElement)?.focus();
219
- };
220
-
221
- const selectOption = (index: number) => {
222
- const options = getOptions();
223
- if (index < 0 || index >= options.length) return;
95
+ const handleTab = (e: KeyboardEvent) => {
96
+ if (e.key !== 'Tab') return;
97
+
98
+ if (e.shiftKey && document.activeElement === first) {
99
+ e.preventDefault();
100
+ last.focus();
101
+ } else if (!e.shiftKey && document.activeElement === last) {
102
+ e.preventDefault();
103
+ first.focus();
104
+ }
105
+ };
224
106
 
225
- if (multiselect) {
226
- const isSelected = options[index]?.getAttribute('aria-selected') === 'true';
227
- options[index]?.setAttribute('aria-selected', (!isSelected).toString());
228
- } else {
229
- // Single select - clear all others
230
- options.forEach(option => option.setAttribute('aria-selected', 'false'));
231
- options[index]?.setAttribute('aria-selected', 'true');
232
- }
233
- };
234
-
235
- const handleKeyDown = (e: Event) => {
236
- const keyEvent = e as KeyboardEvent;
237
- const options = getOptions();
107
+ container.addEventListener('keydown', handleTab);
108
+ first.focus();
238
109
 
239
- switch (keyEvent.key) {
240
- case 'ArrowDown':
241
- keyEvent.preventDefault();
242
- setCurrentIndex(Math.min(currentIndex + 1, options.length - 1));
243
- break;
244
- case 'ArrowUp':
245
- keyEvent.preventDefault();
246
- setCurrentIndex(Math.max(currentIndex - 1, 0));
247
- break;
248
- case 'Home':
249
- keyEvent.preventDefault();
250
- setCurrentIndex(0);
251
- break;
252
- case 'End':
253
- keyEvent.preventDefault();
254
- setCurrentIndex(options.length - 1);
255
- break;
256
- case 'Enter':
257
- case ' ':
258
- keyEvent.preventDefault();
259
- selectOption(currentIndex);
260
- break;
261
- }
262
- };
263
-
264
- const handleClick = (e: Event) => {
265
- const target = e.target as Element;
266
- const option = target.closest('[role="option"]');
267
- if (!option) return;
268
-
269
- const options = Array.from(getOptions());
270
- const index = options.indexOf(option);
271
- if (index >= 0) {
272
- setCurrentIndex(index);
273
- selectOption(index);
274
- }
275
- };
276
-
277
- setup();
278
- rootEl.addEventListener('keydown', handleKeyDown);
279
- rootEl.addEventListener('click', handleClick);
280
-
281
- return {
282
- destroy: () => {
283
- rootEl.removeEventListener('keydown', handleKeyDown);
284
- rootEl.removeEventListener('click', handleClick);
285
- }
286
- };
287
- }
288
-
289
- /**
290
- * Combobox primitive with filtering and multiselect
291
- */
292
- export function combobox(
293
- root: Element | string,
294
- opts: ComboboxOptions = {}
295
- ): Controller {
296
- const rootEl = typeof root === 'string' ? document.querySelector(root) : root;
297
- if (!rootEl) throw new Error('Combobox root element not found');
298
-
299
- const { multiselect = false, filtering: _filtering } = opts;
300
- let isOpen = false;
301
-
302
- const setup = () => {
303
- rootEl.setAttribute('role', 'combobox');
304
- rootEl.setAttribute('aria-expanded', 'false');
305
- if (multiselect) {
306
- rootEl.setAttribute('aria-multiselectable', 'true');
307
- }
110
+ return () => container.removeEventListener('keydown', handleTab);
308
111
  };
309
112
 
310
- const handleKeyDown = (e: Event) => {
311
- const keyEvent = e as KeyboardEvent;
312
-
313
- switch (keyEvent.key) {
314
- case 'ArrowDown':
315
- keyEvent.preventDefault();
316
- if (!isOpen) {
317
- isOpen = true;
318
- rootEl.setAttribute('aria-expanded', 'true');
319
- }
320
- // Navigate options logic would go here
321
- break;
322
- case 'Escape':
323
- keyEvent.preventDefault();
324
- isOpen = false;
325
- rootEl.setAttribute('aria-expanded', 'false');
326
- break;
327
- }
328
- };
329
-
330
- setup();
331
- rootEl.addEventListener('keydown', handleKeyDown);
113
+ let deactivate = () => {};
332
114
 
333
115
  return {
334
- destroy: () => {
335
- rootEl.removeEventListener('keydown', handleKeyDown);
336
- }
116
+ activate: () => { deactivate = activate() || (() => {}); },
117
+ deactivate: () => deactivate()
337
118
  };
338
119
  }
339
120
 
340
- /**
341
- * Tabs primitive with keyboard navigation
342
- */
343
- export function tabs(root: Element | string): Controller {
344
- const rootEl = typeof root === 'string' ? document.querySelector(root) : root;
345
- if (!rootEl) throw new Error('Tabs root element not found');
346
-
121
+ export function menu(container: Element): Controller {
122
+ const items = container.querySelectorAll('[role="menuitem"]');
347
123
  let currentIndex = 0;
348
124
 
349
- const setup = () => {
350
- const tabList = rootEl.querySelector('[role="tablist"]');
351
- const tabs = rootEl.querySelectorAll('[role="tab"]');
352
- const panels = rootEl.querySelectorAll('[role="tabpanel"]');
353
-
354
- if (!tabList) {
355
- rootEl.setAttribute('role', 'tablist');
356
- }
357
-
358
- tabs.forEach((tab, index) => {
359
- tab.setAttribute('tabindex', index === 0 ? '0' : '-1');
360
- tab.setAttribute('aria-selected', index === 0 ? 'true' : 'false');
361
- });
362
-
363
- panels.forEach((panel, index) => {
364
- panel.setAttribute('hidden', index === 0 ? '' : 'true');
365
- });
366
- };
367
-
368
- const handleKeyDown = (e: Event) => {
369
- const keyEvent = e as KeyboardEvent;
370
- const tabs = Array.from(rootEl.querySelectorAll('[role="tab"]'));
371
-
372
- switch (keyEvent.key) {
373
- case 'ArrowRight':
374
- keyEvent.preventDefault();
375
- currentIndex = (currentIndex + 1) % tabs.length;
376
- activateTab(currentIndex);
377
- break;
378
- case 'ArrowLeft':
379
- keyEvent.preventDefault();
380
- currentIndex = currentIndex === 0 ? tabs.length - 1 : currentIndex - 1;
381
- activateTab(currentIndex);
382
- break;
383
- }
384
- };
385
-
386
- const activateTab = (index: number) => {
387
- const tabs = rootEl.querySelectorAll('[role="tab"]');
388
- const panels = rootEl.querySelectorAll('[role="tabpanel"]');
389
-
390
- tabs.forEach((tab, i) => {
391
- tab.setAttribute('tabindex', i === index ? '0' : '-1');
392
- tab.setAttribute('aria-selected', i === index ? 'true' : 'false');
393
- if (i === index) {
394
- (tab as HTMLElement).focus();
395
- }
396
- });
397
-
398
- panels.forEach((panel, i) => {
399
- if (i === index) {
400
- panel.removeAttribute('hidden');
401
- } else {
402
- panel.setAttribute('hidden', 'true');
403
- }
404
- });
405
- };
406
-
407
- setup();
408
- rootEl.addEventListener('keydown', handleKeyDown);
409
-
410
- return {
411
- destroy: () => {
412
- rootEl.removeEventListener('keydown', handleKeyDown);
413
- }
414
- };
415
- }
416
-
417
- /**
418
- * Menu primitive with keyboard navigation
419
- */
420
- export function menu(root: Element | string): Controller {
421
- const rootEl = typeof root === 'string' ? document.querySelector(root) : root;
422
- if (!rootEl) throw new Error('Menu root element not found');
423
-
424
- let currentIndex = -1;
425
-
426
- const setup = () => {
427
- rootEl.setAttribute('role', 'menu');
428
-
429
- const items = rootEl.querySelectorAll('[role="menuitem"]');
430
- items.forEach((item, index) => {
431
- item.setAttribute('tabindex', index === 0 ? '0' : '-1');
432
- });
433
-
434
- if (items.length > 0) {
435
- currentIndex = 0;
436
- }
437
- };
438
-
439
- const handleKeyDown = (e: Event) => {
440
- const keyEvent = e as KeyboardEvent;
441
- const items = Array.from(rootEl.querySelectorAll('[role="menuitem"]'));
442
-
443
- switch (keyEvent.key) {
125
+ const navigate = (e: KeyboardEvent) => {
126
+ switch (e.key) {
444
127
  case 'ArrowDown':
445
- keyEvent.preventDefault();
128
+ e.preventDefault();
446
129
  currentIndex = (currentIndex + 1) % items.length;
447
- setCurrentItem(currentIndex);
130
+ (items[currentIndex] as HTMLElement).focus();
448
131
  break;
449
132
  case 'ArrowUp':
450
- keyEvent.preventDefault();
133
+ e.preventDefault();
451
134
  currentIndex = currentIndex === 0 ? items.length - 1 : currentIndex - 1;
452
- setCurrentItem(currentIndex);
453
- break;
454
- case 'Enter':
455
- case ' ':
456
- keyEvent.preventDefault();
457
- if (items[currentIndex]) {
458
- (items[currentIndex] as HTMLElement).click();
459
- }
135
+ (items[currentIndex] as HTMLElement).focus();
460
136
  break;
461
- }
462
- };
463
-
464
- const setCurrentItem = (index: number) => {
465
- const items = rootEl.querySelectorAll('[role="menuitem"]');
466
- items.forEach((item, i) => {
467
- item.setAttribute('tabindex', i === index ? '0' : '-1');
468
- if (i === index) {
469
- (item as HTMLElement).focus();
470
- }
471
- });
472
- };
473
-
474
- setup();
475
- rootEl.addEventListener('keydown', handleKeyDown);
476
-
477
- return {
478
- destroy: () => {
479
- rootEl.removeEventListener('keydown', handleKeyDown);
480
- }
481
- };
482
- }
483
-
484
- /**
485
- * Focus trap utility
486
- */
487
- export function focusTrap(root: Element | string): FocusTrapController {
488
- const rootEl = typeof root === 'string' ? document.querySelector(root) : root;
489
- if (!rootEl) throw new Error('Focus trap root element not found');
490
-
491
- let isActive = false;
492
- let focusableElements: Element[] = [];
493
-
494
- const updateFocusableElements = () => {
495
- const selector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
496
- focusableElements = Array.from(rootEl.querySelectorAll(selector));
497
- };
498
-
499
- const handleKeyDown = (e: KeyboardEvent) => {
500
- if (!isActive || e.key !== 'Tab') return;
501
-
502
- updateFocusableElements();
503
- if (focusableElements.length === 0) return;
504
-
505
- const firstElement = focusableElements[0] as HTMLElement;
506
- const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
507
-
508
- if (e.shiftKey) {
509
- if (document.activeElement === firstElement) {
510
- e.preventDefault();
511
- lastElement.focus();
512
- }
513
- } else {
514
- if (document.activeElement === lastElement) {
137
+ case 'Escape':
515
138
  e.preventDefault();
516
- firstElement.focus();
517
- }
518
- }
519
- };
520
-
521
- const activate = () => {
522
- if (isActive) return;
523
- isActive = true;
524
- updateFocusableElements();
525
-
526
- if (focusableElements.length > 0) {
527
- (focusableElements[0] as HTMLElement).focus();
139
+ container.dispatchEvent(new CustomEvent('menu:close'));
140
+ break;
528
141
  }
529
-
530
- document.addEventListener('keydown', handleKeyDown);
531
142
  };
532
143
 
533
- const deactivate = () => {
534
- if (!isActive) return;
535
- isActive = false;
536
- document.removeEventListener('keydown', handleKeyDown);
537
- };
144
+ container.setAttribute('role', 'menu');
145
+ container.addEventListener('keydown', navigate);
538
146
 
539
147
  return {
540
- activate,
541
- deactivate
148
+ destroy: () => container.removeEventListener('keydown', navigate)
542
149
  };
543
150
  }
544
151
 
545
- // Export all functions
546
- export default {
547
- dialog,
548
- tooltip,
549
- combobox,
550
- listbox,
551
- tabs,
552
- menu,
553
- focusTrap
554
- };
152
+ export default { dialog, tooltip, focusTrap, menu };
@@ -16,6 +16,7 @@ export interface TetherOptions {
16
16
  }
17
17
 
18
18
  export interface TetherController {
19
+ update(): void;
19
20
  destroy(): void;
20
21
  }
21
22
 
@@ -247,6 +248,7 @@ export function tether(
247
248
  }
248
249
 
249
250
  return {
251
+ update: updatePosition,
250
252
  destroy
251
253
  };
252
254
  }