@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.
- package/.omc/state/agent-replay-0cead415-b3bd-40fd-b199-47371946c4db.jsonl +25 -0
- package/.omc/state/idle-notif-cooldown.json +3 -0
- package/.omc/state/last-tool-error.json +7 -0
- package/.omc/state/mission-state.json +179 -0
- package/.omc/state/subagent-tracking.json +116 -0
- package/.turbo/turbo-build.log +28 -28
- package/.turbo/turbo-lint.log +140 -0
- package/dist/index.d.mts +94 -4
- package/dist/index.d.ts +94 -4
- package/dist/index.js +2133 -1156
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2023 -1048
- package/dist/index.mjs.map +1 -1
- package/dist/styles/globals.css +156 -16
- package/dist/styles/globals.css.map +1 -1
- package/package.json +1 -1
- package/plans/20260330-1230-spreadsheet-features/phase-01-types-and-duplicates-hook.md +73 -0
- package/plans/20260330-1230-spreadsheet-features/phase-02-filter-dropdown-portal.md +90 -0
- package/plans/20260330-1230-spreadsheet-features/phase-03-header-overflow-menu.md +101 -0
- package/plans/20260330-1230-spreadsheet-features/phase-04-integration.md +193 -0
- package/plans/20260330-1230-spreadsheet-features/plan.md +59 -0
- package/src/components/ColorPickerPopover.tsx +77 -32
- package/src/components/ColumnHeaderActions.tsx +241 -1
- package/src/components/RowIndexColumnHeader.tsx +13 -17
- package/src/components/SelectionSummaryBar.tsx +103 -0
- package/src/components/Spreadsheet.stories.tsx +254 -0
- package/src/components/Spreadsheet.tsx +234 -189
- package/src/components/SpreadsheetCell.tsx +280 -42
- package/src/components/SpreadsheetFilterDropdown.tsx +178 -13
- package/src/components/SpreadsheetHeader.tsx +79 -24
- package/src/components/SpreadsheetSettingsModal.tsx +4 -0
- package/src/hooks/useSpreadsheetColumnResize.ts +143 -0
- package/src/hooks/useSpreadsheetDuplicates.ts +149 -0
- package/src/hooks/useSpreadsheetFiltering.ts +18 -1
- package/src/hooks/useSpreadsheetHighlighting.ts +23 -3
- package/src/hooks/useSpreadsheetKeyboardShortcuts.ts +16 -0
- package/src/hooks/useSpreadsheetPinning.ts +148 -134
- package/src/hooks/useSpreadsheetSelection.ts +10 -22
- package/src/hooks/useSpreadsheetSummary.ts +68 -0
- package/src/index.ts +4 -1
- package/src/styles/globals.css +51 -0
- package/src/types.ts +50 -2
- package/storybook-static/assets/Color-YHDXOIA2-CtQurLnT.js +1 -0
- package/storybook-static/assets/DocsRenderer-CFRXHY34-oxrW8Hvo.js +575 -0
- package/storybook-static/assets/Spreadsheet.stories-DvhhzuK4.js +1357 -0
- package/storybook-static/assets/chunk-XP5HYGXS-BpfKkqn7.js +1 -0
- package/storybook-static/assets/entry-preview-CkBGHCAN.js +2 -0
- package/storybook-static/assets/entry-preview-docs-ugJb6pa8.js +46 -0
- package/storybook-static/assets/iframe-CPp2u3vg.js +211 -0
- package/storybook-static/assets/index-BB9bPxRC.js +24 -0
- package/storybook-static/assets/index-BQFlzFLk.js +9 -0
- package/storybook-static/assets/index-CtvPRVHf.js +9 -0
- package/storybook-static/assets/index-DgH-xKnr.js +11 -0
- package/storybook-static/assets/index-DrFu-skq.js +6 -0
- package/storybook-static/assets/index-DrdPSA1J.js +240 -0
- package/storybook-static/assets/index-DzFBShOR.js +20 -0
- package/storybook-static/assets/index-v-1boR4t.js +1 -0
- package/storybook-static/assets/preview-B8lJiyuQ.js +34 -0
- package/storybook-static/assets/preview-BBWR9nbA.js +1 -0
- package/storybook-static/assets/preview-BWzBA1C2.js +396 -0
- package/storybook-static/assets/preview-Bm0S-uxO.css +1 -0
- package/storybook-static/assets/preview-CvbIS5ZJ.js +1 -0
- package/storybook-static/assets/preview-DD_OYowb.js +1 -0
- package/storybook-static/assets/preview-DGUiP6tS.js +7 -0
- package/storybook-static/assets/preview-DHQbi4pV.js +1 -0
- package/storybook-static/assets/preview-DwI0w3cI.js +1 -0
- package/storybook-static/assets/preview-DyR7iiFG.js +1 -0
- package/storybook-static/assets/preview-zxZ6Be2V.js +2 -0
- package/storybook-static/assets/react-18-Pj8skaX9.js +1 -0
- package/storybook-static/assets/test-utils-quxJ1Z79.js +9 -0
- package/storybook-static/favicon.svg +1 -0
- package/storybook-static/iframe.html +666 -0
- package/storybook-static/index.html +177 -0
- package/storybook-static/index.json +1 -0
- package/storybook-static/nunito-sans-bold-italic.woff2 +0 -0
- package/storybook-static/nunito-sans-bold.woff2 +0 -0
- package/storybook-static/nunito-sans-italic.woff2 +0 -0
- package/storybook-static/nunito-sans-regular.woff2 +0 -0
- package/storybook-static/project.json +1 -0
- package/storybook-static/sb-addons/essentials-actions-3/manager-bundle.js +3 -0
- package/storybook-static/sb-addons/essentials-backgrounds-5/manager-bundle.js +12 -0
- package/storybook-static/sb-addons/essentials-controls-2/manager-bundle.js +405 -0
- package/storybook-static/sb-addons/essentials-docs-4/manager-bundle.js +245 -0
- package/storybook-static/sb-addons/essentials-measure-8/manager-bundle.js +3 -0
- package/storybook-static/sb-addons/essentials-outline-9/manager-bundle.js +3 -0
- package/storybook-static/sb-addons/essentials-toolbars-7/manager-bundle.js +3 -0
- package/storybook-static/sb-addons/essentials-viewport-6/manager-bundle.js +3 -0
- package/storybook-static/sb-addons/interactions-10/manager-bundle.js +222 -0
- package/storybook-static/sb-addons/links-1/manager-bundle.js +3 -0
- package/storybook-static/sb-addons/storybook-core-core-server-presets-0/common-manager-bundle.js +3 -0
- package/storybook-static/sb-common-assets/favicon.svg +1 -0
- package/storybook-static/sb-common-assets/nunito-sans-bold-italic.woff2 +0 -0
- package/storybook-static/sb-common-assets/nunito-sans-bold.woff2 +0 -0
- package/storybook-static/sb-common-assets/nunito-sans-italic.woff2 +0 -0
- package/storybook-static/sb-common-assets/nunito-sans-regular.woff2 +0 -0
- package/storybook-static/sb-manager/globals-module-info.js +1052 -0
- package/storybook-static/sb-manager/globals-runtime.js +42127 -0
- package/storybook-static/sb-manager/globals.js +48 -0
- 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-
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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
|
|
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
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
241
|
-
if (selectionEdge.right) {
|
|
242
|
-
|
|
243
|
-
selectionBorderStyles.
|
|
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-
|
|
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
|
|
507
|
+
!isPinned && 'z-0',
|
|
272
508
|
className
|
|
273
509
|
)}
|
|
274
510
|
style={{
|
|
275
511
|
backgroundColor: isInSelection ? 'rgb(239 246 255)' : getBackgroundColor(),
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|
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;
|