@protobase/ui 0.19.15 → 0.19.16

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 (68) hide show
  1. package/dist/app/app.js +2 -2
  2. package/dist/app/displays/board-column.js +114 -0
  3. package/dist/app/displays/board-columns.js +45 -0
  4. package/dist/app/displays/board-display.js +96 -0
  5. package/dist/app/displays/board-dnd.js +32 -0
  6. package/dist/app/displays/list-displays.js +13 -1
  7. package/dist/app/displays/record-card.js +16 -5
  8. package/dist/app/displays/use-board-column.js +23 -0
  9. package/dist/app/displays/use-board-move.js +60 -0
  10. package/dist/app/displays/virtual-cards.js +1 -0
  11. package/dist/app/embedded-list/embedded-list.js +9 -0
  12. package/dist/app/export/export-options.js +2 -2
  13. package/dist/app/gallery/use-gallery-photos.js +2 -2
  14. package/dist/app/import/lookups.js +2 -2
  15. package/dist/app/list-edit/cell-field-editor.js +61 -0
  16. package/dist/app/list-edit/use-list-cell-editor.js +30 -0
  17. package/dist/app/list-edit/use-list-edits.js +75 -0
  18. package/dist/app/list-page.js +28 -10
  19. package/dist/app/map/map-area.js +6 -6
  20. package/dist/app/pages/action-href.js +4 -2
  21. package/dist/app/pages/use-run-action.js +4 -3
  22. package/dist/app/record-page.js +30 -16
  23. package/dist/app/record-preview.js +25 -0
  24. package/dist/app/record-row/record-row-body.js +2 -2
  25. package/dist/app/record-row/record-row.js +1 -0
  26. package/dist/app/related-dots/card-dots-lines.js +16 -0
  27. package/dist/app/related-dots/card-dots.js +34 -0
  28. package/dist/app/related-dots/dot-card.js +3 -3
  29. package/dist/app/related-dots/related-dots-columns.js +3 -1
  30. package/dist/app/related-dots/related-dots.js +22 -9
  31. package/dist/app/related-section.js +4 -4
  32. package/dist/app/relation-picker.js +2 -2
  33. package/dist/app/saved/saved-records.js +2 -2
  34. package/dist/app/search/search-model.js +2 -2
  35. package/dist/app/searches/saved-search.js +3 -3
  36. package/dist/app/searches/search-diff.js +8 -8
  37. package/dist/app/use-nav-recent.js +1 -2
  38. package/dist/data/api-provider.d.ts +2 -1
  39. package/dist/data/api-provider.js +12 -5
  40. package/dist/data/focus-refresh.js +44 -0
  41. package/dist/data/query-keys.js +13 -0
  42. package/dist/data/use-record.js +17 -2
  43. package/dist/data-grid/cell-moves.js +26 -0
  44. package/dist/data-grid/data-grid.js +32 -7
  45. package/dist/data-grid/grid-header.js +1 -1
  46. package/dist/data-grid/grid-row.js +17 -13
  47. package/dist/data-grid/page-cache.js +3 -0
  48. package/dist/data-grid/use-cell-editing.js +113 -0
  49. package/dist/data-grid/use-paged-rows.js +25 -4
  50. package/dist/live/field-values.js +3 -1
  51. package/dist/live/related-column-rows.js +3 -3
  52. package/dist/live/relation-labels.js +2 -2
  53. package/dist/live/use-live-rows.js +17 -4
  54. package/dist/live/use-reference-search.js +2 -2
  55. package/dist/package.js +1 -1
  56. package/dist/shell/assets/app-xQmLJM07.js +69 -0
  57. package/dist/shell/assets/index-CmJl5dIO.css +2 -0
  58. package/dist/shell/assets/{index-CzV9U_NJ.js → index-CmmYBR7Y.js} +1 -1
  59. package/dist/shell/assets/{maplibre-G35v6TYU.js → maplibre-Siom2Zwj.js} +1 -1
  60. package/dist/shell/assets/{markdown-content-DGFnggSX.js → markdown-content-ChyGEubR.js} +1 -1
  61. package/dist/shell/assets/{record-map-ZAC_U52Y.js → record-map-B_QnHF99.js} +1 -1
  62. package/dist/shell/assets/{records-map-DLXOQVpM.js → records-map-CcHhEDBC.js} +1 -1
  63. package/dist/shell/assets/{shared-protobase-ui-xByMDSPg.js → shared-protobase-ui-zRmreKZS.js} +1 -1
  64. package/dist/shell/index.html +4 -4
  65. package/dist/shell/protobase.shell.json +1 -1
  66. package/package.json +4 -4
  67. package/dist/shell/assets/app-WromtADN.js +0 -69
  68. package/dist/shell/assets/index-DEEIUcYb.css +0 -2
