@xcelsior/ui-spreadsheets 1.2.2 → 1.3.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 (99) hide show
  1. package/.omc/state/agent-replay-0cead415-b3bd-40fd-b199-47371946c4db.jsonl +27 -0
  2. package/.omc/state/idle-notif-cooldown.json +3 -0
  3. package/.omc/state/last-tool-error.json +7 -0
  4. package/.omc/state/mission-state.json +189 -0
  5. package/.omc/state/subagent-tracking.json +125 -0
  6. package/.turbo/turbo-build.log +28 -28
  7. package/.turbo/turbo-lint.log +140 -0
  8. package/dist/index.d.mts +94 -4
  9. package/dist/index.d.ts +94 -4
  10. package/dist/index.js +2134 -1157
  11. package/dist/index.js.map +1 -1
  12. package/dist/index.mjs +2024 -1049
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/styles/globals.css +156 -16
  15. package/dist/styles/globals.css.map +1 -1
  16. package/package.json +1 -1
  17. package/plans/20260330-1230-spreadsheet-features/phase-01-types-and-duplicates-hook.md +73 -0
  18. package/plans/20260330-1230-spreadsheet-features/phase-02-filter-dropdown-portal.md +90 -0
  19. package/plans/20260330-1230-spreadsheet-features/phase-03-header-overflow-menu.md +101 -0
  20. package/plans/20260330-1230-spreadsheet-features/phase-04-integration.md +193 -0
  21. package/plans/20260330-1230-spreadsheet-features/plan.md +59 -0
  22. package/src/components/ColorPickerPopover.tsx +77 -32
  23. package/src/components/ColumnHeaderActions.tsx +241 -1
  24. package/src/components/RowIndexColumnHeader.tsx +13 -17
  25. package/src/components/SelectionSummaryBar.tsx +103 -0
  26. package/src/components/Spreadsheet.stories.tsx +254 -0
  27. package/src/components/Spreadsheet.tsx +235 -190
  28. package/src/components/SpreadsheetCell.tsx +280 -42
  29. package/src/components/SpreadsheetFilterDropdown.tsx +178 -13
  30. package/src/components/SpreadsheetHeader.tsx +79 -24
  31. package/src/components/SpreadsheetSettingsModal.tsx +4 -0
  32. package/src/hooks/useSpreadsheetColumnResize.ts +143 -0
  33. package/src/hooks/useSpreadsheetDuplicates.ts +149 -0
  34. package/src/hooks/useSpreadsheetFiltering.ts +18 -1
  35. package/src/hooks/useSpreadsheetHighlighting.ts +23 -3
  36. package/src/hooks/useSpreadsheetKeyboardShortcuts.ts +16 -0
  37. package/src/hooks/useSpreadsheetPinning.ts +148 -134
  38. package/src/hooks/useSpreadsheetSelection.ts +10 -22
  39. package/src/hooks/useSpreadsheetSummary.ts +68 -0
  40. package/src/index.ts +4 -1
  41. package/src/styles/globals.css +51 -0
  42. package/src/types.ts +50 -2
  43. package/storybook-static/assets/Color-YHDXOIA2-CtQurLnT.js +1 -0
  44. package/storybook-static/assets/DocsRenderer-CFRXHY34-oxrW8Hvo.js +575 -0
  45. package/storybook-static/assets/Spreadsheet.stories-DvhhzuK4.js +1357 -0
  46. package/storybook-static/assets/chunk-XP5HYGXS-BpfKkqn7.js +1 -0
  47. package/storybook-static/assets/entry-preview-CkBGHCAN.js +2 -0
  48. package/storybook-static/assets/entry-preview-docs-ugJb6pa8.js +46 -0
  49. package/storybook-static/assets/iframe-CPp2u3vg.js +211 -0
  50. package/storybook-static/assets/index-BB9bPxRC.js +24 -0
  51. package/storybook-static/assets/index-BQFlzFLk.js +9 -0
  52. package/storybook-static/assets/index-CtvPRVHf.js +9 -0
  53. package/storybook-static/assets/index-DgH-xKnr.js +11 -0
  54. package/storybook-static/assets/index-DrFu-skq.js +6 -0
  55. package/storybook-static/assets/index-DrdPSA1J.js +240 -0
  56. package/storybook-static/assets/index-DzFBShOR.js +20 -0
  57. package/storybook-static/assets/index-v-1boR4t.js +1 -0
  58. package/storybook-static/assets/preview-B8lJiyuQ.js +34 -0
  59. package/storybook-static/assets/preview-BBWR9nbA.js +1 -0
  60. package/storybook-static/assets/preview-BWzBA1C2.js +396 -0
  61. package/storybook-static/assets/preview-Bm0S-uxO.css +1 -0
  62. package/storybook-static/assets/preview-CvbIS5ZJ.js +1 -0
  63. package/storybook-static/assets/preview-DD_OYowb.js +1 -0
  64. package/storybook-static/assets/preview-DGUiP6tS.js +7 -0
  65. package/storybook-static/assets/preview-DHQbi4pV.js +1 -0
  66. package/storybook-static/assets/preview-DwI0w3cI.js +1 -0
  67. package/storybook-static/assets/preview-DyR7iiFG.js +1 -0
  68. package/storybook-static/assets/preview-zxZ6Be2V.js +2 -0
  69. package/storybook-static/assets/react-18-Pj8skaX9.js +1 -0
  70. package/storybook-static/assets/test-utils-quxJ1Z79.js +9 -0
  71. package/storybook-static/favicon.svg +1 -0
  72. package/storybook-static/iframe.html +666 -0
  73. package/storybook-static/index.html +177 -0
  74. package/storybook-static/index.json +1 -0
  75. package/storybook-static/nunito-sans-bold-italic.woff2 +0 -0
  76. package/storybook-static/nunito-sans-bold.woff2 +0 -0
  77. package/storybook-static/nunito-sans-italic.woff2 +0 -0
  78. package/storybook-static/nunito-sans-regular.woff2 +0 -0
  79. package/storybook-static/project.json +1 -0
  80. package/storybook-static/sb-addons/essentials-actions-3/manager-bundle.js +3 -0
  81. package/storybook-static/sb-addons/essentials-backgrounds-5/manager-bundle.js +12 -0
  82. package/storybook-static/sb-addons/essentials-controls-2/manager-bundle.js +405 -0
  83. package/storybook-static/sb-addons/essentials-docs-4/manager-bundle.js +245 -0
  84. package/storybook-static/sb-addons/essentials-measure-8/manager-bundle.js +3 -0
  85. package/storybook-static/sb-addons/essentials-outline-9/manager-bundle.js +3 -0
  86. package/storybook-static/sb-addons/essentials-toolbars-7/manager-bundle.js +3 -0
  87. package/storybook-static/sb-addons/essentials-viewport-6/manager-bundle.js +3 -0
  88. package/storybook-static/sb-addons/interactions-10/manager-bundle.js +222 -0
  89. package/storybook-static/sb-addons/links-1/manager-bundle.js +3 -0
  90. package/storybook-static/sb-addons/storybook-core-core-server-presets-0/common-manager-bundle.js +3 -0
  91. package/storybook-static/sb-common-assets/favicon.svg +1 -0
  92. package/storybook-static/sb-common-assets/nunito-sans-bold-italic.woff2 +0 -0
  93. package/storybook-static/sb-common-assets/nunito-sans-bold.woff2 +0 -0
  94. package/storybook-static/sb-common-assets/nunito-sans-italic.woff2 +0 -0
  95. package/storybook-static/sb-common-assets/nunito-sans-regular.woff2 +0 -0
  96. package/storybook-static/sb-manager/globals-module-info.js +1052 -0
  97. package/storybook-static/sb-manager/globals-runtime.js +42127 -0
  98. package/storybook-static/sb-manager/globals.js +48 -0
  99. package/storybook-static/sb-manager/runtime.js +12048 -0
