@protobase/ui 0.12.22 → 0.13.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.
Files changed (43) hide show
  1. package/dist/app/displays/card-layout.js +19 -0
  2. package/dist/app/displays/cards-display.js +16 -11
  3. package/dist/app/displays/list-cards.js +50 -0
  4. package/dist/app/displays/list-displays.js +4 -3
  5. package/dist/app/displays/map-display.js +3 -3
  6. package/dist/app/displays/virtual-cards.js +138 -0
  7. package/dist/app/embedded-list/embedded-columns.js +52 -0
  8. package/dist/app/embedded-list/embedded-display.js +26 -0
  9. package/dist/app/embedded-list/embedded-list.js +155 -0
  10. package/dist/app/embedded-list/use-embedded-display.js +23 -0
  11. package/dist/app/embedded-list/use-embedded-rows.js +33 -0
  12. package/dist/app/list-page.js +14 -5
  13. package/dist/app/list-scroll.js +25 -0
  14. package/dist/app/pages/blocks/table.js +21 -41
  15. package/dist/app/record-page.js +2 -1
  16. package/dist/app/related-section.js +30 -52
  17. package/dist/app/router.js +1 -0
  18. package/dist/data-grid/data-grid.js +19 -39
  19. package/dist/data-grid/geometry.js +7 -9
  20. package/dist/data-grid/use-grid-scroll.js +8 -1
  21. package/dist/data-grid/use-virtual-rows.js +31 -0
  22. package/dist/live/columns-from-model.js +6 -4
  23. package/dist/live/grid-loader.js +26 -10
  24. package/dist/package.js +1 -1
  25. package/dist/record-view/section.js +24 -17
  26. package/dist/shell/assets/app-DLjaMVJD.js +66 -0
  27. package/dist/shell/assets/index-BYPphaEm.css +2 -0
  28. package/dist/shell/assets/{index-B8MhIaLr.js → index-D7IcSr6n.js} +3 -3
  29. package/dist/shell/assets/maplibre-gl-Bt-3iOM-.js +1 -0
  30. package/dist/shell/assets/record-map-DL4b9dZO.js +814 -0
  31. package/dist/shell/assets/records-map-D0EOO5kZ.js +1 -0
  32. package/dist/shell/assets/shared-protobase-ui-B8UXOIeW.js +1 -0
  33. package/dist/shell/index.html +5 -5
  34. package/dist/shell/protobase.shell.json +1 -1
  35. package/package.json +4 -5
  36. package/dist/app/pages/record-table.js +0 -114
  37. package/dist/app/pages/use-relation-labels.js +0 -30
  38. package/dist/shell/assets/app-BYw-8qpE.js +0 -66
  39. package/dist/shell/assets/index-CdSIk0BQ.css +0 -2
  40. package/dist/shell/assets/navigation-control-CGS0pamh.js +0 -814
  41. package/dist/shell/assets/record-map-9Y3IL_dg.js +0 -1
  42. package/dist/shell/assets/records-map-DXIWowJ3.js +0 -1
  43. package/dist/shell/assets/shared-protobase-ui-Bol6wL_S.js +0 -1
@@ -0,0 +1,25 @@
1
+ import { useRouter } from "./router.js";
2
+ import { useCallback } from "react";
3
+ //#region src/app/list-scroll.ts
4
+ /** The first record in view of the list each history entry last showed, for this tab; a page load starts empty. */
5
+ const remembered = /* @__PURE__ */ new Map();
6
+ /**
7
+ * Where a list starts and how it remembers where it is, so going back to it shows the same records. Only the list the
8
+ * history entry showed last comes back: after a change of sort or filter, which replaces the entry, the list starts at
9
+ * the top again.
10
+ */
11
+ const useListScroll = () => {
12
+ const { entry, path, params } = useRouter();
13
+ const url = `${path}?${params.toString()}`;
14
+ const last = remembered.get(entry);
15
+ const remember = useCallback((index) => void remembered.set(entry, {
16
+ url,
17
+ index
18
+ }), [entry, url]);
19
+ return {
20
+ initialIndex: last?.url === url ? last.index : 0,
21
+ remember
22
+ };
23
+ };
24
+ //#endregion
25
+ export { useListScroll };
@@ -1,54 +1,34 @@
1
- import { Skeleton } from "../../../primitives/skeleton.js";
2
1
  import { useAdminMeta } from "../../meta-gate.js";
