@xcelsior/ui-spreadsheets 1.0.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.
- package/.storybook/main.ts +27 -0
- package/.storybook/preview.tsx +28 -0
- package/.turbo/turbo-build.log +22 -0
- package/CHANGELOG.md +9 -0
- package/biome.json +3 -0
- package/dist/index.d.mts +687 -0
- package/dist/index.d.ts +687 -0
- package/dist/index.js +3459 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +3417 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +51 -0
- package/postcss.config.js +5 -0
- package/src/components/ColorPickerPopover.tsx +73 -0
- package/src/components/ColumnHeaderActions.tsx +139 -0
- package/src/components/CommentModals.tsx +137 -0
- package/src/components/KeyboardShortcutsModal.tsx +119 -0
- package/src/components/RowIndexColumnHeader.tsx +70 -0
- package/src/components/Spreadsheet.stories.tsx +1146 -0
- package/src/components/Spreadsheet.tsx +1005 -0
- package/src/components/SpreadsheetCell.tsx +341 -0
- package/src/components/SpreadsheetFilterDropdown.tsx +341 -0
- package/src/components/SpreadsheetHeader.tsx +111 -0
- package/src/components/SpreadsheetSettingsModal.tsx +555 -0
- package/src/components/SpreadsheetToolbar.tsx +346 -0
- package/src/hooks/index.ts +40 -0
- package/src/hooks/useSpreadsheetComments.ts +132 -0
- package/src/hooks/useSpreadsheetFiltering.ts +379 -0
- package/src/hooks/useSpreadsheetHighlighting.ts +201 -0
- package/src/hooks/useSpreadsheetKeyboardShortcuts.ts +149 -0
- package/src/hooks/useSpreadsheetPinning.ts +203 -0
- package/src/hooks/useSpreadsheetUndoRedo.ts +167 -0
- package/src/index.ts +31 -0
- package/src/types.ts +612 -0
- package/src/utils.ts +16 -0
- package/tsconfig.json +30 -0
- package/tsup.config.ts +12 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
import { useState, useRef, useEffect } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
HiOutlineClipboardCopy,
|
|
5
|
+
HiOutlineClipboardCheck,
|
|
6
|
+
HiOutlineAnnotation,
|
|
7
|
+
HiOutlineChatAlt,
|
|
8
|
+
HiOutlinePencil,
|
|
9
|
+
} from 'react-icons/hi';
|
|
10
|
+
import { cn } from '../utils';
|
|
11
|
+
import type { SpreadsheetCellProps } from '../types';
|
|
12
|
+
|
|
13
|
+
const cellPaddingCompact = 'px-1.5 py-0.5';
|
|
14
|
+
const cellPaddingNormal = 'px-2 py-1';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* SpreadsheetCell component - A single cell in the spreadsheet table.
|
|
18
|
+
* Supports static display, inline editing, and various cell interactions.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```tsx
|
|
22
|
+
* <SpreadsheetCell
|
|
23
|
+
* value="John Doe"
|
|
24
|
+
* column={{ id: 'name', label: 'Name', editable: true }}
|
|
25
|
+
* row={rowData}
|
|
26
|
+
* rowIndex={0}
|
|
27
|
+
* rowId="1"
|
|
28
|
+
* isEditable={true}
|
|
29
|
+
* onClick={handleClick}
|
|
30
|
+
* onChange={handleChange}
|
|
31
|
+
* />
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export const SpreadsheetCell: React.FC<SpreadsheetCellProps> = ({
|
|
35
|
+
value,
|
|
36
|
+
column,
|
|
37
|
+
row,
|
|
38
|
+
rowIndex,
|
|
39
|
+
rowId: _rowId,
|
|
40
|
+
isEditable = false,
|
|
41
|
+
isEditing = false,
|
|
42
|
+
isFocused = false,
|
|
43
|
+
isRowSelected = false,
|
|
44
|
+
isRowHovered = false,
|
|
45
|
+
highlightColor,
|
|
46
|
+
hasComments = false,
|
|
47
|
+
unresolvedCommentCount = 0,
|
|
48
|
+
isCopied = false,
|
|
49
|
+
compactMode = false,
|
|
50
|
+
isPinned = false,
|
|
51
|
+
pinSide,
|
|
52
|
+
leftOffset = 0,
|
|
53
|
+
rightOffset = 0,
|
|
54
|
+
onClick,
|
|
55
|
+
onChange,
|
|
56
|
+
onConfirm,
|
|
57
|
+
onCancel,
|
|
58
|
+
onCopyDown,
|
|
59
|
+
onCopyToSelected,
|
|
60
|
+
onHighlight,
|
|
61
|
+
onAddComment,
|
|
62
|
+
onViewComments,
|
|
63
|
+
hasSelectedRows = false,
|
|
64
|
+
className,
|
|
65
|
+
}) => {
|
|
66
|
+
const [localValue, setLocalValue] = useState(value);
|
|
67
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
68
|
+
const selectRef = useRef<HTMLSelectElement>(null);
|
|
69
|
+
|
|
70
|
+
// Sync local value when prop value changes
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
setLocalValue(value);
|
|
73
|
+
}, [value]);
|
|
74
|
+
|
|
75
|
+
// Focus input when editing starts
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (isEditing) {
|
|
78
|
+
if (column.type === 'select') {
|
|
79
|
+
selectRef.current?.focus();
|
|
80
|
+
} else {
|
|
81
|
+
inputRef.current?.focus();
|
|
82
|
+
inputRef.current?.select();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}, [isEditing, column.type]);
|
|
86
|
+
|
|
87
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
88
|
+
if (e.key === 'Enter') {
|
|
89
|
+
e.preventDefault();
|
|
90
|
+
onConfirm?.();
|
|
91
|
+
} else if (e.key === 'Escape') {
|
|
92
|
+
e.preventDefault();
|
|
93
|
+
setLocalValue(value);
|
|
94
|
+
onChange?.(value);
|
|
95
|
+
onCancel?.();
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Determine background color
|
|
100
|
+
const getBackgroundColor = () => {
|
|
101
|
+
if (highlightColor) return highlightColor;
|
|
102
|
+
if (isRowSelected) return 'rgb(219 234 254)'; // blue-100
|
|
103
|
+
if (isRowHovered) return 'rgb(243 244 246)'; // gray-100
|
|
104
|
+
return 'white';
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Render cell content based on column type
|
|
108
|
+
const renderContent = () => {
|
|
109
|
+
if (column.render) {
|
|
110
|
+
return column.render(value, row, rowIndex);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (value === null || value === undefined || value === '') {
|
|
114
|
+
return <span className="text-gray-400">-</span>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (column.type === 'boolean') {
|
|
118
|
+
return value ? 'Yes' : 'No';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (column.type === 'number') {
|
|
122
|
+
return typeof value === 'number' ? value.toLocaleString() : value;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return String(value);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// Render editing input
|
|
129
|
+
const renderEditInput = () => {
|
|
130
|
+
if (column.type === 'select' && column.options) {
|
|
131
|
+
return (
|
|
132
|
+
<select
|
|
133
|
+
ref={selectRef}
|
|
134
|
+
value={localValue ?? ''}
|
|
135
|
+
onChange={(e) => {
|
|
136
|
+
setLocalValue(e.target.value);
|
|
137
|
+
onChange?.(e.target.value);
|
|
138
|
+
onConfirm?.();
|
|
139
|
+
}}
|
|
140
|
+
onKeyDown={handleKeyDown}
|
|
141
|
+
onBlur={() => onConfirm?.()}
|
|
142
|
+
className={cn(
|
|
143
|
+
'w-full border border-gray-300 rounded text-xs focus:outline-none focus:ring-1 focus:ring-blue-500',
|
|
144
|
+
compactMode ? 'px-1 py-0.5' : 'px-2 py-1'
|
|
145
|
+
)}
|
|
146
|
+
>
|
|
147
|
+
{column.options.map((option) => (
|
|
148
|
+
<option key={option} value={option}>
|
|
149
|
+
{option}
|
|
150
|
+
</option>
|
|
151
|
+
))}
|
|
152
|
+
</select>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<input
|
|
158
|
+
ref={inputRef}
|
|
159
|
+
type={column.type === 'number' ? 'number' : 'text'}
|
|
160
|
+
step={column.type === 'number' ? '0.01' : undefined}
|
|
161
|
+
value={localValue ?? ''}
|
|
162
|
+
onChange={(e) => {
|
|
163
|
+
const newValue =
|
|
164
|
+
column.type === 'number'
|
|
165
|
+
? e.target.value === ''
|
|
166
|
+
? ''
|
|
167
|
+
: parseFloat(e.target.value)
|
|
168
|
+
: e.target.value;
|
|
169
|
+
setLocalValue(newValue);
|
|
170
|
+
onChange?.(newValue);
|
|
171
|
+
}}
|
|
172
|
+
onKeyDown={handleKeyDown}
|
|
173
|
+
onBlur={() => onConfirm?.()}
|
|
174
|
+
className={cn(
|
|
175
|
+
'w-full border border-gray-300 rounded text-xs focus:outline-none focus:ring-1 focus:ring-blue-500 bg-yellow-50',
|
|
176
|
+
compactMode ? 'px-1 py-0.5' : 'px-2 py-1'
|
|
177
|
+
)}
|
|
178
|
+
/>
|
|
179
|
+
);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const cellPadding = compactMode ? cellPaddingCompact : cellPaddingNormal;
|
|
183
|
+
|
|
184
|
+
const handleCellKeyDown = (e: React.KeyboardEvent<HTMLTableCellElement>) => {
|
|
185
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
186
|
+
e.preventDefault();
|
|
187
|
+
onClick?.(e as unknown as React.MouseEvent);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// Build sticky positioning styles for pinned columns
|
|
192
|
+
const positionStyles: React.CSSProperties = {};
|
|
193
|
+
if (isPinned) {
|
|
194
|
+
if (pinSide === 'left') {
|
|
195
|
+
positionStyles.left = `${leftOffset}px`;
|
|
196
|
+
positionStyles.position = 'sticky';
|
|
197
|
+
} else if (pinSide === 'right') {
|
|
198
|
+
positionStyles.right = `${rightOffset}px`;
|
|
199
|
+
positionStyles.position = 'sticky';
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<td
|
|
205
|
+
onClick={onClick}
|
|
206
|
+
onKeyDown={handleCellKeyDown}
|
|
207
|
+
className={cn(
|
|
208
|
+
'border border-gray-200 text-xs group cursor-pointer transition-colors',
|
|
209
|
+
cellPadding,
|
|
210
|
+
column.align === 'right' && 'text-right',
|
|
211
|
+
column.align === 'center' && 'text-center',
|
|
212
|
+
isCopied && 'animate-pulse',
|
|
213
|
+
isFocused && 'ring-2 ring-blue-500 ring-inset',
|
|
214
|
+
isPinned ? 'z-20' : 'z-0',
|
|
215
|
+
className
|
|
216
|
+
)}
|
|
217
|
+
style={{
|
|
218
|
+
backgroundColor: getBackgroundColor(),
|
|
219
|
+
minWidth: column.minWidth || column.width,
|
|
220
|
+
...positionStyles,
|
|
221
|
+
}}
|
|
222
|
+
>
|
|
223
|
+
{isEditing ? (
|
|
224
|
+
renderEditInput()
|
|
225
|
+
) : (
|
|
226
|
+
<div className="flex items-center gap-1">
|
|
227
|
+
{/* Main content */}
|
|
228
|
+
<div
|
|
229
|
+
className={cn(
|
|
230
|
+
'flex-1 truncate',
|
|
231
|
+
isEditable &&
|
|
232
|
+
'cursor-text hover:bg-gray-50 px-0.5 rounded min-h-[18px] flex items-center bg-yellow-50/50'
|
|
233
|
+
)}
|
|
234
|
+
title={String(value ?? '')}
|
|
235
|
+
>
|
|
236
|
+
{renderContent()}
|
|
237
|
+
</div>
|
|
238
|
+
|
|
239
|
+
{/* Comment indicator */}
|
|
240
|
+
{hasComments && (
|
|
241
|
+
<button
|
|
242
|
+
type="button"
|
|
243
|
+
onClick={(e) => {
|
|
244
|
+
e.stopPropagation();
|
|
245
|
+
onViewComments?.();
|
|
246
|
+
}}
|
|
247
|
+
className="p-0.5 hover:bg-gray-100 rounded relative shrink-0"
|
|
248
|
+
title={`${unresolvedCommentCount} unresolved comment(s)`}
|
|
249
|
+
>
|
|
250
|
+
<HiOutlineChatAlt
|
|
251
|
+
className={cn(
|
|
252
|
+
'h-3 w-3',
|
|
253
|
+
unresolvedCommentCount > 0 ? 'text-amber-500' : 'text-gray-400'
|
|
254
|
+
)}
|
|
255
|
+
/>
|
|
256
|
+
{unresolvedCommentCount > 0 && (
|
|
257
|
+
<span className="absolute -top-1 -right-1 bg-amber-500 text-white text-[8px] rounded-full w-3 h-3 flex items-center justify-center">
|
|
258
|
+
{unresolvedCommentCount}
|
|
259
|
+
</span>
|
|
260
|
+
)}
|
|
261
|
+
</button>
|
|
262
|
+
)}
|
|
263
|
+
|
|
264
|
+
{/* Action buttons - show on hover */}
|
|
265
|
+
<div className="opacity-0 group-hover:opacity-100 flex items-center gap-0.5 transition-opacity shrink-0">
|
|
266
|
+
{/* Copy down button */}
|
|
267
|
+
{value !== null && value !== undefined && value !== '' && onCopyDown && (
|
|
268
|
+
<button
|
|
269
|
+
type="button"
|
|
270
|
+
onClick={(e) => {
|
|
271
|
+
e.stopPropagation();
|
|
272
|
+
onCopyDown();
|
|
273
|
+
}}
|
|
274
|
+
className="p-0.5 bg-gray-100 hover:bg-gray-200 rounded"
|
|
275
|
+
title="Copy value down to rows below"
|
|
276
|
+
>
|
|
277
|
+
<HiOutlineClipboardCopy className="h-2.5 w-2.5 text-gray-500" />
|
|
278
|
+
</button>
|
|
279
|
+
)}
|
|
280
|
+
|
|
281
|
+
{/* Copy to selected button */}
|
|
282
|
+
{hasSelectedRows &&
|
|
283
|
+
value !== null &&
|
|
284
|
+
value !== undefined &&
|
|
285
|
+
value !== '' &&
|
|
286
|
+
onCopyToSelected && (
|
|
287
|
+
<button
|
|
288
|
+
type="button"
|
|
289
|
+
onClick={(e) => {
|
|
290
|
+
e.stopPropagation();
|
|
291
|
+
onCopyToSelected();
|
|
292
|
+
}}
|
|
293
|
+
className="p-0.5 bg-green-100 hover:bg-green-200 rounded"
|
|
294
|
+
title="Copy to selected rows"
|
|
295
|
+
>
|
|
296
|
+
<HiOutlineClipboardCheck className="h-2.5 w-2.5 text-green-600" />
|
|
297
|
+
</button>
|
|
298
|
+
)}
|
|
299
|
+
|
|
300
|
+
{/* Highlight button */}
|
|
301
|
+
{onHighlight && (
|
|
302
|
+
<button
|
|
303
|
+
type="button"
|
|
304
|
+
onClick={(e) => {
|
|
305
|
+
e.stopPropagation();
|
|
306
|
+
onHighlight();
|
|
307
|
+
}}
|
|
308
|
+
className="p-0.5 hover:bg-gray-100 rounded"
|
|
309
|
+
title="Highlight cell"
|
|
310
|
+
>
|
|
311
|
+
<HiOutlinePencil
|
|
312
|
+
className={cn(
|
|
313
|
+
'h-2.5 w-2.5',
|
|
314
|
+
highlightColor ? 'text-amber-500' : 'text-gray-400'
|
|
315
|
+
)}
|
|
316
|
+
/>
|
|
317
|
+
</button>
|
|
318
|
+
)}
|
|
319
|
+
|
|
320
|
+
{/* Add comment button */}
|
|
321
|
+
{onAddComment && (
|
|
322
|
+
<button
|
|
323
|
+
type="button"
|
|
324
|
+
onClick={(e) => {
|
|
325
|
+
e.stopPropagation();
|
|
326
|
+
onAddComment();
|
|
327
|
+
}}
|
|
328
|
+
className="p-0.5 hover:bg-gray-100 rounded"
|
|
329
|
+
title="Add comment"
|
|
330
|
+
>
|
|
331
|
+
<HiOutlineAnnotation className="h-2.5 w-2.5 text-gray-400" />
|
|
332
|
+
</button>
|
|
333
|
+
)}
|
|
334
|
+
</div>
|
|
335
|
+
</div>
|
|
336
|
+
)}
|
|
337
|
+
</td>
|
|
338
|
+
);
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
SpreadsheetCell.displayName = 'SpreadsheetCell';
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { HiCheck, HiX } from 'react-icons/hi';
|
|
4
|
+
import { cn } from '../utils';
|
|
5
|
+
import type {
|
|
6
|
+
SpreadsheetFilterDropdownProps,
|
|
7
|
+
TextFilterOperator,
|
|
8
|
+
NumberFilterOperator,
|
|
9
|
+
DateFilterOperator,
|
|
10
|
+
SpreadsheetColumnFilter,
|
|
11
|
+
} from '../types';
|
|
12
|
+
|
|
13
|
+
/** Text filter operator labels */
|
|
14
|
+
const TEXT_OPERATORS: { value: TextFilterOperator; label: string }[] = [
|
|
15
|
+
{ value: 'contains', label: 'Contains' },
|
|
16
|
+
{ value: 'notContains', label: 'Does not contain' },
|
|
17
|
+
{ value: 'equals', label: 'Equals' },
|
|
18
|
+
{ value: 'notEquals', label: 'Does not equal' },
|
|
19
|
+
{ value: 'startsWith', label: 'Starts with' },
|
|
20
|
+
{ value: 'endsWith', label: 'Ends with' },
|
|
21
|
+
{ value: 'isEmpty', label: 'Is empty' },
|
|
22
|
+
{ value: 'isNotEmpty', label: 'Is not empty' },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/** Number filter operator labels */
|
|
26
|
+
const NUMBER_OPERATORS: { value: NumberFilterOperator; label: string }[] = [
|
|
27
|
+
{ value: 'equals', label: 'Equals' },
|
|
28
|
+
{ value: 'notEquals', label: 'Does not equal' },
|
|
29
|
+
{ value: 'greaterThan', label: 'Greater than' },
|
|
30
|
+
{ value: 'greaterThanOrEqual', label: 'Greater than or equal' },
|
|
31
|
+
{ value: 'lessThan', label: 'Less than' },
|
|
32
|
+
{ value: 'lessThanOrEqual', label: 'Less than or equal' },
|
|
33
|
+
{ value: 'between', label: 'Between' },
|
|
34
|
+
{ value: 'isEmpty', label: 'Is empty' },
|
|
35
|
+
{ value: 'isNotEmpty', label: 'Is not empty' },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
/** Date filter operator labels */
|
|
39
|
+
const DATE_OPERATORS: { value: DateFilterOperator; label: string }[] = [
|
|
40
|
+
{ value: 'equals', label: 'Equals' },
|
|
41
|
+
{ value: 'notEquals', label: 'Does not equal' },
|
|
42
|
+
{ value: 'before', label: 'Before' },
|
|
43
|
+
{ value: 'after', label: 'After' },
|
|
44
|
+
{ value: 'between', label: 'Between' },
|
|
45
|
+
{ value: 'today', label: 'Today' },
|
|
46
|
+
{ value: 'yesterday', label: 'Yesterday' },
|
|
47
|
+
{ value: 'thisWeek', label: 'This week' },
|
|
48
|
+
{ value: 'lastWeek', label: 'Last week' },
|
|
49
|
+
{ value: 'thisMonth', label: 'This month' },
|
|
50
|
+
{ value: 'lastMonth', label: 'Last month' },
|
|
51
|
+
{ value: 'thisYear', label: 'This year' },
|
|
52
|
+
{ value: 'isEmpty', label: 'Is empty' },
|
|
53
|
+
{ value: 'isNotEmpty', label: 'Is not empty' },
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* SpreadsheetFilterDropdown component - Condition-based filter dropdown for columns.
|
|
58
|
+
* Supports text conditions, number conditions, and date conditions.
|
|
59
|
+
*/
|
|
60
|
+
export const SpreadsheetFilterDropdown: React.FC<SpreadsheetFilterDropdownProps> = ({
|
|
61
|
+
column,
|
|
62
|
+
filter,
|
|
63
|
+
onFilterChange,
|
|
64
|
+
onClose,
|
|
65
|
+
className,
|
|
66
|
+
}) => {
|
|
67
|
+
const [textOperator, setTextOperator] = useState<TextFilterOperator>(
|
|
68
|
+
filter?.textCondition?.operator || 'contains'
|
|
69
|
+
);
|
|
70
|
+
const [textValue, setTextValue] = useState(filter?.textCondition?.value || '');
|
|
71
|
+
|
|
72
|
+
const [numberOperator, setNumberOperator] = useState<NumberFilterOperator>(
|
|
73
|
+
filter?.numberCondition?.operator || 'equals'
|
|
74
|
+
);
|
|
75
|
+
const [numberValue, setNumberValue] = useState(
|
|
76
|
+
filter?.numberCondition?.value?.toString() || ''
|
|
77
|
+
);
|
|
78
|
+
const [numberValueTo, setNumberValueTo] = useState(
|
|
79
|
+
filter?.numberCondition?.valueTo?.toString() || ''
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const [dateOperator, setDateOperator] = useState<DateFilterOperator>(
|
|
83
|
+
filter?.dateCondition?.operator || 'equals'
|
|
84
|
+
);
|
|
85
|
+
const [dateValue, setDateValue] = useState(filter?.dateCondition?.value || '');
|
|
86
|
+
const [dateValueTo, setDateValueTo] = useState(filter?.dateCondition?.valueTo || '');
|
|
87
|
+
|
|
88
|
+
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
89
|
+
|
|
90
|
+
const isNumeric = column.type === 'number';
|
|
91
|
+
const isDate = column.type === 'date';
|
|
92
|
+
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
95
|
+
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
96
|
+
onClose();
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
100
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
101
|
+
}, [onClose]);
|
|
102
|
+
|
|
103
|
+
const handleApplyFilter = () => {
|
|
104
|
+
let newFilter: SpreadsheetColumnFilter | undefined;
|
|
105
|
+
|
|
106
|
+
if (isNumeric) {
|
|
107
|
+
const needsValue = !['isEmpty', 'isNotEmpty'].includes(numberOperator);
|
|
108
|
+
if (needsValue && !numberValue) {
|
|
109
|
+
onFilterChange(undefined);
|
|
110
|
+
onClose();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
newFilter = {
|
|
114
|
+
numberCondition: {
|
|
115
|
+
operator: numberOperator,
|
|
116
|
+
value: numberValue ? parseFloat(numberValue) : undefined,
|
|
117
|
+
valueTo: numberValueTo ? parseFloat(numberValueTo) : undefined,
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
} else if (isDate) {
|
|
121
|
+
const needsValue = ![
|
|
122
|
+
'isEmpty',
|
|
123
|
+
'isNotEmpty',
|
|
124
|
+
'today',
|
|
125
|
+
'yesterday',
|
|
126
|
+
'thisWeek',
|
|
127
|
+
'lastWeek',
|
|
128
|
+
'thisMonth',
|
|
129
|
+
'lastMonth',
|
|
130
|
+
'thisYear',
|
|
131
|
+
].includes(dateOperator);
|
|
132
|
+
if (needsValue && !dateValue) {
|
|
133
|
+
onFilterChange(undefined);
|
|
134
|
+
onClose();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
newFilter = {
|
|
138
|
+
dateCondition: {
|
|
139
|
+
operator: dateOperator,
|
|
140
|
+
value: dateValue || undefined,
|
|
141
|
+
valueTo: dateValueTo || undefined,
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
} else {
|
|
145
|
+
const needsValue = !['isEmpty', 'isNotEmpty'].includes(textOperator);
|
|
146
|
+
if (needsValue && !textValue) {
|
|
147
|
+
onFilterChange(undefined);
|
|
148
|
+
onClose();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
newFilter = {
|
|
152
|
+
textCondition: {
|
|
153
|
+
operator: textOperator,
|
|
154
|
+
value: textValue || undefined,
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
onFilterChange(newFilter);
|
|
160
|
+
onClose();
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const handleClearFilter = () => {
|
|
164
|
+
setTextValue('');
|
|
165
|
+
setNumberValue('');
|
|
166
|
+
setNumberValueTo('');
|
|
167
|
+
setDateValue('');
|
|
168
|
+
setDateValueTo('');
|
|
169
|
+
onFilterChange(undefined);
|
|
170
|
+
onClose();
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const textNeedsValue = !['isEmpty', 'isNotEmpty'].includes(textOperator);
|
|
174
|
+
const numberNeedsValue = !['isEmpty', 'isNotEmpty'].includes(numberOperator);
|
|
175
|
+
const dateNeedsValue = ![
|
|
176
|
+
'isEmpty',
|
|
177
|
+
'isNotEmpty',
|
|
178
|
+
'today',
|
|
179
|
+
'yesterday',
|
|
180
|
+
'thisWeek',
|
|
181
|
+
'lastWeek',
|
|
182
|
+
'thisMonth',
|
|
183
|
+
'lastMonth',
|
|
184
|
+
'thisYear',
|
|
185
|
+
].includes(dateOperator);
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<div
|
|
189
|
+
ref={dropdownRef}
|
|
190
|
+
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]',
|
|
192
|
+
className
|
|
193
|
+
)}
|
|
194
|
+
onClick={(e) => e.stopPropagation()}
|
|
195
|
+
>
|
|
196
|
+
<div className="px-3 py-2 border-b border-gray-200 bg-gray-50">
|
|
197
|
+
<span className="text-xs font-medium text-gray-700">Filter: {column.label}</span>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<div className="p-3 space-y-3">
|
|
201
|
+
{isNumeric ? (
|
|
202
|
+
<>
|
|
203
|
+
<div>
|
|
204
|
+
<label className="text-xs text-gray-500 mb-1 block">Condition</label>
|
|
205
|
+
<select
|
|
206
|
+
value={numberOperator}
|
|
207
|
+
onChange={(e) =>
|
|
208
|
+
setNumberOperator(e.target.value as NumberFilterOperator)
|
|
209
|
+
}
|
|
210
|
+
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"
|
|
211
|
+
>
|
|
212
|
+
{NUMBER_OPERATORS.map((op) => (
|
|
213
|
+
<option key={op.value} value={op.value}>
|
|
214
|
+
{op.label}
|
|
215
|
+
</option>
|
|
216
|
+
))}
|
|
217
|
+
</select>
|
|
218
|
+
</div>
|
|
219
|
+
{numberNeedsValue && (
|
|
220
|
+
<div>
|
|
221
|
+
<label className="text-xs text-gray-500 mb-1 block">Value</label>
|
|
222
|
+
<input
|
|
223
|
+
type="number"
|
|
224
|
+
placeholder="Enter value"
|
|
225
|
+
value={numberValue}
|
|
226
|
+
onChange={(e) => setNumberValue(e.target.value)}
|
|
227
|
+
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
|
+
/>
|
|
229
|
+
</div>
|
|
230
|
+
)}
|
|
231
|
+
{numberOperator === 'between' && (
|
|
232
|
+
<div>
|
|
233
|
+
<label className="text-xs text-gray-500 mb-1 block">And</label>
|
|
234
|
+
<input
|
|
235
|
+
type="number"
|
|
236
|
+
placeholder="Enter end value"
|
|
237
|
+
value={numberValueTo}
|
|
238
|
+
onChange={(e) => setNumberValueTo(e.target.value)}
|
|
239
|
+
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
|
+
/>
|
|
241
|
+
</div>
|
|
242
|
+
)}
|
|
243
|
+
</>
|
|
244
|
+
) : isDate ? (
|
|
245
|
+
<>
|
|
246
|
+
<div>
|
|
247
|
+
<label className="text-xs text-gray-500 mb-1 block">Condition</label>
|
|
248
|
+
<select
|
|
249
|
+
value={dateOperator}
|
|
250
|
+
onChange={(e) =>
|
|
251
|
+
setDateOperator(e.target.value as DateFilterOperator)
|
|
252
|
+
}
|
|
253
|
+
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"
|
|
254
|
+
>
|
|
255
|
+
{DATE_OPERATORS.map((op) => (
|
|
256
|
+
<option key={op.value} value={op.value}>
|
|
257
|
+
{op.label}
|
|
258
|
+
</option>
|
|
259
|
+
))}
|
|
260
|
+
</select>
|
|
261
|
+
</div>
|
|
262
|
+
{dateNeedsValue && (
|
|
263
|
+
<div>
|
|
264
|
+
<label className="text-xs text-gray-500 mb-1 block">Date</label>
|
|
265
|
+
<input
|
|
266
|
+
type="date"
|
|
267
|
+
value={dateValue}
|
|
268
|
+
onChange={(e) => setDateValue(e.target.value)}
|
|
269
|
+
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
|
+
/>
|
|
271
|
+
</div>
|
|
272
|
+
)}
|
|
273
|
+
{dateOperator === 'between' && (
|
|
274
|
+
<div>
|
|
275
|
+
<label className="text-xs text-gray-500 mb-1 block">And</label>
|
|
276
|
+
<input
|
|
277
|
+
type="date"
|
|
278
|
+
value={dateValueTo}
|
|
279
|
+
onChange={(e) => setDateValueTo(e.target.value)}
|
|
280
|
+
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
|
+
/>
|
|
282
|
+
</div>
|
|
283
|
+
)}
|
|
284
|
+
</>
|
|
285
|
+
) : (
|
|
286
|
+
<>
|
|
287
|
+
<div>
|
|
288
|
+
<label className="text-xs text-gray-500 mb-1 block">Condition</label>
|
|
289
|
+
<select
|
|
290
|
+
value={textOperator}
|
|
291
|
+
onChange={(e) =>
|
|
292
|
+
setTextOperator(e.target.value as TextFilterOperator)
|
|
293
|
+
}
|
|
294
|
+
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"
|
|
295
|
+
>
|
|
296
|
+
{TEXT_OPERATORS.map((op) => (
|
|
297
|
+
<option key={op.value} value={op.value}>
|
|
298
|
+
{op.label}
|
|
299
|
+
</option>
|
|
300
|
+
))}
|
|
301
|
+
</select>
|
|
302
|
+
</div>
|
|
303
|
+
{textNeedsValue && (
|
|
304
|
+
<div>
|
|
305
|
+
<label className="text-xs text-gray-500 mb-1 block">Value</label>
|
|
306
|
+
<input
|
|
307
|
+
type="text"
|
|
308
|
+
placeholder="Enter text"
|
|
309
|
+
value={textValue}
|
|
310
|
+
onChange={(e) => setTextValue(e.target.value)}
|
|
311
|
+
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
|
+
/>
|
|
313
|
+
</div>
|
|
314
|
+
)}
|
|
315
|
+
</>
|
|
316
|
+
)}
|
|
317
|
+
</div>
|
|
318
|
+
|
|
319
|
+
<div className="p-2 border-t border-gray-200 flex gap-2">
|
|
320
|
+
<button
|
|
321
|
+
type="button"
|
|
322
|
+
onClick={handleClearFilter}
|
|
323
|
+
className="flex-1 px-3 py-1.5 text-xs text-red-600 hover:bg-red-50 border border-red-200 rounded transition-colors flex items-center justify-center gap-1"
|
|
324
|
+
>
|
|
325
|
+
<HiX className="h-3 w-3" />
|
|
326
|
+
Clear
|
|
327
|
+
</button>
|
|
328
|
+
<button
|
|
329
|
+
type="button"
|
|
330
|
+
onClick={handleApplyFilter}
|
|
331
|
+
className="flex-1 px-3 py-1.5 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors flex items-center justify-center gap-1"
|
|
332
|
+
>
|
|
333
|
+
<HiCheck className="h-3 w-3" />
|
|
334
|
+
Apply
|
|
335
|
+
</button>
|
|
336
|
+
</div>
|
|
337
|
+
</div>
|
|
338
|
+
);
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
SpreadsheetFilterDropdown.displayName = 'SpreadsheetFilterDropdown';
|