@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,195 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
AlertDialog,
|
|
5
|
+
AlertDialogAction,
|
|
6
|
+
AlertDialogCancel,
|
|
7
|
+
AlertDialogContent,
|
|
8
|
+
AlertDialogDescription,
|
|
9
|
+
AlertDialogFooter,
|
|
10
|
+
AlertDialogHeader,
|
|
11
|
+
AlertDialogTitle,
|
|
12
|
+
} from "./alert-dialog";
|
|
13
|
+
|
|
14
|
+
export interface ConfirmationModalProps {
|
|
15
|
+
/** Whether the modal is open */
|
|
16
|
+
open: boolean;
|
|
17
|
+
/** Callback when open state changes */
|
|
18
|
+
onOpenChange: (open: boolean) => void;
|
|
19
|
+
/** Title of the confirmation dialog */
|
|
20
|
+
title: string;
|
|
21
|
+
/** Description/message to display */
|
|
22
|
+
description: string;
|
|
23
|
+
/** Label for the confirm button */
|
|
24
|
+
confirmLabel?: string;
|
|
25
|
+
/** Label for the cancel button */
|
|
26
|
+
cancelLabel?: string;
|
|
27
|
+
/** Whether this is a destructive action (styles confirm button as destructive) */
|
|
28
|
+
destructive?: boolean;
|
|
29
|
+
/** Callback when confirmed */
|
|
30
|
+
onConfirm: () => void;
|
|
31
|
+
/** Callback when cancelled (optional, defaults to closing) */
|
|
32
|
+
onCancel?: () => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* ConfirmationModal
|
|
37
|
+
*
|
|
38
|
+
* A simple yes/no confirmation dialog built on AlertDialog.
|
|
39
|
+
* Use for actions that need user confirmation before proceeding.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* <ConfirmationModal
|
|
43
|
+
* open={isOpen}
|
|
44
|
+
* onOpenChange={setIsOpen}
|
|
45
|
+
* title="Delete Item"
|
|
46
|
+
* description="Are you sure you want to delete this item?"
|
|
47
|
+
* destructive
|
|
48
|
+
* onConfirm={handleDelete}
|
|
49
|
+
* />
|
|
50
|
+
*/
|
|
51
|
+
export function ConfirmationModal({
|
|
52
|
+
open,
|
|
53
|
+
onOpenChange,
|
|
54
|
+
title,
|
|
55
|
+
description,
|
|
56
|
+
confirmLabel = "Confirm",
|
|
57
|
+
cancelLabel = "Cancel",
|
|
58
|
+
destructive = false,
|
|
59
|
+
onConfirm,
|
|
60
|
+
onCancel,
|
|
61
|
+
}: ConfirmationModalProps) {
|
|
62
|
+
const handleCancel = () => {
|
|
63
|
+
onCancel?.();
|
|
64
|
+
onOpenChange(false);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const handleConfirm = () => {
|
|
68
|
+
onConfirm();
|
|
69
|
+
onOpenChange(false);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
|
74
|
+
<AlertDialogContent>
|
|
75
|
+
<AlertDialogHeader>
|
|
76
|
+
<AlertDialogTitle>{title}</AlertDialogTitle>
|
|
77
|
+
<AlertDialogDescription>{description}</AlertDialogDescription>
|
|
78
|
+
</AlertDialogHeader>
|
|
79
|
+
<AlertDialogFooter>
|
|
80
|
+
<AlertDialogCancel onClick={handleCancel}>
|
|
81
|
+
{cancelLabel}
|
|
82
|
+
</AlertDialogCancel>
|
|
83
|
+
<AlertDialogAction
|
|
84
|
+
onClick={handleConfirm}
|
|
85
|
+
className={
|
|
86
|
+
destructive
|
|
87
|
+
? "bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
88
|
+
: undefined
|
|
89
|
+
}
|
|
90
|
+
>
|
|
91
|
+
{confirmLabel}
|
|
92
|
+
</AlertDialogAction>
|
|
93
|
+
</AlertDialogFooter>
|
|
94
|
+
</AlertDialogContent>
|
|
95
|
+
</AlertDialog>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* useConfirmation Hook
|
|
101
|
+
*
|
|
102
|
+
* A hook that provides imperative confirmation dialogs.
|
|
103
|
+
* Returns a confirm function that shows a dialog and returns a promise.
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* const { confirm, ConfirmationDialog } = useConfirmation();
|
|
107
|
+
*
|
|
108
|
+
* const handleDelete = async () => {
|
|
109
|
+
* const confirmed = await confirm({
|
|
110
|
+
* title: "Delete Item",
|
|
111
|
+
* description: "Are you sure you want to delete this item?",
|
|
112
|
+
* destructive: true,
|
|
113
|
+
* });
|
|
114
|
+
* if (confirmed) {
|
|
115
|
+
* // perform delete
|
|
116
|
+
* }
|
|
117
|
+
* };
|
|
118
|
+
*
|
|
119
|
+
* return (
|
|
120
|
+
* <>
|
|
121
|
+
* <button onClick={handleDelete}>Delete</button>
|
|
122
|
+
* {ConfirmationDialog}
|
|
123
|
+
* </>
|
|
124
|
+
* );
|
|
125
|
+
*/
|
|
126
|
+
export function useConfirmation() {
|
|
127
|
+
const [state, setState] = React.useState<{
|
|
128
|
+
open: boolean;
|
|
129
|
+
title: string;
|
|
130
|
+
description: string;
|
|
131
|
+
confirmLabel?: string;
|
|
132
|
+
cancelLabel?: string;
|
|
133
|
+
destructive?: boolean;
|
|
134
|
+
resolve?: (value: boolean) => void;
|
|
135
|
+
}>({
|
|
136
|
+
open: false,
|
|
137
|
+
title: "",
|
|
138
|
+
description: "",
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const confirm = React.useCallback(
|
|
142
|
+
(options: {
|
|
143
|
+
title: string;
|
|
144
|
+
description: string;
|
|
145
|
+
confirmLabel?: string;
|
|
146
|
+
cancelLabel?: string;
|
|
147
|
+
destructive?: boolean;
|
|
148
|
+
}): Promise<boolean> => {
|
|
149
|
+
return new Promise((resolve) => {
|
|
150
|
+
setState({
|
|
151
|
+
open: true,
|
|
152
|
+
...options,
|
|
153
|
+
resolve,
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
},
|
|
157
|
+
[]
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const handleConfirm = React.useCallback(() => {
|
|
161
|
+
state.resolve?.(true);
|
|
162
|
+
setState((prev) => ({ ...prev, open: false }));
|
|
163
|
+
}, [state.resolve]);
|
|
164
|
+
|
|
165
|
+
const handleCancel = React.useCallback(() => {
|
|
166
|
+
state.resolve?.(false);
|
|
167
|
+
setState((prev) => ({ ...prev, open: false }));
|
|
168
|
+
}, [state.resolve]);
|
|
169
|
+
|
|
170
|
+
const handleOpenChange = React.useCallback(
|
|
171
|
+
(open: boolean) => {
|
|
172
|
+
if (!open) {
|
|
173
|
+
state.resolve?.(false);
|
|
174
|
+
}
|
|
175
|
+
setState((prev) => ({ ...prev, open }));
|
|
176
|
+
},
|
|
177
|
+
[state.resolve]
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const ConfirmationDialog = (
|
|
181
|
+
<ConfirmationModal
|
|
182
|
+
open={state.open}
|
|
183
|
+
onOpenChange={handleOpenChange}
|
|
184
|
+
title={state.title}
|
|
185
|
+
description={state.description}
|
|
186
|
+
confirmLabel={state.confirmLabel}
|
|
187
|
+
cancelLabel={state.cancelLabel}
|
|
188
|
+
destructive={state.destructive}
|
|
189
|
+
onConfirm={handleConfirm}
|
|
190
|
+
onCancel={handleCancel}
|
|
191
|
+
/>
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
return { confirm, ConfirmationDialog };
|
|
195
|
+
}
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ContextMenu Component
|
|
3
|
+
*
|
|
4
|
+
* Unified context menu primitive with support for:
|
|
5
|
+
* - Icons
|
|
6
|
+
* - Keyboard shortcuts
|
|
7
|
+
* - Nested submenus
|
|
8
|
+
* - Dividers
|
|
9
|
+
* - Destructive actions
|
|
10
|
+
* - Disabled states
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as React from "react";
|
|
14
|
+
import { ChevronRight, type LucideIcon } from "lucide-react";
|
|
15
|
+
|
|
16
|
+
import { cn } from "../utils/cn";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Context menu item definition
|
|
20
|
+
*/
|
|
21
|
+
export interface ContextMenuItem {
|
|
22
|
+
/** Unique identifier */
|
|
23
|
+
id: string;
|
|
24
|
+
/** Display label */
|
|
25
|
+
label: string;
|
|
26
|
+
/** Optional icon component */
|
|
27
|
+
icon?: LucideIcon;
|
|
28
|
+
/** Click handler (ignored if has submenu) */
|
|
29
|
+
action?: () => void;
|
|
30
|
+
/** Keyboard shortcut display text */
|
|
31
|
+
shortcut?: string;
|
|
32
|
+
/** Whether item is disabled */
|
|
33
|
+
disabled?: boolean;
|
|
34
|
+
/** Whether to show as destructive/danger action */
|
|
35
|
+
destructive?: boolean;
|
|
36
|
+
/** Show divider line after this item */
|
|
37
|
+
divider?: boolean;
|
|
38
|
+
/** Nested submenu items */
|
|
39
|
+
submenu?: ContextMenuItem[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ContextMenuProps {
|
|
43
|
+
/** Menu items */
|
|
44
|
+
items: ContextMenuItem[];
|
|
45
|
+
/** Whether menu is open */
|
|
46
|
+
isOpen: boolean;
|
|
47
|
+
/** Close handler */
|
|
48
|
+
onClose: () => void;
|
|
49
|
+
/** Menu position (x, y) */
|
|
50
|
+
position: { x: number; y: number };
|
|
51
|
+
/** Anchor element (alternative to position) */
|
|
52
|
+
anchorEl?: HTMLElement | null;
|
|
53
|
+
/** Additional class names */
|
|
54
|
+
className?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Calculate safe menu position to keep it within viewport
|
|
59
|
+
*/
|
|
60
|
+
function getSafePosition(
|
|
61
|
+
x: number,
|
|
62
|
+
y: number,
|
|
63
|
+
menuWidth: number,
|
|
64
|
+
menuHeight: number
|
|
65
|
+
): { x: number; y: number } {
|
|
66
|
+
const padding = 8;
|
|
67
|
+
const viewportWidth = window.innerWidth;
|
|
68
|
+
const viewportHeight = window.innerHeight;
|
|
69
|
+
|
|
70
|
+
let safeX = x;
|
|
71
|
+
let safeY = y;
|
|
72
|
+
|
|
73
|
+
if (x + menuWidth > viewportWidth - padding) {
|
|
74
|
+
safeX = viewportWidth - menuWidth - padding;
|
|
75
|
+
}
|
|
76
|
+
if (safeX < padding) {
|
|
77
|
+
safeX = padding;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (y + menuHeight > viewportHeight - padding) {
|
|
81
|
+
safeY = viewportHeight - menuHeight - padding;
|
|
82
|
+
}
|
|
83
|
+
if (safeY < padding) {
|
|
84
|
+
safeY = padding;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { x: safeX, y: safeY };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Individual menu item component with submenu support
|
|
92
|
+
*/
|
|
93
|
+
interface MenuItemProps {
|
|
94
|
+
item: ContextMenuItem;
|
|
95
|
+
onClose: () => void;
|
|
96
|
+
depth?: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function MenuItem({ item, onClose, depth = 0 }: MenuItemProps) {
|
|
100
|
+
const [isSubmenuOpen, setIsSubmenuOpen] = React.useState(false);
|
|
101
|
+
const [submenuPosition, setSubmenuPosition] = React.useState<{
|
|
102
|
+
x: number;
|
|
103
|
+
y: number;
|
|
104
|
+
} | null>(null);
|
|
105
|
+
const itemRef = React.useRef<HTMLButtonElement>(null);
|
|
106
|
+
const hoverTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
107
|
+
|
|
108
|
+
const hasSubmenu = item.submenu && item.submenu.length > 0;
|
|
109
|
+
|
|
110
|
+
React.useEffect(() => {
|
|
111
|
+
return () => {
|
|
112
|
+
if (hoverTimeoutRef.current) {
|
|
113
|
+
clearTimeout(hoverTimeoutRef.current);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
}, []);
|
|
117
|
+
|
|
118
|
+
const handleMouseEnter = React.useCallback(() => {
|
|
119
|
+
if (hoverTimeoutRef.current) {
|
|
120
|
+
clearTimeout(hoverTimeoutRef.current);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (hasSubmenu && itemRef.current) {
|
|
124
|
+
const rect = itemRef.current.getBoundingClientRect();
|
|
125
|
+
const submenuWidth = 200;
|
|
126
|
+
const submenuHeight = (item.submenu?.length || 0) * 40;
|
|
127
|
+
|
|
128
|
+
const spaceOnRight = window.innerWidth - rect.right;
|
|
129
|
+
const posX =
|
|
130
|
+
spaceOnRight > submenuWidth
|
|
131
|
+
? rect.right - 4
|
|
132
|
+
: rect.left - submenuWidth + 4;
|
|
133
|
+
|
|
134
|
+
const { x, y } = getSafePosition(
|
|
135
|
+
posX,
|
|
136
|
+
rect.top,
|
|
137
|
+
submenuWidth,
|
|
138
|
+
submenuHeight
|
|
139
|
+
);
|
|
140
|
+
setSubmenuPosition({ x, y });
|
|
141
|
+
setIsSubmenuOpen(true);
|
|
142
|
+
}
|
|
143
|
+
}, [hasSubmenu, item.submenu?.length]);
|
|
144
|
+
|
|
145
|
+
const handleMouseLeave = React.useCallback(() => {
|
|
146
|
+
hoverTimeoutRef.current = setTimeout(() => {
|
|
147
|
+
setIsSubmenuOpen(false);
|
|
148
|
+
}, 100);
|
|
149
|
+
}, []);
|
|
150
|
+
|
|
151
|
+
const handleSubmenuMouseEnter = React.useCallback(() => {
|
|
152
|
+
if (hoverTimeoutRef.current) {
|
|
153
|
+
clearTimeout(hoverTimeoutRef.current);
|
|
154
|
+
}
|
|
155
|
+
}, []);
|
|
156
|
+
|
|
157
|
+
const handleSubmenuMouseLeave = React.useCallback(() => {
|
|
158
|
+
hoverTimeoutRef.current = setTimeout(() => {
|
|
159
|
+
setIsSubmenuOpen(false);
|
|
160
|
+
}, 100);
|
|
161
|
+
}, []);
|
|
162
|
+
|
|
163
|
+
const handleClick = React.useCallback(() => {
|
|
164
|
+
if (item.disabled) return;
|
|
165
|
+
|
|
166
|
+
if (!hasSubmenu && item.action) {
|
|
167
|
+
item.action();
|
|
168
|
+
onClose();
|
|
169
|
+
}
|
|
170
|
+
}, [item, hasSubmenu, onClose]);
|
|
171
|
+
|
|
172
|
+
if (item.divider && !item.label) {
|
|
173
|
+
return <div className="my-1 h-px bg-border" />;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<div className="relative" onMouseLeave={handleMouseLeave}>
|
|
178
|
+
<button
|
|
179
|
+
ref={itemRef}
|
|
180
|
+
onClick={handleClick}
|
|
181
|
+
onMouseEnter={handleMouseEnter}
|
|
182
|
+
disabled={item.disabled}
|
|
183
|
+
className={cn(
|
|
184
|
+
"w-full flex items-center gap-3 px-3 py-2 text-sm rounded-sm",
|
|
185
|
+
"transition-colors text-left",
|
|
186
|
+
"focus:outline-none focus:bg-accent focus:text-accent-foreground",
|
|
187
|
+
item.disabled
|
|
188
|
+
? "opacity-50 cursor-not-allowed text-muted-foreground"
|
|
189
|
+
: "hover:bg-accent hover:text-accent-foreground cursor-pointer",
|
|
190
|
+
item.destructive &&
|
|
191
|
+
!item.disabled &&
|
|
192
|
+
"text-destructive hover:text-destructive"
|
|
193
|
+
)}
|
|
194
|
+
>
|
|
195
|
+
{item.icon && (
|
|
196
|
+
<item.icon
|
|
197
|
+
className={cn(
|
|
198
|
+
"w-4 h-4 flex-shrink-0",
|
|
199
|
+
item.destructive && !item.disabled && "text-destructive"
|
|
200
|
+
)}
|
|
201
|
+
/>
|
|
202
|
+
)}
|
|
203
|
+
<span className="flex-1 truncate">{item.label}</span>
|
|
204
|
+
{item.shortcut && (
|
|
205
|
+
<span className="ml-auto text-xs text-muted-foreground opacity-60">
|
|
206
|
+
{item.shortcut}
|
|
207
|
+
</span>
|
|
208
|
+
)}
|
|
209
|
+
{hasSubmenu && (
|
|
210
|
+
<ChevronRight className="w-4 h-4 flex-shrink-0 ml-2 text-muted-foreground" />
|
|
211
|
+
)}
|
|
212
|
+
</button>
|
|
213
|
+
|
|
214
|
+
{item.divider && <div className="my-1 h-px bg-border" />}
|
|
215
|
+
|
|
216
|
+
{hasSubmenu && isSubmenuOpen && submenuPosition && (
|
|
217
|
+
<div
|
|
218
|
+
className={cn(
|
|
219
|
+
"fixed z-[60] min-w-[180px] max-w-[280px]",
|
|
220
|
+
"rounded-md border border-border",
|
|
221
|
+
"bg-popover text-popover-foreground",
|
|
222
|
+
"shadow-lg",
|
|
223
|
+
"animate-in fade-in-0 zoom-in-95",
|
|
224
|
+
"p-1"
|
|
225
|
+
)}
|
|
226
|
+
style={{
|
|
227
|
+
left: `${submenuPosition.x}px`,
|
|
228
|
+
top: `${submenuPosition.y}px`,
|
|
229
|
+
}}
|
|
230
|
+
onMouseEnter={handleSubmenuMouseEnter}
|
|
231
|
+
onMouseLeave={handleSubmenuMouseLeave}
|
|
232
|
+
>
|
|
233
|
+
{item.submenu!.map((subItem) => (
|
|
234
|
+
<MenuItem
|
|
235
|
+
key={subItem.id}
|
|
236
|
+
item={subItem}
|
|
237
|
+
onClose={onClose}
|
|
238
|
+
depth={depth + 1}
|
|
239
|
+
/>
|
|
240
|
+
))}
|
|
241
|
+
</div>
|
|
242
|
+
)}
|
|
243
|
+
</div>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* ContextMenu component
|
|
249
|
+
*
|
|
250
|
+
* A unified context menu with support for icons, shortcuts, submenus, and more.
|
|
251
|
+
*
|
|
252
|
+
* @example
|
|
253
|
+
* const { isOpen, position, openMenu, closeMenu } = useContextMenu();
|
|
254
|
+
*
|
|
255
|
+
* <div onContextMenu={(e) => {
|
|
256
|
+
* e.preventDefault();
|
|
257
|
+
* openMenu(e.clientX, e.clientY, item);
|
|
258
|
+
* }}>
|
|
259
|
+
* Right click me
|
|
260
|
+
* </div>
|
|
261
|
+
*
|
|
262
|
+
* <ContextMenu
|
|
263
|
+
* isOpen={isOpen}
|
|
264
|
+
* position={position}
|
|
265
|
+
* onClose={closeMenu}
|
|
266
|
+
* items={[
|
|
267
|
+
* { id: 'edit', label: 'Edit', icon: Edit, action: handleEdit },
|
|
268
|
+
* { id: 'delete', label: 'Delete', icon: Trash, destructive: true, action: handleDelete },
|
|
269
|
+
* ]}
|
|
270
|
+
* />
|
|
271
|
+
*/
|
|
272
|
+
export function ContextMenu({
|
|
273
|
+
items,
|
|
274
|
+
isOpen,
|
|
275
|
+
onClose,
|
|
276
|
+
position,
|
|
277
|
+
anchorEl,
|
|
278
|
+
className,
|
|
279
|
+
}: ContextMenuProps) {
|
|
280
|
+
const menuRef = React.useRef<HTMLDivElement>(null);
|
|
281
|
+
const [menuPosition, setMenuPosition] = React.useState<{ x: number; y: number }>({
|
|
282
|
+
x: 0,
|
|
283
|
+
y: 0,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
React.useEffect(() => {
|
|
287
|
+
if (!isOpen) return;
|
|
288
|
+
|
|
289
|
+
let x = position.x;
|
|
290
|
+
let y = position.y;
|
|
291
|
+
|
|
292
|
+
if (anchorEl) {
|
|
293
|
+
const rect = anchorEl.getBoundingClientRect();
|
|
294
|
+
x = rect.left;
|
|
295
|
+
y = rect.bottom + 4;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
requestAnimationFrame(() => {
|
|
299
|
+
if (menuRef.current) {
|
|
300
|
+
const menuRect = menuRef.current.getBoundingClientRect();
|
|
301
|
+
const safePos = getSafePosition(x, y, menuRect.width, menuRect.height);
|
|
302
|
+
setMenuPosition(safePos);
|
|
303
|
+
} else {
|
|
304
|
+
setMenuPosition({ x, y });
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
}, [isOpen, position, anchorEl]);
|
|
308
|
+
|
|
309
|
+
React.useEffect(() => {
|
|
310
|
+
if (!isOpen) return;
|
|
311
|
+
|
|
312
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
313
|
+
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
|
314
|
+
onClose();
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const handleEscape = (e: KeyboardEvent) => {
|
|
319
|
+
if (e.key === "Escape") {
|
|
320
|
+
onClose();
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
325
|
+
document.addEventListener("keydown", handleEscape);
|
|
326
|
+
|
|
327
|
+
return () => {
|
|
328
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
329
|
+
document.removeEventListener("keydown", handleEscape);
|
|
330
|
+
};
|
|
331
|
+
}, [isOpen, onClose]);
|
|
332
|
+
|
|
333
|
+
if (!isOpen) return null;
|
|
334
|
+
|
|
335
|
+
return (
|
|
336
|
+
<div
|
|
337
|
+
ref={menuRef}
|
|
338
|
+
className={cn(
|
|
339
|
+
"fixed z-50 min-w-[180px] max-w-[280px]",
|
|
340
|
+
"rounded-md border border-border",
|
|
341
|
+
"bg-popover text-popover-foreground",
|
|
342
|
+
"shadow-lg",
|
|
343
|
+
"animate-in fade-in-0 zoom-in-95",
|
|
344
|
+
"p-1",
|
|
345
|
+
className
|
|
346
|
+
)}
|
|
347
|
+
style={{
|
|
348
|
+
left: `${menuPosition.x}px`,
|
|
349
|
+
top: `${menuPosition.y}px`,
|
|
350
|
+
}}
|
|
351
|
+
role="menu"
|
|
352
|
+
aria-orientation="vertical"
|
|
353
|
+
>
|
|
354
|
+
{items.map((item) => (
|
|
355
|
+
<MenuItem key={item.id} item={item} onClose={onClose} />
|
|
356
|
+
))}
|
|
357
|
+
</div>
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Hook to manage context menu state
|
|
363
|
+
*/
|
|
364
|
+
export interface UseContextMenuResult<T = unknown> {
|
|
365
|
+
/** Whether menu is open */
|
|
366
|
+
isOpen: boolean;
|
|
367
|
+
/** Menu position */
|
|
368
|
+
position: { x: number; y: number };
|
|
369
|
+
/** Target item that was right-clicked */
|
|
370
|
+
targetItem: T | null;
|
|
371
|
+
/** Ref for the menu element */
|
|
372
|
+
menuRef: React.RefObject<HTMLDivElement>;
|
|
373
|
+
/** Open the context menu */
|
|
374
|
+
openMenu: (x: number, y: number, item: T) => void;
|
|
375
|
+
/** Close the context menu */
|
|
376
|
+
closeMenu: () => void;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function useContextMenu<T = unknown>(): UseContextMenuResult<T> {
|
|
380
|
+
const [isOpen, setIsOpen] = React.useState(false);
|
|
381
|
+
const [position, setPosition] = React.useState({ x: 0, y: 0 });
|
|
382
|
+
const [targetItem, setTargetItem] = React.useState<T | null>(null);
|
|
383
|
+
const menuRef = React.useRef<HTMLDivElement>(null);
|
|
384
|
+
|
|
385
|
+
const openMenu = React.useCallback((x: number, y: number, item: T) => {
|
|
386
|
+
setPosition({ x, y });
|
|
387
|
+
setTargetItem(item);
|
|
388
|
+
setIsOpen(true);
|
|
389
|
+
}, []);
|
|
390
|
+
|
|
391
|
+
const closeMenu = React.useCallback(() => {
|
|
392
|
+
setIsOpen(false);
|
|
393
|
+
setTargetItem(null);
|
|
394
|
+
}, []);
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
isOpen,
|
|
398
|
+
position,
|
|
399
|
+
targetItem,
|
|
400
|
+
menuRef,
|
|
401
|
+
openMenu,
|
|
402
|
+
closeMenu,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export default ContextMenu;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Copy, Check } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
import { cn } from "../utils/cn";
|
|
5
|
+
|
|
6
|
+
export interface CopyButtonProps {
|
|
7
|
+
/**
|
|
8
|
+
* Content to copy to clipboard
|
|
9
|
+
*/
|
|
10
|
+
content: string;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Additional class names
|
|
14
|
+
*/
|
|
15
|
+
className?: string;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Size of the button
|
|
19
|
+
*/
|
|
20
|
+
size?: "sm" | "md" | "lg";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* CopyButton component
|
|
25
|
+
*
|
|
26
|
+
* A button that copies content to clipboard and shows feedback.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* <CopyButton content="Text to copy" />
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* <div className="group relative">
|
|
33
|
+
* <pre>some code</pre>
|
|
34
|
+
* <CopyButton content="some code" className="absolute top-2 right-2" />
|
|
35
|
+
* </div>
|
|
36
|
+
*/
|
|
37
|
+
export function CopyButton({ content, className, size = "md" }: CopyButtonProps) {
|
|
38
|
+
const [copied, setCopied] = React.useState(false);
|
|
39
|
+
|
|
40
|
+
const handleCopy = async () => {
|
|
41
|
+
try {
|
|
42
|
+
await navigator.clipboard.writeText(content);
|
|
43
|
+
setCopied(true);
|
|
44
|
+
setTimeout(() => setCopied(false), 2000);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
console.error("Failed to copy text: ", err);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const sizeClasses = {
|
|
51
|
+
sm: "w-6 h-6",
|
|
52
|
+
md: "w-8 h-8",
|
|
53
|
+
lg: "w-10 h-10",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const iconSizes = {
|
|
57
|
+
sm: 12,
|
|
58
|
+
md: 16,
|
|
59
|
+
lg: 20,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<button
|
|
64
|
+
onClick={handleCopy}
|
|
65
|
+
className={cn(
|
|
66
|
+
"inline-flex items-center justify-center",
|
|
67
|
+
"bg-black/20 hover:bg-black/30",
|
|
68
|
+
"text-white/80 hover:text-white",
|
|
69
|
+
"rounded-md transition-all duration-200",
|
|
70
|
+
"opacity-0 group-hover:opacity-100",
|
|
71
|
+
"focus:opacity-100 focus:outline-none",
|
|
72
|
+
sizeClasses[size],
|
|
73
|
+
className
|
|
74
|
+
)}
|
|
75
|
+
title={copied ? "Copied!" : "Copy to clipboard"}
|
|
76
|
+
>
|
|
77
|
+
{copied ? (
|
|
78
|
+
<Check size={iconSizes[size]} />
|
|
79
|
+
) : (
|
|
80
|
+
<Copy size={iconSizes[size]} />
|
|
81
|
+
)}
|
|
82
|
+
</button>
|
|
83
|
+
);
|
|
84
|
+
}
|