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,259 @@
1
+ import React, { useState, useEffect, useCallback, useRef } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { motion, AnimatePresence, useMotionValue, useTransform, animate } from 'framer-motion';
4
+ import { X, ChevronLeft, ChevronRight, Maximize2 } from 'lucide-react';
5
+ import { Skeleton } from './Skeleton';
6
+
7
+ export interface GalleryImage {
8
+ src: string;
9
+ alt: string;
10
+ caption?: string;
11
+ }
12
+
13
+ export interface ImageLightboxProps {
14
+ images: GalleryImage[];
15
+ className?: string;
16
+ isLoading?: boolean;
17
+ }
18
+
19
+ export const ImageLightbox: React.FC<ImageLightboxProps> = ({ images, className = '', isLoading = false }) => {
20
+ const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
21
+ const containerRef = useRef<HTMLDivElement>(null);
22
+ const [containerWidth, setContainerWidth] = useState(typeof window !== 'undefined' ? window.innerWidth : 800);
23
+ const dragX = useMotionValue(0);
24
+
25
+ // Measure container width for slide translations
26
+ useEffect(() => {
27
+ if (selectedIndex === null || !containerRef.current) return;
28
+ const measure = () => {
29
+ if (containerRef.current) {
30
+ setContainerWidth(containerRef.current.offsetWidth);
31
+ }
32
+ };
33
+ measure();
34
+ window.addEventListener('resize', measure);
35
+ return () => window.removeEventListener('resize', measure);
36
+ }, [selectedIndex]);
37
+
38
+ const handleOpen = (idx: number) => {
39
+ setSelectedIndex(idx);
40
+ dragX.set(0);
41
+ };
42
+
43
+ const handleClose = () => {
44
+ setSelectedIndex(null);
45
+ };
46
+
47
+ const prevIndex = selectedIndex !== null ? (selectedIndex > 0 ? selectedIndex - 1 : images.length - 1) : 0;
48
+ const nextIndex = selectedIndex !== null ? (selectedIndex < images.length - 1 ? selectedIndex + 1 : 0) : 0;
49
+
50
+ const currentX = dragX;
51
+ const prevX = useTransform(dragX, (val) => val - containerWidth);
52
+ const nextX = useTransform(dragX, (val) => val + containerWidth);
53
+
54
+ const handlePrev = useCallback((e?: React.MouseEvent) => {
55
+ if (e) e.stopPropagation();
56
+ if (selectedIndex === null) return;
57
+
58
+ // Slide right to show previous image
59
+ animate(dragX, containerWidth, { type: 'spring', stiffness: 300, damping: 30 }).then(() => {
60
+ setSelectedIndex((prev) => (prev !== null && prev > 0 ? prev - 1 : images.length - 1));
61
+ dragX.set(0);
62
+ });
63
+ }, [selectedIndex, containerWidth, images.length, dragX]);
64
+
65
+ const handleNext = useCallback((e?: React.MouseEvent) => {
66
+ if (e) e.stopPropagation();
67
+ if (selectedIndex === null) return;
68
+
69
+ // Slide left to show next image
70
+ animate(dragX, -containerWidth, { type: 'spring', stiffness: 300, damping: 30 }).then(() => {
71
+ setSelectedIndex((prev) => (prev !== null && prev < images.length - 1 ? prev + 1 : 0));
72
+ dragX.set(0);
73
+ });
74
+ }, [selectedIndex, containerWidth, images.length, dragX]);
75
+
76
+ // Keyboard navigation support
77
+ useEffect(() => {
78
+ const handleKeyDown = (e: KeyboardEvent) => {
79
+ if (selectedIndex === null) return;
80
+ if (e.key === 'ArrowRight') handleNext();
81
+ if (e.key === 'ArrowLeft') handlePrev();
82
+ if (e.key === 'Escape') handleClose();
83
+ };
84
+
85
+ window.addEventListener('keydown', handleKeyDown);
86
+ if (selectedIndex !== null) {
87
+ document.body.style.overflow = 'hidden';
88
+ }
89
+ return () => {
90
+ window.removeEventListener('keydown', handleKeyDown);
91
+ document.body.style.overflow = '';
92
+ };
93
+ }, [selectedIndex, handleNext, handlePrev]);
94
+
95
+ return (
96
+ <div className={`w-full ${className}`}>
97
+
98
+ {/* Photo grid */}
99
+ <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
100
+ {isLoading ? (
101
+ Array(4).fill(0).map((_, idx) => (
102
+ <Skeleton
103
+ key={idx}
104
+ variant="rect"
105
+ className="w-full aspect-square rounded-2xl border border-border-app"
106
+ />
107
+ ))
108
+ ) : (
109
+ images.map((img, idx) => (
110
+ <div
111
+ key={idx}
112
+ onClick={() => handleOpen(idx)}
113
+ className="group relative aspect-square rounded-2xl overflow-hidden bg-bg-card border border-border-app cursor-pointer shadow-sm hover:shadow-md transition-all duration-300"
114
+ >
115
+ <img
116
+ src={img.src}
117
+ alt={img.alt}
118
+ className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-108"
119
+ loading="lazy"
120
+ />
121
+ {/* Hover overlay */}
122
+ <div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
123
+ <div className="w-10 h-10 rounded-full bg-white/20 backdrop-blur-md flex items-center justify-center text-white border border-white/20">
124
+ <Maximize2 className="w-5 h-5" />
125
+ </div>
126
+ </div>
127
+ </div>
128
+ ))
129
+ )}
130
+ </div>
131
+
132
+ {/* Lightbox full-screen Overlay */}
133
+ {typeof document !== 'undefined' && createPortal(
134
+ <AnimatePresence>
135
+ {selectedIndex !== null && (
136
+ <div className="fixed inset-0 z-50 flex items-center justify-center p-4 select-none">
137
+
138
+ {/* Backdrop Blur overlay */}
139
+ <motion.div
140
+ initial={{ opacity: 0 }}
141
+ animate={{ opacity: 1 }}
142
+ exit={{ opacity: 0 }}
143
+ onClick={handleClose}
144
+ className="fixed inset-0 bg-black/90 backdrop-blur-md cursor-pointer"
145
+ />
146
+
147
+ {/* Upper Action Controls */}
148
+ <div className="absolute top-6 left-6 right-6 z-10 flex items-center justify-between text-white pointer-events-none">
149
+ <span className="text-sm font-semibold tracking-wider font-mono">
150
+ {selectedIndex + 1} / {images.length}
151
+ </span>
152
+ <button
153
+ onClick={handleClose}
154
+ className="pointer-events-auto p-2 rounded-xl bg-white/10 hover:bg-white/20 border border-white/10 text-white transition-colors cursor-pointer"
155
+ aria-label="Cerrar galería"
156
+ >
157
+ <X className="w-6 h-6" />
158
+ </button>
159
+ </div>
160
+
161
+ {/* Previous Arrow Button */}
162
+ <button
163
+ onClick={handlePrev}
164
+ className="absolute left-6 z-10 p-3 rounded-xl bg-white/10 hover:bg-white/20 border border-white/10 text-white transition-colors cursor-pointer"
165
+ aria-label="Imagen anterior"
166
+ >
167
+ <ChevronLeft className="w-6 h-6" />
168
+ </button>
169
+
170
+ {/* Slider container zoom wrapper */}
171
+ <motion.div
172
+ initial={{ scale: 0.92, opacity: 0 }}
173
+ animate={{ scale: 1, opacity: 1 }}
174
+ exit={{ scale: 0.92, opacity: 0 }}
175
+ transition={{ type: 'spring', stiffness: 350, damping: 26 }}
176
+ ref={containerRef}
177
+ className="relative max-w-5xl max-h-[80vh] aspect-auto w-full h-full flex items-center justify-center z-10 overflow-hidden pointer-events-auto"
178
+ >
179
+ {/* Previous Slide */}
180
+ <motion.div
181
+ style={{ x: prevX }}
182
+ className="absolute inset-0 w-full h-full flex flex-col items-center justify-center p-4 select-none pointer-events-none"
183
+ >
184
+ <img
185
+ src={images[prevIndex].src}
186
+ alt={images[prevIndex].alt}
187
+ className="max-w-full max-h-[70vh] object-contain rounded-xl shadow-2xl"
188
+ />
189
+ {images[prevIndex].caption && (
190
+ <span className="mt-4 text-sm font-semibold text-white/80 drop-shadow-md text-center max-w-lg">
191
+ {images[prevIndex].caption}
192
+ </span>
193
+ )}
194
+ </motion.div>
195
+
196
+ {/* Current Slide */}
197
+ <motion.div
198
+ style={{ x: currentX }}
199
+ drag="x"
200
+ dragConstraints={{ left: 0, right: 0 }}
201
+ dragElastic={0.6}
202
+ onDragEnd={(_, info) => {
203
+ const threshold = containerWidth * 0.2; // 20% of width is enough to swipe
204
+ if (info.offset.x < -threshold) {
205
+ handleNext();
206
+ } else if (info.offset.x > threshold) {
207
+ handlePrev();
208
+ } else {
209
+ animate(dragX, 0, { type: 'spring', stiffness: 300, damping: 30 });
210
+ }
211
+ }}
212
+ className="absolute inset-0 w-full h-full flex flex-col items-center justify-center p-4 select-none cursor-grab active:cursor-grabbing z-20"
213
+ >
214
+ <img
215
+ src={images[selectedIndex].src}
216
+ alt={images[selectedIndex].alt}
217
+ className="max-w-full max-h-[70vh] object-contain rounded-xl shadow-2xl pointer-events-none"
218
+ />
219
+ {images[selectedIndex].caption && (
220
+ <span className="mt-4 text-sm font-semibold text-white/80 drop-shadow-md text-center max-w-lg">
221
+ {images[selectedIndex].caption}
222
+ </span>
223
+ )}
224
+ </motion.div>
225
+
226
+ {/* Next Slide */}
227
+ <motion.div
228
+ style={{ x: nextX }}
229
+ className="absolute inset-0 w-full h-full flex flex-col items-center justify-center p-4 select-none pointer-events-none"
230
+ >
231
+ <img
232
+ src={images[nextIndex].src}
233
+ alt={images[nextIndex].alt}
234
+ className="max-w-full max-h-[70vh] object-contain rounded-xl shadow-2xl"
235
+ />
236
+ {images[nextIndex].caption && (
237
+ <span className="mt-4 text-sm font-semibold text-white/80 drop-shadow-md text-center max-w-lg">
238
+ {images[nextIndex].caption}
239
+ </span>
240
+ )}
241
+ </motion.div>
242
+ </motion.div>
243
+
244
+ {/* Next Arrow Button */}
245
+ <button
246
+ onClick={handleNext}
247
+ className="absolute right-6 z-10 p-3 rounded-xl bg-white/10 hover:bg-white/20 border border-white/10 text-white transition-colors cursor-pointer"
248
+ aria-label="Siguiente imagen"
249
+ >
250
+ <ChevronRight className="w-6 h-6" />
251
+ </button>
252
+ </div>
253
+ )}
254
+ </AnimatePresence>,
255
+ document.body
256
+ )}
257
+ </div>
258
+ );
259
+ };
@@ -0,0 +1,118 @@
1
+ import React from 'react';
2
+
3
+ // 1. INPUT GROUP ADDON / TEXT COMPONENT
4
+ export interface InputGroupTextProps {
5
+ children: React.ReactNode;
6
+ className?: string;
7
+ }
8
+
9
+ export const InputGroupText: React.FC<InputGroupTextProps> = ({ children, className = '' }) => {
10
+ return (
11
+ <div className={`input-group-text px-4 py-3 flex items-center justify-center bg-bg-app/50 border border-border-app text-xs font-bold text-text-muted select-none whitespace-nowrap ${className}`}>
12
+ {children}
13
+ </div>
14
+ );
15
+ };
16
+
17
+ // 2. MAIN INPUT GROUP CONTAINER
18
+ export interface InputGroupProps {
19
+ children: React.ReactNode;
20
+ className?: string;
21
+ disabled?: boolean;
22
+ }
23
+
24
+ export const InputGroup: React.FC<InputGroupProps> = ({ children, className = '', disabled = false }) => {
25
+ return (
26
+ <div className={`input-group-container flex items-stretch w-full relative ${
27
+ disabled ? 'opacity-40 cursor-not-allowed select-none' : ''
28
+ } ${className}`}>
29
+
30
+ {/* Dynamic inline styles to target complex nested components cleanly */}
31
+ <style>{`
32
+ .input-group-container > * {
33
+ flex: 1;
34
+ }
35
+ .input-group-container > .input-group-text,
36
+ .input-group-container > button,
37
+ .input-group-container > div > button {
38
+ flex: 0 0 auto;
39
+ }
40
+ /* First element rounding adjustments */
41
+ .input-group-container > *:first-child,
42
+ .input-group-container > *:first-child * {
43
+ border-top-right-radius: 0px !important;
44
+ border-bottom-right-radius: 0px !important;
45
+ }
46
+ /* Last element rounding adjustments */
47
+ .input-group-container > *:last-child,
48
+ .input-group-container > *:last-child * {
49
+ border-top-left-radius: 0px !important;
50
+ border-bottom-left-radius: 0px !important;
51
+ }
52
+ /* Middle elements rounding adjustments */
53
+ .input-group-container > *:not(:first-child):not(:last-child),
54
+ .input-group-container > *:not(:first-child):not(:last-child) * {
55
+ border-radius: 0px !important;
56
+ }
57
+ /* Overlap duplicate borders smoothly */
58
+ .input-group-container > *:not(:first-child) {
59
+ margin-left: -1px;
60
+ }
61
+ /* Ensure inputs take full height within wrappers */
62
+ .input-group-container > * > div {
63
+ height: 100%;
64
+ }
65
+ `}</style>
66
+
67
+ {React.Children.map(children, (child) => {
68
+ if (!React.isValidElement(child)) return child;
69
+
70
+ // Propagate disabled property to children
71
+ return React.cloneElement(child as React.ReactElement<{ disabled?: boolean }>, {
72
+ disabled: (child as React.ReactElement<{ disabled?: boolean }>).props.disabled !== undefined
73
+ ? (child as React.ReactElement<{ disabled?: boolean }>).props.disabled
74
+ : disabled
75
+ });
76
+ })}
77
+ </div>
78
+ );
79
+ };
80
+
81
+ // 3. ICON FIELD CONTAINER (To place icons absolute-positioned inside inputs)
82
+ export type IconFieldPosition = 'left' | 'right';
83
+
84
+ export interface IconFieldProps {
85
+ icon: React.ReactNode;
86
+ iconPosition?: IconFieldPosition;
87
+ children: React.ReactElement; // Must be a single input element
88
+ className?: string;
89
+ }
90
+
91
+ export const IconField: React.FC<IconFieldProps> = ({
92
+ icon,
93
+ iconPosition = 'left',
94
+ children,
95
+ className = ''
96
+ }) => {
97
+ const isLeft = iconPosition === 'left';
98
+
99
+ return (
100
+ <div className={`relative w-full ${className}`}>
101
+ {/* Icon Wrapper */}
102
+ <div
103
+ className={`absolute top-1/2 -translate-y-1/2 flex items-center justify-center text-text-muted/70 z-20 pointer-events-none ${
104
+ isLeft ? 'left-4' : 'right-4'
105
+ }`}
106
+ >
107
+ {icon}
108
+ </div>
109
+
110
+ {/* Clone Input to inject correct padding */}
111
+ {React.cloneElement(children as React.ReactElement<{ className?: string }>, {
112
+ className: `${(children as React.ReactElement<{ className?: string }>).props.className || ''} ${
113
+ isLeft ? '!pl-10.5' : '!pr-10.5'
114
+ }`.trim()
115
+ })}
116
+ </div>
117
+ );
118
+ };
@@ -0,0 +1,147 @@
1
+ import React, { useRef, useEffect } from 'react';
2
+ import { motion } from 'framer-motion';
3
+
4
+ export interface InputOTPProps {
5
+ value: string;
6
+ onChange: (value: string) => void;
7
+ length?: 4 | 6;
8
+ disabled?: boolean;
9
+ isInvalid?: boolean;
10
+ className?: string;
11
+ }
12
+
13
+ export const InputOTP: React.FC<InputOTPProps> = ({
14
+ value,
15
+ onChange,
16
+ length = 6,
17
+ disabled = false,
18
+ isInvalid = false,
19
+ className = ''
20
+ }) => {
21
+ const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
22
+ const otpArray = value.split('').concat(Array(length).fill('')).slice(0, length);
23
+
24
+ // Focus the first empty input or the first one if empty
25
+ useEffect(() => {
26
+ if (inputRefs.current[0] && value.length === 0) {
27
+ // Don't auto-focus unless requested, just set up array sizes
28
+ inputRefs.current = inputRefs.current.slice(0, length);
29
+ }
30
+ }, [length, value]);
31
+
32
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>, idx: number) => {
33
+ const val = e.target.value.replace(/\D/g, ''); // numbers only
34
+ if (!val) return;
35
+
36
+ const valChars = [...otpArray];
37
+ valChars[idx] = val.slice(-1); // keep only last character
38
+ const nextVal = valChars.slice(0, length).join('');
39
+ onChange(nextVal);
40
+
41
+ // Auto-focus next box
42
+ if (idx < length - 1) {
43
+ inputRefs.current[idx + 1]?.focus();
44
+ }
45
+ };
46
+
47
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>, idx: number) => {
48
+ if (e.key === 'Backspace') {
49
+ const valChars = [...otpArray];
50
+
51
+ if (otpArray[idx] === '') {
52
+ // If current is empty, delete previous and focus it
53
+ if (idx > 0) {
54
+ valChars[idx - 1] = '';
55
+ onChange(valChars.slice(0, length).join(''));
56
+ inputRefs.current[idx - 1]?.focus();
57
+ }
58
+ } else {
59
+ // Delete current char
60
+ valChars[idx] = '';
61
+ onChange(valChars.slice(0, length).join(''));
62
+ }
63
+ } else if (e.key === 'ArrowLeft' && idx > 0) {
64
+ inputRefs.current[idx - 1]?.focus();
65
+ } else if (e.key === 'ArrowRight' && idx < length - 1) {
66
+ inputRefs.current[idx + 1]?.focus();
67
+ }
68
+ };
69
+
70
+ const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
71
+ e.preventDefault();
72
+ const pastedData = e.clipboardData.getData('text')
73
+ .replace(/\D/g, '')
74
+ .slice(0, length);
75
+
76
+ if (pastedData) {
77
+ onChange(pastedData);
78
+ const focusIdx = Math.min(pastedData.length, length - 1);
79
+ inputRefs.current[focusIdx]?.focus();
80
+ }
81
+ };
82
+
83
+ return (
84
+ <div className={`flex flex-col gap-2.5 items-center ${className}`}>
85
+ <div className="flex gap-2.5 items-center justify-center">
86
+ {otpArray.map((char, idx) => {
87
+ const isFocused = document.activeElement === inputRefs.current[idx];
88
+
89
+ return (
90
+ <div key={idx} className="relative w-12 h-14 overflow-visible">
91
+
92
+ {/* Focus Glow border ring */}
93
+ <motion.div
94
+ animate={{
95
+ opacity: isFocused && !disabled ? 1 : 0,
96
+ scale: isFocused && !disabled ? 1 : 0.95,
97
+ }}
98
+ className="absolute -inset-[1px] bg-gradient-to-r from-accent to-pink-500 rounded-xl pointer-events-none z-0 blur-[1px]"
99
+ />
100
+
101
+ <input
102
+ ref={(el) => { inputRefs.current[idx] = el; }}
103
+ type="text"
104
+ inputMode="numeric"
105
+ pattern="[0-9]*"
106
+ maxLength={1}
107
+ value={char}
108
+ onChange={(e) => handleInputChange(e, idx)}
109
+ onKeyDown={(e) => handleKeyDown(e, idx)}
110
+ onPaste={handlePaste}
111
+ disabled={disabled}
112
+ className={`w-full h-full text-center text-lg font-extrabold text-text-main bg-bg-card border rounded-xl z-10 relative focus:outline-hidden transition-all ${
113
+ isInvalid
114
+ ? 'border-error/60'
115
+ : isFocused
116
+ ? 'border-transparent'
117
+ : 'border-border-app/80'
118
+ } ${
119
+ disabled
120
+ ? 'opacity-40 cursor-not-allowed select-none bg-bg-app/10'
121
+ : 'cursor-text'
122
+ }`}
123
+ style={{
124
+ boxShadow: isFocused && !disabled && !isInvalid
125
+ ? '0 0 10px var(--color-accent-glow)'
126
+ : isInvalid
127
+ ? '0 0 10px var(--color-error-glow)'
128
+ : 'none'
129
+ }}
130
+ />
131
+ </div>
132
+ );
133
+ })}
134
+ </div>
135
+
136
+ {isInvalid && (
137
+ <motion.span
138
+ initial={{ opacity: 0, y: -4 }}
139
+ animate={{ opacity: 1, y: 0 }}
140
+ className="text-[11px] text-red-500 font-semibold mt-1"
141
+ >
142
+ Código de verificación incorrecto
143
+ </motion.span>
144
+ )}
145
+ </div>
146
+ );
147
+ };