@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.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +107 -0
  3. package/dist/index.cjs +6003 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +2310 -0
  6. package/dist/index.d.ts +2310 -0
  7. package/dist/index.js +5828 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/styles.css +96 -0
  10. package/dist/tailwind-preset.cjs +106 -0
  11. package/dist/tailwind-preset.cjs.map +1 -0
  12. package/dist/tailwind-preset.d.cts +23 -0
  13. package/dist/tailwind-preset.d.ts +23 -0
  14. package/dist/tailwind-preset.js +101 -0
  15. package/dist/tailwind-preset.js.map +1 -0
  16. package/package.json +154 -0
  17. package/src/components/accordion.tsx +187 -0
  18. package/src/components/alert-dialog.tsx +143 -0
  19. package/src/components/autocomplete.tsx +271 -0
  20. package/src/components/badge.tsx +62 -0
  21. package/src/components/button.tsx +85 -0
  22. package/src/components/calendar.tsx +235 -0
  23. package/src/components/card.tsx +94 -0
  24. package/src/components/checkbox.tsx +77 -0
  25. package/src/components/chip.tsx +77 -0
  26. package/src/components/confirmation-modal.tsx +195 -0
  27. package/src/components/context-menu.tsx +406 -0
  28. package/src/components/copy-button.tsx +84 -0
  29. package/src/components/data-grid/DataGrid.tsx +1027 -0
  30. package/src/components/data-grid/components/CellEditor.tsx +346 -0
  31. package/src/components/data-grid/components/FilterPopover.tsx +459 -0
  32. package/src/components/data-grid/components/HeaderCell.tsx +207 -0
  33. package/src/components/data-grid/components/index.ts +14 -0
  34. package/src/components/data-grid/hooks/index.ts +28 -0
  35. package/src/components/data-grid/hooks/useColumnResize.ts +378 -0
  36. package/src/components/data-grid/hooks/useDataGridState.ts +346 -0
  37. package/src/components/data-grid/hooks/useKeyboardNavigation.ts +361 -0
  38. package/src/components/data-grid/index.ts +71 -0
  39. package/src/components/data-grid/types.ts +478 -0
  40. package/src/components/data-grid/utils/dataProcessing.ts +277 -0
  41. package/src/components/data-grid/utils/index.ts +12 -0
  42. package/src/components/date-picker.tsx +366 -0
  43. package/src/components/dropdown-menu.tsx +230 -0
  44. package/src/components/icon-button.tsx +157 -0
  45. package/src/components/input.tsx +40 -0
  46. package/src/components/label.tsx +37 -0
  47. package/src/components/loading-spinner.tsx +113 -0
  48. package/src/components/modal.tsx +207 -0
  49. package/src/components/popover.tsx +62 -0
  50. package/src/components/progress.tsx +41 -0
  51. package/src/components/resizable-panel.tsx +434 -0
  52. package/src/components/resize-handle.tsx +187 -0
  53. package/src/components/select.tsx +160 -0
  54. package/src/components/separator.tsx +50 -0
  55. package/src/components/skeleton.tsx +37 -0
  56. package/src/components/switch.tsx +59 -0
  57. package/src/components/table.tsx +136 -0
  58. package/src/components/tabs.tsx +102 -0
  59. package/src/components/textarea.tsx +36 -0
  60. package/src/components/theme-picker.tsx +245 -0
  61. package/src/components/toaster.tsx +84 -0
  62. package/src/components/tooltip.tsx +199 -0
  63. package/src/index.ts +318 -0
  64. package/src/styles.css +96 -0
  65. package/src/tailwind-preset.ts +129 -0
  66. package/src/theme/index.ts +41 -0
  67. package/src/theme/presets.ts +502 -0
  68. package/src/theme/types.ts +164 -0
  69. package/src/theme/utils.ts +309 -0
  70. 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
+ }