alexui 1.0.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 (124) hide show
  1. package/README.md +57 -0
  2. package/components/ActionTable.tsx +307 -0
  3. package/components/AlertBanner.tsx +124 -0
  4. package/components/AnimatedAccordion.tsx +95 -0
  5. package/components/Autocomplete.tsx +144 -0
  6. package/components/Avatar.tsx +123 -0
  7. package/components/Badge.tsx +80 -0
  8. package/components/Breadcrumb.tsx +74 -0
  9. package/components/Calendar.tsx +340 -0
  10. package/components/Card3D.tsx +117 -0
  11. package/components/Carousel3D.tsx +193 -0
  12. package/components/CascadeSelect.tsx +232 -0
  13. package/components/ChartShowcase.tsx +700 -0
  14. package/components/Checkbox.tsx +212 -0
  15. package/components/ChipsInput.tsx +152 -0
  16. package/components/CircularKnob.tsx +240 -0
  17. package/components/CodeVisualizer.tsx +67 -0
  18. package/components/Collapsible.tsx +72 -0
  19. package/components/ColorThemeManager.tsx +458 -0
  20. package/components/CommandMenu.tsx +191 -0
  21. package/components/ConfirmDialog.tsx +152 -0
  22. package/components/ContextMenu.tsx +192 -0
  23. package/components/DashboardLayout.tsx +115 -0
  24. package/components/DatePicker.tsx +108 -0
  25. package/components/Divider.tsx +67 -0
  26. package/components/Dock.tsx +93 -0
  27. package/components/DragDropLists.tsx +160 -0
  28. package/components/Drawer.tsx +161 -0
  29. package/components/DropdownPlus.tsx +304 -0
  30. package/components/EmptyState.tsx +49 -0
  31. package/components/ErrorPage.tsx +62 -0
  32. package/components/FileDropzone.tsx +206 -0
  33. package/components/ForgotPassword.tsx +137 -0
  34. package/components/FormField.tsx +81 -0
  35. package/components/GlassButton.tsx +56 -0
  36. package/components/GlassCard.tsx +82 -0
  37. package/components/GlassInput.tsx +96 -0
  38. package/components/GlassmorphicModal.tsx +108 -0
  39. package/components/GlowInput.tsx +111 -0
  40. package/components/GlowSelect.tsx +203 -0
  41. package/components/GlowTextArea.tsx +105 -0
  42. package/components/HorizontalTimeline.tsx +121 -0
  43. package/components/HoverCard.tsx +105 -0
  44. package/components/ImageLightbox.tsx +259 -0
  45. package/components/InputGroup.tsx +118 -0
  46. package/components/InputOTP.tsx +147 -0
  47. package/components/InteractiveNavbar.tsx +266 -0
  48. package/components/InteractiveSidebar.tsx +211 -0
  49. package/components/Kbd.tsx +51 -0
  50. package/components/LiteYouTube.tsx +118 -0
  51. package/components/LoaderCollection.tsx +368 -0
  52. package/components/LoginForm.tsx +192 -0
  53. package/components/MagneticButton.tsx +101 -0
  54. package/components/MaskedInput.tsx +79 -0
  55. package/components/MentionInput.tsx +413 -0
  56. package/components/MorphingSwitch.tsx +86 -0
  57. package/components/MultiSelect.tsx +158 -0
  58. package/components/NumberInput.tsx +203 -0
  59. package/components/Panel.tsx +104 -0
  60. package/components/PasswordInput.tsx +203 -0
  61. package/components/Popover.tsx +91 -0
  62. package/components/PricingTable.tsx +113 -0
  63. package/components/ProgressBar.tsx +152 -0
  64. package/components/RadioButton.tsx +211 -0
  65. package/components/Rating.tsx +82 -0
  66. package/components/ResizablePanel.tsx +114 -0
  67. package/components/ScrollPanel.tsx +103 -0
  68. package/components/SettingsPage.tsx +154 -0
  69. package/components/SignupForm.tsx +182 -0
  70. package/components/Skeleton.tsx +41 -0
  71. package/components/Slider.tsx +95 -0
  72. package/components/SlidingTabs.tsx +54 -0
  73. package/components/SortableList.tsx +91 -0
  74. package/components/SpeedDial.tsx +134 -0
  75. package/components/Spinner.tsx +40 -0
  76. package/components/Stepper.tsx +124 -0
  77. package/components/TabMenu.tsx +72 -0
  78. package/components/TableControls.tsx +77 -0
  79. package/components/TablePagination.tsx +88 -0
  80. package/components/TextEditor.tsx +329 -0
  81. package/components/TextReveal.tsx +99 -0
  82. package/components/ThemeSwitcher.tsx +133 -0
  83. package/components/TimelineGSAP.tsx +164 -0
  84. package/components/ToastSystem.tsx +110 -0
  85. package/components/ToggleButton.tsx +79 -0
  86. package/components/Tooltip.tsx +121 -0
  87. package/components/Tree.tsx +138 -0
  88. package/dist/commands/add.d.ts +7 -0
  89. package/dist/commands/add.js +110 -0
  90. package/dist/commands/init.d.ts +5 -0
  91. package/dist/commands/init.js +76 -0
  92. package/dist/commands/list.d.ts +1 -0
  93. package/dist/commands/list.js +32 -0
  94. package/dist/index.d.ts +2 -0
  95. package/dist/index.js +60 -0
  96. package/dist/registry.d.ts +6 -0
  97. package/dist/registry.js +38 -0
  98. package/dist/tui/browse.d.ts +3 -0
  99. package/dist/tui/browse.js +139 -0
  100. package/dist/tui/format.d.ts +11 -0
  101. package/dist/tui/format.js +52 -0
  102. package/dist/tui/main.d.ts +1 -0
  103. package/dist/tui/main.js +86 -0
  104. package/dist/tui/panels.d.ts +9 -0
  105. package/dist/tui/panels.js +50 -0
  106. package/dist/tui/theme.d.ts +28 -0
  107. package/dist/tui/theme.js +76 -0
  108. package/dist/types.d.ts +28 -0
  109. package/dist/types.js +1 -0
  110. package/dist/utils/config.d.ts +6 -0
  111. package/dist/utils/config.js +24 -0
  112. package/dist/utils/copy.d.ts +9 -0
  113. package/dist/utils/copy.js +43 -0
  114. package/dist/utils/cwd.d.ts +6 -0
  115. package/dist/utils/cwd.js +30 -0
  116. package/dist/utils/deps.d.ts +1 -0
  117. package/dist/utils/deps.js +19 -0
  118. package/dist/utils/project.d.ts +5 -0
  119. package/dist/utils/project.js +30 -0
  120. package/dist/utils/theme.d.ts +1 -0
  121. package/dist/utils/theme.js +24 -0
  122. package/package.json +52 -0
  123. package/registry.json +1133 -0
  124. package/templates/theme.css +81 -0
