@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.
Files changed (37) hide show
  1. package/.storybook/main.ts +27 -0
  2. package/.storybook/preview.tsx +28 -0
  3. package/.turbo/turbo-build.log +22 -0
  4. package/CHANGELOG.md +9 -0
  5. package/biome.json +3 -0
  6. package/dist/index.d.mts +687 -0
  7. package/dist/index.d.ts +687 -0
  8. package/dist/index.js +3459 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/index.mjs +3417 -0
  11. package/dist/index.mjs.map +1 -0
  12. package/package.json +51 -0
  13. package/postcss.config.js +5 -0
  14. package/src/components/ColorPickerPopover.tsx +73 -0
  15. package/src/components/ColumnHeaderActions.tsx +139 -0
  16. package/src/components/CommentModals.tsx +137 -0
  17. package/src/components/KeyboardShortcutsModal.tsx +119 -0
  18. package/src/components/RowIndexColumnHeader.tsx +70 -0
  19. package/src/components/Spreadsheet.stories.tsx +1146 -0
  20. package/src/components/Spreadsheet.tsx +1005 -0
  21. package/src/components/SpreadsheetCell.tsx +341 -0
  22. package/src/components/SpreadsheetFilterDropdown.tsx +341 -0
  23. package/src/components/SpreadsheetHeader.tsx +111 -0
  24. package/src/components/SpreadsheetSettingsModal.tsx +555 -0
  25. package/src/components/SpreadsheetToolbar.tsx +346 -0
  26. package/src/hooks/index.ts +40 -0
  27. package/src/hooks/useSpreadsheetComments.ts +132 -0
  28. package/src/hooks/useSpreadsheetFiltering.ts +379 -0
  29. package/src/hooks/useSpreadsheetHighlighting.ts +201 -0
  30. package/src/hooks/useSpreadsheetKeyboardShortcuts.ts +149 -0
  31. package/src/hooks/useSpreadsheetPinning.ts +203 -0
  32. package/src/hooks/useSpreadsheetUndoRedo.ts +167 -0
  33. package/src/index.ts +31 -0
  34. package/src/types.ts +612 -0
  35. package/src/utils.ts +16 -0
  36. package/tsconfig.json +30 -0
  37. 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
+ }