@xcelsior/ui-spreadsheets 1.0.1
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/.storybook/main.ts +27 -0
- package/.storybook/preview.tsx +28 -0
- package/.turbo/turbo-build.log +22 -0
- package/CHANGELOG.md +9 -0
- package/biome.json +3 -0
- package/dist/index.d.mts +687 -0
- package/dist/index.d.ts +687 -0
- package/dist/index.js +3459 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +3417 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +51 -0
- package/postcss.config.js +5 -0
- package/src/components/ColorPickerPopover.tsx +73 -0
- package/src/components/ColumnHeaderActions.tsx +139 -0
- package/src/components/CommentModals.tsx +137 -0
- package/src/components/KeyboardShortcutsModal.tsx +119 -0
- package/src/components/RowIndexColumnHeader.tsx +70 -0
- package/src/components/Spreadsheet.stories.tsx +1146 -0
- package/src/components/Spreadsheet.tsx +1005 -0
- package/src/components/SpreadsheetCell.tsx +341 -0
- package/src/components/SpreadsheetFilterDropdown.tsx +341 -0
- package/src/components/SpreadsheetHeader.tsx +111 -0
- package/src/components/SpreadsheetSettingsModal.tsx +555 -0
- package/src/components/SpreadsheetToolbar.tsx +346 -0
- package/src/hooks/index.ts +40 -0
- package/src/hooks/useSpreadsheetComments.ts +132 -0
- package/src/hooks/useSpreadsheetFiltering.ts +379 -0
- package/src/hooks/useSpreadsheetHighlighting.ts +201 -0
- package/src/hooks/useSpreadsheetKeyboardShortcuts.ts +149 -0
- package/src/hooks/useSpreadsheetPinning.ts +203 -0
- package/src/hooks/useSpreadsheetUndoRedo.ts +167 -0
- package/src/index.ts +31 -0
- package/src/types.ts +612 -0
- package/src/utils.ts +16 -0
- package/tsconfig.json +30 -0
- package/tsup.config.ts +12 -0
|
@@ -0,0 +1,1005 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
HiAnnotation,
|
|
5
|
+
HiChatAlt2,
|
|
6
|
+
HiChevronDown,
|
|
7
|
+
HiChevronRight,
|
|
8
|
+
HiColorSwatch,
|
|
9
|
+
HiDuplicate,
|
|
10
|
+
} from 'react-icons/hi';
|
|
11
|
+
import { cn } from '../utils';
|
|
12
|
+
import { SpreadsheetCell } from './SpreadsheetCell';
|
|
13
|
+
import { SpreadsheetFilterDropdown } from './SpreadsheetFilterDropdown';
|
|
14
|
+
import { SpreadsheetToolbar } from './SpreadsheetToolbar';
|
|
15
|
+
import { SpreadsheetHeader } from './SpreadsheetHeader';
|
|
16
|
+
import { RowIndexColumnHeader } from './RowIndexColumnHeader';
|
|
17
|
+
import { ColorPickerPopover } from './ColorPickerPopover';
|
|
18
|
+
import { type SpreadsheetSettings, SpreadsheetSettingsModal } from './SpreadsheetSettingsModal';
|
|
19
|
+
import { AddCommentModal, ViewCommentsModal } from './CommentModals';
|
|
20
|
+
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
|
|
21
|
+
import { Pagination } from '@xcelsior/design-system';
|
|
22
|
+
import { useSpreadsheetFiltering } from '../hooks/useSpreadsheetFiltering';
|
|
23
|
+
import {
|
|
24
|
+
useSpreadsheetHighlighting,
|
|
25
|
+
ROW_INDEX_COLUMN_ID,
|
|
26
|
+
} from '../hooks/useSpreadsheetHighlighting';
|
|
27
|
+
import { useSpreadsheetPinning, ROW_INDEX_COLUMN_WIDTH } from '../hooks/useSpreadsheetPinning';
|
|
28
|
+
import { useSpreadsheetComments } from '../hooks/useSpreadsheetComments';
|
|
29
|
+
import { useSpreadsheetUndoRedo } from '../hooks/useSpreadsheetUndoRedo';
|
|
30
|
+
import { useSpreadsheetKeyboardShortcuts } from '../hooks/useSpreadsheetKeyboardShortcuts';
|
|
31
|
+
import type { CellPosition, SpreadsheetProps } from '../types';
|
|
32
|
+
|
|
33
|
+
type SpreadsheetUndoEntry = {
|
|
34
|
+
type: 'cell-edit';
|
|
35
|
+
rowId: string | number;
|
|
36
|
+
columnId: string;
|
|
37
|
+
previousValue: any;
|
|
38
|
+
nextValue: any;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Spreadsheet component - A feature-rich data grid with Excel-like functionality.
|
|
43
|
+
*
|
|
44
|
+
* Features:
|
|
45
|
+
* - Sortable columns
|
|
46
|
+
* - Filterable columns (text search, multi-select, range)
|
|
47
|
+
* - Inline cell editing
|
|
48
|
+
* - Row selection (single, multi, range)
|
|
49
|
+
* - Column pinning
|
|
50
|
+
* - Column grouping with collapse
|
|
51
|
+
* - Pagination
|
|
52
|
+
* - Zoom controls
|
|
53
|
+
* - Undo/Redo
|
|
54
|
+
* - Cell highlighting
|
|
55
|
+
* - Keyboard navigation
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* ```tsx
|
|
59
|
+
* const columns = [
|
|
60
|
+
* { id: 'name', label: 'Name', sortable: true, filterable: true },
|
|
61
|
+
* { id: 'email', label: 'Email', editable: true },
|
|
62
|
+
* { id: 'status', label: 'Status', type: 'select', options: ['Active', 'Inactive'] },
|
|
63
|
+
* ];
|
|
64
|
+
*
|
|
65
|
+
* <Spreadsheet
|
|
66
|
+
* data={users}
|
|
67
|
+
* columns={columns}
|
|
68
|
+
* getRowId={(row) => row.id}
|
|
69
|
+
* onCellEdit={(rowId, columnId, value) => handleEdit(rowId, columnId, value)}
|
|
70
|
+
* showToolbar
|
|
71
|
+
* showPagination
|
|
72
|
+
* enableRowSelection
|
|
73
|
+
* />
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
export function Spreadsheet<T extends Record<string, any>>({
|
|
77
|
+
data,
|
|
78
|
+
columns,
|
|
79
|
+
columnGroups,
|
|
80
|
+
getRowId,
|
|
81
|
+
onCellEdit,
|
|
82
|
+
onSelectionChange,
|
|
83
|
+
onSortChange,
|
|
84
|
+
onFilterChange,
|
|
85
|
+
onRowClick,
|
|
86
|
+
onRowDoubleClick,
|
|
87
|
+
onRowClone,
|
|
88
|
+
onAddRowComment,
|
|
89
|
+
onRowHighlight,
|
|
90
|
+
showToolbar = true,
|
|
91
|
+
showPagination = true,
|
|
92
|
+
showRowIndex = true,
|
|
93
|
+
enableRowSelection = true,
|
|
94
|
+
enableCellEditing = true,
|
|
95
|
+
enableComments = true,
|
|
96
|
+
enableHighlighting = true,
|
|
97
|
+
enableUndoRedo = true,
|
|
98
|
+
defaultPageSize = 25,
|
|
99
|
+
pageSizeOptions = [25, 50, 100, 200],
|
|
100
|
+
defaultZoom = 100,
|
|
101
|
+
autoSave = true,
|
|
102
|
+
compactMode = false,
|
|
103
|
+
isLoading = false,
|
|
104
|
+
className,
|
|
105
|
+
emptyMessage = 'No data available',
|
|
106
|
+
rowHighlights: externalRowHighlights,
|
|
107
|
+
rowComments: externalRowComments,
|
|
108
|
+
rowActions,
|
|
109
|
+
// Server-side mode props
|
|
110
|
+
serverSide = false,
|
|
111
|
+
totalItems,
|
|
112
|
+
currentPage: controlledCurrentPage,
|
|
113
|
+
pageSize: controlledPageSize,
|
|
114
|
+
onPageChange,
|
|
115
|
+
sortConfig: controlledSortConfig,
|
|
116
|
+
filters: controlledFilters,
|
|
117
|
+
}: SpreadsheetProps<T>) {
|
|
118
|
+
// ==================== HOOKS ====================
|
|
119
|
+
|
|
120
|
+
// Filtering and sorting hook
|
|
121
|
+
const {
|
|
122
|
+
filters,
|
|
123
|
+
sortConfig,
|
|
124
|
+
filteredData,
|
|
125
|
+
activeFilterColumn,
|
|
126
|
+
setActiveFilterColumn,
|
|
127
|
+
handleFilterChange,
|
|
128
|
+
handleSort,
|
|
129
|
+
clearAllFilters,
|
|
130
|
+
hasActiveFilters,
|
|
131
|
+
} = useSpreadsheetFiltering({
|
|
132
|
+
data,
|
|
133
|
+
columns,
|
|
134
|
+
onFilterChange,
|
|
135
|
+
onSortChange,
|
|
136
|
+
serverSide,
|
|
137
|
+
controlledFilters,
|
|
138
|
+
controlledSortConfig,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Highlighting hook
|
|
142
|
+
const {
|
|
143
|
+
getCellHighlight,
|
|
144
|
+
handleCellHighlightToggle,
|
|
145
|
+
getRowHighlight,
|
|
146
|
+
handleRowHighlightToggle,
|
|
147
|
+
getColumnHighlight,
|
|
148
|
+
handleColumnHighlightToggle,
|
|
149
|
+
highlightPickerRow,
|
|
150
|
+
setHighlightPickerRow,
|
|
151
|
+
highlightPickerColumn,
|
|
152
|
+
setHighlightPickerColumn,
|
|
153
|
+
} = useSpreadsheetHighlighting({
|
|
154
|
+
externalRowHighlights,
|
|
155
|
+
onRowHighlight,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Pinning hook
|
|
159
|
+
const {
|
|
160
|
+
pinnedColumns,
|
|
161
|
+
isRowIndexPinned,
|
|
162
|
+
collapsedGroups,
|
|
163
|
+
visibleColumns,
|
|
164
|
+
handleTogglePin,
|
|
165
|
+
handleToggleRowIndexPin,
|
|
166
|
+
handleToggleGroupCollapse,
|
|
167
|
+
getColumnLeftOffset,
|
|
168
|
+
isColumnPinned,
|
|
169
|
+
getColumnPinSide,
|
|
170
|
+
} = useSpreadsheetPinning({
|
|
171
|
+
columns,
|
|
172
|
+
columnGroups,
|
|
173
|
+
showRowIndex,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// Comments hook
|
|
177
|
+
const {
|
|
178
|
+
getRowComments,
|
|
179
|
+
getUnresolvedCommentCount,
|
|
180
|
+
hasComments,
|
|
181
|
+
commentModalRow,
|
|
182
|
+
setCommentModalRow,
|
|
183
|
+
commentText,
|
|
184
|
+
setCommentText,
|
|
185
|
+
viewCommentsRow,
|
|
186
|
+
setViewCommentsRow,
|
|
187
|
+
handleAddRowComment,
|
|
188
|
+
handleToggleCommentResolved,
|
|
189
|
+
} = useSpreadsheetComments({
|
|
190
|
+
externalRowComments,
|
|
191
|
+
onAddRowComment,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// Undo/Redo hook
|
|
195
|
+
const {
|
|
196
|
+
canUndo,
|
|
197
|
+
canRedo,
|
|
198
|
+
undoCount,
|
|
199
|
+
redoCount,
|
|
200
|
+
hasUnsavedChanges,
|
|
201
|
+
saveStatus,
|
|
202
|
+
handleUndo: popUndoEntry,
|
|
203
|
+
handleRedo: popRedoEntry,
|
|
204
|
+
handleSave,
|
|
205
|
+
pushToUndoStack,
|
|
206
|
+
markAsChanged,
|
|
207
|
+
} = useSpreadsheetUndoRedo<SpreadsheetUndoEntry>({
|
|
208
|
+
enabled: enableUndoRedo,
|
|
209
|
+
autoSave,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// ==================== LOCAL STATE ====================
|
|
213
|
+
|
|
214
|
+
// Selection state
|
|
215
|
+
const [selectedRows, setSelectedRows] = useState<Set<string | number>>(new Set());
|
|
216
|
+
const [lastSelectedRow, setLastSelectedRow] = useState<string | number | null>(null);
|
|
217
|
+
const [focusedCell, setFocusedCell] = useState<CellPosition | null>(null);
|
|
218
|
+
const [editingCell, setEditingCell] = useState<CellPosition | null>(null);
|
|
219
|
+
const [editValue, setEditValue] = useState<any>('');
|
|
220
|
+
const [hoveredRow, setHoveredRow] = useState<string | number | null>(null);
|
|
221
|
+
|
|
222
|
+
// Pagination state (supports both controlled and uncontrolled modes)
|
|
223
|
+
const [internalCurrentPage, setInternalCurrentPage] = useState(1);
|
|
224
|
+
const [internalPageSize, setInternalPageSize] = useState(defaultPageSize);
|
|
225
|
+
const [zoom, setZoom] = useState(defaultZoom);
|
|
226
|
+
|
|
227
|
+
// Use controlled state if provided, otherwise use internal state
|
|
228
|
+
const currentPage = controlledCurrentPage ?? internalCurrentPage;
|
|
229
|
+
const pageSize = controlledPageSize ?? internalPageSize;
|
|
230
|
+
|
|
231
|
+
// Helper to update pagination state
|
|
232
|
+
const handlePageChange = useCallback(
|
|
233
|
+
(newPage: number) => {
|
|
234
|
+
if (controlledCurrentPage === undefined) {
|
|
235
|
+
setInternalCurrentPage(newPage);
|
|
236
|
+
}
|
|
237
|
+
onPageChange?.(newPage, pageSize);
|
|
238
|
+
},
|
|
239
|
+
[controlledCurrentPage, onPageChange, pageSize]
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const handlePageSizeChange = useCallback(
|
|
243
|
+
(newPageSize: number) => {
|
|
244
|
+
if (controlledPageSize === undefined) {
|
|
245
|
+
setInternalPageSize(newPageSize);
|
|
246
|
+
}
|
|
247
|
+
if (controlledCurrentPage === undefined) {
|
|
248
|
+
setInternalCurrentPage(1);
|
|
249
|
+
}
|
|
250
|
+
onPageChange?.(1, newPageSize);
|
|
251
|
+
},
|
|
252
|
+
[controlledPageSize, controlledCurrentPage, onPageChange]
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
// Modal state
|
|
256
|
+
const [showSettingsModal, setShowSettingsModal] = useState(false);
|
|
257
|
+
|
|
258
|
+
// Settings state
|
|
259
|
+
const [spreadsheetSettings, setSpreadsheetSettings] = useState<SpreadsheetSettings>({
|
|
260
|
+
defaultPinnedColumns: [],
|
|
261
|
+
defaultSort: null,
|
|
262
|
+
defaultPageSize,
|
|
263
|
+
defaultZoom,
|
|
264
|
+
autoSave,
|
|
265
|
+
compactView: compactMode,
|
|
266
|
+
showRowIndex: showRowIndex,
|
|
267
|
+
pinRowIndex: false,
|
|
268
|
+
rowIndexHighlightColor: undefined,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// Keyboard shortcuts hook (needs to be declared before escape handler can reference its modal states)
|
|
272
|
+
const handleEscapeCallback = useCallback(() => {
|
|
273
|
+
if (commentModalRow !== null) {
|
|
274
|
+
setCommentModalRow(null);
|
|
275
|
+
} else if (viewCommentsRow !== null) {
|
|
276
|
+
setViewCommentsRow(null);
|
|
277
|
+
} else if (highlightPickerRow !== null) {
|
|
278
|
+
setHighlightPickerRow(null);
|
|
279
|
+
} else if (highlightPickerColumn !== null) {
|
|
280
|
+
setHighlightPickerColumn(null);
|
|
281
|
+
} else {
|
|
282
|
+
setSelectedRows(new Set());
|
|
283
|
+
setLastSelectedRow(null);
|
|
284
|
+
setFocusedCell(null);
|
|
285
|
+
setEditingCell(null);
|
|
286
|
+
}
|
|
287
|
+
}, [
|
|
288
|
+
commentModalRow,
|
|
289
|
+
setCommentModalRow,
|
|
290
|
+
viewCommentsRow,
|
|
291
|
+
setViewCommentsRow,
|
|
292
|
+
highlightPickerRow,
|
|
293
|
+
setHighlightPickerRow,
|
|
294
|
+
highlightPickerColumn,
|
|
295
|
+
setHighlightPickerColumn,
|
|
296
|
+
]);
|
|
297
|
+
|
|
298
|
+
const applyUndo = useCallback(() => {
|
|
299
|
+
const entry = popUndoEntry();
|
|
300
|
+
if (!entry || !onCellEdit) return;
|
|
301
|
+
|
|
302
|
+
if (entry.type === 'cell-edit') {
|
|
303
|
+
onCellEdit(entry.rowId, entry.columnId, entry.previousValue);
|
|
304
|
+
markAsChanged();
|
|
305
|
+
}
|
|
306
|
+
}, [popUndoEntry, onCellEdit, markAsChanged]);
|
|
307
|
+
|
|
308
|
+
const applyRedo = useCallback(() => {
|
|
309
|
+
const entry = popRedoEntry();
|
|
310
|
+
if (!entry || !onCellEdit) return;
|
|
311
|
+
|
|
312
|
+
if (entry.type === 'cell-edit') {
|
|
313
|
+
onCellEdit(entry.rowId, entry.columnId, entry.nextValue);
|
|
314
|
+
markAsChanged();
|
|
315
|
+
}
|
|
316
|
+
}, [popRedoEntry, onCellEdit, markAsChanged]);
|
|
317
|
+
|
|
318
|
+
const { showKeyboardShortcuts, setShowKeyboardShortcuts, shortcuts } =
|
|
319
|
+
useSpreadsheetKeyboardShortcuts({
|
|
320
|
+
onUndo: applyUndo,
|
|
321
|
+
onRedo: applyRedo,
|
|
322
|
+
onEscape: handleEscapeCallback,
|
|
323
|
+
enabled: true,
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// ==================== COMPUTED VALUES ====================
|
|
327
|
+
|
|
328
|
+
const effectiveShowRowIndex = spreadsheetSettings.showRowIndex !== false;
|
|
329
|
+
const rowIndexHighlightColor = getColumnHighlight(ROW_INDEX_COLUMN_ID);
|
|
330
|
+
|
|
331
|
+
// Refs
|
|
332
|
+
const tableRef = useRef<HTMLDivElement>(null);
|
|
333
|
+
|
|
334
|
+
// Calculate total items for pagination
|
|
335
|
+
// In server-side mode, use totalItems prop; otherwise use filteredData length
|
|
336
|
+
const effectiveTotalItems = serverSide ? (totalItems ?? data.length) : filteredData.length;
|
|
337
|
+
|
|
338
|
+
// Paginate data (skip pagination in server-side mode - data is already paginated)
|
|
339
|
+
const paginatedData = useMemo(() => {
|
|
340
|
+
if (serverSide) {
|
|
341
|
+
// In server-side mode, data is already paginated by the server
|
|
342
|
+
return filteredData;
|
|
343
|
+
}
|
|
344
|
+
const startIndex = (currentPage - 1) * pageSize;
|
|
345
|
+
return filteredData.slice(startIndex, startIndex + pageSize);
|
|
346
|
+
}, [filteredData, currentPage, pageSize, serverSide]);
|
|
347
|
+
|
|
348
|
+
const totalPages = Math.max(1, Math.ceil(effectiveTotalItems / pageSize));
|
|
349
|
+
|
|
350
|
+
// Reset page when filters change (only in client-side mode)
|
|
351
|
+
useEffect(() => {
|
|
352
|
+
if (!serverSide && currentPage > totalPages) {
|
|
353
|
+
setInternalCurrentPage(1);
|
|
354
|
+
}
|
|
355
|
+
}, [totalPages, currentPage, serverSide]);
|
|
356
|
+
|
|
357
|
+
// ==================== EVENT HANDLERS ====================
|
|
358
|
+
|
|
359
|
+
const handleRowSelect = useCallback(
|
|
360
|
+
(rowId: string | number, event: React.MouseEvent) => {
|
|
361
|
+
if (!enableRowSelection) return;
|
|
362
|
+
|
|
363
|
+
event.stopPropagation();
|
|
364
|
+
|
|
365
|
+
if (event.shiftKey && lastSelectedRow !== null) {
|
|
366
|
+
// Range selection
|
|
367
|
+
const currentIndex = filteredData.findIndex((r) => getRowId(r) === rowId);
|
|
368
|
+
const lastIndex = filteredData.findIndex((r) => getRowId(r) === lastSelectedRow);
|
|
369
|
+
if (currentIndex !== -1 && lastIndex !== -1) {
|
|
370
|
+
const start = Math.min(currentIndex, lastIndex);
|
|
371
|
+
const end = Math.max(currentIndex, lastIndex);
|
|
372
|
+
const newSelection = new Set(selectedRows);
|
|
373
|
+
for (let i = start; i <= end; i++) {
|
|
374
|
+
newSelection.add(getRowId(filteredData[i]));
|
|
375
|
+
}
|
|
376
|
+
setSelectedRows(newSelection);
|
|
377
|
+
onSelectionChange?.(Array.from(newSelection));
|
|
378
|
+
}
|
|
379
|
+
} else if (event.metaKey || event.ctrlKey) {
|
|
380
|
+
// Toggle individual selection
|
|
381
|
+
const newSelection = new Set(selectedRows);
|
|
382
|
+
if (newSelection.has(rowId)) {
|
|
383
|
+
newSelection.delete(rowId);
|
|
384
|
+
} else {
|
|
385
|
+
newSelection.add(rowId);
|
|
386
|
+
}
|
|
387
|
+
setSelectedRows(newSelection);
|
|
388
|
+
setLastSelectedRow(rowId);
|
|
389
|
+
onSelectionChange?.(Array.from(newSelection));
|
|
390
|
+
} else {
|
|
391
|
+
// Single selection
|
|
392
|
+
if (selectedRows.has(rowId) && selectedRows.size === 1) {
|
|
393
|
+
setSelectedRows(new Set());
|
|
394
|
+
setLastSelectedRow(null);
|
|
395
|
+
onSelectionChange?.([]);
|
|
396
|
+
} else {
|
|
397
|
+
setSelectedRows(new Set([rowId]));
|
|
398
|
+
setLastSelectedRow(rowId);
|
|
399
|
+
onSelectionChange?.([rowId]);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
[
|
|
404
|
+
enableRowSelection,
|
|
405
|
+
lastSelectedRow,
|
|
406
|
+
filteredData,
|
|
407
|
+
selectedRows,
|
|
408
|
+
getRowId,
|
|
409
|
+
onSelectionChange,
|
|
410
|
+
]
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
const handleCellClick = useCallback(
|
|
414
|
+
(rowId: string | number, columnId: string, event: React.MouseEvent) => {
|
|
415
|
+
event.stopPropagation();
|
|
416
|
+
setFocusedCell({ rowId, columnId });
|
|
417
|
+
|
|
418
|
+
const column = (columns || []).find((c) => c.id === columnId);
|
|
419
|
+
if (column?.editable && enableCellEditing) {
|
|
420
|
+
const row = (data || []).find((r) => getRowId(r) === rowId);
|
|
421
|
+
if (row) {
|
|
422
|
+
const value = column.getValue ? column.getValue(row) : row[columnId];
|
|
423
|
+
setEditingCell({ rowId, columnId });
|
|
424
|
+
setEditValue(value);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
},
|
|
428
|
+
[columns, data, getRowId, enableCellEditing]
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
const handleCellChange = useCallback(
|
|
432
|
+
(rowId: string | number, columnId: string, newValue: any) => {
|
|
433
|
+
const row = data.find((r) => getRowId(r) === rowId);
|
|
434
|
+
const previousValue = row ? (row as Record<string, any>)[columnId] : undefined;
|
|
435
|
+
|
|
436
|
+
if (row && Object.is(previousValue, newValue)) {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (row && enableUndoRedo) {
|
|
441
|
+
pushToUndoStack({
|
|
442
|
+
type: 'cell-edit',
|
|
443
|
+
rowId,
|
|
444
|
+
columnId,
|
|
445
|
+
previousValue,
|
|
446
|
+
nextValue: newValue,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
onCellEdit?.(rowId, columnId, newValue);
|
|
451
|
+
markAsChanged();
|
|
452
|
+
},
|
|
453
|
+
[data, getRowId, enableUndoRedo, onCellEdit, pushToUndoStack, markAsChanged]
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
const handleConfirmEdit = useCallback(() => {
|
|
457
|
+
if (editingCell) {
|
|
458
|
+
handleCellChange(editingCell.rowId, editingCell.columnId, editValue);
|
|
459
|
+
setEditingCell(null);
|
|
460
|
+
}
|
|
461
|
+
}, [editingCell, editValue, handleCellChange]);
|
|
462
|
+
|
|
463
|
+
const handleCancelEdit = useCallback(() => {
|
|
464
|
+
setEditingCell(null);
|
|
465
|
+
setEditValue('');
|
|
466
|
+
}, []);
|
|
467
|
+
|
|
468
|
+
// Handle row clone/duplicate
|
|
469
|
+
const handleRowClone = useCallback(
|
|
470
|
+
(row: T, rowId: string | number) => {
|
|
471
|
+
onRowClone?.(row, rowId);
|
|
472
|
+
},
|
|
473
|
+
[onRowClone]
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
// Handle row index highlight (using unified column highlight with special ID)
|
|
477
|
+
const handleRowIndexHighlightClick = useCallback(() => {
|
|
478
|
+
setHighlightPickerColumn(ROW_INDEX_COLUMN_ID);
|
|
479
|
+
}, [setHighlightPickerColumn]);
|
|
480
|
+
|
|
481
|
+
// ==================== RENDER ====================
|
|
482
|
+
|
|
483
|
+
return (
|
|
484
|
+
<div className={cn('flex flex-col h-full bg-white', className)}>
|
|
485
|
+
{/* Toolbar */}
|
|
486
|
+
{showToolbar && (
|
|
487
|
+
<SpreadsheetToolbar
|
|
488
|
+
zoom={zoom}
|
|
489
|
+
canUndo={canUndo}
|
|
490
|
+
canRedo={canRedo}
|
|
491
|
+
undoCount={undoCount}
|
|
492
|
+
redoCount={redoCount}
|
|
493
|
+
selectedRowCount={selectedRows.size}
|
|
494
|
+
hasUnsavedChanges={hasUnsavedChanges}
|
|
495
|
+
saveStatus={saveStatus}
|
|
496
|
+
autoSave={autoSave}
|
|
497
|
+
hasActiveFilters={hasActiveFilters}
|
|
498
|
+
onClearFilters={clearAllFilters}
|
|
499
|
+
onZoomIn={() => setZoom((z) => Math.min(z + 10, 200))}
|
|
500
|
+
onZoomOut={() => setZoom((z) => Math.max(z - 10, 50))}
|
|
501
|
+
onZoomReset={() => setZoom(100)}
|
|
502
|
+
onUndo={applyUndo}
|
|
503
|
+
onRedo={applyRedo}
|
|
504
|
+
onClearSelection={() => {
|
|
505
|
+
setSelectedRows(new Set());
|
|
506
|
+
setLastSelectedRow(null);
|
|
507
|
+
onSelectionChange?.([]);
|
|
508
|
+
}}
|
|
509
|
+
onSave={handleSave}
|
|
510
|
+
onSettings={() => setShowSettingsModal(true)}
|
|
511
|
+
onShowShortcuts={() => setShowKeyboardShortcuts(true)}
|
|
512
|
+
onExport={() => console.log('Export clicked')}
|
|
513
|
+
/>
|
|
514
|
+
)}
|
|
515
|
+
|
|
516
|
+
{/* Table Container */}
|
|
517
|
+
<div ref={tableRef} className="flex-1 overflow-auto border border-gray-200 rounded">
|
|
518
|
+
<div
|
|
519
|
+
style={{
|
|
520
|
+
transform: `scale(${zoom / 100})`,
|
|
521
|
+
transformOrigin: 'top left',
|
|
522
|
+
width: `${100 / (zoom / 100)}%`,
|
|
523
|
+
}}
|
|
524
|
+
>
|
|
525
|
+
<table className="w-full border-separate border-spacing-0 text-xs">
|
|
526
|
+
<thead>
|
|
527
|
+
{/* Column Group Headers */}
|
|
528
|
+
{columnGroups && (
|
|
529
|
+
<tr>
|
|
530
|
+
{/* Row index column header (rowSpan=2 for groups) */}
|
|
531
|
+
{effectiveShowRowIndex && (
|
|
532
|
+
<RowIndexColumnHeader
|
|
533
|
+
enableHighlighting={enableHighlighting}
|
|
534
|
+
highlightColor={rowIndexHighlightColor}
|
|
535
|
+
isPinned={isRowIndexPinned}
|
|
536
|
+
onHighlightClick={handleRowIndexHighlightClick}
|
|
537
|
+
onPinClick={handleToggleRowIndexPin}
|
|
538
|
+
hasColumnGroups={true}
|
|
539
|
+
/>
|
|
540
|
+
)}
|
|
541
|
+
{columnGroups.map((group) => {
|
|
542
|
+
const groupColumns = (columns || []).filter((c) =>
|
|
543
|
+
group.columns.includes(c.id)
|
|
544
|
+
);
|
|
545
|
+
const isCollapsed = collapsedGroups.has(group.id);
|
|
546
|
+
const visibleGroupColumns = isCollapsed
|
|
547
|
+
? groupColumns.filter((c) => pinnedColumns.has(c.id))
|
|
548
|
+
: groupColumns;
|
|
549
|
+
const colSpan = Math.max(
|
|
550
|
+
1,
|
|
551
|
+
visibleGroupColumns.length + (isCollapsed ? 1 : 0)
|
|
552
|
+
);
|
|
553
|
+
|
|
554
|
+
return (
|
|
555
|
+
<th
|
|
556
|
+
key={group.id}
|
|
557
|
+
colSpan={colSpan}
|
|
558
|
+
className={cn(
|
|
559
|
+
'border border-gray-200 px-2 py-1.5 text-center font-bold text-gray-700',
|
|
560
|
+
group.collapsible &&
|
|
561
|
+
'cursor-pointer hover:bg-gray-100'
|
|
562
|
+
)}
|
|
563
|
+
style={{
|
|
564
|
+
backgroundColor:
|
|
565
|
+
group.headerColor || 'rgb(243 244 246)',
|
|
566
|
+
}}
|
|
567
|
+
onClick={() =>
|
|
568
|
+
group.collapsible &&
|
|
569
|
+
handleToggleGroupCollapse(group.id)
|
|
570
|
+
}
|
|
571
|
+
>
|
|
572
|
+
<div className="flex items-center justify-center gap-1">
|
|
573
|
+
{group.collapsible &&
|
|
574
|
+
(isCollapsed ? (
|
|
575
|
+
<HiChevronRight className="h-3 w-3" />
|
|
576
|
+
) : (
|
|
577
|
+
<HiChevronDown className="h-3 w-3" />
|
|
578
|
+
))}
|
|
579
|
+
<span>{group.label}</span>
|
|
580
|
+
</div>
|
|
581
|
+
</th>
|
|
582
|
+
);
|
|
583
|
+
})}
|
|
584
|
+
</tr>
|
|
585
|
+
)}
|
|
586
|
+
|
|
587
|
+
{/* Column Headers */}
|
|
588
|
+
<tr>
|
|
589
|
+
{/* Row index column header (when no groups) */}
|
|
590
|
+
{effectiveShowRowIndex && !columnGroups && (
|
|
591
|
+
<RowIndexColumnHeader
|
|
592
|
+
enableHighlighting={enableHighlighting}
|
|
593
|
+
highlightColor={rowIndexHighlightColor}
|
|
594
|
+
isPinned={isRowIndexPinned}
|
|
595
|
+
onHighlightClick={handleRowIndexHighlightClick}
|
|
596
|
+
onPinClick={handleToggleRowIndexPin}
|
|
597
|
+
hasColumnGroups={false}
|
|
598
|
+
/>
|
|
599
|
+
)}
|
|
600
|
+
{visibleColumns.map((column) => {
|
|
601
|
+
const isPinnedLeft =
|
|
602
|
+
isColumnPinned(column.id) &&
|
|
603
|
+
getColumnPinSide(column.id) === 'left';
|
|
604
|
+
return (
|
|
605
|
+
<SpreadsheetHeader
|
|
606
|
+
key={column.id}
|
|
607
|
+
column={column}
|
|
608
|
+
sortConfig={sortConfig}
|
|
609
|
+
hasActiveFilter={!!filters[column.id]}
|
|
610
|
+
isPinned={isColumnPinned(column.id)}
|
|
611
|
+
pinSide={getColumnPinSide(column.id)}
|
|
612
|
+
leftOffset={
|
|
613
|
+
isPinnedLeft ? getColumnLeftOffset(column.id) : 0
|
|
614
|
+
}
|
|
615
|
+
highlightColor={getColumnHighlight(column.id)}
|
|
616
|
+
compactMode={compactMode}
|
|
617
|
+
onClick={() => handleSort(column.id)}
|
|
618
|
+
onFilterClick={() =>
|
|
619
|
+
setActiveFilterColumn(
|
|
620
|
+
activeFilterColumn === column.id
|
|
621
|
+
? null
|
|
622
|
+
: column.id
|
|
623
|
+
)
|
|
624
|
+
}
|
|
625
|
+
onPinClick={() => handleTogglePin(column.id)}
|
|
626
|
+
onHighlightClick={
|
|
627
|
+
enableHighlighting
|
|
628
|
+
? () => setHighlightPickerColumn(column.id)
|
|
629
|
+
: undefined
|
|
630
|
+
}
|
|
631
|
+
>
|
|
632
|
+
{/* Filter dropdown */}
|
|
633
|
+
{activeFilterColumn === column.id && (
|
|
634
|
+
<SpreadsheetFilterDropdown
|
|
635
|
+
column={column}
|
|
636
|
+
filter={filters[column.id]}
|
|
637
|
+
onFilterChange={(filter) =>
|
|
638
|
+
handleFilterChange(column.id, filter)
|
|
639
|
+
}
|
|
640
|
+
onClose={() => setActiveFilterColumn(null)}
|
|
641
|
+
/>
|
|
642
|
+
)}
|
|
643
|
+
</SpreadsheetHeader>
|
|
644
|
+
);
|
|
645
|
+
})}
|
|
646
|
+
</tr>
|
|
647
|
+
</thead>
|
|
648
|
+
|
|
649
|
+
<tbody>
|
|
650
|
+
{isLoading ? (
|
|
651
|
+
<tr>
|
|
652
|
+
<td
|
|
653
|
+
colSpan={
|
|
654
|
+
visibleColumns.length + (effectiveShowRowIndex ? 1 : 0)
|
|
655
|
+
}
|
|
656
|
+
className="text-center py-8 text-gray-500"
|
|
657
|
+
>
|
|
658
|
+
<div className="flex items-center justify-center gap-2">
|
|
659
|
+
<div className="w-4 h-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
|
660
|
+
Loading...
|
|
661
|
+
</div>
|
|
662
|
+
</td>
|
|
663
|
+
</tr>
|
|
664
|
+
) : paginatedData.length === 0 ? (
|
|
665
|
+
<tr>
|
|
666
|
+
<td
|
|
667
|
+
colSpan={
|
|
668
|
+
visibleColumns.length + (effectiveShowRowIndex ? 1 : 0)
|
|
669
|
+
}
|
|
670
|
+
className="text-center py-8 text-gray-500"
|
|
671
|
+
>
|
|
672
|
+
{emptyMessage}
|
|
673
|
+
</td>
|
|
674
|
+
</tr>
|
|
675
|
+
) : (
|
|
676
|
+
paginatedData.map((row, rowIndex) => {
|
|
677
|
+
const rowId = getRowId(row);
|
|
678
|
+
const isRowSelected = selectedRows.has(rowId);
|
|
679
|
+
const isRowHovered = hoveredRow === rowId;
|
|
680
|
+
const rowHighlight = getRowHighlight(rowId);
|
|
681
|
+
const rowCommentsList = getRowComments(rowId);
|
|
682
|
+
const hasRowComments = hasComments(rowId);
|
|
683
|
+
const unresolvedCount = getUnresolvedCommentCount(rowId);
|
|
684
|
+
const displayIndex =
|
|
685
|
+
rowIndex + 1 + (currentPage - 1) * pageSize;
|
|
686
|
+
|
|
687
|
+
return (
|
|
688
|
+
<tr
|
|
689
|
+
key={rowId}
|
|
690
|
+
onMouseEnter={() => setHoveredRow(rowId)}
|
|
691
|
+
onMouseLeave={() => setHoveredRow(null)}
|
|
692
|
+
onClick={() => {
|
|
693
|
+
onRowClick?.(row, rowIndex);
|
|
694
|
+
}}
|
|
695
|
+
onDoubleClick={() => onRowDoubleClick?.(row, rowIndex)}
|
|
696
|
+
className="transition-colors"
|
|
697
|
+
>
|
|
698
|
+
{/* Row Index Column */}
|
|
699
|
+
{effectiveShowRowIndex && (
|
|
700
|
+
<td
|
|
701
|
+
onClick={(e) => handleRowSelect(rowId, e)}
|
|
702
|
+
className={cn(
|
|
703
|
+
'border border-gray-200 text-center font-semibold cursor-pointer group relative',
|
|
704
|
+
isRowIndexPinned ? 'z-20' : 'z-0',
|
|
705
|
+
isRowSelected && 'bg-blue-100',
|
|
706
|
+
!isRowSelected && rowHighlight && '',
|
|
707
|
+
isRowHovered &&
|
|
708
|
+
!isRowSelected &&
|
|
709
|
+
!rowHighlight &&
|
|
710
|
+
'bg-gray-50'
|
|
711
|
+
)}
|
|
712
|
+
style={{
|
|
713
|
+
backgroundColor:
|
|
714
|
+
rowHighlight?.color ||
|
|
715
|
+
(isRowSelected
|
|
716
|
+
? '#dbeafe'
|
|
717
|
+
: isRowHovered
|
|
718
|
+
? '#f9fafb'
|
|
719
|
+
: rowIndexHighlightColor ||
|
|
720
|
+
'white'),
|
|
721
|
+
minWidth: `${ROW_INDEX_COLUMN_WIDTH}px`,
|
|
722
|
+
width: `${ROW_INDEX_COLUMN_WIDTH}px`,
|
|
723
|
+
...(isRowIndexPinned && {
|
|
724
|
+
position: 'sticky' as const,
|
|
725
|
+
left: 0,
|
|
726
|
+
}),
|
|
727
|
+
}}
|
|
728
|
+
>
|
|
729
|
+
{/* Row number */}
|
|
730
|
+
<div className="py-1 px-1">{displayIndex}</div>
|
|
731
|
+
|
|
732
|
+
{/* Row comments indicator */}
|
|
733
|
+
{hasRowComments && (
|
|
734
|
+
<button
|
|
735
|
+
type="button"
|
|
736
|
+
onClick={(e) => {
|
|
737
|
+
e.stopPropagation();
|
|
738
|
+
setViewCommentsRow(rowId);
|
|
739
|
+
}}
|
|
740
|
+
className="absolute top-0 right-0 p-0.5 hover:bg-gray-200/80 rounded-bl"
|
|
741
|
+
title={`${rowCommentsList.length} row comment(s) - ${unresolvedCount} unresolved`}
|
|
742
|
+
>
|
|
743
|
+
<HiChatAlt2
|
|
744
|
+
className={cn(
|
|
745
|
+
'h-2.5 w-2.5',
|
|
746
|
+
unresolvedCount > 0
|
|
747
|
+
? 'text-amber-500'
|
|
748
|
+
: 'text-gray-400'
|
|
749
|
+
)}
|
|
750
|
+
/>
|
|
751
|
+
{unresolvedCount > 0 && (
|
|
752
|
+
<span className="absolute -top-0.5 -right-0.5 bg-amber-500 text-white text-[6px] rounded-full w-2 h-2 flex items-center justify-center">
|
|
753
|
+
{unresolvedCount}
|
|
754
|
+
</span>
|
|
755
|
+
)}
|
|
756
|
+
</button>
|
|
757
|
+
)}
|
|
758
|
+
|
|
759
|
+
{/* Row Actions (visible on hover) */}
|
|
760
|
+
<div className="absolute inset-x-0 bottom-0 opacity-0 group-hover:opacity-100 flex items-center justify-center gap-0.5 transition-opacity bg-white/90 py-0.5 border-t border-gray-100">
|
|
761
|
+
{/* Clone/Duplicate Row */}
|
|
762
|
+
{onRowClone && (
|
|
763
|
+
<button
|
|
764
|
+
type="button"
|
|
765
|
+
onClick={(e) => {
|
|
766
|
+
e.stopPropagation();
|
|
767
|
+
handleRowClone(row, rowId);
|
|
768
|
+
}}
|
|
769
|
+
className="p-0.5 hover:bg-gray-200 rounded"
|
|
770
|
+
title="Duplicate row"
|
|
771
|
+
>
|
|
772
|
+
<HiDuplicate className="h-2.5 w-2.5 text-gray-500" />
|
|
773
|
+
</button>
|
|
774
|
+
)}
|
|
775
|
+
|
|
776
|
+
{/* Highlight Row */}
|
|
777
|
+
{enableHighlighting && (
|
|
778
|
+
<button
|
|
779
|
+
type="button"
|
|
780
|
+
onClick={(e) => {
|
|
781
|
+
e.stopPropagation();
|
|
782
|
+
setHighlightPickerRow(rowId);
|
|
783
|
+
}}
|
|
784
|
+
className="p-0.5 hover:bg-gray-200 rounded"
|
|
785
|
+
title="Highlight row"
|
|
786
|
+
>
|
|
787
|
+
<HiColorSwatch
|
|
788
|
+
className={cn(
|
|
789
|
+
'h-2.5 w-2.5',
|
|
790
|
+
rowHighlight
|
|
791
|
+
? 'text-amber-500'
|
|
792
|
+
: 'text-gray-500'
|
|
793
|
+
)}
|
|
794
|
+
/>
|
|
795
|
+
</button>
|
|
796
|
+
)}
|
|
797
|
+
|
|
798
|
+
{/* Add Row Comment */}
|
|
799
|
+
{enableComments && (
|
|
800
|
+
<button
|
|
801
|
+
type="button"
|
|
802
|
+
onClick={(e) => {
|
|
803
|
+
e.stopPropagation();
|
|
804
|
+
setCommentModalRow(rowId);
|
|
805
|
+
}}
|
|
806
|
+
className="p-0.5 hover:bg-gray-200 rounded"
|
|
807
|
+
title="Add row comment"
|
|
808
|
+
>
|
|
809
|
+
<HiAnnotation className="h-2.5 w-2.5 text-gray-500" />
|
|
810
|
+
</button>
|
|
811
|
+
)}
|
|
812
|
+
|
|
813
|
+
{/* Custom Row Actions */}
|
|
814
|
+
{rowActions?.map((action) => {
|
|
815
|
+
if (
|
|
816
|
+
action.visible &&
|
|
817
|
+
!action.visible(row)
|
|
818
|
+
)
|
|
819
|
+
return null;
|
|
820
|
+
return (
|
|
821
|
+
<button
|
|
822
|
+
key={action.id}
|
|
823
|
+
type="button"
|
|
824
|
+
onClick={(e) => {
|
|
825
|
+
e.stopPropagation();
|
|
826
|
+
action.onClick(row, rowId);
|
|
827
|
+
}}
|
|
828
|
+
className={cn(
|
|
829
|
+
'p-0.5 hover:bg-gray-200 rounded',
|
|
830
|
+
action.className
|
|
831
|
+
)}
|
|
832
|
+
title={action.tooltip}
|
|
833
|
+
>
|
|
834
|
+
{action.icon}
|
|
835
|
+
</button>
|
|
836
|
+
);
|
|
837
|
+
})}
|
|
838
|
+
</div>
|
|
839
|
+
</td>
|
|
840
|
+
)}
|
|
841
|
+
|
|
842
|
+
{/* Data Cells */}
|
|
843
|
+
{visibleColumns.map((column) => {
|
|
844
|
+
const value = column.getValue
|
|
845
|
+
? column.getValue(row)
|
|
846
|
+
: row[column.id];
|
|
847
|
+
const isEditing =
|
|
848
|
+
editingCell?.rowId === rowId &&
|
|
849
|
+
editingCell?.columnId === column.id;
|
|
850
|
+
const isFocused =
|
|
851
|
+
focusedCell?.rowId === rowId &&
|
|
852
|
+
focusedCell?.columnId === column.id;
|
|
853
|
+
// Priority: cell highlight > row highlight > column highlight
|
|
854
|
+
const cellOrRowOrColumnHighlight =
|
|
855
|
+
getCellHighlight(rowId, column.id) ||
|
|
856
|
+
rowHighlight?.color ||
|
|
857
|
+
getColumnHighlight(column.id);
|
|
858
|
+
const isColPinned = isColumnPinned(column.id);
|
|
859
|
+
const colPinSide = getColumnPinSide(column.id);
|
|
860
|
+
|
|
861
|
+
return (
|
|
862
|
+
<SpreadsheetCell
|
|
863
|
+
key={column.id}
|
|
864
|
+
value={isEditing ? editValue : value}
|
|
865
|
+
column={column}
|
|
866
|
+
row={row}
|
|
867
|
+
rowIndex={rowIndex}
|
|
868
|
+
rowId={rowId}
|
|
869
|
+
isEditable={
|
|
870
|
+
column.editable && enableCellEditing
|
|
871
|
+
}
|
|
872
|
+
isEditing={isEditing}
|
|
873
|
+
isFocused={isFocused}
|
|
874
|
+
isRowSelected={isRowSelected}
|
|
875
|
+
isRowHovered={isRowHovered}
|
|
876
|
+
highlightColor={cellOrRowOrColumnHighlight}
|
|
877
|
+
compactMode={compactMode}
|
|
878
|
+
hasSelectedRows={selectedRows.size > 1}
|
|
879
|
+
isPinned={isColPinned}
|
|
880
|
+
pinSide={colPinSide}
|
|
881
|
+
leftOffset={getColumnLeftOffset(column.id)}
|
|
882
|
+
onClick={(e) =>
|
|
883
|
+
handleCellClick(rowId, column.id, e)
|
|
884
|
+
}
|
|
885
|
+
onChange={(newValue) =>
|
|
886
|
+
setEditValue(newValue)
|
|
887
|
+
}
|
|
888
|
+
onConfirm={handleConfirmEdit}
|
|
889
|
+
onCancel={handleCancelEdit}
|
|
890
|
+
onHighlight={
|
|
891
|
+
enableHighlighting
|
|
892
|
+
? () => {
|
|
893
|
+
handleCellHighlightToggle(
|
|
894
|
+
rowId,
|
|
895
|
+
column.id
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
: undefined
|
|
899
|
+
}
|
|
900
|
+
/>
|
|
901
|
+
);
|
|
902
|
+
})}
|
|
903
|
+
</tr>
|
|
904
|
+
);
|
|
905
|
+
})
|
|
906
|
+
)}
|
|
907
|
+
</tbody>
|
|
908
|
+
</table>
|
|
909
|
+
</div>
|
|
910
|
+
</div>
|
|
911
|
+
|
|
912
|
+
{/* Pagination */}
|
|
913
|
+
{showPagination && effectiveTotalItems > 0 && (
|
|
914
|
+
<Pagination
|
|
915
|
+
currentPage={currentPage}
|
|
916
|
+
totalPages={totalPages}
|
|
917
|
+
pageSize={pageSize}
|
|
918
|
+
totalItems={effectiveTotalItems}
|
|
919
|
+
startItem={(currentPage - 1) * pageSize + 1}
|
|
920
|
+
endItem={Math.min(currentPage * pageSize, effectiveTotalItems)}
|
|
921
|
+
pageSizeOptions={pageSizeOptions}
|
|
922
|
+
itemLabel="rows"
|
|
923
|
+
onPageChange={handlePageChange}
|
|
924
|
+
onPageSizeChange={handlePageSizeChange}
|
|
925
|
+
/>
|
|
926
|
+
)}
|
|
927
|
+
|
|
928
|
+
{/* Add Row Comment Modal */}
|
|
929
|
+
<AddCommentModal
|
|
930
|
+
isOpen={commentModalRow !== null}
|
|
931
|
+
commentText={commentText}
|
|
932
|
+
onCommentTextChange={setCommentText}
|
|
933
|
+
onAdd={() => commentModalRow !== null && handleAddRowComment(commentModalRow)}
|
|
934
|
+
onClose={() => {
|
|
935
|
+
setCommentText('');
|
|
936
|
+
setCommentModalRow(null);
|
|
937
|
+
}}
|
|
938
|
+
/>
|
|
939
|
+
|
|
940
|
+
{/* View Row Comments Modal */}
|
|
941
|
+
<ViewCommentsModal
|
|
942
|
+
isOpen={viewCommentsRow !== null}
|
|
943
|
+
comments={viewCommentsRow !== null ? getRowComments(viewCommentsRow) : []}
|
|
944
|
+
onToggleResolved={handleToggleCommentResolved}
|
|
945
|
+
onClose={() => setViewCommentsRow(null)}
|
|
946
|
+
/>
|
|
947
|
+
|
|
948
|
+
{/* Row Highlight Color Picker */}
|
|
949
|
+
{highlightPickerRow !== null && (
|
|
950
|
+
<ColorPickerPopover
|
|
951
|
+
title="Highlight Row"
|
|
952
|
+
paletteType="row"
|
|
953
|
+
onSelectColor={(color) => handleRowHighlightToggle(highlightPickerRow, color)}
|
|
954
|
+
onClose={() => setHighlightPickerRow(null)}
|
|
955
|
+
/>
|
|
956
|
+
)}
|
|
957
|
+
|
|
958
|
+
{/* Column Highlight Color Picker (unified - works for regular columns and row index) */}
|
|
959
|
+
{highlightPickerColumn !== null && (
|
|
960
|
+
<ColorPickerPopover
|
|
961
|
+
title={
|
|
962
|
+
highlightPickerColumn === ROW_INDEX_COLUMN_ID
|
|
963
|
+
? 'Highlight Row Index Column'
|
|
964
|
+
: `Highlight Column: ${columns.find((c) => c.id === highlightPickerColumn)?.label || ''}`
|
|
965
|
+
}
|
|
966
|
+
paletteType="column"
|
|
967
|
+
onSelectColor={(color) =>
|
|
968
|
+
handleColumnHighlightToggle(highlightPickerColumn, color)
|
|
969
|
+
}
|
|
970
|
+
onClose={() => setHighlightPickerColumn(null)}
|
|
971
|
+
/>
|
|
972
|
+
)}
|
|
973
|
+
|
|
974
|
+
{/* Keyboard Shortcuts Modal */}
|
|
975
|
+
<KeyboardShortcutsModal
|
|
976
|
+
isOpen={showKeyboardShortcuts}
|
|
977
|
+
onClose={() => setShowKeyboardShortcuts(false)}
|
|
978
|
+
shortcuts={shortcuts}
|
|
979
|
+
/>
|
|
980
|
+
|
|
981
|
+
{/* Settings Modal */}
|
|
982
|
+
<SpreadsheetSettingsModal
|
|
983
|
+
isOpen={showSettingsModal}
|
|
984
|
+
onClose={() => setShowSettingsModal(false)}
|
|
985
|
+
settings={spreadsheetSettings}
|
|
986
|
+
onSave={(newSettings) => {
|
|
987
|
+
setSpreadsheetSettings(newSettings);
|
|
988
|
+
setZoom(newSettings.defaultZoom);
|
|
989
|
+
// Update page size through the proper handler
|
|
990
|
+
if (newSettings.defaultPageSize !== pageSize) {
|
|
991
|
+
handlePageSizeChange(newSettings.defaultPageSize);
|
|
992
|
+
}
|
|
993
|
+
if (newSettings.defaultSort) {
|
|
994
|
+
// Note: sortConfig is managed by the filtering hook
|
|
995
|
+
}
|
|
996
|
+
}}
|
|
997
|
+
columns={columns || []}
|
|
998
|
+
title="Spreadsheet Settings"
|
|
999
|
+
pageSizeOptions={pageSizeOptions}
|
|
1000
|
+
/>
|
|
1001
|
+
</div>
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
Spreadsheet.displayName = 'Spreadsheet';
|