@xcelsior/ui-spreadsheets 1.2.2 → 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 -1156
  11. package/dist/index.js.map +1 -1
  12. package/dist/index.mjs +2023 -1048
  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 +234 -189
  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,13 +1,14 @@
1
1
  import type React from 'react';
2
- import { useState, useRef, useEffect, memo } from 'react';
2
+ import { useState, useRef, useEffect, useCallback, memo } from 'react';
3
+ import { createPortal } from 'react-dom';
3
4
  import { AiFillHighlight } from 'react-icons/ai';
5
+ import { HiChevronDown } from 'react-icons/hi';
4
6
  import { FaComment, FaRegComment } from 'react-icons/fa';
5
7
  import { cn } from '../utils';
6
8
  import type { SpreadsheetCellProps } from '../types';
7
- import { MIN_PINNED_COLUMN_WIDTH } from '../hooks/useSpreadsheetPinning';
8
9
 
9
- const cellPaddingCompact = 'px-1 py-px';
10
- const cellPaddingNormal = 'px-2 py-1';
10
+ const cellPaddingCompact = 'px-1.5 py-0.5';
11
+ const cellPaddingNormal = 'px-2.5 py-1.5';
11
12
 
12
13
  /**
13
14
  * SpreadsheetCell component - A single cell in the spreadsheet table.
@@ -27,6 +28,219 @@ const cellPaddingNormal = 'px-2 py-1';
27
28
  * />
28
29
  * ```
29
30
  */
