@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,346 @@
1
+ import React from 'react';
2
+ import {
3
+ HiCheck,
4
+ HiCog,
5
+ HiDotsVertical,
6
+ HiDownload,
7
+ HiFilter,
8
+ HiOutlineQuestionMarkCircle,
9
+ HiReply,
10
+ HiX,
11
+ HiZoomIn,
12
+ HiZoomOut,
13
+ } from 'react-icons/hi';
14
+ import { cn } from '../utils';
15
+ import type { SpreadsheetToolbarProps } from '../types';
16
+
17
+ /**
18
+ * SpreadsheetToolbar component - Top toolbar with zoom controls, undo/redo, filters, and actions.
19
+ *
20
+ * @example
21
+ * ```tsx
22
+ * <SpreadsheetToolbar
23
+ * zoom={100}
24
+ * canUndo={true}
25
+ * canRedo={false}
26
+ * selectedRowCount={3}
27
+ * showFilters={false}
28
+ * hasUnsavedChanges={false}
29
+ * saveStatus="saved"
30
+ * autoSave={true}
31
+ * onZoomIn={() => setZoom(zoom + 10)}
32
+ * onZoomOut={() => setZoom(zoom - 10)}
33
+ * onZoomReset={() => setZoom(100)}
34
+ * onUndo={handleUndo}
35
+ * onRedo={handleRedo}
36
+ * onToggleFilters={() => setShowFilters(!showFilters)}
37
+ * onClearSelection={() => setSelectedRows(new Set())}
38
+ * />
39
+ * ```
40
+ */
41
+ export const SpreadsheetToolbar: React.FC<SpreadsheetToolbarProps> = ({
42
+ zoom,
43
+ canUndo,
44
+ canRedo,
45
+ undoCount = 0,
46
+ redoCount = 0,
47
+ selectedRowCount,
48
+ hasUnsavedChanges,
49
+ saveStatus,
50
+ autoSave,
51
+ summary,
52
+ onZoomIn,
53
+ onZoomOut,
54
+ onZoomReset,
55
+ onUndo,
56
+ onRedo,
57
+ onClearSelection,
58
+ onSave,
59
+ onExport,
60
+ onSettings,
61
+ onShowShortcuts,
62
+ hasActiveFilters,
63
+ onClearFilters,
64
+ className,
65
+ }) => {
66
+ const [showMoreMenu, setShowMoreMenu] = React.useState(false);
67
+ const menuRef = React.useRef<HTMLDivElement>(null);
68
+
69
+ // Close menu on outside click
70
+ React.useEffect(() => {
71
+ const handleClickOutside = (event: MouseEvent) => {
72
+ if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
73
+ setShowMoreMenu(false);
74
+ }
75
+ };
76
+
77
+ document.addEventListener('mousedown', handleClickOutside);
78
+ return () => document.removeEventListener('mousedown', handleClickOutside);
79
+ }, []);
80
+
81
+ const buttonBaseClasses =
82
+ 'p-1.5 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed';
83
+
84
+ const getSaveStatusDisplay = () => {
85
+ switch (saveStatus) {
86
+ case 'saved':
87
+ return {
88
+ text: '✓ All changes saved',
89
+ className: 'text-gray-500',
90
+ };
91
+ case 'saving':
92
+ return {
93
+ text: '⟳ Saving...',
94
+ className: 'text-amber-500',
95
+ };
96
+ case 'unsaved':
97
+ return {
98
+ text: '● Unsaved changes',
99
+ className: 'text-amber-500',
100
+ };
101
+ case 'error':
102
+ return {
103
+ text: '⚠ Error saving',
104
+ className: 'text-red-500',
105
+ };
106
+ default:
107
+ return null;
108
+ }
109
+ };
110
+
111
+ const saveStatusDisplay = getSaveStatusDisplay();
112
+
113
+ const getSummaryVariantClasses = (variant?: string) => {
114
+ switch (variant) {
115
+ case 'success':
116
+ return 'bg-green-50 border-green-200 text-green-700';
117
+ case 'danger':
118
+ return 'bg-red-50 border-red-200 text-red-700';
119
+ case 'warning':
120
+ return 'bg-amber-50 border-amber-200 text-amber-700';
121
+ default:
122
+ return 'bg-blue-50 border-blue-200 text-blue-700';
123
+ }
124
+ };
125
+
126
+ return (
127
+ <div
128
+ className={cn(
129
+ 'flex flex-wrap items-center justify-between gap-2 px-4 py-2 border-b border-gray-200 bg-white',
130
+ className
131
+ )}
132
+ >
133
+ {/* Left section: Primary actions */}
134
+ <div className="flex items-center gap-2">
135
+ {/* Undo/Redo buttons */}
136
+ <div className="flex items-center gap-1">
137
+ <button
138
+ type={'button'}
139
+ onClick={onUndo}
140
+ disabled={!canUndo}
141
+ className={cn(
142
+ buttonBaseClasses,
143
+ canUndo
144
+ ? 'bg-gray-100 text-gray-700 hover:bg-gray-200'
145
+ : 'bg-gray-50 text-gray-400'
146
+ )}
147
+ title={`Undo (${undoCount} changes)`}
148
+ >
149
+ <HiReply className="h-4 w-4" />
150
+ </button>
151
+ <button
152
+ type={'button'}
153
+ onClick={onRedo}
154
+ disabled={!canRedo}
155
+ className={cn(
156
+ buttonBaseClasses,
157
+ canRedo
158
+ ? 'bg-gray-100 text-gray-700 hover:bg-gray-200'
159
+ : 'bg-gray-50 text-gray-400'
160
+ )}
161
+ title={`Redo (${redoCount} changes)`}
162
+ style={{ transform: 'scaleX(-1)' }}
163
+ >
164
+ <HiReply className="h-4 w-4" />
165
+ </button>
166
+ </div>
167
+
168
+ {/* Zoom controls */}
169
+ <div className="flex items-center gap-1 px-1.5 py-1 bg-gray-100 rounded">
170
+ <button
171
+ type={'button'}
172
+ onClick={onZoomOut}
173
+ className="p-1 hover:bg-white rounded"
174
+ title="Zoom out"
175
+ >
176
+ <HiZoomOut className="h-4 w-4 text-gray-600" />
177
+ </button>
178
+ <button
179
+ type={'button'}
180
+ onClick={onZoomReset}
181
+ className="px-2 py-0.5 hover:bg-white rounded text-xs min-w-[45px] text-center text-gray-600"
182
+ title="Reset zoom"
183
+ >
184
+ {zoom}%
185
+ </button>
186
+ <button
187
+ type={'button'}
188
+ onClick={onZoomIn}
189
+ className="p-1 hover:bg-white rounded"
190
+ title="Zoom in"
191
+ >
192
+ <HiZoomIn className="h-4 w-4 text-gray-600" />
193
+ </button>
194
+ </div>
195
+ </div>
196
+
197
+ {/* Center section: Status indicators */}
198
+ <div className="flex items-center gap-2 flex-1 min-w-0">
199
+ {/* Selected rows indicator */}
200
+ {selectedRowCount > 0 && (
201
+ <div className="flex items-center gap-2 px-2.5 py-1.5 bg-blue-600 text-white rounded">
202
+ <span className="text-xs font-medium whitespace-nowrap">
203
+ {selectedRowCount} row{selectedRowCount !== 1 ? 's' : ''} selected
204
+ </span>
205
+ <button
206
+ type={'button'}
207
+ onClick={onClearSelection}
208
+ className="p-0.5 hover:bg-blue-700 rounded"
209
+ title="Clear selection"
210
+ >
211
+ <HiX className="h-3 w-3" />
212
+ </button>
213
+ </div>
214
+ )}
215
+
216
+ {/* Clear filters button */}
217
+ {hasActiveFilters && onClearFilters && (
218
+ <div className="flex items-center gap-2 px-2.5 py-1.5 bg-amber-500 text-white rounded">
219
+ <HiFilter className="h-3.5 w-3.5" />
220
+ <span className="text-xs font-medium whitespace-nowrap">
221
+ Filters active
222
+ </span>
223
+ <button
224
+ type={'button'}
225
+ onClick={onClearFilters}
226
+ className="p-0.5 hover:bg-amber-600 rounded"
227
+ title="Clear all filters"
228
+ >
229
+ <HiX className="h-3 w-3" />
230
+ </button>
231
+ </div>
232
+ )}
233
+
234
+ {/* Summary badge */}
235
+ {summary && (
236
+ <div
237
+ className={cn(
238
+ 'flex items-center gap-2 px-2.5 py-1.5 rounded border text-xs',
239
+ getSummaryVariantClasses(summary.variant)
240
+ )}
241
+ >
242
+ <span className="font-semibold whitespace-nowrap">{summary.label}:</span>
243
+ <span className="font-bold whitespace-nowrap">{summary.value}</span>
244
+ </div>
245
+ )}
246
+ </div>
247
+
248
+ {/* Right section: Action buttons */}
249
+ <div className="flex items-center gap-2">
250
+ {/* Save status */}
251
+ {saveStatusDisplay && (
252
+ <span
253
+ className={cn(
254
+ 'text-xs flex items-center gap-1',
255
+ saveStatusDisplay.className
256
+ )}
257
+ >
258
+ {saveStatusDisplay.text}
259
+ </span>
260
+ )}
261
+
262
+ {/* Manual save button (when auto-save is off) */}
263
+ {!autoSave && onSave && (
264
+ <button
265
+ type={'button'}
266
+ onClick={onSave}
267
+ disabled={!hasUnsavedChanges}
268
+ className={cn(
269
+ 'px-3 py-1.5 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors flex items-center gap-1.5',
270
+ 'disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-blue-600'
271
+ )}
272
+ >
273
+ <HiCheck className="h-3.5 w-3.5" />
274
+ Save
275
+ </button>
276
+ )}
277
+
278
+ {/* More menu dropdown */}
279
+ <div className="relative" ref={menuRef}>
280
+ <button
281
+ type={'button'}
282
+ onClick={() => setShowMoreMenu(!showMoreMenu)}
283
+ className="px-2.5 py-1.5 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors flex items-center gap-1.5 text-xs"
284
+ title="More actions"
285
+ >
286
+ <HiDotsVertical className="h-3.5 w-3.5" />
287
+ <span className="hidden lg:inline">More</span>
288
+ </button>
289
+
290
+ {/* Dropdown Menu */}
291
+ {showMoreMenu && (
292
+ <div className="absolute right-0 top-full mt-1 bg-white border border-gray-200 shadow-lg rounded py-1 min-w-[180px] z-20">
293
+ {onSettings && (
294
+ <button
295
+ type={'button'}
296
+ onClick={() => {
297
+ onSettings();
298
+ setShowMoreMenu(false);
299
+ }}
300
+ className="w-full px-3 py-2 text-left hover:bg-gray-50 flex items-center gap-2 text-xs transition-colors"
301
+ >
302
+ <HiCog className="h-3.5 w-3.5 text-gray-500" />
303
+ <span className="text-gray-700">Settings</span>
304
+ </button>
305
+ )}
306
+
307
+ {onShowShortcuts && (
308
+ <button
309
+ type={'button'}
310
+ onClick={() => {
311
+ onShowShortcuts();
312
+ setShowMoreMenu(false);
313
+ }}
314
+ className="w-full px-3 py-2 text-left hover:bg-gray-50 flex items-center gap-2 text-xs transition-colors"
315
+ >
316
+ <HiOutlineQuestionMarkCircle className="h-3.5 w-3.5 text-gray-500" />
317
+ <span className="text-gray-700">Keyboard Shortcuts</span>
318
+ </button>
319
+ )}
320
+
321
+ {(onSettings || onShowShortcuts) && onExport && (
322
+ <div className="border-t border-gray-100 my-1" />
323
+ )}
324
+
325
+ {onExport && (
326
+ <button
327
+ type={'button'}
328
+ onClick={() => {
329
+ onExport();
330
+ setShowMoreMenu(false);
331
+ }}
332
+ className="w-full px-3 py-2 text-left hover:bg-gray-50 flex items-center gap-2 text-xs transition-colors"
333
+ >
334
+ <HiDownload className="h-3.5 w-3.5 text-gray-500" />
335
+ <span className="text-gray-700">Export</span>
336
+ </button>
337
+ )}
338
+ </div>
339
+ )}
340
+ </div>
341
+ </div>
342
+ </div>
343
+ );
344
+ };
345
+
346
+ SpreadsheetToolbar.displayName = 'SpreadsheetToolbar';
@@ -0,0 +1,40 @@
1
+ export {
2
+ useSpreadsheetFiltering,
3
+ type UseSpreadsheetFilteringOptions,
4
+ type UseSpreadsheetFilteringReturn,
5
+ } from './useSpreadsheetFiltering';
6
+
7
+ export {
8
+ useSpreadsheetHighlighting,
9
+ HIGHLIGHT_COLORS,
10
+ ROW_INDEX_COLUMN_ID,
11
+ type UseSpreadsheetHighlightingOptions,
12
+ type UseSpreadsheetHighlightingReturn,
13
+ } from './useSpreadsheetHighlighting';
14
+
15
+ export {
16
+ useSpreadsheetPinning,
17
+ ROW_INDEX_COLUMN_WIDTH,
18
+ ROW_INDEX_COLUMN_ID as PINNING_ROW_INDEX_COLUMN_ID,
19
+ type UseSpreadsheetPinningOptions,
20
+ type UseSpreadsheetPinningReturn,
21
+ } from './useSpreadsheetPinning';
22
+
23
+ export {
24
+ useSpreadsheetComments,
25
+ type UseSpreadsheetCommentsOptions,
26
+ type UseSpreadsheetCommentsReturn,
27
+ } from './useSpreadsheetComments';
28
+
29
+ export {
30
+ useSpreadsheetUndoRedo,
31
+ type UseSpreadsheetUndoRedoOptions,
32
+ type UseSpreadsheetUndoRedoReturn,
33
+ } from './useSpreadsheetUndoRedo';
34
+
35
+ export {
36
+ useSpreadsheetKeyboardShortcuts,
37
+ type UseSpreadsheetKeyboardShortcutsOptions,
38
+ type UseSpreadsheetKeyboardShortcutsReturn,
39
+ type KeyboardShortcutHandler,
40
+ } from './useSpreadsheetKeyboardShortcuts';
@@ -0,0 +1,132 @@
1
+ import { useCallback, useState } from 'react';
2
+ import type { CellComment } from '../types';
3
+
4
+ export interface UseSpreadsheetCommentsOptions {
5
+ /** External row comments (controlled mode) */
6
+ externalRowComments?: CellComment[];
7
+ /** Callback when a row comment is added (controlled mode) */
8
+ onAddRowComment?: (rowId: string | number, comment: string) => void;
9
+ }
10
+
11
+ export interface UseSpreadsheetCommentsReturn {
12
+ // Comments data
13
+ rowComments: CellComment[];
14
+ getRowComments: (rowId: string | number) => CellComment[];
15
+ getUnresolvedCommentCount: (rowId: string | number) => number;
16
+
17
+ // Add comment modal state
18
+ commentModalRow: string | number | null;
19
+ setCommentModalRow: (rowId: string | number | null) => void;
20
+ commentText: string;
21
+ setCommentText: (text: string) => void;
22
+
23
+ // View comments modal state
24
+ viewCommentsRow: string | number | null;
25
+ setViewCommentsRow: (rowId: string | number | null) => void;
26
+
27
+ // Actions
28
+ handleAddRowComment: (rowId: string | number) => void;
29
+ handleToggleCommentResolved: (commentId: string) => void;
30
+
31
+ // Utility
32
+ hasComments: (rowId: string | number) => boolean;
33
+ }
34
+
35
+ export function useSpreadsheetComments({
36
+ externalRowComments,
37
+ onAddRowComment,
38
+ }: UseSpreadsheetCommentsOptions = {}): UseSpreadsheetCommentsReturn {
39
+ // Internal comments state
40
+ const [rowCommentsInternal, setRowCommentsInternal] = useState<CellComment[]>([]);
41
+
42
+ // Modal states
43
+ const [commentModalRow, setCommentModalRow] = useState<string | number | null>(null);
44
+ const [commentText, setCommentText] = useState('');
45
+ const [viewCommentsRow, setViewCommentsRow] = useState<string | number | null>(null);
46
+
47
+ // Use external comments if provided, otherwise use internal
48
+ const rowComments = externalRowComments || rowCommentsInternal;
49
+
50
+ // Get comments for a specific row
51
+ const getRowComments = useCallback(
52
+ (rowId: string | number): CellComment[] => {
53
+ return rowComments.filter((c) => c.rowId === rowId && !c.columnId);
54
+ },
55
+ [rowComments]
56
+ );
57
+
58
+ // Get unresolved comment count for a row
59
+ const getUnresolvedCommentCount = useCallback(
60
+ (rowId: string | number): number => {
61
+ return rowComments.filter((c) => c.rowId === rowId && !c.columnId && !c.resolved)
62
+ .length;
63
+ },
64
+ [rowComments]
65
+ );
66
+
67
+ // Check if row has comments
68
+ const hasComments = useCallback(
69
+ (rowId: string | number): boolean => {
70
+ return rowComments.some((c) => c.rowId === rowId && !c.columnId);
71
+ },
72
+ [rowComments]
73
+ );
74
+
75
+ // Add a row comment
76
+ const handleAddRowComment = useCallback(
77
+ (rowId: string | number) => {
78
+ if (!commentText.trim()) return;
79
+
80
+ if (onAddRowComment) {
81
+ // Controlled mode
82
+ onAddRowComment(rowId, commentText);
83
+ } else {
84
+ // Uncontrolled mode
85
+ setRowCommentsInternal((prev) => [
86
+ ...prev,
87
+ {
88
+ id: `comment-${Date.now()}`,
89
+ rowId,
90
+ text: commentText,
91
+ timestamp: new Date(),
92
+ resolved: false,
93
+ },
94
+ ]);
95
+ }
96
+ setCommentText('');
97
+ setCommentModalRow(null);
98
+ },
99
+ [commentText, onAddRowComment]
100
+ );
101
+
102
+ // Toggle comment resolved status
103
+ const handleToggleCommentResolved = useCallback((commentId: string) => {
104
+ setRowCommentsInternal((prev) =>
105
+ prev.map((c) => (c.id === commentId ? { ...c, resolved: !c.resolved } : c))
106
+ );
107
+ }, []);
108
+
109
+ return {
110
+ // Comments data
111
+ rowComments,
112
+ getRowComments,
113
+ getUnresolvedCommentCount,
114
+
115
+ // Add comment modal state
116
+ commentModalRow,
117
+ setCommentModalRow,
118
+ commentText,
119
+ setCommentText,
120
+
121
+ // View comments modal state
122
+ viewCommentsRow,
123
+ setViewCommentsRow,
124
+
125
+ // Actions
126
+ handleAddRowComment,
127
+ handleToggleCommentResolved,
128
+
129
+ // Utility
130
+ hasComments,
131
+ };
132
+ }