@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.d.ts CHANGED
@@ -32,6 +32,12 @@ declare function isActivePath(href: string, currentPath: string): boolean;
32
32
  * http(s) URL is followed; anything else returns null and the row just closes.
33
33
  */
34
34
  declare function safeNavigate(target: string | null | undefined): string | null;
35
+ /**
36
+ * F092.10 — is this link leaving the app? An http(s) link to another origin.
37
+ * Paths, "#…", mailto:/tel: and the app's own origin are not. Owner decision
38
+ * D-d28547: every external link opens in a new tab.
39
+ */
40
+ declare function isExternal(href: string | null | undefined, origin?: string | undefined): boolean;
35
41
  /**
36
42
  * The label of the nav item the user is on, for the content header (F092.4).
37
43
  * Sub-items count, and the most specific match wins: on "/models/genesis" a
@@ -100,6 +106,8 @@ declare const TEXT: {
100
106
  readonly openMenu: "Åbn menu";
101
107
  readonly closeMenu: "Luk menu";
102
108
  readonly home: "Forside";
109
+ readonly language: "Sprog";
110
+ readonly couldNotMarkAll: "Kunne ikke markere alle som læst. Prøv igen.";
103
111
  readonly collapse: "Skjul sidemenu";
104
112
  readonly expand: "Vis sidemenu";
105
113
  readonly notifications: "Notifikationer";
@@ -126,6 +134,8 @@ declare const TEXT: {
126
134
  readonly openMenu: "Open menu";
127
135
  readonly closeMenu: "Close menu";
128
136
  readonly home: "Home";
137
+ readonly language: "Language";
138
+ readonly couldNotMarkAll: "Could not mark all as read. Try again.";
129
139
  readonly collapse: "Hide sidebar";
130
140
  readonly expand: "Show sidebar";
131
141
  readonly notifications: "Notifications";
@@ -179,6 +189,8 @@ interface SidebarProps {
179
189
  homeHref?: string;
180
190
  /** Accessible name of the brandMark link (it has no visible text). Default "Forside"/"Home". */
181
191
  homeLabel?: string;
192
+ /** data-testid of the brand link in the sidebar head. */
193
+ brandTestId?: string;
182
194
  /** Phone, drawer mode: is the drawer open. */
183
195
  mobileOpen?: boolean;
184
196
  onClose?: () => void;
@@ -222,6 +234,18 @@ interface UserMenuProps {
222
234
  onNavigate?: Navigate;
223
235
  /** Show Theme · Palette · Surfaces · Backdrop. Default true. */
224
236
  appearance?: boolean;
237
+ /**
238
+ * F092.11 — a «Sprog»/«Language» row with a segmented control, like the
239
+ * appearance rows. The app owns the language; the shell shows and reports it.
240
+ */
241
+ language?: {
242
+ value: string;
243
+ options: {
244
+ id: string;
245
+ label: string;
246
+ }[];
247
+ onChange: (id: string) => void;
248
+ };
225
249
  lang: Lang;
226
250
  }
227
251
  declare function Avatar({ user, size }: {
@@ -294,6 +318,12 @@ interface AppShellProps {
294
318
  collapse?: "offcanvas" | "icon";
295
319
  /** The app's logo icon for the top of the icon rail. */
296
320
  brandMark?: ComponentChildren;
321
+ /**
322
+ * F092.8 — "inset" (default, shadcn dashboard-01): no full-width top bar; the
323
+ * brand heads the sidebar, and the content's top row is sidebar button ·
324
+ * title · actions · bell · user menu. "topbar": the 0.2.x full-width bar.
325
+ */
326
+ layout?: "inset" | "topbar";
297
327
  /** F092.7 — the app's start page. Brand and brandMark become links to it; on a phone the click closes the drawer. */
298
328
  homeHref?: string;
299
329
  /** Accessible name for the brandMark link. Default "Forside"/"Home". */
@@ -311,4 +341,4 @@ interface AppShellProps {
311
341
  }
312
342
  declare function AppShell(p: AppShellProps): JSX.Element;
313
343
 
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 };
344
+ 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, isExternal, readFlag, readSet, relativeTime, safeNavigate, useIsMobile, writeFlag, writeSet };
package/dist/preact.js CHANGED
@@ -23,6 +23,18 @@ function safeNavigate(target) {
23
23
  return null;
24
24
  }
25
25
  }
