@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,10 +1,10 @@
1
- import { useCallback, useMemo, useState } from 'react';
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import type { SpreadsheetColumn, SpreadsheetColumnGroup } from '../types';
3
3
 
4
4
  // Special column ID for row index
5
5
  export const ROW_INDEX_COLUMN_ID = '__row_index__';
6
- export const ROW_INDEX_COLUMN_WIDTH = 80;
7
- // Minimum width for any pinned column to ensure header actions (pin, filter, highlight icons) fit
6
+ export const ROW_INDEX_COLUMN_WIDTH = 90;
7
+ // Kept for backward compatibility but no longer enforced on cells
8
8
  export const MIN_PINNED_COLUMN_WIDTH = 150;
9
9
 
10
10
  export interface UseSpreadsheetPinningOptions<T> {
@@ -13,31 +13,24 @@ export interface UseSpreadsheetPinningOptions<T> {
13
13
  showRowIndex?: boolean;
14
14
  defaultPinnedColumns?: string[];
15
15
  defaultPinnedRightColumns?: string[];
16
+ /** Function to get the current (possibly resized) width for a column */
17
+ getColumnWidth?: (columnId: string, defaultWidth?: number) => number | undefined;
16
18
  }
17
19
 
18
20
  export interface UseSpreadsheetPinningReturn<T> {
19
- // Pinned state
20
21
  pinnedColumns: Map<string, 'left' | 'right'>;
21
22
  isRowIndexPinned: boolean;
22
-
23
- // Collapsed groups
24
23
  collapsedGroups: Set<string>;
25
-
26
- // Visible columns (filtered by groups and reordered by pinning)
27
24
  visibleColumns: SpreadsheetColumn<T>[];
28
-
29
- // Actions
30
25
  handleTogglePin: (columnId: string) => void;
31
26
  handleToggleGroupCollapse: (groupId: string) => void;
32
27
  setPinnedColumnsFromIds: (columnIds: string[]) => void;
33
-
34
- // Offset calculations
35
28
  getColumnLeftOffset: (columnId: string) => number;
36
29
  getColumnRightOffset: (columnId: string) => number;
37
-
38
- // Utility
39
30
  isColumnPinned: (columnId: string) => boolean;
40
31
  getColumnPinSide: (columnId: string) => 'left' | 'right' | undefined;
32
+ getPinnedZIndex: (columnId: string) => number;
33
+ measureRef: (el: HTMLElement | null) => void;
41
34
  }
42
35
 
43
36
  export function useSpreadsheetPinning<T>({
@@ -46,47 +39,94 @@ export function useSpreadsheetPinning<T>({
46
39
  showRowIndex = true,
47
40
  defaultPinnedColumns = [],
48
41
  defaultPinnedRightColumns = [],
42
+ getColumnWidth,
49
43
  }: UseSpreadsheetPinningOptions<T>): UseSpreadsheetPinningReturn<T> {
50
- // Initialize pinned columns from defaults (including row index)
51
44
  const [pinnedColumns, setPinnedColumns] = useState<Map<string, 'left' | 'right'>>(() => {
52
45
  const map = new Map<string, 'left' | 'right'>();
46
+ if (showRowIndex) {
47
+ map.set(ROW_INDEX_COLUMN_ID, 'left');
48
+ }
49
+ const midpoint = Math.floor(columns.length / 2);
53
50
  defaultPinnedColumns.forEach((col) => {
54
- map.set(col, 'left');
55
- });
56
- defaultPinnedRightColumns.forEach((col) => {
57
- map.set(col, 'right');
51
+ if (col === ROW_INDEX_COLUMN_ID) return;
52
+ const colIndex = columns.findIndex((c) => c.id === col);
53
+ map.set(col, colIndex >= 0 && colIndex <= midpoint ? 'left' : 'right');
58
54
  });
55
+ defaultPinnedRightColumns.forEach((col) => map.set(col, 'right'));
59
56
  return map;
60
57
  });
61
58
 
62
59
  const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
63
60
 
64
- // Derive isRowIndexPinned from pinnedColumns for convenience
65
- const isRowIndexPinned = pinnedColumns.has(ROW_INDEX_COLUMN_ID);
66
-
67
- // Toggle column pin (works for any column including row index)
68
- const handleTogglePin = useCallback((columnId: string) => {
69
- setPinnedColumns((prev) => {
70
- const newMap = new Map(prev);
71
- if (newMap.has(columnId)) {
72
- newMap.delete(columnId);
73
- } else {
74
- newMap.set(columnId, 'left');
61
+ // DOM measurement for accurate widths
62
+ const measuredWidthsRef = useRef<Map<string, number>>(new Map());
63
+ const tableElRef = useRef<HTMLElement | null>(null);
64
+ // Counter to trigger re-computation of offset memos after measurement
65
+ const [measureVersion, setMeasureVersion] = useState(0);
66
+
67
+ const measureColumnWidths = useCallback(() => {
68
+ const el = tableElRef.current;
69
+ if (!el) return;
70
+ const newMap = new Map<string, number>();
71
+ const headers = el.querySelectorAll('th[data-column-id]');
72
+ headers.forEach((th) => {
73
+ const colId = th.getAttribute('data-column-id');
74
+ if (colId) {
75
+ newMap.set(colId, (th as HTMLElement).offsetWidth);
75
76
  }
76
- return newMap;
77
77
  });
78
+ // Measure row index from first data row
79
+ const rowIndexTd = el.querySelector('td[data-column-id="__row_index__"]');
80
+ if (rowIndexTd) {
81
+ newMap.set(ROW_INDEX_COLUMN_ID, (rowIndexTd as HTMLElement).offsetWidth);
82
+ }
83
+ measuredWidthsRef.current = newMap;
84
+ setMeasureVersion((v) => v + 1);
78
85
  }, []);
79
86
 
80
- // Set pinned columns from an array of column IDs
87
+ const measureRef = useCallback((el: HTMLElement | null) => {
88
+ tableElRef.current = el;
89
+ }, []);
90
+
91
+ const isRowIndexPinned = pinnedColumns.has(ROW_INDEX_COLUMN_ID);
92
+
93
+ // Toggle pin with auto left/right detection
94
+ const handleTogglePin = useCallback(
95
+ (columnId: string) => {
96
+ setPinnedColumns((prev) => {
97
+ const newMap = new Map(prev);
98
+ if (newMap.has(columnId)) {
99
+ newMap.delete(columnId);
100
+ } else {
101
+ if (columnId === ROW_INDEX_COLUMN_ID) {
102
+ newMap.set(columnId, 'left');
103
+ } else {
104
+ const colIndex = columns.findIndex((c) => c.id === columnId);
105
+ const midpoint = Math.floor(columns.length / 2);
106
+ newMap.set(columnId, colIndex <= midpoint ? 'left' : 'right');
107
+ }
108
+ }
109
+ return newMap;
110
+ });
111
+ },
112
+ [columns]
113
+ );
114
+
81
115
  const setPinnedColumnsFromIds = useCallback((columnIds: string[]) => {
82
116
  const map = new Map<string, 'left' | 'right'>();
117
+ if (showRowIndex) {
118
+ map.set(ROW_INDEX_COLUMN_ID, 'left');
119
+ }
120
+ const midpoint = Math.floor(columns.length / 2);
83
121
  columnIds.forEach((col) => {
84
- map.set(col, 'left');
122
+ if (col !== ROW_INDEX_COLUMN_ID) {
123
+ const colIndex = columns.findIndex((c) => c.id === col);
124
+ map.set(col, colIndex >= 0 && colIndex <= midpoint ? 'left' : 'right');
125
+ }
85
126
  });
86
127
  setPinnedColumns(map);
87
- }, []);
128
+ }, [showRowIndex, columns]);
88
129
 
89
- // Toggle group collapse
90
130
  const handleToggleGroupCollapse = useCallback((groupId: string) => {
91
131
  setCollapsedGroups((prev) => {
92
132
  const newSet = new Set(prev);
@@ -99,135 +139,107 @@ export function useSpreadsheetPinning<T>({
99
139
  });
100
140
  }, []);
101
141
 
102
- // Calculate visible columns based on groups, collapse state, and pinning order
142
+ // Visible columns filtered by collapse, natural order preserved
103
143
  const visibleColumns = useMemo(() => {
104
- if (!columns || !Array.isArray(columns) || columns.length === 0) {
105
- return [];
106
- }
144
+ if (!columns || !Array.isArray(columns) || columns.length === 0) return [];
107
145
 
108
146
  let result: SpreadsheetColumn<T>[] = [...columns];
109
147
 
110
- // Filter based on column groups and collapse state
111
148
  if (columnGroups && Array.isArray(columnGroups)) {
112
149
  result = result.filter((column) => {
113
150
  const group = columnGroups.find((g) => g.columns.includes(column.id));
114
151
  if (!group) return true;
115
152
  if (!collapsedGroups.has(group.id)) return true;
116
- // When collapsed, only show pinned columns
117
153
  return pinnedColumns.has(column.id);
118
154
  });
119
155
  }
120
156
 
121
- // If no columns are pinned (excluding row index), return result as-is to preserve original order
122
- const nonRowIndexPinned = Array.from(pinnedColumns.keys()).filter(
123
- (id) => id !== ROW_INDEX_COLUMN_ID
124
- );
125
- if (nonRowIndexPinned.length === 0) {
126
- return result;
127
- }
157
+ return result;
158
+ }, [columns, columnGroups, collapsedGroups, pinnedColumns]);
128
159
 
129
- // Reorder columns: left-pinned first, unpinned in middle, right-pinned last
130
- const leftPinned: SpreadsheetColumn<T>[] = [];
131
- const unpinned: SpreadsheetColumn<T>[] = [];
132
- const rightPinned: SpreadsheetColumn<T>[] = [];
133
-
134
- // Maintain the order of pinned columns as they were added
135
- const pinnedLeftIds = Array.from(pinnedColumns.entries())
136
- .filter(([id, side]) => side === 'left' && id !== ROW_INDEX_COLUMN_ID)
137
- .map(([id]) => id);
138
- const pinnedRightIds = Array.from(pinnedColumns.entries())
139
- .filter(([id, side]) => side === 'right' && id !== ROW_INDEX_COLUMN_ID)
140
- .map(([id]) => id);
141
-
142
- for (const column of result) {
143
- const pinSide = pinnedColumns.get(column.id);
144
- if (pinSide === 'left') {
145
- leftPinned.push(column);
146
- } else if (pinSide === 'right') {
147
- rightPinned.push(column);
148
- } else {
149
- unpinned.push(column);
150
- }
160
+ // Measure after render when layout changes
161
+ useEffect(() => {
162
+ requestAnimationFrame(() => measureColumnWidths());
163
+ }, [measureColumnWidths, visibleColumns, pinnedColumns]);
164
+
165
+ // Pre-compute a width lookup map (fast O(1) per column)
166
+ const widthMap = useMemo(() => {
167
+ // measureVersion is in deps to recompute when DOM measurements update
168
+ void measureVersion;
169
+ const map = new Map<string, number>();
170
+ for (const col of columns) {
171
+ const measured = measuredWidthsRef.current.get(col.id);
172
+ if (measured) { map.set(col.id, measured); continue; }
173
+ const resized = getColumnWidth?.(col.id);
174
+ if (resized) { map.set(col.id, resized); continue; }
175
+ map.set(col.id, col.width || col.minWidth || 100);
176
+ }
177
+ // Row index
178
+ const measuredRI = measuredWidthsRef.current.get(ROW_INDEX_COLUMN_ID);
179
+ map.set(ROW_INDEX_COLUMN_ID, measuredRI || ROW_INDEX_COLUMN_WIDTH);
180
+ return map;
181
+ }, [columns, getColumnWidth, measureVersion]);
182
+
183
+ // Pre-compute all offsets and z-indices in a single pass (O(n) total, not O(n²))
184
+ const { leftOffsets, rightOffsets, zIndices } = useMemo(() => {
185
+ const leftOffsets = new Map<string, number>();
186
+ const rightOffsets = new Map<string, number>();
187
+ const zIndices = new Map<string, number>();
188
+
189
+ // Row index
190
+ leftOffsets.set(ROW_INDEX_COLUMN_ID, 0);
191
+ zIndices.set(ROW_INDEX_COLUMN_ID, 40);
192
+
193
+ // Collect left-pinned and right-pinned in natural order
194
+ const leftPinned = visibleColumns.filter((c) => pinnedColumns.get(c.id) === 'left');
195
+ const rightPinned = visibleColumns.filter((c) => pinnedColumns.get(c.id) === 'right');
196
+
197
+ // Left offsets: cumulative from left edge
198
+ const isRowIndexPinnedNow = pinnedColumns.has(ROW_INDEX_COLUMN_ID);
199
+ let leftOffset = showRowIndex && isRowIndexPinnedNow ? (widthMap.get(ROW_INDEX_COLUMN_ID) || ROW_INDEX_COLUMN_WIDTH) : 0;
200
+ for (let i = 0; i < leftPinned.length; i++) {
201
+ leftOffsets.set(leftPinned[i].id, leftOffset);
202
+ // Earlier left-pinned = higher z-index
203
+ zIndices.set(leftPinned[i].id, 20 + (leftPinned.length - i));
204
+ leftOffset += widthMap.get(leftPinned[i].id) || 100;
151
205
  }
152
206
 
153
- // Sort left and right pinned columns based on their order in the pinnedColumns map
154
- leftPinned.sort((a, b) => pinnedLeftIds.indexOf(a.id) - pinnedLeftIds.indexOf(b.id));
155
- rightPinned.sort((a, b) => pinnedRightIds.indexOf(a.id) - pinnedRightIds.indexOf(b.id));
207
+ // Right offsets: cumulative from right edge
208
+ let rightOffset = 0;
209
+ for (let i = rightPinned.length - 1; i >= 0; i--) {
210
+ rightOffsets.set(rightPinned[i].id, rightOffset);
211
+ // Later right-pinned (closer to edge) = higher z-index
212
+ zIndices.set(rightPinned[i].id, 20 + (rightPinned.length - i));
213
+ rightOffset += widthMap.get(rightPinned[i].id) || 100;
214
+ }
156
215
 
157
- return [...leftPinned, ...unpinned, ...rightPinned];
158
- }, [columns, columnGroups, collapsedGroups, pinnedColumns]);
216
+ return { leftOffsets, rightOffsets, zIndices };
217
+ }, [pinnedColumns, visibleColumns, showRowIndex, widthMap]);
159
218
 
160
- // Calculate column offset for sticky positioning
219
+ // O(1) lookups using pre-computed maps
161
220
  const getColumnLeftOffset = useCallback(
162
- (columnId: string): number => {
163
- // Row index column is always at left: 0 when pinned
164
- if (columnId === ROW_INDEX_COLUMN_ID) {
165
- return 0;
166
- }
221
+ (columnId: string): number => leftOffsets.get(columnId) ?? 0,
222
+ [leftOffsets]
223
+ );
167
224
 
168
- // Get left-pinned columns (excluding row index)
169
- const pinnedLeft = Array.from(pinnedColumns.entries())
170
- .filter(([id, side]) => side === 'left' && id !== ROW_INDEX_COLUMN_ID)
171
- .map(([id]) => id);
172
- const index = pinnedLeft.indexOf(columnId);
173
-
174
- // Base offset includes the row index column if shown and pinned
175
- const isRowIndexPinnedNow = pinnedColumns.has(ROW_INDEX_COLUMN_ID);
176
- const baseOffset = showRowIndex && isRowIndexPinnedNow ? ROW_INDEX_COLUMN_WIDTH : 0;
177
-
178
- if (index === -1) return baseOffset;
179
-
180
- let offset = baseOffset;
181
- for (let i = 0; i < index; i++) {
182
- const col = columns.find((c) => c.id === pinnedLeft[i]);
183
- // Pinned columns are clamped to at least MIN_PINNED_COLUMN_WIDTH
184
- // so that header actions (pin, filter, highlight icons) always fit
185
- const configuredWidth = col?.minWidth || col?.width || MIN_PINNED_COLUMN_WIDTH;
186
- offset += Math.max(configuredWidth, MIN_PINNED_COLUMN_WIDTH);
187
- }
188
- return offset;
189
- },
190
- [pinnedColumns, columns, showRowIndex]
225
+ const getColumnRightOffset = useCallback(
226
+ (columnId: string): number => rightOffsets.get(columnId) ?? 0,
227
+ [rightOffsets]
191
228
  );
192
229
 
193
- // Check if column is pinned
194
230
  const isColumnPinned = useCallback(
195
- (columnId: string): boolean => {
196
- return pinnedColumns.has(columnId);
197
- },
231
+ (columnId: string): boolean => pinnedColumns.has(columnId),
198
232
  [pinnedColumns]
199
233
  );
200
234
 
201
- // Get column pin side
202
235
  const getColumnPinSide = useCallback(
203
- (columnId: string): 'left' | 'right' | undefined => {
204
- return pinnedColumns.get(columnId);
205
- },
236
+ (columnId: string): 'left' | 'right' | undefined => pinnedColumns.get(columnId),
206
237
  [pinnedColumns]
207
238
  );
208
239
 
209
- // Calculate column offset for right sticky positioning
210
- const getColumnRightOffset = useCallback(
211
- (columnId: string): number => {
212
- // Get right-pinned columns
213
- const pinnedRight = Array.from(pinnedColumns.entries())
214
- .filter(([, side]) => side === 'right')
215
- .map(([id]) => id);
216
-
217
- const index = pinnedRight.indexOf(columnId);
218
- if (index === -1) return 0;
219
-
220
- // Calculate offset from the right edge
221
- // Start from 0 and add widths of columns to the right of this one
222
- let offset = 0;
223
- for (let i = pinnedRight.length - 1; i > index; i--) {
224
- const col = columns.find((c) => c.id === pinnedRight[i]);
225
- const configuredWidth = col?.minWidth || col?.width || MIN_PINNED_COLUMN_WIDTH;
226
- offset += Math.max(configuredWidth, MIN_PINNED_COLUMN_WIDTH);
227
- }
228
- return offset;
229
- },
230
- [pinnedColumns, columns]
240
+ const getPinnedZIndex = useCallback(
241
+ (columnId: string): number => zIndices.get(columnId) ?? 0,
242
+ [zIndices]
231
243
  );
232
244
 
233
245
  return {
@@ -242,5 +254,7 @@ export function useSpreadsheetPinning<T>({
242
254
  getColumnRightOffset,
243
255
  isColumnPinned,
244
256
  getColumnPinSide,
257
+ getPinnedZIndex,
258
+ measureRef,
245
259
  };
246
260
  }
@@ -256,7 +256,7 @@ export function useSpreadsheetSelection<T extends Record<string, any>>({
256
256
  });
257
257
  }, [getSelectedCells, data, columns, getRowId]);
258
258
 
259
- // Handle cell click
259
+ // Handle cell click (focus only — edit mode requires double-click or F2)
260
260
  const handleCellClick = useCallback(
261
261
  (rowId: string | number, columnId: string, event: React.MouseEvent) => {
262
262
  event.stopPropagation();
@@ -271,22 +271,17 @@ export function useSpreadsheetSelection<T extends Record<string, any>>({
271
271
  });
272
272
  setFocusedCell(newCell);
273
273
  } else {
274
- // Single cell selection
274
+ // Single cell selection — focus only, no edit
275
275
  anchorCell.current = newCell;
276
276
  setFocusedCell(newCell);
277
277
  setSelectedCellRange(null);
278
-
279
- // Enter edit mode if editable
280
- const column = columns.find((c) => c.id === columnId);
281
- if (column?.editable && enableCellEditing) {
282
- setEditingCell(newCell);
283
- }
278
+ setEditingCell(null);
284
279
  }
285
280
  },
286
- [columns, enableCellEditing, setSelectedCellRange]
281
+ [setSelectedCellRange]
287
282
  );
288
283
 
289
- // Handle cell mouse down (for shift+click selection)
284
+ // Handle cell mouse down (for focus and drag-to-select initiation)
290
285
  const handleCellMouseDown = useCallback(
291
286
  (rowId: string | number, columnId: string, event: React.MouseEvent) => {
292
287
  // Only handle left mouse button
@@ -304,26 +299,19 @@ export function useSpreadsheetSelection<T extends Record<string, any>>({
304
299
  setFocusedCell(newCell);
305
300
  setEditingCell(null);
306
301
  } else {
307
- // Regular click: focus cell, clear any selection, and enter edit mode if editable
302
+ // Regular click: focus cell only (edit mode requires double-click or F2)
308
303
  anchorCell.current = newCell;
309
304
  setFocusedCell(newCell);
310
305
  setSelectedCellRange(null);
311
-
312
- // Enter edit mode if editable
313
- const column = columns.find((c) => c.id === columnId);
314
- if (column?.editable && enableCellEditing) {
315
- setEditingCell(newCell);
316
- } else {
317
- setEditingCell(null);
318
- }
306
+ setEditingCell(null);
319
307
  }
320
308
  },
321
- [columns, enableCellEditing, setSelectedCellRange]
309
+ [setSelectedCellRange]
322
310
  );
323
311
 
324
- // Handle cell mouse enter (currently unused - drag selection disabled)
312
+ // Handle cell mouse enter (drag selection disabled use Shift+Click or Shift+Arrow for range selection)
325
313
  const handleCellMouseEnter = useCallback((_rowId: string | number, _columnId: string) => {
326
- // Drag selection is disabled - selection only via shift+click or shift+arrow
314
+ // Intentionally empty drag-to-select disabled for better UX in data table context
327
315
  }, []);
328
316
 
329
317
  // Handle mouse up (end drag)
@@ -0,0 +1,68 @@
1
+ import { useMemo } from 'react';
2
+ import type { CellPosition, SpreadsheetColumn } from '../types';
3
+
4
+ export interface SelectionSummary {
5
+ sum: number;
6
+ avg: number;
7
+ count: number;
8
+ numericCount: number;
9
+ min: number;
10
+ max: number;
11
+ }
12
+
13
+ export interface UseSpreadsheetSummaryOptions<T> {
14
+ /** Currently selected cell values */
15
+ selectedCellValues: { position: CellPosition; value: any }[];
16
+ /** Column definitions for type checking */
17
+ columns: SpreadsheetColumn<T>[];
18
+ }
19
+
20
+ export interface UseSpreadsheetSummaryReturn {
21
+ /** Computed summary for the current selection */
22
+ summary: SelectionSummary | null;
23
+ /** Whether there are numeric values to summarize */
24
+ hasNumericValues: boolean;
25
+ }
26
+
27
+ /**
28
+ * Hook for computing Excel-like summary statistics (Sum, Avg, Count, Min, Max)
29
+ * for the current cell selection. Only computes for numeric values.
30
+ */
31
+ export function useSpreadsheetSummary<T>({
32
+ selectedCellValues,
33
+ columns,
34
+ }: UseSpreadsheetSummaryOptions<T>): UseSpreadsheetSummaryReturn {
35
+ const summary = useMemo(() => {
36
+ if (selectedCellValues.length === 0) return null;
37
+
38
+ const numericValues: number[] = [];
39
+
40
+ for (const { position, value } of selectedCellValues) {
41
+ const column = columns.find((c) => c.id === position.columnId);
42
+ // Include values from numeric columns or any parseable number
43
+ if (column?.type === 'number' || typeof value === 'number') {
44
+ const num = typeof value === 'number' ? value : parseFloat(value);
45
+ if (!isNaN(num)) {
46
+ numericValues.push(num);
47
+ }
48
+ }
49
+ }
50
+
51
+ if (numericValues.length === 0) return null;
52
+
53
+ const sum = numericValues.reduce((a, b) => a + b, 0);
54
+ return {
55
+ sum: Math.round(sum * 100) / 100,
56
+ avg: Math.round((sum / numericValues.length) * 100) / 100,
57
+ count: selectedCellValues.length,
58
+ numericCount: numericValues.length,
59
+ min: Math.round(Math.min(...numericValues) * 100) / 100,
60
+ max: Math.round(Math.max(...numericValues) * 100) / 100,
61
+ };
62
+ }, [selectedCellValues, columns]);
63
+
64
+ return {
65
+ summary,
66
+ hasNumericValues: summary !== null,
67
+ };
68
+ }
package/src/index.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  // Main Spreadsheet component
2
2
  export { Spreadsheet } from './components/Spreadsheet';
3
3
 
4
+ // Hooks
5
+ export { useSpreadsheetDuplicates } from './hooks/useSpreadsheetDuplicates';
6
+
4
7
  // Sub-components
5
8
  export { SpreadsheetCell } from './components/SpreadsheetCell';
6
- export { SpreadsheetHeader } from './components/SpreadsheetHeader';
9
+ export { SpreadsheetHeader, MemoizedSpreadsheetHeader } from './components/SpreadsheetHeader';
7
10
  export { SpreadsheetFilterDropdown } from './components/SpreadsheetFilterDropdown';
8
11
  export { SpreadsheetToolbar } from './components/SpreadsheetToolbar';
9
12
  export { SpreadsheetSettingsModal } from './components/SpreadsheetSettingsModal';
@@ -1,3 +1,54 @@
1
1
  @import "tailwindcss";
2
2
 
3
3
  @source "../**/*.{ts,tsx,css}";
4
+
5
+ /* Minimal scrollbar styling scoped to the spreadsheet container */
6
+ .spreadsheet-scroll-container {
7
+ scrollbar-width: thin;
8
+ scrollbar-color: rgb(156 163 175) transparent;
9
+ }
10
+
11
+ .spreadsheet-scroll-container::-webkit-scrollbar {
12
+ width: 6px;
13
+ height: 6px;
14
+ }
15
+
16
+ .spreadsheet-scroll-container::-webkit-scrollbar-track {
17
+ background: transparent;
18
+ }
19
+
20
+ .spreadsheet-scroll-container::-webkit-scrollbar-thumb {
21
+ background-color: rgb(209 213 219);
22
+ border-radius: 3px;
23
+ }
24
+
25
+ .spreadsheet-scroll-container::-webkit-scrollbar-thumb:hover {
26
+ background-color: rgb(156 163 175);
27
+ }
28
+
29
+ .spreadsheet-scroll-container::-webkit-scrollbar-corner {
30
+ background: transparent;
31
+ }
32
+
33
+ /* Row hover via CSS to avoid React re-renders */
34
+ .spreadsheet-scroll-container tbody tr:hover > td {
35
+ background-color: rgb(249 250 251) !important; /* gray-50 */
36
+ }
37
+
38
+ /* Don't override selected or highlighted row backgrounds */
39
+ .spreadsheet-scroll-container tbody tr:hover > td[style*="dbeafe"],
40
+ .spreadsheet-scroll-container tbody tr:hover > td[style*="239 246 255"] {
41
+ background-color: rgb(219 234 254) !important; /* blue-100, keep selected */
42
+ }
43
+
44
+ /* Processing overlay anchored to the table wrapper */
45
+ .spreadsheet-processing-overlay {
46
+ position: absolute;
47
+ inset: 0;
48
+ background: rgba(255, 255, 255, 0.6);
49
+ display: flex;
50
+ align-items: center;
51
+ justify-content: center;
52
+ z-index: 60;
53
+ pointer-events: none;
54
+ }