@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.
- 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 -1155
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2023 -1047
- package/dist/index.mjs.map +1 -1
- package/dist/styles/globals.css +159 -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 -16
- package/src/components/SelectionSummaryBar.tsx +103 -0
- package/src/components/Spreadsheet.stories.tsx +396 -0
- package/src/components/Spreadsheet.tsx +233 -187
- 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,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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
293
|
+
const dropdownContent = (
|
|
188
294
|
<div
|
|
189
295
|
ref={dropdownRef}
|
|
190
296
|
className={cn(
|
|
191
|
-
'
|
|
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
|
|
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-
|
|
9
|
-
const cellPaddingNormal = 'px-2 py-
|
|
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
|
-
|
|
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
|
|
64
|
-
|
|
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
|
-
|
|
69
|
-
isPinned
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
+
}
|