3
- import { ErrorBanner } from "../../error-banner.js";
4
- import { useListPage } from "../use-layout-data.js";
5
2
  import { propsOf } from "./block-props.js";
6
- import { CardFrame, Quiet } from "./card-frame.js";
7
- import { RecordTable } from "../record-table.js";
8
- import { useState } from "react";
9
- import { jsx } from "react/jsx-runtime";
3
+ import { CardFrame } from "./card-frame.js";
4
+ import { EmbeddedDisplaySwitch, EmbeddedList } from "../../embedded-list/embedded-list.js";
5
+ import { useEmbeddedDisplay } from "../../embedded-list/use-embedded-display.js";
6
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
10
7
  //#region src/app/pages/blocks/table.tsx
11
- /** The view's list columns, else the first five fields that are not keys or the tenant. */
12
- const defaultColumns = (model, view) => view?.list?.columns ?? Object.keys(model.fields).filter((name) => !model.primaryKey.includes(name) && name !== model.tenant).slice(0, 5);
13
- const LoadedTable = ({ model, view, filter, sort, columns, pageSize = 10, title, description, empty, actions }) => {
14
- const shown = (columns ?? defaultColumns(model, view)).filter((name) => Object.hasOwn(model.fields, name));
15
- const [tokens, setTokens] = useState([""]);
16
- const page = useListPage(model, {
17
- filter,
18
- sort,
19
- pageSize,
20
- pageToken: tokens.at(-1)
21
- });
22
- const rows = page.data?.items ?? [];
23
- const next = page.data?.nextPageToken;
8
+ const LoadedTable = ({ model, view, filter = "", sort = "", columns, limit = 10, display: preferred, title, description, empty, actions }) => {
9
+ const [display, pick] = useEmbeddedDisplay(view, `page:${model.name}:${title ?? ""}:${filter}`, preferred);
24
10
  return /* @__PURE__ */ jsx(CardFrame, {
25
11
  title,
26
12
  description,
27
- actions,
13
+ actions: /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(EmbeddedDisplaySwitch, {
14
+ view,
15
+ value: display,
16
+ onChange: pick
17
+ }), actions] }),
28
18
  className: "h-full",
29
- children: page.isPending ? /* @__PURE__ */ jsx("div", {
30
- className: "flex flex-col gap-2",
31
- children: Array.from({ length: 3 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-full" }, index))
32
- }) : page.error ? /* @__PURE__ */ jsx(ErrorBanner, {
33
- title: "Could not load",
34
- error: page.error
35
- }) : rows.length === 0 && tokens.length === 1 ? /* @__PURE__ */ jsx(Quiet, { children: empty ?? "Nothing here yet." }) : /* @__PURE__ */ jsx(RecordTable, {
19
+ children: /* @__PURE__ */ jsx(EmbeddedList, {
36
20
  model,
37
- columns: shown,
38
- rows,
39
- label: title ?? model.name,
40
- pager: {
41
- page: tokens.length,
42
- hasPrevious: tokens.length > 1,
43
- hasNext: Boolean(next),
44
- busy: page.isFetching,
45
- previous: () => setTokens((stack) => stack.slice(0, -1)),
46
- next: () => next && setTokens((stack) => [...stack, next])
47
- }
21
+ view,
22
+ filter,
23
+ sort,
24
+ columns,
25
+ limit,
26
+ display,
27
+ empty
48
28
  })
49
29
  });
50
30
  };
51
- /** A page of records at a time, in `sort` order; the first column opens the record. */
31
+ /** The first `limit` records in `sort` order, in the list page's table or display, with a link to the whole list. */
52
32
  const TableBlock = ({ node, slots }) => {
53
33
  const props = propsOf(node);
54
34
  const { resources, views } = useAdminMeta();
@@ -62,4 +42,4 @@ const TableBlock = ({ node, slots }) => {
62
42
  });
63
43
  };
64
44
  //#endregion
65
- export { TableBlock, defaultColumns };
45
+ export { TableBlock };
@@ -292,7 +292,8 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
292
292
  children: [layout.flatMap((item, index) => {
293
293
  if (item.kind === "related") return [/* @__PURE__ */ jsx(RelatedSection, {
294
294
  item,
295
- recordKey
295
+ recordKey,
296
+ owner: model.name
296
297
  }, `related:${item.title}`)];
297
298
  if (item.kind === "custom") return item.sidebar ? [] : [/* @__PURE__ */ jsx(CustomSection, {
298
299
  item,
@@ -4,17 +4,20 @@ import { useClient } from "../data/api-provider.js";
4
4
  import { useAdminMeta } from "./meta-gate.js";
5
5
  import { ErrorBanner } from "./error-banner.js";
6
6
  import { Section } from "../record-view/section.js";
7
- import { RecordTable } from "./pages/record-table.js";
8
- import { defaultColumns } from "./pages/blocks/table.js";
9
- import { useState } from "react";
7
+ import { listColumnNames } from "../live/columns-from-model.js";
8
+ import { EmbeddedDisplaySwitch, EmbeddedList } from "./embedded-list/embedded-list.js";
9
+ import { useEmbeddedDisplay } from "./embedded-list/use-embedded-display.js";
10
10
  import { jsx } from "react/jsx-runtime";
11
- import { keepPreviousData, useQuery } from "@tanstack/react-query";
11
+ import { useQuery } from "@tanstack/react-query";
12
12
  import { createWhere, printFilter } from "@protobase/schema";
13
13
  //#region src/app/related-section.tsx
14
14
  const where = createWhere();
15
15
  /** The most join records a section reads to find its records; the list API's largest page. */
16
16
  const maxThrough = 500;
17
- const and = (...parts) => parts.filter((part) => Boolean(part)).map((part) => `(${part})`).join(" AND ");
17
+ const and = (...parts) => {
18
+ const given = parts.filter((part) => Boolean(part));
19
+ return given.length === 1 ? given[0] : given.map((part) => `(${part})`).join(" AND ");
20
+ };
18
21
  /**
19
22
  * The filter on the section's resource: its `field` is this record's key, or, through a join resource, one of the keys
20
23
  * the join records that point at this record hold. `null` when no join record does, so nothing can match.
@@ -54,47 +57,6 @@ const useMatch = (item, recordKey) => {
54
57
  error: null
55
58
  };
56
59
  };
57
- const RelatedRows = ({ item, model, filter }) => {
58
- const client = useClient();
59
- const { views } = useAdminMeta();
60
- const [tokens, setTokens] = useState([""]);
61
- const pageSize = item.pageSize ?? 10;
62
- const sort = item.sort ?? "";
63
- const token = tokens.at(-1) ?? "";
64
- const page = useQuery({
65
- queryKey: keys.list(model.name, filter, sort, pageSize, token),
66
- placeholderData: keepPreviousData,
67
- queryFn: () => client.search(model.name, {
68
- filter,
69
- orderBy: sort || void 0,
70
- pageSize,
71
- pageToken: token || void 0
72
- })
73
- });
74
- if (page.isPending) return /* @__PURE__ */ jsx(Loading, {});
75
- if (page.error) return /* @__PURE__ */ jsx(ErrorBanner, {
76
- title: "Could not load",
77
- error: page.error
78
- });
79
- const rows = page.data.items;
80
- if (rows.length === 0 && tokens.length === 1) return /* @__PURE__ */ jsx(Empty, { text: item.empty });
81
- const columns = (item.columns ?? defaultColumns(model, views[model.name]).filter((name) => name !== item.field)).filter((name) => name.includes(".") || Object.hasOwn(model.fields, name));
82
- const next = page.data.nextPageToken;
83
- return /* @__PURE__ */ jsx(RecordTable, {
84
- model,
85
- columns,
86
- rows,
87
- label: item.title,
88
- pager: {
89
- page: tokens.length,
90
- hasPrevious: tokens.length > 1,
91
- hasNext: Boolean(next),
92
- busy: page.isFetching,
93
- previous: () => setTokens((stack) => stack.slice(0, -1)),
94
- next: () => next && setTokens((stack) => [...stack, next])
95
- }
96
- });
97
- };
98
60
  const Loading = () => /* @__PURE__ */ jsx("div", {
99
61
  className: "flex flex-col gap-2",
100
62
  children: Array.from({ length: 3 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-full" }, index))
@@ -103,22 +65,38 @@ const Empty = ({ text }) => /* @__PURE__ */ jsx("p", {
103
65
  className: "text-[13px] text-muted-foreground",
104
66
  children: text ?? "Nothing here yet."
105
67
  });
106
- /** A record page section listing the records of another resource that point at this record, directly or through a join resource. */
107
- const RelatedSection = ({ item, recordKey }) => {
108
- const { resources } = useAdminMeta();
68
+ /**
69
+ * A record page section listing the first records of another resource that point at this record, directly or through
70
+ * a join resource, in the list page's table or display, with a link to them all in the list.
71
+ */
72
+ const RelatedSection = ({ item, recordKey, owner }) => {
73
+ const { resources, views } = useAdminMeta();
109
74
  const model = resources[item.resource];
75
+ const view = model && views[model.name];
110
76
  const match = useMatch(item, recordKey);
77
+ const [display, pick] = useEmbeddedDisplay(view, `related:${owner}:${item.title}`, item.display);
111
78
  if (!model) return null;
79
+ const columns = item.columns ?? listColumnNames(model, view).filter((name) => name !== item.field);
112
80
  return /* @__PURE__ */ jsx(Section, {
113
81
  title: item.title,
114
82
  help: item.help,
83
+ actions: /* @__PURE__ */ jsx(EmbeddedDisplaySwitch, {
84
+ view,
85
+ value: display,
86
+ onChange: pick
87
+ }),
115
88
  children: match.isPending ? /* @__PURE__ */ jsx(Loading, {}) : match.error ? /* @__PURE__ */ jsx(ErrorBanner, {
116
89
  title: "Could not load",
117
90
  error: match.error
118
- }) : match.filter === null || match.filter === void 0 ? /* @__PURE__ */ jsx(Empty, { text: item.empty }) : /* @__PURE__ */ jsx(RelatedRows, {
119
- item,
91
+ }) : match.filter === null || match.filter === void 0 ? /* @__PURE__ */ jsx(Empty, { text: item.empty }) : /* @__PURE__ */ jsx(EmbeddedList, {
120
92
  model,
121
- filter: match.filter
93
+ view,
94
+ filter: match.filter,
95
+ sort: item.sort ?? "",
96
+ columns,
97
+ limit: item.limit ?? 10,
98
+ display,
99
+ empty: item.empty
122
100
  }, match.filter)
123
101
  });
124
102
  };
@@ -85,6 +85,7 @@ const Router = ({ basePath = "", initialUrl, children }) => {
85
85
  path: parsed.pathname.slice(basePath.length) || "/",
86
86
  params: parsed.searchParams,
87
87
  basePath,
88
+ entry: position.current,
88
89
  navigate,
89
90
  registerGuard
90
91
  };
@@ -8,16 +8,14 @@ import { useHorizontalScroll } from "./use-horizontal-scroll.js";
8
8
  import { usePinnedColumns } from "./use-pinned-columns.js";
9
9
  import { GridHeader } from "./grid-header.js";
10
10
  import { DataRow, SkeletonRow } from "./grid-row.js";
11
- import { windowFor } from "./geometry.js";
12
- import { useGridScroll } from "./use-grid-scroll.js";
13
11
  import { usePagedRows } from "./use-paged-rows.js";
14
- import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
12
+ import { useVirtualRows } from "./use-virtual-rows.js";
13
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
15
14
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
16
- import { useVirtualizer } from "@tanstack/react-virtual";
17
15
  import { useTable } from "@tanstack/react-table";
18
16
  //#region src/data-grid/data-grid.tsx
19
17
  const headerHeight = 32;
20
- const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0, latencyMs = 150, sort: controlledSort, onSortChange, onOpenRow, rowActions, bulkActions, pinKey, defaultPinned, rowNumbers = true, compact = false, selectable = true, className }) => {
18
+ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0, onScrollIndex, latencyMs = 150, sort: controlledSort, onSortChange, onOpenRow, rowActions, bulkActions, pinKey, defaultPinned, rowNumbers = true, compact = false, selectable = true, fit = false, className }) => {
21
19
  const rowHeight = compact ? 28 : 36;
22
20
  const [pinned, setPinned] = usePinnedColumns(pinKey, defaultPinned);
23
21
  const columns = useMemo(() => {
@@ -52,45 +50,27 @@ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0,
52
50
  reverse,
53
51
  total
54
52
  ]);
55
- const { ref, onScroll, viewport, top, geometry } = useGridScroll(total, rowHeight, initialScrollIndex);
53
+ const { ref, onScroll, viewport, top, geometry, rows: virtualRows, first: firstIndex, last: lastIndex } = useVirtualRows({
54
+ total,
55
+ rowHeight,
56
+ initialIndex: initialScrollIndex,
57
+ inset: headerHeight
58
+ });
56
59
  const narrow = !useIsDesktop();
57
60
  const gutter = narrow || !rowNumbers ? 40 : gutterWidthFor(total);
58
61
  const bodyHeight = Math.max(0, viewport - headerHeight);
59
- const bodyRef = useRef(null);
60
- const offsetListener = useRef(null);
61
- const { base, count } = windowFor(total, top, rowHeight);
62
- const localOffset = top - base * rowHeight;
63
- const virtualizer = useVirtualizer({
64
- count,
65
- getScrollElement: () => bodyRef.current,
66
- estimateSize: () => rowHeight,
67
- overscan: 6,
68
- initialRect: {
69
- width: 0,
70
- height: bodyHeight
71
- },
72
- observeElementOffset: (_, callback) => {
73
- offsetListener.current = callback;
74
- callback(localOffset, false);
75
- return () => {
76
- offsetListener.current = null;
77
- };
78
- }
79
- });
80
- useLayoutEffect(() => offsetListener.current?.(localOffset, false), [localOffset, base]);
81
- const items = virtualizer.getVirtualItems();
82
- const firstIndex = base + (items[0]?.index ?? 0);
83
- const lastIndex = base + (items[items.length - 1]?.index ?? 0);
62
+ const topIndex = Math.floor(top / rowHeight);
63
+ useEffect(() => onScrollIndex?.(topIndex), [topIndex, onScrollIndex]);
84
64
  const getRow = usePagedRows({
85
65
  total,
86
66
  rowAt: sourceRowAt ? rowAt : void 0,
87
67
  loadRows: source.loadRows,
88
68
  latencyMs
89
69
  }, firstIndex, lastIndex);
90
- const loaded = items.map((item) => ({
91
- item,
92
- index: base + item.index,
93
- row: getRow(base + item.index)
70
+ const loaded = virtualRows.map(({ index, offset }) => ({
71
+ index,
72
+ offset,
73
+ row: getRow(index)
94
74
  }));
95
75
  const data = useMemo(() => loaded.flatMap(({ row }) => row ? [row] : []), [
96
76
  getRow,
@@ -143,6 +123,7 @@ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0,
143
123
  const last = Math.min(total, first + visibleRows - 1);
144
124
  return /* @__PURE__ */ jsxs("div", {
145
125
  className: cn("flex min-h-0 flex-col overflow-hidden rounded-lg border bg-background", className),
126
+ style: fit ? { height: headerHeight + total * rowHeight + 2 } : void 0,
146
127
  children: [/* @__PURE__ */ jsx("div", {
147
128
  ref,
148
129
  onScroll,
@@ -175,14 +156,13 @@ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0,
175
156
  selectedCount,
176
157
  onClearSelection: () => setRowSelection({})
177
158
  }), /* @__PURE__ */ jsx("div", {
178
- ref: bodyRef,
179
159
  className: "relative min-h-0 flex-1 overflow-clip",
180
- children: loaded.map(({ item, index, row }) => {
160
+ children: loaded.map(({ index, offset, row }) => {
181
161
  const frame = {
182
162
  specs: columns,
183
163
  gutter,
184
164
  index,
185
- offset: item.start - localOffset,
165
+ offset,
186
166
  height: rowHeight,
187
167
  selectable,
188
168
  trailing,
@@ -217,7 +197,7 @@ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0,
217
197
  })] })
218
198
  })
219
199
  })
220
- }), /* @__PURE__ */ jsx(GridFooter, {
200
+ }), !fit && /* @__PURE__ */ jsx(GridFooter, {
221
201
  total,
222
202
  first,
223
203
  last,
@@ -1,8 +1,6 @@
1
1
  //#region src/data-grid/geometry.ts
2
2
  /** Browsers cap element height (about 17M px in the strictest); stay well below it. */
3
3
  const maxContentHeight = 1e7;
4
- const chunk = 2e3;
5
- const windowSize = 5e3;
6
4
  const gridGeometry = (total, rowHeight, viewportHeight) => {
7
5
  const trueHeight = total * rowHeight;
8
6
  const contentHeight = Math.min(trueHeight, maxContentHeight);
@@ -19,14 +17,14 @@ const gridGeometry = (total, rowHeight, viewportHeight) => {
19
17
  const topFromScroll = (geometry, scrollTop) => geometry.maxScroll === 0 ? 0 : scrollTop / geometry.maxScroll * geometry.maxTop;
20
18
  const scrollFromTop = (geometry, top) => geometry.maxTop === 0 ? 0 : top / geometry.maxTop * geometry.maxScroll;
21
19
  const clampTop = (geometry, top) => Math.min(Math.max(0, top), geometry.maxTop);
22
- /** The slice of rows handed to the virtualizer; it moves in whole chunks so row measurements stay small. */
23
- const windowFor = (total, top, rowHeight) => {
24
- const topRow = Math.floor(top / rowHeight);
25
- const base = Math.min(Math.max(0, Math.floor(topRow / chunk) * chunk - chunk), Math.max(0, total - windowSize));
20
+ /** The rows drawn for a scroll offset: those in a viewport of `height`, and `overscan` more on each side. */
21
+ const rowsInView = (total, top, rowHeight, height, overscan) => {
22
+ const first = Math.max(0, Math.floor(top / rowHeight) - overscan);
23
+ const last = Math.min(total - 1, Math.floor((top + height) / rowHeight) + overscan);
26
24
  return {
27
- base,
28
- count: Math.min(windowSize, total - base)
25
+ first,
26
+ last: Math.max(first - 1, last)
29
27
  };
30
28
  };
31
29
  //#endregion
32
- export { clampTop, gridGeometry, maxContentHeight, scrollFromTop, topFromScroll, windowFor };
30
+ export { clampTop, gridGeometry, maxContentHeight, rowsInView, scrollFromTop, topFromScroll };
@@ -64,6 +64,12 @@ const useGridScroll = (total, rowHeight, initialIndex) => {
64
64
  element.addEventListener("wheel", onWheel, { passive: false });
65
65
  return () => element.removeEventListener("wheel", onWheel);
66
66
  }, [syncNative]);
67
+ /** Moves to a virtual offset, for a layout change that keeps a row in view. */
68
+ const scrollTo = useCallback((nextTop) => {
69
+ const next = clampTop(latest.current.geometry, nextTop);
70
+ setTop(next);
71
+ syncNative(next);
72
+ }, [syncNative]);
67
73
  return {
68
74
  ref,
69
75
  onScroll: useCallback(() => {
@@ -73,7 +79,8 @@ const useGridScroll = (total, rowHeight, initialIndex) => {
73
79
  }, []),
74
80
  viewport,
75
81
  top,
76
- geometry
82
+ geometry,
83
+ scrollTo
77
84
  };
78
85
  };
79
86
  //#endregion
@@ -0,0 +1,31 @@
1
+ import { rowsInView } from "./geometry.js";
2
+ import { useGridScroll } from "./use-grid-scroll.js";
3
+ //#region src/data-grid/use-virtual-rows.ts
4
+ const overscan = 6;
5
+ /**
6
+ * Rows of a fixed height over a proxy scroller, so any number of rows scrolls at native speed: `ref` and `onScroll` go
7
+ * on the scrolling element, and each drawn row sits at `offset` from the top of the viewport's rows, below `inset`.
8
+ * `first` and `last` are the indexes of the drawn rows, overscan included.
9
+ */
10
+ const useVirtualRows = ({ total, rowHeight, initialIndex, inset = 0, settle }) => {
11
+ const scroll = useGridScroll(total, rowHeight, initialIndex);
12
+ const { ref, onScroll, viewport, geometry, scrollTo } = scroll;
13
+ const top = settle ? settle(scroll.top) : scroll.top;
14
+ const { first, last } = rowsInView(total, top, rowHeight, Math.max(0, viewport - inset), overscan);
15
+ return {
16
+ ref,
17
+ onScroll,
18
+ viewport,
19
+ top,
20
+ geometry,
21
+ scrollTo,
22
+ rows: Array.from({ length: last - first + 1 }, (_, offset) => ({
23
+ index: first + offset,
24
+ offset: (first + offset) * rowHeight - top
25
+ })),
26
+ first,
27
+ last
28
+ };
29
+ };
30
+ //#endregion
31
+ export { useVirtualRows };
@@ -61,9 +61,11 @@ const kindOf = (field, view, index) => {
61
61
  }
62
62
  };
63
63
  const currencyCents = (value) => Math.round(Number.parseFloat(String(value)) * 100);
64
- /** Grid columns for a view's list: kinds follow the field types and the view's format hints. */
65
- const columnsFromModel = (resource, view, highlight = []) => {
66
- return (view?.list?.columns ?? Object.keys(resource.fields).slice(0, 6)).flatMap((name, index) => {
64
+ /** The fields a view's list shows as columns: its list columns, else the resource's first six fields. */
65
+ const listColumnNames = (resource, view) => view?.list?.columns ?? Object.keys(resource.fields).slice(0, 6);
66
+ /** Grid columns for `names`, by default the view's list: kinds follow the field types and the view's format hints. */
67
+ const columnsFromModel = (resource, view, highlight = [], names = listColumnNames(resource, view)) => {
68
+ return names.flatMap((name, index) => {
67
69
  const field = resource.fields[name];
68
70
  if (!field) return [];
69
71
  const viewField = view?.fields[name];
@@ -97,4 +99,4 @@ const columnsFromModel = (resource, view, highlight = []) => {
97
99
  });
98
100
  };
99
101
  //#endregion
100
- export { columnsFromModel, headerWidth, toneFor };
102
+ export { columnsFromModel, headerWidth, listColumnNames, toneFor };
@@ -4,11 +4,12 @@ import { withLabels } from "./relation-labels.js";
4
4
  //#region src/live/grid-loader.ts
5
5
  const exactBelow = 5e3;
6
6
  /**
7
- * Loads fixed-size pages by number. Sequential scrolling follows `next_page_token`; a jump asks `:seek` for the
8
- * page at that row position. Every page is cached by TanStack Query.
7
+ * Loads fixed-size pages by number. The page after a cached or loading one follows that page's `next_page_token`; a
8
+ * jump to a page whose predecessor is neither asks `:seek` for the page at that row position, so the pages of one jump
9
+ * are read on from a single seek. Every page is cached by TanStack Query.
9
10
  */
10
11
  const createGridLoader = ({ queryClient, client, resources, views, model, columns, filter, orderBy }) => {
11
- const tokens = /* @__PURE__ */ new Map();
12
+ const pageKey = (number) => keys.page(model.name, filter, orderBy, 40, number);
12
13
  /** The server's total is a planner estimate, off by a lot for small results; settle those exactly. */
13
14
  const firstPageTotal = async (result) => {
14
15
  if (result.nextPageToken === "") return result.items.length;
@@ -20,36 +21,51 @@ const createGridLoader = ({ queryClient, client, resources, views, model, column
20
21
  count: "exact"
21
22
  })).totalSize ?? result.totalSizeEstimate;
22
23
  };
24
+ /** The token that continues after the page before `number`, or undefined when that page is neither cached nor loading. */
25
+ const tokenBefore = async (number) => {
26
+ if (number === 0) return "";
27
+ const key = pageKey(number - 1);
28
+ const cached = queryClient.getQueryData(key);
29
+ if (cached) return cached.nextPageToken;
30
+ if (queryClient.getQueryState(key)?.fetchStatus !== "fetching") return void 0;
31
+ return (await page(number - 1)).nextPageToken;
32
+ };
23
33
  const fetchPage = async (page) => {
24
- const token = tokens.get(page);
34
+ const token = await tokenBefore(page);
35
+ if (page > 0 && token === "") return {
36
+ rows: [],
37
+ total: 0,
38
+ nextPageToken: ""
39
+ };
25
40
  const params = {
26
41
  filter,
27
42
  orderBy,
28
43
  pageSize: 40
29
44
  };
30
- const result = page === 0 || token !== void 0 ? await client.list(model.name, {
45
+ const result = token !== void 0 ? await client.list(model.name, {
31
46
  ...params,
32
- pageToken: token ?? ""
47
+ pageToken: token
33
48
  }) : await client.seek(model.name, {
34
49
  ...params,
35
50
  position: page * 40
36
51
  });
37
52
  if (result.items.length === 0 && page > 0) return {
38
53
  rows: [],
39
- total: 0
54
+ total: 0,
55
+ nextPageToken: ""
40
56
  };
41
- tokens.set(page + 1, result.nextPageToken);
42
57
  const ids = result.items.map((record) => recordId(model, record));
43
58
  return {
44
59
  rows: (await withLabels(queryClient, client, resources, views, model, columns, result.items)).map((record, index) => ({
45
60
  ...record,
46
61
  id: ids[index]
47
62
  })),
48
- total: page === 0 ? await firstPageTotal(result) : result.totalSizeEstimate
63
+ total: page === 0 ? await firstPageTotal(result) : result.totalSizeEstimate,
64
+ nextPageToken: result.nextPageToken
49
65
  };
50
66
  };
51
67
  const page = (number) => queryClient.fetchQuery({
52
- queryKey: keys.page(model.name, filter, orderBy, 40, number),
68
+ queryKey: pageKey(number),
53
69
  staleTime: 6e4,
54
70
  queryFn: () => fetchPage(number)
55
71
  });
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.12.22";
2
+ var version = "0.13.0";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };
@@ -1,21 +1,28 @@
1
1
  import { jsx, jsxs } from "react/jsx-runtime";
2
2
  //#region src/record-view/section.tsx
3
- const Section = ({ title, help, children }) => /* @__PURE__ */ jsxs("section", {
4
- className: "py-8 first:pt-8",
5
- children: [
6
- /* @__PURE__ */ jsx("h2", {
7
- className: "text-[15px] font-semibold text-foreground",
8
- children: title
9
- }),
10
- help && /* @__PURE__ */ jsx("p", {
11
- className: "mt-1 max-w-xl text-[13px] leading-relaxed text-muted-foreground",
12
- children: help
13
- }),
14
- /* @__PURE__ */ jsx("div", {
15
- className: "mt-5",
16
- children
17
- })
18
- ]
19
- });
3
+ /** A titled part of the record page; `actions` sit at the end of the title's line. */
4
+ const Section = ({ title, help, actions, children }) => {
5
+ const heading = /* @__PURE__ */ jsx("h2", {
6
+ className: "text-[15px] font-semibold text-foreground",
7
+ children: title
8
+ });
9
+ return /* @__PURE__ */ jsxs("section", {
10
+ className: "py-8 first:pt-8",
11
+ children: [
12
+ actions ? /* @__PURE__ */ jsxs("div", {
13
+ className: "flex flex-wrap items-center justify-between gap-x-4 gap-y-2",
14
+ children: [heading, actions]
15
+ }) : heading,
16
+ help && /* @__PURE__ */ jsx("p", {
17
+ className: "mt-1 max-w-xl text-[13px] leading-relaxed text-muted-foreground",
18
+ children: help
19
+ }),
20
+ /* @__PURE__ */ jsx("div", {
21
+ className: "mt-5",
22
+ children
23
+ })
24
+ ]
25
+ });
26
+ };
20
27
  //#endregion
21
28
  export { Section };