@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.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +107 -0
  3. package/dist/index.cjs +6003 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +2310 -0
  6. package/dist/index.d.ts +2310 -0
  7. package/dist/index.js +5828 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/styles.css +96 -0
  10. package/dist/tailwind-preset.cjs +106 -0
  11. package/dist/tailwind-preset.cjs.map +1 -0
  12. package/dist/tailwind-preset.d.cts +23 -0
  13. package/dist/tailwind-preset.d.ts +23 -0
  14. package/dist/tailwind-preset.js +101 -0
  15. package/dist/tailwind-preset.js.map +1 -0
  16. package/package.json +154 -0
  17. package/src/components/accordion.tsx +187 -0
  18. package/src/components/alert-dialog.tsx +143 -0
  19. package/src/components/autocomplete.tsx +271 -0
  20. package/src/components/badge.tsx +62 -0
  21. package/src/components/button.tsx +85 -0
  22. package/src/components/calendar.tsx +235 -0
  23. package/src/components/card.tsx +94 -0
  24. package/src/components/checkbox.tsx +77 -0
  25. package/src/components/chip.tsx +77 -0
  26. package/src/components/confirmation-modal.tsx +195 -0
  27. package/src/components/context-menu.tsx +406 -0
  28. package/src/components/copy-button.tsx +84 -0
  29. package/src/components/data-grid/DataGrid.tsx +1027 -0
  30. package/src/components/data-grid/components/CellEditor.tsx +346 -0
  31. package/src/components/data-grid/components/FilterPopover.tsx +459 -0
  32. package/src/components/data-grid/components/HeaderCell.tsx +207 -0
  33. package/src/components/data-grid/components/index.ts +14 -0
  34. package/src/components/data-grid/hooks/index.ts +28 -0
  35. package/src/components/data-grid/hooks/useColumnResize.ts +378 -0
  36. package/src/components/data-grid/hooks/useDataGridState.ts +346 -0
  37. package/src/components/data-grid/hooks/useKeyboardNavigation.ts +361 -0
  38. package/src/components/data-grid/index.ts +71 -0
  39. package/src/components/data-grid/types.ts +478 -0
  40. package/src/components/data-grid/utils/dataProcessing.ts +277 -0
  41. package/src/components/data-grid/utils/index.ts +12 -0
  42. package/src/components/date-picker.tsx +366 -0
  43. package/src/components/dropdown-menu.tsx +230 -0
  44. package/src/components/icon-button.tsx +157 -0
  45. package/src/components/input.tsx +40 -0
  46. package/src/components/label.tsx +37 -0
  47. package/src/components/loading-spinner.tsx +113 -0
  48. package/src/components/modal.tsx +207 -0
  49. package/src/components/popover.tsx +62 -0
  50. package/src/components/progress.tsx +41 -0
  51. package/src/components/resizable-panel.tsx +434 -0
  52. package/src/components/resize-handle.tsx +187 -0
  53. package/src/components/select.tsx +160 -0
  54. package/src/components/separator.tsx +50 -0
  55. package/src/components/skeleton.tsx +37 -0
  56. package/src/components/switch.tsx +59 -0
  57. package/src/components/table.tsx +136 -0
  58. package/src/components/tabs.tsx +102 -0
  59. package/src/components/textarea.tsx +36 -0
  60. package/src/components/theme-picker.tsx +245 -0
  61. package/src/components/toaster.tsx +84 -0
  62. package/src/components/tooltip.tsx +199 -0
  63. package/src/index.ts +318 -0
  64. package/src/styles.css +96 -0
  65. package/src/tailwind-preset.ts +129 -0
  66. package/src/theme/index.ts +41 -0
  67. package/src/theme/presets.ts +502 -0
  68. package/src/theme/types.ts +164 -0
  69. package/src/theme/utils.ts +309 -0
  70. 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
+ }