@broberg/app-shell 0.1.0 → 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/preact.d.ts CHANGED
@@ -10,6 +10,12 @@ interface NavItem<Icon = unknown> {
10
10
  icon?: Icon;
11
11
  /** A count shown on the item (99+ above 99). 0/undefined shows nothing. */
12
12
  badge?: number;
13
+ /**
14
+ * Sub-items, ONE level (F092.5). An item with children is a toggle that opens
15
+ * and closes them, not a link; its own `href` is used only to decide whether
16
+ * it is on the current path.
17
+ */
18
+ children?: NavItem<Icon>[];
13
19
  }
14
20
  interface NavGroup<Icon = unknown> {
15
21
  label: string;
@@ -20,6 +26,18 @@ interface NavGroup<Icon = unknown> {
20
26
  * item or below it ("/board/123" is on "/board", "/boardroom" is not).
21
27
  */
22
28
  declare function isActivePath(href: string, currentPath: string): boolean;
29
+ /**
30
+ * A notification's `navigate` is SERVER DATA, and it ends in `location.assign`.
31
+ * `javascript:` there runs code in the app, so only a same-site path or an
32
+ * http(s) URL is followed; anything else returns null and the row just closes.
33
+ */
34
+ declare function safeNavigate(target: string | null | undefined): string | null;
35
+ /**
36
+ * The label of the nav item the user is on, for the content header (F092.4).
37
+ * Sub-items count, and the most specific match wins: on "/models/genesis" a
38
+ * "Genesis" child beats its "Models" parent.
39
+ */
40
+ declare function activeNavLabel<Icon>(groups: NavGroup<Icon>[], footer: NavItem<Icon>[] | undefined, currentPath: string): string | undefined;
23
41
  /** "99+" above 99, nothing for 0 or less. */
24
42
  declare function badgeLabel(n: number | undefined): string | null;
25
43
  /** Read a boolean flag from localStorage; storage errors read as `fallback`. */
@@ -80,6 +98,8 @@ declare function relativeTime(ts: string | number, lang: "da" | "en", now?: numb
80
98
  declare const TEXT: {
81
99
  readonly da: {
82
100
  readonly openMenu: "Åbn menu";
101
+ readonly closeMenu: "Luk menu";
102
+ readonly home: "Forside";
83
103
  readonly collapse: "Skjul sidemenu";
84
104
  readonly expand: "Vis sidemenu";
85
105
  readonly notifications: "Notifikationer";
@@ -104,6 +124,8 @@ declare const TEXT: {
104
124
  };
105
125
  readonly en: {
106
126
  readonly openMenu: "Open menu";
127
+ readonly closeMenu: "Close menu";
128
+ readonly home: "Home";
107
129
  readonly collapse: "Hide sidebar";
108
130
  readonly expand: "Show sidebar";
109
131
  readonly notifications: "Notifications";
@@ -143,9 +165,20 @@ interface SidebarProps {
143
165
  footer?: PreactNavItem[];
144
166
  currentPath: string;
145
167
  onNavigate?: Navigate;
146
- /** Desktop: collapsed to nothing (drawer mode) or to the icon rail (rail mode). */
168
+ /** Desktop: is the sidebar folded (to nothing, or to the icon rail — see `collapse`). */
147
169
  collapsed?: boolean;
148
- onToggleCollapse?: () => void;
170
+ /**
171
+ * F092.6 — what "folded" means on desktop, as shadcn's `collapsible`:
172
+ * "offcanvas" hides the sidebar, "icon" keeps a 48px rail of item icons with
173
+ * `brandMark` on top. Default: "icon" when `mobile` is "rail", else "offcanvas".
174
+ */
175
+ collapse?: "offcanvas" | "icon";
176
+ /** The app's logo icon, shown at the top of the icon rail. */
177
+ brandMark?: ComponentChildren;
178
+ /** F092.7 — the app's start page: `header` and `brandMark` become links to it. */
179
+ homeHref?: string;
180
+ /** Accessible name of the brandMark link (it has no visible text). Default "Forside"/"Home". */
181
+ homeLabel?: string;
149
182
  /** Phone, drawer mode: is the drawer open. */
150
183
  mobileOpen?: boolean;
151
184
  onClose?: () => void;
@@ -154,6 +187,8 @@ interface SidebarProps {
154
187
  header?: ComponentChildren;
155
188
  /** localStorage key for which groups are folded. */
156
189
  groupsKey?: string;
190
+ /** localStorage key for which items with children are open (F092.5). */
191
+ itemsKey?: string;
157
192
  lang: Lang;
158
193
  }
159
194
  declare function Sidebar(p: SidebarProps): JSX.Element;
@@ -195,6 +230,10 @@ declare function Avatar({ user, size }: {
195
230
  }): JSX.Element;
196
231
  declare function UserMenu(p: UserMenuProps): JSX.Element;
197
232
  interface TopBarProps {
233
+ /** F092.7 — when set, the brand is a link to the app's start page. */
234
+ homeHref?: string;
235
+ /** Called after the brand link was followed (AppShell closes the drawer). */
236
+ onHome?: () => void;
198
237
  brand?: ComponentChildren;
199
238
  /** Anything app-specific (project switcher, search) between brand and bell. */
200
239
  actions?: ComponentChildren;
@@ -241,9 +280,25 @@ interface AppShellProps {
241
280
  notifications?: NotificationSource;
242
281
  user?: ShellUser;
243
282
  userMenu?: Omit<UserMenuProps, "user" | "lang" | "onNavigate">;
244
- /** Phone behaviour: a slide-in drawer behind a hamburger, or a 60px icon rail. */
283
+ /** Phone behaviour: a slide-in drawer, or a 60px icon rail. */
245
284
  mobile?: "drawer" | "rail";
246
- /** localStorage key prefix for the collapsed sidebar and folded groups. */
285
+ /**
286
+ * F092.6 — desktop fold: "offcanvas" hides the sidebar, "icon" keeps a 48px
287
+ * icon rail with `brandMark` on top. Default: "icon" when `mobile` is "rail".
288
+ */
289
+ collapse?: "offcanvas" | "icon";
290
+ /** The app's logo icon for the top of the icon rail. */
291
+ brandMark?: ComponentChildren;
292
+ /** F092.7 — the app's start page. Brand and brandMark become links to it; on a phone the click closes the drawer. */
293
+ homeHref?: string;
294
+ /** Accessible name for the brandMark link. Default "Forside"/"Home". */
295
+ homeLabel?: string;
296
+ /**
297
+ * F092.4 — the page title in the content header, after the sidebar button.
298
+ * Absent: the label of the active nav item (a sub-item wins over its parent).
299
+ */
300
+ title?: ComponentChildren;
301
+ /** localStorage key prefix for the folded sidebar, folded groups and open items. */
247
302
  storageKey?: string;
248
303
  /** Required: the shell ships no default language (see @broberg/notifications/shell). */
249
304
  lang: Lang;
@@ -251,4 +306,4 @@ interface AppShellProps {
251
306
  }
252
307
  declare function AppShell(p: AppShellProps): JSX.Element;
253
308
 
254
- export { AppShell, type AppShellProps, Avatar, type Lang, type NavGroup, type NavItem, type Navigate, NotificationBell, type NotificationBellProps, type NotificationSource, PageHeader, type PageTab, PageTabs, type PreactNavGroup, type PreactNavItem, type ShellUser, Sidebar, type SidebarProps, TEXT, TopBar, type TopBarProps, UserMenu, type UserMenuItem, type UserMenuProps, badgeLabel, createFetchNotificationSource, createMemoryNotificationSource, initials, isActivePath, readFlag, readSet, relativeTime, useIsMobile, writeFlag, writeSet };
309
+ export { AppShell, type AppShellProps, Avatar, type Lang, type NavGroup, type NavItem, type Navigate, NotificationBell, type NotificationBellProps, type NotificationSource, PageHeader, type PageTab, PageTabs, type PreactNavGroup, type PreactNavItem, type ShellUser, Sidebar, type SidebarProps, TEXT, TopBar, type TopBarProps, UserMenu, type UserMenuItem, type UserMenuProps, activeNavLabel, badgeLabel, createFetchNotificationSource, createMemoryNotificationSource, initials, isActivePath, readFlag, readSet, relativeTime, safeNavigate, useIsMobile, writeFlag, writeSet };
package/dist/preact.js CHANGED
@@ -12,6 +12,30 @@ function isActivePath(href, currentPath) {
12
12
  if (target === "/") return path === "/";
13
13
  return path === target || path.startsWith(target + "/");
14
14
  }
15
+ function safeNavigate(target) {
16
+ if (typeof target !== "string") return null;
17
+ const t = target.trim();
18
+ if (t.startsWith("/") && !t.startsWith("//") && !t.startsWith("/\\")) return t;
19
+ try {
20
+ const u = new URL(t);
21
+ return u.protocol === "https:" || u.protocol === "http:" ? u.href : null;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+ function activeNavLabel(groups, footer, currentPath) {
27
+ let best;
28
+ const visit = (it) => {
29
+ if (it.href && isActivePath(it.href, currentPath)) {
30
+ const len = it.href.length;
31
+ if (!best || len > best.len) best = { label: it.label, len };
32
+ }
33
+ it.children?.forEach(visit);
34
+ };
35
+ for (const g of groups) g.items.forEach(visit);
36
+ footer?.forEach(visit);
37
+ return best?.label;
38
+ }
15
39
  function badgeLabel(n) {
16
40
  if (!n || n <= 0) return null;
17
41
  return n > 99 ? "99+" : String(n);
@@ -146,6 +170,8 @@ function relativeTime(ts, lang, now = Date.now()) {
146
170
  var TEXT = {
147
171
  da: {
148
172
  openMenu: "\xC5bn menu",
173
+ closeMenu: "Luk menu",
174
+ home: "Forside",
149
175
  collapse: "Skjul sidemenu",
150
176
  expand: "Vis sidemenu",
151
177
  notifications: "Notifikationer",
@@ -170,6 +196,8 @@ var TEXT = {
170
196
  },
171
197
  en: {
172
198
  openMenu: "Open menu",
199
+ closeMenu: "Close menu",
200
+ home: "Home",
173
201
  collapse: "Hide sidebar",
174
202
  expand: "Show sidebar",
175
203
  notifications: "Notifications",
@@ -227,8 +255,10 @@ function NavLink(props) {
227
255
  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
256
  var IconMenu = () => svg(/* @__PURE__ */ jsx("path", { d: "M4 6h16M4 12h16M4 18h16" }), 20);
229
257
  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);
258
+ var IconPanelLeft = () => svg(/* @__PURE__ */ jsxs(Fragment, { children: [
259
+ /* @__PURE__ */ jsx("rect", { width: "18", height: "18", x: "3", y: "3", rx: "2" }),
260
+ /* @__PURE__ */ jsx("path", { d: "M9 3v18" })
261
+ ] }), 16);
232
262
  var IconBell = () => svg(/* @__PURE__ */ jsxs(Fragment, { children: [
233
263
  /* @__PURE__ */ jsx("path", { d: "M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" }),
234
264
  /* @__PURE__ */ jsx("path", { d: "M10.3 21a1.94 1.94 0 0 0 3.4 0" })
@@ -243,8 +273,12 @@ var IconUser = () => svg(/* @__PURE__ */ jsxs(Fragment, { children: [
243
273
  /* @__PURE__ */ jsx("path", { d: "M4 21a8 8 0 0 1 16 0" })
244
274
  ] }), 14);
245
275
  function Sidebar(p) {
246
- const t = TEXT[p.lang];
247
276
  const key = p.groupsKey ?? "broberg-app-shell.groups";
277
+ const itemsKey = p.itemsKey ?? "broberg-app-shell.items";
278
+ const mode = p.mobile ?? "drawer";
279
+ const collapseMode = p.collapse ?? (mode === "rail" ? "icon" : "offcanvas");
280
+ const isMobile = useIsMobile();
281
+ const railed = isMobile ? mode === "rail" : !!p.collapsed && collapseMode === "icon";
248
282
  const [folded, setFolded] = useState(() => readSet(key));
249
283
  const toggleGroup = (label) => {
250
284
  const next = new Set(folded);
@@ -252,9 +286,47 @@ function Sidebar(p) {
252
286
  setFolded(next);
253
287
  writeSet(key, next);
254
288
  };
255
- const mode = p.mobile ?? "drawer";
256
- const close = () => p.onClose?.();
257
- const item = (it) => {
289
+ const all = [...p.groups.flatMap((g) => g.items), ...p.footer ?? []];
290
+ const childActive = (it) => !!it.children?.some((c) => isActivePath(c.href, p.currentPath));
291
+ const [openItems, setOpenItems] = useState(() => {
292
+ const s = readSet(itemsKey);
293
+ for (const it of all) if (childActive(it)) s.add(it.id);
294
+ return s;
295
+ });
296
+ const toggleItem = (id) => {
297
+ const next = new Set(openItems);
298
+ next.has(id) ? next.delete(id) : next.add(id);
299
+ setOpenItems(next);
300
+ writeSet(itemsKey, next);
301
+ };
302
+ const [flyout, setFlyout] = useState(null);
303
+ const flyoutRef = useRef(null);
304
+ useEffect(() => {
305
+ if (!flyout) return;
306
+ const onKey = (e) => {
307
+ if (e.key === "Escape") setFlyout(null);
308
+ };
309
+ const onDown = (e) => {
310
+ const t = e.target;
311
+ if (flyoutRef.current && t && flyoutRef.current.contains(t)) return;
312
+ if (t instanceof Element && t.closest(`[data-flyout-for="${flyout.id}"]`)) return;
313
+ setFlyout(null);
314
+ };
315
+ document.addEventListener("keydown", onKey);
316
+ document.addEventListener("mousedown", onDown);
317
+ return () => {
318
+ document.removeEventListener("keydown", onKey);
319
+ document.removeEventListener("mousedown", onDown);
320
+ };
321
+ }, [flyout]);
322
+ useEffect(() => {
323
+ if (!railed) setFlyout(null);
324
+ }, [railed]);
325
+ const close = () => {
326
+ setFlyout(null);
327
+ p.onClose?.();
328
+ };
329
+ const leaf = (it, sub = false) => {
258
330
  const active = isActivePath(it.href, p.currentPath);
259
331
  const badge = badgeLabel(it.badge);
260
332
  return /* @__PURE__ */ jsxs(
@@ -263,12 +335,13 @@ function Sidebar(p) {
263
335
  href: it.href,
264
336
  onNavigate: p.onNavigate,
265
337
  onAfter: close,
266
- class: "bas-item" + (active ? " is-active" : ""),
338
+ class: "bas-item" + (sub ? " bas-item--sub" : "") + (active ? " is-active" : ""),
267
339
  "data-testid": `sidebar-item-${it.id}`,
268
340
  "aria-current": active ? "page" : void 0,
341
+ "aria-label": railed && !sub ? it.label : void 0,
269
342
  title: it.label,
270
343
  children: [
271
- active ? /* @__PURE__ */ jsx("span", { class: "bas-item__bar" }) : null,
344
+ active && !sub ? /* @__PURE__ */ jsx("span", { class: "bas-item__bar" }) : null,
272
345
  it.icon ? /* @__PURE__ */ jsx("span", { class: "bas-item__icon", children: it.icon }) : null,
273
346
  /* @__PURE__ */ jsx("span", { class: "bas-item__label", children: it.label }),
274
347
  badge ? /* @__PURE__ */ jsx("span", { class: "bas-item__count", "data-testid": `sidebar-badge-${it.id}`, children: badge }) : null
@@ -277,22 +350,84 @@ function Sidebar(p) {
277
350
  it.id
278
351
  );
279
352
  };
353
+ const parent = (it) => {
354
+ const children = it.children;
355
+ const onPage = childActive(it) || isActivePath(it.href, p.currentPath);
356
+ const open = railed ? flyout?.id === it.id : openItems.has(it.id);
357
+ const onClick = (e) => {
358
+ if (!railed) return toggleItem(it.id);
359
+ if (flyout?.id === it.id) return setFlyout(null);
360
+ const r = e.currentTarget.getBoundingClientRect();
361
+ setFlyout({ id: it.id, top: r.top, left: r.right + 6 });
362
+ };
363
+ return /* @__PURE__ */ jsxs("div", { class: "bas-itemgroup", children: [
364
+ /* @__PURE__ */ jsxs(
365
+ "button",
366
+ {
367
+ type: "button",
368
+ class: "bas-item bas-item--parent" + (onPage ? " is-parent-active" : ""),
369
+ "data-testid": `sidebar-item-${it.id}-toggle`,
370
+ "data-flyout-for": it.id,
371
+ "aria-expanded": open,
372
+ "aria-label": railed ? it.label : void 0,
373
+ title: it.label,
374
+ onClick,
375
+ children: [
376
+ onPage && railed ? /* @__PURE__ */ jsx("span", { class: "bas-item__bar" }) : null,
377
+ it.icon ? /* @__PURE__ */ jsx("span", { class: "bas-item__icon", children: it.icon }) : null,
378
+ /* @__PURE__ */ jsx("span", { class: "bas-item__label", children: it.label }),
379
+ /* @__PURE__ */ jsx("span", { class: "bas-item__chev", children: /* @__PURE__ */ jsx(IconChevron, { open }) })
380
+ ]
381
+ }
382
+ ),
383
+ !railed && open ? /* @__PURE__ */ jsx("div", { class: "bas-sub", children: children.map((c) => leaf(c, true)) }) : null,
384
+ railed && flyout?.id === it.id ? /* @__PURE__ */ jsxs(
385
+ "div",
386
+ {
387
+ ref: flyoutRef,
388
+ class: "bas-flyout",
389
+ "data-testid": `sidebar-flyout-${it.id}`,
390
+ role: "menu",
391
+ "aria-label": it.label,
392
+ style: { top: `${flyout.top}px`, left: `${flyout.left}px` },
393
+ children: [
394
+ /* @__PURE__ */ jsx("div", { class: "bas-flyout__title", children: it.label }),
395
+ children.map((c) => leaf(c, true))
396
+ ]
397
+ }
398
+ ) : null
399
+ ] }, it.id);
400
+ };
401
+ const item = (it) => it.children?.length ? parent(it) : leaf(it);
280
402
  return /* @__PURE__ */ jsxs(Fragment, { children: [
281
403
  mode === "drawer" ? /* @__PURE__ */ jsx("div", { class: "bas-backdrop" + (p.mobileOpen ? " is-open" : ""), "data-testid": "sidebar-backdrop", onClick: close }) : null,
282
404
  /* @__PURE__ */ jsxs(
283
405
  "aside",
284
406
  {
285
- class: `bas-sidebar is-mobile-${mode}` + (p.mobileOpen ? " is-open" : "") + (p.collapsed ? " is-collapsed" : ""),
407
+ class: `bas-sidebar is-mobile-${mode}` + (p.mobileOpen ? " is-open" : "") + (p.collapsed ? " is-collapsed" : "") + (railed ? " is-railed" : ""),
286
408
  "data-testid": "sidebar-root",
287
409
  "data-mobile": mode,
410
+ "data-collapse": collapseMode,
288
411
  "aria-label": "Navigation",
289
412
  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
- ] }),
413
+ p.header || p.brandMark ? /* @__PURE__ */ jsxs("div", { class: "bas-sidebar__head", children: [
414
+ p.header ? /* @__PURE__ */ jsx("div", { class: "bas-sidebar__brand", children: p.homeHref ? /* @__PURE__ */ jsx(NavLink, { href: p.homeHref, onNavigate: p.onNavigate, onAfter: close, class: "bas-home", "data-testid": "sidebar-brand-home", children: p.header }) : p.header }) : null,
415
+ p.brandMark ? /* @__PURE__ */ jsx("div", { class: "bas-sidebar__mark", "data-testid": "sidebar-brand-mark", children: p.homeHref ? /* @__PURE__ */ jsx(
416
+ NavLink,
417
+ {
418
+ href: p.homeHref,
419
+ onNavigate: p.onNavigate,
420
+ onAfter: close,
421
+ class: "bas-home",
422
+ "data-testid": "brand-mark-home",
423
+ "aria-label": p.homeLabel ?? TEXT[p.lang].home,
424
+ title: p.homeLabel ?? TEXT[p.lang].home,
425
+ children: p.brandMark
426
+ }
427
+ ) : p.brandMark }) : null
428
+ ] }) : null,
294
429
  /* @__PURE__ */ jsx("nav", { class: "bas-sidebar__nav", children: p.groups.map((g) => {
295
- const open = !folded.has(g.label);
430
+ const open = railed || !folded.has(g.label);
296
431
  const slug = g.label.toLowerCase().replace(/[^a-z0-9]+/g, "-");
297
432
  return /* @__PURE__ */ jsxs("div", { class: "bas-group", children: [
298
433
  /* @__PURE__ */ jsxs("button", { type: "button", class: "bas-group__label", "data-testid": `sidebar-group-${slug}`, "aria-expanded": open, onClick: () => toggleGroup(g.label), children: [
@@ -364,9 +499,10 @@ function NotificationBell({ source, onNavigate, lang }) {
364
499
  await source.markSeen([n.id]);
365
500
  await shell.refresh();
366
501
  }
367
- if (n.navigate) {
368
- if (onNavigate) onNavigate(n.navigate);
369
- else if (typeof window !== "undefined") window.location.assign(n.navigate);
502
+ const target = safeNavigate(n.navigate);
503
+ if (target) {
504
+ if (onNavigate) onNavigate(target);
505
+ else if (typeof window !== "undefined") window.location.assign(target);
370
506
  }
371
507
  };
372
508
  const seen = (n) => n.seenAt != null || state.clearedIds.includes(n.id);
@@ -579,7 +715,7 @@ function TopBar(p) {
579
715
  const t = TEXT[p.lang];
580
716
  return /* @__PURE__ */ jsx("header", { class: "bas-topbar", "data-testid": "topbar-root", children: /* @__PURE__ */ jsxs("div", { class: "bas-topbar__inner", children: [
581
717
  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,
718
+ p.brand ? /* @__PURE__ */ jsx("div", { class: "bas-topbar__brand", children: p.homeHref ? /* @__PURE__ */ jsx(NavLink, { href: p.homeHref, onNavigate: p.onNavigate, onAfter: p.onHome, class: "bas-home", "data-testid": "brand-home", children: p.brand }) : p.brand }) : null,
583
719
  p.actions ? /* @__PURE__ */ jsx("div", { class: "bas-topbar__actions", children: p.actions }) : null,
584
720
  /* @__PURE__ */ jsx("div", { class: "bas-spacer" }),
585
721
  /* @__PURE__ */ jsx(NotificationBell, { source: p.notifications, onNavigate: p.onNavigate, lang: p.lang }),
@@ -614,21 +750,29 @@ function PageTabs(p) {
614
750
  function AppShell(p) {
615
751
  const prefix = p.storageKey ?? "broberg-app-shell";
616
752
  const mode = p.mobile ?? "drawer";
753
+ const collapse = p.collapse ?? (mode === "rail" ? "icon" : "offcanvas");
617
754
  const [collapsed, setCollapsed] = useState(() => readFlag(`${prefix}.collapsed`, false));
618
755
  const [drawer, setDrawer] = useState(false);
756
+ const isMobile = useIsMobile();
619
757
  const t = TEXT[p.lang];
620
758
  const toggle = () => {
621
759
  const next = !collapsed;
622
760
  setCollapsed(next);
623
761
  writeFlag(`${prefix}.collapsed`, next);
624
762
  };
763
+ const drawerMode = isMobile && mode === "drawer";
764
+ const onTrigger = () => drawerMode ? setDrawer(!drawer) : toggle();
765
+ const expanded = drawerMode ? drawer : !collapsed;
766
+ const triggerLabel = drawerMode ? drawer ? t.closeMenu : t.openMenu : collapsed ? t.expand : t.collapse;
767
+ const heading = p.title ?? activeNavLabel(p.groups, p.footer, p.currentPath);
625
768
  return /* @__PURE__ */ jsxs("div", { class: "bas-root", "data-testid": "app-shell", "data-mobile": mode, children: [
626
769
  /* @__PURE__ */ jsx(
627
770
  TopBar,
628
771
  {
629
772
  brand: p.brand,
773
+ homeHref: p.homeHref,
774
+ onHome: () => setDrawer(false),
630
775
  actions: p.actions,
631
- onOpenMenu: mode === "drawer" ? () => setDrawer(true) : void 0,
632
776
  notifications: p.notifications,
633
777
  user: p.user,
634
778
  userMenu: p.userMenu,
@@ -645,21 +789,45 @@ function AppShell(p) {
645
789
  currentPath: p.currentPath,
646
790
  onNavigate: p.onNavigate,
647
791
  collapsed,
648
- onToggleCollapse: toggle,
792
+ collapse,
793
+ brandMark: p.brandMark,
794
+ homeHref: p.homeHref,
795
+ homeLabel: p.homeLabel,
649
796
  mobileOpen: drawer,
650
797
  onClose: () => setDrawer(false),
651
798
  mobile: mode,
652
799
  header: mode === "drawer" ? p.brand : void 0,
653
800
  groupsKey: `${prefix}.groups`,
801
+ itemsKey: `${prefix}.items`,
654
802
  lang: p.lang
655
803
  }
656
804
  ),
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 })
805
+ /* @__PURE__ */ jsxs("main", { class: "bas-main", "data-testid": "app-content", children: [
806
+ /* @__PURE__ */ jsxs("div", { class: "bas-contenthead", "data-testid": "content-header", children: [
807
+ /* @__PURE__ */ jsx(
808
+ "button",
809
+ {
810
+ type: "button",
811
+ class: "bas-iconbtn bas-trigger",
812
+ "data-testid": "sidebar-trigger",
813
+ "aria-label": triggerLabel,
814
+ "aria-expanded": expanded,
815
+ title: triggerLabel,
816
+ onClick: onTrigger,
817
+ children: /* @__PURE__ */ jsx(IconPanelLeft, {})
818
+ }
819
+ ),
820
+ heading ? /* @__PURE__ */ jsxs(Fragment, { children: [
821
+ /* @__PURE__ */ jsx("span", { class: "bas-contenthead__sep", "data-testid": "content-header-separator", "aria-hidden": "true" }),
822
+ /* @__PURE__ */ jsx("h1", { class: "bas-contenthead__title", "data-testid": "content-header-title", children: heading })
823
+ ] }) : null
824
+ ] }),
825
+ p.children
826
+ ] })
659
827
  ] })
660
828
  ] });
661
829
  }
662
830
 
663
- export { AppShell, Avatar, NotificationBell, PageHeader, PageTabs, Sidebar, TEXT, TopBar, UserMenu, badgeLabel, createFetchNotificationSource, createMemoryNotificationSource, initials, isActivePath, readFlag, readSet, relativeTime, useIsMobile, writeFlag, writeSet };
831
+ export { AppShell, Avatar, NotificationBell, PageHeader, PageTabs, Sidebar, TEXT, TopBar, UserMenu, activeNavLabel, badgeLabel, createFetchNotificationSource, createMemoryNotificationSource, initials, isActivePath, readFlag, readSet, relativeTime, safeNavigate, useIsMobile, writeFlag, writeSet };
664
832
  //# sourceMappingURL=preact.js.map
665
833
  //# sourceMappingURL=preact.js.map