@protobase/ui 0.21.1 → 0.21.2

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.
@@ -0,0 +1,21 @@
1
+ import { can } from "../permissions.js";
2
+ //#region src/app/hand-order/hand-order.ts
3
+ /** The field a user may sort `model`'s records by hand in: its `position`, when they may update it. */
4
+ const writablePosition = (model, permissions) => {
5
+ const position = model.position;
6
+ if (!position || !can(permissions, "update")) return void 0;
7
+ const field = model.fields[position.field];
8
+ return field && !field.readOnly ? position.field : void 0;
9
+ };
10
+ /**
11
+ * The field the list page sorts all of `model`'s records by hand in. A resource whose records are ordered within a
12
+ * parent is sorted in the parent's related section instead, since its list mixes the parents.
13
+ */
14
+ const listHandOrder = (model, permissions) => model.position?.within === void 0 ? writablePosition(model, permissions) : void 0;
15
+ /**
16
+ * The field a related section sorts its records by hand in: those of one parent, when the section lists every record
17
+ * whose `within` relation points at the page's record, no filter or join resource leaving some out.
18
+ */
19
+ const relatedHandOrder = (item, model, permissions) => model.position?.within !== void 0 && item.field === model.position.within && !item.through && !item.filter ? writablePosition(model, permissions) : void 0;
20
+ //#endregion
21
+ export { listHandOrder, relatedHandOrder };
@@ -0,0 +1,98 @@
1
+ import { Skeleton } from "../../primitives/skeleton.js";
2
+ import { recordKey, recordTitle } from "../../live/model-helpers.js";
3
+ import { humanize } from "../../live/naming.js";
4
+ import { useProjectUi } from "../pages/project-ui.js";
5
+ import { useAdminMeta } from "../meta-gate.js";
6
+ import { ErrorBanner } from "../error-banner.js";
7
+ import { loadedFields } from "../displays/list-displays.js";
8
+ import { useAppAddress } from "../use-app-address.js";
9
+ import { DataGrid } from "../../data-grid/data-grid.js";
10
+ import { embeddedColumns } from "../embedded-list/embedded-columns.js";
11
+ import { useHandOrder } from "./use-hand-order.js";
12
+ import { useMemo } from "react";
13
+ import { jsx, jsxs } from "react/jsx-runtime";
14
+ //#region src/app/hand-order/hand-sorted-list.tsx
15
+ /**
16
+ * The records in their order by hand, in the list's table with a drag handle in each row's checkbox place: dragging a
17
+ * row, or moving it with the keyboard from its handle, renumbers them all. The page scrolls them, and a row still opens
18
+ * its record.
19
+ */
20
+ const HandSortedList = ({ model, view, field, filter, columns }) => {
21
+ const { resources, views } = useAdminMeta();
22
+ const { fields: fieldTypes } = useProjectUi();
23
+ const { go } = useAppAddress();
24
+ const specs = useMemo(() => embeddedColumns(model, view, columns, {
25
+ resources,
26
+ views,
27
+ fieldTypes
28
+ }).map((spec) => ({
29
+ ...spec,
30
+ sortable: false
31
+ })), [
32
+ model,
33
+ view,
34
+ columns.join(","),
35
+ resources,
36
+ views,
37
+ fieldTypes
38
+ ]);
39
+ const fields = useMemo(() => loadedFields(model, view, specs.map((spec) => spec.id)), [
40
+ model,
41
+ view,
42
+ specs
43
+ ]);
44
+ const order = useHandOrder({
45
+ model,
46
+ field,
47
+ filter,
48
+ fields
49
+ });
50
+ const rows = order.rows;
51
+ const source = useMemo(() => rows && {
52
+ total: rows.length,
53
+ rowAt: (index) => rows[index]
54
+ }, [rows]);
55
+ const plural = (view?.names?.plural ?? humanize(model.name)).toLowerCase();
56
+ if (order.loading) return /* @__PURE__ */ jsx("div", {
57
+ className: "flex flex-col gap-2",
58
+ children: Array.from({ length: 3 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-full" }, index))
59
+ });
60
+ if (order.error) return /* @__PURE__ */ jsx(ErrorBanner, {
61
+ title: "Could not load",
62
+ error: order.error
63
+ });
64
+ if (order.more) return /* @__PURE__ */ jsxs("p", {
65
+ className: "text-muted-foreground",
66
+ children: [
67
+ "There are more than ",
68
+ 500 .toLocaleString("en-IE"),
69
+ " ",
70
+ plural,
71
+ ", too many to sort by hand."
72
+ ]
73
+ });
74
+ if (!source || source.total === 0) return /* @__PURE__ */ jsx("p", {
75
+ className: "text-muted-foreground",
76
+ children: "Nothing here yet."
77
+ });
78
+ const openRecord = (row) => go({
79
+ kind: "record",
80
+ resource: model.name,
81
+ key: recordKey(model, row)
82
+ });
83
+ return /* @__PURE__ */ jsx(DataGrid, {
84
+ fit: true,
85
+ scroll: "parent",
86
+ columns: specs,
87
+ source,
88
+ latencyMs: 0,
89
+ onOpenRow: openRecord,
90
+ rowNumbers: false,
91
+ reorder: {
92
+ label: (row) => recordTitle(model, view, row, row.id),
93
+ onMove: order.move
94
+ }
95
+ });
96
+ };
97
+ //#endregion
98
+ export { HandSortedList };
@@ -0,0 +1,20 @@
1
+ import { Button } from "../../primitives/button.js";
2
+ import { ArrowUpDown, Check } from "lucide-react";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ //#region src/app/hand-order/sort-button.tsx
5
+ /** The button that turns sorting by hand on, as **Sort**, and off again, as **Done**; with `iconOnly` its icon alone. */
6
+ const SortButton = ({ sorting, onChange, size, iconOnly = false }) => {
7
+ const label = sorting ? "Done" : "Sort";
8
+ return /* @__PURE__ */ jsxs(Button, {
9
+ size,
10
+ variant: sorting ? "primary" : "secondary",
11
+ onClick: () => onChange(!sorting),
12
+ ...iconOnly && {
13
+ "aria-label": label,
14
+ title: label
15
+ },
16
+ children: [sorting ? /* @__PURE__ */ jsx(Check, { className: "size-3.5" }) : /* @__PURE__ */ jsx(ArrowUpDown, { className: "size-3.5" }), !iconOnly && label]
17
+ });
18
+ };
19
+ //#endregion
20
+ export { SortButton };
@@ -0,0 +1,105 @@
1
+ import { keys } from "../../data/query-keys.js";
2
+ import { useClient } from "../../data/api-provider.js";
3
+ import { recordId, recordKey } from "../../live/model-helpers.js";
4
+ import { useToast } from "../../toasts/toast-provider.js";
5
+ import { useAdminMeta } from "../meta-gate.js";
6
+ import { refusalReason } from "../displays/board-columns.js";
7
+ import { useRefreshResource } from "../pages/use-layout-data.js";
8
+ import { listLabels, withLabels } from "../../live/relation-labels.js";
9
+ import { useRef, useState } from "react";
10
+ import { useQuery } from "@tanstack/react-query";
11
+ import { encodeKey } from "@protobase/schema/internal";
12
+ import { arrayMove } from "@dnd-kit/sortable";
13
+ //#region src/app/hand-order/use-hand-order.ts
14
+ /**
15
+ * The records of `filter` in the order of `field`, all of them up to `handOrderLimit`, with `more` when there are
16
+ * others, and `move` to put one elsewhere. A move shows at once and renumbers them all 1, 2, 3, ... in one batch
17
+ * `reorder` with their ETags; moves made while one saves are saved after it, with the ETags it gave back. A refusal
18
+ * puts them back as the server has them and says why; either way every list of the resource reloads.
19
+ */
20
+ const useHandOrder = ({ model, field, filter, fields }) => {
21
+ const client = useClient();
22
+ const toast = useToast();
23
+ const refresh = useRefreshResource();
24
+ const { resources, views } = useAdminMeta();
25
+ const orderBy = `${field} asc`;
26
+ const loaded = useQuery({
27
+ queryKey: [...keys.list(model.name, filter, orderBy, 500, "hand-order"), fields.join(",")],
28
+ queryFn: async () => {
29
+ const labels = listLabels(resources, views, model, fields);
30
+ const page = await client.search(model.name, {
31
+ filter: filter || void 0,
32
+ orderBy,
33
+ pageSize: 500,
34
+ labels
35
+ });
36
+ return {
37
+ rows: withLabels(resources, model, fields, page.items, page.labels).map((row, index) => ({
38
+ ...row,
39
+ id: recordId(model, page.items[index])
40
+ })),
41
+ more: Boolean(page.nextPageToken)
42
+ };
43
+ }
44
+ });
45
+ const [moved, setMoved] = useState();
46
+ const latest = useRef(void 0);
47
+ const saving = useRef(Promise.resolve());
48
+ const waiting = useRef(0);
49
+ const save = async () => {
50
+ const rows = latest.current;
51
+ if (!rows) return;
52
+ const etags = Object.fromEntries(rows.flatMap((row) => typeof row.etag === "string" ? [[encodeKey(recordKey(model, row)), row.etag]] : []));
53
+ const written = (await client.batchWrite([{
54
+ op: "reorder",
55
+ resource: model.name,
56
+ field,
57
+ keys: rows.map((row) => recordKey(model, row)),
58
+ etags
59
+ }])).results[0];
60
+ if (written?.op !== "reorder" || !latest.current) return;
61
+ const stored = new Map(written.records.map((entry) => [recordId(model, entry.record), entry]));
62
+ latest.current = latest.current.map((row) => {
63
+ const entry = stored.get(row.id);
64
+ return entry ? {
65
+ ...row,
66
+ [field]: entry.record[field],
67
+ etag: entry.etag
68
+ } : row;
69
+ });
70
+ };
71
+ const refused = (error) => {
72
+ latest.current = void 0;
73
+ setMoved(void 0);
74
+ toast.show({
75
+ state: "error",
76
+ title: "Could not change the order",
77
+ description: refusalReason(error)
78
+ });
79
+ };
80
+ const settle = async () => {
81
+ waiting.current -= 1;
82
+ if (waiting.current > 0) return;
83
+ await refresh(model.name);
84
+ if (waiting.current > 0) return;
85
+ latest.current = void 0;
86
+ setMoved(void 0);
87
+ };
88
+ const move = (from, to) => {
89
+ const rows = latest.current ?? loaded.data?.rows;
90
+ if (!rows || from === to) return;
91
+ latest.current = arrayMove(rows, from, to);
92
+ setMoved(latest.current);
93
+ waiting.current += 1;
94
+ saving.current = saving.current.then(save).catch(refused).then(settle);
95
+ };
96
+ return {
97
+ rows: moved ?? loaded.data?.rows,
98
+ more: loaded.data?.more ?? false,
99
+ loading: loaded.isPending,
100
+ error: loaded.error,
101
+ move
102
+ };
103
+ };
104
+ //#endregion
105
+ export { useHandOrder };
@@ -14,7 +14,7 @@ import { parseSort, sortToOrderBy } from "./list-order.js";
14
14
  import { listedResource } from "./current-address.js";
15
15
  import { AppLink, useAppAddress } from "./use-app-address.js";
16
16
  import { can, rowAllows } from "./permissions.js";
17
- import { columnsFromModel } from "../live/columns-from-model.js";
17
+ import { columnsFromModel, listColumnNames } from "../live/columns-from-model.js";
18
18
  import { pinnedStorageKey } from "../data-grid/pinned-storage.js";
19
19
  import { DataGrid } from "../data-grid/data-grid.js";
20
20
  import { useLiveRows } from "../live/use-live-rows.js";
@@ -52,6 +52,9 @@ import { SavedList } from "./saved/saved-list.js";
52
52
  import { SaveSearchButton } from "./searches/save-search-button.js";
53
53
  import { SavedSearches } from "./searches/saved-searches.js";
54
54
  import { useListCellEditor } from "./list-edit/use-list-cell-editor.js";
55
+ import { listHandOrder } from "./hand-order/hand-order.js";
56
+ import { HandSortedList } from "./hand-order/hand-sorted-list.js";
57
+ import { SortButton } from "./hand-order/sort-button.js";
55
58
  import { useMemo, useState } from "react";
56
59
  import { Download, Plus, SlidersHorizontal } from "lucide-react";
57
60
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
@@ -160,7 +163,11 @@ const LoadedList = ({ model, address, scope, heading }) => {
160
163
  related
161
164
  });
162
165
  const display = shownDisplay(view, address.display);
163
- const pageScroll = display?.kind !== "map";
166
+ const permissions = usePermissions(resource);
167
+ const handOrder = scope ? void 0 : listHandOrder(model, permissions);
168
+ const [sorting, setSorting] = useState(false);
169
+ const handSorting = sorting && handOrder !== void 0;
170
+ const pageScroll = handSorting || display?.kind !== "map";
164
171
  usePageScroll(pageScroll);
165
172
  const displayRows = useDisplayRows(model, rows.page, rows.key, display?.kind === "custom");
166
173
  const scroll = useListScroll();
@@ -172,7 +179,6 @@ const LoadedList = ({ model, address, scope, heading }) => {
172
179
  snapshot: row
173
180
  });
