@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
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xcelsior/ui-spreadsheets",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"main": "dist/index.js",
|
|
5
|
+
"types": "dist/index.d.ts",
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"tailwind-merge": "^2.2.2",
|
|
8
|
+
"clsx": "^2.1.0"
|
|
9
|
+
},
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"@types/react": "^18.2.0",
|
|
12
|
+
"@types/react-dom": "^18.2.0",
|
|
13
|
+
"@storybook/addon-essentials": "^8.6.14",
|
|
14
|
+
"@storybook/addon-interactions": "^8.6.14",
|
|
15
|
+
"@storybook/addon-links": "^8.6.14",
|
|
16
|
+
"@storybook/blocks": "^8.6.14",
|
|
17
|
+
"@storybook/react": "^8.6.14",
|
|
18
|
+
"@storybook/react-vite": "^8.6.14",
|
|
19
|
+
"@storybook/testing-library": "^0.2.2",
|
|
20
|
+
"storybook": "^8.6.14",
|
|
21
|
+
"react-icons": "^4.12.0",
|
|
22
|
+
"tsup": "^8.5.0",
|
|
23
|
+
"typescript": "^5.8.3",
|
|
24
|
+
"tailwindcss": "^4.0.0",
|
|
25
|
+
"@xcelsior/design-system": "1.0.6"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"react": "^18.2.0",
|
|
29
|
+
"react-dom": "^18.2.0",
|
|
30
|
+
"react-hook-form": "^7",
|
|
31
|
+
"@xcelsior/design-system": "^1.0.6",
|
|
32
|
+
"flowbite": "^1.5.3",
|
|
33
|
+
"flowbite-react": "^0.12"
|
|
34
|
+
},
|
|
35
|
+
"exports": {
|
|
36
|
+
".": {
|
|
37
|
+
"import": "./src/index.ts",
|
|
38
|
+
"require": "./dist/index.js"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "tsup && tsc --noEmit",
|
|
43
|
+
"prepublish": "npm run build",
|
|
44
|
+
"build:watch": "tsup --watch",
|
|
45
|
+
"dev": "tsup --watch",
|
|
46
|
+
"storybook": "storybook dev -p 6007",
|
|
47
|
+
"build-storybook": "storybook build",
|
|
48
|
+
"lint": "biome check . && tsc",
|
|
49
|
+
"type-check": "tsc --noEmit"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { cn } from '../utils';
|
|
2
|
+
import { HIGHLIGHT_COLORS } from '../hooks/useSpreadsheetHighlighting';
|
|
3
|
+
|
|
4
|
+
export type ColorPaletteType = 'row' | 'column';
|
|
5
|
+
|
|
6
|
+
export interface ColorPickerPopoverProps {
|
|
7
|
+
/** Title displayed in the popover */
|
|
8
|
+
title: string;
|
|
9
|
+
/** Type of color palette to use */
|
|
10
|
+
paletteType?: ColorPaletteType;
|
|
11
|
+
/** Custom colors array (overrides paletteType) */
|
|
12
|
+
colors?: (string | null)[];
|
|
13
|
+
/** Callback when a color is selected */
|
|
14
|
+
onSelectColor: (color: string | null) => void;
|
|
15
|
+
/** Callback when the popover is closed/cancelled */
|
|
16
|
+
onClose: () => void;
|
|
17
|
+
/** Additional className for the container */
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A reusable color picker popover component for highlighting.
|
|
23
|
+
* Supports both row (darker) and column (lighter) color palettes.
|
|
24
|
+
*/
|
|
25
|
+
export function ColorPickerPopover({
|
|
26
|
+
title,
|
|
27
|
+
paletteType = 'column',
|
|
28
|
+
colors,
|
|
29
|
+
onSelectColor,
|
|
30
|
+
onClose,
|
|
31
|
+
className,
|
|
32
|
+
}: ColorPickerPopoverProps) {
|
|
33
|
+
// Use custom colors if provided, otherwise use palette based on type
|
|
34
|
+
const colorPalette = colors ?? [...HIGHLIGHT_COLORS[paletteType], null];
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
|
38
|
+
<div className={cn('bg-white rounded-lg shadow-xl p-4 w-64', className)}>
|
|
39
|
+
<h3 className="text-sm font-semibold mb-3">{title}</h3>
|
|
40
|
+
<div className="grid grid-cols-5 gap-2">
|
|
41
|
+
{colorPalette.map((color) => (
|
|
42
|
+
<button
|
|
43
|
+
key={color || 'clear'}
|
|
44
|
+
type="button"
|
|
45
|
+
onClick={() => onSelectColor(color)}
|
|
46
|
+
className={cn(
|
|
47
|
+
'w-8 h-8 rounded border-2 transition-transform hover:scale-110',
|
|
48
|
+
color
|
|
49
|
+
? 'border-gray-300'
|
|
50
|
+
: 'border-gray-300 bg-white flex items-center justify-center text-gray-400 text-xs'
|
|
51
|
+
)}
|
|
52
|
+
style={color ? { backgroundColor: color } : undefined}
|
|
53
|
+
title={color || 'Clear highlight'}
|
|
54
|
+
>
|
|
55
|
+
{!color && '✕'}
|
|
56
|
+
</button>
|
|
57
|
+
))}
|
|
58
|
+
</div>
|
|
59
|
+
<div className="flex justify-end mt-4">
|
|
60
|
+
<button
|
|
61
|
+
type="button"
|
|
62
|
+
onClick={onClose}
|
|
63
|
+
className="px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 rounded transition-colors"
|
|
64
|
+
>
|
|
65
|
+
Cancel
|
|
66
|
+
</button>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
ColorPickerPopover.displayName = 'ColorPickerPopover';
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
import { HiColorSwatch, HiFilter } from 'react-icons/hi';
|
|
3
|
+
import { MdOutlinePushPin, MdPushPin } from 'react-icons/md';
|
|
4
|
+
import { cn } from '../utils';
|
|
5
|
+
|
|
6
|
+
export interface ColumnHeaderActionsProps {
|
|
7
|
+
/** Whether filtering is enabled for this column */
|
|
8
|
+
enableFiltering?: boolean;
|
|
9
|
+
/** Whether highlighting is enabled for this column */
|
|
10
|
+
enableHighlighting?: boolean;
|
|
11
|
+
/** Whether pinning is enabled for this column */
|
|
12
|
+
enablePinning?: boolean;
|
|
13
|
+
/** Whether the column currently has an active filter */
|
|
14
|
+
hasActiveFilter?: boolean;
|
|
15
|
+
/** Whether the column currently has an active highlight */
|
|
16
|
+
hasActiveHighlight?: boolean;
|
|
17
|
+
/** Whether the column is currently pinned */
|
|
18
|
+
isPinned?: boolean;
|
|
19
|
+
/** Callback when filter button is clicked */
|
|
20
|
+
onFilterClick?: () => void;
|
|
21
|
+
/** Callback when highlight button is clicked */
|
|
22
|
+
onHighlightClick?: () => void;
|
|
23
|
+
/** Callback when pin button is clicked */
|
|
24
|
+
onPinClick?: () => void;
|
|
25
|
+
/** Title for the filter button */
|
|
26
|
+
filterTitle?: string;
|
|
27
|
+
/** Title for the highlight button */
|
|
28
|
+
highlightTitle?: string;
|
|
29
|
+
/** Title for the pin button when pinned */
|
|
30
|
+
pinnedTitle?: string;
|
|
31
|
+
/** Title for the pin button when unpinned */
|
|
32
|
+
unpinnedTitle?: string;
|
|
33
|
+
/** Additional className */
|
|
34
|
+
className?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Reusable action buttons for column headers (filter, highlight, pin).
|
|
39
|
+
* Works for both regular columns and the row index column.
|
|
40
|
+
*/
|
|
41
|
+
export function ColumnHeaderActions({
|
|
42
|
+
enableFiltering = false,
|
|
43
|
+
enableHighlighting = false,
|
|
44
|
+
enablePinning = true,
|
|
45
|
+
hasActiveFilter = false,
|
|
46
|
+
hasActiveHighlight = false,
|
|
47
|
+
isPinned = false,
|
|
48
|
+
onFilterClick,
|
|
49
|
+
onHighlightClick,
|
|
50
|
+
onPinClick,
|
|
51
|
+
filterTitle = 'Filter column',
|
|
52
|
+
highlightTitle = 'Highlight column',
|
|
53
|
+
pinnedTitle = 'Unpin column',
|
|
54
|
+
unpinnedTitle = 'Pin column',
|
|
55
|
+
className,
|
|
56
|
+
}: ColumnHeaderActionsProps) {
|
|
57
|
+
const handleClick = (e: React.MouseEvent) => {
|
|
58
|
+
e.stopPropagation();
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
62
|
+
e.stopPropagation();
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<button
|
|
67
|
+
type="button"
|
|
68
|
+
className={cn('flex items-center gap-0.5', className)}
|
|
69
|
+
onClick={handleClick}
|
|
70
|
+
onKeyDown={handleKeyDown}
|
|
71
|
+
>
|
|
72
|
+
{/* Filter button */}
|
|
73
|
+
{enableFiltering && onFilterClick && (
|
|
74
|
+
<button
|
|
75
|
+
type="button"
|
|
76
|
+
onClick={(e) => {
|
|
77
|
+
e.stopPropagation();
|
|
78
|
+
onFilterClick();
|
|
79
|
+
}}
|
|
80
|
+
className={cn(
|
|
81
|
+
'p-0.5 hover:bg-gray-200 rounded transition-opacity',
|
|
82
|
+
hasActiveFilter
|
|
83
|
+
? 'text-blue-600 opacity-100'
|
|
84
|
+
: 'text-gray-400 opacity-0 group-hover:opacity-100'
|
|
85
|
+
)}
|
|
86
|
+
title={filterTitle}
|
|
87
|
+
>
|
|
88
|
+
<HiFilter className="h-3 w-3" />
|
|
89
|
+
</button>
|
|
90
|
+
)}
|
|
91
|
+
|
|
92
|
+
{/* Highlight button */}
|
|
93
|
+
{enableHighlighting && onHighlightClick && (
|
|
94
|
+
<button
|
|
95
|
+
type="button"
|
|
96
|
+
onClick={(e) => {
|
|
97
|
+
e.stopPropagation();
|
|
98
|
+
onHighlightClick();
|
|
99
|
+
}}
|
|
100
|
+
className={cn(
|
|
101
|
+
'p-0.5 hover:bg-gray-200 rounded transition-opacity',
|
|
102
|
+
hasActiveHighlight
|
|
103
|
+
? 'text-amber-500 opacity-100'
|
|
104
|
+
: 'text-gray-400 opacity-0 group-hover:opacity-100'
|
|
105
|
+
)}
|
|
106
|
+
title={highlightTitle}
|
|
107
|
+
>
|
|
108
|
+
<HiColorSwatch className="h-3 w-3" />
|
|
109
|
+
</button>
|
|
110
|
+
)}
|
|
111
|
+
|
|
112
|
+
{/* Pin button */}
|
|
113
|
+
{enablePinning && onPinClick && (
|
|
114
|
+
<button
|
|
115
|
+
type="button"
|
|
116
|
+
onClick={(e) => {
|
|
117
|
+
e.stopPropagation();
|
|
118
|
+
onPinClick();
|
|
119
|
+
}}
|
|
120
|
+
className={cn(
|
|
121
|
+
'p-0.5 hover:bg-gray-200 rounded transition-opacity',
|
|
122
|
+
isPinned
|
|
123
|
+
? 'text-blue-600 opacity-100'
|
|
124
|
+
: 'text-gray-400 opacity-0 group-hover:opacity-100'
|
|
125
|
+
)}
|
|
126
|
+
title={isPinned ? pinnedTitle : unpinnedTitle}
|
|
127
|
+
>
|
|
128
|
+
{isPinned ? (
|
|
129
|
+
<MdPushPin className="h-3 w-3" />
|
|
130
|
+
) : (
|
|
131
|
+
<MdOutlinePushPin className="h-3 w-3" />
|
|
132
|
+
)}
|
|
133
|
+
</button>
|
|
134
|
+
)}
|
|
135
|
+
</button>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
ColumnHeaderActions.displayName = 'ColumnHeaderActions';
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { cn } from '../utils';
|
|
2
|
+
import type { CellComment } from '../types';
|
|
3
|
+
|
|
4
|
+
// ==================== Add Comment Modal ====================
|
|
5
|
+
|
|
6
|
+
export interface AddCommentModalProps {
|
|
7
|
+
/** Whether the modal is open */
|
|
8
|
+
isOpen: boolean;
|
|
9
|
+
/** Current comment text */
|
|
10
|
+
commentText: string;
|
|
11
|
+
/** Callback to update comment text */
|
|
12
|
+
onCommentTextChange: (text: string) => void;
|
|
13
|
+
/** Callback to add the comment */
|
|
14
|
+
onAdd: () => void;
|
|
15
|
+
/** Callback to close/cancel */
|
|
16
|
+
onClose: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function AddCommentModal({
|
|
20
|
+
isOpen,
|
|
21
|
+
commentText,
|
|
22
|
+
onCommentTextChange,
|
|
23
|
+
onAdd,
|
|
24
|
+
onClose,
|
|
25
|
+
}: AddCommentModalProps) {
|
|
26
|
+
if (!isOpen) return null;
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
|
30
|
+
<div className="bg-white rounded-lg shadow-xl p-6 w-96 max-w-full mx-4">
|
|
31
|
+
<h3 className="text-lg font-semibold mb-4">Add Row Comment</h3>
|
|
32
|
+
<textarea
|
|
33
|
+
value={commentText}
|
|
34
|
+
onChange={(e) => onCommentTextChange(e.target.value)}
|
|
35
|
+
placeholder="Enter your comment..."
|
|
36
|
+
className="w-full h-24 p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
|
|
37
|
+
/>
|
|
38
|
+
<div className="flex justify-end gap-2 mt-4">
|
|
39
|
+
<button
|
|
40
|
+
type="button"
|
|
41
|
+
onClick={onClose}
|
|
42
|
+
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
|
43
|
+
>
|
|
44
|
+
Cancel
|
|
45
|
+
</button>
|
|
46
|
+
<button
|
|
47
|
+
type="button"
|
|
48
|
+
onClick={onAdd}
|
|
49
|
+
disabled={!commentText.trim()}
|
|
50
|
+
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
51
|
+
>
|
|
52
|
+
Add Comment
|
|
53
|
+
</button>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
AddCommentModal.displayName = 'AddCommentModal';
|
|
61
|
+
|
|
62
|
+
// ==================== View Comments Modal ====================
|
|
63
|
+
|
|
64
|
+
export interface ViewCommentsModalProps {
|
|
65
|
+
/** Whether the modal is open */
|
|
66
|
+
isOpen: boolean;
|
|
67
|
+
/** Comments to display */
|
|
68
|
+
comments: CellComment[];
|
|
69
|
+
/** Callback to toggle comment resolved status */
|
|
70
|
+
onToggleResolved: (commentId: string) => void;
|
|
71
|
+
/** Callback to close the modal */
|
|
72
|
+
onClose: () => void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function ViewCommentsModal({
|
|
76
|
+
isOpen,
|
|
77
|
+
comments,
|
|
78
|
+
onToggleResolved,
|
|
79
|
+
onClose,
|
|
80
|
+
}: ViewCommentsModalProps) {
|
|
81
|
+
if (!isOpen) return null;
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
|
85
|
+
<div className="bg-white rounded-lg shadow-xl p-6 w-[480px] max-w-full mx-4 max-h-[80vh] flex flex-col">
|
|
86
|
+
<div className="flex items-center justify-between mb-4">
|
|
87
|
+
<h3 className="text-lg font-semibold">Row Comments</h3>
|
|
88
|
+
<button
|
|
89
|
+
type="button"
|
|
90
|
+
onClick={onClose}
|
|
91
|
+
className="p-1 hover:bg-gray-100 rounded-lg transition-colors"
|
|
92
|
+
>
|
|
93
|
+
✕
|
|
94
|
+
</button>
|
|
95
|
+
</div>
|
|
96
|
+
<div className="flex-1 overflow-y-auto space-y-3">
|
|
97
|
+
{comments.map((comment) => (
|
|
98
|
+
<div
|
|
99
|
+
key={comment.id}
|
|
100
|
+
className={cn(
|
|
101
|
+
'p-3 rounded-lg border',
|
|
102
|
+
comment.resolved
|
|
103
|
+
? 'bg-gray-50 border-gray-200'
|
|
104
|
+
: 'bg-yellow-50 border-yellow-200'
|
|
105
|
+
)}
|
|
106
|
+
>
|
|
107
|
+
<div className="flex items-start justify-between gap-2">
|
|
108
|
+
<p className="text-sm text-gray-700">{comment.text}</p>
|
|
109
|
+
<button
|
|
110
|
+
type="button"
|
|
111
|
+
onClick={() => onToggleResolved(comment.id)}
|
|
112
|
+
className={cn(
|
|
113
|
+
'flex-shrink-0 px-2 py-1 text-xs rounded transition-colors',
|
|
114
|
+
comment.resolved
|
|
115
|
+
? 'bg-gray-200 text-gray-600 hover:bg-gray-300'
|
|
116
|
+
: 'bg-green-100 text-green-700 hover:bg-green-200'
|
|
117
|
+
)}
|
|
118
|
+
>
|
|
119
|
+
{comment.resolved ? 'Reopen' : 'Resolve'}
|
|
120
|
+
</button>
|
|
121
|
+
</div>
|
|
122
|
+
<div className="flex items-center gap-2 mt-2 text-xs text-gray-500">
|
|
123
|
+
{comment.author && <span>{comment.author}</span>}
|
|
124
|
+
<span>{new Date(comment.timestamp).toLocaleString()}</span>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
))}
|
|
128
|
+
{comments.length === 0 && (
|
|
129
|
+
<p className="text-center text-gray-500 py-8">No comments for this row.</p>
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
ViewCommentsModal.displayName = 'ViewCommentsModal';
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
export interface KeyboardShortcutsModalProps {
|
|
4
|
+
/** Whether the modal is open */
|
|
5
|
+
isOpen: boolean;
|
|
6
|
+
/** Callback to close the modal */
|
|
7
|
+
onClose: () => void;
|
|
8
|
+
/** Shortcut definitions */
|
|
9
|
+
shortcuts: {
|
|
10
|
+
general: Array<{ label: string; keys: string[] }>;
|
|
11
|
+
rowSelection: Array<{ label: string; keys: string[] }>;
|
|
12
|
+
editing: Array<{ label: string; keys: string[] }>;
|
|
13
|
+
rowActions: Array<{ label: string; description: string }>;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Modal component displaying keyboard shortcuts for the spreadsheet.
|
|
19
|
+
*/
|
|
20
|
+
export function KeyboardShortcutsModal({
|
|
21
|
+
isOpen,
|
|
22
|
+
onClose,
|
|
23
|
+
shortcuts,
|
|
24
|
+
}: KeyboardShortcutsModalProps) {
|
|
25
|
+
if (!isOpen) return null;
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
29
|
+
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-2xl max-h-[80vh] overflow-y-auto mx-4">
|
|
30
|
+
<div className="flex items-center justify-between mb-4">
|
|
31
|
+
<h3 className="text-xl font-bold text-gray-900">Keyboard Shortcuts</h3>
|
|
32
|
+
<button
|
|
33
|
+
type="button"
|
|
34
|
+
onClick={onClose}
|
|
35
|
+
className="p-1 hover:bg-gray-100 rounded"
|
|
36
|
+
>
|
|
37
|
+
<span className="text-gray-500 text-xl">✕</span>
|
|
38
|
+
</button>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<div className="space-y-6">
|
|
42
|
+
{/* General Section */}
|
|
43
|
+
<ShortcutSection title="General">
|
|
44
|
+
{shortcuts.general.map((shortcut, index) => (
|
|
45
|
+
<ShortcutRow key={index} label={shortcut.label} keys={shortcut.keys} />
|
|
46
|
+
))}
|
|
47
|
+
</ShortcutSection>
|
|
48
|
+
|
|
49
|
+
{/* Row Selection Section */}
|
|
50
|
+
<ShortcutSection title="Row Selection">
|
|
51
|
+
{shortcuts.rowSelection.map((shortcut, index) => (
|
|
52
|
+
<ShortcutRow key={index} label={shortcut.label} keys={shortcut.keys} />
|
|
53
|
+
))}
|
|
54
|
+
</ShortcutSection>
|
|
55
|
+
|
|
56
|
+
{/* Editing Section */}
|
|
57
|
+
<ShortcutSection title="Editing">
|
|
58
|
+
{shortcuts.editing.map((shortcut, index) => (
|
|
59
|
+
<ShortcutRow key={index} label={shortcut.label} keys={shortcut.keys} />
|
|
60
|
+
))}
|
|
61
|
+
</ShortcutSection>
|
|
62
|
+
|
|
63
|
+
{/* Row Actions Section */}
|
|
64
|
+
<ShortcutSection title="Row Actions (hover over row #)">
|
|
65
|
+
{shortcuts.rowActions.map((action, index) => (
|
|
66
|
+
<div key={index} className="flex items-center justify-between">
|
|
67
|
+
<span className="text-gray-600 text-sm">{action.label}</span>
|
|
68
|
+
<span className="text-gray-500 text-xs">{action.description}</span>
|
|
69
|
+
</div>
|
|
70
|
+
))}
|
|
71
|
+
</ShortcutSection>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface ShortcutSectionProps {
|
|
79
|
+
title: string;
|
|
80
|
+
children: React.ReactNode;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function ShortcutSection({ title, children }: ShortcutSectionProps) {
|
|
84
|
+
return (
|
|
85
|
+
<div>
|
|
86
|
+
<h4 className="text-gray-900 font-semibold mb-3">{title}</h4>
|
|
87
|
+
<div className="space-y-2">{children}</div>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface ShortcutRowProps {
|
|
93
|
+
label: string;
|
|
94
|
+
keys: string[];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function ShortcutRow({ label, keys }: ShortcutRowProps) {
|
|
98
|
+
return (
|
|
99
|
+
<div className="flex items-center justify-between">
|
|
100
|
+
<span className="text-gray-600 text-sm">{label}</span>
|
|
101
|
+
<div className="flex items-center gap-1">
|
|
102
|
+
{keys.map((key, index) => (
|
|
103
|
+
<React.Fragment key={index}>
|
|
104
|
+
{index > 0 && <span className="text-gray-400">+</span>}
|
|
105
|
+
{key.includes('Click') ? (
|
|
106
|
+
<span className="text-gray-500 text-xs">{key}</span>
|
|
107
|
+
) : (
|
|
108
|
+
<kbd className="px-2 py-1 bg-gray-100 text-gray-800 rounded text-xs border border-gray-200">
|
|
109
|
+
{key}
|
|
110
|
+
</kbd>
|
|
111
|
+
)}
|
|
112
|
+
</React.Fragment>
|
|
113
|
+
))}
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
KeyboardShortcutsModal.displayName = 'KeyboardShortcutsModal';
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { cn } from '../utils';
|
|
2
|
+
import { ColumnHeaderActions } from './ColumnHeaderActions';
|
|
3
|
+
import { ROW_INDEX_COLUMN_WIDTH } from '../hooks/useSpreadsheetPinning';
|
|
4
|
+
|
|
5
|
+
export interface RowIndexColumnHeaderProps {
|
|
6
|
+
/** Whether highlighting is enabled */
|
|
7
|
+
enableHighlighting?: boolean;
|
|
8
|
+
/** Current highlight color (if any) */
|
|
9
|
+
highlightColor?: string;
|
|
10
|
+
/** Whether the column is pinned */
|
|
11
|
+
isPinned?: boolean;
|
|
12
|
+
/** Callback when highlight button is clicked */
|
|
13
|
+
onHighlightClick?: () => void;
|
|
14
|
+
/** Callback when pin button is clicked */
|
|
15
|
+
onPinClick?: () => void;
|
|
16
|
+
/** Whether this is in the column groups row (needs rowSpan=2) */
|
|
17
|
+
hasColumnGroups?: boolean;
|
|
18
|
+
/** Additional className */
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Row index column header (#) with highlight and pin actions.
|
|
24
|
+
* Uses the same ColumnHeaderActions component as regular columns for consistency.
|
|
25
|
+
*/
|
|
26
|
+
export function RowIndexColumnHeader({
|
|
27
|
+
enableHighlighting = false,
|
|
28
|
+
highlightColor,
|
|
29
|
+
isPinned = false,
|
|
30
|
+
onHighlightClick,
|
|
31
|
+
onPinClick,
|
|
32
|
+
hasColumnGroups = false,
|
|
33
|
+
className,
|
|
34
|
+
}: RowIndexColumnHeaderProps) {
|
|
35
|
+
return (
|
|
36
|
+
<th
|
|
37
|
+
className={cn(
|
|
38
|
+
'border border-gray-200 px-2 py-1.5 text-center font-bold text-gray-700 group',
|
|
39
|
+
isPinned ? 'z-30' : 'z-20',
|
|
40
|
+
className
|
|
41
|
+
)}
|
|
42
|
+
rowSpan={hasColumnGroups ? 2 : undefined}
|
|
43
|
+
style={{
|
|
44
|
+
minWidth: `${ROW_INDEX_COLUMN_WIDTH}px`,
|
|
45
|
+
width: `${ROW_INDEX_COLUMN_WIDTH}px`,
|
|
46
|
+
position: 'sticky',
|
|
47
|
+
top: 0,
|
|
48
|
+
left: isPinned ? 0 : undefined,
|
|
49
|
+
backgroundColor: highlightColor || 'rgb(243 244 246)',
|
|
50
|
+
}}
|
|
51
|
+
>
|
|
52
|
+
<div className="flex items-center justify-center gap-1">
|
|
53
|
+
<span>#</span>
|
|
54
|
+
<ColumnHeaderActions
|
|
55
|
+
enableHighlighting={enableHighlighting}
|
|
56
|
+
enablePinning={true}
|
|
57
|
+
hasActiveHighlight={!!highlightColor}
|
|
58
|
+
isPinned={isPinned}
|
|
59
|
+
onHighlightClick={onHighlightClick}
|
|
60
|
+
onPinClick={onPinClick}
|
|
61
|
+
highlightTitle="Highlight row index column"
|
|
62
|
+
pinnedTitle="Unpin row index column"
|
|
63
|
+
unpinnedTitle="Pin row index column"
|
|
64
|
+
/>
|
|
65
|
+
</div>
|
|
66
|
+
</th>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
RowIndexColumnHeader.displayName = 'RowIndexColumnHeader';
|