@broberg/app-shell 0.2.3 → 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
@@ -99,8 +105,9 @@ declare const TEXT: {
99
105
  readonly da: {
100
106
  readonly openMenu: "Åbn menu";
101
107
  readonly closeMenu: "Luk menu";
102
- readonly couldNotMarkAll: "Kunne ikke markere alle som læst. Prøv igen.";
103
108
  readonly home: "Forside";
109
+ readonly language: "Sprog";
110
+ readonly couldNotMarkAll: "Kunne ikke markere alle som læst. Prøv igen.";
104
111
  readonly collapse: "Skjul sidemenu";
105
112
  readonly expand: "Vis sidemenu";
106
113
  readonly notifications: "Notifikationer";
@@ -126,8 +133,9 @@ declare const TEXT: {
126
133
  readonly en: {
127
134
  readonly openMenu: "Open menu";
128
135
  readonly closeMenu: "Close menu";
129
- readonly couldNotMarkAll: "Could not mark all as read. Try again.";
130
136
  readonly home: "Home";
137
+ readonly language: "Language";
138
+ readonly couldNotMarkAll: "Could not mark all as read. Try again.";
131
139
  readonly collapse: "Hide sidebar";
132
140
  readonly expand: "Show sidebar";
133
141
  readonly notifications: "Notifications";
@@ -181,6 +189,8 @@ interface SidebarProps {
181
189
  homeHref?: string;
182
190
  /** Accessible name of the brandMark link (it has no visible text). Default "Forside"/"Home". */
183
191
  homeLabel?: string;
192
+ /** data-testid of the brand link in the sidebar head. */
193
+ brandTestId?: string;
184
194
  /** Phone, drawer mode: is the drawer open. */
185
195
  mobileOpen?: boolean;
186
196
  onClose?: () => void;
@@ -224,6 +234,18 @@ interface UserMenuProps {
224
234
  onNavigate?: Navigate;
225
235
  /** Show Theme · Palette · Surfaces · Backdrop. Default true. */
226
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
+ };
227
249
  lang: Lang;
228
250
  }
229
251
  declare function Avatar({ user, size }: {
@@ -296,6 +318,12 @@ interface AppShellProps {
296
318
  collapse?: "offcanvas" | "icon";
297
319
  /** The app's logo icon for the top of the icon rail. */
298
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";
299
327
  /** F092.7 — the app's start page. Brand and brandMark become links to it; on a phone the click closes the drawer. */
300
328
  homeHref?: string;
301
329
  /** Accessible name for the brandMark link. Default "Forside"/"Home". */
@@ -313,4 +341,4 @@ interface AppShellProps {
313
341
  }
314
342
  declare function AppShell(p: AppShellProps): JSX.Element;
315
343
 
316
- 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) => {
@@ -171,8 +183,9 @@ var TEXT = {
171
183
  da: {
172
184
  openMenu: "\xC5bn menu",
173
185
  closeMenu: "Luk menu",
174
- couldNotMarkAll: "Kunne ikke markere alle som l\xE6st. Pr\xF8v igen.",
175
186
  home: "Forside",
187
+ language: "Sprog",
188
+ couldNotMarkAll: "Kunne ikke markere alle som l\xE6st. Pr\xF8v igen.",
176
189
  collapse: "Skjul sidemenu",
177
190
  expand: "Vis sidemenu",
178
191
  notifications: "Notifikationer",
@@ -198,8 +211,9 @@ var TEXT = {
198
211
  en: {
199
212
  openMenu: "Open menu",
200
213
  closeMenu: "Close menu",
201
- couldNotMarkAll: "Could not mark all as read. Try again.",
202
214
  home: "Home",
215
+ language: "Language",
216
+ couldNotMarkAll: "Could not mark all as read. Try again.",
203
217
  collapse: "Hide sidebar",
204
218
  expand: "Show sidebar",
205
219
  notifications: "Notifications",
@@ -238,6 +252,9 @@ function useIsMobile() {
238
252
  }
239
253
  function NavLink(props) {
240
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
+ }
241
258
  return /* @__PURE__ */ jsx(
242
259
  "a",
243
260
  {
@@ -413,7 +430,7 @@ function Sidebar(p) {
413
430
  "aria-label": "Navigation",
414
431
  children: [
415
432
  p.header || p.brandMark ? /* @__PURE__ */ jsxs("div", { class: "bas-sidebar__head", children: [
416
- 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,
417
434
  p.brandMark ? /* @__PURE__ */ jsx("div", { class: "bas-sidebar__mark", "data-testid": "sidebar-brand-mark", children: p.homeHref ? /* @__PURE__ */ jsx(
418
435
  NavLink,
419
436
  {
@@ -530,7 +547,9 @@ function NotificationBell({ source, onNavigate, lang }) {
530
547
  await shell.refresh();
531
548
  }
532
549
  const target = safeNavigate(n.navigate);
533
- if (target) {
550
+ if (target && isExternal(target)) {
551
+ if (typeof window !== "undefined") window.open(target, "_blank", "noopener,noreferrer");
552
+ } else if (target) {
534
553
  if (onNavigate) onNavigate(target);
535
554
  else if (typeof window !== "undefined") window.location.assign(target);
536
555
  }
@@ -606,7 +625,16 @@ function Avatar({ user, size = 28 }) {
606
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) });
607
626
  }
608
627
  function Segmented(props) {
609
- 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)) });
610
638
  }
611
639
  function UserMenu(p) {
612
640
  const t = TEXT[p.lang];
@@ -675,7 +703,7 @@ function UserMenu(p) {
675
703
  ] })
676
704
  ] }),
677
705
  /* @__PURE__ */ jsx("div", { class: "bas-sep" }),
678
- 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: [
679
707
  /* @__PURE__ */ jsx(IconUser, {}),
680
708
  /* @__PURE__ */ jsx("span", { children: t.account })
681
709
  ] }) : null,
@@ -734,6 +762,22 @@ function UserMenu(p) {
734
762
  ] })
735
763
  ] })
736
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,
737
781
  p.onSignOut ? /* @__PURE__ */ jsxs(Fragment, { children: [
738
782
  /* @__PURE__ */ jsx("div", { class: "bas-sep" }),
739
783
  /* @__PURE__ */ jsxs("button", { type: "button", class: "bas-mi is-danger", role: "menuitem", "data-testid": "topbar-sign-out", disabled: busy, onClick: () => void signOut(), children: [
@@ -783,6 +827,8 @@ function PageTabs(p) {
783
827
  function AppShell(p) {
784
828
  const prefix = p.storageKey ?? "broberg-app-shell";
785
829
  const mode = p.mobile ?? "drawer";
830
+ const layout = p.layout ?? "inset";
831
+ const inset = layout === "inset";
786
832
  const collapse = p.collapse ?? (mode === "rail" ? "icon" : "offcanvas");
787
833
  const [collapsed, setCollapsed] = useState(() => readFlag(`${prefix}.collapsed`, false));
788
834
  const [drawer, setDrawer] = useState(false);
@@ -798,8 +844,8 @@ function AppShell(p) {
798
844
  const expanded = drawerMode ? drawer : !collapsed;
799
845
  const triggerLabel = drawerMode ? drawer ? t.closeMenu : t.openMenu : collapsed ? t.expand : t.collapse;
800
846
  const heading = p.title ?? activeNavLabel(p.groups, p.footer, p.currentPath);
801
- return /* @__PURE__ */ jsxs("div", { class: "bas-root", "data-testid": "app-shell", "data-mobile": mode, children: [
802
- /* @__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(
803
849
  TopBar,
804
850
  {
805
851
  brand: p.brand,
@@ -829,7 +875,8 @@ function AppShell(p) {
829
875
  mobileOpen: drawer,
830
876
  onClose: () => setDrawer(false),
831
877
  mobile: mode,
832
- header: mode === "drawer" ? p.brand : void 0,
878
+ header: inset || mode === "drawer" ? p.brand : void 0,
879
+ brandTestId: inset ? "brand-home" : void 0,
833
880
  groupsKey: `${prefix}.groups`,
834
881
  itemsKey: `${prefix}.items`,
835
882
  lang: p.lang
@@ -853,6 +900,12 @@ function AppShell(p) {
853
900
  heading ? /* @__PURE__ */ jsxs(Fragment, { children: [
854
901
  /* @__PURE__ */ jsx("span", { class: "bas-contenthead__sep", "data-testid": "content-header-separator", "aria-hidden": "true" }),
855
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
856
909
  ] }) : null
857
910
  ] }),
858
911
  p.children
@@ -861,6 +914,6 @@ function AppShell(p) {
861
914
  ] });
862
915
  }
863
916
 
864
- 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 };
865
918
  //# sourceMappingURL=preact.js.map
866
919
  //# sourceMappingURL=preact.js.map