174
181
  const singular = view?.names?.singular ?? humanize(resource);
175
- const permissions = usePermissions(resource);
176
182
  const cellEditing = useListCellEditor(model, view, permissions);
177
183
  const { state: auth } = useAuth();
178
184
  const restore = useRestore(model);
@@ -308,7 +314,12 @@ const LoadedList = ({ model, address, scope, heading }) => {
308
314
  },
309
315
  children: [/* @__PURE__ */ jsx(Download, { className: "size-3.5" }), desktop && "Export"]
310
316
  });
311
- const displaySwitch = (menu) => /* @__PURE__ */ jsx(DisplaySwitch, {
317
+ const sortButton = handOrder && /* @__PURE__ */ jsx(SortButton, {
318
+ sorting: handSorting,
319
+ onChange: setSorting,
320
+ iconOnly: !desktop
321
+ });
322
+ const displaySwitch = (menu) => !handSorting && /* @__PURE__ */ jsx(DisplaySwitch, {
312
323
  view,
313
324
  value: display?.name ?? tableDisplay,
314
325
  onChange: (name) => set({ display: name }),
@@ -392,7 +403,19 @@ const LoadedList = ({ model, address, scope, heading }) => {
392
403
  list: displayRows
393
404
  });
394
405
  };
395
- const shown = drawDisplay();
406
+ const shown = handSorting ? /* @__PURE__ */ jsxs("div", {
407
+ className: "flex flex-col gap-3",
408
+ children: [/* @__PURE__ */ jsx("p", {
409
+ className: "text-muted-foreground",
410
+ children: "Drag a row by its handle to move it. Every move is saved as you drop it."
411
+ }), /* @__PURE__ */ jsx(HandSortedList, {
412
+ model,
413
+ view,
414
+ field: handOrder,
415
+ filter: "",
416
+ columns: listColumnNames(model, view)
417
+ })]
418
+ }) : drawDisplay();
396
419
  return /* @__PURE__ */ jsxs("div", {
397
420
  className: "flex min-h-0 flex-1 flex-col gap-3 px-5 py-4",
398
421
  children: [
@@ -404,11 +427,13 @@ const LoadedList = ({ model, address, scope, heading }) => {
404
427
  aside: !desktop && displaySwitch(true),
405
428
  actions: desktop ? /* @__PURE__ */ jsxs(Fragment$1, { children: [
406
429
  displaySwitch(false),
430
+ sortButton,
407
431
  saveSearch,
408
432
  importButton,
409
433
  exportButton,
410
434
  createButton
411
435
  ] }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [
436
+ sortButton,
412
437
  saveSearch,
413
438
  importButton,
414
439
  exportButton,
@@ -419,11 +444,11 @@ const LoadedList = ({ model, address, scope, heading }) => {
419
444
  title: "The filter in the address could not be read",
420
445
  error: new Error(parsed.errors.map((error) => error.message).join("; "))
421
446
  }),
422
- rows.error && !display && /* @__PURE__ */ jsx(ErrorBanner, {
447
+ rows.error && !display && !handSorting && /* @__PURE__ */ jsx(ErrorBanner, {
423
448
  title: "Could not load rows",
424
449
  error: rows.error
425
450
  }),
426
- view?.chart && (isGroupChart(view.chart) ? /* @__PURE__ */ jsx(ListGroupChart, {
451
+ view?.chart && !handSorting && (isGroupChart(view.chart) ? /* @__PURE__ */ jsx(ListGroupChart, {
427
452
  model,
428
453
  view,
429
454
  chart: view.chart,
@@ -436,8 +461,8 @@ const LoadedList = ({ model, address, scope, heading }) => {
436
461
  noun: plural.toLowerCase(),
437
462
  filter: loadFilter
438
463
  })),
439
- desktop ? desktopToolbar : phoneFilters,
440
- desktop && layout === "panel" ? /* @__PURE__ */ jsxs("div", {
464
+ !handSorting && (desktop ? desktopToolbar : phoneFilters),
465
+ desktop && layout === "panel" && !handSorting ? /* @__PURE__ */ jsxs("div", {
441
466
  className: "flex min-h-0 flex-1 gap-4",
442
467
  children: [/* @__PURE__ */ jsx("div", {
443
468
  className: pageScroll ? "sticky top-[calc(var(--pb-sticky-top,0px)+1rem)] flex max-h-[calc(100dvh-var(--pb-sticky-top,0px)-var(--pb-sticky-bottom,0px)-2rem)] shrink-0 self-start" : "flex shrink-0",
@@ -1,13 +1,17 @@
1
1
  import { Skeleton } from "../primitives/skeleton.js";
2
2
  import { keys } from "../data/query-keys.js";
3
3
  import { useClient } from "../data/api-provider.js";
4
- import { useAdminMeta } from "./meta-gate.js";
4
+ import { useAdminMeta, usePermissions } from "./meta-gate.js";
5
5
  import { Section } from "../record-view/section.js";
6
6
  import { ErrorBanner } from "./error-banner.js";
7
7
  import { listColumnNames } from "../live/columns-from-model.js";
8
8
  import { EmbeddedDisplaySwitch, EmbeddedList } from "./embedded-list/embedded-list.js";
9
9
  import { useEmbeddedDisplay } from "./embedded-list/use-embedded-display.js";
10
- import { jsx } from "react/jsx-runtime";
10
+ import { relatedHandOrder } from "./hand-order/hand-order.js";
11
+ import { HandSortedList } from "./hand-order/hand-sorted-list.js";
12
+ import { SortButton } from "./hand-order/sort-button.js";
13
+ import { useState } from "react";
14
+ import { jsx, jsxs } from "react/jsx-runtime";
11
15
  import { useQuery } from "@tanstack/react-query";
12
16
  import { createWhere, printFilter } from "@protobase/schema";
13
17
  //#region src/app/related-section.tsx
@@ -67,7 +71,8 @@ const Empty = ({ text }) => /* @__PURE__ */ jsx("p", {
67
71
  });
68
72
  /**
69
73
  * A record page section listing the first records of another resource that point at this record, directly or through
70
- * a join resource, in the list page's table or display, with a link to them all in the list.
74
+ * a join resource, in the list page's table or display, with a link to them all in the list. When they are ordered by
75
+ * hand within this record, **Sort** shows them all in their order, to be moved by their drag handles.
71
76
  */
72
77
  const RelatedSection = ({ item, recordKey, owner }) => {
73
78
  const { resources, views } = useAdminMeta();
@@ -75,16 +80,40 @@ const RelatedSection = ({ item, recordKey, owner }) => {
75
80
  const view = model && views[model.name];
76
81
  const match = useMatch(item, recordKey);
77
82
  const [display, pick] = useEmbeddedDisplay(view, `related:${owner}:${item.title}`, item.display);
83
+ const permissions = usePermissions(item.resource);
84
+ const [sorting, setSorting] = useState(false);
78
85
  if (!model) return null;
79
86
  const columns = item.columns ?? listColumnNames(model, view).filter((name) => name !== item.field);
80
- return /* @__PURE__ */ jsx(Section, {
87
+ const handOrder = relatedHandOrder(item, model, permissions);
88
+ const displaySwitch = /* @__PURE__ */ jsx(EmbeddedDisplaySwitch, {
89
+ view,
90
+ value: display,
91
+ onChange: pick
92
+ });
93
+ const actions = handOrder ? /* @__PURE__ */ jsxs("div", {
94
+ className: "flex items-center gap-2",
95
+ children: [!sorting && displaySwitch, /* @__PURE__ */ jsx(SortButton, {
96
+ size: "sm",
97
+ sorting,
98
+ onChange: setSorting
99
+ })]
100
+ }) : displaySwitch;
101
+ if (sorting && handOrder) return /* @__PURE__ */ jsx(Section, {
81
102
  title: item.title,
82
103
  help: item.help,
83
- actions: /* @__PURE__ */ jsx(EmbeddedDisplaySwitch, {
104
+ actions,
105
+ children: /* @__PURE__ */ jsx(HandSortedList, {
106
+ model,
84
107
  view,
85
- value: display,
86
- onChange: pick
87
- }),
108
+ field: handOrder,
109
+ filter: printFilter(where$1.eq(item.field, recordKey)),
110
+ columns
111
+ })
112
+ });
113
+ return /* @__PURE__ */ jsx(Section, {
114
+ title: item.title,
115
+ help: item.help,
116
+ actions,
88
117
  children: match.isPending ? /* @__PURE__ */ jsx(Loading, {}) : match.error ? /* @__PURE__ */ jsx(ErrorBanner, {
89
118
  title: "Could not load",
90
119
  error: match.error
@@ -11,12 +11,14 @@ 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
13
  import { useCellEditing } from "./use-cell-editing.js";
14
+ import { ReorderRows, SortableRow } from "./row-reorder.js";
14
15
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
15
16
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
16
17
  import { useTable } from "@tanstack/react-table";
17
18
  //#region src/data-grid/data-grid.tsx
18
19
  const headerHeight = 32;
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 }) => {
20
+ 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: selecting = true, reorder, fit = false, scroll = "own", className }) => {
21
+ const selectable = selecting && !reorder;
20
22
  const rowHeight = compact ? 28 : 36;
21
23
  const [pinned, setPinned] = usePinnedColumns(pinKey, defaultPinned);
22
24
  const columns = useMemo(() => {
@@ -166,6 +168,46 @@ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0,
166
168
  })
167
169
  })
168
170
  });
171
+ const rowsInView = loaded.map(({ index, offset, row }) => {
172
+ const frame = {
173
+ specs: columns,
174
+ gutter,
175
+ index,
176
+ offset,
177
+ height: rowHeight,
178
+ selectable,
179
+ trailing,
180
+ minScale,
181
+ scrolled: horizontal.scrolled,
182
+ pins,
183
+ lastPinned
184
+ };
185
+ const tableRow = row ? tableRows.get(row.id) : void 0;
186
+ if (!tableRow || !row) return /* @__PURE__ */ jsx(SkeletonRow, { ...frame }, `skeleton-${index}`);
187
+ const actions = trailing ? availableActions(rowActions, row) : [];
188
+ const props = {
189
+ ...frame,
190
+ selected: tableRow.getIsSelected(),
191
+ onToggle: () => tableRow.toggleSelected(),
192
+ onOpen: onOpenRow ? () => onOpenRow(row) : void 0,
193
+ menu: actions.length > 0 ? /* @__PURE__ */ jsx(RowMenu, {
194
+ row,
195
+ actions
196
+ }) : void 0,
197
+ cells: tableRow.getAllCells().map((cell) => {
198
+ const content = /* @__PURE__ */ jsx(table.FlexRender, { cell });
199
+ return {
200
+ spec: columns.find((spec) => spec.id === cell.column.id),
201
+ ...cellEditing ? cellEditing.cell(index, row, cell.column.id, content) : { content }
202
+ };
203
+ })
204
+ };
205
+ return reorder ? /* @__PURE__ */ jsx(SortableRow, {
206
+ ...props,
207
+ row,
208
+ label: reorder.label(row)
209
+ }, row.id) : /* @__PURE__ */ jsx(DataRow, { ...props }, row.id);
210
+ });
169
211
  const body = /* @__PURE__ */ jsx("div", {
170
212
  ref: horizontal.attach,
171
213
  onScroll: onBodyScroll,
@@ -177,41 +219,12 @@ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0,
177
219
  minWidth,
178
220
  height: geometry.contentHeight
179
221
  },
180
- children: loaded.map(({ index, offset, row }) => {
181
- const frame = {
182
- specs: columns,
183
- gutter,
184
- index,
185
- offset,
186
- height: rowHeight,
187
- selectable,
188
- trailing,
189
- minScale,
190
- scrolled: horizontal.scrolled,
191
- pins,
192
- lastPinned
193
- };
194
- const tableRow = row ? tableRows.get(row.id) : void 0;
195
- if (!tableRow) return /* @__PURE__ */ jsx(SkeletonRow, { ...frame }, `skeleton-${index}`);
196
- const actions = trailing && row ? availableActions(rowActions, row) : [];
197
- return /* @__PURE__ */ jsx(DataRow, {
198
- ...frame,
199
- selected: tableRow.getIsSelected(),
200
- onToggle: () => tableRow.toggleSelected(),
201
- onOpen: onOpenRow && row ? () => onOpenRow(row) : void 0,
202
- menu: row && actions.length > 0 ? /* @__PURE__ */ jsx(RowMenu, {
203
- row,
204
- actions
205
- }) : void 0,
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
- })
213
- }, row?.id);
214
- })
222
+ children: reorder ? /* @__PURE__ */ jsx(ReorderRows, {
223
+ ids: Array.from({ length: total }, (_, index) => patchedRow(index)?.id ?? `missing-${index}`),
224
+ rowAt: patchedRow,
225
+ reorder,
226
+ children: rowsInView
227
+ }) : rowsInView
215
228
  })
216
229
  });
217
230
  return /* @__PURE__ */ jsxs("div", {
@@ -33,13 +33,25 @@ const Gutter = ({ width, index, selectable, selected, onToggle, edge }) => /* @_
33
33
  children: formatInt(index + 1)
34
34
  })]
35
35
  });
36
- const DataRow = ({ cells, selected, onToggle, onOpen, menu, ...frame }) => /* @__PURE__ */ jsxs("div", {
36
+ const dragStyle = (frame, dragY, transition) => ({
37
+ gridTemplateColumns: gridTemplate(frame.specs, frame.gutter, frame.trailing, frame.minScale),
38
+ height: frame.height,
39
+ top: frame.offset,
40
+ transform: dragY === 0 ? void 0 : `translateY(${dragY}px)`,
41
+ transition
42
+ });
43
+ const DataRow = ({ cells, selected, onToggle, onOpen, menu, handle, rowRef, dragY, transition, dragging, ...frame }) => /* @__PURE__ */ jsxs("div", {
44
+ ref: rowRef,
37
45
  role: "row",
38
46
  onClick: onOpen,
39
- className: cn("absolute inset-x-0 top-0 grid items-center border-b", selected ? "bg-primary-soft" : "bg-background hover:bg-surface", onOpen && "cursor-pointer"),
40
- style: frameStyle(frame),
47
+ className: cn("absolute inset-x-0 top-0 grid items-center border-b", selected ? "bg-primary-soft" : "bg-background hover:bg-surface", onOpen && "cursor-pointer", dragging && "z-30 bg-surface shadow-pop"),
48
+ style: dragY === void 0 ? frameStyle(frame) : dragStyle(frame, dragY, transition),
41
49
  children: [
42
- /* @__PURE__ */ jsx(Gutter, {
50
+ handle ? /* @__PURE__ */ jsx("div", {
51
+ className: cn("sticky left-0 z-10 flex h-full items-center bg-inherit pl-1.5", frame.scrolled && !frame.lastPinned && "shadow-[1px_0_0_var(--border),4px_0_6px_-2px_rgb(0_0_0/0.12)]"),
52
+ style: { width: frame.gutter },
53
+ children: handle
54
+ }) : /* @__PURE__ */ jsx(Gutter, {
43
55
  width: frame.gutter,
44
56
  index: frame.index,
45
57
  selectable: frame.selectable,
@@ -0,0 +1,68 @@
1
+ import { DataRow } from "./grid-row.js";
2
+ import { GripVertical } from "lucide-react";
3
+ import { jsx } from "react/jsx-runtime";
4
+ import { DndContext, KeyboardSensor, MouseSensor, TouchSensor, closestCenter, useSensor, useSensors } from "@dnd-kit/core";
5
+ import { SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
6
+ //#region src/data-grid/row-reorder.tsx
7
+ const instructions = "To move a row, press Space on its handle, move it with the up and down arrow keys, and press Space again to drop it there. Escape puts it back.";
8
+ /** The rows of a grid that are moved by hand, `ids` in their order, each loaded or not, so the drop's index is the row's place in the whole list. */
9
+ const ReorderRows = ({ ids, rowAt, reorder, children }) => {
10
+ const sensors = useSensors(useSensor(MouseSensor, { activationConstraint: { distance: 4 } }), useSensor(TouchSensor, { activationConstraint: {
11
+ delay: 250,
12
+ tolerance: 5
13
+ } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }));
14
+ const place = (id) => ids.indexOf(String(id));
15
+ const name = (id) => {
16
+ const row = rowAt(place(id));
17
+ return row ? reorder.label(row) : "row";
18
+ };
19
+ const announcements = {
20
+ onDragStart: ({ active }) => `Picked up ${name(active.id)}, row ${place(active.id) + 1} of ${ids.length}.`,
21
+ onDragOver: ({ active, over }) => over ? `${name(active.id)} is over row ${place(over.id) + 1} of ${ids.length}.` : void 0,
22
+ onDragEnd: ({ active, over }) => over ? `${name(active.id)} was dropped at row ${place(over.id) + 1} of ${ids.length}.` : `${name(active.id)} was put back.`,
23
+ onDragCancel: ({ active }) => `Moving ${name(active.id)} was cancelled. It is back at row ${place(active.id) + 1}.`
24
+ };
25
+ const end = ({ active, over }) => {
26
+ if (!over || active.id === over.id) return;
27
+ reorder.onMove(place(active.id), place(over.id));
28
+ };
29
+ return /* @__PURE__ */ jsx(DndContext, {
30
+ sensors,
31
+ collisionDetection: closestCenter,
32
+ onDragEnd: end,
33
+ accessibility: {
34
+ announcements,
35
+ screenReaderInstructions: { draggable: instructions }
36
+ },
37
+ children: /* @__PURE__ */ jsx(SortableContext, {
38
+ items: ids,
39
+ strategy: verticalListSortingStrategy,
40
+ children
41
+ })
42
+ });
43
+ };
44
+ /** A row moved by its handle, which takes the checkbox's place; it follows the pointer up and down only. */
45
+ const SortableRow = ({ row, label, ...props }) => {
46
+ const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({ id: row.id });
47
+ const handle = /* @__PURE__ */ jsx("button", {
48
+ ref: setActivatorNodeRef,
49
+ type: "button",
50
+ "aria-label": `Move ${label}`,
51
+ ...attributes,
52
+ ...listeners,
53
+ onClick: (event) => event.stopPropagation(),
54
+ style: { touchAction: "none" },
55
+ className: "flex size-7 cursor-grab items-center justify-center rounded text-faint-foreground hover:bg-muted hover:text-foreground focus-visible:text-foreground active:cursor-grabbing pointer-coarse:size-9",
56
+ children: /* @__PURE__ */ jsx(GripVertical, { className: "size-4" })
57
+ });
58
+ return /* @__PURE__ */ jsx(DataRow, {
59
+ ...props,
60
+ rowRef: setNodeRef,
61
+ handle,
62
+ dragY: transform?.y ?? 0,
63
+ transition,
64
+ dragging: isDragging
65
+ });
66
+ };
67
+ //#endregion
68
+ export { ReorderRows, SortableRow };
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.21.1";
2
+ var version = "0.21.2";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };