@xcelsior/ui-spreadsheets 1.1.13 → 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';
@@ -21,6 +21,7 @@ import {
21
21
  useSpreadsheetPinning,
22
22
  ROW_INDEX_COLUMN_WIDTH,
23
23
  ROW_INDEX_COLUMN_ID,
24
+ MIN_PINNED_COLUMN_WIDTH,
24
25
  } from '../hooks/useSpreadsheetPinning';
25
26
  import { useSpreadsheetComments } from '../hooks/useSpreadsheetComments';
26
27
  import { useSpreadsheetUndoRedo } from '../hooks/useSpreadsheetUndoRedo';
@@ -225,6 +226,9 @@ export function Spreadsheet<T extends Record<string, any>>({
225
226
  // Modal state
226
227
  const [showSettingsModal, setShowSettingsModal] = useState(false);
227
228
 
229
+ // Filters panel state
230
+ const [showFiltersPanel, setShowFiltersPanel] = useState(false);
231
+
228
232
  // Undo/Redo hook
229
233
  const {
230
234
  canUndo,
@@ -825,6 +829,11 @@ export function Spreadsheet<T extends Record<string, any>>({
825
829
  autoSave={spreadsheetSettings.autoSave}
826
830
  hasActiveFilters={hasActiveFilters}
827
831
  onClearFilters={clearAllFilters}
832
+ filters={filters}
833
+ columns={columns}
834
+ onClearFilter={(columnId) => handleFilterChange(columnId, undefined)}
835
+ showFiltersPanel={showFiltersPanel}
836
+ onToggleFiltersPanel={() => setShowFiltersPanel(!showFiltersPanel)}
828
837
  onZoomIn={() => setZoom((z) => Math.min(z + 10, 200))}
829
838
  onZoomOut={() => setZoom((z) => Math.max(z - 10, 50))}
830
839
  onZoomReset={() => setZoom(100)}
@@ -868,6 +877,12 @@ export function Spreadsheet<T extends Record<string, any>>({
868
877
  if (item.type === 'pinned-column') {
869
878
  const col = columns.find((c) => c.id === item.columnId);
870
879
  const isPinnedLeft = item.pinSide === 'left';
880
+ const pinnedWidth = Math.max(
881
+ col?.minWidth ||
882
+ col?.width ||
883
+ MIN_PINNED_COLUMN_WIDTH,
884
+ MIN_PINNED_COLUMN_WIDTH
885
+ );
871
886
  return (
872
887
  <th
873
888
  key={`pinned-group-${item.columnId}`}
@@ -885,7 +900,7 @@ export function Spreadsheet<T extends Record<string, any>>({
885
900
  right: !isPinnedLeft
886
901
  ? `${getColumnRightOffset(item.columnId)}px`
887
902
  : undefined,
888
- minWidth: col?.minWidth || col?.width,
903
+ minWidth: pinnedWidth,
889
904
  }}
890
905
  />
891
906
  );
@@ -4,6 +4,7 @@ import { AiFillHighlight } from 'react-icons/ai';
4
4
  import { FaComment, FaRegComment } from 'react-icons/fa';
5
5
  import { cn } from '../utils';
6
6
  import type { SpreadsheetCellProps } from '../types';
7
+ import { MIN_PINNED_COLUMN_WIDTH } from '../hooks/useSpreadsheetPinning';
7
8
 
8
9
  const cellPaddingCompact = 'px-1 py-px';
9
10
  const cellPaddingNormal = 'px-2 py-1';
@@ -273,6 +274,18 @@ const SpreadsheetCell: React.FC<SpreadsheetCellProps> = ({
273
274
  style={{
274
275
  backgroundColor: isInSelection ? 'rgb(239 246 255)' : getBackgroundColor(),
275
276
  minWidth: column.minWidth || column.width,
277
+ // Pinned columns must have a fixed width so sticky offsets stay correct.
278
+ // Enforce MIN_PINNED_COLUMN_WIDTH so header actions always fit.
279
+ ...(isPinned && {
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
+ ),
288
+ }),
276
289
  ...positionStyles,
277
290
  ...selectionBorderStyles,
278
291
  }}
@@ -3,6 +3,7 @@ import { HiChevronDown, HiChevronUp } from 'react-icons/hi';
3
3
  import { cn } from '../utils';
4
4
  import type { SpreadsheetHeaderProps } from '../types';
5
5
  import { ColumnHeaderActions } from './ColumnHeaderActions';
6
+ import { MIN_PINNED_COLUMN_WIDTH } from '../hooks/useSpreadsheetPinning';
6
7
 
7
8
  const cellPaddingCompact = 'px-1 py-0.5';
8
9
  const cellPaddingNormal = 'px-2 py-1.5';
@@ -71,6 +72,18 @@ export const SpreadsheetHeader: React.FC<
71
72
  style={{
72
73
  backgroundColor: highlightColor || 'rgb(243 244 246)', // gray-100
73
74
  minWidth: column.minWidth || column.width,
75
+ // Pinned columns must have a fixed width so sticky offsets stay correct.
76
+ // Enforce MIN_PINNED_COLUMN_WIDTH so header actions (pin/filter/highlight) always fit.
77
+ ...(isPinned && {
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
+ ),
86
+ }),
74
87
  top: 0, // For sticky header
75
88
  ...positionStyles,
76
89
  }}
@@ -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
  };
@@ -4,6 +4,8 @@ import type { SpreadsheetColumn, SpreadsheetColumnGroup } from '../types';
4
4
  // Special column ID for row index
5
5
  export const ROW_INDEX_COLUMN_ID = '__row_index__';
6
6
  export const ROW_INDEX_COLUMN_WIDTH = 80;
7
+ // Minimum width for any pinned column to ensure header actions (pin, filter, highlight icons) fit
8
+ export const MIN_PINNED_COLUMN_WIDTH = 150;
7
9
 
8
10
  export interface UseSpreadsheetPinningOptions<T> {
9
11
  columns: SpreadsheetColumn<T>[];
@@ -178,8 +180,10 @@ export function useSpreadsheetPinning<T>({
178
180
  let offset = baseOffset;
179
181
  for (let i = 0; i < index; i++) {
180
182
  const col = columns.find((c) => c.id === pinnedLeft[i]);
181
- // Use minWidth || width to match the rendered cell width
182
- offset += col?.minWidth || col?.width || 100;
183
+ // Pinned columns are clamped to at least MIN_PINNED_COLUMN_WIDTH
184
+ // so that header actions (pin, filter, highlight icons) always fit
185
+ const configuredWidth = col?.minWidth || col?.width || MIN_PINNED_COLUMN_WIDTH;
186
+ offset += Math.max(configuredWidth, MIN_PINNED_COLUMN_WIDTH);
183
187
  }
184
188
  return offset;
185
189
  },
@@ -218,7 +222,8 @@ export function useSpreadsheetPinning<T>({
218
222
  let offset = 0;
219
223
  for (let i = pinnedRight.length - 1; i > index; i--) {
220
224
  const col = columns.find((c) => c.id === pinnedRight[i]);
221
- offset += col?.minWidth || col?.width || 100;
225
+ const configuredWidth = col?.minWidth || col?.width || MIN_PINNED_COLUMN_WIDTH;
226
+ offset += Math.max(configuredWidth, MIN_PINNED_COLUMN_WIDTH);
222
227
  }
223
228
  return offset;
224
229
  },
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
  }