@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,149 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ export interface KeyboardShortcutHandler {
4
+ key: string;
5
+ modifiers?: {
6
+ meta?: boolean;
7
+ ctrl?: boolean;
8
+ shift?: boolean;
9
+ alt?: boolean;
10
+ };
11
+ handler: () => void;
12
+ description?: string;
13
+ }
14
+
15
+ export interface UseSpreadsheetKeyboardShortcutsOptions {
16
+ /** Handlers for undo action */
17
+ onUndo?: () => void;
18
+ /** Handler for redo action */
19
+ onRedo?: () => void;
20
+ /** Handler for escape key - receives context of what to close */
21
+ onEscape?: () => void;
22
+ /** Whether the shortcuts modal is open */
23
+ isShortcutsModalOpen?: boolean;
24
+ /** Additional custom shortcuts */
25
+ customShortcuts?: KeyboardShortcutHandler[];
26
+ /** Whether shortcuts are enabled */
27
+ enabled?: boolean;
28
+ }
29
+
30
+ export interface UseSpreadsheetKeyboardShortcutsReturn {
31
+ // Shortcuts modal state
32
+ showKeyboardShortcuts: boolean;
33
+ setShowKeyboardShortcuts: (show: boolean) => void;
34
+
35
+ // OS detection for display
36
+ isMac: boolean;
37
+ modifierKey: string;
38
+
39
+ // Shortcut definitions for display
40
+ shortcuts: {
41
+ general: Array<{ label: string; keys: string[] }>;
42
+ rowSelection: Array<{ label: string; keys: string[] }>;
43
+ editing: Array<{ label: string; keys: string[] }>;
44
+ rowActions: Array<{ label: string; description: string }>;
45
+ };
46
+ }
47
+
48
+ export function useSpreadsheetKeyboardShortcuts({
49
+ onUndo,
50
+ onRedo,
51
+ onEscape,
52
+ customShortcuts = [],
53
+ enabled = true,
54
+ }: UseSpreadsheetKeyboardShortcutsOptions = {}): UseSpreadsheetKeyboardShortcutsReturn {
55
+ const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false);
56
+
57
+ // Detect OS for keyboard shortcuts display
58
+ const isMac =
59
+ typeof navigator !== 'undefined' && /Mac|iPhone|iPod|iPad/.test(navigator.platform);
60
+ const modifierKey = isMac ? '⌘' : 'Ctrl';
61
+
62
+ // Keyboard event handler
63
+ useEffect(() => {
64
+ if (!enabled) return;
65
+
66
+ const handleKeyDown = (event: KeyboardEvent) => {
67
+ // Escape key
68
+ if (event.key === 'Escape') {
69
+ if (showKeyboardShortcuts) {
70
+ setShowKeyboardShortcuts(false);
71
+ } else {
72
+ onEscape?.();
73
+ }
74
+ return;
75
+ }
76
+
77
+ // Cmd/Ctrl + / : Toggle keyboard shortcuts modal
78
+ if ((event.metaKey || event.ctrlKey) && event.key === '/') {
79
+ event.preventDefault();
80
+ setShowKeyboardShortcuts((prev) => !prev);
81
+ return;
82
+ }
83
+
84
+ // Cmd/Ctrl+Z: Undo (without shift)
85
+ if ((event.metaKey || event.ctrlKey) && event.key === 'z' && !event.shiftKey) {
86
+ event.preventDefault();
87
+ onUndo?.();
88
+ return;
89
+ }
90
+
91
+ // Cmd/Ctrl+Shift+Z: Redo
92
+ if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key === 'z') {
93
+ event.preventDefault();
94
+ onRedo?.();
95
+ return;
96
+ }
97
+
98
+ // Custom shortcuts
99
+ for (const shortcut of customShortcuts) {
100
+ const modifiersMatch =
101
+ (!shortcut.modifiers?.meta || event.metaKey) &&
102
+ (!shortcut.modifiers?.ctrl || event.ctrlKey) &&
103
+ (!shortcut.modifiers?.shift || event.shiftKey) &&
104
+ (!shortcut.modifiers?.alt || event.altKey);
105
+
106
+ if (event.key === shortcut.key && modifiersMatch) {
107
+ event.preventDefault();
108
+ shortcut.handler();
109
+ return;
110
+ }
111
+ }
112
+ };
113
+
114
+ document.addEventListener('keydown', handleKeyDown);
115
+ return () => document.removeEventListener('keydown', handleKeyDown);
116
+ }, [enabled, showKeyboardShortcuts, onUndo, onRedo, onEscape, customShortcuts]);
117
+
118
+ // Shortcut definitions for display in modal
119
+ const shortcuts = {
120
+ general: [
121
+ { label: 'Show keyboard shortcuts', keys: [modifierKey, '/'] },
122
+ { label: 'Close modal / Clear selection', keys: ['Escape'] },
123
+ ],
124
+ rowSelection: [
125
+ { label: 'Select single row', keys: ['Click row number'] },
126
+ { label: 'Select multiple rows', keys: [modifierKey, 'Click row number'] },
127
+ { label: 'Select range of rows', keys: ['Shift', 'Click row number'] },
128
+ ],
129
+ editing: [
130
+ { label: 'Undo', keys: [modifierKey, 'Z'] },
131
+ { label: 'Redo', keys: [modifierKey, 'Shift', 'Z'] },
132
+ { label: 'Confirm cell edit', keys: ['Enter'] },
133
+ { label: 'Cancel cell edit', keys: ['Escape'] },
134
+ ],
135
+ rowActions: [
136
+ { label: 'Duplicate row', description: 'Click duplicate icon' },
137
+ { label: 'Highlight row', description: 'Click highlight icon' },
138
+ { label: 'Add row comment', description: 'Click comment icon' },
139
+ ],
140
+ };
141
+
142
+ return {
143
+ showKeyboardShortcuts,
144
+ setShowKeyboardShortcuts,
145
+ isMac,
146
+ modifierKey,
147
+ shortcuts,
148
+ };
149
+ }
@@ -0,0 +1,203 @@
1
+ import { useCallback, useMemo, useState } from 'react';
2
+ import type { SpreadsheetColumn, SpreadsheetColumnGroup } from '../types';
3
+
4
+ // Special column ID for row index
5
+ export const ROW_INDEX_COLUMN_ID = '__row_index__';
6
+ export const ROW_INDEX_COLUMN_WIDTH = 56;
7
+
8
+ export interface UseSpreadsheetPinningOptions<T> {
9
+ columns: SpreadsheetColumn<T>[];
10
+ columnGroups?: SpreadsheetColumnGroup[];
11
+ showRowIndex?: boolean;
12
+ defaultPinnedColumns?: string[];
13
+ defaultPinRowIndex?: boolean;
14
+ }
15
+
16
+ export interface UseSpreadsheetPinningReturn<T> {
17
+ // Pinned state
18
+ pinnedColumns: Map<string, 'left' | 'right'>;
19
+ isRowIndexPinned: boolean;
20
+
21
+ // Collapsed groups
22
+ collapsedGroups: Set<string>;
23
+
24
+ // Visible columns (filtered by groups and reordered by pinning)
25
+ visibleColumns: SpreadsheetColumn<T>[];
26
+
27
+ // Actions
28
+ handleTogglePin: (columnId: string) => void;
29
+ handleToggleRowIndexPin: () => void;
30
+ handleToggleGroupCollapse: (groupId: string) => void;
31
+
32
+ // Offset calculations
33
+ getColumnLeftOffset: (columnId: string) => number;
34
+ getRowIndexLeftOffset: () => number;
35
+
36
+ // Utility
37
+ isColumnPinned: (columnId: string) => boolean;
38
+ getColumnPinSide: (columnId: string) => 'left' | 'right' | undefined;
39
+ }
40
+
41
+ export function useSpreadsheetPinning<T>({
42
+ columns,
43
+ columnGroups,
44
+ showRowIndex = true,
45
+ defaultPinnedColumns = [],
46
+ defaultPinRowIndex = false,
47
+ }: UseSpreadsheetPinningOptions<T>): UseSpreadsheetPinningReturn<T> {
48
+ // Initialize pinned columns from defaults
49
+ const [pinnedColumns, setPinnedColumns] = useState<Map<string, 'left' | 'right'>>(() => {
50
+ const map = new Map<string, 'left' | 'right'>();
51
+ defaultPinnedColumns.forEach((col) => {
52
+ map.set(col, 'left');
53
+ });
54
+ return map;
55
+ });
56
+
57
+ const [isRowIndexPinned, setIsRowIndexPinned] = useState(defaultPinRowIndex);
58
+ const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
59
+
60
+ // Toggle column pin
61
+ const handleTogglePin = useCallback((columnId: string) => {
62
+ setPinnedColumns((prev) => {
63
+ const newMap = new Map(prev);
64
+ if (newMap.has(columnId)) {
65
+ newMap.delete(columnId);
66
+ } else {
67
+ newMap.set(columnId, 'left');
68
+ }
69
+ return newMap;
70
+ });
71
+ }, []);
72
+
73
+ // Toggle row index pin
74
+ const handleToggleRowIndexPin = useCallback(() => {
75
+ setIsRowIndexPinned((prev) => !prev);
76
+ }, []);
77
+
78
+ // Toggle group collapse
79
+ const handleToggleGroupCollapse = useCallback((groupId: string) => {
80
+ setCollapsedGroups((prev) => {
81
+ const newSet = new Set(prev);
82
+ if (newSet.has(groupId)) {
83
+ newSet.delete(groupId);
84
+ } else {
85
+ newSet.add(groupId);
86
+ }
87
+ return newSet;
88
+ });
89
+ }, []);
90
+
91
+ // Calculate visible columns based on groups, collapse state, and pinning order
92
+ const visibleColumns = useMemo(() => {
93
+ if (!columns || !Array.isArray(columns) || columns.length === 0) {
94
+ return [];
95
+ }
96
+
97
+ let result: SpreadsheetColumn<T>[] = [...columns];
98
+
99
+ // Filter based on column groups and collapse state
100
+ if (columnGroups && Array.isArray(columnGroups)) {
101
+ result = result.filter((column) => {
102
+ const group = columnGroups.find((g) => g.columns.includes(column.id));
103
+ if (!group) return true;
104
+ if (!collapsedGroups.has(group.id)) return true;
105
+ // When collapsed, only show pinned columns
106
+ return pinnedColumns.has(column.id);
107
+ });
108
+ }
109
+
110
+ // If no columns are pinned, return result as-is to preserve original order
111
+ if (pinnedColumns.size === 0) {
112
+ return result;
113
+ }
114
+
115
+ // Reorder columns: left-pinned first, unpinned in middle, right-pinned last
116
+ const leftPinned: SpreadsheetColumn<T>[] = [];
117
+ const unpinned: SpreadsheetColumn<T>[] = [];
118
+ const rightPinned: SpreadsheetColumn<T>[] = [];
119
+
120
+ // Maintain the order of pinned columns as they were added
121
+ const pinnedLeftIds = Array.from(pinnedColumns.entries())
122
+ .filter(([, side]) => side === 'left')
123
+ .map(([id]) => id);
124
+ const pinnedRightIds = Array.from(pinnedColumns.entries())
125
+ .filter(([, side]) => side === 'right')
126
+ .map(([id]) => id);
127
+
128
+ for (const column of result) {
129
+ const pinSide = pinnedColumns.get(column.id);
130
+ if (pinSide === 'left') {
131
+ leftPinned.push(column);
132
+ } else if (pinSide === 'right') {
133
+ rightPinned.push(column);
134
+ } else {
135
+ unpinned.push(column);
136
+ }
137
+ }
138
+
139
+ // Sort left and right pinned columns based on their order in the pinnedColumns map
140
+ leftPinned.sort((a, b) => pinnedLeftIds.indexOf(a.id) - pinnedLeftIds.indexOf(b.id));
141
+ rightPinned.sort((a, b) => pinnedRightIds.indexOf(a.id) - pinnedRightIds.indexOf(b.id));
142
+
143
+ return [...leftPinned, ...unpinned, ...rightPinned];
144
+ }, [columns, columnGroups, collapsedGroups, pinnedColumns]);
145
+
146
+ // Get left offset for row index column
147
+ const getRowIndexLeftOffset = useCallback((): number => {
148
+ return 0;
149
+ }, []);
150
+
151
+ // Calculate column offset for sticky positioning
152
+ const getColumnLeftOffset = useCallback(
153
+ (columnId: string): number => {
154
+ const pinnedLeft = Array.from(pinnedColumns.entries())
155
+ .filter(([, side]) => side === 'left')
156
+ .map(([id]) => id);
157
+ const index = pinnedLeft.indexOf(columnId);
158
+
159
+ // Base offset includes the row index column if shown and pinned
160
+ const baseOffset = showRowIndex && isRowIndexPinned ? ROW_INDEX_COLUMN_WIDTH : 0;
161
+
162
+ if (index === -1) return baseOffset;
163
+
164
+ let offset = baseOffset;
165
+ for (let i = 0; i < index; i++) {
166
+ const col = columns.find((c) => c.id === pinnedLeft[i]);
167
+ offset += col?.width || col?.minWidth || 100;
168
+ }
169
+ return offset;
170
+ },
171
+ [pinnedColumns, columns, showRowIndex, isRowIndexPinned]
172
+ );
173
+
174
+ // Check if column is pinned
175
+ const isColumnPinned = useCallback(
176
+ (columnId: string): boolean => {
177
+ return pinnedColumns.has(columnId);
178
+ },
179
+ [pinnedColumns]
180
+ );
181
+
182
+ // Get column pin side
183
+ const getColumnPinSide = useCallback(
184
+ (columnId: string): 'left' | 'right' | undefined => {
185
+ return pinnedColumns.get(columnId);
186
+ },
187
+ [pinnedColumns]
188
+ );
189
+
190
+ return {
191
+ pinnedColumns,
192
+ isRowIndexPinned,
193
+ collapsedGroups,
194
+ visibleColumns,
195
+ handleTogglePin,
196
+ handleToggleRowIndexPin,
197
+ handleToggleGroupCollapse,
198
+ getColumnLeftOffset,
199
+ getRowIndexLeftOffset,
200
+ isColumnPinned,
201
+ getColumnPinSide,
202
+ };
203
+ }
@@ -0,0 +1,167 @@
1
+ import { useCallback, useState } from 'react';
2
+
3
+ export interface UseSpreadsheetUndoRedoOptions {
4
+ /** Whether undo/redo is enabled */
5
+ enabled?: boolean;
6
+ /** Maximum stack size (default: 50) */
7
+ maxStackSize?: number;
8
+ /** Auto-save mode */
9
+ autoSave?: boolean;
10
+ }
11
+
12
+ export interface UseSpreadsheetUndoRedoReturn<TSnapshot> {
13
+ // State
14
+ undoStack: TSnapshot[];
15
+ redoStack: TSnapshot[];
16
+ hasUnsavedChanges: boolean;
17
+ saveStatus: 'saved' | 'saving' | 'unsaved' | 'error';
18
+
19
+ // Computed
20
+ canUndo: boolean;
21
+ canRedo: boolean;
22
+ undoCount: number;
23
+ redoCount: number;
24
+
25
+ // Actions
26
+ handleUndo: () => TSnapshot | null;
27
+ handleRedo: () => TSnapshot | null;
28
+ handleSave: () => void;
29
+ pushToUndoStack: (snapshot: TSnapshot) => void;
30
+ clearStacks: () => void;
31
+
32
+ // For tracking changes
33
+ markAsChanged: () => void;
34
+ markAsSaved: () => void;
35
+ }
36
+
37
+ export function useSpreadsheetUndoRedo<TSnapshot>({
38
+ enabled = true,
39
+ maxStackSize = 50,
40
+ autoSave = true,
41
+ }: UseSpreadsheetUndoRedoOptions): UseSpreadsheetUndoRedoReturn<TSnapshot> {
42
+ const [undoStack, setUndoStack] = useState<TSnapshot[]>([]);
43
+ const [redoStack, setRedoStack] = useState<TSnapshot[]>([]);
44
+ const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
45
+ const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'unsaved' | 'error'>('saved');
46
+
47
+ // Push current state to undo stack before making changes
48
+ const pushToUndoStack = useCallback(
49
+ (snapshot: TSnapshot) => {
50
+ if (!enabled) return;
51
+
52
+ setUndoStack((prev) => {
53
+ const newStack = [...prev, snapshot];
54
+ // Limit stack size
55
+ if (newStack.length > maxStackSize) {
56
+ return newStack.slice(-maxStackSize);
57
+ }
58
+ return newStack;
59
+ });
60
+ // Clear redo stack when new action is performed
61
+ setRedoStack([]);
62
+ },
63
+ [enabled, maxStackSize]
64
+ );
65
+
66
+ // Undo action - returns the state to restore
67
+ const handleUndo = useCallback((): TSnapshot | null => {
68
+ if (!enabled || undoStack.length === 0) return null;
69
+
70
+ const previousSnapshot = undoStack[undoStack.length - 1];
71
+
72
+ // Pop from undo stack
73
+ setUndoStack((prev) => prev.slice(0, -1));
74
+
75
+ // Move snapshot to redo stack
76
+ setRedoStack((prev) => {
77
+ const newStack = [...prev, previousSnapshot];
78
+ if (newStack.length > maxStackSize) {
79
+ return newStack.slice(-maxStackSize);
80
+ }
81
+ return newStack;
82
+ });
83
+
84
+ return previousSnapshot;
85
+ }, [enabled, undoStack, maxStackSize]);
86
+
87
+ // Redo action - returns the state to restore
88
+ const handleRedo = useCallback((): TSnapshot | null => {
89
+ if (!enabled || redoStack.length === 0) return null;
90
+
91
+ const nextSnapshot = redoStack[redoStack.length - 1];
92
+
93
+ // Pop from redo stack
94
+ setRedoStack((prev) => prev.slice(0, -1));
95
+
96
+ // Move snapshot back to undo stack
97
+ setUndoStack((prev) => {
98
+ const newStack = [...prev, nextSnapshot];
99
+ if (newStack.length > maxStackSize) {
100
+ return newStack.slice(-maxStackSize);
101
+ }
102
+ return newStack;
103
+ });
104
+
105
+ return nextSnapshot;
106
+ }, [enabled, redoStack, maxStackSize]);
107
+
108
+ // Save action
109
+ const handleSave = useCallback(() => {
110
+ if (!hasUnsavedChanges) return;
111
+
112
+ setSaveStatus('saving');
113
+ // Simulate save delay
114
+ setTimeout(() => {
115
+ setSaveStatus('saved');
116
+ setHasUnsavedChanges(false);
117
+ }, 500);
118
+ }, [hasUnsavedChanges]);
119
+
120
+ // Mark as changed (call after data modification)
121
+ const markAsChanged = useCallback(() => {
122
+ setHasUnsavedChanges(true);
123
+ if (autoSave) {
124
+ setSaveStatus('saving');
125
+ setTimeout(() => setSaveStatus('saved'), 500);
126
+ } else {
127
+ setSaveStatus('unsaved');
128
+ }
129
+ }, [autoSave]);
130
+
131
+ // Mark as saved
132
+ const markAsSaved = useCallback(() => {
133
+ setHasUnsavedChanges(false);
134
+ setSaveStatus('saved');
135
+ }, []);
136
+
137
+ // Clear both stacks
138
+ const clearStacks = useCallback(() => {
139
+ setUndoStack([]);
140
+ setRedoStack([]);
141
+ }, []);
142
+
143
+ return {
144
+ // State
145
+ undoStack,
146
+ redoStack,
147
+ hasUnsavedChanges,
148
+ saveStatus,
149
+
150
+ // Computed
151
+ canUndo: enabled && undoStack.length > 0,
152
+ canRedo: enabled && redoStack.length > 0,
153
+ undoCount: undoStack.length,
154
+ redoCount: redoStack.length,
155
+
156
+ // Actions
157
+ handleUndo,
158
+ handleRedo,
159
+ handleSave,
160
+ pushToUndoStack,
161
+ clearStacks,
162
+
163
+ // Change tracking
164
+ markAsChanged,
165
+ markAsSaved,
166
+ };
167
+ }
package/src/index.ts ADDED
@@ -0,0 +1,31 @@
1
+ // Main Spreadsheet component
2
+ export { Spreadsheet } from './components/Spreadsheet';
3
+
4
+ // Sub-components
5
+ export { SpreadsheetCell } from './components/SpreadsheetCell';
6
+ export { SpreadsheetHeader } from './components/SpreadsheetHeader';
7
+ export { SpreadsheetFilterDropdown } from './components/SpreadsheetFilterDropdown';
8
+ export { SpreadsheetToolbar } from './components/SpreadsheetToolbar';
9
+ export { SpreadsheetSettingsModal } from './components/SpreadsheetSettingsModal';
10
+ export type { SpreadsheetSettings } from './components/SpreadsheetSettingsModal';
11
+
12
+ // Types
13
+ export type {
14
+ SpreadsheetProps,
15
+ SpreadsheetColumn,
16
+ SpreadsheetColumnGroup,
17
+ SpreadsheetSortConfig,
18
+ SpreadsheetColumnFilter,
19
+ SpreadsheetState,
20
+ SpreadsheetCellProps,
21
+ SpreadsheetHeaderProps,
22
+ SpreadsheetFilterDropdownProps,
23
+ SpreadsheetToolbarProps,
24
+ SpreadsheetColumnGroupHeaderProps,
25
+ CellPosition,
26
+ CellHighlight,
27
+ CellComment,
28
+ SelectionState,
29
+ PaginationState,
30
+ RowAction,
31
+ } from './types';