@@ -0,0 +1,203 @@
1
+ import React, { useState, useRef, useEffect } from 'react';
2
+ import { motion, AnimatePresence } from 'framer-motion';
3
+ import { ChevronDown } from 'lucide-react';
4
+
5
+ export interface SelectOption {
6
+ value: string;
7
+ label: string;
8
+ }
9
+
10
+ export interface GlowSelectProps {
11
+ label?: string;
12
+ options: SelectOption[];
13
+ value: string;
14
+ onChange: (value: string) => void;
15
+ className?: string;
16
+ error?: string;
17
+ disabled?: boolean;
18
+ placeholder?: string;
19
+ size?: 'sm' | 'md' | 'lg';
20
+ }
21
+
22
+ export const GlowSelect: React.FC<GlowSelectProps> = ({
23
+ label,
24
+ options,
25
+ value,
26
+ onChange,
27
+ className = '',
28
+ error,
29
+ disabled = false,
30
+ placeholder = 'Seleccionar...',
31
+ size = 'md'
32
+ }) => {
33
+ const [isOpen, setIsOpen] = useState(false);
34
+ const containerRef = useRef<HTMLDivElement>(null);
35
+
36
+ const selectedOption = options.find((opt) => opt.value === value);
37
+
38
+ // Close dropdown when clicking outside
39
+ useEffect(() => {
40
+ const handleClickOutside = (event: MouseEvent) => {
41
+ if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
42
+ setIsOpen(false);
43
+ }
44
+ };
45
+ document.addEventListener('mousedown', handleClickOutside);
46
+ return () => document.removeEventListener('mousedown', handleClickOutside);
47
+ }, []);
48
+
49
+ const handleSelect = (val: string) => {
50
+ if (disabled) return;
51
+ onChange(val);
52
+ setIsOpen(false);
53
+ };
54
+
55
+ const hasFloatingLabel = !!label && size !== 'sm';
56
+ const isActive = isOpen || (value && value.length > 0);
57
+
58
+ return (
59
+ <div ref={containerRef} className={`relative w-full flex flex-col gap-1.5 ${className}`}>
60
+
61
+ {/* External label for size === 'sm' */}
62
+ {label && !hasFloatingLabel && (
63
+ <span className="text-[10px] font-bold text-text-muted uppercase tracking-wider px-1">
64
+ {label}
65
+ </span>
66
+ )}
67
+
68
+ {/* Selector Wrapper with glow boundary */}
69
+ <div className="relative rounded-xl overflow-visible transition-all duration-300">
70
+
71
+ {/* Animated Glow Border background */}
72
+ <motion.div
73
+ animate={{
74
+ opacity: isOpen && !disabled ? 1 : 0,
75
+ scale: isOpen && !disabled ? 1 : 0.95,
76
+ }}
77
+ transition={{ duration: 0.3 }}
78
+ className={`absolute -inset-[1px] bg-gradient-to-r from-accent to-pink-500 pointer-events-none z-0 blur-[2px] ${
79
+ size === 'lg' ? 'rounded-2xl' : 'rounded-xl'
80
+ }`}
81
+ />
82
+
83
+ {/* Selected trigger element */}
84
+ <button
85
+ type="button"
86
+ onClick={() => {
87
+ if (!disabled) setIsOpen(!isOpen);
88
+ }}
89
+ disabled={disabled}
90
+ className={`w-full relative bg-bg-card/60 border border-border-app z-10 text-left text-text-main focus:outline-hidden flex items-center justify-between transition-colors duration-300 ${
91
+ hasFloatingLabel
92
+ ? 'pb-2 pt-7 px-4 text-sm rounded-xl h-14'
93
+ : size === 'sm'
94
+ ? 'py-1.5 px-3 text-xs rounded-xl h-9'
95
+ : size === 'lg'
96
+ ? 'py-3.5 px-5 text-base rounded-2xl h-[52px]'
97
+ : 'py-2.5 px-4 text-sm rounded-xl h-[44px]'
98
+ } ${
99
+ disabled ? 'opacity-40 cursor-not-allowed select-none bg-bg-app/10' : 'cursor-pointer'
100
+ }`}
101
+ style={{
102
+ boxShadow: isOpen && !disabled ? '0 0 10px var(--color-accent-glow)' : 'none'
103
+ }}
104
+ >
105
+ {/* Label (Floating only) */}
106
+ {hasFloatingLabel && (
107
+ <motion.span
108
+ initial={{ y: 0, scale: 1 }}
109
+ animate={{
110
+ y: isActive ? 4 : 14,
111
+ scale: isActive ? 0.75 : 1,
112
+ color: isOpen && !disabled
113
+ ? 'var(--color-accent)'
114
+ : 'var(--color-text-muted)'
115
+ }}
116
+ transition={{ type: 'spring', stiffness: 200, damping: 20 }}
117
+ className="absolute left-4 top-0 origin-top-left pointer-events-none font-medium text-xs"
118
+ >
119
+ {label}
120
+ </motion.span>
121
+ )}
122
+
123
+ {/* Option text */}
124
+ <span className={`block truncate ${!selectedOption ? 'text-text-muted/60 font-normal' : 'font-medium'}`}>
125
+ {selectedOption ? selectedOption.label : placeholder}
126
+ </span>
127
+
128
+ {/* Chevron */}
129
+ <motion.div
130
+ animate={{ rotate: isOpen ? 180 : 0 }}
131
+ transition={{ duration: 0.2 }}
132
+ className="text-text-muted flex items-center justify-center"
133
+ >
134
+ <ChevronDown className={size === 'sm' ? 'w-4 h-4' : 'w-5 h-5'} />
135
+ </motion.div>
136
+ </button>
137
+
138
+ {/* Animated Custom Options List */}
139
+ <AnimatePresence>
140
+ {isOpen && (
141
+ <motion.ul
142
+ initial={{ opacity: 0, y: 10, scale: 0.95 }}
143
+ animate={{
144
+ opacity: 1,
145
+ y: 4,
146
+ scale: 1,
147
+ transition: { type: 'spring', stiffness: 350, damping: 25 }
148
+ }}
149
+ exit={{
150
+ opacity: 0,
151
+ y: 10,
152
+ scale: 0.95,
153
+ transition: { duration: 0.15 }
154
+ }}
155
+ className={`absolute left-0 w-full glass bg-bg-card rounded-xl shadow-2xl z-50 overflow-y-auto focus:outline-hidden border border-border-app/50 ${
156
+ size === 'sm' ? 'py-1 max-h-48' : size === 'lg' ? 'py-2 max-h-72' : 'py-1.5 max-h-60'
157
+ }`}
158
+ style={{
159
+ top: 'calc(100% + 4px)'
160
+ }}
161
+ >
162
+ {options.map((opt) => {
163
+ const isSelected = opt.value === value;
164
+ return (
165
+ <li
166
+ key={opt.value}
167
+ onClick={() => handleSelect(opt.value)}
168
+ className={`cursor-pointer select-none transition-colors duration-200 flex items-center justify-between ${
169
+ size === 'sm'
170
+ ? 'px-3 py-1.5 text-xs'
171
+ : size === 'lg'
172
+ ? 'px-5 py-3 text-base'
173
+ : 'px-4 py-2.5 text-sm'
174
+ } ${
175
+ isSelected
176
+ ? 'bg-accent/10 text-accent font-bold'
177
+ : 'text-text-main hover:bg-bg-app'
178
+ }`}
179
+ >
180
+ <span>{opt.label}</span>
181
+ {isSelected && <motion.span layoutId={`activeSelectOptionTick-${label || placeholder || 'select'}`}>✓</motion.span>}
182
+ </li>
183
+ );
184
+ })}
185
+ </motion.ul>
186
+ )}
187
+ </AnimatePresence>
188
+ </div>
189
+
190
+ {/* Error message */}
191
+ {error && (
192
+ <motion.span
193
+ initial={{ opacity: 0, y: -5 }}
194
+ animate={{ opacity: 1, y: 0 }}
195
+ className="text-xs text-red-500 font-medium px-2"
196
+ >
197
+ {error}
198
+ </motion.span>
199
+ )}
200
+ </div>
201
+ );
202
+ };
203
+
@@ -0,0 +1,105 @@
1
+ import React, { useState } from 'react';
2
+ import { motion } from 'framer-motion';
3
+
4
+ export interface GlowTextAreaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
5
+ label: string;
6
+ error?: string;
7
+ }
8
+
9
+ export const GlowTextArea: React.FC<GlowTextAreaProps> = ({
10
+ label,
11
+ error,
12
+ id,
13
+ value,
14
+ onChange,
15
+ onFocus,
16
+ onBlur,
17
+ className = '',
18
+ rows = 4,
19
+ ...props
20
+ }) => {
21
+ const [isFocused, setIsFocused] = useState(false);
22
+ const [hasText, setHasText] = useState(false);
23
+
24
+ const textareaId = id || (label ? `textarea-${label.toLowerCase().replace(/\s+/g, '-')}` : 'glow-textarea');
25
+
26
+ const handleFocus = (e: React.FocusEvent<HTMLTextAreaElement>) => {
27
+ setIsFocused(true);
28
+ if (onFocus) onFocus(e);
29
+ };
30
+
31
+ const handleBlur = (e: React.FocusEvent<HTMLTextAreaElement>) => {
32
+ setIsFocused(false);
33
+ setHasText(e.target.value.length > 0);
34
+ if (onBlur) onBlur(e);
35
+ };
36
+
37
+ const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
38
+ setHasText(e.target.value.length > 0);
39
+ if (onChange) onChange(e);
40
+ };
41
+
42
+ const isActive = isFocused || hasText || (value && String(value).length > 0) || !!props.placeholder;
43
+
44
+ return (
45
+ <div className={`relative w-full flex flex-col gap-1.5 ${className}`}>
46
+
47
+ {/* Wrapper with border glow container */}
48
+ <div className="relative rounded-xl overflow-hidden transition-all duration-300">
49
+
50
+ {/* Animated Glow Border background */}
51
+ <motion.div
52
+ animate={{
53
+ opacity: isFocused ? 1 : 0,
54
+ scale: isFocused ? 1 : 0.98,
55
+ }}
56
+ transition={{ duration: 0.3 }}
57
+ className="absolute -inset-[1px] bg-gradient-to-r from-accent to-pink-500 rounded-xl pointer-events-none z-0 blur-[2px]"
58
+ />
59
+
60
+ {/* Textarea container holder */}
61
+ <div className="relative bg-bg-card border border-border-app rounded-xl z-10 transition-colors duration-300">
62
+
63
+ {/* Floating Label */}
64
+ <motion.label
65
+ htmlFor={textareaId}
66
+ initial={{ y: 14, scale: 1 }}
67
+ animate={{
68
+ y: isActive ? 4 : 14,
69
+ scale: isActive ? 0.75 : 1,
70
+ color: isFocused
71
+ ? 'var(--color-accent)'
72
+ : 'var(--color-text-muted)'
73
+ }}
74
+ transition={{ type: 'spring', stiffness: 200, damping: 20 }}
75
+ className="absolute left-4 origin-top-left pointer-events-none select-none text-sm z-20 font-medium"
76
+ >
77
+ {label}
78
+ </motion.label>
79
+
80
+ <textarea
81
+ id={textareaId}
82
+ value={value}
83
+ onChange={handleChange}
84
+ onFocus={handleFocus}
85
+ onBlur={handleBlur}
86
+ rows={rows}
87
+ className="w-full bg-transparent px-4 pb-2.5 pt-7 text-sm text-text-main focus:outline-hidden z-10 relative resize-y min-h-[100px]"
88
+ {...props}
89
+ />
90
+ </div>
91
+ </div>
92
+
93
+ {/* Error message */}
94
+ {error && (
95
+ <motion.span
96
+ initial={{ opacity: 0, y: -5 }}
97
+ animate={{ opacity: 1, y: 0 }}
98
+ className="text-xs text-red-500 font-medium px-2"
99
+ >
100
+ {error}
101
+ </motion.span>
102
+ )}
103
+ </div>
104
+ );
105
+ };
@@ -0,0 +1,121 @@
1
+ import React from 'react';
2
+ import { motion, AnimatePresence } from 'framer-motion';
3
+
4
+ export interface TimelineItem {
5
+ id: string | number;
6
+ date: string;
7
+ title: string;
8
+ description: string;
9
+ }
10
+
11
+ export interface HorizontalTimelineProps {
12
+ items: TimelineItem[];
13
+ activeId: string | number;
14
+ onChange: (id: string | number) => void;
15
+ className?: string;
16
+ disabled?: boolean;
17
+ }
18
+
19
+ export const HorizontalTimeline: React.FC<HorizontalTimelineProps> = ({
20
+ items,
21
+ activeId,
22
+ onChange,
23
+ className = '',
24
+ disabled = false
25
+ }) => {
26
+ const activeIdx = items.findIndex((item) => item.id === activeId);
27
+ const percentage = items.length > 1 ? (activeIdx / (items.length - 1)) * 100 : 0;
28
+
29
+ const currentItem = items[activeIdx] || items[0];
30
+
31
+ return (
32
+ <div className={`w-full flex flex-col gap-8 select-none ${
33
+ disabled ? 'opacity-40 cursor-not-allowed' : ''
34
+ } ${className}`}>
35
+
36
+ {/* Horizontal Line and Nodes Container */}
37
+ <div className="relative py-6 flex items-center justify-between w-full px-6">
38
+
39
+ {/* Background Track Line */}
40
+ <div className="absolute left-6 right-6 h-1 bg-border-app/40 rounded-full z-0 pointer-events-none" />
41
+
42
+ {/* Active Progress Fill Line container */}
43
+ <div className="absolute left-6 right-6 h-1 z-10 pointer-events-none">
44
+ <motion.div
45
+ initial={{ width: 0 }}
46
+ animate={{ width: `${percentage}%` }}
47
+ transition={{ type: 'spring', stiffness: 260, damping: 26 }}
48
+ className="h-full bg-accent rounded-full origin-left"
49
+ />
50
+ </div>
51
+
52
+ {/* Milestone Nodes */}
53
+ {items.map((item, idx) => {
54
+ const isSelected = item.id === activeId;
55
+ const isPassed = idx <= activeIdx;
56
+
57
+ return (
58
+ <div
59
+ key={item.id}
60
+ className="relative z-20 flex flex-col items-center"
61
+ style={{
62
+ left: items.length > 1 ? undefined : '50%'
63
+ }}
64
+ >
65
+ {/* Node Button */}
66
+ <button
67
+ type="button"
68
+ onClick={() => !disabled && onChange(item.id)}
69
+ disabled={disabled}
70
+ className={`w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all duration-300 focus:outline-hidden ${
71
+ isSelected
72
+ ? 'border-accent bg-bg-card shadow-[0_0_12px_var(--color-accent)] scale-110'
73
+ : isPassed
74
+ ? 'border-accent bg-accent text-white scale-100'
75
+ : 'border-border-app bg-bg-card hover:border-text-muted text-text-muted scale-95'
76
+ } ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`}
77
+ >
78
+ {/* Node center dot */}
79
+ <div className={`w-2.5 h-2.5 rounded-full transition-transform ${
80
+ isSelected ? 'bg-accent' : isPassed ? 'bg-white scale-0' : 'bg-transparent'
81
+ }`} />
82
+ </button>
83
+
84
+ {/* Node Date Label */}
85
+ <span className={`absolute top-full mt-2.5 text-[10px] font-extrabold uppercase tracking-wider font-mono whitespace-nowrap transition-colors duration-300 ${
86
+ isSelected ? 'text-accent' : 'text-text-muted/80'
87
+ }`}>
88
+ {item.date}
89
+ </span>
90
+ </div>
91
+ );
92
+ })}
93
+ </div>
94
+
95
+ {/* Selected Milestone Details display card */}
96
+ <div className="min-h-[100px] mt-2 w-full">
97
+ <AnimatePresence mode="wait">
98
+ {currentItem && (
99
+ <motion.div
100
+ key={currentItem.id}
101
+ initial={{ opacity: 0, y: 12 }}
102
+ animate={{ opacity: 1, y: 0 }}
103
+ exit={{ opacity: 0, y: -12 }}
104
+ transition={{ duration: 0.25 }}
105
+ className="glass border border-border-app/40 rounded-2xl p-5 shadow-sm"
106
+ >
107
+ <h3 className="text-sm font-extrabold text-text-main font-display mb-1 flex items-center gap-2">
108
+ <span className="w-1.5 h-3.5 bg-accent rounded-full inline-block" />
109
+ {currentItem.title}
110
+ </h3>
111
+ <p className="text-xs text-text-muted leading-relaxed">
112
+ {currentItem.description}
113
+ </p>
114
+ </motion.div>
115
+ )}
116
+ </AnimatePresence>
117
+ </div>
118
+
119
+ </div>
120
+ );
121
+ };
@@ -0,0 +1,105 @@
1
+ import React, { useState, useRef, useEffect } from 'react';
2
+ import { motion, AnimatePresence } from 'framer-motion';
3
+
4
+ export interface HoverCardProps {
5
+ children: React.ReactNode;
6
+ content: React.ReactNode;
7
+ openDelay?: number;
8
+ closeDelay?: number;
9
+ placement?: 'top' | 'bottom';
10
+ className?: string;
11
+ disabled?: boolean;
12
+ }
13
+
14
+ export const HoverCard: React.FC<HoverCardProps> = ({
15
+ children,
16
+ content,
17
+ openDelay = 300,
18
+ closeDelay = 200,
19
+ placement = 'bottom',
20
+ className = '',
21
+ disabled = false
22
+ }) => {
23
+ const [isOpen, setIsOpen] = useState(false);
24
+ const enterTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
25
+ const leaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
26
+
27
+ const handleMouseEnter = () => {
28
+ if (disabled) return;
29
+ if (leaveTimeoutRef.current) clearTimeout(leaveTimeoutRef.current);
30
+ enterTimeoutRef.current = setTimeout(() => {
31
+ setIsOpen(true);
32
+ }, openDelay);
33
+ };
34
+
35
+ const handleMouseLeave = () => {
36
+ if (disabled) return;
37
+ if (enterTimeoutRef.current) clearTimeout(enterTimeoutRef.current);
38
+ leaveTimeoutRef.current = setTimeout(() => {
39
+ setIsOpen(false);
40
+ }, closeDelay);
41
+ };
42
+
43
+ useEffect(() => {
44
+ return () => {
45
+ if (enterTimeoutRef.current) clearTimeout(enterTimeoutRef.current);
46
+ if (leaveTimeoutRef.current) clearTimeout(leaveTimeoutRef.current);
47
+ };
48
+ }, []);
49
+
50
+ const animationVariants = {
51
+ hidden: {
52
+ opacity: 0,
53
+ y: placement === 'top' ? 10 : -10,
54
+ scale: 0.95
55
+ },
56
+ visible: {
57
+ opacity: 1,
58
+ y: 0,
59
+ scale: 1,
60
+ transition: { type: 'spring' as const, damping: 20, stiffness: 300 }
61
+ },
62
+ exit: {
63
+ opacity: 0,
64
+ y: placement === 'top' ? 10 : -10,
65
+ scale: 0.95,
66
+ transition: { duration: 0.15 }
67
+ }
68
+ };
69
+
70
+ return (
71
+ <div
72
+ className="relative inline-block"
73
+ onMouseEnter={handleMouseEnter}
74
+ onMouseLeave={handleMouseLeave}
75
+ >
76
+ {/* Trigger */}
77
+ <div className="cursor-pointer">
78
+ {children}
79
+ </div>
80
+
81
+ {/* Card Content */}
82
+ <AnimatePresence>
83
+ {isOpen && !disabled && (
84
+ <motion.div
85
+ variants={animationVariants}
86
+ initial="hidden"
87
+ animate="visible"
88
+ exit="exit"
89
+ className={`absolute z-50 min-w-[280px] p-4 glass rounded-2xl border border-border-app shadow-xl ${placement === 'top' ? 'mb-2' : 'mt-2'} ${className}`}
90
+ style={{
91
+ [placement === 'top' ? 'bottom' : 'top']: '100%',
92
+ left: '50%',
93
+ transform: 'translateX(-50%)'
94
+ }}
95
+ // Keep open if user hovers the card itself
96
+ onMouseEnter={handleMouseEnter}
97
+ onMouseLeave={handleMouseLeave}
98
+ >
99
+ {content}
100
+ </motion.div>
101
+ )}
102
+ </AnimatePresence>
103
+ </div>
104
+ );
105
+ };