@@ -1,5 +1,5 @@
1
1
  import type React from 'react';
2
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { useCallback, useEffect, useMemo, useRef, useState, startTransition } from 'react';
3
3
  import { HiChevronDown, HiChevronRight } from 'react-icons/hi';
4
4
  import { AiFillHighlight } from 'react-icons/ai';
5
5
  import { FaComment, FaRegComment } from 'react-icons/fa';
@@ -7,7 +7,7 @@ import { cn } from '../utils';
7
7
  import { SpreadsheetCell } from './SpreadsheetCell';
8
8
  import { SpreadsheetFilterDropdown } from './SpreadsheetFilterDropdown';
9
9
  import { SpreadsheetToolbar } from './SpreadsheetToolbar';
10
- import { SpreadsheetHeader } from './SpreadsheetHeader';
10
+ import { MemoizedSpreadsheetHeader } from './SpreadsheetHeader';
11
11
  import { RowIndexColumnHeader } from './RowIndexColumnHeader';
12
12
  import { ColorPickerPopover } from './ColorPickerPopover';
13
13
  import { type SpreadsheetSettings, SpreadsheetSettingsModal } from './SpreadsheetSettingsModal';
@@ -16,17 +16,20 @@ import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
16
16
  import { RowContextMenu } from './RowContextMenu';
17
17
  import { Pagination } from '@xcelsior/design-system';
18
18
  import { useSpreadsheetFiltering } from '../hooks/useSpreadsheetFiltering';
19
+ import { useSpreadsheetDuplicates } from '../hooks/useSpreadsheetDuplicates';
19
20
  import { useSpreadsheetHighlighting } from '../hooks/useSpreadsheetHighlighting';
20
21
  import {
21
22
  useSpreadsheetPinning,
22
23
  ROW_INDEX_COLUMN_WIDTH,
23
24
  ROW_INDEX_COLUMN_ID,
24
- MIN_PINNED_COLUMN_WIDTH,
25
25
  } from '../hooks/useSpreadsheetPinning';
26
26
  import { useSpreadsheetComments } from '../hooks/useSpreadsheetComments';
27
27
  import { useSpreadsheetUndoRedo } from '../hooks/useSpreadsheetUndoRedo';
28
28
  import { useSpreadsheetKeyboardShortcuts } from '../hooks/useSpreadsheetKeyboardShortcuts';
29
29
  import { useSpreadsheetSelection } from '../hooks/useSpreadsheetSelection';
