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,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
+ };