@stackframe/dashboard-ui-components 2.8.89 → 2.8.91

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 (67) hide show
  1. package/dist/components/button.d.ts +4 -4
  2. package/dist/components/data-grid/data-grid-sizing.d.ts +6 -5
  3. package/dist/components/data-grid/data-grid-sizing.d.ts.map +1 -1
  4. package/dist/components/data-grid/data-grid-sizing.js +9 -28
  5. package/dist/components/data-grid/data-grid-sizing.js.map +1 -1
  6. package/dist/components/data-grid/data-grid.d.ts +17 -237
  7. package/dist/components/data-grid/data-grid.d.ts.map +1 -1
  8. package/dist/components/data-grid/data-grid.js +377 -523
  9. package/dist/components/data-grid/data-grid.js.map +1 -1
  10. package/dist/components/data-grid/data-grid.test.js +82 -0
  11. package/dist/components/data-grid/data-grid.test.js.map +1 -1
  12. package/dist/components/data-grid/index.d.ts +4 -3
  13. package/dist/components/data-grid/index.js +17 -58
  14. package/dist/components/data-grid/state.d.ts +4 -61
  15. package/dist/components/data-grid/state.d.ts.map +1 -1
  16. package/dist/components/data-grid/state.js +13 -160
  17. package/dist/components/data-grid/state.js.map +1 -1
  18. package/dist/components/data-grid/types.d.ts +9 -4
  19. package/dist/components/data-grid/types.d.ts.map +1 -1
  20. package/dist/components/data-grid/use-url-state.d.ts +38 -0
  21. package/dist/components/data-grid/use-url-state.d.ts.map +1 -0
  22. package/dist/components/data-grid/use-url-state.js +214 -0
  23. package/dist/components/data-grid/use-url-state.js.map +1 -0
  24. package/dist/components/data-grid/use-url-state.test.d.ts +1 -0
  25. package/dist/components/data-grid/use-url-state.test.js +91 -0
  26. package/dist/components/data-grid/use-url-state.test.js.map +1 -0
  27. package/dist/components/dialog.d.ts +67 -0
  28. package/dist/components/dialog.d.ts.map +1 -0
  29. package/dist/components/dialog.js +94 -0
  30. package/dist/components/dialog.js.map +1 -0
  31. package/dist/dashboard-ui-components.global.js +10648 -6394
  32. package/dist/dashboard-ui-components.global.js.map +4 -4
  33. package/dist/esm/components/button.d.ts +4 -4
  34. package/dist/esm/components/data-grid/data-grid-sizing.d.ts +6 -5
  35. package/dist/esm/components/data-grid/data-grid-sizing.d.ts.map +1 -1
  36. package/dist/esm/components/data-grid/data-grid-sizing.js +7 -26
  37. package/dist/esm/components/data-grid/data-grid-sizing.js.map +1 -1
  38. package/dist/esm/components/data-grid/data-grid.d.ts +17 -237
  39. package/dist/esm/components/data-grid/data-grid.d.ts.map +1 -1
  40. package/dist/esm/components/data-grid/data-grid.js +380 -526
  41. package/dist/esm/components/data-grid/data-grid.js.map +1 -1
  42. package/dist/esm/components/data-grid/data-grid.test.js +82 -0
  43. package/dist/esm/components/data-grid/data-grid.test.js.map +1 -1
  44. package/dist/esm/components/data-grid/index.d.ts +4 -3
  45. package/dist/esm/components/data-grid/index.js +4 -3
  46. package/dist/esm/components/data-grid/state.d.ts +4 -61
  47. package/dist/esm/components/data-grid/state.d.ts.map +1 -1
  48. package/dist/esm/components/data-grid/state.js +15 -150
  49. package/dist/esm/components/data-grid/state.js.map +1 -1
  50. package/dist/esm/components/data-grid/types.d.ts +9 -4
  51. package/dist/esm/components/data-grid/types.d.ts.map +1 -1
  52. package/dist/esm/components/data-grid/use-url-state.d.ts +38 -0
  53. package/dist/esm/components/data-grid/use-url-state.d.ts.map +1 -0
  54. package/dist/esm/components/data-grid/use-url-state.js +212 -0
  55. package/dist/esm/components/data-grid/use-url-state.js.map +1 -0
  56. package/dist/esm/components/data-grid/use-url-state.test.d.ts +1 -0
  57. package/dist/esm/components/data-grid/use-url-state.test.js +91 -0
  58. package/dist/esm/components/data-grid/use-url-state.test.js.map +1 -0
  59. package/dist/esm/components/dialog.d.ts +67 -0
  60. package/dist/esm/components/dialog.d.ts.map +1 -0
  61. package/dist/esm/components/dialog.js +86 -0
  62. package/dist/esm/components/dialog.js.map +1 -0
  63. package/dist/esm/index.d.ts +2 -1
  64. package/dist/esm/index.js +2 -1
  65. package/dist/index.d.ts +5 -3
  66. package/dist/index.js +37 -0
  67. package/package.json +4 -3
@@ -3,83 +3,17 @@
3
3
  import { ArrowDown, ArrowUp, CaretDown, CaretUp, CheckSquare, MinusSquare, Square } from "@phosphor-icons/react";
4
4
  import { cn } from "@stackframe/stack-ui";
5
5
  import { jsx, jsxs } from "react/jsx-runtime";
6
- import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react";
7
- import { clearSelection, exportToCsv, formatGridDate, getSortDirection, getSortIndex, isColumnVisible, resolveColumnValue, resolveColumnWidth, selectAll, toggleRowSelection, toggleSort } from "./state.js";
6
+ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
7
+ import { exportToCsv, formatGridDate, resolveColumnValue } from "./state.js";
8
8
  import { resolveDataGridStrings } from "./strings.js";
9
9
  import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
10
+ import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
10
11
  import { useVirtualizer } from "@tanstack/react-virtual";
11
12
  import { DesignSkeleton } from "../skeleton.js";
12
- import { applyDraggedColumnWidth, clampColumnWidth, createGridSizingStyle, getColumnSizingStyle } from "./data-grid-sizing.js";
13
+ import { DEFAULT_COL_WIDTH, clampColumnWidth, getEffectiveMaxWidth, getEffectiveMinWidth } from "./data-grid-sizing.js";
13
14
  import { DataGridToolbar } from "./data-grid-toolbar.js";
14
15
 
15
16
  //#region src/components/data-grid/data-grid.tsx
