@protobase/ui 0.13.4 → 0.14.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.
Files changed (47) hide show
  1. package/dist/app/current-address.js +1 -1
  2. package/dist/app/custom-section.js +1 -1
  3. package/dist/app/displays/custom-display.js +1 -1
  4. package/dist/app/displays/list-cards.js +2 -2
  5. package/dist/app/displays/list-displays.js +1 -1
  6. package/dist/app/displays/virtual-cards.js +43 -65
  7. package/dist/app/error-reporting.js +1 -1
  8. package/dist/app/list-page.js +11 -3
  9. package/dist/app/nav-recent.js +2 -1
  10. package/dist/app/pages/blocks/link.js +2 -1
  11. package/dist/app/pages/render-node.js +1 -1
  12. package/dist/app/pages/use-layout-data.js +1 -1
  13. package/dist/app/pages/use-run-action.js +1 -1
  14. package/dist/app/search/search-model.js +2 -1
  15. package/dist/app/use-lines-draft.js +1 -1
  16. package/dist/app-shell/app-shell.js +54 -38
  17. package/dist/app-shell/bottom-bar.d.ts +1 -0
  18. package/dist/app-shell/bottom-bar.js +3 -2
  19. package/dist/app-shell/index.d.ts +1 -0
  20. package/dist/app-shell/page-scroll.d.ts +1 -0
  21. package/dist/app-shell/page-scroll.js +56 -0
  22. package/dist/app-shell/sidebar.js +2 -2
  23. package/dist/app-shell/top-bar.js +3 -2
  24. package/dist/assistant-dock/use-assistant.js +1 -1
  25. package/dist/data/use-record.js +1 -1
  26. package/dist/data/use-update-record.js +1 -1
  27. package/dist/data-grid/data-grid.js +92 -85
  28. package/dist/data-grid/geometry.js +26 -6
  29. package/dist/data-grid/grid-footer.js +5 -12
  30. package/dist/data-grid/scroll-parent.js +35 -0
  31. package/dist/data-grid/use-virtual-rows.js +123 -18
  32. package/dist/package.js +1 -1
  33. package/dist/plugins/load-plugin.d.ts +1 -1
  34. package/dist/plugins/load-plugin.js +1 -1
  35. package/dist/shell/assets/app-Dkz6z_t_.js +66 -0
  36. package/dist/shell/assets/{index-rR2iasG9.js → index-BCLK6g9a.js} +1 -1
  37. package/dist/shell/assets/index-BQ4FIU4i.css +2 -0
  38. package/dist/shell/assets/{maplibre-gl-estTAzEH.js → maplibre-gl-DWx1yzYO.js} +1 -1
  39. package/dist/shell/assets/{record-map-B57A7PQ0.js → record-map-C2M8dEjy.js} +2 -2
  40. package/dist/shell/assets/{records-map-BwsWXD8A.js → records-map-huS0J9xV.js} +1 -1
  41. package/dist/shell/assets/{shared-protobase-ui-CE7mIalS.js → shared-protobase-ui-CsJxPCx6.js} +1 -1
  42. package/dist/shell/index.html +4 -4
  43. package/dist/shell/protobase.shell.json +1 -1
  44. package/package.json +4 -4
  45. package/dist/data-grid/use-grid-scroll.js +0 -87
  46. package/dist/shell/assets/app-BBslM0jG.js +0 -66
  47. package/dist/shell/assets/index-CYWfU_c1.css +0 -2
@@ -1,6 +1,6 @@
1
1
  import { defaultDisplay } from "./displays/list-displays.js";
2
2
  import { defaultOrderBy } from "./list-order.js";
3
- import { encodeKey } from "@protobase/schema";
3
+ import { encodeKey } from "@protobase/schema/internal";
4
4
  //#region src/app/current-address.ts
5
5
  /** What a list shows when its address names nothing: its default display for the screen, the list's sort, the filters in a bar. */
