@marktiderman/genesis-ui 2.9.0 → 2.10.1

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.
@@ -7,13 +7,13 @@ 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';
11
10
  import { Progress } from '../../chunk-W5Y3LG5K.js';
12
11
  import { Checkbox } from '../../chunk-SEBU6JCK.js';
13
12
  import { Card, CardContent, CardHeader, CardTitle } from '../../chunk-FN3FZVUR.js';
14
13
  import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction } from '../../chunk-4C4ESW6O.js';
15
14
  import { PageLoading } from '../../chunk-YRYQXU3G.js';
16
15
  import { Spinner } from '../../chunk-OVU3Z2MT.js';
16
+ import { useAnnouncer } from '../../chunk-I4NBE2KL.js';
17
17
  import { Input } from '../../chunk-QSXVL5UB.js';
18
18
  import '../../chunk-B2GIBUQD.js';
19
19
  import { Badge } from '../../chunk-NPNADZHM.js';
@@ -29,27 +29,30 @@ export { EmptyState } from '../../chunk-65SPT7EA.js';
29
29
  import { Button } from '../../chunk-NWSCYLPT.js';
30
30
  import { FormField, FormLabel, FormError, FormDescription } from '../../chunk-R7QFP6EL.js';
31
31
  import '../../chunk-F47U5KNW.js';
32
- import { titleCase, useResourcePage } from '../../chunk-RKPS7ERS.js';
33
- import { useResourceUrlState } from '../../chunk-DIV4BYXX.js';
32
+ import { showUndoToast, showWriteErrorToast, useResourceTableModel } from '../../chunk-C7PQ54JI.js';
33
+ export { showUndoToast, showWriteErrorToast, useResourceTable } from '../../chunk-C7PQ54JI.js';
34
+ import { titleCase } from '../../chunk-RKPS7ERS.js';
35
+ import '../../chunk-DIV4BYXX.js';
34
36
  export { useSavedViews } from '../../chunk-GCQFVMPI.js';
35
37
  import '../../chunk-PKFDGGZV.js';
36
- import { useViewSettings } from '../../chunk-OZ7ZAYCS.js';
38
+ import '../../chunk-OZ7ZAYCS.js';
39
+ import '../../chunk-4RAZJ6LL.js';
37
40
  export { CommandPalette, useCommandPaletteHotkey } from '../../chunk-CNTWQSUT.js';
38
41
  import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandSeparator } from '../../chunk-SEFK4NZV.js';
39
42
  import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '../../chunk-7XMZVISM.js';
40
43
  import { cn } from '../../chunk-NZ5WBA4R.js';
41
- import { DefaultLink, defaultNavigate } from '../../chunk-WGMEVX3Y.js';
44
+ import { DefaultLink } from '../../chunk-WGMEVX3Y.js';
42
45
  export { DefaultLink, defaultNavigate, isPathActive, resolveNavigation } from '../../chunk-WGMEVX3Y.js';
43
- import { useKeyboardNavigation } from '../../chunk-YJ6MPS6U.js';
46
+ import '../../chunk-YJ6MPS6U.js';
44
47
  import { useIsMobile } from '../../chunk-VFO3I7PV.js';
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';
48
+ import { AlertTriangle, RefreshCw, ChevronDown, Check, Search, X, ArrowUpDown, Bookmark, Trash2, BookmarkPlus, Loader2, Columns3, ArrowUp, ArrowDown, ChevronLeft, ChevronRight, ExternalLink, GripVertical, ArrowRightLeft, Pencil, ChevronsUpDown, ImageIcon, Upload, AlertCircle, FileIcon, Plus } from 'lucide-react';
46
49
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
47
- import { useState, useRef, useEffect, useCallback, useMemo, useId, Fragment as Fragment$1 } from 'react';
50
+ import { useState, useRef, useEffect, useCallback, useMemo, useId, Fragment as Fragment$1, useLayoutEffect } from 'react';
48
51
  import { useReactTable, getPaginationRowModel, getSortedRowModel, getCoreRowModel } from '@tanstack/react-table';
49
52
  import { useVirtualizer } from '@tanstack/react-virtual';
50
53
  import { formatDistanceToNow, format } from 'date-fns';
51
54
  import { useFieldArray, Controller, useWatch } from 'react-hook-form';
52
- import { useResource, useWizard, useResourceForm, useOne, useCapabilities, buildArrayItemDefaults } from '@marktiderman/genesis-core/hooks';
55
+ import { useResource, useWizard, useResourceForm, useOne, buildArrayItemDefaults } from '@marktiderman/genesis-core/hooks';
53
56
  import { useDataProvider, isDataReadError } from '@marktiderman/genesis-core/provider';
54
57
  import { useDropzone } from 'react-dropzone';
55
58
 
@@ -139,29 +142,43 @@ function DataPageShell({
139
142
  /* @__PURE__ */ jsx(Header, { ...sharedHeaderProps, options: baseHeaderOptions }),
140
143
  filters
141
144
  ] }),