16
- function ResizeHandle({ onResize, onResizeEnd }) {
17
- const startXRef = useRef(0);
18
- const rafRef = useRef(0);
19
- const latestDeltaRef = useRef(0);
20
- const callbacksRef = useRef({
21
- onResize,
22
- onResizeEnd
23
- });
24
- callbacksRef.current = {
25
- onResize,
26
- onResizeEnd
27
- };
28
- const onPointerDown = useCallback((e) => {
29
- e.preventDefault();
30
- e.stopPropagation();
31
- startXRef.current = e.clientX;
32
- latestDeltaRef.current = 0;
33
- const el = e.currentTarget;
34
- el.setPointerCapture(e.pointerId);
35
- let finished = false;
36
- const onMove = (ev) => {
37
- latestDeltaRef.current = ev.clientX - startXRef.current;
38
- if (rafRef.current !== 0) return;
39
- rafRef.current = requestAnimationFrame(() => {
40
- rafRef.current = 0;
41
- callbacksRef.current.onResize(latestDeltaRef.current);
42
- });
43
- };
44
- const finish = () => {
45
- if (finished) return;
46
- finished = true;
47
- if (rafRef.current !== 0) {
48
- cancelAnimationFrame(rafRef.current);
49
- rafRef.current = 0;
50
- callbacksRef.current.onResize(latestDeltaRef.current);
51
- }
52
- el.removeEventListener("pointermove", onMove);
53
- el.removeEventListener("pointerup", finish);
54
- el.removeEventListener("pointercancel", finish);
55
- el.removeEventListener("lostpointercapture", finish);
56
- if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
57
- callbacksRef.current.onResizeEnd();
58
- };
59
- el.addEventListener("pointermove", onMove);
60
- el.addEventListener("pointerup", finish);
61
- el.addEventListener("pointercancel", finish);
62
- el.addEventListener("lostpointercapture", finish);
63
- }, []);
64
- return /* @__PURE__ */ jsx("div", {
65
- className: cn("absolute right-0 top-0 bottom-0 z-10 w-[5px] cursor-col-resize touch-none", "group-hover/header:bg-foreground/[0.06] hover:!bg-blue-500/30", "transition-colors duration-100"),
66
- onClick: (e) => {
67
- e.preventDefault();
68
- e.stopPropagation();
69
- },
70
- onPointerDown
71
- });
72
- }
73
- function getNearestVerticalScrollElement(element) {
74
- let current = element?.parentElement ?? null;
75
- while (current) {
76
- const style = window.getComputedStyle(current);
77
- const overflowY = style.overflowY === "visible" ? style.overflow : style.overflowY;
78
- if ((overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") && current.scrollHeight > current.clientHeight + 1) return current;
79
- current = current.parentElement;
80
- }
81
- return window;
82
- }
83
17
  function getEventTargetElement(target) {
84
18
  if (target instanceof Element) return target;
85
19
  if (target instanceof Node) return target.parentElement;
@@ -101,41 +35,122 @@ function isDataGridInteractiveRowClickTarget(target) {
101
35
  function shouldIgnoreRowClick(event) {
102
36
  return event.defaultPrevented || isDataGridInteractiveRowClickTarget(event.target);
103
37
  }
104
- function HeaderCell({ col, isSorted, sortIndex, resizable, onSort, onResize, onResizeEnd }) {
38
+ function toTanstackSorting(sorting) {
39
+ return sorting.map((s) => ({
40
+ id: s.columnId,
41
+ desc: s.direction === "desc"
42
+ }));
43
+ }
44
+ function fromTanstackSorting(sorting) {
45
+ return sorting.map((s) => ({
46
+ columnId: s.id,
47
+ direction: s.desc ? "desc" : "asc"
48
+ }));
49
+ }
50
+ function toTanstackRowSelection(ids) {
51
+ const out = {};
52
+ for (const id of ids) out[id] = true;
53
+ return out;
54
+ }
55
+ function resolveUpdater(updater, current) {
56
+ return typeof updater === "function" ? updater(current) : updater;
57
+ }
58
+ function distributeFlexWidths(sizes, visibleColumns, available) {
59
+ const flexCols = visibleColumns.filter((c) => c.flex != null && c.flex > 0);
60
+ if (flexCols.length === 0 || available <= 0) return;
61
+ const totalFlex = flexCols.reduce((acc, c) => acc + (c.flex ?? 0), 0);
62
+ let remaining = available;
63
+ flexCols.forEach((col, i) => {
64
+ const share = i === flexCols.length - 1 ? remaining : Math.floor(available * ((col.flex ?? 0) / totalFlex));
65
+ const max = col.maxWidth ?? Infinity;
66
+ const add = Math.max(0, Math.min(share, max - sizes[col.id]));
67
+ sizes[col.id] += add;
68
+ remaining -= add;
69
+ });
70
+ }
71
+ function selectSingle(current, rowId) {
72
+ const isSelected = current.selectedIds.has(rowId);
73
+ return {
74
+ selectedIds: isSelected ? /* @__PURE__ */ new Set() : new Set([rowId]),
75
+ anchorId: isSelected ? null : rowId
76
+ };
77
+ }
78
+ function selectRange(current, rowId, allRowIds, additive) {
79
+ if (current.anchorId == null) return null;
80
+ const anchorIdx = allRowIds.indexOf(current.anchorId);
81
+ const currentIdx = allRowIds.indexOf(rowId);
82
+ if (anchorIdx < 0 || currentIdx < 0) return null;
83
+ const start = Math.min(anchorIdx, currentIdx);
84
+ const end = Math.max(anchorIdx, currentIdx);
85
+ const next = additive ? new Set(current.selectedIds) : /* @__PURE__ */ new Set();
86
+ for (let i = start; i <= end; i++) next.add(allRowIds[i]);
87
+ return {
88
+ selectedIds: next,
89
+ anchorId: current.anchorId
90
+ };
91
+ }
92
+ function selectToggle(current, rowId) {
93
+ const next = new Set(current.selectedIds);
94
+ if (next.has(rowId)) next.delete(rowId);
95
+ else next.add(rowId);
96
+ return {
97
+ selectedIds: next,
98
+ anchorId: rowId
99
+ };
100
+ }
101
+ function nextSelection(input) {
102
+ const { current, rowId, mode, modifiers, allRowIds } = input;
103
+ if (mode === "single") return selectSingle(current, rowId);
104
+ if (modifiers.shift) {
105
+ const range = selectRange(current, rowId, allRowIds, modifiers.ctrl);
106
+ if (range != null) return range;
107
+ }
108
+ if (modifiers.ctrl) return selectToggle(current, rowId);
109
+ return {
110
+ selectedIds: new Set([rowId]),
111
+ anchorId: rowId
112
+ };
113
+ }
114
+ function HeaderCell({ header, col, resizable }) {
115
+ const sorted = header.column.getIsSorted();
116
+ const sortIndex = header.column.getSortIndex();
117
+ const totalSorts = header.column.getCanMultiSort() ? header.getContext().table.getState().sorting.length : 0;
105
118
  const ctx = {
106
119
  columnId: col.id,
107
120
  columnDef: col,
108
- isSorted,
109
- sortIndex
121
+ isSorted: sorted === false ? false : sorted,
122
+ sortIndex: totalSorts > 1 && sortIndex >= 0 ? sortIndex + 1 : null
110
123
  };
111
124
  const label = typeof col.header === "function" ? col.header(ctx) : col.header;
112
- const sortable = col.sortable !== false;
125
+ const sortable = header.column.getCanSort();
126
+ const canResize = resizable && header.column.getCanResize();
127
+ const isResizing = header.column.getIsResizing();
113
128
  return /* @__PURE__ */ jsxs("div", {
114
129
  className: cn("group/header relative flex items-center gap-1.5 px-3 select-none bg-transparent overflow-hidden", "border-r border-black/[0.04] dark:border-white/[0.04] last:border-r-0", sortable && "cursor-pointer"),
115
- style: getColumnSizingStyle(col),
130
+ style: { width: `calc(var(--col-${col.id}-size) * 1px)` },
116
131
  "data-col-id": col.id,
117
- onClick: (e) => sortable && onSort(col.id, e.metaKey || e.ctrlKey),
132
+ onClick: sortable ? header.column.getToggleSortingHandler() : void 0,
118
133
  role: "columnheader",
119
- "aria-sort": isSorted === "asc" ? "ascending" : isSorted === "desc" ? "descending" : "none",
134
+ "aria-sort": sorted === "asc" ? "ascending" : sorted === "desc" ? "descending" : "none",
120
135
  children: [
121
136
  /* @__PURE__ */ jsx("span", {
122
137
  className: cn("flex-1 min-w-0 truncate text-xs font-semibold uppercase tracking-wider text-muted-foreground", col.align === "center" && "text-center", col.align === "right" && "text-right"),
123
138
  children: label
124
139
  }),
125
- isSorted && /* @__PURE__ */ jsxs("span", {
140
+ sorted && /* @__PURE__ */ jsxs("span", {
126
141
  className: "flex items-center gap-0.5 text-foreground/60",
127
- children: [isSorted === "asc" ? /* @__PURE__ */ jsx(ArrowUp, {
142
+ children: [sorted === "asc" ? /* @__PURE__ */ jsx(ArrowUp, {
128
143
  className: "h-3 w-3",
129
144
  weight: "bold"
130
145
  }) : /* @__PURE__ */ jsx(ArrowDown, {
131
146
  className: "h-3 w-3",
132
147
  weight: "bold"
133
- }), sortIndex != null && /* @__PURE__ */ jsx("span", {
148
+ }), ctx.sortIndex != null && /* @__PURE__ */ jsx("span", {
134
149
  className: "text-[10px] font-medium tabular-nums",
135
- children: sortIndex
150
+ children: ctx.sortIndex
136
151
  })]
137
152
  }),
138
- !isSorted && sortable && /* @__PURE__ */ jsxs("span", {
153
+ !sorted && sortable && /* @__PURE__ */ jsxs("span", {
139
154
  className: "hidden group-hover/header:flex items-center text-foreground/20",
140
155
  children: [/* @__PURE__ */ jsx(CaretUp, {
141
156
  className: "h-2.5 w-2.5 -mb-[1px]",
@@ -145,9 +160,14 @@ function HeaderCell({ col, isSorted, sortIndex, resizable, onSort, onResize, onR
145
160
  weight: "bold"
146
161
  })]
147
162
  }),
148
- resizable && col.resizable !== false && /* @__PURE__ */ jsx(ResizeHandle, {
149
- onResize: (delta) => onResize(col.id, delta),
150
- onResizeEnd
163
+ canResize && /* @__PURE__ */ jsx("div", {
164
+ onMouseDown: header.getResizeHandler(),
165
+ onTouchStart: header.getResizeHandler(),
166
+ onClick: (e) => {
167
+ e.preventDefault();
168
+ e.stopPropagation();
169
+ },
170
+ className: cn("absolute right-0 top-0 bottom-0 z-10 w-[5px] cursor-col-resize touch-none", "group-hover/header:bg-foreground/[0.06] hover:!bg-blue-500/30", "transition-colors duration-100", isResizing && "bg-blue-500/40")
151
171
  })
152
172
  ]
153
173
  });
@@ -172,7 +192,7 @@ function DataCell({ col, row, rowId, rowIndex, isSelected, dateDisplay }) {
172
192
  const isWrap = col.cellOverflow === "wrap";
173
193
  return /* @__PURE__ */ jsx("div", {
174
194
  className: cn("flex px-3 bg-transparent overflow-hidden", "border-r border-black/[0.04] dark:border-white/[0.04] last:border-r-0", "text-sm text-foreground", isWrap ? "items-start py-2" : "items-center", col.align === "center" && "justify-center", col.align === "right" && "justify-end", hasCellClick && "cursor-pointer"),
175
- style: getColumnSizingStyle(col),
195
+ style: { width: `calc(var(--col-${col.id}-size) * 1px)` },
176
196
  "data-col-id": col.id,
177
197
  role: "gridcell",
178
198
  onClick: col.onCellClick ? (e) => {
@@ -207,10 +227,6 @@ function formatCellValue(value) {
207
227
  children: String(value)
208
228
  });
209
229
  }
210
- /** Built-in date cell — mirrors what `formatGridDate` returns but wraps
211
- * the display in a `<span>` with a `title` tooltip showing the absolute
212
- * datetime. Only used when the column has `type: "date" | "dateTime"`
213
- * and no custom `renderCell`. */
214
230
  function renderDateCell(value, dateDisplay, col) {
215
231
  const { display, tooltip } = formatGridDate(value, dateDisplay, {
216
232
  parseValue: col.parseValue,
@@ -242,7 +258,7 @@ function SkeletonRow({ columns, height, showCheckbox }) {
242
258
  children: /* @__PURE__ */ jsx(DesignSkeleton, { className: "h-4 w-4 rounded" })
243
259
  }), columns.map((col) => /* @__PURE__ */ jsx("div", {
244
260
  className: "flex items-center px-3 border-r border-black/[0.04] dark:border-white/[0.04] last:border-r-0",
245
- style: getColumnSizingStyle(col),
261
+ style: { width: `calc(var(--col-${col.id}-size) * 1px)` },
246
262
  children: /* @__PURE__ */ jsx(DesignSkeleton, {
247
263
  className: "h-3.5 rounded-md",
248
264
  style: { width: `${40 + hashStringToInt(col.id) % 40}%` }
@@ -250,7 +266,7 @@ function SkeletonRow({ columns, height, showCheckbox }) {
250
266
  }, col.id))]
251
267
  });
252
268
  }
253
- function SelectionCheckbox({ checked, indeterminate, onChange, ariaLabel }) {
269
+ function SelectionCheckbox({ checked, indeterminate, onChange, ariaLabel, title }) {
254
270
  const Icon = indeterminate ? MinusSquare : checked ? CheckSquare : Square;
255
271
  return /* @__PURE__ */ jsx("button", {
256
272
  className: cn("flex items-center justify-center w-full h-full", "hover:bg-foreground/[0.04] transition-colors duration-75", checked || indeterminate ? "text-blue-600 dark:text-blue-400" : "text-muted-foreground/40 hover:text-muted-foreground/60"),
@@ -259,6 +275,7 @@ function SelectionCheckbox({ checked, indeterminate, onChange, ariaLabel }) {
259
275
  onChange(e);
260
276
  },
261
277
  "aria-label": ariaLabel,
278
+ title: title ?? ariaLabel,
262
279
  role: "checkbox",
263
280
  "aria-checked": indeterminate ? "mixed" : checked,
264
281
  children: /* @__PURE__ */ jsx(Icon, {
@@ -373,396 +390,292 @@ function DefaultFooter({ ctx, pagination, onChange }) {
373
390
  });
374
391
  }
375
392
  /**
376
- * Interactive table with sorting, quick search, pagination, selection,
377
- * and virtualization. Handles 10k+ rows smoothly. Pair with
378
- * `useDataSource` for client-side data; use an async `dataSource`
379
- * generator for server or infinite-scroll modes.
393
+ * Interactive table built on TanStack Table v8. Sorting, column sizing,
394
+ * visibility, ordering, and pinning are owned by the table instance; we
395
+ * layer virtualization, sticky toolbar/header/footer, infinite scroll,
396
+ * quick search, CSV export, and date-format toggling on top.
380
397
  *
381
- * ## Mental model (read this first everything else depends on it)
382
- *
383
- * DataGrid is a **display** component. It does NOT sort, search, or
384
- * paginate your data directly — you own that, but `useDataSource` does
385
- * it for you. The `rows` prop is always the already-processed slice to
386
- * show. The grid tracks user intent in `state` (sort model, quick
387
- * search text, page index). You feed that state into `useDataSource`,
388
- * and its output goes back in as `rows`.
389
- *
390
- * `useDataSource` IS the processor. Given your full dataset and the
391
- * grid's state, it returns the searched + sorted + paginated rows
392
- * ready to pass to DataGrid. This is the ONLY correct pattern for
393
- * client-side data — do NOT pass a raw array to `rows`.
394
- *
395
- * ## Search (client vs async)
396
- *
397
- * - **Client mode** (`useDataSource` with `data`): a case-insensitive
398
- * substring match across every column is applied automatically.
399
- * Override the matcher with `matchRow` for fuzzy / weighted search,
400
- * or disable by passing `matchRow: () => true`.
401
- * - **Async mode** (`useDataSource` with `dataSource`): `state.quickSearch`
402
- * is forwarded to the generator as `params.quickSearch`. Same
403
- * mechanism as `params.sorting` — a change triggers a refetch, and
404
- * the generator is the "matching logic" (typically a WHERE / ILIKE
405
- * clause in the backend query). The grid does NO client-side
406
- * filtering in async mode.
407
- *
408
- * ## The canonical pattern
398
+ * The grid is display-only it does not fetch or page data itself. Pair
399
+ * with `useDataSource` for client- or server-side data and pass the
400
+ * already-processed slice through `rows`.
409
401
  *
410
402
  * ```tsx
411
- * // 1. Columns — define OUTSIDE the component or inside a useMemo. Must be stable.
412
- * const columns = React.useMemo(() => [
413
- * { id: "name", header: "Name", accessor: "name", width: 180, type: "string" },
414
- * { id: "email", header: "Email", accessor: "email", width: 240, type: "string" },
415
- * { id: "role", header: "Role", accessor: "role", width: 120, type: "singleSelect",
416
- * valueOptions: [{ value: "admin", label: "Admin" }, { value: "member", label: "Member" }] },
417
- * { id: "signUps", header: "Sign-ups", accessor: "signUps", width: 120, type: "number", align: "right",
418
- * renderCell: ({ value }) => <span className="tabular-nums">{Number(value).toLocaleString()}</span> },
419
- * ], []);
420
- *
421
- * // 2. Grid state — one hook, initialized from the columns. NEVER build the state object by hand.
422
- * const [gridState, setGridState] = React.useState(() => createDefaultDataGridState(columns));
423
- *
424
- * // 3. Data source — wires your raw array through the grid state. ALWAYS call this
425
- * // hook unconditionally at the top level (no if/return before it).
403
+ * const columns = useMemo(() => [...], []);
404
+ * const [gridState, setGridState] = useState(() => createDefaultDataGridState(columns));
426
405
  * const gridData = useDataSource({
427
- * data: users, // your raw array (can be [] while loading)
428
- * columns,
429
- * getRowId: (row) => row.id,
406
+ * data: users, columns, getRowId: (r) => r.id,
430
407
  * sorting: gridState.sorting,
431
408
  * quickSearch: gridState.quickSearch,
432
409
  * pagination: gridState.pagination,
433
- * paginationMode: "client", // "client" | "server" | "infinite"
410
+ * paginationMode: "client",
434
411
  * });
435
412
  *
436
- * // 4. Render — `rows` comes from gridData.rows, NOT from your raw array.
437
413
  * <DataGrid
438
414
  * columns={columns}
439
415
  * rows={gridData.rows}
440
- * getRowId={(row) => row.id}
416
+ * getRowId={(r) => r.id}
441
417
  * totalRowCount={gridData.totalRowCount}
442
418
  * isLoading={gridData.isLoading}
443
419
  * state={gridState}
444
420
  * onChange={setGridState}
445
- * selectionMode="none" // "none" | "single" | "multiple"
446
- * maxHeight={480}
447
421
  * />
448
422
  * ```
449
423
  *
450
- * ## Iron rules (violating any of these breaks the grid)
451
- *
452
- * 1. The prop is `rows`, NOT `data`. There is no `data` prop on DataGrid.
453
- * `data` belongs on `useDataSource`.
454
- * 2. `rows` is ALWAYS `gridData.rows`. Never pass your raw array to
455
- * `rows` — the grid won't search, sort, or paginate it.
456
- * 3. Columns must be stable across renders. Define them outside the
457
- * component or wrap in `React.useMemo`. A fresh columns array every
458
- * render will reset sorting state.
459
- * 4. Initialize grid state with `createDefaultDataGridState(columns)`.
460
- * Do NOT spell out the state object manually — you will miss fields
461
- * and crash.
462
- * 5. `onChange` takes a `SetStateAction` (the setter you got from
463
- * `useState`). Pass `setGridState` directly. Do NOT wrap it unless
464
- * you know exactly what you're doing.
465
- * 6. Call `useDataSource` ONCE per grid, at the top level, before any
466
- * early return. It contains hooks.
467
- * 7. `renderCell` is a PURE function of its context. NEVER call React
468
- * hooks inside it (no `useState`, `useMemo`, `useEffect`, nothing).
469
- * If you need derived data per row, compute it BEFORE the render —
470
- * e.g. build a `Map<rowId, sparklineData>` in a `useMemo` and look
471
- * it up in `renderCell`.
472
- * 8. `toolbar` accepts `false` (hide it) or a render function
473
- * `(ctx) => ReactNode`. Anything else — `true`, `undefined`, a state
474
- * variable — will either show the default toolbar or crash. If you
475
- * just want the default toolbar, omit the prop entirely.
476
- * 9. The toolbar's search input writes to `state.quickSearch`. That
477
- * value is consumed by `useDataSource` — client mode filters
478
- * client-side, async mode forwards to the generator. Do NOT wire
479
- * a separate "controlled" search prop, everything flows through
480
- * grid state.
481
- *
482
- * ## renderCell — what you can and cannot do inside it
483
- *
484
- * ```tsx
485
- * // OK — pure rendering from ctx:
486
- * renderCell: ({ value }) => <span className="tabular-nums">{Number(value).toLocaleString()}</span>
487
- * renderCell: ({ row }) => <Badge variant={row.active ? "default" : "outline"}>{row.status}</Badge>
488
- *
489
- * // OK — looking up pre-computed data by row id:
490
- * // BEFORE the return, in the component body:
491
- * const sparklinesById = React.useMemo(() => {
492
- * const m = new Map();
493
- * for (const u of users) {
494
- * m.set(u.id, u.recentActivity.map((n, i) => ({ ts: i, values: { primary: n } })));
495
- * }
496
- * return m;
497
- * }, [users]);
498
- * // Then inside the column def:
499
- * renderCell: ({ rowId }) => <MiniSparkline data={sparklinesById.get(rowId) ?? []} />
500
- *
501
- * // NOT OK — hooks inside renderCell:
502
- * renderCell: ({ row }) => {
503
- * const [hovered, setHovered] = React.useState(false); // ← crashes the grid
504
- * const data = React.useMemo(() => ..., []); // ← crashes the grid
505
- * return ...;
506
- * }
507
- *
508
- * // NOT OK — embedding AnalyticsChart (or any other controlled, stateful chart) per row:
509
- * // AnalyticsChart owns its own state, tooltips, zoom, and virtualized data
510
- * // pipeline. Instantiating one per row is expensive and fights the grid's
511
- * // virtualizer. Don't do it.
512
- * ```
513
- *
514
- * ## Sparklines and mini-charts in cells — use raw Recharts
515
- *
516
- * If you want a tiny chart (sparkline, micro bar chart, trend line) inside
517
- * a cell, drop down to raw `Recharts.*` components — they are lightweight
518
- * and stateless, so they render cleanly per row without owning any state.
519
- * Read pre-computed points off the row (or off a `Map<rowId, points>` you
520
- * built in a `useMemo` above) and pass them directly to the Recharts
521
- * primitive. Do NOT wrap them in `DesignChartContainer` or
522
- * `DesignChartCard` inside a cell — those add chrome meant for full-size
523
- * charts.
524
- *
525
- * ```tsx
526
- * // OK — raw Recharts sparkline per row:
527
- * renderCell: ({ rowId }) => {
528
- * const points = sparklinesById.get(rowId) ?? [];
529
- * return (
530
- * <Recharts.ResponsiveContainer width="100%" height={28}>
531
- * <Recharts.LineChart data={points} margin={{ top: 2, right: 2, bottom: 2, left: 2 }}>
532
- * <Recharts.Line type="monotone" dataKey="v" stroke="currentColor" strokeWidth={1.5} dot={false} isAnimationActive={false} />
533
- * </Recharts.LineChart>
534
- * </Recharts.ResponsiveContainer>
535
- * );
536
- * }
537
- * ```
538
- *
539
- * Keep in-cell Recharts configs minimal: no axes, no tooltips, no animation
540
- * (`isAnimationActive={false}`), tight margins, fixed height. The goal is a
541
- * visual summary, not an interactive chart.
542
- *
543
- * ## State shape (from `createDefaultDataGridState`)
544
- *
545
- * ```ts
546
- * {
547
- * sorting: [], // { columnId, direction: "asc" | "desc" }[]
548
- * quickSearch: "", // search input text
549
- * dateDisplay: "relative", // "relative" | "absolute"
550
- * columnVisibility: {}, columnWidths: {...},
551
- * columnPinning: { left: [], right: [] }, columnOrder: [...],
552
- * pagination: { pageIndex: 0, pageSize: 50 },
553
- * selection: { selectedIds: new Set(), anchorId: null },
554
- * }
555
- * ```
556
- *
557
- * Everything is updated through `setGridState` — the toolbar, header,
558
- * and footer all call it for you. You do not need to wire any of this
559
- * manually.
560
- *
561
- * ## Cell overflow and dynamic row heights
562
- *
563
- * By default every cell truncates its content with an ellipsis
564
- * (`cellOverflow: "truncate"`). For columns whose content should wrap
565
- * — badge lists, multi-line text, permission chips — set
566
- * `cellOverflow: "wrap"` on the column definition.
567
- *
568
- * To let rows grow to fit their tallest cell, set `rowHeight="auto"`
569
- * on the grid. The virtualizer will measure each row after render and
570
- * adjust scroll positions accordingly. Pair with `estimatedRowHeight`
571
- * (default 44) for better scroll-position estimates before measurement.
572
- *
573
- * ```tsx
574
- * // Columns: UUIDs truncate, auth-method badges wrap
575
- * const columns = [
576
- * { id: "userId", header: "User ID", width: 130 }, // default truncate
577
- * { id: "auth", header: "Auth methods", width: 150, cellOverflow: "wrap",
578
- * renderCell: ({ row }) => (
579
- * <div className="flex flex-wrap gap-1">
580
- * {row.authTypes.map((t) => <Badge key={t}>{t}</Badge>)}
581
- * </div>
582
- * ),
583
- * },
584
- * ];
585
- *
586
- * <DataGrid columns={columns} rowHeight="auto" estimatedRowHeight={48} ... />
587
- * ```
588
- *
589
- * With a fixed numeric `rowHeight` (the default), `cellOverflow: "wrap"`
590
- * still lets content wrap within the row, but anything exceeding the
591
- * fixed height is clipped. This is useful when you want controlled
592
- * wrapping without variable row heights.
593
- *
594
- * ## Height and scrolling
595
- *
596
- * DataGrid is NOT a card. It has no border, rounded corners, or shadow of
597
- * its own. Wrap it in whatever chrome you want — a `DesignCard`, a section,
598
- * or just raw layout. The grid itself fills its parent's height via
599
- * `h-full`.
600
- *
601
- * How the grid gets its height (pick ONE):
602
- * 1. Bounded parent — put the grid inside a flex/grid container with a
603
- * definite height (e.g. `flex-1 min-h-0` inside a page-filling flex
604
- * column). The grid stretches to that height and scrolls its body.
605
- * 2. `maxHeight` prop — pass a number (pixels) or CSS string
606
- * (`"480px"`, `"60vh"`, `"100%"`). The grid caps at that size and
607
- * scrolls its body.
608
- * 3. Unbounded — omit `maxHeight` and let the parent grow freely. The
609
- * grid renders at its full content height and the page scrolls. Fine
610
- * for small lists; bad UX for thousands of rows.
611
- *
612
- * The toolbar, header, and footer are always `shrink-0`; only the body
613
- * scrolls. You do NOT need to subtract toolbar/footer heights from
614
- * `maxHeight` — the grid's internal flex layout handles that.
615
- *
616
- * ## When to use what
617
- *
618
- * - Simple static list, < 20 rows, no interaction → use a plain table component instead.
619
- * - Interactive table, sortable + searchable, any size → `DataGrid` +
620
- * `useDataSource` with `paginationMode: "client"`.
621
- * - Infinite scroll over a huge dataset you fetch in pages → `dataSource` async
622
- * generator + `paginationMode: "infinite"`. Only reach for this if you actually
623
- * need pagination over a remote source. For anything that fits in memory,
624
- * `"client"` is simpler and faster.
625
- *
626
- * ## Features you get for free
627
- *
628
- * Quick search, sortable columns (shift-click for multi-sort), column
629
- * visibility toggle, column resize, CSV export, virtualized rendering
630
- * for 10k+ rows, keyboard navigation, and a relative/absolute date
631
- * toggle for `date` / `dateTime` columns.
424
+ * Iron rules:
425
+ * - `rows` is always `gridData.rows`, never your raw array.
426
+ * - Columns must be stable (define outside the component or wrap in `useMemo`).
427
+ * - Initialize state with `createDefaultDataGridState(columns)`.
428
+ * - `renderCell` must be a pure function no React hooks inside.
632
429
  */
633
430
  function DataGrid(props) {
634
- const { columns: allColumns, rows, getRowId, totalRowCount, isLoading = false, isRefetching = false, hasMore = false, isLoadingMore = false, onLoadMore, state, onChange, paginationMode = "paginated", selectionMode = "none", resizable = true, rowHeight: rowHeightProp = 44, estimatedRowHeight: estimatedRowHeightProp, headerHeight = 44, overscan = 5, maxHeight, fillHeight = true, stickyTop, toolbar, toolbarExtra, emptyState, loadingState, footer, footerExtra, exportFilename = "export", strings: stringsOverride, className, onRowClick, onRowDoubleClick, onSelectionChange, onSortChange } = props;
431
+ const { columns: allColumns, rows, getRowId, totalRowCount, isLoading = false, isRefetching = false, hasMore = false, isLoadingMore = false, onLoadMore, state, onChange, paginationMode = "paginated", selectionMode = "none", resizable = true, rowHeight: rowHeightProp = 44, estimatedRowHeight: estimatedRowHeightProp, headerHeight = 44, overscan = 5, maxHeight, fillHeight = true, stickyTop, toolbar, toolbarExtra, emptyState, loadingState, footer, footerExtra, exportFilename = "export", strings: stringsOverride, className, onRowClick, onRowDoubleClick, onSelectionChange, onSortChange, onColumnResize, onColumnVisibilityChange } = props;
635
432
  const isDynamicRowHeight = rowHeightProp === "auto";
636
433
  const fixedRowHeight = isDynamicRowHeight ? void 0 : rowHeightProp;
637
434
  const estimatedRowHeight = estimatedRowHeightProp ?? fixedRowHeight ?? 44;
638
435
  const strings = useMemo(() => resolveDataGridStrings(stringsOverride), [stringsOverride]);
639
- const visibleColumns = useMemo(() => (state.columnOrder.length > 0 ? state.columnOrder.map((id) => allColumns.find((c) => c.id === id)).filter(Boolean) : allColumns).filter((col) => isColumnVisible(col.id, state.columnVisibility)), [
640
- allColumns,
641
- state.columnOrder,
642
- state.columnVisibility
436
+ const tableColumns = useMemo(() => allColumns.map((col) => ({
437
+ id: col.id,
438
+ accessorFn: (row) => resolveColumnValue(col, row),
439
+ header: typeof col.header === "string" ? col.header : col.id,
440
+ size: col.width ?? DEFAULT_COL_WIDTH,
441
+ minSize: getEffectiveMinWidth(col),
442
+ maxSize: getEffectiveMaxWidth(col),
443
+ enableSorting: col.sortable !== false,
444
+ enableHiding: col.hideable !== false,
445
+ enableResizing: col.resizable !== false,
446
+ enableMultiSort: true
447
+ })), [allColumns]);
448
+ const tanstackSorting = useMemo(() => toTanstackSorting(state.sorting), [state.sorting]);
449
+ const tanstackRowSelection = useMemo(() => toTanstackRowSelection(state.selection.selectedIds), [state.selection.selectedIds]);
450
+ const tanstackColumnPinning = useMemo(() => ({
451
+ left: [...state.columnPinning.left],
452
+ right: [...state.columnPinning.right]
453
+ }), [state.columnPinning]);
454
+ const tanstackColumnOrder = useMemo(() => [...state.columnOrder], [state.columnOrder]);
455
+ const allColumnsRef = useRef(allColumns);
456
+ allColumnsRef.current = allColumns;
457
+ const handleSortingChange = useCallback((updater) => {
458
+ const ours = fromTanstackSorting(resolveUpdater(updater, toTanstackSorting(state.sorting))).map((s) => ({ ...s }));
459
+ onChange((s) => ({
460
+ ...s,
461
+ sorting: ours,
462
+ pagination: {
463
+ ...s.pagination,
464
+ pageIndex: 0
465
+ }
466
+ }));
467
+ onSortChange?.(ours);
468
+ }, [
469
+ onChange,
470
+ onSortChange,
471
+ state.sorting
643
472
  ]);
644
- const rowIds = useMemo(() => rows.map(getRowId), [rows, getRowId]);
645
- const visibleColumnMetrics = useMemo(() => {
646
- const widths = /* @__PURE__ */ new Map();
647
- let totalWidth = selectionMode !== "none" ? 44 : 0;
648
- for (const col of visibleColumns) {
649
- const width = resolveColumnWidth(col, state.columnWidths[col.id]);
650
- widths.set(col.id, width);
651
- totalWidth += width;
473
+ const handleColumnSizingChange = useCallback((updater) => {
474
+ const next = resolveUpdater(updater, state.columnWidths);
475
+ const clamped = {};
476
+ for (const [id, w] of Object.entries(next)) {
477
+ const col = allColumnsRef.current.find((c) => c.id === id);
478
+ clamped[id] = col ? clampColumnWidth(col, w) : w;
479
+ }
480
+ onChange((s) => ({
481
+ ...s,
482
+ columnWidths: clamped
483
+ }));
484
+ if (onColumnResize) {
485
+ for (const [id, w] of Object.entries(clamped)) if (state.columnWidths[id] !== w) onColumnResize(id, w);
652
486
  }
653
- return {
654
- widths,
655
- totalWidth
656
- };
657
487
  }, [
658
- selectionMode,
659
- state.columnWidths,
660
- visibleColumns
488
+ onChange,
489
+ onColumnResize,
490
+ state.columnWidths
661
491
  ]);
662
- const gridSizingStyle = useMemo(() => createGridSizingStyle(visibleColumnMetrics.widths, visibleColumnMetrics.totalWidth), [visibleColumnMetrics]);
663
- const resizeRef = useRef(null);
664
- const gridRef = useRef(null);
665
- const handleSort = useCallback((columnId, multi) => {
666
- const next = toggleSort(state.sorting, columnId, multi);
492
+ const handleVisibilityChange = useCallback((updater) => {
493
+ const next = resolveUpdater(updater, state.columnVisibility);
667
494
  onChange((s) => ({
668
495
  ...s,
669
- sorting: next
496
+ columnVisibility: next
670
497
  }));
671
- onSortChange?.(next);
498
+ onColumnVisibilityChange?.(next);
672
499
  }, [
673
500
  onChange,
674
- onSortChange,
675
- state.sorting
501
+ onColumnVisibilityChange,
502
+ state.columnVisibility
676
503
  ]);
677
- const handleResize = useCallback((columnId, delta) => {
678
- const col = allColumns.find((c) => c.id === columnId);
679
- if (!col) return;
680
- if (!resizeRef.current || resizeRef.current.columnId !== columnId) {
681
- const baseWidth = visibleColumnMetrics.widths.get(columnId) ?? resolveColumnWidth(col, state.columnWidths[columnId]);
682
- resizeRef.current = {
683
- columnId,
684
- baseWidth,
685
- baseTotalWidth: visibleColumnMetrics.totalWidth,
686
- latestWidth: baseWidth
687
- };
688
- }
689
- const newWidth = clampColumnWidth(col, resizeRef.current.baseWidth + delta);
690
- resizeRef.current.latestWidth = newWidth;
691
- if (gridRef.current) applyDraggedColumnWidth(gridRef.current, columnId, newWidth, resizeRef.current.baseTotalWidth + (newWidth - resizeRef.current.baseWidth));
504
+ const handleColumnOrderChange = useCallback((updater) => {
505
+ const next = resolveUpdater(updater, [...state.columnOrder]);
506
+ onChange((s) => ({
507
+ ...s,
508
+ columnOrder: next
509
+ }));
510
+ }, [onChange, state.columnOrder]);
511
+ const handleColumnPinningChange = useCallback((updater) => {
512
+ const next = resolveUpdater(updater, {
513
+ left: [...state.columnPinning.left],
514
+ right: [...state.columnPinning.right]
515
+ });
516
+ onChange((s) => ({
517
+ ...s,
518
+ columnPinning: {
519
+ left: next.left ?? [],
520
+ right: next.right ?? []
521
+ }
522
+ }));
523
+ }, [onChange, state.columnPinning]);
524
+ const table = useReactTable({
525
+ data: rows,
526
+ columns: tableColumns,
527
+ getRowId: (row) => getRowId(row),
528
+ getCoreRowModel: getCoreRowModel(),
529
+ state: {
530
+ sorting: tanstackSorting,
531
+ columnVisibility: state.columnVisibility,
532
+ columnSizing: state.columnWidths,
533
+ columnOrder: tanstackColumnOrder,
534
+ columnPinning: tanstackColumnPinning,
535
+ rowSelection: tanstackRowSelection
536
+ },
537
+ onSortingChange: handleSortingChange,
538
+ onColumnSizingChange: handleColumnSizingChange,
539
+ onColumnVisibilityChange: handleVisibilityChange,
540
+ onColumnOrderChange: handleColumnOrderChange,
541
+ onColumnPinningChange: handleColumnPinningChange,
542
+ columnResizeMode: "onEnd",
543
+ enableRowSelection: selectionMode !== "none",
544
+ enableMultiRowSelection: selectionMode === "multiple",
545
+ enableColumnResizing: resizable,
546
+ manualSorting: true,
547
+ manualPagination: true,
548
+ manualFiltering: true
549
+ });
550
+ const visibleColumns = useMemo(() => {
551
+ const colMap = new Map(allColumns.map((c) => [c.id, c]));
552
+ return table.getVisibleLeafColumns().map((c) => colMap.get(c.id)).filter(Boolean);
692
553
  }, [
693
554
  allColumns,
694
- state.columnWidths,
695
- visibleColumnMetrics
555
+ table,
556
+ state.columnOrder,
557
+ state.columnVisibility
696
558
  ]);
559
+ const rowIds = useMemo(() => rows.map(getRowId), [rows, getRowId]);
560
+ const [containerWidth, setContainerWidth] = useState(0);
697
561
  useLayoutEffect(() => {
698
- const r = resizeRef.current;
699
- if (r && gridRef.current) applyDraggedColumnWidth(gridRef.current, r.columnId, r.latestWidth, r.baseTotalWidth + (r.latestWidth - r.baseWidth));
700
- }, [gridSizingStyle]);
701
- const handleResizeEnd = useCallback(() => {
702
- const r = resizeRef.current;
703
- resizeRef.current = null;
704
- if (!r || r.latestWidth === r.baseWidth) return;
562
+ const grid = gridRef.current;
563
+ const scroller = scrollContainerRef.current;
564
+ if (!grid) return;
565
+ const update = () => {
566
+ const w = scroller?.clientWidth ?? grid.clientWidth;
567
+ if (w > 0) setContainerWidth(w);
568
+ };
569
+ update();
570
+ const observer = new ResizeObserver(update);
571
+ observer.observe(grid);
572
+ if (scroller) observer.observe(scroller);
573
+ return () => observer.disconnect();
574
+ }, []);
575
+ const columnSizingInfo = table.getState().columnSizingInfo;
576
+ const columnSizes = useMemo(() => {
577
+ const sizes = {};
578
+ let baseTotal = selectionMode !== "none" ? 44 : 0;
579
+ const resizingId = columnSizingInfo.isResizingColumn || null;
580
+ const deltaOffset = columnSizingInfo.deltaOffset ?? 0;
581
+ for (const col of visibleColumns) {
582
+ const baseSize = table.getColumn(col.id)?.getSize() ?? col.width ?? DEFAULT_COL_WIDTH;
583
+ const liveSize = resizingId === col.id ? clampColumnWidth(col, baseSize + deltaOffset) : baseSize;
584
+ sizes[col.id] = liveSize;
585
+ baseTotal += liveSize;
586
+ }
587
+ distributeFlexWidths(sizes, visibleColumns, containerWidth - baseTotal);
588
+ return sizes;
589
+ }, [
590
+ visibleColumns,
591
+ table,
592
+ columnSizingInfo,
593
+ state.columnWidths,
594
+ containerWidth,
595
+ selectionMode
596
+ ]);
597
+ const totalContentWidth = useMemo(() => {
598
+ let total = selectionMode !== "none" ? 44 : 0;
599
+ for (const col of visibleColumns) total += columnSizes[col.id] ?? 0;
600
+ return total;
601
+ }, [
602
+ visibleColumns,
603
+ columnSizes,
604
+ selectionMode
605
+ ]);
606
+ const cssVars = useMemo(() => {
607
+ const vars = { "--grid-total-w": `${totalContentWidth}px` };
608
+ for (const col of visibleColumns) vars[`--col-${col.id}-size`] = columnSizes[col.id];
609
+ return vars;
610
+ }, [
611
+ visibleColumns,
612
+ columnSizes,
613
+ totalContentWidth
614
+ ]);
615
+ const fireSelection = useCallback((next) => {
705
616
  onChange((s) => ({
706
617
  ...s,
707
- columnWidths: {
708
- ...s.columnWidths,
709
- [r.columnId]: r.latestWidth
710
- }
618
+ selection: next
711
619
  }));
712
- }, [onChange]);
713
- const handleRowClick = useCallback((row, rowId, event) => {
714
- if (selectionMode !== "none") {
715
- const next = toggleRowSelection(state.selection, rowId, selectionMode, event.shiftKey, event.metaKey || event.ctrlKey, rowIds);
716
- onChange((s) => ({
717
- ...s,
718
- selection: next
719
- }));
720
- if (onSelectionChange) {
721
- const selectedRows = rows.filter((r) => next.selectedIds.has(getRowId(r)));
722
- onSelectionChange(next.selectedIds, selectedRows);
723
- }
620
+ if (onSelectionChange) {
621
+ const idSet = next.selectedIds;
622
+ onSelectionChange(idSet, rows.filter((r) => idSet.has(getRowId(r))));
724
623
  }
725
- onRowClick?.(row, rowId, event);
726
624
  }, [
727
- selectionMode,
728
625
  onChange,
729
- onRowClick,
730
626
  onSelectionChange,
731
- rowIds,
732
627
  rows,
733
- getRowId,
734
- state.selection
628
+ getRowId
735
629
  ]);
736
- const handleRowSelectionCheckboxClick = useCallback((row, rowId, event) => {
737
- handleRowClick(row, rowId, event);
738
- }, [handleRowClick]);
739
- const handleSelectAll = useCallback(() => {
740
- const allSelectedNow = rowIds.every((id) => state.selection.selectedIds.has(id));
741
- const next = allSelectedNow ? clearSelection() : selectAll(rowIds);
742
- const selectedRows = allSelectedNow ? [] : rows;
743
- onChange((s) => ({
744
- ...s,
745
- selection: next
630
+ const handleRowClick = useCallback((row, rowId, event) => {
631
+ if (selectionMode !== "none") fireSelection(nextSelection({
632
+ current: state.selection,
633
+ rowId,
634
+ mode: selectionMode,
635
+ modifiers: {
636
+ shift: event.shiftKey,
637
+ ctrl: event.metaKey || event.ctrlKey
638
+ },
639
+ allRowIds: rowIds
746
640
  }));
747
- if (onSelectionChange) onSelectionChange(next.selectedIds, [...selectedRows]);
641
+ onRowClick?.(row, rowId, event);
748
642
  }, [
749
- onChange,
643
+ selectionMode,
644
+ state.selection,
750
645
  rowIds,
751
- rows,
752
- onSelectionChange,
753
- state.selection
646
+ fireSelection,
647
+ onRowClick
648
+ ]);
649
+ const handleSelectAll = useCallback(() => {
650
+ fireSelection(rowIds.length > 0 && rowIds.every((id) => state.selection.selectedIds.has(id)) ? {
651
+ selectedIds: /* @__PURE__ */ new Set(),
652
+ anchorId: null
653
+ } : {
654
+ selectedIds: new Set(rowIds),
655
+ anchorId: null
656
+ });
657
+ }, [
658
+ rowIds,
659
+ state.selection.selectedIds,
660
+ fireSelection
754
661
  ]);
755
662
  const handleExportCsv = useCallback(() => {
663
+ if (typeof window !== "undefined" && rows.length > 0) {
664
+ const totalSuffix = totalRowCount != null && totalRowCount > rows.length ? ` of ${totalRowCount} total — load more rows first to include them` : "";
665
+ if (!window.confirm(`Export ${rows.length.toLocaleString()} loaded row${rows.length === 1 ? "" : "s"}${totalSuffix}?`)) return;
666
+ }
756
667
  exportToCsv(rows, visibleColumns, exportFilename);
757
668
  }, [
758
669
  rows,
759
670
  visibleColumns,
760
- exportFilename
671
+ exportFilename,
672
+ totalRowCount
761
673
  ]);
762
674
  const scrollContainerRef = useRef(null);
763
675
  const headerScrollRef = useRef(null);
764
676
  const stickyChromeRef = useRef(null);
765
677
  const rowsClipRef = useRef(null);
678
+ const gridRef = useRef(null);
766
679
  const measureElementFn = useCallback((el) => el.getBoundingClientRect().height, []);
767
680
  const rowVirtualizer = useVirtualizer({
768
681
  count: rows.length,
@@ -775,104 +688,33 @@ function DataGrid(props) {
775
688
  },
776
689
  ...isDynamicRowHeight ? { measureElement: measureElementFn } : {}
777
690
  });
778
- useLayoutEffect(() => {
779
- const grid = gridRef.current;
780
- const stickyEl = stickyChromeRef.current;
781
- if (!grid || !stickyEl) return;
782
- const parseRgba = (raw) => {
783
- const rgbaMatch = raw.match(/rgba?\(\s*([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\s*\)/);
784
- if (!rgbaMatch) return null;
785
- const alphaRaw = rgbaMatch[4];
786
- return [
787
- Number(rgbaMatch[1]),
788
- Number(rgbaMatch[2]),
789
- Number(rgbaMatch[3]),
790
- alphaRaw === void 0 ? 1 : Number(alphaRaw)
791
- ];
792
- };
793
- const blendOver = (base, top) => {
794
- const [tr, tg, tb, ta] = top;
795
- const [br, bg, bb, ba] = base;
796
- const outA = ta + ba * (1 - ta);
797
- if (outA === 0) return [
798
- 0,
799
- 0,
800
- 0,
801
- 0
802
- ];
803
- return [
804
- (tr * ta + br * ba * (1 - ta)) / outA,
805
- (tg * ta + bg * ba * (1 - ta)) / outA,
806
- (tb * ta + bb * ba * (1 - ta)) / outA,
807
- outA
808
- ];
809
- };
810
- const detect = () => {
811
- const layers = [];
812
- let ancestor = grid.parentElement;
813
- while (ancestor) {
814
- const parsed = parseRgba(getComputedStyle(ancestor).backgroundColor);
815
- if (parsed && parsed[3] > 0) {
816
- layers.push(parsed);
817
- if (parsed[3] >= 1) break;
818
- }
819
- ancestor = ancestor.parentElement;
820
- }
821
- if (layers.length === 0) {
822
- stickyEl.style.backgroundColor = "";
823
- return;
824
- }
825
- let result = layers[layers.length - 1];
826
- for (let i = layers.length - 2; i >= 0; i--) result = blendOver(result, layers[i]);
827
- const [r, g, b] = result;
828
- stickyEl.style.backgroundColor = `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`;
829
- };
830
- detect();
831
- const observer = new MutationObserver(detect);
832
- observer.observe(document.documentElement, {
833
- attributes: true,
834
- attributeFilter: ["class"]
835
- });
836
- return () => observer.disconnect();
837
- }, []);
838
691
  useLayoutEffect(() => {
839
692
  const gridEl = gridRef.current;
840
693
  const stickyEl = stickyChromeRef.current;
841
694
  const bodyEl = scrollContainerRef.current;
842
695
  const clipEl = rowsClipRef.current;
843
696
  if (!gridEl || !stickyEl || !bodyEl || !clipEl) return;
844
- const verticalScrollEl = fillHeight ? bodyEl : getNearestVerticalScrollElement(gridEl);
845
- let extraObservedScrollEl = null;
846
- if (verticalScrollEl instanceof HTMLElement && verticalScrollEl !== bodyEl) extraObservedScrollEl = verticalScrollEl;
847
697
  const updateClip = () => {
848
698
  const stickyRect = stickyEl.getBoundingClientRect();
849
699
  const clipRect = clipEl.getBoundingClientRect();
850
700
  const overlap = Math.max(0, stickyRect.bottom - clipRect.top);
851
- const clipValue = overlap > 0 ? `inset(${overlap}px 0 0 0)` : "";
852
- const maskValue = overlap > 0 ? `linear-gradient(to bottom, transparent 0px, transparent ${overlap}px, black ${overlap}px, black 100%)` : "";
853
- clipEl.style.clipPath = clipValue;
854
- clipEl.style.setProperty("-webkit-clip-path", clipValue);
855
- clipEl.style.maskImage = maskValue;
856
- clipEl.style.setProperty("-webkit-mask-image", maskValue);
701
+ clipEl.style.setProperty("--data-grid-sticky-overlap", `${overlap}px`);
857
702
  };
858
703
  updateClip();
859
704
  bodyEl.addEventListener("scroll", updateClip);
860
- if (verticalScrollEl === window) window.addEventListener("scroll", updateClip, true);
861
- else if (extraObservedScrollEl) extraObservedScrollEl.addEventListener("scroll", updateClip);
705
+ window.addEventListener("scroll", updateClip, true);
862
706
  window.addEventListener("resize", updateClip);
863
- const ro = new ResizeObserver(updateClip);
864
- ro.observe(gridEl);
865
- ro.observe(stickyEl);
866
- ro.observe(bodyEl);
867
- if (extraObservedScrollEl) ro.observe(extraObservedScrollEl);
707
+ const observer = new ResizeObserver(updateClip);
708
+ observer.observe(gridEl);
709
+ observer.observe(stickyEl);
710
+ observer.observe(bodyEl);
868
711
  return () => {
869
712
  bodyEl.removeEventListener("scroll", updateClip);
870
- if (verticalScrollEl === window) window.removeEventListener("scroll", updateClip, true);
871
- else if (extraObservedScrollEl) extraObservedScrollEl.removeEventListener("scroll", updateClip);
713
+ window.removeEventListener("scroll", updateClip, true);
872
714
  window.removeEventListener("resize", updateClip);
873
- ro.disconnect();
715
+ observer.disconnect();
874
716
  };
875
- }, [fillHeight]);
717
+ }, []);
876
718
  const handleBodyScroll = useCallback(() => {
877
719
  const body = scrollContainerRef.current;
878
720
  const header = headerScrollRef.current;
@@ -913,21 +755,28 @@ function DataGrid(props) {
913
755
  const allSelected = rowIds.length > 0 && rowIds.every((id) => state.selection.selectedIds.has(id));
914
756
  const someSelected = !allSelected && rowIds.some((id) => state.selection.selectedIds.has(id));
915
757
  const infiniteScrollRootRef = paginationMode === "infinite" && (fillHeight || maxHeight != null) ? scrollContainerRef : void 0;
758
+ const headers = useMemo(() => table.getHeaderGroups()[0]?.headers.filter((h) => visibleColumns.some((c) => c.id === h.column.id)) ?? [], [table, visibleColumns]);
759
+ const headerByColId = useMemo(() => {
760
+ const m = /* @__PURE__ */ new Map();
761
+ for (const h of headers) m.set(h.column.id, h);
762
+ return m;
763
+ }, [headers]);
764
+ const isBounded = fillHeight || maxHeight != null;
916
765
  return /* @__PURE__ */ jsxs("div", {
917
766
  ref: gridRef,
918
- className: cn("flex w-full min-w-0 max-w-full flex-col bg-transparent rounded-[calc(var(--radius)*2)]", fillHeight ? "min-h-0 h-full" : "min-h-0 h-auto", className),
767
+ className: cn("isolate flex w-full min-w-0 max-w-full flex-col bg-transparent rounded-[calc(var(--radius)*2)]", fillHeight ? "min-h-0 h-full" : "min-h-0 h-auto", isBounded && "overflow-hidden", className),
919
768
  style: maxHeight != null ? {
920
- ...gridSizingStyle,
769
+ ...cssVars,
921
770
  maxHeight
922
- } : gridSizingStyle,
771
+ } : cssVars,
923
772
  role: "grid",
924
773
  "aria-rowcount": totalRowCount ?? rows.length,
925
774
  "aria-colcount": visibleColumns.length,
926
775
  children: [
927
776
  /* @__PURE__ */ jsxs("div", {
928
777
  ref: stickyChromeRef,
929
- className: "sticky z-20 w-full min-w-0 shrink-0 rounded-t-[calc(var(--radius)*2)] bg-background",
930
- style: { top: stickyTop ?? "var(--data-grid-sticky-top, 0px)" },
778
+ className: "sticky z-30 w-full min-w-0 shrink-0 overflow-visible rounded-t-[calc(var(--radius)*2)] bg-white/90 dark:bg-background/60 backdrop-blur-xl",
779
+ style: { top: stickyTop ?? (maxHeight != null ? 0 : "var(--data-grid-sticky-top, 0px)") },
931
780
  children: [toolbar !== false && /* @__PURE__ */ jsx("div", {
932
781
  className: "relative bg-transparent",
933
782
  children: toolbar ? toolbar(toolbarCtx) : /* @__PURE__ */ jsx(DataGridToolbar, {
@@ -946,7 +795,7 @@ function DataGrid(props) {
946
795
  className: "flex",
947
796
  style: {
948
797
  height: headerHeight,
949
- minWidth: visibleColumnMetrics.totalWidth
798
+ minWidth: totalContentWidth
950
799
  },
951
800
  role: "row",
952
801
  children: [selectionMode !== "none" && /* @__PURE__ */ jsx("div", {
@@ -956,30 +805,37 @@ function DataGrid(props) {
956
805
  checked: allSelected,
957
806
  indeterminate: someSelected,
958
807
  onChange: handleSelectAll,
959
- ariaLabel: "Select all rows"
808
+ ariaLabel: "Select all rows on this page",
809
+ title: "Select all rows on this page"
960
810
  })
961
- }), visibleColumns.map((col) => /* @__PURE__ */ jsx(HeaderCell, {
962
- col,
963
- isSorted: getSortDirection(state.sorting, col.id),
964
- sortIndex: getSortIndex(state.sorting, col.id),
965
- resizable,
966
- onSort: handleSort,
967
- onResize: handleResize,
968
- onResizeEnd: handleResizeEnd
969
- }, col.id))]
811
+ }), visibleColumns.map((col) => {
812
+ const header = headerByColId.get(col.id);
813
+ if (!header) return null;
814
+ return /* @__PURE__ */ jsx(HeaderCell, {
815
+ header,
816
+ col,
817
+ resizable
818
+ }, col.id);
819
+ })]
970
820
  })
971
821
  })]
972
822
  })]
973
823
  }),
974
824
  /* @__PURE__ */ jsx("div", {
975
825
  ref: scrollContainerRef,
976
- className: cn("w-full min-w-0 overflow-auto bg-transparent", fillHeight ? "min-h-0 flex-1" : "flex-none", "[&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar]:h-1.5", "[&::-webkit-scrollbar-track]:bg-transparent", "[&::-webkit-scrollbar-thumb]:bg-foreground/[0.08] [&::-webkit-scrollbar-thumb]:rounded-full", "[&::-webkit-scrollbar-thumb]:hover:bg-foreground/[0.15]"),
826
+ className: cn("relative z-0 w-full min-w-0 overflow-auto bg-transparent", isBounded ? "min-h-0 flex-1" : "flex-none", "[&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar]:h-1.5", "[&::-webkit-scrollbar-track]:bg-transparent", "[&::-webkit-scrollbar-thumb]:bg-foreground/[0.08] [&::-webkit-scrollbar-thumb]:rounded-full", "[&::-webkit-scrollbar-thumb]:hover:bg-foreground/[0.15]"),
977
827
  onScroll: handleBodyScroll,
978
828
  children: /* @__PURE__ */ jsxs("div", {
979
829
  ref: rowsClipRef,
830
+ "data-data-grid-rows-clip": "",
831
+ className: "relative z-0",
832
+ style: {
833
+ minWidth: totalContentWidth,
834
+ clipPath: "inset(var(--data-grid-sticky-overlap, 0px) 0 0 0)"
835
+ },
980
836
  children: [
981
837
  isLoading && /* @__PURE__ */ jsx("div", {
982
- style: { minWidth: visibleColumnMetrics.totalWidth },
838
+ style: { minWidth: totalContentWidth },
983
839
  children: loadingState ?? Array.from({ length: 8 }).map((_, i) => /* @__PURE__ */ jsx(SkeletonRow, {
984
840
  columns: visibleColumns,
985
841
  height: estimatedRowHeight,
@@ -988,14 +844,14 @@ function DataGrid(props) {
988
844
  }),
989
845
  !isLoading && rows.length === 0 && /* @__PURE__ */ jsx("div", {
990
846
  className: "flex items-center justify-center py-16 text-sm text-muted-foreground",
991
- style: { minWidth: visibleColumnMetrics.totalWidth },
847
+ style: { minWidth: totalContentWidth },
992
848
  children: emptyState ?? strings.noData
993
849
  }),
994
850
  !isLoading && rows.length > 0 && /* @__PURE__ */ jsx("div", {
995
851
  style: {
996
852
  height: rowVirtualizer.getTotalSize(),
997
853
  width: "100%",
998
- minWidth: visibleColumnMetrics.totalWidth,
854
+ minWidth: totalContentWidth,
999
855
  position: "relative"
1000
856
  },
1001
857
  children: rowVirtualizer.getVirtualItems().map((virtualRow) => {
@@ -1012,12 +868,10 @@ function DataGrid(props) {
1012
868
  transform: `translateY(${virtualRow.start}px)`
1013
869
  },
1014
870
  onClick: (e) => {
1015
- if (shouldIgnoreRowClick(e)) return;
1016
- handleRowClick(row, rowId, e);
871
+ if (!shouldIgnoreRowClick(e)) handleRowClick(row, rowId, e);
1017
872
  },
1018
873
  onDoubleClick: (e) => {
1019
- if (shouldIgnoreRowClick(e)) return;
1020
- onRowDoubleClick?.(row, rowId, e);
874
+ if (!shouldIgnoreRowClick(e)) onRowDoubleClick?.(row, rowId, e);
1021
875
  },
1022
876
  role: "row",
1023
877
  "aria-rowindex": virtualRow.index + 2,
@@ -1029,7 +883,7 @@ function DataGrid(props) {
1029
883
  style: { width: 44 },
1030
884
  children: /* @__PURE__ */ jsx(SelectionCheckbox, {
1031
885
  checked: isSelected,
1032
- onChange: (event) => handleRowSelectionCheckboxClick(row, rowId, event),
886
+ onChange: (event) => handleRowClick(row, rowId, event),
1033
887
  ariaLabel: `Select row ${rowId}`
1034
888
  })
1035
889
  }), visibleColumns.map((col) => /* @__PURE__ */ jsx(DataCell, {
@@ -1053,7 +907,7 @@ function DataGrid(props) {
1053
907
  })
1054
908
  }),
1055
909
  footer !== false && /* @__PURE__ */ jsxs("div", {
1056
- className: "relative z-10 shrink-0 bg-transparent",
910
+ className: "sticky bottom-0 z-30 shrink-0 overflow-hidden rounded-b-[calc(var(--radius)*2)] bg-white/90 dark:bg-background/60 backdrop-blur-xl",
1057
911
  children: [footer ? footer(footerCtx) : /* @__PURE__ */ jsx(DefaultFooter, {
1058
912
  ctx: footerCtx,
1059
913
  pagination: paginationMode,