@meetreeve/ui 0.2.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/dist/index.mjs ADDED
@@ -0,0 +1,910 @@
1
+ "use client";
2
+
3
+ // src/brand-glyph.tsx
4
+ import Image from "next/image";
5
+ import { useState } from "react";
6
+
7
+ // src/lib/cn.ts
8
+ function cn(...inputs) {
9
+ const out = [];
10
+ for (const input of inputs) {
11
+ if (!input) continue;
12
+ if (typeof input === "string" || typeof input === "number") {
13
+ out.push(String(input));
14
+ } else if (Array.isArray(input)) {
15
+ const nested = cn(...input);
16
+ if (nested) out.push(nested);
17
+ } else if (typeof input === "object") {
18
+ for (const [key, value] of Object.entries(input)) {
19
+ if (value) out.push(key);
20
+ }
21
+ }
22
+ }
23
+ return out.join(" ");
24
+ }
25
+
26
+ // src/lib/initials.ts
27
+ function initials(name) {
28
+ if (!name) return "\xB7\xB7";
29
+ const parts = name.trim().split(/\s+/).slice(0, 2);
30
+ return parts.map((p) => p[0]?.toUpperCase() ?? "").join("") || "\xB7\xB7";
31
+ }
32
+
33
+ // src/lib/monogram.ts
34
+ var MONOGRAM_COLORS = [
35
+ "#7c3aed",
36
+ // violet
37
+ "#2563eb",
38
+ // blue
39
+ "#0e7490",
40
+ // cyan
41
+ "#047857",
42
+ // emerald
43
+ "#c2410c",
44
+ // orange
45
+ "#dc2626",
46
+ // red
47
+ "#db2777",
48
+ // pink
49
+ "#4f46e5"
50
+ // indigo
51
+ ];
52
+ function monogramColor(seed) {
53
+ let h = 0;
54
+ for (let i = 0; i < seed.length; i++) h = h * 31 + seed.charCodeAt(i) | 0;
55
+ return MONOGRAM_COLORS[Math.abs(h) % MONOGRAM_COLORS.length];
56
+ }
57
+
58
+ // src/brand-glyph.tsx
59
+ import { jsx } from "react/jsx-runtime";
60
+ function faviconUrl(domain) {
61
+ if (!domain) return null;
62
+ const host = domain.trim().toLowerCase();
63
+ if (!host.includes(".") || /\s/.test(host)) return null;
64
+ return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=128`;
65
+ }
66
+ function BrandGlyph({ brand, size = "sm", subtle = false, className }) {
67
+ const box = size === "lg" ? "h-6 w-6" : "h-5 w-5";
68
+ const img = size === "lg" ? "h-4 w-4" : "h-3.5 w-3.5";
69
+ const dim = size === "lg" ? 16 : 14;
70
+ const candidates = [];
71
+ if (brand.favicon) candidates.push(brand.favicon);
72
+ if (brand.logo) candidates.push(brand.logo);
73
+ const [failed, setFailed] = useState(() => /* @__PURE__ */ new Set());
74
+ const src = candidates.find((u) => !failed.has(u)) ?? null;
75
+ if (src) {
76
+ return /* @__PURE__ */ jsx(
77
+ "span",
78
+ {
79
+ className: cn(
80
+ "flex shrink-0 items-center justify-center overflow-hidden rounded",
81
+ // Tint needs a dark-mode counterpart or it vanishes on dark backgrounds.
82
+ subtle ? "bg-black/5 dark:bg-white/10" : "bg-black/10 dark:bg-white/15",
83
+ box,
84
+ className
85
+ ),
86
+ children: /* @__PURE__ */ jsx(
87
+ Image,
88
+ {
89
+ src,
90
+ alt: "",
91
+ width: dim,
92
+ height: dim,
93
+ "data-testid": "brand-image",
94
+ unoptimized: true,
95
+ onError: () => setFailed((prev) => new Set(prev).add(src)),
96
+ className: cn(img, "rounded-sm object-contain")
97
+ }
98
+ )
99
+ }
100
+ );
101
+ }
102
+ return /* @__PURE__ */ jsx(
103
+ "span",
104
+ {
105
+ "aria-hidden": true,
106
+ "data-testid": "brand-monogram",
107
+ className: cn(
108
+ "flex shrink-0 select-none items-center justify-center rounded font-semibold uppercase text-white",
109
+ size === "lg" ? "text-[11px]" : "text-[9px]",
110
+ box,
111
+ className
112
+ ),
113
+ style: { backgroundColor: brand.color || monogramColor(brand.name || "?") },
114
+ children: initials(brand.name)
115
+ }
116
+ );
117
+ }
118
+
119
+ // src/responsive-header.tsx
120
+ import { useEffect, useId, useRef, useState as useState2 } from "react";
121
+ import { Menu, X } from "lucide-react";
122
+
123
+ // src/lib/use-media-query.ts
124
+ import { useCallback, useSyncExternalStore } from "react";
125
+ function useMediaQuery(query) {
126
+ const subscribe = useCallback(
127
+ (onChange) => {
128
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
129
+ return () => {
130
+ };
131
+ }
132
+ const mql = window.matchMedia(query);
133
+ mql.addEventListener("change", onChange);
134
+ return () => mql.removeEventListener("change", onChange);
135
+ },
136
+ [query]
137
+ );
138
+ const getSnapshot = () => typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia(query).matches : false;
139
+ return useSyncExternalStore(subscribe, getSnapshot, () => false);
140
+ }
141
+
142
+ // src/tokens.ts
143
+ var MIN_SIZES = {
144
+ /** WCAG 2.5.5 minimum interactive target, px. Applies to BOTH width and height. */
145
+ touchTarget: 44,
146
+ /** Floor width for the always-inline search field on mobile, px. */
147
+ searchInput: 200,
148
+ /** Generic minimum width/height for a secondary control (button, select), px. */
149
+ control: 44,
150
+ /** Max width of the brand wordmark before it truncates, px. */
151
+ wordmarkMaxWidth: 180
152
+ };
153
+ var BREAKPOINTS = {
154
+ /** Tailwind `sm`. */
155
+ sm: 640,
156
+ /** Tailwind `md` — the header switches to its full desktop layout at/above this. */
157
+ md: 768
158
+ };
159
+
160
+ // src/responsive-header.tsx
161
+ import { Fragment, jsx as jsx2, jsxs } from "react/jsx-runtime";
162
+ function MenuIcon({ open }) {
163
+ return open ? /* @__PURE__ */ jsx2(X, { className: "h-5 w-5", "aria-hidden": "true" }) : /* @__PURE__ */ jsx2(Menu, { className: "h-5 w-5", "aria-hidden": "true" });
164
+ }
165
+ function SecondaryControls({
166
+ nav,
167
+ auth,
168
+ themeToggle
169
+ }) {
170
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
171
+ nav?.map(
172
+ (item) => (
173
+ // A link navigates; an action-only item (no href) is a button — never an
174
+ // <a href="#"> that scroll-jumps and pushes a history entry.
175
+ item.href ? /* @__PURE__ */ jsx2(
176
+ "a",
177
+ {
178
+ href: item.href,
179
+ onClick: item.onSelect,
180
+ className: "text-sm font-medium text-foreground/80 hover:text-foreground",
181
+ children: item.label
182
+ },
183
+ item.label
184
+ ) : /* @__PURE__ */ jsx2(
185
+ "button",
186
+ {
187
+ type: "button",
188
+ onClick: item.onSelect,
189
+ className: "text-left text-sm font-medium text-foreground/80 hover:text-foreground",
190
+ children: item.label
191
+ },
192
+ item.label
193
+ )
194
+ )
195
+ ),
196
+ themeToggle,
197
+ auth
198
+ ] });
199
+ }
200
+ function ResponsiveHeader({
201
+ brand,
202
+ search,
203
+ nav,
204
+ auth,
205
+ themeToggle,
206
+ menuLabel = "Menu",
207
+ className
208
+ }) {
209
+ const isDesktop = useMediaQuery(`(min-width: ${BREAKPOINTS.md}px)`);
210
+ const [open, setOpen] = useState2(false);
211
+ const panelId = useId();
212
+ const buttonRef = useRef(null);
213
+ const panelRef = useRef(null);
214
+ useEffect(() => {
215
+ if (isDesktop) setOpen(false);
216
+ }, [isDesktop]);
217
+ useEffect(() => {
218
+ if (!open) return;
219
+ const onKey = (e) => {
220
+ if (e.key === "Escape") {
221
+ setOpen(false);
222
+ buttonRef.current?.focus();
223
+ }
224
+ };
225
+ const onPointerDown = (e) => {
226
+ const target = e.target;
227
+ if (!panelRef.current?.contains(target) && !buttonRef.current?.contains(target)) {
228
+ setOpen(false);
229
+ }
230
+ };
231
+ document.addEventListener("keydown", onKey);
232
+ document.addEventListener("pointerdown", onPointerDown);
233
+ return () => {
234
+ document.removeEventListener("keydown", onKey);
235
+ document.removeEventListener("pointerdown", onPointerDown);
236
+ };
237
+ }, [open]);
238
+ const hasSecondary = Boolean(nav && nav.length > 0 || auth || themeToggle);
239
+ return /* @__PURE__ */ jsxs(
240
+ "header",
241
+ {
242
+ "data-testid": "responsive-header",
243
+ className: cn(
244
+ "flex w-full items-center gap-3 border-b border-border bg-background px-4 py-2",
245
+ className
246
+ ),
247
+ children: [
248
+ /* @__PURE__ */ jsxs("div", { "data-testid": "header-brand", className: "flex shrink-0 items-center gap-2", children: [
249
+ /* @__PURE__ */ jsx2(BrandGlyph, { brand, size: "lg" }),
250
+ isDesktop && /* @__PURE__ */ jsx2(
251
+ "span",
252
+ {
253
+ "data-testid": "header-wordmark",
254
+ className: "truncate text-sm font-semibold text-foreground",
255
+ style: { maxWidth: MIN_SIZES.wordmarkMaxWidth },
256
+ children: brand.name
257
+ }
258
+ )
259
+ ] }),
260
+ /* @__PURE__ */ jsx2(
261
+ "div",
262
+ {
263
+ "data-testid": "header-search",
264
+ className: "flex flex-1 items-center",
265
+ style: { minWidth: MIN_SIZES.searchInput },
266
+ children: search
267
+ }
268
+ ),
269
+ hasSecondary && (isDesktop ? /* @__PURE__ */ jsx2("nav", { "data-testid": "header-actions", className: "flex shrink-0 items-center gap-3", children: /* @__PURE__ */ jsx2(SecondaryControls, { nav, auth, themeToggle }) }) : /* @__PURE__ */ jsxs("div", { className: "relative shrink-0", children: [
270
+ /* @__PURE__ */ jsx2(
271
+ "button",
272
+ {
273
+ ref: buttonRef,
274
+ type: "button",
275
+ "data-testid": "header-menu-button",
276
+ "aria-label": menuLabel,
277
+ "aria-expanded": open,
278
+ "aria-controls": panelId,
279
+ onClick: () => setOpen((v) => !v),
280
+ className: "flex items-center justify-center rounded-md border border-border text-foreground",
281
+ style: { minWidth: MIN_SIZES.touchTarget, minHeight: MIN_SIZES.touchTarget },
282
+ children: /* @__PURE__ */ jsx2(MenuIcon, { open })
283
+ }
284
+ ),
285
+ open && /* @__PURE__ */ jsx2(
286
+ "div",
287
+ {
288
+ ref: panelRef,
289
+ id: panelId,
290
+ "data-testid": "header-menu-panel",
291
+ className: "absolute right-0 top-full z-50 mt-2 flex min-w-[12rem] flex-col gap-3 rounded-md border border-border bg-background p-3 shadow-lg",
292
+ children: /* @__PURE__ */ jsx2(SecondaryControls, { nav, auth, themeToggle })
293
+ }
294
+ )
295
+ ] }))
296
+ ]
297
+ }
298
+ );
299
+ }
300
+
301
+ // src/app-sidebar.tsx
302
+ import { useEffect as useEffect4, useId as useId3, useMemo, useRef as useRef4, useState as useState5 } from "react";
303
+ import { Menu as Menu2, X as X2 } from "lucide-react";
304
+
305
+ // src/lib/resolve-icon.ts
306
+ import * as LucideIcons from "lucide-react";
307
+ var FALLBACK_ICON_NAME = "Layers";
308
+ var REACT_FORWARD_REF = /* @__PURE__ */ Symbol.for("react.forward_ref");
309
+ var NON_ICON_EXPORTS = /* @__PURE__ */ new Set(["Icon"]);
310
+ function isIconComponent(icons, name) {
311
+ if (NON_ICON_EXPORTS.has(name)) return false;
312
+ if (!Object.prototype.hasOwnProperty.call(icons, name)) return false;
313
+ const value = icons[name];
314
+ return typeof value === "object" && value !== null && value.$$typeof === REACT_FORWARD_REF;
315
+ }
316
+ function resolveIcon(name) {
317
+ const icons = LucideIcons;
318
+ const candidate = isIconComponent(icons, name) ? icons[name] : void 0;
319
+ return candidate ?? icons[FALLBACK_ICON_NAME];
320
+ }
321
+
322
+ // src/lib/use-dismissable-disclosure.ts
323
+ import { useEffect as useEffect2 } from "react";
324
+ function useDismissableDisclosure({
325
+ open,
326
+ onClose,
327
+ panelRef,
328
+ triggerRef,
329
+ initialFocusRef
330
+ }) {
331
+ useEffect2(() => {
332
+ if (open) initialFocusRef?.current?.focus();
333
+ }, [open]);
334
+ useEffect2(() => {
335
+ if (!open) return;
336
+ const onKey = (e) => {
337
+ if (e.key === "Escape") {
338
+ onClose();
339
+ triggerRef.current?.focus();
340
+ }
341
+ };
342
+ const onPointerDown = (e) => {
343
+ const target = e.target;
344
+ if (!panelRef.current?.contains(target) && !triggerRef.current?.contains(target)) {
345
+ onClose();
346
+ }
347
+ };
348
+ document.addEventListener("keydown", onKey);
349
+ document.addEventListener("pointerdown", onPointerDown);
350
+ return () => {
351
+ document.removeEventListener("keydown", onKey);
352
+ document.removeEventListener("pointerdown", onPointerDown);
353
+ };
354
+ }, [open, onClose, panelRef, triggerRef]);
355
+ }
356
+
357
+ // src/ladder/types.ts
358
+ import { z } from "zod";
359
+ var zAppRungItem = z.object({
360
+ type: z.literal("app"),
361
+ moduleId: z.string().min(1)
362
+ });
363
+ var zGroupRungItem = z.object({
364
+ type: z.literal("group"),
365
+ id: z.string().min(1),
366
+ label: z.string().min(1),
367
+ icon: z.string().min(1),
368
+ children: z.array(z.string().min(1)).min(1)
369
+ });
370
+ var zRungItem = z.discriminatedUnion("type", [zAppRungItem, zGroupRungItem]);
371
+ var zSidebarRungShape = z.object({
372
+ rows: z.number().int().positive(),
373
+ items: z.array(zRungItem).min(1)
374
+ });
375
+ function moduleIdsOf(rung) {
376
+ const ids = [];
377
+ for (const item of rung.items) {
378
+ if (item.type === "app") {
379
+ ids.push(item.moduleId);
380
+ } else {
381
+ ids.push(...item.children);
382
+ }
383
+ }
384
+ return ids;
385
+ }
386
+ var zSidebarLadder = z.object({
387
+ version: z.number().int().nonnegative(),
388
+ rungs: z.array(zSidebarRungShape).min(1)
389
+ }).superRefine((ladder, ctx) => {
390
+ const { rungs } = ladder;
391
+ if (rungs.length === 0) return;
392
+ const rung0HasGroup = rungs[0].items.some((item) => item.type === "group");
393
+ if (rung0HasGroup) {
394
+ ctx.addIssue({
395
+ code: z.ZodIssueCode.custom,
396
+ message: "rung 0 must be fully expanded \u2014 no folded groups allowed",
397
+ path: ["rungs", 0, "items"]
398
+ });
399
+ }
400
+ let baselineModuleSet = null;
401
+ rungs.forEach((rung, i) => {
402
+ if (rung.rows !== rung.items.length) {
403
+ ctx.addIssue({
404
+ code: z.ZodIssueCode.custom,
405
+ message: `rung ${i}: rows (${rung.rows}) must equal its item count (${rung.items.length})`,
406
+ path: ["rungs", i, "rows"]
407
+ });
408
+ }
409
+ if (i > 0 && rung.rows >= rungs[i - 1].rows) {
410
+ ctx.addIssue({
411
+ code: z.ZodIssueCode.custom,
412
+ message: `rung ${i}: rows (${rung.rows}) must be strictly less than rung ${i - 1}'s (${rungs[i - 1].rows})`,
413
+ path: ["rungs", i, "rows"]
414
+ });
415
+ }
416
+ const ids = moduleIdsOf(rung);
417
+ const seen = /* @__PURE__ */ new Set();
418
+ for (const id of ids) {
419
+ if (seen.has(id)) {
420
+ ctx.addIssue({
421
+ code: z.ZodIssueCode.custom,
422
+ message: `rung ${i}: module "${id}" appears more than once (as two rows, or as a row AND inside a group)`,
423
+ path: ["rungs", i]
424
+ });
425
+ }
426
+ seen.add(id);
427
+ }
428
+ const groupIds = /* @__PURE__ */ new Set();
429
+ for (const item of rung.items) {
430
+ if (item.type !== "group") continue;
431
+ if (groupIds.has(item.id)) {
432
+ ctx.addIssue({
433
+ code: z.ZodIssueCode.custom,
434
+ message: `rung ${i}: group id "${item.id}" appears more than once`,
435
+ path: ["rungs", i]
436
+ });
437
+ }
438
+ groupIds.add(item.id);
439
+ }
440
+ if (baselineModuleSet === null) {
441
+ baselineModuleSet = seen;
442
+ } else {
443
+ const missing = [...baselineModuleSet].filter((id) => !seen.has(id));
444
+ const extra = [...seen].filter((id) => !baselineModuleSet.has(id));
445
+ if (missing.length > 0 || extra.length > 0) {
446
+ ctx.addIssue({
447
+ code: z.ZodIssueCode.custom,
448
+ message: `rung ${i}: module set differs from rung 0 (missing: ${missing.join(", ") || "none"}; extra: ${extra.join(", ") || "none"})`,
449
+ path: ["rungs", i]
450
+ });
451
+ }
452
+ }
453
+ });
454
+ });
455
+
456
+ // src/ladder/fallback.ts
457
+ var FALLBACK_GROUP_ICON = "Layers";
458
+ function deriveFallbackLadder(registry, groupOrder) {
459
+ const rung0Items = registry.map((m) => ({
460
+ type: "app",
461
+ moduleId: m.moduleId
462
+ }));
463
+ const rungs = [{ rows: rung0Items.length, items: rung0Items }];
464
+ const effectiveGroupOrder = [.../* @__PURE__ */ new Set([...groupOrder, ...registry.map((m) => m.group)])];
465
+ const foldOrder = [...effectiveGroupOrder].reverse();
466
+ const folded = /* @__PURE__ */ new Set();
467
+ let previousRows = rungs[0].rows;
468
+ for (const group of foldOrder) {
469
+ const membersOfGroup = registry.filter((m) => m.group === group).map((m) => m.moduleId);
470
+ if (membersOfGroup.length === 0) continue;
471
+ folded.add(group);
472
+ const items = [];
473
+ for (const g of effectiveGroupOrder) {
474
+ const membersOfG = registry.filter((m) => m.group === g).map((m) => m.moduleId);
475
+ if (membersOfG.length === 0) continue;
476
+ if (folded.has(g)) {
477
+ items.push({ type: "group", id: g, label: g, icon: FALLBACK_GROUP_ICON, children: membersOfG });
478
+ } else {
479
+ for (const moduleId of membersOfG) {
480
+ items.push({ type: "app", moduleId });
481
+ }
482
+ }
483
+ }
484
+ if (items.length < previousRows) {
485
+ rungs.push({ rows: items.length, items });
486
+ previousRows = items.length;
487
+ }
488
+ }
489
+ return { version: 1, rungs };
490
+ }
491
+ function reconcileLadder(ladder, registry) {
492
+ const validIds = new Set(registry.map((m) => m.moduleId));
493
+ function dropUnknown(items) {
494
+ const out = [];
495
+ for (const item of items) {
496
+ if (item.type === "app") {
497
+ if (validIds.has(item.moduleId)) out.push(item);
498
+ } else {
499
+ const children = item.children.filter((id) => validIds.has(id));
500
+ if (children.length > 0) out.push({ ...item, children });
501
+ }
502
+ }
503
+ return out;
504
+ }
505
+ const cleanedRungs = ladder.rungs.map((rung) => {
506
+ const items = dropUnknown(rung.items);
507
+ return { rows: items.length, items };
508
+ });
509
+ const rung0Ids = new Set(
510
+ (cleanedRungs[0]?.items ?? []).flatMap((i) => i.type === "app" ? [i.moduleId] : i.children)
511
+ );
512
+ const missing = registry.filter((m) => !rung0Ids.has(m.moduleId));
513
+ if (missing.length === 0) {
514
+ return { ...ladder, rungs: cleanedRungs };
515
+ }
516
+ const appended = missing.map((m) => ({ type: "app", moduleId: m.moduleId }));
517
+ const withAppended = cleanedRungs.map((rung) => ({
518
+ rows: rung.items.length + appended.length,
519
+ items: [...rung.items, ...appended]
520
+ }));
521
+ return { ...ladder, rungs: withAppended };
522
+ }
523
+ function resolveLadder(raw, registry, groupOrder) {
524
+ if (raw != null) {
525
+ const parsed = zSidebarLadder.safeParse(raw);
526
+ if (parsed.success) {
527
+ const reconciled = reconcileLadder(parsed.data, registry);
528
+ if (zSidebarLadder.safeParse(reconciled).success) {
529
+ return reconciled;
530
+ }
531
+ }
532
+ }
533
+ return deriveFallbackLadder(registry, groupOrder);
534
+ }
535
+
536
+ // src/ladder/use-ladder-rung.ts
537
+ import { useLayoutEffect, useRef as useRef2, useState as useState3 } from "react";
538
+ var DEFAULT_ROW_HEIGHT = 36;
539
+ var DEFAULT_HYSTERESIS_ROWS = 1;
540
+ function useLadderRung(ladder, containerRef, options = {}) {
541
+ const rowHeight = options.rowHeight ?? DEFAULT_ROW_HEIGHT;
542
+ const hysteresisRows = options.hysteresisRows ?? DEFAULT_HYSTERESIS_ROWS;
543
+ const [rungIndex, setRungIndex] = useState3(0);
544
+ const rungIndexRef = useRef2(0);
545
+ const ladderSignature = `${ladder.version}:${ladder.rungs.map((r) => r.rows).join(",")}`;
546
+ useLayoutEffect(() => {
547
+ rungIndexRef.current = 0;
548
+ setRungIndex(0);
549
+ const node = containerRef.current;
550
+ if (!node || typeof ResizeObserver === "undefined") return;
551
+ const select = (height) => {
552
+ const rowsAvailable = Math.floor(height / rowHeight);
553
+ const currentRows = ladder.rungs[rungIndexRef.current].rows;
554
+ let bestIndex = ladder.rungs.length - 1;
555
+ for (let i = 0; i < ladder.rungs.length; i++) {
556
+ if (ladder.rungs[i].rows <= rowsAvailable) {
557
+ bestIndex = i;
558
+ break;
559
+ }
560
+ }
561
+ const bestRows = ladder.rungs[bestIndex].rows;
562
+ const isSteppingUp = bestRows > currentRows;
563
+ if (isSteppingUp && rowsAvailable < bestRows + hysteresisRows) {
564
+ return;
565
+ }
566
+ if (bestIndex !== rungIndexRef.current) {
567
+ rungIndexRef.current = bestIndex;
568
+ setRungIndex(bestIndex);
569
+ }
570
+ };
571
+ const ro = new ResizeObserver((entries) => {
572
+ const entry = entries[0];
573
+ if (entry) select(entry.contentRect.height);
574
+ });
575
+ ro.observe(node);
576
+ return () => ro.disconnect();
577
+ }, [ladderSignature, containerRef, rowHeight, hysteresisRows]);
578
+ return ladder.rungs[Math.min(rungIndex, ladder.rungs.length - 1)];
579
+ }
580
+
581
+ // src/ladder/flyout.tsx
582
+ import { useCallback as useCallback2, useEffect as useEffect3, useId as useId2, useLayoutEffect as useLayoutEffect2, useRef as useRef3, useState as useState4 } from "react";
583
+ import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
584
+ var PANEL_GAP = 4;
585
+ function Flyout({ label, icon, active, children, className }) {
586
+ const [hoverOpen, setHoverOpen] = useState4(false);
587
+ const [clickOpen, setClickOpen] = useState4(false);
588
+ const [panelPosition, setPanelPosition] = useState4(null);
589
+ const open = hoverOpen || clickOpen;
590
+ const panelId = useId2();
591
+ const triggerRef = useRef3(null);
592
+ const panelRef = useRef3(null);
593
+ const firstItemRef = useRef3(null);
594
+ const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
595
+ const Icon = resolveIcon(icon);
596
+ const reposition = useCallback2(() => {
597
+ const rect = triggerRef.current?.getBoundingClientRect();
598
+ if (rect) setPanelPosition({ top: rect.top, left: rect.right + PANEL_GAP });
599
+ }, []);
600
+ useLayoutEffect2(() => {
601
+ if (open) reposition();
602
+ }, [open, reposition]);
603
+ useEffect3(() => {
604
+ if (!open) return;
605
+ window.addEventListener("resize", reposition);
606
+ window.addEventListener("scroll", reposition, true);
607
+ return () => {
608
+ window.removeEventListener("resize", reposition);
609
+ window.removeEventListener("scroll", reposition, true);
610
+ };
611
+ }, [open, reposition]);
612
+ useDismissableDisclosure({
613
+ open,
614
+ onClose: () => {
615
+ setHoverOpen(false);
616
+ setClickOpen(false);
617
+ },
618
+ panelRef,
619
+ triggerRef
620
+ });
621
+ useEffect3(() => {
622
+ if (clickOpen && panelPosition) firstItemRef.current?.focus();
623
+ }, [clickOpen, panelPosition]);
624
+ return /* @__PURE__ */ jsxs2(
625
+ "div",
626
+ {
627
+ "data-testid": "ladder-flyout",
628
+ className: cn("relative", className),
629
+ onMouseEnter: () => setHoverOpen(true),
630
+ onMouseLeave: () => setHoverOpen(false),
631
+ children: [
632
+ /* @__PURE__ */ jsxs2(
633
+ "button",
634
+ {
635
+ ref: triggerRef,
636
+ type: "button",
637
+ "data-testid": "ladder-flyout-trigger",
638
+ "aria-expanded": open,
639
+ "aria-controls": panelId,
640
+ onClick: () => setClickOpen((v) => !v),
641
+ className: cn(
642
+ "group flex w-full items-center gap-2.5 rounded-md px-3 py-1.5 text-sm transition-colors",
643
+ active ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-foreground/5 hover:text-foreground"
644
+ ),
645
+ children: [
646
+ /* @__PURE__ */ jsx3(
647
+ Icon,
648
+ {
649
+ className: cn("size-4 shrink-0", active ? "text-primary" : "text-muted-foreground"),
650
+ strokeWidth: 1.5,
651
+ "aria-hidden": "true"
652
+ }
653
+ ),
654
+ /* @__PURE__ */ jsx3("span", { className: "truncate", children: label })
655
+ ]
656
+ }
657
+ ),
658
+ open && panelPosition && /* @__PURE__ */ jsx3(
659
+ "div",
660
+ {
661
+ ref: panelRef,
662
+ id: panelId,
663
+ "data-testid": "ladder-flyout-panel",
664
+ style: { position: "fixed", top: panelPosition.top, left: panelPosition.left },
665
+ className: cn(
666
+ "z-50 flex min-w-[12rem] flex-col gap-0.5 rounded-md border border-border bg-background p-2 shadow-lg",
667
+ !prefersReducedMotion && "transition-all duration-150 ease-out"
668
+ ),
669
+ children: children.map((child, i) => /* @__PURE__ */ jsx3(
670
+ "a",
671
+ {
672
+ ref: i === 0 ? firstItemRef : void 0,
673
+ href: child.href,
674
+ "aria-current": child.active ? "page" : void 0,
675
+ className: cn(
676
+ "truncate rounded-md px-3 py-1.5 text-sm transition-colors",
677
+ child.active ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-foreground/5 hover:text-foreground"
678
+ ),
679
+ children: child.label
680
+ },
681
+ child.moduleId
682
+ ))
683
+ }
684
+ )
685
+ ]
686
+ }
687
+ );
688
+ }
689
+
690
+ // src/app-sidebar.tsx
691
+ import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
692
+ function isHrefActive(activePath, href) {
693
+ return activePath === href || activePath.startsWith(`${href}/`);
694
+ }
695
+ function NavLink({
696
+ href,
697
+ active,
698
+ icon,
699
+ label,
700
+ testId
701
+ }) {
702
+ const Icon = resolveIcon(icon);
703
+ return /* @__PURE__ */ jsxs3(
704
+ "a",
705
+ {
706
+ href,
707
+ "data-testid": testId,
708
+ "aria-current": active ? "page" : void 0,
709
+ className: cn(
710
+ "group flex items-center gap-2.5 rounded-md px-3 py-1.5 text-sm transition-colors",
711
+ active ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-foreground/5 hover:text-foreground"
712
+ ),
713
+ children: [
714
+ /* @__PURE__ */ jsx4(
715
+ Icon,
716
+ {
717
+ className: cn("size-4 shrink-0", active ? "text-primary" : "text-muted-foreground"),
718
+ strokeWidth: 1.5,
719
+ "aria-hidden": "true"
720
+ }
721
+ ),
722
+ /* @__PURE__ */ jsx4("span", { className: "truncate", children: label })
723
+ ]
724
+ }
725
+ );
726
+ }
727
+ function AppSidebar({
728
+ ladder,
729
+ modules,
730
+ groupOrder,
731
+ activePath,
732
+ brand,
733
+ topItem,
734
+ menuLabel = "Menu",
735
+ ladderOptions,
736
+ className
737
+ }) {
738
+ const isDesktop = useMediaQuery(`(min-width: ${BREAKPOINTS.md}px)`);
739
+ const [mobileOpen, setMobileOpen] = useState5(false);
740
+ const containerRef = useRef4(null);
741
+ const drawerId = useId3();
742
+ const mobileTriggerRef = useRef4(null);
743
+ const mobileDrawerRef = useRef4(null);
744
+ const mobileCloseRef = useRef4(null);
745
+ const moduleMap = useMemo(() => new Map(modules.map((m) => [m.moduleId, m])), [modules]);
746
+ const registryForLadder = useMemo(
747
+ () => modules.map((m) => ({ moduleId: m.moduleId, group: m.group })),
748
+ [modules]
749
+ );
750
+ const resolvedLadder = useMemo(
751
+ () => resolveLadder(ladder, registryForLadder, groupOrder),
752
+ [ladder, registryForLadder, groupOrder]
753
+ );
754
+ const rung = useLadderRung(resolvedLadder, containerRef, ladderOptions);
755
+ const rung0 = resolvedLadder.rungs[0];
756
+ useEffect4(() => {
757
+ if (isDesktop) setMobileOpen(false);
758
+ }, [isDesktop]);
759
+ useDismissableDisclosure({
760
+ open: mobileOpen,
761
+ onClose: () => setMobileOpen(false),
762
+ panelRef: mobileDrawerRef,
763
+ triggerRef: mobileTriggerRef,
764
+ initialFocusRef: mobileCloseRef
765
+ });
766
+ function renderAppItem(item, testIdPrefix) {
767
+ const def = moduleMap.get(item.moduleId);
768
+ if (!def) return null;
769
+ return /* @__PURE__ */ jsx4(
770
+ NavLink,
771
+ {
772
+ href: def.href,
773
+ active: isHrefActive(activePath, def.href),
774
+ icon: def.icon,
775
+ label: def.label,
776
+ testId: `${testIdPrefix}-${item.moduleId}`
777
+ },
778
+ item.moduleId
779
+ );
780
+ }
781
+ function renderGroupItem(item) {
782
+ const children = item.children.map((moduleId) => {
783
+ const def = moduleMap.get(moduleId);
784
+ const href = def?.href ?? "#";
785
+ return {
786
+ moduleId,
787
+ label: def?.label ?? moduleId,
788
+ href,
789
+ active: isHrefActive(activePath, href)
790
+ };
791
+ });
792
+ const active = children.some((c) => c.active);
793
+ return /* @__PURE__ */ jsx4(Flyout, { label: item.label, icon: item.icon, active, children }, item.id);
794
+ }
795
+ const brandBlock = brand && /* @__PURE__ */ jsxs3(
796
+ "a",
797
+ {
798
+ href: brand.href,
799
+ "data-testid": "app-sidebar-brand",
800
+ className: "flex items-center gap-2.5 border-b border-border px-5 py-4",
801
+ children: [
802
+ /* @__PURE__ */ jsx4("span", { className: "grid size-7 place-items-center rounded-md bg-primary text-primary-foreground", children: /* @__PURE__ */ jsx4("span", { className: "font-mono text-sm font-bold", children: (brand.label[0] ?? "r").toUpperCase() }) }),
803
+ /* @__PURE__ */ jsx4("span", { className: "truncate text-sm font-semibold tracking-tight", children: brand.label })
804
+ ]
805
+ }
806
+ );
807
+ function topItemBlock(testId) {
808
+ if (!topItem) return null;
809
+ return /* @__PURE__ */ jsx4("div", { className: "px-3 pt-4", children: /* @__PURE__ */ jsx4(
810
+ NavLink,
811
+ {
812
+ href: topItem.href,
813
+ active: isHrefActive(activePath, topItem.href),
814
+ icon: topItem.icon,
815
+ label: topItem.label,
816
+ testId
817
+ }
818
+ ) });
819
+ }
820
+ return /* @__PURE__ */ jsxs3(Fragment2, { children: [
821
+ !isDesktop && /* @__PURE__ */ jsx4(
822
+ "button",
823
+ {
824
+ ref: mobileTriggerRef,
825
+ type: "button",
826
+ "data-testid": "app-sidebar-mobile-trigger",
827
+ "aria-label": menuLabel,
828
+ "aria-expanded": mobileOpen,
829
+ "aria-controls": drawerId,
830
+ onClick: () => setMobileOpen((v) => !v),
831
+ className: "flex items-center justify-center rounded-md border border-border p-2 text-foreground",
832
+ children: mobileOpen ? /* @__PURE__ */ jsx4(X2, { className: "h-5 w-5", "aria-hidden": "true" }) : /* @__PURE__ */ jsx4(Menu2, { className: "h-5 w-5", "aria-hidden": "true" })
833
+ }
834
+ ),
835
+ !isDesktop && mobileOpen && /* @__PURE__ */ jsxs3("div", { className: "fixed inset-0 z-50", children: [
836
+ /* @__PURE__ */ jsx4(
837
+ "div",
838
+ {
839
+ "data-testid": "app-sidebar-mobile-scrim",
840
+ className: "absolute inset-0 bg-black/50",
841
+ "aria-hidden": "true",
842
+ onClick: () => setMobileOpen(false)
843
+ }
844
+ ),
845
+ /* @__PURE__ */ jsxs3(
846
+ "div",
847
+ {
848
+ ref: mobileDrawerRef,
849
+ id: drawerId,
850
+ role: "dialog",
851
+ "aria-modal": "true",
852
+ "aria-label": "Navigation",
853
+ "data-testid": "app-sidebar-mobile-drawer",
854
+ className: "absolute inset-y-0 left-0 flex w-72 flex-col overflow-y-auto border-r border-border bg-sidebar",
855
+ children: [
856
+ /* @__PURE__ */ jsx4("div", { className: "sticky top-0 z-10 flex justify-end bg-sidebar p-2", children: /* @__PURE__ */ jsx4(
857
+ "button",
858
+ {
859
+ ref: mobileCloseRef,
860
+ type: "button",
861
+ "data-testid": "app-sidebar-mobile-close",
862
+ "aria-label": `Close ${menuLabel.toLowerCase()}`,
863
+ onClick: () => setMobileOpen(false),
864
+ className: "flex items-center justify-center rounded-md p-2 text-foreground hover:bg-foreground/5",
865
+ children: /* @__PURE__ */ jsx4(X2, { className: "h-5 w-5", "aria-hidden": "true" })
866
+ }
867
+ ) }),
868
+ brandBlock,
869
+ topItemBlock("app-sidebar-mobile-top-item"),
870
+ /* @__PURE__ */ jsx4("nav", { className: "flex-1 px-3 py-4", children: /* @__PURE__ */ jsx4("div", { className: "flex flex-col gap-0.5", children: rung0.items.map(
871
+ (item) => item.type === "app" ? renderAppItem(item, "app-sidebar-mobile-navlink") : null
872
+ ) }) })
873
+ ]
874
+ }
875
+ )
876
+ ] }),
877
+ /* @__PURE__ */ jsxs3(
878
+ "aside",
879
+ {
880
+ "data-testid": "app-sidebar-desktop",
881
+ className: cn("hidden w-64 shrink-0 flex-col border-r border-border bg-sidebar md:flex", className),
882
+ children: [
883
+ brandBlock,
884
+ topItemBlock("app-sidebar-top-item"),
885
+ /* @__PURE__ */ jsx4(
886
+ "nav",
887
+ {
888
+ ref: containerRef,
889
+ "data-testid": "app-sidebar-nav",
890
+ className: "flex-1 overflow-y-auto px-3 py-4",
891
+ children: /* @__PURE__ */ jsx4("div", { className: "flex flex-col gap-0.5", children: rung.items.map((item) => item.type === "app" ? renderAppItem(item, "app-sidebar-navlink") : renderGroupItem(item)) })
892
+ }
893
+ )
894
+ ]
895
+ }
896
+ )
897
+ ] });
898
+ }
899
+ export {
900
+ AppSidebar,
901
+ BrandGlyph,
902
+ Flyout,
903
+ ResponsiveHeader,
904
+ deriveFallbackLadder,
905
+ faviconUrl,
906
+ reconcileLadder,
907
+ resolveLadder,
908
+ useLadderRung,
909
+ zSidebarLadder
910
+ };