@protobase/ui 0.16.0 → 0.17.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 (42) hide show
  1. package/dist/app/current-address.d.ts +5 -1
  2. package/dist/app/current-address.js +2 -1
  3. package/dist/app/displays/record-card.js +28 -16
  4. package/dist/app/embedded-list/embedded-display.js +7 -3
  5. package/dist/app/embedded-list/embedded-list.js +5 -4
  6. package/dist/app/list-page.js +4 -2
  7. package/dist/app/pages/blocks/record-row.js +51 -0
  8. package/dist/app/pages/render-node.js +2 -0
  9. package/dist/app/record-row/record-row-body.js +114 -0
  10. package/dist/app/record-row/record-row.js +93 -0
  11. package/dist/app/record-row/row-window.js +20 -0
  12. package/dist/app/record-row/saved-record-row.js +42 -0
  13. package/dist/app/record-row/use-row-scroll.js +51 -0
  14. package/dist/app/searches/filter-chip-list.js +28 -0
  15. package/dist/app/searches/save-search-button.js +72 -8
  16. package/dist/app/searches/saved-search-rows.js +73 -0
  17. package/dist/app/searches/saved-search-section.js +91 -73
  18. package/dist/app/searches/saved-search.js +7 -3
  19. package/dist/app/searches/saved-searches.js +3 -5
  20. package/dist/app/searches/search-controls.js +13 -2
  21. package/dist/app/searches/search-diff.js +97 -0
  22. package/dist/app/searches/search-name-dialog.js +62 -0
  23. package/dist/app/searches/update-search-dialog.js +196 -0
  24. package/dist/app/searches/use-opened-search.js +15 -0
  25. package/dist/app/searches/use-save-search.js +64 -18
  26. package/dist/app/searches/use-search-row.js +56 -0
  27. package/dist/package.js +1 -1
  28. package/dist/primitives/dialog.js +3 -2
  29. package/dist/primitives/responsive-dialog.d.ts +3 -1
  30. package/dist/primitives/responsive-dialog.js +2 -1
  31. package/dist/shell/assets/app-BSKdnId2.js +66 -0
  32. package/dist/shell/assets/index-D0AzKE47.css +2 -0
  33. package/dist/shell/assets/{index-LBhMBiK4.js → index-DNc-xM_v.js} +1 -1
  34. package/dist/shell/assets/{maplibre-Dy0ZTnFZ.js → maplibre-1qVldvCU.js} +1 -1
  35. package/dist/shell/assets/{record-map-BiWSbqqT.js → record-map-BPHuPMlK.js} +1 -1
  36. package/dist/shell/assets/{records-map-DabYc3ad.js → records-map-BORhj6IH.js} +1 -1
  37. package/dist/shell/assets/{shared-protobase-ui-BURelnPG.js → shared-protobase-ui-CHRI5o6a.js} +1 -1
  38. package/dist/shell/index.html +4 -4
  39. package/dist/shell/protobase.shell.json +1 -1
  40. package/package.json +4 -4
  41. package/dist/shell/assets/app-DJAZP1yB.js +0 -66
  42. package/dist/shell/assets/index-C5pYkUfv.css +0 -2
@@ -1,6 +1,9 @@
1
1
  import { AppAddress, FilterLayout, MapView, ResourceModel, ViewModel } from "@protobase/schema";
2
2
  //#region src/app/current-address.d.ts
