@broberg/app-shell 0.1.1 → 0.2.1

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;
@@ -26,6 +32,12 @@ declare function isActivePath(href: string, currentPath: string): boolean;
26
32
  * http(s) URL is followed; anything else returns null and the row just closes.
27
33
  */
28
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;
29
41
  /** "99+" above 99, nothing for 0 or less. */
30
42
  declare function badgeLabel(n: number | undefined): string | null;
31
43
  /** Read a boolean flag from localStorage; storage errors read as `fallback`. */
@@ -86,6 +98,8 @@ declare function relativeTime(ts: string | number, lang: "da" | "en", now?: numb
86
98
  declare const TEXT: {
87
99
  readonly da: {
88
100
  readonly openMenu: "Åbn menu";
101
+ readonly closeMenu: "Luk menu";
102
+ readonly home: "Forside";
89
103
  readonly collapse: "Skjul sidemenu";
90
104
  readonly expand: "Vis sidemenu";
91
105
  readonly notifications: "Notifikationer";
@@ -110,6 +124,8 @@ declare const TEXT: {
110
124
  };
111
125
  readonly en: {
112
126
  readonly openMenu: "Open menu";
127
+ readonly closeMenu: "Close menu";
128
+ readonly home: "Home";
113
129
  readonly collapse: "Hide sidebar";
114
130
  readonly expand: "Show sidebar";
115
131
  readonly notifications: "Notifications";
@@ -149,9 +165,20 @@ interface SidebarProps {
149
165
  footer?: PreactNavItem[];
150
166
  currentPath: string;
151
167
  onNavigate?: Navigate;
152
- /** 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`). */
153
169
  collapsed?: boolean;
154
- 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;
155
182
  /** Phone, drawer mode: is the drawer open. */
156
183
  mobileOpen?: boolean;
157
184
  onClose?: () => void;
@@ -160,6 +187,8 @@ interface SidebarProps {
160
187
  header?: ComponentChildren;
161
188
  /** localStorage key for which groups are folded. */
162
189
  groupsKey?: string;
190
+ /** localStorage key for which items with children are open (F092.5). */
191
+ itemsKey?: string;
163
192
  lang: Lang;
164
193
  }
165
194
  declare function Sidebar(p: SidebarProps): JSX.Element;
@@ -201,6 +230,10 @@ declare function Avatar({ user, size }: {
201
230
  }): JSX.Element;
202
231
  declare function UserMenu(p: UserMenuProps): JSX.Element;
203
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;
204
237
  brand?: ComponentChildren;
205
238
  /** Anything app-specific (project switcher, search) between brand and bell. */
206
239
  actions?: ComponentChildren;
@@ -214,9 +247,14 @@ interface TopBarProps {
214
247
  }
215
248
  /** Brand left, actions, then the notification bell and the user menu ALWAYS last, in that order. */
216
249
  declare function TopBar(p: TopBarProps): JSX.Element;
250
+ /**
251
+ * Optional `title` (0.2.1): inside AppShell the page title already sits in the
252
+ * content header, so a page can use PageHeader for its description and actions
253
+ * alone — no second, and no empty, <h1>.
254
+ */
217
255
  declare function PageHeader(p: {
218
256
  eyebrow?: ComponentChildren;
219
- title: ComponentChildren;
257
+ title?: ComponentChildren;
220
258
  description?: ComponentChildren;
221
259
  actions?: ComponentChildren;
222
260
  }): JSX.Element;
@@ -247,9 +285,25 @@ interface AppShellProps {
247
285
  notifications?: NotificationSource;
248
286
  user?: ShellUser;
249
287
  userMenu?: Omit<UserMenuProps, "user" | "lang" | "onNavigate">;
250
- /** Phone behaviour: a slide-in drawer behind a hamburger, or a 60px icon rail. */
288
+ /** Phone behaviour: a slide-in drawer, or a 60px icon rail. */
251
289
  mobile?: "drawer" | "rail";
252
- /** localStorage key prefix for the collapsed sidebar and folded groups. */
290
+ /**
291
+ * F092.6 — desktop fold: "offcanvas" hides the sidebar, "icon" keeps a 48px
292
+ * icon rail with `brandMark` on top. Default: "icon" when `mobile` is "rail".
293
+ */
294
+ collapse?: "offcanvas" | "icon";
295
+ /** The app's logo icon for the top of the icon rail. */
296
+ brandMark?: ComponentChildren;
297
+ /** F092.7 — the app's start page. Brand and brandMark become links to it; on a phone the click closes the drawer. */
298
+ homeHref?: string;
299
+ /** Accessible name for the brandMark link. Default "Forside"/"Home". */
300
+ homeLabel?: string;
301
+ /**
302
+ * F092.4 — the page title in the content header, after the sidebar button.
303
+ * Absent: the label of the active nav item (a sub-item wins over its parent).
304
+ */
305
+ title?: ComponentChildren;
306
+ /** localStorage key prefix for the folded sidebar, folded groups and open items. */
253
307
  storageKey?: string;
254
308
  /** Required: the shell ships no default language (see @broberg/notifications/shell). */
255
309
  lang: Lang;
@@ -257,4 +311,4 @@ interface AppShellProps {
257
311
  }
258
312
  declare function AppShell(p: AppShellProps): JSX.Element;
259
313
 
260
- 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, safeNavigate, useIsMobile, writeFlag, writeSet };
314
+ 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
@@ -23,6 +23,19 @@ function safeNavigate(target) {
23
23
  return null;
24
24
  }
25
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
+ }
26
39
  function badgeLabel(n) {
27
40
  if (!n || n <= 0) return null;
28
41
  return n > 99 ? "99+" : String(n);
@@ -157,6 +170,8 @@ function relativeTime(ts, lang, now = Date.now()) {
157
170
  var TEXT = {
158
171
  da: {
159
172
  openMenu: "\xC5bn menu",
173
+ closeMenu: "Luk menu",
174
+ home: "Forside",
160
175
  collapse: "Skjul sidemenu",
161
176
  expand: "Vis sidemenu",
162
177
  notifications: "Notifikationer",
@@ -181,6 +196,8 @@ var TEXT = {
181
196
  },
182
197
  en: {
183
198
  openMenu: "Open menu",
199
+ closeMenu: "Close menu",
200
+ home: "Home",
184
201
  collapse: "Hide sidebar",
185
202
  expand: "Show sidebar",
186
203
  notifications: "Notifications",
@@ -238,8 +255,10 @@ function NavLink(props) {
238
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 });
239
256
  var IconMenu = () => svg(/* @__PURE__ */ jsx("path", { d: "M4 6h16M4 12h16M4 18h16" }), 20);
240
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) });
241
- var IconCollapse = () => svg(/* @__PURE__ */ jsx("path", { d: "m15 18-6-6 6-6" }), 15);
242
- 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);
243
262
  var IconBell = () => svg(/* @__PURE__ */ jsxs(Fragment, { children: [
244
263
  /* @__PURE__ */ jsx("path", { d: "M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" }),
245
264
  /* @__PURE__ */ jsx("path", { d: "M10.3 21a1.94 1.94 0 0 0 3.4 0" })
@@ -254,8 +273,12 @@ var IconUser = () => svg(/* @__PURE__ */ jsxs(Fragment, { children: [
254
273
  /* @__PURE__ */ jsx("path", { d: "M4 21a8 8 0 0 1 16 0" })
255
274
  ] }), 14);
256
275
  function Sidebar(p) {
257
- const t = TEXT[p.lang];
258
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";
259
282
  const [folded, setFolded] = useState(() => readSet(key));
260
283
  const toggleGroup = (label) => {
261
284
  const next = new Set(folded);
@@ -263,9 +286,47 @@ function Sidebar(p) {
263
286
  setFolded(next);
264
287
  writeSet(key, next);
265
288
  };
266
- const mode = p.mobile ?? "drawer";
267
- const close = () => p.onClose?.();
268
- 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) => {
269
330
  const active = isActivePath(it.href, p.currentPath);
270
331
  const badge = badgeLabel(it.badge);
271
332
  return /* @__PURE__ */ jsxs(
@@ -274,12 +335,13 @@ function Sidebar(p) {
274
335
  href: it.href,
275
336
  onNavigate: p.onNavigate,
276
337
  onAfter: close,
277
- class: "bas-item" + (active ? " is-active" : ""),
338
+ class: "bas-item" + (sub ? " bas-item--sub" : "") + (active ? " is-active" : ""),
278
339
  "data-testid": `sidebar-item-${it.id}`,
279
340
  "aria-current": active ? "page" : void 0,
341
+ "aria-label": railed && !sub ? it.label : void 0,
280
342
  title: it.label,
281
343
  children: [
282
- active ? /* @__PURE__ */ jsx("span", { class: "bas-item__bar" }) : null,
344
+ active && !sub ? /* @__PURE__ */ jsx("span", { class: "bas-item__bar" }) : null,
283
345
  it.icon ? /* @__PURE__ */ jsx("span", { class: "bas-item__icon", children: it.icon }) : null,
284
346
  /* @__PURE__ */ jsx("span", { class: "bas-item__label", children: it.label }),
285
347
  badge ? /* @__PURE__ */ jsx("span", { class: "bas-item__count", "data-testid": `sidebar-badge-${it.id}`, children: badge }) : null
@@ -288,22 +350,84 @@ function Sidebar(p) {
288
350
  it.id
289
351
  );
290
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);
291
402
  return /* @__PURE__ */ jsxs(Fragment, { children: [
292
403
  mode === "drawer" ? /* @__PURE__ */ jsx("div", { class: "bas-backdrop" + (p.mobileOpen ? " is-open" : ""), "data-testid": "sidebar-backdrop", onClick: close }) : null,
293
404
  /* @__PURE__ */ jsxs(
294
405
  "aside",
295
406
  {
296
- 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" : ""),
297
408
  "data-testid": "sidebar-root",
298
409
  "data-mobile": mode,
410
+ "data-collapse": collapseMode,
299
411
  "aria-label": "Navigation",
300
412
  children: [
301
- /* @__PURE__ */ jsxs("div", { class: "bas-sidebar__head", children: [
302
- p.header ? /* @__PURE__ */ jsx("div", { class: "bas-sidebar__brand", children: p.header }) : /* @__PURE__ */ jsx("span", {}),
303
- 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
304
- ] }),
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,
305
429
  /* @__PURE__ */ jsx("nav", { class: "bas-sidebar__nav", children: p.groups.map((g) => {
306
- const open = !folded.has(g.label);
430
+ const open = railed || !folded.has(g.label);
307
431
  const slug = g.label.toLowerCase().replace(/[^a-z0-9]+/g, "-");
308
432
  return /* @__PURE__ */ jsxs("div", { class: "bas-group", children: [
309
433
  /* @__PURE__ */ jsxs("button", { type: "button", class: "bas-group__label", "data-testid": `sidebar-group-${slug}`, "aria-expanded": open, onClick: () => toggleGroup(g.label), children: [
@@ -591,7 +715,7 @@ function TopBar(p) {
591
715
  const t = TEXT[p.lang];
592
716
  return /* @__PURE__ */ jsx("header", { class: "bas-topbar", "data-testid": "topbar-root", children: /* @__PURE__ */ jsxs("div", { class: "bas-topbar__inner", children: [
593
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,
594
- 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,
595
719
  p.actions ? /* @__PURE__ */ jsx("div", { class: "bas-topbar__actions", children: p.actions }) : null,
596
720
  /* @__PURE__ */ jsx("div", { class: "bas-spacer" }),
597
721
  /* @__PURE__ */ jsx(NotificationBell, { source: p.notifications, onNavigate: p.onNavigate, lang: p.lang }),
@@ -602,7 +726,7 @@ function PageHeader(p) {
602
726
  return /* @__PURE__ */ jsxs("div", { class: "bas-pagehead", "data-testid": "page-header", children: [
603
727
  /* @__PURE__ */ jsxs("div", { class: "bas-pagehead__text", children: [
604
728
  p.eyebrow ? /* @__PURE__ */ jsx("div", { class: "bas-pagehead__eyebrow", children: p.eyebrow }) : null,
605
- /* @__PURE__ */ jsx("h1", { class: "bas-pagehead__title", children: p.title }),
729
+ p.title != null && p.title !== "" ? /* @__PURE__ */ jsx("h1", { class: "bas-pagehead__title", children: p.title }) : null,
606
730
  p.description ? /* @__PURE__ */ jsx("p", { class: "bas-pagehead__desc", children: p.description }) : null
607
731
  ] }),
608
732
  p.actions ? /* @__PURE__ */ jsx("div", { class: "bas-pagehead__actions", children: p.actions }) : null
@@ -626,21 +750,29 @@ function PageTabs(p) {
626
750
  function AppShell(p) {
627
751
  const prefix = p.storageKey ?? "broberg-app-shell";
628
752
  const mode = p.mobile ?? "drawer";
753
+ const collapse = p.collapse ?? (mode === "rail" ? "icon" : "offcanvas");
629
754
  const [collapsed, setCollapsed] = useState(() => readFlag(`${prefix}.collapsed`, false));
630
755
  const [drawer, setDrawer] = useState(false);
756
+ const isMobile = useIsMobile();
631
757
  const t = TEXT[p.lang];
632
758
  const toggle = () => {
633
759
  const next = !collapsed;
634
760
  setCollapsed(next);
635
761
  writeFlag(`${prefix}.collapsed`, next);
636
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);
637
768
  return /* @__PURE__ */ jsxs("div", { class: "bas-root", "data-testid": "app-shell", "data-mobile": mode, children: [
638
769
  /* @__PURE__ */ jsx(
639
770
  TopBar,
640
771
  {
641
772
  brand: p.brand,
773
+ homeHref: p.homeHref,
774
+ onHome: () => setDrawer(false),
642
775
  actions: p.actions,
643
- onOpenMenu: mode === "drawer" ? () => setDrawer(true) : void 0,
644
776
  notifications: p.notifications,
645
777
  user: p.user,
646
778
  userMenu: p.userMenu,
@@ -657,21 +789,45 @@ function AppShell(p) {
657
789
  currentPath: p.currentPath,
658
790
  onNavigate: p.onNavigate,
659
791
  collapsed,
660
- onToggleCollapse: toggle,
792
+ collapse,
793
+ brandMark: p.brandMark,
794
+ homeHref: p.homeHref,
795
+ homeLabel: p.homeLabel,
661
796
  mobileOpen: drawer,
662
797
  onClose: () => setDrawer(false),
663
798
  mobile: mode,
664
799
  header: mode === "drawer" ? p.brand : void 0,
665
800
  groupsKey: `${prefix}.groups`,
801
+ itemsKey: `${prefix}.items`,
666
802
  lang: p.lang
667
803
  }
668
804
  ),
669
- 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,
670
- /* @__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
+ ] })
671
827
  ] })
672
828
  ] });
673
829
  }
674
830
 
675
- export { AppShell, Avatar, NotificationBell, PageHeader, PageTabs, Sidebar, TEXT, TopBar, UserMenu, badgeLabel, createFetchNotificationSource, createMemoryNotificationSource, initials, isActivePath, readFlag, readSet, relativeTime, safeNavigate, 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 };
676
832
  //# sourceMappingURL=preact.js.map
677
833
  //# sourceMappingURL=preact.js.map