@marktiderman/genesis-ui 2.7.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.
Files changed (74) hide show
  1. package/EXTENDING.md +40 -1
  2. package/SKILL.md +198 -9
  3. package/dist/{AppShell-CX0M-dfc.d.ts → AppShell-LXQykEqe.d.ts} +47 -5
  4. package/dist/{chunk-M62CHO3N.js → chunk-4C4ESW6O.js} +9 -4
  5. package/dist/chunk-4C4ESW6O.js.map +1 -0
  6. package/dist/chunk-DIV4BYXX.js +58 -0
  7. package/dist/chunk-DIV4BYXX.js.map +1 -0
  8. package/dist/chunk-FUVWKTPL.js +19 -0
  9. package/dist/chunk-FUVWKTPL.js.map +1 -0
  10. package/dist/{chunk-PLIYF6KI.js → chunk-MPI33I4K.js} +2 -2
  11. package/dist/{chunk-PLIYF6KI.js.map → chunk-MPI33I4K.js.map} +1 -1
  12. package/dist/{chunk-JA3ZKULS.js → chunk-OITWCIG2.js} +3 -3
  13. package/dist/chunk-OITWCIG2.js.map +1 -0
  14. package/dist/chunk-PKFDGGZV.js +65 -0
  15. package/dist/chunk-PKFDGGZV.js.map +1 -0
  16. package/dist/{chunk-IXGAEECT.js → chunk-RKPS7ERS.js} +9 -5
  17. package/dist/chunk-RKPS7ERS.js.map +1 -0
  18. package/dist/{chunk-TXOZMKNW.js → chunk-S7NXD37O.js} +27 -26
  19. package/dist/chunk-S7NXD37O.js.map +1 -0
  20. package/dist/{chunk-ZPPLUGLA.js → chunk-UYI3GLFJ.js} +162 -19
  21. package/dist/chunk-UYI3GLFJ.js.map +1 -0
  22. package/dist/{chunk-CDYZRZ6U.js → chunk-WGMEVX3Y.js} +2 -2
  23. package/dist/chunk-WGMEVX3Y.js.map +1 -0
  24. package/dist/chunk-ZDHWNQTR.js +552 -0
  25. package/dist/chunk-ZDHWNQTR.js.map +1 -0
  26. package/dist/components/data/index.d.ts +306 -19
  27. package/dist/components/data/index.js +1177 -284
  28. package/dist/components/data/index.js.map +1 -1
  29. package/dist/components/layout/form-page.d.ts +1 -1
  30. package/dist/components/layout/form-page.js +1 -1
  31. package/dist/components/layout/index.d.ts +2 -2
  32. package/dist/components/layout/index.js +8 -8
  33. package/dist/components/patterns/index.d.ts +1 -1
  34. package/dist/components/patterns/index.js +5 -5
  35. package/dist/components/patterns/sortable-list.js +1 -1
  36. package/dist/components/rich-text/rich-text-editor.d.ts +194 -0
  37. package/dist/components/rich-text/rich-text-editor.js +833 -0
  38. package/dist/components/rich-text/rich-text-editor.js.map +1 -0
  39. package/dist/components/rich-text/rich-text.d.ts +51 -0
  40. package/dist/components/rich-text/rich-text.js +4 -0
  41. package/dist/components/rich-text/rich-text.js.map +1 -0
  42. package/dist/components/ui/alert-dialog.d.ts +12 -1
  43. package/dist/components/ui/alert-dialog.js +1 -1
  44. package/dist/hooks/index.d.ts +4 -3
  45. package/dist/hooks/index.js +5 -4
  46. package/dist/hooks/use-data-filters.d.ts +5 -3
  47. package/dist/hooks/use-data-filters.js +2 -2
  48. package/dist/hooks/use-resource-page.d.ts +2 -1
  49. package/dist/hooks/use-resource-page.js +3 -2
  50. package/dist/hooks/use-resource-url-state.d.ts +68 -0
  51. package/dist/hooks/use-resource-url-state.js +3 -0
  52. package/dist/hooks/use-resource-url-state.js.map +1 -0
  53. package/dist/hooks/use-view-preference.d.ts +17 -2
  54. package/dist/hooks/use-view-preference.js +2 -1
  55. package/dist/index.d.ts +2 -2
  56. package/dist/index.js +32 -32
  57. package/dist/{navigation-DBwcZ6vb.d.ts → navigation-Bha0R3Fu.d.ts} +6 -3
  58. package/dist/rich-text-doc-C54fME2C.d.ts +180 -0
  59. package/dist/router/react-router.d.ts +47 -0
  60. package/dist/router/react-router.js +53 -0
  61. package/dist/router/react-router.js.map +1 -0
  62. package/dist/router/tanstack-router.d.ts +47 -0
  63. package/dist/router/tanstack-router.js +105 -0
  64. package/dist/router/tanstack-router.js.map +1 -0
  65. package/dist/{use-resource-page-Dd2VUv30.d.ts → use-resource-page-BDnnCssE.d.ts} +83 -5
  66. package/package.json +61 -14
  67. package/dist/chunk-CDYZRZ6U.js.map +0 -1
  68. package/dist/chunk-FTTNGGLA.js +0 -20
  69. package/dist/chunk-FTTNGGLA.js.map +0 -1
  70. package/dist/chunk-IXGAEECT.js.map +0 -1
  71. package/dist/chunk-JA3ZKULS.js.map +0 -1
  72. package/dist/chunk-M62CHO3N.js.map +0 -1
  73. package/dist/chunk-TXOZMKNW.js.map +0 -1
  74. package/dist/chunk-ZPPLUGLA.js.map +0 -1
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { AppShell, PageHeader } from '../../chunk-ZPPLUGLA.js';
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
5
  import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from '../../chunk-N2ZFNOOT.js';
@@ -7,45 +7,50 @@ import { Textarea } from '../../chunk-YETMISCE.js';
7
7
  import { Separator } from '../../chunk-SXSCHWZU.js';
8
8
  import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '../../chunk-XCGZPFT7.js';
9
9
  import { Skeleton } from '../../chunk-QFM4Z2SS.js';
10
+ import { toast } from '../../chunk-4RAZJ6LL.js';
10
11
  import { Progress } from '../../chunk-W5Y3LG5K.js';
11
- import { Input } from '../../chunk-QSXVL5UB.js';
12
- import { Card, CardContent, CardHeader, CardTitle } from '../../chunk-FN3FZVUR.js';
13
12
  import { Checkbox } from '../../chunk-SEBU6JCK.js';
13
+ import { Card, CardContent, CardHeader, CardTitle } from '../../chunk-FN3FZVUR.js';
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';
17
+ import { Input } from '../../chunk-QSXVL5UB.js';
14
18
  import '../../chunk-B2GIBUQD.js';
15
19
  import { Badge } from '../../chunk-NPNADZHM.js';
16
20
  import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '../../chunk-FK42ZGGV.js';
17
- import { Popover, PopoverTrigger, PopoverContent } from '../../chunk-22XB3Q6H.js';
18
21
  import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuCheckboxItem, DropdownMenuSeparator, DropdownMenuItem, DropdownMenuLabel } from '../../chunk-QJ6P2DW6.js';
19
22
  import { Switch } from '../../chunk-27ELYTGE.js';
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';
20
27
  import { EmptyState } from '../../chunk-65SPT7EA.js';
21
28
  export { EmptyState } from '../../chunk-65SPT7EA.js';
22
29
  import { Button } from '../../chunk-NWSCYLPT.js';
23
- import { PageLoading } from '../../chunk-YRYQXU3G.js';
24
- import { Spinner } from '../../chunk-OVU3Z2MT.js';
25
30
  import { FormField, FormLabel, FormError, FormDescription } from '../../chunk-R7QFP6EL.js';
26
31
  import '../../chunk-F47U5KNW.js';
27
- import { DetailPage } from '../../chunk-AFZ76Q26.js';
28
- import '../../chunk-D2LIXA2Y.js';
29
- import '../../chunk-AVFZ4P4B.js';
30
- import { titleCase, useResourcePage } from '../../chunk-IXGAEECT.js';
32
+ import { titleCase, useResourcePage } from '../../chunk-RKPS7ERS.js';
33
+ import { useResourceUrlState } from '../../chunk-DIV4BYXX.js';
31
34
  export { useSavedViews } from '../../chunk-GCQFVMPI.js';
32
- import '../../chunk-FTTNGGLA.js';
35
+ import '../../chunk-PKFDGGZV.js';
33
36
  import { useViewSettings } from '../../chunk-OZ7ZAYCS.js';
34
37
  export { CommandPalette, useCommandPaletteHotkey } from '../../chunk-CNTWQSUT.js';
35
38
  import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandSeparator } from '../../chunk-SEFK4NZV.js';
36
39
  import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '../../chunk-7XMZVISM.js';
37
40
  import { cn } from '../../chunk-NZ5WBA4R.js';
38
- import { DefaultLink, defaultNavigate } from '../../chunk-CDYZRZ6U.js';
39
- export { DefaultLink, defaultNavigate, isPathActive, resolveNavigation } from '../../chunk-CDYZRZ6U.js';
41
+ import { DefaultLink, defaultNavigate } from '../../chunk-WGMEVX3Y.js';
42
+ export { DefaultLink, defaultNavigate, isPathActive, resolveNavigation } from '../../chunk-WGMEVX3Y.js';
40
43
  import { useKeyboardNavigation } from '../../chunk-YJ6MPS6U.js';
41
44
  import { useIsMobile } from '../../chunk-VFO3I7PV.js';
42
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';
43
46
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
44
- import { useState, useRef, useEffect, useCallback, useMemo, Fragment as Fragment$1 } from 'react';
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';
45
50
  import { formatDistanceToNow, format } from 'date-fns';
46
51
  import { useFieldArray, Controller, useWatch } from 'react-hook-form';
47
- import { useResource, useWizard, useResourceForm, useOne, buildArrayItemDefaults } from '@marktiderman/genesis-core/hooks';
48
- 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';
49
54
  import { useDropzone } from 'react-dropzone';
50
55
 
