@xcelsior/ui-spreadsheets 1.1.14 → 1.1.16
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/dist/index.d.mts +28 -1
- package/dist/index.d.ts +28 -1
- package/dist/index.js +659 -401
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +652 -395
- package/dist/index.mjs.map +1 -1
- package/dist/styles/globals.css +58 -0
- package/dist/styles/globals.css.map +1 -1
- package/package.json +1 -1
- package/src/components/ActiveFiltersDisplay.tsx +257 -0
- package/src/components/Spreadsheet.tsx +39 -5
- package/src/components/SpreadsheetCell.tsx +8 -2
- package/src/components/SpreadsheetHeader.tsx +8 -2
- package/src/components/SpreadsheetToolbar.tsx +249 -203
- package/src/hooks/useSpreadsheetPinning.ts +1 -1
- package/src/index.ts +2 -0
- package/src/types.ts +10 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
import { HiX } from 'react-icons/hi';
|
|
3
|
+
import { cn } from '../utils';
|
|
4
|
+
import type {
|
|
5
|
+
SpreadsheetColumn,
|
|
6
|
+
SpreadsheetColumnFilter,
|
|
7
|
+
TextFilterOperator,
|
|
8
|
+
NumberFilterOperator,
|
|
9
|
+
DateFilterOperator,
|
|
10
|
+
} from '../types';
|
|
11
|
+
|
|
12
|
+
/** Text filter operator labels */
|
|
13
|
+
const TEXT_OPERATOR_LABELS: Record<TextFilterOperator, string> = {
|
|
14
|
+
contains: 'contains',
|
|
15
|
+
notContains: 'does not contain',
|
|
16
|
+
equals: 'equals',
|
|
17
|
+
notEquals: 'does not equal',
|
|
18
|
+
startsWith: 'starts with',
|
|
19
|
+
endsWith: 'ends with',
|
|
20
|
+
isEmpty: 'is empty',
|
|
21
|
+
isNotEmpty: 'is not empty',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Number filter operator labels */
|
|
25
|
+
const NUMBER_OPERATOR_LABELS: Record<NumberFilterOperator, string> = {
|
|
26
|
+
equals: '=',
|
|
27
|
+
notEquals: '≠',
|
|
28
|
+
greaterThan: '>',
|
|
29
|
+
greaterThanOrEqual: '≥',
|
|
30
|
+
lessThan: '<',
|
|
31
|
+
lessThanOrEqual: '≤',
|
|
32
|
+
between: 'between',
|
|
33
|
+
isEmpty: 'is empty',
|
|
34
|
+
isNotEmpty: 'is not empty',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Date filter operator labels */
|
|
38
|
+
const DATE_OPERATOR_LABELS: Record<DateFilterOperator, string> = {
|
|
39
|
+
equals: 'is',
|
|
40
|
+
notEquals: 'is not',
|
|
41
|
+
before: 'before',
|
|
42
|
+
after: 'after',
|
|
43
|
+
between: 'between',
|
|
44
|
+
today: 'is today',
|
|
45
|
+
yesterday: 'is yesterday',
|
|
46
|
+
thisWeek: 'is this week',
|
|
47
|
+
lastWeek: 'is last week',
|
|
48
|
+
thisMonth: 'is this month',
|
|
49
|
+
lastMonth: 'is last month',
|
|
50
|
+
thisYear: 'is this year',
|
|
51
|
+
isEmpty: 'is empty',
|
|
52
|
+
isNotEmpty: 'is not empty',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export interface ActiveFiltersDisplayProps {
|
|
56
|
+
/** Current filters */
|
|
57
|
+
filters: Record<string, SpreadsheetColumnFilter>;
|
|
58
|
+
/** Column definitions */
|
|
59
|
+
columns: SpreadsheetColumn[];
|
|
60
|
+
/** Callback to clear individual filter */
|
|
61
|
+
onClearFilter: (columnId: string) => void;
|
|
62
|
+
/** Callback to clear all filters */
|
|
63
|
+
onClearAllFilters: () => void;
|
|
64
|
+
/** Custom className */
|
|
65
|
+
className?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Format a filter into a human-readable string
|
|
70
|
+
*/
|
|
71
|
+
function formatFilter(filter: SpreadsheetColumnFilter, _column: SpreadsheetColumn): string {
|
|
72
|
+
const parts: string[] = [];
|
|
73
|
+
|
|
74
|
+
// Text condition
|
|
75
|
+
if (filter.textCondition) {
|
|
76
|
+
const { operator, value } = filter.textCondition;
|
|
77
|
+
const label = TEXT_OPERATOR_LABELS[operator];
|
|
78
|
+
if (['isEmpty', 'isNotEmpty'].includes(operator)) {
|
|
79
|
+
parts.push(label);
|
|
80
|
+
} else if (value) {
|
|
81
|
+
parts.push(`${label} "${value}"`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Number condition
|
|
86
|
+
if (filter.numberCondition) {
|
|
87
|
+
const { operator, value, valueTo } = filter.numberCondition;
|
|
88
|
+
const label = NUMBER_OPERATOR_LABELS[operator];
|
|
89
|
+
if (['isEmpty', 'isNotEmpty'].includes(operator)) {
|
|
90
|
+
parts.push(label);
|
|
91
|
+
} else if (operator === 'between' && value !== undefined && valueTo !== undefined) {
|
|
92
|
+
parts.push(`${label} ${value} and ${valueTo}`);
|
|
93
|
+
} else if (value !== undefined) {
|
|
94
|
+
parts.push(`${label} ${value}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Date condition
|
|
99
|
+
if (filter.dateCondition) {
|
|
100
|
+
const { operator, value, valueTo } = filter.dateCondition;
|
|
101
|
+
const label = DATE_OPERATOR_LABELS[operator];
|
|
102
|
+
const noValueOperators = [
|
|
103
|
+
'isEmpty',
|
|
104
|
+
'isNotEmpty',
|
|
105
|
+
'today',
|
|
106
|
+
'yesterday',
|
|
107
|
+
'thisWeek',
|
|
108
|
+
'lastWeek',
|
|
109
|
+
'thisMonth',
|
|
110
|
+
'lastMonth',
|
|
111
|
+
'thisYear',
|
|
112
|
+
];
|
|
113
|
+
if (noValueOperators.includes(operator)) {
|
|
114
|
+
parts.push(label);
|
|
115
|
+
} else if (operator === 'between' && value && valueTo) {
|
|
116
|
+
parts.push(`${label} ${formatDate(value)} and ${formatDate(valueTo)}`);
|
|
117
|
+
} else if (value) {
|
|
118
|
+
parts.push(`${label} ${formatDate(value)}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Legacy filters
|
|
123
|
+
if (filter.text) {
|
|
124
|
+
parts.push(`contains "${filter.text}"`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (filter.selectedValues && filter.selectedValues.length > 0) {
|
|
128
|
+
if (filter.selectedValues.length === 1) {
|
|
129
|
+
parts.push(`is "${filter.selectedValues[0]}"`);
|
|
130
|
+
} else {
|
|
131
|
+
parts.push(`is one of ${filter.selectedValues.length} values`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (filter.min !== undefined && filter.max !== undefined) {
|
|
136
|
+
parts.push(`between ${filter.min} and ${filter.max}`);
|
|
137
|
+
} else if (filter.min !== undefined) {
|
|
138
|
+
parts.push(`≥ ${filter.min}`);
|
|
139
|
+
} else if (filter.max !== undefined) {
|
|
140
|
+
parts.push(`≤ ${filter.max}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (filter.includeBlanks) {
|
|
144
|
+
parts.push('includes blanks');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (filter.excludeBlanks) {
|
|
148
|
+
parts.push('excludes blanks');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return parts.join(', ') || 'filtered';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Format a date string for display
|
|
156
|
+
*/
|
|
157
|
+
function formatDate(dateStr: string): string {
|
|
158
|
+
try {
|
|
159
|
+
const date = new Date(dateStr);
|
|
160
|
+
return date.toLocaleDateString('en-US', {
|
|
161
|
+
month: 'short',
|
|
162
|
+
day: 'numeric',
|
|
163
|
+
year: 'numeric',
|
|
164
|
+
});
|
|
165
|
+
} catch {
|
|
166
|
+
return dateStr;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* ActiveFiltersDisplay component - Shows active filters as chips with clear buttons
|
|
172
|
+
*/
|
|
173
|
+
export const ActiveFiltersDisplay: React.FC<ActiveFiltersDisplayProps> = ({
|
|
174
|
+
filters,
|
|
175
|
+
columns,
|
|
176
|
+
onClearFilter,
|
|
177
|
+
onClearAllFilters,
|
|
178
|
+
className,
|
|
179
|
+
}) => {
|
|
180
|
+
const activeFilters = Object.entries(filters).filter(([_, filter]) => {
|
|
181
|
+
// Check if filter has any active conditions
|
|
182
|
+
return (
|
|
183
|
+
filter.textCondition ||
|
|
184
|
+
filter.numberCondition ||
|
|
185
|
+
filter.dateCondition ||
|
|
186
|
+
filter.text ||
|
|
187
|
+
(filter.selectedValues && filter.selectedValues.length > 0) ||
|
|
188
|
+
filter.min !== undefined ||
|
|
189
|
+
filter.max !== undefined ||
|
|
190
|
+
filter.includeBlanks ||
|
|
191
|
+
filter.excludeBlanks
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
if (activeFilters.length === 0) {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const getColumnLabel = (columnId: string): string => {
|
|
200
|
+
const column = columns.find((c) => c.id === columnId);
|
|
201
|
+
return column?.label || columnId;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const getColumn = (columnId: string): SpreadsheetColumn | undefined => {
|
|
205
|
+
return columns.find((c) => c.id === columnId);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
return (
|
|
209
|
+
<div
|
|
210
|
+
className={cn(
|
|
211
|
+
'flex flex-wrap items-center gap-2 px-4 py-2 bg-amber-50 border-b border-amber-200',
|
|
212
|
+
className
|
|
213
|
+
)}
|
|
214
|
+
>
|
|
215
|
+
<span className="text-xs font-medium text-amber-700 mr-1">Active filters:</span>
|
|
216
|
+
|
|
217
|
+
{activeFilters.map(([columnId, filter]) => {
|
|
218
|
+
const column = getColumn(columnId);
|
|
219
|
+
const filterDescription = column
|
|
220
|
+
? formatFilter(filter, column)
|
|
221
|
+
: formatFilter(filter, { id: columnId, label: columnId });
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<div
|
|
225
|
+
key={columnId}
|
|
226
|
+
className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white border border-amber-300 rounded-full shadow-sm"
|
|
227
|
+
>
|
|
228
|
+
<span className="text-xs font-medium text-gray-700">
|
|
229
|
+
{getColumnLabel(columnId)}
|
|
230
|
+
</span>
|
|
231
|
+
<span className="text-xs text-gray-500">{filterDescription}</span>
|
|
232
|
+
<button
|
|
233
|
+
type="button"
|
|
234
|
+
onClick={() => onClearFilter(columnId)}
|
|
235
|
+
className="p-0.5 hover:bg-amber-100 rounded-full transition-colors"
|
|
236
|
+
title={`Clear filter for ${getColumnLabel(columnId)}`}
|
|
237
|
+
>
|
|
238
|
+
<HiX className="h-3 w-3 text-amber-600 hover:text-amber-800" />
|
|
239
|
+
</button>
|
|
240
|
+
</div>
|
|
241
|
+
);
|
|
242
|
+
})}
|
|
243
|
+
|
|
244
|
+
{activeFilters.length > 1 && (
|
|
245
|
+
<button
|
|
246
|
+
type="button"
|
|
247
|
+
onClick={onClearAllFilters}
|
|
248
|
+
className="text-xs text-amber-700 hover:text-amber-900 underline ml-2 transition-colors"
|
|
249
|
+
>
|
|
250
|
+
Clear all
|
|
251
|
+
</button>
|
|
252
|
+
)}
|
|
253
|
+
</div>
|
|
254
|
+
);
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
ActiveFiltersDisplay.displayName = 'ActiveFiltersDisplay';
|
|
@@ -226,6 +226,9 @@ export function Spreadsheet<T extends Record<string, any>>({
|
|
|
226
226
|
// Modal state
|
|
227
227
|
const [showSettingsModal, setShowSettingsModal] = useState(false);
|
|
228
228
|
|
|
229
|
+
// Filters panel state
|
|
230
|
+
const [showFiltersPanel, setShowFiltersPanel] = useState(false);
|
|
231
|
+
|
|
229
232
|
// Undo/Redo hook
|
|
230
233
|
const {
|
|
231
234
|
canUndo,
|
|
@@ -283,6 +286,29 @@ export function Spreadsheet<T extends Record<string, any>>({
|
|
|
283
286
|
[controlledPageSize, controlledCurrentPage, onPageChange]
|
|
284
287
|
);
|
|
285
288
|
|
|
289
|
+
// Reset pagination to page 1 when filters change
|
|
290
|
+
const resetPaginationToFirstPage = useCallback(() => {
|
|
291
|
+
if (controlledCurrentPage === undefined) {
|
|
292
|
+
setInternalCurrentPage(1);
|
|
293
|
+
}
|
|
294
|
+
onPageChange?.(1, pageSize);
|
|
295
|
+
}, [controlledCurrentPage, onPageChange, pageSize]);
|
|
296
|
+
|
|
297
|
+
// Wrapper for handleFilterChange that resets pagination
|
|
298
|
+
const handleFilterChangeWithReset = useCallback(
|
|
299
|
+
(columnId: string, filter: Parameters<typeof handleFilterChange>[1]) => {
|
|
300
|
+
handleFilterChange(columnId, filter);
|
|
301
|
+
resetPaginationToFirstPage();
|
|
302
|
+
},
|
|
303
|
+
[handleFilterChange, resetPaginationToFirstPage]
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
// Wrapper for clearAllFilters that resets pagination
|
|
307
|
+
const clearAllFiltersWithReset = useCallback(() => {
|
|
308
|
+
clearAllFilters();
|
|
309
|
+
resetPaginationToFirstPage();
|
|
310
|
+
}, [clearAllFilters, resetPaginationToFirstPage]);
|
|
311
|
+
|
|
286
312
|
// Sync sortConfig to spreadsheetSettings when sorting changes
|
|
287
313
|
useEffect(() => {
|
|
288
314
|
setSpreadsheetSettings((prev) => ({
|
|
@@ -825,7 +851,12 @@ export function Spreadsheet<T extends Record<string, any>>({
|
|
|
825
851
|
saveStatus={saveStatus}
|
|
826
852
|
autoSave={spreadsheetSettings.autoSave}
|
|
827
853
|
hasActiveFilters={hasActiveFilters}
|
|
828
|
-
onClearFilters={
|
|
854
|
+
onClearFilters={clearAllFiltersWithReset}
|
|
855
|
+
filters={filters}
|
|
856
|
+
columns={columns}
|
|
857
|
+
onClearFilter={(columnId) => handleFilterChangeWithReset(columnId, undefined)}
|
|
858
|
+
showFiltersPanel={showFiltersPanel}
|
|
859
|
+
onToggleFiltersPanel={() => setShowFiltersPanel(!showFiltersPanel)}
|
|
829
860
|
onZoomIn={() => setZoom((z) => Math.min(z + 10, 200))}
|
|
830
861
|
onZoomOut={() => setZoom((z) => Math.max(z - 10, 50))}
|
|
831
862
|
onZoomReset={() => setZoom(100)}
|
|
@@ -869,7 +900,12 @@ export function Spreadsheet<T extends Record<string, any>>({
|
|
|
869
900
|
if (item.type === 'pinned-column') {
|
|
870
901
|
const col = columns.find((c) => c.id === item.columnId);
|
|
871
902
|
const isPinnedLeft = item.pinSide === 'left';
|
|
872
|
-
const pinnedWidth = Math.max(
|
|
903
|
+
const pinnedWidth = Math.max(
|
|
904
|
+
col?.minWidth ||
|
|
905
|
+
col?.width ||
|
|
906
|
+
MIN_PINNED_COLUMN_WIDTH,
|
|
907
|
+
MIN_PINNED_COLUMN_WIDTH
|
|
908
|
+
);
|
|
873
909
|
return (
|
|
874
910
|
<th
|
|
875
911
|
key={`pinned-group-${item.columnId}`}
|
|
@@ -888,8 +924,6 @@ export function Spreadsheet<T extends Record<string, any>>({
|
|
|
888
924
|
? `${getColumnRightOffset(item.columnId)}px`
|
|
889
925
|
: undefined,
|
|
890
926
|
minWidth: pinnedWidth,
|
|
891
|
-
width: pinnedWidth,
|
|
892
|
-
maxWidth: pinnedWidth,
|
|
893
927
|
}}
|
|
894
928
|
/>
|
|
895
929
|
);
|
|
@@ -1002,7 +1036,7 @@ export function Spreadsheet<T extends Record<string, any>>({
|
|
|
1002
1036
|
column={column}
|
|
1003
1037
|
filter={filters[column.id]}
|
|
1004
1038
|
onFilterChange={(filter) =>
|
|
1005
|
-
|
|
1039
|
+
handleFilterChangeWithReset(column.id, filter)
|
|
1006
1040
|
}
|
|
1007
1041
|
onClose={() => setActiveFilterColumn(null)}
|
|
1008
1042
|
/>
|
|
@@ -277,8 +277,14 @@ const SpreadsheetCell: React.FC<SpreadsheetCellProps> = ({
|
|
|
277
277
|
// Pinned columns must have a fixed width so sticky offsets stay correct.
|
|
278
278
|
// Enforce MIN_PINNED_COLUMN_WIDTH so header actions always fit.
|
|
279
279
|
...(isPinned && {
|
|
280
|
-
width: Math.max(
|
|
281
|
-
|
|
280
|
+
width: Math.max(
|
|
281
|
+
column.minWidth || column.width || MIN_PINNED_COLUMN_WIDTH,
|
|
282
|
+
MIN_PINNED_COLUMN_WIDTH
|
|
283
|
+
),
|
|
284
|
+
maxWidth: Math.max(
|
|
285
|
+
column.minWidth || column.width || MIN_PINNED_COLUMN_WIDTH,
|
|
286
|
+
MIN_PINNED_COLUMN_WIDTH
|
|
287
|
+
),
|
|
282
288
|
}),
|
|
283
289
|
...positionStyles,
|
|
284
290
|
...selectionBorderStyles,
|
|
@@ -75,8 +75,14 @@ export const SpreadsheetHeader: React.FC<
|
|
|
75
75
|
// Pinned columns must have a fixed width so sticky offsets stay correct.
|
|
76
76
|
// Enforce MIN_PINNED_COLUMN_WIDTH so header actions (pin/filter/highlight) always fit.
|
|
77
77
|
...(isPinned && {
|
|
78
|
-
width: Math.max(
|
|
79
|
-
|
|
78
|
+
width: Math.max(
|
|
79
|
+
column.minWidth || column.width || MIN_PINNED_COLUMN_WIDTH,
|
|
80
|
+
MIN_PINNED_COLUMN_WIDTH
|
|
81
|
+
),
|
|
82
|
+
maxWidth: Math.max(
|
|
83
|
+
column.minWidth || column.width || MIN_PINNED_COLUMN_WIDTH,
|
|
84
|
+
MIN_PINNED_COLUMN_WIDTH
|
|
85
|
+
),
|
|
80
86
|
}),
|
|
81
87
|
top: 0, // For sticky header
|
|
82
88
|
...positionStyles,
|