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,158 @@
|
|
|
1
|
+
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
|
2
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
3
|
+
import { ChevronDown, Check, X } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
export interface MultiSelectOption {
|
|
6
|
+
value: string;
|
|
7
|
+
label: string;
|
|
8
|
+
disabled?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface MultiSelectProps {
|
|
12
|
+
options: MultiSelectOption[];
|
|
13
|
+
value: string[];
|
|
14
|
+
onChange: (value: string[]) => void;
|
|
15
|
+
label?: string;
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
max?: number;
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const MultiSelect: React.FC<MultiSelectProps> = ({
|
|
23
|
+
options,
|
|
24
|
+
value,
|
|
25
|
+
onChange,
|
|
26
|
+
label,
|
|
27
|
+
placeholder = 'Seleccionar opciones...',
|
|
28
|
+
disabled = false,
|
|
29
|
+
max,
|
|
30
|
+
className = ''
|
|
31
|
+
}) => {
|
|
32
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
33
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
34
|
+
|
|
35
|
+
const selectedOptions = useMemo(
|
|
36
|
+
() => options.filter((opt) => value.includes(opt.value)),
|
|
37
|
+
[options, value]
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
42
|
+
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
43
|
+
setIsOpen(false);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
47
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
const toggleOption = (optionValue: string) => {
|
|
51
|
+
if (disabled) return;
|
|
52
|
+
const isSelected = value.includes(optionValue);
|
|
53
|
+
if (isSelected) {
|
|
54
|
+
onChange(value.filter((v) => v !== optionValue));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (max && value.length >= max) return;
|
|
58
|
+
onChange([...value, optionValue]);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const removeChip = (optionValue: string, e: React.MouseEvent) => {
|
|
62
|
+
e.stopPropagation();
|
|
63
|
+
if (disabled) return;
|
|
64
|
+
onChange(value.filter((v) => v !== optionValue));
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div ref={containerRef} className={`relative w-full flex flex-col gap-1.5 ${className}`}>
|
|
69
|
+
{label && (
|
|
70
|
+
<span className="text-xs font-bold text-text-muted uppercase tracking-wider px-1">
|
|
71
|
+
{label}
|
|
72
|
+
</span>
|
|
73
|
+
)}
|
|
74
|
+
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
disabled={disabled}
|
|
78
|
+
onClick={() => !disabled && setIsOpen(!isOpen)}
|
|
79
|
+
className={`w-full min-h-11 rounded-xl border border-border-app bg-bg-card/60 px-3 py-2 text-left transition-all duration-300 flex items-center justify-between gap-2 ${
|
|
80
|
+
disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer hover:border-accent/50'
|
|
81
|
+
} ${isOpen ? 'border-accent shadow-[0_0_10px_var(--color-accent-glow)]' : ''}`}
|
|
82
|
+
>
|
|
83
|
+
<div className="flex flex-wrap items-center gap-1.5 flex-1 min-w-0">
|
|
84
|
+
{selectedOptions.length === 0 ? (
|
|
85
|
+
<span className="text-sm text-text-muted/70">{placeholder}</span>
|
|
86
|
+
) : (
|
|
87
|
+
selectedOptions.map((opt) => (
|
|
88
|
+
<span
|
|
89
|
+
key={opt.value}
|
|
90
|
+
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-lg bg-accent/15 text-accent border border-accent/25 text-xs font-bold"
|
|
91
|
+
>
|
|
92
|
+
{opt.label}
|
|
93
|
+
{!disabled && (
|
|
94
|
+
<span
|
|
95
|
+
role="button"
|
|
96
|
+
tabIndex={0}
|
|
97
|
+
onClick={(e) => removeChip(opt.value, e)}
|
|
98
|
+
onKeyDown={(e) => {
|
|
99
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
100
|
+
e.preventDefault();
|
|
101
|
+
removeChip(opt.value, e as unknown as React.MouseEvent);
|
|
102
|
+
}
|
|
103
|
+
}}
|
|
104
|
+
className="hover:text-red-400 transition-colors cursor-pointer"
|
|
105
|
+
aria-label={`Quitar ${opt.label}`}
|
|
106
|
+
>
|
|
107
|
+
<X className="w-3 h-3" />
|
|
108
|
+
</span>
|
|
109
|
+
)}
|
|
110
|
+
</span>
|
|
111
|
+
))
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
<motion.div animate={{ rotate: isOpen ? 180 : 0 }} transition={{ duration: 0.2 }}>
|
|
115
|
+
<ChevronDown className="w-4 h-4 text-text-muted flex-shrink-0" />
|
|
116
|
+
</motion.div>
|
|
117
|
+
</button>
|
|
118
|
+
|
|
119
|
+
<AnimatePresence>
|
|
120
|
+
{isOpen && !disabled && (
|
|
121
|
+
<motion.ul
|
|
122
|
+
initial={{ opacity: 0, y: 8, scale: 0.98 }}
|
|
123
|
+
animate={{ opacity: 1, y: 4, scale: 1 }}
|
|
124
|
+
exit={{ opacity: 0, y: 8, scale: 0.98 }}
|
|
125
|
+
transition={{ duration: 0.15 }}
|
|
126
|
+
className="absolute left-0 right-0 top-full mt-1 z-50 glass bg-bg-card border border-border-app rounded-xl shadow-2xl py-1.5 max-h-56 overflow-y-auto"
|
|
127
|
+
>
|
|
128
|
+
{options.map((opt) => {
|
|
129
|
+
const isSelected = value.includes(opt.value);
|
|
130
|
+
const isMaxed = Boolean(max && value.length >= max && !isSelected);
|
|
131
|
+
return (
|
|
132
|
+
<li key={opt.value}>
|
|
133
|
+
<button
|
|
134
|
+
type="button"
|
|
135
|
+
disabled={opt.disabled || isMaxed}
|
|
136
|
+
onClick={() => toggleOption(opt.value)}
|
|
137
|
+
className={`w-full px-3 py-2 text-sm flex items-center justify-between gap-2 transition-colors ${
|
|
138
|
+
isSelected ? 'bg-accent/10 text-accent font-bold' : 'text-text-main hover:bg-bg-app'
|
|
139
|
+
} ${opt.disabled || isMaxed ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
|
|
140
|
+
>
|
|
141
|
+
<span>{opt.label}</span>
|
|
142
|
+
{isSelected && <Check className="w-4 h-4" />}
|
|
143
|
+
</button>
|
|
144
|
+
</li>
|
|
145
|
+
);
|
|
146
|
+
})}
|
|
147
|
+
</motion.ul>
|
|
148
|
+
)}
|
|
149
|
+
</AnimatePresence>
|
|
150
|
+
|
|
151
|
+
{max && (
|
|
152
|
+
<p className="text-[10px] text-text-muted px-1">
|
|
153
|
+
{value.length}/{max} seleccionados
|
|
154
|
+
</p>
|
|
155
|
+
)}
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
};
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { ChevronUp, ChevronDown, Plus, Minus } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
export interface NumberInputProps {
|
|
5
|
+
value: number | null;
|
|
6
|
+
onChange: (value: number | null) => void;
|
|
7
|
+
min?: number;
|
|
8
|
+
max?: number;
|
|
9
|
+
step?: number;
|
|
10
|
+
mode?: 'decimal' | 'currency';
|
|
11
|
+
currency?: string; // e.g. 'ARS', 'USD', 'EUR'
|
|
12
|
+
prefix?: string;
|
|
13
|
+
suffix?: string;
|
|
14
|
+
showButtons?: boolean;
|
|
15
|
+
buttonLayout?: 'stacked' | 'horizontal';
|
|
16
|
+
integerOnly?: boolean;
|
|
17
|
+
maxFractionDigits?: number;
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
className?: string;
|
|
20
|
+
placeholder?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const NumberInput: React.FC<NumberInputProps> = ({
|
|
24
|
+
value,
|
|
25
|
+
onChange,
|
|
26
|
+
min,
|
|
27
|
+
max,
|
|
28
|
+
step = 1,
|
|
29
|
+
mode = 'decimal',
|
|
30
|
+
currency = 'USD',
|
|
31
|
+
prefix,
|
|
32
|
+
suffix,
|
|
33
|
+
showButtons = true,
|
|
34
|
+
buttonLayout = 'stacked',
|
|
35
|
+
integerOnly = false,
|
|
36
|
+
maxFractionDigits = 2,
|
|
37
|
+
disabled = false,
|
|
38
|
+
className = '',
|
|
39
|
+
placeholder = '0'
|
|
40
|
+
}) => {
|
|
41
|
+
const [displayValue, setDisplayValue] = useState('');
|
|
42
|
+
|
|
43
|
+
// Formatter configuration
|
|
44
|
+
const formatNumber = (val: number | null): string => {
|
|
45
|
+
if (val === null || isNaN(val)) return '';
|
|
46
|
+
|
|
47
|
+
if (mode === 'currency') {
|
|
48
|
+
return new Intl.NumberFormat('es-AR', {
|
|
49
|
+
style: 'currency',
|
|
50
|
+
currency: currency,
|
|
51
|
+
minimumFractionDigits: integerOnly ? 0 : 2,
|
|
52
|
+
maximumFractionDigits: integerOnly ? 0 : maxFractionDigits
|
|
53
|
+
}).format(val);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const formatted = new Intl.NumberFormat('es-AR', {
|
|
57
|
+
minimumFractionDigits: 0,
|
|
58
|
+
maximumFractionDigits: integerOnly ? 0 : maxFractionDigits
|
|
59
|
+
}).format(val);
|
|
60
|
+
|
|
61
|
+
const fullPrefix = prefix ? `${prefix} ` : '';
|
|
62
|
+
const fullSuffix = suffix ? ` ${suffix}` : '';
|
|
63
|
+
return `${fullPrefix}${formatted}${fullSuffix}`;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// Synchronize internal display when value prop changes
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
setDisplayValue(formatNumber(value));
|
|
69
|
+
}, [value, mode, currency, prefix, suffix, integerOnly, maxFractionDigits]);
|
|
70
|
+
|
|
71
|
+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
72
|
+
let rawInput = e.target.value;
|
|
73
|
+
|
|
74
|
+
// If empty, return null
|
|
75
|
+
if (rawInput === '') {
|
|
76
|
+
onChange(null);
|
|
77
|
+
setDisplayValue('');
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Clean formatting characters to parse value
|
|
82
|
+
// es-AR uses dot for thousands and comma for decimal
|
|
83
|
+
// Convert to standard float representation (dot as decimal separator)
|
|
84
|
+
let cleaned = rawInput
|
|
85
|
+
.replace(new RegExp(`[^0-9\\,\\-]`, 'g'), '') // keep digits, minus, and comma
|
|
86
|
+
.replace(',', '.'); // convert comma to dot
|
|
87
|
+
|
|
88
|
+
let parsed = parseFloat(cleaned);
|
|
89
|
+
if (!isNaN(parsed)) {
|
|
90
|
+
if (integerOnly) parsed = Math.round(parsed);
|
|
91
|
+
onChange(parsed);
|
|
92
|
+
setDisplayValue(rawInput); // let user type naturally
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const handleBlur = () => {
|
|
97
|
+
if (value === null) {
|
|
98
|
+
setDisplayValue('');
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Enforce min/max boundaries on blur
|
|
103
|
+
let bounded = value;
|
|
104
|
+
if (min !== undefined && bounded < min) bounded = min;
|
|
105
|
+
if (max !== undefined && bounded > max) bounded = max;
|
|
106
|
+
|
|
107
|
+
onChange(bounded);
|
|
108
|
+
setDisplayValue(formatNumber(bounded));
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const increment = () => {
|
|
112
|
+
if (disabled) return;
|
|
113
|
+
const current = value === null ? 0 : value;
|
|
114
|
+
let next = current + step;
|
|
115
|
+
if (max !== undefined && next > max) next = max;
|
|
116
|
+
onChange(next);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const decrement = () => {
|
|
120
|
+
if (disabled) return;
|
|
121
|
+
const current = value === null ? 0 : value;
|
|
122
|
+
let next = current - step;
|
|
123
|
+
if (min !== undefined && next < min) next = min;
|
|
124
|
+
onChange(next);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const isStacked = buttonLayout === 'stacked';
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<div className={`relative flex items-stretch bg-bg-card/60 border border-border-app rounded-xl overflow-hidden transition-all duration-300 focus-within:border-accent ${
|
|
131
|
+
disabled ? 'opacity-40 cursor-not-allowed select-none bg-bg-app/10' : ''
|
|
132
|
+
} ${className}`}>
|
|
133
|
+
|
|
134
|
+
{/* Horizontal Layout - Decrement Button on the Left */}
|
|
135
|
+
{showButtons && !isStacked && (
|
|
136
|
+
<button
|
|
137
|
+
type="button"
|
|
138
|
+
onClick={decrement}
|
|
139
|
+
disabled={disabled || (min !== undefined && value !== null && value <= min)}
|
|
140
|
+
className={`px-3 border-r border-border-app flex items-center justify-center text-text-muted hover:text-text-main hover:bg-bg-app/50 transition-colors focus:outline-hidden ${
|
|
141
|
+
disabled ? 'cursor-not-allowed' : 'cursor-pointer'
|
|
142
|
+
}`}
|
|
143
|
+
>
|
|
144
|
+
<Minus className="w-3.5 h-3.5" />
|
|
145
|
+
</button>
|
|
146
|
+
)}
|
|
147
|
+
|
|
148
|
+
{/* Input element */}
|
|
149
|
+
<input
|
|
150
|
+
type="text"
|
|
151
|
+
value={displayValue}
|
|
152
|
+
onChange={handleInputChange}
|
|
153
|
+
onBlur={handleBlur}
|
|
154
|
+
disabled={disabled}
|
|
155
|
+
placeholder={placeholder}
|
|
156
|
+
className={`w-full bg-transparent py-3 px-4 text-sm text-text-main focus:outline-hidden ${
|
|
157
|
+
disabled ? 'cursor-not-allowed' : ''
|
|
158
|
+
} ${showButtons && isStacked ? 'pr-12' : ''}`}
|
|
159
|
+
/>
|
|
160
|
+
|
|
161
|
+
{/* Horizontal Layout - Increment Button on the Right */}
|
|
162
|
+
{showButtons && !isStacked && (
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
onClick={increment}
|
|
166
|
+
disabled={disabled || (max !== undefined && value !== null && value >= max)}
|
|
167
|
+
className={`px-3 border-l border-border-app flex items-center justify-center text-text-muted hover:text-text-main hover:bg-bg-app/50 transition-colors focus:outline-hidden ${
|
|
168
|
+
disabled ? 'cursor-not-allowed' : 'cursor-pointer'
|
|
169
|
+
}`}
|
|
170
|
+
>
|
|
171
|
+
<Plus className="w-3.5 h-3.5" />
|
|
172
|
+
</button>
|
|
173
|
+
)}
|
|
174
|
+
|
|
175
|
+
{/* Stacked Layout - Buttons Stacked on the Right edge */}
|
|
176
|
+
{showButtons && isStacked && (
|
|
177
|
+
<div className="absolute right-0 top-0 bottom-0 w-8 border-l border-border-app flex flex-col items-stretch z-20">
|
|
178
|
+
<button
|
|
179
|
+
type="button"
|
|
180
|
+
onClick={increment}
|
|
181
|
+
disabled={disabled || (max !== undefined && value !== null && value >= max)}
|
|
182
|
+
className={`flex-1 flex items-center justify-center text-text-muted hover:text-text-main hover:bg-bg-app/50 transition-colors border-b border-border-app focus:outline-hidden ${
|
|
183
|
+
disabled ? 'cursor-not-allowed' : 'cursor-pointer'
|
|
184
|
+
}`}
|
|
185
|
+
>
|
|
186
|
+
<ChevronUp className="w-3.5 h-3.5" />
|
|
187
|
+
</button>
|
|
188
|
+
<button
|
|
189
|
+
type="button"
|
|
190
|
+
onClick={decrement}
|
|
191
|
+
disabled={disabled || (min !== undefined && value !== null && value <= min)}
|
|
192
|
+
className={`flex-1 flex items-center justify-center text-text-muted hover:text-text-main hover:bg-bg-app/50 transition-colors focus:outline-hidden ${
|
|
193
|
+
disabled ? 'cursor-not-allowed' : 'cursor-pointer'
|
|
194
|
+
}`}
|
|
195
|
+
>
|
|
196
|
+
<ChevronDown className="w-3.5 h-3.5" />
|
|
197
|
+
</button>
|
|
198
|
+
</div>
|
|
199
|
+
)}
|
|
200
|
+
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
3
|
+
import { ChevronDown } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
export interface PanelProps {
|
|
6
|
+
title: string;
|
|
7
|
+
children: React.ReactNode;
|
|
8
|
+
subtitle?: string;
|
|
9
|
+
icon?: React.ReactNode;
|
|
10
|
+
defaultOpen?: boolean;
|
|
11
|
+
collapsible?: boolean;
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const Panel: React.FC<PanelProps> = ({
|
|
17
|
+
title,
|
|
18
|
+
children,
|
|
19
|
+
subtitle,
|
|
20
|
+
icon,
|
|
21
|
+
defaultOpen = true,
|
|
22
|
+
collapsible = true,
|
|
23
|
+
disabled = false,
|
|
24
|
+
className = ''
|
|
25
|
+
}) => {
|
|
26
|
+
const [isOpen, setIsOpen] = useState(defaultOpen);
|
|
27
|
+
|
|
28
|
+
const toggle = () => {
|
|
29
|
+
if (disabled || !collapsible) return;
|
|
30
|
+
setIsOpen((prev) => !prev);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className={`glass rounded-2xl border border-border-app overflow-hidden ${disabled ? 'opacity-50' : ''} ${className}`}>
|
|
35
|
+
<button
|
|
36
|
+
type="button"
|
|
37
|
+
onClick={toggle}
|
|
38
|
+
disabled={disabled || !collapsible}
|
|
39
|
+
className={`w-full flex items-center justify-between gap-3 p-4 text-left transition-colors ${
|
|
40
|
+
collapsible && !disabled ? 'cursor-pointer hover:bg-bg-app/40' : 'cursor-default'
|
|
41
|
+
}`}
|
|
42
|
+
>
|
|
43
|
+
<div className="flex items-center gap-3 min-w-0">
|
|
44
|
+
{icon && (
|
|
45
|
+
<div className="p-2 rounded-xl bg-accent/10 text-accent flex-shrink-0">
|
|
46
|
+
{icon}
|
|
47
|
+
</div>
|
|
48
|
+
)}
|
|
49
|
+
<div className="flex flex-col min-w-0">
|
|
50
|
+
<span className="font-extrabold text-text-main font-display truncate">{title}</span>
|
|
51
|
+
{subtitle && <span className="text-xs text-text-muted truncate">{subtitle}</span>}
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
{collapsible && (
|
|
55
|
+
<motion.div
|
|
56
|
+
animate={{ rotate: isOpen ? 180 : 0 }}
|
|
57
|
+
transition={{ type: 'spring' as const, stiffness: 300, damping: 22 }}
|
|
58
|
+
className="text-text-muted flex-shrink-0"
|
|
59
|
+
>
|
|
60
|
+
<ChevronDown className="w-5 h-5" />
|
|
61
|
+
</motion.div>
|
|
62
|
+
)}
|
|
63
|
+
</button>
|
|
64
|
+
|
|
65
|
+
<AnimatePresence initial={false}>
|
|
66
|
+
{isOpen && (
|
|
67
|
+
<motion.div
|
|
68
|
+
initial={{ height: 0, opacity: 0 }}
|
|
69
|
+
animate={{ height: 'auto', opacity: 1 }}
|
|
70
|
+
exit={{ height: 0, opacity: 0 }}
|
|
71
|
+
transition={{ type: 'spring' as const, stiffness: 300, damping: 28 }}
|
|
72
|
+
className="overflow-hidden"
|
|
73
|
+
>
|
|
74
|
+
<div className="px-4 pb-4 pt-0 border-t border-border-app/50">
|
|
75
|
+
<div className="pt-4 text-sm text-text-muted leading-relaxed">
|
|
76
|
+
{children}
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
</motion.div>
|
|
80
|
+
)}
|
|
81
|
+
</AnimatePresence>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export interface FieldsetProps {
|
|
87
|
+
legend: string;
|
|
88
|
+
children: React.ReactNode;
|
|
89
|
+
description?: string;
|
|
90
|
+
className?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const Fieldset: React.FC<FieldsetProps> = ({
|
|
94
|
+
legend,
|
|
95
|
+
children,
|
|
96
|
+
description,
|
|
97
|
+
className = ''
|
|
98
|
+
}) => (
|
|
99
|
+
<fieldset className={`glass rounded-2xl border border-border-app p-5 flex flex-col gap-4 ${className}`}>
|
|
100
|
+
<legend className="px-2 text-sm font-extrabold text-accent font-display">{legend}</legend>
|
|
101
|
+
{description && <p className="text-xs text-text-muted -mt-2">{description}</p>}
|
|
102
|
+
{children}
|
|
103
|
+
</fieldset>
|
|
104
|
+
);
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { motion } from 'framer-motion';
|
|
3
|
+
import { Eye, EyeOff, Check, X } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
export interface PasswordInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
|
6
|
+
label?: string;
|
|
7
|
+
error?: string;
|
|
8
|
+
showStrength?: boolean;
|
|
9
|
+
showRequirements?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const PasswordInput: React.FC<PasswordInputProps> = ({
|
|
13
|
+
label = 'Contraseña',
|
|
14
|
+
error,
|
|
15
|
+
showStrength = true,
|
|
16
|
+
showRequirements = true,
|
|
17
|
+
className = '',
|
|
18
|
+
value = '',
|
|
19
|
+
onChange,
|
|
20
|
+
onFocus,
|
|
21
|
+
onBlur,
|
|
22
|
+
disabled = false,
|
|
23
|
+
...props
|
|
24
|
+
}) => {
|
|
25
|
+
const [showPassword, setShowPassword] = useState(false);
|
|
26
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
27
|
+
|
|
28
|
+
const valString = String(value);
|
|
29
|
+
|
|
30
|
+
// Requirements analysis
|
|
31
|
+
const requirements = [
|
|
32
|
+
{ label: 'Al menos 8 caracteres', test: (val: string) => val.length >= 8 },
|
|
33
|
+
{ label: 'Una mayúscula', test: (val: string) => /[A-Z]/.test(val) },
|
|
34
|
+
{ label: 'Una minúscula', test: (val: string) => /[a-z]/.test(val) },
|
|
35
|
+
{ label: 'Un número', test: (val: string) => /[0-9]/.test(val) },
|
|
36
|
+
{ label: 'Un carácter especial (@, $, !, etc.)', test: (val: string) => /[^A-Za-z0-9]/.test(val) }
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
// Calculate password strength rating (from 0 to 4)
|
|
40
|
+
const calculateStrength = (val: string): { score: number; text: string; color: string } => {
|
|
41
|
+
if (!val) return { score: 0, text: 'Vacía', color: 'bg-text-muted/30' };
|
|
42
|
+
|
|
43
|
+
let passedTests = 0;
|
|
44
|
+
if (val.length >= 8) passedTests++;
|
|
45
|
+
if (/[A-Z]/.test(val) && /[a-z]/.test(val)) passedTests++;
|
|
46
|
+
if (/[0-9]/.test(val)) passedTests++;
|
|
47
|
+
if (/[^A-Za-z0-9]/.test(val)) passedTests++;
|
|
48
|
+
|
|
49
|
+
switch (passedTests) {
|
|
50
|
+
case 1:
|
|
51
|
+
return { score: 1, text: 'Débil', color: 'bg-error' };
|
|
52
|
+
case 2:
|
|
53
|
+
return { score: 2, text: 'Aceptable', color: 'bg-warning' };
|
|
54
|
+
case 3:
|
|
55
|
+
return { score: 3, text: 'Buena', color: 'bg-info' };
|
|
56
|
+
case 4:
|
|
57
|
+
return { score: 4, text: 'Fuerte', color: 'bg-success' };
|
|
58
|
+
default:
|
|
59
|
+
return { score: 0, text: 'Muy Débil', color: 'bg-error' };
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const strength = calculateStrength(valString);
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className={`relative w-full flex flex-col gap-2 ${className}`}>
|
|
67
|
+
|
|
68
|
+
{/* Input Outer Glow Wrapper */}
|
|
69
|
+
<div className="relative rounded-xl overflow-hidden transition-all duration-300">
|
|
70
|
+
|
|
71
|
+
{/* Glow border ring */}
|
|
72
|
+
<motion.div
|
|
73
|
+
animate={{
|
|
74
|
+
opacity: isFocused && !disabled ? 1 : 0,
|
|
75
|
+
scale: isFocused && !disabled ? 1 : 0.95,
|
|
76
|
+
}}
|
|
77
|
+
transition={{ duration: 0.25 }}
|
|
78
|
+
className="absolute -inset-[1px] bg-gradient-to-r from-accent to-pink-500 rounded-xl pointer-events-none z-0 blur-[2px]"
|
|
79
|
+
/>
|
|
80
|
+
|
|
81
|
+
{/* Input container */}
|
|
82
|
+
<div className={`relative bg-bg-card border border-border-app rounded-xl z-10 flex items-center transition-colors duration-300 ${
|
|
83
|
+
isFocused && !disabled ? 'border-transparent' : 'border-border-app'
|
|
84
|
+
} ${disabled ? 'opacity-40 cursor-not-allowed select-none bg-bg-app/10' : ''}`}>
|
|
85
|
+
|
|
86
|
+
<div className="flex-1 relative">
|
|
87
|
+
{/* Floating Label */}
|
|
88
|
+
<motion.label
|
|
89
|
+
initial={{ y: 14, scale: 1 }}
|
|
90
|
+
animate={{
|
|
91
|
+
y: isFocused || valString.length > 0 || !!props.placeholder ? 4 : 14,
|
|
92
|
+
scale: isFocused || valString.length > 0 || !!props.placeholder ? 0.75 : 1,
|
|
93
|
+
color: isFocused && !disabled
|
|
94
|
+
? 'var(--color-accent)'
|
|
95
|
+
: 'var(--color-text-muted)'
|
|
96
|
+
}}
|
|
97
|
+
transition={{ type: 'spring', stiffness: 200, damping: 20 }}
|
|
98
|
+
className="absolute left-4 origin-top-left pointer-events-none select-none text-sm z-20 font-medium"
|
|
99
|
+
>
|
|
100
|
+
{label}
|
|
101
|
+
</motion.label>
|
|
102
|
+
|
|
103
|
+
<input
|
|
104
|
+
type={showPassword ? 'text' : 'password'}
|
|
105
|
+
value={value}
|
|
106
|
+
onChange={onChange}
|
|
107
|
+
onFocus={(e) => {
|
|
108
|
+
setIsFocused(true);
|
|
109
|
+
if (onFocus) onFocus(e);
|
|
110
|
+
}}
|
|
111
|
+
onBlur={(e) => {
|
|
112
|
+
setIsFocused(false);
|
|
113
|
+
if (onBlur) onBlur(e);
|
|
114
|
+
}}
|
|
115
|
+
disabled={disabled}
|
|
116
|
+
className={`w-full bg-transparent px-4 pb-2 pt-7 text-sm text-text-main focus:outline-hidden z-10 relative ${
|
|
117
|
+
disabled ? 'cursor-not-allowed' : ''
|
|
118
|
+
}`}
|
|
119
|
+
{...props}
|
|
120
|
+
/>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
{/* Visibility toggle button */}
|
|
124
|
+
<button
|
|
125
|
+
type="button"
|
|
126
|
+
onClick={() => !disabled && setShowPassword(!showPassword)}
|
|
127
|
+
disabled={disabled}
|
|
128
|
+
className={`pr-4 flex items-center justify-center text-text-muted hover:text-text-main transition-colors focus:outline-hidden ${
|
|
129
|
+
disabled ? 'cursor-not-allowed' : 'cursor-pointer'
|
|
130
|
+
}`}
|
|
131
|
+
>
|
|
132
|
+
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
133
|
+
</button>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
{/* Strength Indicator bars */}
|
|
138
|
+
{showStrength && valString.length > 0 && !disabled && (
|
|
139
|
+
<div className="flex flex-col gap-1.5 px-1 mt-0.5">
|
|
140
|
+
<div className="flex items-center justify-between text-[10px] font-bold text-text-muted uppercase">
|
|
141
|
+
<span>Fortaleza de clave</span>
|
|
142
|
+
<span style={{ color: `var(--color-${strength.score === 1 ? 'error' : strength.score === 2 ? 'warning' : strength.score === 3 ? 'info' : 'success'})` }}>
|
|
143
|
+
{strength.text}
|
|
144
|
+
</span>
|
|
145
|
+
</div>
|
|
146
|
+
<div className="grid grid-cols-4 gap-1.5 h-1.5 w-full rounded-full bg-bg-app border border-border-app/40 overflow-hidden">
|
|
147
|
+
{[1, 2, 3, 4].map((index) => (
|
|
148
|
+
<div
|
|
149
|
+
key={index}
|
|
150
|
+
className={`h-full transition-all duration-500 ${
|
|
151
|
+
index <= strength.score ? strength.color : 'bg-transparent'
|
|
152
|
+
}`}
|
|
153
|
+
/>
|
|
154
|
+
))}
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
)}
|
|
158
|
+
|
|
159
|
+
{/* Password requirements list */}
|
|
160
|
+
{showRequirements && isFocused && !disabled && (
|
|
161
|
+
<motion.div
|
|
162
|
+
initial={{ opacity: 0, height: 0 }}
|
|
163
|
+
animate={{ opacity: 1, height: 'auto' }}
|
|
164
|
+
exit={{ opacity: 0, height: 0 }}
|
|
165
|
+
className="flex flex-col gap-1.5 bg-bg-card/40 border border-border-app/30 rounded-xl p-3.5 mt-1"
|
|
166
|
+
>
|
|
167
|
+
<span className="text-[10px] font-bold text-text-muted uppercase tracking-wider mb-0.5 font-display">
|
|
168
|
+
Requisitos de seguridad
|
|
169
|
+
</span>
|
|
170
|
+
<div className="flex flex-col gap-1">
|
|
171
|
+
{requirements.map((req, index) => {
|
|
172
|
+
const passed = req.test(valString);
|
|
173
|
+
return (
|
|
174
|
+
<div key={index} className="flex items-center gap-2 text-xs">
|
|
175
|
+
{passed ? (
|
|
176
|
+
<Check className="w-3.5 h-3.5 text-success stroke-[3]" />
|
|
177
|
+
) : (
|
|
178
|
+
<X className="w-3.5 h-3.5 text-text-muted/40 stroke-[3]" />
|
|
179
|
+
)}
|
|
180
|
+
<span className={passed ? 'text-text-main font-medium' : 'text-text-muted'}>
|
|
181
|
+
{req.label}
|
|
182
|
+
</span>
|
|
183
|
+
</div>
|
|
184
|
+
);
|
|
185
|
+
})}
|
|
186
|
+
</div>
|
|
187
|
+
</motion.div>
|
|
188
|
+
)}
|
|
189
|
+
|
|
190
|
+
{/* Error message */}
|
|
191
|
+
{error && !isFocused && (
|
|
192
|
+
<motion.span
|
|
193
|
+
initial={{ opacity: 0, y: -4 }}
|
|
194
|
+
animate={{ opacity: 1, y: 0 }}
|
|
195
|
+
className="text-xs text-red-500 font-semibold px-2"
|
|
196
|
+
>
|
|
197
|
+
{error}
|
|
198
|
+
</motion.span>
|
|
199
|
+
)}
|
|
200
|
+
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
};
|