@pindoba/astro-menu 0.0.0-alpha-20260829223243

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.
@@ -0,0 +1,57 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, it, expect, beforeEach } from "vitest";
3
+ import { getMenu, type MenuHandle, type MenuRootElement } from "../runtime.js";
4
+
5
+ /**
6
+ * `getMenu` is the documented consumer entry point for driving an Astro menu
7
+ * imperatively (`getMenu(root)?.open()`), so it has to be safe to call against
8
+ * anything a consumer might hand it — including a root that has not booted yet
9
+ * and a nullable `querySelector` result.
10
+ */
11
+
12
+ function makeHandle(): MenuHandle {
13
+ return {
14
+ open: () => {},
15
+ close: () => {},
16
+ toggle: () => {},
17
+ isOpen: () => false,
18
+ setRadioValue: () => {},
19
+ getRadioValue: () => undefined,
20
+ destroy: () => {},
21
+ };
22
+ }
23
+
24
+ let root: MenuRootElement;
25
+
26
+ beforeEach(() => {
27
+ document.body.innerHTML = "";
28
+ root = document.createElement("div");
29
+ document.body.appendChild(root);
30
+ });
31
+
32
+ describe("getMenu", () => {
33
+ it("returns the handle a booted root carries", () => {
34
+ const handle = makeHandle();
35
+ root.menu = handle;
36
+ expect(getMenu(root)).toBe(handle);
37
+ });
38
+
39
+ it("returns null for a root that has not booted", () => {
40
+ // The boot may not have run yet (module order, an early script), and the
41
+ // consumer must get null rather than a crash.
42
+ expect(getMenu(root)).toBeNull();
43
+ });
44
+
45
+ it("returns null for null and undefined", () => {
46
+ // `getMenu(document.querySelector("…"))` is the documented call shape, and
47
+ // that returns null when the selector misses.
48
+ expect(getMenu(null)).toBeNull();
49
+ expect(getMenu(undefined)).toBeNull();
50
+ });
51
+
52
+ it("returns null again once the menu is destroyed", () => {
53
+ root.menu = makeHandle();
54
+ delete root.menu;
55
+ expect(getMenu(root)).toBeNull();
56
+ });
57
+ });
@@ -0,0 +1,598 @@
1
+ /**
2
+ * Client controller for the Astro Menu — the vanilla-DOM translation of
3
+ * `menu.svelte` + `menu-level.svelte` + `menu-context.svelte.ts`.
4
+ *
5
+ * Astro has no client reactivity framework, so this rebuilds the interaction
6
+ * model by hand from the `data-menu-config` payload `menu.astro` serialized:
7
+ *
8
+ * - a cross-level open PATH (root→leaf submenu ids),
9
+ * - the root Popover driven by a click trigger,
10
+ * - submenu Popovers opened/closed imperatively (open-driven, never torn down),
11
+ * - safe-triangle pointer gating with a 300ms grace,
12
+ * - hover opens a submenu / hovering a sibling closes it (unless the triangle
13
+ * still defers),
14
+ * - capture-phase scroll closes the open submenu,
15
+ * - keyboard: list nav delegated to each level's self-booting ListBox
16
+ * (roving), while ArrowRight/ArrowLeft/Escape are scoped here with
17
+ * stopPropagation,
18
+ * - explicit first-row focus on open (retry + preventScroll — native dialog
19
+ * autofocus is disabled and every menu row is `tabindex -1`),
20
+ * - activation dispatched as `pindoba:menu:*` CustomEvents (no callbacks).
21
+ */
22
+ import {
23
+ createPopover,
24
+ type HeadlessPopover,
25
+ type Placement,
26
+ } from "@pindoba/core-popover";
27
+ import {
28
+ createSubmenuAimTracker,
29
+ type Point,
30
+ type Rect,
31
+ type SubmenuSide,
32
+ } from "@pindoba/core-menu";
33
+ import { isSectionInput, type ListBoxNodeInput } from "@pindoba/core-listbox";
34
+ import type { ListBoxRootElement } from "@pindoba/astro-listbox/boot-listbox";
35
+ import type {
36
+ MenuConfig,
37
+ MenuHandle,
38
+ MenuLevelConfig,
39
+ MenuRootElement,
40
+ MenuSubmenuRef,
41
+ } from "./runtime";
42
+
43
+ export interface BootResult {
44
+ destroy: () => void;
45
+ }
46
+
47
+ type MenuConfigWithOpen = MenuConfig & { open?: boolean };
48
+
49
+ // The dialog instance type, derived from the popover's own `setDialog` signature
50
+ // so we don't need a direct dependency on `@pindoba/core-dialog`.
51
+ type DialogInstance = Parameters<HeadlessPopover["setDialog"]>[0];
52
+
53
+ declare global {
54
+ interface Window {
55
+ __PindobaDialogManager?: {
56
+ getInstance(dialogId: string): DialogInstance;
57
+ };
58
+ }
59
+ }
60
+
61
+ function waitForManager(): Promise<
62
+ NonNullable<Window["__PindobaDialogManager"]>
63
+ > {
64
+ if (window.__PindobaDialogManager) {
65
+ return Promise.resolve(window.__PindobaDialogManager);
66
+ }
67
+ return new Promise((resolve) => {
68
+ document.addEventListener(
69
+ "pindoba:dialog-manager-ready",
70
+ () => resolve(window.__PindobaDialogManager!),
71
+ { once: true },
72
+ );
73
+ });
74
+ }
75
+
76
+ interface LevelRuntime {
77
+ cfg: MenuLevelConfig;
78
+ popover: HeadlessPopover;
79
+ }
80
+
81
+ /**
82
+ * Wire one server-rendered menu. `triggerEl` carries the `data-menu-config`
83
+ * payload; the popovers/listboxes are found by their serialized ids.
84
+ */
85
+ export function bootMenu(triggerEl: HTMLElement): BootResult | null {
86
+ if (triggerEl.hasAttribute("data-menu-initialized")) return null;
87
+
88
+ // Ownership checks come BEFORE any mutation: every framework's menu trigger
89
+ // carries `data-menu-trigger` (from the shared core bag), and pages can mix
90
+ // frameworks — marking or wiring a foreign (React/Vue/Svelte) trigger causes
91
+ // hydration mismatches and steals their interactions.
92
+ let config: MenuConfigWithOpen;
93
+ try {
94
+ config = JSON.parse(
95
+ triggerEl.getAttribute("data-menu-config") || "{}",
96
+ ) as MenuConfigWithOpen;
97
+ } catch {
98
+ return null;
99
+ }
100
+ if (!Array.isArray(config.levels)) return null;
101
+
102
+ const root = triggerEl.closest<HTMLElement>("[data-pindoba-menu]");
103
+ if (!root) return null;
104
+
105
+ triggerEl.setAttribute("data-menu-initialized", "true");
106
+
107
+ const side: SubmenuSide = config.side ?? "right";
108
+ const ac = new AbortController();
109
+ const { signal } = ac;
110
+
111
+ // --- Cross-level state (mirrors menu.svelte) ------------------------------
112
+ let openPath: string[] = [];
113
+ // Safe-triangle bookkeeping — the shared core tracker.
114
+ const aimTracker = createSubmenuAimTracker();
115
+
116
+ // Checkbox / radio state lives here; the server render seeded the initial
117
+ // aria-checked. Toggles are written back into each level ListBox's own items
118
+ // (via its `setItems` handle), so the pattern-aware boot keeps rendering the
119
+ // correct aria-checked on every internal re-render.
120
+ const checkedState = new Map<string, boolean>();
121
+ const radioState = new Map<string, string>(
122
+ Object.entries(config.radioInitial ?? {}),
123
+ );
124
+ for (const [id, meta] of Object.entries(config.nodeIndex)) {
125
+ if (meta.type === "checkbox") checkedState.set(id, meta.checked);
126
+ }
127
+
128
+ // --- Popover per level ----------------------------------------------------
129
+ const levels: LevelRuntime[] = [];
130
+ const byId = new Map<string, LevelRuntime>();
131
+ let rootRuntime: LevelRuntime | null = null;
132
+
133
+ for (const cfg of config.levels) {
134
+ const dialogEl = document.getElementById(
135
+ cfg.popoverId,
136
+ ) as HTMLDialogElement | null;
137
+
138
+ const isRoot = cfg.id === null;
139
+ const popover = createPopover(
140
+ isRoot
141
+ ? {
142
+ id: cfg.popoverId,
143
+ placement: config.placement as Placement,
144
+ triggerStrategy: "click",
145
+ anchorElement: triggerEl,
146
+ autoFocus: false,
147
+ minWidthReference: true,
148
+ }
149
+ : {
150
+ id: cfg.popoverId,
151
+ placement: "right-start",
152
+ triggerStrategy: "manual",
153
+ autoFocus: false,
154
+ // A small positive mainAxis tucks the submenu under the parent
155
+ // panel edge; the negative crossAxis lifts its first row to align
156
+ // with the trigger row (see connect-menu). Per-depth z-index keeps
157
+ // a nested non-modal dialog above the panel it overlaps.
158
+ positionOffset: { mainAxis: 2, crossAxis: -8 },
159
+ zIndex: 10000 + cfg.depth,
160
+ },
161
+ );
162
+
163
+ if (dialogEl) {
164
+ // Relocate the non-modal dialog out of any clipping subtree (mirrors
165
+ // usePopover / boot-select). Prefer <body>; nest into an ancestor only
166
+ // when it's a MODAL <dialog> so a menu inside a modal isn't occluded
167
+ // behind it — never a non-modal ancestor whose clip would clip the menu.
168
+ if (dialogEl.dataset.isModal === "false") {
169
+ const target =
170
+ dialogEl.parentElement?.closest(
171
+ 'dialog:modal, dialog[data-is-modal="true"]',
172
+ ) ?? document.body;
173
+ if (dialogEl.parentElement !== target) target.appendChild(dialogEl);
174
+ }
175
+ popover.setDialogElement(dialogEl);
176
+ }
177
+ if (isRoot) popover.setTriggerElement(triggerEl);
178
+
179
+ void waitForManager().then((manager) => {
180
+ const instance = manager.getInstance(cfg.popoverId);
181
+ if (instance) popover.setDialog(instance);
182
+ });
183
+
184
+ const rt: LevelRuntime = { cfg, popover };
185
+ levels.push(rt);
186
+ if (isRoot) rootRuntime = rt;
187
+ else byId.set(cfg.id as string, rt);
188
+ }
189
+
190
+ if (!rootRuntime) {
191
+ // A level-less payload is a render bug, but bailing here happens *after* the
192
+ // per-level popovers were built — leaving them alive would keep their
193
+ // Floating UI autoUpdate loops and dialog listeners running forever. Unmark
194
+ // the trigger too, so a corrected re-render can still boot.
195
+ ac.abort();
196
+ for (const rt of levels) rt.popover.destroy();
197
+ triggerEl.removeAttribute("data-menu-initialized");
198
+ return null;
199
+ }
200
+ const rootPopover = rootRuntime.popover;
201
+
202
+ // The submenu dialogs are "inside" the root's outside-click boundary (they
203
+ // portal outside its subtree). Additive `insideElements` on the core popover.
204
+ const submenuDialogEls = config.levels
205
+ .filter((l) => l.id !== null)
206
+ .map((l) => document.getElementById(l.popoverId))
207
+ .filter((el): el is HTMLElement => el != null);
208
+ rootPopover.setInsideElements(submenuDialogEls);
209
+
210
+ // --- Helpers --------------------------------------------------------------
211
+ function emit(type: string, detail: Record<string, unknown>) {
212
+ root!.dispatchEvent(new CustomEvent(type, { detail, bubbles: true }));
213
+ }
214
+
215
+ function focusFirstRow(menuId: string, attempts = 10) {
216
+ const first = document
217
+ .getElementById(menuId)
218
+ ?.querySelector<HTMLElement>("[data-key]");
219
+ if (first) {
220
+ first.focus({ preventScroll: true });
221
+ if (document.activeElement === first) return;
222
+ }
223
+ if (attempts > 0) {
224
+ requestAnimationFrame(() => focusFirstRow(menuId, attempts - 1));
225
+ }
226
+ }
227
+
228
+ function focusSubmenuFirstItem(subId: string, attempts = 10) {
229
+ const dialog = document.getElementById(`${subId}-popover`);
230
+ const first = dialog?.querySelector<HTMLElement>("[data-key]");
231
+ if (first) {
232
+ first.focus({ preventScroll: true });
233
+ if (document.activeElement === first) return;
234
+ }
235
+ if (attempts > 0) {
236
+ requestAnimationFrame(() => focusSubmenuFirstItem(subId, attempts - 1));
237
+ }
238
+ }
239
+
240
+ /** Open every submenu whose id is in the path; close the rest. */
241
+ function reconcile() {
242
+ for (const rt of levels) {
243
+ if (rt.cfg.id === null) continue;
244
+ const want = openPath.includes(rt.cfg.id);
245
+ if (want) {
246
+ // Anchor re-resolved per open (the parent row is guaranteed rendered).
247
+ if (rt.cfg.parentTriggerItemId) {
248
+ rt.popover.setAnchorElement(
249
+ document.getElementById(rt.cfg.parentTriggerItemId),
250
+ );
251
+ }
252
+ if (!rt.popover.$open.get()) rt.popover.$open.set(true);
253
+ } else if (rt.popover.$open.get()) {
254
+ rt.popover.$open.set(false);
255
+ }
256
+ }
257
+ }
258
+
259
+ function openSubmenu(
260
+ id: string,
261
+ parentId: string | null,
262
+ via: "pointer" | "keyboard" = "pointer",
263
+ /** The submenu's instance-unique id (desc.qualifiedId) for DOM lookups. */
264
+ qualifiedId?: string,
265
+ ) {
266
+ if (openPath.includes(id)) return;
267
+ const prevActive = document.activeElement as HTMLElement | null;
268
+ if (parentId == null) {
269
+ openPath = [id];
270
+ } else {
271
+ const idx = openPath.indexOf(parentId);
272
+ openPath =
273
+ idx === -1 ? [parentId, id] : [...openPath.slice(0, idx + 1), id];
274
+ }
275
+ reconcile();
276
+ // A pointer open must not move focus. The native `<dialog>.show()` may still
277
+ // focus the dialog itself; if it did, put focus back — scroll untouched.
278
+ if (via === "pointer" && prevActive && prevActive !== document.body) {
279
+ requestAnimationFrame(() => {
280
+ const dialog = document.getElementById(`${qualifiedId ?? id}-popover`);
281
+ const active = document.activeElement;
282
+ if (
283
+ dialog &&
284
+ active &&
285
+ (dialog === active || dialog.contains(active))
286
+ ) {
287
+ prevActive.focus({ preventScroll: true });
288
+ }
289
+ });
290
+ }
291
+ }
292
+
293
+ function closeSubmenu(id: string) {
294
+ const idx = openPath.indexOf(id);
295
+ if (idx === -1) return;
296
+ openPath = openPath.slice(0, idx);
297
+ reconcile();
298
+ }
299
+
300
+ function closeRoot() {
301
+ rootPopover.$open.set(false);
302
+ }
303
+
304
+ function trackPointer(pointer: Point, rect: Rect) {
305
+ aimTracker.trackPointer(pointer, rect, side);
306
+ }
307
+
308
+ function shouldDeferSiblingHover() {
309
+ return aimTracker.shouldDeferSiblingHover();
310
+ }
311
+
312
+ /** The key of the roving-focused row inside a level's list, if any. */
313
+ function activeRowKey(menuId: string): string | null {
314
+ const ul = document.getElementById(menuId);
315
+ const active = document.activeElement;
316
+ if (!ul || !(active instanceof HTMLElement)) return null;
317
+ const row = active.closest<HTMLElement>("[data-key]");
318
+ if (row && ul.contains(row)) return row.dataset.key ?? null;
319
+ return null;
320
+ }
321
+
322
+ /** What `checked` should be for a node, per the live toggle state. */
323
+ function desiredChecked(key: string): boolean | undefined {
324
+ const meta = config.nodeIndex[key];
325
+ if (meta?.type === "checkbox") return Boolean(checkedState.get(key));
326
+ if (meta?.type === "radio")
327
+ return radioState.get(meta.groupId) === meta.value;
328
+ return undefined;
329
+ }
330
+
331
+ // Each level ListBox's own items (parsed once from its config blob) — the
332
+ // source the pattern-aware boot renders aria-checked/data-checked from.
333
+ const levelItems = new Map<string, ListBoxNodeInput[]>();
334
+
335
+ /**
336
+ * Write the toggle state into a level's items and re-feed its ListBox.
337
+ * Returns whether the repaint actually happened — `false` means the level's
338
+ * ListBox hasn't booted yet (see `setRadioValue`, which retries).
339
+ */
340
+ function syncChecks(lvl: MenuLevelConfig): boolean {
341
+ const ul = document.getElementById(lvl.menuId) as ListBoxRootElement | null;
342
+ const lb = ul?.listbox;
343
+ if (!ul || !lb) return false;
344
+ let items = levelItems.get(lvl.menuId);
345
+ if (!items) {
346
+ try {
347
+ items = (
348
+ JSON.parse(ul.getAttribute("data-listbox-config") || "{}") as {
349
+ items?: ListBoxNodeInput[];
350
+ }
351
+ ).items;
352
+ } catch {
353
+ return false;
354
+ }
355
+ if (!Array.isArray(items)) return false;
356
+ levelItems.set(lvl.menuId, items);
357
+ }
358
+ const update = (nodes: ListBoxNodeInput[]) => {
359
+ for (const node of nodes) {
360
+ if (isSectionInput(node)) {
361
+ update(node.items);
362
+ continue;
363
+ }
364
+ const want = desiredChecked(String(node.id));
365
+ if (want !== undefined) {
366
+ (node as { checked?: boolean }).checked = want;
367
+ }
368
+ }
369
+ };
370
+ update(items);
371
+ lb.setItems(items);
372
+ return true;
373
+ }
374
+
375
+ // --- Activation (from each list's `listbox:action`) -----------------------
376
+ function onAction(lvl: MenuLevelConfig, key: string) {
377
+ const meta = config.nodeIndex[key];
378
+ if (!meta) return;
379
+ switch (meta.type) {
380
+ case "action":
381
+ emit("pindoba:menu:action", { id: key });
382
+ if (meta.closeOnSelect) closeRoot();
383
+ break;
384
+ case "link":
385
+ // Native <a> navigation already fired on the click / Enter.
386
+ closeRoot();
387
+ break;
388
+ case "checkbox": {
389
+ const next = !checkedState.get(key);
390
+ checkedState.set(key, next);
391
+ syncChecks(lvl);
392
+ emit("pindoba:menu:checkedchange", { id: key, checked: next });
393
+ if (meta.closeOnSelect) closeRoot();
394
+ break;
395
+ }
396
+ case "radio":
397
+ radioState.set(meta.groupId, meta.value);
398
+ syncChecks(lvl);
399
+ emit("pindoba:menu:valuechange", {
400
+ groupId: meta.groupId,
401
+ value: meta.value,
402
+ });
403
+ break;
404
+ case "submenu": {
405
+ const desc = lvl.submenus.find((s) => s.id === meta.subId);
406
+ openSubmenu(meta.subId, lvl.id, "keyboard", desc?.qualifiedId);
407
+ focusSubmenuFirstItem(desc?.qualifiedId ?? meta.subId);
408
+ break;
409
+ }
410
+ }
411
+ }
412
+
413
+ // --- Per-level DOM handlers (attached to each dialog) ----------------------
414
+ function openChildOf(lvl: MenuLevelConfig): MenuSubmenuRef | null {
415
+ for (const s of lvl.submenus) if (openPath.includes(s.id)) return s;
416
+ return null;
417
+ }
418
+
419
+ function closeSelf(lvl: MenuLevelConfig) {
420
+ if (lvl.id == null) return;
421
+ closeSubmenu(lvl.id);
422
+ if (lvl.parentTriggerItemId) {
423
+ document.getElementById(lvl.parentTriggerItemId)?.focus();
424
+ }
425
+ }
426
+
427
+ function onKeydown(lvl: MenuLevelConfig, event: KeyboardEvent) {
428
+ switch (event.key) {
429
+ case "ArrowRight": {
430
+ const focusedKey = activeRowKey(lvl.menuId);
431
+ const desc = lvl.submenus.find((s) => s.id === focusedKey);
432
+ if (desc) {
433
+ event.preventDefault();
434
+ event.stopPropagation();
435
+ openSubmenu(desc.id, lvl.id, "keyboard", desc.qualifiedId);
436
+ focusSubmenuFirstItem(desc.qualifiedId);
437
+ }
438
+ break;
439
+ }
440
+ case "ArrowLeft":
441
+ if (lvl.id != null) {
442
+ event.preventDefault();
443
+ event.stopPropagation();
444
+ closeSelf(lvl);
445
+ }
446
+ break;
447
+ case "Escape":
448
+ // Escape closes just the topmost level. stopPropagation prevents the
449
+ // native <dialog> cancel (which would desync state) and an ancestor
450
+ // level from closing on the same keystroke.
451
+ event.preventDefault();
452
+ event.stopPropagation();
453
+ if (lvl.id != null) closeSelf(lvl);
454
+ else closeRoot();
455
+ break;
456
+ }
457
+ }
458
+
459
+ function onPointerMove(lvl: MenuLevelConfig, event: PointerEvent) {
460
+ const openChild = openChildOf(lvl);
461
+ if (openChild) {
462
+ const dialog = document.getElementById(
463
+ `${openChild.qualifiedId}-popover`,
464
+ );
465
+ if (dialog) {
466
+ const r = dialog.getBoundingClientRect();
467
+ trackPointer(
468
+ { x: event.clientX, y: event.clientY },
469
+ { top: r.top, right: r.right, bottom: r.bottom, left: r.left },
470
+ );
471
+ }
472
+ }
473
+ const target = event.target as Element | null;
474
+ const el = target?.closest?.<HTMLElement>("[data-key]");
475
+ const key = el?.dataset.key;
476
+ if (!key) return;
477
+ const desc = lvl.submenus.find((s) => s.id === key);
478
+ if (desc) {
479
+ openSubmenu(desc.id, lvl.id, "pointer", desc.qualifiedId);
480
+ } else if (openChild && !shouldDeferSiblingHover()) {
481
+ closeSubmenu(openChild.id);
482
+ }
483
+ }
484
+
485
+ function onScroll(lvl: MenuLevelConfig) {
486
+ const openChild = openChildOf(lvl);
487
+ if (openChild) closeSubmenu(openChild.id);
488
+ }
489
+
490
+ // Attach handlers to every level's dialog + list.
491
+ for (const lvl of config.levels) {
492
+ const dialogEl = document.getElementById(lvl.popoverId);
493
+ const ul = document.getElementById(lvl.menuId);
494
+ if (dialogEl) {
495
+ dialogEl.addEventListener(
496
+ "keydown",
497
+ (e) => onKeydown(lvl, e as KeyboardEvent),
498
+ { capture: true, signal },
499
+ );
500
+ dialogEl.addEventListener(
501
+ "pointermove",
502
+ (e) => onPointerMove(lvl, e as PointerEvent),
503
+ { signal },
504
+ );
505
+ dialogEl.addEventListener("scroll", () => onScroll(lvl), {
506
+ capture: true,
507
+ signal,
508
+ });
509
+ }
510
+ if (ul) {
511
+ ul.addEventListener(
512
+ "listbox:action",
513
+ (e) => {
514
+ const key = (e as CustomEvent<{ key: string }>).detail?.key;
515
+ if (typeof key === "string") onAction(lvl, key);
516
+ },
517
+ { signal },
518
+ );
519
+ }
520
+ }
521
+
522
+ // --- Root open/close orchestration ----------------------------------------
523
+ const unsubOpen = rootPopover.$open.listen((isOpen) => {
524
+ triggerEl.setAttribute("aria-expanded", isOpen ? "true" : "false");
525
+ triggerEl.setAttribute("data-state", isOpen ? "open" : "closed");
526
+ if (isOpen) {
527
+ focusFirstRow(config.menuId);
528
+ } else {
529
+ if (openPath.length) {
530
+ openPath = [];
531
+ reconcile();
532
+ }
533
+ aimTracker.reset();
534
+ }
535
+ emit("pindoba:menu:openchange", { id: config.rootId, open: isOpen });
536
+ });
537
+
538
+ // Initial open (SSR `open` prop) — after wiring + manager readiness.
539
+ if (config.open) {
540
+ void waitForManager().then(() => rootPopover.$open.set(true));
541
+ }
542
+
543
+ // --- Handle + teardown ----------------------------------------------------
544
+ const destroy = () => {
545
+ ac.abort();
546
+ unsubOpen();
547
+ for (const rt of levels) rt.popover.destroy();
548
+ delete (root as MenuRootElement).menu;
549
+ // Allow a later re-boot (transition:persist islands re-run page-load).
550
+ triggerEl.removeAttribute("data-menu-initialized");
551
+ };
552
+
553
+ const handle: MenuHandle = {
554
+ open: () => rootPopover.$open.set(true),
555
+ close: () => rootPopover.$open.set(false),
556
+ toggle: () => rootPopover.$open.set(!rootPopover.$open.get()),
557
+ isOpen: () => rootPopover.$open.get(),
558
+ // Every level is repainted, not just the one holding the group: a radio
559
+ // group lives at exactly one level, but the caller doesn't know which, and
560
+ // `syncChecks` is a no-op for levels whose items don't carry the ids.
561
+ setRadioValue: (groupId, value) => {
562
+ radioState.set(groupId, value);
563
+ // Retried across frames: an owner restoring persisted state can call this
564
+ // before the level's ListBox has booted, and a dropped repaint would
565
+ // leave the panel showing the server-rendered value forever.
566
+ let tries = 0;
567
+ const paint = () => {
568
+ let allPainted = true;
569
+ for (const lvl of levels) {
570
+ if (!syncChecks(lvl.cfg)) allPainted = false;
571
+ }
572
+ if (!allPainted && tries++ < 15) requestAnimationFrame(paint);
573
+ };
574
+ paint();
575
+ },
576
+ getRadioValue: (groupId) => radioState.get(groupId),
577
+ destroy,
578
+ };
579
+ (root as MenuRootElement).menu = handle;
580
+
581
+ return { destroy };
582
+ }
583
+
584
+ /** Boot every server-rendered menu on the page. Returns teardown handles. */
585
+ export function bootAllMenus(): BootResult[] {
586
+ // Scoped to astro-owned menu roots: other frameworks' triggers carry the
587
+ // same `data-menu-trigger` attribute (shared core bag) and must not be
588
+ // touched by this boot.
589
+ const triggers = document.querySelectorAll<HTMLElement>(
590
+ "[data-pindoba-menu] [data-menu-trigger]",
591
+ );
592
+ const results: BootResult[] = [];
593
+ for (const trigger of triggers) {
594
+ const result = bootMenu(trigger);
595
+ if (result) results.push(result);
596
+ }
597
+ return results;
598
+ }