3
- /** A list as the app shows it: every value there, the view's default where the address names none; `map` has none. */
3
+ /**
4
+ * A list as the app shows it: every value there, the view's default where the address names none; `map` and
5
+ * `savedSearch`, the saved search it was opened from, have none.
6
+ */
4
7
  export type ListAddress = {
5
8
  kind: 'list';
6
9
  resource: string;
@@ -9,6 +12,7 @@ export type ListAddress = {
9
12
  orderBy: string;
10
13
  filterLayout: FilterLayout;
11
14
  map?: MapView;
15
+ savedSearch?: string;
12
16
  };
13
17
  /** The address the app shows, as logical values. A record's key is in the text form the record API takes. */
14
18
  export type CurrentAddress = ListAddress | {
@@ -54,7 +54,8 @@ const changedList = (read, change, defaults) => {
54
54
  ...differs("display") && { display: next.display },
55
55
  ...differs("orderBy") && { orderBy: next.orderBy },
56
56
  ...differs("filterLayout") && { filterLayout: next.filterLayout },
57
- ...next.map && { map: next.map }
57
+ ...next.map && { map: next.map },
58
+ ...next.savedSearch && { savedSearch: next.savedSearch }
58
59
  };
59
60
  };
60
61
  //#endregion
@@ -32,7 +32,7 @@ const MetaValue = ({ icon, children }) => {
32
32
  * value, joined by bullets; or, with a `card` component, what it draws of the row, the built-in content again when it
33
33
  * is missing or throws. With a favorite button, a heart in the top right corner saves the record.
34
34
  */
35
- const RecordCard = ({ model, view, display, row, swipe = true }) => {
35
+ const RecordCard = ({ model, view, display, row, swipe = true, mark }) => {
36
36
  const { image, title, subtitle, amount, badge, meta, footer, flags } = cardFields(model, view, display);
37
37
  const label = (name) => view?.fields[name]?.label ?? humanize(name);
38
38
  const text = (name) => {
@@ -135,32 +135,44 @@ const RecordCard = ({ model, view, display, row, swipe = true }) => {
135
135
  }), shownValue(name)] }, name))
136
136
  })
137
137
  ] });
