@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,1027 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataGrid Component
|
|
3
|
+
*
|
|
4
|
+
* A powerful, reusable grid component with:
|
|
5
|
+
* - Virtualization for large datasets
|
|
6
|
+
* - Resizable columns
|
|
7
|
+
* - Sorting, filtering, and pagination
|
|
8
|
+
* - Inline cell editing with validation
|
|
9
|
+
* - Full keyboard navigation
|
|
10
|
+
* - Controlled and uncontrolled state modes
|
|
11
|
+
*
|
|
12
|
+
* Built on @tanstack/react-virtual for performance.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as React from "react";
|
|
16
|
+
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
17
|
+
import {
|
|
18
|
+
ChevronsLeft,
|
|
19
|
+
ChevronLeft,
|
|
20
|
+
ChevronRight,
|
|
21
|
+
ChevronsRight,
|
|
22
|
+
Search,
|
|
23
|
+
} from "lucide-react";
|
|
24
|
+
import { cn } from "../../utils/cn";
|
|
25
|
+
import { Button } from "../button";
|
|
26
|
+
import { Input } from "../input";
|
|
27
|
+
import { HeaderCell } from "./components/HeaderCell";
|
|
28
|
+
import { CellEditor } from "./components/CellEditor";
|
|
29
|
+
import { useDataGridState } from "./hooks/useDataGridState";
|
|
30
|
+
import { useKeyboardNavigation } from "./hooks/useKeyboardNavigation";
|
|
31
|
+
import { useColumnResizeManager } from "./hooks/useColumnResize";
|
|
32
|
+
import { applySorting, applyFilters, getCellValue as getCellValueUtil } from "./utils/dataProcessing";
|
|
33
|
+
import type {
|
|
34
|
+
DataGridProps,
|
|
35
|
+
ColumnDef,
|
|
36
|
+
SortConfig,
|
|
37
|
+
FilterConfig,
|
|
38
|
+
CellPosition,
|
|
39
|
+
CellValue,
|
|
40
|
+
} from "./types";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Default row height for virtualization
|
|
44
|
+
*/
|
|
45
|
+
const DEFAULT_ROW_HEIGHT = 35;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Default overscan for virtualization
|
|
49
|
+
*/
|
|
50
|
+
const DEFAULT_OVERSCAN = 10;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Estimate minimum column width based on header text and features
|
|
54
|
+
* Ensures columns are wide enough to show full header text
|
|
55
|
+
*/
|
|
56
|
+
function estimateColumnWidth<T>(column: ColumnDef<T>): number {
|
|
57
|
+
const headerText = typeof column.header === "string" ? column.header : "";
|
|
58
|
+
// Base width: ~8px per character for the font size used
|
|
59
|
+
const textWidth = headerText.length * 8;
|
|
60
|
+
// Padding: 24px (px-3 = 12px on each side)
|
|
61
|
+
const padding = 24;
|
|
62
|
+
// Sort icon: ~20px if sortable
|
|
63
|
+
const sortIconWidth = column.sortable ? 20 : 0;
|
|
64
|
+
// Filter icon: ~28px if filterable (icon + some spacing)
|
|
65
|
+
const filterIconWidth = column.filterable ? 28 : 0;
|
|
66
|
+
// Resize handle area: ~16px if resizable (handled separately, but add small buffer)
|
|
67
|
+
const resizeBuffer = 8;
|
|
68
|
+
|
|
69
|
+
const estimatedWidth = textWidth + padding + sortIconWidth + filterIconWidth + resizeBuffer;
|
|
70
|
+
|
|
71
|
+
// Minimum 80px, reasonable default max of 200px unless header is very long
|
|
72
|
+
return Math.max(80, Math.min(estimatedWidth, 300));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* DataGrid Component
|
|
77
|
+
*/
|
|
78
|
+
export function DataGrid<T = Record<string, CellValue>>({
|
|
79
|
+
// Data
|
|
80
|
+
data,
|
|
81
|
+
columns,
|
|
82
|
+
getRowKey = (_, index) => String(index),
|
|
83
|
+
|
|
84
|
+
// Feature toggles
|
|
85
|
+
resizableColumns = false,
|
|
86
|
+
virtualized,
|
|
87
|
+
stickyHeader = true,
|
|
88
|
+
showTooltips = true,
|
|
89
|
+
tooltipMinLength = 30,
|
|
90
|
+
enableKeyboardNavigation = true,
|
|
91
|
+
showColumnBorders = true,
|
|
92
|
+
enableInternalSorting = true,
|
|
93
|
+
enableInternalFiltering = true,
|
|
94
|
+
|
|
95
|
+
// Controlled sorting
|
|
96
|
+
sorting: controlledSorting,
|
|
97
|
+
onSortChange,
|
|
98
|
+
|
|
99
|
+
// Controlled filtering
|
|
100
|
+
filters: controlledFilters,
|
|
101
|
+
onFilterChange,
|
|
102
|
+
|
|
103
|
+
// Controlled column widths
|
|
104
|
+
columnWidths: controlledColumnWidths,
|
|
105
|
+
onColumnResize,
|
|
106
|
+
onColumnResizeStart,
|
|
107
|
+
onColumnResizeEnd,
|
|
108
|
+
|
|
109
|
+
// Uncontrolled defaults
|
|
110
|
+
defaultSorting,
|
|
111
|
+
defaultFilters,
|
|
112
|
+
defaultColumnWidths,
|
|
113
|
+
|
|
114
|
+
// State change callback
|
|
115
|
+
onStateChange,
|
|
116
|
+
|
|
117
|
+
// Row interactions
|
|
118
|
+
onRowClick,
|
|
119
|
+
onRowDoubleClick,
|
|
120
|
+
selectedRows = [],
|
|
121
|
+
onSelectedRowsChange,
|
|
122
|
+
rowClassName,
|
|
123
|
+
|
|
124
|
+
// Cell editing
|
|
125
|
+
onCellEdit,
|
|
126
|
+
onCellEditStart,
|
|
127
|
+
onCellEditCancel,
|
|
128
|
+
|
|
129
|
+
// Keyboard navigation
|
|
130
|
+
focusedCell: controlledFocusedCell,
|
|
131
|
+
onFocusedCellChange,
|
|
132
|
+
|
|
133
|
+
// Pagination
|
|
134
|
+
pagination,
|
|
135
|
+
|
|
136
|
+
// Search
|
|
137
|
+
search,
|
|
138
|
+
|
|
139
|
+
// Loading & empty states
|
|
140
|
+
loading = false,
|
|
141
|
+
loadingComponent,
|
|
142
|
+
emptyMessage = "No data available",
|
|
143
|
+
emptyComponent,
|
|
144
|
+
|
|
145
|
+
// Styling
|
|
146
|
+
className,
|
|
147
|
+
tableClassName,
|
|
148
|
+
|
|
149
|
+
// Infinite scroll
|
|
150
|
+
infiniteScroll = false,
|
|
151
|
+
onLoadMore,
|
|
152
|
+
hasMore = false,
|
|
153
|
+
loadingMore = false,
|
|
154
|
+
}: DataGridProps<T>) {
|
|
155
|
+
const parentRef = React.useRef<HTMLDivElement>(null);
|
|
156
|
+
const headerRef = React.useRef<HTMLDivElement>(null);
|
|
157
|
+
const [headerHeight, setHeaderHeight] = React.useState(40);
|
|
158
|
+
const [hoveredCell, setHoveredCell] = React.useState<{
|
|
159
|
+
row: number;
|
|
160
|
+
col: number;
|
|
161
|
+
content: string;
|
|
162
|
+
x: number;
|
|
163
|
+
y: number;
|
|
164
|
+
} | null>(null);
|
|
165
|
+
|
|
166
|
+
// Filter visible columns
|
|
167
|
+
const visibleColumns = React.useMemo(
|
|
168
|
+
() => columns.filter((col) => !col.hidden),
|
|
169
|
+
[columns]
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
// Get cell value helper
|
|
173
|
+
const getCellValue = React.useCallback(
|
|
174
|
+
(row: T, column: ColumnDef<T>): CellValue => {
|
|
175
|
+
if (column.accessor) {
|
|
176
|
+
return column.accessor(row);
|
|
177
|
+
}
|
|
178
|
+
// Dynamic property access - row is expected to be an object with string keys
|
|
179
|
+
return (row as Record<string, CellValue>)[column.key];
|
|
180
|
+
},
|
|
181
|
+
[]
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
// Use data grid state hook
|
|
185
|
+
const { state, actions, isControlled } = useDataGridState({
|
|
186
|
+
sorting: controlledSorting,
|
|
187
|
+
filters: controlledFilters,
|
|
188
|
+
columnWidths: controlledColumnWidths,
|
|
189
|
+
focusedCell: controlledFocusedCell,
|
|
190
|
+
defaultSorting,
|
|
191
|
+
defaultFilters,
|
|
192
|
+
defaultColumnWidths,
|
|
193
|
+
onSortChange,
|
|
194
|
+
onFilterChange,
|
|
195
|
+
onColumnResize,
|
|
196
|
+
onFocusedCellChange,
|
|
197
|
+
onStateChange,
|
|
198
|
+
onCellEdit,
|
|
199
|
+
onCellEditStart,
|
|
200
|
+
onCellEditCancel,
|
|
201
|
+
columns: visibleColumns,
|
|
202
|
+
data,
|
|
203
|
+
getCellValue,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Process data with internal sorting/filtering (uncontrolled mode)
|
|
207
|
+
const processedData = React.useMemo(() => {
|
|
208
|
+
let result = data;
|
|
209
|
+
|
|
210
|
+
// Apply internal filtering when not controlled
|
|
211
|
+
if (enableInternalFiltering && !isControlled.filters && state.filters.length > 0) {
|
|
212
|
+
result = applyFilters(result, state.filters, visibleColumns);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Apply internal sorting when not controlled
|
|
216
|
+
if (enableInternalSorting && !isControlled.sorting && state.sorting.length > 0) {
|
|
217
|
+
result = applySorting(result, state.sorting, visibleColumns);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return result;
|
|
221
|
+
}, [
|
|
222
|
+
data,
|
|
223
|
+
enableInternalFiltering,
|
|
224
|
+
enableInternalSorting,
|
|
225
|
+
isControlled.filters,
|
|
226
|
+
isControlled.sorting,
|
|
227
|
+
state.filters,
|
|
228
|
+
state.sorting,
|
|
229
|
+
visibleColumns,
|
|
230
|
+
]);
|
|
231
|
+
|
|
232
|
+
// Use column resize manager
|
|
233
|
+
const { resizingColumn, getResizeProps } = useColumnResizeManager({
|
|
234
|
+
columns: visibleColumns,
|
|
235
|
+
columnWidths: state.columnWidths,
|
|
236
|
+
resizableColumns,
|
|
237
|
+
onColumnResize: actions.setColumnWidth,
|
|
238
|
+
onColumnResizeStart,
|
|
239
|
+
onColumnResizeEnd,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// Auto-enable virtualization for large datasets
|
|
243
|
+
const shouldVirtualize = virtualized ?? processedData.length > 100;
|
|
244
|
+
|
|
245
|
+
// Measure header height for virtualization offset
|
|
246
|
+
React.useLayoutEffect(() => {
|
|
247
|
+
if (headerRef.current && shouldVirtualize) {
|
|
248
|
+
setHeaderHeight(headerRef.current.offsetHeight);
|
|
249
|
+
}
|
|
250
|
+
}, [visibleColumns, shouldVirtualize]);
|
|
251
|
+
|
|
252
|
+
// Virtualize rows
|
|
253
|
+
const rowVirtualizer = useVirtualizer({
|
|
254
|
+
count: processedData.length,
|
|
255
|
+
getScrollElement: () => parentRef.current,
|
|
256
|
+
estimateSize: () => DEFAULT_ROW_HEIGHT,
|
|
257
|
+
overscan: DEFAULT_OVERSCAN,
|
|
258
|
+
enabled: shouldVirtualize,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// Calculate table width - prefer explicit widths, fall back to estimated width from header
|
|
262
|
+
const tableWidth = React.useMemo(() => {
|
|
263
|
+
return visibleColumns.reduce((acc, col) => {
|
|
264
|
+
const width = state.columnWidths[col.key] || col.width || estimateColumnWidth(col);
|
|
265
|
+
return acc + width;
|
|
266
|
+
}, 0);
|
|
267
|
+
}, [visibleColumns, state.columnWidths]);
|
|
268
|
+
|
|
269
|
+
// Get visible row count for keyboard navigation
|
|
270
|
+
const visibleRowCount = React.useMemo(() => {
|
|
271
|
+
if (!parentRef.current) return 10;
|
|
272
|
+
return Math.floor(parentRef.current.clientHeight / DEFAULT_ROW_HEIGHT);
|
|
273
|
+
}, []);
|
|
274
|
+
|
|
275
|
+
// Use keyboard navigation hook
|
|
276
|
+
const { handleKeyDown, containerRef, focusContainer } = useKeyboardNavigation({
|
|
277
|
+
enabled: enableKeyboardNavigation,
|
|
278
|
+
focusedCell: state.focusedCell,
|
|
279
|
+
editingCell: state.editingCell,
|
|
280
|
+
columns: visibleColumns,
|
|
281
|
+
rowCount: processedData.length,
|
|
282
|
+
visibleRowCount,
|
|
283
|
+
onFocusedCellChange: actions.setFocusedCell,
|
|
284
|
+
onStartEditing: actions.startEditing,
|
|
285
|
+
onCommitEdit: actions.commitEdit,
|
|
286
|
+
onCancelEdit: actions.cancelEdit,
|
|
287
|
+
onScrollToRow: (rowIndex) => {
|
|
288
|
+
rowVirtualizer.scrollToIndex(rowIndex, { align: "auto" });
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// Handle blur to clear selection when focus leaves the grid data area
|
|
293
|
+
// This includes clicking the search bar, pagination, or anywhere outside
|
|
294
|
+
const handleBlur = React.useCallback(
|
|
295
|
+
(event: React.FocusEvent) => {
|
|
296
|
+
const target = event.target as HTMLElement;
|
|
297
|
+
const relatedTarget = event.relatedTarget as HTMLElement | null;
|
|
298
|
+
|
|
299
|
+
// Helper to check if an element is part of the cell editing flow (Radix portals, etc.)
|
|
300
|
+
const isEditingFlowElement = (el: HTMLElement | null): boolean => {
|
|
301
|
+
if (!el) return false;
|
|
302
|
+
return !!(
|
|
303
|
+
el.closest("[data-radix-popper-content-wrapper]") ||
|
|
304
|
+
el.closest("[data-radix-select-viewport]") ||
|
|
305
|
+
el.closest("[data-radix-menu-content]") ||
|
|
306
|
+
el.closest("[role='dialog']") ||
|
|
307
|
+
el.closest("[data-cell-editor]")
|
|
308
|
+
);
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
// Helper to check if an element is inside the grid data area (rows/cells)
|
|
312
|
+
const isInGridDataArea = (el: HTMLElement | null): boolean => {
|
|
313
|
+
if (!el) return false;
|
|
314
|
+
// Check if the element is a grid cell, or inside a row/cell
|
|
315
|
+
return !!(
|
|
316
|
+
el.closest("[role='gridcell']") ||
|
|
317
|
+
el.closest("[role='row']") ||
|
|
318
|
+
el.closest("[data-cell-editor]")
|
|
319
|
+
);
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
// If the blur originated from inside a cell editor, let the CellEditor's
|
|
323
|
+
// own blur handler manage the commit - don't double-process
|
|
324
|
+
if (target.closest("[data-cell-editor]")) {
|
|
325
|
+
// Clear focused cell if focus is moving outside the grid data area
|
|
326
|
+
if (relatedTarget && !isInGridDataArea(relatedTarget) && !isEditingFlowElement(relatedTarget)) {
|
|
327
|
+
setTimeout(() => {
|
|
328
|
+
actions.setFocusedCell(null);
|
|
329
|
+
}, 0);
|
|
330
|
+
} else if (!relatedTarget) {
|
|
331
|
+
// Focus moving to non-focusable element - check after timeout
|
|
332
|
+
setTimeout(() => {
|
|
333
|
+
const activeElement = document.activeElement as HTMLElement | null;
|
|
334
|
+
if (!isInGridDataArea(activeElement) && !isEditingFlowElement(activeElement)) {
|
|
335
|
+
actions.setFocusedCell(null);
|
|
336
|
+
}
|
|
337
|
+
}, 0);
|
|
338
|
+
}
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// If we're editing and focus is moving to another element
|
|
343
|
+
if (state.editingCell && relatedTarget) {
|
|
344
|
+
// If focus is moving to a Radix portal or cell editor, keep editing
|
|
345
|
+
if (isEditingFlowElement(relatedTarget)) {
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
// Otherwise, commit the edit
|
|
349
|
+
actions.commitEdit();
|
|
350
|
+
// Clear focus if moving outside grid data area (e.g., search bar, pagination, outside)
|
|
351
|
+
if (!isInGridDataArea(relatedTarget)) {
|
|
352
|
+
actions.setFocusedCell(null);
|
|
353
|
+
}
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Not editing - clear focused cell if focus moves outside the grid data area
|
|
358
|
+
if (relatedTarget) {
|
|
359
|
+
if (!isInGridDataArea(relatedTarget) && !isEditingFlowElement(relatedTarget)) {
|
|
360
|
+
actions.setFocusedCell(null);
|
|
361
|
+
}
|
|
362
|
+
} else {
|
|
363
|
+
// Focus is moving to nothing (e.g., clicking on non-focusable element)
|
|
364
|
+
setTimeout(() => {
|
|
365
|
+
const activeElement = document.activeElement as HTMLElement | null;
|
|
366
|
+
if (isEditingFlowElement(activeElement)) {
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (!isInGridDataArea(activeElement)) {
|
|
370
|
+
if (state.editingCell) {
|
|
371
|
+
actions.commitEdit();
|
|
372
|
+
}
|
|
373
|
+
actions.setFocusedCell(null);
|
|
374
|
+
}
|
|
375
|
+
}, 0);
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
[state.editingCell, actions]
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
// Handle infinite scroll
|
|
382
|
+
React.useEffect(() => {
|
|
383
|
+
if (!infiniteScroll || !hasMore || loadingMore || !parentRef.current) return;
|
|
384
|
+
|
|
385
|
+
const observer = new IntersectionObserver(
|
|
386
|
+
(entries) => {
|
|
387
|
+
if (entries[0].isIntersecting) {
|
|
388
|
+
onLoadMore?.();
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
{ root: parentRef.current, threshold: 0.1 }
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
// Observe the last row
|
|
395
|
+
const lastRow = parentRef.current.querySelector("[data-last-row]");
|
|
396
|
+
if (lastRow) {
|
|
397
|
+
observer.observe(lastRow);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return () => observer.disconnect();
|
|
401
|
+
}, [infiniteScroll, hasMore, loadingMore, onLoadMore, data.length]);
|
|
402
|
+
|
|
403
|
+
// Get sort config for a column
|
|
404
|
+
const getColumnSort = (columnKey: string): SortConfig | undefined => {
|
|
405
|
+
return state.sorting.find((s) => s.field === columnKey);
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
// Get filter config for a column
|
|
409
|
+
const getColumnFilter = (columnKey: string): FilterConfig | undefined => {
|
|
410
|
+
return state.filters.find((f) => f.columnKey === columnKey);
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
// Handle header sort click
|
|
414
|
+
const handleSort = (columnKey: string) => {
|
|
415
|
+
actions.toggleSort(columnKey);
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
// Handle filter change
|
|
419
|
+
const handleFilterChange = (
|
|
420
|
+
columnKey: string,
|
|
421
|
+
filter: FilterConfig | null
|
|
422
|
+
) => {
|
|
423
|
+
actions.setFilter(filter, columnKey);
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
// Render cell content
|
|
427
|
+
const renderCell = (
|
|
428
|
+
row: T,
|
|
429
|
+
column: ColumnDef<T>,
|
|
430
|
+
rowIndex: number,
|
|
431
|
+
isEditing: boolean
|
|
432
|
+
) => {
|
|
433
|
+
// If editing this cell
|
|
434
|
+
if (isEditing && state.editingCell) {
|
|
435
|
+
return (
|
|
436
|
+
<CellEditor
|
|
437
|
+
column={column}
|
|
438
|
+
value={state.editingCell.value}
|
|
439
|
+
row={row}
|
|
440
|
+
rowIndex={rowIndex}
|
|
441
|
+
onCommit={actions.commitEdit}
|
|
442
|
+
onCancel={actions.cancelEdit}
|
|
443
|
+
onChange={actions.updateEditingValue}
|
|
444
|
+
/>
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Custom cell renderer
|
|
449
|
+
if (column.cell) {
|
|
450
|
+
return column.cell(row, rowIndex);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Default rendering
|
|
454
|
+
const value = getCellValue(row, column);
|
|
455
|
+
if (value === null || value === undefined) {
|
|
456
|
+
return <span className="text-muted-foreground italic">NULL</span>;
|
|
457
|
+
}
|
|
458
|
+
return String(value);
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
// Loading state
|
|
462
|
+
if (loading) {
|
|
463
|
+
if (loadingComponent) {
|
|
464
|
+
return <>{loadingComponent}</>;
|
|
465
|
+
}
|
|
466
|
+
return (
|
|
467
|
+
<div
|
|
468
|
+
className={cn(
|
|
469
|
+
"flex items-center justify-center h-full bg-muted/30",
|
|
470
|
+
className
|
|
471
|
+
)}
|
|
472
|
+
>
|
|
473
|
+
<div className="text-muted-foreground">Loading...</div>
|
|
474
|
+
</div>
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Empty state - but keep the search bar if it exists
|
|
479
|
+
if (processedData.length === 0) {
|
|
480
|
+
const emptyContent = emptyComponent ? (
|
|
481
|
+
<>{emptyComponent}</>
|
|
482
|
+
) : (
|
|
483
|
+
<div className="flex items-center justify-center flex-1 bg-muted/30">
|
|
484
|
+
<div className="text-muted-foreground text-sm">{emptyMessage}</div>
|
|
485
|
+
</div>
|
|
486
|
+
);
|
|
487
|
+
|
|
488
|
+
// If search is enabled, show the search bar with empty state below
|
|
489
|
+
if (search) {
|
|
490
|
+
return (
|
|
491
|
+
<div className={cn("flex flex-col h-full w-full min-h-0", className)}>
|
|
492
|
+
<div className="flex-shrink-0 p-3 border-b border-border bg-background">
|
|
493
|
+
<div className="relative">
|
|
494
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
495
|
+
<Input
|
|
496
|
+
type="text"
|
|
497
|
+
placeholder={search.placeholder || "Search..."}
|
|
498
|
+
value={search.searchQuery}
|
|
499
|
+
onChange={(e) => search.onSearchChange(e.target.value)}
|
|
500
|
+
className="pl-9"
|
|
501
|
+
/>
|
|
502
|
+
</div>
|
|
503
|
+
</div>
|
|
504
|
+
{emptyContent}
|
|
505
|
+
</div>
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return (
|
|
510
|
+
<div
|
|
511
|
+
className={cn(
|
|
512
|
+
"flex items-center justify-center h-full bg-muted/30",
|
|
513
|
+
className
|
|
514
|
+
)}
|
|
515
|
+
>
|
|
516
|
+
<div className="text-muted-foreground text-sm">{emptyMessage}</div>
|
|
517
|
+
</div>
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Virtualized rendering
|
|
522
|
+
if (shouldVirtualize) {
|
|
523
|
+
return (
|
|
524
|
+
<div
|
|
525
|
+
ref={containerRef as React.RefObject<HTMLDivElement>}
|
|
526
|
+
className={cn("flex flex-col h-full w-full min-h-0", className)}
|
|
527
|
+
onKeyDown={handleKeyDown}
|
|
528
|
+
onBlur={handleBlur}
|
|
529
|
+
tabIndex={enableKeyboardNavigation ? 0 : -1}
|
|
530
|
+
role="grid"
|
|
531
|
+
aria-rowcount={processedData.length}
|
|
532
|
+
aria-colcount={visibleColumns.length}
|
|
533
|
+
>
|
|
534
|
+
{/* Search bar */}
|
|
535
|
+
{search && (
|
|
536
|
+
<div className="flex-shrink-0 p-3 border-b border-border bg-background">
|
|
537
|
+
<div className="relative">
|
|
538
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
539
|
+
<Input
|
|
540
|
+
type="text"
|
|
541
|
+
placeholder={search.placeholder || "Search..."}
|
|
542
|
+
value={search.searchQuery}
|
|
543
|
+
onChange={(e) => search.onSearchChange(e.target.value)}
|
|
544
|
+
className="pl-9"
|
|
545
|
+
/>
|
|
546
|
+
</div>
|
|
547
|
+
</div>
|
|
548
|
+
)}
|
|
549
|
+
|
|
550
|
+
<div
|
|
551
|
+
ref={parentRef}
|
|
552
|
+
className="flex-1 overflow-auto bg-background relative w-full min-h-0 max-h-full"
|
|
553
|
+
style={{ contain: "strict" }}
|
|
554
|
+
>
|
|
555
|
+
<div
|
|
556
|
+
style={{
|
|
557
|
+
height: `${rowVirtualizer.getTotalSize() + headerHeight}px`,
|
|
558
|
+
width: tableWidth ? `${tableWidth}px` : "100%",
|
|
559
|
+
position: "relative",
|
|
560
|
+
minWidth: "100%",
|
|
561
|
+
}}
|
|
562
|
+
>
|
|
563
|
+
{/* Sticky Header */}
|
|
564
|
+
<div
|
|
565
|
+
ref={headerRef}
|
|
566
|
+
className={cn(
|
|
567
|
+
"sticky top-0 z-10 bg-muted border-b border-border",
|
|
568
|
+
stickyHeader && "sticky"
|
|
569
|
+
)}
|
|
570
|
+
style={{
|
|
571
|
+
display: "flex",
|
|
572
|
+
width: tableWidth ? `${tableWidth}px` : "100%",
|
|
573
|
+
}}
|
|
574
|
+
role="row"
|
|
575
|
+
>
|
|
576
|
+
{visibleColumns.map((column, colIndex) => {
|
|
577
|
+
const width = state.columnWidths[column.key] || column.width || estimateColumnWidth(column);
|
|
578
|
+
const resizeProps = getResizeProps(column.key);
|
|
579
|
+
|
|
580
|
+
return (
|
|
581
|
+
<HeaderCell
|
|
582
|
+
key={column.key}
|
|
583
|
+
column={column}
|
|
584
|
+
columnIndex={colIndex}
|
|
585
|
+
width={width}
|
|
586
|
+
sorting={getColumnSort(column.key)}
|
|
587
|
+
filter={getColumnFilter(column.key)}
|
|
588
|
+
isResizable={
|
|
589
|
+
resizableColumns && column.resizable !== false
|
|
590
|
+
}
|
|
591
|
+
onSort={() => handleSort(column.key)}
|
|
592
|
+
onFilterChange={(filter) =>
|
|
593
|
+
handleFilterChange(column.key, filter)
|
|
594
|
+
}
|
|
595
|
+
onResizeMouseDown={resizeProps.handleMouseDown}
|
|
596
|
+
onResizeDoubleClick={resizeProps.handleDoubleClick}
|
|
597
|
+
isResizing={resizeProps.isDragging}
|
|
598
|
+
/>
|
|
599
|
+
);
|
|
600
|
+
})}
|
|
601
|
+
</div>
|
|
602
|
+
|
|
603
|
+
{/* Virtualized Rows */}
|
|
604
|
+
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
|
605
|
+
const row = processedData[virtualRow.index];
|
|
606
|
+
const rowKey = getRowKey(row, virtualRow.index);
|
|
607
|
+
const isSelected = selectedRows.includes(rowKey);
|
|
608
|
+
const isLastRow = virtualRow.index === processedData.length - 1;
|
|
609
|
+
|
|
610
|
+
return (
|
|
611
|
+
<div
|
|
612
|
+
key={virtualRow.key}
|
|
613
|
+
data-last-row={isLastRow ? true : undefined}
|
|
614
|
+
className={cn(
|
|
615
|
+
"absolute top-0 left-0 transition-colors border-b border-border",
|
|
616
|
+
onRowClick && "cursor-pointer",
|
|
617
|
+
isSelected && "bg-accent/20",
|
|
618
|
+
!isSelected && "hover:bg-muted/50",
|
|
619
|
+
rowClassName && rowClassName(row, virtualRow.index)
|
|
620
|
+
)}
|
|
621
|
+
style={{
|
|
622
|
+
height: `${virtualRow.size}px`,
|
|
623
|
+
transform: `translateY(${virtualRow.start + headerHeight}px)`,
|
|
624
|
+
display: "flex",
|
|
625
|
+
width: tableWidth ? `${tableWidth}px` : "100%",
|
|
626
|
+
}}
|
|
627
|
+
onClick={() => {
|
|
628
|
+
onRowClick?.(row, virtualRow.index);
|
|
629
|
+
if (enableKeyboardNavigation && visibleColumns.length > 0) {
|
|
630
|
+
actions.setFocusedCell({
|
|
631
|
+
rowIndex: virtualRow.index,
|
|
632
|
+
columnKey: visibleColumns[0].key,
|
|
633
|
+
});
|
|
634
|
+
focusContainer();
|
|
635
|
+
}
|
|
636
|
+
}}
|
|
637
|
+
onDoubleClick={() =>
|
|
638
|
+
onRowDoubleClick?.(row, virtualRow.index)
|
|
639
|
+
}
|
|
640
|
+
role="row"
|
|
641
|
+
aria-rowindex={virtualRow.index + 1}
|
|
642
|
+
aria-selected={isSelected}
|
|
643
|
+
>
|
|
644
|
+
{visibleColumns.map((column, colIndex) => {
|
|
645
|
+
const width =
|
|
646
|
+
state.columnWidths[column.key] || column.width || estimateColumnWidth(column);
|
|
647
|
+
const isEditingThisCell =
|
|
648
|
+
state.editingCell?.rowIndex === virtualRow.index &&
|
|
649
|
+
state.editingCell?.columnKey === column.key;
|
|
650
|
+
const isFocused =
|
|
651
|
+
state.focusedCell?.rowIndex === virtualRow.index &&
|
|
652
|
+
state.focusedCell?.columnKey === column.key;
|
|
653
|
+
|
|
654
|
+
const cellContent = renderCell(
|
|
655
|
+
row,
|
|
656
|
+
column,
|
|
657
|
+
virtualRow.index,
|
|
658
|
+
isEditingThisCell
|
|
659
|
+
);
|
|
660
|
+
const cellValue =
|
|
661
|
+
typeof cellContent === "string"
|
|
662
|
+
? cellContent
|
|
663
|
+
: String(getCellValue(row, column) || "");
|
|
664
|
+
const isLong =
|
|
665
|
+
showTooltips && cellValue.length > tooltipMinLength;
|
|
666
|
+
|
|
667
|
+
return (
|
|
668
|
+
<div
|
|
669
|
+
key={column.key}
|
|
670
|
+
className={cn(
|
|
671
|
+
"flex-shrink-0 px-3 py-2 text-sm overflow-hidden",
|
|
672
|
+
showColumnBorders && "border-r border-border last:border-r-0",
|
|
673
|
+
isFocused && !isEditingThisCell && "ring-2 ring-inset ring-primary",
|
|
674
|
+
column.align === "center" && "text-center",
|
|
675
|
+
column.align === "right" && "text-right"
|
|
676
|
+
)}
|
|
677
|
+
style={{ width }}
|
|
678
|
+
onMouseDown={(e) => {
|
|
679
|
+
// Prevent cell from capturing mouse events when editing
|
|
680
|
+
// This allows clicks on dropdowns, date pickers, etc. to work
|
|
681
|
+
if (isEditingThisCell && e.target !== e.currentTarget) {
|
|
682
|
+
e.stopPropagation();
|
|
683
|
+
}
|
|
684
|
+
}}
|
|
685
|
+
onClick={(e) => {
|
|
686
|
+
e.stopPropagation();
|
|
687
|
+
// Don't change focus if already editing this cell
|
|
688
|
+
if (isEditingThisCell) return;
|
|
689
|
+
if (enableKeyboardNavigation) {
|
|
690
|
+
actions.setFocusedCell({
|
|
691
|
+
rowIndex: virtualRow.index,
|
|
692
|
+
columnKey: column.key,
|
|
693
|
+
});
|
|
694
|
+
focusContainer();
|
|
695
|
+
}
|
|
696
|
+
}}
|
|
697
|
+
onDoubleClick={(e) => {
|
|
698
|
+
e.stopPropagation();
|
|
699
|
+
// Don't re-trigger edit if already editing
|
|
700
|
+
if (isEditingThisCell) return;
|
|
701
|
+
if (column.editable && onCellEdit) {
|
|
702
|
+
actions.startEditing(virtualRow.index, column.key);
|
|
703
|
+
}
|
|
704
|
+
}}
|
|
705
|
+
onMouseEnter={(e) => {
|
|
706
|
+
if (isLong && !isEditingThisCell) {
|
|
707
|
+
const rect =
|
|
708
|
+
e.currentTarget.getBoundingClientRect();
|
|
709
|
+
setHoveredCell({
|
|
710
|
+
row: virtualRow.index,
|
|
711
|
+
col: colIndex,
|
|
712
|
+
content: cellValue,
|
|
713
|
+
x: rect.left,
|
|
714
|
+
y: rect.bottom + 4,
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
}}
|
|
718
|
+
onMouseLeave={() => setHoveredCell(null)}
|
|
719
|
+
role="gridcell"
|
|
720
|
+
aria-colindex={colIndex + 1}
|
|
721
|
+
tabIndex={-1}
|
|
722
|
+
>
|
|
723
|
+
<div className="truncate">{cellContent}</div>
|
|
724
|
+
</div>
|
|
725
|
+
);
|
|
726
|
+
})}
|
|
727
|
+
</div>
|
|
728
|
+
);
|
|
729
|
+
})}
|
|
730
|
+
|
|
731
|
+
{/* Infinite scroll loading indicator */}
|
|
732
|
+
{infiniteScroll && loadingMore && (
|
|
733
|
+
<div
|
|
734
|
+
className="absolute bottom-0 left-0 right-0 flex items-center justify-center py-4 bg-background/80"
|
|
735
|
+
style={{
|
|
736
|
+
transform: `translateY(${rowVirtualizer.getTotalSize() + headerHeight}px)`,
|
|
737
|
+
}}
|
|
738
|
+
>
|
|
739
|
+
<div className="text-muted-foreground text-sm">
|
|
740
|
+
Loading more...
|
|
741
|
+
</div>
|
|
742
|
+
</div>
|
|
743
|
+
)}
|
|
744
|
+
</div>
|
|
745
|
+
</div>
|
|
746
|
+
|
|
747
|
+
{/* Pagination */}
|
|
748
|
+
{pagination && (
|
|
749
|
+
<PaginationFooter pagination={pagination} />
|
|
750
|
+
)}
|
|
751
|
+
|
|
752
|
+
{/* Tooltip for long content */}
|
|
753
|
+
{hoveredCell && (
|
|
754
|
+
<div
|
|
755
|
+
className="fixed z-[200] p-3 bg-popover text-popover-foreground border border-border rounded-md shadow-xl max-w-md break-words text-sm pointer-events-none"
|
|
756
|
+
style={{
|
|
757
|
+
left: `${hoveredCell.x}px`,
|
|
758
|
+
top: `${hoveredCell.y}px`,
|
|
759
|
+
}}
|
|
760
|
+
>
|
|
761
|
+
<div className="whitespace-pre-wrap font-mono text-xs">
|
|
762
|
+
{hoveredCell.content}
|
|
763
|
+
</div>
|
|
764
|
+
</div>
|
|
765
|
+
)}
|
|
766
|
+
</div>
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Non-virtualized rendering (standard table)
|
|
771
|
+
return (
|
|
772
|
+
<div
|
|
773
|
+
ref={containerRef as React.RefObject<HTMLDivElement>}
|
|
774
|
+
className={cn("flex flex-col h-full min-h-0", className)}
|
|
775
|
+
onKeyDown={handleKeyDown}
|
|
776
|
+
onBlur={handleBlur}
|
|
777
|
+
tabIndex={enableKeyboardNavigation ? 0 : -1}
|
|
778
|
+
role="grid"
|
|
779
|
+
aria-rowcount={processedData.length}
|
|
780
|
+
aria-colcount={visibleColumns.length}
|
|
781
|
+
>
|
|
782
|
+
{/* Search bar */}
|
|
783
|
+
{search && (
|
|
784
|
+
<div className="flex-shrink-0 p-3 border-b border-border bg-background">
|
|
785
|
+
<div className="relative">
|
|
786
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
787
|
+
<Input
|
|
788
|
+
type="text"
|
|
789
|
+
placeholder={search.placeholder || "Search..."}
|
|
790
|
+
value={search.searchQuery}
|
|
791
|
+
onChange={(e) => search.onSearchChange(e.target.value)}
|
|
792
|
+
className="pl-9"
|
|
793
|
+
/>
|
|
794
|
+
</div>
|
|
795
|
+
</div>
|
|
796
|
+
)}
|
|
797
|
+
|
|
798
|
+
<div className="flex-1 overflow-auto min-h-0">
|
|
799
|
+
{/* Header row using HeaderCell for consistent features */}
|
|
800
|
+
<div
|
|
801
|
+
className={cn(
|
|
802
|
+
"flex border-b border-border bg-muted",
|
|
803
|
+
stickyHeader && "sticky top-0 z-10"
|
|
804
|
+
)}
|
|
805
|
+
style={{ width: tableWidth ? `${tableWidth}px` : "100%" }}
|
|
806
|
+
role="row"
|
|
807
|
+
>
|
|
808
|
+
{visibleColumns.map((column, colIndex) => {
|
|
809
|
+
const width = state.columnWidths[column.key] || column.width || estimateColumnWidth(column);
|
|
810
|
+
const resizeProps = getResizeProps(column.key);
|
|
811
|
+
|
|
812
|
+
return (
|
|
813
|
+
<HeaderCell
|
|
814
|
+
key={column.key}
|
|
815
|
+
column={column}
|
|
816
|
+
columnIndex={colIndex}
|
|
817
|
+
width={width}
|
|
818
|
+
sorting={getColumnSort(column.key)}
|
|
819
|
+
filter={getColumnFilter(column.key)}
|
|
820
|
+
isResizable={resizableColumns && column.resizable !== false}
|
|
821
|
+
onSort={() => handleSort(column.key)}
|
|
822
|
+
onFilterChange={(filter) => handleFilterChange(column.key, filter)}
|
|
823
|
+
onResizeMouseDown={resizeProps.handleMouseDown}
|
|
824
|
+
onResizeDoubleClick={resizeProps.handleDoubleClick}
|
|
825
|
+
isResizing={resizeProps.isDragging}
|
|
826
|
+
/>
|
|
827
|
+
);
|
|
828
|
+
})}
|
|
829
|
+
</div>
|
|
830
|
+
|
|
831
|
+
{/* Data rows */}
|
|
832
|
+
{processedData.map((row, rowIndex) => {
|
|
833
|
+
const rowKey = getRowKey(row, rowIndex);
|
|
834
|
+
const isSelected = selectedRows.includes(rowKey);
|
|
835
|
+
|
|
836
|
+
return (
|
|
837
|
+
<div
|
|
838
|
+
key={rowKey}
|
|
839
|
+
className={cn(
|
|
840
|
+
"flex border-b border-border transition-colors",
|
|
841
|
+
onRowClick && "cursor-pointer",
|
|
842
|
+
isSelected && "bg-accent/20",
|
|
843
|
+
!isSelected && "hover:bg-muted/50",
|
|
844
|
+
rowClassName && rowClassName(row, rowIndex)
|
|
845
|
+
)}
|
|
846
|
+
style={{ width: tableWidth ? `${tableWidth}px` : "100%" }}
|
|
847
|
+
onClick={() => onRowClick?.(row, rowIndex)}
|
|
848
|
+
onDoubleClick={() => onRowDoubleClick?.(row, rowIndex)}
|
|
849
|
+
role="row"
|
|
850
|
+
>
|
|
851
|
+
{visibleColumns.map((column) => {
|
|
852
|
+
const width = state.columnWidths[column.key] || column.width || estimateColumnWidth(column);
|
|
853
|
+
const isEditingThisCell =
|
|
854
|
+
state.editingCell?.rowIndex === rowIndex &&
|
|
855
|
+
state.editingCell?.columnKey === column.key;
|
|
856
|
+
const isFocused =
|
|
857
|
+
state.focusedCell?.rowIndex === rowIndex &&
|
|
858
|
+
state.focusedCell?.columnKey === column.key;
|
|
859
|
+
|
|
860
|
+
return (
|
|
861
|
+
<div
|
|
862
|
+
key={column.key}
|
|
863
|
+
className={cn(
|
|
864
|
+
"flex-shrink-0 px-3 py-2 text-sm overflow-hidden",
|
|
865
|
+
showColumnBorders && "border-r border-border last:border-r-0",
|
|
866
|
+
isFocused && !isEditingThisCell && "ring-2 ring-inset ring-primary",
|
|
867
|
+
column.align === "center" && "text-center",
|
|
868
|
+
column.align === "right" && "text-right"
|
|
869
|
+
)}
|
|
870
|
+
style={{ width }}
|
|
871
|
+
onMouseDown={(e) => {
|
|
872
|
+
// Prevent cell from capturing mouse events when editing
|
|
873
|
+
// This allows clicks on dropdowns, date pickers, etc. to work
|
|
874
|
+
if (isEditingThisCell && e.target !== e.currentTarget) {
|
|
875
|
+
e.stopPropagation();
|
|
876
|
+
}
|
|
877
|
+
}}
|
|
878
|
+
onClick={(e) => {
|
|
879
|
+
e.stopPropagation();
|
|
880
|
+
// Don't change focus if already editing this cell
|
|
881
|
+
if (isEditingThisCell) return;
|
|
882
|
+
if (enableKeyboardNavigation) {
|
|
883
|
+
actions.setFocusedCell({
|
|
884
|
+
rowIndex,
|
|
885
|
+
columnKey: column.key,
|
|
886
|
+
});
|
|
887
|
+
focusContainer();
|
|
888
|
+
}
|
|
889
|
+
}}
|
|
890
|
+
onDoubleClick={(e) => {
|
|
891
|
+
e.stopPropagation();
|
|
892
|
+
// Don't re-trigger edit if already editing
|
|
893
|
+
if (isEditingThisCell) return;
|
|
894
|
+
if (column.editable && onCellEdit) {
|
|
895
|
+
actions.startEditing(rowIndex, column.key);
|
|
896
|
+
}
|
|
897
|
+
}}
|
|
898
|
+
role="gridcell"
|
|
899
|
+
>
|
|
900
|
+
{renderCell(row, column, rowIndex, isEditingThisCell)}
|
|
901
|
+
</div>
|
|
902
|
+
);
|
|
903
|
+
})}
|
|
904
|
+
</div>
|
|
905
|
+
);
|
|
906
|
+
})}
|
|
907
|
+
</div>
|
|
908
|
+
|
|
909
|
+
{/* Pagination */}
|
|
910
|
+
{pagination && (
|
|
911
|
+
<PaginationFooter pagination={pagination} />
|
|
912
|
+
)}
|
|
913
|
+
</div>
|
|
914
|
+
);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* Pagination Footer Component
|
|
919
|
+
*/
|
|
920
|
+
interface PaginationFooterProps {
|
|
921
|
+
pagination: NonNullable<DataGridProps["pagination"]>;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function PaginationFooter({ pagination }: PaginationFooterProps) {
|
|
925
|
+
const [goToPage, setGoToPage] = React.useState("");
|
|
926
|
+
|
|
927
|
+
const handleGoToPage = () => {
|
|
928
|
+
const page = parseInt(goToPage, 10);
|
|
929
|
+
if (!isNaN(page) && page >= 1 && page <= pagination.totalPages) {
|
|
930
|
+
pagination.onPageChange(page - 1);
|
|
931
|
+
setGoToPage("");
|
|
932
|
+
}
|
|
933
|
+
};
|
|
934
|
+
|
|
935
|
+
return (
|
|
936
|
+
<div className="flex-shrink-0 border-t border-border p-4 flex items-center justify-between bg-background">
|
|
937
|
+
<div className="flex items-center gap-2">
|
|
938
|
+
<span className="text-sm text-muted-foreground">Rows per page:</span>
|
|
939
|
+
<select
|
|
940
|
+
value={pagination.pageSize}
|
|
941
|
+
onChange={(e) => pagination.onPageSizeChange(Number(e.target.value))}
|
|
942
|
+
className="px-2 py-1 border border-border rounded bg-background text-foreground text-sm"
|
|
943
|
+
>
|
|
944
|
+
{(pagination.pageSizeOptions || [15, 50, 100, 500]).map((size) => (
|
|
945
|
+
<option key={size} value={size}>
|
|
946
|
+
{size}
|
|
947
|
+
</option>
|
|
948
|
+
))}
|
|
949
|
+
</select>
|
|
950
|
+
</div>
|
|
951
|
+
|
|
952
|
+
<div className="flex items-center gap-4">
|
|
953
|
+
<span className="text-sm text-muted-foreground">
|
|
954
|
+
Page {pagination.currentPage + 1} of {pagination.totalPages}
|
|
955
|
+
{pagination.totalItems && ` (${pagination.totalItems} total)`}
|
|
956
|
+
</span>
|
|
957
|
+
|
|
958
|
+
{/* Go to page input */}
|
|
959
|
+
{pagination.showGoToPage && (
|
|
960
|
+
<div className="flex items-center gap-1">
|
|
961
|
+
<Input
|
|
962
|
+
type="number"
|
|
963
|
+
min={1}
|
|
964
|
+
max={pagination.totalPages}
|
|
965
|
+
value={goToPage}
|
|
966
|
+
onChange={(e) => setGoToPage(e.target.value)}
|
|
967
|
+
onKeyDown={(e) => {
|
|
968
|
+
if (e.key === "Enter") {
|
|
969
|
+
handleGoToPage();
|
|
970
|
+
}
|
|
971
|
+
}}
|
|
972
|
+
placeholder="Go to"
|
|
973
|
+
className="w-16 h-8 text-sm"
|
|
974
|
+
/>
|
|
975
|
+
<Button
|
|
976
|
+
onClick={handleGoToPage}
|
|
977
|
+
variant="outline"
|
|
978
|
+
size="sm"
|
|
979
|
+
disabled={!goToPage}
|
|
980
|
+
>
|
|
981
|
+
Go
|
|
982
|
+
</Button>
|
|
983
|
+
</div>
|
|
984
|
+
)}
|
|
985
|
+
|
|
986
|
+
<div className="flex items-center gap-1">
|
|
987
|
+
<Button
|
|
988
|
+
onClick={() => pagination.onPageChange(0)}
|
|
989
|
+
disabled={pagination.currentPage === 0}
|
|
990
|
+
variant="ghost"
|
|
991
|
+
size="icon"
|
|
992
|
+
aria-label="First page"
|
|
993
|
+
>
|
|
994
|
+
<ChevronsLeft className="w-4 h-4" />
|
|
995
|
+
</Button>
|
|
996
|
+
<Button
|
|
997
|
+
onClick={() => pagination.onPageChange(pagination.currentPage - 1)}
|
|
998
|
+
disabled={pagination.currentPage === 0}
|
|
999
|
+
variant="ghost"
|
|
1000
|
+
size="icon"
|
|
1001
|
+
aria-label="Previous page"
|
|
1002
|
+
>
|
|
1003
|
+
<ChevronLeft className="w-4 h-4" />
|
|
1004
|
+
</Button>
|
|
1005
|
+
<Button
|
|
1006
|
+
onClick={() => pagination.onPageChange(pagination.currentPage + 1)}
|
|
1007
|
+
disabled={pagination.currentPage >= pagination.totalPages - 1}
|
|
1008
|
+
variant="ghost"
|
|
1009
|
+
size="icon"
|
|
1010
|
+
aria-label="Next page"
|
|
1011
|
+
>
|
|
1012
|
+
<ChevronRight className="w-4 h-4" />
|
|
1013
|
+
</Button>
|
|
1014
|
+
<Button
|
|
1015
|
+
onClick={() => pagination.onPageChange(pagination.totalPages - 1)}
|
|
1016
|
+
disabled={pagination.currentPage >= pagination.totalPages - 1}
|
|
1017
|
+
variant="ghost"
|
|
1018
|
+
size="icon"
|
|
1019
|
+
aria-label="Last page"
|
|
1020
|
+
>
|
|
1021
|
+
<ChevronsRight className="w-4 h-4" />
|
|
1022
|
+
</Button>
|
|
1023
|
+
</div>
|
|
1024
|
+
</div>
|
|
1025
|
+
</div>
|
|
1026
|
+
);
|
|
1027
|
+
}
|