@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.
- package/.storybook/main.ts +27 -0
- package/.storybook/preview.tsx +28 -0
- package/.turbo/turbo-build.log +22 -0
- package/CHANGELOG.md +9 -0
- package/biome.json +3 -0
- package/dist/index.d.mts +687 -0
- package/dist/index.d.ts +687 -0
- package/dist/index.js +3459 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +3417 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +51 -0
- package/postcss.config.js +5 -0
- package/src/components/ColorPickerPopover.tsx +73 -0
- package/src/components/ColumnHeaderActions.tsx +139 -0
- package/src/components/CommentModals.tsx +137 -0
- package/src/components/KeyboardShortcutsModal.tsx +119 -0
- package/src/components/RowIndexColumnHeader.tsx +70 -0
- package/src/components/Spreadsheet.stories.tsx +1146 -0
- package/src/components/Spreadsheet.tsx +1005 -0
- package/src/components/SpreadsheetCell.tsx +341 -0
- package/src/components/SpreadsheetFilterDropdown.tsx +341 -0
- package/src/components/SpreadsheetHeader.tsx +111 -0
- package/src/components/SpreadsheetSettingsModal.tsx +555 -0
- package/src/components/SpreadsheetToolbar.tsx +346 -0
- package/src/hooks/index.ts +40 -0
- package/src/hooks/useSpreadsheetComments.ts +132 -0
- package/src/hooks/useSpreadsheetFiltering.ts +379 -0
- package/src/hooks/useSpreadsheetHighlighting.ts +201 -0
- package/src/hooks/useSpreadsheetKeyboardShortcuts.ts +149 -0
- package/src/hooks/useSpreadsheetPinning.ts +203 -0
- package/src/hooks/useSpreadsheetUndoRedo.ts +167 -0
- package/src/index.ts +31 -0
- package/src/types.ts +612 -0
- package/src/utils.ts +16 -0
- package/tsconfig.json +30 -0
- package/tsup.config.ts +12 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
2
|
+
import type { SpreadsheetColumn, SpreadsheetColumnFilter, SpreadsheetSortConfig } from '../types';
|
|
3
|
+
import { isBlankValue } from '../utils';
|
|
4
|
+
|
|
5
|
+
export interface UseSpreadsheetFilteringOptions<T> {
|
|
6
|
+
data: T[];
|
|
7
|
+
columns: SpreadsheetColumn<T>[];
|
|
8
|
+
onFilterChange?: (filters: Record<string, SpreadsheetColumnFilter>) => void;
|
|
9
|
+
onSortChange?: (sortConfig: SpreadsheetSortConfig | null) => void;
|
|
10
|
+
/**
|
|
11
|
+
* Enable server-side mode. When true, filtering and sorting are skipped
|
|
12
|
+
* and data is returned as-is (expecting server to handle these operations).
|
|
13
|
+
* @default false
|
|
14
|
+
*/
|
|
15
|
+
serverSide?: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Controlled filters state. When provided, filters become controlled by parent.
|
|
18
|
+
* Use with serverSide mode for server-side filtering.
|
|
19
|
+
*/
|
|
20
|
+
controlledFilters?: Record<string, SpreadsheetColumnFilter>;
|
|
21
|
+
/**
|
|
22
|
+
* Controlled sort configuration. When provided, sorting becomes controlled by parent.
|
|
23
|
+
* Use with serverSide mode for server-side sorting.
|
|
24
|
+
*/
|
|
25
|
+
controlledSortConfig?: SpreadsheetSortConfig | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface UseSpreadsheetFilteringReturn<T> {
|
|
29
|
+
filters: Record<string, SpreadsheetColumnFilter>;
|
|
30
|
+
sortConfig: SpreadsheetSortConfig | null;
|
|
31
|
+
filteredData: T[];
|
|
32
|
+
activeFilterColumn: string | null;
|
|
33
|
+
setActiveFilterColumn: (columnId: string | null) => void;
|
|
34
|
+
handleFilterChange: (columnId: string, filter: SpreadsheetColumnFilter | undefined) => void;
|
|
35
|
+
handleSort: (columnId: string) => void;
|
|
36
|
+
clearAllFilters: () => void;
|
|
37
|
+
hasActiveFilters: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function useSpreadsheetFiltering<T extends Record<string, any>>({
|
|
41
|
+
data,
|
|
42
|
+
columns,
|
|
43
|
+
onFilterChange,
|
|
44
|
+
onSortChange,
|
|
45
|
+
serverSide = false,
|
|
46
|
+
controlledFilters,
|
|
47
|
+
controlledSortConfig,
|
|
48
|
+
}: UseSpreadsheetFilteringOptions<T>): UseSpreadsheetFilteringReturn<T> {
|
|
49
|
+
// Internal state for uncontrolled mode
|
|
50
|
+
const [internalFilters, setInternalFilters] = useState<Record<string, SpreadsheetColumnFilter>>(
|
|
51
|
+
{}
|
|
52
|
+
);
|
|
53
|
+
const [internalSortConfig, setInternalSortConfig] = useState<SpreadsheetSortConfig | null>(
|
|
54
|
+
null
|
|
55
|
+
);
|
|
56
|
+
const [activeFilterColumn, setActiveFilterColumn] = useState<string | null>(null);
|
|
57
|
+
|
|
58
|
+
// Use controlled state if provided, otherwise use internal state
|
|
59
|
+
const filters = controlledFilters ?? internalFilters;
|
|
60
|
+
const sortConfig =
|
|
61
|
+
controlledSortConfig !== undefined ? controlledSortConfig : internalSortConfig;
|
|
62
|
+
|
|
63
|
+
// Helper function to apply text condition filter
|
|
64
|
+
const applyTextCondition = useCallback(
|
|
65
|
+
(value: any, condition: { operator: string; value?: string }): boolean => {
|
|
66
|
+
const strValue = String(value ?? '').toLowerCase();
|
|
67
|
+
const filterValue = (condition.value ?? '').toLowerCase();
|
|
68
|
+
|
|
69
|
+
switch (condition.operator) {
|
|
70
|
+
case 'contains':
|
|
71
|
+
return strValue.includes(filterValue);
|
|
72
|
+
case 'notContains':
|
|
73
|
+
return !strValue.includes(filterValue);
|
|
74
|
+
case 'equals':
|
|
75
|
+
return strValue === filterValue;
|
|
76
|
+
case 'notEquals':
|
|
77
|
+
return strValue !== filterValue;
|
|
78
|
+
case 'startsWith':
|
|
79
|
+
return strValue.startsWith(filterValue);
|
|
80
|
+
case 'endsWith':
|
|
81
|
+
return strValue.endsWith(filterValue);
|
|
82
|
+
case 'isEmpty':
|
|
83
|
+
return isBlankValue(value);
|
|
84
|
+
case 'isNotEmpty':
|
|
85
|
+
return !isBlankValue(value);
|
|
86
|
+
default:
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
[]
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
// Helper function to apply number condition filter
|
|
94
|
+
const applyNumberCondition = useCallback(
|
|
95
|
+
(
|
|
96
|
+
value: any,
|
|
97
|
+
condition: { operator: string; value?: number; valueTo?: number }
|
|
98
|
+
): boolean => {
|
|
99
|
+
if (condition.operator === 'isEmpty') return isBlankValue(value);
|
|
100
|
+
if (condition.operator === 'isNotEmpty') return !isBlankValue(value);
|
|
101
|
+
|
|
102
|
+
const numValue = typeof value === 'number' ? value : parseFloat(value);
|
|
103
|
+
if (Number.isNaN(numValue)) return false;
|
|
104
|
+
|
|
105
|
+
const filterValue = condition.value;
|
|
106
|
+
const filterValueTo = condition.valueTo;
|
|
107
|
+
|
|
108
|
+
switch (condition.operator) {
|
|
109
|
+
case 'equals':
|
|
110
|
+
return filterValue !== undefined && numValue === filterValue;
|
|
111
|
+
case 'notEquals':
|
|
112
|
+
return filterValue !== undefined && numValue !== filterValue;
|
|
113
|
+
case 'greaterThan':
|
|
114
|
+
return filterValue !== undefined && numValue > filterValue;
|
|
115
|
+
case 'greaterThanOrEqual':
|
|
116
|
+
return filterValue !== undefined && numValue >= filterValue;
|
|
117
|
+
case 'lessThan':
|
|
118
|
+
return filterValue !== undefined && numValue < filterValue;
|
|
119
|
+
case 'lessThanOrEqual':
|
|
120
|
+
return filterValue !== undefined && numValue <= filterValue;
|
|
121
|
+
case 'between':
|
|
122
|
+
return (
|
|
123
|
+
filterValue !== undefined &&
|
|
124
|
+
filterValueTo !== undefined &&
|
|
125
|
+
numValue >= filterValue &&
|
|
126
|
+
numValue <= filterValueTo
|
|
127
|
+
);
|
|
128
|
+
default:
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
[]
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// Helper function to apply date condition filter
|
|
136
|
+
const applyDateCondition = useCallback(
|
|
137
|
+
(
|
|
138
|
+
value: any,
|
|
139
|
+
condition: { operator: string; value?: string; valueTo?: string }
|
|
140
|
+
): boolean => {
|
|
141
|
+
if (condition.operator === 'isEmpty') return isBlankValue(value);
|
|
142
|
+
if (condition.operator === 'isNotEmpty') return !isBlankValue(value);
|
|
143
|
+
|
|
144
|
+
const dateValue = value ? new Date(value) : null;
|
|
145
|
+
if (!dateValue || Number.isNaN(dateValue.getTime())) return false;
|
|
146
|
+
|
|
147
|
+
const today = new Date();
|
|
148
|
+
today.setHours(0, 0, 0, 0);
|
|
149
|
+
|
|
150
|
+
const getStartOfWeek = (date: Date) => {
|
|
151
|
+
const d = new Date(date);
|
|
152
|
+
const day = d.getDay();
|
|
153
|
+
const diff = d.getDate() - day;
|
|
154
|
+
return new Date(d.setDate(diff));
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const getStartOfMonth = (date: Date) =>
|
|
158
|
+
new Date(date.getFullYear(), date.getMonth(), 1);
|
|
159
|
+
const getStartOfYear = (date: Date) => new Date(date.getFullYear(), 0, 1);
|
|
160
|
+
|
|
161
|
+
switch (condition.operator) {
|
|
162
|
+
case 'equals':
|
|
163
|
+
if (!condition.value) return false;
|
|
164
|
+
return dateValue.toDateString() === new Date(condition.value).toDateString();
|
|
165
|
+
case 'notEquals':
|
|
166
|
+
if (!condition.value) return false;
|
|
167
|
+
return dateValue.toDateString() !== new Date(condition.value).toDateString();
|
|
168
|
+
case 'before':
|
|
169
|
+
if (!condition.value) return false;
|
|
170
|
+
return dateValue < new Date(condition.value);
|
|
171
|
+
case 'after':
|
|
172
|
+
if (!condition.value) return false;
|
|
173
|
+
return dateValue > new Date(condition.value);
|
|
174
|
+
case 'between':
|
|
175
|
+
if (!condition.value || !condition.valueTo) return false;
|
|
176
|
+
return (
|
|
177
|
+
dateValue >= new Date(condition.value) &&
|
|
178
|
+
dateValue <= new Date(condition.valueTo)
|
|
179
|
+
);
|
|
180
|
+
case 'today':
|
|
181
|
+
return dateValue.toDateString() === today.toDateString();
|
|
182
|
+
case 'yesterday': {
|
|
183
|
+
const yesterday = new Date(today);
|
|
184
|
+
yesterday.setDate(yesterday.getDate() - 1);
|
|
185
|
+
return dateValue.toDateString() === yesterday.toDateString();
|
|
186
|
+
}
|
|
187
|
+
case 'thisWeek': {
|
|
188
|
+
const thisWeekStart = getStartOfWeek(today);
|
|
189
|
+
const thisWeekEnd = new Date(thisWeekStart);
|
|
190
|
+
thisWeekEnd.setDate(thisWeekEnd.getDate() + 7);
|
|
191
|
+
return dateValue >= thisWeekStart && dateValue < thisWeekEnd;
|
|
192
|
+
}
|
|
193
|
+
case 'lastWeek': {
|
|
194
|
+
const lastWeekStart = getStartOfWeek(today);
|
|
195
|
+
lastWeekStart.setDate(lastWeekStart.getDate() - 7);
|
|
196
|
+
const lastWeekEnd = getStartOfWeek(today);
|
|
197
|
+
return dateValue >= lastWeekStart && dateValue < lastWeekEnd;
|
|
198
|
+
}
|
|
199
|
+
case 'thisMonth': {
|
|
200
|
+
const thisMonthStart = getStartOfMonth(today);
|
|
201
|
+
const thisMonthEnd = new Date(today.getFullYear(), today.getMonth() + 1, 1);
|
|
202
|
+
return dateValue >= thisMonthStart && dateValue < thisMonthEnd;
|
|
203
|
+
}
|
|
204
|
+
case 'lastMonth': {
|
|
205
|
+
const lastMonthStart = new Date(today.getFullYear(), today.getMonth() - 1, 1);
|
|
206
|
+
const lastMonthEnd = getStartOfMonth(today);
|
|
207
|
+
return dateValue >= lastMonthStart && dateValue < lastMonthEnd;
|
|
208
|
+
}
|
|
209
|
+
case 'thisYear': {
|
|
210
|
+
const thisYearStart = getStartOfYear(today);
|
|
211
|
+
const thisYearEnd = new Date(today.getFullYear() + 1, 0, 1);
|
|
212
|
+
return dateValue >= thisYearStart && dateValue < thisYearEnd;
|
|
213
|
+
}
|
|
214
|
+
default:
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
[]
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
// Filter and sort data (skip in server-side mode)
|
|
222
|
+
const filteredData = useMemo(() => {
|
|
223
|
+
if (!data || !Array.isArray(data)) return [];
|
|
224
|
+
|
|
225
|
+
// In server-side mode, return data as-is (server handles filtering/sorting)
|
|
226
|
+
if (serverSide) {
|
|
227
|
+
return data;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (!columns || !Array.isArray(columns)) return data;
|
|
231
|
+
let result = [...data];
|
|
232
|
+
|
|
233
|
+
// Apply filters
|
|
234
|
+
for (const [columnId, filter] of Object.entries(filters)) {
|
|
235
|
+
if (!filter) continue;
|
|
236
|
+
|
|
237
|
+
const column = columns.find((c) => c.id === columnId);
|
|
238
|
+
if (!column) continue;
|
|
239
|
+
|
|
240
|
+
result = result.filter((row) => {
|
|
241
|
+
const value = column.getValue ? column.getValue(row) : row[columnId];
|
|
242
|
+
|
|
243
|
+
// Handle blanks filter
|
|
244
|
+
if (filter.includeBlanks && isBlankValue(value)) {
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
if (filter.excludeBlanks && isBlankValue(value)) {
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Text condition filter (advanced)
|
|
252
|
+
if (filter.textCondition) {
|
|
253
|
+
return applyTextCondition(value, filter.textCondition);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Number condition filter (advanced)
|
|
257
|
+
if (filter.numberCondition) {
|
|
258
|
+
return applyNumberCondition(value, filter.numberCondition);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Date condition filter (advanced)
|
|
262
|
+
if (filter.dateCondition) {
|
|
263
|
+
return applyDateCondition(value, filter.dateCondition);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Text/selected values filter (legacy and value-based)
|
|
267
|
+
if (filter.selectedValues && filter.selectedValues.length > 0) {
|
|
268
|
+
const strValue = String(value ?? '');
|
|
269
|
+
return filter.selectedValues.includes(strValue);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Range filter (legacy)
|
|
273
|
+
if (filter.min !== undefined || filter.max !== undefined) {
|
|
274
|
+
const numValue = typeof value === 'number' ? value : parseFloat(value);
|
|
275
|
+
if (Number.isNaN(numValue)) return false;
|
|
276
|
+
if (filter.min !== undefined && numValue < Number(filter.min)) return false;
|
|
277
|
+
if (filter.max !== undefined && numValue > Number(filter.max)) return false;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return true;
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Apply sorting
|
|
285
|
+
if (sortConfig) {
|
|
286
|
+
const column = columns.find((c) => c.id === sortConfig.columnId);
|
|
287
|
+
result.sort((a, b) => {
|
|
288
|
+
const aValue = column?.getValue ? column.getValue(a) : a[sortConfig.columnId];
|
|
289
|
+
const bValue = column?.getValue ? column.getValue(b) : b[sortConfig.columnId];
|
|
290
|
+
|
|
291
|
+
if (aValue === null || aValue === undefined) return 1;
|
|
292
|
+
if (bValue === null || bValue === undefined) return -1;
|
|
293
|
+
|
|
294
|
+
if (typeof aValue === 'string' && typeof bValue === 'string') {
|
|
295
|
+
return sortConfig.direction === 'asc'
|
|
296
|
+
? aValue.localeCompare(bValue)
|
|
297
|
+
: bValue.localeCompare(aValue);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return sortConfig.direction === 'asc'
|
|
301
|
+
? aValue < bValue
|
|
302
|
+
? -1
|
|
303
|
+
: aValue > bValue
|
|
304
|
+
? 1
|
|
305
|
+
: 0
|
|
306
|
+
: aValue > bValue
|
|
307
|
+
? -1
|
|
308
|
+
: aValue < bValue
|
|
309
|
+
? 1
|
|
310
|
+
: 0;
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return result;
|
|
315
|
+
}, [
|
|
316
|
+
data,
|
|
317
|
+
filters,
|
|
318
|
+
sortConfig,
|
|
319
|
+
columns,
|
|
320
|
+
serverSide,
|
|
321
|
+
applyDateCondition,
|
|
322
|
+
applyNumberCondition,
|
|
323
|
+
applyTextCondition,
|
|
324
|
+
]);
|
|
325
|
+
|
|
326
|
+
const handleFilterChange = useCallback(
|
|
327
|
+
(columnId: string, filter: SpreadsheetColumnFilter | undefined) => {
|
|
328
|
+
const newFilters = { ...filters };
|
|
329
|
+
if (filter) {
|
|
330
|
+
newFilters[columnId] = filter;
|
|
331
|
+
} else {
|
|
332
|
+
delete newFilters[columnId];
|
|
333
|
+
}
|
|
334
|
+
// Only update internal state if not controlled
|
|
335
|
+
if (controlledFilters === undefined) {
|
|
336
|
+
setInternalFilters(newFilters);
|
|
337
|
+
}
|
|
338
|
+
onFilterChange?.(newFilters);
|
|
339
|
+
},
|
|
340
|
+
[filters, onFilterChange, controlledFilters]
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
const handleSort = useCallback(
|
|
344
|
+
(columnId: string) => {
|
|
345
|
+
const newSortConfig: SpreadsheetSortConfig =
|
|
346
|
+
sortConfig?.columnId === columnId
|
|
347
|
+
? { columnId, direction: sortConfig.direction === 'asc' ? 'desc' : 'asc' }
|
|
348
|
+
: { columnId, direction: 'asc' };
|
|
349
|
+
// Only update internal state if not controlled
|
|
350
|
+
if (controlledSortConfig === undefined) {
|
|
351
|
+
setInternalSortConfig(newSortConfig);
|
|
352
|
+
}
|
|
353
|
+
onSortChange?.(newSortConfig);
|
|
354
|
+
},
|
|
355
|
+
[sortConfig, onSortChange, controlledSortConfig]
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
const clearAllFilters = useCallback(() => {
|
|
359
|
+
// Only update internal state if not controlled
|
|
360
|
+
if (controlledFilters === undefined) {
|
|
361
|
+
setInternalFilters({});
|
|
362
|
+
}
|
|
363
|
+
onFilterChange?.({});
|
|
364
|
+
}, [onFilterChange, controlledFilters]);
|
|
365
|
+
|
|
366
|
+
const hasActiveFilters = Object.keys(filters).length > 0;
|
|
367
|
+
|
|
368
|
+
return {
|
|
369
|
+
filters,
|
|
370
|
+
sortConfig,
|
|
371
|
+
filteredData,
|
|
372
|
+
activeFilterColumn,
|
|
373
|
+
setActiveFilterColumn,
|
|
374
|
+
handleFilterChange,
|
|
375
|
+
handleSort,
|
|
376
|
+
clearAllFilters,
|
|
377
|
+
hasActiveFilters,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react';
|
|
2
|
+
import type { CellHighlight } from '../types';
|
|
3
|
+
|
|
4
|
+
// Standard color palettes
|
|
5
|
+
export const HIGHLIGHT_COLORS = {
|
|
6
|
+
// Darker colors for rows (more visible)
|
|
7
|
+
row: [
|
|
8
|
+
'#fef08a', // yellow
|
|
9
|
+
'#bbf7d0', // green
|
|
10
|
+
'#bfdbfe', // blue
|
|
11
|
+
'#fecaca', // red
|
|
12
|
+
'#e9d5ff', // purple
|
|
13
|
+
'#fed7aa', // orange
|
|
14
|
+
'#a5f3fc', // cyan
|
|
15
|
+
'#fce7f3', // pink
|
|
16
|
+
'#d1d5db', // gray
|
|
17
|
+
],
|
|
18
|
+
// Lighter colors for columns/headers (subtle background)
|
|
19
|
+
column: [
|
|
20
|
+
'#fef9c3', // yellow-100
|
|
21
|
+
'#dcfce7', // green-100
|
|
22
|
+
'#dbeafe', // blue-100
|
|
23
|
+
'#fee2e2', // red-100
|
|
24
|
+
'#f3e8ff', // purple-100
|
|
25
|
+
'#ffedd5', // orange-100
|
|
26
|
+
'#cffafe', // cyan-100
|
|
27
|
+
'#fce7f3', // pink-100
|
|
28
|
+
'#e5e7eb', // gray-200
|
|
29
|
+
],
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
export interface UseSpreadsheetHighlightingOptions {
|
|
33
|
+
/** External row highlights (controlled mode) */
|
|
34
|
+
externalRowHighlights?: CellHighlight[];
|
|
35
|
+
/** Callback when row highlight changes (controlled mode) */
|
|
36
|
+
onRowHighlight?: (rowId: string | number, color: string | null) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface UseSpreadsheetHighlightingReturn {
|
|
40
|
+
// Cell highlights
|
|
41
|
+
cellHighlights: CellHighlight[];
|
|
42
|
+
getCellHighlight: (rowId: string | number, columnId: string) => string | undefined;
|
|
43
|
+
handleCellHighlightToggle: (rowId: string | number, columnId: string, color?: string) => void;
|
|
44
|
+
|
|
45
|
+
// Row highlights
|
|
46
|
+
rowHighlights: CellHighlight[];
|
|
47
|
+
getRowHighlight: (rowId: string | number) => CellHighlight | undefined;
|
|
48
|
+
handleRowHighlightToggle: (rowId: string | number, color: string | null) => void;
|
|
49
|
+
|
|
50
|
+
// Column highlights (unified - includes row index)
|
|
51
|
+
columnHighlights: Record<string, string>;
|
|
52
|
+
getColumnHighlight: (columnId: string) => string | undefined;
|
|
53
|
+
handleColumnHighlightToggle: (columnId: string, color: string | null) => void;
|
|
54
|
+
|
|
55
|
+
// Picker state management
|
|
56
|
+
highlightPickerRow: string | number | null;
|
|
57
|
+
setHighlightPickerRow: (rowId: string | number | null) => void;
|
|
58
|
+
highlightPickerColumn: string | null;
|
|
59
|
+
setHighlightPickerColumn: (columnId: string | null) => void;
|
|
60
|
+
|
|
61
|
+
// Utility
|
|
62
|
+
clearAllHighlights: () => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Special column ID for row index
|
|
66
|
+
export const ROW_INDEX_COLUMN_ID = '__row_index__';
|
|
67
|
+
|
|
68
|
+
export function useSpreadsheetHighlighting({
|
|
69
|
+
externalRowHighlights,
|
|
70
|
+
onRowHighlight,
|
|
71
|
+
}: UseSpreadsheetHighlightingOptions = {}): UseSpreadsheetHighlightingReturn {
|
|
72
|
+
// Cell-level highlights
|
|
73
|
+
const [cellHighlights, setCellHighlights] = useState<CellHighlight[]>([]);
|
|
74
|
+
|
|
75
|
+
// Row-level highlights (internal state, can be overridden by external)
|
|
76
|
+
const [rowHighlightsInternal, setRowHighlightsInternal] = useState<CellHighlight[]>([]);
|
|
77
|
+
|
|
78
|
+
// Column-level highlights (includes row index column using ROW_INDEX_COLUMN_ID)
|
|
79
|
+
const [columnHighlights, setColumnHighlights] = useState<Record<string, string>>({});
|
|
80
|
+
|
|
81
|
+
// Picker states
|
|
82
|
+
const [highlightPickerRow, setHighlightPickerRow] = useState<string | number | null>(null);
|
|
83
|
+
const [highlightPickerColumn, setHighlightPickerColumn] = useState<string | null>(null);
|
|
84
|
+
|
|
85
|
+
// Use external row highlights if provided, otherwise use internal
|
|
86
|
+
const rowHighlights = externalRowHighlights || rowHighlightsInternal;
|
|
87
|
+
|
|
88
|
+
// Get highlight color for a specific cell
|
|
89
|
+
const getCellHighlight = useCallback(
|
|
90
|
+
(rowId: string | number, columnId: string): string | undefined => {
|
|
91
|
+
return cellHighlights.find((h) => h.rowId === rowId && h.columnId === columnId)?.color;
|
|
92
|
+
},
|
|
93
|
+
[cellHighlights]
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// Toggle cell highlight
|
|
97
|
+
const handleCellHighlightToggle = useCallback(
|
|
98
|
+
(rowId: string | number, columnId: string, color: string = '#fef08a') => {
|
|
99
|
+
setCellHighlights((prev) => {
|
|
100
|
+
const existing = prev.find((h) => h.rowId === rowId && h.columnId === columnId);
|
|
101
|
+
if (existing) {
|
|
102
|
+
return prev.filter((h) => !(h.rowId === rowId && h.columnId === columnId));
|
|
103
|
+
}
|
|
104
|
+
return [...prev, { rowId, columnId, color }];
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
[]
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// Get row-level highlight
|
|
111
|
+
const getRowHighlight = useCallback(
|
|
112
|
+
(rowId: string | number): CellHighlight | undefined => {
|
|
113
|
+
return rowHighlights.find((h) => h.rowId === rowId && !h.columnId);
|
|
114
|
+
},
|
|
115
|
+
[rowHighlights]
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// Handle row highlight toggle
|
|
119
|
+
const handleRowHighlightToggle = useCallback(
|
|
120
|
+
(rowId: string | number, color: string | null) => {
|
|
121
|
+
if (onRowHighlight) {
|
|
122
|
+
// Controlled mode
|
|
123
|
+
onRowHighlight(rowId, color);
|
|
124
|
+
} else {
|
|
125
|
+
// Uncontrolled mode
|
|
126
|
+
setRowHighlightsInternal((prev) => {
|
|
127
|
+
const existing = prev.find((h) => h.rowId === rowId && !h.columnId);
|
|
128
|
+
if (existing) {
|
|
129
|
+
if (color === null) {
|
|
130
|
+
return prev.filter((h) => !(h.rowId === rowId && !h.columnId));
|
|
131
|
+
}
|
|
132
|
+
return prev.map((h) =>
|
|
133
|
+
h.rowId === rowId && !h.columnId ? { ...h, color } : h
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
if (color) {
|
|
137
|
+
return [...prev, { rowId, color }];
|
|
138
|
+
}
|
|
139
|
+
return prev;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
setHighlightPickerRow(null);
|
|
143
|
+
},
|
|
144
|
+
[onRowHighlight]
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
// Get column highlight (works for both regular columns and row index)
|
|
148
|
+
const getColumnHighlight = useCallback(
|
|
149
|
+
(columnId: string): string | undefined => {
|
|
150
|
+
return columnHighlights[columnId];
|
|
151
|
+
},
|
|
152
|
+
[columnHighlights]
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
// Handle column highlight toggle (works for both regular columns and row index)
|
|
156
|
+
const handleColumnHighlightToggle = useCallback((columnId: string, color: string | null) => {
|
|
157
|
+
setColumnHighlights((prev) => {
|
|
158
|
+
const newHighlights = { ...prev };
|
|
159
|
+
if (color === null) {
|
|
160
|
+
delete newHighlights[columnId];
|
|
161
|
+
} else {
|
|
162
|
+
newHighlights[columnId] = color;
|
|
163
|
+
}
|
|
164
|
+
return newHighlights;
|
|
165
|
+
});
|
|
166
|
+
setHighlightPickerColumn(null);
|
|
167
|
+
}, []);
|
|
168
|
+
|
|
169
|
+
// Clear all highlights
|
|
170
|
+
const clearAllHighlights = useCallback(() => {
|
|
171
|
+
setCellHighlights([]);
|
|
172
|
+
setRowHighlightsInternal([]);
|
|
173
|
+
setColumnHighlights({});
|
|
174
|
+
}, []);
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
// Cell highlights
|
|
178
|
+
cellHighlights,
|
|
179
|
+
getCellHighlight,
|
|
180
|
+
handleCellHighlightToggle,
|
|
181
|
+
|
|
182
|
+
// Row highlights
|
|
183
|
+
rowHighlights,
|
|
184
|
+
getRowHighlight,
|
|
185
|
+
handleRowHighlightToggle,
|
|
186
|
+
|
|
187
|
+
// Column highlights
|
|
188
|
+
columnHighlights,
|
|
189
|
+
getColumnHighlight,
|
|
190
|
+
handleColumnHighlightToggle,
|
|
191
|
+
|
|
192
|
+
// Picker state
|
|
193
|
+
highlightPickerRow,
|
|
194
|
+
setHighlightPickerRow,
|
|
195
|
+
highlightPickerColumn,
|
|
196
|
+
setHighlightPickerColumn,
|
|
197
|
+
|
|
198
|
+
// Utility
|
|
199
|
+
clearAllHighlights,
|
|
200
|
+
};
|
|
201
|
+
}
|