@marktiderman/genesis-ui 2.8.0 → 2.9.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.
@@ -2,33 +2,35 @@
2
2
  import { AppShell, PageHeader } from '../../chunk-UYI3GLFJ.js';
3
3
  export { ViewSettings, ViewToggle } from '../../chunk-46TZGMFW.js';
4
4
  import { renderSlot } from '../../chunk-7S7LKQBD.js';
5
+ import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from '../../chunk-N2ZFNOOT.js';
5
6
  import { Textarea } from '../../chunk-YETMISCE.js';
6
7
  import { Separator } from '../../chunk-SXSCHWZU.js';
7
8
  import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '../../chunk-XCGZPFT7.js';
8
9
  import { Skeleton } from '../../chunk-QFM4Z2SS.js';
9
- import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from '../../chunk-N2ZFNOOT.js';
10
+ import { toast } from '../../chunk-4RAZJ6LL.js';
10
11
  import { Progress } from '../../chunk-W5Y3LG5K.js';
11
- import { Card, CardContent, CardHeader, CardTitle } from '../../chunk-FN3FZVUR.js';
12
12
  import { Checkbox } from '../../chunk-SEBU6JCK.js';
13
+ import { Card, CardContent, CardHeader, CardTitle } from '../../chunk-FN3FZVUR.js';
13
14
  import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction } from '../../chunk-4C4ESW6O.js';
15
+ import { PageLoading } from '../../chunk-YRYQXU3G.js';
16
+ import { Spinner } from '../../chunk-OVU3Z2MT.js';
14
17
  import { Input } from '../../chunk-QSXVL5UB.js';
15
18
  import '../../chunk-B2GIBUQD.js';
16
19
  import { Badge } from '../../chunk-NPNADZHM.js';
17
- import { DetailPage } from '../../chunk-AFZ76Q26.js';
18
- import '../../chunk-D2LIXA2Y.js';
19
- import '../../chunk-AVFZ4P4B.js';
20
- import { PageLoading } from '../../chunk-YRYQXU3G.js';
21
- import { Spinner } from '../../chunk-OVU3Z2MT.js';
22
20
  import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '../../chunk-FK42ZGGV.js';
23
21
  import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuCheckboxItem, DropdownMenuSeparator, DropdownMenuItem, DropdownMenuLabel } from '../../chunk-QJ6P2DW6.js';
24
22
  import { Switch } from '../../chunk-27ELYTGE.js';
25
23
  import { Popover, PopoverTrigger, PopoverContent } from '../../chunk-22XB3Q6H.js';
24
+ import { DetailPage } from '../../chunk-AFZ76Q26.js';
25
+ import '../../chunk-D2LIXA2Y.js';
26
+ import '../../chunk-AVFZ4P4B.js';
26
27
  import { EmptyState } from '../../chunk-65SPT7EA.js';
27
28
  export { EmptyState } from '../../chunk-65SPT7EA.js';
28
29
  import { Button } from '../../chunk-NWSCYLPT.js';
29
30
  import { FormField, FormLabel, FormError, FormDescription } from '../../chunk-R7QFP6EL.js';
30
31
  import '../../chunk-F47U5KNW.js';
31
- import { titleCase, useResourcePage } from '../../chunk-DWCAC2E7.js';
32
+ import { titleCase, useResourcePage } from '../../chunk-RKPS7ERS.js';
33
+ import { useResourceUrlState } from '../../chunk-DIV4BYXX.js';
32
34
  export { useSavedViews } from '../../chunk-GCQFVMPI.js';
33
35
  import '../../chunk-PKFDGGZV.js';
34
36
  import { useViewSettings } from '../../chunk-OZ7ZAYCS.js';
@@ -43,10 +45,12 @@ import { useIsMobile } from '../../chunk-VFO3I7PV.js';
43
45
  import { AlertTriangle, RefreshCw, ChevronDown, Check, Search, X, ArrowUpDown, Bookmark, Trash2, BookmarkPlus, Loader2, Columns3, ArrowUp, ArrowDown, ChevronLeft, ChevronRight, ExternalLink, ArrowRightLeft, Pencil, ChevronsUpDown, ImageIcon, Upload, AlertCircle, FileIcon, Plus } from 'lucide-react';
44
46
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
45
47
  import { useState, useRef, useEffect, useCallback, useMemo, useId, Fragment as Fragment$1 } from 'react';
48
+ import { useReactTable, getPaginationRowModel, getSortedRowModel, getCoreRowModel } from '@tanstack/react-table';
49
+ import { useVirtualizer } from '@tanstack/react-virtual';
46
50
  import { formatDistanceToNow, format } from 'date-fns';
47
51
  import { useFieldArray, Controller, useWatch } from 'react-hook-form';
48
- import { useResource, useWizard, useResourceForm, useOne, buildArrayItemDefaults } from '@marktiderman/genesis-core/hooks';
49
- import { useDataProvider } from '@marktiderman/genesis-core/provider';
52
+ import { useResource, useWizard, useResourceForm, useOne, useCapabilities, buildArrayItemDefaults } from '@marktiderman/genesis-core/hooks';
53
+ import { useDataProvider, isDataReadError } from '@marktiderman/genesis-core/provider';
50
54
  import { useDropzone } from 'react-dropzone';
51
55
 
