@papernote/ui 1.6.0 → 1.7.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.
@@ -0,0 +1,1025 @@
1
+ import React, {
2
+ useState,
3
+ useCallback,
4
+ useMemo,
5
+ useRef,
6
+ useEffect,
7
+ forwardRef,
8
+ useImperativeHandle,
9
+ } from 'react';
10
+ // @ts-ignore - fast-formula-parser doesn't have types
11
+ import * as FormulaParserModule from 'fast-formula-parser';
12
+ // Handle both ESM default export and CommonJS module.exports
13
+ // @ts-ignore
14
+ const FormulaParser = FormulaParserModule.default || FormulaParserModule;
15
+ import {
16
+ ChevronUp,
17
+ ChevronDown,
18
+ Filter,
19
+ X,
20
+ Download,
21
+ Save,
22
+ ArrowUpDown,
23
+ Pin,
24
+ PinOff,
25
+ } from 'lucide-react';
26
+ import Button from './Button';
27
+ import Input from './Input';
28
+ import Stack from './Stack';
29
+ import { addSuccessMessage, addErrorMessage } from './StatusBar';
30
+ import FormulaAutocomplete from './FormulaAutocomplete';
31
+
32
+ /**
33
+ * Cell value type - can be primitive or formula
34
+ */
35
+ export type CellValue = string | number | boolean | null;
36
+
37
+ /**
38
+ * Cell data structure
39
+ */
40
+ export interface DataGridCell {
41
+ /** The display/computed value */
42
+ value: CellValue;
43
+ /** Optional formula (e.g., "=SUM(B2:B5)") */
44
+ formula?: string;
45
+ /** Read-only cell */
46
+ readOnly?: boolean;
47
+ /** Custom class name */
48
+ className?: string;
49
+ }
50
+
51
+ /**
52
+ * Column configuration
53
+ */
54
+ export interface DataGridColumn {
55
+ /** Unique column key */
56
+ key: string;
57
+ /** Header text */
58
+ header: string;
59
+ /** Column width in pixels */
60
+ width?: number;
61
+ /** Minimum width */
62
+ minWidth?: number;
63
+ /** Text alignment */
64
+ align?: 'left' | 'center' | 'right';
65
+ /** Enable sorting */
66
+ sortable?: boolean;
67
+ /** Enable filtering */
68
+ filterable?: boolean;
69
+ /** Read-only column */
70
+ readOnly?: boolean;
71
+ /** Cell type for formatting */
72
+ type?: 'text' | 'number' | 'currency' | 'percent' | 'date';
73
+ /** Number format options */
74
+ format?: {
75
+ decimals?: number;
76
+ prefix?: string;
77
+ suffix?: string;
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Sort configuration
83
+ */
84
+ export interface SortConfig {
85
+ key: string;
86
+ direction: 'asc' | 'desc';
87
+ }
88
+
89
+ /**
90
+ * Filter configuration
91
+ */
92
+ export interface FilterConfig {
93
+ key: string;
94
+ value: string;
95
+ }
96
+
97
+ /**
98
+ * Frozen row mode options
99
+ * - 'none': No frozen rows
100
+ * - 'first': Freeze first data row (common for headers in data)
101
+ * - 'selected': Freeze the currently selected row
102
+ * - number: Freeze specific number of rows from top
103
+ */
104
+ export type FrozenRowMode = 'none' | 'first' | 'selected' | number;
105
+
106
+ /**
107
+ * DataGrid component props
108
+ */
109
+ export interface DataGridProps {
110
+ /** 2D array of cell data */
111
+ data: DataGridCell[][];
112
+ /** Column configurations */
113
+ columns: DataGridColumn[];
114
+ /** Callback when data changes */
115
+ onChange?: (data: DataGridCell[][], rowIndex: number, colIndex: number) => void;
116
+ /** Row headers (e.g., ["1", "2", "3"] or true for auto) */
117
+ rowHeaders?: boolean | string[];
118
+ /**
119
+ * Frozen rows configuration:
120
+ * - 'none' or 0: No frozen rows
121
+ * - 'first' or 1: Freeze first data row (common for headers in data)
122
+ * - 'selected': Freeze the currently selected row (moves with selection)
123
+ * - number > 1: Freeze specific number of rows from top
124
+ */
125
+ frozenRows?: FrozenRowMode;
126
+ /** Number of frozen columns at left */
127
+ frozenColumns?: number;
128
+ /** Show freeze row toggle button in toolbar */
129
+ showFreezeRowToggle?: boolean;
130
+ /** Enable zebra striping */
131
+ zebraStripes?: boolean;
132
+ /** Enable formula evaluation */
133
+ formulas?: boolean;
134
+ /** Read-only mode */
135
+ readOnly?: boolean;
136
+ /** Table height */
137
+ height?: number | string;
138
+ /** Table width */
139
+ width?: number | string;
140
+ /** Show toolbar */
141
+ showToolbar?: boolean;
142
+ /** Toolbar title */
143
+ title?: string;
144
+ /** Enable export */
145
+ enableExport?: boolean;
146
+ /** Export filename */
147
+ exportFileName?: string;
148
+ /** Enable save */
149
+ enableSave?: boolean;
150
+ /** Save handler */
151
+ onSave?: (data: DataGridCell[][]) => Promise<void> | void;
152
+ /** Custom toolbar actions */
153
+ toolbarActions?: React.ReactNode;
154
+ /** Custom class name */
155
+ className?: string;
156
+ /** Density */
157
+ density?: 'compact' | 'normal' | 'comfortable';
158
+ }
159
+
160
+ /**
161
+ * DataGrid imperative handle
162
+ */
163
+ export interface DataGridHandle {
164
+ /** Get current data */
165
+ getData: () => DataGridCell[][];
166
+ /** Set cell value */
167
+ setCell: (rowIndex: number, colIndex: number, value: CellValue | DataGridCell) => void;
168
+ /** Clear all filters */
169
+ clearFilters: () => void;
170
+ /** Clear sorting */
171
+ clearSort: () => void;
172
+ /** Export to CSV */
173
+ exportToCSV: () => void;
174
+ /** Freeze/unfreeze the first row */
175
+ toggleFreezeFirstRow: () => void;
176
+ /** Freeze/unfreeze the selected row */
177
+ toggleFreezeSelectedRow: () => void;
178
+ /** Set frozen rows mode */
179
+ setFrozenRows: (mode: FrozenRowMode) => void;
180
+ }
181
+
182
+ /**
183
+ * Convert column index to Excel-style letter (0 = A, 1 = B, ..., 26 = AA)
184
+ */
185
+ const colIndexToLetter = (index: number): string => {
186
+ let result = '';
187
+ let num = index;
188
+ while (num >= 0) {
189
+ result = String.fromCharCode((num % 26) + 65) + result;
190
+ num = Math.floor(num / 26) - 1;
191
+ }
192
+ return result;
193
+ };
194
+
195
+ // Note: parseRef is available for future formula reference parsing
196
+ // const parseRef = (ref: string): { row: number; col: number } | null => { ... }
197
+
198
+ /**
199
+ * DataGrid - Excel-like data grid component with formulas
200
+ *
201
+ * A grid-based spreadsheet component that provides:
202
+ * - Cell-level editing with formula support (280+ Excel formulas)
203
+ * - Sorting and filtering
204
+ * - Frozen rows and columns
205
+ * - Zebra striping
206
+ * - CSV export
207
+ * - Keyboard navigation
208
+ *
209
+ * Uses fast-formula-parser (MIT licensed) for formula evaluation.
210
+ *
211
+ * @example Basic usage
212
+ * ```tsx
213
+ * const columns = [
214
+ * { key: 'name', header: 'Name' },
215
+ * { key: 'q1', header: 'Q1', type: 'number' },
216
+ * { key: 'q2', header: 'Q2', type: 'number' },
217
+ * { key: 'total', header: 'Total', type: 'number' },
218
+ * ];
219
+ *
220
+ * const data = [
221
+ * [{ value: 'Widget A' }, { value: 100 }, { value: 150 }, { value: 0, formula: '=SUM(B1:C1)' }],
222
+ * [{ value: 'Widget B' }, { value: 200 }, { value: 250 }, { value: 0, formula: '=SUM(B2:C2)' }],
223
+ * ];
224
+ *
225
+ * <DataGrid
226
+ * data={data}
227
+ * columns={columns}
228
+ * formulas
229
+ * zebraStripes
230
+ * frozenRows={1}
231
+ * />
232
+ * ```
233
+ */
234
+ export const DataGrid = forwardRef<DataGridHandle, DataGridProps>(
235
+ (
236
+ {
237
+ data: initialData,
238
+ columns,
239
+ onChange,
240
+ rowHeaders = false,
241
+ frozenRows: frozenRowsProp = 'none',
242
+ frozenColumns = 0,
243
+ showFreezeRowToggle = false,
244
+ zebraStripes = false,
245
+ formulas = false,
246
+ readOnly = false,
247
+ height = 400,
248
+ width = '100%',
249
+ showToolbar = false,
250
+ title,
251
+ enableExport = false,
252
+ exportFileName = 'export.csv',
253
+ enableSave = false,
254
+ onSave,
255
+ toolbarActions,
256
+ className = '',
257
+ density = 'normal',
258
+ },
259
+ ref
260
+ ) => {
261
+ // State
262
+ const [data, setData] = useState<DataGridCell[][]>(initialData);
263
+ const [editingCell, setEditingCell] = useState<{ row: number; col: number } | null>(null);
264
+ const [editValue, setEditValue] = useState<string>('');
265
+ const [sortConfig, setSortConfig] = useState<SortConfig | null>(null);
266
+ const [filters, setFilters] = useState<FilterConfig[]>([]);
267
+ const [activeFilter, setActiveFilter] = useState<string | null>(null);
268
+ const [filterValue, setFilterValue] = useState<string>('');
269
+ const [isSaving, setIsSaving] = useState(false);
270
+ const [selectedCell, setSelectedCell] = useState<{ row: number; col: number } | null>(null);
271
+ const [frozenRowsState, setFrozenRowsState] = useState<FrozenRowMode>(frozenRowsProp);
272
+ const [editingCellRect, setEditingCellRect] = useState<DOMRect | null>(null);
273
+
274
+ // Update frozen rows when prop changes
275
+ useEffect(() => {
276
+ setFrozenRowsState(frozenRowsProp);
277
+ }, [frozenRowsProp]);
278
+
279
+ const tableRef = useRef<HTMLDivElement>(null);
280
+ const inputRef = useRef<HTMLInputElement>(null);
281
+
282
+ // Compute actual number of frozen rows based on mode
283
+ const frozenRows = useMemo(() => {
284
+ if (frozenRowsState === 'none') return 0;
285
+ if (frozenRowsState === 'first') return 1;
286
+ if (frozenRowsState === 'selected') {
287
+ // Return selected row + 1 (to include it), or 0 if nothing selected
288
+ return selectedCell ? selectedCell.row + 1 : 0;
289
+ }
290
+ if (typeof frozenRowsState === 'number') return frozenRowsState;
291
+ return 0;
292
+ }, [frozenRowsState, selectedCell]);
293
+
294
+ // Check if a specific row is frozen
295
+ const isRowFrozen = useCallback(
296
+ (rowIndex: number) => {
297
+ if (frozenRowsState === 'none') return false;
298
+ if (frozenRowsState === 'first') return rowIndex === 0;
299
+ if (frozenRowsState === 'selected') {
300
+ return selectedCell ? rowIndex === selectedCell.row : false;
301
+ }
302
+ if (typeof frozenRowsState === 'number') return rowIndex < frozenRowsState;
303
+ return false;
304
+ },
305
+ [frozenRowsState, selectedCell]
306
+ );
307
+
308
+ // Update data when initialData changes
309
+ useEffect(() => {
310
+ setData(initialData);
311
+ }, [initialData]);
312
+
313
+ // Get computed data with formulas evaluated
314
+ // Uses a cache to handle formula dependencies (formulas referencing other formulas)
315
+ const computedData = useMemo(() => {
316
+ if (!formulas) return data;
317
+
318
+ // Cache for computed cell values to handle dependencies
319
+ const computedCache: Map<string, CellValue> = new Map();
320
+
321
+ // Recursive function to get cell value, evaluating formulas as needed
322
+ const getCellValue = (r: number, c: number): CellValue => {
323
+ const cacheKey = `${r},${c}`;
324
+ if (computedCache.has(cacheKey)) {
325
+ return computedCache.get(cacheKey)!;
326
+ }
327
+
328
+ if (r < 0 || r >= data.length || c < 0 || c >= (data[r]?.length || 0)) {
329
+ return null;
330
+ }
331
+
332
+ const cell = data[r][c];
333
+ if (!cell?.formula) {
334
+ return cell?.value ?? null;
335
+ }
336
+
337
+ // Mark as computing to detect circular references
338
+ computedCache.set(cacheKey, '#CIRCULAR');
339
+
340
+ try {
341
+ const result = parser.parse(cell.formula.substring(1));
342
+ computedCache.set(cacheKey, result as CellValue);
343
+ return result as CellValue;
344
+ } catch (error) {
345
+ computedCache.set(cacheKey, '#ERROR');
346
+ return '#ERROR';
347
+ }
348
+ };
349
+
350
+ // Create parser with callbacks that resolve formula dependencies
351
+ const parser = new FormulaParser({
352
+ onCell: ({ row, col }: { sheet: string; row: number; col: number }) => {
353
+ // row and col are 1-indexed in the parser
354
+ return getCellValue(row - 1, col - 1);
355
+ },
356
+ onRange: ({ from, to }: { sheet: string; from: { row: number; col: number }; to: { row: number; col: number } }) => {
357
+ const result: (CellValue)[][] = [];
358
+ for (let r = from.row - 1; r <= to.row - 1; r++) {
359
+ const rowData: (CellValue)[] = [];
360
+ for (let c = from.col - 1; c <= to.col - 1; c++) {
361
+ rowData.push(getCellValue(r, c));
362
+ }
363
+ result.push(rowData);
364
+ }
365
+ return result;
366
+ },
367
+ });
368
+
369
+ // Compute all cells
370
+ return data.map((row, rowIndex) =>
371
+ row.map((cell, colIndex) => {
372
+ if (cell?.formula) {
373
+ return {
374
+ ...cell,
375
+ value: getCellValue(rowIndex, colIndex),
376
+ };
377
+ }
378
+ return cell;
379
+ })
380
+ );
381
+ }, [formulas, data]);
382
+
383
+ // Apply sorting
384
+ const sortedData = useMemo(() => {
385
+ if (!sortConfig) return computedData;
386
+
387
+ const colIndex = columns.findIndex((c) => c.key === sortConfig.key);
388
+ if (colIndex === -1) return computedData;
389
+
390
+ // Keep frozen rows at top
391
+ const frozenData = computedData.slice(0, frozenRows);
392
+ const sortableData = [...computedData.slice(frozenRows)];
393
+
394
+ sortableData.sort((a, b) => {
395
+ const aVal = a[colIndex]?.value ?? '';
396
+ const bVal = b[colIndex]?.value ?? '';
397
+
398
+ if (typeof aVal === 'number' && typeof bVal === 'number') {
399
+ return sortConfig.direction === 'asc' ? aVal - bVal : bVal - aVal;
400
+ }
401
+
402
+ const aStr = String(aVal).toLowerCase();
403
+ const bStr = String(bVal).toLowerCase();
404
+ const cmp = aStr.localeCompare(bStr);
405
+ return sortConfig.direction === 'asc' ? cmp : -cmp;
406
+ });
407
+
408
+ return [...frozenData, ...sortableData];
409
+ }, [computedData, sortConfig, columns, frozenRows]);
410
+
411
+ // Apply filters
412
+ const filteredData = useMemo(() => {
413
+ if (filters.length === 0) return sortedData;
414
+
415
+ // Keep frozen rows
416
+ const frozenData = sortedData.slice(0, frozenRows);
417
+ const filterableData = sortedData.slice(frozenRows);
418
+
419
+ const filtered = filterableData.filter((row) => {
420
+ return filters.every((filter) => {
421
+ const colIndex = columns.findIndex((c) => c.key === filter.key);
422
+ if (colIndex === -1) return true;
423
+
424
+ const cellValue = String(row[colIndex]?.value ?? '').toLowerCase();
425
+ return cellValue.includes(filter.value.toLowerCase());
426
+ });
427
+ });
428
+
429
+ return [...frozenData, ...filtered];
430
+ }, [sortedData, filters, columns, frozenRows]);
431
+
432
+ // Handle cell edit start
433
+ const handleCellDoubleClick = useCallback(
434
+ (rowIndex: number, colIndex: number, cellElement?: HTMLElement) => {
435
+ if (readOnly) return;
436
+ const column = columns[colIndex];
437
+ if (column?.readOnly) return;
438
+ const cell = data[rowIndex]?.[colIndex];
439
+ if (cell?.readOnly) return;
440
+
441
+ setEditingCell({ row: rowIndex, col: colIndex });
442
+ setEditValue(cell?.formula || String(cell?.value ?? ''));
443
+
444
+ // Capture cell position for formula autocomplete dropdown
445
+ if (cellElement) {
446
+ setEditingCellRect(cellElement.getBoundingClientRect());
447
+ }
448
+
449
+ setTimeout(() => inputRef.current?.focus(), 0);
450
+ },
451
+ [readOnly, columns, data]
452
+ );
453
+
454
+ // Handle cell edit complete
455
+ const handleEditComplete = useCallback(() => {
456
+ if (!editingCell) return;
457
+
458
+ const { row, col } = editingCell;
459
+ const newData = [...data];
460
+ if (!newData[row]) newData[row] = [];
461
+
462
+ const isFormula = editValue.startsWith('=');
463
+ const numValue = parseFloat(editValue);
464
+ const value = isFormula ? 0 : !isNaN(numValue) ? numValue : editValue;
465
+
466
+ newData[row][col] = {
467
+ ...newData[row][col],
468
+ value,
469
+ formula: isFormula ? editValue : undefined,
470
+ };
471
+
472
+ setData(newData);
473
+ setEditingCell(null);
474
+ setEditValue('');
475
+
476
+ if (onChange) {
477
+ onChange(newData, row, col);
478
+ }
479
+ }, [editingCell, editValue, data, onChange]);
480
+
481
+ // Handle cell edit cancel
482
+ const handleEditCancel = useCallback(() => {
483
+ setEditingCell(null);
484
+ setEditValue('');
485
+ }, []);
486
+
487
+ // Handle key down in edit mode
488
+ const handleEditKeyDown = useCallback(
489
+ (e: React.KeyboardEvent) => {
490
+ if (e.key === 'Enter') {
491
+ e.preventDefault();
492
+ handleEditComplete();
493
+ } else if (e.key === 'Escape') {
494
+ handleEditCancel();
495
+ } else if (e.key === 'Tab') {
496
+ e.preventDefault();
497
+ handleEditComplete();
498
+ // Move to next cell
499
+ if (editingCell) {
500
+ const nextCol = e.shiftKey ? editingCell.col - 1 : editingCell.col + 1;
501
+ if (nextCol >= 0 && nextCol < columns.length) {
502
+ handleCellDoubleClick(editingCell.row, nextCol);
503
+ }
504
+ }
505
+ }
506
+ },
507
+ [handleEditComplete, handleEditCancel, editingCell, columns.length, handleCellDoubleClick]
508
+ );
509
+
510
+ // Handle cell click
511
+ const handleCellClick = useCallback((rowIndex: number, colIndex: number) => {
512
+ setSelectedCell({ row: rowIndex, col: colIndex });
513
+ }, []);
514
+
515
+ // Handle keyboard navigation
516
+ const handleKeyDown = useCallback(
517
+ (e: React.KeyboardEvent) => {
518
+ if (editingCell) return;
519
+ if (!selectedCell) return;
520
+
521
+ const { row, col } = selectedCell;
522
+ let newRow = row;
523
+ let newCol = col;
524
+
525
+ switch (e.key) {
526
+ case 'ArrowUp':
527
+ newRow = Math.max(0, row - 1);
528
+ break;
529
+ case 'ArrowDown':
530
+ newRow = Math.min(filteredData.length - 1, row + 1);
531
+ break;
532
+ case 'ArrowLeft':
533
+ newCol = Math.max(0, col - 1);
534
+ break;
535
+ case 'ArrowRight':
536
+ newCol = Math.min(columns.length - 1, col + 1);
537
+ break;
538
+ case 'Enter':
539
+ case 'F2':
540
+ handleCellDoubleClick(row, col);
541
+ e.preventDefault();
542
+ return;
543
+ default:
544
+ return;
545
+ }
546
+
547
+ if (newRow !== row || newCol !== col) {
548
+ setSelectedCell({ row: newRow, col: newCol });
549
+ e.preventDefault();
550
+ }
551
+ },
552
+ [editingCell, selectedCell, filteredData.length, columns.length, handleCellDoubleClick]
553
+ );
554
+
555
+ // Handle sort
556
+ const handleSort = useCallback((key: string) => {
557
+ setSortConfig((prev) => {
558
+ if (prev?.key === key) {
559
+ if (prev.direction === 'asc') {
560
+ return { key, direction: 'desc' };
561
+ }
562
+ return null; // Clear sort
563
+ }
564
+ return { key, direction: 'asc' };
565
+ });
566
+ }, []);
567
+
568
+ // Handle filter
569
+ const handleFilter = useCallback((key: string) => {
570
+ setActiveFilter((prev) => (prev === key ? null : key));
571
+ const existing = filters.find((f) => f.key === key);
572
+ setFilterValue(existing?.value || '');
573
+ }, [filters]);
574
+
575
+ // Apply filter
576
+ const applyFilter = useCallback(() => {
577
+ if (!activeFilter) return;
578
+
579
+ setFilters((prev) => {
580
+ const existing = prev.findIndex((f) => f.key === activeFilter);
581
+ if (filterValue) {
582
+ if (existing >= 0) {
583
+ const newFilters = [...prev];
584
+ newFilters[existing] = { key: activeFilter, value: filterValue };
585
+ return newFilters;
586
+ }
587
+ return [...prev, { key: activeFilter, value: filterValue }];
588
+ } else {
589
+ return prev.filter((f) => f.key !== activeFilter);
590
+ }
591
+ });
592
+ setActiveFilter(null);
593
+ }, [activeFilter, filterValue]);
594
+
595
+ // Clear filter
596
+ const clearFilter = useCallback((key: string) => {
597
+ setFilters((prev) => prev.filter((f) => f.key !== key));
598
+ }, []);
599
+
600
+ // Export to CSV
601
+ const exportToCSV = useCallback(() => {
602
+ const headers = columns.map((c) => c.header).join(',');
603
+ const rows = filteredData.map((row) =>
604
+ row
605
+ .map((cell) => {
606
+ const val = String(cell?.value ?? '');
607
+ // Escape quotes and wrap in quotes if contains comma
608
+ if (val.includes(',') || val.includes('"') || val.includes('\n')) {
609
+ return `"${val.replace(/"/g, '""')}"`;
610
+ }
611
+ return val;
612
+ })
613
+ .join(',')
614
+ );
615
+
616
+ const csv = [headers, ...rows].join('\n');
617
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
618
+ const url = URL.createObjectURL(blob);
619
+ const link = document.createElement('a');
620
+ link.href = url;
621
+ link.download = exportFileName;
622
+ link.click();
623
+ URL.revokeObjectURL(url);
624
+
625
+ addSuccessMessage('Exported to CSV successfully');
626
+ }, [columns, filteredData, exportFileName]);
627
+
628
+ // Save handler
629
+ const handleSave = useCallback(async () => {
630
+ if (!onSave) return;
631
+
632
+ setIsSaving(true);
633
+ try {
634
+ await onSave(data);
635
+ addSuccessMessage('Data saved successfully');
636
+ } catch (error) {
637
+ console.error('Save failed:', error);
638
+ addErrorMessage('Failed to save data');
639
+ } finally {
640
+ setIsSaving(false);
641
+ }
642
+ }, [onSave, data]);
643
+
644
+ // Toggle freeze first row
645
+ const toggleFreezeFirstRow = useCallback(() => {
646
+ setFrozenRowsState((prev) => (prev === 'first' || prev === 1 ? 'none' : 'first'));
647
+ }, []);
648
+
649
+ // Toggle freeze selected row
650
+ const toggleFreezeSelectedRow = useCallback(() => {
651
+ setFrozenRowsState((prev) => (prev === 'selected' ? 'none' : 'selected'));
652
+ }, []);
653
+
654
+ // Expose imperative handle
655
+ useImperativeHandle(ref, () => ({
656
+ getData: () => data,
657
+ setCell: (rowIndex, colIndex, value) => {
658
+ const newData = [...data];
659
+ if (!newData[rowIndex]) newData[rowIndex] = [];
660
+ newData[rowIndex][colIndex] =
661
+ typeof value === 'object' && value !== null ? value : { value: value as CellValue };
662
+ setData(newData);
663
+ },
664
+ clearFilters: () => setFilters([]),
665
+ clearSort: () => setSortConfig(null),
666
+ exportToCSV,
667
+ toggleFreezeFirstRow,
668
+ toggleFreezeSelectedRow,
669
+ setFrozenRows: setFrozenRowsState,
670
+ }));
671
+
672
+ // Format cell value for display
673
+ const formatValue = useCallback(
674
+ (value: CellValue, column: DataGridColumn): string => {
675
+ if (value === null || value === undefined) return '';
676
+ if (typeof value === 'boolean') return value ? 'Yes' : 'No';
677
+
678
+ const numVal = typeof value === 'number' ? value : parseFloat(String(value));
679
+
680
+ if (column.type === 'currency' && !isNaN(numVal)) {
681
+ const decimals = column.format?.decimals ?? 2;
682
+ const prefix = column.format?.prefix ?? '$';
683
+ return `${prefix}${numVal.toFixed(decimals)}`;
684
+ }
685
+
686
+ if (column.type === 'percent' && !isNaN(numVal)) {
687
+ const decimals = column.format?.decimals ?? 1;
688
+ return `${(numVal * 100).toFixed(decimals)}%`;
689
+ }
690
+
691
+ if (column.type === 'number' && !isNaN(numVal)) {
692
+ const decimals = column.format?.decimals;
693
+ if (decimals !== undefined) {
694
+ return numVal.toFixed(decimals);
695
+ }
696
+ }
697
+
698
+ return String(value);
699
+ },
700
+ []
701
+ );
702
+
703
+ // Density classes
704
+ const densityClasses = {
705
+ compact: 'py-1 px-2 text-xs',
706
+ normal: 'py-2 px-3 text-sm',
707
+ comfortable: 'py-3 px-4 text-sm',
708
+ };
709
+
710
+ const cellPadding = densityClasses[density];
711
+
712
+ return (
713
+ <div className={`data-grid ${className}`} style={{ width }}>
714
+ {/* Toolbar */}
715
+ {showToolbar && (
716
+ <Stack direction="horizontal" spacing="md" align="center" className="mb-3 px-1">
717
+ {title && (
718
+ <div className="text-lg font-medium text-ink-900 flex-1">{title}</div>
719
+ )}
720
+
721
+ {showFreezeRowToggle && (
722
+ <div className="relative">
723
+ <Button
724
+ variant="ghost"
725
+ size="sm"
726
+ icon={
727
+ frozenRowsState !== 'none' ? (
728
+ <Pin className="h-4 w-4 text-primary-600" />
729
+ ) : (
730
+ <PinOff className="h-4 w-4" />
731
+ )
732
+ }
733
+ onClick={toggleFreezeFirstRow}
734
+ title={
735
+ frozenRowsState === 'first' || frozenRowsState === 1
736
+ ? 'Unfreeze first row'
737
+ : 'Freeze first row'
738
+ }
739
+ >
740
+ {frozenRowsState === 'first' || frozenRowsState === 1
741
+ ? 'Unfreeze Row'
742
+ : 'Freeze Row'}
743
+ </Button>
744
+ </div>
745
+ )}
746
+
747
+ {enableExport && (
748
+ <Button
749
+ variant="ghost"
750
+ size="sm"
751
+ icon={<Download className="h-4 w-4" />}
752
+ onClick={exportToCSV}
753
+ >
754
+ Export
755
+ </Button>
756
+ )}
757
+
758
+ {enableSave && onSave && (
759
+ <Button
760
+ variant="primary"
761
+ size="sm"
762
+ icon={<Save className="h-4 w-4" />}
763
+ onClick={handleSave}
764
+ loading={isSaving}
765
+ >
766
+ Save
767
+ </Button>
768
+ )}
769
+
770
+ {toolbarActions}
771
+ </Stack>
772
+ )}
773
+
774
+ {/* Active filters display */}
775
+ {filters.length > 0 && (
776
+ <Stack direction="horizontal" spacing="sm" className="mb-2 px-1 flex-wrap">
777
+ {filters.map((filter) => {
778
+ const column = columns.find((c) => c.key === filter.key);
779
+ return (
780
+ <div
781
+ key={filter.key}
782
+ className="inline-flex items-center gap-1 px-2 py-1 bg-primary-100 text-primary-700 rounded text-xs"
783
+ >
784
+ <span className="font-medium">{column?.header}:</span>
785
+ <span>{filter.value}</span>
786
+ <button
787
+ onClick={() => clearFilter(filter.key)}
788
+ className="ml-1 hover:bg-primary-200 rounded p-0.5"
789
+ >
790
+ <X className="h-3 w-3" />
791
+ </button>
792
+ </div>
793
+ );
794
+ })}
795
+ </Stack>
796
+ )}
797
+
798
+ {/* Table container */}
799
+ <div
800
+ ref={tableRef}
801
+ className="relative overflow-auto border border-stone-200 rounded-lg bg-white"
802
+ style={{ height }}
803
+ onKeyDown={handleKeyDown}
804
+ tabIndex={0}
805
+ >
806
+ <table className="border-collapse" style={{ tableLayout: 'auto' }}>
807
+ {/* Header */}
808
+ <thead className="sticky top-0 z-20 bg-stone-100">
809
+ <tr>
810
+ {/* Row header column */}
811
+ {rowHeaders && (
812
+ <th
813
+ className={`${cellPadding} border-b border-r border-stone-200 bg-stone-100 text-left font-semibold text-ink-600 sticky left-0 z-30`}
814
+ style={{ width: 50, minWidth: 50, maxWidth: 50 }}
815
+ >
816
+ #
817
+ </th>
818
+ )}
819
+
820
+ {/* Column headers */}
821
+ {columns.map((column, colIndex) => {
822
+ const isFrozen = colIndex < frozenColumns;
823
+ const leftOffset = rowHeaders ? 50 + columns.slice(0, colIndex).reduce((sum, c) => sum + (c.width || 100), 0) : columns.slice(0, colIndex).reduce((sum, c) => sum + (c.width || 100), 0);
824
+
825
+ return (
826
+ <th
827
+ key={column.key}
828
+ className={`${cellPadding} border-b border-r border-stone-200 bg-stone-100 font-semibold text-ink-600 text-${column.align || 'left'} ${
829
+ isFrozen ? 'sticky z-30' : ''
830
+ }`}
831
+ style={{
832
+ width: column.width,
833
+ minWidth: column.minWidth || 80,
834
+ left: isFrozen ? leftOffset : undefined,
835
+ }}
836
+ >
837
+ <div className="flex items-center gap-1">
838
+ <span className="flex-1">{column.header}</span>
839
+
840
+ {/* Sort button */}
841
+ {column.sortable && (
842
+ <button
843
+ onClick={() => handleSort(column.key)}
844
+ className="p-0.5 hover:bg-stone-200 rounded"
845
+ >
846
+ {sortConfig?.key === column.key ? (
847
+ sortConfig.direction === 'asc' ? (
848
+ <ChevronUp className="h-4 w-4 text-primary-600" />
849
+ ) : (
850
+ <ChevronDown className="h-4 w-4 text-primary-600" />
851
+ )
852
+ ) : (
853
+ <ArrowUpDown className="h-4 w-4 text-ink-400" />
854
+ )}
855
+ </button>
856
+ )}
857
+
858
+ {/* Filter button */}
859
+ {column.filterable && (
860
+ <div className="relative">
861
+ <button
862
+ onClick={() => handleFilter(column.key)}
863
+ className={`p-0.5 hover:bg-stone-200 rounded ${
864
+ filters.some((f) => f.key === column.key)
865
+ ? 'text-primary-600'
866
+ : 'text-ink-400'
867
+ }`}
868
+ >
869
+ <Filter className="h-4 w-4" />
870
+ </button>
871
+
872
+ {/* Filter dropdown */}
873
+ {activeFilter === column.key && (
874
+ <div className="absolute top-full right-0 mt-1 p-2 bg-white border border-stone-200 rounded-lg shadow-lg z-50 min-w-48">
875
+ <Input
876
+ size="sm"
877
+ placeholder={`Filter ${column.header}...`}
878
+ value={filterValue}
879
+ onChange={(e) => setFilterValue(e.target.value)}
880
+ onKeyDown={(e) => {
881
+ if (e.key === 'Enter') applyFilter();
882
+ if (e.key === 'Escape') setActiveFilter(null);
883
+ }}
884
+ autoFocus
885
+ />
886
+ <Stack direction="horizontal" spacing="sm" className="mt-2">
887
+ <Button
888
+ size="sm"
889
+ variant="ghost"
890
+ onClick={() => setActiveFilter(null)}
891
+ >
892
+ Cancel
893
+ </Button>
894
+ <Button size="sm" variant="primary" onClick={applyFilter}>
895
+ Apply
896
+ </Button>
897
+ </Stack>
898
+ </div>
899
+ )}
900
+ </div>
901
+ )}
902
+ </div>
903
+ </th>
904
+ );
905
+ })}
906
+ </tr>
907
+ </thead>
908
+
909
+ {/* Body */}
910
+ <tbody>
911
+ {filteredData.map((row, rowIndex) => {
912
+ const isFrozen = isRowFrozen(rowIndex);
913
+ const isZebra = zebraStripes && rowIndex % 2 === 1;
914
+
915
+ return (
916
+ <tr
917
+ key={rowIndex}
918
+ className={`${isZebra ? 'bg-paper-50' : 'bg-white'} ${
919
+ isFrozen ? 'sticky z-10' : ''
920
+ } ${isFrozen ? 'shadow-sm' : ''}`}
921
+ style={{
922
+ top: isFrozen ? `${40 + rowIndex * 40}px` : undefined,
923
+ }}
924
+ >
925
+ {/* Row header */}
926
+ {rowHeaders && (
927
+ <td
928
+ className={`${cellPadding} border-b border-r border-stone-200 bg-stone-50 text-ink-500 font-medium sticky left-0 z-10`}
929
+ style={{ width: 50, minWidth: 50, maxWidth: 50 }}
930
+ >
931
+ {Array.isArray(rowHeaders) ? rowHeaders[rowIndex] : rowIndex + 1}
932
+ </td>
933
+ )}
934
+
935
+ {/* Data cells */}
936
+ {row.map((cell, colIndex) => {
937
+ const column = columns[colIndex];
938
+ const isFrozenCol = colIndex < frozenColumns;
939
+ const isEditing =
940
+ editingCell?.row === rowIndex && editingCell?.col === colIndex;
941
+ const isSelected =
942
+ selectedCell?.row === rowIndex && selectedCell?.col === colIndex;
943
+ const hasFormula = !!cell?.formula;
944
+ const leftOffset = rowHeaders
945
+ ? 50 + columns.slice(0, colIndex).reduce((sum, c) => sum + (c.width || 100), 0)
946
+ : columns.slice(0, colIndex).reduce((sum, c) => sum + (c.width || 100), 0);
947
+
948
+ return (
949
+ <td
950
+ key={colIndex}
951
+ className={`${cellPadding} border-b border-r border-stone-200 text-${
952
+ column?.align || 'left'
953
+ } ${isFrozenCol ? 'sticky z-10' : ''} ${
954
+ isZebra && isFrozenCol ? 'bg-paper-50' : isFrozenCol ? 'bg-white' : ''
955
+ } ${isSelected ? 'ring-2 ring-inset ring-primary-500' : ''} ${
956
+ hasFormula ? 'bg-blue-50' : ''
957
+ } ${cell?.className || ''}`}
958
+ style={{
959
+ left: isFrozenCol ? leftOffset : undefined,
960
+ minWidth: column?.minWidth || 80,
961
+ }}
962
+ onClick={() => handleCellClick(rowIndex, colIndex)}
963
+ onDoubleClick={(e) => handleCellDoubleClick(rowIndex, colIndex, e.currentTarget)}
964
+ >
965
+ {isEditing ? (
966
+ formulas ? (
967
+ <FormulaAutocomplete
968
+ value={editValue}
969
+ onChange={setEditValue}
970
+ onComplete={handleEditComplete}
971
+ onCancel={handleEditCancel}
972
+ anchorRect={editingCellRect}
973
+ autoFocus
974
+ />
975
+ ) : (
976
+ <input
977
+ ref={inputRef}
978
+ type="text"
979
+ value={editValue}
980
+ onChange={(e) => setEditValue(e.target.value)}
981
+ onBlur={handleEditComplete}
982
+ onKeyDown={handleEditKeyDown}
983
+ className="w-full h-full border-none outline-none bg-transparent"
984
+ style={{ margin: '-4px', padding: '4px' }}
985
+ />
986
+ )
987
+ ) : (
988
+ formatValue(cell?.value, column)
989
+ )}
990
+ </td>
991
+ );
992
+ })}
993
+ </tr>
994
+ );
995
+ })}
996
+ </tbody>
997
+ </table>
998
+ </div>
999
+
1000
+ {/* Status bar */}
1001
+ <div className="flex items-center justify-between px-2 py-1 text-xs text-ink-500 border-t border-stone-200 bg-stone-50 rounded-b-lg">
1002
+ <span>
1003
+ {filteredData.length} row{filteredData.length !== 1 ? 's' : ''}
1004
+ {filters.length > 0 && ` (filtered)`}
1005
+ </span>
1006
+ {selectedCell && (
1007
+ <span>
1008
+ {colIndexToLetter(selectedCell.col)}
1009
+ {selectedCell.row + 1}
1010
+ {data[selectedCell.row]?.[selectedCell.col]?.formula && (
1011
+ <span className="ml-2 text-blue-600">
1012
+ {data[selectedCell.row][selectedCell.col].formula}
1013
+ </span>
1014
+ )}
1015
+ </span>
1016
+ )}
1017
+ </div>
1018
+ </div>
1019
+ );
1020
+ }
1021
+ );
1022
+
1023
+ DataGrid.displayName = 'DataGrid';
1024
+
1025
+ export default DataGrid;