@@ -7,21 +7,24 @@ import { displayRow } from "../displays/list-displays.js";
7
7
  import { AppLink } from "../use-app-address.js";
8
8
  import { toneFor } from "../../live/columns-from-model.js";
9
9
  import { valueColor } from "../../live/value-color.js";
10
- import { RecordCard } from "../displays/record-card.js";
11
- import { dotCard } from "./dot-card.js";
12
10
  import { placeCard } from "./card-place.js";
11
+ import { dotCard } from "./dot-card.js";
13
12
  import { dotsSummary } from "./dots-summary.js";
14
13
  import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
15
14
  import { jsx, jsxs } from "react/jsx-runtime";
16
15
  import { createPortal } from "react-dom";
17
16
  //#region src/app/related-dots/related-dots.tsx
17
+ /** A dot's box and its mark, which a card's room for its dots (`DotsRoom`) takes the size of. */
18
+ const dotBox = "flex shrink-0 items-center justify-center rounded-full p-0.5 pointer-coarse:p-1.5";
19
+ const dotMark = "size-2.5 rounded-full";
20
+ const dotsLine = "flex min-w-0 items-center gap-0.5";
18
21
  const hideMs = 150;
19
22
  const cardWidth = 288;
20
23
  /**
21
24
  * The card of the dot that is hovered, focused or tapped, portalled to the body: every grid row is its own stacking
22
25
  * context and the grid body clips, so a card inside the row would sit under the rows below it.
23
26
  */
