@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.
Files changed (99) hide show
  1. package/.omc/state/agent-replay-0cead415-b3bd-40fd-b199-47371946c4db.jsonl +25 -0
  2. package/.omc/state/idle-notif-cooldown.json +3 -0
  3. package/.omc/state/last-tool-error.json +7 -0
  4. package/.omc/state/mission-state.json +179 -0
  5. package/.omc/state/subagent-tracking.json +116 -0
  6. package/.turbo/turbo-build.log +28 -28
  7. package/.turbo/turbo-lint.log +140 -0
  8. package/dist/index.d.mts +94 -4
  9. package/dist/index.d.ts +94 -4
  10. package/dist/index.js +2133 -1156
  11. package/dist/index.js.map +1 -1
  12. package/dist/index.mjs +2023 -1048
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/styles/globals.css +156 -16
  15. package/dist/styles/globals.css.map +1 -1
  16. package/package.json +1 -1
  17. package/plans/20260330-1230-spreadsheet-features/phase-01-types-and-duplicates-hook.md +73 -0
  18. package/plans/20260330-1230-spreadsheet-features/phase-02-filter-dropdown-portal.md +90 -0
  19. package/plans/20260330-1230-spreadsheet-features/phase-03-header-overflow-menu.md +101 -0
  20. package/plans/20260330-1230-spreadsheet-features/phase-04-integration.md +193 -0
  21. package/plans/20260330-1230-spreadsheet-features/plan.md +59 -0
  22. package/src/components/ColorPickerPopover.tsx +77 -32
  23. package/src/components/ColumnHeaderActions.tsx +241 -1
  24. package/src/components/RowIndexColumnHeader.tsx +13 -17
  25. package/src/components/SelectionSummaryBar.tsx +103 -0
  26. package/src/components/Spreadsheet.stories.tsx +254 -0
  27. package/src/components/Spreadsheet.tsx +234 -189
  28. package/src/components/SpreadsheetCell.tsx +280 -42
  29. package/src/components/SpreadsheetFilterDropdown.tsx +178 -13
  30. package/src/components/SpreadsheetHeader.tsx +79 -24
  31. package/src/components/SpreadsheetSettingsModal.tsx +4 -0
  32. package/src/hooks/useSpreadsheetColumnResize.ts +143 -0
  33. package/src/hooks/useSpreadsheetDuplicates.ts +149 -0
  34. package/src/hooks/useSpreadsheetFiltering.ts +18 -1
  35. package/src/hooks/useSpreadsheetHighlighting.ts +23 -3
  36. package/src/hooks/useSpreadsheetKeyboardShortcuts.ts +16 -0
  37. package/src/hooks/useSpreadsheetPinning.ts +148 -134
  38. package/src/hooks/useSpreadsheetSelection.ts +10 -22
  39. package/src/hooks/useSpreadsheetSummary.ts +68 -0
  40. package/src/index.ts +4 -1
  41. package/src/styles/globals.css +51 -0
  42. package/src/types.ts +50 -2
  43. package/storybook-static/assets/Color-YHDXOIA2-CtQurLnT.js +1 -0
  44. package/storybook-static/assets/DocsRenderer-CFRXHY34-oxrW8Hvo.js +575 -0
  45. package/storybook-static/assets/Spreadsheet.stories-DvhhzuK4.js +1357 -0
  46. package/storybook-static/assets/chunk-XP5HYGXS-BpfKkqn7.js +1 -0
  47. package/storybook-static/assets/entry-preview-CkBGHCAN.js +2 -0
  48. package/storybook-static/assets/entry-preview-docs-ugJb6pa8.js +46 -0
  49. package/storybook-static/assets/iframe-CPp2u3vg.js +211 -0
  50. package/storybook-static/assets/index-BB9bPxRC.js +24 -0
  51. package/storybook-static/assets/index-BQFlzFLk.js +9 -0
  52. package/storybook-static/assets/index-CtvPRVHf.js +9 -0
  53. package/storybook-static/assets/index-DgH-xKnr.js +11 -0
  54. package/storybook-static/assets/index-DrFu-skq.js +6 -0
  55. package/storybook-static/assets/index-DrdPSA1J.js +240 -0
  56. package/storybook-static/assets/index-DzFBShOR.js +20 -0
  57. package/storybook-static/assets/index-v-1boR4t.js +1 -0
  58. package/storybook-static/assets/preview-B8lJiyuQ.js +34 -0
  59. package/storybook-static/assets/preview-BBWR9nbA.js +1 -0
  60. package/storybook-static/assets/preview-BWzBA1C2.js +396 -0
  61. package/storybook-static/assets/preview-Bm0S-uxO.css +1 -0
  62. package/storybook-static/assets/preview-CvbIS5ZJ.js +1 -0
  63. package/storybook-static/assets/preview-DD_OYowb.js +1 -0
  64. package/storybook-static/assets/preview-DGUiP6tS.js +7 -0
  65. package/storybook-static/assets/preview-DHQbi4pV.js +1 -0
  66. package/storybook-static/assets/preview-DwI0w3cI.js +1 -0
  67. package/storybook-static/assets/preview-DyR7iiFG.js +1 -0
  68. package/storybook-static/assets/preview-zxZ6Be2V.js +2 -0
  69. package/storybook-static/assets/react-18-Pj8skaX9.js +1 -0
  70. package/storybook-static/assets/test-utils-quxJ1Z79.js +9 -0
  71. package/storybook-static/favicon.svg +1 -0
  72. package/storybook-static/iframe.html +666 -0
  73. package/storybook-static/index.html +177 -0
  74. package/storybook-static/index.json +1 -0
  75. package/storybook-static/nunito-sans-bold-italic.woff2 +0 -0
  76. package/storybook-static/nunito-sans-bold.woff2 +0 -0
  77. package/storybook-static/nunito-sans-italic.woff2 +0 -0
  78. package/storybook-static/nunito-sans-regular.woff2 +0 -0
  79. package/storybook-static/project.json +1 -0
  80. package/storybook-static/sb-addons/essentials-actions-3/manager-bundle.js +3 -0
  81. package/storybook-static/sb-addons/essentials-backgrounds-5/manager-bundle.js +12 -0
  82. package/storybook-static/sb-addons/essentials-controls-2/manager-bundle.js +405 -0
  83. package/storybook-static/sb-addons/essentials-docs-4/manager-bundle.js +245 -0
  84. package/storybook-static/sb-addons/essentials-measure-8/manager-bundle.js +3 -0
  85. package/storybook-static/sb-addons/essentials-outline-9/manager-bundle.js +3 -0
  86. package/storybook-static/sb-addons/essentials-toolbars-7/manager-bundle.js +3 -0
  87. package/storybook-static/sb-addons/essentials-viewport-6/manager-bundle.js +3 -0
  88. package/storybook-static/sb-addons/interactions-10/manager-bundle.js +222 -0
  89. package/storybook-static/sb-addons/links-1/manager-bundle.js +3 -0
  90. package/storybook-static/sb-addons/storybook-core-core-server-presets-0/common-manager-bundle.js +3 -0
  91. package/storybook-static/sb-common-assets/favicon.svg +1 -0
  92. package/storybook-static/sb-common-assets/nunito-sans-bold-italic.woff2 +0 -0
  93. package/storybook-static/sb-common-assets/nunito-sans-bold.woff2 +0 -0
  94. package/storybook-static/sb-common-assets/nunito-sans-italic.woff2 +0 -0
  95. package/storybook-static/sb-common-assets/nunito-sans-regular.woff2 +0 -0
  96. package/storybook-static/sb-manager/globals-module-info.js +1052 -0
  97. package/storybook-static/sb-manager/globals-runtime.js +42127 -0
  98. package/storybook-static/sb-manager/globals.js +48 -0
  99. 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 (overrides paletteType) */
11
+ /** Custom colors array (unused — kept for API compat) */
12
12
  colors?: (string | null)[];
13
- /** Callback when a color is selected */
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
- * A reusable color picker popover component for highlighting.
23
- * Supports both row (darker) and column (lighter) color palettes.
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
- paletteType = 'column',
28
- colors,
29
+ recentColors = [],
29
30
  onSelectColor,
30
31
  onClose,
31
32
  className,
32
33
  }: ColorPickerPopoverProps) {
33
- // Use custom colors if provided, otherwise use palette based on type
34
- const colorPalette = colors ?? [...HIGHLIGHT_COLORS[paletteType], null];
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 className={cn('bg-white rounded-lg shadow-xl p-4 w-64', className)}>
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
- <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
- ))}
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
- <div className="flex justify-end mt-4">
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-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 rounded transition-colors"
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 { HiFilter } from 'react-icons/hi';
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
  }