@xcelsior/ui-spreadsheets 1.1.14 → 1.1.15

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,
@@ -826,6 +829,11 @@ export function Spreadsheet<T extends Record<string, any>>({
826
829
  autoSave={spreadsheetSettings.autoSave}
827
830
  hasActiveFilters={hasActiveFilters}
828
831
  onClearFilters={clearAllFilters}
832
+ filters={filters}
833
+ columns={columns}
834
+ onClearFilter={(columnId) => handleFilterChange(columnId, undefined)}
835
+ showFiltersPanel={showFiltersPanel}
836
+ onToggleFiltersPanel={() => setShowFiltersPanel(!showFiltersPanel)}
829
837
  onZoomIn={() => setZoom((z) => Math.min(z + 10, 200))}
830
838
  onZoomOut={() => setZoom((z) => Math.max(z - 10, 50))}
831
839
  onZoomReset={() => setZoom(100)}
@@ -869,7 +877,12 @@ export function Spreadsheet<T extends Record<string, any>>({
869
877
  if (item.type === 'pinned-column') {
870
878
  const col = columns.find((c) => c.id === item.columnId);
871
879
  const isPinnedLeft = item.pinSide === 'left';
872
- const pinnedWidth = Math.max(col?.minWidth || col?.width || MIN_PINNED_COLUMN_WIDTH, MIN_PINNED_COLUMN_WIDTH);
880
+ const pinnedWidth = Math.max(
881
+ col?.minWidth ||
882
+ col?.width ||
883
+ MIN_PINNED_COLUMN_WIDTH,
884
+ MIN_PINNED_COLUMN_WIDTH
885
+ );
873
886
  return (
874
887
  <th
875
888
  key={`pinned-group-${item.columnId}`}
@@ -888,8 +901,6 @@ export function Spreadsheet<T extends Record<string, any>>({
888
901
  ? `${getColumnRightOffset(item.columnId)}px`
889
902
  : undefined,
890
903
  minWidth: pinnedWidth,
891
- width: pinnedWidth,
892
- maxWidth: pinnedWidth,
893
904
  }}
894
905
  />
895
906
  );
@@ -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,
@@ -1,6 +1,8 @@
1
1
  import React from 'react';
2
2
  import {
3
3
  HiCheck,
4
+ HiChevronDown,
5
+ HiChevronUp,
4
6
  HiCog,
5
7
  HiDotsVertical,
6
8
  HiFilter,
@@ -12,6 +14,7 @@ import {
12
14
  } from 'react-icons/hi';
13
15
  import { cn } from '../utils';
14
16
  import type { SpreadsheetToolbarProps } from '../types';
17
+ import { ActiveFiltersDisplay } from './ActiveFiltersDisplay';
15
18
 
16
19
  /**
17
20
  * SpreadsheetToolbar component - Top toolbar with zoom controls, undo/redo, filters, and actions.
@@ -60,6 +63,11 @@ export const SpreadsheetToolbar: React.FC<SpreadsheetToolbarProps> = ({
60
63
  onShowShortcuts,
61
64
  hasActiveFilters,
62
65
  onClearFilters,
66
+ filters,
67
+ columns,
68
+ onClearFilter,
69
+ showFiltersPanel,
70
+ onToggleFiltersPanel,
63
71
  className,
64
72
  }) => {
65
73
  const [showMoreMenu, setShowMoreMenu] = React.useState(false);
@@ -122,13 +130,30 @@ export const SpreadsheetToolbar: React.FC<SpreadsheetToolbarProps> = ({
122
130
  }
123
131
  };
124
132
 
133
+ // Count active filters
134
+ const activeFilterCount = filters
135
+ ? Object.values(filters).filter(
136
+ (f) =>
137
+ f.textCondition ||
138
+ f.numberCondition ||
139
+ f.dateCondition ||
140
+ f.text ||
141
+ (f.selectedValues && f.selectedValues.length > 0) ||
142
+ f.min !== undefined ||
143
+ f.max !== undefined ||
144
+ f.includeBlanks ||
145
+ f.excludeBlanks
146
+ ).length
147
+ : 0;
148
+
125
149
  return (
126
- <div
127
- className={cn(
128
- 'flex flex-wrap items-center justify-between gap-2 px-4 py-2 border-b border-gray-200 bg-white',
129
- className
130
- )}
131
- >
150
+ <div className="flex flex-col">
151
+ <div
152
+ className={cn(
153
+ 'flex flex-wrap items-center justify-between gap-2 px-4 py-2 border-b border-gray-200 bg-white',
154
+ className
155
+ )}
156
+ >
132
157
  {/* Left section: Primary actions */}
133
158
  <div className="flex items-center gap-2">
134
159
  {/* Undo/Redo buttons */}
@@ -212,22 +237,29 @@ export const SpreadsheetToolbar: React.FC<SpreadsheetToolbarProps> = ({
212
237
  </div>
213
238
  )}
214
239
 
215
- {/* Clear filters button */}
216
- {hasActiveFilters && onClearFilters && (
217
- <div className="flex items-center gap-2 px-2.5 py-1.5 bg-amber-500 text-white rounded">
240
+ {/* Show filters button */}
241
+ {hasActiveFilters && onToggleFiltersPanel && (
242
+ <button
243
+ type={'button'}
244
+ onClick={onToggleFiltersPanel}
245
+ className={cn(
246
+ 'flex items-center gap-2 px-2.5 py-1.5 rounded transition-colors',
247
+ showFiltersPanel
248
+ ? 'bg-amber-600 text-white hover:bg-amber-700'
249
+ : 'bg-amber-500 text-white hover:bg-amber-600'
250
+ )}
251
+ title={showFiltersPanel ? 'Hide active filters' : 'Show active filters'}
252
+ >
218
253
  <HiFilter className="h-3.5 w-3.5" />
219
254
  <span className="text-xs font-medium whitespace-nowrap">
220
- Filters active
255
+ {activeFilterCount} filter{activeFilterCount !== 1 ? 's' : ''} active
221
256
  </span>
222
- <button
223
- type={'button'}
224
- onClick={onClearFilters}
225
- className="p-0.5 hover:bg-amber-600 rounded"
226
- title="Clear all filters"
227
- >
228
- <HiX className="h-3 w-3" />
229
- </button>
230
- </div>
257
+ {showFiltersPanel ? (
258
+ <HiChevronUp className="h-3 w-3" />
259
+ ) : (
260
+ <HiChevronDown className="h-3 w-3" />
261
+ )}
262
+ </button>
231
263
  )}
232
264
 
233
265
  {/* Summary badge */}
@@ -350,6 +382,17 @@ export const SpreadsheetToolbar: React.FC<SpreadsheetToolbarProps> = ({
350
382
  )}
351
383
  </div>
352
384
  </div>
385
+ </div>
386
+
387
+ {/* Active filters panel */}
388
+ {showFiltersPanel && filters && columns && onClearFilter && onClearFilters && (
389
+ <ActiveFiltersDisplay
390
+ filters={filters}
391
+ columns={columns}
392
+ onClearFilter={onClearFilter}
393
+ onClearAllFilters={onClearFilters}
394
+ />
395
+ )}
353
396
  </div>
354
397
  );
355
398
  };
@@ -5,7 +5,7 @@ import type { SpreadsheetColumn, SpreadsheetColumnGroup } from '../types';
5
5
  export const ROW_INDEX_COLUMN_ID = '__row_index__';
6
6
  export const ROW_INDEX_COLUMN_WIDTH = 80;
7
7
  // Minimum width for any pinned column to ensure header actions (pin, filter, highlight icons) fit
8
- export const MIN_PINNED_COLUMN_WIDTH = 100;
8
+ export const MIN_PINNED_COLUMN_WIDTH = 150;
9
9
 
10
10
  export interface UseSpreadsheetPinningOptions<T> {
11
11
  columns: SpreadsheetColumn<T>[];
package/src/index.ts CHANGED
@@ -8,6 +8,8 @@ export { SpreadsheetFilterDropdown } from './components/SpreadsheetFilterDropdow
8
8
  export { SpreadsheetToolbar } from './components/SpreadsheetToolbar';
9
9
  export { SpreadsheetSettingsModal } from './components/SpreadsheetSettingsModal';
10
10
  export { RowContextMenu } from './components/RowContextMenu';
11
+ export { ActiveFiltersDisplay } from './components/ActiveFiltersDisplay';
12
+ export type { ActiveFiltersDisplayProps } from './components/ActiveFiltersDisplay';
11
13
  export type { SpreadsheetSettings } from './components/SpreadsheetSettingsModal';
12
14
 
13
15
  // Types
package/src/types.ts CHANGED
@@ -703,6 +703,16 @@ export interface SpreadsheetToolbarProps {
703
703
  hasActiveFilters?: boolean;
704
704
  /** Callback to clear all filters */
705
705
  onClearFilters?: () => void;
706
+ /** Current filters (for displaying active filters) */
707
+ filters?: Record<string, SpreadsheetColumnFilter>;
708
+ /** Column definitions (for displaying filter column names) */
709
+ columns?: SpreadsheetColumn[];
710
+ /** Callback to clear individual filter */
711
+ onClearFilter?: (columnId: string) => void;
712
+ /** Whether to show the active filters panel */
713
+ showFiltersPanel?: boolean;
714
+ /** Callback to toggle the active filters panel */
715
+ onToggleFiltersPanel?: () => void;
706
716
  /** Custom className */
707
717
  className?: string;
708
718
  }