@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,19 @@
1
+ /** How many cards fit across `width`, as a grid of `repeat(auto-fill, minmax(15rem, 1fr))` would place them. */
2
+ const cardColumns = (width) => Math.max(1, Math.floor((width + 12) / 252));
3
+ const cardWidth = (width, columns) => Math.max(0, (width - (columns - 1) * 12) / columns);
4
+ /** The records the card rows `first` to `last` hold, as a first and last record index. */
5
+ const recordRange = (first, last, columns, total) => ({
6
+ start: first * columns,
7
+ end: Math.max(0, Math.min(total, (last + 1) * columns) - 1)
8
+ });
9
+ /**
10
+ * The scroll offset after a layout change that keeps the same records in view: the same place in the same row when
11
+ * only the row height changed, else the row now holding the first record of the row that was at the top.
12
+ */
13
+ const anchoredTop = (top, before, after) => {
14
+ const row = top / before.rowHeight;
15
+ if (before.columns === after.columns) return row * after.rowHeight;
16
+ return Math.floor(Math.floor(row) * before.columns / after.columns) * after.rowHeight;
17
+ };
18
+ //#endregion
19
+ export { anchoredTop, cardColumns, cardWidth, recordRange };
@@ -1,12 +1,14 @@
1
+ import { cn } from "../../lib/cn.js";
1
2
  import { Skeleton } from "../../primitives/skeleton.js";
2
3
  import { humanize } from "../../live/naming.js";
3
- import { DisplayFooter, DisplayState } from "./display-frame.js";
4
4
  import { RecordCard } from "./record-card.js";
5
+ import { DisplayFooter, DisplayState } from "./display-frame.js";
5
6
  import { jsx, jsxs } from "react/jsx-runtime";
6
7
  //#region src/app/displays/cards-display.tsx
7
8
  const grid = "grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-3";
8
- /** A card per record (see `RecordCard`), in a grid, with how many match and a button for more below. */
9
- const CardsDisplay = ({ model, view, display, list }) => {
9
+ const scroller = "flex snap-x snap-mandatory gap-3 overflow-x-auto pb-1";
10
+ /** A card per record (see `RecordCard`), in a grid or a row, with how many match and a button for more below. */
11
+ const CardsDisplay = ({ model, view, display, list, row = false, footer = true }) => {
10
12
  const plural = (view?.names?.plural ?? humanize(model.name)).toLowerCase();
11
13
  return /* @__PURE__ */ jsxs(DisplayState, {
12
14
  list,
@@ -16,15 +18,18 @@ const CardsDisplay = ({ model, view, display, list }) => {
16
18
  children: Array.from({ length: 6 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-24" }, index))
17
19
  }),
18
20
  children: [/* @__PURE__ */ jsx("ul", {
19
- className: grid,
21
+ className: row ? scroller : grid,
20
22
  "aria-label": view?.names?.plural ?? humanize(model.name),
21
- children: list.rows.map((row) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(RecordCard, {
22
- model,
23
- view,
24
- display,
25
- row
26
- }) }, row.key))
27
- }), /* @__PURE__ */ jsx(DisplayFooter, { list })]
23
+ children: list.rows.map((record) => /* @__PURE__ */ jsx("li", {
24
+ className: cn(row && "w-64 shrink-0 snap-start"),
25
+ children: /* @__PURE__ */ jsx(RecordCard, {
26
+ model,
27
+ view,
28
+ display,
29
+ row: record
30
+ })
31
+ }, record.key))
32
+ }), footer && /* @__PURE__ */ jsx(DisplayFooter, { list })]
28
33
  });
29
34
  };
30
35
  //#endregion
