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