@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.
- package/README.md +5 -5
- package/dist/.tsbuildinfo +1 -1
- package/dist/adapters/react.d.ts +1 -0
- package/dist/adapters/react.esm.js +2 -1
- package/dist/adapters/react.esm.js.map +1 -1
- package/dist/adapters/svelte.esm.js +2 -1
- package/dist/adapters/svelte.esm.js.map +1 -1
- package/dist/adapters/vue.esm.js +2 -1
- package/dist/adapters/vue.esm.js.map +1 -1
- package/dist/modules/a11y-audit.d.ts +1 -9
- package/dist/modules/a11y-audit.esm.js +30 -475
- package/dist/modules/a11y-audit.esm.js.map +1 -1
- package/dist/modules/a11y-primitives.d.ts +8 -41
- package/dist/modules/a11y-primitives.esm.js +69 -400
- package/dist/modules/a11y-primitives.esm.js.map +1 -1
- package/dist/modules/anchor.d.ts +1 -0
- package/dist/modules/anchor.esm.js +2 -1
- package/dist/modules/anchor.esm.js.map +1 -1
- package/dist/modules/container.esm.js +1 -1
- package/dist/modules/perf.esm.js +1 -1
- package/dist/modules/popover.esm.js +1 -1
- package/dist/modules/scroll.esm.js +1 -1
- package/dist/modules/transitions.esm.js +1 -1
- package/dist/modules/typography.esm.js +1 -1
- package/dist/proteus.cjs.js +97 -875
- package/dist/proteus.cjs.js.map +1 -1
- package/dist/proteus.d.ts +11 -56
- package/dist/proteus.esm.js +97 -875
- 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 +97 -875
- 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 +9 -4
- package/src/index.ts +1 -1
- package/src/modules/a11y-audit/index.ts +29 -553
- package/src/modules/a11y-primitives/index.ts +73 -475
- package/src/modules/anchor/index.ts +2 -0
@@ -1,6 +1,6 @@
|
|
1
|
-
/**
|
1
|
+
/**
|
2
2
|
* @sc4rfurryx/proteusjs/a11y-primitives
|
3
|
-
*
|
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
|
42
|
-
if (!
|
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
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
128
|
-
|
129
|
-
|
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
|
-
|
134
|
-
|
135
|
-
|
64
|
+
clearTimeout(timeout);
|
65
|
+
content.style.display = 'none';
|
66
|
+
trigger.removeAttribute('aria-describedby');
|
136
67
|
};
|
137
68
|
|
138
|
-
|
139
|
-
|
140
|
-
|
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
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
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
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
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
|
-
|
213
|
-
|
92
|
+
const first = elements[0] as HTMLElement;
|
93
|
+
const last = elements[elements.length - 1] as HTMLElement;
|
214
94
|
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
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
|
-
|
226
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
335
|
-
|
336
|
-
}
|
116
|
+
activate: () => { deactivate = activate() || (() => {}); },
|
117
|
+
deactivate: () => deactivate()
|
337
118
|
};
|
338
119
|
}
|
339
120
|
|
340
|
-
|
341
|
-
|
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
|
350
|
-
|
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
|
-
|
128
|
+
e.preventDefault();
|
446
129
|
currentIndex = (currentIndex + 1) % items.length;
|
447
|
-
|
130
|
+
(items[currentIndex] as HTMLElement).focus();
|
448
131
|
break;
|
449
132
|
case 'ArrowUp':
|
450
|
-
|
133
|
+
e.preventDefault();
|
451
134
|
currentIndex = currentIndex === 0 ? items.length - 1 : currentIndex - 1;
|
452
|
-
|
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
|
-
|
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
|
-
|
534
|
-
|
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
|
-
|
541
|
-
deactivate
|
148
|
+
destroy: () => container.removeEventListener('keydown', navigate)
|
542
149
|
};
|
543
150
|
}
|
544
151
|
|
545
|
-
|
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
|
}
|