@broberg/app-shell 0.2.2 → 0.3.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.cjs CHANGED
@@ -25,6 +25,18 @@ function safeNavigate(target) {
25
25
  return null;
26
26
  }
27
27
  }
28
+ function isExternal(href, origin = typeof location !== "undefined" ? location.origin : void 0) {
29
+ if (typeof href !== "string") return false;
30
+ const h = href.trim();
31
+ if (!origin) return /^(https?:)?\/\//i.test(h);
32
+ let u;
33
+ try {
34
+ u = new URL(h, origin);
35
+ } catch {
36
+ return false;
37
+ }
38
+ return (u.protocol === "http:" || u.protocol === "https:") && u.origin !== origin;
39
+ }
28
40
  function activeNavLabel(groups, footer, currentPath) {
29
41
  let best;
30
42
  const visit = (it) => {
@@ -174,6 +186,8 @@ var TEXT = {
174
186
  openMenu: "\xC5bn menu",
175
187
  closeMenu: "Luk menu",
176
188
  home: "Forside",
189
+ language: "Sprog",
190
+ couldNotMarkAll: "Kunne ikke markere alle som l\xE6st. Pr\xF8v igen.",
177
191
  collapse: "Skjul sidemenu",
178
192
  expand: "Vis sidemenu",
179
193
  notifications: "Notifikationer",
@@ -200,6 +214,8 @@ var TEXT = {
200
214
  openMenu: "Open menu",
201
215
  closeMenu: "Close menu",
202
216
  home: "Home",
217
+ language: "Language",
218
+ couldNotMarkAll: "Could not mark all as read. Try again.",
203
219
  collapse: "Hide sidebar",
204
220
  expand: "Show sidebar",
205
221
  notifications: "Notifications",
@@ -238,6 +254,9 @@ function useIsMobile() {
238
254
  }
239
255
  function NavLink(props) {
240
256
  const { href, onNavigate, onAfter, children, ...rest } = props;
257
+ if (isExternal(href)) {
258
+ return /* @__PURE__ */ jsxRuntime.jsx("a", { ...rest, href, target: "_blank", rel: "noopener noreferrer", onClick: () => onAfter?.(), children });
259
+ }
241
260
  return /* @__PURE__ */ jsxRuntime.jsx(
242
261
  "a",
243
262
  {
@@ -413,7 +432,7 @@ function Sidebar(p) {
413
432
  "aria-label": "Navigation",
414
433
  children: [
415
434
  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,
435
+ 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": p.brandTestId ?? "sidebar-brand-home", children: p.header }) : p.header }) : null,
417
436
  p.brandMark ? /* @__PURE__ */ jsxRuntime.jsx("div", { class: "bas-sidebar__mark", "data-testid": "sidebar-brand-mark", children: p.homeHref ? /* @__PURE__ */ jsxRuntime.jsx(
418
437
  NavLink,
419
438
  {
@@ -486,14 +505,42 @@ function NotificationBell({ source, onNavigate, lang }) {
486
505
  else void shell$1.refresh();
487
506
  });
488
507
  }, [shell$1, source]);
508
+ const bellRef = hooks.useRef(null);
509
+ const panelRef = hooks.useRef(null);
489
510
  hooks.useEffect(() => {
490
511
  if (!state.open) return;
491
512
  const onKey = (e) => {
492
- if (e.key === "Escape" && shell$1.escape() === "close") e.preventDefault();
513
+ if (e.key === "Escape" && shell$1.escape() === "close") {
514
+ e.preventDefault();
515
+ bellRef.current?.focus();
516
+ }
517
+ };
518
+ const onDown = (e) => {
519
+ const t2 = e.target;
520
+ if (t2 && (panelRef.current?.contains(t2) || bellRef.current?.contains(t2))) return;
521
+ shell$1.close();
493
522
  };
494
523
  document.addEventListener("keydown", onKey);
495
- return () => document.removeEventListener("keydown", onKey);
524
+ document.addEventListener("mousedown", onDown);
525
+ return () => {
526
+ document.removeEventListener("keydown", onKey);
527
+ document.removeEventListener("mousedown", onDown);
528
+ };
496
529
  }, [state.open, shell$1]);
530
+ const [markAllFailed, setMarkAllFailed] = hooks.useState(false);
531
+ hooks.useEffect(() => {
532
+ if (!state.open) setMarkAllFailed(false);
533
+ }, [state.open]);
534
+ const onMarkAll = async () => {
535
+ setMarkAllFailed(false);
536
+ try {
537
+ await shell$1.markAllSeen();
538
+ } catch {
539
+ setMarkAllFailed(true);
540
+ return;
541
+ }
542
+ shell$1.close();
543
+ };
497
544
  const label = badgeLabel(state.count);
498
545
  const onRow = async (n) => {
499
546
  shell$1.close();
@@ -502,7 +549,9 @@ function NotificationBell({ source, onNavigate, lang }) {
502
549
  await shell$1.refresh();
503
550
  }
504
551
  const target = safeNavigate(n.navigate);
505
- if (target) {
552
+ if (target && isExternal(target)) {
553
+ if (typeof window !== "undefined") window.open(target, "_blank", "noopener,noreferrer");
554
+ } else if (target) {
506
555
  if (onNavigate) onNavigate(target);
507
556
  else if (typeof window !== "undefined") window.location.assign(target);
508
557
  }
@@ -513,6 +562,7 @@ function NotificationBell({ source, onNavigate, lang }) {
513
562
  "button",
514
563
  {
515
564
  type: "button",
565
+ ref: bellRef,
516
566
  class: "bas-iconbtn",
517
567
  "data-testid": "topbar-notifications",
518
568
  "aria-label": t.notifications,
@@ -532,6 +582,7 @@ function NotificationBell({ source, onNavigate, lang }) {
532
582
  /* @__PURE__ */ jsxRuntime.jsxs(
533
583
  "div",
534
584
  {
585
+ ref: panelRef,
535
586
  class: "bas-panel " + (mobile ? "bas-panel--sheet" : "bas-panel--drop"),
536
587
  role: "dialog",
537
588
  "aria-label": shell$1.labels.panel,
@@ -539,8 +590,9 @@ function NotificationBell({ source, onNavigate, lang }) {
539
590
  children: [
540
591
  /* @__PURE__ */ jsxRuntime.jsxs("div", { class: "bas-panel__head", children: [
541
592
  /* @__PURE__ */ jsxRuntime.jsx("h3", { children: t.notifications }),
542
- /* @__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 })
593
+ /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", class: "bas-link", "data-testid": "notifications-mark-all", onClick: () => void onMarkAll(), disabled: !state.count, children: shell$1.labels.markAll })
543
594
  ] }),
595
+ markAllFailed ? /* @__PURE__ */ jsxRuntime.jsx("p", { class: "bas-panel__note is-error", role: "alert", "data-testid": "notifications-mark-all-error", children: t.couldNotMarkAll }) : null,
544
596
  /* @__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(
545
597
  "button",
546
598
  {
@@ -575,7 +627,16 @@ function Avatar({ user, size = 28 }) {
575
627
  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) });
576
628
  }
577
629
  function Segmented(props) {
578
- 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)) });
630
+ const onKeyDown = (e) => {
631
+ if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
632
+ const buttons = [...e.currentTarget.querySelectorAll("button")];
633
+ const i = buttons.indexOf(document.activeElement);
634
+ if (i < 0) return;
635
+ e.preventDefault();
636
+ const next = (i + (e.key === "ArrowRight" ? 1 : -1) + buttons.length) % buttons.length;
637
+ buttons[next].focus();
638
+ };
639
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { class: "bas-seg", "data-testid": props.testid, role: "group", "aria-label": props.label, onKeyDown, 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)) });
579
640
  }
580
641
  function UserMenu(p) {
581
642
  const t = TEXT[p.lang];
@@ -644,7 +705,7 @@ function UserMenu(p) {
644
705
  ] })
645
706
  ] }),
646
707
  /* @__PURE__ */ jsxRuntime.jsx("div", { class: "bas-sep" }),
647
- p.accountHref ? /* @__PURE__ */ jsxRuntime.jsxs("a", { class: "bas-mi", href: p.accountHref, "data-testid": "user-menu-account", role: "menuitem", children: [
708
+ p.accountHref ? /* @__PURE__ */ jsxRuntime.jsxs(NavLink, { href: p.accountHref, onNavigate: p.onNavigate, onAfter: () => setOpen(false), class: "bas-mi", "data-testid": "user-menu-account", role: "menuitem", children: [
648
709
  /* @__PURE__ */ jsxRuntime.jsx(IconUser, {}),
649
710
  /* @__PURE__ */ jsxRuntime.jsx("span", { children: t.account })
650
711
  ] }) : null,
@@ -703,6 +764,22 @@ function UserMenu(p) {
703
764
  ] })
704
765
  ] })
705
766
  ] }) : null,
767
+ p.language ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
768
+ p.appearance === false ? /* @__PURE__ */ jsxRuntime.jsx("div", { class: "bas-sep" }) : null,
769
+ /* @__PURE__ */ jsxRuntime.jsx("div", { class: "bas-prefs", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { class: "bas-pref", children: [
770
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: t.language }),
771
+ /* @__PURE__ */ jsxRuntime.jsx(
772
+ Segmented,
773
+ {
774
+ testid: "user-menu-language",
775
+ label: t.language,
776
+ value: p.language.value,
777
+ options: p.language.options.map((o) => [o.id, o.label]),
778
+ onChange: p.language.onChange
779
+ }
780
+ )
781
+ ] }) })
782
+ ] }) : null,
706
783
  p.onSignOut ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
707
784
  /* @__PURE__ */ jsxRuntime.jsx("div", { class: "bas-sep" }),
708
785
  /* @__PURE__ */ jsxRuntime.jsxs("button", { type: "button", class: "bas-mi is-danger", role: "menuitem", "data-testid": "topbar-sign-out", disabled: busy, onClick: () => void signOut(), children: [
@@ -752,6 +829,8 @@ function PageTabs(p) {
752
829
  function AppShell(p) {
753
830
  const prefix = p.storageKey ?? "broberg-app-shell";
754
831
  const mode = p.mobile ?? "drawer";
832
+ const layout = p.layout ?? "inset";
833
+ const inset = layout === "inset";
755
834
  const collapse = p.collapse ?? (mode === "rail" ? "icon" : "offcanvas");
756
835
  const [collapsed, setCollapsed] = hooks.useState(() => readFlag(`${prefix}.collapsed`, false));
757
836
  const [drawer, setDrawer] = hooks.useState(false);
@@ -767,8 +846,8 @@ function AppShell(p) {
767
846
  const expanded = drawerMode ? drawer : !collapsed;
768
847
  const triggerLabel = drawerMode ? drawer ? t.closeMenu : t.openMenu : collapsed ? t.expand : t.collapse;
769
848
  const heading = p.title ?? activeNavLabel(p.groups, p.footer, p.currentPath);
770
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { class: "bas-root", "data-testid": "app-shell", "data-mobile": mode, children: [
771
- /* @__PURE__ */ jsxRuntime.jsx(
849
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { class: "bas-root", "data-testid": "app-shell", "data-mobile": mode, "data-layout": layout, children: [
850
+ inset ? null : /* @__PURE__ */ jsxRuntime.jsx(
772
851
  TopBar,
773
852
  {
774
853
  brand: p.brand,
@@ -798,7 +877,8 @@ function AppShell(p) {
798
877
  mobileOpen: drawer,
799
878
  onClose: () => setDrawer(false),
800
879
  mobile: mode,
801
- header: mode === "drawer" ? p.brand : void 0,
880
+ header: inset || mode === "drawer" ? p.brand : void 0,
881
+ brandTestId: inset ? "brand-home" : void 0,
802
882
  groupsKey: `${prefix}.groups`,
803
883
  itemsKey: `${prefix}.items`,
804
884
  lang: p.lang
@@ -822,6 +902,12 @@ function AppShell(p) {
822
902
  heading ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
823
903
  /* @__PURE__ */ jsxRuntime.jsx("span", { class: "bas-contenthead__sep", "data-testid": "content-header-separator", "aria-hidden": "true" }),
824
904
  /* @__PURE__ */ jsxRuntime.jsx("h1", { class: "bas-contenthead__title", "data-testid": "content-header-title", children: heading })
905
+ ] }) : null,
906
+ inset ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
907
+ /* @__PURE__ */ jsxRuntime.jsx("div", { class: "bas-spacer" }),
908
+ p.actions ? /* @__PURE__ */ jsxRuntime.jsx("div", { class: "bas-contenthead__actions", children: p.actions }) : null,
909
+ /* @__PURE__ */ jsxRuntime.jsx(NotificationBell, { source: p.notifications, onNavigate: p.onNavigate, lang: p.lang }),
910
+ p.user ? /* @__PURE__ */ jsxRuntime.jsx(UserMenu, { user: p.user, ...p.userMenu, onNavigate: p.onNavigate, lang: p.lang }) : null
825
911
  ] }) : null
826
912
  ] }),
827
913
  p.children
@@ -845,6 +931,7 @@ exports.createFetchNotificationSource = createFetchNotificationSource;
845
931
  exports.createMemoryNotificationSource = createMemoryNotificationSource;
846
932
  exports.initials = initials;
847
933
  exports.isActivePath = isActivePath;
934
+ exports.isExternal = isExternal;
848
935
  exports.readFlag = readFlag;
849
936
  exports.readSet = readSet;
850
937
  exports.relativeTime = relativeTime;