51
56
  function DataPageShell({
@@ -59,6 +64,7 @@ function DataPageShell({
59
64
  isEmpty = false,
60
65
  emptyIcon,
61
66
  emptyMessage,
67
+ emptyDescription,
62
68
  emptyAction,
63
69
  hasError = false,
64
70
  errorMessage: errorMessage2,
@@ -89,11 +95,13 @@ function DataPageShell({
89
95
  }) {
90
96
  const entity = entityName || title.toLowerCase();
91
97
  const collapseFilters = filtersCollapsible && filters != null && header !== null;
98
+ const toolbarOnly = header === "toolbar";
99
+ const baseHeaderOptions = toolbarOnly ? { showTitle: false, ...headerOptions } : headerOptions;
92
100
  const mergedOptions = {
93
101
  defaultDetailsOpen: defaultFiltersOpen,
94
102
  detailsLabel: filtersLabel,
95
103
  subtitleInDetails: collapseFilters ? true : void 0,
96
- ...headerOptions
104
+ ...baseHeaderOptions
97
105
  };
98
106
  const sharedHeaderProps = {
99
107
  title,
@@ -114,7 +122,7 @@ function DataPageShell({
114
122
  stats,
115
123
  optionsStorageKey: headerOptionsStorageKey
116
124
  };
117
- const Header = header === null ? (() => null) : header ?? PageHeader;
125
+ const Header = header === null ? () => null : header === "toolbar" || header === void 0 ? PageHeader : header;
118
126
  return /* @__PURE__ */ jsxs("div", { className: cn("space-y-6", className), children: [
119
127
  collapseFilters ? /* @__PURE__ */ jsx(
120
128
  Header,
@@ -128,7 +136,7 @@ function DataPageShell({
128
136
  options: mergedOptions
129
137
  }
130
138
  ) : /* @__PURE__ */ jsxs(Fragment, { children: [
131
- /* @__PURE__ */ jsx(Header, { ...sharedHeaderProps, options: headerOptions }),
139
+ /* @__PURE__ */ jsx(Header, { ...sharedHeaderProps, options: baseHeaderOptions }),
132
140
  filters
133
141
  ] }),
134
142
  isLoading ? /* @__PURE__ */ jsx(
@@ -158,6 +166,7 @@ function DataPageShell({
158
166
  {
159
167
  icon: emptyIcon,
160
168
  title: emptyMessage,
169
+ ...emptyDescription ? { description: emptyDescription } : {},
161
170
  action: emptyAction,
162
171
  hasFilters: hasActiveFilters,
163
172
  onClearFilters
@@ -540,6 +549,28 @@ function DataFilters({
540
549
  ] });
541
550
  }
542
551
  var DEFAULT_BATCH_SIZE = 48;
552
+ var NESTED_CONTROL = "a,button,input,select,textarea,label,[role=button],[role=link],[role=checkbox],[role=switch],[role=menuitem],[contenteditable=true]";
553
+ function activation(item, onItemClick, getItemLabel) {
554
+ if (!onItemClick) return {};
555
+ return {
556
+ role: "button",
557
+ tabIndex: 0,
558
+ "aria-label": getItemLabel?.(item),
559
+ onClick: (e) => {
560
+ const control = e.target.closest(NESTED_CONTROL);
561
+ if (control && control !== e.currentTarget) return;
562
+ onItemClick(item);
563
+ },
564
+ onKeyDown: (e) => {
565
+ if (e.target !== e.currentTarget) return;
566
+ if (e.key === "Enter" || e.key === " ") {
567
+ e.preventDefault();
568
+ onItemClick(item);
569
+ }
570
+ }
571
+ };
572
+ }
573
+ var ACTIVATABLE = "cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background";
543
574
  function DataGrid({
544
575
  items,
545
576
  viewMode,
@@ -551,7 +582,9 @@ function DataGrid({
551
582
  staggerAnimation = true,
552
583
  getKey,
553
584
  focusedIndex = -1,
554
- keyboardContainerRef
585
+ keyboardContainerRef,
586
+ onItemClick,
587
+ getItemLabel
555
588
  }) {
556
589
  const [visibleCount, setVisibleCount] = useState(batchSize);
557
590
  const sentinelRef = useRef(null);
@@ -587,8 +620,10 @@ function DataGrid({
587
620
  "div",
588
621
  {
589
622
  "data-nav-item": true,
623
+ ...activation(item, onItemClick, getItemLabel),
590
624
  className: cn(
591
625
  "px-4 py-3 transition-colors hover:bg-accent/50",
626
+ onItemClick && ACTIVATABLE,
592
627
  focusedIndex === i && "ring-2 ring-inset ring-primary bg-accent/50"
593
628
  ),
594
629
  children: renderListItem(item, i)
@@ -605,8 +640,10 @@ function DataGrid({
605
640
  "div",
606
641
  {
607
642
  "data-nav-item": true,
643
+ ...activation(item, onItemClick, getItemLabel),
608
644
  className: cn(
609
645
  staggerAnimation && "animate-fade-in",
646
+ onItemClick && cn(ACTIVATABLE, "h-full rounded-xl"),
610
647
  focusedIndex === i && "ring-2 ring-primary ring-offset-2 ring-offset-background rounded-xl transition-shadow duration-150"
611
648
  ),
612
649
  style: staggerAnimation ? {
@@ -637,9 +674,66 @@ var DENSITY_HEAD = {
637
674
  comfortable: "h-10",
638
675
  spacious: "h-12 py-3"
639
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
+ }
640
721
  function getCellClasses(col) {
641
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" : "";
642
- return cn(col.width, hide);
723
+ return cn(typeof col.width === "string" && !PX_WIDTH.test(col.width) ? col.width : void 0, hide);
724
+ }
725
+ var PX_WIDTH = /^\s*\d+(\.\d+)?(px)?\s*$/;
726
+ function pxWidth(width) {
727
+ if (typeof width === "number") return width > 0 ? width : void 0;
728
+ if (width && PX_WIDTH.test(width)) {
729
+ const px = parseFloat(width);
730
+ return px > 0 ? px : void 0;
731
+ }
732
+ return void 0;
733
+ }
734
+ function colHideClass(hideBelow) {
735
+ if (!hideBelow) return void 0;
736
+ return hideBelow === "sm" ? "hidden sm:table-column" : hideBelow === "md" ? "hidden md:table-column" : hideBelow === "lg" ? "hidden lg:table-column" : "hidden xl:table-column";
643
737
  }
644
738
  function errorMessage(error) {
645
739
  return error instanceof Error ? error.message : String(error);
@@ -842,29 +936,29 @@ function DataTable({
842
936
  stickyHeader = true,
843
937
  resizeKey,
844
938
  stickyFirstColumn,
845
- density = "comfortable"
939
+ density = "comfortable",
940
+ keyboardNavigation = true,
941
+ openColumn,
942
+ virtualize,
943
+ pinnedColumns,
944
+ reorderableColumns = true,
945
+ columnOrderKey,
946
+ sort: sortProp,
947
+ onSortChange
846
948
  }) {
949
+ const rowKeys = keyboardNavigation && !!onRowClick;
950
+ const [activeRowKey, setActiveRowKey] = useState(null);
847
951
  const cellDensity = DENSITY_CELL[density];
848
952
  const headDensity = DENSITY_HEAD[density];
849
- const [sorts, setSorts] = useState([]);
953
+ const [ownSorts, setOwnSorts] = useState([]);
954
+ const sorts = sortProp ?? ownSorts;
850
955
  const handleSort = useCallback(
851
956
  (key, shiftKey) => {
852
- setSorts((prev) => {
853
- const existing = prev.findIndex((s) => s.key === key);
854
- if (existing >= 0) {
855
- const entry = prev[existing];
856
- if (entry.dir === "asc") {
857
- const next = [...prev];
858
- next[existing] = { key, dir: "desc" };
859
- return next;
860
- }
861
- return prev.filter((_, i) => i !== existing);
862
- }
863
- if (shiftKey) return [...prev, { key, dir: "asc" }];
864
- return [{ key, dir: "asc" }];
865
- });
957
+ const next = nextSort(sorts, key, shiftKey);
958
+ if (sortProp === void 0) setOwnSorts(next);
959
+ onSortChange?.(next);
866
960
  },
867
- []
961
+ [sorts, sortProp, onSortChange]
868
962
  );
869
963
  const [visibleCols, setVisibleCols] = useState(
870
964
  () => {
@@ -904,7 +998,7 @@ function DataTable({
904
998
  }, [columnVisibilityKey]);
905
999
  useEffect(() => {
906
1000
  if (!visibilityHydrated || !columnVisibilityKey) return;
907
- localStorage.setItem(
1001
+ storeSet(
908
1002
  `dt-vis-${columnVisibilityKey}`,
909
1003
  JSON.stringify(visibleCols)
910
1004
  );
@@ -918,8 +1012,9 @@ function DataTable({
918
1012
  defaults[c.key] = c.defaultVisible !== false;
919
1013
  });
920
1014
  setVisibleCols(defaults);
1015
+ setColOrder(null);
921
1016
  if (columnVisibilityKey) {
922
- localStorage.removeItem(`dt-vis-${columnVisibilityKey}`);
1017
+ storeRemove(`dt-vis-${columnVisibilityKey}`);
923
1018
  }
924
1019
  }, [columns, columnVisibilityKey]);
925
1020
  useEffect(() => {
@@ -935,7 +1030,54 @@ function DataTable({
935
1030
  return changed ? updated : prev;
936
1031
  });
937
1032
  }, [columns]);
938
- 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
+ ];
939
1081
  const [colWidths, setColWidths] = useState(() => {
940
1082
  if (resizeKey) {
941
1083
  try {
@@ -946,10 +1088,8 @@ function DataTable({
946
1088
  }
947
1089
  const initial = {};
948
1090
  columns.forEach((c) => {
949
- if (c.width) {
950
- const px = parseInt(c.width, 10);
951
- if (!isNaN(px) && px > 0) initial[c.key] = px;
952
- }
1091
+ const px = pxWidth(c.width);
1092
+ if (px) initial[c.key] = px;
953
1093
  });
954
1094
  return initial;
955
1095
  });
@@ -964,46 +1104,232 @@ function DataTable({
964
1104
  );
965
1105
  const handleResizeEnd = useCallback(() => {
966
1106
  if (resizeKey) {
967
- localStorage.setItem(`dt-resize-${resizeKey}`, JSON.stringify(colWidths));
1107
+ storeSet(`dt-resize-${resizeKey}`, JSON.stringify(colWidths));
968
1108
  }
969
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
+ };
970
1137
  const [currentPage, setCurrentPage] = useState(0);
971
1138
  const [currentPageSize, setCurrentPageSize] = useState(initialPageSize);
972
1139
  useEffect(() => {
973
1140
  setCurrentPage(0);
974
1141
  }, [items.length, currentPageSize]);
975
- const sorted = (() => {
976
- if (sorts.length === 0) return items;
977
- return [...items].sort((a, b) => {
978
- for (const s of sorts) {
979
- const col = columns.find((c) => c.key === s.key);
980
- if (!col) continue;
981
- const acc = col.accessor || ((item) => item[col.key]);
982
- const va = acc(a);
983
- const vb = acc(b);
984
- const sa = va == null ? "" : String(va);
985
- const sb = vb == null ? "" : String(vb);
986
- const cmp = sa.localeCompare(sb, void 0, {
987
- numeric: true,
988
- sensitivity: "base"
989
- });
990
- if (cmp !== 0) return s.dir === "asc" ? cmp : -cmp;
991
- }
992
- return 0;
993
- });
994
- })();
995
- 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;
996
1174
  const totalPages = pagination ? Math.max(1, Math.ceil(totalItems / currentPageSize)) : 1;
997
- const displayItems = pagination ? sorted.slice(
998
- currentPage * currentPageSize,
999
- (currentPage + 1) * currentPageSize
1000
- ) : 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
+ };
1001
1204
  const showingFrom = totalItems === 0 ? 0 : currentPage * currentPageSize + 1;
1002
1205
  const showingTo = Math.min(
1003
1206
  (currentPage + 1) * currentPageSize,
1004
1207
  totalItems
1005
1208
  );
1006
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
+ };
1007
1333
  return /* @__PURE__ */ jsxs("div", { className: "border rounded-lg animate-fade-in flex flex-col", children: [
1008
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: [
1009
1335
  /* @__PURE__ */ jsx(DropdownMenuTrigger, { asChild: true, children: /* @__PURE__ */ jsxs(
@@ -1041,135 +1367,181 @@ function DataTable({
1041
1367
  }
1042
1368
  )
1043
1369
  ] }) }),
1044
- /* @__PURE__ */ jsx("div", { className: "overflow-x-auto", children: /* @__PURE__ */ jsxs(Table, { style: { tableLayout: "fixed" }, children: [
1045
- /* @__PURE__ */ jsx(
1046
- TableHeader,
1047
- {
1048
- className: stickyHeader ? "sticky top-0 z-10 bg-background" : "",
1049
- children: /* @__PURE__ */ jsxs(TableRow, { children: [
1050
- selectable && /* @__PURE__ */ jsx(TableHead, { className: cn("w-10", headDensity), children: /* @__PURE__ */ jsx(
1051
- Checkbox,
1052
- {
1053
- checked: items.length > 0 && selected?.size === items.length,
1054
- onCheckedChange: onToggleAll
1055
- }
1056
- ) }),
1057
- activeColumns.map((col, colIndex) => {
1058
- const sortIndex = sorts.findIndex((s) => s.key === col.key);
1059
- const sortEntry = sortIndex >= 0 ? sorts[sortIndex] : null;
1060
- const resizeWidth = colWidths[col.key];
1061
- const isFirstCol = colIndex === 0 && stickyFirstColumn;
1062
- return /* @__PURE__ */ jsxs(
1063
- TableHead,
1064
- {
1065
- className: cn(
1066
- getCellClasses(col),
1067
- headDensity,
1068
- col.sortable && "cursor-pointer select-none",
1069
- resizableColumns && "relative",
1070
- isFirstCol && "sticky left-0 bg-background",
1071
- isFirstCol && stickyHeader ? "z-[15]" : isFirstCol ? "z-[5]" : void 0
1072
- ),
1073
- style: resizeWidth ? { width: resizeWidth, minWidth: resizeWidth } : void 0,
1074
- onClick: col.sortable ? (e) => handleSort(col.key, e.shiftKey) : void 0,
1075
- children: [
1076
- /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1", children: [
1077
- col.header,
1078
- col.sortable && (sortEntry ? /* @__PURE__ */ jsxs(Fragment, { children: [
1079
- sortEntry.dir === "asc" ? /* @__PURE__ */ jsx(ArrowUp, { className: "h-3 w-3 text-foreground" }) : /* @__PURE__ */ jsx(ArrowDown, { className: "h-3 w-3 text-foreground" }),
1080
- sorts.length > 1 && /* @__PURE__ */ jsx("span", { className: "text-[10px] text-muted-foreground font-mono", children: sortIndex + 1 })
1081
- ] }) : /* @__PURE__ */ jsx(ArrowUpDown, { className: "h-3 w-3 text-muted-foreground/50" }))
1082
- ] }),
1083
- resizableColumns && /* @__PURE__ */ jsx(
1084
- ResizeHandle,
1085
- {
1086
- onResize: (delta) => handleResize(
1087
- col.key,
1088
- delta,
1089
- col.minWidth ?? 60
1090
- ),
1091
- onResizeEnd: handleResizeEnd
1092
- }
1093
- )
1094
- ]
1095
- },
1096
- col.key
1097
- );
1098
- }),
1099
- renderActions && /* @__PURE__ */ jsx(TableHead, { className: cn("w-10", headDensity) })
1100
- ] })
1101
- }
1102
- ),
1103
- /* @__PURE__ */ jsx(TableBody, { children: displayItems.length === 0 ? /* @__PURE__ */ jsx(TableRow, { children: /* @__PURE__ */ jsx(
1104
- TableCell,
1105
- {
1106
- colSpan: totalCols,
1107
- className: "text-center py-12 text-muted-foreground",
1108
- children: emptyMessage
1109
- }
1110
- ) }) : displayItems.map((item) => {
1111
- const id = getKey(item);
1112
- return /* @__PURE__ */ jsxs(
1113
- 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,
1114
1378
  {
1115
- className: cn(onRowClick && "cursor-pointer"),
1116
- "data-state": selected?.has(id) ? "selected" : void 0,
1117
- onClick: onRowClick ? () => onRowClick(item) : void 0,
1379
+ style: { tableLayout: "fixed" },
1380
+ "aria-rowcount": virtualizing ? displayItems.length + 1 : void 0,
1118
1381
  children: [
1119
- selectable && /* @__PURE__ */ jsx(
1120
- TableCell,
1121
- {
1122
- className: cellDensity,
1123
- onClick: (e) => e.stopPropagation(),
1124
- children: /* @__PURE__ */ jsx(
1125
- 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",
1126
1388
  {
1127
- checked: selected?.has(id),
1128
- onCheckedChange: () => onToggleSelect?.(id)
1129
- }
1130
- )
1131
- }
1132
- ),
1133
- activeColumns.map((col, colIndex) => {
1134
- const acc = col.accessor || ((it) => it[col.key]);
1135
- const value = acc(item);
1136
- const isFirstCol = colIndex === 0 && stickyFirstColumn;
1137
- return /* @__PURE__ */ jsx(
1138
- TableCell,
1139
- {
1140
- className: cn(
1141
- getCellClasses(
1142
- col
1143
- ),
1144
- cellDensity,
1145
- 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
+ }
1146
1416
  ),
1147
- children: col.editable ? /* @__PURE__ */ jsx(
1148
- 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,
1149
1509
  {
1150
- item,
1151
- column: col,
1152
- value
1510
+ className: cn("w-10", headDensity, pinProps("__actions", true)?.className),
1511
+ style: pinProps("__actions", true)?.style
1153
1512
  }
1154
- ) : col.render ? col.render(item, value) : /* @__PURE__ */ jsx("span", { className: "truncate block max-w-[300px]", children: String(value ?? "\u2014") })
1155
- },
1156
- col.key
1157
- );
1158
- }),
1159
- renderActions && /* @__PURE__ */ jsx(
1513
+ )
1514
+ ] })
1515
+ }
1516
+ ),
1517
+ /* @__PURE__ */ jsx(TableBody, { ref: bodyRef, children: displayItems.length === 0 ? /* @__PURE__ */ jsx(TableRow, { children: /* @__PURE__ */ jsx(
1160
1518
  TableCell,
1161
1519
  {
1162
- className: cellDensity,
1163
- onClick: (e) => e.stopPropagation(),
1164
- children: renderActions(item)
1520
+ colSpan: totalCols,
1521
+ className: "text-center py-12 text-muted-foreground",
1522
+ children: emptyMessage
1165
1523
  }
1166
- )
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)) })
1167
1540
  ]
1168
- },
1169
- id
1170
- );
1171
- }) })
1172
- ] }) }),
1541
+ }
1542
+ )
1543
+ }
1544
+ ),
1173
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: [
1174
1546
  /* @__PURE__ */ jsx("span", { children: totalItems === 0 ? "No records" : `Showing ${showingFrom}\u2013${showingTo} of ${totalItems}` }),
1175
1547
  /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3", children: [
@@ -1750,11 +2122,48 @@ function DetailPanel({
1750
2122
  }
1751
2123
  );
1752
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
+ }
1753
2158
  function RelationField({
1754
2159
  field,
1755
2160
  value,
1756
2161
  onChange,
1757
- disabled
2162
+ disabled,
2163
+ id,
2164
+ "aria-describedby": ariaDescribedBy,
2165
+ "aria-invalid": ariaInvalid,
2166
+ "aria-required": ariaRequired
1758
2167
  }) {
1759
2168
  const [open, setOpen] = useState(false);
1760
2169
  const [createOpen, setCreateOpen] = useState(false);
@@ -1813,7 +2222,11 @@ function RelationField({
1813
2222
  {
1814
2223
  variant: "outline",
1815
2224
  role: "combobox",
2225
+ id,
1816
2226
  "aria-expanded": open,
2227
+ "aria-describedby": ariaDescribedBy,
2228
+ "aria-invalid": ariaInvalid,
2229
+ "aria-required": ariaRequired,
1817
2230
  className: "w-full justify-between font-normal",
1818
2231
  disabled: disabled || isLoading,
1819
2232
  children: [
@@ -1924,7 +2337,12 @@ function FileUploadField({
1924
2337
  field,
1925
2338
  value,
1926
2339
  onChange,
1927
- disabled
2340
+ disabled,
2341
+ id,
2342
+ "aria-labelledby": ariaLabelledBy,
2343
+ "aria-describedby": ariaDescribedBy,
2344
+ "aria-invalid": ariaInvalid,
2345
+ "aria-required": ariaRequired
1928
2346
  }) {
1929
2347
  const multiple = field.multiple ?? false;
1930
2348
  const maxSize = field.maxSize ?? DEFAULT_MAX_SIZE;
@@ -1997,7 +2415,16 @@ function FileUploadField({
1997
2415
  /* @__PURE__ */ jsxs(
1998
2416
  "div",
1999
2417
  {
2000
- ...getRootProps(),
2418
+ ...getRootProps({
2419
+ // The focusable drop zone carries the field's name, description
2420
+ // and state; react-dropzone's default `role="presentation"` would
2421
+ // leave a keyboard user on an unnamed tab stop.
2422
+ role: "button",
2423
+ "aria-labelledby": ariaLabelledBy,
2424
+ "aria-describedby": ariaDescribedBy,
2425
+ "aria-invalid": ariaInvalid,
2426
+ "aria-disabled": disabled || void 0
2427
+ }),
2001
2428
  className: cn(
2002
2429
  "flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed px-6 py-8 text-center transition-colors",
2003
2430
  isDragActive ? "border-primary bg-primary/5" : "border-muted-foreground/25 hover:border-muted-foreground/50",
@@ -2005,7 +2432,17 @@ function FileUploadField({
2005
2432
  hasFiles && "py-4"
2006
2433
  ),
2007
2434
  children: [
2008
- /* @__PURE__ */ jsx("input", { ...getInputProps() }),
2435
+ /* @__PURE__ */ jsx(
2436
+ "input",
2437
+ {
2438
+ ...getInputProps({
2439
+ id,
2440
+ "aria-describedby": ariaDescribedBy,
2441
+ "aria-invalid": ariaInvalid,
2442
+ "aria-required": ariaRequired
2443
+ })
2444
+ }
2445
+ ),
2009
2446
  isImage ? /* @__PURE__ */ jsx(ImageIcon, { className: "mb-2 h-8 w-8 text-muted-foreground" }) : /* @__PURE__ */ jsx(Upload, { className: "mb-2 h-8 w-8 text-muted-foreground" }),
2010
2447
  /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: isDragActive ? "Drop files here..." : "Drag & drop or click to upload" }),
2011
2448
  /* @__PURE__ */ jsxs("p", { className: "mt-1 text-xs text-muted-foreground/70", children: [
@@ -2182,6 +2619,11 @@ function ResourceFormFieldRenderer({
2182
2619
  watchedValues
2183
2620
  }) {
2184
2621
  const label = field.label ?? titleCase(field.key);
2622
+ const baseId = useId();
2623
+ const inputId = `${baseId}-input`;
2624
+ const errorId = `${baseId}-error`;
2625
+ const descId = `${baseId}-desc`;
2626
+ const labelId = `${baseId}-label`;
2185
2627
  const fieldType = field.type ?? "text";
2186
2628
  const isReadOnly = action === "edit" && field.readOnlyOnEdit;
2187
2629
  const isDisabled = isReadOnly || (watchedValues ? field.disabled?.(watchedValues) === true : false);
@@ -2213,14 +2655,20 @@ function ResourceFormFieldRenderer({
2213
2655
  control,
2214
2656
  render: ({ field: f, fieldState }) => {
2215
2657
  const errorMessage2 = fieldState.error?.message;
2658
+ const describedBy = [field.description ? descId : null, errorMessage2 ? errorId : null].filter(Boolean).join(" ") || void 0;
2216
2659
  const ariaProps = {
2660
+ id: inputId,
2217
2661
  "aria-invalid": !!errorMessage2 || void 0,
2218
- "aria-describedby": errorMessage2 ? `${field.key}-error` : field.description ? `${field.key}-desc` : void 0,
2662
+ "aria-describedby": describedBy,
2219
2663
  "aria-required": field.required || void 0
2220
2664
  };
2221
2665
  if (fieldType === "switch") {
2222
2666
  return /* @__PURE__ */ jsxs(FormField, { className: "flex flex-row items-center justify-between rounded-lg border p-4", children: [
2223
- /* @__PURE__ */ jsx("div", { className: "space-y-0.5", children: /* @__PURE__ */ jsx(FormLabel, { error: !!errorMessage2, children: label }) }),
2667
+ /* @__PURE__ */ jsxs("div", { className: "space-y-0.5", children: [
2668
+ /* @__PURE__ */ jsx(FormLabel, { htmlFor: inputId, error: !!errorMessage2, children: label }),
2669
+ field.description && /* @__PURE__ */ jsx(FormDescription, { id: descId, children: field.description }),
2670
+ /* @__PURE__ */ jsx(FormError, { id: errorId, children: errorMessage2 })
2671
+ ] }),
2224
2672
  /* @__PURE__ */ jsx(
2225
2673
  Switch,
2226
2674
  {
@@ -2244,14 +2692,15 @@ function ResourceFormFieldRenderer({
2244
2692
  }
2245
2693
  ),
2246
2694
  /* @__PURE__ */ jsxs("div", { className: "space-y-1 leading-none", children: [
2247
- /* @__PURE__ */ jsx(FormLabel, { error: !!errorMessage2, children: label }),
2248
- /* @__PURE__ */ jsx(FormError, { id: `${field.key}-error`, children: errorMessage2 })
2695
+ /* @__PURE__ */ jsx(FormLabel, { htmlFor: inputId, error: !!errorMessage2, children: label }),
2696
+ field.description && /* @__PURE__ */ jsx(FormDescription, { id: descId, children: field.description }),
2697
+ /* @__PURE__ */ jsx(FormError, { id: errorId, children: errorMessage2 })
2249
2698
  ] })
2250
2699
  ] });
2251
2700
  }
2252
2701
  if (fieldType === "select" && field.options) {
2253
2702
  return /* @__PURE__ */ jsxs(FormField, { children: [
2254
- /* @__PURE__ */ jsx(FormLabel, { error: !!errorMessage2, required: field.required, children: label }),
2703
+ /* @__PURE__ */ jsx(FormLabel, { htmlFor: inputId, error: !!errorMessage2, required: field.required, children: label }),
2255
2704
  /* @__PURE__ */ jsxs(
2256
2705
  Select,
2257
2706
  {
@@ -2270,49 +2719,53 @@ function ResourceFormFieldRenderer({
2270
2719
  ]
2271
2720
  }
2272
2721
  ),
2273
- field.description && /* @__PURE__ */ jsx(FormDescription, { id: `${field.key}-desc`, children: field.description }),
2274
- /* @__PURE__ */ jsx(FormError, { id: `${field.key}-error`, children: errorMessage2 })
2722
+ field.description && /* @__PURE__ */ jsx(FormDescription, { id: descId, children: field.description }),
2723
+ /* @__PURE__ */ jsx(FormError, { id: errorId, children: errorMessage2 })
2275
2724
  ] });
2276
2725
  }
2277
2726
  if (fieldType === "relation" && field.relation) {
2278
2727
  return /* @__PURE__ */ jsxs(FormField, { children: [
2279
- /* @__PURE__ */ jsx(FormLabel, { error: !!errorMessage2, required: field.required, children: label }),
2728
+ /* @__PURE__ */ jsx(FormLabel, { htmlFor: inputId, error: !!errorMessage2, required: field.required, children: label }),
2280
2729
  /* @__PURE__ */ jsx(
2281
2730
  RelationField,
2282
2731
  {
2283
2732
  field,
2284
2733
  value: f.value != null ? String(f.value) : "",
2285
2734
  onChange: f.onChange,
2286
- disabled: isDisabled
2735
+ disabled: isDisabled,
2736
+ ...ariaProps
2287
2737
  }
2288
2738
  ),
2289
- field.description && /* @__PURE__ */ jsx(FormDescription, { id: `${field.key}-desc`, children: field.description }),
2290
- /* @__PURE__ */ jsx(FormError, { id: `${field.key}-error`, children: errorMessage2 })
2739
+ field.description && /* @__PURE__ */ jsx(FormDescription, { id: descId, children: field.description }),
2740
+ /* @__PURE__ */ jsx(FormError, { id: errorId, children: errorMessage2 })
2291
2741
  ] });
2292
2742
  }
2293
2743
  if (fieldType === "file" || fieldType === "image") {
2294
2744
  return /* @__PURE__ */ jsxs(FormField, { children: [
2295
- /* @__PURE__ */ jsx(FormLabel, { error: !!errorMessage2, required: field.required, children: label }),
2745
+ /* @__PURE__ */ jsx(FormLabel, { id: labelId, htmlFor: inputId, error: !!errorMessage2, required: field.required, children: label }),
2296
2746
  /* @__PURE__ */ jsx(
2297
2747
  FileUploadField,
2298
2748
  {
2749
+ "aria-labelledby": labelId,
2299
2750
  field,
2300
2751
  value: f.value,
2301
2752
  onChange: f.onChange,
2302
- disabled: isDisabled
2753
+ disabled: isDisabled,
2754
+ ...ariaProps
2303
2755
  }
2304
2756
  ),
2305
- field.description && /* @__PURE__ */ jsx(FormDescription, { id: `${field.key}-desc`, children: field.description }),
2306
- /* @__PURE__ */ jsx(FormError, { id: `${field.key}-error`, children: errorMessage2 })
2757
+ field.description && /* @__PURE__ */ jsx(FormDescription, { id: descId, children: field.description }),
2758
+ /* @__PURE__ */ jsx(FormError, { id: errorId, children: errorMessage2 })
2307
2759
  ] });
2308
2760
  }
2309
2761
  if (fieldType === "textarea") {
2310
2762
  return /* @__PURE__ */ jsxs(FormField, { children: [
2311
- /* @__PURE__ */ jsx(FormLabel, { error: !!errorMessage2, required: field.required, children: label }),
2763
+ /* @__PURE__ */ jsx(FormLabel, { htmlFor: inputId, error: !!errorMessage2, required: field.required, children: label }),
2312
2764
  /* @__PURE__ */ jsx(
2313
2765
  Textarea,
2314
2766
  {
2315
2767
  placeholder: field.placeholder,
2768
+ maxLength: field.maxLength,
2316
2769
  rows: field.rows ?? 3,
2317
2770
  value: f.value != null ? String(f.value) : "",
2318
2771
  onChange: (e) => f.onChange(e.target.value),
@@ -2321,13 +2774,13 @@ function ResourceFormFieldRenderer({
2321
2774
  ...ariaProps
2322
2775
  }
2323
2776
  ),
2324
- field.description && /* @__PURE__ */ jsx(FormDescription, { id: `${field.key}-desc`, children: field.description }),
2325
- /* @__PURE__ */ jsx(FormError, { id: `${field.key}-error`, children: errorMessage2 })
2777
+ field.description && /* @__PURE__ */ jsx(FormDescription, { id: descId, children: field.description }),
2778
+ /* @__PURE__ */ jsx(FormError, { id: errorId, children: errorMessage2 })
2326
2779
  ] });
2327
2780
  }
2328
2781
  if (fieldType === "number") {
2329
2782
  return /* @__PURE__ */ jsxs(FormField, { children: [
2330
- /* @__PURE__ */ jsx(FormLabel, { error: !!errorMessage2, required: field.required, children: label }),
2783
+ /* @__PURE__ */ jsx(FormLabel, { htmlFor: inputId, error: !!errorMessage2, required: field.required, children: label }),
2331
2784
  /* @__PURE__ */ jsx(
2332
2785
  Input,
2333
2786
  {
@@ -2345,17 +2798,18 @@ function ResourceFormFieldRenderer({
2345
2798
  ...ariaProps
2346
2799
  }
2347
2800
  ),
2348
- field.description && /* @__PURE__ */ jsx(FormDescription, { id: `${field.key}-desc`, children: field.description }),
2349
- /* @__PURE__ */ jsx(FormError, { id: `${field.key}-error`, children: errorMessage2 })
2801
+ field.description && /* @__PURE__ */ jsx(FormDescription, { id: descId, children: field.description }),
2802
+ /* @__PURE__ */ jsx(FormError, { id: errorId, children: errorMessage2 })
2350
2803
  ] });
2351
2804
  }
2352
2805
  return /* @__PURE__ */ jsxs(FormField, { children: [
2353
- /* @__PURE__ */ jsx(FormLabel, { error: !!errorMessage2, required: field.required, children: label }),
2806
+ /* @__PURE__ */ jsx(FormLabel, { htmlFor: inputId, error: !!errorMessage2, required: field.required, children: label }),
2354
2807
  /* @__PURE__ */ jsx(
2355
2808
  Input,
2356
2809
  {
2357
2810
  type: fieldType,
2358
2811
  placeholder: field.placeholder,
2812
+ maxLength: field.maxLength,
2359
2813
  value: f.value != null ? String(f.value) : "",
2360
2814
  onChange: (e) => f.onChange(e.target.value),
2361
2815
  disabled: isDisabled,
@@ -2363,8 +2817,8 @@ function ResourceFormFieldRenderer({
2363
2817
  ...ariaProps
2364
2818
  }
2365
2819
  ),
2366
- field.description && /* @__PURE__ */ jsx(FormDescription, { id: `${field.key}-desc`, children: field.description }),
2367
- /* @__PURE__ */ jsx(FormError, { id: `${field.key}-error`, children: errorMessage2 })
2820
+ field.description && /* @__PURE__ */ jsx(FormDescription, { id: descId, children: field.description }),
2821
+ /* @__PURE__ */ jsx(FormError, { id: errorId, children: errorMessage2 })
2368
2822
  ] });
2369
2823
  }
2370
2824
  }
@@ -2476,6 +2930,19 @@ function FormBody({
2476
2930
  control: form.control,
2477
2931
  disabled: !hasConditionalFields
2478
2932
  });
2933
+ const rootError = form.formState.errors.root?.message;
2934
+ const rootErrorBanner = rootError ? /* @__PURE__ */ jsxs(
2935
+ "div",
2936
+ {
2937
+ role: "alert",
2938
+ "data-testid": "resource-form-error",
2939
+ className: "flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive",
2940
+ children: [
2941
+ /* @__PURE__ */ jsx(AlertTriangle, { className: "mt-0.5 h-4 w-4 shrink-0", "aria-hidden": "true" }),
2942
+ /* @__PURE__ */ jsx("span", { children: rootError })
2943
+ ]
2944
+ }
2945
+ ) : null;
2479
2946
  const onInvalid = (errors) => {
2480
2947
  const firstErrorKey = Object.keys(errors)[0];
2481
2948
  if (firstErrorKey) form.setFocus(firstErrorKey);
@@ -2484,18 +2951,21 @@ function FormBody({
2484
2951
  return /* @__PURE__ */ jsx("div", { className: "flex items-center justify-center py-8", children: /* @__PURE__ */ jsx(Spinner, {}) });
2485
2952
  }
2486
2953
  if (mode === "wizard" && steps) {
2487
- return /* @__PURE__ */ jsx(
2488
- WizardForm,
2489
- {
2490
- steps,
2491
- form,
2492
- fields: resolvedFields,
2493
- onSubmit: handleSave,
2494
- isSaving,
2495
- action,
2496
- onInvalid
2497
- }
2498
- );
2954
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [
2955
+ rootErrorBanner,
2956
+ /* @__PURE__ */ jsx(
2957
+ WizardForm,
2958
+ {
2959
+ steps,
2960
+ form,
2961
+ fields: resolvedFields,
2962
+ onSubmit: handleSave,
2963
+ isSaving,
2964
+ action,
2965
+ onInvalid
2966
+ }
2967
+ )
2968
+ ] });
2499
2969
  }
2500
2970
  const visibleFields = resolvedFields.filter(
2501
2971
  (field) => field.visible?.(watchedValues) !== false
@@ -2535,6 +3005,7 @@ function FormBody({
2535
3005
  return /* @__PURE__ */ jsxs(
2536
3006
  "form",
2537
3007
  {
3008
+ noValidate: true,
2538
3009
  onSubmit: (e) => {
2539
3010
  e.preventDefault();
2540
3011
  void form.handleSubmit(handleSave, onInvalid)(e);
@@ -2548,6 +3019,7 @@ function FormBody({
2548
3019
  ] }),
2549
3020
  renderFields(group.fields)
2550
3021
  ] }, group.name ?? "__default")) }) }),
3022
+ rootErrorBanner,
2551
3023
  /* @__PURE__ */ jsxs("div", { className: "flex justify-end gap-2", children: [
2552
3024
  /* @__PURE__ */ jsx(
2553
3025
  Button,
@@ -2584,7 +3056,14 @@ function ResourceForm(props) {
2584
3056
  layout: layoutProp = "dialog",
2585
3057
  mode = "standard",
2586
3058
  steps,
2587
- columns = 1
3059
+ columns = 1,
3060
+ prefillFromUrl,
3061
+ autoPlaceholder = false,
3062
+ warnWhenUnsaved = false,
3063
+ onDirtyChange,
3064
+ mutationMode = "pessimistic",
3065
+ undoDelayMs,
3066
+ onUndoable
2588
3067
  } = props;
2589
3068
  const isMobile = useIsMobile();
2590
3069
  const effectiveLayout = isMobile ? "sheet" : layoutProp;
@@ -2602,9 +3081,31 @@ function ResourceForm(props) {
2602
3081
  onOpenChange(false);
2603
3082
  onSuccess?.(data);
2604
3083
  },
2605
- 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),
3087
+ prefillFromUrl,
3088
+ autoPlaceholder,
3089
+ warnWhenUnsaved,
3090
+ mutationMode,
3091
+ undoDelayMs,
3092
+ onUndoable: onUndoable ?? showUndoToast
2606
3093
  };
2607
- 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
+ );
2608
3109
  const bodyProps = {
2609
3110
  form,
2610
3111
  resolvedFields,
@@ -2612,14 +3113,14 @@ function ResourceForm(props) {
2612
3113
  action,
2613
3114
  isSaving,
2614
3115
  isLoading,
2615
- onOpenChange,
3116
+ onOpenChange: requestOpenChange,
2616
3117
  handleSave,
2617
3118
  mode,
2618
3119
  steps,
2619
3120
  columns
2620
3121
  };
2621
3122
  if (effectiveLayout === "dialog") {
2622
- 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: [
2623
3124
  /* @__PURE__ */ jsxs(DialogHeader, { children: [
2624
3125
  /* @__PURE__ */ jsx(DialogTitle, { children: formTitle }),
2625
3126
  /* @__PURE__ */ jsx(DialogDescription, { className: "sr-only", children: formDescription })
@@ -2628,7 +3129,7 @@ function ResourceForm(props) {
2628
3129
  ] }) });
2629
3130
  }
2630
3131
  if (effectiveLayout === "sheet") {
2631
- 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: [
2632
3133
  /* @__PURE__ */ jsxs(SheetHeader, { children: [
2633
3134
  /* @__PURE__ */ jsx(SheetTitle, { children: formTitle }),
2634
3135
  /* @__PURE__ */ jsx(SheetDescription, { className: "sr-only", children: formDescription })
@@ -2642,11 +3143,86 @@ function ResourceForm(props) {
2642
3143
  /* @__PURE__ */ jsx(CardContent, { children: /* @__PURE__ */ jsx(FormBody, { ...bodyProps }) })
2643
3144
  ] });
2644
3145
  }
3146
+ function ConfirmDeleteDialog({
3147
+ open,
3148
+ onOpenChange,
3149
+ onConfirm,
3150
+ title = "Delete this record?",
3151
+ kind = "permanent",
3152
+ description = kind === "recycle" ? "It moves to the recycle bin, where it can be restored." : "This can't be undone.",
3153
+ confirmLabel = "Delete",
3154
+ cancelLabel = "Cancel"
3155
+ }) {
3156
+ const [pending, setPending] = useState(false);
3157
+ const [error, setError] = useState(null);
3158
+ useEffect(() => {
3159
+ if (open) setError(null);
3160
+ }, [open]);
3161
+ const confirm = async () => {
3162
+ setPending(true);
3163
+ setError(null);
3164
+ try {
3165
+ await onConfirm();
3166
+ onOpenChange(false);
3167
+ } catch (err) {
3168
+ setError(err instanceof Error ? err.message : String(err));
3169
+ } finally {
3170
+ setPending(false);
3171
+ }
3172
+ };
3173
+ return /* @__PURE__ */ jsx(
3174
+ AlertDialog,
3175
+ {
3176
+ open,
3177
+ onOpenChange: (next) => {
3178
+ if (!pending) onOpenChange(next);
3179
+ },
3180
+ children: /* @__PURE__ */ jsxs(AlertDialogContent, { "data-testid": "confirm-delete-dialog", children: [
3181
+ /* @__PURE__ */ jsxs(AlertDialogHeader, { children: [
3182
+ /* @__PURE__ */ jsx(AlertDialogTitle, { children: title }),
3183
+ /* @__PURE__ */ jsx(AlertDialogDescription, { children: description })
3184
+ ] }),
3185
+ error ? /* @__PURE__ */ jsxs(
3186
+ "div",
3187
+ {
3188
+ role: "alert",
3189
+ "data-testid": "confirm-delete-error",
3190
+ className: "flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive",
3191
+ children: [
3192
+ /* @__PURE__ */ jsx(AlertTriangle, { className: "mt-0.5 h-4 w-4 shrink-0", "aria-hidden": "true" }),
3193
+ /* @__PURE__ */ jsx("span", { children: error })
3194
+ ]
3195
+ }
3196
+ ) : null,
3197
+ /* @__PURE__ */ jsxs(AlertDialogFooter, { children: [
3198
+ /* @__PURE__ */ jsx(AlertDialogCancel, { disabled: pending, "data-testid": "confirm-delete-cancel", children: cancelLabel }),
3199
+ /* @__PURE__ */ jsxs(
3200
+ AlertDialogAction,
3201
+ {
3202
+ disabled: pending,
3203
+ "data-testid": "confirm-delete-confirm",
3204
+ onClick: (e) => {
3205
+ e.preventDefault();
3206
+ void confirm();
3207
+ },
3208
+ children: [
3209
+ pending ? /* @__PURE__ */ jsx(Spinner, { size: "sm", className: "mr-2" }) : null,
3210
+ confirmLabel
3211
+ ]
3212
+ }
3213
+ )
3214
+ ] })
3215
+ ] })
3216
+ }
3217
+ );
3218
+ }
2645
3219
  function ResourcePage(props) {
2646
3220
  const {
2647
3221
  resource: resourceName,
2648
3222
  data: dataProp,
2649
3223
  total: totalProp,
3224
+ pagination = "client",
3225
+ clientRowLimit = 1e3,
2650
3226
  title,
2651
3227
  subtitle,
2652
3228
  icon,
@@ -2656,6 +3232,8 @@ function ResourcePage(props) {
2656
3232
  detailWidth = "md",
2657
3233
  titleField: titleFieldProp,
2658
3234
  subtitleField: subtitleFieldProp,
3235
+ getTitle,
3236
+ getSubtitle,
2659
3237
  renderDetail: renderDetailProp,
2660
3238
  detailFields,
2661
3239
  statusFilter,
@@ -2689,7 +3267,14 @@ function ResourcePage(props) {
2689
3267
  onCreate,
2690
3268
  onEdit,
2691
3269
  onDelete,
3270
+ can,
3271
+ canEditItem,
2692
3272
  createLabel = "New",
3273
+ canEdit,
3274
+ canDelete,
3275
+ allowDelete,
3276
+ confirmDelete,
3277
+ onDeleted,
2693
3278
  secondaryActions,
2694
3279
  allowCreate,
2695
3280
  allowEdit,
@@ -2698,15 +3283,22 @@ function ResourcePage(props) {
2698
3283
  formMode,
2699
3284
  formSteps,
2700
3285
  formLayout: formLayoutProp,
3286
+ mutationMode: mutationModeProp,
2701
3287
  bulkActions,
2702
3288
  renderCard: renderCardProp,
3289
+ openOnCardClick = false,
2703
3290
  renderTable: renderTableProp,
2704
3291
  gridCols: gridColsProp,
2705
3292
  keyboardNavigation = true,
2706
3293
  capabilities,
2707
3294
  toolbar,
2708
3295
  emptyState,
3296
+ emptyIcon,
3297
+ emptyMessage,
3298
+ emptyDescription,
3299
+ header: headerMode = "page",
2709
3300
  resourceOptions,
3301
+ urlState: urlStateProp,
2710
3302
  defaultView = "table",
2711
3303
  onNavigate = defaultNavigate,
2712
3304
  viewSettingsKey: viewSettingsKeyProp,
@@ -2715,14 +3307,46 @@ function ResourcePage(props) {
2715
3307
  className
2716
3308
  } = props;
2717
3309
  const isClientMode = !!resourceName;
2718
- const isServerControlled = pageProp !== void 0 || perPageProp !== void 0 || onPageChange !== void 0 || onPerPageChange !== void 0 || onSortChange !== void 0 || onSearchChange !== void 0;
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}` : "";
3323
+ const isServerControlled = resourceServerPaging || pageProp !== void 0 || perPageProp !== void 0 || onPageChange !== void 0 || onPerPageChange !== void 0 || onSortChange !== void 0 || onSearchChange !== void 0;
2719
3324
  const viewSettingsKey = viewSettingsKeyProp ?? resourceName ?? title.toLowerCase().replace(/\s+/g, "-");
2720
3325
  const { settings, updateSetting } = useViewSettings(viewSettingsKey);
2721
- const effectivePageSize = perPageProp ?? settings.pageSize;
3326
+ const serverSearchFields = resourceOptions?.searchFields ?? searchFields;
2722
3327
  const resourceHook = useResource(
2723
3328
  resourceName ?? "__noop__",
2724
- isClientMode ? resourceOptions : { enabled: false }
3329
+ isClientMode ? {
3330
+ ...resourceOptions,
3331
+ // Client paging fetches everything it can in one request; server
3332
+ // paging starts at the user's stored page size. An explicit
3333
+ // `defaultPerPage` wins over both.
3334
+ defaultPerPage: resourceOptions?.defaultPerPage ?? (resourceServerPaging ? perPageProp ?? settings.pageSize : clientRowLimit),
3335
+ searchFields: resourceServerPaging ? serverSearchFields : resourceOptions?.searchFields,
3336
+ // Paging, searching and sorting change the query key; keep the
3337
+ // current page on screen while the next one loads.
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)
3347
+ } : { enabled: false }
2725
3348
  );
3349
+ const effectivePageSize = resourceServerPaging ? resourceHook.params.pagination?.perPage ?? settings.pageSize : perPageProp ?? settings.pageSize;
2726
3350
  const displayData = isClientMode ? resourceHook.list.data?.data ?? [] : dataProp ?? [];
2727
3351
  const displayTotal = isClientMode ? resourceHook.list.data?.total ?? 0 : totalProp ?? 0;
2728
3352
  const isLoading = isClientMode ? resourceHook.list.isLoading : loadingProp ?? false;
@@ -2734,14 +3358,80 @@ function ResourcePage(props) {
2734
3358
  columns: columnsProp,
2735
3359
  statusFilter,
2736
3360
  searchFields,
2737
- 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),
2738
3365
  onFiltersChange,
2739
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,
2740
3370
  detail,
2741
3371
  titleField: titleFieldProp,
2742
3372
  subtitleField: subtitleFieldProp,
2743
3373
  controlled: isServerControlled
2744
3374
  });
3375
+ const {
3376
+ setSearch: setResourceSearch,
3377
+ setSort: setResourceSort,
3378
+ setFilters: setResourceFilters
3379
+ } = resourceHook;
3380
+ const pushedSearchRef = useRef(resourceServerPaging ? urlSeed?.q ?? "" : "");
3381
+ const searchEffectRanRef = useRef(false);
3382
+ useEffect(() => {
3383
+ if (!resourceServerPaging) return;
3384
+ const firstRun = !searchEffectRanRef.current;
3385
+ searchEffectRanRef.current = true;
3386
+ if (page.search === pushedSearchRef.current) return;
3387
+ const push = () => {
3388
+ pushedSearchRef.current = page.search;
3389
+ setResourceSearch(page.search);
3390
+ };
3391
+ if (firstRun || page.search === "") {
3392
+ push();
3393
+ return;
3394
+ }
3395
+ const timer = setTimeout(push, 300);
3396
+ return () => clearTimeout(timer);
3397
+ }, [resourceServerPaging, page.search, setResourceSearch]);
3398
+ const defaultSort = resourceOptions?.defaultSort;
3399
+ const pushedSortRef = useRef(resourceServerPaging ? urlSortKey : "");
3400
+ useEffect(() => {
3401
+ if (!resourceServerPaging) return;
3402
+ const key = page.sort ? `${page.sort.field}:${page.sort.order}` : "";
3403
+ if (key === pushedSortRef.current) return;
3404
+ pushedSortRef.current = key;
3405
+ setResourceSort(page.sort ?? defaultSort);
3406
+ }, [resourceServerPaging, page.sort, defaultSort, setResourceSort]);
3407
+ const statusField = statusFilter ? typeof statusFilter === "string" ? statusFilter : statusFilter.field : void 0;
3408
+ const defaultFilters = resourceOptions?.defaultFilters;
3409
+ const pushedStatusRef = useRef("");
3410
+ useEffect(() => {
3411
+ if (!resourceServerPaging || !statusField) return;
3412
+ const key = JSON.stringify([page.statusValue, defaultFilters ?? null]);
3413
+ if (key === pushedStatusRef.current) return;
3414
+ pushedStatusRef.current = key;
3415
+ setResourceFilters([
3416
+ ...defaultFilters ?? [],
3417
+ ...page.statusValue.length > 0 ? [{ field: statusField, operator: "in", value: page.statusValue }] : []
3418
+ ]);
3419
+ }, [
3420
+ resourceServerPaging,
3421
+ statusField,
3422
+ page.statusValue,
3423
+ defaultFilters,
3424
+ setResourceFilters
3425
+ ]);
3426
+ const clientTruncated = isClientMode && !resourceServerPaging && !isLoading && !hasError && displayTotal > displayData.length;
3427
+ const warnedTruncatedRef = useRef(false);
3428
+ useEffect(() => {
3429
+ if (!clientTruncated || warnedTruncatedRef.current) return;
3430
+ warnedTruncatedRef.current = true;
3431
+ console.warn(
3432
+ `ResourcePage: "${resourceName}" has ${displayTotal} rows but only ${displayData.length} were fetched. Pass \`pagination="server"\` to page through all of them, or raise \`clientRowLimit\`.`
3433
+ );
3434
+ }, [clientTruncated, resourceName, displayTotal, displayData.length]);
2745
3435
  const actions = useMemo(() => {
2746
3436
  if (isClientMode) {
2747
3437
  return {
@@ -2774,6 +3464,17 @@ function ResourcePage(props) {
2774
3464
  (item) => getKeyProp ? getKeyProp(item) : String(item.id ?? ""),
2775
3465
  [getKeyProp]
2776
3466
  );
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;
2777
3478
  const [selectedIds, setSelectedIds] = useState(/* @__PURE__ */ new Set());
2778
3479
  const handleToggleSelect = useCallback((id) => {
2779
3480
  setSelectedIds((prev) => {
@@ -2806,8 +3507,20 @@ function ResourcePage(props) {
2806
3507
  onCreate();
2807
3508
  }
2808
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
+ );
2809
3521
  const handleEdit = useCallback(
2810
3522
  (item) => {
3523
+ if (!allows("edit", item)) return;
2811
3524
  if (useBuiltInEdit) {
2812
3525
  setFormAction("edit");
2813
3526
  setFormItem(item);
@@ -2816,14 +3529,83 @@ function ResourcePage(props) {
2816
3529
  onEdit(item);
2817
3530
  }
2818
3531
  },
2819
- [useBuiltInEdit, onEdit]
3532
+ [useBuiltInEdit, onEdit, allows]
3533
+ );
3534
+ const closeDetail = useCallback(() => {
3535
+ page.setDetailOpen(false);
3536
+ page.setSelectedItem(null);
3537
+ urlPeek?.setDetailId(null);
3538
+ }, [page, urlPeek]);
3539
+ const useBuiltInDelete = !!allowDelete && !onDelete && isClientMode;
3540
+ const deleteConfirmed = confirmDelete === void 0 ? useBuiltInDelete : !!confirmDelete;
3541
+ const deleteCopy = typeof confirmDelete === "object" ? confirmDelete : void 0;
3542
+ const [pendingDelete, setPendingDelete] = useState(null);
3543
+ const [deleteOpen, setDeleteOpen] = useState(false);
3544
+ const { remove: removeRecord } = resourceHook;
3545
+ const { data: providerCaps } = useCapabilities(resourceName, {
3546
+ enabled: useBuiltInDelete && deleteConfirmed
3547
+ });
3548
+ const deleteKind = providerCaps?.softDelete ? "recycle" : "permanent";
3549
+ const performDelete = useCallback(
3550
+ async (item) => {
3551
+ if (useBuiltInDelete) {
3552
+ await removeRecord(
3553
+ getKeyProp ? keyOf(item) : item.id
3554
+ );
3555
+ } else if (onDelete) {
3556
+ await onDelete(item);
3557
+ }
3558
+ closeDetail();
3559
+ onDeleted?.(item);
3560
+ },
3561
+ [
3562
+ useBuiltInDelete,
3563
+ removeRecord,
3564
+ getKeyProp,
3565
+ keyOf,
3566
+ onDelete,
3567
+ closeDetail,
3568
+ onDeleted
3569
+ ]
2820
3570
  );
3571
+ const deleteHandler = !(useBuiltInDelete || onDelete) ? void 0 : deleteConfirmed ? (item) => {
3572
+ setPendingDelete(item);
3573
+ setDeleteOpen(true);
3574
+ } : useBuiltInDelete ? (item) => {
3575
+ performDelete(item).catch((err) => {
3576
+ if (!resourceOptions?.onError) {
3577
+ toast.error(err instanceof Error ? err.message : "The delete failed.");
3578
+ }
3579
+ });
3580
+ } : (
3581
+ // Unconfirmed `onDelete` keeps its original contract exactly:
3582
+ // called straight from the peek, nothing closed for it.
3583
+ (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
+ }
3596
+ }
3597
+ );
3598
+ const selectedEditable = !!selectedItem && allows("edit", selectedItem);
3599
+ const selectedDeletable = !!selectedItem && allows("delete", selectedItem);
2821
3600
  const tableColumns = useMemo(() => {
2822
3601
  const base = isClientMode ? page.columns.map((col) => ({
2823
3602
  key: col.key,
2824
3603
  header: col.header ?? col.key,
2825
3604
  sortable: col.sortable ?? true,
2826
3605
  hideBelow: col.hideBelow,
3606
+ width: col.width,
3607
+ minWidth: col.minWidth,
3608
+ defaultVisible: col.defaultVisible,
2827
3609
  editable: col.editable,
2828
3610
  accessor: col.accessor,
2829
3611
  render: col.render ? (item, value) => col.render(item, value, actions) : void 0
@@ -2837,19 +3619,49 @@ function ResourcePage(props) {
2837
3619
  page.tableColumns,
2838
3620
  actions
2839
3621
  ]);
2840
- const currentPage = pageProp ?? 1;
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
+ );
3639
+ const pagerPage = resourceServerPaging ? resourceHook.params.pagination?.page ?? 1 : pageProp;
3640
+ const pagerPerPage = resourceServerPaging ? resourceHook.params.pagination?.perPage : perPageProp;
3641
+ const pagerTotal = resourceServerPaging ? displayTotal : totalProp;
3642
+ const pagerOnPageChange = resourceServerPaging ? resourceHook.setPage : onPageChange;
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]);
2841
3652
  const handleSearchChange = useCallback(
2842
3653
  (value) => {
2843
3654
  page.filterConfig.onSearchChange(value);
2844
3655
  if (!isServerControlled) return;
2845
3656
  onSearchChange?.(value);
2846
- if (currentPage !== 1) onPageChange?.(1);
3657
+ if (currentPage !== 1 && !resourceServerPaging) onPageChange?.(1);
2847
3658
  },
2848
3659
  [
2849
3660
  page.filterConfig,
2850
3661
  isServerControlled,
2851
3662
  onSearchChange,
2852
3663
  onPageChange,
3664
+ resourceServerPaging,
2853
3665
  currentPage
2854
3666
  ]
2855
3667
  );
@@ -2861,24 +3673,43 @@ function ResourcePage(props) {
2861
3673
  onSortChange?.(
2862
3674
  field && (order === "asc" || order === "desc") ? { field, order } : null
2863
3675
  );
2864
- onPageChange?.(1);
3676
+ if (!resourceServerPaging) onPageChange?.(1);
2865
3677
  },
2866
- [page.filterConfig, isServerControlled, onSortChange, onPageChange]
3678
+ [
3679
+ page.filterConfig,
3680
+ isServerControlled,
3681
+ onSortChange,
3682
+ onPageChange,
3683
+ resourceServerPaging
3684
+ ]
2867
3685
  );
2868
3686
  const handleClearFilters = useCallback(() => {
2869
3687
  page.clearFilters();
2870
3688
  if (!isServerControlled) return;
2871
3689
  onSearchChange?.("");
2872
3690
  onSortChange?.(null);
2873
- onPageChange?.(1);
2874
- }, [page, isServerControlled, onSearchChange, onSortChange, onPageChange]);
3691
+ if (!resourceServerPaging) onPageChange?.(1);
3692
+ }, [
3693
+ page,
3694
+ isServerControlled,
3695
+ onSearchChange,
3696
+ onSortChange,
3697
+ onPageChange,
3698
+ resourceServerPaging
3699
+ ]);
2875
3700
  const handleStatusChange = useCallback(
2876
3701
  (values) => {
2877
3702
  page.filterConfig.statusChips?.onChange(values);
2878
3703
  if (!isServerControlled) return;
2879
- if (currentPage !== 1) onPageChange?.(1);
3704
+ if (currentPage !== 1 && !resourceServerPaging) onPageChange?.(1);
2880
3705
  },
2881
- [page.filterConfig, isServerControlled, onPageChange, currentPage]
3706
+ [
3707
+ page.filterConfig,
3708
+ isServerControlled,
3709
+ onPageChange,
3710
+ resourceServerPaging,
3711
+ currentPage
3712
+ ]
2882
3713
  );
2883
3714
  const statusChipsConfig = useMemo(() => {
2884
3715
  const base = page.filterConfig.statusChips;
@@ -2889,12 +3720,20 @@ function ResourcePage(props) {
2889
3720
  (size) => {
2890
3721
  updateSetting("pageSize", size);
2891
3722
  onPerPageChange?.(size);
2892
- if (isServerControlled) onPageChange?.(1);
3723
+ if (resourceServerPaging) resourceHook.setPerPage(size);
3724
+ else if (isServerControlled) onPageChange?.(1);
2893
3725
  },
2894
- [updateSetting, onPerPageChange, isServerControlled, onPageChange]
3726
+ [
3727
+ updateSetting,
3728
+ onPerPageChange,
3729
+ resourceServerPaging,
3730
+ resourceHook.setPerPage,
3731
+ isServerControlled,
3732
+ onPageChange
3733
+ ]
2895
3734
  );
2896
- const missingPerPage = isServerControlled && !!onPageChange && perPageProp === void 0;
2897
- const missingTotal = isServerControlled && !!onPageChange && totalProp === void 0;
3735
+ const missingPerPage = isServerControlled && !!pagerOnPageChange && pagerPerPage === void 0;
3736
+ const missingTotal = isServerControlled && !!pagerOnPageChange && pagerTotal === void 0;
2898
3737
  const pagerMisconfigured = missingPerPage || missingTotal;
2899
3738
  const missingPagerProps = [
2900
3739
  ...missingPerPage ? ["perPage"] : [],
@@ -2909,18 +3748,23 @@ function ResourcePage(props) {
2909
3748
  `ResourcePage: ${missingPagerProps.map((name) => `\`${name}\``).join(" and ")} ${missingPagerProps.length > 1 ? "are" : "is"} required when \`onPageChange\` is supplied.`
2910
3749
  );
2911
3750
  }, [pagerMisconfigured]);
2912
- const pagerPageSize = perPageProp ?? effectivePageSize;
3751
+ const pagerPageSize = pagerPerPage ?? effectivePageSize;
2913
3752
  const totalPages = Math.max(
2914
3753
  1,
2915
3754
  Math.ceil(displayTotal / Math.max(1, pagerPageSize))
2916
3755
  );
2917
3756
  const rangeFrom = page.data.length === 0 ? 0 : (currentPage - 1) * pagerPageSize + 1;
2918
3757
  const rangeTo = rangeFrom === 0 ? 0 : rangeFrom + page.data.length - 1;
2919
- const detailTitle = page.selectedItem ? String(
2920
- page.selectedItem[page.titleField] ?? ""
2921
- ) : "";
2922
- const detailSubtitle = page.selectedItem && page.subtitleField ? String(
2923
- page.selectedItem[page.subtitleField] ?? ""
3758
+ const { setPage: setResourcePage } = resourceHook;
3759
+ const settledPage = !isLoading && !hasError && !resourceHook.list.isPlaceholderData;
3760
+ useEffect(() => {
3761
+ if (!resourceServerPaging || !settledPage) return;
3762
+ if (currentPage > totalPages) setResourcePage(totalPages);
3763
+ }, [resourceServerPaging, settledPage, currentPage, totalPages, setResourcePage]);
3764
+ const titleOf = (item) => getTitle ? getTitle(item) : String(item[page.titleField] ?? "");
3765
+ const detailTitle = selectedItem ? titleOf(selectedItem) : "";
3766
+ const detailSubtitle = !selectedItem ? void 0 : getSubtitle ? getSubtitle(selectedItem) : page.subtitleField ? String(
3767
+ selectedItem[page.subtitleField] ?? ""
2924
3768
  ) : void 0;
2925
3769
  const handleRowClick = (item) => {
2926
3770
  if (detail === "route") {
@@ -2931,9 +3775,10 @@ function ResourcePage(props) {
2931
3775
  return;
2932
3776
  }
2933
3777
  page.handleRowClick(item);
3778
+ urlPeek?.setDetailId(keyOf(item));
2934
3779
  };
2935
3780
  const hasBulkSelect = !!bulkActions && bulkActions.length > 0;
2936
- const overlayOpen = page.detailOpen || formOpen;
3781
+ const overlayOpen = detailOpen || formOpen || deleteOpen;
2937
3782
  const keyboardNav = useKeyboardNavigation({
2938
3783
  itemCount: page.data.length,
2939
3784
  enabled: keyboardNavigation && page.viewMode !== "table",
@@ -2954,12 +3799,9 @@ function ResourcePage(props) {
2954
3799
  // every grid/list page (including detail="none" and detail="route",
2955
3800
  // which never open one). `detailOpen` is only ever true for the panel
2956
3801
  // and modal modes, so it is the precise precondition here.
2957
- onBack: page.detailOpen ? () => {
2958
- page.setDetailOpen(false);
2959
- page.setSelectedItem(null);
2960
- } : void 0
3802
+ onBack: detailOpen ? closeDetail : void 0
2961
3803
  });
2962
- const showCreateButton = onCreate || useBuiltInCreate;
3804
+ const showCreateButton = (onCreate || useBuiltInCreate) && allows("create");
2963
3805
  const createButton = showCreateButton ? /* @__PURE__ */ jsxs(Button, { size: "sm", onClick: handleCreate, children: [
2964
3806
  /* @__PURE__ */ jsx(Plus, { className: "h-4 w-4 mr-1" }),
2965
3807
  createLabel
@@ -3059,7 +3901,8 @@ function ResourcePage(props) {
3059
3901
  isLoading,
3060
3902
  hasError,
3061
3903
  isEmpty,
3062
- 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),
3063
3906
  onRetry: isClientMode ? () => resourceHook.list.refetch() : onRetryProp,
3064
3907
  viewMode: page.viewMode,
3065
3908
  onViewModeChange: page.setViewMode,
@@ -3072,6 +3915,10 @@ function ResourcePage(props) {
3072
3915
  hasActiveFilters: page.hasActiveFilters,
3073
3916
  onClearFilters: handleClearFilters,
3074
3917
  emptyAction: emptyState,
3918
+ emptyIcon,
3919
+ emptyMessage,
3920
+ emptyDescription,
3921
+ ...headerMode === "toolbar" ? { header: "toolbar" } : {},
3075
3922
  filters: renderSlot(
3076
3923
  DataFilters,
3077
3924
  slots?.filters,
@@ -3090,7 +3937,7 @@ function ResourcePage(props) {
3090
3937
  onDeleteView,
3091
3938
  canSaveViews,
3092
3939
  onClearAll: page.hasActiveFilters ? handleClearFilters : void 0,
3093
- showSearch: capabilities?.search !== false,
3940
+ showSearch: capabilities?.search !== false && !(resourceServerPaging && !serverSearchFields?.length),
3094
3941
  showSort: capabilities?.sort !== false
3095
3942
  },
3096
3943
  slotProps?.filters
@@ -3150,18 +3997,42 @@ function ResourcePage(props) {
3150
3997
  resizeKey,
3151
3998
  stickyHeader,
3152
3999
  stickyFirstColumn,
4000
+ keyboardNavigation,
3153
4001
  // The server already cut the page window; DataTable's own
3154
4002
  // pagination would slice the slice.
3155
- 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 }
3156
4007
  },
3157
4008
  slotProps?.table
3158
4009
  ),
4010
+ ...openOnCardClick && renderCardProp && detail !== "none" ? {
4011
+ onItemClick: handleRowClick,
4012
+ getItemLabel: (item) => `Open ${titleOf(item)}`.trim()
4013
+ } : {},
3159
4014
  renderCard: (item, index) => renderCardProp ? renderCardProp(item, index, cardActionsFor(item)) : renderDefaultCard(item),
3160
4015
  renderListItem: (item, index) => renderCardProp ? renderCardProp(item, index, cardActionsFor(item)) : renderDefaultListItem(item)
3161
4016
  },
3162
4017
  slotProps?.grid
3163
4018
  ),
3164
- isServerControlled && onPageChange && pagerMisconfigured && /* @__PURE__ */ jsxs(
4019
+ clientTruncated && /* @__PURE__ */ jsxs(
4020
+ "p",
4021
+ {
4022
+ role: "status",
4023
+ className: "px-3 py-2 text-sm text-muted-foreground",
4024
+ "data-testid": "resource-page-truncated",
4025
+ children: [
4026
+ "Showing the first ",
4027
+ displayData.length.toLocaleString(),
4028
+ " of",
4029
+ " ",
4030
+ displayTotal.toLocaleString(),
4031
+ " records."
4032
+ ]
4033
+ }
4034
+ ),
4035
+ isServerControlled && pagerOnPageChange && pagerMisconfigured && /* @__PURE__ */ jsxs(
3165
4036
  "div",
3166
4037
  {
3167
4038
  className: "flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive",
@@ -3186,10 +4057,11 @@ function ResourcePage(props) {
3186
4057
  ]
3187
4058
  }
3188
4059
  ),
3189
- isServerControlled && onPageChange && !pagerMisconfigured && /* @__PURE__ */ jsxs(
4060
+ isServerControlled && pagerOnPageChange && !pagerMisconfigured && /* @__PURE__ */ jsxs(
3190
4061
  "nav",
3191
4062
  {
3192
4063
  "aria-label": "Pagination",
4064
+ "aria-busy": resourceServerPaging && resourceHook.list.isPlaceholderData || void 0,
3193
4065
  className: "flex flex-col sm:flex-row items-center justify-between gap-2 px-3 py-2 text-sm",
3194
4066
  "data-testid": "resource-page-pagination",
3195
4067
  children: [
@@ -3209,7 +4081,7 @@ function ResourcePage(props) {
3209
4081
  variant: "outline",
3210
4082
  size: "sm",
3211
4083
  disabled: currentPage <= 1,
3212
- onClick: () => onPageChange(currentPage - 1),
4084
+ onClick: () => pagerOnPageChange(currentPage - 1),
3213
4085
  testID: "resource-page-prev",
3214
4086
  children: [
3215
4087
  /* @__PURE__ */ jsx(ChevronLeft, { className: "h-4 w-4 mr-1", "aria-hidden": "true" }),
@@ -3236,7 +4108,7 @@ function ResourcePage(props) {
3236
4108
  variant: "outline",
3237
4109
  size: "sm",
3238
4110
  disabled: currentPage >= totalPages,
3239
- onClick: () => onPageChange(currentPage + 1),
4111
+ onClick: () => pagerOnPageChange(currentPage + 1),
3240
4112
  testID: "resource-page-next",
3241
4113
  children: [
3242
4114
  "Next",
@@ -3251,17 +4123,23 @@ function ResourcePage(props) {
3251
4123
  (detail === "panel" || detail === "modal") && renderSlot(
3252
4124
  DetailPanel,
3253
4125
  slots?.detail,
3254
- {
3255
- item: page.selectedItem,
3256
- open: page.detailOpen,
3257
- onClose: () => {
3258
- page.setDetailOpen(false);
3259
- page.setSelectedItem(null);
3260
- },
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
+ } : {
4136
+ item: selectedItem,
4137
+ open: detailOpen,
4138
+ onClose: closeDetail,
3261
4139
  title: detailTitle,
3262
4140
  subtitle: detailSubtitle,
3263
- onEdit: onEdit ?? (useBuiltInEdit ? handleEdit : void 0),
3264
- onDelete,
4141
+ onEdit: selectedEditable ? onEdit ?? (useBuiltInEdit ? handleEdit : void 0) : void 0,
4142
+ onDelete: selectedDeletable ? deleteHandler : void 0,
3265
4143
  fields: detailFields,
3266
4144
  width: detailWidth,
3267
4145
  layout: detail === "modal" ? "dialog" : "sheet",
@@ -3278,6 +4156,7 @@ function ResourcePage(props) {
3278
4156
  layout: formLayoutProp ?? (formAction === "edit" ? "sheet" : "dialog"),
3279
4157
  mode: formMode,
3280
4158
  steps: formSteps,
4159
+ mutationMode: mutationModeProp,
3281
4160
  item: formAction === "edit" ? formItem ?? void 0 : page.data.length > 0 ? Object.fromEntries(
3282
4161
  Object.keys(page.data[0]).filter((k) => k !== "id").map((k) => [k, ""])
3283
4162
  ) : void 0,
@@ -3287,11 +4166,25 @@ function ResourcePage(props) {
3287
4166
  listData: page.data,
3288
4167
  renderForm: renderFormProp,
3289
4168
  onSuccess: () => {
3290
- page.setDetailOpen(false);
3291
- page.setSelectedItem(null);
4169
+ if (formAction === "create") closeDetail();
3292
4170
  }
3293
4171
  },
3294
4172
  slotProps?.form
4173
+ ),
4174
+ deleteConfirmed && (useBuiltInDelete || !!onDelete) && renderSlot(
4175
+ ConfirmDeleteDialog,
4176
+ slots?.deleteDialog,
4177
+ {
4178
+ open: deleteOpen,
4179
+ onOpenChange: setDeleteOpen,
4180
+ onConfirm: () => pendingDelete ? performDelete(pendingDelete) : void 0,
4181
+ kind: deleteKind,
4182
+ title: typeof deleteCopy?.title === "function" ? pendingDelete ? deleteCopy.title(pendingDelete) : void 0 : deleteCopy?.title,
4183
+ description: typeof deleteCopy?.description === "function" ? pendingDelete ? deleteCopy.description(pendingDelete) : void 0 : deleteCopy?.description,
4184
+ confirmLabel: deleteCopy?.confirmLabel,
4185
+ cancelLabel: deleteCopy?.cancelLabel
4186
+ },
4187
+ slotProps?.deleteDialog
3295
4188
  )
3296
4189
  ]
3297
4190
  }
@@ -3371,6 +4264,6 @@ function ResourceDetailPage({
3371
4264
  var AppShell2 = AppShell;
3372
4265
  var PageHeader2 = PageHeader;
3373
4266
 
3374
- export { AppShell2 as AppShell, BadgeCell, CurrencyCell, DataBulkBar, DataFilters, DataGrid, DataList, DataPageShell, DataTable, DateCell, DetailPanel, FieldArrayRenderer, FileUploadField, FilterCombobox, KanbanBoard, LinkCell, NumberCell, PageHeader2 as PageHeader, RelationField, ResourceDetailPage, ResourceForm, ResourcePage, StatCard, ThumbnailCell, WizardForm };
4267
+ export { AppShell2 as AppShell, BadgeCell, ConfirmDeleteDialog, CurrencyCell, DataBulkBar, DataFilters, DataGrid, DataList, DataPageShell, DataTable, DateCell, DetailPanel, FieldArrayRenderer, FileUploadField, FilterCombobox, KanbanBoard, LinkCell, NumberCell, PageHeader2 as PageHeader, RelationField, ResourceDetailPage, ResourceForm, ResourcePage, StatCard, ThumbnailCell, WizardForm };
3375
4268
  //# sourceMappingURL=index.js.map
3376
4269
  //# sourceMappingURL=index.js.map