@broberg/app-shell 0.1.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/preact.js ADDED
@@ -0,0 +1,665 @@
1
+ import { useState, useEffect, useRef, useMemo } from 'preact/hooks';
2
+ import { createBellShell } from '@broberg/notifications/shell';
3
+ import { getPreference, getPalette, getSurfaces, getBackdrop, onThemeChange, onPaletteChange, onSurfacesChange, onBackdropChange, setPreference, setPalette, PALETTES, PALETTE_LABELS, setSurfaces, setBackdrop } from '@broberg/theme';
4
+ import { jsxs, Fragment, jsx } from 'preact/jsx-runtime';
5
+
6
+ // src/preact.tsx
7
+
8
+ // src/index.ts
9
+ function isActivePath(href, currentPath) {
10
+ const path = (currentPath.split(/[?#]/)[0] || "/").replace(/\/+$/, "") || "/";
11
+ const target = (href.split(/[?#]/)[0] || "/").replace(/\/+$/, "") || "/";
12
+ if (target === "/") return path === "/";
13
+ return path === target || path.startsWith(target + "/");
14
+ }
15
+ function badgeLabel(n) {
16
+ if (!n || n <= 0) return null;
17
+ return n > 99 ? "99+" : String(n);
18
+ }
19
+ function readFlag(key, fallback) {
20
+ try {
21
+ const v = typeof localStorage === "undefined" ? null : localStorage.getItem(key);
22
+ return v === null ? fallback : v === "1";
23
+ } catch {
24
+ return fallback;
25
+ }
26
+ }
27
+ function writeFlag(key, value) {
28
+ try {
29
+ if (typeof localStorage !== "undefined") localStorage.setItem(key, value ? "1" : "0");
30
+ } catch {
31
+ }
32
+ }
33
+ function readSet(key) {
34
+ try {
35
+ const raw = typeof localStorage === "undefined" ? null : localStorage.getItem(key);
36
+ const v = raw ? JSON.parse(raw) : [];
37
+ return new Set(Array.isArray(v) ? v.filter((x) => typeof x === "string") : []);
38
+ } catch {
39
+ return /* @__PURE__ */ new Set();
40
+ }
41
+ }
42
+ function writeSet(key, value) {
43
+ try {
44
+ if (typeof localStorage !== "undefined") localStorage.setItem(key, JSON.stringify([...value]));
45
+ } catch {
46
+ }
47
+ }
48
+ function initials(name, email) {
49
+ const source = (name ?? "").trim() || (email ?? "").split("@")[0] || "";
50
+ const words = source.split(/[\s._-]+/).filter(Boolean);
51
+ if (words.length === 0) return "?";
52
+ const first = words[0][0] ?? "";
53
+ const last = words.length > 1 ? words[words.length - 1][0] ?? "" : "";
54
+ return (first + last).toUpperCase() || "?";
55
+ }
56
+ function createFetchNotificationSource(opts) {
57
+ const base = opts.url.replace(/\/+$/, "");
58
+ const doFetch = (input, init) => (opts.fetch ?? fetch)(input, { credentials: opts.credentials ?? "include", ...init });
59
+ const get = async () => {
60
+ const res = await doFetch(base, { headers: { accept: "application/json" } });
61
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
62
+ const body = await res.json().catch(() => null);
63
+ if (!body || !Array.isArray(body.items)) throw new Error("response has no items array");
64
+ const items = body.items;
65
+ const unseen = typeof body.unseen === "number" ? body.unseen : items.filter((n) => n.seenAt == null).length;
66
+ return { items, unseen };
67
+ };
68
+ const post = async (path, body) => {
69
+ const res = await doFetch(`${base}${path}`, {
70
+ method: "POST",
71
+ headers: body === void 0 ? void 0 : { "content-type": "application/json" },
72
+ body: body === void 0 ? void 0 : JSON.stringify(body)
73
+ });
74
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
75
+ const r = await res.json().catch(() => ({}));
76
+ return Array.isArray(r.clearedIds) ? r.clearedIds : [];
77
+ };
78
+ return {
79
+ loadRows: async () => (await get()).items,
80
+ countUnseen: async () => (await get()).unseen,
81
+ markSeen: (ids) => post("/seen", { ids }),
82
+ markAllSeen: () => post("/seen-all"),
83
+ subscribe(onChange) {
84
+ if (typeof window === "undefined") return () => {
85
+ };
86
+ const onVisible = () => {
87
+ if (document.visibilityState === "visible") onChange();
88
+ };
89
+ window.addEventListener("focus", onChange);
90
+ document.addEventListener("visibilitychange", onVisible);
91
+ const ms = opts.pollMs ?? 6e4;
92
+ const timer = ms > 0 ? setInterval(onChange, ms) : null;
93
+ return () => {
94
+ window.removeEventListener("focus", onChange);
95
+ document.removeEventListener("visibilitychange", onVisible);
96
+ if (timer) clearInterval(timer);
97
+ };
98
+ }
99
+ };
100
+ }
101
+ function createMemoryNotificationSource(initial = []) {
102
+ let rows = initial.map((n) => ({ ...n }));
103
+ const listeners = /* @__PURE__ */ new Set();
104
+ const emit = () => listeners.forEach((l) => l());
105
+ const mark = (pred) => {
106
+ const now = Date.now();
107
+ const cleared = [];
108
+ rows = rows.map((n) => {
109
+ if (n.seenAt == null && pred(n)) {
110
+ cleared.push(n.id);
111
+ return { ...n, seenAt: now };
112
+ }
113
+ return n;
114
+ });
115
+ if (cleared.length) emit();
116
+ return cleared;
117
+ };
118
+ return {
119
+ loadRows: async () => rows.map((n) => ({ ...n })),
120
+ countUnseen: async () => rows.filter((n) => n.seenAt == null).length,
121
+ markSeen: async (ids) => mark((n) => ids.includes(n.id)),
122
+ markAllSeen: async () => mark(() => true),
123
+ subscribe(onChange) {
124
+ listeners.add(onChange);
125
+ return () => listeners.delete(onChange);
126
+ },
127
+ push(n) {
128
+ rows = [n, ...rows];
129
+ emit();
130
+ },
131
+ rows: () => rows.map((n) => ({ ...n }))
132
+ };
133
+ }
134
+ function relativeTime(ts, lang, now = Date.now()) {
135
+ const ms = typeof ts === "number" ? ts : Date.parse(ts);
136
+ if (!Number.isFinite(ms)) return "";
137
+ const m = Math.floor((now - ms) / 6e4);
138
+ if (m < 1) return lang === "da" ? "nu" : "now";
139
+ if (m < 60) return `${m} min`;
140
+ const h = Math.floor(m / 60);
141
+ if (h < 24) return lang === "da" ? `${h} t` : `${h} h`;
142
+ const d = Math.floor(h / 24);
143
+ if (d === 1) return lang === "da" ? "i g\xE5r" : "yesterday";
144
+ return `${d} d`;
145
+ }
146
+ var TEXT = {
147
+ da: {
148
+ openMenu: "\xC5bn menu",
149
+ collapse: "Skjul sidemenu",
150
+ expand: "Vis sidemenu",
151
+ notifications: "Notifikationer",
152
+ markAllRead: "Mark\xE9r alle l\xE6st",
153
+ noNotifications: "Ingen notifikationer endnu.",
154
+ couldNotLoad: "Kunne ikke hente notifikationer.",
155
+ seeAll: "Vis alle",
156
+ close: "Luk",
157
+ account: "Konto",
158
+ signOut: "Log ud",
159
+ theme: "Tema",
160
+ light: "Lys",
161
+ dark: "M\xF8rk",
162
+ system: "System",
163
+ palette: "Palet",
164
+ surfaces: "Flader",
165
+ flat: "Flade",
166
+ layered: "Lag",
167
+ backdrop: "Baggrund",
168
+ neurons: "Neuroner",
169
+ plain: "Ren"
170
+ },
171
+ en: {
172
+ openMenu: "Open menu",
173
+ collapse: "Hide sidebar",
174
+ expand: "Show sidebar",
175
+ notifications: "Notifications",
176
+ markAllRead: "Mark all read",
177
+ noNotifications: "No notifications yet.",
178
+ couldNotLoad: "Could not load notifications.",
179
+ seeAll: "See all",
180
+ close: "Close",
181
+ account: "Account",
182
+ signOut: "Sign out",
183
+ theme: "Theme",
184
+ light: "Light",
185
+ dark: "Dark",
186
+ system: "System",
187
+ palette: "Palette",
188
+ surfaces: "Surfaces",
189
+ flat: "Flat",
190
+ layered: "Layered",
191
+ backdrop: "Backdrop",
192
+ neurons: "Neurons",
193
+ plain: "Plain"
194
+ }
195
+ };
196
+ var MOBILE_QUERY = "(max-width: 767px)";
197
+ function useIsMobile() {
198
+ const get = () => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia(MOBILE_QUERY).matches;
199
+ const [mobile, setMobile] = useState(get);
200
+ useEffect(() => {
201
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
202
+ const mq = window.matchMedia(MOBILE_QUERY);
203
+ const on = () => setMobile(mq.matches);
204
+ mq.addEventListener?.("change", on);
205
+ return () => mq.removeEventListener?.("change", on);
206
+ }, []);
207
+ return mobile;
208
+ }
209
+ function NavLink(props) {
210
+ const { href, onNavigate, onAfter, children, ...rest } = props;
211
+ return /* @__PURE__ */ jsx(
212
+ "a",
213
+ {
214
+ ...rest,
215
+ href,
216
+ onClick: (e) => {
217
+ if (onNavigate && !e.metaKey && !e.ctrlKey && !e.shiftKey && e.button === 0) {
218
+ e.preventDefault();
219
+ onNavigate(href);
220
+ }
221
+ onAfter?.();
222
+ },
223
+ children
224
+ }
225
+ );
226
+ }
227
+ var svg = (d, size = 18) => /* @__PURE__ */ jsx("svg", { viewBox: "0 0 24 24", width: size, height: size, fill: "none", stroke: "currentColor", "stroke-width": "1.9", "stroke-linecap": "round", "stroke-linejoin": "round", "aria-hidden": "true", children: d });
228
+ var IconMenu = () => svg(/* @__PURE__ */ jsx("path", { d: "M4 6h16M4 12h16M4 18h16" }), 20);
229
+ var IconChevron = ({ open }) => /* @__PURE__ */ jsx("span", { class: "bas-chev" + (open ? " is-open" : ""), children: svg(/* @__PURE__ */ jsx("path", { d: "m9 18 6-6-6-6" }), 12) });
230
+ var IconCollapse = () => svg(/* @__PURE__ */ jsx("path", { d: "m15 18-6-6 6-6" }), 15);
231
+ var IconExpand = () => svg(/* @__PURE__ */ jsx("path", { d: "m9 18 6-6-6-6" }), 15);
232
+ var IconBell = () => svg(/* @__PURE__ */ jsxs(Fragment, { children: [
233
+ /* @__PURE__ */ jsx("path", { d: "M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" }),
234
+ /* @__PURE__ */ jsx("path", { d: "M10.3 21a1.94 1.94 0 0 0 3.4 0" })
235
+ ] }), 19);
236
+ var IconLogOut = () => svg(/* @__PURE__ */ jsxs(Fragment, { children: [
237
+ /* @__PURE__ */ jsx("path", { d: "M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" }),
238
+ /* @__PURE__ */ jsx("path", { d: "m16 17 5-5-5-5" }),
239
+ /* @__PURE__ */ jsx("path", { d: "M21 12H9" })
240
+ ] }), 14);
241
+ var IconUser = () => svg(/* @__PURE__ */ jsxs(Fragment, { children: [
242
+ /* @__PURE__ */ jsx("circle", { cx: "12", cy: "8", r: "4" }),
243
+ /* @__PURE__ */ jsx("path", { d: "M4 21a8 8 0 0 1 16 0" })
244
+ ] }), 14);
245
+ function Sidebar(p) {
246
+ const t = TEXT[p.lang];
247
+ const key = p.groupsKey ?? "broberg-app-shell.groups";
248
+ const [folded, setFolded] = useState(() => readSet(key));
249
+ const toggleGroup = (label) => {
250
+ const next = new Set(folded);
251
+ next.has(label) ? next.delete(label) : next.add(label);
252
+ setFolded(next);
253
+ writeSet(key, next);
254
+ };
255
+ const mode = p.mobile ?? "drawer";
256
+ const close = () => p.onClose?.();
257
+ const item = (it) => {
258
+ const active = isActivePath(it.href, p.currentPath);
259
+ const badge = badgeLabel(it.badge);
260
+ return /* @__PURE__ */ jsxs(
261
+ NavLink,
262
+ {
263
+ href: it.href,
264
+ onNavigate: p.onNavigate,
265
+ onAfter: close,
266
+ class: "bas-item" + (active ? " is-active" : ""),
267
+ "data-testid": `sidebar-item-${it.id}`,
268
+ "aria-current": active ? "page" : void 0,
269
+ title: it.label,
270
+ children: [
271
+ active ? /* @__PURE__ */ jsx("span", { class: "bas-item__bar" }) : null,
272
+ it.icon ? /* @__PURE__ */ jsx("span", { class: "bas-item__icon", children: it.icon }) : null,
273
+ /* @__PURE__ */ jsx("span", { class: "bas-item__label", children: it.label }),
274
+ badge ? /* @__PURE__ */ jsx("span", { class: "bas-item__count", "data-testid": `sidebar-badge-${it.id}`, children: badge }) : null
275
+ ]
276
+ },
277
+ it.id
278
+ );
279
+ };
280
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
281
+ mode === "drawer" ? /* @__PURE__ */ jsx("div", { class: "bas-backdrop" + (p.mobileOpen ? " is-open" : ""), "data-testid": "sidebar-backdrop", onClick: close }) : null,
282
+ /* @__PURE__ */ jsxs(
283
+ "aside",
284
+ {
285
+ class: `bas-sidebar is-mobile-${mode}` + (p.mobileOpen ? " is-open" : "") + (p.collapsed ? " is-collapsed" : ""),
286
+ "data-testid": "sidebar-root",
287
+ "data-mobile": mode,
288
+ "aria-label": "Navigation",
289
+ children: [
290
+ /* @__PURE__ */ jsxs("div", { class: "bas-sidebar__head", children: [
291
+ p.header ? /* @__PURE__ */ jsx("div", { class: "bas-sidebar__brand", children: p.header }) : /* @__PURE__ */ jsx("span", {}),
292
+ p.onToggleCollapse ? /* @__PURE__ */ jsx("button", { type: "button", class: "bas-iconbtn bas-collapse", "data-testid": "sidebar-collapse", "aria-label": t.collapse, title: t.collapse, onClick: p.onToggleCollapse, children: /* @__PURE__ */ jsx(IconCollapse, {}) }) : null
293
+ ] }),
294
+ /* @__PURE__ */ jsx("nav", { class: "bas-sidebar__nav", children: p.groups.map((g) => {
295
+ const open = !folded.has(g.label);
296
+ const slug = g.label.toLowerCase().replace(/[^a-z0-9]+/g, "-");
297
+ return /* @__PURE__ */ jsxs("div", { class: "bas-group", children: [
298
+ /* @__PURE__ */ jsxs("button", { type: "button", class: "bas-group__label", "data-testid": `sidebar-group-${slug}`, "aria-expanded": open, onClick: () => toggleGroup(g.label), children: [
299
+ /* @__PURE__ */ jsx(IconChevron, { open }),
300
+ /* @__PURE__ */ jsx("span", { children: g.label })
301
+ ] }),
302
+ open ? g.items.map(item) : null
303
+ ] }, g.label);
304
+ }) }),
305
+ p.footer?.length ? /* @__PURE__ */ jsx("div", { class: "bas-sidebar__foot", children: p.footer.map(item) }) : null
306
+ ]
307
+ }
308
+ )
309
+ ] });
310
+ }
311
+ function NotificationBell({ source, onNavigate, lang }) {
312
+ const t = TEXT[lang];
313
+ const mobile = useIsMobile();
314
+ const [error, setError] = useState(null);
315
+ const lastCount = useRef(0);
316
+ const shell = useMemo(
317
+ () => createBellShell({
318
+ labels: { bell: t.notifications, panel: t.notifications, markAll: t.markAllRead, seeAll: t.seeAll, empty: t.noNotifications, close: t.close },
319
+ countUnseen: async () => {
320
+ if (!source) return 0;
321
+ try {
322
+ lastCount.current = await source.countUnseen();
323
+ setError(null);
324
+ } catch (e) {
325
+ setError(e instanceof Error ? e.message : String(e));
326
+ }
327
+ return lastCount.current;
328
+ },
329
+ loadRows: async () => {
330
+ if (!source) return [];
331
+ try {
332
+ return await source.loadRows();
333
+ } catch (e) {
334
+ setError(e instanceof Error ? e.message : String(e));
335
+ return [];
336
+ }
337
+ },
338
+ markAllSeen: async () => source ? source.markAllSeen() : []
339
+ }),
340
+ [source, lang]
341
+ );
342
+ const [state, setState] = useState(shell.getState());
343
+ useEffect(() => shell.subscribe(setState), [shell]);
344
+ useEffect(() => {
345
+ setState(shell.getState());
346
+ void shell.refresh();
347
+ return source?.subscribe?.(() => {
348
+ if (shell.getState().open) void shell.open(shell.getState().anchor);
349
+ else void shell.refresh();
350
+ });
351
+ }, [shell, source]);
352
+ useEffect(() => {
353
+ if (!state.open) return;
354
+ const onKey = (e) => {
355
+ if (e.key === "Escape" && shell.escape() === "close") e.preventDefault();
356
+ };
357
+ document.addEventListener("keydown", onKey);
358
+ return () => document.removeEventListener("keydown", onKey);
359
+ }, [state.open, shell]);
360
+ const label = badgeLabel(state.count);
361
+ const onRow = async (n) => {
362
+ shell.close();
363
+ if (n.seenAt == null && source) {
364
+ await source.markSeen([n.id]);
365
+ await shell.refresh();
366
+ }
367
+ if (n.navigate) {
368
+ if (onNavigate) onNavigate(n.navigate);
369
+ else if (typeof window !== "undefined") window.location.assign(n.navigate);
370
+ }
371
+ };
372
+ const seen = (n) => n.seenAt != null || state.clearedIds.includes(n.id);
373
+ return /* @__PURE__ */ jsxs("div", { class: "bas-bell", children: [
374
+ /* @__PURE__ */ jsxs(
375
+ "button",
376
+ {
377
+ type: "button",
378
+ class: "bas-iconbtn",
379
+ "data-testid": "topbar-notifications",
380
+ "aria-label": t.notifications,
381
+ "aria-expanded": state.open,
382
+ onClick: (e) => {
383
+ const r = e.currentTarget.getBoundingClientRect();
384
+ void shell.toggle({ top: r.top, right: r.right, bottom: r.bottom, left: r.left, width: r.width, height: r.height });
385
+ },
386
+ children: [
387
+ /* @__PURE__ */ jsx(IconBell, {}),
388
+ label ? /* @__PURE__ */ jsx("span", { class: "bas-bell__count", "data-testid": "topbar-notifications-count", children: label }) : null
389
+ ]
390
+ }
391
+ ),
392
+ state.open ? /* @__PURE__ */ jsxs(Fragment, { children: [
393
+ /* @__PURE__ */ jsx("div", { class: "bas-overlay" + (mobile ? " is-dim" : ""), "data-testid": "notifications-backdrop", onClick: () => shell.close() }),
394
+ /* @__PURE__ */ jsxs(
395
+ "div",
396
+ {
397
+ class: "bas-panel " + (mobile ? "bas-panel--sheet" : "bas-panel--drop"),
398
+ role: "dialog",
399
+ "aria-label": shell.labels.panel,
400
+ "data-testid": mobile ? "notifications-drawer" : "notifications-dropdown",
401
+ children: [
402
+ /* @__PURE__ */ jsxs("div", { class: "bas-panel__head", children: [
403
+ /* @__PURE__ */ jsx("h3", { children: t.notifications }),
404
+ /* @__PURE__ */ jsx("button", { type: "button", class: "bas-link", "data-testid": "notifications-mark-all", onClick: () => void shell.markAllSeen(), disabled: !state.count, children: shell.labels.markAll })
405
+ ] }),
406
+ /* @__PURE__ */ jsx("div", { class: "bas-panel__list", "data-testid": "notifications-list", children: error && state.rows.length === 0 ? /* @__PURE__ */ jsx("p", { class: "bas-panel__note is-error", "data-testid": "notifications-error", children: t.couldNotLoad }) : state.rows.length === 0 ? /* @__PURE__ */ jsx("p", { class: "bas-panel__note", "data-testid": "notifications-empty", children: shell.labels.empty }) : state.rows.map((n) => /* @__PURE__ */ jsxs(
407
+ "button",
408
+ {
409
+ type: "button",
410
+ class: "bas-note" + (seen(n) ? " is-read" : ""),
411
+ "data-testid": "notification-row",
412
+ "data-id": n.id,
413
+ onClick: () => void onRow(n),
414
+ children: [
415
+ /* @__PURE__ */ jsxs("span", { class: "bas-note__text", children: [
416
+ /* @__PURE__ */ jsx("span", { class: "bas-note__title", children: n.title }),
417
+ n.body ? /* @__PURE__ */ jsx("span", { class: "bas-note__body", children: n.body }) : null,
418
+ /* @__PURE__ */ jsx("span", { class: "bas-note__time", children: relativeTime(n.createdAt, lang) })
419
+ ] }),
420
+ !seen(n) ? /* @__PURE__ */ jsx("span", { class: "bas-note__dot" }) : null
421
+ ]
422
+ },
423
+ n.id
424
+ )) })
425
+ ]
426
+ }
427
+ )
428
+ ] }) : null
429
+ ] });
430
+ }
431
+ function Avatar({ user, size = 28 }) {
432
+ const [broken, setBroken] = useState(false);
433
+ const style = `width:${size}px;height:${size}px;font-size:${Math.round(size * 0.4)}px`;
434
+ if (user.picture && !broken) {
435
+ return /* @__PURE__ */ jsx("img", { class: "bas-avatar", style, src: user.picture, alt: "", referrerpolicy: "no-referrer", onError: () => setBroken(true), "data-testid": "avatar-picture" });
436
+ }
437
+ return /* @__PURE__ */ jsx("span", { class: "bas-avatar bas-avatar--initials", style, "data-testid": "avatar-initials", "aria-hidden": "true", children: initials(user.name, user.email) });
438
+ }
439
+ function Segmented(props) {
440
+ return /* @__PURE__ */ jsx("div", { class: "bas-seg", "data-testid": props.testid, role: "group", children: props.options.map(([v, label]) => /* @__PURE__ */ jsx("button", { type: "button", "aria-pressed": props.value === v, "data-testid": `${props.testid}-${v}`, onClick: () => props.onChange(v), children: label }, v)) });
441
+ }
442
+ function UserMenu(p) {
443
+ const t = TEXT[p.lang];
444
+ const [open, setOpen] = useState(false);
445
+ const [busy, setBusy] = useState(false);
446
+ const root = useRef(null);
447
+ const [pref, setPref] = useState(() => getPreference());
448
+ const [palette, setPal] = useState(() => getPalette());
449
+ const [surfaces, setSurf] = useState(() => getSurfaces());
450
+ const [backdrop, setBack] = useState(() => getBackdrop());
451
+ useEffect(() => {
452
+ const offs = [
453
+ onThemeChange(() => setPref(getPreference())),
454
+ onPaletteChange(setPal),
455
+ onSurfacesChange(setSurf),
456
+ onBackdropChange(setBack)
457
+ ];
458
+ return () => offs.forEach((off) => off());
459
+ }, []);
460
+ useEffect(() => {
461
+ if (!open) return;
462
+ const onDown = (e) => {
463
+ if (root.current && !root.current.contains(e.target)) setOpen(false);
464
+ };
465
+ const onKey = (e) => e.key === "Escape" && setOpen(false);
466
+ document.addEventListener("mousedown", onDown);
467
+ document.addEventListener("keydown", onKey);
468
+ return () => {
469
+ document.removeEventListener("mousedown", onDown);
470
+ document.removeEventListener("keydown", onKey);
471
+ };
472
+ }, [open]);
473
+ const firstName = (p.user.name ?? "").trim().split(/\s+/)[0] || p.user.email || "";
474
+ const signOut = async () => {
475
+ setBusy(true);
476
+ try {
477
+ await p.onSignOut?.();
478
+ } finally {
479
+ setBusy(false);
480
+ setOpen(false);
481
+ }
482
+ };
483
+ return /* @__PURE__ */ jsxs("div", { class: "bas-usermenu", ref: root, children: [
484
+ /* @__PURE__ */ jsxs(
485
+ "button",
486
+ {
487
+ type: "button",
488
+ class: "bas-userbtn" + (open ? " is-open" : ""),
489
+ "data-testid": "topbar-user-menu",
490
+ "aria-haspopup": "menu",
491
+ "aria-expanded": open,
492
+ "aria-label": p.user.email ?? p.user.name ?? t.account,
493
+ onClick: () => setOpen((v) => !v),
494
+ children: [
495
+ /* @__PURE__ */ jsx(Avatar, { user: p.user }),
496
+ /* @__PURE__ */ jsx("span", { class: "bas-userbtn__name", children: firstName })
497
+ ]
498
+ }
499
+ ),
500
+ open ? /* @__PURE__ */ jsxs("div", { class: "bas-menu", role: "menu", "data-testid": "topbar-user-menu-dropdown", children: [
501
+ /* @__PURE__ */ jsxs("div", { class: "bas-menu__who", children: [
502
+ /* @__PURE__ */ jsx(Avatar, { user: p.user, size: 36 }),
503
+ /* @__PURE__ */ jsxs("div", { class: "bas-menu__whotext", children: [
504
+ p.user.name ? /* @__PURE__ */ jsx("div", { class: "bas-menu__name", children: p.user.name }) : null,
505
+ p.user.email ? /* @__PURE__ */ jsx("div", { class: "bas-menu__email", children: p.user.email }) : null
506
+ ] })
507
+ ] }),
508
+ /* @__PURE__ */ jsx("div", { class: "bas-sep" }),
509
+ p.accountHref ? /* @__PURE__ */ jsxs("a", { class: "bas-mi", href: p.accountHref, "data-testid": "user-menu-account", role: "menuitem", children: [
510
+ /* @__PURE__ */ jsx(IconUser, {}),
511
+ /* @__PURE__ */ jsx("span", { children: t.account })
512
+ ] }) : null,
513
+ (p.items ?? []).map(
514
+ (it) => it.href ? /* @__PURE__ */ jsxs(NavLink, { href: it.href, onNavigate: p.onNavigate, onAfter: () => setOpen(false), class: "bas-mi", "data-testid": `user-menu-${it.id}`, children: [
515
+ it.icon ?? null,
516
+ /* @__PURE__ */ jsx("span", { children: it.label })
517
+ ] }, it.id) : /* @__PURE__ */ jsxs(
518
+ "button",
519
+ {
520
+ type: "button",
521
+ class: "bas-mi",
522
+ role: "menuitem",
523
+ "data-testid": `user-menu-${it.id}`,
524
+ onClick: () => {
525
+ setOpen(false);
526
+ it.onSelect?.();
527
+ },
528
+ children: [
529
+ it.icon ?? null,
530
+ /* @__PURE__ */ jsx("span", { children: it.label })
531
+ ]
532
+ },
533
+ it.id
534
+ )
535
+ ),
536
+ p.appearance !== false ? /* @__PURE__ */ jsxs(Fragment, { children: [
537
+ /* @__PURE__ */ jsx("div", { class: "bas-sep" }),
538
+ /* @__PURE__ */ jsxs("div", { class: "bas-prefs", children: [
539
+ /* @__PURE__ */ jsxs("div", { class: "bas-pref", children: [
540
+ /* @__PURE__ */ jsx("span", { children: t.theme }),
541
+ /* @__PURE__ */ jsx(
542
+ Segmented,
543
+ {
544
+ testid: "user-menu-theme",
545
+ value: pref,
546
+ options: [["light", t.light], ["dark", t.dark], ["system", t.system]],
547
+ onChange: (v) => {
548
+ setPreference(v);
549
+ setPref(v);
550
+ }
551
+ }
552
+ )
553
+ ] }),
554
+ /* @__PURE__ */ jsxs("div", { class: "bas-pref", children: [
555
+ /* @__PURE__ */ jsx("span", { children: t.palette }),
556
+ /* @__PURE__ */ jsx(Segmented, { testid: "user-menu-palette", value: palette, options: PALETTES.map((x) => [x, PALETTE_LABELS[x]]), onChange: setPalette })
557
+ ] }),
558
+ /* @__PURE__ */ jsxs("div", { class: "bas-pref", children: [
559
+ /* @__PURE__ */ jsx("span", { children: t.surfaces }),
560
+ /* @__PURE__ */ jsx(Segmented, { testid: "user-menu-surfaces", value: surfaces, options: [["flat", t.flat], ["layered", t.layered]], onChange: setSurfaces })
561
+ ] }),
562
+ /* @__PURE__ */ jsxs("div", { class: "bas-pref", children: [
563
+ /* @__PURE__ */ jsx("span", { children: t.backdrop }),
564
+ /* @__PURE__ */ jsx(Segmented, { testid: "user-menu-backdrop", value: backdrop, options: [["neurons", t.neurons], ["plain", t.plain]], onChange: setBackdrop })
565
+ ] })
566
+ ] })
567
+ ] }) : null,
568
+ p.onSignOut ? /* @__PURE__ */ jsxs(Fragment, { children: [
569
+ /* @__PURE__ */ jsx("div", { class: "bas-sep" }),
570
+ /* @__PURE__ */ jsxs("button", { type: "button", class: "bas-mi is-danger", role: "menuitem", "data-testid": "topbar-sign-out", disabled: busy, onClick: () => void signOut(), children: [
571
+ /* @__PURE__ */ jsx(IconLogOut, {}),
572
+ /* @__PURE__ */ jsx("span", { children: t.signOut })
573
+ ] })
574
+ ] }) : null
575
+ ] }) : null
576
+ ] });
577
+ }
578
+ function TopBar(p) {
579
+ const t = TEXT[p.lang];
580
+ return /* @__PURE__ */ jsx("header", { class: "bas-topbar", "data-testid": "topbar-root", children: /* @__PURE__ */ jsxs("div", { class: "bas-topbar__inner", children: [
581
+ p.onOpenMenu ? /* @__PURE__ */ jsx("button", { type: "button", class: "bas-iconbtn bas-hamburger", "data-testid": "sidebar-toggle", "aria-label": t.openMenu, onClick: p.onOpenMenu, children: /* @__PURE__ */ jsx(IconMenu, {}) }) : null,
582
+ p.brand ? /* @__PURE__ */ jsx("div", { class: "bas-topbar__brand", children: p.brand }) : null,
583
+ p.actions ? /* @__PURE__ */ jsx("div", { class: "bas-topbar__actions", children: p.actions }) : null,
584
+ /* @__PURE__ */ jsx("div", { class: "bas-spacer" }),
585
+ /* @__PURE__ */ jsx(NotificationBell, { source: p.notifications, onNavigate: p.onNavigate, lang: p.lang }),
586
+ p.user ? /* @__PURE__ */ jsx(UserMenu, { user: p.user, ...p.userMenu, onNavigate: p.onNavigate, lang: p.lang }) : null
587
+ ] }) });
588
+ }
589
+ function PageHeader(p) {
590
+ return /* @__PURE__ */ jsxs("div", { class: "bas-pagehead", "data-testid": "page-header", children: [
591
+ /* @__PURE__ */ jsxs("div", { class: "bas-pagehead__text", children: [
592
+ p.eyebrow ? /* @__PURE__ */ jsx("div", { class: "bas-pagehead__eyebrow", children: p.eyebrow }) : null,
593
+ /* @__PURE__ */ jsx("h1", { class: "bas-pagehead__title", children: p.title }),
594
+ p.description ? /* @__PURE__ */ jsx("p", { class: "bas-pagehead__desc", children: p.description }) : null
595
+ ] }),
596
+ p.actions ? /* @__PURE__ */ jsx("div", { class: "bas-pagehead__actions", children: p.actions }) : null
597
+ ] });
598
+ }
599
+ function PageTabs(p) {
600
+ const mode = p.mode ?? "query";
601
+ const url = new URL(p.currentUrl, "http://x");
602
+ const activeId = mode === "query" ? url.searchParams.get("tab") ?? p.tabs[0]?.id : p.tabs.find((tab) => tab.href && isActivePath(tab.href, url.pathname))?.id;
603
+ return /* @__PURE__ */ jsx("div", { class: "bas-tabs", role: "tablist", "data-testid": "page-tabs", children: p.tabs.map((tab) => {
604
+ let href = tab.href ?? "";
605
+ if (mode === "query") {
606
+ const u = new URL(url.toString());
607
+ u.searchParams.set("tab", tab.id);
608
+ href = u.pathname + u.search;
609
+ }
610
+ const active = tab.id === activeId;
611
+ return /* @__PURE__ */ jsx(NavLink, { href, onNavigate: p.onNavigate, class: "bas-tab" + (active ? " is-active" : ""), "data-testid": `page-tab-${tab.id}`, "aria-current": active ? "page" : void 0, children: tab.label }, tab.id);
612
+ }) });
613
+ }
614
+ function AppShell(p) {
615
+ const prefix = p.storageKey ?? "broberg-app-shell";
616
+ const mode = p.mobile ?? "drawer";
617
+ const [collapsed, setCollapsed] = useState(() => readFlag(`${prefix}.collapsed`, false));
618
+ const [drawer, setDrawer] = useState(false);
619
+ const t = TEXT[p.lang];
620
+ const toggle = () => {
621
+ const next = !collapsed;
622
+ setCollapsed(next);
623
+ writeFlag(`${prefix}.collapsed`, next);
624
+ };
625
+ return /* @__PURE__ */ jsxs("div", { class: "bas-root", "data-testid": "app-shell", "data-mobile": mode, children: [
626
+ /* @__PURE__ */ jsx(
627
+ TopBar,
628
+ {
629
+ brand: p.brand,
630
+ actions: p.actions,
631
+ onOpenMenu: mode === "drawer" ? () => setDrawer(true) : void 0,
632
+ notifications: p.notifications,
633
+ user: p.user,
634
+ userMenu: p.userMenu,
635
+ onNavigate: p.onNavigate,
636
+ lang: p.lang
637
+ }
638
+ ),
639
+ /* @__PURE__ */ jsxs("div", { class: "bas-row", children: [
640
+ /* @__PURE__ */ jsx(
641
+ Sidebar,
642
+ {
643
+ groups: p.groups,
644
+ footer: p.footer,
645
+ currentPath: p.currentPath,
646
+ onNavigate: p.onNavigate,
647
+ collapsed,
648
+ onToggleCollapse: toggle,
649
+ mobileOpen: drawer,
650
+ onClose: () => setDrawer(false),
651
+ mobile: mode,
652
+ header: mode === "drawer" ? p.brand : void 0,
653
+ groupsKey: `${prefix}.groups`,
654
+ lang: p.lang
655
+ }
656
+ ),
657
+ collapsed && mode === "drawer" ? /* @__PURE__ */ jsx("button", { type: "button", class: "bas-iconbtn bas-expand", "data-testid": "sidebar-expand", "aria-label": t.expand, title: t.expand, onClick: toggle, children: /* @__PURE__ */ jsx(IconExpand, {}) }) : null,
658
+ /* @__PURE__ */ jsx("main", { class: "bas-main", "data-testid": "app-content", children: p.children })
659
+ ] })
660
+ ] });
661
+ }
662
+
663
+ export { AppShell, Avatar, NotificationBell, PageHeader, PageTabs, Sidebar, TEXT, TopBar, UserMenu, badgeLabel, createFetchNotificationSource, createMemoryNotificationSource, initials, isActivePath, readFlag, readSet, relativeTime, useIsMobile, writeFlag, writeSet };
664
+ //# sourceMappingURL=preact.js.map
665
+ //# sourceMappingURL=preact.js.map