@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,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
+ }