gp-grid-react 0.1.2 → 0.1.4

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 DELETED
@@ -1,1334 +0,0 @@
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
- const [isDraggingSelection, setIsDraggingSelection] = useState(false);
275
-
276
- // Computed heights
277
- const filterRowHeight = showFilters ? 40 : 0;
278
- const totalHeaderHeight = headerHeight + filterRowHeight;
279
-
280
- // Create data source from rowData if not provided
281
- const dataSource = useMemo(() => {
282
- if (providedDataSource) {
283
- return providedDataSource;
284
- }
285
- if (rowData) {
286
- return createDataSourceFromArray(rowData);
287
- }
288
- // Empty data source
289
- return createClientDataSource<TData>([]);
290
- }, [providedDataSource, rowData]);
291
-
292
- // Compute column positions
293
- const columnPositions = useMemo(() => {
294
- const positions = [0];
295
- let pos = 0;
296
- for (const col of columns) {
297
- pos += col.width;
298
- positions.push(pos);
299
- }
300
- return positions;
301
- }, [columns]);
302
-
303
- const totalWidth = columnPositions[columnPositions.length - 1] ?? 0;
304
-
305
- // Initialize GridCore
306
- useEffect(() => {
307
- const core = new GridCore<TData>({
308
- columns,
309
- dataSource,
310
- rowHeight,
311
- headerHeight: totalHeaderHeight,
312
- overscan,
313
- });
314
-
315
- coreRef.current = core;
316
-
317
- // Subscribe to batched instructions for efficient state updates
318
- const unsubscribe = core.onBatchInstruction((instructions) => {
319
- dispatch({ type: "BATCH_INSTRUCTIONS", instructions });
320
- });
321
-
322
- // Initialize
323
- core.initialize();
324
-
325
- return () => {
326
- unsubscribe();
327
- coreRef.current = null;
328
- };
329
- }, [columns, dataSource, rowHeight, totalHeaderHeight, overscan]);
330
-
331
- // Handle scroll
332
- const handleScroll = useCallback(() => {
333
- const container = containerRef.current;
334
- const core = coreRef.current;
335
- if (!container || !core) return;
336
-
337
- core.setViewport(
338
- container.scrollTop,
339
- container.scrollLeft,
340
- container.clientWidth,
341
- container.clientHeight
342
- );
343
- }, []);
344
-
345
- // Initial measurement
346
- useEffect(() => {
347
- const container = containerRef.current;
348
- const core = coreRef.current;
349
- if (!container || !core) return;
350
-
351
- const resizeObserver = new ResizeObserver(() => {
352
- core.setViewport(
353
- container.scrollTop,
354
- container.scrollLeft,
355
- container.clientWidth,
356
- container.clientHeight
357
- );
358
- });
359
-
360
- resizeObserver.observe(container);
361
- handleScroll();
362
-
363
- return () => resizeObserver.disconnect();
364
- }, [handleScroll]);
365
-
366
- // Handle filter change with debounce
367
- const handleFilterChange = useCallback(
368
- (colId: string, value: string) => {
369
- setFilterValues((prev) => ({ ...prev, [colId]: value }));
370
-
371
- // Clear existing timeout
372
- if (filterTimeoutRef.current[colId]) {
373
- clearTimeout(filterTimeoutRef.current[colId]);
374
- }
375
-
376
- // Debounce the actual filter application
377
- filterTimeoutRef.current[colId] = setTimeout(() => {
378
- const core = coreRef.current;
379
- if (core) {
380
- core.setFilter(colId, value);
381
- }
382
- }, filterDebounce);
383
- },
384
- [filterDebounce]
385
- );
386
-
387
- // Keyboard navigation
388
- const handleKeyDown = useCallback(
389
- (e: React.KeyboardEvent) => {
390
- const core = coreRef.current;
391
- if (!core) return;
392
-
393
- // Don't handle keyboard events when editing
394
- if (state.editingCell && e.key !== "Enter" && e.key !== "Escape" && e.key !== "Tab") {
395
- return;
396
- }
397
-
398
- const { selection } = core;
399
- const isShift = e.shiftKey;
400
- const isCtrl = e.ctrlKey || e.metaKey;
401
-
402
- switch (e.key) {
403
- case "ArrowUp":
404
- e.preventDefault();
405
- selection.moveFocus("up", isShift);
406
- break;
407
- case "ArrowDown":
408
- e.preventDefault();
409
- selection.moveFocus("down", isShift);
410
- break;
411
- case "ArrowLeft":
412
- e.preventDefault();
413
- selection.moveFocus("left", isShift);
414
- break;
415
- case "ArrowRight":
416
- e.preventDefault();
417
- selection.moveFocus("right", isShift);
418
- break;
419
- case "Enter":
420
- e.preventDefault();
421
- if (state.editingCell) {
422
- core.commitEdit();
423
- } else if (state.activeCell) {
424
- core.startEdit(state.activeCell.row, state.activeCell.col);
425
- }
426
- break;
427
- case "Escape":
428
- e.preventDefault();
429
- if (state.editingCell) {
430
- core.cancelEdit();
431
- } else {
432
- selection.clearSelection();
433
- }
434
- break;
435
- case "Tab":
436
- e.preventDefault();
437
- if (state.editingCell) {
438
- core.commitEdit();
439
- }
440
- selection.moveFocus(isShift ? "left" : "right", false);
441
- break;
442
- case "a":
443
- if (isCtrl) {
444
- e.preventDefault();
445
- selection.selectAll();
446
- }
447
- break;
448
- case "c":
449
- if (isCtrl) {
450
- e.preventDefault();
451
- selection.copySelectionToClipboard();
452
- }
453
- break;
454
- case "F2":
455
- e.preventDefault();
456
- if (state.activeCell && !state.editingCell) {
457
- core.startEdit(state.activeCell.row, state.activeCell.col);
458
- }
459
- break;
460
- case "Delete":
461
- case "Backspace":
462
- // Start editing with empty value on delete/backspace
463
- if (state.activeCell && !state.editingCell) {
464
- e.preventDefault();
465
- core.startEdit(state.activeCell.row, state.activeCell.col);
466
- }
467
- break;
468
- default:
469
- // Start editing on any printable character
470
- if (
471
- state.activeCell &&
472
- !state.editingCell &&
473
- !isCtrl &&
474
- e.key.length === 1
475
- ) {
476
- core.startEdit(state.activeCell.row, state.activeCell.col);
477
- }
478
- break;
479
- }
480
- },
481
- [state.activeCell, state.editingCell]
482
- );
483
-
484
- // Scroll active cell into view when navigating with keyboard
485
- useEffect(() => {
486
- // Skip scrolling when editing - the user just clicked on the cell so it's already visible
487
- if (!state.activeCell || !containerRef.current || state.editingCell) return;
488
-
489
- const { row, col } = state.activeCell;
490
- const container = containerRef.current;
491
-
492
- // Calculate cell position
493
- const cellTop = row * rowHeight + totalHeaderHeight;
494
- const cellBottom = cellTop + rowHeight;
495
- const cellLeft = columnPositions[col] ?? 0;
496
- const cellRight = cellLeft + (columns[col]?.width ?? 0);
497
-
498
- // Get visible area
499
- const visibleTop = container.scrollTop + totalHeaderHeight;
500
- const visibleBottom = container.scrollTop + container.clientHeight;
501
- const visibleLeft = container.scrollLeft;
502
- const visibleRight = container.scrollLeft + container.clientWidth;
503
-
504
- // Scroll vertically if needed
505
- if (cellTop < visibleTop) {
506
- container.scrollTop = cellTop - totalHeaderHeight;
507
- } else if (cellBottom > visibleBottom) {
508
- container.scrollTop = cellBottom - container.clientHeight;
509
- }
510
-
511
- // Scroll horizontally if needed
512
- if (cellLeft < visibleLeft) {
513
- container.scrollLeft = cellLeft;
514
- } else if (cellRight > visibleRight) {
515
- container.scrollLeft = cellRight - container.clientWidth;
516
- }
517
- }, [state.activeCell, state.editingCell, rowHeight, totalHeaderHeight, columnPositions, columns]);
518
-
519
- // Cell mouse down handler (starts selection and drag)
520
- const handleCellMouseDown = useCallback(
521
- (rowIndex: number, colIndex: number, e: React.MouseEvent) => {
522
- // console.log("[GP-Grid] Cell mousedown:", { rowIndex, colIndex, coreExists: !!coreRef.current });
523
- const core = coreRef.current;
524
- if (!core || core.getEditState() !== null) {
525
- // console.warn("[GP-Grid] Core not initialized on cell mousedown");
526
- return;
527
- }
528
-
529
- // Only handle left mouse button
530
- if (e.button !== 0) return;
531
-
532
- // Focus the container to enable keyboard navigation
533
- containerRef.current?.focus();
534
-
535
- core.selection.startSelection(
536
- { row: rowIndex, col: colIndex },
537
- { shift: e.shiftKey, ctrl: e.ctrlKey || e.metaKey }
538
- );
539
-
540
- // Start drag selection (unless shift is held - that's a one-time extend)
541
- if (!e.shiftKey) {
542
- setIsDraggingSelection(true);
543
- }
544
- },
545
- []
546
- );
547
-
548
- // Cell double-click handler
549
- const handleCellDoubleClick = useCallback(
550
- (rowIndex: number, colIndex: number) => {
551
- const core = coreRef.current;
552
- if (!core) return;
553
-
554
- core.startEdit(rowIndex, colIndex);
555
- },
556
- []
557
- );
558
-
559
- // Header click handler (sort)
560
- const handleHeaderClick = useCallback(
561
- (colIndex: number, e: React.MouseEvent) => {
562
- // console.log("[GP-Grid] Header click:", { colIndex, coreExists: !!coreRef.current });
563
- const core = coreRef.current;
564
- if (!core) {
565
- // console.warn("[GP-Grid] Core not initialized on header click");
566
- return;
567
- }
568
-
569
- const column = columns[colIndex];
570
- if (!column) {
571
- // console.warn("[GP-Grid] Column not found for index:", colIndex);
572
- return;
573
- }
574
-
575
- const colId = column.colId ?? column.field;
576
- const headerInfo = state.headers.get(colIndex);
577
- const currentDirection = headerInfo?.sortDirection;
578
-
579
- // Cycle: none -> asc -> desc -> none
580
- let newDirection: SortDirection | null;
581
- if (!currentDirection) {
582
- newDirection = "asc";
583
- } else if (currentDirection === "asc") {
584
- newDirection = "desc";
585
- } else {
586
- newDirection = null;
587
- }
588
-
589
- // console.log("[GP-Grid] Setting sort:", { colId, newDirection });
590
- core.setSort(colId, newDirection, e.shiftKey);
591
- },
592
- [columns, state.headers]
593
- );
594
-
595
- // Fill handle drag handlers
596
- const handleFillHandleMouseDown = useCallback(
597
- (e: React.MouseEvent) => {
598
- // console.log("[GP-Grid] Fill handle mousedown triggered");
599
- e.preventDefault();
600
- e.stopPropagation();
601
-
602
- const core = coreRef.current;
603
- if (!core) return;
604
-
605
- const { activeCell, selectionRange } = state;
606
- if (!activeCell && !selectionRange) return;
607
-
608
- // Create source range from selection or active cell
609
- const sourceRange = selectionRange ?? {
610
- startRow: activeCell!.row,
611
- startCol: activeCell!.col,
612
- endRow: activeCell!.row,
613
- endCol: activeCell!.col,
614
- };
615
-
616
- // console.log("[GP-Grid] Starting fill drag with source range:", sourceRange);
617
- core.fill.startFillDrag(sourceRange);
618
- setFillSourceRange(sourceRange);
619
- setFillTarget({
620
- row: Math.max(sourceRange.startRow, sourceRange.endRow),
621
- col: Math.max(sourceRange.startCol, sourceRange.endCol)
622
- });
623
- setIsDraggingFill(true);
624
- },
625
- [state.activeCell, state.selectionRange]
626
- );
627
-
628
- // Handle mouse move during fill drag
629
- useEffect(() => {
630
- if (!isDraggingFill) return;
631
-
632
- // Auto-scroll configuration
633
- const SCROLL_THRESHOLD = 40; // pixels from edge to trigger scroll
634
- const SCROLL_SPEED = 10; // pixels per frame
635
-
636
- const handleMouseMove = (e: MouseEvent) => {
637
- const core = coreRef.current;
638
- const container = containerRef.current;
639
- if (!core || !container) return;
640
-
641
- // Get container bounds
642
- const rect = container.getBoundingClientRect();
643
- const scrollLeft = container.scrollLeft;
644
- const scrollTop = container.scrollTop;
645
-
646
- // Calculate mouse position relative to grid content
647
- const mouseX = e.clientX - rect.left + scrollLeft;
648
- const mouseY = e.clientY - rect.top + scrollTop - totalHeaderHeight;
649
-
650
- // Find the row and column under the mouse
651
- const targetRow = Math.max(0, Math.floor(mouseY / rowHeight));
652
-
653
- // Find column by checking column positions
654
- let targetCol = 0;
655
- for (let i = 0; i < columnPositions.length - 1; i++) {
656
- if (mouseX >= columnPositions[i]! && mouseX < columnPositions[i + 1]!) {
657
- targetCol = i;
658
- break;
659
- }
660
- if (mouseX >= columnPositions[columnPositions.length - 1]!) {
661
- targetCol = columnPositions.length - 2;
662
- }
663
- }
664
-
665
- core.fill.updateFillDrag(targetRow, targetCol);
666
- setFillTarget({ row: targetRow, col: targetCol });
667
-
668
- // Auto-scroll logic
669
- const mouseYInContainer = e.clientY - rect.top;
670
- const mouseXInContainer = e.clientX - rect.left;
671
-
672
- // Clear any existing auto-scroll
673
- if (autoScrollIntervalRef.current) {
674
- clearInterval(autoScrollIntervalRef.current);
675
- autoScrollIntervalRef.current = null;
676
- }
677
-
678
- // Check if we need to auto-scroll
679
- let scrollDeltaX = 0;
680
- let scrollDeltaY = 0;
681
-
682
- // Vertical scrolling
683
- if (mouseYInContainer < SCROLL_THRESHOLD + totalHeaderHeight) {
684
- scrollDeltaY = -SCROLL_SPEED;
685
- } else if (mouseYInContainer > rect.height - SCROLL_THRESHOLD) {
686
- scrollDeltaY = SCROLL_SPEED;
687
- }
688
-
689
- // Horizontal scrolling
690
- if (mouseXInContainer < SCROLL_THRESHOLD) {
691
- scrollDeltaX = -SCROLL_SPEED;
692
- } else if (mouseXInContainer > rect.width - SCROLL_THRESHOLD) {
693
- scrollDeltaX = SCROLL_SPEED;
694
- }
695
-
696
- // Start auto-scroll if needed
697
- if (scrollDeltaX !== 0 || scrollDeltaY !== 0) {
698
- autoScrollIntervalRef.current = setInterval(() => {
699
- if (containerRef.current) {
700
- containerRef.current.scrollTop += scrollDeltaY;
701
- containerRef.current.scrollLeft += scrollDeltaX;
702
- }
703
- }, 16); // ~60fps
704
- }
705
- };
706
-
707
- const handleMouseUp = () => {
708
- // Clear auto-scroll
709
- if (autoScrollIntervalRef.current) {
710
- clearInterval(autoScrollIntervalRef.current);
711
- autoScrollIntervalRef.current = null;
712
- }
713
-
714
- const core = coreRef.current;
715
- if (core) {
716
- core.fill.commitFillDrag();
717
- // Refresh slots to show updated values
718
- core.refreshSlotData();
719
- }
720
- setIsDraggingFill(false);
721
- setFillTarget(null);
722
- setFillSourceRange(null);
723
- };
724
-
725
- document.addEventListener("mousemove", handleMouseMove);
726
- document.addEventListener("mouseup", handleMouseUp);
727
-
728
- return () => {
729
- // Clear auto-scroll on cleanup
730
- if (autoScrollIntervalRef.current) {
731
- clearInterval(autoScrollIntervalRef.current);
732
- autoScrollIntervalRef.current = null;
733
- }
734
- document.removeEventListener("mousemove", handleMouseMove);
735
- document.removeEventListener("mouseup", handleMouseUp);
736
- };
737
- }, [isDraggingFill, totalHeaderHeight, rowHeight, columnPositions]);
738
-
739
- // Handle mouse move/up during selection drag
740
- useEffect(() => {
741
- if (!isDraggingSelection) return;
742
-
743
- // Auto-scroll configuration
744
- const SCROLL_THRESHOLD = 40;
745
- const SCROLL_SPEED = 10;
746
-
747
- const handleMouseMove = (e: MouseEvent) => {
748
- const core = coreRef.current;
749
- const container = containerRef.current;
750
- if (!core || !container) return;
751
-
752
- // Get container bounds
753
- const rect = container.getBoundingClientRect();
754
- const scrollLeft = container.scrollLeft;
755
- const scrollTop = container.scrollTop;
756
-
757
- // Calculate mouse position relative to grid content
758
- const mouseX = e.clientX - rect.left + scrollLeft;
759
- const mouseY = e.clientY - rect.top + scrollTop - totalHeaderHeight;
760
-
761
- // Find the row and column under the mouse
762
- const targetRow = Math.max(0, Math.min(Math.floor(mouseY / rowHeight), core.getRowCount() - 1));
763
-
764
- // Find column by checking column positions
765
- let targetCol = 0;
766
- for (let i = 0; i < columnPositions.length - 1; i++) {
767
- if (mouseX >= columnPositions[i]! && mouseX < columnPositions[i + 1]!) {
768
- targetCol = i;
769
- break;
770
- }
771
- if (mouseX >= columnPositions[columnPositions.length - 1]!) {
772
- targetCol = columnPositions.length - 2;
773
- }
774
- }
775
- targetCol = Math.max(0, Math.min(targetCol, columns.length - 1));
776
-
777
- // Extend selection to target cell (like shift+click)
778
- core.selection.startSelection(
779
- { row: targetRow, col: targetCol },
780
- { shift: true }
781
- );
782
-
783
- // Auto-scroll logic
784
- const mouseYInContainer = e.clientY - rect.top;
785
- const mouseXInContainer = e.clientX - rect.left;
786
-
787
- // Clear any existing auto-scroll
788
- if (autoScrollIntervalRef.current) {
789
- clearInterval(autoScrollIntervalRef.current);
790
- autoScrollIntervalRef.current = null;
791
- }
792
-
793
- // Check if we need to auto-scroll
794
- let scrollDeltaX = 0;
795
- let scrollDeltaY = 0;
796
-
797
- // Vertical scrolling
798
- if (mouseYInContainer < SCROLL_THRESHOLD + totalHeaderHeight) {
799
- scrollDeltaY = -SCROLL_SPEED;
800
- } else if (mouseYInContainer > rect.height - SCROLL_THRESHOLD) {
801
- scrollDeltaY = SCROLL_SPEED;
802
- }
803
-
804
- // Horizontal scrolling
805
- if (mouseXInContainer < SCROLL_THRESHOLD) {
806
- scrollDeltaX = -SCROLL_SPEED;
807
- } else if (mouseXInContainer > rect.width - SCROLL_THRESHOLD) {
808
- scrollDeltaX = SCROLL_SPEED;
809
- }
810
-
811
- // Start auto-scroll if needed
812
- if (scrollDeltaX !== 0 || scrollDeltaY !== 0) {
813
- autoScrollIntervalRef.current = setInterval(() => {
814
- if (containerRef.current) {
815
- containerRef.current.scrollTop += scrollDeltaY;
816
- containerRef.current.scrollLeft += scrollDeltaX;
817
- }
818
- }, 16); // ~60fps
819
- }
820
- };
821
-
822
- const handleMouseUp = () => {
823
- // Clear auto-scroll
824
- if (autoScrollIntervalRef.current) {
825
- clearInterval(autoScrollIntervalRef.current);
826
- autoScrollIntervalRef.current = null;
827
- }
828
- setIsDraggingSelection(false);
829
- };
830
-
831
- document.addEventListener("mousemove", handleMouseMove);
832
- document.addEventListener("mouseup", handleMouseUp);
833
-
834
- return () => {
835
- if (autoScrollIntervalRef.current) {
836
- clearInterval(autoScrollIntervalRef.current);
837
- autoScrollIntervalRef.current = null;
838
- }
839
- document.removeEventListener("mousemove", handleMouseMove);
840
- document.removeEventListener("mouseup", handleMouseUp);
841
- };
842
- }, [isDraggingSelection, totalHeaderHeight, rowHeight, columnPositions, columns.length]);
843
-
844
- // Render helpers
845
- const isSelected = useCallback(
846
- (row: number, col: number): boolean => {
847
- const { selectionRange } = state;
848
- if (!selectionRange) return false;
849
-
850
- const minRow = Math.min(selectionRange.startRow, selectionRange.endRow);
851
- const maxRow = Math.max(selectionRange.startRow, selectionRange.endRow);
852
- const minCol = Math.min(selectionRange.startCol, selectionRange.endCol);
853
- const maxCol = Math.max(selectionRange.startCol, selectionRange.endCol);
854
-
855
- return row >= minRow && row <= maxRow && col >= minCol && col <= maxCol;
856
- },
857
- [state.selectionRange]
858
- );
859
-
860
- const isActiveCell = useCallback(
861
- (row: number, col: number): boolean => {
862
- return state.activeCell?.row === row && state.activeCell?.col === col;
863
- },
864
- [state.activeCell]
865
- );
866
-
867
- const isEditingCell = useCallback(
868
- (row: number, col: number): boolean => {
869
- return state.editingCell?.row === row && state.editingCell?.col === col;
870
- },
871
- [state.editingCell]
872
- );
873
-
874
- // Check if cell is in fill preview range
875
- const isInFillPreview = useCallback(
876
- (row: number, col: number): boolean => {
877
- if (!isDraggingFill || !fillSourceRange || !fillTarget) return false;
878
-
879
- const srcMinRow = Math.min(fillSourceRange.startRow, fillSourceRange.endRow);
880
- const srcMaxRow = Math.max(fillSourceRange.startRow, fillSourceRange.endRow);
881
- const srcMinCol = Math.min(fillSourceRange.startCol, fillSourceRange.endCol);
882
- const srcMaxCol = Math.max(fillSourceRange.startCol, fillSourceRange.endCol);
883
-
884
- // Determine fill direction and range
885
- const fillDown = fillTarget.row > srcMaxRow;
886
- const fillUp = fillTarget.row < srcMinRow;
887
- const fillRight = fillTarget.col > srcMaxCol;
888
- const fillLeft = fillTarget.col < srcMinCol;
889
-
890
- // Check if cell is in the fill preview area (not the source area)
891
- if (fillDown) {
892
- return row > srcMaxRow && row <= fillTarget.row && col >= srcMinCol && col <= srcMaxCol;
893
- }
894
- if (fillUp) {
895
- return row < srcMinRow && row >= fillTarget.row && col >= srcMinCol && col <= srcMaxCol;
896
- }
897
- if (fillRight) {
898
- return col > srcMaxCol && col <= fillTarget.col && row >= srcMinRow && row <= srcMaxRow;
899
- }
900
- if (fillLeft) {
901
- return col < srcMinCol && col >= fillTarget.col && row >= srcMinRow && row <= srcMaxRow;
902
- }
903
-
904
- return false;
905
- },
906
- [isDraggingFill, fillSourceRange, fillTarget]
907
- );
908
-
909
- // Get cell value from row data
910
- const getCellValue = useCallback((rowData: Row, field: string): CellValue => {
911
- const parts = field.split(".");
912
- let value: unknown = rowData;
913
-
914
- for (const part of parts) {
915
- if (value == null || typeof value !== "object") {
916
- return null;
917
- }
918
- value = (value as Record<string, unknown>)[part];
919
- }
920
-
921
- return (value ?? null) as CellValue;
922
- }, []);
923
-
924
- // Render cell content
925
- const renderCell = useCallback(
926
- (
927
- column: ColumnDefinition,
928
- rowData: Row,
929
- rowIndex: number,
930
- colIndex: number
931
- ): React.ReactNode => {
932
- const value = getCellValue(rowData, column.field);
933
- const params: CellRendererParams = {
934
- value,
935
- rowData,
936
- column,
937
- rowIndex,
938
- colIndex,
939
- isActive: isActiveCell(rowIndex, colIndex),
940
- isSelected: isSelected(rowIndex, colIndex),
941
- isEditing: isEditingCell(rowIndex, colIndex),
942
- };
943
-
944
- // Check for column-specific renderer
945
- if (column.cellRenderer && typeof column.cellRenderer === "string") {
946
- const renderer = cellRenderers[column.cellRenderer];
947
- if (renderer) {
948
- return renderer(params);
949
- }
950
- }
951
-
952
- // Fall back to global renderer
953
- if (cellRenderer) {
954
- return cellRenderer(params);
955
- }
956
-
957
- // Default text rendering
958
- return value == null ? "" : String(value);
959
- },
960
- [getCellValue, isActiveCell, isSelected, isEditingCell, cellRenderers, cellRenderer]
961
- );
962
-
963
- // Render edit cell
964
- const renderEditCell = useCallback(
965
- (
966
- column: ColumnDefinition,
967
- rowData: Row,
968
- rowIndex: number,
969
- colIndex: number,
970
- initialValue: CellValue
971
- ): React.ReactNode => {
972
- const core = coreRef.current;
973
- if (!core) return null;
974
-
975
- const value = getCellValue(rowData, column.field);
976
- const params: EditRendererParams = {
977
- value,
978
- rowData,
979
- column,
980
- rowIndex,
981
- colIndex,
982
- isActive: true,
983
- isSelected: true,
984
- isEditing: true,
985
- initialValue,
986
- onValueChange: (newValue) => core.updateEditValue(newValue),
987
- onCommit: () => core.commitEdit(),
988
- onCancel: () => core.cancelEdit(),
989
- };
990
-
991
- // Check for column-specific renderer
992
- if (column.editRenderer && typeof column.editRenderer === "string") {
993
- const renderer = editRenderers[column.editRenderer];
994
- if (renderer) {
995
- return renderer(params);
996
- }
997
- }
998
-
999
- // Fall back to global renderer
1000
- if (editRenderer) {
1001
- return editRenderer(params);
1002
- }
1003
-
1004
- // Default input
1005
- return (
1006
- <input
1007
- className="gp-grid-edit-input"
1008
- type="text"
1009
- defaultValue={initialValue == null ? "" : String(initialValue)}
1010
- autoFocus
1011
- onFocus={(e) => e.target.select()}
1012
- onChange={(e) => core.updateEditValue(e.target.value)}
1013
- onKeyDown={(e) => {
1014
- e.stopPropagation();
1015
- if (e.key === "Enter") {
1016
- core.commitEdit();
1017
- } else if (e.key === "Escape") {
1018
- core.cancelEdit();
1019
- } else if (e.key === "Tab") {
1020
- e.preventDefault();
1021
- core.commitEdit();
1022
- core.selection.moveFocus(e.shiftKey ? "left" : "right", false);
1023
- }
1024
- }}
1025
- onBlur={() => core.commitEdit()}
1026
- />
1027
- );
1028
- },
1029
- [getCellValue, editRenderers, editRenderer]
1030
- );
1031
-
1032
- // Render header
1033
- const renderHeader = useCallback(
1034
- (
1035
- column: ColumnDefinition,
1036
- colIndex: number,
1037
- sortDirection?: SortDirection,
1038
- sortIndex?: number
1039
- ): React.ReactNode => {
1040
- const core = coreRef.current;
1041
- const params: HeaderRendererParams = {
1042
- column,
1043
- colIndex,
1044
- sortDirection,
1045
- sortIndex,
1046
- onSort: (direction, addToExisting) => {
1047
- if (core) {
1048
- core.setSort(column.colId ?? column.field, direction, addToExisting);
1049
- }
1050
- },
1051
- };
1052
-
1053
- // Check for column-specific renderer
1054
- if (column.headerRenderer && typeof column.headerRenderer === "string") {
1055
- const renderer = headerRenderers[column.headerRenderer];
1056
- if (renderer) {
1057
- return renderer(params);
1058
- }
1059
- }
1060
-
1061
- // Fall back to global renderer
1062
- if (headerRenderer) {
1063
- return headerRenderer(params);
1064
- }
1065
-
1066
- // Default header
1067
- return (
1068
- <>
1069
- <span className="gp-grid-header-text">
1070
- {column.headerName ?? column.field}
1071
- </span>
1072
- {sortDirection && (
1073
- <span className="gp-grid-sort-indicator">
1074
- {sortDirection === "asc" ? "▲" : "▼"}
1075
- {sortIndex !== undefined && sortIndex > 0 && (
1076
- <span className="gp-grid-sort-index">{sortIndex}</span>
1077
- )}
1078
- </span>
1079
- )}
1080
- </>
1081
- );
1082
- },
1083
- [headerRenderers, headerRenderer]
1084
- );
1085
-
1086
- // Convert slots map to array for rendering
1087
- const slotsArray = useMemo(() => Array.from(state.slots.values()), [state.slots]);
1088
-
1089
- // Calculate fill handle position (only show for editable columns)
1090
- const fillHandlePosition = useMemo(() => {
1091
- const { activeCell, selectionRange } = state;
1092
- if (!activeCell && !selectionRange) return null;
1093
-
1094
- // Get the bottom-right corner and column range of selection or active cell
1095
- let row: number, col: number;
1096
- let minCol: number, maxCol: number;
1097
-
1098
- if (selectionRange) {
1099
- row = Math.max(selectionRange.startRow, selectionRange.endRow);
1100
- col = Math.max(selectionRange.startCol, selectionRange.endCol);
1101
- minCol = Math.min(selectionRange.startCol, selectionRange.endCol);
1102
- maxCol = Math.max(selectionRange.startCol, selectionRange.endCol);
1103
- } else if (activeCell) {
1104
- row = activeCell.row;
1105
- col = activeCell.col;
1106
- minCol = col;
1107
- maxCol = col;
1108
- } else {
1109
- return null;
1110
- }
1111
-
1112
- // Check if ALL columns in the selection are editable
1113
- for (let c = minCol; c <= maxCol; c++) {
1114
- const column = columns[c];
1115
- if (!column || column.editable !== true) {
1116
- return null; // Don't show fill handle if any column is not editable
1117
- }
1118
- }
1119
-
1120
- const cellTop = row * rowHeight + totalHeaderHeight;
1121
- const cellLeft = columnPositions[col] ?? 0;
1122
- const cellWidth = columns[col]?.width ?? 0;
1123
-
1124
- return {
1125
- top: cellTop + rowHeight - 5,
1126
- left: cellLeft + cellWidth - 20, // Move significantly left to avoid scrollbar overlap
1127
- };
1128
- }, [state.activeCell, state.selectionRange, rowHeight, totalHeaderHeight, columnPositions, columns]);
1129
-
1130
- return (
1131
- <div
1132
- ref={containerRef}
1133
- className={`gp-grid-container${darkMode ? " gp-grid-container--dark" : ""}`}
1134
- style={{
1135
- width: "100%",
1136
- height: "100%",
1137
- overflow: "auto",
1138
- position: "relative",
1139
- }}
1140
- onScroll={handleScroll}
1141
- onKeyDown={handleKeyDown}
1142
- tabIndex={0}
1143
- >
1144
- {/* Content sizer */}
1145
- <div
1146
- style={{
1147
- width: Math.max(state.contentWidth, totalWidth),
1148
- height: Math.max(state.contentHeight, totalHeaderHeight),
1149
- position: "relative",
1150
- minWidth: "100%",
1151
- }}
1152
- >
1153
- {/* Headers */}
1154
- <div
1155
- className="gp-grid-header"
1156
- style={{
1157
- position: "sticky",
1158
- top: 0,
1159
- left: 0,
1160
- height: headerHeight,
1161
- width: Math.max(state.contentWidth, totalWidth),
1162
- minWidth: "100%",
1163
- zIndex: 100,
1164
- }}
1165
- >
1166
- {columns.map((column, colIndex) => {
1167
- const headerInfo = state.headers.get(colIndex);
1168
- return (
1169
- <div
1170
- key={column.colId ?? column.field}
1171
- className="gp-grid-header-cell"
1172
- style={{
1173
- position: "absolute",
1174
- left: `${columnPositions[colIndex]}px`,
1175
- top: 0,
1176
- width: `${column.width}px`,
1177
- height: `${headerHeight}px`,
1178
- background: "transparent",
1179
- }}
1180
- onClick={(e) => handleHeaderClick(colIndex, e)}
1181
- >
1182
- {renderHeader(
1183
- column,
1184
- colIndex,
1185
- headerInfo?.sortDirection,
1186
- headerInfo?.sortIndex
1187
- )}
1188
- </div>
1189
- );
1190
- })}
1191
- </div>
1192
-
1193
- {/* Filter Row */}
1194
- {showFilters && (
1195
- <div
1196
- className="gp-grid-filter-row"
1197
- style={{
1198
- position: "sticky",
1199
- top: headerHeight,
1200
- left: 0,
1201
- height: filterRowHeight,
1202
- width: Math.max(state.contentWidth, totalWidth),
1203
- minWidth: "100%",
1204
- zIndex: 99,
1205
- }}
1206
- >
1207
- {columns.map((column, colIndex) => {
1208
- const colId = column.colId ?? column.field;
1209
- return (
1210
- <div
1211
- key={`filter-${colId}`}
1212
- className="gp-grid-filter-cell"
1213
- style={{
1214
- position: "absolute",
1215
- left: `${columnPositions[colIndex]}px`,
1216
- top: 0,
1217
- width: `${column.width}px`,
1218
- height: `${filterRowHeight}px`,
1219
- }}
1220
- >
1221
- <input
1222
- className="gp-grid-filter-input"
1223
- type="text"
1224
- placeholder={`Filter ${column.headerName ?? column.field}...`}
1225
- value={filterValues[colId] ?? ""}
1226
- onChange={(e) => handleFilterChange(colId, e.target.value)}
1227
- onKeyDown={(e) => e.stopPropagation()}
1228
- />
1229
- </div>
1230
- );
1231
- })}
1232
- </div>
1233
- )}
1234
-
1235
- {/* Row slots */}
1236
- {slotsArray.map((slot) => {
1237
- if (slot.rowIndex < 0) return null;
1238
-
1239
- const isEvenRow = slot.rowIndex % 2 === 0;
1240
-
1241
- return (
1242
- <div
1243
- key={slot.slotId}
1244
- className={`gp-grid-row ${isEvenRow ? "gp-grid-row--even" : ""}`}
1245
- style={{
1246
- position: "absolute",
1247
- top: 0,
1248
- left: 0,
1249
- transform: `translateY(${slot.translateY}px)`,
1250
- width: `${Math.max(state.contentWidth, totalWidth)}px`,
1251
- height: `${rowHeight}px`,
1252
- }}
1253
- >
1254
- {columns.map((column, colIndex) => {
1255
- const isEditing = isEditingCell(slot.rowIndex, colIndex);
1256
- const active = isActiveCell(slot.rowIndex, colIndex);
1257
- const selected = isSelected(slot.rowIndex, colIndex);
1258
- const inFillPreview = isInFillPreview(slot.rowIndex, colIndex);
1259
-
1260
- const cellClasses = [
1261
- "gp-grid-cell",
1262
- active && "gp-grid-cell--active",
1263
- selected && !active && "gp-grid-cell--selected",
1264
- isEditing && "gp-grid-cell--editing",
1265
- inFillPreview && "gp-grid-cell--fill-preview",
1266
- ]
1267
- .filter(Boolean)
1268
- .join(" ");
1269
-
1270
- return (
1271
- <div
1272
- key={`${slot.slotId}-${colIndex}`}
1273
- className={cellClasses}
1274
- style={{
1275
- position: "absolute",
1276
- left: `${columnPositions[colIndex]}px`,
1277
- top: 0,
1278
- width: `${column.width}px`,
1279
- height: `${rowHeight}px`,
1280
- }}
1281
- onMouseDown={(e) => handleCellMouseDown(slot.rowIndex, colIndex, e)}
1282
- onDoubleClick={() => handleCellDoubleClick(slot.rowIndex, colIndex)}
1283
- >
1284
- {isEditing && state.editingCell
1285
- ? renderEditCell(
1286
- column,
1287
- slot.rowData,
1288
- slot.rowIndex,
1289
- colIndex,
1290
- state.editingCell.initialValue
1291
- )
1292
- : renderCell(column, slot.rowData, slot.rowIndex, colIndex)}
1293
- </div>
1294
- );
1295
- })}
1296
- </div>
1297
- );
1298
- })}
1299
-
1300
- {/* Fill handle (drag to fill) */}
1301
- {fillHandlePosition && !state.editingCell && (
1302
- <div
1303
- className="gp-grid-fill-handle"
1304
- style={{
1305
- position: "absolute",
1306
- top: fillHandlePosition.top,
1307
- left: fillHandlePosition.left,
1308
- zIndex: 200,
1309
- }}
1310
- onMouseDown={handleFillHandleMouseDown}
1311
- />
1312
- )}
1313
-
1314
- {/* Loading indicator */}
1315
- {state.isLoading && (
1316
- <div className="gp-grid-loading">
1317
- <div className="gp-grid-loading-spinner" />
1318
- Loading...
1319
- </div>
1320
- )}
1321
-
1322
- {/* Error message */}
1323
- {state.error && (
1324
- <div className="gp-grid-error">Error: {state.error}</div>
1325
- )}
1326
-
1327
- {/* Empty state */}
1328
- {!state.isLoading && !state.error && state.totalRows === 0 && (
1329
- <div className="gp-grid-empty">No data to display</div>
1330
- )}
1331
- </div>
1332
- </div>
1333
- );
1334
- }