24
- const DotCardPopover = ({ shown, row, target, view, column, onEnter, onLeave }) => {
27
+ const DotCardPopover = ({ shown, row, target, view, column, Card, onEnter, onLeave }) => {
25
28
  const card = useRef(null);
26
29
  const [place, setPlace] = useState();
27
30
  useLayoutEffect(() => {
@@ -48,7 +51,7 @@ const DotCardPopover = ({ shown, row, target, view, column, onEnter, onLeave })
48
51
  }
49
52
  },
50
53
  className: "fixed z-50 rounded-lg shadow-pop",
51
- children: /* @__PURE__ */ jsx(RecordCard, {
54
+ children: /* @__PURE__ */ jsx(Card, {
52
55
  model: target,
53
56
  view,
54
57
  display: dotCard(view, column.status),
@@ -61,7 +64,7 @@ const DotCardPopover = ({ shown, row, target, view, column, onEnter, onLeave })
61
64
  * or focusing a dot shows the record's card; on a touch screen the first tap shows it, and the card opens the record.
62
65
  * The dots say how many records have each status to a screen reader.
63
66
  */
64
- const RelatedDots = ({ column, records, target, view }) => {
67
+ const RelatedDots = ({ column, records, target, view, wrap = false, Card }) => {
65
68
  const rows = useMemo(() => records.map((record) => displayRow(target, record)), [records, target]);
66
69
  const [shown, setShown] = useState();
67
70
  const hideTimer = useRef(void 0);
@@ -123,7 +126,7 @@ const RelatedDots = ({ column, records, target, view }) => {
123
126
  const shownRow = shown && rows.find((row) => row.key === shown.key);
124
127
  return /* @__PURE__ */ jsxs("div", {
125
128
  ref: box,
126
- className: "flex min-w-0 items-center gap-0.5",
129
+ className: cn(dotsLine, wrap && "pointer-events-none flex-wrap"),
127
130
  onClick: (event) => event.stopPropagation(),
128
131
  onPointerDown: (event) => pointer.current = event.pointerType,
129
132
  children: [
@@ -146,11 +149,11 @@ const RelatedDots = ({ column, records, target, view }) => {
146
149
  onFocus: (event) => event.currentTarget.matches(":focus-visible") && show(row.key, event.currentTarget),
147
150
  onBlur: blur,
148
151
  onClick: tap(row.key),
149
- className: "flex shrink-0 items-center justify-center rounded-full p-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 pointer-coarse:p-1.5",
152
+ className: cn(dotBox, "pointer-events-auto focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"),
150
153
  children: /* @__PURE__ */ jsx("span", {
151
154
  "aria-hidden": true,
152
155
  style: color === void 0 ? void 0 : { backgroundColor: color },
153
- className: cn("size-2.5 rounded-full", color === void 0 && toneDots[toneFor(String(value))], shown?.key === row.key && "ring-2 ring-foreground/30")
156
+ className: cn(dotMark, color === void 0 && toneDots[toneFor(String(value))], shown?.key === row.key && "ring-2 ring-foreground/30")
154
157
  })
155
158
  }, row.key);
156
159
  }),
@@ -160,11 +163,21 @@ const RelatedDots = ({ column, records, target, view }) => {
160
163
  target,
161
164
  view,
162
165
  column,
166
+ Card,
163
167
  onEnter: cancelHide,
164
168
  onLeave: hideSoon
165
169
  })
166
170
  ]
167
171
  });
168
172
  };
173
+ /** Room for `count` dots that wrap as `RelatedDots` with `wrap` does, for a card to hold the dots drawn over it. */
174
+ const DotsRoom = ({ count }) => /* @__PURE__ */ jsx("span", {
175
+ "aria-hidden": true,
176
+ className: cn(dotsLine, "invisible flex-wrap"),
177
+ children: Array.from({ length: count }, (_, index) => /* @__PURE__ */ jsx("span", {
178
+ className: dotBox,
179
+ children: /* @__PURE__ */ jsx("span", { className: dotMark })
180
+ }, index))
181
+ });
169
182
  //#endregion
170
- export { RelatedDots };
183
+ export { DotsRoom, RelatedDots };
@@ -11,7 +11,7 @@ import { jsx } from "react/jsx-runtime";
11
11
  import { useQuery } from "@tanstack/react-query";
12
12
  import { createWhere, printFilter } from "@protobase/schema";
13
13
  //#region src/app/related-section.tsx
14
- const where = createWhere();
14
+ const where$1 = 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
17
  const and = (...parts) => {
@@ -28,7 +28,7 @@ const useMatch = (item, recordKey) => {
28
28
  const through = item.through;
29
29
  const via = through ? resources[through.resource] : void 0;
30
30
  const key = through?.key ?? via?.primaryKey[0];
31
- const viaFilter = through ? and(printFilter(where.eq(through.field, recordKey)), through.filter) : "";
31
+ const viaFilter = through ? and(printFilter(where$1.eq(through.field, recordKey)), through.filter) : "";
32
32
  const collected = useQuery({
33
33
  queryKey: keys.list(via?.name ?? "", viaFilter, "", maxThrough, `related:${key}`),
34
34
  enabled: Boolean(via && key),
@@ -42,7 +42,7 @@ const useMatch = (item, recordKey) => {
42
42
  }
43
43
  });
44
44
  if (!through) return {
45
- filter: and(printFilter(where.eq(item.field, recordKey)), item.filter),
45
+ filter: and(printFilter(where$1.eq(item.field, recordKey)), item.filter),
46
46
  isPending: false,
47
47
  error: null
48
48
  };
@@ -52,7 +52,7 @@ const useMatch = (item, recordKey) => {
52
52
  error: collected.error
53
53
  };
54
54
  return {
55
- filter: collected.data.length === 0 ? null : and(printFilter(where.in(item.field, collected.data)), item.filter),
55
+ filter: collected.data.length === 0 ? null : and(printFilter(where$1.in(item.field, collected.data)), item.filter),
56
56
  isPending: false,
57
57
  error: null
58
58
  };
@@ -6,7 +6,7 @@ import { jsx, jsxs } from "react/jsx-runtime";
6
6
  import { useQuery } from "@tanstack/react-query";
7
7
  import { createWhere, printFilter } from "@protobase/schema";
8
8
  //#region src/app/relation-picker.tsx
9
- const where = createWhere();
9
+ const where$1 = createWhere();
10
10
  /** Picks a record of another resource: type to search (when the target has search fields), choose from the first matches. */
11
11
  const RelationPicker = ({ target, view, value, label, invalid, onChange }) => {
12
12
  const client = useClient();
@@ -22,7 +22,7 @@ const RelationPicker = ({ target, view, value, label, invalid, onChange }) => {
22
22
  ],
23
23
  enabled: open,
24
24
  queryFn: () => client.list(target.name, {
25
- filter: text && target.search?.length ? printFilter(where.search(text)) : void 0,
25
+ filter: text && target.search?.length ? printFilter(where$1.search(text)) : void 0,
26
26
  fields: [keyField, labelField],
27
27
  pageSize: 8
28
28
  })
@@ -2,7 +2,7 @@ import { recordKey } from "../../live/model-helpers.js";
2
2
  import { createWhere } from "@protobase/schema";
3
3
  import { encodeKey } from "@protobase/schema/internal";
4
4
  //#region src/app/saved/saved-records.ts
5
- const where = createWhere();
5
+ const where$1 = createWhere();
6
6
  const relationValue = (value) => {
7
7
  if (typeof value === "string" || typeof value === "number") return value;
8
8
  if (value && typeof value === "object" && "id" in value) return relationValue(value.id);
@@ -32,7 +32,7 @@ const savedScope = (target, saved) => {
32
32
  const [key] = target.primaryKey;
33
33
  if (target.primaryKey.length !== 1 || key === void 0) throw new Error(`${target.name} has a composite key, so its saved records cannot be listed`);
34
34
  const values = [...new Set(saved.map((entry) => entry.target))];
35
- return values.length === 0 ? void 0 : where.in(key, values);
35
+ return values.length === 0 ? void 0 : where$1.in(key, values);
36
36
  };
37
37
  //#endregion
38
38
  export { savedFor, savedRecordsOf, savedScope, withSaved, withoutSaved };
@@ -3,7 +3,7 @@ import { humanize } from "../../live/naming.js";
3
3
  import { appPath, createWhere, enumLabel, printFilter } from "@protobase/schema";
4
4
  import { encodeKey, matchedSearchField } from "@protobase/schema/internal";
5
5
  //#region src/app/search/search-model.ts
6
- const where = createWhere();
6
+ const where$1 = createWhere();
7
7
  const searchTargets = (meta) => Object.values(meta.resources).flatMap((model) => {
8
8
  const view = meta.views[model.name];
9
9
  if (!model.search?.length || !view || view.nav?.hidden || meta.permissions[model.name]?.read === false) return [];
@@ -22,7 +22,7 @@ const searchPlaceholder = (targets) => {
22
22
  return `Search ${names[0]}, ${names[1]} and more`;
23
23
  };
24
24
  /** The list filter for the text: the server's partial-word `search(...)` over the resource's search fields. */
25
- const searchFilter = (text) => printFilter(where.search(text.trim()));
25
+ const searchFilter = (text) => printFilter(where$1.search(text.trim()));
26
26
  /** The fields a result's bold line joins: the view's `searchResult.title`, else the record's title field. */
27
27
  const titleFields = (target) => {
28
28
  const fields = target.view?.searchResult?.title;
@@ -4,7 +4,7 @@ import { textToState } from "../../live/filter-string.js";
4
4
  import { rangeFormat, widgetFields } from "../../live/view-fields.js";
5
5
  import { createWhere, enumLabel, parseFilter, printFilter } from "@protobase/schema";
6
6
  //#region src/app/searches/saved-search.ts
7
- const where = createWhere();
7
+ const where$1 = createWhere();
8
8
  const span = {
9
9
  start: 0,
10
10
  end: 0
@@ -79,14 +79,14 @@ const facetNames = (config) => Object.fromEntries(config.facets.filter((group) =
79
79
  */
80
80
  const unseenFilter = (text, field, seenAt) => {
81
81
  if (typeof seenAt !== "string" || seenAt === "") return text;
82
- const after = where.gt(field, {
82
+ const after = where$1.gt(field, {
83
83
  kind: "timestamp",
84
84
  value: new Date(seenAt).toISOString(),
85
85
  span
86
86
  });
87
87
  const parsed = parseFilter(text);
88
88
  if (!parsed.ok) throw new Error(`The saved filter cannot be read: ${parsed.errors.map((error) => error.message).join("; ")}`);
89
- return printFilter(parsed.ast ? where.and(parsed.ast, after) : after);
89
+ return printFilter(parsed.ast ? where$1.and(parsed.ast, after) : after);
90
90
  };
91
91
  /** Whether a record whose `field` holds `value` is new to the user, as `unseenFilter` finds it: after `seenAt`, or any. */
92
92
  const isUnseen = (value, seenAt) => {
@@ -1,11 +1,11 @@
1
1
  import { createWhere, parseFilter, printFilter } from "@protobase/schema";
2
2
  //#region src/app/searches/search-diff.ts
3
- const where = createWhere();
3
+ const where$1 = createWhere();
4
4
  const span = {
5
5
  start: 0,
6
6
  end: 0
7
7
  };
8
- const guarded = (atom, condition) => where.and(condition, {
8
+ const guarded = (atom, condition) => where$1.and(condition, {
9
9
  kind: "present",
10
10
  field: atom.field,
11
11
  span
@@ -32,13 +32,13 @@ const surely = (expr) => {
32
32
  /** The filter that holds exactly where `expr` fails, and fails, never unknown, everywhere else. */
33
33
  const surelyNot = (expr) => {
34
34
  switch (expr.kind) {
35
- case "and": return where.or(...expr.args.map(surelyNot));
36
- case "or": return where.and(...expr.args.map(surelyNot));
35
+ case "and": return where$1.or(...expr.args.map(surelyNot));
36
+ case "or": return where$1.and(...expr.args.map(surelyNot));
37
37
  case "not": return surely(expr.arg);
38
38
  case "search":
39
39
  case "present":
40
- case "isNull": return where.not(expr);
41
- default: return guarded(expr, where.not(expr));
40
+ case "isNull": return where$1.not(expr);
41
+ default: return guarded(expr, where$1.not(expr));
42
42
  }
43
43
  };
44
44
  const read = (text) => {
@@ -55,8 +55,8 @@ const addedFilter = (before, after) => {
55
55
  const old = read(before);
56
56
  if (!old) return null;
57
57
  const next = read(after);
58
- const notOld = where.not(surely(old));
59
- return printFilter(next ? where.and(next, notOld) : notOld);
58
+ const notOld = where$1.not(surely(old));
59
+ return printFilter(next ? where$1.and(next, notOld) : notOld);
60
60
  };
61
61
  /** The filter of the records `before` finds and `after` no longer does; `null` when none can be. */
62
62
  const removedFilter = (before, after) => addedFilter(after, before);
@@ -50,8 +50,7 @@ const useNavRecent = (meta, basePath, active) => {
50
50
  useFollowRecords(groups);
51
51
  const results = useQueries({ queries: groups.map((group) => ({
52
52
  queryKey: groupKey(group),
53
- queryFn: () => client.list(group.model.name, group.request),
54
- refetchOnWindowFocus: true
53
+ queryFn: () => client.list(group.model.name, group.request)
55
54
  })) });
56
55
  return Object.fromEntries(groups.map(({ model, view, recent }, index) => {
57
56
  const result = results[index];
@@ -97,7 +97,8 @@ export type ApiProviderProps = {
97
97
  /**
98
98
  * Owns the query cache and the API client; refetches `/meta` whenever `X-Meta-Version` changes. The version is
99
99
  * compared only with the last one seen: it hashes the models and roles, while the `/meta` ETag hashes the whole
100
- * per-caller body, so the two never match.
100
+ * per-caller body, so the two never match. The data on screen refetches whenever the window regains focus, unless a
101
+ * form holds unsaved changes (`usePauseFocusRefresh`).
101
102
  */
102
103
  export declare const ApiProvider: ({ baseUrl, client: given, sources, fetch, queryClient: givenQueryClient, token, onUnauthenticated, children }: ApiProviderProps) => import("react").JSX.Element;
103
104
  //#endregion
@@ -1,8 +1,9 @@
1
+ import { FocusRefreshContext, createFocusRefresh, listenToWindowFocus } from "./focus-refresh.js";
1
2
  import { keys } from "./query-keys.js";
2
- import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
3
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
3
4
  import { createClient } from "@protobase/client";
4
5
  import { jsx } from "react/jsx-runtime";
5
- import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
6
+ import { QueryClient, QueryClientProvider, focusManager } from "@tanstack/react-query";
6
7
  //#region src/data/api-provider.tsx
7
8
  const ApiContext = createContext(null);
8
9
  const SourceClientsContext = createContext(null);
@@ -44,14 +45,17 @@ const ClientProvider = ({ client, resourceClient, children }) => /* @__PURE__ */
44
45
  /**
45
46
  * Owns the query cache and the API client; refetches `/meta` whenever `X-Meta-Version` changes. The version is
46
47
  * compared only with the last one seen: it hashes the models and roles, while the `/meta` ETag hashes the whole
47
- * per-caller body, so the two never match.
48
+ * per-caller body, so the two never match. The data on screen refetches whenever the window regains focus, unless a
49
+ * form holds unsaved changes (`usePauseFocusRefresh`).
48
50
  */
49
51
  const ApiProvider = ({ baseUrl, client: given, sources, fetch, queryClient: givenQueryClient, token, onUnauthenticated, children }) => {
52
+ const [focusRefresh] = useState(createFocusRefresh);
50
53
  const [queryClient] = useState(() => givenQueryClient ?? new QueryClient({ defaultOptions: { queries: {
51
54
  staleTime: 3e4,
52
55
  retry: false,
53
- refetchOnWindowFocus: false
56
+ refetchOnWindowFocus: focusRefresh.refetchOnWindowFocus
54
57
  } } }));
58
+ useEffect(() => focusManager.setEventListener(listenToWindowFocus), []);
55
59
  const metaVersion = useRef(void 0);
56
60
  const client = useMemo(() => given ?? createClient({
57
61
  baseUrl,
@@ -104,7 +108,10 @@ const ApiProvider = ({ baseUrl, client: given, sources, fetch, queryClient: give
104
108
  value: client,
105
109
  children: /* @__PURE__ */ jsx(SourceClientsContext.Provider, {
106
110
  value: sourceClient,
107
- children
111
+ children: /* @__PURE__ */ jsx(FocusRefreshContext.Provider, {
112
+ value: focusRefresh,
113
+ children
114
+ })
108
115
  })
109
116
  })
110
117
  });
@@ -0,0 +1,44 @@
1
+ import { createContext, useContext, useEffect } from "react";
2
+ //#region src/data/focus-refresh.ts
3
+ /**
4
+ * Whether the data on screen refetches when the window regains focus: always, unless a form on screen holds unsaved
5
+ * changes. A refetch under such a form would hand it a newer version of the record without the changes it was edited
6
+ * from, so its Save would no longer find a conflict.
7
+ */
8
+ const createFocusRefresh = () => {
9
+ let paused = 0;
10
+ return {
11
+ refetchOnWindowFocus: () => paused > 0 ? false : "always",
12
+ /** Holds the refetches until the returned function is called. */
13
+ pause: () => {
14
+ paused += 1;
15
+ let released = false;
16
+ return () => {
17
+ if (released) return;
18
+ released = true;
19
+ paused -= 1;
20
+ };
21
+ }
22
+ };
23
+ };
24
+ const FocusRefreshContext = createContext(null);
25
+ /** Holds the app's refetches on window focus while `paused`, such as while a form has unsaved changes. */
26
+ const usePauseFocusRefresh = (paused) => {
27
+ const refresh = useContext(FocusRefreshContext);
28
+ useEffect(() => paused && refresh ? refresh.pause() : void 0, [paused, refresh]);
29
+ };
30
+ /**
31
+ * Tells TanStack Query the window regained focus on a return to the browser tab, as by default, and on a return to the
32
+ * browser window from another program, which leaves the tab visible.
33
+ */
34
+ const listenToWindowFocus = (onFocus) => {
35
+ const listener = () => onFocus();
36
+ window.addEventListener("visibilitychange", listener, false);
37
+ window.addEventListener("focus", listener, false);
38
+ return () => {
39
+ window.removeEventListener("visibilitychange", listener);
40
+ window.removeEventListener("focus", listener);
41
+ };
42
+ };
43
+ //#endregion
44
+ export { FocusRefreshContext, createFocusRefresh, listenToWindowFocus, usePauseFocusRefresh };
@@ -25,6 +25,13 @@ const keys = {
25
25
  pageSize,
26
26
  page
27
27
  ],
28
+ /** Every `page` of one list. */
29
+ pages: (resource, filter, orderBy) => [
30
+ "page",
31
+ resource,
32
+ filter,
33
+ orderBy
34
+ ],
28
35
  /** A sidebar group's records; starts with `list`, so invalidating a resource's lists refreshes it too. */
29
36
  navRecent: (resource, filter, orderBy, pageSize) => [
30
37
  "list",
@@ -45,6 +52,12 @@ const keys = {
45
52
  resource,
46
53
  key
47
54
  ],
55
+ /** What a list row knows of a record, which its page shows until the record has loaded. */
56
+ recordPreview: (resource, key) => [
57
+ "record-preview",
58
+ resource,
59
+ key
60
+ ],
48
61
  facets: (resource, field, filter) => [
49
62
  "facets",
50
63
  resource,
@@ -1,6 +1,6 @@
1
1
  import { keys } from "./query-keys.js";
2
2
  import { useClient } from "./api-provider.js";
3
- import { useQuery } from "@tanstack/react-query";
3
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
4
4
  import { encodeKey } from "@protobase/schema/internal";
5
5
  //#region src/data/use-record.ts
6
6
  const useRecord = (resource, key, enabled = true) => {
@@ -12,5 +12,20 @@ const useRecord = (resource, key, enabled = true) => {
12
12
  queryFn: () => client.get(resource, key)
13
13
  });
14
14
  };
15
+ /**
16
+ * `useRecord` for the record's page: until the record has loaded the first time, `data` is what the list row it was
17
+ * opened from knows of it, when it was opened from one, with `isPlaceholderData` set: only the fields the list loaded.
18
+ */
19
+ const useOpenedRecord = (resource, key, enabled = true) => {
20
+ const client = useClient();
21
+ const queryClient = useQueryClient();
22
+ const encoded = encodeKey(key);
23
+ return useQuery({
24
+ queryKey: keys.record(resource, encoded),
25
+ enabled,
26
+ queryFn: () => client.get(resource, key),
27
+ placeholderData: () => queryClient.getQueryData(keys.recordPreview(resource, encoded))
28
+ });
29
+ };
15
30
  //#endregion
16
- export { useRecord };
31
+ export { useOpenedRecord, useRecord };
@@ -0,0 +1,26 @@
1
+ //#region src/data-grid/cell-moves.ts
2
+ const isMoveKey = (key) => key === "ArrowUp" || key === "ArrowDown" || key === "ArrowLeft" || key === "ArrowRight";
3
+ /** The cell an arrow key moves to from `at` among `total` rows of `columns`, or undefined at the grid's edge. */
4
+ const movedCell = (at, key, columns, total) => {
5
+ const column = columns.indexOf(at.column);
6
+ if (key === "ArrowUp") return at.index > 0 ? {
7
+ ...at,
8
+ index: at.index - 1
9
+ } : void 0;
10
+ if (key === "ArrowDown") return at.index < total - 1 ? {
11
+ ...at,
12
+ index: at.index + 1
13
+ } : void 0;
14
+ const next = columns[key === "ArrowLeft" ? column - 1 : column + 1];
15
+ return column >= 0 && next !== void 0 ? {
16
+ ...at,
17
+ column: next
18
+ } : void 0;
19
+ };
20
+ /** The editable column after (`1`) or before (`-1`) `column` in its row, or undefined when there is none. */
21
+ const nextEditable = (column, columns, editable, direction) => {
22
+ const from = columns.indexOf(column);
23
+ return (direction === 1 ? columns.slice(from + 1) : columns.slice(0, Math.max(0, from)).reverse()).find(editable);
24
+ };
25
+ //#endregion
26
+ export { isMoveKey, movedCell, nextEditable };
@@ -10,12 +10,13 @@ import { GridHeader } from "./grid-header.js";
10
10
  import { DataRow, SkeletonRow } from "./grid-row.js";
11
11
  import { usePagedRows } from "./use-paged-rows.js";
12
12
  import { useVirtualRows } from "./use-virtual-rows.js";
13
+ import { useCellEditing } from "./use-cell-editing.js";
13
14
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
14
15
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
15
16
  import { useTable } from "@tanstack/react-table";
16
17
  //#region src/data-grid/data-grid.tsx
17
18
  const headerHeight = 32;
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, scroll = "own", className }) => {
19
+ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0, onScrollIndex, latencyMs = 150, sort: controlledSort, onSortChange, onOpenRow, rowActions, cellEditor, rowPatches, bulkActions, pinKey, defaultPinned, rowNumbers = true, compact = false, selectable = true, fit = false, scroll = "own", className }) => {
19
20
  const rowHeight = compact ? 28 : 36;
20
21
  const [pinned, setPinned] = usePinnedColumns(pinKey, defaultPinned);
21
22
  const columns = useMemo(() => {
@@ -65,15 +66,24 @@ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0,
65
66
  total,
66
67
  rowAt: sourceRowAt ? rowAt : void 0,
67
68
  loadRows: source.loadRows,
69
+ revision: source.revision,
68
70
  latencyMs
69
71
  }, firstIndex, lastIndex);
72
+ const patchedRow = useCallback((index) => {
73
+ const row = getRow(index);
74
+ const patch = row && rowPatches?.get(row.id);
75
+ return patch ? {
76
+ ...row,
77
+ ...patch
78
+ } : row;
79
+ }, [getRow, rowPatches]);
70
80
  const loaded = virtualRows.map(({ index, offset }) => ({
71
81
  index,
72
82
  offset,
73
- row: getRow(index)
83
+ row: patchedRow(index)
74
84
  }));
75
85
  const data = useMemo(() => loaded.flatMap(({ row }) => row ? [row] : []), [
76
- getRow,
86
+ patchedRow,
77
87
  firstIndex,
78
88
  lastIndex
79
89
  ]);
@@ -123,6 +133,16 @@ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0,
123
133
  sorted: header.column.getIsSorted(),
124
134
  onSort: (event) => header.column.getToggleSortingHandler()?.(event)
125
135
  })));
136
+ const gridRef = useRef(null);
137
+ const columnIds = useMemo(() => columns.map((spec) => spec.id), [columns]);
138
+ const cellEditing = useCellEditing({
139
+ editor: cellEditor,
140
+ columns: columnIds,
141
+ total,
142
+ rowAt: patchedRow,
143
+ onOpenRow,
144
+ container: gridRef
145
+ });
126
146
  const minWidth = gridMinWidth(columns, gutter, trailing, minScale);
127
147
  const own = scroll === "own";
128
148
  const header = /* @__PURE__ */ jsx("div", {
@@ -183,17 +203,22 @@ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0,
183
203
  row,
184
204
  actions
185
205
  }) : void 0,
186
- cells: tableRow.getAllCells().map((cell) => ({
187
- spec: columns.find((spec) => spec.id === cell.column.id),
188
- content: /* @__PURE__ */ jsx(table.FlexRender, { cell })
189
- }))
206
+ cells: tableRow.getAllCells().map((cell) => {
207
+ const content = /* @__PURE__ */ jsx(table.FlexRender, { cell });
208
+ return {
209
+ spec: columns.find((spec) => spec.id === cell.column.id),
210
+ ...cellEditing && row ? cellEditing.cell(index, row, cell.column.id, content) : { content }
211
+ };
212
+ })
190
213
  }, row?.id);
191
214
  })
192
215
  })
193
216
  });
194
217
  return /* @__PURE__ */ jsxs("div", {
218
+ ref: gridRef,
195
219
  role: "grid",
196
220
  "aria-rowcount": total,
221
+ onKeyDown: cellEditing?.onKeyDown,
197
222
  className: cn("flex flex-col rounded-lg border bg-background", own ? "min-h-0 overflow-hidden" : "overflow-clip", className),
198
223
  style: fit && own ? { height: headerHeight + total * rowHeight + 2 } : void 0,
199
224
  children: [/* @__PURE__ */ jsxs("div", {
@@ -62,7 +62,7 @@ const GridHeader = ({ cells, specs, gutter, selectedCount, selectable, trailing,
62
62
  })]
63
63
  }, spec.id);
64
64
  }),
65
- trailing ? /* @__PURE__ */ jsx("div", {}) : null
65
+ trailing ? /* @__PURE__ */ jsx("div", { className: "sticky right-0 z-10 h-full bg-inherit" }) : null
66
66
  ]
67
67
  });
68
68
  //#endregion
@@ -14,17 +14,20 @@ const frameStyle = (frame) => ({
14
14
  /** Pinned cells need a solid background; `bg-inherit` takes the row's, so hover and selection still show. */
15
15
  const cellClass = (spec, frame) => cn("flex h-full min-w-0 items-center overflow-hidden whitespace-nowrap px-3", isRightAligned(spec.kind) && "justify-end", spec.highlight && "bg-primary-soft/60", frame.pins && spec.id in frame.pins && "sticky z-10 bg-inherit", frame.scrolled && spec.id === frame.lastPinned && "shadow-[1px_0_0_var(--border),4px_0_6px_-2px_rgb(0_0_0/0.12)]");
16
16
  const pinStyle = (spec, pins) => pins && spec.id in pins ? { left: pins[spec.id] } : void 0;
17
- /** The selection column never scrolls away, so rows can be selected from anywhere in a wide grid. */
17
+ /**
18
+ * The selection column never scrolls away, so rows can be selected from anywhere in a wide grid.
19
+ * The whole cell toggles the row instead of opening it; the checkbox's own click bubbles up to it.
20
+ */
18
21
  const Gutter = ({ width, index, selectable, selected, onToggle, edge }) => /* @__PURE__ */ jsxs("div", {
19
- className: cn("sticky left-0 z-10 flex h-full items-center gap-2 bg-inherit pl-3", edge && "shadow-[1px_0_0_var(--border),4px_0_6px_-2px_rgb(0_0_0/0.12)]"),
22
+ onClick: selectable ? (event) => {
23
+ event.stopPropagation();
24
+ onToggle?.();
25
+ } : void 0,
26
+ className: cn("sticky left-0 z-10 flex h-full items-center gap-2 bg-inherit pl-3", selectable && "cursor-default", edge && "shadow-[1px_0_0_var(--border),4px_0_6px_-2px_rgb(0_0_0/0.12)]"),
20
27
  style: { width },
21
- children: [selectable && /* @__PURE__ */ jsx("span", {
22
- onClick: (event) => event.stopPropagation(),
23
- children: /* @__PURE__ */ jsx(Checkbox, {
24
- checked: Boolean(selected),
25
- onChange: onToggle,
26
- label: `Select row ${index + 1}`
27
- })
28
+ children: [selectable && /* @__PURE__ */ jsx(Checkbox, {
29
+ checked: Boolean(selected),
30
+ label: `Select row ${index + 1}`
28
31
  }), width > 40 && /* @__PURE__ */ jsx("span", {
29
32
  className: "min-w-6 text-right text-[11px] tabular-nums text-faint-foreground",
30
33
  children: formatInt(index + 1)
@@ -44,14 +47,15 @@ const DataRow = ({ cells, selected, onToggle, onOpen, menu, ...frame }) => /* @_
44
47
  onToggle,
45
48
  edge: frame.scrolled && !frame.lastPinned
46
49
  }),
47
- cells.map(({ spec, content }) => /* @__PURE__ */ jsx("div", {
48
- role: "cell",
49
- className: cellClass(spec, frame),
50
+ cells.map(({ spec, content, props, chosen, editing, editable }) => /* @__PURE__ */ jsx("div", {
51
+ role: props ? "gridcell" : "cell",
52
+ ...props,
53
+ className: cn(cellClass(spec, frame), props && "scroll-mb-[calc(var(--pb-sticky-bottom,0px)+3rem)] scroll-mt-[calc(var(--pb-sticky-top,0px)+2rem)] outline-none", editable && !editing && "cursor-default", chosen && "shadow-[inset_0_0_0_2px_var(--primary)]", editing && "z-30 overflow-visible bg-background px-1"),
50
54
  style: pinStyle(spec, frame.pins),
51
55
  children: content
52
56
  }, spec.id)),
53
57
  menu && /* @__PURE__ */ jsx("div", {
54
- className: "flex h-full items-center justify-center",
58
+ className: "sticky right-0 z-10 flex h-full items-center justify-center bg-inherit",
55
59
  children: menu
56
60
  })
57
61
  ]
@@ -13,6 +13,9 @@ const createPageCache = (pageSize, maxPages) => {
13
13
  if (oldest.done) return;
14
14
  pages.delete(oldest.value);
15
15
  },
16
+ keepOnly: (kept) => {
17
+ for (const page of [...pages.keys()]) if (!kept.includes(page)) pages.delete(page);
18
+ },
16
19
  get size() {
17
20
  return pages.size;
18
21
  }