@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.
- package/README.md +3 -3
- package/dist/components/DataGrid.d.ts +182 -0
- package/dist/components/DataGrid.d.ts.map +1 -0
- package/dist/components/FormulaAutocomplete.d.ts +29 -0
- package/dist/components/FormulaAutocomplete.d.ts.map +1 -0
- package/dist/components/Select.d.ts +2 -0
- package/dist/components/Select.d.ts.map +1 -1
- package/dist/components/index.d.ts +4 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/index.d.ts +195 -2
- package/dist/index.esm.js +2338 -346
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +2343 -344
- package/dist/index.js.map +1 -1
- package/dist/styles.css +51 -0
- package/dist/utils/formulaDefinitions.d.ts +25 -0
- package/dist/utils/formulaDefinitions.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/components/DataGrid.stories.tsx +356 -0
- package/src/components/DataGrid.tsx +1025 -0
- package/src/components/FormulaAutocomplete.tsx +417 -0
- package/src/components/Select.tsx +121 -7
- package/src/components/index.ts +30 -0
- package/src/utils/formulaDefinitions.ts +1228 -0
|
@@ -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;
|