31
+ /**
32
+ * AutocompleteEditor — self-contained autocomplete dropdown for a spreadsheet cell.
33
+ * Uses position:fixed + getBoundingClientRect so it escapes overflow:hidden parents.
34
+ */
35
+ const AutocompleteEditor: React.FC<{
36
+ value: any;
37
+ column: import('../types').SpreadsheetColumn;
38
+ compactMode: boolean;
39
+ onConfirm?: (value: any) => void;
40
+ onCancel?: () => void;
41
+ }> = ({ value, column, compactMode, onConfirm, onCancel }) => {
42
+ // Derive initial label from value
43
+ const getLabel = useCallback(
44
+ (val: any): string => {
45
+ if (val === null || val === undefined || val === '') return '';
46
+ if (column.getOptionLabel) return column.getOptionLabel(val);
47
+ const match = column.autocompleteOptions?.find((o) => o.value === val);
48
+ return match ? match.label : String(val);
49
+ },
50
+ [column]
51
+ );
52
+
53
+ const [searchText, setSearchText] = useState(() => getLabel(value));
54
+ const [filteredOptions, setFilteredOptions] = useState<{ label: string; value: string | number }[]>(
55
+ column.autocompleteOptions ?? []
56
+ );
57
+ const [focusedIndex, setFocusedIndex] = useState(-1);
58
+ const [isOpen, setIsOpen] = useState(true);
59
+ const [dropdownPos, setDropdownPos] = useState<{ top: number; left: number; width: number } | null>(null);
60
+
61
+ const inputRef = useRef<HTMLInputElement>(null);
62
+ const dropdownRef = useRef<HTMLDivElement>(null);
63
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
64
+
65
+ // Position dropdown using fixed coordinates
66
+ const updateDropdownPos = useCallback(() => {
67
+ if (inputRef.current) {
68
+ const rect = inputRef.current.getBoundingClientRect();
69
+ setDropdownPos({
70
+ top: rect.bottom + 2,
71
+ left: rect.left,
72
+ width: rect.width,
73
+ });
74
+ }
75
+ }, []);
76
+
77
+ // Focus input on mount
78
+ useEffect(() => {
79
+ inputRef.current?.focus();
80
+ inputRef.current?.select();
81
+ updateDropdownPos();
82
+ }, [updateDropdownPos]);
83
+
84
+ // Client-side filter helper
85
+ const filterClientSide = useCallback(
86
+ (term: string) => {
87
+ const opts = column.autocompleteOptions ?? [];
88
+ if (!term.trim()) return opts;
89
+ const lower = term.toLowerCase();
90
+ return opts.filter((o) => o.label.toLowerCase().includes(lower));
91
+ },
92
+ [column.autocompleteOptions]
93
+ );
94
+
95
+ // Debounced search handler
96
+ const handleSearchChange = useCallback(
97
+ (e: React.ChangeEvent<HTMLInputElement>) => {
98
+ const term = e.target.value;
99
+ setSearchText(term);
100
+ setFocusedIndex(-1);
101
+ setIsOpen(true);
102
+ updateDropdownPos();
103
+
104
+ if (debounceRef.current) clearTimeout(debounceRef.current);
105
+
106
+ debounceRef.current = setTimeout(async () => {
107
+ if (column.onAutocompleteSearch) {
108
+ try {
109
+ const results = await column.onAutocompleteSearch(term);
110
+ setFilteredOptions(results);
111
+ } catch {
112
+ setFilteredOptions([]);
113
+ }
114
+ } else {
115
+ setFilteredOptions(filterClientSide(term));
116
+ }
117
+ }, 300);
118
+ },
119
+ [column, filterClientSide, updateDropdownPos]
120
+ );
121
+
122
+ // Cleanup debounce on unmount
123
+ useEffect(() => {
124
+ return () => {
125
+ if (debounceRef.current) clearTimeout(debounceRef.current);
126
+ };
127
+ }, []);
128
+
129
+ const selectOption = useCallback(
130
+ (option: { label: string; value: string | number }) => {
131
+ setSearchText(option.label);
132
+ setIsOpen(false);
133
+ onConfirm?.(option.value);
134
+ },
135
+ [onConfirm]
136
+ );
137
+
138
+ const handleKeyDown = useCallback(
139
+ (e: React.KeyboardEvent<HTMLInputElement>) => {
140
+ const visibleOptions = filteredOptions.slice(0, 8);
141
+
142
+ if (e.key === 'ArrowDown') {
143
+ e.preventDefault();
144
+ setFocusedIndex((prev) => Math.min(prev + 1, visibleOptions.length - 1));
145
+ } else if (e.key === 'ArrowUp') {
146
+ e.preventDefault();
147
+ setFocusedIndex((prev) => Math.max(prev - 1, -1));
148
+ } else if (e.key === 'Enter') {
149
+ e.preventDefault();
150
+ if (focusedIndex >= 0 && visibleOptions[focusedIndex]) {
151
+ selectOption(visibleOptions[focusedIndex]);
152
+ } else {
153
+ // Confirm with current search text as-is (no match selected)
154
+ onConfirm?.(value);
155
+ }
156
+ } else if (e.key === 'Escape') {
157
+ e.preventDefault();
158
+ e.stopPropagation();
159
+ setIsOpen(false);
160
+ onCancel?.();
161
+ }
162
+ },
163
+ [filteredOptions, focusedIndex, selectOption, onConfirm, onCancel, value]
164
+ );
165
+
166
+ const handleBlur = useCallback(
167
+ (e: React.FocusEvent) => {
168
+ // Delay to allow click on dropdown option to fire first
169
+ setTimeout(() => {
170
+ if (
171
+ dropdownRef.current &&
172
+ dropdownRef.current.contains(document.activeElement)
173
+ ) {
174
+ return;
175
+ }
176
+ setIsOpen(false);
177
+ onConfirm?.(value);
178
+ }, 150);
179
+ },
180
+ [onConfirm, value]
181
+ );
182
+
183
+ const visibleOptions = filteredOptions.slice(0, 8);
184
+
185
+ const dropdown =
186
+ isOpen && visibleOptions.length > 0 && dropdownPos
187
+ ? createPortal(
188
+ <div
189
+ ref={dropdownRef}
190
+ style={{
191
+ position: 'fixed',
192
+ top: dropdownPos.top,
193
+ left: dropdownPos.left,
194
+ width: Math.max(dropdownPos.width, 180),
195
+ zIndex: 50,
196
+ }}
197
+ className="bg-white border border-gray-200 rounded-md shadow-lg max-h-48 overflow-y-auto"
198
+ onMouseDown={(e) => e.preventDefault()} // prevent input blur before click
199
+ >
200
+ {visibleOptions.map((option, index) => (
201
+ <div
202
+ key={`${option.value}-${index}`}
203
+ onMouseDown={(e) => {
204
+ e.preventDefault();
205
+ selectOption(option);
206
+ }}
207
+ className={cn(
208
+ 'px-3 py-1.5 cursor-pointer',
209
+ compactMode ? 'text-xs' : 'text-sm',
210
+ index === focusedIndex ? 'bg-blue-100' : 'hover:bg-blue-50'
211
+ )}
212
+ >
213
+ {option.label}
214
+ </div>
215
+ ))}
216
+ </div>,
217
+ document.body
218
+ )
219
+ : null;
220
+
221
+ return (
222
+ <>
223
+ <input
224
+ ref={inputRef}
225
+ type="text"
226
+ value={searchText}
227
+ onChange={handleSearchChange}
228
+ onKeyDown={handleKeyDown}
229
+ onBlur={handleBlur}
230
+ autoComplete="off"
231
+ autoCorrect="off"
232
+ autoCapitalize="off"
233
+ spellCheck={false}
234
+ className={cn(
235
+ 'w-full border-0 bg-blue-50 focus:outline-none focus:ring-1 focus:ring-blue-500 rounded-sm',
236
+ compactMode ? 'text-xs' : 'text-sm'
237
+ )}
238
+ />
239
+ {dropdown}
240
+ </>
241
+ );
242
+ };
243
+
30
244
  const SpreadsheetCell: React.FC<SpreadsheetCellProps> = ({
31
245
  value,
32
246
  column,
@@ -41,6 +255,7 @@ const SpreadsheetCell: React.FC<SpreadsheetCellProps> = ({
41
255
  isRowSelected = false,
42
256
  isRowHovered = false,
43
257
  highlightColor,
258
+ isDuplicate = false,
44
259
  hasComments = false,
45
260
  unresolvedCommentCount = 0,
46
261
  isCopied = false,
@@ -49,7 +264,11 @@ const SpreadsheetCell: React.FC<SpreadsheetCellProps> = ({
49
264
  pinSide,
50
265
  leftOffset = 0,
51
266
  rightOffset = 0,
267
+ isOddRow = false,
268
+ resolvedWidth,
269
+ pinnedZIndex,
52
270
  onClick,
271
+ onDoubleClick,
53
272
  onMouseDown,
54
273
  onMouseEnter,
55
274
  onChange,
@@ -74,7 +293,8 @@ const SpreadsheetCell: React.FC<SpreadsheetCellProps> = ({
74
293
  if (isEditing) {
75
294
  if (column.type === 'select') {
76
295
  selectRef.current?.focus();
77
- } else {
296
+ } else if (column.type !== 'autocomplete') {
297
+ // autocomplete manages its own focus internally
78
298
  inputRef.current?.focus();
79
299
  inputRef.current?.select();
80
300
  }
@@ -96,8 +316,10 @@ const SpreadsheetCell: React.FC<SpreadsheetCellProps> = ({
96
316
  // Determine background color
97
317
  const getBackgroundColor = () => {
98
318
  if (highlightColor) return highlightColor;
319
+ if (isDuplicate) return 'rgb(254 202 202)'; // red-200 - duplicate highlight
99
320
  if (isRowSelected) return 'rgb(219 234 254)'; // blue-100
100
321
  if (isRowHovered) return 'rgb(243 244 246)'; // gray-100
322
+ if (isOddRow) return 'rgb(249 250 251)'; // gray-50 zebra stripe
101
323
  return 'white';
102
324
  };
103
325
 
@@ -142,6 +364,12 @@ const SpreadsheetCell: React.FC<SpreadsheetCellProps> = ({
142
364
  return typeof value === 'number' ? value.toLocaleString() : value;
143
365
  }
144
366
 
367
+ if (column.type === 'autocomplete') {
368
+ if (column.getOptionLabel) return column.getOptionLabel(value, row);
369
+ const match = column.autocompleteOptions?.find((o) => o.value === value);
370
+ return match ? match.label : String(value);
371
+ }
372
+
145
373
  return String(value);
146
374
  };
147
375
 
@@ -152,6 +380,18 @@ const SpreadsheetCell: React.FC<SpreadsheetCellProps> = ({
152
380
  return renderContent();
153
381
  }
154
382
 
383
+ if (column.type === 'autocomplete') {
384
+ return (
385
+ <AutocompleteEditor
386
+ value={localValue}
387
+ column={column}
388
+ compactMode={compactMode}
389
+ onConfirm={onConfirm}
390
+ onCancel={onCancel}
391
+ />
392
+ );
393
+ }
394
+
155
395
  if (column.type === 'select' && column.options) {
156
396
  return (
157
397
  <select
@@ -163,10 +403,11 @@ const SpreadsheetCell: React.FC<SpreadsheetCellProps> = ({
163
403
  onConfirm?.(newValue);
164
404
  }}
165
405
  onKeyDown={handleKeyDown}
166
- onBlur={() => onConfirm?.(localValue)}
406
+ onClick={(e) => e.stopPropagation()}
407
+ onMouseDown={(e) => e.stopPropagation()}
167
408
  className={cn(
168
409
  'w-full border-0 bg-blue-50 focus:outline-none focus:ring-1 focus:ring-blue-500 rounded-sm',
169
- compactMode ? 'text-[10px]' : 'text-xs'
410
+ compactMode ? 'text-xs' : 'text-sm'
170
411
  )}
171
412
  >
172
413
  {column.options.map((option) => (
@@ -201,7 +442,7 @@ const SpreadsheetCell: React.FC<SpreadsheetCellProps> = ({
201
442
  spellCheck={false}
202
443
  className={cn(
203
444
  'w-full border-0 bg-blue-50 focus:outline-none focus:ring-1 focus:ring-blue-500 rounded-sm',
204
- compactMode ? 'text-[10px]' : 'text-xs'
445
+ compactMode ? 'text-xs' : 'text-sm'
205
446
  )}
206
447
  />
207
448
  );
@@ -229,63 +470,54 @@ const SpreadsheetCell: React.FC<SpreadsheetCellProps> = ({
229
470
  }
230
471
  }
231
472
 
232
- // Build selection edge border styles
473
+ // Build selection edge styles using inset box-shadow to avoid layout shift
474
+ // (border-width changes cause column width recalculation in border-separate tables)
233
475
  const selectionBorderStyles: React.CSSProperties = {};
234
476
  if (isInSelection && selectionEdge) {
235
- const borderColor = 'rgb(59 130 246)'; // blue-500
236
- const borderWidth = '2px';
237
- if (selectionEdge.top) {
238
- selectionBorderStyles.borderTopColor = borderColor;
239
- selectionBorderStyles.borderTopWidth = borderWidth;
240
- }
241
- if (selectionEdge.right) {
242
- selectionBorderStyles.borderRightColor = borderColor;
243
- selectionBorderStyles.borderRightWidth = borderWidth;
244
- }
245
- if (selectionEdge.bottom) {
246
- selectionBorderStyles.borderBottomColor = borderColor;
247
- selectionBorderStyles.borderBottomWidth = borderWidth;
248
- }
249
- if (selectionEdge.left) {
250
- selectionBorderStyles.borderLeftColor = borderColor;
251
- selectionBorderStyles.borderLeftWidth = borderWidth;
477
+ const color = 'rgb(59 130 246)'; // blue-500
478
+ const w = '2px';
479
+ const shadows: string[] = [];
480
+ if (selectionEdge.top) shadows.push(`inset 0 ${w} 0 0 ${color}`);
481
+ if (selectionEdge.bottom) shadows.push(`inset 0 -${w} 0 0 ${color}`);
482
+ if (selectionEdge.left) shadows.push(`inset ${w} 0 0 0 ${color}`);
483
+ if (selectionEdge.right) shadows.push(`inset -${w} 0 0 0 ${color}`);
484
+ if (shadows.length > 0) {
485
+ selectionBorderStyles.boxShadow = shadows.join(', ');
252
486
  }
253
487
  }
254
488
 
255
489
  return (
256
490
  <td
257
491
  onClick={onClick}
492
+ onDoubleClick={onDoubleClick}
258
493
  onMouseDown={onMouseDown}
259
494
  onMouseEnter={onMouseEnter}
260
495
  onKeyDown={handleCellKeyDown}
261
496
  data-cell-id={`${rowId}-${column.id}`}
497
+ data-column-id={column.id}
262
498
  className={cn(
263
- 'border border-gray-200 group cursor-pointer transition-colors select-none',
264
- compactMode ? 'text-[10px]' : 'text-xs',
499
+ 'border border-gray-200 group cursor-pointer transition-colors select-none relative',
500
+ compactMode ? 'text-xs' : 'text-sm',
265
501
  cellPadding,
266
502
  column.align === 'right' && 'text-right',
267
503
  column.align === 'center' && 'text-center',
268
504
  isCopied && 'animate-pulse',
269
505
  isFocused && !isInSelection && 'ring-2 ring-blue-500 ring-inset',
270
506
  isInSelection && 'bg-blue-50',
271
- isPinned ? 'z-20' : 'z-0',
507
+ !isPinned && 'z-0',
272
508
  className
273
509
  )}
274
510
  style={{
275
511
  backgroundColor: isInSelection ? 'rgb(239 246 255)' : getBackgroundColor(),
276
- minWidth: column.minWidth || column.width,
277
- // Pinned columns must have a fixed width so sticky offsets stay correct.
278
- // Enforce MIN_PINNED_COLUMN_WIDTH so header actions always fit.
279
- ...(isPinned && {
280
- width: Math.max(
281
- column.minWidth || column.width || MIN_PINNED_COLUMN_WIDTH,
282
- MIN_PINNED_COLUMN_WIDTH
283
- ),
284
- maxWidth: Math.max(
285
- column.minWidth || column.width || MIN_PINNED_COLUMN_WIDTH,
286
- MIN_PINNED_COLUMN_WIDTH
287
- ),
512
+ // Always set explicit width to prevent layout shift on selection/re-render
513
+ ...(resolvedWidth ? {
514
+ width: resolvedWidth,
515
+ minWidth: resolvedWidth,
516
+ } : {
517
+ width: column.width || column.minWidth,
518
+ minWidth: column.minWidth || column.width,
288
519
  }),
520
+ ...(isPinned && pinnedZIndex !== undefined && { zIndex: pinnedZIndex }),
289
521
  ...positionStyles,
290
522
  ...selectionBorderStyles,
291
523
  }}
@@ -298,12 +530,16 @@ const SpreadsheetCell: React.FC<SpreadsheetCellProps> = ({
298
530
  <div
299
531
  className={cn(
300
532
  'flex-1 truncate',
301
- isEditable && 'cursor-text bg-blue-50/50 rounded'
533
+ isEditable && 'cursor-text bg-blue-50 rounded px-1'
302
534
  )}
303
535
  title={String(value ?? '')}
304
536
  >
305
537
  {renderContent()}
306
538
  </div>
539
+ {/* Dropdown indicator for select/autocomplete-type editable cells */}
540
+ {isEditable && (column.type === 'select' || column.type === 'autocomplete') && !isEditing && (
541
+ <HiChevronDown className="h-3 w-3 shrink-0 text-gray-400" />
542
+ )}
307
543
 
308
544
  {/* Action buttons - show on hover, except comment indicator which is always visible */}
309
545
  {/* Hide actions when cell is focused or in a selection */}
@@ -385,6 +621,7 @@ export const MemoizedSpreadsheetCell = memo(SpreadsheetCell, (prevProps, nextPro
385
621
  if (prevProps.isRowSelected !== nextProps.isRowSelected) return false;
386
622
  if (prevProps.isRowHovered !== nextProps.isRowHovered) return false;
387
623
  if (prevProps.highlightColor !== nextProps.highlightColor) return false;
624
+ if (prevProps.isDuplicate !== nextProps.isDuplicate) return false;
388
625
  if (prevProps.hasComments !== nextProps.hasComments) return false;
389
626
  if (prevProps.unresolvedCommentCount !== nextProps.unresolvedCommentCount) return false;
390
627
  if (prevProps.isCopied !== nextProps.isCopied) return false;
@@ -392,6 +629,7 @@ export const MemoizedSpreadsheetCell = memo(SpreadsheetCell, (prevProps, nextPro
392
629
  if (prevProps.leftOffset !== nextProps.leftOffset) return false;
393
630
  if (prevProps.rightOffset !== nextProps.rightOffset) return false;
394
631
  if (prevProps.compactMode !== nextProps.compactMode) return false;
632
+ if (prevProps.isOddRow !== nextProps.isOddRow) return false;
395
633
 
396
634
  // Check selection edge changes
397
635
  const prevEdge = prevProps.selectionEdge;