30
+ import { useSpreadsheetSummary } from '../hooks/useSpreadsheetSummary';
31
+ import { useSpreadsheetColumnResize } from '../hooks/useSpreadsheetColumnResize';
32
+ import { SelectionSummaryBar } from './SelectionSummaryBar';
30
33
  import type {
31
34
  CellEdit,
32
35
  SpreadsheetColumn,
@@ -126,6 +129,8 @@ export function Spreadsheet<T extends Record<string, any>>({
126
129
  sortConfig: controlledSortConfig,
127
130
  onPageChange,
128
131
  filters: controlledFilters,
132
+ duplicateCheckColumns: propDuplicateCheckColumns,
133
+ onDuplicateCheckChange,
129
134
  }: SpreadsheetProps<T>) {
130
135
  // ==================== HOOKS ====================
131
136
 
@@ -139,6 +144,41 @@ export function Spreadsheet<T extends Record<string, any>>({
139
144
  compactView: initialSettings?.compactView ?? false,
140
145
  });
141
146
 
147
+ // Duplicate detection hook
148
+ const {
149
+ isCellDuplicate,
150
+ toggleDuplicateCheck,
151
+ duplicateCheckColumns,
152
+ duplicateRowIds,
153
+ getDuplicateColumnCount,
154
+ } = useSpreadsheetDuplicates({
155
+ data,
156
+ columns,
157
+ duplicateCheckColumns: propDuplicateCheckColumns ?? [],
158
+ getRowId,
159
+ });
160
+
161
+ // Callback to handle toggling duplicate check with external notification
162
+ const handleDuplicateCheckToggle = useCallback(
163
+ (columnId: string) => {
164
+ setIsProcessing(true);
165
+ startTransition(() => {
166
+ toggleDuplicateCheck(columnId);
167
+ // Build updated list for external callback
168
+ const currentCols = Array.from(duplicateCheckColumns);
169
+ const next = currentCols.includes(columnId)
170
+ ? currentCols.filter((id) => id !== columnId)
171
+ : [...currentCols, columnId];
172
+ onDuplicateCheckChange?.(next);
173
+ setIsProcessing(false);
174
+ });
175
+ },
176
+ [toggleDuplicateCheck, duplicateCheckColumns, onDuplicateCheckChange]
177
+ );
178
+
179
+ // Processing overlay state
180
+ const [isProcessing, setIsProcessing] = useState(false);
181
+
142
182
  // Filtering and sorting hook
143
183
  const {
144
184
  filters,
@@ -160,6 +200,8 @@ export function Spreadsheet<T extends Record<string, any>>({
160
200
  controlledFilters,
161
201
  controlledSortConfig,
162
202
  defaultSortConfig: spreadsheetSettings.defaultSort,
203
+ duplicateRowIds,
204
+ getRowId,
163
205
  });
164
206
 
165
207
  // Highlighting hook
@@ -176,6 +218,7 @@ export function Spreadsheet<T extends Record<string, any>>({
176
218
  setHighlightPickerColumn,
177
219
  highlightPickerCell,
178
220
  setHighlightPickerCell,
221
+ recentColors,
179
222
  } = useSpreadsheetHighlighting({
180
223
  externalRowHighlights,
181
224
  onRowHighlight,
@@ -185,6 +228,17 @@ export function Spreadsheet<T extends Record<string, any>>({
185
228
  onCellHighlight,
186
229
  });
187
230
 
231
+ // Column resize hook (must be before pinning hook so getColumnWidth is available for offset calculations)
232
+ const { getColumnWidth, getResizeHandleProps, isResizing, columnWidths } = useSpreadsheetColumnResize({
233
+ initialColumnWidths: initialSettings?.columnWidths,
234
+ onColumnResize: (columnId, width) => {
235
+ setSpreadsheetSettings((prev) => ({
236
+ ...prev,
237
+ columnWidths: { ...prev.columnWidths, [columnId]: width },
238
+ }));
239
+ },
240
+ });
241
+
188
242
  // Pinning hook
189
243
  const {
190
244
  pinnedColumns,
@@ -198,11 +252,14 @@ export function Spreadsheet<T extends Record<string, any>>({
198
252
  getColumnRightOffset,
199
253
  isColumnPinned,
200
254
  getColumnPinSide,
255
+ getPinnedZIndex,
256
+ measureRef,
201
257
  } = useSpreadsheetPinning({
202
258
  columns,
203
259
  columnGroups,
204
260
  defaultPinnedColumns: initialSettings?.defaultPinnedColumns,
205
261
  defaultPinnedRightColumns: initialSettings?.defaultPinnedRightColumns,
262
+ getColumnWidth,
206
263
  });
207
264
 
208
265
  // Comments hook
@@ -252,7 +309,9 @@ export function Spreadsheet<T extends Record<string, any>>({
252
309
  // Row selection state
253
310
  const [selectedRows, setSelectedRows] = useState<Set<string | number>>(new Set());
254
311
  const [lastSelectedRow, setLastSelectedRow] = useState<string | number | null>(null);
255
- const [hoveredRow, setHoveredRow] = useState<string | number | null>(null);
312
+ // hoveredRow uses a ref instead of state to avoid full re-renders on mouse move
313
+ // Hover styling is handled via CSS tr:hover in the stylesheet
314
+ const hoveredRowRef = useRef<string | number | null>(null);
256
315
 
257
316
  // Pagination state (supports both controlled and uncontrolled modes)
258
317
  const [internalCurrentPage, setInternalCurrentPage] = useState(1);
@@ -318,6 +377,23 @@ export function Spreadsheet<T extends Record<string, any>>({
318
377
  }));
319
378
  }, [sortConfig]);
320
379
 
380
+ // Sync pinned columns from external settings (e.g., after hydration from localStorage)
381
+ // Only fires once when settings arrive with pinned columns that differ from current state
382
+ const hasSyncedPinnedFromSettings = useRef(false);
383
+ useEffect(() => {
384
+ if (hasSyncedPinnedFromSettings.current) return;
385
+ const settingsPinned = initialSettings?.defaultPinnedColumns;
386
+ if (settingsPinned && settingsPinned.length > 0) {
387
+ // Check if current pinnedColumns already matches (initialized correctly)
388
+ const currentIds = Array.from(pinnedColumns.keys());
389
+ const hasAllSettingsPinned = settingsPinned.every((id) => pinnedColumns.has(id));
390
+ if (!hasAllSettingsPinned) {
391
+ setPinnedColumnsFromIds(settingsPinned);
392
+ }
393
+ hasSyncedPinnedFromSettings.current = true;
394
+ }
395
+ }, [initialSettings?.defaultPinnedColumns, pinnedColumns, setPinnedColumnsFromIds]);
396
+
321
397
  // Sync pinned columns to spreadsheetSettings when pinning changes
322
398
  useEffect(() => {
323
399
  const pinnedColumnIds = Array.from(pinnedColumns.keys());
@@ -389,7 +465,7 @@ export function Spreadsheet<T extends Record<string, any>>({
389
465
  const paginatedData = useMemo(() => {
390
466
  if (serverSide) {
391
467
  // In server-side mode, data is already paginated by the server
392
- return filteredData;
468
+ return filteredData.toArray();
393
469
  }
394
470
  const startIndex = (currentPage - 1) * pageSize;
395
471
  return filteredData.slice(startIndex, startIndex + pageSize);
@@ -398,9 +474,14 @@ export function Spreadsheet<T extends Record<string, any>>({
398
474
  // Cell selection hook (for multi-cell selection, copy/paste)
399
475
  const {
400
476
  focusedCell,
477
+ setFocusedCell,
401
478
  editingCell,
402
479
  setEditingCell,
480
+ selectedCellRange,
481
+ setSelectedCellRange,
403
482
  handleCellMouseDown,
483
+ handleCellMouseEnter,
484
+ handleMouseUp,
404
485
  isCellInSelection,
405
486
  getCellSelectionEdge,
406
487
  clearSelection,
@@ -409,6 +490,7 @@ export function Spreadsheet<T extends Record<string, any>>({
409
490
  enterEditMode,
410
491
  copySelectedCells,
411
492
  pasteFromSystemClipboard,
493
+ getSelectedCellValues,
412
494
  } = useSpreadsheetSelection({
413
495
  data: paginatedData as T[],
414
496
  columns: visibleColumns,
@@ -416,6 +498,13 @@ export function Spreadsheet<T extends Record<string, any>>({
416
498
  enableCellEditing,
417
499
  });
418
500
 
501
+ // Summary hook for selection statistics
502
+ const selectedCellValues = useMemo(() => getSelectedCellValues(), [getSelectedCellValues]);
503
+ const { summary: selectionSummary, hasNumericValues } = useSpreadsheetSummary({
504
+ selectedCellValues,
505
+ columns: visibleColumns,
506
+ });
507
+
419
508
  // Escape handler for clearing modals and selection
420
509
  const handleEscapeCallback = useCallback(() => {
421
510
  if (commentModalCell !== null) {
@@ -523,6 +612,19 @@ export function Spreadsheet<T extends Record<string, any>>({
523
612
  onTabNavigation: handleTabNavigation,
524
613
  onCopy: copySelectedCells,
525
614
  onPaste: handlePaste,
615
+ onSelectAll: () => {
616
+ if (paginatedData.length > 0 && visibleColumns.length > 0) {
617
+ const firstRow = paginatedData[0];
618
+ const lastRow = paginatedData[paginatedData.length - 1];
619
+ const firstCol = visibleColumns[0];
620
+ const lastCol = visibleColumns[visibleColumns.length - 1];
621
+ setSelectedCellRange({
622
+ start: { rowId: getRowId(firstRow), columnId: firstCol.id },
623
+ end: { rowId: getRowId(lastRow), columnId: lastCol.id },
624
+ });
625
+ setFocusedCell({ rowId: getRowId(firstRow), columnId: firstCol.id });
626
+ }
627
+ },
526
628
  hasFocusedCell: focusedCell !== null,
527
629
  isEditing: editingCell !== null,
528
630
  enabled: true,
@@ -616,19 +718,26 @@ export function Spreadsheet<T extends Record<string, any>>({
616
718
  ]
617
719
  );
618
720
 
619
- // Handle cell click - use the selection hook's handler for focus and selection
620
- // but also handle entering edit mode for editable cells
721
+ // Handle cell click - focus only (edit mode requires double-click or F2)
621
722
  const handleCellClick = useCallback(
622
723
  (rowId: string | number, columnId: string, event: React.MouseEvent) => {
623
724
  event.stopPropagation();
624
725
  handleCellMouseDown(rowId, columnId, event);
625
-
626
- // Double-click to edit is handled by the selection hook
627
- // For single click, we just focus. Edit mode is entered via handleCellMouseDown for editable cells
628
726
  },
629
727
  [handleCellMouseDown]
630
728
  );
631
729
 
730
+ // Handle cell double-click - enter edit mode
731
+ const handleCellDoubleClick = useCallback(
732
+ (rowId: string | number, columnId: string) => {
733
+ const column = columns.find((c) => c.id === columnId);
734
+ if (column?.editable && enableCellEditing) {
735
+ setEditingCell({ rowId, columnId });
736
+ }
737
+ },
738
+ [columns, enableCellEditing, setEditingCell]
739
+ );
740
+
632
741
  const handleCellChange = useCallback(
633
742
  (rowId: string | number, columnId: string, newValue: any) => {
634
743
  const row = data.find((r) => getRowId(r) === rowId);
@@ -676,8 +785,7 @@ export function Spreadsheet<T extends Record<string, any>>({
676
785
  setHighlightPickerColumn(ROW_INDEX_COLUMN_ID);
677
786
  }, [setHighlightPickerColumn]);
678
787
 
679
- // Build render items that include placeholder cells for collapsed groups
680
- // Pinned columns are moved to the edges: left-pinned first, then unpinned, then right-pinned
788
+ // Build render items in group order — collapsed groups get a placeholder, columns stay in natural order
681
789
  const columnRenderItems = useMemo(() => {
682
790
  if (!columnGroups || columnGroups.length === 0) {
683
791
  return visibleColumns.map((col) => ({
@@ -694,83 +802,43 @@ export function Spreadsheet<T extends Record<string, any>>({
694
802
  };
695
803
  type RenderItem = ColumnItem | PlaceholderItem;
696
804
 
697
- const leftPinnedItems: ColumnItem[] = [];
698
- const middleItems: RenderItem[] = [];
699
- const rightPinnedItems: ColumnItem[] = [];
805
+ const items: RenderItem[] = [];
806
+ const allGroupedIds = new Set(columnGroups.flatMap((g) => g.columns));
700
807
 
808
+ // Add ungrouped columns first (if any appear before the first group)
809
+ for (const col of visibleColumns) {
810
+ if (!allGroupedIds.has(col.id)) {
811
+ items.push({ type: 'column', column: col });
812
+ }
813
+ }
814
+
815
+ // Add columns by group order
701
816
  for (const group of columnGroups) {
702
817
  const isCollapsed = collapsedGroups.has(group.id);
818
+ const groupVisibleCols = visibleColumns.filter((c) => group.columns.includes(c.id));
703
819
 
704
820
  if (isCollapsed) {
705
- middleItems.push({
821
+ // Add collapsed placeholder
822
+ items.push({
706
823
  type: 'collapsed-placeholder',
707
824
  groupId: group.id,
708
825
  headerColor: group.headerColor,
709
826
  });
710
827
  }
711
828
 
712
- // Get columns from this group that are visible (preserving original order)
713
- const groupVisibleCols = (columns || []).filter((c) => {
714
- if (!group.columns.includes(c.id)) return false;
715
- if (isCollapsed) return pinnedColumns.has(c.id);
716
- return true;
717
- });
718
-
829
+ // Add visible columns from this group
719
830
  for (const col of groupVisibleCols) {
720
- const pinSide = pinnedColumns.get(col.id);
721
- if (pinSide === 'left') {
722
- leftPinnedItems.push({ type: 'column', column: col });
723
- } else if (pinSide === 'right') {
724
- rightPinnedItems.push({ type: 'column', column: col });
725
- } else {
726
- middleItems.push({ type: 'column', column: col });
727
- }
831
+ items.push({ type: 'column', column: col });
728
832
  }
729
833
  }
730
834
 
731
- // Add any columns not in any group
732
- const allGroupedIds = new Set(columnGroups.flatMap((g) => g.columns));
733
- for (const col of visibleColumns) {
734
- if (!allGroupedIds.has(col.id)) {
735
- const pinSide = pinnedColumns.get(col.id);
736
- if (pinSide === 'left') {
737
- leftPinnedItems.push({ type: 'column', column: col });
738
- } else if (pinSide === 'right') {
739
- rightPinnedItems.push({ type: 'column', column: col });
740
- } else {
741
- middleItems.push({ type: 'column', column: col });
742
- }
743
- }
744
- }
835
+ return items;
836
+ }, [columnGroups, collapsedGroups, visibleColumns]);
745
837
 
746
- // Sort pinned items by their order in the pinnedColumns map
747
- const pinnedLeftOrder = Array.from(pinnedColumns.entries())
748
- .filter(([id, side]) => side === 'left' && id !== ROW_INDEX_COLUMN_ID)
749
- .map(([id]) => id);
750
- const pinnedRightOrder = Array.from(pinnedColumns.entries())
751
- .filter(([, side]) => side === 'right')
752
- .map(([id]) => id);
753
-
754
- leftPinnedItems.sort(
755
- (a, b) => pinnedLeftOrder.indexOf(a.column.id) - pinnedLeftOrder.indexOf(b.column.id)
756
- );
757
- rightPinnedItems.sort(
758
- (a, b) => pinnedRightOrder.indexOf(a.column.id) - pinnedRightOrder.indexOf(b.column.id)
759
- );
760
-
761
- return [...leftPinnedItems, ...middleItems, ...rightPinnedItems];
762
- }, [columnGroups, collapsedGroups, columns, pinnedColumns, visibleColumns]);
763
-
764
- // Build group header items that account for pinned columns being moved to edges
838
+ // Build group header items columns stay in their groups (freeze panes, no splitting)
765
839
  const groupHeaderItems = useMemo(() => {
766
840
  if (!columnGroups || columnGroups.length === 0) return null;
767
841
 
768
- type PinnedGroupHeaderItem = {
769
- type: 'pinned-column';
770
- columnId: string;
771
- headerColor?: string;
772
- pinSide: 'left' | 'right';
773
- };
774
842
  type GroupHeaderItem = {
775
843
  type: 'group';
776
844
  group: SpreadsheetColumnGroup;
@@ -778,46 +846,16 @@ export function Spreadsheet<T extends Record<string, any>>({
778
846
  isCollapsed: boolean;
779
847
  };
780
848
 
781
- const leftPinned: PinnedGroupHeaderItem[] = [];
782
- const groups: GroupHeaderItem[] = [];
783
- const rightPinned: PinnedGroupHeaderItem[] = [];
849
+ const items: GroupHeaderItem[] = [];
784
850
 
785
851
  for (const group of columnGroups) {
786
852
  const isCollapsed = collapsedGroups.has(group.id);
787
- const groupColumns = (columns || []).filter((c) => group.columns.includes(c.id));
788
- const visibleGroupColumns = isCollapsed
789
- ? groupColumns.filter((c) => pinnedColumns.has(c.id))
790
- : groupColumns;
791
-
792
- let movedLeftCount = 0;
793
- let movedRightCount = 0;
794
-
795
- for (const col of visibleGroupColumns) {
796
- const pinSide = pinnedColumns.get(col.id);
797
- if (pinSide === 'left') {
798
- movedLeftCount++;
799
- leftPinned.push({
800
- type: 'pinned-column',
801
- columnId: col.id,
802
- headerColor: group.headerColor,
803
- pinSide: 'left',
804
- });
805
- } else if (pinSide === 'right') {
806
- movedRightCount++;
807
- rightPinned.push({
808
- type: 'pinned-column',
809
- columnId: col.id,
810
- headerColor: group.headerColor,
811
- pinSide: 'right',
812
- });
813
- }
814
- }
815
-
816
- const remainingCols = visibleGroupColumns.length - movedLeftCount - movedRightCount;
817
- const colSpan = remainingCols + (isCollapsed ? 1 : 0);
853
+ // Count visible columns in this group (from visibleColumns which respects collapse)
854
+ const visibleCount = visibleColumns.filter((c) => group.columns.includes(c.id)).length;
855
+ const colSpan = visibleCount + (isCollapsed ? 1 : 0); // +1 for collapsed placeholder
818
856
 
819
857
  if (colSpan > 0) {
820
- groups.push({
858
+ items.push({
821
859
  type: 'group',
822
860
  group,
823
861
  colSpan,
@@ -826,23 +864,9 @@ export function Spreadsheet<T extends Record<string, any>>({
826
864
  }
827
865
  }
828
866
 
829
- // Sort pinned items by their order in the pinnedColumns map
830
- const pinnedLeftOrder = Array.from(pinnedColumns.entries())
831
- .filter(([id, side]) => side === 'left' && id !== ROW_INDEX_COLUMN_ID)
832
- .map(([id]) => id);
833
- const pinnedRightOrder = Array.from(pinnedColumns.entries())
834
- .filter(([, side]) => side === 'right')
835
- .map(([id]) => id);
867
+ return items;
868
+ }, [columnGroups, collapsedGroups, visibleColumns]);
836
869
 
837
- leftPinned.sort(
838
- (a, b) => pinnedLeftOrder.indexOf(a.columnId) - pinnedLeftOrder.indexOf(b.columnId)
839
- );
840
- rightPinned.sort(
841
- (a, b) => pinnedRightOrder.indexOf(a.columnId) - pinnedRightOrder.indexOf(b.columnId)
842
- );
843
-
844
- return [...leftPinned, ...groups, ...rightPinned];
845
- }, [columnGroups, collapsedGroups, columns, pinnedColumns]);
846
870
 
847
871
  // ==================== RENDER ====================
848
872
 
@@ -885,14 +909,18 @@ export function Spreadsheet<T extends Record<string, any>>({
885
909
  )}
886
910
 
887
911
  {/* Table Container */}
888
- <div ref={tableRef} className="flex-1 overflow-auto border border-gray-200 rounded">
889
- <div
890
- style={{
891
- zoom: zoom / 100,
892
- }}
893
- >
894
- <table className="border-separate border-spacing-0 text-xs select-none">
895
- <thead>
912
+ <div ref={tableRef} className={cn('flex-1 overflow-auto border border-gray-200 rounded spreadsheet-scroll-container relative', isResizing && 'select-none')} onMouseUp={handleMouseUp}>
913
+ {/* Processing overlay */}
914
+ {isProcessing && (
915
+ <div className="spreadsheet-processing-overlay">
916
+ <div className="flex items-center gap-2 text-gray-500">
917
+ <div className="w-4 h-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
918
+ Processing...
919
+ </div>
920
+ </div>
921
+ )}
922
+ <table ref={measureRef as React.Ref<HTMLTableElement>} className="border-separate border-spacing-0 text-sm select-none" style={zoom !== 100 ? { zoom: zoom / 100 } : undefined}>
923
+ <thead className="sticky top-0" style={{ zIndex: 50 }}>
896
924
  {/* Column Group Headers */}
897
925
  {columnGroups && groupHeaderItems && (
898
926
  <tr>
@@ -904,37 +932,6 @@ export function Spreadsheet<T extends Record<string, any>>({
904
932
  compactMode={effectiveCompactMode}
905
933
  />
906
934
  {groupHeaderItems.map((item) => {
907
- if (item.type === 'pinned-column') {
908
- const col = columns.find((c) => c.id === item.columnId);
909
- const isPinnedLeft = item.pinSide === 'left';
910
- const pinnedWidth = Math.max(
911
- col?.minWidth ||
912
- col?.width ||
913
- MIN_PINNED_COLUMN_WIDTH,
914
- MIN_PINNED_COLUMN_WIDTH
915
- );
916
- return (
917
- <th
918
- key={`pinned-group-${item.columnId}`}
919
- className={cn(
920
- 'border border-gray-200 px-2 py-1.5 text-center font-bold text-gray-700',
921
- 'z-30'
922
- )}
923
- style={{
924
- backgroundColor:
925
- item.headerColor || 'rgb(243 244 246)',
926
- position: 'sticky',
927
- left: isPinnedLeft
928
- ? `${getColumnLeftOffset(item.columnId)}px`
929
- : undefined,
930
- right: !isPinnedLeft
931
- ? `${getColumnRightOffset(item.columnId)}px`
932
- : undefined,
933
- minWidth: pinnedWidth,
934
- }}
935
- />
936
- );
937
- }
938
935
  const { group, colSpan, isCollapsed } = item;
939
936
  return (
940
937
  <th
@@ -985,12 +982,11 @@ export function Spreadsheet<T extends Record<string, any>>({
985
982
  return (
986
983
  <th
987
984
  key={`${item.groupId}-placeholder`}
988
- className="border border-gray-200 px-2 py-1 text-center text-gray-400 sticky z-20"
985
+ className="border border-gray-200 px-2 py-1 text-center text-gray-400"
989
986
  style={{
990
987
  backgroundColor:
991
988
  item.headerColor || 'rgb(243 244 246)',
992
989
  minWidth: '30px',
993
- top: 0,
994
990
  }}
995
991
  >
996
992
  ...
@@ -1005,13 +1001,14 @@ export function Spreadsheet<T extends Record<string, any>>({
1005
1001
  isColumnPinned(column.id) &&
1006
1002
  getColumnPinSide(column.id) === 'right';
1007
1003
  return (
1008
- <SpreadsheetHeader
1004
+ <MemoizedSpreadsheetHeader
1009
1005
  key={column.id}
1010
1006
  column={column}
1011
1007
  sortConfig={sortConfig}
1012
1008
  hasActiveFilter={!!filters[column.id]}
1013
1009
  isPinned={isColumnPinned(column.id)}
1014
1010
  pinSide={getColumnPinSide(column.id)}
1011
+ pinnedZIndex={isColumnPinned(column.id) ? getPinnedZIndex(column.id) : undefined}
1015
1012
  leftOffset={
1016
1013
  isPinnedLeft ? getColumnLeftOffset(column.id) : 0
1017
1014
  }
@@ -1020,7 +1017,30 @@ export function Spreadsheet<T extends Record<string, any>>({
1020
1017
  }
1021
1018
  highlightColor={getColumnHighlight(column.id)}
1022
1019
  compactMode={effectiveCompactMode}
1023
- onClick={() => handleSort(column.id)}
1020
+ onClick={() => {
1021
+ // Toggle column selection
1022
+ if (paginatedData.length > 0) {
1023
+ const firstRowId = getRowId(paginatedData[0]);
1024
+ const lastRowId = getRowId(paginatedData[paginatedData.length - 1]);
1025
+ // Check if this column is already fully selected — deselect if so
1026
+ const isAlreadySelected =
1027
+ selectedCellRange?.start.columnId === column.id &&
1028
+ selectedCellRange?.end.columnId === column.id &&
1029
+ selectedCellRange?.start.rowId === firstRowId &&
1030
+ selectedCellRange?.end.rowId === lastRowId;
1031
+ if (isAlreadySelected) {
1032
+ setSelectedCellRange(null);
1033
+ setFocusedCell(null);
1034
+ } else {
1035
+ setSelectedCellRange({
1036
+ start: { rowId: firstRowId, columnId: column.id },
1037
+ end: { rowId: lastRowId, columnId: column.id },
1038
+ });
1039
+ setFocusedCell({ rowId: firstRowId, columnId: column.id });
1040
+ }
1041
+ }
1042
+ }}
1043
+ onSortClick={() => handleSort(column.id)}
1024
1044
  onFilterClick={() =>
1025
1045
  setActiveFilterColumn(
1026
1046
  activeFilterColumn === column.id
@@ -1034,6 +1054,14 @@ export function Spreadsheet<T extends Record<string, any>>({
1034
1054
  ? () => setHighlightPickerColumn(column.id)
1035
1055
  : undefined
1036
1056
  }
1057
+ resizeHandleProps={getResizeHandleProps(
1058
+ column.id,
1059
+ column.width || column.minWidth || 100
1060
+ )}
1061
+ resolvedWidth={getColumnWidth(column.id)}
1062
+ hasDuplicateCheck={duplicateCheckColumns.has(column.id)}
1063
+ onDuplicateCheckClick={() => handleDuplicateCheckToggle(column.id)}
1064
+ duplicateCount={getDuplicateColumnCount(column.id)}
1037
1065
  >
1038
1066
  {/* Filter dropdown */}
1039
1067
  {activeFilterColumn === column.id && (
@@ -1047,9 +1075,10 @@ export function Spreadsheet<T extends Record<string, any>>({
1047
1075
  )
1048
1076
  }
1049
1077
  onClose={() => setActiveFilterColumn(null)}
1078
+ hasDuplicateCheck={duplicateCheckColumns.has(column.id)}
1050
1079
  />
1051
1080
  )}
1052
- </SpreadsheetHeader>
1081
+ </MemoizedSpreadsheetHeader>
1053
1082
  );
1054
1083
  })}
1055
1084
  </tr>
@@ -1081,7 +1110,6 @@ export function Spreadsheet<T extends Record<string, any>>({
1081
1110
  paginatedData.map((row, rowIndex) => {
1082
1111
  const rowId = getRowId(row);
1083
1112
  const isRowSelected = selectedRows.has(rowId);
1084
- const isRowHovered = hoveredRow === rowId;
1085
1113
  const rowHighlight = getRowHighlight(rowId);
1086
1114
  const displayIndex =
1087
1115
  rowIndex + 1 + (currentPage - 1) * pageSize;
@@ -1089,8 +1117,8 @@ export function Spreadsheet<T extends Record<string, any>>({
1089
1117
  return (
1090
1118
  <tr
1091
1119
  key={rowId}
1092
- onMouseEnter={() => setHoveredRow(rowId)}
1093
- onMouseLeave={() => setHoveredRow(null)}
1120
+ onMouseEnter={() => { hoveredRowRef.current = rowId; }}
1121
+ onMouseLeave={() => { hoveredRowRef.current = null; }}
1094
1122
  onClick={() => {
1095
1123
  onRowClick?.(row, rowIndex);
1096
1124
  }}
@@ -1098,42 +1126,36 @@ export function Spreadsheet<T extends Record<string, any>>({
1098
1126
  >
1099
1127
  {/* Row Index Column */}
1100
1128
  <td
1129
+ data-column-id="__row_index__"
1101
1130
  onClick={(e) => handleRowSelect(rowId, e)}
1102
1131
  className={cn(
1103
1132
  'border border-gray-200 text-center font-semibold cursor-pointer group',
1104
1133
  effectiveCompactMode
1105
- ? 'text-[10px] px-1 py-px'
1106
- : 'text-xs px-2 py-1',
1107
- isRowIndexPinned ? 'z-20' : 'z-0',
1134
+ ? 'text-xs px-1.5 py-0.5'
1135
+ : 'text-sm px-2.5 py-1.5',
1136
+ 'sticky',
1108
1137
  isRowSelected && 'bg-blue-100',
1109
- !isRowSelected && rowHighlight && '',
1110
- isRowHovered &&
1111
- !isRowSelected &&
1112
- !rowHighlight &&
1113
- 'bg-gray-50'
1138
+ !isRowSelected && rowHighlight && ''
1114
1139
  )}
1115
1140
  style={{
1116
1141
  backgroundColor:
1117
1142
  rowHighlight?.color ||
1118
1143
  (isRowSelected
1119
1144
  ? '#dbeafe'
1120
- : isRowHovered
1121
- ? '#f9fafb'
1122
- : rowIndexHighlightColor || 'white'),
1145
+ : rowIndexHighlightColor || (rowIndex % 2 !== 0 ? '#f9fafb' : 'white')),
1123
1146
  minWidth: `${ROW_INDEX_COLUMN_WIDTH}px`,
1124
1147
  width: `${ROW_INDEX_COLUMN_WIDTH}px`,
1125
- ...(isRowIndexPinned && {
1126
- position: 'sticky' as const,
1127
- left: 0,
1128
- }),
1148
+ position: 'sticky' as const,
1149
+ left: 0,
1150
+ zIndex: 40,
1129
1151
  }}
1130
1152
  >
1131
- <div className="relative flex items-center justify-center w-full h-full">
1132
- {/* Row number - centered */}
1133
- <span>{displayIndex}</span>
1153
+ <div className="relative flex items-center w-full h-full">
1154
+ {/* Row number - left aligned to leave room for hover icons */}
1155
+ <span className="pl-1">{displayIndex}</span>
1134
1156
 
1135
- {/* Action buttons - absolute overlay, spaced between */}
1136
- <div className="absolute inset-0 flex items-center justify-between">
1157
+ {/* Action buttons - absolute right side to avoid blocking row select */}
1158
+ <div className="absolute inset-y-0 right-0 flex items-center gap-0.5 pr-0.5">
1137
1159
  {/* Context Menu (3-dot menu for row actions) */}
1138
1160
  {rowContextMenuItems &&
1139
1161
  rowContextMenuItems.length > 0 && (
@@ -1310,11 +1332,15 @@ export function Spreadsheet<T extends Record<string, any>>({
1310
1332
  isInSelection={isInSelection}
1311
1333
  selectionEdge={selectionEdge}
1312
1334
  isRowSelected={isRowSelected}
1313
- isRowHovered={isRowHovered}
1335
+ isRowHovered={false}
1314
1336
  highlightColor={cellOrRowOrColumnHighlight}
1337
+ isDuplicate={isCellDuplicate(rowId, column.id)}
1315
1338
  compactMode={effectiveCompactMode}
1339
+ isOddRow={rowIndex % 2 !== 0}
1340
+ resolvedWidth={getColumnWidth(column.id)}
1316
1341
  isPinned={isColPinned}
1317
1342
  pinSide={colPinSide}
1343
+ pinnedZIndex={isColPinned ? getPinnedZIndex(column.id) : undefined}
1318
1344
  leftOffset={getColumnLeftOffset(column.id)}
1319
1345
  rightOffset={getColumnRightOffset(
1320
1346
  column.id
@@ -1322,6 +1348,12 @@ export function Spreadsheet<T extends Record<string, any>>({
1322
1348
  onClick={(e) =>
1323
1349
  handleCellClick(rowId, column.id, e)
1324
1350
  }
1351
+ onDoubleClick={() =>
1352
+ handleCellDoubleClick(rowId, column.id)
1353
+ }
1354
+ onMouseEnter={() =>
1355
+ handleCellMouseEnter(rowId, column.id)
1356
+ }
1325
1357
  onConfirm={handleConfirmEdit}
1326
1358
  onCancel={handleCancelEdit}
1327
1359
  onHighlight={
@@ -1370,9 +1402,19 @@ export function Spreadsheet<T extends Record<string, any>>({
1370
1402
  )}
1371
1403
  </tbody>
1372
1404
  </table>
1373
- </div>
1374
1405
  </div>
1375
1406
 
1407
+ {/* Status Bar (address + selection summary) */}
1408
+ <SelectionSummaryBar
1409
+ summary={selectionSummary}
1410
+ focusedCell={focusedCell}
1411
+ columns={visibleColumns}
1412
+ data={paginatedData as T[]}
1413
+ getRowId={getRowId}
1414
+ currentPage={currentPage}
1415
+ pageSize={pageSize}
1416
+ />
1417
+
1376
1418
  {/* Pagination */}
1377
1419
  {showPagination && effectiveTotalItems > 0 && (
1378
1420
  <Pagination
@@ -1442,6 +1484,7 @@ export function Spreadsheet<T extends Record<string, any>>({
1442
1484
  <ColorPickerPopover
1443
1485
  title="Highlight Row"
1444
1486
  paletteType="row"
1487
+ recentColors={recentColors}
1445
1488
  onSelectColor={(color) => handleRowHighlightToggle(highlightPickerRow, color)}
1446
1489
  onClose={() => setHighlightPickerRow(null)}
1447
1490
  />
@@ -1456,6 +1499,7 @@ export function Spreadsheet<T extends Record<string, any>>({
1456
1499
  : `Highlight Column: ${columns.find((c) => c.id === highlightPickerColumn)?.label || ''}`
1457
1500
  }
1458
1501
  paletteType="column"
1502
+ recentColors={recentColors}
1459
1503
  onSelectColor={(color) =>
1460
1504
  handleColumnHighlightToggle(highlightPickerColumn, color)
1461
1505
  }
@@ -1468,6 +1512,7 @@ export function Spreadsheet<T extends Record<string, any>>({
1468
1512
  <ColorPickerPopover
1469
1513
  title="Highlight Cell"
1470
1514
  paletteType="row"
1515
+ recentColors={recentColors}
1471
1516
  onSelectColor={(color) =>
1472
1517
  handleCellHighlightToggle(
1473
1518
  highlightPickerCell.rowId,