@xcelsior/ui-spreadsheets 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/.omc/state/agent-replay-0cead415-b3bd-40fd-b199-47371946c4db.jsonl +25 -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 +179 -0
  5. package/.omc/state/subagent-tracking.json +116 -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 +2133 -1155
  11. package/dist/index.js.map +1 -1
  12. package/dist/index.mjs +2023 -1047
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/styles/globals.css +159 -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 -16
  25. package/src/components/SelectionSummaryBar.tsx +103 -0
  26. package/src/components/Spreadsheet.stories.tsx +396 -0
  27. package/src/components/Spreadsheet.tsx +233 -187
  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());
@@ -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
@@ -1004,13 +1001,14 @@ export function Spreadsheet<T extends Record<string, any>>({
1004
1001
  isColumnPinned(column.id) &&
1005
1002
  getColumnPinSide(column.id) === 'right';
1006
1003
  return (
1007
- <SpreadsheetHeader
1004
+ <MemoizedSpreadsheetHeader
1008
1005
  key={column.id}
1009
1006
  column={column}
1010
1007
  sortConfig={sortConfig}
1011
1008
  hasActiveFilter={!!filters[column.id]}
1012
1009
  isPinned={isColumnPinned(column.id)}
1013
1010
  pinSide={getColumnPinSide(column.id)}
1011
+ pinnedZIndex={isColumnPinned(column.id) ? getPinnedZIndex(column.id) : undefined}
1014
1012
  leftOffset={
1015
1013
  isPinnedLeft ? getColumnLeftOffset(column.id) : 0
1016
1014
  }
@@ -1019,7 +1017,30 @@ export function Spreadsheet<T extends Record<string, any>>({
1019
1017
  }
1020
1018
  highlightColor={getColumnHighlight(column.id)}
1021
1019
  compactMode={effectiveCompactMode}
1022
- 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)}
1023
1044
  onFilterClick={() =>
1024
1045
  setActiveFilterColumn(
1025
1046
  activeFilterColumn === column.id
@@ -1033,6 +1054,14 @@ export function Spreadsheet<T extends Record<string, any>>({
1033
1054
  ? () => setHighlightPickerColumn(column.id)
1034
1055
  : undefined
1035
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)}
1036
1065
  >
1037
1066
  {/* Filter dropdown */}
1038
1067
  {activeFilterColumn === column.id && (
@@ -1046,9 +1075,10 @@ export function Spreadsheet<T extends Record<string, any>>({
1046
1075
  )
1047
1076
  }
1048
1077
  onClose={() => setActiveFilterColumn(null)}
1078
+ hasDuplicateCheck={duplicateCheckColumns.has(column.id)}
1049
1079
  />
1050
1080
  )}
1051
- </SpreadsheetHeader>
1081
+ </MemoizedSpreadsheetHeader>
1052
1082
  );
1053
1083
  })}
1054
1084
  </tr>
