@optilogic/core 1.0.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/dist/index.cjs +6003 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2310 -0
- package/dist/index.d.ts +2310 -0
- package/dist/index.js +5828 -0
- package/dist/index.js.map +1 -0
- package/dist/styles.css +96 -0
- package/dist/tailwind-preset.cjs +106 -0
- package/dist/tailwind-preset.cjs.map +1 -0
- package/dist/tailwind-preset.d.cts +23 -0
- package/dist/tailwind-preset.d.ts +23 -0
- package/dist/tailwind-preset.js +101 -0
- package/dist/tailwind-preset.js.map +1 -0
- package/package.json +154 -0
- package/src/components/accordion.tsx +187 -0
- package/src/components/alert-dialog.tsx +143 -0
- package/src/components/autocomplete.tsx +271 -0
- package/src/components/badge.tsx +62 -0
- package/src/components/button.tsx +85 -0
- package/src/components/calendar.tsx +235 -0
- package/src/components/card.tsx +94 -0
- package/src/components/checkbox.tsx +77 -0
- package/src/components/chip.tsx +77 -0
- package/src/components/confirmation-modal.tsx +195 -0
- package/src/components/context-menu.tsx +406 -0
- package/src/components/copy-button.tsx +84 -0
- package/src/components/data-grid/DataGrid.tsx +1027 -0
- package/src/components/data-grid/components/CellEditor.tsx +346 -0
- package/src/components/data-grid/components/FilterPopover.tsx +459 -0
- package/src/components/data-grid/components/HeaderCell.tsx +207 -0
- package/src/components/data-grid/components/index.ts +14 -0
- package/src/components/data-grid/hooks/index.ts +28 -0
- package/src/components/data-grid/hooks/useColumnResize.ts +378 -0
- package/src/components/data-grid/hooks/useDataGridState.ts +346 -0
- package/src/components/data-grid/hooks/useKeyboardNavigation.ts +361 -0
- package/src/components/data-grid/index.ts +71 -0
- package/src/components/data-grid/types.ts +478 -0
- package/src/components/data-grid/utils/dataProcessing.ts +277 -0
- package/src/components/data-grid/utils/index.ts +12 -0
- package/src/components/date-picker.tsx +366 -0
- package/src/components/dropdown-menu.tsx +230 -0
- package/src/components/icon-button.tsx +157 -0
- package/src/components/input.tsx +40 -0
- package/src/components/label.tsx +37 -0
- package/src/components/loading-spinner.tsx +113 -0
- package/src/components/modal.tsx +207 -0
- package/src/components/popover.tsx +62 -0
- package/src/components/progress.tsx +41 -0
- package/src/components/resizable-panel.tsx +434 -0
- package/src/components/resize-handle.tsx +187 -0
- package/src/components/select.tsx +160 -0
- package/src/components/separator.tsx +50 -0
- package/src/components/skeleton.tsx +37 -0
- package/src/components/switch.tsx +59 -0
- package/src/components/table.tsx +136 -0
- package/src/components/tabs.tsx +102 -0
- package/src/components/textarea.tsx +36 -0
- package/src/components/theme-picker.tsx +245 -0
- package/src/components/toaster.tsx +84 -0
- package/src/components/tooltip.tsx +199 -0
- package/src/index.ts +318 -0
- package/src/styles.css +96 -0
- package/src/tailwind-preset.ts +129 -0
- package/src/theme/index.ts +41 -0
- package/src/theme/presets.ts +502 -0
- package/src/theme/types.ts +164 -0
- package/src/theme/utils.ts +309 -0
- package/src/utils/cn.ts +14 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useColumnResize Hook
|
|
3
|
+
*
|
|
4
|
+
* Handles column resizing logic with:
|
|
5
|
+
* - Mouse drag support
|
|
6
|
+
* - Min/max width constraints
|
|
7
|
+
* - Callbacks for resize start/end
|
|
8
|
+
* - Double-click to auto-fit (optional)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useState, useCallback, useRef, useEffect } from "react";
|
|
12
|
+
import type { ColumnDef } from "../types";
|
|
13
|
+
|
|
14
|
+
/** Default minimum column width */
|
|
15
|
+
const DEFAULT_MIN_WIDTH = 50;
|
|
16
|
+
|
|
17
|
+
/** Default maximum column width */
|
|
18
|
+
const DEFAULT_MAX_WIDTH = 1000;
|
|
19
|
+
|
|
20
|
+
export interface UseColumnResizeOptions {
|
|
21
|
+
/** Column key being resized */
|
|
22
|
+
columnKey: string;
|
|
23
|
+
/** Column definition (for min/max width) */
|
|
24
|
+
column: ColumnDef;
|
|
25
|
+
/** Current column width */
|
|
26
|
+
currentWidth: number;
|
|
27
|
+
/** Whether resizing is enabled */
|
|
28
|
+
enabled: boolean;
|
|
29
|
+
/** Callback when width changes during resize */
|
|
30
|
+
onResize: (width: number) => void;
|
|
31
|
+
/** Callback when resize starts */
|
|
32
|
+
onResizeStart?: () => void;
|
|
33
|
+
/** Callback when resize ends */
|
|
34
|
+
onResizeEnd?: (width: number) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface UseColumnResizeReturn {
|
|
38
|
+
/** Whether currently dragging */
|
|
39
|
+
isDragging: boolean;
|
|
40
|
+
/** Props to spread on the resize handle element */
|
|
41
|
+
resizeHandleProps: {
|
|
42
|
+
onMouseDown: (event: React.MouseEvent) => void;
|
|
43
|
+
onDoubleClick: (event: React.MouseEvent) => void;
|
|
44
|
+
style: React.CSSProperties;
|
|
45
|
+
role: string;
|
|
46
|
+
"aria-label": string;
|
|
47
|
+
tabIndex: number;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Hook to handle column resize drag interactions
|
|
53
|
+
*/
|
|
54
|
+
export function useColumnResize(
|
|
55
|
+
options: UseColumnResizeOptions
|
|
56
|
+
): UseColumnResizeReturn {
|
|
57
|
+
const {
|
|
58
|
+
columnKey,
|
|
59
|
+
column,
|
|
60
|
+
currentWidth,
|
|
61
|
+
enabled,
|
|
62
|
+
onResize,
|
|
63
|
+
onResizeStart,
|
|
64
|
+
onResizeEnd,
|
|
65
|
+
} = options;
|
|
66
|
+
|
|
67
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
68
|
+
const startXRef = useRef(0);
|
|
69
|
+
const startWidthRef = useRef(0);
|
|
70
|
+
|
|
71
|
+
// Get min/max widths
|
|
72
|
+
const minWidth = column.minWidth ?? DEFAULT_MIN_WIDTH;
|
|
73
|
+
const maxWidth = column.maxWidth ?? DEFAULT_MAX_WIDTH;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Clamp width to min/max bounds
|
|
77
|
+
*/
|
|
78
|
+
const clampWidth = useCallback(
|
|
79
|
+
(width: number): number => {
|
|
80
|
+
return Math.max(minWidth, Math.min(maxWidth, width));
|
|
81
|
+
},
|
|
82
|
+
[minWidth, maxWidth]
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Handle mouse down on resize handle
|
|
87
|
+
*/
|
|
88
|
+
const handleMouseDown = useCallback(
|
|
89
|
+
(event: React.MouseEvent) => {
|
|
90
|
+
if (!enabled) return;
|
|
91
|
+
|
|
92
|
+
event.preventDefault();
|
|
93
|
+
event.stopPropagation();
|
|
94
|
+
|
|
95
|
+
setIsDragging(true);
|
|
96
|
+
startXRef.current = event.clientX;
|
|
97
|
+
startWidthRef.current = currentWidth;
|
|
98
|
+
|
|
99
|
+
// Prevent text selection during drag
|
|
100
|
+
document.body.style.userSelect = "none";
|
|
101
|
+
document.body.style.cursor = "col-resize";
|
|
102
|
+
|
|
103
|
+
onResizeStart?.();
|
|
104
|
+
},
|
|
105
|
+
[enabled, currentWidth, onResizeStart]
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Handle mouse move during drag
|
|
110
|
+
*/
|
|
111
|
+
const handleMouseMove = useCallback(
|
|
112
|
+
(event: MouseEvent) => {
|
|
113
|
+
if (!isDragging) return;
|
|
114
|
+
|
|
115
|
+
const deltaX = event.clientX - startXRef.current;
|
|
116
|
+
const newWidth = clampWidth(startWidthRef.current + deltaX);
|
|
117
|
+
|
|
118
|
+
onResize(newWidth);
|
|
119
|
+
},
|
|
120
|
+
[isDragging, clampWidth, onResize]
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Handle mouse up to end drag
|
|
125
|
+
*/
|
|
126
|
+
const handleMouseUp = useCallback(() => {
|
|
127
|
+
if (!isDragging) return;
|
|
128
|
+
|
|
129
|
+
setIsDragging(false);
|
|
130
|
+
|
|
131
|
+
// Restore styles
|
|
132
|
+
document.body.style.userSelect = "";
|
|
133
|
+
document.body.style.cursor = "";
|
|
134
|
+
|
|
135
|
+
// Calculate final width
|
|
136
|
+
const finalWidth = clampWidth(currentWidth);
|
|
137
|
+
onResizeEnd?.(finalWidth);
|
|
138
|
+
}, [isDragging, currentWidth, clampWidth, onResizeEnd]);
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Handle double-click to auto-fit column width
|
|
142
|
+
* For now, this resets to the column's default width
|
|
143
|
+
*/
|
|
144
|
+
const handleDoubleClick = useCallback(
|
|
145
|
+
(event: React.MouseEvent) => {
|
|
146
|
+
if (!enabled) return;
|
|
147
|
+
|
|
148
|
+
event.preventDefault();
|
|
149
|
+
event.stopPropagation();
|
|
150
|
+
|
|
151
|
+
// Reset to column's defined width or default
|
|
152
|
+
const defaultWidth = column.width ?? 200;
|
|
153
|
+
const clampedWidth = clampWidth(defaultWidth);
|
|
154
|
+
onResize(clampedWidth);
|
|
155
|
+
onResizeEnd?.(clampedWidth);
|
|
156
|
+
},
|
|
157
|
+
[enabled, column.width, clampWidth, onResize, onResizeEnd]
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
// Attach global mouse listeners during drag
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
if (isDragging) {
|
|
163
|
+
window.addEventListener("mousemove", handleMouseMove);
|
|
164
|
+
window.addEventListener("mouseup", handleMouseUp);
|
|
165
|
+
|
|
166
|
+
return () => {
|
|
167
|
+
window.removeEventListener("mousemove", handleMouseMove);
|
|
168
|
+
window.removeEventListener("mouseup", handleMouseUp);
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
}, [isDragging, handleMouseMove, handleMouseUp]);
|
|
172
|
+
|
|
173
|
+
// Clean up on unmount
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
return () => {
|
|
176
|
+
if (isDragging) {
|
|
177
|
+
document.body.style.userSelect = "";
|
|
178
|
+
document.body.style.cursor = "";
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
}, [isDragging]);
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
isDragging,
|
|
185
|
+
resizeHandleProps: {
|
|
186
|
+
onMouseDown: handleMouseDown,
|
|
187
|
+
onDoubleClick: handleDoubleClick,
|
|
188
|
+
style: {
|
|
189
|
+
cursor: enabled ? "col-resize" : "default",
|
|
190
|
+
touchAction: "none",
|
|
191
|
+
},
|
|
192
|
+
role: "separator",
|
|
193
|
+
"aria-label": `Resize column ${columnKey}`,
|
|
194
|
+
tabIndex: enabled ? 0 : -1,
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Hook to manage resize state for all columns
|
|
201
|
+
*/
|
|
202
|
+
export interface UseColumnResizeManagerOptions<T = any> {
|
|
203
|
+
/** Column definitions */
|
|
204
|
+
columns: ColumnDef<T>[];
|
|
205
|
+
/** Current column widths */
|
|
206
|
+
columnWidths: Record<string, number>;
|
|
207
|
+
/** Whether resizing is enabled globally */
|
|
208
|
+
resizableColumns: boolean;
|
|
209
|
+
/** Callback when a column width changes */
|
|
210
|
+
onColumnResize: (columnKey: string, width: number) => void;
|
|
211
|
+
/** Callback when resize starts */
|
|
212
|
+
onColumnResizeStart?: (columnKey: string) => void;
|
|
213
|
+
/** Callback when resize ends */
|
|
214
|
+
onColumnResizeEnd?: (columnKey: string, width: number) => void;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export interface UseColumnResizeManagerReturn {
|
|
218
|
+
/** Currently resizing column key */
|
|
219
|
+
resizingColumn: string | null;
|
|
220
|
+
/** Get resize props for a specific column */
|
|
221
|
+
getResizeProps: (columnKey: string) => {
|
|
222
|
+
isDragging: boolean;
|
|
223
|
+
handleMouseDown: (event: React.MouseEvent) => void;
|
|
224
|
+
handleDoubleClick: (event: React.MouseEvent) => void;
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Hook to manage column resize state across all columns
|
|
230
|
+
*/
|
|
231
|
+
export function useColumnResizeManager<T = any>(
|
|
232
|
+
options: UseColumnResizeManagerOptions<T>
|
|
233
|
+
): UseColumnResizeManagerReturn {
|
|
234
|
+
const {
|
|
235
|
+
columns,
|
|
236
|
+
columnWidths,
|
|
237
|
+
resizableColumns,
|
|
238
|
+
onColumnResize,
|
|
239
|
+
onColumnResizeStart,
|
|
240
|
+
onColumnResizeEnd,
|
|
241
|
+
} = options;
|
|
242
|
+
|
|
243
|
+
const [resizingColumn, setResizingColumn] = useState<string | null>(null);
|
|
244
|
+
const startXRef = useRef(0);
|
|
245
|
+
const startWidthRef = useRef(0);
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Get column by key
|
|
249
|
+
*/
|
|
250
|
+
const getColumn = useCallback(
|
|
251
|
+
(columnKey: string): ColumnDef<T> | undefined => {
|
|
252
|
+
return columns.find((c) => c.key === columnKey);
|
|
253
|
+
},
|
|
254
|
+
[columns]
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Clamp width for a specific column
|
|
259
|
+
*/
|
|
260
|
+
const clampWidth = useCallback(
|
|
261
|
+
(columnKey: string, width: number): number => {
|
|
262
|
+
const column = getColumn(columnKey);
|
|
263
|
+
const minWidth = column?.minWidth ?? DEFAULT_MIN_WIDTH;
|
|
264
|
+
const maxWidth = column?.maxWidth ?? DEFAULT_MAX_WIDTH;
|
|
265
|
+
return Math.max(minWidth, Math.min(maxWidth, width));
|
|
266
|
+
},
|
|
267
|
+
[getColumn]
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Handle mouse move during resize
|
|
272
|
+
*/
|
|
273
|
+
const handleMouseMove = useCallback(
|
|
274
|
+
(event: MouseEvent) => {
|
|
275
|
+
if (!resizingColumn) return;
|
|
276
|
+
|
|
277
|
+
const deltaX = event.clientX - startXRef.current;
|
|
278
|
+
const newWidth = clampWidth(
|
|
279
|
+
resizingColumn,
|
|
280
|
+
startWidthRef.current + deltaX
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
onColumnResize(resizingColumn, newWidth);
|
|
284
|
+
},
|
|
285
|
+
[resizingColumn, clampWidth, onColumnResize]
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Handle mouse up to end resize
|
|
290
|
+
*/
|
|
291
|
+
const handleMouseUp = useCallback(() => {
|
|
292
|
+
if (!resizingColumn) return;
|
|
293
|
+
|
|
294
|
+
const finalWidth = columnWidths[resizingColumn] ?? 200;
|
|
295
|
+
|
|
296
|
+
setResizingColumn(null);
|
|
297
|
+
document.body.style.userSelect = "";
|
|
298
|
+
document.body.style.cursor = "";
|
|
299
|
+
|
|
300
|
+
onColumnResizeEnd?.(resizingColumn, finalWidth);
|
|
301
|
+
}, [resizingColumn, columnWidths, onColumnResizeEnd]);
|
|
302
|
+
|
|
303
|
+
// Attach global listeners during resize
|
|
304
|
+
useEffect(() => {
|
|
305
|
+
if (resizingColumn) {
|
|
306
|
+
window.addEventListener("mousemove", handleMouseMove);
|
|
307
|
+
window.addEventListener("mouseup", handleMouseUp);
|
|
308
|
+
|
|
309
|
+
return () => {
|
|
310
|
+
window.removeEventListener("mousemove", handleMouseMove);
|
|
311
|
+
window.removeEventListener("mouseup", handleMouseUp);
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
}, [resizingColumn, handleMouseMove, handleMouseUp]);
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Get resize props for a specific column
|
|
318
|
+
*/
|
|
319
|
+
const getResizeProps = useCallback(
|
|
320
|
+
(columnKey: string) => {
|
|
321
|
+
const column = getColumn(columnKey);
|
|
322
|
+
const isResizable =
|
|
323
|
+
resizableColumns && (column?.resizable !== false);
|
|
324
|
+
|
|
325
|
+
const handleMouseDown = (event: React.MouseEvent) => {
|
|
326
|
+
if (!isResizable) return;
|
|
327
|
+
|
|
328
|
+
event.preventDefault();
|
|
329
|
+
event.stopPropagation();
|
|
330
|
+
|
|
331
|
+
setResizingColumn(columnKey);
|
|
332
|
+
startXRef.current = event.clientX;
|
|
333
|
+
startWidthRef.current = columnWidths[columnKey] ?? column?.width ?? 200;
|
|
334
|
+
|
|
335
|
+
document.body.style.userSelect = "none";
|
|
336
|
+
document.body.style.cursor = "col-resize";
|
|
337
|
+
|
|
338
|
+
onColumnResizeStart?.(columnKey);
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const handleDoubleClick = (event: React.MouseEvent) => {
|
|
342
|
+
if (!isResizable) return;
|
|
343
|
+
|
|
344
|
+
event.preventDefault();
|
|
345
|
+
event.stopPropagation();
|
|
346
|
+
|
|
347
|
+
// Reset to default width
|
|
348
|
+
const defaultWidth = column?.width ?? 200;
|
|
349
|
+
const minWidth = column?.minWidth ?? DEFAULT_MIN_WIDTH;
|
|
350
|
+
const maxWidth = column?.maxWidth ?? DEFAULT_MAX_WIDTH;
|
|
351
|
+
const clampedWidth = Math.max(minWidth, Math.min(maxWidth, defaultWidth));
|
|
352
|
+
|
|
353
|
+
onColumnResize(columnKey, clampedWidth);
|
|
354
|
+
onColumnResizeEnd?.(columnKey, clampedWidth);
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
isDragging: resizingColumn === columnKey,
|
|
359
|
+
handleMouseDown,
|
|
360
|
+
handleDoubleClick,
|
|
361
|
+
};
|
|
362
|
+
},
|
|
363
|
+
[
|
|
364
|
+
getColumn,
|
|
365
|
+
resizableColumns,
|
|
366
|
+
columnWidths,
|
|
367
|
+
resizingColumn,
|
|
368
|
+
onColumnResize,
|
|
369
|
+
onColumnResizeStart,
|
|
370
|
+
onColumnResizeEnd,
|
|
371
|
+
]
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
resizingColumn,
|
|
376
|
+
getResizeProps,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useDataGridState Hook
|
|
3
|
+
*
|
|
4
|
+
* Manages internal state for the DataGrid component, supporting both
|
|
5
|
+
* controlled and uncontrolled modes for sorting, filtering, column widths,
|
|
6
|
+
* cell focus, and cell editing.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useState, useCallback, useMemo, useRef, useEffect } from "react";
|
|
10
|
+
import type {
|
|
11
|
+
SortConfig,
|
|
12
|
+
FilterConfig,
|
|
13
|
+
CellPosition,
|
|
14
|
+
EditingCell,
|
|
15
|
+
DataGridInternalState,
|
|
16
|
+
DataGridState,
|
|
17
|
+
ColumnDef,
|
|
18
|
+
CellValue,
|
|
19
|
+
} from "../types";
|
|
20
|
+
|
|
21
|
+
export interface UseDataGridStateOptions<T = Record<string, CellValue>> {
|
|
22
|
+
sorting?: SortConfig[];
|
|
23
|
+
filters?: FilterConfig[];
|
|
24
|
+
columnWidths?: Record<string, number>;
|
|
25
|
+
focusedCell?: CellPosition | null;
|
|
26
|
+
|
|
27
|
+
defaultSorting?: SortConfig[];
|
|
28
|
+
defaultFilters?: FilterConfig[];
|
|
29
|
+
defaultColumnWidths?: Record<string, number>;
|
|
30
|
+
|
|
31
|
+
onSortChange?: (sorting: SortConfig[]) => void;
|
|
32
|
+
onFilterChange?: (filters: FilterConfig[]) => void;
|
|
33
|
+
onColumnResize?: (columnKey: string, width: number) => void;
|
|
34
|
+
onFocusedCellChange?: (cell: CellPosition | null) => void;
|
|
35
|
+
onStateChange?: (state: DataGridState) => void;
|
|
36
|
+
|
|
37
|
+
onCellEdit?: (
|
|
38
|
+
rowIndex: number,
|
|
39
|
+
columnKey: string,
|
|
40
|
+
newValue: CellValue,
|
|
41
|
+
oldValue: CellValue
|
|
42
|
+
) => void;
|
|
43
|
+
onCellEditStart?: (rowIndex: number, columnKey: string) => boolean | void;
|
|
44
|
+
onCellEditCancel?: (rowIndex: number, columnKey: string) => void;
|
|
45
|
+
|
|
46
|
+
columns: ColumnDef<T>[];
|
|
47
|
+
data: T[];
|
|
48
|
+
getCellValue: (row: T, column: ColumnDef<T>) => CellValue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface UseDataGridStateReturn {
|
|
52
|
+
state: DataGridInternalState;
|
|
53
|
+
actions: {
|
|
54
|
+
setSorting: (sorting: SortConfig[]) => void;
|
|
55
|
+
toggleSort: (columnKey: string) => void;
|
|
56
|
+
setFilters: (filters: FilterConfig[]) => void;
|
|
57
|
+
setFilter: (filter: FilterConfig | null, columnKey: string) => void;
|
|
58
|
+
clearFilters: () => void;
|
|
59
|
+
setColumnWidth: (columnKey: string, width: number) => void;
|
|
60
|
+
setFocusedCell: (cell: CellPosition | null) => void;
|
|
61
|
+
startEditing: (rowIndex: number, columnKey: string) => void;
|
|
62
|
+
updateEditingValue: (value: CellValue) => void;
|
|
63
|
+
/** Commit the current edit. Optionally pass a value to commit immediately (for async editors like checkbox/date) */
|
|
64
|
+
commitEdit: (value?: CellValue) => void;
|
|
65
|
+
cancelEdit: () => void;
|
|
66
|
+
};
|
|
67
|
+
isControlled: {
|
|
68
|
+
sorting: boolean;
|
|
69
|
+
filters: boolean;
|
|
70
|
+
columnWidths: boolean;
|
|
71
|
+
focusedCell: boolean;
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Hook to manage DataGrid state with controlled/uncontrolled support
|
|
77
|
+
*/
|
|
78
|
+
export function useDataGridState<T = Record<string, CellValue>>(
|
|
79
|
+
options: UseDataGridStateOptions<T>
|
|
80
|
+
): UseDataGridStateReturn {
|
|
81
|
+
const {
|
|
82
|
+
sorting: controlledSorting,
|
|
83
|
+
filters: controlledFilters,
|
|
84
|
+
columnWidths: controlledColumnWidths,
|
|
85
|
+
focusedCell: controlledFocusedCell,
|
|
86
|
+
|
|
87
|
+
defaultSorting = [],
|
|
88
|
+
defaultFilters = [],
|
|
89
|
+
defaultColumnWidths = {},
|
|
90
|
+
|
|
91
|
+
onSortChange,
|
|
92
|
+
onFilterChange,
|
|
93
|
+
onColumnResize,
|
|
94
|
+
onFocusedCellChange,
|
|
95
|
+
onStateChange,
|
|
96
|
+
onCellEdit,
|
|
97
|
+
onCellEditStart,
|
|
98
|
+
onCellEditCancel,
|
|
99
|
+
|
|
100
|
+
columns,
|
|
101
|
+
data,
|
|
102
|
+
getCellValue,
|
|
103
|
+
} = options;
|
|
104
|
+
const isControlled = useMemo(
|
|
105
|
+
() => ({
|
|
106
|
+
sorting: controlledSorting !== undefined,
|
|
107
|
+
filters: controlledFilters !== undefined,
|
|
108
|
+
columnWidths: controlledColumnWidths !== undefined,
|
|
109
|
+
focusedCell: controlledFocusedCell !== undefined,
|
|
110
|
+
}),
|
|
111
|
+
[
|
|
112
|
+
controlledSorting,
|
|
113
|
+
controlledFilters,
|
|
114
|
+
controlledColumnWidths,
|
|
115
|
+
controlledFocusedCell,
|
|
116
|
+
]
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const [internalSorting, setInternalSorting] =
|
|
120
|
+
useState<SortConfig[]>(defaultSorting);
|
|
121
|
+
const [internalFilters, setInternalFilters] =
|
|
122
|
+
useState<FilterConfig[]>(defaultFilters);
|
|
123
|
+
const [internalColumnWidths, setInternalColumnWidths] =
|
|
124
|
+
useState<Record<string, number>>(defaultColumnWidths);
|
|
125
|
+
const [internalFocusedCell, setInternalFocusedCell] =
|
|
126
|
+
useState<CellPosition | null>(null);
|
|
127
|
+
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
|
|
128
|
+
|
|
129
|
+
const sorting = isControlled.sorting ? controlledSorting! : internalSorting;
|
|
130
|
+
const filters = isControlled.filters ? controlledFilters! : internalFilters;
|
|
131
|
+
const focusedCell = isControlled.focusedCell
|
|
132
|
+
? controlledFocusedCell!
|
|
133
|
+
: internalFocusedCell;
|
|
134
|
+
|
|
135
|
+
const columnWidths = useMemo(() => {
|
|
136
|
+
const controlled = isControlled.columnWidths
|
|
137
|
+
? controlledColumnWidths!
|
|
138
|
+
: internalColumnWidths;
|
|
139
|
+
const result: Record<string, number> = {};
|
|
140
|
+
|
|
141
|
+
for (const column of columns) {
|
|
142
|
+
result[column.key] = controlled[column.key] ?? column.width ?? 200;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return result;
|
|
146
|
+
}, [isControlled.columnWidths, controlledColumnWidths, internalColumnWidths, columns]);
|
|
147
|
+
|
|
148
|
+
const prevStateRef = useRef<DataGridState | null>(null);
|
|
149
|
+
|
|
150
|
+
const state: DataGridInternalState = useMemo(
|
|
151
|
+
() => ({
|
|
152
|
+
sorting,
|
|
153
|
+
filters,
|
|
154
|
+
columnWidths,
|
|
155
|
+
focusedCell,
|
|
156
|
+
editingCell,
|
|
157
|
+
}),
|
|
158
|
+
[sorting, filters, columnWidths, focusedCell, editingCell]
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
if (onStateChange) {
|
|
163
|
+
const currentState: DataGridState = {
|
|
164
|
+
sorting,
|
|
165
|
+
filters,
|
|
166
|
+
columnWidths,
|
|
167
|
+
focusedCell,
|
|
168
|
+
editingCell,
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
if (
|
|
172
|
+
JSON.stringify(currentState) !== JSON.stringify(prevStateRef.current)
|
|
173
|
+
) {
|
|
174
|
+
prevStateRef.current = currentState;
|
|
175
|
+
onStateChange(currentState);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}, [sorting, filters, columnWidths, focusedCell, editingCell, onStateChange]);
|
|
179
|
+
|
|
180
|
+
// ============ SORTING ACTIONS ============
|
|
181
|
+
const setSorting = useCallback(
|
|
182
|
+
(newSorting: SortConfig[]) => {
|
|
183
|
+
if (isControlled.sorting) {
|
|
184
|
+
onSortChange?.(newSorting);
|
|
185
|
+
} else {
|
|
186
|
+
setInternalSorting(newSorting);
|
|
187
|
+
onSortChange?.(newSorting);
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
[isControlled.sorting, onSortChange]
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const toggleSort = useCallback(
|
|
194
|
+
(columnKey: string) => {
|
|
195
|
+
const currentSort = sorting.find((s) => s.field === columnKey);
|
|
196
|
+
let newSorting: SortConfig[];
|
|
197
|
+
|
|
198
|
+
if (!currentSort) {
|
|
199
|
+
newSorting = [{ field: columnKey, direction: "asc" }];
|
|
200
|
+
} else if (currentSort.direction === "asc") {
|
|
201
|
+
newSorting = [{ field: columnKey, direction: "desc" }];
|
|
202
|
+
} else {
|
|
203
|
+
newSorting = [];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
setSorting(newSorting);
|
|
207
|
+
},
|
|
208
|
+
[sorting, setSorting]
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
// ============ FILTER ACTIONS ============
|
|
212
|
+
const setFilters = useCallback(
|
|
213
|
+
(newFilters: FilterConfig[]) => {
|
|
214
|
+
if (isControlled.filters) {
|
|
215
|
+
onFilterChange?.(newFilters);
|
|
216
|
+
} else {
|
|
217
|
+
setInternalFilters(newFilters);
|
|
218
|
+
onFilterChange?.(newFilters);
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
[isControlled.filters, onFilterChange]
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const setFilter = useCallback(
|
|
225
|
+
(filter: FilterConfig | null, columnKey: string) => {
|
|
226
|
+
const newFilters = filters.filter((f) => f.columnKey !== columnKey);
|
|
227
|
+
if (filter) {
|
|
228
|
+
newFilters.push(filter);
|
|
229
|
+
}
|
|
230
|
+
setFilters(newFilters);
|
|
231
|
+
},
|
|
232
|
+
[filters, setFilters]
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
const clearFilters = useCallback(() => {
|
|
236
|
+
setFilters([]);
|
|
237
|
+
}, [setFilters]);
|
|
238
|
+
|
|
239
|
+
// ============ COLUMN WIDTH ACTIONS ============
|
|
240
|
+
const setColumnWidth = useCallback(
|
|
241
|
+
(columnKey: string, width: number) => {
|
|
242
|
+
if (isControlled.columnWidths) {
|
|
243
|
+
onColumnResize?.(columnKey, width);
|
|
244
|
+
} else {
|
|
245
|
+
setInternalColumnWidths((prev) => ({
|
|
246
|
+
...prev,
|
|
247
|
+
[columnKey]: width,
|
|
248
|
+
}));
|
|
249
|
+
onColumnResize?.(columnKey, width);
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
[isControlled.columnWidths, onColumnResize]
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
// ============ FOCUS ACTIONS ============
|
|
256
|
+
const setFocusedCell = useCallback(
|
|
257
|
+
(cell: CellPosition | null) => {
|
|
258
|
+
if (isControlled.focusedCell) {
|
|
259
|
+
onFocusedCellChange?.(cell);
|
|
260
|
+
} else {
|
|
261
|
+
setInternalFocusedCell(cell);
|
|
262
|
+
onFocusedCellChange?.(cell);
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
[isControlled.focusedCell, onFocusedCellChange]
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
// ============ EDITING ACTIONS ============
|
|
269
|
+
const startEditing = useCallback(
|
|
270
|
+
(rowIndex: number, columnKey: string) => {
|
|
271
|
+
if (onCellEditStart) {
|
|
272
|
+
const allowed = onCellEditStart(rowIndex, columnKey);
|
|
273
|
+
if (allowed === false) return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const column = columns.find((c) => c.key === columnKey);
|
|
277
|
+
if (!column || !column.editable) return;
|
|
278
|
+
|
|
279
|
+
const row = data[rowIndex];
|
|
280
|
+
if (!row) return;
|
|
281
|
+
|
|
282
|
+
const value = getCellValue(row, column);
|
|
283
|
+
|
|
284
|
+
setEditingCell({
|
|
285
|
+
rowIndex,
|
|
286
|
+
columnKey,
|
|
287
|
+
value,
|
|
288
|
+
originalValue: value,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
setFocusedCell({ rowIndex, columnKey });
|
|
292
|
+
},
|
|
293
|
+
[columns, data, getCellValue, onCellEditStart, setFocusedCell]
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
const updateEditingValue = useCallback((value: CellValue) => {
|
|
297
|
+
setEditingCell((prev) => (prev ? { ...prev, value } : null));
|
|
298
|
+
}, []);
|
|
299
|
+
|
|
300
|
+
const commitEdit = useCallback((valueOverride?: CellValue) => {
|
|
301
|
+
if (!editingCell) return;
|
|
302
|
+
|
|
303
|
+
const { rowIndex, columnKey, originalValue } = editingCell;
|
|
304
|
+
const value = valueOverride !== undefined ? valueOverride : editingCell.value;
|
|
305
|
+
|
|
306
|
+
const column = columns.find((c) => c.key === columnKey);
|
|
307
|
+
if (column?.validator) {
|
|
308
|
+
const row = data[rowIndex];
|
|
309
|
+
const validationResult = column.validator(value, row);
|
|
310
|
+
if (validationResult !== true && typeof validationResult === "string") {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (value !== originalValue) {
|
|
316
|
+
onCellEdit?.(rowIndex, columnKey, value, originalValue);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
setEditingCell(null);
|
|
320
|
+
}, [editingCell, columns, data, onCellEdit]);
|
|
321
|
+
|
|
322
|
+
const cancelEdit = useCallback(() => {
|
|
323
|
+
if (editingCell) {
|
|
324
|
+
onCellEditCancel?.(editingCell.rowIndex, editingCell.columnKey);
|
|
325
|
+
}
|
|
326
|
+
setEditingCell(null);
|
|
327
|
+
}, [editingCell, onCellEditCancel]);
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
state,
|
|
331
|
+
actions: {
|
|
332
|
+
setSorting,
|
|
333
|
+
toggleSort,
|
|
334
|
+
setFilters,
|
|
335
|
+
setFilter,
|
|
336
|
+
clearFilters,
|
|
337
|
+
setColumnWidth,
|
|
338
|
+
setFocusedCell,
|
|
339
|
+
startEditing,
|
|
340
|
+
updateEditingValue,
|
|
341
|
+
commitEdit,
|
|
342
|
+
cancelEdit,
|
|
343
|
+
},
|
|
344
|
+
isControlled,
|
|
345
|
+
};
|
|
346
|
+
}
|