@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.
package/src/menu.astro ADDED
@@ -0,0 +1,396 @@
1
+ ---
2
+ import {
3
+ connectMenu,
4
+ connectMenuSub,
5
+ optionElementId,
6
+ type MenuBaseProps,
7
+ type MenuNode,
8
+ type MenuSubNode,
9
+ type MenuSubDescriptor,
10
+ type MenuListboxBag,
11
+ } from "@pindoba/core-menu";
12
+ import Popover from "@pindoba/astro-popover";
13
+ import DialogScript from "@pindoba/astro-dialog/script";
14
+ import ListBox from "@pindoba/astro-listbox";
15
+ import Button from "@pindoba/astro-button";
16
+ import { generateShortUUID } from "@pindoba/utils";
17
+ import type {
18
+ MenuConfig,
19
+ MenuLevelConfig,
20
+ MenuNodeActivation,
21
+ } from "./runtime";
22
+
23
+ export interface Props extends MenuBaseProps {
24
+ /** The menu content (data-driven authoring mode). Required in Astro. */
25
+ items: MenuNode[];
26
+ /** Stable id root (auto-generated when omitted); also the root element id. */
27
+ id?: string;
28
+ /** Initial open state (opened client-side after boot). @default false */
29
+ open?: boolean;
30
+ /** Emphasis of the default trigger `<Button>`. @default "secondary" */
31
+ triggerEmphasis?: "primary" | "secondary" | "ghost";
32
+ /** Extra class on the root wrapper. */
33
+ class?: string;
34
+ }
35
+
36
+ const {
37
+ items,
38
+ id: consumerId,
39
+ open = false,
40
+ size = "md",
41
+ placement = "bottom-start",
42
+ listboxRadius,
43
+ listboxShadow,
44
+ listboxBorder,
45
+ listboxBackground,
46
+ "aria-label": ariaLabel,
47
+ triggerEmphasis = "secondary",
48
+ passThrough,
49
+ class: className,
50
+ } = Astro.props;
51
+
52
+ // Stable id root. Deterministic ids below (`${rootId}-menu`, `${subId}-popover`,
53
+ // …) mirror the connect / lower conventions so the boot can find every level.
54
+ const rootId = consumerId ?? `menu-${generateShortUUID()}`;
55
+
56
+ // --- Serialize the tree (function-free) for the boot controller --------------
57
+ // Submenu triggers can live at top level OR inside a group (MenuGroupChild).
58
+ function levelSubmenuNodes(nodes: MenuNode[]): MenuSubNode[] {
59
+ const out: MenuSubNode[] = [];
60
+ for (const n of nodes) {
61
+ if (n.type === "submenu") out.push(n);
62
+ else if (n.type === "group") {
63
+ for (const c of n.items) if (c.type === "submenu") out.push(c);
64
+ }
65
+ }
66
+ return out;
67
+ }
68
+
69
+ const levels: MenuLevelConfig[] = [];
70
+ const nodeIndex: Record<string, MenuNodeActivation> = {};
71
+ const radioInitial: Record<string, string> = {};
72
+
73
+ function indexNodes(nodes: MenuNode[]): void {
74
+ for (const n of nodes) {
75
+ switch (n.type) {
76
+ case "action":
77
+ nodeIndex[n.id] = {
78
+ type: "action",
79
+ closeOnSelect: n.closeOnSelect !== false,
80
+ };
81
+ break;
82
+ case "link":
83
+ nodeIndex[n.id] = { type: "link" };
84
+ break;
85
+ case "checkbox":
86
+ nodeIndex[n.id] = {
87
+ type: "checkbox",
88
+ checked: Boolean(n.checked),
89
+ closeOnSelect: n.closeOnSelect === true,
90
+ };
91
+ break;
92
+ case "submenu":
93
+ nodeIndex[n.id] = { type: "submenu", subId: n.id };
94
+ indexNodes(n.items);
95
+ break;
96
+ case "group":
97
+ indexNodes(n.items);
98
+ break;
99
+ case "radiogroup":
100
+ if (n.value !== undefined) radioInitial[n.id] = n.value;
101
+ for (const r of n.items) {
102
+ nodeIndex[r.id] = { type: "radio", groupId: n.id, value: r.value };
103
+ }
104
+ break;
105
+ case "separator":
106
+ break;
107
+ }
108
+ }
109
+ }
110
+
111
+ function buildLevel(
112
+ nodes: MenuNode[],
113
+ id: string | null,
114
+ menuId: string,
115
+ popoverId: string,
116
+ depth: number,
117
+ parentTriggerItemId: string | null,
118
+ ): void {
119
+ const subs = levelSubmenuNodes(nodes);
120
+ // Namespaced by the owning level's (already instance-unique) listbox id, so
121
+ // several menus on one page never collide — mirrors `lower.ts`'s
122
+ // `qualifiedId: ${ctx.listboxId}-sub-${node.id}` (this level's `menuId` is
123
+ // exactly that `ctx.listboxId`).
124
+ const qualifiedIds = new Map(
125
+ subs.map((s) => [s.id, `${menuId}-sub-${s.id}`]),
126
+ );
127
+ levels.push({
128
+ id,
129
+ popoverId,
130
+ menuId,
131
+ depth,
132
+ parentTriggerItemId,
133
+ submenus: subs.map((s) => {
134
+ const qualifiedId = qualifiedIds.get(s.id)!;
135
+ return {
136
+ id: s.id,
137
+ qualifiedId,
138
+ triggerItemId: optionElementId(menuId, s.id),
139
+ popoverId: `${qualifiedId}-popover`,
140
+ menuId: `${qualifiedId}-menu`,
141
+ };
142
+ }),
143
+ });
144
+ for (const s of subs) {
145
+ const qualifiedId = qualifiedIds.get(s.id)!;
146
+ buildLevel(
147
+ s.items,
148
+ s.id,
149
+ `${qualifiedId}-menu`,
150
+ `${qualifiedId}-popover`,
151
+ depth + 1,
152
+ optionElementId(menuId, s.id),
153
+ );
154
+ }
155
+ }
156
+
157
+ indexNodes(items);
158
+ buildLevel(items, null, `${rootId}-menu`, `${rootId}-popover`, 0, null);
159
+
160
+ // --- Forward per-item rich content (Stamp/Badge) into each level's ListBox ---
161
+ // Astro items are JSON-serialized for hydration, so components can't ride in
162
+ // `items` — instead Menu adopts the ListBox's own convention (per-item named
163
+ // slots keyed `<id>:leading` / `<id>:trailing`) and forwards its resolved
164
+ // slot HTML down to each level's ListBox via `optionSlotsHtml`. Node ids must
165
+ // be unique across the WHOLE tree (they already are — they key `nodeIndex`
166
+ // and the boot's `openPath`), so a flat `<id>:region` slot name never
167
+ // collides across levels.
168
+ //
169
+ // Only ids that are rendered as an option ROW at a given level get resolved
170
+ // there — a submenu's own row lives at its parent's level, its nested
171
+ // `items` render at the next level down.
172
+ function levelItemIds(nodes: MenuNode[]): string[] {
173
+ const ids: string[] = [];
174
+ for (const n of nodes) {
175
+ switch (n.type) {
176
+ case "action":
177
+ case "link":
178
+ case "checkbox":
179
+ case "submenu":
180
+ ids.push(n.id);
181
+ break;
182
+ case "radiogroup":
183
+ for (const r of n.items) ids.push(r.id);
184
+ break;
185
+ case "group":
186
+ for (const c of n.items) ids.push(c.id);
187
+ break;
188
+ case "separator":
189
+ break;
190
+ }
191
+ }
192
+ return ids;
193
+ }
194
+
195
+ async function resolveOptionSlotsHtml(
196
+ ids: string[],
197
+ ): Promise<Record<string, string>> {
198
+ const out: Record<string, string> = {};
199
+ for (const id of ids) {
200
+ for (const region of ["leading", "trailing"] as const) {
201
+ const name = `${id}:${region}`;
202
+ if (!Astro.slots.has(name)) continue;
203
+ const html = await Astro.slots.render(name);
204
+ if (html && html.trim() !== "") out[name] = html;
205
+ }
206
+ }
207
+ return out;
208
+ }
209
+
210
+ // Non-interactive `type:"custom"` blocks (content keyed by the node id) and
211
+ // rich `group` headers (keyed `<id>:title`) ride the same named-slot →
212
+ // forwarded-HTML path as leading/trailing, but into the ListBox's
213
+ // `customSlotsHtml` instead. Custom blocks live at the top level; groups can
214
+ // nest, so this runs per level like the option resolver.
215
+ function levelCustomSlotNames(nodes: MenuNode[]): string[] {
216
+ const names: string[] = [];
217
+ for (const n of nodes) {
218
+ if (n.type === "custom") names.push(n.id);
219
+ else if (n.type === "group") names.push(`${n.id}:title`);
220
+ }
221
+ return names;
222
+ }
223
+
224
+ async function resolveCustomSlotsHtml(
225
+ names: string[],
226
+ ): Promise<Record<string, string>> {
227
+ const out: Record<string, string> = {};
228
+ for (const name of names) {
229
+ if (!Astro.slots.has(name)) continue;
230
+ const html = await Astro.slots.render(name);
231
+ if (html && html.trim() !== "") out[name] = html;
232
+ }
233
+ return out;
234
+ }
235
+
236
+ const rootOptionSlotsHtml = await resolveOptionSlotsHtml(levelItemIds(items));
237
+ const rootCustomSlotsHtml = await resolveCustomSlotsHtml(
238
+ levelCustomSlotNames(items),
239
+ );
240
+
241
+ const config: MenuConfig & { open: boolean } = {
242
+ rootId,
243
+ triggerId: `${rootId}-trigger`,
244
+ popoverId: `${rootId}-popover`,
245
+ menuId: `${rootId}-menu`,
246
+ side: "right",
247
+ placement,
248
+ levels,
249
+ nodeIndex,
250
+ radioInitial,
251
+ open,
252
+ };
253
+
254
+ // --- Lower each level via the pure connect functions -------------------------
255
+ const rootApi = connectMenu({
256
+ items,
257
+ id: rootId,
258
+ size,
259
+ placement,
260
+ listboxRadius,
261
+ listboxShadow,
262
+ listboxBorder,
263
+ listboxBackground,
264
+ "aria-label": ariaLabel ?? null,
265
+ passThrough,
266
+ openSubmenuIds: [],
267
+ triggerAttrs: {
268
+ // The whole hydration payload rides on the trigger (mirrors select's
269
+ // `data-select-config`); the emphasis is baked into the bag so the template
270
+ // spreads a single connect bag onto <Button> (Connect Contract v1.1).
271
+ "data-menu-config": JSON.stringify(config),
272
+ emphasis: triggerEmphasis,
273
+ },
274
+ });
275
+
276
+ interface SubLevelRender {
277
+ popoverProps: Record<string, unknown>;
278
+ listboxProps: MenuListboxBag;
279
+ }
280
+ const subLevels: SubLevelRender[] = [];
281
+
282
+ async function collectSub(
283
+ descriptors: MenuSubDescriptor[],
284
+ depth: number,
285
+ ): Promise<void> {
286
+ for (const desc of descriptors) {
287
+ const subApi = connectMenuSub({
288
+ items: desc.items,
289
+ // Instance-unique (multiple menus can share node ids on one page).
290
+ id: desc.qualifiedId,
291
+ triggerItemId: desc.triggerItemId,
292
+ depth,
293
+ size,
294
+ // The level surface travels with `size`, so a submenu paints exactly
295
+ // like the root that spawned it.
296
+ listboxRadius,
297
+ listboxShadow,
298
+ listboxBorder,
299
+ listboxBackground,
300
+ openSubmenuIds: [],
301
+ passThrough,
302
+ });
303
+ const optionSlotsHtml = await resolveOptionSlotsHtml(
304
+ levelItemIds(desc.items),
305
+ );
306
+ const customSlotsHtml = await resolveCustomSlotsHtml(
307
+ levelCustomSlotNames(desc.items),
308
+ );
309
+ subLevels.push({
310
+ popoverProps: subApi.popoverProps,
311
+ listboxProps: {
312
+ ...subApi.listboxProps,
313
+ optionSlotsHtml,
314
+ customSlotsHtml,
315
+ },
316
+ });
317
+ await collectSub(subApi.submenus, depth + 1);
318
+ }
319
+ }
320
+ await collectSub(rootApi.submenus, 1);
321
+
322
+ // The Astro <Popover> is an SSR Dialog wrapper: it only consumes markup props.
323
+ // Interactive positioning (placement / offset / z-index / anchor / trigger
324
+ // strategy) is applied at runtime in boot-menu via createPopover — exactly the
325
+ // split the astro-popover component documents. So pick only the SSR keys here.
326
+ function toAstroPopover(pp: Record<string, unknown>) {
327
+ return {
328
+ id: pp.id as string,
329
+ isModal: false as const,
330
+ fitContentWidth: true as const,
331
+ // The core bag already carries the menu panel's chrome (`transparent` /
332
+ // `none`) — the ListBox draws its own border. Without these, the Astro
333
+ // <Popover> falls back to the Dialog's default panel chrome, painting a
334
+ // second border around the ListBox's own.
335
+ background: pp.background as string | undefined,
336
+ border: pp.border as string | undefined,
337
+ passThrough: pp.passThrough as Record<string, unknown> | undefined,
338
+ };
339
+ }
340
+
341
+ const rootPopover = toAstroPopover(rootApi.popoverProps);
342
+ const rootListboxProps: MenuListboxBag = {
343
+ ...rootApi.listboxProps,
344
+ optionSlotsHtml: rootOptionSlotsHtml,
345
+ customSlotsHtml: rootCustomSlotsHtml,
346
+ };
347
+ ---
348
+
349
+ <div
350
+ data-pindoba-menu
351
+ id={consumerId}
352
+ class={className}
353
+ data-connect-allow="menu root: boot scope + custom-event dispatch target (no core bag owns the wrapper)"
354
+ >
355
+ <Button {...rootApi.triggerProps}>
356
+ {/* Trigger button label / content. @group Slots */}
357
+ <slot />
358
+ </Button>
359
+
360
+ {/* Root level: the menu-pattern ListBox inside its Popover shell. */}
361
+ <Popover {...rootPopover}>
362
+ <ListBox {...rootListboxProps} />
363
+ </Popover>
364
+
365
+ {
366
+ /* Every submenu level, always in the DOM (open-driven). Each dialog portals
367
+ to <body> and is positioned/opened only by the boot controller. Rendered
368
+ flat — the boot re-parents + anchors them per open. */
369
+ }
370
+ {
371
+ subLevels.map((lvl) => (
372
+ <Popover {...toAstroPopover(lvl.popoverProps)}>
373
+ <ListBox {...lvl.listboxProps} />
374
+ </Popover>
375
+ ))
376
+ }
377
+
378
+ {/* Bootstraps the global dialog manager the popovers rely on. */}
379
+ <DialogScript />
380
+ </div>
381
+
382
+ <script>
383
+ import { bootAllMenus } from "./boot-menu";
384
+
385
+ function boot() {
386
+ const results = bootAllMenus();
387
+ const teardown = () => {
388
+ for (const r of results) r.destroy();
389
+ };
390
+ window.addEventListener("pagehide", teardown, { once: true });
391
+ document.addEventListener("astro:before-swap", teardown, { once: true });
392
+ }
393
+
394
+ boot();
395
+ document.addEventListener("astro:page-load", boot);
396
+ </script>
package/src/runtime.ts ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Shared runtime types for the Astro Menu: the hydration payload serialized by
3
+ * `menu.astro` and parsed by `boot-menu.ts`, plus the imperative handle stashed
4
+ * on the root element (`getMenu(el)`).
5
+ *
6
+ * The Menu is a data-driven, boot-script-controlled component: Astro has no
7
+ * client reactivity framework, so `menu.astro` SSR-renders the trigger + every
8
+ * level's Popover/ListBox pair, serializes a function-free description of the
9
+ * MenuNode tree here, and `boot-menu.ts` rebuilds the interaction model in
10
+ * vanilla DOM — mirroring `menu.svelte` / `menu-level.svelte`.
11
+ */
12
+
13
+ /** How a single activatable node behaves when its ListBox row fires. */
14
+ export type MenuNodeActivation =
15
+ | { type: "action"; closeOnSelect: boolean }
16
+ | { type: "link" }
17
+ | { type: "checkbox"; checked: boolean; closeOnSelect: boolean }
18
+ | { type: "radio"; groupId: string; value: string }
19
+ | { type: "submenu"; subId: string };
20
+
21
+ /** A submenu reachable from a given level (its trigger row + child dialog). */
22
+ export interface MenuSubmenuRef {
23
+ /** The submenu node id. */
24
+ id: string;
25
+ /** Instance-unique id (chains with the owning level's listbox id) — used for
26
+ * DOM lookups (`${qualifiedId}-popover`) so several menus on one page never
27
+ * collide. Node-id state (`id` above) stays as the open-path key. */
28
+ qualifiedId: string;
29
+ /** The rendered element id of the trigger row (a `[data-key]` option). */
30
+ triggerItemId: string;
31
+ /** The submenu dialog's id. */
32
+ popoverId: string;
33
+ /** The submenu ListBox `<ul>` id. */
34
+ menuId: string;
35
+ }
36
+
37
+ /** One menu level (the root or a submenu): its dialog + list + child submenus. */
38
+ export interface MenuLevelConfig {
39
+ /** `null` for the root level; the submenu node id for a submenu level. */
40
+ id: string | null;
41
+ /** This level's dialog id. */
42
+ popoverId: string;
43
+ /** This level's ListBox `<ul>` id. */
44
+ menuId: string;
45
+ /** Nesting depth (0 = root). */
46
+ depth: number;
47
+ /** The parent trigger row element id (submenu levels only; focus restore). */
48
+ parentTriggerItemId: string | null;
49
+ /** Submenus opened from this level. */
50
+ submenus: MenuSubmenuRef[];
51
+ }
52
+
53
+ /** The full hydration payload carried on the trigger's `data-menu-config`. */
54
+ export interface MenuConfig {
55
+ rootId: string;
56
+ triggerId: string;
57
+ /** The root dialog id. */
58
+ popoverId: string;
59
+ /** The root ListBox `<ul>` id. */
60
+ menuId: string;
61
+ /** Which physical side submenus open on (always `"right"` today). */
62
+ side: "right" | "left";
63
+ /** Preferred placement of the root panel relative to the trigger. */
64
+ placement: string;
65
+ /** Every level, root first. */
66
+ levels: MenuLevelConfig[];
67
+ /** Activation metadata keyed by node id (across all levels). */
68
+ nodeIndex: Record<string, MenuNodeActivation>;
69
+ /** Initially-selected value per radio group id (server-rendered state). */
70
+ radioInitial: Record<string, string>;
71
+ }
72
+
73
+ /** Imperative controls exposed on the Menu root via {@link getMenu}. */
74
+ export interface MenuHandle {
75
+ /** Open the menu. */
76
+ open: () => void;
77
+ /** Close the menu (and every submenu). */
78
+ close: () => void;
79
+ /** Toggle the menu open/closed. */
80
+ toggle: () => void;
81
+ /** Whether the root menu is currently open. */
82
+ isOpen: () => boolean;
83
+ /**
84
+ * Select a value in one of the menu's radio groups from outside the menu —
85
+ * the counterpart of the `pindoba:menu:valuechange` event. Repaints the
86
+ * checked row without dispatching (so an owner restoring persisted state
87
+ * doesn't echo its own event back).
88
+ */
89
+ setRadioValue: (groupId: string, value: string) => void;
90
+ /** The currently selected value of a radio group, if any. */
91
+ getRadioValue: (groupId: string) => string | undefined;
92
+ /** Tear down all listeners/popovers. */
93
+ destroy: () => void;
94
+ }
95
+
96
+ /** The Menu root element with its handle stashed after boot. */
97
+ export type MenuRootElement = HTMLElement & { menu?: MenuHandle };
98
+
99
+ /** Retrieve the imperative handle for a booted Menu root element. */
100
+ export function getMenu(el: HTMLElement | null | undefined): MenuHandle | null {
101
+ return (el as MenuRootElement | null | undefined)?.menu ?? null;
102
+ }