@@ -0,0 +1,50 @@
1
+ import { Skeleton } from "../../primitives/skeleton.js";
2
+ import { humanize } from "../../live/naming.js";
3
+ import { displayRow } from "./list-displays.js";
4
+ import { ErrorBanner } from "../error-banner.js";
5
+ import { RecordCard } from "./record-card.js";
6
+ import { VirtualCards } from "./virtual-cards.js";
7
+ import { jsx, jsxs } from "react/jsx-runtime";
8
+ //#region src/app/displays/list-cards.tsx
9
+ const grid = "grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-3";
10
+ const pictureHeight = (width) => width * .75;
11
+ const plainHeight = 44;
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`.
15
+ */
16
+ const ListCards = ({ model, view, display, list }) => {
17
+ const plural = view?.names?.plural ?? humanize(model.name);
18
+ const pictured = display.card === void 0 && (display.image !== void 0 || display.photos !== void 0);
19
+ if (list.isLoading) return /* @__PURE__ */ jsx("div", {
20
+ className: `min-h-0 flex-1 overflow-hidden ${grid}`,
21
+ children: Array.from({ length: 6 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-24" }, index))
22
+ });
23
+ if (list.error) return /* @__PURE__ */ jsx(ErrorBanner, {
24
+ title: "Could not load rows",
25
+ error: list.error
26
+ });
27
+ if (!list.source || list.source.total === 0) return /* @__PURE__ */ jsxs("p", {
28
+ className: "py-8 text-center text-[13px] text-muted-foreground",
29
+ children: [
30
+ "No ",
31
+ plural.toLowerCase(),
32
+ " match."
33
+ ]
34
+ });
35
+ return /* @__PURE__ */ jsx(VirtualCards, {
36
+ source: list.source,
37
+ label: plural,
38
+ initialIndex: list.initialIndex,
39
+ onScrollIndex: list.onScrollIndex,
40
+ minHeight: (width) => (pictured ? pictureHeight(width) : 0) + plainHeight,
41
+ children: (row) => /* @__PURE__ */ jsx(RecordCard, {
42
+ model,
43
+ view,
44
+ display,
45
+ row: displayRow(model, row)
46
+ })
47
+ });
48
+ };
49
+ //#endregion
50
+ export { ListCards };
@@ -6,10 +6,11 @@ import { encodeKey, tableDisplay } from "@protobase/schema";
6
6
  const displaysOf = (view) => view?.list?.displays ?? [];
7
7
  /**
8
8
  * The display a list opens in when its address names none. Below the `sm` breakpoint a list with a cards display opens
9
- * in one: the view's default when that is a cards display, else the first. Otherwise the view's default, or the table.
9
+ * in one: `preferred` or the view's default when that is a cards display, else the first. Otherwise `preferred`, the
10
+ * view's default, or the table.
10
11
  */
11
- const defaultDisplay = (view, xs) => {
12
- const fallback = view?.list?.default ?? tableDisplay;
12
+ const defaultDisplay = (view, xs, preferred) => {
13
+ const fallback = preferred ?? view?.list?.default ?? tableDisplay;
13
14
  if (!xs) return fallback;
14
15
  const cards = displaysOf(view).filter((display) => display.kind === "cards");
15
16
  return cards.find((display) => display.name === fallback)?.name ?? cards[0]?.name ?? fallback;
@@ -3,8 +3,8 @@ import { humanize } from "../../live/naming.js";
3
3
  import { mapCards } from "./list-displays.js";
4
4
  import { useAdminMeta } from "../meta-gate.js";
5
5
  import { Problem } from "../pages/problem.js";
6
- import { DisplayFooter, DisplayState } from "./display-frame.js";
7
6
  import { RecordCard } from "./record-card.js";
7
+ import { DisplayFooter, DisplayState } from "./display-frame.js";
8
8
  import { keptSelection, mapPoints, stepSelection } from "./map-groups.js";
9
9
  import { Suspense, lazy, useLayoutEffect, useRef, useState } from "react";
10
10
  import { ChevronLeft, ChevronRight, X } from "lucide-react";
@@ -17,7 +17,7 @@ const arrow = "flex size-8 items-center justify-center rounded-full border borde
17
17
  * record at the map's bottom, with arrows through the records the marker stands for; closing it or choosing the map
18
18
  * elsewhere clears it. Below the map, how many of the matching records are on it and a button for more.
19
19
  */
20
- const MapDisplay = ({ model, view, display, list }) => {
20
+ const MapDisplay = ({ model, view, display, list, footer = true }) => {
21
21
  const style = useAdminMeta().maps?.style;
22
22
  const [chosen, setChosen] = useState();
23
23
  const cards = mapCards(view, display);
@@ -112,7 +112,7 @@ const MapDisplay = ({ model, view, display, list }) => {
112
112
  })]
113
113
  })
114
114
  })]
115
- }), /* @__PURE__ */ jsx(DisplayFooter, { list })]
115
+ }), footer && /* @__PURE__ */ jsx(DisplayFooter, { list })]
116
116
  })
117
117
  });
118
118
  };
@@ -0,0 +1,138 @@
1
+ import { Skeleton } from "../../primitives/skeleton.js";
2
+ import { usePagedRows } from "../../data-grid/use-paged-rows.js";
3
+ import { useVirtualRows } from "../../data-grid/use-virtual-rows.js";
4
+ import { anchoredTop, cardColumns, cardWidth, recordRange } from "./card-layout.js";
5
+ import { useCallback, useLayoutEffect, useRef, useState } from "react";
6
+ import { jsx, jsxs } from "react/jsx-runtime";
7
+ //#region src/app/displays/virtual-cards.tsx
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.
12
+ */
13
+ const VirtualCards = ({ source, label, initialIndex, onScrollIndex, minHeight, children }) => {
14
+ const { total } = source;
15
+ const [width, setWidth] = useState(0);
16
+ const columns = cardColumns(width);
17
+ const itemWidth = cardWidth(width, columns);
18
+ const [measured, setMeasured] = useState({
19
+ width: 0,
20
+ height: 0
21
+ });
22
+ const itemHeight = Math.ceil(Math.max(minHeight(itemWidth), measured.width === itemWidth ? measured.height : 0));
23
+ const rowHeight = itemHeight + 12;
24
+ const layout = {
25
+ columns,
26
+ rowHeight
27
+ };
28
+ const anchor = useRef({
29
+ layout,
30
+ top: 0
31
+ });
32
+ const changed = anchor.current.layout.columns !== columns || anchor.current.layout.rowHeight !== rowHeight;
33
+ const settle = (top) => changed ? anchoredTop(anchor.current.top, anchor.current.layout, layout) : top;
34
+ const scroll = useVirtualRows({
35
+ total: Math.ceil(total / columns),
36
+ rowHeight,
37
+ initialIndex: Math.floor(initialIndex / columns),
38
+ settle
39
+ });
40
+ useLayoutEffect(() => {
41
+ if (changed) scroll.scrollTo(scroll.top);
42
+ anchor.current = {
43
+ layout,
44
+ top: scroll.top
45
+ };
46
+ if (!changed) onScrollIndex(Math.floor(scroll.top / rowHeight) * columns);
47
+ });
48
+ const range = recordRange(scroll.first, scroll.last, columns, total);
49
+ const getRow = usePagedRows({
50
+ total,
51
+ rowAt: source.rowAt,
52
+ loadRows: source.loadRows,
53
+ latencyMs: 0
54
+ }, range.start, range.end);
55
+ const bodyRef = useRef(null);
56
+ useLayoutEffect(() => {
57
+ const body = bodyRef.current;
58
+ if (!body) return;
59
+ const observer = new ResizeObserver(() => setWidth(body.clientWidth));
60
+ observer.observe(body);
61
+ setWidth(body.clientWidth);
62
+ return () => observer.disconnect();
63
+ }, []);
64
+ const widthRef = useRef(itemWidth);
65
+ widthRef.current = itemWidth;
66
+ const [observer] = useState(() => new ResizeObserver((entries) => {
67
+ const tallest = Math.max(...entries.map((entry) => entry.borderBoxSize[0]?.blockSize ?? entry.contentRect.height));
68
+ const at = widthRef.current;
69
+ setMeasured((current) => current.width === at && current.height >= tallest ? current : {
70
+ width: at,
71
+ height: Math.max(tallest, current.width === at ? current.height : 0)
72
+ });
73
+ }));
74
+ useLayoutEffect(() => () => observer.disconnect(), [observer]);
75
+ const observe = useCallback((item) => {
76
+ if (!item) return;
77
+ observer.observe(item);
78
+ return () => observer.unobserve(item);
79
+ }, [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
+ })]
135
+ });
136
+ };
137
+ //#endregion
138
+ export { VirtualCards };
@@ -0,0 +1,52 @@
1
+ import { humanize } from "../../live/naming.js";
2
+ import { displayRow } from "../displays/list-displays.js";
3
+ import { columnsFromModel } from "../../live/columns-from-model.js";
4
+ import { withFieldTypeCells } from "../field-types/field-type-cells.js";
5
+ import { scopeOf } from "../pages/use-layout-data.js";
6
+ import { FieldValue } from "../pages/field-value.js";
7
+ import { usePathValue } from "../pages/use-path-value.js";
8
+ import { jsx } from "react/jsx-runtime";
9
+ import { resolveFieldPath } from "@protobase/layout";
10
+ //#region src/app/embedded-list/embedded-columns.tsx
11
+ const isPath = (column) => column.includes(".");
12
+ /** A value through relations (`unit_id.property_id`), fetched like the record page fetches it and cached. */
13
+ const PathCell = ({ model, row, column }) => {
14
+ const value = usePathValue(scopeOf(model, displayRow(model, row).record), column);
15
+ if (value.state === "loading") return /* @__PURE__ */ jsx("span", {
16
+ className: "text-muted-foreground",
17
+ children: "…"
18
+ });
19
+ if (value.state === "missing") return /* @__PURE__ */ jsx("span", {
20
+ className: "text-muted-foreground",
21
+ children: "—"
22
+ });
23
+ return /* @__PURE__ */ jsx(FieldValue, {
24
+ model: value.model,
25
+ field: value.field,
26
+ value: value.value
27
+ });
28
+ };
29
+ /** A column through relations, headed by the label of the field it ends at. */
30
+ const pathColumn = (model, column, { resources, views }) => {
31
+ const resolved = resolveFieldPath(resources, model.name, column);
32
+ const last = resolved.ok ? resolved.steps.at(-1) : void 0;
33
+ const name = last?.field.name ?? column;
34
+ return {
35
+ id: column,
36
+ header: views[last?.model.name ?? model.name]?.fields[name]?.label ?? humanize(name),
37
+ kind: "text",
38
+ width: 200,
39
+ render: (row) => /* @__PURE__ */ jsx(PathCell, {
40
+ model,
41
+ row,
42
+ column
43
+ })
44
+ };
45
+ };
46
+ /** The list page's columns for `names`: fields of `model` as its table draws them, and paths through relations. */
47
+ const embeddedColumns = (model, view, names, meta) => {
48
+ const fields = new Map(withFieldTypeCells(columnsFromModel(model, view, [], names.filter((name) => !isPath(name))), model, view, meta.fieldTypes).map((spec) => [spec.id, spec]));
49
+ return names.flatMap((name) => isPath(name) ? [pathColumn(model, name, meta)] : fields.get(name) ?? []);
50
+ };
51
+ //#endregion
52
+ export { embeddedColumns };
@@ -0,0 +1,26 @@
1
+ import { guarded } from "../../lib/guarded.js";
2
+ import { defaultDisplay, displayChoices } from "../displays/list-displays.js";
3
+ import { changedList, listDefaults } from "../current-address.js";
4
+ //#region src/app/embedded-list/embedded-display.ts
5
+ const prefix = "protobase:embedded-display:";
6
+ /** Where a viewer's pick of display is kept for one embedded list, named by the place it is shown. */
7
+ const embeddedDisplayKey = (place) => `${prefix}${place}`;
8
+ const readPickedDisplay = (storage, key) => guarded(() => storage.getItem(key) ?? void 0, void 0);
9
+ const writePickedDisplay = (storage, key, display) => guarded(() => storage.setItem(key, display), void 0);
10
+ /**
11
+ * The display an embedded list shows: the viewer's pick while the view still has it, else what the list opens in for
12
+ * the screen, with the display the page names in place of the view's default (below `sm`, a cards display when the
13
+ * view has one).
14
+ */
15
+ const embeddedDisplay = (view, xs, { picked, preferred }) => {
16
+ const names = displayChoices(view).map((choice) => choice.name);
17
+ if (picked !== void 0 && names.includes(picked)) return picked;
18
+ return defaultDisplay(view, xs, preferred !== void 0 && names.includes(preferred) ? preferred : void 0);
19
+ };
20
+ /** The resource's list with the embedded list's filter, order and display, leaving out what the list does by default. */
21
+ const viewAllAddress = (model, view, xs, list) => changedList({
22
+ kind: "list",
23
+ resource: model.name
24
+ }, list, listDefaults(model, view, xs));
25
+ //#endregion
26
+ export { embeddedDisplay, embeddedDisplayKey, readPickedDisplay, viewAllAddress, writePickedDisplay };
@@ -0,0 +1,155 @@
1
+ import { useIsDesktop, useIsXs } from "../../lib/use-media-query.js";
2
+ import { Skeleton } from "../../primitives/skeleton.js";
3
+ import { recordKey } from "../../live/model-helpers.js";
4
+ import { humanize } from "../../live/naming.js";
5
+ import { useProjectUi } from "../pages/project-ui.js";
6
+ import { displayRow, loadedFields, shownDisplay } from "../displays/list-displays.js";
7
+ import { defaultOrderBy, parseSort, sortToOrderBy } from "../list-order.js";
8
+ import { useAdminMeta } from "../meta-gate.js";
9
+ import { AppLink, useAppAddress } from "../use-app-address.js";
10
+ import { ErrorBanner } from "../error-banner.js";
11
+ import { DataGrid } from "../../data-grid/data-grid.js";
12
+ import { listColumnNames } from "../../live/columns-from-model.js";
13
+ import { MapDisplay } from "../displays/map-display.js";
14
+ import { CustomDisplay } from "../displays/custom-display.js";
15
+ import { DisplaySwitch } from "../displays/display-controls.js";
16
+ import { CardsDisplay } from "../displays/cards-display.js";
17
+ import { embeddedColumns } from "./embedded-columns.js";
18
+ import { viewAllAddress } from "./embedded-display.js";
19
+ import { useEmbeddedRows } from "./use-embedded-rows.js";
20
+ import { useMemo, useState } from "react";
21
+ import { ArrowRight } from "lucide-react";
22
+ import { jsx, jsxs } from "react/jsx-runtime";
23
+ //#region src/app/embedded-list/embedded-list.tsx
24
+ /** The switch between the table and the view's displays, for an embedded list's heading; nothing with only a table. */
25
+ const EmbeddedDisplaySwitch = ({ view, value, onChange }) => /* @__PURE__ */ jsx(DisplaySwitch, {
26
+ view,
27
+ value,
28
+ onChange,
29
+ menu: !useIsDesktop()
30
+ });
31
+ const Loading = () => /* @__PURE__ */ jsx("div", {
32
+ className: "flex flex-col gap-2",
33
+ children: Array.from({ length: 3 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-full" }, index))
34
+ });
35
+ /**
36
+ * The first `limit` records of a list, drawn by the list page's own table (sized to its rows) or display, and a link
37
+ * to the whole list with the same filter, order and display. On a phone a cards display is one row that scrolls sideways.
38
+ */
39
+ const EmbeddedList = ({ model, view, filter, sort, columns, limit, display, empty }) => {
40
+ const { resources, views } = useAdminMeta();
41
+ const { fields: fieldTypes } = useProjectUi();
42
+ const { go } = useAppAddress();
43
+ const xs = useIsXs();
44
+ const [orderBy, setOrderBy] = useState(sort || defaultOrderBy(model, view));
45
+ const names = columns ?? listColumnNames(model, view);
46
+ const specs = useMemo(() => embeddedColumns(model, view, names, {
47
+ resources,
48
+ views,
49
+ fieldTypes
50
+ }), [
51
+ model,
52
+ view,
53
+ names.join(","),
54
+ resources,
55
+ views,
56
+ fieldTypes
57
+ ]);
58
+ const fields = useMemo(() => loadedFields(model, view, specs.map((spec) => spec.id)), [
59
+ model,
60
+ view,
61
+ specs
62
+ ]);
63
+ const rows = useEmbeddedRows({
64
+ model,
65
+ filter,
66
+ orderBy,
67
+ limit,
68
+ fields
69
+ });
70
+ const loaded = rows.data;
71
+ const source = useMemo(() => loaded && {
72
+ total: loaded.length,
73
+ rowAt: (index) => loaded[index]
74
+ }, [loaded]);
75
+ const shown = shownDisplay(view, display);
76
+ if (rows.isPending) return /* @__PURE__ */ jsx(Loading, {});
77
+ if (rows.error) return /* @__PURE__ */ jsx(ErrorBanner, {
78
+ title: "Could not load",
79
+ error: rows.error
80
+ });
81
+ if (!source || source.total === 0) return /* @__PURE__ */ jsx("p", {
82
+ className: "text-[13px] text-muted-foreground",
83
+ children: empty ?? "Nothing here yet."
84
+ });
85
+ const list = {
86
+ resource: model.name,
87
+ rows: rows.data.map((row) => displayRow(model, row)),
88
+ total: rows.data.length,
89
+ loading: false,
90
+ error: null,
91
+ more: false,
92
+ loadMore: () => void 0
93
+ };
94
+ const openRecord = (row) => go({
95
+ kind: "record",
96
+ resource: model.name,
97
+ key: recordKey(model, row)
98
+ });
99
+ const singular = view?.names?.singular ?? humanize(model.name);
100
+ const plural = view?.names?.plural ?? humanize(model.name);
101
+ return /* @__PURE__ */ jsxs("div", {
102
+ className: "flex flex-col gap-3",
103
+ children: [!shown ? /* @__PURE__ */ jsx(DataGrid, {
104
+ fit: true,
105
+ columns: specs,
106
+ source,
107
+ latencyMs: 0,
108
+ sort: parseSort(orderBy),
109
+ onSortChange: (next) => setOrderBy(sortToOrderBy(next)),
110
+ onOpenRow: openRecord,
111
+ rowActions: [{
112
+ label: `Open ${singular.toLowerCase()}`,
113
+ onSelect: openRecord
114
+ }],
115
+ rowNumbers: false,
116
+ selectable: false
117
+ }) : shown.kind === "cards" ? /* @__PURE__ */ jsx(CardsDisplay, {
118
+ model,
119
+ view,
120
+ display: shown,
121
+ list,
122
+ row: xs,
123
+ footer: false
124
+ }) : shown.kind === "map" ? /* @__PURE__ */ jsx(MapDisplay, {
125
+ model,
126
+ view,
127
+ display: shown,
128
+ list,
129
+ footer: false
130
+ }) : /* @__PURE__ */ jsx(CustomDisplay, {
131
+ display: shown,
132
+ list
133
+ }), /* @__PURE__ */ jsxs(AppLink, {
134
+ to: viewAllAddress(model, view, xs, {
135
+ filter,
136
+ orderBy,
137
+ display
138
+ }),
139
+ className: "flex items-center gap-1 self-end text-[13px] font-medium text-primary-text hover:underline",
140
+ children: [
141
+ "View all ",
142
+ /* @__PURE__ */ jsx("span", {
143
+ className: "sr-only",
144
+ children: plural.toLowerCase()
145
+ }),
146
+ /* @__PURE__ */ jsx(ArrowRight, {
147
+ className: "size-3.5",
148
+ "aria-hidden": true
149
+ })
150
+ ]
151
+ })]
152
+ });
153
+ };
154
+ //#endregion
155
+ export { EmbeddedDisplaySwitch, EmbeddedList };
@@ -0,0 +1,23 @@
1
+ import { useIsXs } from "../../lib/use-media-query.js";
2
+ import { embeddedDisplay, embeddedDisplayKey, readPickedDisplay, writePickedDisplay } from "./embedded-display.js";
3
+ import { useCallback, useState } from "react";
4
+ //#region src/app/embedded-list/use-embedded-display.ts
5
+ /**
6
+ * The display an embedded list shows and the way to pick another, which is kept in local storage for the viewer under
7
+ * `place`, the list's place in the app.
8
+ */
9
+ const useEmbeddedDisplay = (view, place, preferred) => {
10
+ const xs = useIsXs();
11
+ const key = embeddedDisplayKey(place);
12
+ const [picked, setPicked] = useState(() => readPickedDisplay(localStorage, key));
13
+ const pick = useCallback((display) => {
14
+ setPicked(display);
15
+ writePickedDisplay(localStorage, key, display);
16
+ }, [key]);
17
+ return [embeddedDisplay(view, xs, {
18
+ picked,
19
+ preferred
20
+ }), pick];
21
+ };
22
+ //#endregion
23
+ export { useEmbeddedDisplay };
@@ -0,0 +1,33 @@
1
+ import { keys } from "../../data/query-keys.js";
2
+ import { useClient } from "../../data/api-provider.js";
3
+ import { recordId } from "../../live/model-helpers.js";
4
+ import { withLabels } from "../../live/relation-labels.js";
5
+ import { useAdminMeta } from "../meta-gate.js";
6
+ import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
7
+ //#region src/app/embedded-list/use-embedded-rows.ts
8
+ /**
9
+ * The first `limit` records in `orderBy` order, as the list page's table and displays get them: relations among
10
+ * `fields` as `{ id, name }`. A filter through a join resource may hold hundreds of keys, so it goes in a JSON body.
11
+ */
12
+ const useEmbeddedRows = ({ model, filter, orderBy, limit, fields }) => {
13
+ const client = useClient();
14
+ const queryClient = useQueryClient();
15
+ const { resources, views } = useAdminMeta();
16
+ return useQuery({
17
+ queryKey: [...keys.list(model.name, filter, orderBy, limit, "embedded"), fields.join(",")],
18
+ placeholderData: keepPreviousData,
19
+ queryFn: async () => {
20
+ const page = await client.search(model.name, {
21
+ filter: filter || void 0,
22
+ orderBy: orderBy || void 0,
23
+ pageSize: limit
24
+ });
25
+ return (await withLabels(queryClient, client, resources, views, model, fields, page.items)).map((row, index) => ({
26
+ ...row,
27
+ id: recordId(model, page.items[index])
28
+ }));
29
+ }
30
+ });
31
+ };
32
+ //#endregion
33
+ export { useEmbeddedRows };
@@ -26,11 +26,12 @@ import { useLiveRows } from "../live/use-live-rows.js";
26
26
  import { Sheet } from "../primitives/sheet.js";
27
27
  import { useDeleteFlow } from "./delete/use-delete-flow.js";
28
28
  import { useRestore } from "./delete/use-restore.js";
29
- import { CardsDisplay } from "./displays/cards-display.js";
29
+ import { ListCards } from "./displays/list-cards.js";
30
30
  import { MapDisplay } from "./displays/map-display.js";
31
31
  import { CustomDisplay } from "./displays/custom-display.js";
32
32
  import { DisplaySwitch, SortMenu } from "./displays/display-controls.js";
33
33
  import { useDisplayRows } from "./displays/use-display-rows.js";
34
+ import { useListScroll } from "./list-scroll.js";
34
35
  import { ListChart } from "./list-chart.js";
35
36
  import { ListSearch } from "./list-search.js";
36
37
  import { withFieldTypeCells } from "./field-types/field-type-cells.js";
@@ -98,7 +99,8 @@ const LoadedList = ({ model, address }) => {
98
99
  orderBy
99
100
  });
100
101
  const display = shownDisplay(view, address.display);
101
- const displayRows = useDisplayRows(model, rows.page, rows.key, display !== void 0);
102
+ const displayRows = useDisplayRows(model, rows.page, rows.key, display !== void 0 && display.kind !== "cards");
103
+ const scroll = useListScroll();
102
104
  const titleField = recordTitleField(model, view);
103
105
  const target = (row) => ({
104
106
  key: row.id,
@@ -234,6 +236,8 @@ const LoadedList = ({ model, address }) => {
234
236
  sort: parseSort(orderBy),
235
237
  onSortChange: (sort) => set({ orderBy: sortToOrderBy(sort) }),
236
238
  onOpenRow: openRecord,
239
+ initialScrollIndex: scroll.initialIndex,
240
+ onScrollIndex: scroll.remember,
237
241
  rowActions: [{
238
242
  label: `Open ${singular.toLowerCase()}`,
239
243
  onSelect: openRecord
@@ -252,14 +256,19 @@ const LoadedList = ({ model, address }) => {
252
256
  pinKey: auth.kind === "signed-in" ? pinnedStorageKey(auth.user.id, resource) : void 0,
253
257
  className: "min-h-0 flex-1"
254
258
  }) : /* @__PURE__ */ jsx("div", { className: "flex-1 rounded-lg border bg-surface" });
259
+ const cardsList = {
260
+ ...rows,
261
+ initialIndex: scroll.initialIndex,
262
+ onScrollIndex: scroll.remember
263
+ };
255
264
  const drawDisplay = () => {
256
265
  if (!display) return table;
257
- if (display.kind === "cards") return /* @__PURE__ */ jsx(CardsDisplay, {
266
+ if (display.kind === "cards") return /* @__PURE__ */ jsx(ListCards, {
258
267
  model,
259
268
  view,
260
269
  display,
261
- list: displayRows
262
- });
270
+ list: cardsList
271
+ }, rows.key.join(" "));
263
272
  if (display.kind === "map") return /* @__PURE__ */ jsx(MapDisplay, {
264
273
  model,
265
274
  view,