pulse-js-framework 1.10.0 → 1.10.3
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/compiler/parser/_extract.js +393 -0
- package/compiler/parser/blocks.js +361 -0
- package/compiler/parser/core.js +306 -0
- package/compiler/parser/expressions.js +386 -0
- package/compiler/parser/imports.js +108 -0
- package/compiler/parser/index.js +47 -0
- package/compiler/parser/state.js +155 -0
- package/compiler/parser/style.js +445 -0
- package/compiler/parser/view.js +632 -0
- package/compiler/parser.js +15 -2372
- package/compiler/parser.js.original +2376 -0
- package/package.json +2 -1
- package/runtime/a11y/announcements.js +213 -0
- package/runtime/a11y/contrast.js +125 -0
- package/runtime/a11y/focus.js +412 -0
- package/runtime/a11y/index.js +35 -0
- package/runtime/a11y/preferences.js +121 -0
- package/runtime/a11y/utils.js +164 -0
- package/runtime/a11y/validation.js +258 -0
- package/runtime/a11y/widgets.js +545 -0
- package/runtime/a11y.js +15 -1840
- package/runtime/a11y.js.original +1844 -0
- package/runtime/graphql/cache.js +69 -0
- package/runtime/graphql/client.js +563 -0
- package/runtime/graphql/hooks.js +492 -0
- package/runtime/graphql/index.js +62 -0
- package/runtime/graphql/subscriptions.js +241 -0
- package/runtime/graphql.js +12 -1322
- package/runtime/graphql.js.original +1326 -0
- package/runtime/router/core.js +956 -0
- package/runtime/router/guards.js +90 -0
- package/runtime/router/history.js +204 -0
- package/runtime/router/index.js +36 -0
- package/runtime/router/lazy.js +180 -0
- package/runtime/router/utils.js +226 -0
- package/runtime/router.js +12 -1600
- package/runtime/router.js.original +1605 -0
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse A11y - ARIA Widgets
|
|
3
|
+
*
|
|
4
|
+
* ARIA widget implementations (modal, tabs, accordion, etc.)
|
|
5
|
+
*
|
|
6
|
+
* @module pulse-js-framework/runtime/a11y/widgets
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { pulse, effect } from '../pulse.js';
|
|
10
|
+
import { generateId, makeInert } from './utils.js';
|
|
11
|
+
import { trapFocus, onEscapeKey, createRovingTabindex } from './focus.js';
|
|
12
|
+
|
|
13
|
+
// =============================================================================
|
|
14
|
+
// ARIA HELPERS
|
|
15
|
+
// =============================================================================
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Set multiple ARIA attributes on an element
|
|
19
|
+
* @param {HTMLElement} element - Target element
|
|
20
|
+
* @param {object} attrs - ARIA attributes (without 'aria-' prefix)
|
|
21
|
+
*/
|
|
22
|
+
export function setAriaAttributes(element, attrs) {
|
|
23
|
+
Object.entries(attrs).forEach(([key, value]) => {
|
|
24
|
+
if (value === null || value === undefined) {
|
|
25
|
+
element.removeAttribute(`aria-${key}`);
|
|
26
|
+
} else {
|
|
27
|
+
element.setAttribute(`aria-${key}`, String(value));
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Create an ARIA-compliant disclosure widget
|
|
34
|
+
* @param {HTMLElement} trigger - Button that toggles disclosure
|
|
35
|
+
* @param {HTMLElement} content - Content to show/hide
|
|
36
|
+
* @param {object} options - Options
|
|
37
|
+
* @returns {object} Control object with toggle, open, close methods
|
|
38
|
+
*/
|
|
39
|
+
export function createDisclosure(trigger, content, options = {}) {
|
|
40
|
+
const { defaultOpen = false, onToggle = null } = options;
|
|
41
|
+
|
|
42
|
+
const expanded = pulse(defaultOpen);
|
|
43
|
+
const id = content.id || `pulse-disclosure-${Date.now()}`;
|
|
44
|
+
|
|
45
|
+
content.id = id;
|
|
46
|
+
trigger.setAttribute('aria-controls', id);
|
|
47
|
+
trigger.setAttribute('aria-expanded', String(expanded.get()));
|
|
48
|
+
|
|
49
|
+
// Update visibility
|
|
50
|
+
effect(() => {
|
|
51
|
+
const isOpen = expanded.get();
|
|
52
|
+
trigger.setAttribute('aria-expanded', String(isOpen));
|
|
53
|
+
content.hidden = !isOpen;
|
|
54
|
+
if (onToggle) onToggle(isOpen);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Handle click
|
|
58
|
+
trigger.addEventListener('click', () => {
|
|
59
|
+
expanded.update(v => !v);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Handle keyboard
|
|
63
|
+
trigger.addEventListener('keydown', (e) => {
|
|
64
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
65
|
+
e.preventDefault();
|
|
66
|
+
expanded.update(v => !v);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
expanded,
|
|
72
|
+
toggle: () => expanded.update(v => !v),
|
|
73
|
+
open: () => expanded.set(true),
|
|
74
|
+
close: () => expanded.set(false)
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Create ARIA-compliant tabs
|
|
80
|
+
* @param {HTMLElement} tablist - Container with role="tablist"
|
|
81
|
+
* @param {object} options - Options
|
|
82
|
+
* @returns {object} Control object
|
|
83
|
+
*/
|
|
84
|
+
export function createTabs(tablist, options = {}) {
|
|
85
|
+
const { defaultIndex = 0, orientation = 'horizontal', onSelect = null } = options;
|
|
86
|
+
|
|
87
|
+
const tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
|
|
88
|
+
const panels = tabs.map(tab => {
|
|
89
|
+
const panelId = tab.getAttribute('aria-controls');
|
|
90
|
+
return document.getElementById(panelId);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const selectedIndex = pulse(defaultIndex);
|
|
94
|
+
|
|
95
|
+
tablist.setAttribute('aria-orientation', orientation);
|
|
96
|
+
|
|
97
|
+
// Update selection
|
|
98
|
+
effect(() => {
|
|
99
|
+
const index = selectedIndex.get();
|
|
100
|
+
|
|
101
|
+
tabs.forEach((tab, i) => {
|
|
102
|
+
const isSelected = i === index;
|
|
103
|
+
tab.setAttribute('aria-selected', String(isSelected));
|
|
104
|
+
tab.setAttribute('tabindex', isSelected ? '0' : '-1');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
panels.forEach((panel, i) => {
|
|
108
|
+
if (panel) {
|
|
109
|
+
panel.hidden = i !== index;
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
if (onSelect) onSelect(index);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Handle click
|
|
117
|
+
tabs.forEach((tab, i) => {
|
|
118
|
+
tab.addEventListener('click', () => {
|
|
119
|
+
selectedIndex.set(i);
|
|
120
|
+
tab.focus();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Handle keyboard navigation
|
|
125
|
+
tablist.addEventListener('keydown', (e) => {
|
|
126
|
+
const currentIndex = selectedIndex.get();
|
|
127
|
+
let newIndex = currentIndex;
|
|
128
|
+
|
|
129
|
+
const isHorizontal = orientation === 'horizontal';
|
|
130
|
+
const prevKey = isHorizontal ? 'ArrowLeft' : 'ArrowUp';
|
|
131
|
+
const nextKey = isHorizontal ? 'ArrowRight' : 'ArrowDown';
|
|
132
|
+
|
|
133
|
+
switch (e.key) {
|
|
134
|
+
case prevKey:
|
|
135
|
+
newIndex = currentIndex > 0 ? currentIndex - 1 : tabs.length - 1;
|
|
136
|
+
break;
|
|
137
|
+
case nextKey:
|
|
138
|
+
newIndex = currentIndex < tabs.length - 1 ? currentIndex + 1 : 0;
|
|
139
|
+
break;
|
|
140
|
+
case 'Home':
|
|
141
|
+
newIndex = 0;
|
|
142
|
+
break;
|
|
143
|
+
case 'End':
|
|
144
|
+
newIndex = tabs.length - 1;
|
|
145
|
+
break;
|
|
146
|
+
default:
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
e.preventDefault();
|
|
151
|
+
selectedIndex.set(newIndex);
|
|
152
|
+
tabs[newIndex].focus();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
selectedIndex,
|
|
157
|
+
select: (index) => selectedIndex.set(index),
|
|
158
|
+
tabs,
|
|
159
|
+
panels
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
// =============================================================================
|
|
163
|
+
// ARIA WIDGETS
|
|
164
|
+
// =============================================================================
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Create an accessible modal dialog
|
|
168
|
+
* Composes trapFocus, onEscapeKey, and proper ARIA attributes
|
|
169
|
+
* @param {HTMLElement} dialog - Dialog element
|
|
170
|
+
* @param {object} options - Options
|
|
171
|
+
* @param {HTMLElement} options.triggerElement - Element that triggered the dialog
|
|
172
|
+
* @param {string} options.labelledBy - ID of element labeling the dialog
|
|
173
|
+
* @param {string} options.describedBy - ID of element describing the dialog
|
|
174
|
+
* @param {HTMLElement} options.initialFocus - Element to focus initially
|
|
175
|
+
* @param {Function} options.onClose - Callback when dialog should close
|
|
176
|
+
* @param {boolean} options.closeOnBackdropClick - Close on backdrop click (default: true)
|
|
177
|
+
* @param {boolean} options.inertBackground - Make background inert (default: true)
|
|
178
|
+
* @returns {object} Control object with open, close methods and isOpen pulse
|
|
179
|
+
*/
|
|
180
|
+
export function createModal(dialog, options = {}) {
|
|
181
|
+
const {
|
|
182
|
+
labelledBy = null,
|
|
183
|
+
describedBy = null,
|
|
184
|
+
initialFocus = null,
|
|
185
|
+
onClose = null,
|
|
186
|
+
closeOnBackdropClick = true,
|
|
187
|
+
inertBackground = true
|
|
188
|
+
} = options;
|
|
189
|
+
|
|
190
|
+
const isOpen = pulse(false);
|
|
191
|
+
let releaseFocusTrap = null;
|
|
192
|
+
let removeEscapeHandler = null;
|
|
193
|
+
let restoreInertFns = null;
|
|
194
|
+
let backdropHandler = null;
|
|
195
|
+
|
|
196
|
+
// Set ARIA attributes
|
|
197
|
+
dialog.setAttribute('role', 'dialog');
|
|
198
|
+
dialog.setAttribute('aria-modal', 'true');
|
|
199
|
+
if (labelledBy) dialog.setAttribute('aria-labelledby', labelledBy);
|
|
200
|
+
if (describedBy) dialog.setAttribute('aria-describedby', describedBy);
|
|
201
|
+
|
|
202
|
+
const open = () => {
|
|
203
|
+
if (isOpen.get()) return;
|
|
204
|
+
|
|
205
|
+
dialog.hidden = false;
|
|
206
|
+
isOpen.set(true);
|
|
207
|
+
|
|
208
|
+
// Make background inert
|
|
209
|
+
if (inertBackground && typeof document !== 'undefined') {
|
|
210
|
+
const siblings = Array.from(document.body.children)
|
|
211
|
+
.filter(el => el !== dialog && !el.hasAttribute('inert'));
|
|
212
|
+
restoreInertFns = siblings.map(el => makeInert(el));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Trap focus
|
|
216
|
+
releaseFocusTrap = trapFocus(dialog, {
|
|
217
|
+
autoFocus: true,
|
|
218
|
+
returnFocus: true,
|
|
219
|
+
initialFocus
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Handle escape key
|
|
223
|
+
removeEscapeHandler = onEscapeKey(dialog, close);
|
|
224
|
+
|
|
225
|
+
// Handle backdrop click
|
|
226
|
+
if (closeOnBackdropClick) {
|
|
227
|
+
backdropHandler = (e) => {
|
|
228
|
+
if (e.target === dialog) close();
|
|
229
|
+
};
|
|
230
|
+
dialog.addEventListener('click', backdropHandler);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Announce to screen readers
|
|
234
|
+
announce('Dialog opened');
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const close = () => {
|
|
238
|
+
if (!isOpen.get()) return;
|
|
239
|
+
|
|
240
|
+
dialog.hidden = true;
|
|
241
|
+
isOpen.set(false);
|
|
242
|
+
|
|
243
|
+
// Clean up
|
|
244
|
+
if (releaseFocusTrap) {
|
|
245
|
+
releaseFocusTrap();
|
|
246
|
+
releaseFocusTrap = null;
|
|
247
|
+
}
|
|
248
|
+
if (removeEscapeHandler) {
|
|
249
|
+
removeEscapeHandler();
|
|
250
|
+
removeEscapeHandler = null;
|
|
251
|
+
}
|
|
252
|
+
if (restoreInertFns) {
|
|
253
|
+
restoreInertFns.forEach(restore => restore());
|
|
254
|
+
restoreInertFns = null;
|
|
255
|
+
}
|
|
256
|
+
if (backdropHandler) {
|
|
257
|
+
dialog.removeEventListener('click', backdropHandler);
|
|
258
|
+
backdropHandler = null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (onClose) onClose();
|
|
262
|
+
announce('Dialog closed');
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
return { isOpen, open, close };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Create an accessible tooltip
|
|
270
|
+
* Manages aria-describedby and visibility
|
|
271
|
+
* @param {HTMLElement} trigger - Element that triggers tooltip
|
|
272
|
+
* @param {HTMLElement} tooltip - Tooltip element
|
|
273
|
+
* @param {object} options - Options
|
|
274
|
+
* @param {number} options.showDelay - Delay before showing (ms, default: 500)
|
|
275
|
+
* @param {number} options.hideDelay - Delay before hiding (ms, default: 100)
|
|
276
|
+
* @returns {object} Control object with show, hide methods and isVisible pulse
|
|
277
|
+
*/
|
|
278
|
+
export function createTooltip(trigger, tooltip, options = {}) {
|
|
279
|
+
const {
|
|
280
|
+
showDelay = 500,
|
|
281
|
+
hideDelay = 100
|
|
282
|
+
} = options;
|
|
283
|
+
|
|
284
|
+
const isVisible = pulse(false);
|
|
285
|
+
let showTimer = null;
|
|
286
|
+
let hideTimer = null;
|
|
287
|
+
|
|
288
|
+
// Generate ID if needed
|
|
289
|
+
const tooltipId = tooltip.id || generateId('tooltip');
|
|
290
|
+
tooltip.id = tooltipId;
|
|
291
|
+
|
|
292
|
+
// Set ARIA attributes
|
|
293
|
+
tooltip.setAttribute('role', 'tooltip');
|
|
294
|
+
trigger.setAttribute('aria-describedby', tooltipId);
|
|
295
|
+
tooltip.hidden = true;
|
|
296
|
+
|
|
297
|
+
const show = () => {
|
|
298
|
+
clearTimeout(hideTimer);
|
|
299
|
+
showTimer = setTimeout(() => {
|
|
300
|
+
tooltip.hidden = false;
|
|
301
|
+
isVisible.set(true);
|
|
302
|
+
}, showDelay);
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const hide = () => {
|
|
306
|
+
clearTimeout(showTimer);
|
|
307
|
+
hideTimer = setTimeout(() => {
|
|
308
|
+
tooltip.hidden = true;
|
|
309
|
+
isVisible.set(false);
|
|
310
|
+
}, hideDelay);
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const showImmediate = () => {
|
|
314
|
+
clearTimeout(hideTimer);
|
|
315
|
+
clearTimeout(showTimer);
|
|
316
|
+
tooltip.hidden = false;
|
|
317
|
+
isVisible.set(true);
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const hideImmediate = () => {
|
|
321
|
+
clearTimeout(hideTimer);
|
|
322
|
+
clearTimeout(showTimer);
|
|
323
|
+
tooltip.hidden = true;
|
|
324
|
+
isVisible.set(false);
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const handleEscapeKey = (e) => {
|
|
328
|
+
if (e.key === 'Escape') hideImmediate();
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
// Event listeners
|
|
332
|
+
trigger.addEventListener('mouseenter', show);
|
|
333
|
+
trigger.addEventListener('mouseleave', hide);
|
|
334
|
+
trigger.addEventListener('focus', showImmediate);
|
|
335
|
+
trigger.addEventListener('blur', hideImmediate);
|
|
336
|
+
trigger.addEventListener('keydown', handleEscapeKey);
|
|
337
|
+
|
|
338
|
+
const cleanup = () => {
|
|
339
|
+
clearTimeout(showTimer);
|
|
340
|
+
clearTimeout(hideTimer);
|
|
341
|
+
trigger.removeEventListener('mouseenter', show);
|
|
342
|
+
trigger.removeEventListener('mouseleave', hide);
|
|
343
|
+
trigger.removeEventListener('focus', showImmediate);
|
|
344
|
+
trigger.removeEventListener('blur', hideImmediate);
|
|
345
|
+
trigger.removeEventListener('keydown', handleEscapeKey);
|
|
346
|
+
trigger.removeAttribute('aria-describedby');
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
return { isVisible, show: showImmediate, hide: hideImmediate, cleanup };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Create an accessible accordion (composed of disclosures)
|
|
354
|
+
* @param {HTMLElement} container - Accordion container
|
|
355
|
+
* @param {object} options - Options
|
|
356
|
+
* @param {string} options.triggerSelector - Selector for accordion triggers
|
|
357
|
+
* @param {string} options.panelSelector - Selector for accordion panels
|
|
358
|
+
* @param {boolean} options.allowMultiple - Allow multiple panels open (default: false)
|
|
359
|
+
* @param {number} options.defaultOpen - Index of initially open panel (-1 for none)
|
|
360
|
+
* @param {Function} options.onToggle - Callback (index, isOpen) => void
|
|
361
|
+
* @returns {object} Control object
|
|
362
|
+
*/
|
|
363
|
+
export function createAccordion(container, options = {}) {
|
|
364
|
+
const {
|
|
365
|
+
triggerSelector = '[data-accordion-trigger]',
|
|
366
|
+
panelSelector = '[data-accordion-panel]',
|
|
367
|
+
allowMultiple = false,
|
|
368
|
+
defaultOpen = -1,
|
|
369
|
+
onToggle = null
|
|
370
|
+
} = options;
|
|
371
|
+
|
|
372
|
+
const triggers = Array.from(container.querySelectorAll(triggerSelector));
|
|
373
|
+
const panels = Array.from(container.querySelectorAll(panelSelector));
|
|
374
|
+
const disclosures = [];
|
|
375
|
+
const openIndices = pulse(defaultOpen >= 0 ? [defaultOpen] : []);
|
|
376
|
+
|
|
377
|
+
triggers.forEach((trigger, index) => {
|
|
378
|
+
const panel = panels[index];
|
|
379
|
+
if (!panel) return;
|
|
380
|
+
|
|
381
|
+
const disclosure = createDisclosure(trigger, panel, {
|
|
382
|
+
defaultOpen: index === defaultOpen,
|
|
383
|
+
onToggle: (isExpanded) => {
|
|
384
|
+
if (isExpanded) {
|
|
385
|
+
if (allowMultiple) {
|
|
386
|
+
openIndices.update(arr => arr.includes(index) ? arr : [...arr, index]);
|
|
387
|
+
} else {
|
|
388
|
+
// Close other panels
|
|
389
|
+
disclosures.forEach((d, i) => {
|
|
390
|
+
if (i !== index && d.expanded.get()) d.close();
|
|
391
|
+
});
|
|
392
|
+
openIndices.set([index]);
|
|
393
|
+
}
|
|
394
|
+
} else {
|
|
395
|
+
openIndices.update(arr => arr.filter(i => i !== index));
|
|
396
|
+
}
|
|
397
|
+
if (onToggle) onToggle(index, isExpanded);
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
disclosures.push(disclosure);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
openIndices,
|
|
406
|
+
disclosures,
|
|
407
|
+
openAll: () => {
|
|
408
|
+
if (allowMultiple) {
|
|
409
|
+
disclosures.forEach(d => d.open());
|
|
410
|
+
}
|
|
411
|
+
},
|
|
412
|
+
closeAll: () => {
|
|
413
|
+
disclosures.forEach(d => d.close());
|
|
414
|
+
},
|
|
415
|
+
open: (index) => {
|
|
416
|
+
if (disclosures[index]) disclosures[index].open();
|
|
417
|
+
},
|
|
418
|
+
close: (index) => {
|
|
419
|
+
if (disclosures[index]) disclosures[index].close();
|
|
420
|
+
},
|
|
421
|
+
toggle: (index) => {
|
|
422
|
+
if (disclosures[index]) disclosures[index].toggle();
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Create an accessible dropdown menu
|
|
429
|
+
* @param {HTMLElement} button - Menu button
|
|
430
|
+
* @param {HTMLElement} menu - Menu container
|
|
431
|
+
* @param {object} options - Options
|
|
432
|
+
* @param {string} options.itemSelector - Selector for menu items (default: '[role="menuitem"]')
|
|
433
|
+
* @param {Function} options.onSelect - Callback when item is selected
|
|
434
|
+
* @param {boolean} options.closeOnSelect - Close menu on item selection (default: true)
|
|
435
|
+
* @returns {object} Control object with open, close, toggle methods and isOpen pulse
|
|
436
|
+
*/
|
|
437
|
+
export function createMenu(button, menu, options = {}) {
|
|
438
|
+
const {
|
|
439
|
+
itemSelector = '[role="menuitem"]',
|
|
440
|
+
onSelect = null,
|
|
441
|
+
closeOnSelect = true
|
|
442
|
+
} = options;
|
|
443
|
+
|
|
444
|
+
const isOpen = pulse(false);
|
|
445
|
+
const menuId = menu.id || generateId('menu');
|
|
446
|
+
let rovingCleanup = null;
|
|
447
|
+
let documentClickHandler = null;
|
|
448
|
+
|
|
449
|
+
// Set ARIA attributes
|
|
450
|
+
menu.id = menuId;
|
|
451
|
+
menu.setAttribute('role', 'menu');
|
|
452
|
+
button.setAttribute('aria-haspopup', 'menu');
|
|
453
|
+
button.setAttribute('aria-controls', menuId);
|
|
454
|
+
button.setAttribute('aria-expanded', 'false');
|
|
455
|
+
menu.hidden = true;
|
|
456
|
+
|
|
457
|
+
const open = () => {
|
|
458
|
+
if (isOpen.get()) return;
|
|
459
|
+
|
|
460
|
+
menu.hidden = false;
|
|
461
|
+
button.setAttribute('aria-expanded', 'true');
|
|
462
|
+
isOpen.set(true);
|
|
463
|
+
|
|
464
|
+
// Setup roving tabindex for menu items
|
|
465
|
+
rovingCleanup = createRovingTabindex(menu, {
|
|
466
|
+
selector: itemSelector,
|
|
467
|
+
orientation: 'vertical',
|
|
468
|
+
onSelect: (el, index) => {
|
|
469
|
+
if (onSelect) onSelect(el, index);
|
|
470
|
+
if (closeOnSelect) close();
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
// Focus first item
|
|
475
|
+
const firstItem = menu.querySelector(itemSelector);
|
|
476
|
+
if (firstItem) firstItem.focus();
|
|
477
|
+
|
|
478
|
+
// Close on click outside (delay to avoid immediate close)
|
|
479
|
+
setTimeout(() => {
|
|
480
|
+
documentClickHandler = (e) => {
|
|
481
|
+
if (!button.contains(e.target) && !menu.contains(e.target)) {
|
|
482
|
+
close();
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
document.addEventListener('click', documentClickHandler);
|
|
486
|
+
}, 0);
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
const close = () => {
|
|
490
|
+
if (!isOpen.get()) return;
|
|
491
|
+
|
|
492
|
+
menu.hidden = true;
|
|
493
|
+
button.setAttribute('aria-expanded', 'false');
|
|
494
|
+
isOpen.set(false);
|
|
495
|
+
|
|
496
|
+
if (rovingCleanup) {
|
|
497
|
+
rovingCleanup();
|
|
498
|
+
rovingCleanup = null;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (documentClickHandler) {
|
|
502
|
+
document.removeEventListener('click', documentClickHandler);
|
|
503
|
+
documentClickHandler = null;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
button.focus();
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
const toggle = () => isOpen.get() ? close() : open();
|
|
510
|
+
|
|
511
|
+
// Button click
|
|
512
|
+
button.addEventListener('click', toggle);
|
|
513
|
+
|
|
514
|
+
// Keyboard navigation on button
|
|
515
|
+
const handleButtonKeyDown = (e) => {
|
|
516
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
517
|
+
e.preventDefault();
|
|
518
|
+
open();
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
button.addEventListener('keydown', handleButtonKeyDown);
|
|
522
|
+
|
|
523
|
+
// Close on escape
|
|
524
|
+
const handleMenuKeyDown = (e) => {
|
|
525
|
+
if (e.key === 'Escape') {
|
|
526
|
+
e.stopPropagation();
|
|
527
|
+
close();
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
menu.addEventListener('keydown', handleMenuKeyDown);
|
|
531
|
+
|
|
532
|
+
const cleanup = () => {
|
|
533
|
+
button.removeEventListener('click', toggle);
|
|
534
|
+
button.removeEventListener('keydown', handleButtonKeyDown);
|
|
535
|
+
menu.removeEventListener('keydown', handleMenuKeyDown);
|
|
536
|
+
if (documentClickHandler) {
|
|
537
|
+
document.removeEventListener('click', documentClickHandler);
|
|
538
|
+
}
|
|
539
|
+
if (rovingCleanup) {
|
|
540
|
+
rovingCleanup();
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
return { isOpen, open, close, toggle, cleanup };
|
|
545
|
+
}
|