@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.cjs CHANGED
@@ -25,6 +25,19 @@ function safeNavigate(target) {
25
25
  return null;
26
26
  }
27
27
  }
28
+ function activeNavLabel(groups, footer, currentPath) {
29
+ let best;
30
+ const visit = (it) => {
31
+ if (it.href && isActivePath(it.href, currentPath)) {
32
+ const len = it.href.length;
33
+ if (!best || len > best.len) best = { label: it.label, len };
34
+ }
35
+ it.children?.forEach(visit);
36
+ };
37
+ for (const g of groups) g.items.forEach(visit);
38
+ footer?.forEach(visit);
39
+ return best?.label;
40
+ }
28
41
  function badgeLabel(n) {
29
42
  if (!n || n <= 0) return null;
30
43
  return n > 99 ? "99+" : String(n);
@@ -159,6 +172,8 @@ function relativeTime(ts, lang, now = Date.now()) {
159
172
  var TEXT = {
160
173
  da: {
161
174
  openMenu: "\xC5bn menu",
175
+ closeMenu: "Luk menu",
176
+ home: "Forside",
162
177
  collapse: "Skjul sidemenu",
163
178
  expand: "Vis sidemenu",
164
179
  notifications: "Notifikationer",
@@ -183,6 +198,8 @@ var TEXT = {
183
198
  },
184
199
  en: {
185
200
  openMenu: "Open menu",
201
+ closeMenu: "Close menu",
202
+ home: "Home",
186
203
  collapse: "Hide sidebar",
187
204
  expand: "Show sidebar",
188
205
  notifications: "Notifications",
@@ -240,8 +257,10 @@ function NavLink(props) {
240
257
  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 });
241
258
  var IconMenu = () => svg(/* @__PURE__ */ jsxRuntime.jsx("path", { d: "M4 6h16M4 12h16M4 18h16" }), 20);
242
259
  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) });
243
- var IconCollapse = () => svg(/* @__PURE__ */ jsxRuntime.jsx("path", { d: "m15 18-6-6 6-6" }), 15);
244
- var IconExpand = () => svg(/* @__PURE__ */ jsxRuntime.jsx("path", { d: "m9 18 6-6-6-6" }), 15);
260
+ var IconPanelLeft = () => svg(/* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
261
+ /* @__PURE__ */ jsxRuntime.jsx("rect", { width: "18", height: "18", x: "3", y: "3", rx: "2" }),
262
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M9 3v18" })
263
+ ] }), 16);
245
264
  var IconBell = () => svg(/* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
246
265
  /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" }),
247
266
  /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M10.3 21a1.94 1.94 0 0 0 3.4 0" })
@@ -256,8 +275,12 @@ var IconUser = () => svg(/* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, {
256
275
  /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M4 21a8 8 0 0 1 16 0" })
257
276
  ] }), 14);
258
277
  function Sidebar(p) {
259
- const t = TEXT[p.lang];
260
278
  const key = p.groupsKey ?? "broberg-app-shell.groups";
279
+ const itemsKey = p.itemsKey ?? "broberg-app-shell.items";
280
+ const mode = p.mobile ?? "drawer";
281
+ const collapseMode = p.collapse ?? (mode === "rail" ? "icon" : "offcanvas");
282
+ const isMobile = useIsMobile();
283
+ const railed = isMobile ? mode === "rail" : !!p.collapsed && collapseMode === "icon";
261
284
  const [folded, setFolded] = hooks.useState(() => readSet(key));
262
285
  const toggleGroup = (label) => {
263
286
  const next = new Set(folded);
@@ -265,9 +288,47 @@ function Sidebar(p) {
265
288
  setFolded(next);
266
289
  writeSet(key, next);
267
290
  };
268
- const mode = p.mobile ?? "drawer";
269
- const close = () => p.onClose?.();
270
- const item = (it) => {
291
+ const all = [...p.groups.flatMap((g) => g.items), ...p.footer ?? []];
292
+ const childActive = (it) => !!it.children?.some((c) => isActivePath(c.href, p.currentPath));
293
+ const [openItems, setOpenItems] = hooks.useState(() => {
294
+ const s = readSet(itemsKey);
295
+ for (const it of all) if (childActive(it)) s.add(it.id);
296
+ return s;
297
+ });
298
+ const toggleItem = (id) => {
299
+ const next = new Set(openItems);
300
+ next.has(id) ? next.delete(id) : next.add(id);
301
+ setOpenItems(next);
302
+ writeSet(itemsKey, next);
303
+ };
304
+ const [flyout, setFlyout] = hooks.useState(null);
305
+ const flyoutRef = hooks.useRef(null);
306
+ hooks.useEffect(() => {
307
+ if (!flyout) return;
308
+ const onKey = (e) => {
309
+ if (e.key === "Escape") setFlyout(null);
310
+ };
311
+ const onDown = (e) => {
312
+ const t = e.target;
313
+ if (flyoutRef.current && t && flyoutRef.current.contains(t)) return;
314
+ if (t instanceof Element && t.closest(`[data-flyout-for="${flyout.id}"]`)) return;
315
+ setFlyout(null);
316
+ };
317
+ document.addEventListener("keydown", onKey);
318
+ document.addEventListener("mousedown", onDown);
319
+ return () => {
320
+ document.removeEventListener("keydown", onKey);
321
+ document.removeEventListener("mousedown", onDown);
322
+ };
323
+ }, [flyout]);
324
+ hooks.useEffect(() => {
325
+ if (!railed) setFlyout(null);
326
+ }, [railed]);
327
+ const close = () => {
328
+ setFlyout(null);
329
+ p.onClose?.();
330
+ };
331
+ const leaf = (it, sub = false) => {
271
332
  const active = isActivePath(it.href, p.currentPath);
272
333
  const badge = badgeLabel(it.badge);
273
334
  return /* @__PURE__ */ jsxRuntime.jsxs(
@@ -276,12 +337,13 @@ function Sidebar(p) {
276
337
  href: it.href,
277
338
  onNavigate: p.onNavigate,
278
339
  onAfter: close,
279
- class: "bas-item" + (active ? " is-active" : ""),
340
+ class: "bas-item" + (sub ? " bas-item--sub" : "") + (active ? " is-active" : ""),
280
341
  "data-testid": `sidebar-item-${it.id}`,
281
342
  "aria-current": active ? "page" : void 0,
343
+ "aria-label": railed && !sub ? it.label : void 0,
282
344
  title: it.label,
283
345
  children: [
284
- active ? /* @__PURE__ */ jsxRuntime.jsx("span", { class: "bas-item__bar" }) : null,
346
+ active && !sub ? /* @__PURE__ */ jsxRuntime.jsx("span", { class: "bas-item__bar" }) : null,
285
347
  it.icon ? /* @__PURE__ */ jsxRuntime.jsx("span", { class: "bas-item__icon", children: it.icon }) : null,
286
348
  /* @__PURE__ */ jsxRuntime.jsx("span", { class: "bas-item__label", children: it.label }),
287
349
  badge ? /* @__PURE__ */ jsxRuntime.jsx("span", { class: "bas-item__count", "data-testid": `sidebar-badge-${it.id}`, children: badge }) : null
@@ -290,22 +352,84 @@ function Sidebar(p) {
290
352
  it.id
291
353
  );
292
354
  };
355
+ const parent = (it) => {
356
+ const children = it.children;
357
+ const onPage = childActive(it) || isActivePath(it.href, p.currentPath);
358
+ const open = railed ? flyout?.id === it.id : openItems.has(it.id);
359
+ const onClick = (e) => {
360
+ if (!railed) return toggleItem(it.id);
361
+ if (flyout?.id === it.id) return setFlyout(null);
362
+ const r = e.currentTarget.getBoundingClientRect();
363
+ setFlyout({ id: it.id, top: r.top, left: r.right + 6 });
364
+ };
365
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { class: "bas-itemgroup", children: [
366
+ /* @__PURE__ */ jsxRuntime.jsxs(
367
+ "button",
368
+ {
369
+ type: "button",
370
+ class: "bas-item bas-item--parent" + (onPage ? " is-parent-active" : ""),
371
+ "data-testid": `sidebar-item-${it.id}-toggle`,
372
+ "data-flyout-for": it.id,
373
+ "aria-expanded": open,
374
+ "aria-label": railed ? it.label : void 0,
375
+ title: it.label,
376
+ onClick,
377
+ children: [
378
+ onPage && railed ? /* @__PURE__ */ jsxRuntime.jsx("span", { class: "bas-item__bar" }) : null,
379
+ it.icon ? /* @__PURE__ */ jsxRuntime.jsx("span", { class: "bas-item__icon", children: it.icon }) : null,
380
+ /* @__PURE__ */ jsxRuntime.jsx("span", { class: "bas-item__label", children: it.label }),
381
+ /* @__PURE__ */ jsxRuntime.jsx("span", { class: "bas-item__chev", children: /* @__PURE__ */ jsxRuntime.jsx(IconChevron, { open }) })
382
+ ]
383
+ }
384
+ ),
385
+ !railed && open ? /* @__PURE__ */ jsxRuntime.jsx("div", { class: "bas-sub", children: children.map((c) => leaf(c, true)) }) : null,
386
+ railed && flyout?.id === it.id ? /* @__PURE__ */ jsxRuntime.jsxs(
387
+ "div",
388
+ {
389
+ ref: flyoutRef,
390
+ class: "bas-flyout",
391
+ "data-testid": `sidebar-flyout-${it.id}`,
392
+ role: "menu",
393
+ "aria-label": it.label,
394
+ style: { top: `${flyout.top}px`, left: `${flyout.left}px` },
395
+ children: [
396
+ /* @__PURE__ */ jsxRuntime.jsx("div", { class: "bas-flyout__title", children: it.label }),
397
+ children.map((c) => leaf(c, true))
398
+ ]
399
+ }
400
+ ) : null
401
+ ] }, it.id);
402
+ };
403
+ const item = (it) => it.children?.length ? parent(it) : leaf(it);
293
404
  return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
294
405
  mode === "drawer" ? /* @__PURE__ */ jsxRuntime.jsx("div", { class: "bas-backdrop" + (p.mobileOpen ? " is-open" : ""), "data-testid": "sidebar-backdrop", onClick: close }) : null,
295
406
  /* @__PURE__ */ jsxRuntime.jsxs(
296
407
  "aside",
297
408
  {
298
- class: `bas-sidebar is-mobile-${mode}` + (p.mobileOpen ? " is-open" : "") + (p.collapsed ? " is-collapsed" : ""),
409
+ class: `bas-sidebar is-mobile-${mode}` + (p.mobileOpen ? " is-open" : "") + (p.collapsed ? " is-collapsed" : "") + (railed ? " is-railed" : ""),
299
410
  "data-testid": "sidebar-root",
300
411
  "data-mobile": mode,
412
+ "data-collapse": collapseMode,
301
413
  "aria-label": "Navigation",
302
414
  children: [
303
- /* @__PURE__ */ jsxRuntime.jsxs("div", { class: "bas-sidebar__head", children: [
304
- p.header ? /* @__PURE__ */ jsxRuntime.jsx("div", { class: "bas-sidebar__brand", children: p.header }) : /* @__PURE__ */ jsxRuntime.jsx("span", {}),
305
- 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
306
- ] }),
415
+ p.header || p.brandMark ? /* @__PURE__ */ jsxRuntime.jsxs("div", { class: "bas-sidebar__head", children: [
416
+ p.header ? /* @__PURE__ */ jsxRuntime.jsx("div", { class: "bas-sidebar__brand", children: p.homeHref ? /* @__PURE__ */ jsxRuntime.jsx(NavLink, { href: p.homeHref, onNavigate: p.onNavigate, onAfter: close, class: "bas-home", "data-testid": "sidebar-brand-home", children: p.header }) : p.header }) : null,
417
+ p.brandMark ? /* @__PURE__ */ jsxRuntime.jsx("div", { class: "bas-sidebar__mark", "data-testid": "sidebar-brand-mark", children: p.homeHref ? /* @__PURE__ */ jsxRuntime.jsx(
418
+ NavLink,
419
+ {
420
+ href: p.homeHref,
421
+ onNavigate: p.onNavigate,
422
+ onAfter: close,
423
+ class: "bas-home",
424
+ "data-testid": "brand-mark-home",
425
+ "aria-label": p.homeLabel ?? TEXT[p.lang].home,
426
+ title: p.homeLabel ?? TEXT[p.lang].home,
427
+ children: p.brandMark
428
+ }
429
+ ) : p.brandMark }) : null
430
+ ] }) : null,
307
431
  /* @__PURE__ */ jsxRuntime.jsx("nav", { class: "bas-sidebar__nav", children: p.groups.map((g) => {
308
- const open = !folded.has(g.label);
432
+ const open = railed || !folded.has(g.label);
309
433
  const slug = g.label.toLowerCase().replace(/[^a-z0-9]+/g, "-");
310
434
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { class: "bas-group", children: [
311
435
  /* @__PURE__ */ jsxRuntime.jsxs("button", { type: "button", class: "bas-group__label", "data-testid": `sidebar-group-${slug}`, "aria-expanded": open, onClick: () => toggleGroup(g.label), children: [
@@ -593,7 +717,7 @@ function TopBar(p) {
593
717
  const t = TEXT[p.lang];
594
718
  return /* @__PURE__ */ jsxRuntime.jsx("header", { class: "bas-topbar", "data-testid": "topbar-root", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { class: "bas-topbar__inner", children: [
595
719
  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,
596
- p.brand ? /* @__PURE__ */ jsxRuntime.jsx("div", { class: "bas-topbar__brand", children: p.brand }) : null,
720
+ p.brand ? /* @__PURE__ */ jsxRuntime.jsx("div", { class: "bas-topbar__brand", children: p.homeHref ? /* @__PURE__ */ jsxRuntime.jsx(NavLink, { href: p.homeHref, onNavigate: p.onNavigate, onAfter: p.onHome, class: "bas-home", "data-testid": "brand-home", children: p.brand }) : p.brand }) : null,
597
721
  p.actions ? /* @__PURE__ */ jsxRuntime.jsx("div", { class: "bas-topbar__actions", children: p.actions }) : null,
598
722
  /* @__PURE__ */ jsxRuntime.jsx("div", { class: "bas-spacer" }),
599
723
  /* @__PURE__ */ jsxRuntime.jsx(NotificationBell, { source: p.notifications, onNavigate: p.onNavigate, lang: p.lang }),
@@ -604,7 +728,7 @@ function PageHeader(p) {
604
728
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { class: "bas-pagehead", "data-testid": "page-header", children: [
605
729
  /* @__PURE__ */ jsxRuntime.jsxs("div", { class: "bas-pagehead__text", children: [
606
730
  p.eyebrow ? /* @__PURE__ */ jsxRuntime.jsx("div", { class: "bas-pagehead__eyebrow", children: p.eyebrow }) : null,
607
- /* @__PURE__ */ jsxRuntime.jsx("h1", { class: "bas-pagehead__title", children: p.title }),
731
+ p.title != null && p.title !== "" ? /* @__PURE__ */ jsxRuntime.jsx("h1", { class: "bas-pagehead__title", children: p.title }) : null,
608
732
  p.description ? /* @__PURE__ */ jsxRuntime.jsx("p", { class: "bas-pagehead__desc", children: p.description }) : null
609
733
  ] }),
610
734
  p.actions ? /* @__PURE__ */ jsxRuntime.jsx("div", { class: "bas-pagehead__actions", children: p.actions }) : null
@@ -628,21 +752,29 @@ function PageTabs(p) {
628
752
  function AppShell(p) {
629
753
  const prefix = p.storageKey ?? "broberg-app-shell";
630
754
  const mode = p.mobile ?? "drawer";
755
+ const collapse = p.collapse ?? (mode === "rail" ? "icon" : "offcanvas");
631
756
  const [collapsed, setCollapsed] = hooks.useState(() => readFlag(`${prefix}.collapsed`, false));
632
757
  const [drawer, setDrawer] = hooks.useState(false);
758
+ const isMobile = useIsMobile();
633
759
  const t = TEXT[p.lang];
634
760
  const toggle = () => {
635
761
  const next = !collapsed;
636
762
  setCollapsed(next);
637
763
  writeFlag(`${prefix}.collapsed`, next);
638
764
  };
765
+ const drawerMode = isMobile && mode === "drawer";
766
+ const onTrigger = () => drawerMode ? setDrawer(!drawer) : toggle();
767
+ const expanded = drawerMode ? drawer : !collapsed;
768
+ const triggerLabel = drawerMode ? drawer ? t.closeMenu : t.openMenu : collapsed ? t.expand : t.collapse;
769
+ const heading = p.title ?? activeNavLabel(p.groups, p.footer, p.currentPath);
639
770
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { class: "bas-root", "data-testid": "app-shell", "data-mobile": mode, children: [
640
771
  /* @__PURE__ */ jsxRuntime.jsx(
641
772
  TopBar,
642
773
  {
643
774
  brand: p.brand,
775
+ homeHref: p.homeHref,
776
+ onHome: () => setDrawer(false),
644
777
  actions: p.actions,
645
- onOpenMenu: mode === "drawer" ? () => setDrawer(true) : void 0,
646
778
  notifications: p.notifications,
647
779
  user: p.user,
648
780
  userMenu: p.userMenu,
@@ -659,17 +791,41 @@ function AppShell(p) {
659
791
  currentPath: p.currentPath,
660
792
  onNavigate: p.onNavigate,
661
793
  collapsed,
662
- onToggleCollapse: toggle,
794
+ collapse,
795
+ brandMark: p.brandMark,
796
+ homeHref: p.homeHref,
797
+ homeLabel: p.homeLabel,
663
798
  mobileOpen: drawer,
664
799
  onClose: () => setDrawer(false),
665
800
  mobile: mode,
666
801
  header: mode === "drawer" ? p.brand : void 0,
667
802
  groupsKey: `${prefix}.groups`,
803
+ itemsKey: `${prefix}.items`,
668
804
  lang: p.lang
669
805
  }
670
806
  ),
671
- 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,
672
- /* @__PURE__ */ jsxRuntime.jsx("main", { class: "bas-main", "data-testid": "app-content", children: p.children })
807
+ /* @__PURE__ */ jsxRuntime.jsxs("main", { class: "bas-main", "data-testid": "app-content", children: [
808
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { class: "bas-contenthead", "data-testid": "content-header", children: [
809
+ /* @__PURE__ */ jsxRuntime.jsx(
810
+ "button",
811
+ {
812
+ type: "button",
813
+ class: "bas-iconbtn bas-trigger",
814
+ "data-testid": "sidebar-trigger",
815
+ "aria-label": triggerLabel,
816
+ "aria-expanded": expanded,
817
+ title: triggerLabel,
818
+ onClick: onTrigger,
819
+ children: /* @__PURE__ */ jsxRuntime.jsx(IconPanelLeft, {})
820
+ }
821
+ ),
822
+ heading ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
823
+ /* @__PURE__ */ jsxRuntime.jsx("span", { class: "bas-contenthead__sep", "data-testid": "content-header-separator", "aria-hidden": "true" }),
824
+ /* @__PURE__ */ jsxRuntime.jsx("h1", { class: "bas-contenthead__title", "data-testid": "content-header-title", children: heading })
825
+ ] }) : null
826
+ ] }),
827
+ p.children
828
+ ] })
673
829
  ] })
674
830
  ] });
675
831
  }
@@ -683,6 +839,7 @@ exports.Sidebar = Sidebar;
683
839
  exports.TEXT = TEXT;
684
840
  exports.TopBar = TopBar;
685
841
  exports.UserMenu = UserMenu;
842
+ exports.activeNavLabel = activeNavLabel;
686
843
  exports.badgeLabel = badgeLabel;
687
844
  exports.createFetchNotificationSource = createFetchNotificationSource;
688
845
  exports.createMemoryNotificationSource = createMemoryNotificationSource;