@sc4rfurryx/proteusjs 1.1.0 → 2.0.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/README.md +684 -899
- package/dist/.tsbuildinfo +1 -1
- package/dist/modules/a11y-audit.d.ts +2 -10
- package/dist/modules/a11y-audit.esm.js +31 -476
- package/dist/modules/a11y-audit.esm.js.map +1 -1
- package/dist/modules/a11y-primitives.d.ts +9 -42
- package/dist/modules/a11y-primitives.esm.js +70 -401
- package/dist/modules/a11y-primitives.esm.js.map +1 -1
- package/dist/modules/anchor.d.ts +2 -1
- package/dist/modules/anchor.esm.js +3 -2
- package/dist/modules/anchor.esm.js.map +1 -1
- package/dist/modules/container.d.ts +1 -1
- package/dist/modules/container.esm.js +34 -34
- package/dist/modules/container.esm.js.map +1 -1
- package/dist/modules/perf.d.ts +1 -1
- package/dist/modules/perf.esm.js +2 -2
- package/dist/modules/popover.d.ts +1 -1
- package/dist/modules/popover.esm.js +2 -2
- package/dist/modules/scroll.d.ts +1 -1
- package/dist/modules/scroll.esm.js +14 -14
- package/dist/modules/scroll.esm.js.map +1 -1
- package/dist/modules/transitions.d.ts +1 -1
- package/dist/modules/transitions.esm.js +12 -12
- package/dist/modules/transitions.esm.js.map +1 -1
- package/dist/modules/typography.d.ts +1 -1
- package/dist/modules/typography.esm.js +2 -2
- package/dist/proteus.cjs.js +163 -941
- package/dist/proteus.cjs.js.map +1 -1
- package/dist/proteus.d.ts +23 -68
- package/dist/proteus.esm.js +163 -941
- package/dist/proteus.esm.js.map +1 -1
- package/dist/proteus.esm.min.js +2 -2
- package/dist/proteus.esm.min.js.map +1 -1
- package/dist/proteus.js +163 -941
- package/dist/proteus.js.map +1 -1
- package/dist/proteus.min.js +2 -2
- package/dist/proteus.min.js.map +1 -1
- package/package.json +44 -7
- package/src/adapters/react.ts +607 -264
- package/src/adapters/svelte.ts +321 -321
- package/src/adapters/vue.ts +268 -268
- package/src/core/ProteusJS.ts +6 -6
- package/src/index.ts +3 -3
- package/src/modules/a11y-audit/index.ts +84 -608
- package/src/modules/a11y-primitives/index.ts +152 -554
- package/src/modules/anchor/index.ts +259 -257
- package/src/modules/container/index.ts +230 -230
- package/src/modules/perf/index.ts +291 -291
- package/src/modules/popover/index.ts +238 -238
- package/src/modules/scroll/index.ts +251 -251
- package/src/modules/transitions/index.ts +145 -145
- package/src/modules/typography/index.ts +239 -239
- package/src/utils/version.ts +1 -1
- package/dist/adapters/react.d.ts +0 -139
- package/dist/adapters/react.esm.js +0 -848
- package/dist/adapters/react.esm.js.map +0 -1
- package/dist/adapters/svelte.d.ts +0 -181
- package/dist/adapters/svelte.esm.js +0 -908
- package/dist/adapters/svelte.esm.js.map +0 -1
- package/dist/adapters/vue.d.ts +0 -205
- package/dist/adapters/vue.esm.js +0 -872
- package/dist/adapters/vue.esm.js.map +0 -1
|
@@ -1,554 +1,152 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @sc4rfurryx/proteusjs/a11y-primitives
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* @version
|
|
6
|
-
* @author sc4rfurry
|
|
7
|
-
* @license MIT
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
export interface Controller {
|
|
11
|
-
destroy(): void;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export interface DialogOptions {
|
|
15
|
-
modal?: boolean;
|
|
16
|
-
restoreFocus?: boolean;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface TooltipOptions {
|
|
20
|
-
delay?: number;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
(
|
|
81
|
-
}
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
let
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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);
|
|
164
|
-
|
|
165
|
-
return {
|
|
166
|
-
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();
|
|
173
|
-
}
|
|
174
|
-
};
|
|
175
|
-
}
|
|
176
|
-
|
|
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;
|
|
211
|
-
|
|
212
|
-
// Remove tabindex from all options
|
|
213
|
-
options.forEach(option => option.setAttribute('tabindex', '-1'));
|
|
214
|
-
|
|
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;
|
|
224
|
-
|
|
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();
|
|
238
|
-
|
|
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
|
-
}
|
|
308
|
-
};
|
|
309
|
-
|
|
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);
|
|
332
|
-
|
|
333
|
-
return {
|
|
334
|
-
destroy: () => {
|
|
335
|
-
rootEl.removeEventListener('keydown', handleKeyDown);
|
|
336
|
-
}
|
|
337
|
-
};
|
|
338
|
-
}
|
|
339
|
-
|
|
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
|
-
|
|
347
|
-
let currentIndex = 0;
|
|
348
|
-
|
|
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) {
|
|
444
|
-
case 'ArrowDown':
|
|
445
|
-
keyEvent.preventDefault();
|
|
446
|
-
currentIndex = (currentIndex + 1) % items.length;
|
|
447
|
-
setCurrentItem(currentIndex);
|
|
448
|
-
break;
|
|
449
|
-
case 'ArrowUp':
|
|
450
|
-
keyEvent.preventDefault();
|
|
451
|
-
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
|
-
}
|
|
460
|
-
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) {
|
|
515
|
-
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();
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
document.addEventListener('keydown', handleKeyDown);
|
|
531
|
-
};
|
|
532
|
-
|
|
533
|
-
const deactivate = () => {
|
|
534
|
-
if (!isActive) return;
|
|
535
|
-
isActive = false;
|
|
536
|
-
document.removeEventListener('keydown', handleKeyDown);
|
|
537
|
-
};
|
|
538
|
-
|
|
539
|
-
return {
|
|
540
|
-
activate,
|
|
541
|
-
deactivate
|
|
542
|
-
};
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
// Export all functions
|
|
546
|
-
export default {
|
|
547
|
-
dialog,
|
|
548
|
-
tooltip,
|
|
549
|
-
combobox,
|
|
550
|
-
listbox,
|
|
551
|
-
tabs,
|
|
552
|
-
menu,
|
|
553
|
-
focusTrap
|
|
554
|
-
};
|
|
1
|
+
/**
|
|
2
|
+
* @sc4rfurryx/proteusjs/a11y-primitives
|
|
3
|
+
* Lightweight accessibility patterns
|
|
4
|
+
*
|
|
5
|
+
* @version 2.0.0
|
|
6
|
+
* @author sc4rfurry
|
|
7
|
+
* @license MIT
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface Controller {
|
|
11
|
+
destroy(): void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface DialogOptions {
|
|
15
|
+
modal?: boolean;
|
|
16
|
+
restoreFocus?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface TooltipOptions {
|
|
20
|
+
delay?: number;
|
|
21
|
+
placement?: 'top' | 'bottom' | 'left' | 'right';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface FocusTrapController {
|
|
25
|
+
activate(): void;
|
|
26
|
+
deactivate(): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function dialog(root: Element | string, opts: DialogOptions = {}): Controller {
|
|
30
|
+
const el = typeof root === 'string' ? document.querySelector(root) : root;
|
|
31
|
+
if (!el) throw new Error('Dialog element not found');
|
|
32
|
+
|
|
33
|
+
const { modal = true, restoreFocus = true } = opts;
|
|
34
|
+
let prevFocus: Element | null = null;
|
|
35
|
+
|
|
36
|
+
const open = () => {
|
|
37
|
+
if (restoreFocus) prevFocus = document.activeElement;
|
|
38
|
+
el.setAttribute('role', 'dialog');
|
|
39
|
+
if (modal) el.setAttribute('aria-modal', 'true');
|
|
40
|
+
(el as HTMLElement).focus();
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const close = () => {
|
|
44
|
+
if (restoreFocus && prevFocus) (prevFocus as HTMLElement).focus();
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return { destroy: () => close() };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function tooltip(trigger: Element, content: HTMLElement, opts: TooltipOptions = {}): Controller {
|
|
51
|
+
const { delay = 300 } = opts;
|
|
52
|
+
let timeout: number;
|
|
53
|
+
|
|
54
|
+
const show = () => {
|
|
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);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const hide = () => {
|
|
64
|
+
clearTimeout(timeout);
|
|
65
|
+
content.style.display = 'none';
|
|
66
|
+
trigger.removeAttribute('aria-describedby');
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
trigger.addEventListener('mouseenter', show);
|
|
70
|
+
trigger.addEventListener('mouseleave', hide);
|
|
71
|
+
trigger.addEventListener('focus', show);
|
|
72
|
+
trigger.addEventListener('blur', hide);
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
destroy: () => {
|
|
76
|
+
clearTimeout(timeout);
|
|
77
|
+
trigger.removeEventListener('mouseenter', show);
|
|
78
|
+
trigger.removeEventListener('mouseleave', hide);
|
|
79
|
+
trigger.removeEventListener('focus', show);
|
|
80
|
+
trigger.removeEventListener('blur', hide);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
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;
|
|
91
|
+
|
|
92
|
+
const first = elements[0] as HTMLElement;
|
|
93
|
+
const last = elements[elements.length - 1] as HTMLElement;
|
|
94
|
+
|
|
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
|
+
};
|
|
106
|
+
|
|
107
|
+
container.addEventListener('keydown', handleTab as EventListener);
|
|
108
|
+
first.focus();
|
|
109
|
+
|
|
110
|
+
return () => container.removeEventListener('keydown', handleTab as EventListener);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
let deactivate = () => {};
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
activate: () => { deactivate = activate() || (() => {}); },
|
|
117
|
+
deactivate: () => deactivate()
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function menu(container: Element): Controller {
|
|
122
|
+
const items = container.querySelectorAll('[role="menuitem"]');
|
|
123
|
+
let currentIndex = 0;
|
|
124
|
+
|
|
125
|
+
const navigate = (e: KeyboardEvent) => {
|
|
126
|
+
switch (e.key) {
|
|
127
|
+
case 'ArrowDown':
|
|
128
|
+
e.preventDefault();
|
|
129
|
+
currentIndex = (currentIndex + 1) % items.length;
|
|
130
|
+
(items[currentIndex] as HTMLElement).focus();
|
|
131
|
+
break;
|
|
132
|
+
case 'ArrowUp':
|
|
133
|
+
e.preventDefault();
|
|
134
|
+
currentIndex = currentIndex === 0 ? items.length - 1 : currentIndex - 1;
|
|
135
|
+
(items[currentIndex] as HTMLElement).focus();
|
|
136
|
+
break;
|
|
137
|
+
case 'Escape':
|
|
138
|
+
e.preventDefault();
|
|
139
|
+
container.dispatchEvent(new CustomEvent('menu:close'));
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
container.setAttribute('role', 'menu');
|
|
145
|
+
container.addEventListener('keydown', navigate as EventListener);
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
destroy: () => container.removeEventListener('keydown', navigate as EventListener)
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export default { dialog, tooltip, focusTrap, menu };
|