@xcelsior/ui-spreadsheets 1.2.2 → 1.3.0
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 +25 -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 +179 -0
- package/.omc/state/subagent-tracking.json +116 -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 +2133 -1156
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2023 -1048
- 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 +234 -189
- 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
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# Phase 04: Integration + Loading Overlay
|
|
2
|
+
|
|
3
|
+
**Parallel group:** B (runs AFTER Phase 1, 2, 3)
|
|
4
|
+
**Files owned:** `Spreadsheet.tsx`, `SpreadsheetHeader.tsx`, `SpreadsheetCell.tsx`, `SpreadsheetSettingsModal.tsx`, `index.ts`
|
|
5
|
+
|
|
6
|
+
## Work
|
|
7
|
+
|
|
8
|
+
### 1. Wire Duplicate Detection into Spreadsheet.tsx
|
|
9
|
+
|
|
10
|
+
**Add hook usage:**
|
|
11
|
+
```ts
|
|
12
|
+
import { useSpreadsheetDuplicates } from '../hooks/useSpreadsheetDuplicates';
|
|
13
|
+
|
|
14
|
+
// In component body:
|
|
15
|
+
const [duplicateCheckColumns, setDuplicateCheckColumns] = useState<string[]>(
|
|
16
|
+
initialSettings?.duplicateCheckColumns ?? props.duplicateCheckColumns ?? []
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const { isCellDuplicate } = useSpreadsheetDuplicates({
|
|
20
|
+
data,
|
|
21
|
+
columns,
|
|
22
|
+
duplicateCheckColumns,
|
|
23
|
+
getRowId,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const handleDuplicateCheckToggle = useCallback((columnId: string) => {
|
|
27
|
+
setDuplicateCheckColumns(prev => {
|
|
28
|
+
const next = prev.includes(columnId)
|
|
29
|
+
? prev.filter(id => id !== columnId)
|
|
30
|
+
: [...prev, columnId];
|
|
31
|
+
onDuplicateCheckChange?.(next);
|
|
32
|
+
return next;
|
|
33
|
+
});
|
|
34
|
+
}, [onDuplicateCheckChange]);
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**Pass to SpreadsheetCell:**
|
|
38
|
+
```tsx
|
|
39
|
+
<SpreadsheetCell
|
|
40
|
+
...existingProps
|
|
41
|
+
isDuplicate={isCellDuplicate(rowId, column.id)}
|
|
42
|
+
/>
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**Pass to SpreadsheetHeader (via ColumnHeaderActions props):**
|
|
46
|
+
```tsx
|
|
47
|
+
<MemoizedSpreadsheetHeader
|
|
48
|
+
...existingProps
|
|
49
|
+
hasDuplicateCheck={duplicateCheckColumns.includes(column.id)}
|
|
50
|
+
onDuplicateCheckClick={() => handleDuplicateCheckToggle(column.id)}
|
|
51
|
+
/>
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### 2. Wire Filter Portal into Spreadsheet.tsx
|
|
55
|
+
|
|
56
|
+
**Add ref for each header cell:**
|
|
57
|
+
The filter dropdown currently renders as a child of `<MemoizedSpreadsheetHeader>`. Change to:
|
|
58
|
+
|
|
59
|
+
```tsx
|
|
60
|
+
// Create a ref map for header cells
|
|
61
|
+
const headerRefsMap = useRef<Map<string, HTMLElement>>(new Map());
|
|
62
|
+
|
|
63
|
+
// In the header render, pass ref callback:
|
|
64
|
+
// SpreadsheetHeader already renders a <th> -- capture via data attribute + ref callback
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**Simpler approach:** Pass a `triggerRef` from the `<th>` element. Since `SpreadsheetFilterDropdown` is rendered inside `MemoizedSpreadsheetHeader`'s children slot, add a ref to the `<th>` in `SpreadsheetHeader.tsx`:
|
|
68
|
+
|
|
69
|
+
In `SpreadsheetHeader.tsx`:
|
|
70
|
+
```tsx
|
|
71
|
+
const thRef = useRef<HTMLTableCellElement>(null);
|
|
72
|
+
// Add ref={thRef} to <th>
|
|
73
|
+
// Pass thRef to children via render prop or context
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**Preferred approach:** Use a ref on the `<th>` in SpreadsheetHeader and pass it down to the filter dropdown.
|
|
77
|
+
|
|
78
|
+
Add `headerRef` prop to `SpreadsheetHeaderProps` in types.ts -- wait, types.ts is Phase 1. Instead, use `useRef` locally in SpreadsheetHeader and pass to filter dropdown child via cloneElement or direct prop.
|
|
79
|
+
|
|
80
|
+
**Implementation:** In `SpreadsheetHeader.tsx`, add a ref to the `<th>` and expose it. Then in `Spreadsheet.tsx`, when rendering the `SpreadsheetFilterDropdown` as children of the header, pass the `triggerRef`:
|
|
81
|
+
|
|
82
|
+
```tsx
|
|
83
|
+
// SpreadsheetHeader.tsx changes:
|
|
84
|
+
const thRef = useRef<HTMLTableCellElement>(null);
|
|
85
|
+
// <th ref={thRef} ...>
|
|
86
|
+
// Pass thRef to children via React.Children.map + cloneElement
|
|
87
|
+
{React.Children.map(children, child =>
|
|
88
|
+
React.isValidElement(child) ? React.cloneElement(child, { triggerRef: thRef }) : child
|
|
89
|
+
)}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### 3. Update SpreadsheetHeader.tsx
|
|
93
|
+
|
|
94
|
+
- Add `thRef` and pass to `<th ref={thRef}>`
|
|
95
|
+
- Clone children with `triggerRef={thRef}`
|
|
96
|
+
- Add `hasDuplicateCheck` and `onDuplicateCheckClick` props, pass through to `ColumnHeaderActions`:
|
|
97
|
+
```tsx
|
|
98
|
+
<ColumnHeaderActions
|
|
99
|
+
...existingProps
|
|
100
|
+
resolvedWidth={resolvedWidth}
|
|
101
|
+
enableDuplicateCheck={true}
|
|
102
|
+
hasDuplicateCheck={hasDuplicateCheck}
|
|
103
|
+
onDuplicateCheckClick={onDuplicateCheckClick}
|
|
104
|
+
/>
|
|
105
|
+
```
|
|
106
|
+
- Update memo comparison to include `hasDuplicateCheck`
|
|
107
|
+
|
|
108
|
+
### 4. Update SpreadsheetCell.tsx
|
|
109
|
+
|
|
110
|
+
**Add duplicate highlight:**
|
|
111
|
+
In `getBackgroundColor()`:
|
|
112
|
+
```ts
|
|
113
|
+
if (isDuplicate) return 'rgb(254 226 226)'; // red-100 - light red for duplicates
|
|
114
|
+
```
|
|
115
|
+
Priority: duplicate highlight should be BELOW explicit `highlightColor` but ABOVE row selection/hover.
|
|
116
|
+
|
|
117
|
+
Updated order:
|
|
118
|
+
```ts
|
|
119
|
+
const getBackgroundColor = () => {
|
|
120
|
+
if (highlightColor) return highlightColor;
|
|
121
|
+
if (isDuplicate) return 'rgb(254 226 226)'; // red-100
|
|
122
|
+
if (isRowSelected) return 'rgb(219 234 254)'; // blue-100
|
|
123
|
+
if (isRowHovered) return 'rgb(243 244 246)'; // gray-100
|
|
124
|
+
if (isOddRow) return 'rgb(249 250 251)'; // gray-50
|
|
125
|
+
return 'white';
|
|
126
|
+
};
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**Update memo comparison** to include `isDuplicate`.
|
|
130
|
+
|
|
131
|
+
### 5. Client-Side Loading Overlay
|
|
132
|
+
|
|
133
|
+
**Add processing state to Spreadsheet.tsx:**
|
|
134
|
+
```ts
|
|
135
|
+
const [isProcessing, setIsProcessing] = useState(false);
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Wrap expensive operations with processing state:
|
|
139
|
+
- `handleSort`: set processing before, clear after
|
|
140
|
+
- `handleFilterChangeWithReset`: set processing before, clear after
|
|
141
|
+
- `handleDuplicateCheckToggle`: set processing before, clear after
|
|
142
|
+
|
|
143
|
+
Use `React.startTransition` for the state updates that trigger re-renders of the data:
|
|
144
|
+
```ts
|
|
145
|
+
const handleSort = useCallback((columnId: string) => {
|
|
146
|
+
setIsProcessing(true);
|
|
147
|
+
startTransition(() => {
|
|
148
|
+
// existing sort logic
|
|
149
|
+
setIsProcessing(false);
|
|
150
|
+
});
|
|
151
|
+
}, [...]);
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
**Overlay rendering in tbody:**
|
|
155
|
+
```tsx
|
|
156
|
+
{(isProcessing) && (
|
|
157
|
+
<div className="absolute inset-0 bg-white/60 flex items-center justify-center z-50 pointer-events-none">
|
|
158
|
+
<div className="flex items-center gap-2 text-gray-500">
|
|
159
|
+
<div className="w-4 h-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
|
160
|
+
Processing...
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The table wrapper div needs `position: relative` to anchor the overlay.
|
|
167
|
+
|
|
168
|
+
### 6. Update SpreadsheetSettingsModal.tsx
|
|
169
|
+
|
|
170
|
+
Add `duplicateCheckColumns` to `SpreadsheetSettings` interface:
|
|
171
|
+
```ts
|
|
172
|
+
duplicateCheckColumns?: string[];
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### 7. Update index.ts
|
|
176
|
+
|
|
177
|
+
Export the new hook:
|
|
178
|
+
```ts
|
|
179
|
+
export { useSpreadsheetDuplicates } from './hooks/useSpreadsheetDuplicates';
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### Acceptance criteria:
|
|
183
|
+
- [ ] Duplicate check toggle works from column header actions (both inline and overflow menu)
|
|
184
|
+
- [ ] Duplicate cells show light red background; explicit highlight color takes priority
|
|
185
|
+
- [ ] Filter dropdown renders as portal, positioned correctly relative to header cell
|
|
186
|
+
- [ ] Filter dropdown repositions on scroll/resize, closes on outside click + Escape
|
|
187
|
+
- [ ] Processing overlay shows during sort/filter/duplicate-check on large datasets
|
|
188
|
+
- [ ] `duplicateCheckColumns` prop controls which columns have duplicate checking
|
|
189
|
+
- [ ] `onDuplicateCheckChange` callback fires when user toggles duplicate check
|
|
190
|
+
- [ ] Settings persist `duplicateCheckColumns` like pinned columns
|
|
191
|
+
- [ ] `useSpreadsheetDuplicates` exported from package index
|
|
192
|
+
- [ ] No regressions: pinning, sorting, filtering, editing, sticky header, column resize all work
|
|
193
|
+
- [ ] MemoizedSpreadsheetCell and MemoizedSpreadsheetHeader updated to include new comparison props
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Spreadsheet Features - Implementation Plan
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
Add 4 features to `packages/ui/ui-spreadsheets`: duplicate check on columns, header icon overflow menu, client-side loading overlay, and filter dropdown portal. All must be parallel-safe with exclusive file ownership per phase.
|
|
5
|
+
|
|
6
|
+
## Dependency Graph
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
Phase 1 (Types + Hook) Phase 2 (Filter Portal)
|
|
10
|
+
types.ts SpreadsheetFilterDropdown.tsx
|
|
11
|
+
useSpreadsheetDuplicates.ts (NEW)
|
|
12
|
+
| |
|
|
13
|
+
v v
|
|
14
|
+
Phase 3 (Column Header Overflow) Phase 4 (Integration)
|
|
15
|
+
ColumnHeaderActions.tsx Spreadsheet.tsx
|
|
16
|
+
SpreadsheetHeader.tsx
|
|
17
|
+
SpreadsheetCell.tsx
|
|
18
|
+
SpreadsheetSettingsModal.tsx
|
|
19
|
+
index.ts
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
**Parallel execution:**
|
|
23
|
+
- Phase 1 and Phase 2: PARALLEL (no shared files)
|
|
24
|
+
- Phase 3: PARALLEL with Phase 1 and Phase 2 (no shared files)
|
|
25
|
+
- Phase 4: SEQUENTIAL after Phase 1 + 2 + 3 (integrates all features into Spreadsheet.tsx)
|
|
26
|
+
|
|
27
|
+
## File Ownership Matrix
|
|
28
|
+
|
|
29
|
+
| File | Phase 1 | Phase 2 | Phase 3 | Phase 4 |
|
|
30
|
+
|------|---------|---------|---------|---------|
|
|
31
|
+
| `types.ts` | WRITE | - | - | - |
|
|
32
|
+
| `hooks/useSpreadsheetDuplicates.ts` (NEW) | WRITE | - | - | - |
|
|
33
|
+
| `SpreadsheetFilterDropdown.tsx` | - | WRITE | - | - |
|
|
34
|
+
| `ColumnHeaderActions.tsx` | - | - | WRITE | - |
|
|
35
|
+
| `Spreadsheet.tsx` | - | - | - | WRITE |
|
|
36
|
+
| `SpreadsheetHeader.tsx` | - | - | - | WRITE |
|
|
37
|
+
| `SpreadsheetCell.tsx` | - | - | - | WRITE |
|
|
38
|
+
| `SpreadsheetSettingsModal.tsx` | - | - | - | WRITE |
|
|
39
|
+
| `index.ts` | - | - | - | WRITE |
|
|
40
|
+
|
|
41
|
+
## Execution Strategy
|
|
42
|
+
|
|
43
|
+
1. Run **Phase 1, 2, 3 in parallel** -- they touch completely independent files
|
|
44
|
+
2. Run **Phase 4 sequentially** -- it wires everything together in Spreadsheet.tsx and updates dependent components
|
|
45
|
+
|
|
46
|
+
## Phases
|
|
47
|
+
|
|
48
|
+
- [Phase 01: Types + Duplicate Detection Hook](./phase-01-types-and-duplicates-hook.md)
|
|
49
|
+
- [Phase 02: Filter Dropdown Portal](./phase-02-filter-dropdown-portal.md)
|
|
50
|
+
- [Phase 03: Column Header Overflow Menu](./phase-03-header-overflow-menu.md)
|
|
51
|
+
- [Phase 04: Integration + Loading Overlay](./phase-04-integration.md)
|
|
52
|
+
|
|
53
|
+
## Success Criteria
|
|
54
|
+
- All 4 features functional with 2000+ rows x 30+ columns
|
|
55
|
+
- No regressions in pinning, sorting, filtering, editing, sticky header, column resize
|
|
56
|
+
- Duplicate check columns controllable via props for DB persistence
|
|
57
|
+
- Filter dropdown renders via portal, repositions on scroll/resize
|
|
58
|
+
- Header actions collapse to "..." when column width < 80px
|
|
59
|
+
- Loading overlay shows during expensive client-side operations
|
|
@@ -1,16 +1,18 @@
|
|
|
1
|
+
import { useState, useRef } from 'react';
|
|
1
2
|
import { cn } from '../utils';
|
|
2
|
-
import { HIGHLIGHT_COLORS } from '../hooks/useSpreadsheetHighlighting';
|
|
3
3
|
|
|
4
4
|
export type ColorPaletteType = 'row' | 'column';
|
|
5
5
|
|
|
6
6
|
export interface ColorPickerPopoverProps {
|
|
7
7
|
/** Title displayed in the popover */
|
|
8
8
|
title: string;
|
|
9
|
-
/** Type of color palette to use */
|
|
9
|
+
/** Type of color palette to use (unused — kept for API compat) */
|
|
10
10
|
paletteType?: ColorPaletteType;
|
|
11
|
-
/** Custom colors array (
|
|
11
|
+
/** Custom colors array (unused — kept for API compat) */
|
|
12
12
|
colors?: (string | null)[];
|
|
13
|
-
/**
|
|
13
|
+
/** Recently used colors */
|
|
14
|
+
recentColors?: string[];
|
|
15
|
+
/** Callback when a color is confirmed */
|
|
14
16
|
onSelectColor: (color: string | null) => void;
|
|
15
17
|
/** Callback when the popover is closed/cancelled */
|
|
16
18
|
onClose: () => void;
|
|
@@ -19,48 +21,91 @@ export interface ColorPickerPopoverProps {
|
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
+
* Color picker popover using the native HTML color input for full flexibility,
|
|
25
|
+
* with a recent colors row and confirm/clear actions.
|
|
24
26
|
*/
|
|
25
27
|
export function ColorPickerPopover({
|
|
26
28
|
title,
|
|
27
|
-
|
|
28
|
-
colors,
|
|
29
|
+
recentColors = [],
|
|
29
30
|
onSelectColor,
|
|
30
31
|
onClose,
|
|
31
32
|
className,
|
|
32
33
|
}: ColorPickerPopoverProps) {
|
|
33
|
-
|
|
34
|
-
const
|
|
34
|
+
const [selectedColor, setSelectedColor] = useState('#fef08a');
|
|
35
|
+
const colorInputRef = useRef<HTMLInputElement>(null);
|
|
36
|
+
|
|
37
|
+
const handleConfirm = () => {
|
|
38
|
+
onSelectColor(selectedColor);
|
|
39
|
+
};
|
|
35
40
|
|
|
36
41
|
return (
|
|
37
|
-
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
|
38
|
-
<div
|
|
42
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
|
43
|
+
<div
|
|
44
|
+
className={cn('bg-white rounded-lg shadow-xl p-4 w-72', className)}
|
|
45
|
+
onClick={(e) => e.stopPropagation()}
|
|
46
|
+
>
|
|
39
47
|
<h3 className="text-sm font-semibold mb-3">{title}</h3>
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
))}
|
|
48
|
+
|
|
49
|
+
{/* Native color picker with preview */}
|
|
50
|
+
<div className="flex items-center gap-3 mb-3">
|
|
51
|
+
<input
|
|
52
|
+
ref={colorInputRef}
|
|
53
|
+
type="color"
|
|
54
|
+
value={selectedColor}
|
|
55
|
+
onChange={(e) => setSelectedColor(e.target.value)}
|
|
56
|
+
className="w-10 h-10 rounded border border-gray-200 cursor-pointer p-0.5"
|
|
57
|
+
/>
|
|
58
|
+
<div className="flex-1">
|
|
59
|
+
<div
|
|
60
|
+
className="w-full h-8 rounded border border-gray-200"
|
|
61
|
+
style={{ backgroundColor: selectedColor }}
|
|
62
|
+
/>
|
|
63
|
+
<span className="text-xs text-gray-500 mt-1 block">{selectedColor}</span>
|
|
64
|
+
</div>
|
|
58
65
|
</div>
|
|
59
|
-
|
|
66
|
+
|
|
67
|
+
{/* Recent colors */}
|
|
68
|
+
{recentColors.length > 0 && (
|
|
69
|
+
<div className="mb-3">
|
|
70
|
+
<span className="text-xs text-gray-500 mb-1.5 block">Recent</span>
|
|
71
|
+
<div className="flex gap-1.5">
|
|
72
|
+
{recentColors.map((color) => (
|
|
73
|
+
<button
|
|
74
|
+
key={color}
|
|
75
|
+
type="button"
|
|
76
|
+
onClick={() => setSelectedColor(color)}
|
|
77
|
+
className={cn(
|
|
78
|
+
'w-7 h-7 rounded border-2 transition-transform hover:scale-110',
|
|
79
|
+
selectedColor === color ? 'border-blue-500' : 'border-gray-200 hover:border-gray-400'
|
|
80
|
+
)}
|
|
81
|
+
style={{ backgroundColor: color }}
|
|
82
|
+
title={color}
|
|
83
|
+
/>
|
|
84
|
+
))}
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
)}
|
|
88
|
+
|
|
89
|
+
{/* Actions */}
|
|
90
|
+
<div className="flex items-center gap-2 pt-2 border-t border-gray-100">
|
|
91
|
+
<button
|
|
92
|
+
type="button"
|
|
93
|
+
onClick={handleConfirm}
|
|
94
|
+
className="flex-1 px-2.5 py-1.5 text-xs text-white bg-blue-600 hover:bg-blue-700 rounded transition-colors font-medium"
|
|
95
|
+
>
|
|
96
|
+
Apply
|
|
97
|
+
</button>
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
onClick={() => onSelectColor(null)}
|
|
101
|
+
className="px-2.5 py-1.5 text-xs text-red-600 hover:bg-red-50 rounded border border-red-200 transition-colors"
|
|
102
|
+
>
|
|
103
|
+
Clear
|
|
104
|
+
</button>
|
|
60
105
|
<button
|
|
61
106
|
type="button"
|
|
62
107
|
onClick={onClose}
|
|
63
|
-
className="px-
|
|
108
|
+
className="px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-100 rounded transition-colors"
|
|
64
109
|
>
|
|
65
110
|
Cancel
|
|
66
111
|
</button>
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import { HiFilter, HiSortAscending, HiSortDescending, HiDotsVertical, HiExclamation } from 'react-icons/hi';
|
|
2
4
|
import { AiFillHighlight } from 'react-icons/ai';
|
|
3
5
|
import { MdOutlinePushPin, MdPushPin } from 'react-icons/md';
|
|
4
6
|
import { cn } from '../utils';
|
|
@@ -16,6 +18,12 @@ export interface ColumnHeaderActionsProps {
|
|
|
16
18
|
hasActiveHighlight?: boolean;
|
|
17
19
|
/** Whether the column is currently pinned */
|
|
18
20
|
isPinned?: boolean;
|
|
21
|
+
/** Whether sorting is enabled for this column */
|
|
22
|
+
enableSorting?: boolean;
|
|
23
|
+
/** Current sort direction for this column (null if not sorted) */
|
|
24
|
+
sortDirection?: 'asc' | 'desc' | null;
|
|
25
|
+
/** Callback when sort button is clicked */
|
|
26
|
+
onSortClick?: () => void;
|
|
19
27
|
/** Callback when filter button is clicked */
|
|
20
28
|
onFilterClick?: () => void;
|
|
21
29
|
/** Callback when highlight button is clicked */
|
|
@@ -32,11 +40,20 @@ export interface ColumnHeaderActionsProps {
|
|
|
32
40
|
unpinnedTitle?: string;
|
|
33
41
|
/** Additional className */
|
|
34
42
|
className?: string;
|
|
43
|
+
/** Resolved column width -- when < 80px, actions collapse to overflow menu */
|
|
44
|
+
resolvedWidth?: number;
|
|
45
|
+
/** Whether duplicate check is enabled for this column */
|
|
46
|
+
enableDuplicateCheck?: boolean;
|
|
47
|
+
/** Whether duplicate check is active */
|
|
48
|
+
hasDuplicateCheck?: boolean;
|
|
49
|
+
/** Callback when duplicate check is toggled */
|
|
50
|
+
onDuplicateCheckClick?: () => void;
|
|
35
51
|
}
|
|
36
52
|
|
|
37
53
|
/**
|
|
38
54
|
* Reusable action buttons for column headers (filter, highlight, pin).
|
|
39
55
|
* Works for both regular columns and the row index column.
|
|
56
|
+
* When resolvedWidth < 80px, all actions collapse into a single overflow "..." menu.
|
|
40
57
|
*/
|
|
41
58
|
export function ColumnHeaderActions({
|
|
42
59
|
enableFiltering = false,
|
|
@@ -45,6 +62,9 @@ export function ColumnHeaderActions({
|
|
|
45
62
|
hasActiveFilter = false,
|
|
46
63
|
hasActiveHighlight = false,
|
|
47
64
|
isPinned = false,
|
|
65
|
+
enableSorting = false,
|
|
66
|
+
sortDirection = null,
|
|
67
|
+
onSortClick,
|
|
48
68
|
onFilterClick,
|
|
49
69
|
onHighlightClick,
|
|
50
70
|
onPinClick,
|
|
@@ -53,9 +73,209 @@ export function ColumnHeaderActions({
|
|
|
53
73
|
pinnedTitle = 'Unpin column',
|
|
54
74
|
unpinnedTitle = 'Pin column',
|
|
55
75
|
className,
|
|
76
|
+
resolvedWidth,
|
|
77
|
+
enableDuplicateCheck = false,
|
|
78
|
+
hasDuplicateCheck = false,
|
|
79
|
+
onDuplicateCheckClick,
|
|
56
80
|
}: ColumnHeaderActionsProps) {
|
|
81
|
+
const [overflowOpen, setOverflowOpen] = useState(false);
|
|
82
|
+
const overflowRef = useRef<HTMLDivElement>(null);
|
|
83
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
84
|
+
const [dropdownPos, setDropdownPos] = useState<{ top: number; right: number } | null>(null);
|
|
85
|
+
|
|
86
|
+
const isCompact = resolvedWidth !== undefined && resolvedWidth < 80;
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (!overflowOpen) return;
|
|
90
|
+
const handler = (e: MouseEvent) => {
|
|
91
|
+
if (
|
|
92
|
+
overflowRef.current &&
|
|
93
|
+
!overflowRef.current.contains(e.target as Node) &&
|
|
94
|
+
triggerRef.current &&
|
|
95
|
+
!triggerRef.current.contains(e.target as Node)
|
|
96
|
+
) {
|
|
97
|
+
setOverflowOpen(false);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
document.addEventListener('mousedown', handler);
|
|
101
|
+
return () => document.removeEventListener('mousedown', handler);
|
|
102
|
+
}, [overflowOpen]);
|
|
103
|
+
|
|
104
|
+
const openOverflow = (e: React.MouseEvent) => {
|
|
105
|
+
e.stopPropagation();
|
|
106
|
+
if (!overflowOpen && triggerRef.current) {
|
|
107
|
+
const rect = triggerRef.current.getBoundingClientRect();
|
|
108
|
+
setDropdownPos({
|
|
109
|
+
top: rect.bottom + window.scrollY + 4,
|
|
110
|
+
right: window.innerWidth - rect.right - window.scrollX,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
setOverflowOpen((prev) => !prev);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
if (isCompact) {
|
|
117
|
+
return (
|
|
118
|
+
<div className={cn('flex items-center', className)}>
|
|
119
|
+
<button
|
|
120
|
+
ref={triggerRef}
|
|
121
|
+
type="button"
|
|
122
|
+
onClick={openOverflow}
|
|
123
|
+
className="p-0.5 hover:bg-gray-200 rounded text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
124
|
+
title="Column actions"
|
|
125
|
+
>
|
|
126
|
+
<HiDotsVertical className="h-3 w-3" />
|
|
127
|
+
</button>
|
|
128
|
+
|
|
129
|
+
{overflowOpen &&
|
|
130
|
+
dropdownPos &&
|
|
131
|
+
createPortal(
|
|
132
|
+
<div
|
|
133
|
+
ref={overflowRef}
|
|
134
|
+
style={{
|
|
135
|
+
position: 'absolute',
|
|
136
|
+
top: dropdownPos.top,
|
|
137
|
+
right: dropdownPos.right,
|
|
138
|
+
}}
|
|
139
|
+
className="bg-white border border-gray-200 shadow-lg rounded-md py-1 w-44 z-[9999]"
|
|
140
|
+
>
|
|
141
|
+
{enableSorting && onSortClick && (
|
|
142
|
+
<button
|
|
143
|
+
type="button"
|
|
144
|
+
onClick={(e) => {
|
|
145
|
+
e.stopPropagation();
|
|
146
|
+
onSortClick();
|
|
147
|
+
setOverflowOpen(false);
|
|
148
|
+
}}
|
|
149
|
+
className={cn(
|
|
150
|
+
'flex items-center gap-2 w-full px-3 py-1.5 text-xs hover:bg-gray-100 text-left',
|
|
151
|
+
sortDirection ? 'text-blue-600' : 'text-gray-700'
|
|
152
|
+
)}
|
|
153
|
+
>
|
|
154
|
+
{sortDirection === 'desc' ? (
|
|
155
|
+
<HiSortDescending className="h-3.5 w-3.5 shrink-0" />
|
|
156
|
+
) : (
|
|
157
|
+
<HiSortAscending className="h-3.5 w-3.5 shrink-0" />
|
|
158
|
+
)}
|
|
159
|
+
{sortDirection === 'asc'
|
|
160
|
+
? 'Sorted ascending'
|
|
161
|
+
: sortDirection === 'desc'
|
|
162
|
+
? 'Sorted descending'
|
|
163
|
+
: 'Sort'}
|
|
164
|
+
</button>
|
|
165
|
+
)}
|
|
166
|
+
|
|
167
|
+
{enableFiltering && onFilterClick && (
|
|
168
|
+
<button
|
|
169
|
+
type="button"
|
|
170
|
+
onClick={(e) => {
|
|
171
|
+
e.stopPropagation();
|
|
172
|
+
onFilterClick();
|
|
173
|
+
setOverflowOpen(false);
|
|
174
|
+
}}
|
|
175
|
+
className={cn(
|
|
176
|
+
'flex items-center gap-2 w-full px-3 py-1.5 text-xs hover:bg-gray-100 text-left',
|
|
177
|
+
hasActiveFilter ? 'text-blue-600' : 'text-gray-700'
|
|
178
|
+
)}
|
|
179
|
+
>
|
|
180
|
+
<HiFilter className="h-3.5 w-3.5 shrink-0" />
|
|
181
|
+
Filter
|
|
182
|
+
{hasActiveFilter && (
|
|
183
|
+
<span className="ml-auto h-1.5 w-1.5 rounded-full bg-blue-500 shrink-0" />
|
|
184
|
+
)}
|
|
185
|
+
</button>
|
|
186
|
+
)}
|
|
187
|
+
|
|
188
|
+
{enableHighlighting && onHighlightClick && (
|
|
189
|
+
<button
|
|
190
|
+
type="button"
|
|
191
|
+
onClick={(e) => {
|
|
192
|
+
e.stopPropagation();
|
|
193
|
+
onHighlightClick();
|
|
194
|
+
setOverflowOpen(false);
|
|
195
|
+
}}
|
|
196
|
+
className={cn(
|
|
197
|
+
'flex items-center gap-2 w-full px-3 py-1.5 text-xs hover:bg-gray-100 text-left',
|
|
198
|
+
hasActiveHighlight ? 'text-amber-500' : 'text-gray-700'
|
|
199
|
+
)}
|
|
200
|
+
>
|
|
201
|
+
<AiFillHighlight className="h-3.5 w-3.5 shrink-0" />
|
|
202
|
+
Highlight
|
|
203
|
+
</button>
|
|
204
|
+
)}
|
|
205
|
+
|
|
206
|
+
{enablePinning && onPinClick && (
|
|
207
|
+
<button
|
|
208
|
+
type="button"
|
|
209
|
+
onClick={(e) => {
|
|
210
|
+
e.stopPropagation();
|
|
211
|
+
onPinClick();
|
|
212
|
+
setOverflowOpen(false);
|
|
213
|
+
}}
|
|
214
|
+
className={cn(
|
|
215
|
+
'flex items-center gap-2 w-full px-3 py-1.5 text-xs hover:bg-gray-100 text-left',
|
|
216
|
+
isPinned ? 'text-blue-600' : 'text-gray-700'
|
|
217
|
+
)}
|
|
218
|
+
>
|
|
219
|
+
{isPinned ? (
|
|
220
|
+
<MdPushPin className="h-3.5 w-3.5 shrink-0" />
|
|
221
|
+
) : (
|
|
222
|
+
<MdOutlinePushPin className="h-3.5 w-3.5 shrink-0" />
|
|
223
|
+
)}
|
|
224
|
+
{isPinned ? 'Unpin' : 'Pin'}
|
|
225
|
+
</button>
|
|
226
|
+
)}
|
|
227
|
+
|
|
228
|
+
{enableDuplicateCheck && onDuplicateCheckClick && (
|
|
229
|
+
<button
|
|
230
|
+
type="button"
|
|
231
|
+
onClick={(e) => {
|
|
232
|
+
e.stopPropagation();
|
|
233
|
+
onDuplicateCheckClick();
|
|
234
|
+
setOverflowOpen(false);
|
|
235
|
+
}}
|
|
236
|
+
className={cn(
|
|
237
|
+
'flex items-center gap-2 w-full px-3 py-1.5 text-xs hover:bg-gray-100 text-left',
|
|
238
|
+
hasDuplicateCheck ? 'text-purple-600' : 'text-gray-700'
|
|
239
|
+
)}
|
|
240
|
+
>
|
|
241
|
+
<HiExclamation className="h-3.5 w-3.5 shrink-0" />
|
|
242
|
+
Check Duplicates
|
|
243
|
+
</button>
|
|
244
|
+
)}
|
|
245
|
+
</div>,
|
|
246
|
+
document.body
|
|
247
|
+
)}
|
|
248
|
+
</div>
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Normal (wide) mode: inline icon buttons
|
|
57
253
|
return (
|
|
58
254
|
<div className={cn('flex items-center gap-0.5', className)}>
|
|
255
|
+
{/* Sort button */}
|
|
256
|
+
{enableSorting && onSortClick && (
|
|
257
|
+
<button
|
|
258
|
+
type="button"
|
|
259
|
+
onClick={(e) => {
|
|
260
|
+
e.stopPropagation();
|
|
261
|
+
onSortClick();
|
|
262
|
+
}}
|
|
263
|
+
className={cn(
|
|
264
|
+
'p-0.5 hover:bg-gray-200 rounded transition-opacity',
|
|
265
|
+
sortDirection
|
|
266
|
+
? 'text-blue-600 opacity-100'
|
|
267
|
+
: 'text-gray-400 opacity-0 group-hover:opacity-100'
|
|
268
|
+
)}
|
|
269
|
+
title={sortDirection === 'asc' ? 'Sorted ascending' : sortDirection === 'desc' ? 'Sorted descending' : 'Sort column'}
|
|
270
|
+
>
|
|
271
|
+
{sortDirection === 'desc' ? (
|
|
272
|
+
<HiSortDescending className="h-3 w-3" />
|
|
273
|
+
) : (
|
|
274
|
+
<HiSortAscending className="h-3 w-3" />
|
|
275
|
+
)}
|
|
276
|
+
</button>
|
|
277
|
+
)}
|
|
278
|
+
|
|
59
279
|
{/* Filter button */}
|
|
60
280
|
{enableFiltering && onFilterClick && (
|
|
61
281
|
<button
|
|
@@ -119,6 +339,26 @@ export function ColumnHeaderActions({
|
|
|
119
339
|
)}
|
|
120
340
|
</button>
|
|
121
341
|
)}
|
|
342
|
+
|
|
343
|
+
{/* Duplicate check button */}
|
|
344
|
+
{enableDuplicateCheck && onDuplicateCheckClick && (
|
|
345
|
+
<button
|
|
346
|
+
type="button"
|
|
347
|
+
onClick={(e) => {
|
|
348
|
+
e.stopPropagation();
|
|
349
|
+
onDuplicateCheckClick();
|
|
350
|
+
}}
|
|
351
|
+
className={cn(
|
|
352
|
+
'p-0.5 hover:bg-gray-200 rounded transition-opacity',
|
|
353
|
+
hasDuplicateCheck
|
|
354
|
+
? 'text-purple-600 opacity-100'
|
|
355
|
+
: 'text-gray-400 opacity-0 group-hover:opacity-100'
|
|
356
|
+
)}
|
|
357
|
+
title="Check duplicates"
|
|
358
|
+
>
|
|
359
|
+
<HiExclamation className="h-3 w-3" />
|
|
360
|
+
</button>
|
|
361
|
+
)}
|
|
122
362
|
</div>
|
|
123
363
|
);
|
|
124
364
|
}
|