@optilogic/core 1.0.0-beta.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/LICENSE +21 -0
- package/README.md +107 -0
- package/dist/index.cjs +6003 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2310 -0
- package/dist/index.d.ts +2310 -0
- package/dist/index.js +5828 -0
- package/dist/index.js.map +1 -0
- package/dist/styles.css +96 -0
- package/dist/tailwind-preset.cjs +106 -0
- package/dist/tailwind-preset.cjs.map +1 -0
- package/dist/tailwind-preset.d.cts +23 -0
- package/dist/tailwind-preset.d.ts +23 -0
- package/dist/tailwind-preset.js +101 -0
- package/dist/tailwind-preset.js.map +1 -0
- package/package.json +154 -0
- package/src/components/accordion.tsx +187 -0
- package/src/components/alert-dialog.tsx +143 -0
- package/src/components/autocomplete.tsx +271 -0
- package/src/components/badge.tsx +62 -0
- package/src/components/button.tsx +85 -0
- package/src/components/calendar.tsx +235 -0
- package/src/components/card.tsx +94 -0
- package/src/components/checkbox.tsx +77 -0
- package/src/components/chip.tsx +77 -0
- package/src/components/confirmation-modal.tsx +195 -0
- package/src/components/context-menu.tsx +406 -0
- package/src/components/copy-button.tsx +84 -0
- package/src/components/data-grid/DataGrid.tsx +1027 -0
- package/src/components/data-grid/components/CellEditor.tsx +346 -0
- package/src/components/data-grid/components/FilterPopover.tsx +459 -0
- package/src/components/data-grid/components/HeaderCell.tsx +207 -0
- package/src/components/data-grid/components/index.ts +14 -0
- package/src/components/data-grid/hooks/index.ts +28 -0
- package/src/components/data-grid/hooks/useColumnResize.ts +378 -0
- package/src/components/data-grid/hooks/useDataGridState.ts +346 -0
- package/src/components/data-grid/hooks/useKeyboardNavigation.ts +361 -0
- package/src/components/data-grid/index.ts +71 -0
- package/src/components/data-grid/types.ts +478 -0
- package/src/components/data-grid/utils/dataProcessing.ts +277 -0
- package/src/components/data-grid/utils/index.ts +12 -0
- package/src/components/date-picker.tsx +366 -0
- package/src/components/dropdown-menu.tsx +230 -0
- package/src/components/icon-button.tsx +157 -0
- package/src/components/input.tsx +40 -0
- package/src/components/label.tsx +37 -0
- package/src/components/loading-spinner.tsx +113 -0
- package/src/components/modal.tsx +207 -0
- package/src/components/popover.tsx +62 -0
- package/src/components/progress.tsx +41 -0
- package/src/components/resizable-panel.tsx +434 -0
- package/src/components/resize-handle.tsx +187 -0
- package/src/components/select.tsx +160 -0
- package/src/components/separator.tsx +50 -0
- package/src/components/skeleton.tsx +37 -0
- package/src/components/switch.tsx +59 -0
- package/src/components/table.tsx +136 -0
- package/src/components/tabs.tsx +102 -0
- package/src/components/textarea.tsx +36 -0
- package/src/components/theme-picker.tsx +245 -0
- package/src/components/toaster.tsx +84 -0
- package/src/components/tooltip.tsx +199 -0
- package/src/index.ts +318 -0
- package/src/styles.css +96 -0
- package/src/tailwind-preset.ts +129 -0
- package/src/theme/index.ts +41 -0
- package/src/theme/presets.ts +502 -0
- package/src/theme/types.ts +164 -0
- package/src/theme/utils.ts +309 -0
- package/src/utils/cn.ts +14 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../utils/cn";
|
|
4
|
+
import { ResizeHandle } from "./resize-handle";
|
|
5
|
+
|
|
6
|
+
export interface ResizablePanelProps {
|
|
7
|
+
/**
|
|
8
|
+
* Which edge the panel anchors to
|
|
9
|
+
*/
|
|
10
|
+
orientation: "left" | "right";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Default collapsed width in vw
|
|
14
|
+
*/
|
|
15
|
+
collapsedSizeVW: number;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Current expanded width in vw (controlled prop)
|
|
19
|
+
*/
|
|
20
|
+
expandedWidthVW?: number;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Minimum width in vw
|
|
24
|
+
*/
|
|
25
|
+
minWidthVW?: number;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Maximum width in vw
|
|
29
|
+
*/
|
|
30
|
+
maxWidthVW?: number;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Whether the panel is expanded (drawer) vs collapsed
|
|
34
|
+
*/
|
|
35
|
+
isExpanded: boolean;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether the panel is in overlay mode
|
|
39
|
+
*/
|
|
40
|
+
isOverlay?: boolean;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* When in overlay mode, the outer panel width in vw
|
|
44
|
+
*/
|
|
45
|
+
outerWidthVW?: number;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* When in overlay mode, the inner panel width in vw
|
|
49
|
+
*/
|
|
50
|
+
innerWidthVW?: number;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Current left panel width in vw (needed for overlay positioning and dynamic constraints)
|
|
54
|
+
*/
|
|
55
|
+
leftWidthVW?: number;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whether the panel is resizable
|
|
59
|
+
*/
|
|
60
|
+
resizable?: boolean;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Show/hide the resize handle
|
|
64
|
+
*/
|
|
65
|
+
showHandle?: boolean;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Callback during resize; emits clamped value in vw
|
|
69
|
+
*/
|
|
70
|
+
onResize?: (widthVW: number) => void;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Callback for collapse/expand toggle
|
|
74
|
+
*/
|
|
75
|
+
onToggle?: (next: boolean) => void;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Callback to promote to overlay mode
|
|
79
|
+
*/
|
|
80
|
+
onPromoteToOverlay?: () => void;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Callback to demote from overlay mode
|
|
84
|
+
*/
|
|
85
|
+
onDemoteFromOverlay?: () => void;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Callback when resizing the overlay outer/inner split
|
|
89
|
+
*/
|
|
90
|
+
onResizeOverlay?: (outerWidthVW: number) => void;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* ARIA label for the resize handle
|
|
94
|
+
*/
|
|
95
|
+
handleAriaLabel?: string;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Optional header slot
|
|
99
|
+
*/
|
|
100
|
+
slotHeader?: React.ReactNode;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Optional footer slot
|
|
104
|
+
*/
|
|
105
|
+
slotFooter?: React.ReactNode;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Optional inner panel content (for overlay mode)
|
|
109
|
+
*/
|
|
110
|
+
innerPanelContent?: React.ReactNode;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Panel content
|
|
114
|
+
*/
|
|
115
|
+
children: React.ReactNode;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Additional class names
|
|
119
|
+
*/
|
|
120
|
+
className?: string;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Data attributes for styling/state
|
|
124
|
+
*/
|
|
125
|
+
dataAttributes?: Record<string, string>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* ResizablePanel component
|
|
130
|
+
*
|
|
131
|
+
* A generic, framework-agnostic panel that can collapse, expand, resize,
|
|
132
|
+
* and promote to overlay mode. Backs both left (search) and right (preview) panels.
|
|
133
|
+
*/
|
|
134
|
+
export function ResizablePanel({
|
|
135
|
+
orientation,
|
|
136
|
+
collapsedSizeVW,
|
|
137
|
+
expandedWidthVW,
|
|
138
|
+
minWidthVW = 5,
|
|
139
|
+
maxWidthVW = 90,
|
|
140
|
+
isExpanded,
|
|
141
|
+
isOverlay = false,
|
|
142
|
+
outerWidthVW,
|
|
143
|
+
innerWidthVW,
|
|
144
|
+
leftWidthVW,
|
|
145
|
+
resizable = true,
|
|
146
|
+
showHandle = true,
|
|
147
|
+
onResize,
|
|
148
|
+
onToggle: _onToggle,
|
|
149
|
+
onPromoteToOverlay: _onPromoteToOverlay,
|
|
150
|
+
onDemoteFromOverlay,
|
|
151
|
+
onResizeOverlay,
|
|
152
|
+
handleAriaLabel,
|
|
153
|
+
slotHeader,
|
|
154
|
+
slotFooter,
|
|
155
|
+
innerPanelContent,
|
|
156
|
+
children,
|
|
157
|
+
className,
|
|
158
|
+
dataAttributes = {},
|
|
159
|
+
}: ResizablePanelProps) {
|
|
160
|
+
const panelRef = React.useRef<HTMLDivElement>(null);
|
|
161
|
+
|
|
162
|
+
const [isDragging, setIsDragging] = React.useState(false);
|
|
163
|
+
const [localWidth, setLocalWidth] = React.useState<number | null>(null);
|
|
164
|
+
const [localOuterWidth, setLocalOuterWidth] = React.useState<number | null>(null);
|
|
165
|
+
|
|
166
|
+
const currentWidthVW = isExpanded
|
|
167
|
+
? (isDragging && localWidth !== null ? localWidth : expandedWidthVW) ||
|
|
168
|
+
collapsedSizeVW
|
|
169
|
+
: collapsedSizeVW;
|
|
170
|
+
|
|
171
|
+
const clampWidth = React.useCallback(
|
|
172
|
+
(width: number): number => {
|
|
173
|
+
let effectiveMinWidth = minWidthVW;
|
|
174
|
+
let effectiveMaxWidth = maxWidthVW;
|
|
175
|
+
|
|
176
|
+
if (orientation === "right" && leftWidthVW !== undefined && !isOverlay) {
|
|
177
|
+
const availableSpace = 100 - leftWidthVW - 3;
|
|
178
|
+
const maxFromSpace = Math.max(effectiveMinWidth, availableSpace - 33);
|
|
179
|
+
effectiveMaxWidth = Math.min(maxWidthVW, maxFromSpace);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return Math.max(effectiveMinWidth, Math.min(effectiveMaxWidth, width));
|
|
183
|
+
},
|
|
184
|
+
[minWidthVW, maxWidthVW, orientation, leftWidthVW, isOverlay]
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const handleDragStart = React.useCallback(() => {
|
|
188
|
+
setIsDragging(true);
|
|
189
|
+
setLocalWidth(currentWidthVW);
|
|
190
|
+
}, [currentWidthVW]);
|
|
191
|
+
|
|
192
|
+
const handleDrag = React.useCallback(
|
|
193
|
+
(deltaX: number) => {
|
|
194
|
+
if (!isExpanded || isOverlay) return;
|
|
195
|
+
|
|
196
|
+
const viewportWidth = window.innerWidth;
|
|
197
|
+
const deltaVW = (deltaX / viewportWidth) * 100;
|
|
198
|
+
|
|
199
|
+
const adjustedDelta = orientation === "left" ? deltaVW : -deltaVW;
|
|
200
|
+
const baseWidth = localWidth !== null ? localWidth : currentWidthVW;
|
|
201
|
+
const newWidth = clampWidth(baseWidth + adjustedDelta);
|
|
202
|
+
|
|
203
|
+
setLocalWidth(newWidth);
|
|
204
|
+
},
|
|
205
|
+
[
|
|
206
|
+
isExpanded,
|
|
207
|
+
isOverlay,
|
|
208
|
+
orientation,
|
|
209
|
+
localWidth,
|
|
210
|
+
currentWidthVW,
|
|
211
|
+
clampWidth,
|
|
212
|
+
]
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
const handleDragEnd = React.useCallback(() => {
|
|
216
|
+
if (localWidth !== null && onResize) {
|
|
217
|
+
onResize(localWidth);
|
|
218
|
+
}
|
|
219
|
+
setIsDragging(false);
|
|
220
|
+
setLocalWidth(null);
|
|
221
|
+
}, [localWidth, onResize]);
|
|
222
|
+
|
|
223
|
+
const handleKeyboardResize = React.useCallback(
|
|
224
|
+
(direction: 1 | -1) => {
|
|
225
|
+
if (!isExpanded || isOverlay || !onResize) return;
|
|
226
|
+
|
|
227
|
+
const step = 1;
|
|
228
|
+
const newWidth = clampWidth(currentWidthVW + step * direction);
|
|
229
|
+
onResize(newWidth);
|
|
230
|
+
},
|
|
231
|
+
[isExpanded, isOverlay, onResize, currentWidthVW, clampWidth]
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const handleOverlayDragStart = React.useCallback(() => {
|
|
235
|
+
if (outerWidthVW) {
|
|
236
|
+
setIsDragging(true);
|
|
237
|
+
setLocalOuterWidth(outerWidthVW);
|
|
238
|
+
}
|
|
239
|
+
}, [outerWidthVW]);
|
|
240
|
+
|
|
241
|
+
const handleOverlayDrag = React.useCallback(
|
|
242
|
+
(deltaX: number) => {
|
|
243
|
+
if (!isOverlay) return;
|
|
244
|
+
|
|
245
|
+
const viewportWidth = window.innerWidth;
|
|
246
|
+
const deltaVW = (deltaX / viewportWidth) * 100;
|
|
247
|
+
|
|
248
|
+
const baseWidth =
|
|
249
|
+
localOuterWidth !== null ? localOuterWidth : outerWidthVW || 30;
|
|
250
|
+
const newOuterWidth = baseWidth - deltaVW;
|
|
251
|
+
|
|
252
|
+
setLocalOuterWidth(newOuterWidth);
|
|
253
|
+
},
|
|
254
|
+
[isOverlay, localOuterWidth, outerWidthVW]
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
const handleOverlayDragEnd = React.useCallback(() => {
|
|
258
|
+
if (localOuterWidth !== null && onResizeOverlay) {
|
|
259
|
+
onResizeOverlay(localOuterWidth);
|
|
260
|
+
}
|
|
261
|
+
setIsDragging(false);
|
|
262
|
+
setLocalOuterWidth(null);
|
|
263
|
+
}, [localOuterWidth, onResizeOverlay]);
|
|
264
|
+
|
|
265
|
+
React.useEffect(() => {
|
|
266
|
+
if (isOverlay && panelRef.current) {
|
|
267
|
+
const focusableElement = panelRef.current.querySelector<HTMLElement>(
|
|
268
|
+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
269
|
+
);
|
|
270
|
+
focusableElement?.focus();
|
|
271
|
+
}
|
|
272
|
+
}, [isOverlay]);
|
|
273
|
+
|
|
274
|
+
React.useEffect(() => {
|
|
275
|
+
if (!isOverlay) return;
|
|
276
|
+
|
|
277
|
+
const handleEscape = (e: KeyboardEvent) => {
|
|
278
|
+
if (e.key === "Escape") {
|
|
279
|
+
onDemoteFromOverlay?.();
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
window.addEventListener("keydown", handleEscape);
|
|
284
|
+
return () => window.removeEventListener("keydown", handleEscape);
|
|
285
|
+
}, [isOverlay, onDemoteFromOverlay]);
|
|
286
|
+
|
|
287
|
+
if (!isExpanded && !isOverlay) {
|
|
288
|
+
return (
|
|
289
|
+
<div
|
|
290
|
+
ref={panelRef}
|
|
291
|
+
className={cn(
|
|
292
|
+
"flex flex-col h-full",
|
|
293
|
+
"bg-background border-border",
|
|
294
|
+
orientation === "left" ? "border-r" : "border-l",
|
|
295
|
+
className
|
|
296
|
+
)}
|
|
297
|
+
style={{
|
|
298
|
+
width: `${collapsedSizeVW}vw`,
|
|
299
|
+
minWidth: "48px", // Ensure minimum pixel width to prevent squishing
|
|
300
|
+
}}
|
|
301
|
+
{...dataAttributes}
|
|
302
|
+
>
|
|
303
|
+
{children}
|
|
304
|
+
</div>
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (isOverlay && outerWidthVW && innerWidthVW) {
|
|
309
|
+
const displayOuterWidth =
|
|
310
|
+
isDragging && localOuterWidth !== null ? localOuterWidth : outerWidthVW;
|
|
311
|
+
const displayInnerWidth =
|
|
312
|
+
isDragging && localOuterWidth !== null
|
|
313
|
+
? outerWidthVW + innerWidthVW - localOuterWidth
|
|
314
|
+
: innerWidthVW;
|
|
315
|
+
|
|
316
|
+
const overlayLeftPosition = leftWidthVW || 3;
|
|
317
|
+
|
|
318
|
+
return (
|
|
319
|
+
<div
|
|
320
|
+
ref={panelRef}
|
|
321
|
+
role="dialog"
|
|
322
|
+
aria-modal="true"
|
|
323
|
+
className={cn("fixed inset-y-0 z-30", "flex", className)}
|
|
324
|
+
style={{
|
|
325
|
+
left: `${overlayLeftPosition}vw`, // Start after left panel
|
|
326
|
+
right: `${3}vw`, // End before nav gutter (fixed right edge) - matches icon nav width
|
|
327
|
+
width: `${displayOuterWidth + displayInnerWidth}vw`,
|
|
328
|
+
}}
|
|
329
|
+
{...dataAttributes}
|
|
330
|
+
>
|
|
331
|
+
<div
|
|
332
|
+
className={cn(
|
|
333
|
+
"flex flex-col h-full",
|
|
334
|
+
"bg-background border-r border-border"
|
|
335
|
+
)}
|
|
336
|
+
style={{
|
|
337
|
+
width: `${displayInnerWidth}vw`,
|
|
338
|
+
}}
|
|
339
|
+
>
|
|
340
|
+
<div className="flex-1 overflow-auto p-4">
|
|
341
|
+
{innerPanelContent || (
|
|
342
|
+
<div className="text-muted-foreground text-sm">
|
|
343
|
+
Inner panel content area
|
|
344
|
+
</div>
|
|
345
|
+
)}
|
|
346
|
+
</div>
|
|
347
|
+
</div>
|
|
348
|
+
|
|
349
|
+
<ResizeHandle
|
|
350
|
+
orientation="right"
|
|
351
|
+
resizable={resizable}
|
|
352
|
+
showHandle={showHandle}
|
|
353
|
+
onDragStart={handleOverlayDragStart}
|
|
354
|
+
onDrag={handleOverlayDrag}
|
|
355
|
+
onDragEnd={handleOverlayDragEnd}
|
|
356
|
+
ariaLabel={handleAriaLabel || "Resize overlay panels"}
|
|
357
|
+
/>
|
|
358
|
+
|
|
359
|
+
<div
|
|
360
|
+
className={cn("flex flex-col h-full", "bg-background border-border")}
|
|
361
|
+
style={{
|
|
362
|
+
width: `${displayOuterWidth}vw`,
|
|
363
|
+
}}
|
|
364
|
+
>
|
|
365
|
+
{slotHeader && (
|
|
366
|
+
<div className="flex-shrink-0 border-b border-border">
|
|
367
|
+
{slotHeader}
|
|
368
|
+
</div>
|
|
369
|
+
)}
|
|
370
|
+
<div className="flex-1 overflow-auto">{children}</div>
|
|
371
|
+
{slotFooter && (
|
|
372
|
+
<div className="flex-shrink-0 border-t border-border">
|
|
373
|
+
{slotFooter}
|
|
374
|
+
</div>
|
|
375
|
+
)}
|
|
376
|
+
</div>
|
|
377
|
+
|
|
378
|
+
<div
|
|
379
|
+
className="absolute inset-0 -z-10 bg-black/20"
|
|
380
|
+
onClick={() => onDemoteFromOverlay?.()}
|
|
381
|
+
/>
|
|
382
|
+
</div>
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return (
|
|
387
|
+
<div
|
|
388
|
+
ref={panelRef}
|
|
389
|
+
className={cn(
|
|
390
|
+
"relative flex h-full min-w-0 overflow-hidden",
|
|
391
|
+
!isDragging && "transition-[width] duration-200 ease-out",
|
|
392
|
+
orientation === "left" ? "flex-row" : "flex-row-reverse",
|
|
393
|
+
className
|
|
394
|
+
)}
|
|
395
|
+
style={{
|
|
396
|
+
width: `${currentWidthVW}vw`,
|
|
397
|
+
}}
|
|
398
|
+
{...dataAttributes}
|
|
399
|
+
>
|
|
400
|
+
<div
|
|
401
|
+
className={cn(
|
|
402
|
+
"flex flex-col flex-1 min-w-0 overflow-hidden",
|
|
403
|
+
"bg-background border-border",
|
|
404
|
+
orientation === "left" ? "border-r" : "border-l"
|
|
405
|
+
)}
|
|
406
|
+
>
|
|
407
|
+
{slotHeader && (
|
|
408
|
+
<div className="flex-shrink-0 border-b border-border min-w-0 overflow-hidden">
|
|
409
|
+
{slotHeader}
|
|
410
|
+
</div>
|
|
411
|
+
)}
|
|
412
|
+
<div className="flex-1 overflow-hidden min-w-0">{children}</div>
|
|
413
|
+
{slotFooter && (
|
|
414
|
+
<div className="flex-shrink-0 border-t border-border">
|
|
415
|
+
{slotFooter}
|
|
416
|
+
</div>
|
|
417
|
+
)}
|
|
418
|
+
</div>
|
|
419
|
+
|
|
420
|
+
{isExpanded && (
|
|
421
|
+
<ResizeHandle
|
|
422
|
+
orientation={orientation}
|
|
423
|
+
resizable={resizable}
|
|
424
|
+
showHandle={showHandle}
|
|
425
|
+
onDragStart={handleDragStart}
|
|
426
|
+
onDrag={handleDrag}
|
|
427
|
+
onDragEnd={handleDragEnd}
|
|
428
|
+
onKeyboardResize={handleKeyboardResize}
|
|
429
|
+
ariaLabel={handleAriaLabel}
|
|
430
|
+
/>
|
|
431
|
+
)}
|
|
432
|
+
</div>
|
|
433
|
+
);
|
|
434
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../utils/cn";
|
|
4
|
+
|
|
5
|
+
export interface ResizeHandleProps {
|
|
6
|
+
/**
|
|
7
|
+
* Orientation of the resize handle
|
|
8
|
+
*/
|
|
9
|
+
orientation: "left" | "right";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Whether the handle is resizable (draggable)
|
|
13
|
+
*/
|
|
14
|
+
resizable?: boolean;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Show/hide the handle UI
|
|
18
|
+
*/
|
|
19
|
+
showHandle?: boolean;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Callback when drag starts
|
|
23
|
+
*/
|
|
24
|
+
onDragStart?: () => void;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Callback during drag; receives pixel delta
|
|
28
|
+
*/
|
|
29
|
+
onDrag?: (deltaX: number) => void;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Callback when drag ends
|
|
33
|
+
*/
|
|
34
|
+
onDragEnd?: () => void;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Callback for keyboard resize (arrow keys)
|
|
38
|
+
*/
|
|
39
|
+
onKeyboardResize?: (direction: 1 | -1) => void;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* ARIA label for accessibility
|
|
43
|
+
*/
|
|
44
|
+
ariaLabel?: string;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Additional class names
|
|
48
|
+
*/
|
|
49
|
+
className?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* ResizeHandle component
|
|
54
|
+
*
|
|
55
|
+
* A draggable handle for resizing panels with full keyboard and mouse support.
|
|
56
|
+
* Includes visual feedback, accessibility features, and smooth interactions.
|
|
57
|
+
*/
|
|
58
|
+
export function ResizeHandle({
|
|
59
|
+
orientation,
|
|
60
|
+
resizable = true,
|
|
61
|
+
showHandle = true,
|
|
62
|
+
onDragStart,
|
|
63
|
+
onDrag,
|
|
64
|
+
onDragEnd,
|
|
65
|
+
onKeyboardResize,
|
|
66
|
+
ariaLabel,
|
|
67
|
+
className,
|
|
68
|
+
}: ResizeHandleProps) {
|
|
69
|
+
const [isDragging, setIsDragging] = React.useState(false);
|
|
70
|
+
const [isFocused, setIsFocused] = React.useState(false);
|
|
71
|
+
const startXRef = React.useRef<number>(0);
|
|
72
|
+
const handleRef = React.useRef<HTMLDivElement>(null);
|
|
73
|
+
|
|
74
|
+
const handleMouseDown = React.useCallback(
|
|
75
|
+
(e: React.MouseEvent) => {
|
|
76
|
+
if (!resizable) return;
|
|
77
|
+
|
|
78
|
+
e.preventDefault();
|
|
79
|
+
setIsDragging(true);
|
|
80
|
+
startXRef.current = e.clientX;
|
|
81
|
+
|
|
82
|
+
// Prevent text selection during drag
|
|
83
|
+
document.body.style.userSelect = "none";
|
|
84
|
+
|
|
85
|
+
// Notify start
|
|
86
|
+
onDragStart?.();
|
|
87
|
+
},
|
|
88
|
+
[resizable, onDragStart]
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const handleMouseMove = React.useCallback(
|
|
92
|
+
(e: MouseEvent) => {
|
|
93
|
+
if (!isDragging) return;
|
|
94
|
+
|
|
95
|
+
const deltaX = e.clientX - startXRef.current;
|
|
96
|
+
startXRef.current = e.clientX;
|
|
97
|
+
|
|
98
|
+
onDrag?.(deltaX);
|
|
99
|
+
},
|
|
100
|
+
[isDragging, onDrag]
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const handleMouseUp = React.useCallback(() => {
|
|
104
|
+
if (!isDragging) return;
|
|
105
|
+
|
|
106
|
+
setIsDragging(false);
|
|
107
|
+
document.body.style.userSelect = "";
|
|
108
|
+
onDragEnd?.();
|
|
109
|
+
}, [isDragging, onDragEnd]);
|
|
110
|
+
|
|
111
|
+
const handleKeyDown = React.useCallback(
|
|
112
|
+
(e: React.KeyboardEvent) => {
|
|
113
|
+
if (!resizable || !onKeyboardResize) return;
|
|
114
|
+
|
|
115
|
+
let direction: 1 | -1 | null = null;
|
|
116
|
+
|
|
117
|
+
// Left arrow = shrink from right / expand from left
|
|
118
|
+
// Right arrow = expand from right / shrink from left
|
|
119
|
+
if (e.key === "ArrowLeft") {
|
|
120
|
+
direction = orientation === "right" ? -1 : 1;
|
|
121
|
+
} else if (e.key === "ArrowRight") {
|
|
122
|
+
direction = orientation === "right" ? 1 : -1;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (direction !== null) {
|
|
126
|
+
e.preventDefault();
|
|
127
|
+
const multiplier = e.shiftKey ? 5 : 1; // Shift = 5vw steps
|
|
128
|
+
for (let i = 0; i < multiplier; i++) {
|
|
129
|
+
onKeyboardResize(direction);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
[resizable, onKeyboardResize, orientation]
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
// Attach global mouse listeners during drag
|
|
137
|
+
React.useEffect(() => {
|
|
138
|
+
if (isDragging) {
|
|
139
|
+
window.addEventListener("mousemove", handleMouseMove);
|
|
140
|
+
window.addEventListener("mouseup", handleMouseUp);
|
|
141
|
+
|
|
142
|
+
return () => {
|
|
143
|
+
window.removeEventListener("mousemove", handleMouseMove);
|
|
144
|
+
window.removeEventListener("mouseup", handleMouseUp);
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}, [isDragging, handleMouseMove, handleMouseUp]);
|
|
148
|
+
|
|
149
|
+
if (!showHandle) return null;
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<div
|
|
153
|
+
ref={handleRef}
|
|
154
|
+
role="separator"
|
|
155
|
+
aria-orientation="vertical"
|
|
156
|
+
aria-label={ariaLabel || `Resize handle ${orientation}`}
|
|
157
|
+
aria-valuenow={undefined}
|
|
158
|
+
tabIndex={resizable ? 0 : -1}
|
|
159
|
+
onMouseDown={handleMouseDown}
|
|
160
|
+
onKeyDown={handleKeyDown}
|
|
161
|
+
onFocus={() => setIsFocused(true)}
|
|
162
|
+
onBlur={() => setIsFocused(false)}
|
|
163
|
+
className={cn(
|
|
164
|
+
// Base styles
|
|
165
|
+
"relative z-10 flex items-center justify-center",
|
|
166
|
+
"transition-colors duration-150",
|
|
167
|
+
|
|
168
|
+
// Hit area (wider for better UX)
|
|
169
|
+
"w-3",
|
|
170
|
+
|
|
171
|
+
// Cursor
|
|
172
|
+
resizable ? "cursor-col-resize" : "cursor-default",
|
|
173
|
+
|
|
174
|
+
// Focus state - no visible outline
|
|
175
|
+
"outline-none",
|
|
176
|
+
|
|
177
|
+
// Disabled state
|
|
178
|
+
!resizable && "opacity-50",
|
|
179
|
+
|
|
180
|
+
className
|
|
181
|
+
)}
|
|
182
|
+
style={{
|
|
183
|
+
touchAction: "none",
|
|
184
|
+
}}
|
|
185
|
+
/>
|
|
186
|
+
);
|
|
187
|
+
}
|