gp-grid-react 0.1.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/src/Grid.tsx ADDED
@@ -0,0 +1,1220 @@
1
+ // packages/react/src/Grid.tsx
2
+
3
+ import React, {
4
+ useEffect,
5
+ useRef,
6
+ useReducer,
7
+ useCallback,
8
+ useMemo,
9
+ useState,
10
+ } from "react";
11
+ import {
12
+ GridCore,
13
+ createClientDataSource,
14
+ createDataSourceFromArray,
15
+ } from "gp-grid-core";
16
+ import type {
17
+ GridInstruction,
18
+ ColumnDefinition,
19
+ DataSource,
20
+ Row,
21
+ CellRendererParams,
22
+ EditRendererParams,
23
+ HeaderRendererParams,
24
+ CellPosition,
25
+ CellRange,
26
+ CellValue,
27
+ SortDirection,
28
+ } from "gp-grid-core";
29
+ import { injectStyles } from "./styles";
30
+
31
+ // =============================================================================
32
+ // Types
33
+ // =============================================================================
34
+
35
+ export type ReactCellRenderer = (params: CellRendererParams) => React.ReactNode;
36
+ export type ReactEditRenderer = (params: EditRendererParams) => React.ReactNode;
37
+ export type ReactHeaderRenderer = (params: HeaderRendererParams) => React.ReactNode;
38
+
39
+ export interface GridProps<TData extends Row = Row> {
40
+ columns: ColumnDefinition[];
41
+ /** Data source for the grid */
42
+ dataSource?: DataSource<TData>;
43
+ /** Legacy: Raw row data (will be wrapped in a client data source) */
44
+ rowData?: TData[];
45
+ rowHeight: number;
46
+ headerHeight?: number;
47
+ overscan?: number;
48
+ /** Show filter row below headers */
49
+ showFilters?: boolean;
50
+ /** Debounce time for filter input (ms) */
51
+ filterDebounce?: number;
52
+ /** Enable dark mode styling */
53
+ darkMode?: boolean;
54
+
55
+ // Renderer registries
56
+ cellRenderers?: Record<string, ReactCellRenderer>;
57
+ editRenderers?: Record<string, ReactEditRenderer>;
58
+ headerRenderers?: Record<string, ReactHeaderRenderer>;
59
+
60
+ // Global fallback renderers
61
+ cellRenderer?: ReactCellRenderer;
62
+ editRenderer?: ReactEditRenderer;
63
+ headerRenderer?: ReactHeaderRenderer;
64
+ }
65
+
66
+ // =============================================================================
67
+ // State Types
68
+ // =============================================================================
69
+
70
+ interface SlotData {
71
+ slotId: string;
72
+ rowIndex: number;
73
+ rowData: Row;
74
+ translateY: number;
75
+ }
76
+
77
+ interface GridState {
78
+ slots: Map<string, SlotData>;
79
+ activeCell: CellPosition | null;
80
+ selectionRange: CellRange | null;
81
+ editingCell: { row: number; col: number; initialValue: CellValue } | null;
82
+ contentWidth: number;
83
+ contentHeight: number;
84
+ headers: Map<number, { column: ColumnDefinition; sortDirection?: SortDirection; sortIndex?: number }>;
85
+ isLoading: boolean;
86
+ error: string | null;
87
+ totalRows: number;
88
+ }
89
+
90
+ type GridAction =
91
+ | { type: "BATCH_INSTRUCTIONS"; instructions: GridInstruction[] }
92
+ | { type: "RESET" };
93
+
94
+ // =============================================================================
95
+ // Reducer
96
+ // =============================================================================
97
+
98
+ /**
99
+ * Apply a single instruction to mutable slot maps and return other state changes.
100
+ * This allows batching multiple slot operations efficiently.
101
+ */
102
+ function applyInstruction(
103
+ instruction: GridInstruction,
104
+ slots: Map<string, SlotData>,
105
+ headers: Map<number, { column: ColumnDefinition; sortDirection?: SortDirection; sortIndex?: number }>
106
+ ): Partial<GridState> | null {
107
+ switch (instruction.type) {
108
+ case "CREATE_SLOT":
109
+ slots.set(instruction.slotId, {
110
+ slotId: instruction.slotId,
111
+ rowIndex: -1,
112
+ rowData: {},
113
+ translateY: 0,
114
+ });
115
+ return null; // Slots map is mutated
116
+
117
+ case "DESTROY_SLOT":
118
+ slots.delete(instruction.slotId);
119
+ return null;
120
+
121
+ case "ASSIGN_SLOT": {
122
+ const existing = slots.get(instruction.slotId);
123
+ if (existing) {
124
+ slots.set(instruction.slotId, {
125
+ ...existing,
126
+ rowIndex: instruction.rowIndex,
127
+ rowData: instruction.rowData,
128
+ });
129
+ }
130
+ return null;
131
+ }
132
+
133
+ case "MOVE_SLOT": {
134
+ const existing = slots.get(instruction.slotId);
135
+ if (existing) {
136
+ slots.set(instruction.slotId, {
137
+ ...existing,
138
+ translateY: instruction.translateY,
139
+ });
140
+ }
141
+ return null;
142
+ }
143
+
144
+ case "SET_ACTIVE_CELL":
145
+ return { activeCell: instruction.position };
146
+
147
+ case "SET_SELECTION_RANGE":
148
+ return { selectionRange: instruction.range };
149
+
150
+ case "START_EDIT":
151
+ return {
152
+ editingCell: {
153
+ row: instruction.row,
154
+ col: instruction.col,
155
+ initialValue: instruction.initialValue,
156
+ },
157
+ };
158
+
159
+ case "STOP_EDIT":
160
+ return { editingCell: null };
161
+
162
+ case "SET_CONTENT_SIZE":
163
+ return {
164
+ contentWidth: instruction.width,
165
+ contentHeight: instruction.height,
166
+ };
167
+
168
+ case "UPDATE_HEADER":
169
+ headers.set(instruction.colIndex, {
170
+ column: instruction.column,
171
+ sortDirection: instruction.sortDirection,
172
+ sortIndex: instruction.sortIndex,
173
+ });
174
+ return null;
175
+
176
+ case "DATA_LOADING":
177
+ return { isLoading: true, error: null };
178
+
179
+ case "DATA_LOADED":
180
+ return { isLoading: false, totalRows: instruction.totalRows };
181
+
182
+ case "DATA_ERROR":
183
+ return { isLoading: false, error: instruction.error };
184
+
185
+ default:
186
+ return null;
187
+ }
188
+ }
189
+
190
+ function gridReducer(state: GridState, action: GridAction): GridState {
191
+ if (action.type === "RESET") {
192
+ return createInitialState();
193
+ }
194
+
195
+ // Process batch of instructions in one state update
196
+ const { instructions } = action;
197
+ // console.log("[GP-Grid Reducer] Processing batch:", instructions.map(i => i.type));
198
+ if (instructions.length === 0) {
199
+ return state;
200
+ }
201
+
202
+ // Create mutable copies of Maps to batch updates
203
+ const newSlots = new Map(state.slots);
204
+ const newHeaders = new Map(state.headers);
205
+ let stateChanges: Partial<GridState> = {};
206
+
207
+ // Apply all instructions
208
+ for (const instruction of instructions) {
209
+ const changes = applyInstruction(instruction, newSlots, newHeaders);
210
+ if (changes) {
211
+ stateChanges = { ...stateChanges, ...changes };
212
+ }
213
+ }
214
+
215
+ // Return new state with all changes applied
216
+ return {
217
+ ...state,
218
+ ...stateChanges,
219
+ slots: newSlots,
220
+ headers: newHeaders,
221
+ };
222
+ }
223
+
224
+ function createInitialState(): GridState {
225
+ return {
226
+ slots: new Map(),
227
+ activeCell: null,
228
+ selectionRange: null,
229
+ editingCell: null,
230
+ contentWidth: 0,
231
+ contentHeight: 0,
232
+ headers: new Map(),
233
+ isLoading: false,
234
+ error: null,
235
+ totalRows: 0,
236
+ };
237
+ }
238
+
239
+ // =============================================================================
240
+ // Grid Component
241
+ // =============================================================================
242
+
243
+ export function Grid<TData extends Row = Row>(props: GridProps<TData>) {
244
+ // Inject styles on first render (safe to call multiple times)
245
+ injectStyles();
246
+
247
+ const {
248
+ columns,
249
+ dataSource: providedDataSource,
250
+ rowData,
251
+ rowHeight,
252
+ headerHeight = rowHeight,
253
+ overscan = 3,
254
+ showFilters = false,
255
+ filterDebounce = 300,
256
+ darkMode = false,
257
+ cellRenderers = {},
258
+ editRenderers = {},
259
+ headerRenderers = {},
260
+ cellRenderer,
261
+ editRenderer,
262
+ headerRenderer,
263
+ } = props;
264
+
265
+ const containerRef = useRef<HTMLDivElement>(null);
266
+ const coreRef = useRef<GridCore<TData> | null>(null);
267
+ const [state, dispatch] = useReducer(gridReducer, null, createInitialState);
268
+ const [filterValues, setFilterValues] = useState<Record<string, string>>({});
269
+ const filterTimeoutRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
270
+ const [isDraggingFill, setIsDraggingFill] = useState(false);
271
+ const [fillTarget, setFillTarget] = useState<{ row: number; col: number } | null>(null);
272
+ const [fillSourceRange, setFillSourceRange] = useState<{ startRow: number; startCol: number; endRow: number; endCol: number } | null>(null);
273
+ const autoScrollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
274
+
275
+ // Computed heights
276
+ const filterRowHeight = showFilters ? 40 : 0;
277
+ const totalHeaderHeight = headerHeight + filterRowHeight;
278
+
279
+ // Create data source from rowData if not provided
280
+ const dataSource = useMemo(() => {
281
+ if (providedDataSource) {
282
+ return providedDataSource;
283
+ }
284
+ if (rowData) {
285
+ return createDataSourceFromArray(rowData);
286
+ }
287
+ // Empty data source
288
+ return createClientDataSource<TData>([]);
289
+ }, [providedDataSource, rowData]);
290
+
291
+ // Compute column positions
292
+ const columnPositions = useMemo(() => {
293
+ const positions = [0];
294
+ let pos = 0;
295
+ for (const col of columns) {
296
+ pos += col.width;
297
+ positions.push(pos);
298
+ }
299
+ return positions;
300
+ }, [columns]);
301
+
302
+ const totalWidth = columnPositions[columnPositions.length - 1] ?? 0;
303
+
304
+ // Initialize GridCore
305
+ useEffect(() => {
306
+ const core = new GridCore<TData>({
307
+ columns,
308
+ dataSource,
309
+ rowHeight,
310
+ headerHeight: totalHeaderHeight,
311
+ overscan,
312
+ });
313
+
314
+ coreRef.current = core;
315
+
316
+ // Subscribe to batched instructions for efficient state updates
317
+ const unsubscribe = core.onBatchInstruction((instructions) => {
318
+ dispatch({ type: "BATCH_INSTRUCTIONS", instructions });
319
+ });
320
+
321
+ // Initialize
322
+ core.initialize();
323
+
324
+ return () => {
325
+ unsubscribe();
326
+ coreRef.current = null;
327
+ };
328
+ }, [columns, dataSource, rowHeight, totalHeaderHeight, overscan]);
329
+
330
+ // Handle scroll
331
+ const handleScroll = useCallback(() => {
332
+ const container = containerRef.current;
333
+ const core = coreRef.current;
334
+ if (!container || !core) return;
335
+
336
+ core.setViewport(
337
+ container.scrollTop,
338
+ container.scrollLeft,
339
+ container.clientWidth,
340
+ container.clientHeight
341
+ );
342
+ }, []);
343
+
344
+ // Initial measurement
345
+ useEffect(() => {
346
+ const container = containerRef.current;
347
+ const core = coreRef.current;
348
+ if (!container || !core) return;
349
+
350
+ const resizeObserver = new ResizeObserver(() => {
351
+ core.setViewport(
352
+ container.scrollTop,
353
+ container.scrollLeft,
354
+ container.clientWidth,
355
+ container.clientHeight
356
+ );
357
+ });
358
+
359
+ resizeObserver.observe(container);
360
+ handleScroll();
361
+
362
+ return () => resizeObserver.disconnect();
363
+ }, [handleScroll]);
364
+
365
+ // Handle filter change with debounce
366
+ const handleFilterChange = useCallback(
367
+ (colId: string, value: string) => {
368
+ setFilterValues((prev) => ({ ...prev, [colId]: value }));
369
+
370
+ // Clear existing timeout
371
+ if (filterTimeoutRef.current[colId]) {
372
+ clearTimeout(filterTimeoutRef.current[colId]);
373
+ }
374
+
375
+ // Debounce the actual filter application
376
+ filterTimeoutRef.current[colId] = setTimeout(() => {
377
+ const core = coreRef.current;
378
+ if (core) {
379
+ core.setFilter(colId, value);
380
+ }
381
+ }, filterDebounce);
382
+ },
383
+ [filterDebounce]
384
+ );
385
+
386
+ // Keyboard navigation
387
+ const handleKeyDown = useCallback(
388
+ (e: React.KeyboardEvent) => {
389
+ const core = coreRef.current;
390
+ if (!core) return;
391
+
392
+ // Don't handle keyboard events when editing
393
+ if (state.editingCell && e.key !== "Enter" && e.key !== "Escape" && e.key !== "Tab") {
394
+ return;
395
+ }
396
+
397
+ const { selection } = core;
398
+ const isShift = e.shiftKey;
399
+ const isCtrl = e.ctrlKey || e.metaKey;
400
+
401
+ switch (e.key) {
402
+ case "ArrowUp":
403
+ e.preventDefault();
404
+ selection.moveFocus("up", isShift);
405
+ break;
406
+ case "ArrowDown":
407
+ e.preventDefault();
408
+ selection.moveFocus("down", isShift);
409
+ break;
410
+ case "ArrowLeft":
411
+ e.preventDefault();
412
+ selection.moveFocus("left", isShift);
413
+ break;
414
+ case "ArrowRight":
415
+ e.preventDefault();
416
+ selection.moveFocus("right", isShift);
417
+ break;
418
+ case "Enter":
419
+ e.preventDefault();
420
+ if (state.editingCell) {
421
+ core.commitEdit();
422
+ } else if (state.activeCell) {
423
+ core.startEdit(state.activeCell.row, state.activeCell.col);
424
+ }
425
+ break;
426
+ case "Escape":
427
+ e.preventDefault();
428
+ if (state.editingCell) {
429
+ core.cancelEdit();
430
+ } else {
431
+ selection.clearSelection();
432
+ }
433
+ break;
434
+ case "Tab":
435
+ e.preventDefault();
436
+ if (state.editingCell) {
437
+ core.commitEdit();
438
+ }
439
+ selection.moveFocus(isShift ? "left" : "right", false);
440
+ break;
441
+ case "a":
442
+ if (isCtrl) {
443
+ e.preventDefault();
444
+ selection.selectAll();
445
+ }
446
+ break;
447
+ case "c":
448
+ if (isCtrl) {
449
+ e.preventDefault();
450
+ selection.copySelectionToClipboard();
451
+ }
452
+ break;
453
+ case "F2":
454
+ e.preventDefault();
455
+ if (state.activeCell && !state.editingCell) {
456
+ core.startEdit(state.activeCell.row, state.activeCell.col);
457
+ }
458
+ break;
459
+ case "Delete":
460
+ case "Backspace":
461
+ // Start editing with empty value on delete/backspace
462
+ if (state.activeCell && !state.editingCell) {
463
+ e.preventDefault();
464
+ core.startEdit(state.activeCell.row, state.activeCell.col);
465
+ }
466
+ break;
467
+ default:
468
+ // Start editing on any printable character
469
+ if (
470
+ state.activeCell &&
471
+ !state.editingCell &&
472
+ !isCtrl &&
473
+ e.key.length === 1
474
+ ) {
475
+ core.startEdit(state.activeCell.row, state.activeCell.col);
476
+ }
477
+ break;
478
+ }
479
+ },
480
+ [state.activeCell, state.editingCell]
481
+ );
482
+
483
+ // Scroll active cell into view when navigating with keyboard
484
+ useEffect(() => {
485
+ // Skip scrolling when editing - the user just clicked on the cell so it's already visible
486
+ if (!state.activeCell || !containerRef.current || state.editingCell) return;
487
+
488
+ const { row, col } = state.activeCell;
489
+ const container = containerRef.current;
490
+
491
+ // Calculate cell position
492
+ const cellTop = row * rowHeight + totalHeaderHeight;
493
+ const cellBottom = cellTop + rowHeight;
494
+ const cellLeft = columnPositions[col] ?? 0;
495
+ const cellRight = cellLeft + (columns[col]?.width ?? 0);
496
+
497
+ // Get visible area
498
+ const visibleTop = container.scrollTop + totalHeaderHeight;
499
+ const visibleBottom = container.scrollTop + container.clientHeight;
500
+ const visibleLeft = container.scrollLeft;
501
+ const visibleRight = container.scrollLeft + container.clientWidth;
502
+
503
+ // Scroll vertically if needed
504
+ if (cellTop < visibleTop) {
505
+ container.scrollTop = cellTop - totalHeaderHeight;
506
+ } else if (cellBottom > visibleBottom) {
507
+ container.scrollTop = cellBottom - container.clientHeight;
508
+ }
509
+
510
+ // Scroll horizontally if needed
511
+ if (cellLeft < visibleLeft) {
512
+ container.scrollLeft = cellLeft;
513
+ } else if (cellRight > visibleRight) {
514
+ container.scrollLeft = cellRight - container.clientWidth;
515
+ }
516
+ }, [state.activeCell, state.editingCell, rowHeight, totalHeaderHeight, columnPositions, columns]);
517
+
518
+ // Cell click handler
519
+ const handleCellClick = useCallback(
520
+ (rowIndex: number, colIndex: number, e: React.MouseEvent) => {
521
+ // console.log("[GP-Grid] Cell click:", { rowIndex, colIndex, coreExists: !!coreRef.current });
522
+ const core = coreRef.current;
523
+ if (!core || core.getEditState() !== null) {
524
+ // console.warn("[GP-Grid] Core not initialized on cell click");
525
+ return;
526
+ }
527
+
528
+ // Focus the container to enable keyboard navigation
529
+ containerRef.current?.focus();
530
+
531
+ core.selection.startSelection(
532
+ { row: rowIndex, col: colIndex },
533
+ { shift: e.shiftKey, ctrl: e.ctrlKey || e.metaKey }
534
+ );
535
+ },
536
+ []
537
+ );
538
+
539
+ // Cell double-click handler
540
+ const handleCellDoubleClick = useCallback(
541
+ (rowIndex: number, colIndex: number) => {
542
+ const core = coreRef.current;
543
+ if (!core) return;
544
+
545
+ core.startEdit(rowIndex, colIndex);
546
+ },
547
+ []
548
+ );
549
+
550
+ // Header click handler (sort)
551
+ const handleHeaderClick = useCallback(
552
+ (colIndex: number, e: React.MouseEvent) => {
553
+ // console.log("[GP-Grid] Header click:", { colIndex, coreExists: !!coreRef.current });
554
+ const core = coreRef.current;
555
+ if (!core) {
556
+ // console.warn("[GP-Grid] Core not initialized on header click");
557
+ return;
558
+ }
559
+
560
+ const column = columns[colIndex];
561
+ if (!column) {
562
+ // console.warn("[GP-Grid] Column not found for index:", colIndex);
563
+ return;
564
+ }
565
+
566
+ const colId = column.colId ?? column.field;
567
+ const headerInfo = state.headers.get(colIndex);
568
+ const currentDirection = headerInfo?.sortDirection;
569
+
570
+ // Cycle: none -> asc -> desc -> none
571
+ let newDirection: SortDirection | null;
572
+ if (!currentDirection) {
573
+ newDirection = "asc";
574
+ } else if (currentDirection === "asc") {
575
+ newDirection = "desc";
576
+ } else {
577
+ newDirection = null;
578
+ }
579
+
580
+ // console.log("[GP-Grid] Setting sort:", { colId, newDirection });
581
+ core.setSort(colId, newDirection, e.shiftKey);
582
+ },
583
+ [columns, state.headers]
584
+ );
585
+
586
+ // Fill handle drag handlers
587
+ const handleFillHandleMouseDown = useCallback(
588
+ (e: React.MouseEvent) => {
589
+ // console.log("[GP-Grid] Fill handle mousedown triggered");
590
+ e.preventDefault();
591
+ e.stopPropagation();
592
+
593
+ const core = coreRef.current;
594
+ if (!core) return;
595
+
596
+ const { activeCell, selectionRange } = state;
597
+ if (!activeCell && !selectionRange) return;
598
+
599
+ // Create source range from selection or active cell
600
+ const sourceRange = selectionRange ?? {
601
+ startRow: activeCell!.row,
602
+ startCol: activeCell!.col,
603
+ endRow: activeCell!.row,
604
+ endCol: activeCell!.col,
605
+ };
606
+
607
+ // console.log("[GP-Grid] Starting fill drag with source range:", sourceRange);
608
+ core.fill.startFillDrag(sourceRange);
609
+ setFillSourceRange(sourceRange);
610
+ setFillTarget({
611
+ row: Math.max(sourceRange.startRow, sourceRange.endRow),
612
+ col: Math.max(sourceRange.startCol, sourceRange.endCol)
613
+ });
614
+ setIsDraggingFill(true);
615
+ },
616
+ [state.activeCell, state.selectionRange]
617
+ );
618
+
619
+ // Handle mouse move during fill drag
620
+ useEffect(() => {
621
+ if (!isDraggingFill) return;
622
+
623
+ // Auto-scroll configuration
624
+ const SCROLL_THRESHOLD = 40; // pixels from edge to trigger scroll
625
+ const SCROLL_SPEED = 10; // pixels per frame
626
+
627
+ const handleMouseMove = (e: MouseEvent) => {
628
+ const core = coreRef.current;
629
+ const container = containerRef.current;
630
+ if (!core || !container) return;
631
+
632
+ // Get container bounds
633
+ const rect = container.getBoundingClientRect();
634
+ const scrollLeft = container.scrollLeft;
635
+ const scrollTop = container.scrollTop;
636
+
637
+ // Calculate mouse position relative to grid content
638
+ const mouseX = e.clientX - rect.left + scrollLeft;
639
+ const mouseY = e.clientY - rect.top + scrollTop - totalHeaderHeight;
640
+
641
+ // Find the row and column under the mouse
642
+ const targetRow = Math.max(0, Math.floor(mouseY / rowHeight));
643
+
644
+ // Find column by checking column positions
645
+ let targetCol = 0;
646
+ for (let i = 0; i < columnPositions.length - 1; i++) {
647
+ if (mouseX >= columnPositions[i]! && mouseX < columnPositions[i + 1]!) {
648
+ targetCol = i;
649
+ break;
650
+ }
651
+ if (mouseX >= columnPositions[columnPositions.length - 1]!) {
652
+ targetCol = columnPositions.length - 2;
653
+ }
654
+ }
655
+
656
+ core.fill.updateFillDrag(targetRow, targetCol);
657
+ setFillTarget({ row: targetRow, col: targetCol });
658
+
659
+ // Auto-scroll logic
660
+ const mouseYInContainer = e.clientY - rect.top;
661
+ const mouseXInContainer = e.clientX - rect.left;
662
+
663
+ // Clear any existing auto-scroll
664
+ if (autoScrollIntervalRef.current) {
665
+ clearInterval(autoScrollIntervalRef.current);
666
+ autoScrollIntervalRef.current = null;
667
+ }
668
+
669
+ // Check if we need to auto-scroll
670
+ let scrollDeltaX = 0;
671
+ let scrollDeltaY = 0;
672
+
673
+ // Vertical scrolling
674
+ if (mouseYInContainer < SCROLL_THRESHOLD + totalHeaderHeight) {
675
+ scrollDeltaY = -SCROLL_SPEED;
676
+ } else if (mouseYInContainer > rect.height - SCROLL_THRESHOLD) {
677
+ scrollDeltaY = SCROLL_SPEED;
678
+ }
679
+
680
+ // Horizontal scrolling
681
+ if (mouseXInContainer < SCROLL_THRESHOLD) {
682
+ scrollDeltaX = -SCROLL_SPEED;
683
+ } else if (mouseXInContainer > rect.width - SCROLL_THRESHOLD) {
684
+ scrollDeltaX = SCROLL_SPEED;
685
+ }
686
+
687
+ // Start auto-scroll if needed
688
+ if (scrollDeltaX !== 0 || scrollDeltaY !== 0) {
689
+ autoScrollIntervalRef.current = setInterval(() => {
690
+ if (containerRef.current) {
691
+ containerRef.current.scrollTop += scrollDeltaY;
692
+ containerRef.current.scrollLeft += scrollDeltaX;
693
+ }
694
+ }, 16); // ~60fps
695
+ }
696
+ };
697
+
698
+ const handleMouseUp = () => {
699
+ // Clear auto-scroll
700
+ if (autoScrollIntervalRef.current) {
701
+ clearInterval(autoScrollIntervalRef.current);
702
+ autoScrollIntervalRef.current = null;
703
+ }
704
+
705
+ const core = coreRef.current;
706
+ if (core) {
707
+ core.fill.commitFillDrag();
708
+ // Refresh slots to show updated values
709
+ core.refreshSlotData();
710
+ }
711
+ setIsDraggingFill(false);
712
+ setFillTarget(null);
713
+ setFillSourceRange(null);
714
+ };
715
+
716
+ document.addEventListener("mousemove", handleMouseMove);
717
+ document.addEventListener("mouseup", handleMouseUp);
718
+
719
+ return () => {
720
+ // Clear auto-scroll on cleanup
721
+ if (autoScrollIntervalRef.current) {
722
+ clearInterval(autoScrollIntervalRef.current);
723
+ autoScrollIntervalRef.current = null;
724
+ }
725
+ document.removeEventListener("mousemove", handleMouseMove);
726
+ document.removeEventListener("mouseup", handleMouseUp);
727
+ };
728
+ }, [isDraggingFill, totalHeaderHeight, rowHeight, columnPositions]);
729
+
730
+ // Render helpers
731
+ const isSelected = useCallback(
732
+ (row: number, col: number): boolean => {
733
+ const { selectionRange } = state;
734
+ if (!selectionRange) return false;
735
+
736
+ const minRow = Math.min(selectionRange.startRow, selectionRange.endRow);
737
+ const maxRow = Math.max(selectionRange.startRow, selectionRange.endRow);
738
+ const minCol = Math.min(selectionRange.startCol, selectionRange.endCol);
739
+ const maxCol = Math.max(selectionRange.startCol, selectionRange.endCol);
740
+
741
+ return row >= minRow && row <= maxRow && col >= minCol && col <= maxCol;
742
+ },
743
+ [state.selectionRange]
744
+ );
745
+
746
+ const isActiveCell = useCallback(
747
+ (row: number, col: number): boolean => {
748
+ return state.activeCell?.row === row && state.activeCell?.col === col;
749
+ },
750
+ [state.activeCell]
751
+ );
752
+
753
+ const isEditingCell = useCallback(
754
+ (row: number, col: number): boolean => {
755
+ return state.editingCell?.row === row && state.editingCell?.col === col;
756
+ },
757
+ [state.editingCell]
758
+ );
759
+
760
+ // Check if cell is in fill preview range
761
+ const isInFillPreview = useCallback(
762
+ (row: number, col: number): boolean => {
763
+ if (!isDraggingFill || !fillSourceRange || !fillTarget) return false;
764
+
765
+ const srcMinRow = Math.min(fillSourceRange.startRow, fillSourceRange.endRow);
766
+ const srcMaxRow = Math.max(fillSourceRange.startRow, fillSourceRange.endRow);
767
+ const srcMinCol = Math.min(fillSourceRange.startCol, fillSourceRange.endCol);
768
+ const srcMaxCol = Math.max(fillSourceRange.startCol, fillSourceRange.endCol);
769
+
770
+ // Determine fill direction and range
771
+ const fillDown = fillTarget.row > srcMaxRow;
772
+ const fillUp = fillTarget.row < srcMinRow;
773
+ const fillRight = fillTarget.col > srcMaxCol;
774
+ const fillLeft = fillTarget.col < srcMinCol;
775
+
776
+ // Check if cell is in the fill preview area (not the source area)
777
+ if (fillDown) {
778
+ return row > srcMaxRow && row <= fillTarget.row && col >= srcMinCol && col <= srcMaxCol;
779
+ }
780
+ if (fillUp) {
781
+ return row < srcMinRow && row >= fillTarget.row && col >= srcMinCol && col <= srcMaxCol;
782
+ }
783
+ if (fillRight) {
784
+ return col > srcMaxCol && col <= fillTarget.col && row >= srcMinRow && row <= srcMaxRow;
785
+ }
786
+ if (fillLeft) {
787
+ return col < srcMinCol && col >= fillTarget.col && row >= srcMinRow && row <= srcMaxRow;
788
+ }
789
+
790
+ return false;
791
+ },
792
+ [isDraggingFill, fillSourceRange, fillTarget]
793
+ );
794
+
795
+ // Get cell value from row data
796
+ const getCellValue = useCallback((rowData: Row, field: string): CellValue => {
797
+ const parts = field.split(".");
798
+ let value: unknown = rowData;
799
+
800
+ for (const part of parts) {
801
+ if (value == null || typeof value !== "object") {
802
+ return null;
803
+ }
804
+ value = (value as Record<string, unknown>)[part];
805
+ }
806
+
807
+ return (value ?? null) as CellValue;
808
+ }, []);
809
+
810
+ // Render cell content
811
+ const renderCell = useCallback(
812
+ (
813
+ column: ColumnDefinition,
814
+ rowData: Row,
815
+ rowIndex: number,
816
+ colIndex: number
817
+ ): React.ReactNode => {
818
+ const value = getCellValue(rowData, column.field);
819
+ const params: CellRendererParams = {
820
+ value,
821
+ rowData,
822
+ column,
823
+ rowIndex,
824
+ colIndex,
825
+ isActive: isActiveCell(rowIndex, colIndex),
826
+ isSelected: isSelected(rowIndex, colIndex),
827
+ isEditing: isEditingCell(rowIndex, colIndex),
828
+ };
829
+
830
+ // Check for column-specific renderer
831
+ if (column.cellRenderer && typeof column.cellRenderer === "string") {
832
+ const renderer = cellRenderers[column.cellRenderer];
833
+ if (renderer) {
834
+ return renderer(params);
835
+ }
836
+ }
837
+
838
+ // Fall back to global renderer
839
+ if (cellRenderer) {
840
+ return cellRenderer(params);
841
+ }
842
+
843
+ // Default text rendering
844
+ return value == null ? "" : String(value);
845
+ },
846
+ [getCellValue, isActiveCell, isSelected, isEditingCell, cellRenderers, cellRenderer]
847
+ );
848
+
849
+ // Render edit cell
850
+ const renderEditCell = useCallback(
851
+ (
852
+ column: ColumnDefinition,
853
+ rowData: Row,
854
+ rowIndex: number,
855
+ colIndex: number,
856
+ initialValue: CellValue
857
+ ): React.ReactNode => {
858
+ const core = coreRef.current;
859
+ if (!core) return null;
860
+
861
+ const value = getCellValue(rowData, column.field);
862
+ const params: EditRendererParams = {
863
+ value,
864
+ rowData,
865
+ column,
866
+ rowIndex,
867
+ colIndex,
868
+ isActive: true,
869
+ isSelected: true,
870
+ isEditing: true,
871
+ initialValue,
872
+ onValueChange: (newValue) => core.updateEditValue(newValue),
873
+ onCommit: () => core.commitEdit(),
874
+ onCancel: () => core.cancelEdit(),
875
+ };
876
+
877
+ // Check for column-specific renderer
878
+ if (column.editRenderer && typeof column.editRenderer === "string") {
879
+ const renderer = editRenderers[column.editRenderer];
880
+ if (renderer) {
881
+ return renderer(params);
882
+ }
883
+ }
884
+
885
+ // Fall back to global renderer
886
+ if (editRenderer) {
887
+ return editRenderer(params);
888
+ }
889
+
890
+ // Default input
891
+ return (
892
+ <input
893
+ className="gp-grid-edit-input"
894
+ type="text"
895
+ defaultValue={initialValue == null ? "" : String(initialValue)}
896
+ autoFocus
897
+ onFocus={(e) => e.target.select()}
898
+ onChange={(e) => core.updateEditValue(e.target.value)}
899
+ onKeyDown={(e) => {
900
+ e.stopPropagation();
901
+ if (e.key === "Enter") {
902
+ core.commitEdit();
903
+ } else if (e.key === "Escape") {
904
+ core.cancelEdit();
905
+ } else if (e.key === "Tab") {
906
+ e.preventDefault();
907
+ core.commitEdit();
908
+ core.selection.moveFocus(e.shiftKey ? "left" : "right", false);
909
+ }
910
+ }}
911
+ onBlur={() => core.commitEdit()}
912
+ />
913
+ );
914
+ },
915
+ [getCellValue, editRenderers, editRenderer]
916
+ );
917
+
918
+ // Render header
919
+ const renderHeader = useCallback(
920
+ (
921
+ column: ColumnDefinition,
922
+ colIndex: number,
923
+ sortDirection?: SortDirection,
924
+ sortIndex?: number
925
+ ): React.ReactNode => {
926
+ const core = coreRef.current;
927
+ const params: HeaderRendererParams = {
928
+ column,
929
+ colIndex,
930
+ sortDirection,
931
+ sortIndex,
932
+ onSort: (direction, addToExisting) => {
933
+ if (core) {
934
+ core.setSort(column.colId ?? column.field, direction, addToExisting);
935
+ }
936
+ },
937
+ };
938
+
939
+ // Check for column-specific renderer
940
+ if (column.headerRenderer && typeof column.headerRenderer === "string") {
941
+ const renderer = headerRenderers[column.headerRenderer];
942
+ if (renderer) {
943
+ return renderer(params);
944
+ }
945
+ }
946
+
947
+ // Fall back to global renderer
948
+ if (headerRenderer) {
949
+ return headerRenderer(params);
950
+ }
951
+
952
+ // Default header
953
+ return (
954
+ <>
955
+ <span className="gp-grid-header-text">
956
+ {column.headerName ?? column.field}
957
+ </span>
958
+ {sortDirection && (
959
+ <span className="gp-grid-sort-indicator">
960
+ {sortDirection === "asc" ? "▲" : "▼"}
961
+ {sortIndex !== undefined && sortIndex > 0 && (
962
+ <span className="gp-grid-sort-index">{sortIndex}</span>
963
+ )}
964
+ </span>
965
+ )}
966
+ </>
967
+ );
968
+ },
969
+ [headerRenderers, headerRenderer]
970
+ );
971
+
972
+ // Convert slots map to array for rendering
973
+ const slotsArray = useMemo(() => Array.from(state.slots.values()), [state.slots]);
974
+
975
+ // Calculate fill handle position (only show for editable columns)
976
+ const fillHandlePosition = useMemo(() => {
977
+ const { activeCell, selectionRange } = state;
978
+ if (!activeCell && !selectionRange) return null;
979
+
980
+ // Get the bottom-right corner and column range of selection or active cell
981
+ let row: number, col: number;
982
+ let minCol: number, maxCol: number;
983
+
984
+ if (selectionRange) {
985
+ row = Math.max(selectionRange.startRow, selectionRange.endRow);
986
+ col = Math.max(selectionRange.startCol, selectionRange.endCol);
987
+ minCol = Math.min(selectionRange.startCol, selectionRange.endCol);
988
+ maxCol = Math.max(selectionRange.startCol, selectionRange.endCol);
989
+ } else if (activeCell) {
990
+ row = activeCell.row;
991
+ col = activeCell.col;
992
+ minCol = col;
993
+ maxCol = col;
994
+ } else {
995
+ return null;
996
+ }
997
+
998
+ // Check if ALL columns in the selection are editable
999
+ for (let c = minCol; c <= maxCol; c++) {
1000
+ const column = columns[c];
1001
+ if (!column || column.editable !== true) {
1002
+ return null; // Don't show fill handle if any column is not editable
1003
+ }
1004
+ }
1005
+
1006
+ const cellTop = row * rowHeight + totalHeaderHeight;
1007
+ const cellLeft = columnPositions[col] ?? 0;
1008
+ const cellWidth = columns[col]?.width ?? 0;
1009
+
1010
+ return {
1011
+ top: cellTop + rowHeight - 5,
1012
+ left: cellLeft + cellWidth - 20, // Move significantly left to avoid scrollbar overlap
1013
+ };
1014
+ }, [state.activeCell, state.selectionRange, rowHeight, totalHeaderHeight, columnPositions, columns]);
1015
+
1016
+ return (
1017
+ <div
1018
+ ref={containerRef}
1019
+ className={`gp-grid-container${darkMode ? " gp-grid-container--dark" : ""}`}
1020
+ style={{
1021
+ width: "100%",
1022
+ height: "100%",
1023
+ overflow: "auto",
1024
+ position: "relative",
1025
+ }}
1026
+ onScroll={handleScroll}
1027
+ onKeyDown={handleKeyDown}
1028
+ tabIndex={0}
1029
+ >
1030
+ {/* Content sizer */}
1031
+ <div
1032
+ style={{
1033
+ width: Math.max(state.contentWidth, totalWidth),
1034
+ height: Math.max(state.contentHeight, totalHeaderHeight),
1035
+ position: "relative",
1036
+ minWidth: "100%",
1037
+ }}
1038
+ >
1039
+ {/* Headers */}
1040
+ <div
1041
+ className="gp-grid-header"
1042
+ style={{
1043
+ position: "sticky",
1044
+ top: 0,
1045
+ left: 0,
1046
+ height: headerHeight,
1047
+ width: Math.max(state.contentWidth, totalWidth),
1048
+ minWidth: "100%",
1049
+ zIndex: 100,
1050
+ }}
1051
+ >
1052
+ {columns.map((column, colIndex) => {
1053
+ const headerInfo = state.headers.get(colIndex);
1054
+ return (
1055
+ <div
1056
+ key={column.colId ?? column.field}
1057
+ className="gp-grid-header-cell"
1058
+ style={{
1059
+ position: "absolute",
1060
+ left: `${columnPositions[colIndex]}px`,
1061
+ top: 0,
1062
+ width: `${column.width}px`,
1063
+ height: `${headerHeight}px`,
1064
+ background: "transparent",
1065
+ }}
1066
+ onClick={(e) => handleHeaderClick(colIndex, e)}
1067
+ >
1068
+ {renderHeader(
1069
+ column,
1070
+ colIndex,
1071
+ headerInfo?.sortDirection,
1072
+ headerInfo?.sortIndex
1073
+ )}
1074
+ </div>
1075
+ );
1076
+ })}
1077
+ </div>
1078
+
1079
+ {/* Filter Row */}
1080
+ {showFilters && (
1081
+ <div
1082
+ className="gp-grid-filter-row"
1083
+ style={{
1084
+ position: "sticky",
1085
+ top: headerHeight,
1086
+ left: 0,
1087
+ height: filterRowHeight,
1088
+ width: Math.max(state.contentWidth, totalWidth),
1089
+ minWidth: "100%",
1090
+ zIndex: 99,
1091
+ }}
1092
+ >
1093
+ {columns.map((column, colIndex) => {
1094
+ const colId = column.colId ?? column.field;
1095
+ return (
1096
+ <div
1097
+ key={`filter-${colId}`}
1098
+ className="gp-grid-filter-cell"
1099
+ style={{
1100
+ position: "absolute",
1101
+ left: `${columnPositions[colIndex]}px`,
1102
+ top: 0,
1103
+ width: `${column.width}px`,
1104
+ height: `${filterRowHeight}px`,
1105
+ }}
1106
+ >
1107
+ <input
1108
+ className="gp-grid-filter-input"
1109
+ type="text"
1110
+ placeholder={`Filter ${column.headerName ?? column.field}...`}
1111
+ value={filterValues[colId] ?? ""}
1112
+ onChange={(e) => handleFilterChange(colId, e.target.value)}
1113
+ onKeyDown={(e) => e.stopPropagation()}
1114
+ />
1115
+ </div>
1116
+ );
1117
+ })}
1118
+ </div>
1119
+ )}
1120
+
1121
+ {/* Row slots */}
1122
+ {slotsArray.map((slot) => {
1123
+ if (slot.rowIndex < 0) return null;
1124
+
1125
+ const isEvenRow = slot.rowIndex % 2 === 0;
1126
+
1127
+ return (
1128
+ <div
1129
+ key={slot.slotId}
1130
+ className={`gp-grid-row ${isEvenRow ? "gp-grid-row--even" : ""}`}
1131
+ style={{
1132
+ position: "absolute",
1133
+ top: 0,
1134
+ left: 0,
1135
+ transform: `translateY(${slot.translateY}px)`,
1136
+ width: `${Math.max(state.contentWidth, totalWidth)}px`,
1137
+ height: `${rowHeight}px`,
1138
+ }}
1139
+ >
1140
+ {columns.map((column, colIndex) => {
1141
+ const isEditing = isEditingCell(slot.rowIndex, colIndex);
1142
+ const active = isActiveCell(slot.rowIndex, colIndex);
1143
+ const selected = isSelected(slot.rowIndex, colIndex);
1144
+ const inFillPreview = isInFillPreview(slot.rowIndex, colIndex);
1145
+
1146
+ const cellClasses = [
1147
+ "gp-grid-cell",
1148
+ active && "gp-grid-cell--active",
1149
+ selected && !active && "gp-grid-cell--selected",
1150
+ isEditing && "gp-grid-cell--editing",
1151
+ inFillPreview && "gp-grid-cell--fill-preview",
1152
+ ]
1153
+ .filter(Boolean)
1154
+ .join(" ");
1155
+
1156
+ return (
1157
+ <div
1158
+ key={`${slot.slotId}-${colIndex}`}
1159
+ className={cellClasses}
1160
+ style={{
1161
+ position: "absolute",
1162
+ left: `${columnPositions[colIndex]}px`,
1163
+ top: 0,
1164
+ width: `${column.width}px`,
1165
+ height: `${rowHeight}px`,
1166
+ }}
1167
+ onClick={(e) => handleCellClick(slot.rowIndex, colIndex, e)}
1168
+ onDoubleClick={() => handleCellDoubleClick(slot.rowIndex, colIndex)}
1169
+ >
1170
+ {isEditing && state.editingCell
1171
+ ? renderEditCell(
1172
+ column,
1173
+ slot.rowData,
1174
+ slot.rowIndex,
1175
+ colIndex,
1176
+ state.editingCell.initialValue
1177
+ )
1178
+ : renderCell(column, slot.rowData, slot.rowIndex, colIndex)}
1179
+ </div>
1180
+ );
1181
+ })}
1182
+ </div>
1183
+ );
1184
+ })}
1185
+
1186
+ {/* Fill handle (drag to fill) */}
1187
+ {fillHandlePosition && !state.editingCell && (
1188
+ <div
1189
+ className="gp-grid-fill-handle"
1190
+ style={{
1191
+ position: "absolute",
1192
+ top: fillHandlePosition.top,
1193
+ left: fillHandlePosition.left,
1194
+ zIndex: 200,
1195
+ }}
1196
+ onMouseDown={handleFillHandleMouseDown}
1197
+ />
1198
+ )}
1199
+
1200
+ {/* Loading indicator */}
1201
+ {state.isLoading && (
1202
+ <div className="gp-grid-loading">
1203
+ <div className="gp-grid-loading-spinner" />
1204
+ Loading...
1205
+ </div>
1206
+ )}
1207
+
1208
+ {/* Error message */}
1209
+ {state.error && (
1210
+ <div className="gp-grid-error">Error: {state.error}</div>
1211
+ )}
1212
+
1213
+ {/* Empty state */}
1214
+ {!state.isLoading && !state.error && state.totalRows === 0 && (
1215
+ <div className="gp-grid-empty">No data to display</div>
1216
+ )}
1217
+ </div>
1218
+ </div>
1219
+ );
1220
+ }