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,329 @@
1
+ import React, { useRef, useState, useEffect } from 'react';
2
+ import {
3
+ Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight,
4
+ List, ListOrdered, Link, Image, Trash2
5
+ } from 'lucide-react';
6
+
7
+ export interface TextEditorProps {
8
+ value?: string;
9
+ onChange?: (html: string) => void;
10
+ placeholder?: string;
11
+ disabled?: boolean;
12
+ className?: string;
13
+ }
14
+
15
+ export const TextEditor: React.FC<TextEditorProps> = ({
16
+ value = '',
17
+ onChange,
18
+ placeholder = 'Comienza a escribir aquí...',
19
+ disabled = false,
20
+ className = ''
21
+ }) => {
22
+ const editorRef = useRef<HTMLDivElement>(null);
23
+ const [isFocused, setIsFocused] = useState(false);
24
+ const [wordCount, setWordCount] = useState(0);
25
+ const [charCount, setCharCount] = useState(0);
26
+ const [activeFormats, setActiveFormats] = useState({
27
+ bold: false,
28
+ italic: false,
29
+ underline: false,
30
+ alignLeft: false,
31
+ alignCenter: false,
32
+ alignRight: false
33
+ });
34
+
35
+ // Sync value from parent if it changes and is different from editor's innerHTML
36
+ useEffect(() => {
37
+ if (editorRef.current && editorRef.current.innerHTML !== value) {
38
+ editorRef.current.innerHTML = value;
39
+ updateCounts();
40
+ }
41
+ }, [value]);
42
+
43
+ const updateCounts = () => {
44
+ if (!editorRef.current) return;
45
+ const text = editorRef.current.innerText || '';
46
+ setCharCount(text.replace(/\n/g, '').length);
47
+ const words = text.trim().split(/\s+/).filter(Boolean);
48
+ setWordCount(words.length);
49
+ };
50
+
51
+ const executeCommand = (command: string, arg: string = '') => {
52
+ if (disabled) return;
53
+ document.execCommand(command, false, arg);
54
+ if (editorRef.current) {
55
+ editorRef.current.focus();
56
+ }
57
+ checkActiveFormats();
58
+ handleInput();
59
+ };
60
+
61
+ const handleInput = () => {
62
+ if (!editorRef.current) return;
63
+ const html = editorRef.current.innerHTML;
64
+ updateCounts();
65
+ if (onChange) {
66
+ onChange(html);
67
+ }
68
+ };
69
+
70
+ const checkActiveFormats = () => {
71
+ setActiveFormats({
72
+ bold: document.queryCommandState('bold'),
73
+ italic: document.queryCommandState('italic'),
74
+ underline: document.queryCommandState('underline'),
75
+ alignLeft: document.queryCommandState('justifyLeft'),
76
+ alignCenter: document.queryCommandState('justifyCenter'),
77
+ alignRight: document.queryCommandState('justifyRight')
78
+ });
79
+ };
80
+
81
+ const handleLinkInsert = () => {
82
+ const url = prompt('Ingresa la URL del enlace:');
83
+ if (url) {
84
+ executeCommand('createLink', url);
85
+ }
86
+ };
87
+
88
+ const handleImageInsert = () => {
89
+ const url = prompt('Ingresa la URL de la imagen:');
90
+ if (url) {
91
+ executeCommand('insertImage', url);
92
+ }
93
+ };
94
+
95
+ const handleFormatClear = () => {
96
+ executeCommand('removeFormat');
97
+ };
98
+
99
+ return (
100
+ <div className={`relative w-full flex flex-col bg-bg-card/40 border border-border-app/40 rounded-2xl overflow-hidden transition-all duration-300 ${
101
+ isFocused ? 'border-accent shadow-[0_0_12px_var(--color-accent)]' : ''
102
+ } ${disabled ? 'opacity-40 cursor-not-allowed select-none pointer-events-none' : ''} ${className}`}>
103
+
104
+ {/* Editor Toolbar */}
105
+ <div className="flex flex-wrap items-center gap-1.5 p-2 bg-bg-app/40 border-b border-border-app/30 select-none">
106
+
107
+ {/* Formatting actions */}
108
+ <button
109
+ type="button"
110
+ onClick={() => executeCommand('bold')}
111
+ className={`p-2 rounded-lg cursor-pointer transition-colors ${
112
+ activeFormats.bold ? 'bg-accent/20 text-accent font-black' : 'text-text-muted hover:text-text-main'
113
+ }`}
114
+ title="Negrita"
115
+ >
116
+ <Bold size={14} />
117
+ </button>
118
+ <button
119
+ type="button"
120
+ onClick={() => executeCommand('italic')}
121
+ className={`p-2 rounded-lg cursor-pointer transition-colors ${
122
+ activeFormats.italic ? 'bg-accent/20 text-accent font-black' : 'text-text-muted hover:text-text-main'
123
+ }`}
124
+ title="Cursiva"
125
+ >
126
+ <Italic size={14} />
127
+ </button>
128
+ <button
129
+ type="button"
130
+ onClick={() => executeCommand('underline')}
131
+ className={`p-2 rounded-lg cursor-pointer transition-colors ${
132
+ activeFormats.underline ? 'bg-accent/20 text-accent font-black' : 'text-text-muted hover:text-text-main'
133
+ }`}
134
+ title="Subrayado"
135
+ >
136
+ <Underline size={14} />
137
+ </button>
138
+
139
+ <span className="w-[1px] h-4 bg-border-app/30 mx-1" />
140
+
141
+ {/* Alignment actions */}
142
+ <button
143
+ type="button"
144
+ onClick={() => executeCommand('justifyLeft')}
145
+ className={`p-2 rounded-lg cursor-pointer transition-colors ${
146
+ activeFormats.alignLeft ? 'bg-accent/20 text-accent' : 'text-text-muted hover:text-text-main'
147
+ }`}
148
+ title="Alinear Izquierda"
149
+ >
150
+ <AlignLeft size={14} />
151
+ </button>
152
+ <button
153
+ type="button"
154
+ onClick={() => executeCommand('justifyCenter')}
155
+ className={`p-2 rounded-lg cursor-pointer transition-colors ${
156
+ activeFormats.alignCenter ? 'bg-accent/20 text-accent' : 'text-text-muted hover:text-text-main'
157
+ }`}
158
+ title="Centrar"
159
+ >
160
+ <AlignCenter size={14} />
161
+ </button>
162
+ <button
163
+ type="button"
164
+ onClick={() => executeCommand('justifyRight')}
165
+ className={`p-2 rounded-lg cursor-pointer transition-colors ${
166
+ activeFormats.alignRight ? 'bg-accent/20 text-accent' : 'text-text-muted hover:text-text-main'
167
+ }`}
168
+ title="Alinear Derecha"
169
+ >
170
+ <AlignRight size={14} />
171
+ </button>
172
+
173
+ <span className="w-[1px] h-4 bg-border-app/30 mx-1" />
174
+
175
+ {/* Lists */}
176
+ <button
177
+ type="button"
178
+ onClick={() => executeCommand('insertUnorderedList')}
179
+ className="p-2 rounded-lg text-text-muted hover:text-text-main transition-colors cursor-pointer"
180
+ title="Lista Viñetas"
181
+ >
182
+ <List size={14} />
183
+ </button>
184
+ <button
185
+ type="button"
186
+ onClick={() => executeCommand('insertOrderedList')}
187
+ className="p-2 rounded-lg text-text-muted hover:text-text-main transition-colors cursor-pointer"
188
+ title="Lista Numerada"
189
+ >
190
+ <ListOrdered size={14} />
191
+ </button>
192
+
193
+ <span className="w-[1px] h-4 bg-border-app/30 mx-1" />
194
+
195
+ {/* Fonts & Headers */}
196
+ <select
197
+ onChange={(e) => executeCommand('fontName', e.target.value)}
198
+ className="bg-transparent text-text-muted text-xs p-1.5 rounded-lg border border-border-app/30 cursor-pointer focus:outline-hidden"
199
+ title="Tipografía"
200
+ >
201
+ <option value="system-ui">Inter / System</option>
202
+ <option value="Georgia">Serif (Georgia)</option>
203
+ <option value="Courier New">Mono (Courier)</option>
204
+ </select>
205
+
206
+ <select
207
+ onChange={(e) => executeCommand('formatBlock', e.target.value)}
208
+ className="bg-transparent text-text-muted text-xs p-1.5 rounded-lg border border-border-app/30 cursor-pointer focus:outline-hidden"
209
+ title="Estilo de Bloque"
210
+ >
211
+ <option value="div">Normal</option>
212
+ <option value="h1">H1 / Título</option>
213
+ <option value="h2">H2 / Subtítulo</option>
214
+ <option value="blockquote">Cita</option>
215
+ </select>
216
+
217
+ <span className="w-[1px] h-4 bg-border-app/30 mx-1" />
218
+
219
+ {/* Media insertion & Reset */}
220
+ <button
221
+ type="button"
222
+ onClick={handleLinkInsert}
223
+ className="p-2 rounded-lg text-text-muted hover:text-accent transition-colors cursor-pointer"
224
+ title="Insertar Enlace"
225
+ >
226
+ <Link size={14} />
227
+ </button>
228
+ <button
229
+ type="button"
230
+ onClick={handleImageInsert}
231
+ className="p-2 rounded-lg text-text-muted hover:text-accent transition-colors cursor-pointer"
232
+ title="Insertar Imagen URL"
233
+ >
234
+ <Image size={14} />
235
+ </button>
236
+ <button
237
+ type="button"
238
+ onClick={handleFormatClear}
239
+ className="p-2 rounded-lg text-text-muted hover:text-red-500 transition-colors cursor-pointer"
240
+ title="Limpiar Formato"
241
+ >
242
+ <Trash2 size={14} />
243
+ </button>
244
+ </div>
245
+
246
+ {/* Editor editable body */}
247
+ <div className="relative min-h-[140px] p-4 text-sm text-text-main flex-grow focus:outline-hidden">
248
+
249
+ {/* Custom HTML5 contentEditable box */}
250
+ <div
251
+ ref={editorRef}
252
+ contentEditable={!disabled}
253
+ onInput={handleInput}
254
+ onFocus={() => setIsFocused(true)}
255
+ onBlur={() => {
256
+ setIsFocused(false);
257
+ checkActiveFormats();
258
+ }}
259
+ onKeyUp={checkActiveFormats}
260
+ onMouseUp={checkActiveFormats}
261
+ className="w-full min-h-[120px] focus:outline-hidden leading-relaxed text-text-main editor-content-area"
262
+ style={{
263
+ minHeight: '120px'
264
+ }}
265
+ />
266
+
267
+ {/* Placeholder label display */}
268
+ {charCount === 0 && (
269
+ <span className="absolute left-4 top-4 text-text-muted/65 pointer-events-none text-sm font-medium">
270
+ {placeholder}
271
+ </span>
272
+ )}
273
+ </div>
274
+
275
+ {/* Status Footer bar showing statistics */}
276
+ <div className="flex items-center justify-between px-4 py-1.5 bg-bg-app/20 border-t border-border-app/20 select-none text-[10px] font-mono text-text-muted">
277
+ <span>Total: {charCount} caracteres</span>
278
+ <span>{wordCount} palabras</span>
279
+ </div>
280
+
281
+ {/* Basic global styles for contentEditable styling anchors */}
282
+ <style>{`
283
+ .editor-content-area h1 {
284
+ font-size: 1.8rem;
285
+ font-weight: 800;
286
+ line-height: 1.25;
287
+ margin-bottom: 0.75rem;
288
+ color: var(--color-text-main);
289
+ }
290
+ .editor-content-area h2 {
291
+ font-size: 1.4rem;
292
+ font-weight: 700;
293
+ line-height: 1.3;
294
+ margin-bottom: 0.5rem;
295
+ color: var(--color-text-main);
296
+ }
297
+ .editor-content-area blockquote {
298
+ border-left: 3px solid var(--color-accent);
299
+ padding-left: 1rem;
300
+ color: var(--color-text-muted);
301
+ font-style: italic;
302
+ margin: 0.75rem 0;
303
+ }
304
+ .editor-content-area ul {
305
+ list-style-type: disc;
306
+ padding-left: 1.5rem;
307
+ margin: 0.5rem 0;
308
+ }
309
+ .editor-content-area ol {
310
+ list-style-type: decimal;
311
+ padding-left: 1.5rem;
312
+ margin: 0.5rem 0;
313
+ }
314
+ .editor-content-area a {
315
+ color: var(--color-accent);
316
+ text-decoration: underline;
317
+ font-weight: 600;
318
+ }
319
+ .editor-content-area img {
320
+ max-width: 100%;
321
+ border-radius: 0.5rem;
322
+ margin: 0.75rem 0;
323
+ display: block;
324
+ }
325
+ `}</style>
326
+
327
+ </div>
328
+ );
329
+ };
@@ -0,0 +1,99 @@
1
+ import React, { useEffect, useRef } from 'react';
2
+ import gsap from 'gsap';
3
+ import { ScrollTrigger } from 'gsap/ScrollTrigger';
4
+
5
+ // Register scroll trigger plugin
6
+ gsap.registerPlugin(ScrollTrigger);
7
+
8
+ export interface TextRevealProps {
9
+ text: string;
10
+ className?: string;
11
+ mode?: 'scrub' | 'play';
12
+ duration?: number;
13
+ }
14
+
15
+ export const TextReveal: React.FC<TextRevealProps> = ({
16
+ text,
17
+ className = '',
18
+ mode = 'play',
19
+ duration = 0.8
20
+ }) => {
21
+ const containerRef = useRef<HTMLDivElement>(null);
22
+ const wordsRef = useRef<HTMLSpanElement[]>([]);
23
+
24
+ useEffect(() => {
25
+ if (!containerRef.current) return;
26
+
27
+ // Select all split spans
28
+ const targets = wordsRef.current.filter(Boolean);
29
+
30
+ if (targets.length === 0) return;
31
+
32
+ const ctx = gsap.context(() => {
33
+ if (mode === 'scrub') {
34
+ gsap.fromTo(
35
+ targets,
36
+ {
37
+ opacity: 0.1,
38
+ y: 10,
39
+ },
40
+ {
41
+ opacity: 1,
42
+ y: 0,
43
+ stagger: 0.1,
44
+ scrollTrigger: {
45
+ trigger: containerRef.current,
46
+ start: 'top 85%',
47
+ end: 'top 50%',
48
+ scrub: true,
49
+ },
50
+ }
51
+ );
52
+ } else {
53
+ gsap.fromTo(
54
+ targets,
55
+ {
56
+ opacity: 0,
57
+ y: 30,
58
+ filter: 'blur(4px)'
59
+ },
60
+ {
61
+ opacity: 1,
62
+ y: 0,
63
+ filter: 'blur(0px)',
64
+ duration: duration,
65
+ stagger: 0.05,
66
+ ease: 'power3.out',
67
+ scrollTrigger: {
68
+ trigger: containerRef.current,
69
+ start: 'top 85%',
70
+ toggleActions: 'play none none reverse',
71
+ },
72
+ }
73
+ );
74
+ }
75
+ }, containerRef);
76
+
77
+ return () => ctx.revert(); // Cleanup GSAP animations
78
+ }, [text, mode, duration]);
79
+
80
+ const words = text.split(' ');
81
+
82
+ return (
83
+ <div ref={containerRef} className={`inline-block ${className}`}>
84
+ <span className="flex flex-wrap gap-x-2 gap-y-1">
85
+ {words.map((word, idx) => (
86
+ <span
87
+ key={idx}
88
+ ref={(el) => {
89
+ if (el) wordsRef.current[idx] = el;
90
+ }}
91
+ className="inline-block origin-bottom transition-all"
92
+ >
93
+ {word}
94
+ </span>
95
+ ))}
96
+ </span>
97
+ </div>
98
+ );
99
+ };
@@ -0,0 +1,133 @@
1
+ import React, { useState, useEffect, useRef } from 'react';
2
+ import { motion, AnimatePresence } from 'framer-motion';
3
+ import { Sun, Moon, Monitor, Check } from 'lucide-react';
4
+
5
+ export type ThemeMode = 'light' | 'dark' | 'system';
6
+
7
+ export interface ThemeSwitcherProps {
8
+ className?: string;
9
+ onThemeChange?: (theme: ThemeMode) => void;
10
+ }
11
+
12
+ export const ThemeSwitcher: React.FC<ThemeSwitcherProps> = ({ className = '', onThemeChange }) => {
13
+ // Mismo valor en servidor y cliente; localStorage se lee tras el mount
14
+ const [theme, setTheme] = useState<ThemeMode>('system');
15
+ const [isOpen, setIsOpen] = useState(false);
16
+ const containerRef = useRef<HTMLDivElement>(null);
17
+
18
+ useEffect(() => {
19
+ const saved = localStorage.getItem('theme-mode') as ThemeMode | null;
20
+ if (saved === 'light' || saved === 'dark' || saved === 'system') {
21
+ setTheme(saved);
22
+ }
23
+ }, []);
24
+
25
+ // Sync theme to document element
26
+ useEffect(() => {
27
+ const root = window.document.documentElement;
28
+
29
+ const applyTheme = (mode: ThemeMode) => {
30
+ root.classList.remove('dark', 'light');
31
+
32
+ if (mode === 'system') {
33
+ const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
34
+ root.classList.add(systemPrefersDark ? 'dark' : 'light');
35
+ } else {
36
+ root.classList.add(mode);
37
+ }
38
+ localStorage.setItem('theme-mode', mode);
39
+ };
40
+
41
+ applyTheme(theme);
42
+
43
+ // Watch system changes if set to system
44
+ if (theme === 'system') {
45
+ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
46
+ const handleSystemChange = () => applyTheme('system');
47
+
48
+ mediaQuery.addEventListener('change', handleSystemChange);
49
+ return () => mediaQuery.removeEventListener('change', handleSystemChange);
50
+ }
51
+ }, [theme, onThemeChange]);
52
+
53
+ // Click outside listener to close dropdown
54
+ useEffect(() => {
55
+ const handleClickOutside = (event: MouseEvent) => {
56
+ if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
57
+ setIsOpen(false);
58
+ }
59
+ };
60
+ document.addEventListener('mousedown', handleClickOutside);
61
+ return () => document.removeEventListener('mousedown', handleClickOutside);
62
+ }, []);
63
+
64
+ const options = [
65
+ { value: 'light', label: 'Claro', icon: <Sun className="w-4 h-4 text-amber-500" /> },
66
+ { value: 'dark', label: 'Oscuro', icon: <Moon className="w-4 h-4 text-indigo-500" /> },
67
+ { value: 'system', label: 'Sistema', icon: <Monitor className="w-4 h-4 text-sky-500" /> }
68
+ ] as const;
69
+
70
+ const currentOption = options.find((opt) => opt.value === theme)!;
71
+
72
+ return (
73
+ <div ref={containerRef} className={`relative inline-block ${className}`}>
74
+
75
+ {/* Dropdown trigger button */}
76
+ <button
77
+ onClick={() => setIsOpen(!isOpen)}
78
+ className="p-2.5 rounded-xl border border-border-app bg-bg-card text-text-muted hover:text-text-main hover:border-accent hover:shadow-[0_0_10px_rgba(99,102,241,0.2)] transition-all duration-300 cursor-pointer flex items-center gap-2"
79
+ aria-label="Seleccionar tema"
80
+ >
81
+ {currentOption.icon}
82
+ </button>
83
+
84
+ {/* Animated Dropdown Menu options */}
85
+ <AnimatePresence>
86
+ {isOpen && (
87
+ <motion.ul
88
+ initial={{ opacity: 0, y: 10, scale: 0.95 }}
89
+ animate={{
90
+ opacity: 1,
91
+ y: 6,
92
+ scale: 1,
93
+ transition: { type: 'spring', stiffness: 350, damping: 25 }
94
+ }}
95
+ exit={{
96
+ opacity: 0,
97
+ y: 10,
98
+ scale: 0.95,
99
+ transition: { duration: 0.15 }
100
+ }}
101
+ className="absolute right-0 w-36 glass rounded-xl shadow-2xl py-1 z-50 p-1 flex flex-col gap-0.5"
102
+ >
103
+ {options.map((opt) => {
104
+ const isSelected = opt.value === theme;
105
+ return (
106
+ <li
107
+ key={opt.value}
108
+ onClick={() => {
109
+ setTheme(opt.value);
110
+ setIsOpen(false);
111
+ if (onThemeChange) onThemeChange(opt.value);
112
+ }}
113
+ className={`px-3 py-2 text-xs font-bold rounded-lg cursor-pointer flex items-center justify-between transition-colors duration-150 ${
114
+ isSelected
115
+ ? 'bg-accent/10 text-accent'
116
+ : 'text-text-main hover:bg-bg-app'
117
+ }`}
118
+ >
119
+ <div className="flex items-center gap-2">
120
+ {opt.icon}
121
+ <span>{opt.label}</span>
122
+ </div>
123
+ {isSelected && <Check className="w-3.5 h-3.5" />}
124
+ </li>
125
+ );
126
+ })}
127
+ </motion.ul>
128
+ )}
129
+ </AnimatePresence>
130
+
131
+ </div>
132
+ );
133
+ };