26
+ function isExternal(href, origin = typeof location !== "undefined" ? location.origin : void 0) {
27
+ if (typeof href !== "string") return false;
28
+ const h = href.trim();
29
+ if (!origin) return /^(https?:)?\/\//i.test(h);
30
+ let u;
31
+ try {
32
+ u = new URL(h, origin);
33
+ } catch {
34
+ return false;
35
+ }
36
+ return (u.protocol === "http:" || u.protocol === "https:") && u.origin !== origin;
37
+ }
26
38
  function activeNavLabel(groups, footer, currentPath) {
27
39
  let best;
28
40
  const visit = (it) => {
@@ -172,6 +184,8 @@ var TEXT = {
172
184
  openMenu: "\xC5bn menu",
173
185
  closeMenu: "Luk menu",
174
186
  home: "Forside",
187
+ language: "Sprog",
188
+ couldNotMarkAll: "Kunne ikke markere alle som l\xE6st. Pr\xF8v igen.",
175
189
  collapse: "Skjul sidemenu",
176
190
  expand: "Vis sidemenu",
177
191
  notifications: "Notifikationer",
@@ -198,6 +212,8 @@ var TEXT = {
198
212
  openMenu: "Open menu",
199
213
  closeMenu: "Close menu",
200
214
  home: "Home",
215
+ language: "Language",
216
+ couldNotMarkAll: "Could not mark all as read. Try again.",
201
217
  collapse: "Hide sidebar",
202
218
  expand: "Show sidebar",
203
219
  notifications: "Notifications",
@@ -236,6 +252,9 @@ function useIsMobile() {
236
252
  }
237
253
  function NavLink(props) {
238
254
  const { href, onNavigate, onAfter, children, ...rest } = props;
255
+ if (isExternal(href)) {
256
+ return /* @__PURE__ */ jsx("a", { ...rest, href, target: "_blank", rel: "noopener noreferrer", onClick: () => onAfter?.(), children });
257
+ }
239
258
  return /* @__PURE__ */ jsx(
240
259
  "a",
241
260
  {
@@ -411,7 +430,7 @@ function Sidebar(p) {
411
430
  "aria-label": "Navigation",
412
431
  children: [
413
432
  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,
433
+ 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": p.brandTestId ?? "sidebar-brand-home", children: p.header }) : p.header }) : null,
415
434
  p.brandMark ? /* @__PURE__ */ jsx("div", { class: "bas-sidebar__mark", "data-testid": "sidebar-brand-mark", children: p.homeHref ? /* @__PURE__ */ jsx(
416
435
  NavLink,
417
436
  {
@@ -484,14 +503,42 @@ function NotificationBell({ source, onNavigate, lang }) {
484
503
  else void shell.refresh();
485
504
  });
486
505
  }, [shell, source]);
506
+ const bellRef = useRef(null);
507
+ const panelRef = useRef(null);
487
508
  useEffect(() => {
488
509
  if (!state.open) return;
489
510
  const onKey = (e) => {
490
- if (e.key === "Escape" && shell.escape() === "close") e.preventDefault();
511
+ if (e.key === "Escape" && shell.escape() === "close") {
512
+ e.preventDefault();
513
+ bellRef.current?.focus();
514
+ }
515
+ };
516
+ const onDown = (e) => {
517
+ const t2 = e.target;
518
+ if (t2 && (panelRef.current?.contains(t2) || bellRef.current?.contains(t2))) return;
519
+ shell.close();
491
520
  };
492
521
  document.addEventListener("keydown", onKey);
493
- return () => document.removeEventListener("keydown", onKey);
522
+ document.addEventListener("mousedown", onDown);
523
+ return () => {
524
+ document.removeEventListener("keydown", onKey);
525
+ document.removeEventListener("mousedown", onDown);
526
+ };
494
527
  }, [state.open, shell]);
528
+ const [markAllFailed, setMarkAllFailed] = useState(false);
529
+ useEffect(() => {
530
+ if (!state.open) setMarkAllFailed(false);
531
+ }, [state.open]);
532
+ const onMarkAll = async () => {
533
+ setMarkAllFailed(false);
534
+ try {
535
+ await shell.markAllSeen();
536
+ } catch {
537
+ setMarkAllFailed(true);
538
+ return;
539
+ }
540
+ shell.close();
541
+ };
495
542
  const label = badgeLabel(state.count);
496
543
  const onRow = async (n) => {
497
544
  shell.close();
@@ -500,7 +547,9 @@ function NotificationBell({ source, onNavigate, lang }) {
500
547
  await shell.refresh();
501
548
  }
502
549
  const target = safeNavigate(n.navigate);
503
- if (target) {
550
+ if (target && isExternal(target)) {
551
+ if (typeof window !== "undefined") window.open(target, "_blank", "noopener,noreferrer");
552
+ } else if (target) {
504
553
  if (onNavigate) onNavigate(target);
505
554
  else if (typeof window !== "undefined") window.location.assign(target);
506
555
  }
@@ -511,6 +560,7 @@ function NotificationBell({ source, onNavigate, lang }) {
511
560
  "button",
512
561
  {
513
562
  type: "button",
563
+ ref: bellRef,
514
564
  class: "bas-iconbtn",
515
565
  "data-testid": "topbar-notifications",
516
566
  "aria-label": t.notifications,
@@ -530,6 +580,7 @@ function NotificationBell({ source, onNavigate, lang }) {
530
580
  /* @__PURE__ */ jsxs(
531
581
  "div",
532
582
  {
583
+ ref: panelRef,
533
584
  class: "bas-panel " + (mobile ? "bas-panel--sheet" : "bas-panel--drop"),
534
585
  role: "dialog",
535
586
  "aria-label": shell.labels.panel,
@@ -537,8 +588,9 @@ function NotificationBell({ source, onNavigate, lang }) {
537
588
  children: [
538
589
  /* @__PURE__ */ jsxs("div", { class: "bas-panel__head", children: [
539
590
  /* @__PURE__ */ jsx("h3", { children: t.notifications }),
540
- /* @__PURE__ */ jsx("button", { type: "button", class: "bas-link", "data-testid": "notifications-mark-all", onClick: () => void shell.markAllSeen(), disabled: !state.count, children: shell.labels.markAll })
591
+ /* @__PURE__ */ jsx("button", { type: "button", class: "bas-link", "data-testid": "notifications-mark-all", onClick: () => void onMarkAll(), disabled: !state.count, children: shell.labels.markAll })
541
592
  ] }),
593
+ markAllFailed ? /* @__PURE__ */ jsx("p", { class: "bas-panel__note is-error", role: "alert", "data-testid": "notifications-mark-all-error", children: t.couldNotMarkAll }) : null,
542
594
  /* @__PURE__ */ jsx("div", { class: "bas-panel__list", "data-testid": "notifications-list", children: error && state.rows.length === 0 ? /* @__PURE__ */ jsx("p", { class: "bas-panel__note is-error", "data-testid": "notifications-error", children: t.couldNotLoad }) : state.rows.length === 0 ? /* @__PURE__ */ jsx("p", { class: "bas-panel__note", "data-testid": "notifications-empty", children: shell.labels.empty }) : state.rows.map((n) => /* @__PURE__ */ jsxs(
543
595
  "button",
544
596
  {
@@ -573,7 +625,16 @@ function Avatar({ user, size = 28 }) {
573
625
  return /* @__PURE__ */ jsx("span", { class: "bas-avatar bas-avatar--initials", style, "data-testid": "avatar-initials", "aria-hidden": "true", children: initials(user.name, user.email) });
574
626
  }
575
627
  function Segmented(props) {
576
- return /* @__PURE__ */ jsx("div", { class: "bas-seg", "data-testid": props.testid, role: "group", children: props.options.map(([v, label]) => /* @__PURE__ */ jsx("button", { type: "button", "aria-pressed": props.value === v, "data-testid": `${props.testid}-${v}`, onClick: () => props.onChange(v), children: label }, v)) });
628
+ const onKeyDown = (e) => {
629
+ if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
630
+ const buttons = [...e.currentTarget.querySelectorAll("button")];
631
+ const i = buttons.indexOf(document.activeElement);
632
+ if (i < 0) return;
633
+ e.preventDefault();
634
+ const next = (i + (e.key === "ArrowRight" ? 1 : -1) + buttons.length) % buttons.length;
635
+ buttons[next].focus();
636
+ };
637
+ return /* @__PURE__ */ jsx("div", { class: "bas-seg", "data-testid": props.testid, role: "group", "aria-label": props.label, onKeyDown, children: props.options.map(([v, label]) => /* @__PURE__ */ jsx("button", { type: "button", "aria-pressed": props.value === v, "data-testid": `${props.testid}-${v}`, onClick: () => props.onChange(v), children: label }, v)) });
577
638
  }
578
639
  function UserMenu(p) {
579
640
  const t = TEXT[p.lang];
@@ -642,7 +703,7 @@ function UserMenu(p) {
642
703
  ] })
643
704
  ] }),
644
705
  /* @__PURE__ */ jsx("div", { class: "bas-sep" }),
645
- p.accountHref ? /* @__PURE__ */ jsxs("a", { class: "bas-mi", href: p.accountHref, "data-testid": "user-menu-account", role: "menuitem", children: [
706
+ p.accountHref ? /* @__PURE__ */ jsxs(NavLink, { href: p.accountHref, onNavigate: p.onNavigate, onAfter: () => setOpen(false), class: "bas-mi", "data-testid": "user-menu-account", role: "menuitem", children: [
646
707
  /* @__PURE__ */ jsx(IconUser, {}),
647
708
  /* @__PURE__ */ jsx("span", { children: t.account })
648
709
  ] }) : null,
@@ -701,6 +762,22 @@ function UserMenu(p) {
701
762
  ] })
702
763
  ] })
703
764
  ] }) : null,
765
+ p.language ? /* @__PURE__ */ jsxs(Fragment, { children: [
766
+ p.appearance === false ? /* @__PURE__ */ jsx("div", { class: "bas-sep" }) : null,
767
+ /* @__PURE__ */ jsx("div", { class: "bas-prefs", children: /* @__PURE__ */ jsxs("div", { class: "bas-pref", children: [
768
+ /* @__PURE__ */ jsx("span", { children: t.language }),
769
+ /* @__PURE__ */ jsx(
770
+ Segmented,
771
+ {
772
+ testid: "user-menu-language",
773
+ label: t.language,
774
+ value: p.language.value,
775
+ options: p.language.options.map((o) => [o.id, o.label]),
776
+ onChange: p.language.onChange
777
+ }
778
+ )
779
+ ] }) })
780
+ ] }) : null,
704
781
  p.onSignOut ? /* @__PURE__ */ jsxs(Fragment, { children: [
705
782
  /* @__PURE__ */ jsx("div", { class: "bas-sep" }),
706
783
  /* @__PURE__ */ jsxs("button", { type: "button", class: "bas-mi is-danger", role: "menuitem", "data-testid": "topbar-sign-out", disabled: busy, onClick: () => void signOut(), children: [
@@ -750,6 +827,8 @@ function PageTabs(p) {
750
827
  function AppShell(p) {
751
828
  const prefix = p.storageKey ?? "broberg-app-shell";
752
829
  const mode = p.mobile ?? "drawer";
830
+ const layout = p.layout ?? "inset";
831
+ const inset = layout === "inset";
753
832
  const collapse = p.collapse ?? (mode === "rail" ? "icon" : "offcanvas");
754
833
  const [collapsed, setCollapsed] = useState(() => readFlag(`${prefix}.collapsed`, false));
755
834
  const [drawer, setDrawer] = useState(false);
@@ -765,8 +844,8 @@ function AppShell(p) {
765
844
  const expanded = drawerMode ? drawer : !collapsed;
766
845
  const triggerLabel = drawerMode ? drawer ? t.closeMenu : t.openMenu : collapsed ? t.expand : t.collapse;
767
846
  const heading = p.title ?? activeNavLabel(p.groups, p.footer, p.currentPath);
768
- return /* @__PURE__ */ jsxs("div", { class: "bas-root", "data-testid": "app-shell", "data-mobile": mode, children: [
769
- /* @__PURE__ */ jsx(
847
+ return /* @__PURE__ */ jsxs("div", { class: "bas-root", "data-testid": "app-shell", "data-mobile": mode, "data-layout": layout, children: [
848
+ inset ? null : /* @__PURE__ */ jsx(
770
849
  TopBar,
771
850
  {
772
851
  brand: p.brand,
@@ -796,7 +875,8 @@ function AppShell(p) {
796
875
  mobileOpen: drawer,
797
876
  onClose: () => setDrawer(false),
798
877
  mobile: mode,
799
- header: mode === "drawer" ? p.brand : void 0,
878
+ header: inset || mode === "drawer" ? p.brand : void 0,
879
+ brandTestId: inset ? "brand-home" : void 0,
800
880
  groupsKey: `${prefix}.groups`,
801
881
  itemsKey: `${prefix}.items`,
802
882
  lang: p.lang
@@ -820,6 +900,12 @@ function AppShell(p) {
820
900
  heading ? /* @__PURE__ */ jsxs(Fragment, { children: [
821
901
  /* @__PURE__ */ jsx("span", { class: "bas-contenthead__sep", "data-testid": "content-header-separator", "aria-hidden": "true" }),
822
902
  /* @__PURE__ */ jsx("h1", { class: "bas-contenthead__title", "data-testid": "content-header-title", children: heading })
903
+ ] }) : null,
904
+ inset ? /* @__PURE__ */ jsxs(Fragment, { children: [
905
+ /* @__PURE__ */ jsx("div", { class: "bas-spacer" }),
906
+ p.actions ? /* @__PURE__ */ jsx("div", { class: "bas-contenthead__actions", children: p.actions }) : null,
907
+ /* @__PURE__ */ jsx(NotificationBell, { source: p.notifications, onNavigate: p.onNavigate, lang: p.lang }),
908
+ p.user ? /* @__PURE__ */ jsx(UserMenu, { user: p.user, ...p.userMenu, onNavigate: p.onNavigate, lang: p.lang }) : null
823
909
  ] }) : null
824
910
  ] }),
825
911
  p.children
@@ -828,6 +914,6 @@ function AppShell(p) {
828
914
  ] });
829
915
  }
830
916
 
831
- export { AppShell, Avatar, NotificationBell, PageHeader, PageTabs, Sidebar, TEXT, TopBar, UserMenu, activeNavLabel, badgeLabel, createFetchNotificationSource, createMemoryNotificationSource, initials, isActivePath, readFlag, readSet, relativeTime, safeNavigate, useIsMobile, writeFlag, writeSet };
917
+ export { AppShell, Avatar, NotificationBell, PageHeader, PageTabs, Sidebar, TEXT, TopBar, UserMenu, activeNavLabel, badgeLabel, createFetchNotificationSource, createMemoryNotificationSource, initials, isActivePath, isExternal, readFlag, readSet, relativeTime, safeNavigate, useIsMobile, writeFlag, writeSet };
832
918
  //# sourceMappingURL=preact.js.map
833
919
  //# sourceMappingURL=preact.js.map