@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,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
|
+
}
|