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.
- package/README.md +57 -0
- package/components/ActionTable.tsx +307 -0
- package/components/AlertBanner.tsx +124 -0
- package/components/AnimatedAccordion.tsx +95 -0
- package/components/Autocomplete.tsx +144 -0
- package/components/Avatar.tsx +123 -0
- package/components/Badge.tsx +80 -0
- package/components/Breadcrumb.tsx +74 -0
- package/components/Calendar.tsx +340 -0
- package/components/Card3D.tsx +117 -0
- package/components/Carousel3D.tsx +193 -0
- package/components/CascadeSelect.tsx +232 -0
- package/components/ChartShowcase.tsx +700 -0
- package/components/Checkbox.tsx +212 -0
- package/components/ChipsInput.tsx +152 -0
- package/components/CircularKnob.tsx +240 -0
- package/components/CodeVisualizer.tsx +67 -0
- package/components/Collapsible.tsx +72 -0
- package/components/ColorThemeManager.tsx +458 -0
- package/components/CommandMenu.tsx +191 -0
- package/components/ConfirmDialog.tsx +152 -0
- package/components/ContextMenu.tsx +192 -0
- package/components/DashboardLayout.tsx +115 -0
- package/components/DatePicker.tsx +108 -0
- package/components/Divider.tsx +67 -0
- package/components/Dock.tsx +93 -0
- package/components/DragDropLists.tsx +160 -0
- package/components/Drawer.tsx +161 -0
- package/components/DropdownPlus.tsx +304 -0
- package/components/EmptyState.tsx +49 -0
- package/components/ErrorPage.tsx +62 -0
- package/components/FileDropzone.tsx +206 -0
- package/components/ForgotPassword.tsx +137 -0
- package/components/FormField.tsx +81 -0
- package/components/GlassButton.tsx +56 -0
- package/components/GlassCard.tsx +82 -0
- package/components/GlassInput.tsx +96 -0
- package/components/GlassmorphicModal.tsx +108 -0
- package/components/GlowInput.tsx +111 -0
- package/components/GlowSelect.tsx +203 -0
- package/components/GlowTextArea.tsx +105 -0
- package/components/HorizontalTimeline.tsx +121 -0
- package/components/HoverCard.tsx +105 -0
- package/components/ImageLightbox.tsx +259 -0
- package/components/InputGroup.tsx +118 -0
- package/components/InputOTP.tsx +147 -0
- package/components/InteractiveNavbar.tsx +266 -0
- package/components/InteractiveSidebar.tsx +211 -0
- package/components/Kbd.tsx +51 -0
- package/components/LiteYouTube.tsx +118 -0
- package/components/LoaderCollection.tsx +368 -0
- package/components/LoginForm.tsx +192 -0
- package/components/MagneticButton.tsx +101 -0
- package/components/MaskedInput.tsx +79 -0
- package/components/MentionInput.tsx +413 -0
- package/components/MorphingSwitch.tsx +86 -0
- package/components/MultiSelect.tsx +158 -0
- package/components/NumberInput.tsx +203 -0
- package/components/Panel.tsx +104 -0
- package/components/PasswordInput.tsx +203 -0
- package/components/Popover.tsx +91 -0
- package/components/PricingTable.tsx +113 -0
- package/components/ProgressBar.tsx +152 -0
- package/components/RadioButton.tsx +211 -0
- package/components/Rating.tsx +82 -0
- package/components/ResizablePanel.tsx +114 -0
- package/components/ScrollPanel.tsx +103 -0
- package/components/SettingsPage.tsx +154 -0
- package/components/SignupForm.tsx +182 -0
- package/components/Skeleton.tsx +41 -0
- package/components/Slider.tsx +95 -0
- package/components/SlidingTabs.tsx +54 -0
- package/components/SortableList.tsx +91 -0
- package/components/SpeedDial.tsx +134 -0
- package/components/Spinner.tsx +40 -0
- package/components/Stepper.tsx +124 -0
- package/components/TabMenu.tsx +72 -0
- package/components/TableControls.tsx +77 -0
- package/components/TablePagination.tsx +88 -0
- package/components/TextEditor.tsx +329 -0
- package/components/TextReveal.tsx +99 -0
- package/components/ThemeSwitcher.tsx +133 -0
- package/components/TimelineGSAP.tsx +164 -0
- package/components/ToastSystem.tsx +110 -0
- package/components/ToggleButton.tsx +79 -0
- package/components/Tooltip.tsx +121 -0
- package/components/Tree.tsx +138 -0
- package/dist/commands/add.d.ts +7 -0
- package/dist/commands/add.js +110 -0
- package/dist/commands/init.d.ts +5 -0
- package/dist/commands/init.js +76 -0
- package/dist/commands/list.d.ts +1 -0
- package/dist/commands/list.js +32 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +60 -0
- package/dist/registry.d.ts +6 -0
- package/dist/registry.js +38 -0
- package/dist/tui/browse.d.ts +3 -0
- package/dist/tui/browse.js +139 -0
- package/dist/tui/format.d.ts +11 -0
- package/dist/tui/format.js +52 -0
- package/dist/tui/main.d.ts +1 -0
- package/dist/tui/main.js +86 -0
- package/dist/tui/panels.d.ts +9 -0
- package/dist/tui/panels.js +50 -0
- package/dist/tui/theme.d.ts +28 -0
- package/dist/tui/theme.js +76 -0
- package/dist/types.d.ts +28 -0
- package/dist/types.js +1 -0
- package/dist/utils/config.d.ts +6 -0
- package/dist/utils/config.js +24 -0
- package/dist/utils/copy.d.ts +9 -0
- package/dist/utils/copy.js +43 -0
- package/dist/utils/cwd.d.ts +6 -0
- package/dist/utils/cwd.js +30 -0
- package/dist/utils/deps.d.ts +1 -0
- package/dist/utils/deps.js +19 -0
- package/dist/utils/project.d.ts +5 -0
- package/dist/utils/project.js +30 -0
- package/dist/utils/theme.d.ts +1 -0
- package/dist/utils/theme.js +24 -0
- package/package.json +52 -0
- package/registry.json +1133 -0
- 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
|
+
};
|