@protobase/ui 0.45.9 → 0.45.11

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/app/app.js CHANGED
@@ -82,7 +82,7 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
82
82
  const user = state.kind === "signed-in" ? state.user : void 0;
83
83
  const staff = state.kind === "signed-in" ? state.staff : void 0;
84
84
  const { path, params, basePath } = useRouter();
85
- const { address, go, open } = useAppAddress();
85
+ const { address, go, open, back } = useAppAddress();
86
86
  const place = placeOf(address);
87
87
  const shown = recordOf(address);
88
88
  const search = useGlobalSearch();
@@ -248,6 +248,7 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
248
248
  onSwitch: role.switchTo
249
249
  } : void 0,
250
250
  onNavigate: open,
251
+ onBack: back,
251
252
  sidebarNotice: /* @__PURE__ */ jsx(NewVersionBanner, {}),
252
253
  banner: (staff || pluginNotice) && /* @__PURE__ */ jsxs(Fragment$1, { children: [staff && /* @__PURE__ */ jsx(StaffBanner, {
253
254
  signIn: staff,
@@ -0,0 +1,34 @@
1
+ import { guarded } from "../lib/guarded.js";
2
+ //#region src/app/page-places.ts
3
+ const storageKey = "protobase:page-places";
4
+ /** The places kept, the most recent ones, so the storage stays small however long the tab is used. */
5
+ const kept = 50;
6
+ const isPlace = (value) => Array.isArray(value) && value.length === 2 && typeof value[0] === "string" && typeof value[1] === "number" && Number.isFinite(value[1]) && value[1] >= 0;
7
+ const read = (storage) => guarded(() => {
8
+ const raw = storage.getItem(storageKey);
9
+ if (raw === null) return [];
10
+ const parsed = JSON.parse(raw);
11
+ return Array.isArray(parsed) ? parsed.filter(isPlace) : [];
12
+ }, []);
13
+ /**
14
+ * How far down the document was scrolled in each history entry of a tab, by the entry's id, kept in `storage` (the
15
+ * tab's session storage) when `persist` is called, so they outlast a reload of the page. An entry never left yet, as
16
+ * one a link just made, starts at the top.
17
+ */
18
+ const createPagePlaces = (storage) => {
19
+ const places = new Map(read(storage));
20
+ const placeOf = (entry) => places.get(entry) ?? 0;
21
+ const save = (entry, top) => {
22
+ places.delete(entry);
23
+ places.set(entry, Math.max(0, Math.round(top)));
24
+ for (const oldest of [...places.keys()].slice(0, Math.max(0, places.size - kept))) places.delete(oldest);
25
+ };
26
+ const persist = () => guarded(() => storage.setItem(storageKey, JSON.stringify([...places])), void 0);
27
+ return {
28
+ placeOf,
29
+ save,
30
+ persist
31
+ };
32
+ };
33
+ //#endregion
34
+ export { createPagePlaces };
@@ -0,0 +1,33 @@
1
+ //#region src/app/restore-scroll.ts
2
+ /** How long a page may take to grow tall enough, as while its data loads, before it is left where it is. */
3
+ const patience = 3e3;
4
+ /**
5
+ * Scrolls the document to `top`. A page still too short for it, as one whose data is loading, is scrolled again each
6
+ * time it grows, until it is there, a few seconds pass, or anything else scrolls it: the person, or a list that brings
7
+ * itself back to its place. Returns how to stop early.
8
+ */
9
+ const restoreScroll = (top) => {
10
+ let placed = 0;
11
+ const place = () => {
12
+ window.scrollTo({
13
+ top,
14
+ behavior: "instant"
15
+ });
16
+ placed = window.scrollY;
17
+ return placed >= top - 1;
18
+ };
19
+ if (place()) return () => {};
20
+ const observer = new ResizeObserver(() => place() && stop());
21
+ const moved = () => Math.abs(window.scrollY - placed) > 1 && stop();
22
+ const stop = () => {
23
+ observer.disconnect();
24
+ clearTimeout(timer);
25
+ window.removeEventListener("scroll", moved);
26
+ };
27
+ const timer = setTimeout(stop, patience);
28
+ observer.observe(document.body);
29
+ window.addEventListener("scroll", moved, { passive: true });
30
+ return stop;
31
+ };
32
+ //#endregion
33
+ export { restoreScroll };
@@ -1,8 +1,11 @@
1
1
  import { RouteLink } from "../primitives/route-link.js";
2
+ import { guarded } from "../lib/guarded.js";
2
3
  import { Button } from "../primitives/button.js";
3
4
  import { Dialog } from "../primitives/dialog.js";
4
5
  import { text } from "./messages.js";
5
- import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
6
+ import { createPagePlaces } from "./page-places.js";
7
+ import { restoreScroll } from "./restore-scroll.js";
8
+ import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
6
9
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
7
10
  //#region src/app/router.tsx
8
11
  const RouterContext = createContext(null);
@@ -28,12 +31,17 @@ const historyEntry = () => {
28
31
  }, "");
29
32
  return entry;
30
33
  };
34
+ const noStorage = {
35
+ getItem: () => null,
36
+ setItem: () => {}
37
+ };
31
38
  const Router = ({ basePath = "", initialUrl, children }) => {
32
39
  const memory = initialUrl !== void 0;
33
40
  const [page, setPage] = useState(() => ({
34
41
  url: initialUrl ?? browserUrl(),
35
42
  entry: memory ? newEntry() : historyEntry()
36
43
  }));
44
+ const [places] = useState(() => createPagePlaces(memory ? noStorage : guarded(() => window.sessionStorage, void 0) ?? noStorage));
37
45
  const guards = useRef(/* @__PURE__ */ new Map());
38
46
  const losesInput = () => [...guards.current.values()].includes("input-lost");
39
47
  const current = useRef(page);
@@ -44,12 +52,23 @@ const Router = ({ basePath = "", initialUrl, children }) => {
44
52
  const leaveAfterUndo = useRef(void 0);
45
53
  const reloading = useRef(false);
46
54
  const [pending, setPending] = useState();
55
+ const rememberScroll = useCallback(() => places.save(current.current.entry, window.scrollY), [places]);
56
+ useLayoutEffect(() => restoreScroll(places.placeOf(page.entry)), [places, page.entry]);
47
57
  useEffect(() => {
48
58
  if (memory) return;
59
+ window.history.scrollRestoration = "manual";
49
60
  window.history.replaceState({
61
+ ...historyState(),
50
62
  protobaseIndex: position.current,
51
63
  protobaseEntry: current.current.entry
52
64
  }, "");
65
+ const persist = () => {
66
+ rememberScroll();
67
+ places.persist();
68
+ };
69
+ const hidden = () => document.visibilityState === "hidden" && persist();
70
+ window.addEventListener("pagehide", persist);
71
+ document.addEventListener("visibilitychange", hidden);
53
72
  const onPop = () => {
54
73
  const next = historyIndex();
55
74
  if (undoing.current) {
@@ -73,28 +92,40 @@ const Router = ({ basePath = "", initialUrl, children }) => {
73
92
  }
74
93
  proceeding.current = false;
75
94
  position.current = next;
95
+ rememberScroll();
76
96
  setPage({
77
97
  url: browserUrl(),
78
98
  entry: historyEntry()
79
99
  });
80
100
  };
81
101
  window.addEventListener("popstate", onPop);
82
- return () => window.removeEventListener("popstate", onPop);
83
- }, [memory]);
102
+ return () => {
103
+ window.removeEventListener("popstate", onPop);
104
+ window.removeEventListener("pagehide", persist);
105
+ document.removeEventListener("visibilitychange", hidden);
106
+ };
107
+ }, [
108
+ memory,
109
+ rememberScroll,
110
+ places
111
+ ]);
84
112
  const go = useCallback((full, replace) => {
85
113
  const entry = replace ? current.current.entry : newEntry();
86
114
  if (!memory) {
87
115
  position.current += replace ? 0 : 1;
116
+ const previous = replace ? historyState().protobasePrevious : current.current.url;
88
117
  window.history[replace ? "replaceState" : "pushState"]({
89
118
  protobaseIndex: position.current,
90
- protobaseEntry: entry
119
+ protobaseEntry: entry,
120
+ protobasePrevious: previous
91
121
  }, "", full);
92
122
  }
123
+ rememberScroll();
93
124
  setPage({
94
125
  url: full,
95
126
  entry
96
127
  });
97
- }, [memory]);
128
+ }, [memory, rememberScroll]);
98
129
  const navigate = useCallback((to, { replace = false } = {}) => {
99
130
  const full = `${basePath}${to}`;
100
131
  if (pathOf(full) !== pathOf(current.current.url) && guards.current.size > 0) return setPending({
@@ -105,6 +136,15 @@ const Router = ({ basePath = "", initialUrl, children }) => {
105
136
  });
106
137
  go(full, replace);
107
138
  }, [basePath, go]);
139
+ const back = useCallback((to) => {
140
+ const previous = memory ? void 0 : historyState().protobasePrevious;
141
+ if (previous !== void 0 && pathOf(previous) === pathOf(`${basePath}${to}`)) return window.history.back();
142
+ navigate(to);
143
+ }, [
144
+ memory,
145
+ basePath,
146
+ navigate
147
+ ]);
108
148
  const registerGuard = useCallback((kind = "draft-kept") => {
109
149
  const token = Symbol("guard");
110
150
  guards.current.set(token, kind);
@@ -138,6 +178,7 @@ const Router = ({ basePath = "", initialUrl, children }) => {
138
178
  basePath,
139
179
  entry: page.entry,
140
180
  navigate,
181
+ back,
141
182
  registerGuard,
142
183
  reload,
143
184
  reloadConfirmed
@@ -146,6 +187,7 @@ const Router = ({ basePath = "", initialUrl, children }) => {
146
187
  page,
147
188
  basePath,
148
189
  navigate,
190
+ back,
149
191
  registerGuard,
150
192
  reload,
151
193
  reloadConfirmed
@@ -5,8 +5,8 @@ import { AppAddress } from "@protobase/schema";
5
5
  /**
6
6
  * The app's address as logical values, and the ways to change it: `set` changes the list in place, `listTo` is the
7
7
  * list after a change for a link to it, `go` moves to
8
- * another address, `href` is an address for an `<a href>` and `open` follows such an href. Every component reads and
9
- * writes the address through this hook, never the query string or a path of its own.
8
+ * another address, `href` is an address for an `<a href>`, `open` follows such an href and `back` goes back to one.
9
+ * Every component reads and writes the address through this hook, never the query string or a path of its own.
10
10
  */
11
11
  export declare const useAppAddress: () => {
12
12
  address: CurrentAddress | undefined;
@@ -17,6 +17,7 @@ export declare const useAppAddress: () => {
17
17
  }) => void;
18
18
  href: (to: AppAddress) => string;
19
19
  open: (target: string) => void;
20
+ back: (target: string) => void;
20
21
  };
21
22
  /** A link to an address in the app: an ordinary anchor that the router follows without reloading the page. */
22
23
  export declare const AppLink: ({ to, ...rest }: AnchorHTMLAttributes<HTMLAnchorElement> & {
@@ -9,11 +9,11 @@ import { appPath, readAppPath } from "@protobase/schema";
9
9
  /**
10
10
  * The app's address as logical values, and the ways to change it: `set` changes the list in place, `listTo` is the
11
11
  * list after a change for a link to it, `go` moves to
12
- * another address, `href` is an address for an `<a href>` and `open` follows such an href. Every component reads and
13
- * writes the address through this hook, never the query string or a path of its own.
12
+ * another address, `href` is an address for an `<a href>`, `open` follows such an href and `back` goes back to one.
13
+ * Every component reads and writes the address through this hook, never the query string or a path of its own.
14
14
  */
15
15
  const useAppAddress = () => {
16
- const { path, params, basePath, navigate } = useRouter();
16
+ const { path, params, basePath, navigate, back: backTo } = useRouter();
17
17
  const { pages, resources, views } = useAdminMeta();
18
18
  const xs = useIsXs();
19
19
  const pageNames = useMemo(() => new Set(Object.keys(pages)), [pages]);
@@ -37,13 +37,17 @@ const useAppAddress = () => {
37
37
  if (read?.kind !== "list") throw new Error("listTo and set change the list the app shows; use go to move to another address");
38
38
  return changedList(read, change, defaults(read.resource, read.list));
39
39
  }, [read, defaults]);
40
+ const set = useCallback((change) => go(listTo(change), { replace: true }), [listTo, go]);
41
+ const href = useCallback((to) => appPath(to, basePath), [basePath]);
42
+ const below = useCallback((target) => target.startsWith(`${basePath}/`) ? target.slice(basePath.length) : target, [basePath]);
40
43
  return {
41
44
  address,
42
- set: useCallback((change) => go(listTo(change), { replace: true }), [listTo, go]),
45
+ set,
43
46
  listTo,
44
47
  go,
45
- href: useCallback((to) => appPath(to, basePath), [basePath]),
46
- open: useCallback((target) => navigate(target.startsWith(`${basePath}/`) ? target.slice(basePath.length) : target), [basePath, navigate])
48
+ href,
49
+ open: useCallback((target) => navigate(below(target)), [below, navigate]),
50
+ back: useCallback((target) => backTo(below(target)), [below, backTo])
47
51
  };
48
52
  };
49
53
  /** A link to an address in the app: an ordinary anchor that the router follows without reloading the page. */
@@ -11,7 +11,7 @@ import { useEffect, useRef, useState } from "react";
11
11
  import { jsx, jsxs } from "react/jsx-runtime";
12
12
  //#region src/app-shell/app-shell.tsx
13
13
  const drawerId = "app-drawer";
14
- const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, appName, appIcon, logo, actions, rightPanel, sidebarNotice, banner, forceExpanded, profileMenuOpen, navGroups, userMenu, bottomBar, onNavigate, onSignOut, signIn, organizations, roles, theme, search, pageScroll: scrollsByDefault = false, className, children }) => {
14
+ const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, appName, appIcon, logo, actions, rightPanel, sidebarNotice, banner, forceExpanded, profileMenuOpen, navGroups, userMenu, bottomBar, onNavigate, onBack, onSignOut, signIn, organizations, roles, theme, search, pageScroll: scrollsByDefault = false, className, children }) => {
15
15
  const desktop = useIsDesktop();
16
16
  const noticeShown = useSidebarNoticeShown(sidebarMode);
17
17
  const [drawerOpen, setDrawerOpen] = useState(false);
@@ -67,6 +67,7 @@ const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, appNam
67
67
  sticky: pageScroll,
68
68
  breadcrumb,
69
69
  onNavigate,
70
+ onBack,
70
71
  actions,
71
72
  onMenu: desktop || tabs ? void 0 : () => setDrawerOpen(true),
72
73
  menuOpen: drawerOpen,
@@ -9,7 +9,7 @@ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
9
9
  //#region src/app-shell/top-bar.tsx
10
10
  /** An icon button of the top bar on a mobile screen, as the menu, back and search buttons are. */
11
11
  const topBarButton = "inline-flex size-11 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted md:hidden";
12
- const TopBar = ({ breadcrumb, onNavigate, actions, onMenu, menuOpen, menuControls, search, sticky, slotRef, ref }) => {
12
+ const TopBar = ({ breadcrumb, onNavigate, onBack, actions, onMenu, menuOpen, menuControls, search, sticky, slotRef, ref }) => {
13
13
  const [searchOpen, setSearchOpen] = useState(false);
14
14
  const back = backTarget(breadcrumb);
15
15
  const desktopSearch = useRef(null);
@@ -42,9 +42,10 @@ const TopBar = ({ breadcrumb, onNavigate, actions, onMenu, menuOpen, menuControl
42
42
  href: back.href,
43
43
  "aria-label": text("backTo", back.label),
44
44
  onClick: (event) => {
45
- if (!onNavigate || !inApp(event)) return;
45
+ const follow = onBack ?? onNavigate;
46
+ if (!follow || !inApp(event)) return;
46
47
  event.preventDefault();
47
- onNavigate(back.href);
48
+ follow(back.href);
48
49
  },
49
50
  className: cn("inline-flex size-11 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted md:hidden", !onMenu && "-ml-3"),
50
51
  children: /* @__PURE__ */ jsx(ChevronLeft, { className: "size-5" })
@@ -3,7 +3,7 @@ import { Button } from "../primitives/button.js";
3
3
  import { OtpInput } from "../primitives/otp-input.js";
4
4
  import { AuthLayout } from "./auth-layout.js";
5
5
  import { ResendCodeButton } from "./resend-code-button.js";
6
- import { useState } from "react";
6
+ import { useEffect, useRef, useState } from "react";
7
7
  import { jsx, jsxs } from "react/jsx-runtime";
8
8
  //#region src/auth/code-form.tsx
9
9
  /**
@@ -12,6 +12,12 @@ import { jsx, jsxs } from "react/jsx-runtime";
12
12
  */
13
13
  const CodeForm = ({ title, description, explanation, submitLabel, onSubmit, onResend, onChangeAddress, changeAddressLabel, busy, error, notice, resendCooldown }) => {
14
14
  const [code, setCode] = useState("");
15
+ const field = useRef(null);
16
+ useEffect(() => {
17
+ if (!notice) return;
18
+ setCode("");
19
+ field.current?.focus();
20
+ }, [notice]);
15
21
  return /* @__PURE__ */ jsxs(AuthLayout, {
16
22
  title,
17
23
  description,
@@ -35,6 +41,7 @@ const CodeForm = ({ title, description, explanation, submitLabel, onSubmit, onRe
35
41
  /* @__PURE__ */ jsxs("label", {
36
42
  className: "flex flex-col gap-1.5 font-medium",
37
43
  children: [text("code"), /* @__PURE__ */ jsx(OtpInput, {
44
+ ref: field,
38
45
  name: "code",
39
46
  required: true,
40
47
  autoFocus: true,
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.45.9";
2
+ var version = "0.45.11";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };