@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.
Files changed (37) hide show
  1. package/.storybook/main.ts +27 -0
  2. package/.storybook/preview.tsx +28 -0
  3. package/.turbo/turbo-build.log +22 -0
  4. package/CHANGELOG.md +9 -0
  5. package/biome.json +3 -0
  6. package/dist/index.d.mts +687 -0
  7. package/dist/index.d.ts +687 -0
  8. package/dist/index.js +3459 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/index.mjs +3417 -0
  11. package/dist/index.mjs.map +1 -0
  12. package/package.json +51 -0
  13. package/postcss.config.js +5 -0
  14. package/src/components/ColorPickerPopover.tsx +73 -0
  15. package/src/components/ColumnHeaderActions.tsx +139 -0
  16. package/src/components/CommentModals.tsx +137 -0
  17. package/src/components/KeyboardShortcutsModal.tsx +119 -0
  18. package/src/components/RowIndexColumnHeader.tsx +70 -0
  19. package/src/components/Spreadsheet.stories.tsx +1146 -0
  20. package/src/components/Spreadsheet.tsx +1005 -0
  21. package/src/components/SpreadsheetCell.tsx +341 -0
  22. package/src/components/SpreadsheetFilterDropdown.tsx +341 -0
  23. package/src/components/SpreadsheetHeader.tsx +111 -0
  24. package/src/components/SpreadsheetSettingsModal.tsx +555 -0
  25. package/src/components/SpreadsheetToolbar.tsx +346 -0
  26. package/src/hooks/index.ts +40 -0
  27. package/src/hooks/useSpreadsheetComments.ts +132 -0
  28. package/src/hooks/useSpreadsheetFiltering.ts +379 -0
  29. package/src/hooks/useSpreadsheetHighlighting.ts +201 -0
  30. package/src/hooks/useSpreadsheetKeyboardShortcuts.ts +149 -0
  31. package/src/hooks/useSpreadsheetPinning.ts +203 -0
  32. package/src/hooks/useSpreadsheetUndoRedo.ts +167 -0
  33. package/src/index.ts +31 -0
  34. package/src/types.ts +612 -0
  35. package/src/utils.ts +16 -0
  36. package/tsconfig.json +30 -0
  37. 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';