138
- const card = (picture) => /* @__PURE__ */ jsx(AppLink, {
138
+ const card = (picture) => /* @__PURE__ */ jsxs(AppLink, {
139
139
  to: {
140
140
  kind: "record",
141
141
  resource: model.name,
142
142
  key: row.key
143
143
  },
144
144
  className: "flex h-full flex-col gap-1.5 rounded-lg border border-border bg-background p-3 text-[13px] hover:border-border-strong hover:bg-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30",
145
- children: Card ? /* @__PURE__ */ jsx(CustomBoundary, {
145
+ children: [mark && !pictured && /* @__PURE__ */ jsx(Badge, {
146
+ tone: "blue",
147
+ className: "self-start",
148
+ children: mark
149
+ }), Card ? /* @__PURE__ */ jsx(CustomBoundary, {
146
150
  name: display.card,
147
151
  fallback: builtIn(picture),
148
152
  children: /* @__PURE__ */ jsx(Card, { row })
149
- }) : builtIn(picture)
153
+ }) : builtIn(picture)]
150
154
  });
151
155
  return /* @__PURE__ */ jsxs("div", {
152
- className: photos ? "group/card relative h-full" : favorite ? "relative h-full" : "h-full",
153
- children: [photos ? /* @__PURE__ */ jsx(CardCarousel, {
154
- photos: row.photos ?? [],
155
- cover,
156
- swipe,
157
- children: card
158
- }, row.key) : card(/* @__PURE__ */ jsx(CardImage, { value: cover })), favorite && /* @__PURE__ */ jsx(FavoriteButton, {
159
- favorite,
160
- recordKey: row.key,
161
- target: recordKey(model, row.record),
162
- className: pictured ? void 0 : "right-1.5 top-1.5"
163
- })]
156
+ className: photos ? "group/card relative h-full" : favorite || mark ? "relative h-full" : "h-full",
157
+ children: [
158
+ photos ? /* @__PURE__ */ jsx(CardCarousel, {
159
+ photos: row.photos ?? [],
160
+ cover,
161
+ swipe,
162
+ children: card
163
+ }, row.key) : card(/* @__PURE__ */ jsx(CardImage, { value: cover })),
164
+ mark && pictured && /* @__PURE__ */ jsx(Badge, {
165
+ tone: "blue",
166
+ className: "pointer-events-none absolute top-2 left-2 shadow-sm",
167
+ children: mark
168
+ }),
169
+ favorite && /* @__PURE__ */ jsx(FavoriteButton, {
170
+ favorite,
171
+ recordKey: row.key,
172
+ target: recordKey(model, row.record),
173
+ className: pictured ? void 0 : "right-1.5 top-1.5"
174
+ })
175
+ ]
164
176
  });
165
177
  };
166
178
  //#endregion
@@ -17,10 +17,14 @@ const embeddedDisplay = (view, xs, { picked, preferred }) => {
17
17
  if (picked !== void 0 && names.includes(picked)) return picked;
18
18
  return defaultDisplay(view, xs, preferred !== void 0 && names.includes(preferred) ? preferred : void 0);
19
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({
20
+ /**
21
+ * The resource's list with the embedded list's filter, order and display, leaving out what the list does by default, and
22
+ * the saved search whose results it shows, if any. `resource` names another list of the same records, such as the
23
+ * favorites that list a user's saved listings.
24
+ */
25
+ const viewAllAddress = (model, view, xs, { resource = model.name, ...list }) => changedList({
22
26
  kind: "list",
23
- resource: model.name
27
+ resource
24
28
  }, list, listDefaults(model, view, xs));
25
29
  //#endregion
26
30
  export { embeddedDisplay, embeddedDisplayKey, readPickedDisplay, viewAllAddress, writePickedDisplay };
@@ -36,7 +36,7 @@ const Loading = () => /* @__PURE__ */ jsx("div", {
36
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
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
38
  */
39
- const EmbeddedList = ({ model, view, filter, sort, columns, limit, display, empty, row = false, viewAll }) => {
39
+ const EmbeddedList = ({ model, view, filter, sort, columns, limit, display, empty, row = false, viewAll, link = true }) => {
40
40
  const { resources, views } = useAdminMeta();
41
41
  const { fields: fieldTypes } = useProjectUi();
42
42
  const { go } = useAppAddress();
@@ -74,11 +74,12 @@ const EmbeddedList = ({ model, view, filter, sort, columns, limit, display, empt
74
74
  }, [loaded]);
75
75
  const shown = shownDisplay(view, display);
76
76
  const plural = view?.names?.plural ?? humanize(model.name);
77
- const allLink = /* @__PURE__ */ jsxs(AppLink, {
77
+ const allLink = link && /* @__PURE__ */ jsxs(AppLink, {
78
78
  to: viewAllAddress(model, view, xs, viewAll ? {
79
79
  filter: viewAll.filter,
80
80
  orderBy: defaultOrderBy(model, view),
81
- display
81
+ display,
82
+ savedSearch: viewAll.savedSearch
82
83
  } : {
83
84
  filter,
84
85
  orderBy,
@@ -104,7 +105,7 @@ const EmbeddedList = ({ model, view, filter, sort, columns, limit, display, empt
104
105
  className: "text-[13px] text-muted-foreground",
105
106
  children: empty ?? "Nothing here yet."
106
107
  });
107
- return viewAll ? /* @__PURE__ */ jsxs("div", {
108
+ return viewAll && link ? /* @__PURE__ */ jsxs("div", {
108
109
  className: "flex flex-col gap-3",
109
110
  children: [nothing, allLink]
110
111
  }) : nothing;
@@ -39,7 +39,7 @@ import { ListChart } from "./list-chart.js";
39
39
  import { ListSearch } from "./list-search.js";
40
40
  import { withFieldTypeCells } from "./field-types/field-type-cells.js";
41
41
  import { SavedList } from "./saved/saved-list.js";
42
- import { facetNames, filterChips, searchName } from "./searches/saved-search.js";
42
+ import { facetNames, filterChips } from "./searches/saved-search.js";
43
43
  import { SaveSearchButton } from "./searches/save-search-button.js";
44
44
  import { SavedSearches } from "./searches/saved-searches.js";
45
45
  import { useMemo, useState } from "react";
@@ -264,9 +264,11 @@ const LoadedList = ({ model, address, scope, heading }) => {
264
264
  ]
265
265
  });
266
266
  const saveSearch = view?.saveSearch && !scope && parsed.ok && filterText && /* @__PURE__ */ jsx(SaveSearchButton, {
267
+ model,
268
+ view,
267
269
  search: view.saveSearch,
268
270
  filter: filterText,
269
- name: searchName(filterChips(model, view, filterText, facetNames(config)))
271
+ chips: filterChips(model, view, filterText, facetNames(config))
270
272
  });
271
273
  const displaySwitch = (menu) => /* @__PURE__ */ jsx(DisplaySwitch, {
272
274
  view,
@@ -0,0 +1,51 @@
1
+ import { useAdminMeta } from "../../meta-gate.js";
2
+ import { RecordRowBody } from "../../record-row/record-row-body.js";
3
+ import { propsOf } from "./block-props.js";
4
+ import { CardFrame } from "./card-frame.js";
5
+ import { SavedRecordRow } from "../../record-row/saved-record-row.js";
6
+ import { SavedSearchRows } from "../../searches/saved-search-rows.js";
7
+ import { jsx } from "react/jsx-runtime";
8
+ //#region src/app/pages/blocks/record-row.tsx
9
+ /**
10
+ * What a filter finds: its chips, the count, its records as a sideways row of their list's cards, and "View all". Of a
11
+ * resource whose view has `listOf`, the records the user's saved ones point at; of one with `searchesOf`, a row per
12
+ * saved search of the user's.
13
+ */
14
+ const RecordRowBlock = ({ node, slots }) => {
15
+ const { resource, filter = "", sort, limit, display, title, description, empty } = propsOf(node);
16
+ const { resources, views } = useAdminMeta();
17
+ const model = resources[resource];
18
+ if (!model) return null;
19
+ const view = views[model.name];
20
+ const body = () => {
21
+ if (view?.searchesOf) return /* @__PURE__ */ jsx(SavedSearchRows, {
22
+ model,
23
+ view,
24
+ empty
25
+ });
26
+ if (view?.listOf !== void 0) return /* @__PURE__ */ jsx(SavedRecordRow, {
27
+ model,
28
+ view,
29
+ limit,
30
+ display,
31
+ empty
32
+ });
33
+ return /* @__PURE__ */ jsx(RecordRowBody, {
34
+ model,
35
+ view,
36
+ filter,
37
+ sort,
38
+ limit,
39
+ display,
40
+ empty
41
+ });
42
+ };
43
+ return /* @__PURE__ */ jsx(CardFrame, {
44
+ title,
45
+ description,
46
+ actions: slots.actions,
47
+ children: body()
48
+ });
49
+ };
50
+ //#endregion
51
+ export { RecordRowBlock };
@@ -11,6 +11,7 @@ import { ModalFormBlock } from "./blocks/modal-form.js";
11
11
  import { PageBlock } from "./blocks/page-block.js";
12
12
  import { ProgressBlock } from "./blocks/progress.js";
13
13
  import { RecordCardBlock } from "./blocks/record-card.js";
14
+ import { RecordRowBlock } from "./blocks/record-row.js";
14
15
  import { ShowBlock } from "./blocks/show.js";
15
16
  import { StatBlock } from "./blocks/stat.js";
16
17
  import { TableBlock } from "./blocks/table.js";
@@ -28,6 +29,7 @@ const blocks = {
28
29
  Field: FieldBlock,
29
30
  Table: TableBlock,
30
31
  CardRow: CardRowBlock,
32
+ RecordRow: RecordRowBlock,
31
33
  Progress: ProgressBlock,
32
34
  ModalForm: ModalFormBlock,
33
35
  Action: ActionBlock,
@@ -0,0 +1,114 @@
1
+ import { useIsXs } from "../../lib/use-media-query.js";
2
+ import { Skeleton } from "../../primitives/skeleton.js";
3
+ import { humanize } from "../../live/naming.js";
4
+ import { displaysOf, loadedFields } from "../displays/list-displays.js";
5
+ import { defaultOrderBy } from "../list-order.js";
6
+ import { useAdminMeta } from "../meta-gate.js";
7
+ import { AppLink } from "../use-app-address.js";
8
+ import { ErrorBanner } from "../error-banner.js";
9
+ import { useLiveRows } from "../../live/use-live-rows.js";
10
+ import { FilterChipList } from "../searches/filter-chip-list.js";
11
+ import { viewAllAddress } from "../embedded-list/embedded-display.js";
12
+ import { useFilterChips } from "../searches/use-filter-chips.js";
13
+ import { RecordRow } from "./record-row.js";
14
+ import { useMemo } from "react";
15
+ import { ArrowRight } from "lucide-react";
16
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
17
+ import { createWhere, parseFilter, printFilter } from "@protobase/schema";
18
+ //#region src/app/record-row/record-row-body.tsx
19
+ const where = createWhere();
20
+ const scoped = (filter, scope) => {
21
+ if (!scope) return filter;
22
+ const parsed = parseFilter(filter);
23
+ if (!parsed.ok) throw new Error(`The filter cannot be read: ${parsed.errors.map((error) => error.message).join("; ")}`);
24
+ return printFilter(parsed.ast ? where.and(parsed.ast, scope) : scope);
25
+ };
26
+ /** The display a row draws its cards with: the one named, else the view's first cards display, else a card of the title. */
27
+ const rowDisplay = (view, name) => {
28
+ const cards = displaysOf(view).filter((display) => display.kind === "cards");
29
+ return cards.find((display) => display.name === name) ?? cards[0] ?? {
30
+ kind: "cards",
31
+ name: "cards"
32
+ };
33
+ };
34
+ const Loading = () => /* @__PURE__ */ jsx("div", {
35
+ className: "flex gap-3 overflow-hidden",
36
+ children: Array.from({ length: 3 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-24 w-64 shrink-0" }, index))
37
+ });
38
+ /**
39
+ * What a filter finds, in a card's body: its filters as chips, how many records match, the records in a row of their
40
+ * list's cards that scrolls sideways (`RecordRow`), and "View all", which opens the list with the filter.
41
+ */
42
+ const RecordRowBody = ({ model, view, filter, sort = "", display: named, limit, chips: showChips = true, empty, note, isNew, viewAll, scope, list }) => {
43
+ const { resources, views } = useAdminMeta();
44
+ const xs = useIsXs();
45
+ const display = rowDisplay(view, named);
46
+ const orderBy = sort || defaultOrderBy(model, view);
47
+ const columns = useMemo(() => loadedFields(model, view, []), [model, view]);
48
+ const loadFilter = useMemo(() => scoped(filter, scope), [filter, scope]);
49
+ const rows = useLiveRows({
50
+ resources,
51
+ views,
52
+ model,
53
+ columns,
54
+ filter: loadFilter,
55
+ orderBy
56
+ });
57
+ const chips = useFilterChips(model, filter);
58
+ const singular = (view?.names?.singular ?? humanize(model.name)).toLowerCase();
59
+ const plural = view?.names?.plural ?? humanize(model.name);
60
+ const total = rows.source?.total;
61
+ const source = useMemo(() => rows.source && {
62
+ ...rows.source,
63
+ total: Math.min(rows.source.total, limit ?? Infinity)
64
+ }, [rows.source, limit]);
65
+ return /* @__PURE__ */ jsxs("div", {
66
+ className: "flex min-w-0 flex-col gap-3",
67
+ children: [
68
+ showChips && chips.length > 0 || total !== void 0 || note ? /* @__PURE__ */ jsxs("div", {
69
+ className: "flex flex-wrap items-center justify-between gap-2",
70
+ children: [showChips && chips.length > 0 ? /* @__PURE__ */ jsx(FilterChipList, { chips }) : /* @__PURE__ */ jsx("span", {}), /* @__PURE__ */ jsxs("p", {
71
+ className: "flex flex-wrap gap-x-3 text-[13px] text-muted-foreground",
72
+ children: [note && /* @__PURE__ */ jsx("span", { children: note }), total !== void 0 && /* @__PURE__ */ jsx("span", {
73
+ className: "tabular-nums",
74
+ children: `${total.toLocaleString("en-GB")} ${total === 1 ? singular : plural.toLowerCase()}`
75
+ })]
76
+ })]
77
+ }) : null,
78
+ rows.isLoading ? /* @__PURE__ */ jsx(Loading, {}) : rows.error ? /* @__PURE__ */ jsx(ErrorBanner, {
79
+ title: "Could not load",
80
+ error: rows.error
81
+ }) : !source || source.total === 0 ? /* @__PURE__ */ jsx("p", {
82
+ className: "text-[13px] text-muted-foreground",
83
+ children: empty ?? `No ${plural.toLowerCase()} match.`
84
+ }) : /* @__PURE__ */ jsx(RecordRow, {
85
+ model,
86
+ view,
87
+ display,
88
+ source,
89
+ label: plural,
90
+ isNew
91
+ }),
92
+ /* @__PURE__ */ jsxs(AppLink, {
93
+ to: viewAllAddress(model, view, xs, {
94
+ filter,
95
+ orderBy,
96
+ display: display.name,
97
+ savedSearch: viewAll?.savedSearch,
98
+ resource: list
99
+ }),
100
+ onClick: viewAll?.onOpen,
101
+ className: "flex items-center gap-1 self-end text-[13px] font-medium text-primary-text hover:underline",
102
+ children: [viewAll?.label ?? /* @__PURE__ */ jsxs(Fragment$1, { children: ["View all ", /* @__PURE__ */ jsx("span", {
103
+ className: "sr-only",
104
+ children: plural.toLowerCase()
105
+ })] }), /* @__PURE__ */ jsx(ArrowRight, {
106
+ className: "size-3.5",
107
+ "aria-hidden": true
108
+ })]
109
+ })
110
+ ]
111
+ });
112
+ };
113
+ //#endregion
114
+ export { RecordRowBody };
@@ -0,0 +1,93 @@
1
+ import { Skeleton } from "../../primitives/skeleton.js";
2
+ import { displayRow } from "../displays/list-displays.js";
3
+ import { usePagedRows } from "../../data-grid/use-paged-rows.js";
4
+ import { RecordCard } from "../displays/record-card.js";
5
+ import { rowOffset } from "./row-window.js";
6
+ import { useRowScroll } from "./use-row-scroll.js";
7
+ import { ChevronLeft, ChevronRight } from "lucide-react";
8
+ import { jsx, jsxs } from "react/jsx-runtime";
9
+ //#region src/app/record-row/record-row.tsx
10
+ const arrowClass = "absolute top-1/2 z-10 hidden size-8 -translate-y-1/2 items-center justify-center rounded-full border border-border-strong bg-background shadow-pop hover:bg-muted sm:flex";
11
+ /** Stands in for the cards before or after the ones drawn, as wide as they would be. */
12
+ const Spacer = ({ cards }) => cards > 0 ? /* @__PURE__ */ jsx("li", {
13
+ "aria-hidden": true,
14
+ className: "shrink-0",
15
+ style: { width: rowOffset(cards) - 12 }
16
+ }) : null;
17
+ /**
18
+ * Records as the cards of their list, in one row that scrolls sideways by touch, trackpad or its arrow buttons: only the
19
+ * cards near the view are drawn, and the records are loaded through the list API in pages as the row scrolls to them.
20
+ * Each card links to its record.
21
+ */
22
+ const RecordRow = ({ model, view, display, source, label, isNew }) => {
23
+ const { total } = source;
24
+ const scroll = useRowScroll(total);
25
+ const getRow = usePagedRows({
26
+ total,
27
+ rowAt: source.rowAt,
28
+ loadRows: source.loadRows,
29
+ latencyMs: 0
30
+ }, scroll.start, Math.max(scroll.start, scroll.end - 1));
31
+ const indexes = Array.from({ length: scroll.end - scroll.start }, (_, offset) => scroll.start + offset);
32
+ return /* @__PURE__ */ jsxs("div", {
33
+ className: "relative min-w-0",
34
+ children: [
35
+ /* @__PURE__ */ jsxs("ul", {
36
+ ref: scroll.ref,
37
+ "aria-label": label,
38
+ className: "flex snap-x gap-3 overflow-x-auto overflow-y-hidden pb-1",
39
+ style: { scrollbarWidth: "thin" },
40
+ children: [
41
+ /* @__PURE__ */ jsx(Spacer, { cards: scroll.start }),
42
+ indexes.map((index) => {
43
+ const loaded = getRow(index);
44
+ if (!loaded) return /* @__PURE__ */ jsx("li", {
45
+ "aria-hidden": true,
46
+ className: "shrink-0",
47
+ style: { width: 256 },
48
+ children: /* @__PURE__ */ jsx(Skeleton, { className: "h-full min-h-24 rounded-lg" })
49
+ }, `skeleton-${index}`);
50
+ const row = displayRow(model, loaded);
51
+ return /* @__PURE__ */ jsx("li", {
52
+ "aria-posinset": index + 1,
53
+ "aria-setsize": total,
54
+ className: "grid shrink-0 snap-start grid-cols-[minmax(0,1fr)]",
55
+ style: { width: 256 },
56
+ children: /* @__PURE__ */ jsx(RecordCard, {
57
+ model,
58
+ view,
59
+ display,
60
+ row,
61
+ swipe: false,
62
+ mark: isNew?.(row) ? "New" : void 0
63
+ })
64
+ }, row.key);
65
+ }),
66
+ /* @__PURE__ */ jsx(Spacer, { cards: total - scroll.end })
67
+ ]
68
+ }),
69
+ scroll.back && /* @__PURE__ */ jsx("button", {
70
+ type: "button",
71
+ "aria-label": `Previous ${label.toLowerCase()}`,
72
+ onClick: () => scroll.page(-1),
73
+ className: `${arrowClass} -left-3`,
74
+ children: /* @__PURE__ */ jsx(ChevronLeft, {
75
+ className: "size-4",
76
+ "aria-hidden": true
77
+ })
78
+ }),
79
+ scroll.on && /* @__PURE__ */ jsx("button", {
80
+ type: "button",
81
+ "aria-label": `Next ${label.toLowerCase()}`,
82
+ onClick: () => scroll.page(1),
83
+ className: `${arrowClass} -right-3`,
84
+ children: /* @__PURE__ */ jsx(ChevronRight, {
85
+ className: "size-4",
86
+ "aria-hidden": true
87
+ })
88
+ })
89
+ ]
90
+ });
91
+ };
92
+ //#endregion
93
+ export { RecordRow };
@@ -0,0 +1,20 @@
1
+ //#region src/app/record-row/row-window.ts
2
+ const step = 268;
3
+ /** How many cards beyond each edge of the view are drawn, so a short scroll shows cards already there. */
4
+ const overscan = 3;
5
+ /**
6
+ * The cards of a row of `total` to draw when it is scrolled `scrollLeft` into a view `width` wide: from `start` up to
7
+ * `end` (not included), the ones in view and `overscan` more on each side, none past either end.
8
+ */
9
+ const rowWindow = (scrollLeft, width, total) => {
10
+ const first = Math.floor(Math.max(0, scrollLeft) / step);
11
+ const last = Math.ceil((Math.max(0, scrollLeft) + width) / step);
12
+ return {
13
+ start: Math.max(0, first - overscan),
14
+ end: Math.min(total, last + overscan)
15
+ };
16
+ };
17
+ /** The width of the cards before `index`, with their gaps: what stands in for the cards not drawn. */
18
+ const rowOffset = (index) => index * step;
19
+ //#endregion
20
+ export { rowOffset, rowWindow };
@@ -0,0 +1,42 @@
1
+ import { Skeleton } from "../../primitives/skeleton.js";
2
+ import { humanize } from "../../live/naming.js";
3
+ import { listedResource } from "../current-address.js";
4
+ import { useAdminMeta } from "../meta-gate.js";
5
+ import { ErrorBanner } from "../error-banner.js";
6
+ import { savedScope } from "../saved/saved-records.js";
7
+ import { useSavedRecords } from "../saved/use-saved-records.js";
8
+ import { RecordRowBody } from "./record-row-body.js";
9
+ import { jsx } from "react/jsx-runtime";
10
+ //#region src/app/record-row/saved-record-row.tsx
11
+ /**
12
+ * The records the user's saved ones of a view with `listOf` point at, such as the listings among their favorites, as a
13
+ * row of their own resource's cards, with "View all" to the saved records' list. `empty` when there are none.
14
+ */
15
+ const SavedRecordRow = ({ model, view, empty, ...props }) => {
16
+ const { resources, views } = useAdminMeta();
17
+ const listed = listedResource(resources, views, model.name);
18
+ const saved = useSavedRecords(model.name, view.listOf, true);
19
+ const plural = (view.names?.plural ?? humanize(model.name)).toLowerCase();
20
+ if (!listed.listOf) return null;
21
+ if (saved.error) return /* @__PURE__ */ jsx(ErrorBanner, {
22
+ title: `Could not load your ${plural}`,
23
+ error: saved.error
24
+ });
25
+ if (!saved.data) return /* @__PURE__ */ jsx(Skeleton, { className: "h-24" });
26
+ const scope = savedScope(listed.model, saved.data);
27
+ if (!scope) return /* @__PURE__ */ jsx("p", {
28
+ className: "text-[13px] text-muted-foreground",
29
+ children: empty ?? `No ${plural} yet.`
30
+ });
31
+ return /* @__PURE__ */ jsx(RecordRowBody, {
32
+ ...props,
33
+ model: listed.model,
34
+ view: listed.view,
35
+ filter: "",
36
+ scope,
37
+ list: model.name,
38
+ empty
39
+ });
40
+ };
41
+ //#endregion
42
+ export { SavedRecordRow };
@@ -0,0 +1,51 @@
1
+ import { rowOffset, rowWindow } from "./row-window.js";
2
+ import { useLayoutEffect, useRef, useState } from "react";
3
+ //#region src/app/record-row/use-row-scroll.ts
4
+ /**
5
+ * A record row's sideways scroll: `ref` goes on the scrolling list. Gives the cards to draw (`start` to `end`), whether
6
+ * it can scroll back or on, and `page`, which scrolls a view's width of cards either way.
7
+ */
8
+ const useRowScroll = (total) => {
9
+ const ref = useRef(null);
10
+ const [view, setView] = useState({
11
+ left: 0,
12
+ width: 0
13
+ });
14
+ useLayoutEffect(() => {
15
+ const list = ref.current;
16
+ if (!list) return;
17
+ const read = () => setView((current) => {
18
+ const next = {
19
+ left: list.scrollLeft,
20
+ width: list.clientWidth
21
+ };
22
+ const same = (a, b) => {
23
+ const [x, y] = [rowWindow(a.left, a.width, total), rowWindow(b.left, b.width, total)];
24
+ return x.start === y.start && x.end === y.end && a.width === b.width && a.left > 0 === b.left > 0 && Math.abs(a.left - b.left) < 1;
25
+ };
26
+ return same(current, next) ? current : next;
27
+ });
28
+ read();
29
+ const observer = new ResizeObserver(read);
30
+ observer.observe(list);
31
+ list.addEventListener("scroll", read, { passive: true });
32
+ return () => {
33
+ observer.disconnect();
34
+ list.removeEventListener("scroll", read);
35
+ };
36
+ }, [total]);
37
+ const contentWidth = Math.max(0, rowOffset(total) - 12);
38
+ const page = (direction) => ref.current?.scrollBy({
39
+ left: direction * Math.max(rowOffset(1), view.width - 12),
40
+ behavior: "smooth"
41
+ });
42
+ return {
43
+ ref,
44
+ ...rowWindow(view.left, view.width, total),
45
+ back: view.left > 0,
46
+ on: view.left + view.width < contentWidth - 1,
47
+ page
48
+ };
49
+ };
50
+ //#endregion
51
+ export { useRowScroll };
@@ -0,0 +1,28 @@
1
+ import { cn } from "../../lib/cn.js";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ //#region src/app/searches/filter-chip-list.tsx
4
+ const chipClass = "inline-flex h-6 items-center rounded-full border border-primary-border bg-primary-soft px-2.5 text-xs font-medium text-primary-text";
5
+ /** What a filter filters on, concisely: its chips as a list labelled "Filters". */
6
+ const FilterChipList = ({ chips, className }) => /* @__PURE__ */ jsx("ul", {
7
+ className: cn("flex flex-wrap gap-1.5", className),
8
+ "aria-label": "Filters",
9
+ children: chips.map((chip) => /* @__PURE__ */ jsx("li", {
10
+ className: chipClass,
11
+ children: chip.label
12
+ }, chip.key))
13
+ });
14
+ /** A filter's chips inside a heading or a link, where a list cannot go: read out one after the other. */
15
+ const InlineFilterChips = ({ chips }) => /* @__PURE__ */ jsx("span", {
16
+ className: "flex flex-wrap gap-1.5",
17
+ children: chips.map((chip, index) => /* @__PURE__ */ jsxs("span", {
18
+ className: chipClass,
19
+ children: [index > 0 && /* @__PURE__ */ jsx("span", {
20
+ className: "sr-only",
21
+ children: ", "
22
+ }), chip.label]
23
+ }, chip.key))
24
+ });
25
+ /** A search without a name as words, for a label or a question: its chips one after the other. */
26
+ const chipsText = (chips) => chips.map((chip) => chip.label).join(", ");
27
+ //#endregion
28
+ export { FilterChipList, InlineFilterChips, chipsText };