142
- isLoading ? /* @__PURE__ */ jsx(
143
- "div",
144
- {
145
- "data-testid": "page-loader",
146
- className: "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4",
147
- children: Array.from({ length: 8 }).map((_, i) => /* @__PURE__ */ jsx(
145
+ isLoading ? (
146
+ // Shaped like the view it stands in for, so a list inside a panel or
147
+ // tab doesn't reserve a page of card placeholders.
148
+ /* @__PURE__ */ jsxs("div", { "data-testid": "page-loader", role: "status", "aria-busy": "true", children: [
149
+ /* @__PURE__ */ jsx("span", { className: "sr-only", children: "Loading\u2026" }),
150
+ viewMode === "grid" ? /* @__PURE__ */ jsx("div", { className: "grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3", children: Array.from({ length: 6 }).map((_, i) => /* @__PURE__ */ jsx(
148
151
  Skeleton,
149
152
  {
150
- className: "h-40 rounded-xl",
153
+ className: "h-32 rounded-xl",
151
154
  style: { animationDelay: `${i * 75}ms` }
152
155
  },
153
156
  i
154
- ))
155
- }
156
- ) : hasError ? /* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center justify-center py-16 text-center", children: [
157
- /* @__PURE__ */ jsx(AlertTriangle, { className: "h-10 w-10 text-destructive mb-3" }),
158
- /* @__PURE__ */ jsx("p", { className: "mb-1 text-sm font-medium text-foreground", children: "Something went wrong" }),
159
- /* @__PURE__ */ jsx("p", { className: "mb-4 text-sm text-muted-foreground", children: errorMessage2 || `Failed to load ${entity}s. Please try again.` }),
160
- onRetry && /* @__PURE__ */ jsxs(Button, { variant: "outline", size: "sm", onClick: onRetry, children: [
161
- /* @__PURE__ */ jsx(RefreshCw, { className: "mr-1 h-4 w-4" }),
162
- " Retry"
157
+ )) }) : /* @__PURE__ */ jsx("div", { className: "flex flex-col gap-2", children: Array.from({ length: 5 }).map((_, i) => /* @__PURE__ */ jsx(
158
+ Skeleton,
159
+ {
160
+ className: "h-10 rounded-md",
161
+ style: { animationDelay: `${i * 75}ms` }
162
+ },
163
+ i
164
+ )) })
163
165
  ] })
164
- ] }) : isEmpty ? /* @__PURE__ */ jsx(
166
+ ) : hasError ? /* @__PURE__ */ jsxs(
167
+ "div",
168
+ {
169
+ role: "alert",
170
+ className: "flex flex-col items-center justify-center py-16 text-center",
171
+ children: [
172
+ /* @__PURE__ */ jsx(AlertTriangle, { className: "h-10 w-10 text-destructive mb-3", "aria-hidden": true }),
173
+ /* @__PURE__ */ jsx("p", { className: "mb-1 text-sm font-medium text-foreground", children: "Something went wrong" }),
174
+ /* @__PURE__ */ jsx("p", { className: "mb-4 text-sm text-muted-foreground", children: errorMessage2 || `Failed to load ${entity}s. Please try again.` }),
175
+ onRetry && /* @__PURE__ */ jsxs(Button, { variant: "outline", size: "sm", onClick: onRetry, children: [
176
+ /* @__PURE__ */ jsx(RefreshCw, { className: "mr-1 h-4 w-4", "aria-hidden": true }),
177
+ " Retry"
178
+ ] })
179
+ ]
180
+ }
181
+ ) : isEmpty ? /* @__PURE__ */ jsx(
165
182
  EmptyState,
166
183
  {
167
184
  icon: emptyIcon,
@@ -1140,8 +1157,8 @@ function DataTable({
1140
1157
  setCurrentPage(0);
1141
1158
  }, [items.length, currentPageSize]);
1142
1159
  const sorting = useMemo(
1143
- () => sorts.map((s) => ({ id: s.key, desc: s.dir === "desc" })),
1144
- [sorts]
1160
+ () => sorts.filter((s) => columns.some((c) => c.key === s.key)).map((s) => ({ id: s.key, desc: s.dir === "desc" })),
1161
+ [sorts, columns]
1145
1162
  );
1146
1163
  const tanstackColumns = useMemo(
1147
1164
  () => columns.map((col) => ({
@@ -1195,7 +1212,11 @@ function DataTable({
1195
1212
  requestAnimationFrame(() => focusRowAt(bodyRef.current, target));
1196
1213
  };
1197
1214
  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;
1215
+ const renderedItems = virtualRows ? virtualRows.map((vr) => displayItems[vr.index]).filter(Boolean) : displayItems;
1216
+ const scrollTop = rowVirtualizer.scrollOffset ?? 0;
1217
+ const firstInView = virtualRows ? virtualRows.find((vr) => vr.start >= scrollTop) ?? virtualRows.find((vr) => vr.end > scrollTop) ?? virtualRows[0] : void 0;
1218
+ const fallbackItem = firstInView ? displayItems[firstInView.index] : renderedItems[0];
1219
+ const tabStopKey = activeRowKey && renderedItems.some((it) => getKey(it) === activeRowKey) ? activeRowKey : fallbackItem ? getKey(fallbackItem) : null;
1199
1220
  const rowLabel = (item, id) => {
1200
1221
  const col = activeColumns.find((c) => c.key === (openKey ?? activeColumns[0]?.key));
1201
1222
  const value = col ? (col.accessor ?? ((it) => it[col.key]))(item) : void 0;
@@ -1789,6 +1810,7 @@ var statusColorMap = {
1789
1810
  primary: { color: "text-primary", bgColor: "bg-primary/15" }
1790
1811
  };
1791
1812
  var DRAG_TYPE = "application/x-genesis-kanban-item";
1813
+ var useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
1792
1814
  function KanbanBoard({
1793
1815
  columns,
1794
1816
  renderCard,
@@ -1801,112 +1823,301 @@ function KanbanBoard({
1801
1823
  }) {
1802
1824
  const [dragging, setDragging] = useState(null);
1803
1825
  const [over, setOver] = useState(null);
1826
+ const [grabbed, setGrabbed] = useState(null);
1827
+ const { message: announcement, announce } = useAnnouncer();
1828
+ const handles = useRef(/* @__PURE__ */ new Map());
1829
+ const refocus = useRef(null);
1830
+ const instructionsId = useId();
1804
1831
  const keyOf = (item, index) => getKey ? getKey(item) : String(index);
1805
- const move = (item, to) => {
1806
- void Promise.resolve(onMoveItem?.(item, to)).catch(() => {
1807
- });
1832
+ const movable = Boolean(onMoveItem && getKey);
1833
+ const find = (key) => {
1834
+ if (!getKey) return null;
1835
+ for (const column of columns) {
1836
+ const item = column.items.find((i) => getKey(i) === key);
1837
+ if (item !== void 0) return { item, column };
1838
+ }
1839
+ return null;
1840
+ };
1841
+ const labelOf = (key) => {
1842
+ const found = find(key);
1843
+ return found && getItemLabel ? getItemLabel(found.item) : "Card";
1844
+ };
1845
+ const columnLabel = (key) => columns.find((c) => c.key === key)?.label ?? key;
1846
+ const move = (item, to, name) => {
1847
+ let result;
1848
+ try {
1849
+ result = onMoveItem?.(item, to);
1850
+ } catch (error) {
1851
+ result = Promise.reject(error);
1852
+ }
1853
+ void Promise.resolve(result).then(
1854
+ () => announce(`${name} moved to ${columnLabel(to)}.`),
1855
+ () => announce(`Couldn\u2019t move ${name}.`)
1856
+ );
1857
+ };
1858
+ const moveKey = (key, to) => {
1859
+ const found = find(key);
1860
+ if (!found || found.column.key === to) return;
1861
+ move(found.item, to, labelOf(key));
1808
1862
  };
1809
1863
  const onDrop = (event, to) => {
1810
1864
  event.preventDefault();
1811
1865
  setOver(null);
1812
1866
  const key = event.dataTransfer.getData(DRAG_TYPE) || dragging?.key;
1813
1867
  setDragging(null);
1814
- if (!key || !getKey) return;
1815
- for (const column of columns) {
1816
- const item = column.items.find((i) => getKey(i) === key);
1817
- if (item) {
1818
- if (column.key !== to) move(item, to);
1819
- return;
1868
+ if (key) moveKey(key, to);
1869
+ };
1870
+ const pickUp = (key, from) => {
1871
+ setGrabbed({ key, from, target: from });
1872
+ announce(
1873
+ `${labelOf(key)} picked up in ${columnLabel(from)}. Use the left and right arrow keys to choose a column, Space to drop, Escape to cancel.`
1874
+ );
1875
+ };
1876
+ const aim = (index) => {
1877
+ if (!grabbed) return;
1878
+ const column = columns[Math.max(0, Math.min(columns.length - 1, index))];
1879
+ if (!column || column.key === grabbed.target) return;
1880
+ setGrabbed({ ...grabbed, target: column.key });
1881
+ announce(`Over ${column.label}, column ${columns.indexOf(column) + 1} of ${columns.length}.`);
1882
+ };
1883
+ const drop = () => {
1884
+ if (!grabbed) return;
1885
+ setGrabbed(null);
1886
+ if (grabbed.target === grabbed.from) {
1887
+ announce(`${labelOf(grabbed.key)} dropped back in ${columnLabel(grabbed.from)}.`);
1888
+ return;
1889
+ }
1890
+ refocus.current = grabbed.key;
1891
+ moveKey(grabbed.key, grabbed.target);
1892
+ };
1893
+ const cancel = () => {
1894
+ if (!grabbed) return;
1895
+ setGrabbed(null);
1896
+ announce(`Move cancelled. ${labelOf(grabbed.key)} stays in ${columnLabel(grabbed.from)}.`);
1897
+ };
1898
+ useEffect(() => {
1899
+ if (!grabbed) return;
1900
+ const onKey = (event) => {
1901
+ if (event.key !== "Escape") return;
1902
+ event.preventDefault();
1903
+ event.stopPropagation();
1904
+ cancel();
1905
+ };
1906
+ window.addEventListener("keydown", onKey, true);
1907
+ return () => window.removeEventListener("keydown", onKey, true);
1908
+ });
1909
+ useEffect(() => {
1910
+ if (grabbed && (!movable || !find(grabbed.key))) setGrabbed(null);
1911
+ if (dragging && (!movable || !find(dragging.key))) {
1912
+ setDragging(null);
1913
+ setOver(null);
1914
+ }
1915
+ });
1916
+ useIsomorphicLayoutEffect(() => {
1917
+ const key = refocus.current;
1918
+ if (!key) return;
1919
+ const handle = handles.current.get(key);
1920
+ if (!handle) return;
1921
+ if (handle.isConnected && document.activeElement === handle) {
1922
+ refocus.current = null;
1923
+ return;
1924
+ }
1925
+ const active = document.activeElement;
1926
+ if (active === null || active === document.body) {
1927
+ handle.focus();
1928
+ refocus.current = null;
1929
+ }
1930
+ });
1931
+ const onHandleKeyDown = (event, key, from) => {
1932
+ const delta = event.key === "ArrowLeft" ? -1 : event.key === "ArrowRight" ? 1 : 0;
1933
+ if (grabbed?.key === key) {
1934
+ const index = columns.findIndex((c) => c.key === grabbed.target);
1935
+ if (delta) {
1936
+ event.preventDefault();
1937
+ aim(index + delta);
1938
+ } else if (event.key === "Home" || event.key === "End") {
1939
+ event.preventDefault();
1940
+ aim(event.key === "Home" ? 0 : columns.length - 1);
1941
+ }
1942
+ return;
1943
+ }
1944
+ if (delta && event.altKey && !grabbed) {
1945
+ event.preventDefault();
1946
+ const to = columns[columns.findIndex((c) => c.key === from) + delta];
1947
+ if (to) {
1948
+ refocus.current = key;
1949
+ moveKey(key, to.key);
1820
1950
  }
1821
1951
  }
1822
1952
  };
1823
- const movable = Boolean(onMoveItem && getKey);
1824
- return /* @__PURE__ */ jsx(
1825
- "div",
1826
- {
1827
- className: cn(
1828
- "grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4",
1829
- className
1830
- ),
1831
- children: columns.map((column) => {
1832
- const resolved = column.status ? statusColorMap[column.status] : { color: column.color ?? "", bgColor: column.bgColor ?? "" };
1833
- return /* @__PURE__ */ jsxs(
1834
- "section",
1835
- {
1836
- "aria-label": column.label,
1837
- "data-kanban-column": column.key,
1838
- className: cn(
1839
- "flex flex-col gap-3 rounded-lg",
1840
- movable && over === column.key && dragging?.from !== column.key && "bg-accent/40 ring-2 ring-primary/40"
1841
- ),
1842
- onDragOver: movable ? (e) => {
1843
- if (!dragging) return;
1844
- e.preventDefault();
1845
- e.dataTransfer.dropEffect = "move";
1846
- if (over !== column.key) setOver(column.key);
1847
- } : void 0,
1848
- onDragLeave: movable ? (e) => {
1849
- if (!e.currentTarget.contains(e.relatedTarget)) setOver(null);
1850
- } : void 0,
1851
- onDrop: movable ? (e) => onDrop(e, column.key) : void 0,
1852
- children: [
1853
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
1854
- /* @__PURE__ */ jsx("span", { className: cn("text-sm font-semibold", resolved.color), children: column.label }),
1855
- /* @__PURE__ */ jsx(
1856
- Badge,
1857
- {
1858
- variant: "secondary",
1859
- className: cn("text-[10px] px-1.5 py-0", resolved.bgColor),
1860
- children: column.total ?? column.items.length
1861
- }
1862
- )
1863
- ] }),
1864
- /* @__PURE__ */ jsx("div", { className: "flex flex-col gap-2 min-h-[80px]", children: column.items.length === 0 ? /* @__PURE__ */ jsx("div", { className: "flex items-center justify-center rounded-lg border border-dashed border-border p-6 text-xs text-muted-foreground", children: emptyMessage }) : column.items.map((item, i) => {
1865
- const key = keyOf(item, i);
1866
- return /* @__PURE__ */ jsxs(
1867
- "div",
1868
- {
1869
- className: cn("animate-fade-in", movable && "group relative", dragging?.key === key && "opacity-50"),
1870
- style: { animationDelay: `${i * 30}ms`, animationFillMode: "both" },
1871
- draggable: movable || void 0,
1872
- onDragStart: movable ? (e) => {
1873
- e.dataTransfer.setData(DRAG_TYPE, key);
1874
- e.dataTransfer.effectAllowed = "move";
1875
- setDragging({ key, from: column.key });
1876
- } : void 0,
1877
- onDragEnd: movable ? () => {
1878
- setDragging(null);
1879
- setOver(null);
1880
- } : void 0,
1881
- children: [
1882
- renderCard(item),
1883
- movable && columns.length > 1 && /* @__PURE__ */ jsxs(DropdownMenu, { children: [
1884
- /* @__PURE__ */ jsx(
1885
- DropdownMenuTrigger,
1953
+ const onHandleClick = (event, key, from) => {
1954
+ if (event.detail !== 0) return;
1955
+ if (grabbed?.key === key) drop();
1956
+ else if (!grabbed) pickUp(key, from);
1957
+ };
1958
+ const columnAt = (x, y) => (typeof document.elementFromPoint === "function" ? document.elementFromPoint(x, y) : null)?.closest("[data-kanban-column]")?.dataset.kanbanColumn ?? null;
1959
+ const onHandlePointerDown = (event, key, from) => {
1960
+ if (event.pointerType === "mouse" || grabbed || dragging) return;
1961
+ event.currentTarget.setPointerCapture?.(event.pointerId);
1962
+ setDragging({ key, from, pointer: event.pointerId });
1963
+ announce(`${labelOf(key)} picked up in ${columnLabel(from)}.`);
1964
+ };
1965
+ const onHandlePointerMove = (event) => {
1966
+ if (dragging?.pointer !== event.pointerId) return;
1967
+ const to = columnAt(event.clientX, event.clientY);
1968
+ if (to !== over) setOver(to);
1969
+ };
1970
+ const onHandlePointerUp = (event) => {
1971
+ if (dragging?.pointer !== event.pointerId) return;
1972
+ const to = columnAt(event.clientX, event.clientY) ?? over;
1973
+ const { key, from } = dragging;
1974
+ setDragging(null);
1975
+ setOver(null);
1976
+ if (to && to !== from) moveKey(key, to);
1977
+ else announce(`${labelOf(key)} dropped back in ${columnLabel(from)}.`);
1978
+ };
1979
+ const onHandlePointerCancel = (event) => {
1980
+ if (dragging?.pointer !== event.pointerId) return;
1981
+ setDragging(null);
1982
+ setOver(null);
1983
+ };
1984
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
1985
+ /* @__PURE__ */ jsx(
1986
+ "div",
1987
+ {
1988
+ className: cn(
1989
+ "grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4",
1990
+ className
1991
+ ),
1992
+ children: columns.map((column) => {
1993
+ const resolved = column.status ? statusColorMap[column.status] : { color: column.color ?? "", bgColor: column.bgColor ?? "" };
1994
+ return /* @__PURE__ */ jsxs(
1995
+ "section",
1996
+ {
1997
+ "aria-label": column.label,
1998
+ "data-kanban-column": column.key,
1999
+ className: cn(
2000
+ "flex flex-col gap-3 rounded-lg",
2001
+ movable && (dragging && over === column.key && dragging.from !== column.key || grabbed && grabbed.target === column.key && grabbed.from !== column.key) && "bg-accent/40 ring-2 ring-primary/40"
2002
+ ),
2003
+ onDragOver: movable ? (e) => {
2004
+ if (!dragging || dragging.pointer !== void 0) return;
2005
+ e.preventDefault();
2006
+ e.dataTransfer.dropEffect = "move";
2007
+ if (over !== column.key) setOver(column.key);
2008
+ } : void 0,
2009
+ onDragLeave: movable ? (e) => {
2010
+ if (!e.currentTarget.contains(e.relatedTarget)) setOver(null);
2011
+ } : void 0,
2012
+ onDrop: movable ? (e) => onDrop(e, column.key) : void 0,
2013
+ children: [
2014
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
2015
+ /* @__PURE__ */ jsx("span", { className: cn("text-sm font-semibold", resolved.color), children: column.label }),
2016
+ /* @__PURE__ */ jsx(
2017
+ Badge,
2018
+ {
2019
+ variant: "secondary",
2020
+ className: cn("text-[10px] px-1.5 py-0", resolved.bgColor),
2021
+ children: column.total ?? column.items.length
2022
+ }
2023
+ )
2024
+ ] }),
2025
+ /* @__PURE__ */ jsx("div", { className: "flex flex-col gap-2 min-h-[80px]", children: column.items.length === 0 ? /* @__PURE__ */ jsx("div", { className: "flex items-center justify-center rounded-lg border border-dashed border-border p-6 text-xs text-muted-foreground", children: emptyMessage }) : column.items.map((item, i) => {
2026
+ const key = keyOf(item, i);
2027
+ const name = getItemLabel?.(item) ?? "card";
2028
+ const isGrabbed = grabbed?.key === key;
2029
+ return /* @__PURE__ */ jsxs(
2030
+ "div",
2031
+ {
2032
+ className: cn(
2033
+ "animate-fade-in",
2034
+ movable && "group relative rounded-lg",
2035
+ dragging?.key === key && "opacity-50",
2036
+ isGrabbed && "ring-2 ring-primary/50"
2037
+ ),
2038
+ style: { animationDelay: `${i * 30}ms`, animationFillMode: "both" },
2039
+ draggable: movable || void 0,
2040
+ onDragStart: movable ? (e) => {
2041
+ e.dataTransfer.setData(DRAG_TYPE, key);
2042
+ e.dataTransfer.effectAllowed = "move";
2043
+ setDragging({ key, from: column.key });
2044
+ } : void 0,
2045
+ onDragEnd: movable ? () => {
2046
+ setDragging(null);
2047
+ setOver(null);
2048
+ } : void 0,
2049
+ children: [
2050
+ renderCard(item),
2051
+ movable && columns.length > 1 && /* @__PURE__ */ jsx(
2052
+ "button",
1886
2053
  {
1887
- "aria-label": `Move ${getItemLabel?.(item) ?? "card"}`,
1888
- className: "absolute right-1.5 top-1.5 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
1889
- children: /* @__PURE__ */ jsx(ArrowRightLeft, { className: "h-3.5 w-3.5", "aria-hidden": true })
2054
+ type: "button",
2055
+ ref: (node) => {
2056
+ if (node) handles.current.set(key, node);
2057
+ else handles.current.delete(key);
2058
+ },
2059
+ "aria-label": `Pick up ${name}`,
2060
+ "aria-describedby": instructionsId,
2061
+ "data-grabbed": isGrabbed || void 0,
2062
+ className: cn(
2063
+ "absolute right-8 top-1.5 cursor-grab touch-none rounded p-1 text-muted-foreground",
2064
+ "hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
2065
+ isGrabbed && "bg-accent text-foreground"
2066
+ ),
2067
+ onClick: (e) => onHandleClick(e, key, column.key),
2068
+ onKeyDown: (e) => onHandleKeyDown(e, key, column.key),
2069
+ onBlur: () => {
2070
+ if (isGrabbed) cancel();
2071
+ },
2072
+ onPointerDown: (e) => onHandlePointerDown(e, key, column.key),
2073
+ onPointerMove: onHandlePointerMove,
2074
+ onPointerUp: onHandlePointerUp,
2075
+ onPointerCancel: onHandlePointerCancel,
2076
+ children: /* @__PURE__ */ jsx(GripVertical, { className: "h-3.5 w-3.5", "aria-hidden": true })
1890
2077
  }
1891
2078
  ),
1892
- /* @__PURE__ */ jsxs(DropdownMenuContent, { align: "end", children: [
1893
- /* @__PURE__ */ jsx(DropdownMenuLabel, { children: "Move to" }),
1894
- columns.filter((c) => c.key !== column.key).map((c) => /* @__PURE__ */ jsx(DropdownMenuItem, { onSelect: () => move(item, c.key), children: c.label }, c.key))
2079
+ movable && columns.length > 1 && /* @__PURE__ */ jsxs(DropdownMenu, { children: [
2080
+ /* @__PURE__ */ jsx(
2081
+ DropdownMenuTrigger,
2082
+ {
2083
+ "aria-label": `Move ${name}`,
2084
+ className: "absolute right-1.5 top-1.5 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
2085
+ children: /* @__PURE__ */ jsx(ArrowRightLeft, { className: "h-3.5 w-3.5", "aria-hidden": true })
2086
+ }
2087
+ ),
2088
+ /* @__PURE__ */ jsxs(DropdownMenuContent, { align: "end", children: [
2089
+ /* @__PURE__ */ jsx(DropdownMenuLabel, { children: "Move to" }),
2090
+ columns.filter((c) => c.key !== column.key).map((c) => /* @__PURE__ */ jsx(
2091
+ DropdownMenuItem,
2092
+ {
2093
+ onSelect: () => {
2094
+ refocus.current = key;
2095
+ move(item, c.key, getItemLabel?.(item) ?? "Card");
2096
+ },
2097
+ children: c.label
2098
+ },
2099
+ c.key
2100
+ ))
2101
+ ] })
1895
2102
  ] })
1896
- ] })
1897
- ]
1898
- },
1899
- key
1900
- );
1901
- }) }),
1902
- renderColumnFooter?.(column)
1903
- ]
1904
- },
1905
- column.key
1906
- );
1907
- })
1908
- }
1909
- );
2103
+ ]
2104
+ },
2105
+ key
2106
+ );
2107
+ }) }),
2108
+ renderColumnFooter?.(column)
2109
+ ]
2110
+ },
2111
+ column.key
2112
+ );
2113
+ })
2114
+ }
2115
+ ),
2116
+ movable && /* @__PURE__ */ jsxs(Fragment, { children: [
2117
+ /* @__PURE__ */ jsx("p", { id: instructionsId, hidden: true, children: "Space or Enter picks the card up. Left and right arrows, Home and End choose a column. Space or Enter drops it, Escape puts it back. Alt with an arrow moves it one column." }),
2118
+ /* @__PURE__ */ jsx("div", { "aria-live": "polite", "aria-atomic": "true", className: "sr-only", children: announcement })
2119
+ ] })
2120
+ ] });
1910
2121
  }
1911
2122
  function DataList({
1912
2123
  items,
@@ -2122,39 +2333,6 @@ function DetailPanel({
2122
2333
  }
2123
2334
  );
2124
2335
  }
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
- }
2158
2336
  function RelationField({
2159
2337
  field,
2160
2338
  value,
@@ -3087,6 +3265,8 @@ function ResourceForm(props) {
3087
3265
  prefillFromUrl,
3088
3266
  autoPlaceholder,
3089
3267
  warnWhenUnsaved,
3268
+ // A closed dialog is not touched by a save that fails later.
3269
+ visible: open,
3090
3270
  mutationMode,
3091
3271
  undoDelayMs,
3092
3272
  onUndoable: onUndoable ?? showUndoToast
@@ -3218,589 +3398,130 @@ function ConfirmDeleteDialog({
3218
3398
  }
3219
3399
  function ResourcePage(props) {
3220
3400
  const {
3221
- resource: resourceName,
3222
- data: dataProp,
3223
- total: totalProp,
3224
- pagination = "client",
3225
- clientRowLimit = 1e3,
3226
- title,
3227
- subtitle,
3228
- icon,
3229
- columns: columnsProp,
3230
- getKey: getKeyProp,
3231
- detail = "panel",
3232
- detailWidth = "md",
3233
- titleField: titleFieldProp,
3234
- subtitleField: subtitleFieldProp,
3235
- getTitle,
3236
- getSubtitle,
3237
- renderDetail: renderDetailProp,
3238
- detailFields,
3239
- statusFilter,
3240
- searchFields,
3241
- searchPlaceholder,
3242
- initialFilters,
3243
- onFiltersChange,
3244
- filters: filtersProp,
3245
- page: pageProp,
3246
- perPage: perPageProp,
3247
- onPageChange,
3248
- onPerPageChange,
3249
- onSortChange,
3250
- onSearchChange,
3251
- loading: loadingProp,
3252
- error: errorProp,
3253
- errorMessage: errorMessage2,
3254
- onRetry: onRetryProp,
3255
- savedViews,
3256
- onLoadView,
3257
- onSaveView,
3258
- onDeleteView,
3401
+ bulkActions,
3402
+ can,
3259
3403
  canSaveViews,
3404
+ capabilities,
3405
+ className,
3260
3406
  columnVisibility,
3261
3407
  columnVisibilityKey,
3262
- resizableColumns,
3263
- resizeKey,
3264
- stickyHeader,
3265
- stickyFirstColumn,
3266
- pageSizeOptions,
3267
- onCreate,
3268
- onEdit,
3269
- onDelete,
3270
- can,
3271
- canEditItem,
3272
3408
  createLabel = "New",
3273
- canEdit,
3274
- canDelete,
3275
- allowDelete,
3276
- confirmDelete,
3277
- onDeleted,
3278
- secondaryActions,
3279
- allowCreate,
3280
- allowEdit,
3409
+ detail = "panel",
3410
+ detailFields,
3411
+ detailWidth = "md",
3412
+ emptyDescription,
3413
+ emptyIcon,
3414
+ emptyMessage,
3415
+ emptyState,
3416
+ errorMessage: errorMessage2,
3417
+ filters: filtersProp,
3281
3418
  formFields,
3282
- renderForm: renderFormProp,
3419
+ formLayout: formLayoutProp,
3283
3420
  formMode,
3284
3421
  formSteps,
3285
- formLayout: formLayoutProp,
3422
+ gridCols: gridColsProp,
3423
+ header: headerMode = "page",
3424
+ icon,
3425
+ keyboardNavigation = true,
3286
3426
  mutationMode: mutationModeProp,
3287
- bulkActions,
3288
- renderCard: renderCardProp,
3427
+ onCreate,
3428
+ onDelete,
3429
+ onDeleteView,
3430
+ onEdit,
3431
+ onLoadView,
3432
+ onPageChange,
3433
+ onRetry: onRetryProp,
3434
+ onSaveView,
3435
+ onSearchChange,
3436
+ onSortChange,
3289
3437
  openOnCardClick = false,
3438
+ pageSizeOptions,
3439
+ pagination = "client",
3440
+ renderCard: renderCardProp,
3441
+ renderDetail: renderDetailProp,
3442
+ renderForm: renderFormProp,
3290
3443
  renderTable: renderTableProp,
3291
- gridCols: gridColsProp,
3292
- keyboardNavigation = true,
3293
- capabilities,
3294
- toolbar,
3295
- emptyState,
3296
- emptyIcon,
3297
- emptyMessage,
3298
- emptyDescription,
3299
- header: headerMode = "page",
3300
- resourceOptions,
3301
- urlState: urlStateProp,
3302
- defaultView = "table",
3303
- onNavigate = defaultNavigate,
3304
- viewSettingsKey: viewSettingsKeyProp,
3305
- slots,
3444
+ resizableColumns,
3445
+ resizeKey,
3446
+ resource: resourceName,
3447
+ savedViews,
3448
+ searchPlaceholder,
3449
+ secondaryActions,
3306
3450
  slotProps,
3307
- className
3451
+ slots,
3452
+ stickyFirstColumn,
3453
+ stickyHeader,
3454
+ subtitle,
3455
+ title,
3456
+ toolbar
3308
3457
  } = props;
3309
- const isClientMode = !!resourceName;
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;
3324
- const viewSettingsKey = viewSettingsKeyProp ?? resourceName ?? title.toLowerCase().replace(/\s+/g, "-");
3325
- const { settings, updateSetting } = useViewSettings(viewSettingsKey);
3326
- const serverSearchFields = resourceOptions?.searchFields ?? searchFields;
3327
- const resourceHook = useResource(
3328
- resourceName ?? "__noop__",
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 }
3348
- );
3349
- const effectivePageSize = resourceServerPaging ? resourceHook.params.pagination?.perPage ?? settings.pageSize : perPageProp ?? settings.pageSize;
3350
- const displayData = isClientMode ? resourceHook.list.data?.data ?? [] : dataProp ?? [];
3351
- const displayTotal = isClientMode ? resourceHook.list.data?.total ?? 0 : totalProp ?? 0;
3352
- const isLoading = isClientMode ? resourceHook.list.isLoading : loadingProp ?? false;
3353
- const hasError = isClientMode ? resourceHook.list.isError : errorProp ?? false;
3354
- const page = useResourcePage({
3355
- resource: resourceName,
3356
- data: displayData,
3357
- total: displayTotal,
3358
- columns: columnsProp,
3359
- statusFilter,
3360
- searchFields,
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),
3365
- onFiltersChange,
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,
3370
- detail,
3371
- titleField: titleFieldProp,
3372
- subtitleField: subtitleFieldProp,
3373
- controlled: isServerControlled
3374
- });
3375
3458
  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]);
3435
- const actions = useMemo(() => {
3436
- if (isClientMode) {
3437
- return {
3438
- update: resourceHook.update,
3439
- remove: resourceHook.remove,
3440
- refetch: () => resourceHook.list.refetch()
3441
- };
3442
- }
3443
- return {
3444
- update: async () => {
3445
- throw new Error(
3446
- "ResourceActions.update not available in server-data mode"
3447
- );
3448
- },
3449
- remove: async () => {
3450
- throw new Error(
3451
- "ResourceActions.remove not available in server-data mode"
3452
- );
3453
- },
3454
- refetch: () => {
3455
- }
3456
- };
3457
- }, [
3458
- isClientMode,
3459
- resourceHook.update,
3460
- resourceHook.remove,
3461
- resourceHook.list
3462
- ]);
3463
- const keyOf = useCallback(
3464
- (item) => getKeyProp ? getKeyProp(item) : String(item.id ?? ""),
3465
- [getKeyProp]
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;
3478
- const [selectedIds, setSelectedIds] = useState(/* @__PURE__ */ new Set());
3479
- const handleToggleSelect = useCallback((id) => {
3480
- setSelectedIds((prev) => {
3481
- const next = new Set(prev);
3482
- if (next.has(id)) next.delete(id);
3483
- else next.add(id);
3484
- return next;
3485
- });
3486
- }, []);
3487
- const handleToggleAll = useCallback(() => {
3488
- setSelectedIds((prev) => {
3489
- if (prev.size === page.data.length) return /* @__PURE__ */ new Set();
3490
- return new Set(page.data.map(keyOf));
3491
- });
3492
- }, [page.data, keyOf]);
3493
- const handleClearSelection = useCallback(() => {
3494
- setSelectedIds(/* @__PURE__ */ new Set());
3495
- }, []);
3496
- const [formOpen, setFormOpen] = useState(false);
3497
- const [formAction, setFormAction] = useState("create");
3498
- const [formItem, setFormItem] = useState(null);
3499
- const useBuiltInCreate = allowCreate && !onCreate && isClientMode;
3500
- const useBuiltInEdit = allowEdit && !onEdit && isClientMode;
3501
- const handleCreate = useCallback(() => {
3502
- if (useBuiltInCreate) {
3503
- setFormAction("create");
3504
- setFormItem(null);
3505
- setFormOpen(true);
3506
- } else if (onCreate) {
3507
- onCreate();
3508
- }
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
- );
3521
- const handleEdit = useCallback(
3522
- (item) => {
3523
- if (!allows("edit", item)) return;
3524
- if (useBuiltInEdit) {
3525
- setFormAction("edit");
3526
- setFormItem(item);
3527
- setFormOpen(true);
3528
- } else if (onEdit) {
3529
- onEdit(item);
3530
- }
3531
- },
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
- ]
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);
3600
- const tableColumns = useMemo(() => {
3601
- const base = isClientMode ? page.columns.map((col) => ({
3602
- key: col.key,
3603
- header: col.header ?? col.key,
3604
- sortable: col.sortable ?? true,
3605
- hideBelow: col.hideBelow,
3606
- width: col.width,
3607
- minWidth: col.minWidth,
3608
- defaultVisible: col.defaultVisible,
3609
- editable: col.editable,
3610
- accessor: col.accessor,
3611
- render: col.render ? (item, value) => col.render(item, value, actions) : void 0
3612
- })) : page.tableColumns;
3613
- if (!isServerControlled) return base;
3614
- return base.map((col) => ({ ...col, sortable: false }));
3615
- }, [
3459
+ actions,
3460
+ allows,
3461
+ clientTruncated,
3462
+ closeDetail,
3463
+ currentPage,
3464
+ deleteConfirmed,
3465
+ deleteCopy,
3466
+ deleteHandler,
3467
+ deleteKind,
3468
+ deleteOpen,
3469
+ detailOpen,
3470
+ detailSubtitle,
3471
+ detailTitle,
3472
+ displayData,
3473
+ displayTotal,
3474
+ effectivePageSize,
3475
+ formAction,
3476
+ formItem,
3477
+ formOpen,
3478
+ handleClearFilters,
3479
+ handleClearSelection,
3480
+ handleCreate,
3481
+ handleEdit,
3482
+ handlePageSizeChange,
3483
+ handleRowClick,
3484
+ handleSearchChange,
3485
+ handleSortSelect,
3486
+ handleTableSort,
3487
+ handleToggleAll,
3488
+ handleToggleSelect,
3489
+ hasError,
3616
3490
  isClientMode,
3491
+ isLoading,
3617
3492
  isServerControlled,
3618
- page.columns,
3619
- page.tableColumns,
3620
- actions
3621
- ]);
3622
- const [tableTieBreaks, setTableTieBreaks] = useState(null);
3623
- const pageSort = page.sort;
3624
- const setPageSort = page.setSort;
3625
- const tableSort = useMemo(() => {
3626
- if (!pageSort) return [];
3627
- const primary = { key: pageSort.field, dir: pageSort.order };
3628
- const rest = tableTieBreaks && tableTieBreaks.primary === `${primary.key}:${primary.dir}` ? tableTieBreaks.rest : [];
3629
- return [primary, ...rest];
3630
- }, [pageSort, tableTieBreaks]);
3631
- const handleTableSort = useCallback(
3632
- (next) => {
3633
- const [primary, ...rest] = next;
3634
- setTableTieBreaks(primary ? { primary: `${primary.key}:${primary.dir}`, rest } : null);
3635
- setPageSort(primary ? { field: primary.key, order: primary.dir } : null);
3636
- },
3637
- [setPageSort]
3638
- );
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]);
3652
- const handleSearchChange = useCallback(
3653
- (value) => {
3654
- page.filterConfig.onSearchChange(value);
3655
- if (!isServerControlled) return;
3656
- onSearchChange?.(value);
3657
- if (currentPage !== 1 && !resourceServerPaging) onPageChange?.(1);
3658
- },
3659
- [
3660
- page.filterConfig,
3661
- isServerControlled,
3662
- onSearchChange,
3663
- onPageChange,
3664
- resourceServerPaging,
3665
- currentPage
3666
- ]
3667
- );
3668
- const handleSortSelect = useCallback(
3669
- (value) => {
3670
- page.filterConfig.onSortChange(value);
3671
- if (!isServerControlled) return;
3672
- const [field, order] = value.split(":");
3673
- onSortChange?.(
3674
- field && (order === "asc" || order === "desc") ? { field, order } : null
3675
- );
3676
- if (!resourceServerPaging) onPageChange?.(1);
3677
- },
3678
- [
3679
- page.filterConfig,
3680
- isServerControlled,
3681
- onSortChange,
3682
- onPageChange,
3683
- resourceServerPaging
3684
- ]
3685
- );
3686
- const handleClearFilters = useCallback(() => {
3687
- page.clearFilters();
3688
- if (!isServerControlled) return;
3689
- onSearchChange?.("");
3690
- onSortChange?.(null);
3691
- if (!resourceServerPaging) onPageChange?.(1);
3692
- }, [
3493
+ keyOf,
3494
+ keyboardNav,
3495
+ linkMissing,
3496
+ linked,
3497
+ missingPagerProps,
3693
3498
  page,
3694
- isServerControlled,
3695
- onSearchChange,
3696
- onSortChange,
3697
- onPageChange,
3698
- resourceServerPaging
3699
- ]);
3700
- const handleStatusChange = useCallback(
3701
- (values) => {
3702
- page.filterConfig.statusChips?.onChange(values);
3703
- if (!isServerControlled) return;
3704
- if (currentPage !== 1 && !resourceServerPaging) onPageChange?.(1);
3705
- },
3706
- [
3707
- page.filterConfig,
3708
- isServerControlled,
3709
- onPageChange,
3710
- resourceServerPaging,
3711
- currentPage
3712
- ]
3713
- );
3714
- const statusChipsConfig = useMemo(() => {
3715
- const base = page.filterConfig.statusChips;
3716
- if (!base) return void 0;
3717
- return { ...base, onChange: handleStatusChange };
3718
- }, [page.filterConfig.statusChips, handleStatusChange]);
3719
- const handlePageSizeChange = useCallback(
3720
- (size) => {
3721
- updateSetting("pageSize", size);
3722
- onPerPageChange?.(size);
3723
- if (resourceServerPaging) resourceHook.setPerPage(size);
3724
- else if (isServerControlled) onPageChange?.(1);
3725
- },
3726
- [
3727
- updateSetting,
3728
- onPerPageChange,
3729
- resourceServerPaging,
3730
- resourceHook.setPerPage,
3731
- isServerControlled,
3732
- onPageChange
3733
- ]
3734
- );
3735
- const missingPerPage = isServerControlled && !!pagerOnPageChange && pagerPerPage === void 0;
3736
- const missingTotal = isServerControlled && !!pagerOnPageChange && pagerTotal === void 0;
3737
- const pagerMisconfigured = missingPerPage || missingTotal;
3738
- const missingPagerProps = [
3739
- ...missingPerPage ? ["perPage"] : [],
3740
- ...missingTotal ? ["total"] : []
3741
- ];
3742
- const loggedMissingPerPageRef = useRef(false);
3743
- useEffect(() => {
3744
- if (!pagerMisconfigured) return;
3745
- if (loggedMissingPerPageRef.current) return;
3746
- loggedMissingPerPageRef.current = true;
3747
- console.error(
3748
- `ResourcePage: ${missingPagerProps.map((name) => `\`${name}\``).join(" and ")} ${missingPagerProps.length > 1 ? "are" : "is"} required when \`onPageChange\` is supplied.`
3749
- );
3750
- }, [pagerMisconfigured]);
3751
- const pagerPageSize = pagerPerPage ?? effectivePageSize;
3752
- const totalPages = Math.max(
3753
- 1,
3754
- Math.ceil(displayTotal / Math.max(1, pagerPageSize))
3755
- );
3756
- const rangeFrom = page.data.length === 0 ? 0 : (currentPage - 1) * pagerPageSize + 1;
3757
- const rangeTo = rangeFrom === 0 ? 0 : rangeFrom + page.data.length - 1;
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] ?? ""
3768
- ) : void 0;
3769
- const handleRowClick = (item) => {
3770
- if (detail === "route") {
3771
- const id = item.id;
3772
- if (resourceName && id) {
3773
- onNavigate(`/${resourceName}/${id}`);
3774
- }
3775
- return;
3776
- }
3777
- page.handleRowClick(item);
3778
- urlPeek?.setDetailId(keyOf(item));
3779
- };
3780
- const hasBulkSelect = !!bulkActions && bulkActions.length > 0;
3781
- const overlayOpen = detailOpen || formOpen || deleteOpen;
3782
- const keyboardNav = useKeyboardNavigation({
3783
- itemCount: page.data.length,
3784
- enabled: keyboardNavigation && page.viewMode !== "table",
3785
- overlayOpen,
3786
- onOpen: (index) => {
3787
- const item = page.data[index];
3788
- if (item) handleRowClick(item);
3789
- },
3790
- onToggleSelect: hasBulkSelect ? (index) => {
3791
- const item = page.data[index];
3792
- if (item) {
3793
- handleToggleSelect(keyOf(item));
3794
- }
3795
- } : void 0,
3796
- // Supplied only while there is an open detail to close — the hook
3797
- // suppresses Backspace's default whenever this is present, so leaving it
3798
- // wired up permanently would swallow the browser/app's Backspace on
3799
- // every grid/list page (including detail="none" and detail="route",
3800
- // which never open one). `detailOpen` is only ever true for the panel
3801
- // and modal modes, so it is the precise precondition here.
3802
- onBack: detailOpen ? closeDetail : void 0
3803
- });
3499
+ pagerMisconfigured,
3500
+ pagerOnPageChange,
3501
+ pendingDelete,
3502
+ performDelete,
3503
+ rangeFrom,
3504
+ rangeTo,
3505
+ resourceHook,
3506
+ resourceServerPaging,
3507
+ selectedDeletable,
3508
+ selectedEditable,
3509
+ selectedIds,
3510
+ selectedItem,
3511
+ serverSearchFields,
3512
+ setDeleteOpen,
3513
+ setFormOpen,
3514
+ settings,
3515
+ statusChipsConfig,
3516
+ tableColumns,
3517
+ tableSort,
3518
+ titleOf,
3519
+ totalPages,
3520
+ updateSetting,
3521
+ useBuiltInCreate,
3522
+ useBuiltInDelete,
3523
+ useBuiltInEdit
3524
+ } = useResourceTableModel(props);
3804
3525
  const showCreateButton = (onCreate || useBuiltInCreate) && allows("create");
3805
3526
  const createButton = showCreateButton ? /* @__PURE__ */ jsxs(Button, { size: "sm", onClick: handleCreate, children: [
3806
3527
  /* @__PURE__ */ jsx(Plus, { className: "h-4 w-4 mr-1" }),