52
56
  function DataPageShell({
@@ -670,6 +674,50 @@ var DENSITY_HEAD = {
670
674
  comfortable: "h-10",
671
675
  spacious: "h-12 py-3"
672
676
  };
677
+ function storeSet(key, value) {
678
+ try {
679
+ localStorage.setItem(key, value);
680
+ } catch {
681
+ }
682
+ }
683
+ function storeRemove(key) {
684
+ try {
685
+ localStorage.removeItem(key);
686
+ } catch {
687
+ }
688
+ }
689
+ var ROW_HEIGHT = { compact: 33, comfortable: 41, spacious: 49 };
690
+ var PINNED_WIDTH = 160;
691
+ function compareValues(a, b) {
692
+ const emptyA = a == null || a === "";
693
+ const emptyB = b == null || b === "";
694
+ if (emptyA || emptyB) return emptyA === emptyB ? 0 : emptyA ? -1 : 1;
695
+ if (typeof a === "number" && typeof b === "number") return a - b;
696
+ if (typeof a === "bigint" && typeof b === "bigint") return a < b ? -1 : a > b ? 1 : 0;
697
+ if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime();
698
+ if (typeof a === "boolean" && typeof b === "boolean") return Number(a) - Number(b);
699
+ return String(a).localeCompare(String(b), void 0, { numeric: true, sensitivity: "base" });
700
+ }
701
+ var ROW_OPEN = "data-row-open";
702
+ function focusRowAt(body, index) {
703
+ const target = body?.querySelector(`[${ROW_OPEN}="${index}"]`);
704
+ target?.focus();
705
+ return !!target;
706
+ }
707
+ function nextSort(prev, key, shiftKey) {
708
+ const existing = prev.findIndex((s) => s.key === key);
709
+ if (existing >= 0) {
710
+ const entry = prev[existing];
711
+ if (entry.dir === "asc") {
712
+ const next = [...prev];
713
+ next[existing] = { key, dir: "desc" };
714
+ return next;
715
+ }
716
+ return prev.filter((_, i) => i !== existing);
717
+ }
718
+ if (shiftKey) return [...prev, { key, dir: "asc" }];
719
+ return [{ key, dir: "asc" }];
720
+ }
673
721
  function getCellClasses(col) {
674
722
  const hide = col.hideBelow ? col.hideBelow === "sm" ? "hidden sm:table-cell" : col.hideBelow === "md" ? "hidden md:table-cell" : col.hideBelow === "lg" ? "hidden lg:table-cell" : "hidden xl:table-cell" : "";
675
723
  return cn(typeof col.width === "string" && !PX_WIDTH.test(col.width) ? col.width : void 0, hide);
@@ -888,29 +936,29 @@ function DataTable({
888
936
  stickyHeader = true,
889
937
  resizeKey,
890
938
  stickyFirstColumn,
891
- density = "comfortable"
939
+ density = "comfortable",
940
+ keyboardNavigation = true,
941
+ openColumn,
942
+ virtualize,
943
+ pinnedColumns,
944
+ reorderableColumns = true,
945
+ columnOrderKey,
946
+ sort: sortProp,
947
+ onSortChange
892
948
  }) {
949
+ const rowKeys = keyboardNavigation && !!onRowClick;
950
+ const [activeRowKey, setActiveRowKey] = useState(null);
893
951
  const cellDensity = DENSITY_CELL[density];
894
952
  const headDensity = DENSITY_HEAD[density];
895
- const [sorts, setSorts] = useState([]);
953
+ const [ownSorts, setOwnSorts] = useState([]);
954
+ const sorts = sortProp ?? ownSorts;
896
955
  const handleSort = useCallback(
897
956
  (key, shiftKey) => {
898
- setSorts((prev) => {
899
- const existing = prev.findIndex((s) => s.key === key);
900
- if (existing >= 0) {
901
- const entry = prev[existing];
902
- if (entry.dir === "asc") {
903
- const next = [...prev];
904
- next[existing] = { key, dir: "desc" };
905
- return next;
906
- }
907
- return prev.filter((_, i) => i !== existing);
908
- }
909
- if (shiftKey) return [...prev, { key, dir: "asc" }];
910
- return [{ key, dir: "asc" }];
911
- });
957
+ const next = nextSort(sorts, key, shiftKey);
958
+ if (sortProp === void 0) setOwnSorts(next);
959
+ onSortChange?.(next);
912
960
  },
913
- []
961
+ [sorts, sortProp, onSortChange]
914
962
  );
915
963
  const [visibleCols, setVisibleCols] = useState(
916
964
  () => {
@@ -950,7 +998,7 @@ function DataTable({
950
998
  }, [columnVisibilityKey]);
951
999
  useEffect(() => {
952
1000
  if (!visibilityHydrated || !columnVisibilityKey) return;
953
- localStorage.setItem(
1001
+ storeSet(
954
1002
  `dt-vis-${columnVisibilityKey}`,
955
1003
  JSON.stringify(visibleCols)
956
1004
  );
@@ -964,8 +1012,9 @@ function DataTable({
964
1012
  defaults[c.key] = c.defaultVisible !== false;
965
1013
  });
966
1014
  setVisibleCols(defaults);
1015
+ setColOrder(null);
967
1016
  if (columnVisibilityKey) {
968
- localStorage.removeItem(`dt-vis-${columnVisibilityKey}`);
1017
+ storeRemove(`dt-vis-${columnVisibilityKey}`);
969
1018
  }
970
1019
  }, [columns, columnVisibilityKey]);
971
1020
  useEffect(() => {
@@ -981,7 +1030,54 @@ function DataTable({
981
1030
  return changed ? updated : prev;
982
1031
  });
983
1032
  }, [columns]);
984
- const activeColumns = columnVisibility ? columns.filter((c) => visibleCols[c.key] !== false) : columns;
1033
+ const [colOrder, setColOrder] = useState(null);
1034
+ const [orderHydrated, setOrderHydrated] = useState(false);
1035
+ useEffect(() => {
1036
+ if (columnOrderKey) {
1037
+ try {
1038
+ const saved = localStorage.getItem(`dt-order-${columnOrderKey}`);
1039
+ const parsed = saved ? JSON.parse(saved) : null;
1040
+ if (Array.isArray(parsed) && parsed.every((k) => typeof k === "string")) {
1041
+ setColOrder(parsed);
1042
+ }
1043
+ } catch {
1044
+ }
1045
+ }
1046
+ setOrderHydrated(true);
1047
+ }, [columnOrderKey]);
1048
+ useEffect(() => {
1049
+ if (!orderHydrated || !columnOrderKey) return;
1050
+ if (colOrder) storeSet(`dt-order-${columnOrderKey}`, JSON.stringify(colOrder));
1051
+ else storeRemove(`dt-order-${columnOrderKey}`);
1052
+ }, [colOrder, columnOrderKey, orderHydrated]);
1053
+ const orderedColumns = useMemo(() => {
1054
+ if (!colOrder) return columns;
1055
+ const rank = new Map(colOrder.map((k, i) => [k, i]));
1056
+ return [...columns].sort(
1057
+ (a, b) => (rank.get(a.key) ?? colOrder.length) - (rank.get(b.key) ?? colOrder.length)
1058
+ );
1059
+ }, [columns, colOrder]);
1060
+ const moveColumn = useCallback(
1061
+ (key, to) => {
1062
+ const keys = orderedColumns.map((c) => c.key);
1063
+ const from = keys.indexOf(key);
1064
+ if (from < 0) return;
1065
+ keys.splice(from, 1);
1066
+ const at = "before" in to ? Math.max(0, keys.indexOf(to.before)) : keys.indexOf(to.after) + 1;
1067
+ keys.splice(at, 0, key);
1068
+ setColOrder(keys);
1069
+ },
1070
+ [orderedColumns]
1071
+ );
1072
+ const [dragKey, setDragKey] = useState(null);
1073
+ const visibleColumns = columnVisibility ? orderedColumns.filter((c) => visibleCols[c.key] !== false) : orderedColumns;
1074
+ const leftKeys = pinnedColumns?.left ?? (stickyFirstColumn && visibleColumns[0] ? [visibleColumns[0].key] : []);
1075
+ const rightKeys = pinnedColumns?.right ?? [];
1076
+ const activeColumns = [
1077
+ ...visibleColumns.filter((c) => leftKeys.includes(c.key)),
1078
+ ...visibleColumns.filter((c) => !leftKeys.includes(c.key) && !rightKeys.includes(c.key)),
1079
+ ...visibleColumns.filter((c) => rightKeys.includes(c.key))
1080
+ ];
985
1081
  const [colWidths, setColWidths] = useState(() => {
986
1082
  if (resizeKey) {
987
1083
  try {
@@ -1008,46 +1104,232 @@ function DataTable({
1008
1104
  );
1009
1105
  const handleResizeEnd = useCallback(() => {
1010
1106
  if (resizeKey) {
1011
- localStorage.setItem(`dt-resize-${resizeKey}`, JSON.stringify(colWidths));
1107
+ storeSet(`dt-resize-${resizeKey}`, JSON.stringify(colWidths));
1012
1108
  }
1013
1109
  }, [resizeKey, colWidths]);
1110
+ const pinWidth = (key) => colWidths[key] ?? PINNED_WIDTH;
1111
+ const pinned = {};
1112
+ {
1113
+ let left = selectable ? 40 : 0;
1114
+ for (const c of activeColumns) {
1115
+ if (!leftKeys.includes(c.key)) continue;
1116
+ pinned[c.key] = { side: "left", offset: left };
1117
+ left += pinWidth(c.key);
1118
+ }
1119
+ let right = renderActions ? 40 : 0;
1120
+ for (const c of [...activeColumns].reverse()) {
1121
+ if (!rightKeys.includes(c.key)) continue;
1122
+ pinned[c.key] = { side: "right", offset: right };
1123
+ right += pinWidth(c.key);
1124
+ }
1125
+ }
1126
+ const pinProps = (key, header) => {
1127
+ const z = header ? stickyHeader ? "z-[15]" : "z-[5]" : "z-[1]";
1128
+ if (key === "__select") {
1129
+ return leftKeys.length ? { className: cn("sticky left-0 bg-background", z), style: { left: 0 } } : null;
1130
+ }
1131
+ if (key === "__actions") {
1132
+ return rightKeys.length ? { className: cn("sticky right-0 bg-background", z), style: { right: 0 } } : null;
1133
+ }
1134
+ const p = pinned[key];
1135
+ return p ? { className: cn("sticky bg-background", z), style: { [p.side]: p.offset } } : null;
1136
+ };
1014
1137
  const [currentPage, setCurrentPage] = useState(0);
1015
1138
  const [currentPageSize, setCurrentPageSize] = useState(initialPageSize);
1016
1139
  useEffect(() => {
1017
1140
  setCurrentPage(0);
1018
1141
  }, [items.length, currentPageSize]);
1019
- const sorted = (() => {
1020
- if (sorts.length === 0) return items;
1021
- return [...items].sort((a, b) => {
1022
- for (const s of sorts) {
1023
- const col = columns.find((c) => c.key === s.key);
1024
- if (!col) continue;
1025
- const acc = col.accessor || ((item) => item[col.key]);
1026
- const va = acc(a);
1027
- const vb = acc(b);
1028
- const sa = va == null ? "" : String(va);
1029
- const sb = vb == null ? "" : String(vb);
1030
- const cmp = sa.localeCompare(sb, void 0, {
1031
- numeric: true,
1032
- sensitivity: "base"
1033
- });
1034
- if (cmp !== 0) return s.dir === "asc" ? cmp : -cmp;
1035
- }
1036
- return 0;
1037
- });
1038
- })();
1039
- const totalItems = sorted.length;
1142
+ const sorting = useMemo(
1143
+ () => sorts.map((s) => ({ id: s.key, desc: s.dir === "desc" })),
1144
+ [sorts]
1145
+ );
1146
+ const tanstackColumns = useMemo(
1147
+ () => columns.map((col) => ({
1148
+ id: col.key,
1149
+ // Blanks read as undefined here, so `sortUndefined` keeps them last
1150
+ // whichever way the column sorts.
1151
+ accessorFn: (row) => {
1152
+ const v = col.accessor ? col.accessor(row) : row[col.key];
1153
+ return v == null || v === "" ? void 0 : v;
1154
+ },
1155
+ sortUndefined: "last",
1156
+ sortingFn: (a, b, id) => compareValues(a.getValue(id), b.getValue(id))
1157
+ })),
1158
+ [columns]
1159
+ );
1160
+ const table = useReactTable({
1161
+ data: items,
1162
+ columns: tanstackColumns,
1163
+ getRowId: (row) => getKey(row),
1164
+ state: {
1165
+ sorting,
1166
+ pagination: { pageIndex: currentPage, pageSize: currentPageSize }
1167
+ },
1168
+ enableMultiSort: true,
1169
+ getCoreRowModel: getCoreRowModel(),
1170
+ getSortedRowModel: getSortedRowModel(),
1171
+ ...pagination ? { getPaginationRowModel: getPaginationRowModel() } : {}
1172
+ });
1173
+ const totalItems = table.getPrePaginationRowModel().rows.length;
1040
1174
  const totalPages = pagination ? Math.max(1, Math.ceil(totalItems / currentPageSize)) : 1;
1041
- const displayItems = pagination ? sorted.slice(
1042
- currentPage * currentPageSize,
1043
- (currentPage + 1) * currentPageSize
1044
- ) : sorted;
1175
+ const displayItems = table.getRowModel().rows.map((row) => row.original);
1176
+ const scrollRef = useRef(null);
1177
+ const bodyRef = useRef(null);
1178
+ const virtualizing = !!virtualize && !pagination;
1179
+ const virtualOptions = typeof virtualize === "object" ? virtualize : {};
1180
+ const viewportHeight = virtualOptions.height ?? 600;
1181
+ const rowVirtualizer = useVirtualizer({
1182
+ count: virtualizing ? displayItems.length : 0,
1183
+ getScrollElement: () => scrollRef.current,
1184
+ estimateSize: () => virtualOptions.estimateRowHeight ?? ROW_HEIGHT[density],
1185
+ overscan: 10,
1186
+ initialRect: { width: 0, height: viewportHeight }
1187
+ });
1188
+ const virtualRows = virtualizing ? rowVirtualizer.getVirtualItems() : null;
1189
+ const focusRow = (index) => {
1190
+ const last = displayItems.length - 1;
1191
+ const target = Math.max(0, Math.min(last, index));
1192
+ if (focusRowAt(bodyRef.current, target)) return;
1193
+ if (!virtualizing) return;
1194
+ rowVirtualizer.scrollToIndex(target);
1195
+ requestAnimationFrame(() => focusRowAt(bodyRef.current, target));
1196
+ };
1197
+ const openKey = !rowKeys ? null : openColumn !== void 0 ? openColumn : activeColumns.find((c) => !c.editable)?.key ?? null;
1198
+ const tabStopKey = activeRowKey && displayItems.some((it) => getKey(it) === activeRowKey) ? activeRowKey : displayItems[0] ? getKey(displayItems[0]) : null;
1199
+ const rowLabel = (item, id) => {
1200
+ const col = activeColumns.find((c) => c.key === (openKey ?? activeColumns[0]?.key));
1201
+ const value = col ? (col.accessor ?? ((it) => it[col.key]))(item) : void 0;
1202
+ return typeof value === "string" || typeof value === "number" ? String(value) : id;
1203
+ };
1045
1204
  const showingFrom = totalItems === 0 ? 0 : currentPage * currentPageSize + 1;
1046
1205
  const showingTo = Math.min(
1047
1206
  (currentPage + 1) * currentPageSize,
1048
1207
  totalItems
1049
1208
  );
1050
1209
  const totalCols = activeColumns.length + (selectable ? 1 : 0) + (renderActions ? 1 : 0);
1210
+ const renderRow = (item, index, measure) => {
1211
+ const id = getKey(item);
1212
+ return /* @__PURE__ */ jsxs(
1213
+ TableRow,
1214
+ {
1215
+ ref: measure,
1216
+ "data-index": index,
1217
+ "aria-rowindex": virtualizing ? index + 2 : void 0,
1218
+ className: cn(
1219
+ onRowClick && "cursor-pointer",
1220
+ "focus-within:bg-muted/40"
1221
+ ),
1222
+ "data-state": selected?.has(id) ? "selected" : void 0,
1223
+ onClick: onRowClick ? () => onRowClick(item) : void 0,
1224
+ children: [
1225
+ selectable && /* @__PURE__ */ jsx(
1226
+ TableCell,
1227
+ {
1228
+ className: cn(cellDensity, pinProps("__select", false)?.className),
1229
+ style: pinProps("__select", false)?.style,
1230
+ onClick: (e) => e.stopPropagation(),
1231
+ children: /* @__PURE__ */ jsx(
1232
+ Checkbox,
1233
+ {
1234
+ "aria-label": `Select ${rowLabel(item, id)}`,
1235
+ checked: selected?.has(id),
1236
+ onCheckedChange: () => onToggleSelect?.(id)
1237
+ }
1238
+ )
1239
+ }
1240
+ ),
1241
+ activeColumns.map((col, colIndex) => {
1242
+ const acc = col.accessor || ((it) => it[col.key]);
1243
+ const value = acc(item);
1244
+ const pin = pinProps(col.key, false);
1245
+ return /* @__PURE__ */ jsx(
1246
+ TableCell,
1247
+ {
1248
+ className: cn(
1249
+ getCellClasses(
1250
+ col
1251
+ ),
1252
+ cellDensity,
1253
+ pin?.className
1254
+ ),
1255
+ style: pin?.style,
1256
+ children: (() => {
1257
+ const content = col.editable ? /* @__PURE__ */ jsx(
1258
+ EditableCell,
1259
+ {
1260
+ item,
1261
+ column: col,
1262
+ value
1263
+ }
1264
+ ) : col.render ? col.render(item, value) : /* @__PURE__ */ jsx("span", { className: "truncate block max-w-[300px]", children: String(value ?? "\u2014") });
1265
+ if (col.key !== openKey) return content;
1266
+ const openProps = {
1267
+ type: "button",
1268
+ [ROW_OPEN]: String(index),
1269
+ tabIndex: id === tabStopKey ? 0 : -1,
1270
+ onFocus: () => setActiveRowKey(id),
1271
+ onClick: (e) => {
1272
+ e.stopPropagation();
1273
+ onRowClick?.(item);
1274
+ },
1275
+ onKeyDown: (e) => {
1276
+ if (e.target !== e.currentTarget) return;
1277
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
1278
+ const move = (to) => {
1279
+ e.preventDefault();
1280
+ focusRow(to);
1281
+ };
1282
+ if (e.key === "ArrowDown" || e.key === "j") move(index + 1);
1283
+ else if (e.key === "ArrowUp" || e.key === "k") move(index - 1);
1284
+ else if (e.key === "Home") move(0);
1285
+ else if (e.key === "End") move(displayItems.length - 1);
1286
+ else if (e.key === "x" && selectable) {
1287
+ e.preventDefault();
1288
+ onToggleSelect?.(id);
1289
+ }
1290
+ }
1291
+ };
1292
+ if (!col.editable && !col.render) {
1293
+ return /* @__PURE__ */ jsx(
1294
+ "button",
1295
+ {
1296
+ ...openProps,
1297
+ className: "-mx-1 block w-[calc(100%+0.5rem)] min-w-0 rounded px-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
1298
+ children: content
1299
+ }
1300
+ );
1301
+ }
1302
+ return /* @__PURE__ */ jsxs("div", { className: "flex min-w-0 items-center gap-1", children: [
1303
+ /* @__PURE__ */ jsx("div", { className: "min-w-0 flex-1", children: content }),
1304
+ /* @__PURE__ */ jsx(
1305
+ "button",
1306
+ {
1307
+ ...openProps,
1308
+ "aria-label": `Open ${rowLabel(item, id)}`,
1309
+ className: "sr-only shrink-0 rounded px-1 text-xs text-muted-foreground focus-visible:not-sr-only focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
1310
+ children: "Open"
1311
+ }
1312
+ )
1313
+ ] });
1314
+ })()
1315
+ },
1316
+ col.key
1317
+ );
1318
+ }),
1319
+ renderActions && /* @__PURE__ */ jsx(
1320
+ TableCell,
1321
+ {
1322
+ className: cn(cellDensity, pinProps("__actions", false)?.className),
1323
+ style: pinProps("__actions", false)?.style,
1324
+ onClick: (e) => e.stopPropagation(),
1325
+ children: renderActions(item)
1326
+ }
1327
+ )
1328
+ ]
1329
+ },
1330
+ id
1331
+ );
1332
+ };
1051
1333
  return /* @__PURE__ */ jsxs("div", { className: "border rounded-lg animate-fade-in flex flex-col", children: [
1052
1334
  columnVisibility && /* @__PURE__ */ jsx("div", { className: "flex items-center justify-end px-3 py-2 border-b bg-muted/30", children: /* @__PURE__ */ jsxs(DropdownMenu, { children: [
1053
1335
  /* @__PURE__ */ jsx(DropdownMenuTrigger, { asChild: true, children: /* @__PURE__ */ jsxs(
@@ -1085,151 +1367,181 @@ function DataTable({
1085
1367
  }
1086
1368
  )
1087
1369
  ] }) }),
1088
- /* @__PURE__ */ jsx("div", { className: "overflow-x-auto", children: /* @__PURE__ */ jsxs(Table, { style: { tableLayout: "fixed" }, children: [
1089
- /* @__PURE__ */ jsxs("colgroup", { children: [
1090
- selectable && /* @__PURE__ */ jsx("col", { style: { width: 40 } }),
1091
- activeColumns.map((col) => {
1092
- const w = colWidths[col.key];
1093
- return /* @__PURE__ */ jsx(
1094
- "col",
1095
- {
1096
- className: colHideClass(col.hideBelow),
1097
- style: w ? { width: w } : void 0
1098
- },
1099
- col.key
1100
- );
1101
- }),
1102
- renderActions && /* @__PURE__ */ jsx("col", { style: { width: 40 } })
1103
- ] }),
1104
- /* @__PURE__ */ jsx(
1105
- TableHeader,
1106
- {
1107
- className: stickyHeader ? "sticky top-0 z-10 bg-background" : "",
1108
- children: /* @__PURE__ */ jsxs(TableRow, { children: [
1109
- selectable && /* @__PURE__ */ jsx(TableHead, { className: cn("w-10", headDensity), children: /* @__PURE__ */ jsx(
1110
- Checkbox,
1111
- {
1112
- checked: items.length > 0 && selected?.size === items.length,
1113
- onCheckedChange: onToggleAll
1114
- }
1115
- ) }),
1116
- activeColumns.map((col, colIndex) => {
1117
- const sortIndex = sorts.findIndex((s) => s.key === col.key);
1118
- const sortEntry = sortIndex >= 0 ? sorts[sortIndex] : null;
1119
- const resizeWidth = colWidths[col.key];
1120
- const isFirstCol = colIndex === 0 && stickyFirstColumn;
1121
- return /* @__PURE__ */ jsxs(
1122
- TableHead,
1123
- {
1124
- className: cn(
1125
- getCellClasses(col),
1126
- headDensity,
1127
- col.sortable && "cursor-pointer select-none",
1128
- "overflow-hidden",
1129
- resizableColumns && "relative",
1130
- isFirstCol && "sticky left-0 bg-background",
1131
- isFirstCol && stickyHeader ? "z-[15]" : isFirstCol ? "z-[5]" : void 0
1132
- ),
1133
- style: resizeWidth ? { width: resizeWidth, minWidth: resizeWidth } : void 0,
1134
- onClick: col.sortable ? (e) => handleSort(col.key, e.shiftKey) : void 0,
1135
- children: [
1136
- /* @__PURE__ */ jsxs("span", { className: "flex min-w-0 items-center gap-1", children: [
1137
- /* @__PURE__ */ jsx("span", { className: "truncate", title: col.header, children: col.header }),
1138
- col.sortable && (sortEntry ? /* @__PURE__ */ jsxs(Fragment, { children: [
1139
- sortEntry.dir === "asc" ? /* @__PURE__ */ jsx(ArrowUp, { className: "h-3 w-3 shrink-0 text-foreground" }) : /* @__PURE__ */ jsx(ArrowDown, { className: "h-3 w-3 shrink-0 text-foreground" }),
1140
- sorts.length > 1 && /* @__PURE__ */ jsx("span", { className: "text-[10px] text-muted-foreground font-mono", children: sortIndex + 1 })
1141
- ] }) : /* @__PURE__ */ jsx(ArrowUpDown, { className: "h-3 w-3 shrink-0 text-muted-foreground/50" }))
1142
- ] }),
1143
- resizableColumns && /* @__PURE__ */ jsx(
1144
- ResizeHandle,
1145
- {
1146
- onResize: (delta) => handleResize(
1147
- col.key,
1148
- delta,
1149
- col.minWidth ?? 60
1150
- ),
1151
- onResizeEnd: handleResizeEnd
1152
- }
1153
- )
1154
- ]
1155
- },
1156
- col.key
1157
- );
1158
- }),
1159
- renderActions && /* @__PURE__ */ jsx(TableHead, { className: cn("w-10", headDensity) })
1160
- ] })
1161
- }
1162
- ),
1163
- /* @__PURE__ */ jsx(TableBody, { children: displayItems.length === 0 ? /* @__PURE__ */ jsx(TableRow, { children: /* @__PURE__ */ jsx(
1164
- TableCell,
1165
- {
1166
- colSpan: totalCols,
1167
- className: "text-center py-12 text-muted-foreground",
1168
- children: emptyMessage
1169
- }
1170
- ) }) : displayItems.map((item) => {
1171
- const id = getKey(item);
1172
- return /* @__PURE__ */ jsxs(
1173
- TableRow,
1370
+ /* @__PURE__ */ jsx(
1371
+ "div",
1372
+ {
1373
+ ref: scrollRef,
1374
+ className: cn("overflow-x-auto", virtualizing && "overflow-y-auto"),
1375
+ style: virtualizing ? { maxHeight: viewportHeight } : void 0,
1376
+ children: /* @__PURE__ */ jsxs(
1377
+ Table,
1174
1378
  {
1175
- className: cn(onRowClick && "cursor-pointer"),
1176
- "data-state": selected?.has(id) ? "selected" : void 0,
1177
- onClick: onRowClick ? () => onRowClick(item) : void 0,
1379
+ style: { tableLayout: "fixed" },
1380
+ "aria-rowcount": virtualizing ? displayItems.length + 1 : void 0,
1178
1381
  children: [
1179
- selectable && /* @__PURE__ */ jsx(
1180
- TableCell,
1181
- {
1182
- className: cellDensity,
1183
- onClick: (e) => e.stopPropagation(),
1184
- children: /* @__PURE__ */ jsx(
1185
- Checkbox,
1382
+ /* @__PURE__ */ jsxs("colgroup", { children: [
1383
+ selectable && /* @__PURE__ */ jsx("col", { style: { width: 40 } }),
1384
+ activeColumns.map((col) => {
1385
+ const w = colWidths[col.key] ?? (pinned[col.key] ? PINNED_WIDTH : void 0);
1386
+ return /* @__PURE__ */ jsx(
1387
+ "col",
1186
1388
  {
1187
- checked: selected?.has(id),
1188
- onCheckedChange: () => onToggleSelect?.(id)
1189
- }
1190
- )
1191
- }
1192
- ),
1193
- activeColumns.map((col, colIndex) => {
1194
- const acc = col.accessor || ((it) => it[col.key]);
1195
- const value = acc(item);
1196
- const isFirstCol = colIndex === 0 && stickyFirstColumn;
1197
- return /* @__PURE__ */ jsx(
1198
- TableCell,
1199
- {
1200
- className: cn(
1201
- getCellClasses(
1202
- col
1203
- ),
1204
- cellDensity,
1205
- isFirstCol && "sticky left-0 z-[1] bg-background"
1389
+ className: colHideClass(col.hideBelow),
1390
+ style: w ? { width: w } : void 0
1391
+ },
1392
+ col.key
1393
+ );
1394
+ }),
1395
+ renderActions && /* @__PURE__ */ jsx("col", { style: { width: 40 } })
1396
+ ] }),
1397
+ /* @__PURE__ */ jsx(
1398
+ TableHeader,
1399
+ {
1400
+ className: stickyHeader ? "sticky top-0 z-10 bg-background" : "",
1401
+ children: /* @__PURE__ */ jsxs(TableRow, { "aria-rowindex": virtualizing ? 1 : void 0, children: [
1402
+ selectable && /* @__PURE__ */ jsx(
1403
+ TableHead,
1404
+ {
1405
+ className: cn("w-10", headDensity, pinProps("__select", true)?.className),
1406
+ style: pinProps("__select", true)?.style,
1407
+ children: /* @__PURE__ */ jsx(
1408
+ Checkbox,
1409
+ {
1410
+ "aria-label": "Select all rows",
1411
+ checked: items.length > 0 && selected?.size === items.length,
1412
+ onCheckedChange: onToggleAll
1413
+ }
1414
+ )
1415
+ }
1206
1416
  ),
1207
- children: col.editable ? /* @__PURE__ */ jsx(
1208
- EditableCell,
1417
+ activeColumns.map((col, colIndex) => {
1418
+ const sortIndex = sorts.findIndex((s) => s.key === col.key);
1419
+ const sortEntry = sortIndex >= 0 ? sorts[sortIndex] : null;
1420
+ const resizeWidth = colWidths[col.key];
1421
+ const pin = pinProps(col.key, true);
1422
+ const draggable = reorderableColumns && !pin;
1423
+ return /* @__PURE__ */ jsxs(
1424
+ TableHead,
1425
+ {
1426
+ className: cn(
1427
+ getCellClasses(col),
1428
+ headDensity,
1429
+ col.sortable && "select-none",
1430
+ "overflow-hidden",
1431
+ resizableColumns && "relative",
1432
+ pin?.className
1433
+ ),
1434
+ style: {
1435
+ ...resizeWidth ? { width: resizeWidth, minWidth: resizeWidth } : {},
1436
+ ...pin?.style
1437
+ },
1438
+ draggable: draggable || void 0,
1439
+ onDragStart: draggable ? (e) => {
1440
+ setDragKey(col.key);
1441
+ e.dataTransfer.effectAllowed = "move";
1442
+ e.dataTransfer.setData("text/plain", col.key);
1443
+ } : void 0,
1444
+ onDragOver: draggable ? (e) => {
1445
+ if (dragKey && dragKey !== col.key) e.preventDefault();
1446
+ } : void 0,
1447
+ onDrop: draggable ? (e) => {
1448
+ e.preventDefault();
1449
+ if (dragKey && dragKey !== col.key) moveColumn(dragKey, { before: col.key });
1450
+ setDragKey(null);
1451
+ } : void 0,
1452
+ onDragEnd: draggable ? () => setDragKey(null) : void 0,
1453
+ "aria-sort": sortEntry && sortIndex === 0 ? sortEntry.dir === "asc" ? "ascending" : "descending" : void 0,
1454
+ children: [
1455
+ (() => {
1456
+ const label = /* @__PURE__ */ jsxs("span", { className: "flex min-w-0 items-center gap-1", children: [
1457
+ /* @__PURE__ */ jsx("span", { className: "truncate", title: col.header, children: col.header }),
1458
+ col.sortable && (sortEntry ? /* @__PURE__ */ jsxs(Fragment, { children: [
1459
+ sortEntry.dir === "asc" ? /* @__PURE__ */ jsx(ArrowUp, { className: "h-3 w-3 shrink-0 text-foreground" }) : /* @__PURE__ */ jsx(ArrowDown, { className: "h-3 w-3 shrink-0 text-foreground" }),
1460
+ sorts.length > 1 && /* @__PURE__ */ jsx(
1461
+ "span",
1462
+ {
1463
+ "aria-hidden": "true",
1464
+ className: "text-[10px] text-muted-foreground font-mono",
1465
+ children: sortIndex + 1
1466
+ }
1467
+ )
1468
+ ] }) : /* @__PURE__ */ jsx(ArrowUpDown, { className: "h-3 w-3 shrink-0 text-muted-foreground/50" }))
1469
+ ] });
1470
+ return col.sortable ? /* @__PURE__ */ jsx(
1471
+ "button",
1472
+ {
1473
+ type: "button",
1474
+ className: "-mx-1 flex w-[calc(100%+0.5rem)] min-w-0 items-center rounded px-1 text-left cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
1475
+ onClick: (e) => handleSort(col.key, e.shiftKey),
1476
+ onKeyDown: (e) => {
1477
+ if (!draggable || !e.altKey) return;
1478
+ if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
1479
+ const movable = activeColumns.filter((c) => !pinned[c.key]);
1480
+ const i = movable.findIndex((c) => c.key === col.key);
1481
+ const neighbour = movable[e.key === "ArrowLeft" ? i - 1 : i + 1];
1482
+ if (!neighbour) return;
1483
+ e.preventDefault();
1484
+ moveColumn(col.key, e.key === "ArrowLeft" ? { before: neighbour.key } : { after: neighbour.key });
1485
+ },
1486
+ "aria-label": sortEntry && sortIndex > 0 ? `${col.header}, sorted ${sortEntry.dir === "asc" ? "ascending" : "descending"}, sort ${sortIndex + 1}` : void 0,
1487
+ children: label
1488
+ }
1489
+ ) : label;
1490
+ })(),
1491
+ resizableColumns && /* @__PURE__ */ jsx(
1492
+ ResizeHandle,
1493
+ {
1494
+ onResize: (delta) => handleResize(
1495
+ col.key,
1496
+ delta,
1497
+ col.minWidth ?? 60
1498
+ ),
1499
+ onResizeEnd: handleResizeEnd
1500
+ }
1501
+ )
1502
+ ]
1503
+ },
1504
+ col.key
1505
+ );
1506
+ }),
1507
+ renderActions && /* @__PURE__ */ jsx(
1508
+ TableHead,
1209
1509
  {
1210
- item,
1211
- column: col,
1212
- value
1510
+ className: cn("w-10", headDensity, pinProps("__actions", true)?.className),
1511
+ style: pinProps("__actions", true)?.style
1213
1512
  }
1214
- ) : col.render ? col.render(item, value) : /* @__PURE__ */ jsx("span", { className: "truncate block max-w-[300px]", children: String(value ?? "\u2014") })
1215
- },
1216
- col.key
1217
- );
1218
- }),
1219
- renderActions && /* @__PURE__ */ jsx(
1513
+ )
1514
+ ] })
1515
+ }
1516
+ ),
1517
+ /* @__PURE__ */ jsx(TableBody, { ref: bodyRef, children: displayItems.length === 0 ? /* @__PURE__ */ jsx(TableRow, { children: /* @__PURE__ */ jsx(
1220
1518
  TableCell,
1221
1519
  {
1222
- className: cellDensity,
1223
- onClick: (e) => e.stopPropagation(),
1224
- children: renderActions(item)
1520
+ colSpan: totalCols,
1521
+ className: "text-center py-12 text-muted-foreground",
1522
+ children: emptyMessage
1225
1523
  }
1226
- )
1524
+ ) }) : virtualRows ? [
1525
+ /* @__PURE__ */ jsx("tr", { "aria-hidden": true, style: { height: virtualRows[0]?.start ?? 0 } }, "__top"),
1526
+ ...virtualRows.map(
1527
+ (vr) => renderRow(displayItems[vr.index], vr.index, rowVirtualizer.measureElement)
1528
+ ),
1529
+ /* @__PURE__ */ jsx(
1530
+ "tr",
1531
+ {
1532
+ "aria-hidden": true,
1533
+ style: {
1534
+ height: rowVirtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0)
1535
+ }
1536
+ },
1537
+ "__bottom"
1538
+ )
1539
+ ] : displayItems.map((item, index) => renderRow(item, index)) })
1227
1540
  ]
1228
- },
1229
- id
1230
- );
1231
- }) })
1232
- ] }) }),
1541
+ }
1542
+ )
1543
+ }
1544
+ ),
1233
1545
  pagination && /* @__PURE__ */ jsxs("div", { className: "sticky bottom-0 z-10 flex flex-col sm:flex-row items-center justify-between gap-2 px-4 py-3 border-t bg-muted/30 text-xs text-muted-foreground", children: [
1234
1546
  /* @__PURE__ */ jsx("span", { children: totalItems === 0 ? "No records" : `Showing ${showingFrom}\u2013${showingTo} of ${totalItems}` }),
1235
1547
  /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3", children: [
@@ -1810,6 +2122,39 @@ function DetailPanel({
1810
2122
  }
1811
2123
  );
1812
2124
  }
2125
+
2126
+ // src/components/data/undo-toast.ts
2127
+ var DONE = {
2128
+ create: "Created.",
2129
+ update: "Saved.",
2130
+ delete: "Deleted."
2131
+ };
2132
+ function showUndoToast(write) {
2133
+ toast(DONE[write.kind], {
2134
+ duration: write.delayMs,
2135
+ action: {
2136
+ label: "Undo",
2137
+ onClick: () => {
2138
+ void write.undo().then((undone) => {
2139
+ if (!undone && write.strategy === "delay") {
2140
+ toast("Already saved. It can no longer be undone.");
2141
+ }
2142
+ });
2143
+ }
2144
+ }
2145
+ });
2146
+ }
2147
+ function showWriteErrorToast(error) {
2148
+ toast.error(error.message || "The change could not be saved.");
2149
+ }
2150
+ function writeModeOptions(resourceOptions, mutationMode) {
2151
+ const mode = resourceOptions?.mutationMode ?? mutationMode ?? "pessimistic";
2152
+ return {
2153
+ mutationMode: mode,
2154
+ onUndoable: resourceOptions?.onUndoable ?? showUndoToast,
2155
+ onError: resourceOptions?.onError ?? (mode === "pessimistic" ? void 0 : showWriteErrorToast)
2156
+ };
2157
+ }
1813
2158
  function RelationField({
1814
2159
  field,
1815
2160
  value,
@@ -2713,7 +3058,12 @@ function ResourceForm(props) {
2713
3058
  steps,
2714
3059
  columns = 1,
2715
3060
  prefillFromUrl,
2716
- autoPlaceholder = false
3061
+ autoPlaceholder = false,
3062
+ warnWhenUnsaved = false,
3063
+ onDirtyChange,
3064
+ mutationMode = "pessimistic",
3065
+ undoDelayMs,
3066
+ onUndoable
2717
3067
  } = props;
2718
3068
  const isMobile = useIsMobile();
2719
3069
  const effectiveLayout = isMobile ? "sheet" : layoutProp;
@@ -2731,11 +3081,31 @@ function ResourceForm(props) {
2731
3081
  onOpenChange(false);
2732
3082
  onSuccess?.(data);
2733
3083
  },
2734
- onError,
3084
+ // The form is closed by the time an optimistic or undoable save fails,
3085
+ // so the error has to reach the user some other way.
3086
+ onError: onError ?? (mutationMode === "pessimistic" ? void 0 : showWriteErrorToast),
2735
3087
  prefillFromUrl,
2736
- autoPlaceholder
3088
+ autoPlaceholder,
3089
+ warnWhenUnsaved,
3090
+ mutationMode,
3091
+ undoDelayMs,
3092
+ onUndoable: onUndoable ?? showUndoToast
2737
3093
  };
2738
- const { form, resolvedFields, handleSave, isSaving, isLoading } = useResourceForm(hookOptions);
3094
+ const { form, resolvedFields, handleSave, isSaving, isLoading, isDirty, confirmLeave } = useResourceForm(hookOptions);
3095
+ const onDirtyChangeRef = useRef(onDirtyChange);
3096
+ useEffect(() => {
3097
+ onDirtyChangeRef.current = onDirtyChange;
3098
+ });
3099
+ useEffect(() => {
3100
+ onDirtyChangeRef.current?.(isDirty, confirmLeave);
3101
+ }, [isDirty, confirmLeave]);
3102
+ const requestOpenChange = useCallback(
3103
+ (next) => {
3104
+ if (!next && !confirmLeave()) return;
3105
+ onOpenChange(next);
3106
+ },
3107
+ [confirmLeave, onOpenChange]
3108
+ );
2739
3109
  const bodyProps = {
2740
3110
  form,
2741
3111
  resolvedFields,
@@ -2743,14 +3113,14 @@ function ResourceForm(props) {
2743
3113
  action,
2744
3114
  isSaving,
2745
3115
  isLoading,
2746
- onOpenChange,
3116
+ onOpenChange: requestOpenChange,
2747
3117
  handleSave,
2748
3118
  mode,
2749
3119
  steps,
2750
3120
  columns
2751
3121
  };
2752
3122
  if (effectiveLayout === "dialog") {
2753
- return /* @__PURE__ */ jsx(Dialog, { open, onOpenChange, children: /* @__PURE__ */ jsxs(DialogContent, { className: "sm:max-w-[560px]", children: [
3123
+ return /* @__PURE__ */ jsx(Dialog, { open, onOpenChange: requestOpenChange, children: /* @__PURE__ */ jsxs(DialogContent, { className: "sm:max-w-[560px]", children: [
2754
3124
  /* @__PURE__ */ jsxs(DialogHeader, { children: [
2755
3125
  /* @__PURE__ */ jsx(DialogTitle, { children: formTitle }),
2756
3126
  /* @__PURE__ */ jsx(DialogDescription, { className: "sr-only", children: formDescription })
@@ -2759,7 +3129,7 @@ function ResourceForm(props) {
2759
3129
  ] }) });
2760
3130
  }
2761
3131
  if (effectiveLayout === "sheet") {
2762
- return /* @__PURE__ */ jsx(Sheet, { open, onOpenChange, children: /* @__PURE__ */ jsxs(SheetContent, { side: "right", className: "sm:max-w-[480px] overflow-y-auto", children: [
3132
+ return /* @__PURE__ */ jsx(Sheet, { open, onOpenChange: requestOpenChange, children: /* @__PURE__ */ jsxs(SheetContent, { side: "right", className: "sm:max-w-[480px] overflow-y-auto", children: [
2763
3133
  /* @__PURE__ */ jsxs(SheetHeader, { children: [
2764
3134
  /* @__PURE__ */ jsx(SheetTitle, { children: formTitle }),
2765
3135
  /* @__PURE__ */ jsx(SheetDescription, { className: "sr-only", children: formDescription })
@@ -2778,7 +3148,8 @@ function ConfirmDeleteDialog({
2778
3148
  onOpenChange,
2779
3149
  onConfirm,
2780
3150
  title = "Delete this record?",
2781
- description = "This can't be undone.",
3151
+ kind = "permanent",
3152
+ description = kind === "recycle" ? "It moves to the recycle bin, where it can be restored." : "This can't be undone.",
2782
3153
  confirmLabel = "Delete",
2783
3154
  cancelLabel = "Cancel"
2784
3155
  }) {
@@ -2896,6 +3267,7 @@ function ResourcePage(props) {
2896
3267
  onCreate,
2897
3268
  onEdit,
2898
3269
  onDelete,
3270
+ can,
2899
3271
  canEditItem,
2900
3272
  createLabel = "New",
2901
3273
  canEdit,
@@ -2911,6 +3283,7 @@ function ResourcePage(props) {
2911
3283
  formMode,
2912
3284
  formSteps,
2913
3285
  formLayout: formLayoutProp,
3286
+ mutationMode: mutationModeProp,
2914
3287
  bulkActions,
2915
3288
  renderCard: renderCardProp,
2916
3289
  openOnCardClick = false,
@@ -2925,6 +3298,7 @@ function ResourcePage(props) {
2925
3298
  emptyDescription,
2926
3299
  header: headerMode = "page",
2927
3300
  resourceOptions,
3301
+ urlState: urlStateProp,
2928
3302
  defaultView = "table",
2929
3303
  onNavigate = defaultNavigate,
2930
3304
  viewSettingsKey: viewSettingsKeyProp,
@@ -2934,6 +3308,18 @@ function ResourcePage(props) {
2934
3308
  } = props;
2935
3309
  const isClientMode = !!resourceName;
2936
3310
  const resourceServerPaging = isClientMode && pagination === "server";
3311
+ const url = useResourceUrlState(
3312
+ urlStateProp ? "setSearchParams" in urlStateProp ? { searchParams: urlStateProp } : urlStateProp : void 0
3313
+ );
3314
+ const [urlSeed] = useState(() => {
3315
+ if (!url) return null;
3316
+ const sortable = !url.sort || !columnsProp ? true : columnsProp.some(
3317
+ (col) => typeof col === "string" ? col === url.sort.field : col.key === url.sort.field && col.sortable !== false
3318
+ );
3319
+ return { q: url.q, sort: sortable ? url.sort : null, page: url.page };
3320
+ });
3321
+ const urlPeek = detail === "panel" || detail === "modal" ? url : void 0;
3322
+ const urlSortKey = urlSeed?.sort ? `${urlSeed.sort.field}:${urlSeed.sort.order}` : "";
2937
3323
  const isServerControlled = resourceServerPaging || pageProp !== void 0 || perPageProp !== void 0 || onPageChange !== void 0 || onPerPageChange !== void 0 || onSortChange !== void 0 || onSearchChange !== void 0;
2938
3324
  const viewSettingsKey = viewSettingsKeyProp ?? resourceName ?? title.toLowerCase().replace(/\s+/g, "-");
2939
3325
  const { settings, updateSetting } = useViewSettings(viewSettingsKey);
@@ -2949,7 +3335,15 @@ function ResourcePage(props) {
2949
3335
  searchFields: resourceServerPaging ? serverSearchFields : resourceOptions?.searchFields,
2950
3336
  // Paging, searching and sorting change the query key; keep the
2951
3337
  // current page on screen while the next one loads.
2952
- keepPreviousData: resourceOptions?.keepPreviousData ?? resourceServerPaging
3338
+ keepPreviousData: resourceOptions?.keepPreviousData ?? resourceServerPaging,
3339
+ // Start where the URL says, so restoring a link doesn't push a
3340
+ // search or sort that resets the page to 1.
3341
+ ...resourceServerPaging && urlSeed ? {
3342
+ defaultSearch: urlSeed.q,
3343
+ ...urlSeed.page ? { defaultPage: urlSeed.page } : {},
3344
+ ...urlSeed.sort ? { defaultSort: urlSeed.sort } : {}
3345
+ } : {},
3346
+ ...writeModeOptions(resourceOptions, mutationModeProp)
2953
3347
  } : { enabled: false }
2954
3348
  );
2955
3349
  const effectivePageSize = resourceServerPaging ? resourceHook.params.pagination?.perPage ?? settings.pageSize : perPageProp ?? settings.pageSize;
@@ -2964,9 +3358,15 @@ function ResourcePage(props) {
2964
3358
  columns: columnsProp,
2965
3359
  statusFilter,
2966
3360
  searchFields,
2967
- initialFilters,
3361
+ initialFilters: initialFilters ?? (urlSeed && (urlSeed.q || urlSeed.sort) ? [
3362
+ ...urlSeed.q ? [{ field: "__search__", operator: "contains", value: urlSeed.q }] : [],
3363
+ ...urlSeed.sort ? [{ field: "__sort__", operator: "eq", value: urlSortKey }] : []
3364
+ ] : void 0),
2968
3365
  onFiltersChange,
2969
3366
  defaultView,
3367
+ // Each page remembers its own table/cards choice, under the same key as
3368
+ // its density and page size — not one key shared by every data page.
3369
+ viewKey: viewSettingsKey,
2970
3370
  detail,
2971
3371
  titleField: titleFieldProp,
2972
3372
  subtitleField: subtitleFieldProp,
@@ -2977,7 +3377,7 @@ function ResourcePage(props) {
2977
3377
  setSort: setResourceSort,
2978
3378
  setFilters: setResourceFilters
2979
3379
  } = resourceHook;
2980
- const pushedSearchRef = useRef("");
3380
+ const pushedSearchRef = useRef(resourceServerPaging ? urlSeed?.q ?? "" : "");
2981
3381
  const searchEffectRanRef = useRef(false);
2982
3382
  useEffect(() => {
2983
3383
  if (!resourceServerPaging) return;
@@ -2996,7 +3396,7 @@ function ResourcePage(props) {
2996
3396
  return () => clearTimeout(timer);
2997
3397
  }, [resourceServerPaging, page.search, setResourceSearch]);
2998
3398
  const defaultSort = resourceOptions?.defaultSort;
2999
- const pushedSortRef = useRef("");
3399
+ const pushedSortRef = useRef(resourceServerPaging ? urlSortKey : "");
3000
3400
  useEffect(() => {
3001
3401
  if (!resourceServerPaging) return;
3002
3402
  const key = page.sort ? `${page.sort.field}:${page.sort.order}` : "";
@@ -3064,8 +3464,17 @@ function ResourcePage(props) {
3064
3464
  (item) => getKeyProp ? getKeyProp(item) : String(item.id ?? ""),
3065
3465
  [getKeyProp]
3066
3466
  );
3067
- const selectedKey = page.selectedItem ? keyOf(page.selectedItem) : null;
3068
- const selectedItem = !page.selectedItem ? null : selectedKey ? displayData.find((row) => keyOf(row) === selectedKey) ?? page.selectedItem : page.selectedItem;
3467
+ const snapshotKey = page.selectedItem ? keyOf(page.selectedItem) : null;
3468
+ const selectedKey = urlPeek ? urlPeek.detailId : snapshotKey;
3469
+ const snapshot = page.selectedItem && snapshotKey === selectedKey ? page.selectedItem : null;
3470
+ const rowOrSnapshot = !selectedKey ? snapshot : displayData.find((row) => keyOf(row) === selectedKey) ?? snapshot;
3471
+ const linkedId = urlPeek && selectedKey && !rowOrSnapshot ? selectedKey : null;
3472
+ const linked = useOne(resourceName ?? "__noop__", linkedId, {
3473
+ enabled: isClientMode && !!linkedId
3474
+ });
3475
+ const selectedItem = rowOrSnapshot ?? (linkedId && linked.data ? linked.data : null);
3476
+ const linkMissing = !!linkedId && !selectedItem && !isLoading && (isClientMode ? linked.isError : true);
3477
+ const detailOpen = urlPeek ? selectedItem !== null || linkMissing : page.detailOpen;
3069
3478
  const [selectedIds, setSelectedIds] = useState(/* @__PURE__ */ new Set());
3070
3479
  const handleToggleSelect = useCallback((id) => {
3071
3480
  setSelectedIds((prev) => {
@@ -3098,10 +3507,20 @@ function ResourcePage(props) {
3098
3507
  onCreate();
3099
3508
  }
3100
3509
  }, [useBuiltInCreate, onCreate]);
3510
+ const allows = useCallback(
3511
+ (action, item) => {
3512
+ if (can && !can(action, item)) return false;
3513
+ if (!item) return true;
3514
+ if (canEditItem && !canEditItem(item)) return false;
3515
+ if (action === "edit" && canEdit && !canEdit(item)) return false;
3516
+ if (action === "delete" && canDelete && !canDelete(item)) return false;
3517
+ return true;
3518
+ },
3519
+ [can, canEditItem, canEdit, canDelete]
3520
+ );
3101
3521
  const handleEdit = useCallback(
3102
3522
  (item) => {
3103
- if (canEdit && !canEdit(item)) return;
3104
- if (canEditItem && !canEditItem(item)) return;
3523
+ if (!allows("edit", item)) return;
3105
3524
  if (useBuiltInEdit) {
3106
3525
  setFormAction("edit");
3107
3526
  setFormItem(item);
@@ -3110,18 +3529,23 @@ function ResourcePage(props) {
3110
3529
  onEdit(item);
3111
3530
  }
3112
3531
  },
3113
- [useBuiltInEdit, onEdit, canEdit, canEditItem]
3532
+ [useBuiltInEdit, onEdit, allows]
3114
3533
  );
3115
3534
  const closeDetail = useCallback(() => {
3116
3535
  page.setDetailOpen(false);
3117
3536
  page.setSelectedItem(null);
3118
- }, [page]);
3537
+ urlPeek?.setDetailId(null);
3538
+ }, [page, urlPeek]);
3119
3539
  const useBuiltInDelete = !!allowDelete && !onDelete && isClientMode;
3120
3540
  const deleteConfirmed = confirmDelete === void 0 ? useBuiltInDelete : !!confirmDelete;
3121
3541
  const deleteCopy = typeof confirmDelete === "object" ? confirmDelete : void 0;
3122
3542
  const [pendingDelete, setPendingDelete] = useState(null);
3123
3543
  const [deleteOpen, setDeleteOpen] = useState(false);
3124
3544
  const { remove: removeRecord } = resourceHook;
3545
+ const { data: providerCaps } = useCapabilities(resourceName, {
3546
+ enabled: useBuiltInDelete && deleteConfirmed
3547
+ });
3548
+ const deleteKind = providerCaps?.softDelete ? "recycle" : "permanent";
3125
3549
  const performDelete = useCallback(
3126
3550
  async (item) => {
3127
3551
  if (useBuiltInDelete) {
@@ -3149,24 +3573,39 @@ function ResourcePage(props) {
3149
3573
  setDeleteOpen(true);
3150
3574
  } : useBuiltInDelete ? (item) => {
3151
3575
  performDelete(item).catch((err) => {
3152
- console.error("ResourcePage: delete failed", err);
3576
+ if (!resourceOptions?.onError) {
3577
+ toast.error(err instanceof Error ? err.message : "The delete failed.");
3578
+ }
3153
3579
  });
3154
3580
  } : (
3155
3581
  // Unconfirmed `onDelete` keeps its original contract exactly:
3156
3582
  // called straight from the peek, nothing closed for it.
3157
3583
  (item) => {
3158
- void onDelete?.(item);
3584
+ const report = (err) => {
3585
+ if (resourceOptions?.onError) {
3586
+ resourceOptions.onError(err instanceof Error ? err : new Error(String(err)));
3587
+ } else {
3588
+ toast.error(err instanceof Error ? err.message : "The delete failed.");
3589
+ }
3590
+ };
3591
+ try {
3592
+ Promise.resolve(onDelete?.(item)).catch(report);
3593
+ } catch (err) {
3594
+ report(err);
3595
+ }
3159
3596
  }
3160
3597
  );
3161
- const mayChange = (item) => canEditItem ? canEditItem(item) : true;
3162
- const selectedEditable = !!selectedItem && mayChange(selectedItem) && (canEdit ? canEdit(selectedItem) : true);
3163
- const selectedDeletable = !!selectedItem && mayChange(selectedItem) && (canDelete ? canDelete(selectedItem) : true);
3598
+ const selectedEditable = !!selectedItem && allows("edit", selectedItem);
3599
+ const selectedDeletable = !!selectedItem && allows("delete", selectedItem);
3164
3600
  const tableColumns = useMemo(() => {
3165
3601
  const base = isClientMode ? page.columns.map((col) => ({
3166
3602
  key: col.key,
3167
3603
  header: col.header ?? col.key,
3168
3604
  sortable: col.sortable ?? true,
3169
3605
  hideBelow: col.hideBelow,
3606
+ width: col.width,
3607
+ minWidth: col.minWidth,
3608
+ defaultVisible: col.defaultVisible,
3170
3609
  editable: col.editable,
3171
3610
  accessor: col.accessor,
3172
3611
  render: col.render ? (item, value) => col.render(item, value, actions) : void 0
@@ -3180,11 +3619,36 @@ function ResourcePage(props) {
3180
3619
  page.tableColumns,
3181
3620
  actions
3182
3621
  ]);
3622
+ const [tableTieBreaks, setTableTieBreaks] = useState(null);
3623
+ const pageSort = page.sort;
3624
+ const setPageSort = page.setSort;
3625
+ const tableSort = useMemo(() => {
3626
+ if (!pageSort) return [];
3627
+ const primary = { key: pageSort.field, dir: pageSort.order };
3628
+ const rest = tableTieBreaks && tableTieBreaks.primary === `${primary.key}:${primary.dir}` ? tableTieBreaks.rest : [];
3629
+ return [primary, ...rest];
3630
+ }, [pageSort, tableTieBreaks]);
3631
+ const handleTableSort = useCallback(
3632
+ (next) => {
3633
+ const [primary, ...rest] = next;
3634
+ setTableTieBreaks(primary ? { primary: `${primary.key}:${primary.dir}`, rest } : null);
3635
+ setPageSort(primary ? { field: primary.key, order: primary.dir } : null);
3636
+ },
3637
+ [setPageSort]
3638
+ );
3183
3639
  const pagerPage = resourceServerPaging ? resourceHook.params.pagination?.page ?? 1 : pageProp;
3184
3640
  const pagerPerPage = resourceServerPaging ? resourceHook.params.pagination?.perPage : perPageProp;
3185
3641
  const pagerTotal = resourceServerPaging ? displayTotal : totalProp;
3186
3642
  const pagerOnPageChange = resourceServerPaging ? resourceHook.setPage : onPageChange;
3187
3643
  const currentPage = pagerPage ?? 1;
3644
+ const writeUrl = url?.write;
3645
+ useEffect(() => {
3646
+ writeUrl?.({
3647
+ q: page.search,
3648
+ sort: page.sort,
3649
+ ...resourceServerPaging ? { page: currentPage } : {}
3650
+ });
3651
+ }, [writeUrl, page.search, page.sort, currentPage, resourceServerPaging]);
3188
3652
  const handleSearchChange = useCallback(
3189
3653
  (value) => {
3190
3654
  page.filterConfig.onSearchChange(value);
@@ -3311,9 +3775,10 @@ function ResourcePage(props) {
3311
3775
  return;
3312
3776
  }
3313
3777
  page.handleRowClick(item);
3778
+ urlPeek?.setDetailId(keyOf(item));
3314
3779
  };
3315
3780
  const hasBulkSelect = !!bulkActions && bulkActions.length > 0;
3316
- const overlayOpen = page.detailOpen || formOpen || deleteOpen;
3781
+ const overlayOpen = detailOpen || formOpen || deleteOpen;
3317
3782
  const keyboardNav = useKeyboardNavigation({
3318
3783
  itemCount: page.data.length,
3319
3784
  enabled: keyboardNavigation && page.viewMode !== "table",
@@ -3334,12 +3799,9 @@ function ResourcePage(props) {
3334
3799
  // every grid/list page (including detail="none" and detail="route",
3335
3800
  // which never open one). `detailOpen` is only ever true for the panel
3336
3801
  // and modal modes, so it is the precise precondition here.
3337
- onBack: page.detailOpen ? () => {
3338
- page.setDetailOpen(false);
3339
- page.setSelectedItem(null);
3340
- } : void 0
3802
+ onBack: detailOpen ? closeDetail : void 0
3341
3803
  });
3342
- const showCreateButton = onCreate || useBuiltInCreate;
3804
+ const showCreateButton = (onCreate || useBuiltInCreate) && allows("create");
3343
3805
  const createButton = showCreateButton ? /* @__PURE__ */ jsxs(Button, { size: "sm", onClick: handleCreate, children: [
3344
3806
  /* @__PURE__ */ jsx(Plus, { className: "h-4 w-4 mr-1" }),
3345
3807
  createLabel
@@ -3439,7 +3901,8 @@ function ResourcePage(props) {
3439
3901
  isLoading,
3440
3902
  hasError,
3441
3903
  isEmpty,
3442
- errorMessage: errorMessage2,
3904
+ errorMessage: errorMessage2 ?? // A provider's read error carries a sentence users can be shown.
3905
+ (isClientMode && isDataReadError(resourceHook.list.error) ? resourceHook.list.error.message : void 0),
3443
3906
  onRetry: isClientMode ? () => resourceHook.list.refetch() : onRetryProp,
3444
3907
  viewMode: page.viewMode,
3445
3908
  onViewModeChange: page.setViewMode,
@@ -3534,9 +3997,13 @@ function ResourcePage(props) {
3534
3997
  resizeKey,
3535
3998
  stickyHeader,
3536
3999
  stickyFirstColumn,
4000
+ keyboardNavigation,
3537
4001
  // The server already cut the page window; DataTable's own
3538
4002
  // pagination would slice the slice.
3539
- pagination: isServerControlled ? false : void 0
4003
+ pagination: isServerControlled ? false : void 0,
4004
+ // Client mode: the header sort is the page's sort, so it
4005
+ // reaches the URL and a link restores what the table shows.
4006
+ ...isServerControlled ? {} : { sort: tableSort, onSortChange: handleTableSort }
3540
4007
  },
3541
4008
  slotProps?.table
3542
4009
  ),
@@ -3656,9 +4123,18 @@ function ResourcePage(props) {
3656
4123
  (detail === "panel" || detail === "modal") && renderSlot(
3657
4124
  DetailPanel,
3658
4125
  slots?.detail,
3659
- {
4126
+ linkMissing ? {
4127
+ // A linked record this user can't read, or that's gone.
4128
+ item: {},
4129
+ open: detailOpen,
4130
+ onClose: closeDetail,
4131
+ title: "Record not found",
4132
+ width: detailWidth,
4133
+ layout: detail === "modal" ? "dialog" : "sheet",
4134
+ children: () => /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", "data-testid": "resource-page-link-missing", children: isDataReadError(linked.error) && linked.error.outcome === "forbidden" ? linked.error.message : "This record doesn't exist, or you don't have access to it." })
4135
+ } : {
3660
4136
  item: selectedItem,
3661
- open: page.detailOpen,
4137
+ open: detailOpen,
3662
4138
  onClose: closeDetail,
3663
4139
  title: detailTitle,
3664
4140
  subtitle: detailSubtitle,
@@ -3680,6 +4156,7 @@ function ResourcePage(props) {
3680
4156
  layout: formLayoutProp ?? (formAction === "edit" ? "sheet" : "dialog"),
3681
4157
  mode: formMode,
3682
4158
  steps: formSteps,
4159
+ mutationMode: mutationModeProp,
3683
4160
  item: formAction === "edit" ? formItem ?? void 0 : page.data.length > 0 ? Object.fromEntries(
3684
4161
  Object.keys(page.data[0]).filter((k) => k !== "id").map((k) => [k, ""])
3685
4162
  ) : void 0,
@@ -3701,6 +4178,7 @@ function ResourcePage(props) {
3701
4178
  open: deleteOpen,
3702
4179
  onOpenChange: setDeleteOpen,
3703
4180
  onConfirm: () => pendingDelete ? performDelete(pendingDelete) : void 0,
4181
+ kind: deleteKind,
3704
4182
  title: typeof deleteCopy?.title === "function" ? pendingDelete ? deleteCopy.title(pendingDelete) : void 0 : deleteCopy?.title,
3705
4183
  description: typeof deleteCopy?.description === "function" ? pendingDelete ? deleteCopy.description(pendingDelete) : void 0 : deleteCopy?.description,
3706
4184
  confirmLabel: deleteCopy?.confirmLabel,