@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.
Files changed (37) hide show
  1. package/.storybook/main.ts +27 -0
  2. package/.storybook/preview.tsx +28 -0
  3. package/.turbo/turbo-build.log +22 -0
  4. package/CHANGELOG.md +9 -0
  5. package/biome.json +3 -0
  6. package/dist/index.d.mts +687 -0
  7. package/dist/index.d.ts +687 -0
  8. package/dist/index.js +3459 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/index.mjs +3417 -0
  11. package/dist/index.mjs.map +1 -0
  12. package/package.json +51 -0
  13. package/postcss.config.js +5 -0
  14. package/src/components/ColorPickerPopover.tsx +73 -0
  15. package/src/components/ColumnHeaderActions.tsx +139 -0
  16. package/src/components/CommentModals.tsx +137 -0
  17. package/src/components/KeyboardShortcutsModal.tsx +119 -0
  18. package/src/components/RowIndexColumnHeader.tsx +70 -0
  19. package/src/components/Spreadsheet.stories.tsx +1146 -0
  20. package/src/components/Spreadsheet.tsx +1005 -0
  21. package/src/components/SpreadsheetCell.tsx +341 -0
  22. package/src/components/SpreadsheetFilterDropdown.tsx +341 -0
  23. package/src/components/SpreadsheetHeader.tsx +111 -0
  24. package/src/components/SpreadsheetSettingsModal.tsx +555 -0
  25. package/src/components/SpreadsheetToolbar.tsx +346 -0
  26. package/src/hooks/index.ts +40 -0
  27. package/src/hooks/useSpreadsheetComments.ts +132 -0
  28. package/src/hooks/useSpreadsheetFiltering.ts +379 -0
  29. package/src/hooks/useSpreadsheetHighlighting.ts +201 -0
  30. package/src/hooks/useSpreadsheetKeyboardShortcuts.ts +149 -0
  31. package/src/hooks/useSpreadsheetPinning.ts +203 -0
  32. package/src/hooks/useSpreadsheetUndoRedo.ts +167 -0
  33. package/src/index.ts +31 -0
  34. package/src/types.ts +612 -0
  35. package/src/utils.ts +16 -0
  36. package/tsconfig.json +30 -0
  37. 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';