@@ -1080,7 +1110,6 @@ export function Spreadsheet<T extends Record<string, any>>({
1080
1110
  paginatedData.map((row, rowIndex) => {
1081
1111
  const rowId = getRowId(row);
1082
1112
  const isRowSelected = selectedRows.has(rowId);
1083
- const isRowHovered = hoveredRow === rowId;
1084
1113
  const rowHighlight = getRowHighlight(rowId);
1085
1114
  const displayIndex =
1086
1115
  rowIndex + 1 + (currentPage - 1) * pageSize;
@@ -1088,8 +1117,8 @@ export function Spreadsheet<T extends Record<string, any>>({
1088
1117
  return (
1089
1118
  <tr
1090
1119
  key={rowId}
1091
- onMouseEnter={() => setHoveredRow(rowId)}
1092
- onMouseLeave={() => setHoveredRow(null)}
1120
+ onMouseEnter={() => { hoveredRowRef.current = rowId; }}
1121
+ onMouseLeave={() => { hoveredRowRef.current = null; }}
1093
1122
  onClick={() => {
1094
1123
  onRowClick?.(row, rowIndex);
1095
1124
  }}
@@ -1097,42 +1126,36 @@ export function Spreadsheet<T extends Record<string, any>>({
1097
1126
  >
1098
1127
  {/* Row Index Column */}
1099
1128
  <td
1129
+ data-column-id="__row_index__"
1100
1130
  onClick={(e) => handleRowSelect(rowId, e)}
1101
1131
  className={cn(
1102
1132
  'border border-gray-200 text-center font-semibold cursor-pointer group',
1103
1133
  effectiveCompactMode
1104
- ? 'text-[10px] px-1 py-px'
1105
- : 'text-xs px-2 py-1',
1106
- 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',
1107
1137
  isRowSelected && 'bg-blue-100',
1108
- !isRowSelected && rowHighlight && '',
1109
- isRowHovered &&
1110
- !isRowSelected &&
1111
- !rowHighlight &&
1112
- 'bg-gray-50'
1138
+ !isRowSelected && rowHighlight && ''
1113
1139
  )}
1114
1140
  style={{
1115
1141
  backgroundColor:
1116
1142
  rowHighlight?.color ||
1117
1143
  (isRowSelected
1118
1144
  ? '#dbeafe'
1119
- : isRowHovered
1120
- ? '#f9fafb'
1121
- : rowIndexHighlightColor || 'white'),
1145
+ : rowIndexHighlightColor || (rowIndex % 2 !== 0 ? '#f9fafb' : 'white')),
1122
1146
  minWidth: `${ROW_INDEX_COLUMN_WIDTH}px`,
1123
1147
  width: `${ROW_INDEX_COLUMN_WIDTH}px`,
1124
- ...(isRowIndexPinned && {
1125
- position: 'sticky' as const,
1126
- left: 0,
1127
- }),
1148
+ position: 'sticky' as const,
1149
+ left: 0,
1150
+ zIndex: 40,
1128
1151
  }}
1129
1152
  >
1130
- <div className="relative flex items-center justify-center w-full h-full">
1131
- {/* Row number - centered */}
1132
- <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>
1133
1156
 
1134
- {/* Action buttons - absolute overlay, spaced between */}
1135
- <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">
1136
1159
  {/* Context Menu (3-dot menu for row actions) */}
1137
1160
  {rowContextMenuItems &&
1138
1161
  rowContextMenuItems.length > 0 && (
@@ -1309,11 +1332,15 @@ export function Spreadsheet<T extends Record<string, any>>({
1309
1332
  isInSelection={isInSelection}
1310
1333
  selectionEdge={selectionEdge}
1311
1334
  isRowSelected={isRowSelected}
1312
- isRowHovered={isRowHovered}
1335
+ isRowHovered={false}
1313
1336
  highlightColor={cellOrRowOrColumnHighlight}
1337
+ isDuplicate={isCellDuplicate(rowId, column.id)}
1314
1338
  compactMode={effectiveCompactMode}
1339
+ isOddRow={rowIndex % 2 !== 0}
1340
+ resolvedWidth={getColumnWidth(column.id)}
1315
1341
  isPinned={isColPinned}
1316
1342
  pinSide={colPinSide}
1343
+ pinnedZIndex={isColPinned ? getPinnedZIndex(column.id) : undefined}
1317
1344
  leftOffset={getColumnLeftOffset(column.id)}
1318
1345
  rightOffset={getColumnRightOffset(
1319
1346
  column.id
@@ -1321,6 +1348,12 @@ export function Spreadsheet<T extends Record<string, any>>({
1321
1348
  onClick={(e) =>
1322
1349
  handleCellClick(rowId, column.id, e)
1323
1350
  }
1351
+ onDoubleClick={() =>
1352
+ handleCellDoubleClick(rowId, column.id)
1353
+ }
1354
+ onMouseEnter={() =>
1355
+ handleCellMouseEnter(rowId, column.id)
1356
+ }
1324
1357
  onConfirm={handleConfirmEdit}
1325
1358
  onCancel={handleCancelEdit}
1326
1359
  onHighlight={
@@ -1369,9 +1402,19 @@ export function Spreadsheet<T extends Record<string, any>>({
1369
1402
  )}
1370
1403
  </tbody>
1371
1404
  </table>
1372
- </div>
1373
1405
  </div>
1374
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
+
1375
1418
  {/* Pagination */}
1376
1419
  {showPagination && effectiveTotalItems > 0 && (
1377
1420
  <Pagination
@@ -1441,6 +1484,7 @@ export function Spreadsheet<T extends Record<string, any>>({
1441
1484
  <ColorPickerPopover
1442
1485
  title="Highlight Row"
1443
1486
  paletteType="row"
1487
+ recentColors={recentColors}
1444
1488
  onSelectColor={(color) => handleRowHighlightToggle(highlightPickerRow, color)}
1445
1489
  onClose={() => setHighlightPickerRow(null)}
1446
1490
  />
@@ -1455,6 +1499,7 @@ export function Spreadsheet<T extends Record<string, any>>({
1455
1499
  : `Highlight Column: ${columns.find((c) => c.id === highlightPickerColumn)?.label || ''}`
1456
1500
  }
1457
1501
  paletteType="column"
1502
+ recentColors={recentColors}
1458
1503
  onSelectColor={(color) =>
1459
1504
  handleColumnHighlightToggle(highlightPickerColumn, color)
1460
1505
  }
@@ -1467,6 +1512,7 @@ export function Spreadsheet<T extends Record<string, any>>({
1467
1512
  <ColorPickerPopover
1468
1513
  title="Highlight Cell"
1469
1514
  paletteType="row"
1515
+ recentColors={recentColors}
1470
1516
  onSelectColor={(color) =>
1471
1517
  handleCellHighlightToggle(
1472
1518
  highlightPickerCell.rowId,