6
6
  const listDefaults = (model, view, xs = false) => ({
@@ -5,7 +5,7 @@ import { Section } from "../record-view/section.js";
5
5
  import { SidebarCard } from "../record-view/record-sidebar.js";
6
6
  import { RecordScope } from "./pages/record-scope.js";
7
7
  import { jsx, jsxs } from "react/jsx-runtime";
8
- import { unmarkFieldRefs } from "@protobase/schema";
8
+ import { unmarkFieldRefs } from "@protobase/schema/internal";
9
9
  //#region src/app/custom-section.tsx
10
10
  /**
11
11
  * A component of the record layout (`l.custom`), drawn with the record around it, so `useLayoutRecord()` gives it, in a
@@ -3,7 +3,7 @@ import { Problem } from "../pages/problem.js";
3
3
  import { CustomBoundary } from "../pages/custom-boundary.js";
4
4
  import { ListDisplayProvider } from "./display-context.js";
5
5
  import { jsx, jsxs } from "react/jsx-runtime";
6
- import { unmarkFieldRefs } from "@protobase/schema";
6
+ import { unmarkFieldRefs } from "@protobase/schema/internal";
7
7
  //#region src/app/displays/custom-display.tsx
8
8
  /**
9
9
  * A display the project draws: the component registered under its name gets the display's props, field references
@@ -10,8 +10,8 @@ const grid = "grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-3";
10
10
  const pictureHeight = (width) => width * .75;
11
11
  const plainHeight = 44;
12
12
  /**
13
- * The list page's cards display: a card per record (see `RecordCard`), scrolled like the table, with which of the
14
- * matching records are in view below. An embedded list, which shows a few cards whole, draws them with `CardsDisplay`.
13
+ * The list page's cards display: a card per record (see `RecordCard`), scrolled like the table. An embedded list,
14
+ * which shows a few cards whole, draws them with `CardsDisplay`.
15
15
  */
16
16
  const ListCards = ({ model, view, display, list }) => {
17
17
  const plural = view?.names?.plural ?? humanize(model.name);
@@ -1,7 +1,7 @@
1
1
  import { recordKey, recordTitleField } from "../../live/model-helpers.js";
2
2
  import { humanize } from "../../live/naming.js";
3
3
  import { displayValue } from "../../live/display-value.js";
4
- import { encodeKey, tableDisplay } from "@protobase/schema";
4
+ import { encodeKey, tableDisplay } from "@protobase/schema/internal";
5
5
  //#region src/app/displays/list-displays.ts
6
6
  const displaysOf = (view) => view?.list?.displays ?? [];
7
7
  /**
@@ -3,15 +3,17 @@ import { usePagedRows } from "../../data-grid/use-paged-rows.js";
3
3
  import { useVirtualRows } from "../../data-grid/use-virtual-rows.js";
4
4
  import { anchoredTop, cardColumns, cardWidth, recordRange } from "./card-layout.js";
5
5
  import { useCallback, useLayoutEffect, useRef, useState } from "react";
6
- import { jsx, jsxs } from "react/jsx-runtime";
6
+ import { jsx } from "react/jsx-runtime";
7
7
  //#region src/app/displays/virtual-cards.tsx
8
8
  /**
9
- * Cards in rows as wide as the list, scrolled like the table: only the rows in view are drawn, over a scroller that
10
- * holds any number of records, and the records are loaded in the table's pages, so a change of width keeps the loaded
11
- * pages and the first record in view. Every card in a width is as tall as the tallest drawn so far.
9
+ * Cards in rows as wide as the list, scrolled like the table by the page or the nearest scrolling ancestor: the list is
10
+ * as tall as all its rows, only the rows in view are drawn, and the records are loaded in the table's pages, so a
11
+ * change of width keeps the loaded pages and the first record in view. Every card in a width is as tall as the tallest
12
+ * drawn so far.
12
13
  */
13
14
  const VirtualCards = ({ source, label, initialIndex, onScrollIndex, minHeight, children }) => {
14
15
  const { total } = source;
16
+ const [startIndex] = useState(initialIndex);
15
17
  const [width, setWidth] = useState(0);
16
18
  const columns = cardColumns(width);
17
19
  const itemWidth = cardWidth(width, columns);
@@ -29,13 +31,15 @@ const VirtualCards = ({ source, label, initialIndex, onScrollIndex, minHeight, c
29
31
  layout,
30
32
  top: 0
31
33
  });
32
- const changed = anchor.current.layout.columns !== columns || anchor.current.layout.rowHeight !== rowHeight;
34
+ const changed = anchor.current.top > 0 && (anchor.current.layout.columns !== columns || anchor.current.layout.rowHeight !== rowHeight);
33
35
  const settle = (top) => changed ? anchoredTop(anchor.current.top, anchor.current.layout, layout) : top;
36
+ const ready = itemWidth > 0 && measured.width === itemWidth;
34
37
  const scroll = useVirtualRows({
35
38
  total: Math.ceil(total / columns),
36
39
  rowHeight,
37
- initialIndex: Math.floor(initialIndex / columns),
38
- settle
40
+ initialIndex: Math.floor(startIndex / columns),
41
+ settle,
42
+ ready
39
43
  });
40
44
  useLayoutEffect(() => {
41
45
  if (changed) scroll.scrollTo(scroll.top);
@@ -43,7 +47,7 @@ const VirtualCards = ({ source, label, initialIndex, onScrollIndex, minHeight, c
43
47
  layout,
44
48
  top: scroll.top
45
49
  };
46
- if (!changed) onScrollIndex(Math.floor(scroll.top / rowHeight) * columns);
50
+ if (!changed) onScrollIndex(Math.floor(Math.max(0, scroll.top) / rowHeight) * columns);
47
51
  });
48
52
  const range = recordRange(scroll.first, scroll.last, columns, total);
49
53
  const getRow = usePagedRows({
@@ -52,9 +56,8 @@ const VirtualCards = ({ source, label, initialIndex, onScrollIndex, minHeight, c
52
56
  loadRows: source.loadRows,
53
57
  latencyMs: 0
54
58
  }, range.start, range.end);
55
- const bodyRef = useRef(null);
56
59
  useLayoutEffect(() => {
57
- const body = bodyRef.current;
60
+ const body = scroll.ref.current;
58
61
  if (!body) return;
59
62
  const observer = new ResizeObserver(() => setWidth(body.clientWidth));
60
63
  observer.observe(body);
@@ -77,61 +80,36 @@ const VirtualCards = ({ source, label, initialIndex, onScrollIndex, minHeight, c
77
80
  observer.observe(item);
78
81
  return () => observer.unobserve(item);
79
82
  }, [observer]);
80
- const firstShown = Math.min(total, Math.floor(scroll.top / rowHeight) * columns + 1);
81
- const lastShown = Math.min(total, (Math.floor((scroll.top + Math.max(0, scroll.viewport - 1)) / rowHeight) + 1) * columns);
82
- return /* @__PURE__ */ jsxs("div", {
83
- className: "flex min-h-0 flex-1 flex-col",
84
- children: [/* @__PURE__ */ jsx("div", {
85
- ref: scroll.ref,
86
- onScroll: scroll.onScroll,
87
- className: "relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden",
88
- children: /* @__PURE__ */ jsx("div", {
89
- style: { height: scroll.geometry.contentHeight || void 0 },
90
- children: /* @__PURE__ */ jsx("div", {
91
- ref: bodyRef,
92
- className: "sticky top-0 overflow-clip",
93
- style: { height: scroll.viewport },
94
- children: /* @__PURE__ */ jsx("ul", {
95
- className: "relative",
96
- "aria-label": label,
97
- children: scroll.rows.flatMap(({ index: rowIndex, offset }) => Array.from({ length: columns }, (_, column) => {
98
- const index = rowIndex * columns + column;
99
- if (index >= total) return [];
100
- const row = getRow(index);
101
- const place = {
102
- top: offset,
103
- left: column * (itemWidth + 12),
104
- width: itemWidth,
105
- minHeight: itemHeight
106
- };
107
- if (!row) return /* @__PURE__ */ jsx("li", {
108
- "aria-hidden": true,
109
- className: "absolute grid grid-cols-[minmax(0,1fr)]",
110
- style: place,
111
- children: /* @__PURE__ */ jsx(Skeleton, { className: "h-full rounded-lg" })
112
- }, `skeleton-${index}`);
113
- return /* @__PURE__ */ jsx("li", {
114
- ref: observe,
115
- "aria-posinset": index + 1,
116
- "aria-setsize": total,
117
- className: "absolute grid grid-cols-[minmax(0,1fr)]",
118
- style: place,
119
- children: children(row)
120
- }, row.id);
121
- }))
122
- })
123
- })
124
- })
125
- }), /* @__PURE__ */ jsxs("div", {
126
- className: "py-3 text-xs tabular-nums text-muted-foreground",
127
- children: [
128
- firstShown.toLocaleString("en-IE"),
129
- "–",
130
- lastShown.toLocaleString("en-IE"),
131
- " of ",
132
- total.toLocaleString("en-IE")
133
- ]
134
- })]
83
+ return /* @__PURE__ */ jsx("ul", {
84
+ ref: scroll.ref,
85
+ className: "relative min-w-0 flex-auto",
86
+ "aria-label": label,
87
+ style: { height: scroll.geometry.contentHeight },
88
+ children: scroll.rows.flatMap(({ index: rowIndex, offset }) => Array.from({ length: columns }, (_, column) => {
89
+ const index = rowIndex * columns + column;
90
+ if (index >= total) return [];
91
+ const row = getRow(index);
92
+ const place = {
93
+ top: offset,
94
+ left: column * (itemWidth + 12),
95
+ width: itemWidth,
96
+ minHeight: itemHeight
97
+ };
98
+ if (!row) return /* @__PURE__ */ jsx("li", {
99
+ "aria-hidden": true,
100
+ className: "absolute grid grid-cols-[minmax(0,1fr)]",
101
+ style: place,
102
+ children: /* @__PURE__ */ jsx(Skeleton, { className: "h-full rounded-lg" })
103
+ }, `skeleton-${index}`);
104
+ return /* @__PURE__ */ jsx("li", {
105
+ ref: observe,
106
+ "aria-posinset": index + 1,
107
+ "aria-setsize": total,
108
+ className: "absolute grid grid-cols-[minmax(0,1fr)]",
109
+ style: place,
110
+ children: children(row)
111
+ }, row.id);
112
+ }))
135
113
  });
136
114
  };
137
115
  //#endregion
@@ -1,4 +1,4 @@
1
- import { redactSecrets } from "@protobase/schema";
1
+ import { redactSecrets } from "@protobase/schema/internal";
2
2
  //#region src/app/error-reporting.ts
3
3
  let loading;
4
4
  let capture;
@@ -1,4 +1,5 @@
1
1
  import { useIsDesktop } from "../lib/use-media-query.js";
2
+ import { usePageScroll } from "../app-shell/page-scroll.js";
2
3
  import { PageHeader } from "../app-shell/page-header.js";
3
4
  import { useAuth } from "../auth/auth-provider.js";
4
5
  import { Button } from "../primitives/button.js";
@@ -38,7 +39,8 @@ import { withFieldTypeCells } from "./field-types/field-type-cells.js";
38
39
  import { useMemo, useState } from "react";
39
40
  import { Plus, SlidersHorizontal } from "lucide-react";
40
41
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
41
- import { createWhere, tableDisplay } from "@protobase/schema";
42
+ import { tableDisplay } from "@protobase/schema/internal";
43
+ import { createWhere } from "@protobase/schema";
42
44
  //#region src/app/list-page.tsx
43
45
  const where = createWhere();
44
46
  const searchText = (extras) => extras.find((clause) => clause.kind === "search");
@@ -99,6 +101,8 @@ const LoadedList = ({ model, address }) => {
99
101
  orderBy
100
102
  });
101
103
  const display = shownDisplay(view, address.display);
104
+ const pageScroll = !display || display.kind === "cards";
105
+ usePageScroll(pageScroll);
102
106
  const displayRows = useDisplayRows(model, rows.page, rows.key, display?.kind === "custom");
103
107
  const scroll = useListScroll();
104
108
  const titleField = recordTitleField(model, view);
@@ -254,7 +258,8 @@ const LoadedList = ({ model, address }) => {
254
258
  }] : [],
255
259
  rowNumbers: false,
256
260
  pinKey: auth.kind === "signed-in" ? pinnedStorageKey(auth.user.id, resource) : void 0,
257
- className: "min-h-0 flex-1"
261
+ scroll: "parent",
262
+ className: "flex-1"
258
263
  }) : /* @__PURE__ */ jsx("div", { className: "flex-1 rounded-lg border bg-surface" });
259
264
  const cardsList = {
260
265
  ...rows,
@@ -313,7 +318,10 @@ const LoadedList = ({ model, address }) => {
313
318
  desktop ? desktopToolbar : phoneFilters,
314
319
  desktop && layout === "panel" ? /* @__PURE__ */ jsxs("div", {
315
320
  className: "flex min-h-0 flex-1 gap-4",
316
- children: [panel, shown]
321
+ children: [/* @__PURE__ */ jsx("div", {
322
+ className: pageScroll ? "sticky top-[calc(var(--pb-sticky-top,0px)+1rem)] flex max-h-[calc(100dvh-var(--pb-sticky-top,0px)-var(--pb-sticky-bottom,0px)-2rem)] shrink-0 self-start" : "flex shrink-0",
323
+ children: panel
324
+ }), shown]
317
325
  }) : shown
318
326
  ]
319
327
  });
@@ -1,6 +1,7 @@
1
1
  import { recordKey, recordTitle, recordTitleField } from "../live/model-helpers.js";
2
2
  import { defaultOrderBy } from "./list-order.js";
3
- import { appPath, defaultRecentLimit, encodeKey, enumLabel } from "@protobase/schema";
3
+ import { defaultRecentLimit, encodeKey } from "@protobase/schema/internal";
4
+ import { appPath, enumLabel } from "@protobase/schema";
4
5
  //#region src/app/nav-recent.ts
5
6
  /** The list request behind a sidebar group: its filter, order (the list's sort when unset), size, and only the fields it shows. */
6
7
  const recentRequest = (model, view, recent) => ({
@@ -2,7 +2,8 @@ import { AppLink } from "../../use-app-address.js";
2
2
  import { useLayoutRecord } from "../record-scope.js";
3
3
  import { propsOf } from "./block-props.js";
4
4
  import { jsx } from "react/jsx-runtime";
5
- import { appPath, fillAddress } from "@protobase/schema";
5
+ import { fillAddress } from "@protobase/schema/internal";
6
+ import { appPath } from "@protobase/schema";
6
7
  //#region src/app/pages/blocks/link.tsx
7
8
  const style = "font-medium text-primary-text hover:underline";
8
9
  /**
@@ -16,7 +16,7 @@ import { StatBlock } from "./blocks/stat.js";
16
16
  import { TableBlock } from "./blocks/table.js";
17
17
  import { Fragment } from "react";
18
18
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
19
- import { unmarkFieldRefs } from "@protobase/schema";
19
+ import { unmarkFieldRefs } from "@protobase/schema/internal";
20
20
  import { isLayoutNode } from "@protobase/layout";
21
21
  //#region src/app/pages/render-node.tsx
22
22
  const blocks = {
@@ -3,7 +3,7 @@ import { useClient } from "../../data/api-provider.js";
3
3
  import { recordKey } from "../../live/model-helpers.js";
4
4
  import { useCallback } from "react";
5
5
  import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
6
- import { encodeKey } from "@protobase/schema";
6
+ import { encodeKey } from "@protobase/schema/internal";
7
7
  //#region src/app/pages/use-layout-data.ts
8
8
  /** A list item as a record scope: the key as the URL carries it, and the etag and permissions the item carries. */
9
9
  const scopeOf = (model, row) => ({
@@ -7,7 +7,7 @@ import { can } from "../permissions.js";
7
7
  import { fillHref } from "./action-href.js";
8
8
  import { useRefreshResource } from "./use-layout-data.js";
9
9
  import { useState } from "react";
10
- import { fillAddress } from "@protobase/schema";
10
+ import { fillAddress } from "@protobase/schema/internal";
11
11
  //#region src/app/pages/use-run-action.ts
12
12
  const message = (error) => error instanceof Error ? error.message : "Something went wrong";
13
13
  /**
@@ -1,6 +1,7 @@
1
1
  import { recordKey, recordTitleField } from "../../live/model-helpers.js";
2
2
  import { humanize } from "../../live/naming.js";
3
- import { appPath, createWhere, encodeKey, enumLabel, matchedSearchField, printFilter } from "@protobase/schema";
3
+ import { encodeKey, matchedSearchField } from "@protobase/schema/internal";
4
+ import { appPath, createWhere, enumLabel, printFilter } from "@protobase/schema";
4
5
  //#region src/app/search/search-model.ts
5
6
  const where = createWhere();
6
7
  const searchTargets = (meta) => Object.values(meta.resources).flatMap((model) => {
@@ -1,6 +1,6 @@
1
1
  import { recordKey } from "../live/model-helpers.js";
2
2
  import { useCallback, useMemo, useState } from "react";
3
- import { encodeKey } from "@protobase/schema";
3
+ import { encodeKey } from "@protobase/schema/internal";
4
4
  //#region src/app/use-lines-draft.ts
5
5
  /**
6
6
  * The order the person has given the lines of a record, kept in the form until Save writes it, together with the
@@ -2,9 +2,10 @@ import { cn } from "../lib/cn.js";
2
2
  import { useIsDesktop } from "../lib/use-media-query.js";
3
3
  import { BottomBar } from "./bottom-bar.js";
4
4
  import { Drawer } from "./drawer.js";
5
+ import { PageScrollContext, usePageInsets } from "./page-scroll.js";
5
6
  import { Sidebar } from "./sidebar.js";
6
7
  import { TopBar } from "./top-bar.js";
7
- import { useEffect, useState } from "react";
8
+ import { useEffect, useRef, useState } from "react";
8
9
  import { jsx, jsxs } from "react/jsx-runtime";
9
10
  //#region src/app-shell/app-shell.tsx
10
11
  const drawerId = "app-drawer";
@@ -15,8 +16,13 @@ const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, action
15
16
  if (desktop) setDrawerOpen(false);
16
17
  }, [desktop]);
17
18
  const tabs = !desktop && bottomBar && bottomBar.length > 0 ? bottomBar : void 0;
18
- const sidebar = (mode) => /* @__PURE__ */ jsx(Sidebar, {
19
+ const [pageScroll, setPageScroll] = useState(false);
20
+ const topBarRef = useRef(null);
21
+ const bottomBarRef = useRef(null);
22
+ usePageInsets(pageScroll, topBarRef, bottomBarRef, Boolean(tabs));
23
+ const sidebar = (mode, sticky = false) => /* @__PURE__ */ jsx(Sidebar, {
19
24
  mode,
25
+ sticky,
20
26
  activeItem,
21
27
  user,
22
28
  workspace,
@@ -30,42 +36,52 @@ const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, action
30
36
  organizations,
31
37
  roles
32
38
  });
33
- return /* @__PURE__ */ jsxs("div", {
34
- className: cn("flex h-full min-h-0 w-full overflow-hidden bg-background", className),
35
- children: [
36
- desktop ? sidebar(sidebarMode) : /* @__PURE__ */ jsx(Drawer, {
37
- id: drawerId,
38
- open: drawerOpen,
39
- onClose: () => setDrawerOpen(false),
40
- children: sidebar("text-large")
41
- }),
42
- /* @__PURE__ */ jsxs("div", {
43
- className: "flex min-w-0 flex-1 flex-col",
44
- children: [
45
- /* @__PURE__ */ jsx(TopBar, {
46
- breadcrumb,
47
- onNavigate,
48
- actions,
49
- onMenu: desktop || tabs ? void 0 : () => setDrawerOpen(true),
50
- menuOpen: drawerOpen,
51
- menuControls: drawerId,
52
- search
53
- }),
54
- /* @__PURE__ */ jsx("main", {
55
- className: "flex min-h-0 flex-1 flex-col",
56
- children
57
- }),
58
- tabs && /* @__PURE__ */ jsx(BottomBar, {
59
- tabs,
60
- onNavigate,
61
- onMore: () => setDrawerOpen(true),
62
- moreOpen: drawerOpen,
63
- moreControls: drawerId
64
- })
65
- ]
66
- }),
67
- rightPanel
68
- ]
39
+ return /* @__PURE__ */ jsx(PageScrollContext.Provider, {
40
+ value: setPageScroll,
41
+ children: /* @__PURE__ */ jsxs("div", {
42
+ className: cn(pageScroll ? "flex min-h-full w-full overflow-x-clip bg-background" : "flex h-full min-h-0 w-full overflow-hidden bg-background", className),
43
+ children: [
44
+ desktop ? sidebar(sidebarMode, pageScroll) : /* @__PURE__ */ jsx(Drawer, {
45
+ id: drawerId,
46
+ open: drawerOpen,
47
+ onClose: () => setDrawerOpen(false),
48
+ children: sidebar("text-large")
49
+ }),
50
+ /* @__PURE__ */ jsxs("div", {
51
+ className: "flex min-w-0 flex-1 flex-col",
52
+ children: [
53
+ /* @__PURE__ */ jsx(TopBar, {
54
+ ref: topBarRef,
55
+ sticky: pageScroll,
56
+ breadcrumb,
57
+ onNavigate,
58
+ actions,
59
+ onMenu: desktop || tabs ? void 0 : () => setDrawerOpen(true),
60
+ menuOpen: drawerOpen,
61
+ menuControls: drawerId,
62
+ search
63
+ }),
64
+ /* @__PURE__ */ jsx("main", {
65
+ className: pageScroll ? "flex flex-1 flex-col" : "flex min-h-0 flex-1 flex-col",
66
+ children
67
+ }),
68
+ tabs && /* @__PURE__ */ jsx(BottomBar, {
69
+ ref: bottomBarRef,
70
+ sticky: pageScroll,
71
+ tabs,
72
+ onNavigate,
73
+ onMore: () => setDrawerOpen(true),
74
+ moreOpen: drawerOpen,
75
+ moreControls: drawerId
76
+ })
77
+ ]
78
+ }),
79
+ rightPanel && /* @__PURE__ */ jsx("div", {
80
+ className: pageScroll ? "sticky top-0 flex h-dvh shrink-0 self-start" : "flex shrink-0",
81
+ children: rightPanel
82
+ })
83
+ ]
84
+ })
69
85
  });
70
86
  };
71
87
  //#endregion
@@ -1 +1,2 @@
1
+ import { Ref } from "react";
1
2
  import "lucide-react";
@@ -9,11 +9,12 @@ const tab = "flex min-h-[49px] min-w-0 flex-1 flex-col items-center justify-cent
9
9
  * last, which opens the navigation drawer and is the current tab on screens the others do not hold. The padding below
10
10
  * keeps the tabs clear of the iPhone home indicator.
11
11
  */
12
- const BottomBar = ({ tabs, onNavigate, onMore, moreOpen, moreControls }) => {
12
+ const BottomBar = ({ tabs, onNavigate, onMore, moreOpen, moreControls, sticky, ref }) => {
13
13
  const moreCurrent = !tabs.some((item) => item.active);
14
14
  return /* @__PURE__ */ jsxs("nav", {
15
+ ref,
15
16
  "aria-label": "Tabs",
16
- className: "flex shrink-0 border-t bg-background pb-[env(safe-area-inset-bottom)] md:hidden",
17
+ className: cn("flex shrink-0 border-t bg-background pb-[env(safe-area-inset-bottom)] md:hidden", sticky && "sticky bottom-0 z-30"),
17
18
  children: [tabs.map((item) => {
18
19
  const Icon = item.icon;
19
20
  return /* @__PURE__ */ jsxs("a", {
@@ -6,6 +6,7 @@ import "./global-search.js";
6
6
  import "./breadcrumb.js";
7
7
  import "./app-shell.js";
8
8
  import "./page-header.js";
9
+ import "./page-scroll.js";
9
10
  import "./sidebar.js";
10
11
  import "./status-dot.js";
11
12
  export type { SidebarMode };
@@ -0,0 +1 @@
1
+ import "react";
@@ -0,0 +1,56 @@
1
+ import { createContext, useContext, useLayoutEffect } from "react";
2
+ //#region src/app-shell/page-scroll.ts
3
+ /** Turns the shell's page scrolling on or off; pages use `usePageScroll`. */
4
+ const PageScrollContext = createContext(() => {});
5
+ /**
6
+ * While the calling page is shown and `on`, the document scrolls instead of a box of the screen's height: the top and
7
+ * bottom bars and the sidebar stick, and the page grows with its content, so the browser's own scrolling applies
8
+ * throughout, scrolling to the top included, as tapping the status bar on an iPhone does. Sticky content in the page
9
+ * stays clear of the bars with `--pb-sticky-top` and `--pb-sticky-bottom`.
10
+ */
11
+ const usePageScroll = (on) => {
12
+ const set = useContext(PageScrollContext);
13
+ useLayoutEffect(() => {
14
+ set(on);
15
+ return () => set(false);
16
+ }, [set, on]);
17
+ };
18
+ const properties = [
19
+ "--pb-sticky-top",
20
+ "--pb-sticky-bottom",
21
+ "scroll-padding-top",
22
+ "scroll-padding-bottom"
23
+ ];
24
+ /**
25
+ * While the page scrolls, says how tall its sticky bars are, on the root element: as `--pb-sticky-top` and
26
+ * `--pb-sticky-bottom` for content that sticks inside them, and as the page's scroll padding, which the browser keeps
27
+ * focused and scrolled-to content clear of, and which a virtual list reads as the part of the page it shows in.
28
+ */
29
+ const usePageInsets = (on, top, bottom, bottomShown) => {
30
+ useLayoutEffect(() => {
31
+ if (!on) return;
32
+ const root = document.documentElement;
33
+ const update = () => {
34
+ const above = `${top.current?.offsetHeight ?? 0}px`;
35
+ const below = `${bottom.current?.offsetHeight ?? 0}px`;
36
+ root.style.setProperty("--pb-sticky-top", above);
37
+ root.style.setProperty("--pb-sticky-bottom", below);
38
+ root.style.setProperty("scroll-padding-top", above);
39
+ root.style.setProperty("scroll-padding-bottom", below);
40
+ };
41
+ const observer = new ResizeObserver(update);
42
+ for (const bar of [top.current, bottom.current]) if (bar) observer.observe(bar);
43
+ update();
44
+ return () => {
45
+ observer.disconnect();
46
+ for (const property of properties) root.style.removeProperty(property);
47
+ };
48
+ }, [
49
+ on,
50
+ top,
51
+ bottom,
52
+ bottomShown
53
+ ]);
54
+ };
55
+ //#endregion
56
+ export { PageScrollContext, usePageInsets, usePageScroll };
@@ -8,13 +8,13 @@ import { SidebarGroup } from "./sidebar-group.js";
8
8
  import { LogIn } from "lucide-react";
9
9
  import { jsx, jsxs } from "react/jsx-runtime";
10
10
  //#region src/app-shell/sidebar.tsx
11
- const Sidebar = ({ mode, activeItem, user, workspace, forceExpanded, profileMenuOpen, groups = navGroups, userMenu, onNavigate, onSignOut, onSignIn, organizations, roles }) => {
11
+ const Sidebar = ({ mode, activeItem, user, workspace, forceExpanded, profileMenuOpen, groups = navGroups, userMenu, onNavigate, onSignOut, onSignIn, organizations, roles, sticky }) => {
12
12
  const expanding = mode === "icon-expand";
13
13
  const expanded = expanding && forceExpanded;
14
14
  const inline = isTextMode(mode) || expanding;
15
15
  return /* @__PURE__ */ jsx("nav", {
16
16
  "aria-label": "Resources",
17
- className: cn("relative z-30 shrink-0", sidebarWidth[mode]),
17
+ className: cn(sticky ? "sticky top-0 h-dvh self-start" : "relative", "z-30 shrink-0", sidebarWidth[mode]),
18
18
  children: /* @__PURE__ */ jsxs("div", {
19
19
  className: cn("group/side absolute inset-y-0 left-0 flex flex-col overflow-hidden border-r bg-sidebar", expanding ? "w-14 transition-[width,box-shadow] duration-150 hover:w-60 hover:shadow-pop focus-within:w-60 focus-within:shadow-pop" : "w-full", expanded && "w-60 shadow-pop"),
20
20
  children: [
@@ -7,7 +7,7 @@ import { ChevronLeft, Menu, Search, X } from "lucide-react";
7
7
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
8
8
  //#region src/app-shell/top-bar.tsx
9
9
  const iconButton = "inline-flex size-11 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted md:hidden";
10
- const TopBar = ({ breadcrumb, onNavigate, actions, onMenu, menuOpen, menuControls, search }) => {
10
+ const TopBar = ({ breadcrumb, onNavigate, actions, onMenu, menuOpen, menuControls, search, sticky, ref }) => {
11
11
  const [searchOpen, setSearchOpen] = useState(false);
12
12
  const back = backTarget(breadcrumb);
13
13
  const desktopSearch = useRef(null);
@@ -24,7 +24,8 @@ const TopBar = ({ breadcrumb, onNavigate, actions, onMenu, menuOpen, menuControl
24
24
  return () => window.removeEventListener("keydown", onKey);
25
25
  }, [search]);
26
26
  return /* @__PURE__ */ jsxs("header", {
27
- className: "relative flex h-12 shrink-0 items-center gap-1 border-b bg-background px-2 md:gap-4 md:px-5",
27
+ ref,
28
+ className: cn(sticky ? "sticky top-0 z-30" : "relative", "flex h-12 shrink-0 items-center gap-1 border-b bg-background px-2 md:gap-4 md:px-5"),
28
29
  children: [
29
30
  onMenu && /* @__PURE__ */ jsx("button", {
30
31
  type: "button",
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useState } from "react";
2
- import { applyAssistantEvent, emptyAssistantState } from "@protobase/schema";
2
+ import { applyAssistantEvent, emptyAssistantState } from "@protobase/schema/internal";
3
3
  //#region src/assistant-dock/use-assistant.ts
4
4
  const messageOf = (error) => error instanceof Error ? error.message : "The assistant did not answer";
5
5
  /** Follows an assistant backend's event stream into state, with its two requests; `undefined` client means not yet. */
@@ -1,7 +1,7 @@
1
1
  import { keys } from "./query-keys.js";
2
2
  import { useClient } from "./api-provider.js";
3
3
  import { useQuery } from "@tanstack/react-query";
4
- import { encodeKey } from "@protobase/schema";
4
+ import { encodeKey } from "@protobase/schema/internal";
5
5
  //#region src/data/use-record.ts
6
6
  const useRecord = (resource, key, enabled = true) => {
7
7
  const client = useClient();
@@ -2,7 +2,7 @@ import { keys } from "./query-keys.js";
2
2
  import { useClient } from "./api-provider.js";
3
3
  import { PreconditionFailedError } from "@protobase/client";
4
4
  import { useMutation, useQueryClient } from "@tanstack/react-query";
5
- import { encodeKey } from "@protobase/schema";
5
+ import { encodeKey } from "@protobase/schema/internal";
6
6
  //#region src/data/use-update-record.ts
7
7
  /**
8
8
  * PATCH with `If-Match`. On success the cached record gets the new ETag and lists refetch;