@xcelsior/ui-spreadsheets 1.2.2 → 1.3.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/.omc/state/agent-replay-0cead415-b3bd-40fd-b199-47371946c4db.jsonl +27 -0
- package/.omc/state/idle-notif-cooldown.json +3 -0
- package/.omc/state/last-tool-error.json +7 -0
- package/.omc/state/mission-state.json +189 -0
- package/.omc/state/subagent-tracking.json +125 -0
- package/.turbo/turbo-build.log +28 -28
- package/.turbo/turbo-lint.log +140 -0
- package/dist/index.d.mts +94 -4
- package/dist/index.d.ts +94 -4
- package/dist/index.js +2134 -1157
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2024 -1049
- package/dist/index.mjs.map +1 -1
- package/dist/styles/globals.css +156 -16
- package/dist/styles/globals.css.map +1 -1
- package/package.json +1 -1
- package/plans/20260330-1230-spreadsheet-features/phase-01-types-and-duplicates-hook.md +73 -0
- package/plans/20260330-1230-spreadsheet-features/phase-02-filter-dropdown-portal.md +90 -0
- package/plans/20260330-1230-spreadsheet-features/phase-03-header-overflow-menu.md +101 -0
- package/plans/20260330-1230-spreadsheet-features/phase-04-integration.md +193 -0
- package/plans/20260330-1230-spreadsheet-features/plan.md +59 -0
- package/src/components/ColorPickerPopover.tsx +77 -32
- package/src/components/ColumnHeaderActions.tsx +241 -1
- package/src/components/RowIndexColumnHeader.tsx +13 -17
- package/src/components/SelectionSummaryBar.tsx +103 -0
- package/src/components/Spreadsheet.stories.tsx +254 -0
- package/src/components/Spreadsheet.tsx +235 -190
- package/src/components/SpreadsheetCell.tsx +280 -42
- package/src/components/SpreadsheetFilterDropdown.tsx +178 -13
- package/src/components/SpreadsheetHeader.tsx +79 -24
- package/src/components/SpreadsheetSettingsModal.tsx +4 -0
- package/src/hooks/useSpreadsheetColumnResize.ts +143 -0
- package/src/hooks/useSpreadsheetDuplicates.ts +149 -0
- package/src/hooks/useSpreadsheetFiltering.ts +18 -1
- package/src/hooks/useSpreadsheetHighlighting.ts +23 -3
- package/src/hooks/useSpreadsheetKeyboardShortcuts.ts +16 -0
- package/src/hooks/useSpreadsheetPinning.ts +148 -134
- package/src/hooks/useSpreadsheetSelection.ts +10 -22
- package/src/hooks/useSpreadsheetSummary.ts +68 -0
- package/src/index.ts +4 -1
- package/src/styles/globals.css +51 -0
- package/src/types.ts +50 -2
- package/storybook-static/assets/Color-YHDXOIA2-CtQurLnT.js +1 -0
- package/storybook-static/assets/DocsRenderer-CFRXHY34-oxrW8Hvo.js +575 -0
- package/storybook-static/assets/Spreadsheet.stories-DvhhzuK4.js +1357 -0
- package/storybook-static/assets/chunk-XP5HYGXS-BpfKkqn7.js +1 -0
- package/storybook-static/assets/entry-preview-CkBGHCAN.js +2 -0
- package/storybook-static/assets/entry-preview-docs-ugJb6pa8.js +46 -0
- package/storybook-static/assets/iframe-CPp2u3vg.js +211 -0
- package/storybook-static/assets/index-BB9bPxRC.js +24 -0
- package/storybook-static/assets/index-BQFlzFLk.js +9 -0
- package/storybook-static/assets/index-CtvPRVHf.js +9 -0
- package/storybook-static/assets/index-DgH-xKnr.js +11 -0
- package/storybook-static/assets/index-DrFu-skq.js +6 -0
- package/storybook-static/assets/index-DrdPSA1J.js +240 -0
- package/storybook-static/assets/index-DzFBShOR.js +20 -0
- package/storybook-static/assets/index-v-1boR4t.js +1 -0
- package/storybook-static/assets/preview-B8lJiyuQ.js +34 -0
- package/storybook-static/assets/preview-BBWR9nbA.js +1 -0
- package/storybook-static/assets/preview-BWzBA1C2.js +396 -0
- package/storybook-static/assets/preview-Bm0S-uxO.css +1 -0
- package/storybook-static/assets/preview-CvbIS5ZJ.js +1 -0
- package/storybook-static/assets/preview-DD_OYowb.js +1 -0
- package/storybook-static/assets/preview-DGUiP6tS.js +7 -0
- package/storybook-static/assets/preview-DHQbi4pV.js +1 -0
- package/storybook-static/assets/preview-DwI0w3cI.js +1 -0
- package/storybook-static/assets/preview-DyR7iiFG.js +1 -0
- package/storybook-static/assets/preview-zxZ6Be2V.js +2 -0
- package/storybook-static/assets/react-18-Pj8skaX9.js +1 -0
- package/storybook-static/assets/test-utils-quxJ1Z79.js +9 -0
- package/storybook-static/favicon.svg +1 -0
- package/storybook-static/iframe.html +666 -0
- package/storybook-static/index.html +177 -0
- package/storybook-static/index.json +1 -0
- package/storybook-static/nunito-sans-bold-italic.woff2 +0 -0
- package/storybook-static/nunito-sans-bold.woff2 +0 -0
- package/storybook-static/nunito-sans-italic.woff2 +0 -0
- package/storybook-static/nunito-sans-regular.woff2 +0 -0
- package/storybook-static/project.json +1 -0
- package/storybook-static/sb-addons/essentials-actions-3/manager-bundle.js +3 -0
- package/storybook-static/sb-addons/essentials-backgrounds-5/manager-bundle.js +12 -0
- package/storybook-static/sb-addons/essentials-controls-2/manager-bundle.js +405 -0
- package/storybook-static/sb-addons/essentials-docs-4/manager-bundle.js +245 -0
- package/storybook-static/sb-addons/essentials-measure-8/manager-bundle.js +3 -0
- package/storybook-static/sb-addons/essentials-outline-9/manager-bundle.js +3 -0
- package/storybook-static/sb-addons/essentials-toolbars-7/manager-bundle.js +3 -0
- package/storybook-static/sb-addons/essentials-viewport-6/manager-bundle.js +3 -0
- package/storybook-static/sb-addons/interactions-10/manager-bundle.js +222 -0
- package/storybook-static/sb-addons/links-1/manager-bundle.js +3 -0
- package/storybook-static/sb-addons/storybook-core-core-server-presets-0/common-manager-bundle.js +3 -0
- package/storybook-static/sb-common-assets/favicon.svg +1 -0
- package/storybook-static/sb-common-assets/nunito-sans-bold-italic.woff2 +0 -0
- package/storybook-static/sb-common-assets/nunito-sans-bold.woff2 +0 -0
- package/storybook-static/sb-common-assets/nunito-sans-italic.woff2 +0 -0
- package/storybook-static/sb-common-assets/nunito-sans-regular.woff2 +0 -0
- package/storybook-static/sb-manager/globals-module-info.js +1052 -0
- package/storybook-static/sb-manager/globals-runtime.js +42127 -0
- package/storybook-static/sb-manager/globals.js +48 -0
- package/storybook-static/sb-manager/runtime.js +12048 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { useCallback, useMemo, useState } from 'react';
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import type { SpreadsheetColumn, SpreadsheetColumnGroup } from '../types';
|
|
3
3
|
|
|
4
4
|
// Special column ID for row index
|
|
5
5
|
export const ROW_INDEX_COLUMN_ID = '__row_index__';
|
|
6
|
-
export const ROW_INDEX_COLUMN_WIDTH =
|
|
7
|
-
//
|
|
6
|
+
export const ROW_INDEX_COLUMN_WIDTH = 90;
|
|
7
|
+
// Kept for backward compatibility but no longer enforced on cells
|
|
8
8
|
export const MIN_PINNED_COLUMN_WIDTH = 150;
|
|
9
9
|
|
|
10
10
|
export interface UseSpreadsheetPinningOptions<T> {
|
|
@@ -13,31 +13,24 @@ export interface UseSpreadsheetPinningOptions<T> {
|
|
|
13
13
|
showRowIndex?: boolean;
|
|
14
14
|
defaultPinnedColumns?: string[];
|
|
15
15
|
defaultPinnedRightColumns?: string[];
|
|
16
|
+
/** Function to get the current (possibly resized) width for a column */
|
|
17
|
+
getColumnWidth?: (columnId: string, defaultWidth?: number) => number | undefined;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
export interface UseSpreadsheetPinningReturn<T> {
|
|
19
|
-
// Pinned state
|
|
20
21
|
pinnedColumns: Map<string, 'left' | 'right'>;
|
|
21
22
|
isRowIndexPinned: boolean;
|
|
22
|
-
|
|
23
|
-
// Collapsed groups
|
|
24
23
|
collapsedGroups: Set<string>;
|
|
25
|
-
|
|
26
|
-
// Visible columns (filtered by groups and reordered by pinning)
|
|
27
24
|
visibleColumns: SpreadsheetColumn<T>[];
|
|
28
|
-
|
|
29
|
-
// Actions
|
|
30
25
|
handleTogglePin: (columnId: string) => void;
|
|
31
26
|
handleToggleGroupCollapse: (groupId: string) => void;
|
|
32
27
|
setPinnedColumnsFromIds: (columnIds: string[]) => void;
|
|
33
|
-
|
|
34
|
-
// Offset calculations
|
|
35
28
|
getColumnLeftOffset: (columnId: string) => number;
|
|
36
29
|
getColumnRightOffset: (columnId: string) => number;
|
|
37
|
-
|
|
38
|
-
// Utility
|
|
39
30
|
isColumnPinned: (columnId: string) => boolean;
|
|
40
31
|
getColumnPinSide: (columnId: string) => 'left' | 'right' | undefined;
|
|
32
|
+
getPinnedZIndex: (columnId: string) => number;
|
|
33
|
+
measureRef: (el: HTMLElement | null) => void;
|
|
41
34
|
}
|
|
42
35
|
|
|
43
36
|
export function useSpreadsheetPinning<T>({
|
|
@@ -46,47 +39,94 @@ export function useSpreadsheetPinning<T>({
|
|
|
46
39
|
showRowIndex = true,
|
|
47
40
|
defaultPinnedColumns = [],
|
|
48
41
|
defaultPinnedRightColumns = [],
|
|
42
|
+
getColumnWidth,
|
|
49
43
|
}: UseSpreadsheetPinningOptions<T>): UseSpreadsheetPinningReturn<T> {
|
|
50
|
-
// Initialize pinned columns from defaults (including row index)
|
|
51
44
|
const [pinnedColumns, setPinnedColumns] = useState<Map<string, 'left' | 'right'>>(() => {
|
|
52
45
|
const map = new Map<string, 'left' | 'right'>();
|
|
46
|
+
if (showRowIndex) {
|
|
47
|
+
map.set(ROW_INDEX_COLUMN_ID, 'left');
|
|
48
|
+
}
|
|
49
|
+
const midpoint = Math.floor(columns.length / 2);
|
|
53
50
|
defaultPinnedColumns.forEach((col) => {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
map.set(col, 'right');
|
|
51
|
+
if (col === ROW_INDEX_COLUMN_ID) return;
|
|
52
|
+
const colIndex = columns.findIndex((c) => c.id === col);
|
|
53
|
+
map.set(col, colIndex >= 0 && colIndex <= midpoint ? 'left' : 'right');
|
|
58
54
|
});
|
|
55
|
+
defaultPinnedRightColumns.forEach((col) => map.set(col, 'right'));
|
|
59
56
|
return map;
|
|
60
57
|
});
|
|
61
58
|
|
|
62
59
|
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
|
63
60
|
|
|
64
|
-
//
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
//
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
61
|
+
// DOM measurement for accurate widths
|
|
62
|
+
const measuredWidthsRef = useRef<Map<string, number>>(new Map());
|
|
63
|
+
const tableElRef = useRef<HTMLElement | null>(null);
|
|
64
|
+
// Counter to trigger re-computation of offset memos after measurement
|
|
65
|
+
const [measureVersion, setMeasureVersion] = useState(0);
|
|
66
|
+
|
|
67
|
+
const measureColumnWidths = useCallback(() => {
|
|
68
|
+
const el = tableElRef.current;
|
|
69
|
+
if (!el) return;
|
|
70
|
+
const newMap = new Map<string, number>();
|
|
71
|
+
const headers = el.querySelectorAll('th[data-column-id]');
|
|
72
|
+
headers.forEach((th) => {
|
|
73
|
+
const colId = th.getAttribute('data-column-id');
|
|
74
|
+
if (colId) {
|
|
75
|
+
newMap.set(colId, (th as HTMLElement).offsetWidth);
|
|
75
76
|
}
|
|
76
|
-
return newMap;
|
|
77
77
|
});
|
|
78
|
+
// Measure row index from first data row
|
|
79
|
+
const rowIndexTd = el.querySelector('td[data-column-id="__row_index__"]');
|
|
80
|
+
if (rowIndexTd) {
|
|
81
|
+
newMap.set(ROW_INDEX_COLUMN_ID, (rowIndexTd as HTMLElement).offsetWidth);
|
|
82
|
+
}
|
|
83
|
+
measuredWidthsRef.current = newMap;
|
|
84
|
+
setMeasureVersion((v) => v + 1);
|
|
78
85
|
}, []);
|
|
79
86
|
|
|
80
|
-
|
|
87
|
+
const measureRef = useCallback((el: HTMLElement | null) => {
|
|
88
|
+
tableElRef.current = el;
|
|
89
|
+
}, []);
|
|
90
|
+
|
|
91
|
+
const isRowIndexPinned = pinnedColumns.has(ROW_INDEX_COLUMN_ID);
|
|
92
|
+
|
|
93
|
+
// Toggle pin with auto left/right detection
|
|
94
|
+
const handleTogglePin = useCallback(
|
|
95
|
+
(columnId: string) => {
|
|
96
|
+
setPinnedColumns((prev) => {
|
|
97
|
+
const newMap = new Map(prev);
|
|
98
|
+
if (newMap.has(columnId)) {
|
|
99
|
+
newMap.delete(columnId);
|
|
100
|
+
} else {
|
|
101
|
+
if (columnId === ROW_INDEX_COLUMN_ID) {
|
|
102
|
+
newMap.set(columnId, 'left');
|
|
103
|
+
} else {
|
|
104
|
+
const colIndex = columns.findIndex((c) => c.id === columnId);
|
|
105
|
+
const midpoint = Math.floor(columns.length / 2);
|
|
106
|
+
newMap.set(columnId, colIndex <= midpoint ? 'left' : 'right');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return newMap;
|
|
110
|
+
});
|
|
111
|
+
},
|
|
112
|
+
[columns]
|
|
113
|
+
);
|
|
114
|
+
|
|
81
115
|
const setPinnedColumnsFromIds = useCallback((columnIds: string[]) => {
|
|
82
116
|
const map = new Map<string, 'left' | 'right'>();
|
|
117
|
+
if (showRowIndex) {
|
|
118
|
+
map.set(ROW_INDEX_COLUMN_ID, 'left');
|
|
119
|
+
}
|
|
120
|
+
const midpoint = Math.floor(columns.length / 2);
|
|
83
121
|
columnIds.forEach((col) => {
|
|
84
|
-
|
|
122
|
+
if (col !== ROW_INDEX_COLUMN_ID) {
|
|
123
|
+
const colIndex = columns.findIndex((c) => c.id === col);
|
|
124
|
+
map.set(col, colIndex >= 0 && colIndex <= midpoint ? 'left' : 'right');
|
|
125
|
+
}
|
|
85
126
|
});
|
|
86
127
|
setPinnedColumns(map);
|
|
87
|
-
}, []);
|
|
128
|
+
}, [showRowIndex, columns]);
|
|
88
129
|
|
|
89
|
-
// Toggle group collapse
|
|
90
130
|
const handleToggleGroupCollapse = useCallback((groupId: string) => {
|
|
91
131
|
setCollapsedGroups((prev) => {
|
|
92
132
|
const newSet = new Set(prev);
|
|
@@ -99,135 +139,107 @@ export function useSpreadsheetPinning<T>({
|
|
|
99
139
|
});
|
|
100
140
|
}, []);
|
|
101
141
|
|
|
102
|
-
//
|
|
142
|
+
// Visible columns — filtered by collapse, natural order preserved
|
|
103
143
|
const visibleColumns = useMemo(() => {
|
|
104
|
-
if (!columns || !Array.isArray(columns) || columns.length === 0)
|
|
105
|
-
return [];
|
|
106
|
-
}
|
|
144
|
+
if (!columns || !Array.isArray(columns) || columns.length === 0) return [];
|
|
107
145
|
|
|
108
146
|
let result: SpreadsheetColumn<T>[] = [...columns];
|
|
109
147
|
|
|
110
|
-
// Filter based on column groups and collapse state
|
|
111
148
|
if (columnGroups && Array.isArray(columnGroups)) {
|
|
112
149
|
result = result.filter((column) => {
|
|
113
150
|
const group = columnGroups.find((g) => g.columns.includes(column.id));
|
|
114
151
|
if (!group) return true;
|
|
115
152
|
if (!collapsedGroups.has(group.id)) return true;
|
|
116
|
-
// When collapsed, only show pinned columns
|
|
117
153
|
return pinnedColumns.has(column.id);
|
|
118
154
|
});
|
|
119
155
|
}
|
|
120
156
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
(id) => id !== ROW_INDEX_COLUMN_ID
|
|
124
|
-
);
|
|
125
|
-
if (nonRowIndexPinned.length === 0) {
|
|
126
|
-
return result;
|
|
127
|
-
}
|
|
157
|
+
return result;
|
|
158
|
+
}, [columns, columnGroups, collapsedGroups, pinnedColumns]);
|
|
128
159
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
.
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
160
|
+
// Measure after render when layout changes
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
requestAnimationFrame(() => measureColumnWidths());
|
|
163
|
+
}, [measureColumnWidths, visibleColumns, pinnedColumns]);
|
|
164
|
+
|
|
165
|
+
// Pre-compute a width lookup map (fast O(1) per column)
|
|
166
|
+
const widthMap = useMemo(() => {
|
|
167
|
+
// measureVersion is in deps to recompute when DOM measurements update
|
|
168
|
+
void measureVersion;
|
|
169
|
+
const map = new Map<string, number>();
|
|
170
|
+
for (const col of columns) {
|
|
171
|
+
const measured = measuredWidthsRef.current.get(col.id);
|
|
172
|
+
if (measured) { map.set(col.id, measured); continue; }
|
|
173
|
+
const resized = getColumnWidth?.(col.id);
|
|
174
|
+
if (resized) { map.set(col.id, resized); continue; }
|
|
175
|
+
map.set(col.id, col.width || col.minWidth || 100);
|
|
176
|
+
}
|
|
177
|
+
// Row index
|
|
178
|
+
const measuredRI = measuredWidthsRef.current.get(ROW_INDEX_COLUMN_ID);
|
|
179
|
+
map.set(ROW_INDEX_COLUMN_ID, measuredRI || ROW_INDEX_COLUMN_WIDTH);
|
|
180
|
+
return map;
|
|
181
|
+
}, [columns, getColumnWidth, measureVersion]);
|
|
182
|
+
|
|
183
|
+
// Pre-compute all offsets and z-indices in a single pass (O(n) total, not O(n²))
|
|
184
|
+
const { leftOffsets, rightOffsets, zIndices } = useMemo(() => {
|
|
185
|
+
const leftOffsets = new Map<string, number>();
|
|
186
|
+
const rightOffsets = new Map<string, number>();
|
|
187
|
+
const zIndices = new Map<string, number>();
|
|
188
|
+
|
|
189
|
+
// Row index
|
|
190
|
+
leftOffsets.set(ROW_INDEX_COLUMN_ID, 0);
|
|
191
|
+
zIndices.set(ROW_INDEX_COLUMN_ID, 40);
|
|
192
|
+
|
|
193
|
+
// Collect left-pinned and right-pinned in natural order
|
|
194
|
+
const leftPinned = visibleColumns.filter((c) => pinnedColumns.get(c.id) === 'left');
|
|
195
|
+
const rightPinned = visibleColumns.filter((c) => pinnedColumns.get(c.id) === 'right');
|
|
196
|
+
|
|
197
|
+
// Left offsets: cumulative from left edge
|
|
198
|
+
const isRowIndexPinnedNow = pinnedColumns.has(ROW_INDEX_COLUMN_ID);
|
|
199
|
+
let leftOffset = showRowIndex && isRowIndexPinnedNow ? (widthMap.get(ROW_INDEX_COLUMN_ID) || ROW_INDEX_COLUMN_WIDTH) : 0;
|
|
200
|
+
for (let i = 0; i < leftPinned.length; i++) {
|
|
201
|
+
leftOffsets.set(leftPinned[i].id, leftOffset);
|
|
202
|
+
// Earlier left-pinned = higher z-index
|
|
203
|
+
zIndices.set(leftPinned[i].id, 20 + (leftPinned.length - i));
|
|
204
|
+
leftOffset += widthMap.get(leftPinned[i].id) || 100;
|
|
151
205
|
}
|
|
152
206
|
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
207
|
+
// Right offsets: cumulative from right edge
|
|
208
|
+
let rightOffset = 0;
|
|
209
|
+
for (let i = rightPinned.length - 1; i >= 0; i--) {
|
|
210
|
+
rightOffsets.set(rightPinned[i].id, rightOffset);
|
|
211
|
+
// Later right-pinned (closer to edge) = higher z-index
|
|
212
|
+
zIndices.set(rightPinned[i].id, 20 + (rightPinned.length - i));
|
|
213
|
+
rightOffset += widthMap.get(rightPinned[i].id) || 100;
|
|
214
|
+
}
|
|
156
215
|
|
|
157
|
-
return
|
|
158
|
-
}, [
|
|
216
|
+
return { leftOffsets, rightOffsets, zIndices };
|
|
217
|
+
}, [pinnedColumns, visibleColumns, showRowIndex, widthMap]);
|
|
159
218
|
|
|
160
|
-
//
|
|
219
|
+
// O(1) lookups using pre-computed maps
|
|
161
220
|
const getColumnLeftOffset = useCallback(
|
|
162
|
-
(columnId: string): number =>
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
return 0;
|
|
166
|
-
}
|
|
221
|
+
(columnId: string): number => leftOffsets.get(columnId) ?? 0,
|
|
222
|
+
[leftOffsets]
|
|
223
|
+
);
|
|
167
224
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
.map(([id]) => id);
|
|
172
|
-
const index = pinnedLeft.indexOf(columnId);
|
|
173
|
-
|
|
174
|
-
// Base offset includes the row index column if shown and pinned
|
|
175
|
-
const isRowIndexPinnedNow = pinnedColumns.has(ROW_INDEX_COLUMN_ID);
|
|
176
|
-
const baseOffset = showRowIndex && isRowIndexPinnedNow ? ROW_INDEX_COLUMN_WIDTH : 0;
|
|
177
|
-
|
|
178
|
-
if (index === -1) return baseOffset;
|
|
179
|
-
|
|
180
|
-
let offset = baseOffset;
|
|
181
|
-
for (let i = 0; i < index; i++) {
|
|
182
|
-
const col = columns.find((c) => c.id === pinnedLeft[i]);
|
|
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);
|
|
187
|
-
}
|
|
188
|
-
return offset;
|
|
189
|
-
},
|
|
190
|
-
[pinnedColumns, columns, showRowIndex]
|
|
225
|
+
const getColumnRightOffset = useCallback(
|
|
226
|
+
(columnId: string): number => rightOffsets.get(columnId) ?? 0,
|
|
227
|
+
[rightOffsets]
|
|
191
228
|
);
|
|
192
229
|
|
|
193
|
-
// Check if column is pinned
|
|
194
230
|
const isColumnPinned = useCallback(
|
|
195
|
-
(columnId: string): boolean =>
|
|
196
|
-
return pinnedColumns.has(columnId);
|
|
197
|
-
},
|
|
231
|
+
(columnId: string): boolean => pinnedColumns.has(columnId),
|
|
198
232
|
[pinnedColumns]
|
|
199
233
|
);
|
|
200
234
|
|
|
201
|
-
// Get column pin side
|
|
202
235
|
const getColumnPinSide = useCallback(
|
|
203
|
-
(columnId: string): 'left' | 'right' | undefined =>
|
|
204
|
-
return pinnedColumns.get(columnId);
|
|
205
|
-
},
|
|
236
|
+
(columnId: string): 'left' | 'right' | undefined => pinnedColumns.get(columnId),
|
|
206
237
|
[pinnedColumns]
|
|
207
238
|
);
|
|
208
239
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
// Get right-pinned columns
|
|
213
|
-
const pinnedRight = Array.from(pinnedColumns.entries())
|
|
214
|
-
.filter(([, side]) => side === 'right')
|
|
215
|
-
.map(([id]) => id);
|
|
216
|
-
|
|
217
|
-
const index = pinnedRight.indexOf(columnId);
|
|
218
|
-
if (index === -1) return 0;
|
|
219
|
-
|
|
220
|
-
// Calculate offset from the right edge
|
|
221
|
-
// Start from 0 and add widths of columns to the right of this one
|
|
222
|
-
let offset = 0;
|
|
223
|
-
for (let i = pinnedRight.length - 1; i > index; i--) {
|
|
224
|
-
const col = columns.find((c) => c.id === pinnedRight[i]);
|
|
225
|
-
const configuredWidth = col?.minWidth || col?.width || MIN_PINNED_COLUMN_WIDTH;
|
|
226
|
-
offset += Math.max(configuredWidth, MIN_PINNED_COLUMN_WIDTH);
|
|
227
|
-
}
|
|
228
|
-
return offset;
|
|
229
|
-
},
|
|
230
|
-
[pinnedColumns, columns]
|
|
240
|
+
const getPinnedZIndex = useCallback(
|
|
241
|
+
(columnId: string): number => zIndices.get(columnId) ?? 0,
|
|
242
|
+
[zIndices]
|
|
231
243
|
);
|
|
232
244
|
|
|
233
245
|
return {
|
|
@@ -242,5 +254,7 @@ export function useSpreadsheetPinning<T>({
|
|
|
242
254
|
getColumnRightOffset,
|
|
243
255
|
isColumnPinned,
|
|
244
256
|
getColumnPinSide,
|
|
257
|
+
getPinnedZIndex,
|
|
258
|
+
measureRef,
|
|
245
259
|
};
|
|
246
260
|
}
|
|
@@ -256,7 +256,7 @@ export function useSpreadsheetSelection<T extends Record<string, any>>({
|
|
|
256
256
|
});
|
|
257
257
|
}, [getSelectedCells, data, columns, getRowId]);
|
|
258
258
|
|
|
259
|
-
// Handle cell click
|
|
259
|
+
// Handle cell click (focus only — edit mode requires double-click or F2)
|
|
260
260
|
const handleCellClick = useCallback(
|
|
261
261
|
(rowId: string | number, columnId: string, event: React.MouseEvent) => {
|
|
262
262
|
event.stopPropagation();
|
|
@@ -271,22 +271,17 @@ export function useSpreadsheetSelection<T extends Record<string, any>>({
|
|
|
271
271
|
});
|
|
272
272
|
setFocusedCell(newCell);
|
|
273
273
|
} else {
|
|
274
|
-
// Single cell selection
|
|
274
|
+
// Single cell selection — focus only, no edit
|
|
275
275
|
anchorCell.current = newCell;
|
|
276
276
|
setFocusedCell(newCell);
|
|
277
277
|
setSelectedCellRange(null);
|
|
278
|
-
|
|
279
|
-
// Enter edit mode if editable
|
|
280
|
-
const column = columns.find((c) => c.id === columnId);
|
|
281
|
-
if (column?.editable && enableCellEditing) {
|
|
282
|
-
setEditingCell(newCell);
|
|
283
|
-
}
|
|
278
|
+
setEditingCell(null);
|
|
284
279
|
}
|
|
285
280
|
},
|
|
286
|
-
[
|
|
281
|
+
[setSelectedCellRange]
|
|
287
282
|
);
|
|
288
283
|
|
|
289
|
-
// Handle cell mouse down (for
|
|
284
|
+
// Handle cell mouse down (for focus and drag-to-select initiation)
|
|
290
285
|
const handleCellMouseDown = useCallback(
|
|
291
286
|
(rowId: string | number, columnId: string, event: React.MouseEvent) => {
|
|
292
287
|
// Only handle left mouse button
|
|
@@ -304,26 +299,19 @@ export function useSpreadsheetSelection<T extends Record<string, any>>({
|
|
|
304
299
|
setFocusedCell(newCell);
|
|
305
300
|
setEditingCell(null);
|
|
306
301
|
} else {
|
|
307
|
-
// Regular click: focus cell
|
|
302
|
+
// Regular click: focus cell only (edit mode requires double-click or F2)
|
|
308
303
|
anchorCell.current = newCell;
|
|
309
304
|
setFocusedCell(newCell);
|
|
310
305
|
setSelectedCellRange(null);
|
|
311
|
-
|
|
312
|
-
// Enter edit mode if editable
|
|
313
|
-
const column = columns.find((c) => c.id === columnId);
|
|
314
|
-
if (column?.editable && enableCellEditing) {
|
|
315
|
-
setEditingCell(newCell);
|
|
316
|
-
} else {
|
|
317
|
-
setEditingCell(null);
|
|
318
|
-
}
|
|
306
|
+
setEditingCell(null);
|
|
319
307
|
}
|
|
320
308
|
},
|
|
321
|
-
[
|
|
309
|
+
[setSelectedCellRange]
|
|
322
310
|
);
|
|
323
311
|
|
|
324
|
-
// Handle cell mouse enter (
|
|
312
|
+
// Handle cell mouse enter (drag selection disabled — use Shift+Click or Shift+Arrow for range selection)
|
|
325
313
|
const handleCellMouseEnter = useCallback((_rowId: string | number, _columnId: string) => {
|
|
326
|
-
//
|
|
314
|
+
// Intentionally empty — drag-to-select disabled for better UX in data table context
|
|
327
315
|
}, []);
|
|
328
316
|
|
|
329
317
|
// Handle mouse up (end drag)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import type { CellPosition, SpreadsheetColumn } from '../types';
|
|
3
|
+
|
|
4
|
+
export interface SelectionSummary {
|
|
5
|
+
sum: number;
|
|
6
|
+
avg: number;
|
|
7
|
+
count: number;
|
|
8
|
+
numericCount: number;
|
|
9
|
+
min: number;
|
|
10
|
+
max: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface UseSpreadsheetSummaryOptions<T> {
|
|
14
|
+
/** Currently selected cell values */
|
|
15
|
+
selectedCellValues: { position: CellPosition; value: any }[];
|
|
16
|
+
/** Column definitions for type checking */
|
|
17
|
+
columns: SpreadsheetColumn<T>[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface UseSpreadsheetSummaryReturn {
|
|
21
|
+
/** Computed summary for the current selection */
|
|
22
|
+
summary: SelectionSummary | null;
|
|
23
|
+
/** Whether there are numeric values to summarize */
|
|
24
|
+
hasNumericValues: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Hook for computing Excel-like summary statistics (Sum, Avg, Count, Min, Max)
|
|
29
|
+
* for the current cell selection. Only computes for numeric values.
|
|
30
|
+
*/
|
|
31
|
+
export function useSpreadsheetSummary<T>({
|
|
32
|
+
selectedCellValues,
|
|
33
|
+
columns,
|
|
34
|
+
}: UseSpreadsheetSummaryOptions<T>): UseSpreadsheetSummaryReturn {
|
|
35
|
+
const summary = useMemo(() => {
|
|
36
|
+
if (selectedCellValues.length === 0) return null;
|
|
37
|
+
|
|
38
|
+
const numericValues: number[] = [];
|
|
39
|
+
|
|
40
|
+
for (const { position, value } of selectedCellValues) {
|
|
41
|
+
const column = columns.find((c) => c.id === position.columnId);
|
|
42
|
+
// Include values from numeric columns or any parseable number
|
|
43
|
+
if (column?.type === 'number' || typeof value === 'number') {
|
|
44
|
+
const num = typeof value === 'number' ? value : parseFloat(value);
|
|
45
|
+
if (!isNaN(num)) {
|
|
46
|
+
numericValues.push(num);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (numericValues.length === 0) return null;
|
|
52
|
+
|
|
53
|
+
const sum = numericValues.reduce((a, b) => a + b, 0);
|
|
54
|
+
return {
|
|
55
|
+
sum: Math.round(sum * 100) / 100,
|
|
56
|
+
avg: Math.round((sum / numericValues.length) * 100) / 100,
|
|
57
|
+
count: selectedCellValues.length,
|
|
58
|
+
numericCount: numericValues.length,
|
|
59
|
+
min: Math.round(Math.min(...numericValues) * 100) / 100,
|
|
60
|
+
max: Math.round(Math.max(...numericValues) * 100) / 100,
|
|
61
|
+
};
|
|
62
|
+
}, [selectedCellValues, columns]);
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
summary,
|
|
66
|
+
hasNumericValues: summary !== null,
|
|
67
|
+
};
|
|
68
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
// Main Spreadsheet component
|
|
2
2
|
export { Spreadsheet } from './components/Spreadsheet';
|
|
3
3
|
|
|
4
|
+
// Hooks
|
|
5
|
+
export { useSpreadsheetDuplicates } from './hooks/useSpreadsheetDuplicates';
|
|
6
|
+
|
|
4
7
|
// Sub-components
|
|
5
8
|
export { SpreadsheetCell } from './components/SpreadsheetCell';
|
|
6
|
-
export { SpreadsheetHeader } from './components/SpreadsheetHeader';
|
|
9
|
+
export { SpreadsheetHeader, MemoizedSpreadsheetHeader } from './components/SpreadsheetHeader';
|
|
7
10
|
export { SpreadsheetFilterDropdown } from './components/SpreadsheetFilterDropdown';
|
|
8
11
|
export { SpreadsheetToolbar } from './components/SpreadsheetToolbar';
|
|
9
12
|
export { SpreadsheetSettingsModal } from './components/SpreadsheetSettingsModal';
|
package/src/styles/globals.css
CHANGED
|
@@ -1,3 +1,54 @@
|
|
|
1
1
|
@import "tailwindcss";
|
|
2
2
|
|
|
3
3
|
@source "../**/*.{ts,tsx,css}";
|
|
4
|
+
|
|
5
|
+
/* Minimal scrollbar styling scoped to the spreadsheet container */
|
|
6
|
+
.spreadsheet-scroll-container {
|
|
7
|
+
scrollbar-width: thin;
|
|
8
|
+
scrollbar-color: rgb(156 163 175) transparent;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.spreadsheet-scroll-container::-webkit-scrollbar {
|
|
12
|
+
width: 6px;
|
|
13
|
+
height: 6px;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.spreadsheet-scroll-container::-webkit-scrollbar-track {
|
|
17
|
+
background: transparent;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.spreadsheet-scroll-container::-webkit-scrollbar-thumb {
|
|
21
|
+
background-color: rgb(209 213 219);
|
|
22
|
+
border-radius: 3px;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.spreadsheet-scroll-container::-webkit-scrollbar-thumb:hover {
|
|
26
|
+
background-color: rgb(156 163 175);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.spreadsheet-scroll-container::-webkit-scrollbar-corner {
|
|
30
|
+
background: transparent;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/* Row hover via CSS to avoid React re-renders */
|
|
34
|
+
.spreadsheet-scroll-container tbody tr:hover > td {
|
|
35
|
+
background-color: rgb(249 250 251) !important; /* gray-50 */
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* Don't override selected or highlighted row backgrounds */
|
|
39
|
+
.spreadsheet-scroll-container tbody tr:hover > td[style*="dbeafe"],
|
|
40
|
+
.spreadsheet-scroll-container tbody tr:hover > td[style*="239 246 255"] {
|
|
41
|
+
background-color: rgb(219 234 254) !important; /* blue-100, keep selected */
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* Processing overlay anchored to the table wrapper */
|
|
45
|
+
.spreadsheet-processing-overlay {
|
|
46
|
+
position: absolute;
|
|
47
|
+
inset: 0;
|
|
48
|
+
background: rgba(255, 255, 255, 0.6);
|
|
49
|
+
display: flex;
|
|
50
|
+
align-items: center;
|
|
51
|
+
justify-content: center;
|
|
52
|
+
z-index: 60;
|
|
53
|
+
pointer-events: none;
|
|
54
|
+
}
|