@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.
@@ -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={clearAllFilters}
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(col?.minWidth || col?.width || MIN_PINNED_COLUMN_WIDTH, MIN_PINNED_COLUMN_WIDTH);
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
- handleFilterChange(column.id, filter)
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(column.minWidth || column.width || MIN_PINNED_COLUMN_WIDTH, MIN_PINNED_COLUMN_WIDTH),
281
- maxWidth: Math.max(column.minWidth || column.width || MIN_PINNED_COLUMN_WIDTH, MIN_PINNED_COLUMN_WIDTH),
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(column.minWidth || column.width || MIN_PINNED_COLUMN_WIDTH, MIN_PINNED_COLUMN_WIDTH),
79
- maxWidth: Math.max(column.minWidth || column.width || MIN_PINNED_COLUMN_WIDTH, MIN_PINNED_COLUMN_WIDTH),
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,