@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,6 @@
1
1
  import type React from 'react';
2
- import { useEffect, useRef, useState } from 'react';
2
+ import { useCallback, useEffect, useRef, useState } from 'react';
3
+ import { createPortal } from 'react-dom';
3
4
  import { HiCheck, HiX } from 'react-icons/hi';
4
5
  import { cn } from '../utils';
5
6
  import type {
@@ -53,9 +54,17 @@ const DATE_OPERATORS: { value: DateFilterOperator; label: string }[] = [
53
54
  { value: 'isNotEmpty', label: 'Is not empty' },
54
55
  ];
55
56
 
57
+ const DROPDOWN_WIDTH = 256; // w-64 = 16rem = 256px
58
+ const DROPDOWN_HEIGHT = 340; // approximate max height
59
+ const ARROW_SIZE = 6; // px, half-width of the arrow caret
60
+
56
61
  /**
57
62
  * SpreadsheetFilterDropdown component - Condition-based filter dropdown for columns.
58
63
  * Supports text conditions, number conditions, and date conditions.
64
+ *
65
+ * When `triggerRef` is provided the dropdown renders via React.createPortal at
66
+ * document.body and positions itself relative to the trigger element.
67
+ * Falls back to absolute positioning when no triggerRef is given.
59
68
  */
60
69
  export const SpreadsheetFilterDropdown: React.FC<SpreadsheetFilterDropdownProps> = ({
61
70
  column,
@@ -63,7 +72,12 @@ export const SpreadsheetFilterDropdown: React.FC<SpreadsheetFilterDropdownProps>
63
72
  onFilterChange,
64
73
  onClose,
65
74
  className,
75
+ triggerRef,
76
+ hasDuplicateCheck = false,
66
77
  }) => {
78
+ const [showDuplicatesOnly, setShowDuplicatesOnly] = useState(
79
+ filter?.showDuplicatesOnly ?? false
80
+ );
67
81
  const [textOperator, setTextOperator] = useState<TextFilterOperator>(
68
82
  filter?.textCondition?.operator || 'contains'
69
83
  );
@@ -87,17 +101,91 @@ export const SpreadsheetFilterDropdown: React.FC<SpreadsheetFilterDropdownProps>
87
101
 
88
102
  const dropdownRef = useRef<HTMLDivElement>(null);
89
103
 
104
+ /** Portal positioning state */
105
+ const [position, setPosition] = useState<{
106
+ top: number;
107
+ left: number;
108
+ arrowLeft: number;
109
+ flippedUp: boolean;
110
+ }>({ top: 0, left: 0, arrowLeft: DROPDOWN_WIDTH / 2, flippedUp: false });
111
+
90
112
  const isNumeric = column.type === 'number';
91
113
  const isDate = column.type === 'date';
92
114
 
115
+ const updatePosition = useCallback(() => {
116
+ if (!triggerRef?.current) return;
117
+ const rect = triggerRef.current.getBoundingClientRect();
118
+
119
+ let top = rect.bottom + 4; // 4px gap below anchor
120
+ let flippedUp = false;
121
+
122
+ // Flip up if overflows bottom of viewport
123
+ if (top + DROPDOWN_HEIGHT > window.innerHeight) {
124
+ top = rect.top - DROPDOWN_HEIGHT - 4;
125
+ flippedUp = true;
126
+ }
127
+
128
+ // Prefer left-aligned with anchor, shift left if overflows right
129
+ let left = rect.left;
130
+ if (left + DROPDOWN_WIDTH > window.innerWidth) {
131
+ left = window.innerWidth - DROPDOWN_WIDTH - 8;
132
+ }
133
+ // Never go off-screen left
134
+ if (left < 8) {
135
+ left = 8;
136
+ }
137
+
138
+ // Arrow points to center of the anchor element, clamped within dropdown
139
+ const anchorCenter = rect.left + rect.width / 2;
140
+ const arrowLeft = Math.min(
141
+ Math.max(anchorCenter - left, ARROW_SIZE + 4),
142
+ DROPDOWN_WIDTH - ARROW_SIZE - 4
143
+ );
144
+
145
+ setPosition({ top, left, arrowLeft, flippedUp });
146
+ }, [triggerRef]);
147
+
148
+ // Reposition on mount, scroll (capture phase), and resize
149
+ useEffect(() => {
150
+ if (!triggerRef?.current) return;
151
+ updatePosition();
152
+ window.addEventListener('scroll', updatePosition, true);
153
+ window.addEventListener('resize', updatePosition);
154
+ return () => {
155
+ window.removeEventListener('scroll', updatePosition, true);
156
+ window.removeEventListener('resize', updatePosition);
157
+ };
158
+ }, [updatePosition, triggerRef]);
159
+
160
+ // Close on outside click (delayed to avoid closing on the same click that opened the dropdown)
93
161
  useEffect(() => {
94
162
  const handleClickOutside = (event: MouseEvent) => {
95
- if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
163
+ const target = event.target as Node;
164
+ if (dropdownRef.current && !dropdownRef.current.contains(target)) {
165
+ // Don't close if clicking the trigger element itself
166
+ if (triggerRef?.current?.contains(target)) return;
96
167
  onClose();
97
168
  }
98
169
  };
99
- document.addEventListener('mousedown', handleClickOutside);
100
- return () => document.removeEventListener('mousedown', handleClickOutside);
170
+ // Use requestAnimationFrame to skip the current event loop tick
171
+ const rafId = requestAnimationFrame(() => {
172
+ document.addEventListener('mousedown', handleClickOutside);
173
+ });
174
+ return () => {
175
+ cancelAnimationFrame(rafId);
176
+ document.removeEventListener('mousedown', handleClickOutside);
177
+ };
178
+ }, [onClose, triggerRef]);
179
+
180
+ // Close on Escape key
181
+ useEffect(() => {
182
+ const handleKeyDown = (event: KeyboardEvent) => {
183
+ if (event.key === 'Escape') {
184
+ onClose();
185
+ }
186
+ };
187
+ document.addEventListener('keydown', handleKeyDown);
188
+ return () => document.removeEventListener('keydown', handleKeyDown);
101
189
  }, [onClose]);
102
190
 
103
191
  const handleApplyFilter = () => {
@@ -105,18 +193,22 @@ export const SpreadsheetFilterDropdown: React.FC<SpreadsheetFilterDropdownProps>
105
193
 
106
194
  if (isNumeric) {
107
195
  const needsValue = !['isEmpty', 'isNotEmpty'].includes(numberOperator);
108
- if (needsValue && !numberValue) {
196
+ if (needsValue && !numberValue && !showDuplicatesOnly) {
109
197
  onFilterChange(undefined);
110
198
  onClose();
111
199
  return;
112
200
  }
113
201
  newFilter = {
114
- numberCondition: {
202
+ numberCondition: needsValue || !numberValue ? {
115
203
  operator: numberOperator,
116
204
  value: numberValue ? parseFloat(numberValue) : undefined,
117
205
  valueTo: numberValueTo ? parseFloat(numberValueTo) : undefined,
118
- },
206
+ } : undefined,
207
+ ...(showDuplicatesOnly && { showDuplicatesOnly: true }),
119
208
  };
209
+ if (!newFilter.numberCondition && !newFilter.showDuplicatesOnly) {
210
+ newFilter = undefined;
211
+ }
120
212
  } else if (isDate) {
121
213
  const needsValue = ![
122
214
  'isEmpty',
@@ -129,7 +221,7 @@ export const SpreadsheetFilterDropdown: React.FC<SpreadsheetFilterDropdownProps>
129
221
  'lastMonth',
130
222
  'thisYear',
131
223
  ].includes(dateOperator);
132
- if (needsValue && !dateValue) {
224
+ if (needsValue && !dateValue && !showDuplicatesOnly) {
133
225
  onFilterChange(undefined);
134
226
  onClose();
135
227
  return;
@@ -140,20 +232,25 @@ export const SpreadsheetFilterDropdown: React.FC<SpreadsheetFilterDropdownProps>
140
232
  value: dateValue || undefined,
141
233
  valueTo: dateValueTo || undefined,
142
234
  },
235
+ ...(showDuplicatesOnly && { showDuplicatesOnly: true }),
143
236
  };
144
237
  } else {
145
238
  const needsValue = !['isEmpty', 'isNotEmpty'].includes(textOperator);
146
- if (needsValue && !textValue) {
239
+ if (needsValue && !textValue && !showDuplicatesOnly) {
147
240
  onFilterChange(undefined);
148
241
  onClose();
149
242
  return;
150
243
  }
151
244
  newFilter = {
152
- textCondition: {
245
+ textCondition: needsValue || !textValue ? {
153
246
  operator: textOperator,
154
247
  value: textValue || undefined,
155
- },
248
+ } : undefined,
249
+ ...(showDuplicatesOnly && { showDuplicatesOnly: true }),
156
250
  };
251
+ if (!newFilter.textCondition && !newFilter.showDuplicatesOnly) {
252
+ newFilter = undefined;
253
+ }
157
254
  }
158
255
 
159
256
  onFilterChange(newFilter);
@@ -161,6 +258,7 @@ export const SpreadsheetFilterDropdown: React.FC<SpreadsheetFilterDropdownProps>
161
258
  };
162
259
 
163
260
  const handleClearFilter = () => {
261
+ setShowDuplicatesOnly(false);
164
262
  setTextValue('');
165
263
  setNumberValue('');
166
264
  setNumberValueTo('');
@@ -170,6 +268,14 @@ export const SpreadsheetFilterDropdown: React.FC<SpreadsheetFilterDropdownProps>
170
268
  onClose();
171
269
  };
172
270
 
271
+ // Submit filter on Enter key press
272
+ const handleFilterKeyDown = (e: React.KeyboardEvent) => {
273
+ if (e.key === 'Enter') {
274
+ e.preventDefault();
275
+ handleApplyFilter();
276
+ }
277
+ };
278
+
173
279
  const textNeedsValue = !['isEmpty', 'isNotEmpty'].includes(textOperator);
174
280
  const numberNeedsValue = !['isEmpty', 'isNotEmpty'].includes(numberOperator);
175
281
  const dateNeedsValue = ![
@@ -184,19 +290,67 @@ export const SpreadsheetFilterDropdown: React.FC<SpreadsheetFilterDropdownProps>
184
290
  'thisYear',
185
291
  ].includes(dateOperator);
186
292
 
187
- return (
293
+ const dropdownContent = (
188
294
  <div
189
295
  ref={dropdownRef}
190
296
  className={cn(
191
- 'absolute top-full left-0 mt-1 bg-white border border-gray-200 shadow-lg rounded-lg w-64 overflow-hidden flex flex-col z-[100]',
297
+ 'bg-white border border-gray-200 shadow-lg rounded-lg w-64 overflow-hidden flex flex-col',
298
+ !triggerRef && 'absolute top-full left-0 mt-1 z-[100]',
192
299
  className
193
300
  )}
301
+ style={
302
+ triggerRef
303
+ ? { position: 'fixed', top: position.top, left: position.left, zIndex: 9999 }
304
+ : undefined
305
+ }
194
306
  onClick={(e) => e.stopPropagation()}
195
307
  >
308
+ {/* Arrow caret pointing to the anchor column */}
309
+ {triggerRef && (
310
+ <div
311
+ className="absolute pointer-events-none"
312
+ style={
313
+ position.flippedUp
314
+ ? {
315
+ bottom: -ARROW_SIZE,
316
+ left: position.arrowLeft - ARROW_SIZE,
317
+ width: 0,
318
+ height: 0,
319
+ borderLeft: `${ARROW_SIZE}px solid transparent`,
320
+ borderRight: `${ARROW_SIZE}px solid transparent`,
321
+ borderTop: `${ARROW_SIZE}px solid #e5e7eb`,
322
+ }
323
+ : {
324
+ top: -ARROW_SIZE,
325
+ left: position.arrowLeft - ARROW_SIZE,
326
+ width: 0,
327
+ height: 0,
328
+ borderLeft: `${ARROW_SIZE}px solid transparent`,
329
+ borderRight: `${ARROW_SIZE}px solid transparent`,
330
+ borderBottom: `${ARROW_SIZE}px solid #e5e7eb`,
331
+ }
332
+ }
333
+ />
334
+ )}
335
+
196
336
  <div className="px-3 py-2 border-b border-gray-200 bg-gray-50">
197
337
  <span className="text-xs font-medium text-gray-700">Filter: {column.label}</span>
198
338
  </div>
199
339
 
340
+ {hasDuplicateCheck && (
341
+ <div className="px-3 pt-2 pb-2 border-b border-gray-200">
342
+ <label className="flex items-center gap-2 cursor-pointer select-none">
343
+ <input
344
+ type="checkbox"
345
+ checked={showDuplicatesOnly}
346
+ onChange={(e) => setShowDuplicatesOnly(e.target.checked)}
347
+ className="h-3 w-3 rounded border-gray-300 text-red-600 focus:ring-red-500"
348
+ />
349
+ <span className="text-xs text-gray-700">Show only duplicates</span>
350
+ </label>
351
+ </div>
352
+ )}
353
+
200
354
  <div className="p-3 space-y-3">
201
355
  {isNumeric ? (
202
356
  <>
@@ -224,6 +378,7 @@ export const SpreadsheetFilterDropdown: React.FC<SpreadsheetFilterDropdownProps>
224
378
  placeholder="Enter value"
225
379
  value={numberValue}
226
380
  onChange={(e) => setNumberValue(e.target.value)}
381
+ onKeyDown={handleFilterKeyDown}
227
382
  className="w-full px-2 py-1.5 text-xs border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500"
228
383
  />
229
384
  </div>
@@ -236,6 +391,7 @@ export const SpreadsheetFilterDropdown: React.FC<SpreadsheetFilterDropdownProps>
236
391
  placeholder="Enter end value"
237
392
  value={numberValueTo}
238
393
  onChange={(e) => setNumberValueTo(e.target.value)}
394
+ onKeyDown={handleFilterKeyDown}
239
395
  className="w-full px-2 py-1.5 text-xs border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500"
240
396
  />
241
397
  </div>
@@ -266,6 +422,7 @@ export const SpreadsheetFilterDropdown: React.FC<SpreadsheetFilterDropdownProps>
266
422
  type="date"
267
423
  value={dateValue}
268
424
  onChange={(e) => setDateValue(e.target.value)}
425
+ onKeyDown={handleFilterKeyDown}
269
426
  className="w-full px-2 py-1.5 text-xs border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500"
270
427
  />
271
428
  </div>
@@ -277,6 +434,7 @@ export const SpreadsheetFilterDropdown: React.FC<SpreadsheetFilterDropdownProps>
277
434
  type="date"
278
435
  value={dateValueTo}
279
436
  onChange={(e) => setDateValueTo(e.target.value)}
437
+ onKeyDown={handleFilterKeyDown}
280
438
  className="w-full px-2 py-1.5 text-xs border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500"
281
439
  />
282
440
  </div>
@@ -308,6 +466,7 @@ export const SpreadsheetFilterDropdown: React.FC<SpreadsheetFilterDropdownProps>
308
466
  placeholder="Enter text"
309
467
  value={textValue}
310
468
  onChange={(e) => setTextValue(e.target.value)}
469
+ onKeyDown={handleFilterKeyDown}
311
470
  className="w-full px-2 py-1.5 text-xs border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500"
312
471
  />
313
472
  </div>
@@ -336,6 +495,12 @@ export const SpreadsheetFilterDropdown: React.FC<SpreadsheetFilterDropdownProps>
336
495
  </div>
337
496
  </div>
338
497
  );
498
+
499
+ if (triggerRef) {
500
+ return createPortal(dropdownContent, document.body);
501
+ }
502
+
503
+ return dropdownContent;
339
504
  };
340
505
 
341
506
  SpreadsheetFilterDropdown.displayName = 'SpreadsheetFilterDropdown';
@@ -1,12 +1,11 @@
1
- import type React from 'react';
1
+ import React, { memo, useRef } from 'react';
2
2
  import { HiChevronDown, HiChevronUp } from 'react-icons/hi';
3
3
  import { cn } from '../utils';
4
4
  import type { SpreadsheetHeaderProps } from '../types';
5
5
  import { ColumnHeaderActions } from './ColumnHeaderActions';
6
- import { MIN_PINNED_COLUMN_WIDTH } from '../hooks/useSpreadsheetPinning';
7
6
 
8
- const cellPaddingCompact = 'px-1 py-0.5';
9
- const cellPaddingNormal = 'px-2 py-1.5';
7
+ const cellPaddingCompact = 'px-1.5 py-1';
8
+ const cellPaddingNormal = 'px-2 py-2';
10
9
 
11
10
  /**
12
11
  * SpreadsheetHeader component - A column header cell with sorting, filtering, and pinning capabilities.
@@ -34,12 +33,21 @@ export const SpreadsheetHeader: React.FC<
34
33
  highlightColor,
35
34
  compactMode = false,
36
35
  onClick,
36
+ pinnedZIndex,
37
+ onSortClick,
37
38
  onFilterClick,
38
39
  onPinClick,
39
40
  onHighlightClick,
41
+ hasDuplicateCheck = false,
42
+ onDuplicateCheckClick,
43
+ duplicateCount,
44
+ resizeHandleProps,
45
+ resolvedWidth,
46
+ topOffset = 0,
40
47
  className,
41
48
  children,
42
49
  }) => {
50
+ const thRef = useRef<HTMLTableCellElement>(null);
43
51
  const isSorted = sortConfig?.columnId === column.id;
44
52
  const sortDirection = isSorted ? sortConfig.direction : null;
45
53
 
@@ -58,33 +66,31 @@ export const SpreadsheetHeader: React.FC<
58
66
 
59
67
  return (
60
68
  <th
61
- onClick={column.sortable ? onClick : undefined}
69
+ ref={thRef}
70
+ data-column-id={column.id}
71
+ onClick={onClick}
62
72
  className={cn(
63
- 'border border-gray-200 font-semibold text-gray-700 sticky group',
64
- compactMode ? 'text-[10px]' : 'text-xs',
73
+ 'border border-gray-200 font-semibold text-gray-700 group relative cursor-pointer',
74
+ isPinned && 'sticky',
75
+ compactMode ? 'text-xs' : 'text-sm',
65
76
  cellPadding,
66
77
  column.align === 'right' && 'text-right',
67
78
  column.align === 'center' && 'text-center',
68
- column.sortable && 'cursor-pointer hover:bg-gray-100',
69
- isPinned ? 'z-30' : 'z-20',
79
+ 'hover:bg-gray-100',
80
+ !isPinned && 'z-20',
70
81
  className
71
82
  )}
72
83
  style={{
73
84
  backgroundColor: highlightColor || 'rgb(243 244 246)', // gray-100
74
- minWidth: column.minWidth || column.width,
75
- // Pinned columns must have a fixed width so sticky offsets stay correct.
76
- // Enforce MIN_PINNED_COLUMN_WIDTH so header actions (pin/filter/highlight) always fit.
77
- ...(isPinned && {
78
- width: Math.max(
79
- column.minWidth || column.width || MIN_PINNED_COLUMN_WIDTH,
80
- MIN_PINNED_COLUMN_WIDTH
81
- ),
82
- maxWidth: Math.max(
83
- column.minWidth || column.width || MIN_PINNED_COLUMN_WIDTH,
84
- MIN_PINNED_COLUMN_WIDTH
85
- ),
85
+ // Always set explicit width to prevent layout shift on selection/re-render
86
+ ...(resolvedWidth ? {
87
+ width: resolvedWidth,
88
+ minWidth: resolvedWidth,
89
+ } : {
90
+ width: column.width || column.minWidth,
91
+ minWidth: column.minWidth || column.width,
86
92
  }),
87
- top: 0, // For sticky header
93
+ ...(isPinned && pinnedZIndex !== undefined && { zIndex: pinnedZIndex + 10 }), // +10 so headers are above cells
88
94
  ...positionStyles,
89
95
  }}
90
96
  >
@@ -101,10 +107,21 @@ export const SpreadsheetHeader: React.FC<
101
107
  )}
102
108
  </span>
103
109
  )}
110
+ {hasDuplicateCheck && duplicateCount != null && duplicateCount > 0 && (
111
+ <span
112
+ className="inline-flex items-center px-1 py-0.5 rounded-full bg-red-100 text-red-600 text-[10px] font-medium leading-none"
113
+ title={`${duplicateCount} duplicate values found`}
114
+ >
115
+ {duplicateCount}
116
+ </span>
117
+ )}
104
118
  </span>
105
119
 
106
120
  {/* Action buttons using unified ColumnHeaderActions */}
107
121
  <ColumnHeaderActions
122
+ enableSorting={column.sortable}
123
+ sortDirection={isSorted ? sortDirection : null}
124
+ onSortClick={onSortClick}
108
125
  enableFiltering={column.filterable}
109
126
  enableHighlighting={column.highlightable !== false && !!onHighlightClick}
110
127
  enablePinning={column.pinnable !== false}
@@ -114,12 +131,50 @@ export const SpreadsheetHeader: React.FC<
114
131
  onFilterClick={onFilterClick}
115
132
  onHighlightClick={onHighlightClick}
116
133
  onPinClick={onPinClick}
134
+ resolvedWidth={resolvedWidth}
135
+ enableDuplicateCheck={true}
136
+ hasDuplicateCheck={hasDuplicateCheck}
137
+ onDuplicateCheckClick={onDuplicateCheckClick}
117
138
  />
118
139
  </div>
119
- {/* Filter dropdown rendered inside th for proper positioning */}
120
- {children}
140
+ {/* Filter dropdown rendered inside th for proper positioning, cloned with triggerRef */}
141
+ {children && React.Children.map(children, (child) =>
142
+ React.isValidElement(child)
143
+ ? React.cloneElement(child as React.ReactElement<any>, { triggerRef: thRef })
144
+ : child
145
+ )}
146
+ {/* Resize handle */}
147
+ {resizeHandleProps && (
148
+ <div
149
+ onMouseDown={resizeHandleProps.onMouseDown}
150
+ style={resizeHandleProps.style}
151
+ className={resizeHandleProps.className}
152
+ />
153
+ )}
121
154
  </th>
122
155
  );
123
156
  };
124
157
 
125
158
  SpreadsheetHeader.displayName = 'SpreadsheetHeader';
159
+
160
+ export const MemoizedSpreadsheetHeader = memo(SpreadsheetHeader, (prevProps, nextProps) => {
161
+ if (prevProps.column.id !== nextProps.column.id) return false;
162
+ if (prevProps.sortConfig?.columnId !== nextProps.sortConfig?.columnId) return false;
163
+ if (prevProps.sortConfig?.direction !== nextProps.sortConfig?.direction) return false;
164
+ if (prevProps.hasActiveFilter !== nextProps.hasActiveFilter) return false;
165
+ if (prevProps.isPinned !== nextProps.isPinned) return false;
166
+ if (prevProps.pinSide !== nextProps.pinSide) return false;
167
+ if (prevProps.leftOffset !== nextProps.leftOffset) return false;
168
+ if (prevProps.rightOffset !== nextProps.rightOffset) return false;
169
+ if (prevProps.highlightColor !== nextProps.highlightColor) return false;
170
+ if (prevProps.compactMode !== nextProps.compactMode) return false;
171
+ if (prevProps.pinnedZIndex !== nextProps.pinnedZIndex) return false;
172
+ if (prevProps.resolvedWidth !== nextProps.resolvedWidth) return false;
173
+ if (prevProps.topOffset !== nextProps.topOffset) return false;
174
+ if (prevProps.hasDuplicateCheck !== nextProps.hasDuplicateCheck) return false;
175
+ if (prevProps.duplicateCount !== nextProps.duplicateCount) return false;
176
+ if (prevProps.children !== nextProps.children) return false;
177
+ return true;
178
+ });
179
+
180
+ MemoizedSpreadsheetHeader.displayName = 'MemoizedSpreadsheetHeader';
@@ -18,6 +18,10 @@ export interface SpreadsheetSettings {
18
18
  autoSave?: boolean;
19
19
  /** Whether compact view is enabled */
20
20
  compactView?: boolean;
21
+ /** Persisted column widths (columnId → width in px) */
22
+ columnWidths?: Record<string, number>;
23
+ /** Column IDs with duplicate checking enabled */
24
+ duplicateCheckColumns?: string[];
21
25
  }
22
26
 
23
27
  export interface SpreadsheetSettingsModalProps {
@@ -0,0 +1,143 @@
1
+ import { useCallback, useState, useRef } from 'react';
2
+
3
+ export interface UseSpreadsheetColumnResizeOptions {
4
+ /** Minimum column width in pixels */
5
+ minWidth?: number;
6
+ /** Initial column widths to restore from persisted settings */
7
+ initialColumnWidths?: Record<string, number>;
8
+ /** Callback when a column is resized (for external persistence) */
9
+ onColumnResize?: (columnId: string, width: number) => void;
10
+ }
11
+
12
+ export interface UseSpreadsheetColumnResizeReturn {
13
+ /** Map of column ID to resized width */
14
+ columnWidths: Map<string, number>;
15
+ /** Get the effective width for a column (resized or fallback) */
16
+ getColumnWidth: (columnId: string, defaultWidth?: number) => number | undefined;
17
+ /** Props to spread on the resize handle element */
18
+ getResizeHandleProps: (columnId: string, currentWidth: number) => {
19
+ onMouseDown: (e: React.MouseEvent) => void;
20
+ style: React.CSSProperties;
21
+ className: string;
22
+ };
23
+ /** Whether a column is currently being resized */
24
+ isResizing: boolean;
25
+ /** The column ID currently being resized */
26
+ resizingColumnId: string | null;
27
+ }
28
+
29
+ const DEFAULT_MIN_WIDTH = 40;
30
+
31
+ /**
32
+ * Hook for managing column resize via drag handles on header edges.
33
+ * Uses direct DOM updates during drag for smooth performance, committing to state on mouseup.
34
+ */
35
+ export function useSpreadsheetColumnResize({
36
+ minWidth = DEFAULT_MIN_WIDTH,
37
+ initialColumnWidths,
38
+ onColumnResize,
39
+ }: UseSpreadsheetColumnResizeOptions = {}): UseSpreadsheetColumnResizeReturn {
40
+ const [columnWidths, setColumnWidths] = useState<Map<string, number>>(() => {
41
+ if (initialColumnWidths) {
42
+ return new Map(Object.entries(initialColumnWidths));
43
+ }
44
+ return new Map();
45
+ });
46
+ const [resizingColumnId, setResizingColumnId] = useState<string | null>(null);
47
+ const startXRef = useRef(0);
48
+ const startWidthRef = useRef(0);
49
+ const rafRef = useRef<number | null>(null);
50
+
51
+ const getColumnWidth = useCallback(
52
+ (columnId: string, defaultWidth?: number): number | undefined => {
53
+ return columnWidths.get(columnId) ?? defaultWidth;
54
+ },
55
+ [columnWidths]
56
+ );
57
+
58
+ /**
59
+ * Directly update DOM widths for the column during drag (no React re-render).
60
+ * Targets both <th> and <td> elements with the matching data-column-id.
61
+ */
62
+ const updateColumnDom = useCallback((columnId: string, width: number) => {
63
+ const widthPx = `${width}px`;
64
+ const cells = document.querySelectorAll(`[data-column-id="${columnId}"]`);
65
+ for (let i = 0; i < cells.length; i++) {
66
+ const el = cells[i] as HTMLElement;
67
+ el.style.width = widthPx;
68
+ el.style.minWidth = widthPx;
69
+ }
70
+ }, []);
71
+
72
+ const getResizeHandleProps = useCallback(
73
+ (columnId: string, currentWidth: number) => {
74
+ return {
75
+ onMouseDown: (e: React.MouseEvent) => {
76
+ e.preventDefault();
77
+ e.stopPropagation();
78
+
79
+ startXRef.current = e.clientX;
80
+ startWidthRef.current = columnWidths.get(columnId) ?? currentWidth;
81
+ setResizingColumnId(columnId);
82
+
83
+ document.body.style.cursor = 'col-resize';
84
+ document.body.style.userSelect = 'none';
85
+
86
+ let latestWidth = startWidthRef.current;
87
+
88
+ const moveHandler = (ev: MouseEvent) => {
89
+ const diff = ev.clientX - startXRef.current;
90
+ latestWidth = Math.max(minWidth, startWidthRef.current + diff);
91
+
92
+ // Throttle DOM updates to animation frames
93
+ if (rafRef.current === null) {
94
+ rafRef.current = requestAnimationFrame(() => {
95
+ rafRef.current = null;
96
+ updateColumnDom(columnId, latestWidth);
97
+ });
98
+ }
99
+ };
100
+
101
+ const upHandler = () => {
102
+ document.removeEventListener('mousemove', moveHandler);
103
+ document.removeEventListener('mouseup', upHandler);
104
+
105
+ if (rafRef.current !== null) {
106
+ cancelAnimationFrame(rafRef.current);
107
+ rafRef.current = null;
108
+ }
109
+
110
+ // Commit final width to React state
111
+ setColumnWidths((prev) => {
112
+ const next = new Map(prev);
113
+ next.set(columnId, latestWidth);
114
+ return next;
115
+ });
116
+ onColumnResize?.(columnId, latestWidth);
117
+
118
+ setResizingColumnId(null);
119
+ document.body.style.cursor = '';
120
+ document.body.style.userSelect = '';
121
+ };
122
+
123
+ document.addEventListener('mousemove', moveHandler);
124
+ document.addEventListener('mouseup', upHandler);
125
+ },
126
+ style: {
127
+ cursor: 'col-resize' as const,
128
+ } as React.CSSProperties,
129
+ className:
130
+ 'absolute top-0 right-0 w-1 h-full hover:bg-blue-400 transition-colors z-10',
131
+ };
132
+ },
133
+ [columnWidths, minWidth, onColumnResize, updateColumnDom]
134
+ );
135
+
136
+ return {
137
+ columnWidths,
138
+ getColumnWidth,
139
+ getResizeHandleProps,
140
+ isResizing: resizingColumnId !== null,
141
+ resizingColumnId,
142
+ };
143
+ }