@broberg/app-shell 0.6.0 → 0.7.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.cts CHANGED
@@ -209,6 +209,28 @@ declare function copenhagenDay(at?: Date): string;
209
209
  declare function fillDays(series: AnalyticsDay[], days: number, now?: Date): AnalyticsDay[];
210
210
  /** Whole-percent change against the previous period, or null when there is nothing to compare with. */
211
211
  declare function changePct(now: number, before: number): number | null;
212
+ interface TenantMembershipRow {
213
+ tenant: {
214
+ id: string;
215
+ slug: string;
216
+ name: string;
217
+ status?: "active" | "suspended";
218
+ };
219
+ role: string;
220
+ }
221
+ interface TenantAdapter {
222
+ load(): Promise<TenantMembershipRow[]>;
223
+ }
224
+ declare class TenantLoadError extends Error {
225
+ readonly status: number;
226
+ constructor(status: number);
227
+ }
228
+ /** GET {url} (default /api/me/memberships) → the user's memberships. Non-2xx or a bad body THROWS. */
229
+ declare function createFetchTenantAdapter(opts?: {
230
+ url?: string;
231
+ fetch?: typeof fetch;
232
+ credentials?: RequestCredentials;
233
+ }): TenantAdapter;
212
234
  declare const TEXT: {
213
235
  readonly da: {
214
236
  readonly openMenu: "Åbn menu";
@@ -271,6 +293,12 @@ declare const TEXT: {
271
293
  readonly analyticsLoading: "Henter tal…";
272
294
  readonly analyticsUnconfigured: "Analytics er ikke sat op for denne app.";
273
295
  readonly analyticsUnavailable: "Upmetrics svarer ikke lige nu. Prøv igen om lidt.";
296
+ readonly tenantSwitch: "Skift organisation";
297
+ readonly tenantLoadFailed: "Kunne ikke hente dine organisationer.";
298
+ readonly tenantSwitchFailed: "Kunne ikke skifte organisation. Du er stadig i {name}.";
299
+ readonly tenantRetry: "Prøv igen";
300
+ readonly tenantSuspended: "lukket";
301
+ readonly tenantActive: "aktiv";
274
302
  };
275
303
  readonly en: {
276
304
  readonly openMenu: "Open menu";
@@ -333,6 +361,12 @@ declare const TEXT: {
333
361
  readonly analyticsLoading: "Loading figures…";
334
362
  readonly analyticsUnconfigured: "Analytics is not set up for this app.";
335
363
  readonly analyticsUnavailable: "Upmetrics is not answering right now. Try again shortly.";
364
+ readonly tenantSwitch: "Switch organisation";
365
+ readonly tenantLoadFailed: "Could not load your organisations.";
366
+ readonly tenantSwitchFailed: "Could not switch organisation. You are still in {name}.";
367
+ readonly tenantRetry: "Try again";
368
+ readonly tenantSuspended: "suspended";
369
+ readonly tenantActive: "active";
336
370
  };
337
371
  };
338
372
  type Lang = keyof typeof TEXT;
@@ -563,5 +597,24 @@ interface AnalyticsPageProps {
563
597
  * as a quiet month.
564
598
  */
565
599
  declare function AnalyticsPage({ lang, adapter, periods, initialDays }: AnalyticsPageProps): JSX.Element;
600
+ interface TenantSwitcherProps {
601
+ lang: Lang;
602
+ /** The slug the app is in right now — the app knows it (from tenantMiddleware). */
603
+ activeSlug: string;
604
+ /** Default: `createFetchTenantAdapter()` → GET /api/me/memberships (@broberg/tenant/hono tenantRoutes). */
605
+ adapter?: TenantAdapter;
606
+ /**
607
+ * Switch through the APP's own route — navigate to `/t/<slug>/…`, or set the
608
+ * preference and reload. A rejection keeps the current organisation and shows
609
+ * why; the switcher never pretends a switch happened.
610
+ */
611
+ onSwitch: (slug: string) => Promise<void> | void;
612
+ }
613
+ /**
614
+ * The user's own organisations, the active one marked, and a switch. Only the
615
+ * memberships the backend returns are listed — never a typed or guessed slug.
616
+ * With one organisation it is a plain label, not a menu.
617
+ */
618
+ declare function TenantSwitcher({ lang, activeSlug, adapter, onSwitch }: TenantSwitcherProps): JSX.Element;
566
619
 
567
- export { AVATAR_MAX_BYTES, AVATAR_TYPES, type AccountAdapter, AccountError, AccountPage, type AccountPageProps, type AccountProfile, type AnalyticsAdapter, type AnalyticsData, type AnalyticsDay, AnalyticsError, AnalyticsPage, type AnalyticsPageProps, 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, accountErrorKind, activeNavLabel, analyticsErrorKind, badgeLabel, changePct, checkAvatarFile, copenhagenDay, createFetchAccountAdapter, createFetchAnalyticsAdapter, createFetchNotificationSource, createMemoryNotificationSource, defaultReauthHref, fillDays, initials, isActivePath, isExternal, readFlag, readSet, relativeTime, safeNavigate, useIsMobile, writeFlag, writeSet };
620
+ export { AVATAR_MAX_BYTES, AVATAR_TYPES, type AccountAdapter, AccountError, AccountPage, type AccountPageProps, type AccountProfile, type AnalyticsAdapter, type AnalyticsData, type AnalyticsDay, AnalyticsError, AnalyticsPage, type AnalyticsPageProps, 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, type TenantAdapter, TenantLoadError, type TenantMembershipRow, TenantSwitcher, type TenantSwitcherProps, TopBar, type TopBarProps, UserMenu, type UserMenuItem, type UserMenuProps, accountErrorKind, activeNavLabel, analyticsErrorKind, badgeLabel, changePct, checkAvatarFile, copenhagenDay, createFetchAccountAdapter, createFetchAnalyticsAdapter, createFetchNotificationSource, createFetchTenantAdapter, createMemoryNotificationSource, defaultReauthHref, fillDays, initials, isActivePath, isExternal, readFlag, readSet, relativeTime, safeNavigate, useIsMobile, writeFlag, writeSet };
package/dist/preact.d.ts CHANGED
@@ -209,6 +209,28 @@ declare function copenhagenDay(at?: Date): string;
209
209
  declare function fillDays(series: AnalyticsDay[], days: number, now?: Date): AnalyticsDay[];
210
210
  /** Whole-percent change against the previous period, or null when there is nothing to compare with. */
211
211
  declare function changePct(now: number, before: number): number | null;
212
+ interface TenantMembershipRow {
213
+ tenant: {
214
+ id: string;
215
+ slug: string;
216
+ name: string;
217
+ status?: "active" | "suspended";
218
+ };
219
+ role: string;
220
+ }
221
+ interface TenantAdapter {
222
+ load(): Promise<TenantMembershipRow[]>;
223
+ }
224
+ declare class TenantLoadError extends Error {
225
+ readonly status: number;
226
+ constructor(status: number);
227
+ }
228
+ /** GET {url} (default /api/me/memberships) → the user's memberships. Non-2xx or a bad body THROWS. */
229
+ declare function createFetchTenantAdapter(opts?: {
230
+ url?: string;
231
+ fetch?: typeof fetch;
232
+ credentials?: RequestCredentials;
233
+ }): TenantAdapter;
212
234
  declare const TEXT: {
213
235
  readonly da: {
214
236
  readonly openMenu: "Åbn menu";
@@ -271,6 +293,12 @@ declare const TEXT: {
271
293
  readonly analyticsLoading: "Henter tal…";
272
294
  readonly analyticsUnconfigured: "Analytics er ikke sat op for denne app.";
273
295
  readonly analyticsUnavailable: "Upmetrics svarer ikke lige nu. Prøv igen om lidt.";
296
+ readonly tenantSwitch: "Skift organisation";
297
+ readonly tenantLoadFailed: "Kunne ikke hente dine organisationer.";
298
+ readonly tenantSwitchFailed: "Kunne ikke skifte organisation. Du er stadig i {name}.";
299
+ readonly tenantRetry: "Prøv igen";
300
+ readonly tenantSuspended: "lukket";
301
+ readonly tenantActive: "aktiv";
274
302
  };
275
303
  readonly en: {
276
304
  readonly openMenu: "Open menu";
@@ -333,6 +361,12 @@ declare const TEXT: {
333
361
  readonly analyticsLoading: "Loading figures…";
334
362
  readonly analyticsUnconfigured: "Analytics is not set up for this app.";
335
363
  readonly analyticsUnavailable: "Upmetrics is not answering right now. Try again shortly.";
364
+ readonly tenantSwitch: "Switch organisation";
365
+ readonly tenantLoadFailed: "Could not load your organisations.";
366
+ readonly tenantSwitchFailed: "Could not switch organisation. You are still in {name}.";
367
+ readonly tenantRetry: "Try again";
368
+ readonly tenantSuspended: "suspended";
369
+ readonly tenantActive: "active";
336
370
  };
337
371
  };
338
372
  type Lang = keyof typeof TEXT;
@@ -563,5 +597,24 @@ interface AnalyticsPageProps {
563
597
  * as a quiet month.
564
598
  */
565
599
  declare function AnalyticsPage({ lang, adapter, periods, initialDays }: AnalyticsPageProps): JSX.Element;
600
+ interface TenantSwitcherProps {
601
+ lang: Lang;
602
+ /** The slug the app is in right now — the app knows it (from tenantMiddleware). */
603
+ activeSlug: string;
604
+ /** Default: `createFetchTenantAdapter()` → GET /api/me/memberships (@broberg/tenant/hono tenantRoutes). */
605
+ adapter?: TenantAdapter;
606
+ /**
607
+ * Switch through the APP's own route — navigate to `/t/<slug>/…`, or set the
608
+ * preference and reload. A rejection keeps the current organisation and shows
609
+ * why; the switcher never pretends a switch happened.
610
+ */
611
+ onSwitch: (slug: string) => Promise<void> | void;
612
+ }
613
+ /**
614
+ * The user's own organisations, the active one marked, and a switch. Only the
615
+ * memberships the backend returns are listed — never a typed or guessed slug.
616
+ * With one organisation it is a plain label, not a menu.
617
+ */
618
+ declare function TenantSwitcher({ lang, activeSlug, adapter, onSwitch }: TenantSwitcherProps): JSX.Element;
566
619
 
567
- export { AVATAR_MAX_BYTES, AVATAR_TYPES, type AccountAdapter, AccountError, AccountPage, type AccountPageProps, type AccountProfile, type AnalyticsAdapter, type AnalyticsData, type AnalyticsDay, AnalyticsError, AnalyticsPage, type AnalyticsPageProps, 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, accountErrorKind, activeNavLabel, analyticsErrorKind, badgeLabel, changePct, checkAvatarFile, copenhagenDay, createFetchAccountAdapter, createFetchAnalyticsAdapter, createFetchNotificationSource, createMemoryNotificationSource, defaultReauthHref, fillDays, initials, isActivePath, isExternal, readFlag, readSet, relativeTime, safeNavigate, useIsMobile, writeFlag, writeSet };
620
+ export { AVATAR_MAX_BYTES, AVATAR_TYPES, type AccountAdapter, AccountError, AccountPage, type AccountPageProps, type AccountProfile, type AnalyticsAdapter, type AnalyticsData, type AnalyticsDay, AnalyticsError, AnalyticsPage, type AnalyticsPageProps, 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, type TenantAdapter, TenantLoadError, type TenantMembershipRow, TenantSwitcher, type TenantSwitcherProps, TopBar, type TopBarProps, UserMenu, type UserMenuItem, type UserMenuProps, accountErrorKind, activeNavLabel, analyticsErrorKind, badgeLabel, changePct, checkAvatarFile, copenhagenDay, createFetchAccountAdapter, createFetchAnalyticsAdapter, createFetchNotificationSource, createFetchTenantAdapter, createMemoryNotificationSource, defaultReauthHref, fillDays, initials, isActivePath, isExternal, readFlag, readSet, relativeTime, safeNavigate, useIsMobile, writeFlag, writeSet };
package/dist/preact.js CHANGED
@@ -272,6 +272,32 @@ function fillDays(series, days, now = /* @__PURE__ */ new Date()) {
272
272
  function changePct(now, before) {
273
273
  return before === 0 ? null : Math.round((now - before) / before * 100);
274
274
  }
275
+ var TenantLoadError = class extends Error {
276
+ constructor(status) {
277
+ super(`HTTP ${status}`);
278
+ this.status = status;
279
+ this.name = "TenantLoadError";
280
+ }
281
+ status;
282
+ };
283
+ function createFetchTenantAdapter(opts = {}) {
284
+ return {
285
+ async load() {
286
+ let res;
287
+ try {
288
+ res = await (opts.fetch ?? fetch)(opts.url ?? "/api/me/memberships", { credentials: opts.credentials ?? "same-origin", headers: { accept: "application/json" } });
289
+ } catch {
290
+ throw new TenantLoadError(0);
291
+ }
292
+ const body = await res.json().catch(() => null);
293
+ if (!res.ok) throw new TenantLoadError(res.status);
294
+ if (!Array.isArray(body) || !body.every((r) => r && typeof r.role === "string" && r.tenant && typeof r.tenant.slug === "string")) {
295
+ throw new TenantLoadError(res.status);
296
+ }
297
+ return body;
298
+ }
299
+ };
300
+ }
275
301
  var TEXT = {
276
302
  da: {
277
303
  openMenu: "\xC5bn menu",
@@ -333,7 +359,13 @@ var TEXT = {
333
359
  analyticsEmpty: "Ingen bes\xF8g i perioden.",
334
360
  analyticsLoading: "Henter tal\u2026",
335
361
  analyticsUnconfigured: "Analytics er ikke sat op for denne app.",
336
- analyticsUnavailable: "Upmetrics svarer ikke lige nu. Pr\xF8v igen om lidt."
362
+ analyticsUnavailable: "Upmetrics svarer ikke lige nu. Pr\xF8v igen om lidt.",
363
+ tenantSwitch: "Skift organisation",
364
+ tenantLoadFailed: "Kunne ikke hente dine organisationer.",
365
+ tenantSwitchFailed: "Kunne ikke skifte organisation. Du er stadig i {name}.",
366
+ tenantRetry: "Pr\xF8v igen",
367
+ tenantSuspended: "lukket",
368
+ tenantActive: "aktiv"
337
369
  },
338
370
  en: {
339
371
  openMenu: "Open menu",
@@ -395,7 +427,13 @@ var TEXT = {
395
427
  analyticsEmpty: "No visits in this period.",
396
428
  analyticsLoading: "Loading figures\u2026",
397
429
  analyticsUnconfigured: "Analytics is not set up for this app.",
398
- analyticsUnavailable: "Upmetrics is not answering right now. Try again shortly."
430
+ analyticsUnavailable: "Upmetrics is not answering right now. Try again shortly.",
431
+ tenantSwitch: "Switch organisation",
432
+ tenantLoadFailed: "Could not load your organisations.",
433
+ tenantSwitchFailed: "Could not switch organisation. You are still in {name}.",
434
+ tenantRetry: "Try again",
435
+ tenantSuspended: "suspended",
436
+ tenantActive: "active"
399
437
  }
400
438
  };
401
439
  var MOBILE_QUERY = "(max-width: 767px)";
@@ -1312,7 +1350,96 @@ function AnalyticsSeries({ data, t, locale, num }) {
1312
1350
  ] })
1313
1351
  ] });
1314
1352
  }
1353
+ function TenantSwitcher({ lang, activeSlug, adapter, onSwitch }) {
1354
+ const t = TEXT[lang];
1355
+ const api = useMemo(() => adapter ?? createFetchTenantAdapter(), [adapter]);
1356
+ const [rows, setRows] = useState(null);
1357
+ const [loadFailed, setLoadFailed] = useState(false);
1358
+ const [open, setOpen] = useState(false);
1359
+ const [busy, setBusy] = useState(null);
1360
+ const [switchFailed, setSwitchFailed] = useState(false);
1361
+ const root = useRef(null);
1362
+ const load = () => {
1363
+ setLoadFailed(false);
1364
+ api.load().then(setRows, () => setLoadFailed(true));
1365
+ };
1366
+ useEffect(load, [api]);
1367
+ useEffect(() => {
1368
+ if (!open) return;
1369
+ const onDown = (e) => {
1370
+ if (root.current && !root.current.contains(e.target)) setOpen(false);
1371
+ };
1372
+ const onKey = (e) => e.key === "Escape" && setOpen(false);
1373
+ document.addEventListener("mousedown", onDown);
1374
+ document.addEventListener("keydown", onKey);
1375
+ return () => {
1376
+ document.removeEventListener("mousedown", onDown);
1377
+ document.removeEventListener("keydown", onKey);
1378
+ };
1379
+ }, [open]);
1380
+ const active = rows?.find((r) => r.tenant.slug === activeSlug);
1381
+ const label = active?.tenant.name ?? activeSlug;
1382
+ const pick = async (slug) => {
1383
+ if (slug === activeSlug || busy) return;
1384
+ setBusy(slug);
1385
+ setSwitchFailed(false);
1386
+ try {
1387
+ await onSwitch(slug);
1388
+ setOpen(false);
1389
+ } catch {
1390
+ setSwitchFailed(true);
1391
+ } finally {
1392
+ setBusy(null);
1393
+ }
1394
+ };
1395
+ if (rows && rows.length <= 1 && !loadFailed) {
1396
+ return /* @__PURE__ */ jsx("div", { class: "bas-tenant", "data-testid": "tenant-switcher", children: /* @__PURE__ */ jsx("span", { class: "bas-tenant__label", "data-testid": "tenant-switcher-label", children: label }) });
1397
+ }
1398
+ return /* @__PURE__ */ jsxs("div", { class: "bas-tenant", ref: root, "data-testid": "tenant-switcher", children: [
1399
+ /* @__PURE__ */ jsx(
1400
+ "button",
1401
+ {
1402
+ type: "button",
1403
+ class: "bas-tenant__btn" + (open ? " is-open" : ""),
1404
+ "aria-haspopup": "menu",
1405
+ "aria-expanded": open,
1406
+ "aria-label": t.tenantSwitch,
1407
+ onClick: () => setOpen((v) => !v),
1408
+ "data-testid": "tenant-switcher-button",
1409
+ children: /* @__PURE__ */ jsx("span", { class: "bas-tenant__label", "data-testid": "tenant-switcher-label", children: label })
1410
+ }
1411
+ ),
1412
+ open ? /* @__PURE__ */ jsxs("div", { class: "bas-menu bas-tenant__menu", role: "menu", "data-testid": "tenant-switcher-menu", children: [
1413
+ loadFailed ? /* @__PURE__ */ jsxs("div", { class: "bas-tenant__note", role: "alert", "data-testid": "tenant-switcher-load-error", children: [
1414
+ /* @__PURE__ */ jsx("span", { children: t.tenantLoadFailed }),
1415
+ /* @__PURE__ */ jsx("button", { type: "button", class: "bas-mi", onClick: load, "data-testid": "tenant-switcher-retry", children: t.tenantRetry })
1416
+ ] }) : null,
1417
+ switchFailed ? /* @__PURE__ */ jsx("p", { class: "bas-tenant__note is-error", role: "alert", "data-testid": "tenant-switcher-switch-error", children: t.tenantSwitchFailed.replace("{name}", label) }) : null,
1418
+ (rows ?? []).map((r) => {
1419
+ const isActive = r.tenant.slug === activeSlug;
1420
+ const suspended = r.tenant.status === "suspended";
1421
+ return /* @__PURE__ */ jsxs(
1422
+ "button",
1423
+ {
1424
+ type: "button",
1425
+ role: "menuitemradio",
1426
+ "aria-checked": isActive,
1427
+ class: "bas-mi bas-tenant__item" + (isActive ? " is-active" : ""),
1428
+ disabled: suspended || busy !== null,
1429
+ onClick: () => void pick(r.tenant.slug),
1430
+ "data-testid": `tenant-switcher-item-${r.tenant.slug}`,
1431
+ children: [
1432
+ /* @__PURE__ */ jsx("span", { class: "bas-tenant__name", children: r.tenant.name }),
1433
+ /* @__PURE__ */ jsx("span", { class: "bas-tenant__role", children: suspended ? t.tenantSuspended : isActive ? t.tenantActive : r.role })
1434
+ ]
1435
+ },
1436
+ r.tenant.id
1437
+ );
1438
+ })
1439
+ ] }) : null
1440
+ ] });
1441
+ }
1315
1442
 
1316
- export { AVATAR_MAX_BYTES, AVATAR_TYPES, AccountError, AccountPage, AnalyticsError, AnalyticsPage, AppShell, Avatar, NotificationBell, PageHeader, PageTabs, Sidebar, TEXT, TopBar, UserMenu, accountErrorKind, activeNavLabel, analyticsErrorKind, badgeLabel, changePct, checkAvatarFile, copenhagenDay, createFetchAccountAdapter, createFetchAnalyticsAdapter, createFetchNotificationSource, createMemoryNotificationSource, defaultReauthHref, fillDays, initials, isActivePath, isExternal, readFlag, readSet, relativeTime, safeNavigate, useIsMobile, writeFlag, writeSet };
1443
+ export { AVATAR_MAX_BYTES, AVATAR_TYPES, AccountError, AccountPage, AnalyticsError, AnalyticsPage, AppShell, Avatar, NotificationBell, PageHeader, PageTabs, Sidebar, TEXT, TenantLoadError, TenantSwitcher, TopBar, UserMenu, accountErrorKind, activeNavLabel, analyticsErrorKind, badgeLabel, changePct, checkAvatarFile, copenhagenDay, createFetchAccountAdapter, createFetchAnalyticsAdapter, createFetchNotificationSource, createFetchTenantAdapter, createMemoryNotificationSource, defaultReauthHref, fillDays, initials, isActivePath, isExternal, readFlag, readSet, relativeTime, safeNavigate, useIsMobile, writeFlag, writeSet };
1317
1444
  //# sourceMappingURL=preact.js.map
1318
1445
  //# sourceMappingURL=preact.js.map