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,79 @@
1
+ import React from 'react';
2
+ import { GlassInput } from './GlassInput';
3
+
4
+ export type MaskType = 'phone' | 'cuit' | 'dni' | 'card';
5
+
6
+ export interface MaskedInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'> {
7
+ mask: MaskType;
8
+ value: string;
9
+ onChange: (value: string, rawValue: string) => void;
10
+ error?: string;
11
+ leftIcon?: React.ReactNode;
12
+ rightIcon?: React.ReactNode;
13
+ }
14
+
15
+ const applyMask = (val: string, maskType: MaskType): string => {
16
+ const clean = val.replace(/\D/g, '');
17
+
18
+ switch (maskType) {
19
+ case 'phone':
20
+ // Formato: (999) 999-9999
21
+ if (clean.length === 0) return '';
22
+ if (clean.length <= 3) return `(${clean}`;
23
+ if (clean.length <= 6) return `(${clean.slice(0, 3)}) ${clean.slice(3)}`;
24
+ return `(${clean.slice(0, 3)}) ${clean.slice(3, 6)}-${clean.slice(6, 10)}`;
25
+
26
+ case 'cuit':
27
+ // Formato: 99-99999999-9
28
+ if (clean.length === 0) return '';
29
+ if (clean.length <= 2) return clean;
30
+ if (clean.length <= 10) return `${clean.slice(0, 2)}-${clean.slice(2)}`;
31
+ return `${clean.slice(0, 2)}-${clean.slice(2, 10)}-${clean.slice(10, 11)}`;
32
+
33
+ case 'dni':
34
+ // Formato: 99.999.999 o 9.999.999
35
+ if (clean.length === 0) return '';
36
+ if (clean.length <= 8) {
37
+ const len = clean.length;
38
+ if (len <= 3) return clean;
39
+ if (len <= 6) return `${clean.slice(0, len - 3)}.${clean.slice(len - 3)}`;
40
+ return `${clean.slice(0, len - 6)}.${clean.slice(len - 6, len - 3)}.${clean.slice(len - 3)}`;
41
+ }
42
+ return clean.slice(0, 8).replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1.');
43
+
44
+ case 'card':
45
+ // Formato: 9999-9999-9999-9999
46
+ if (clean.length === 0) return '';
47
+ const parts = [];
48
+ for (let i = 0; i < clean.length && i < 16; i += 4) {
49
+ parts.push(clean.slice(i, i + 4));
50
+ }
51
+ return parts.join('-');
52
+
53
+ default:
54
+ return val;
55
+ }
56
+ };
57
+
58
+ export const MaskedInput: React.FC<MaskedInputProps> = ({
59
+ mask,
60
+ value,
61
+ onChange,
62
+ disabled = false,
63
+ ...props
64
+ }) => {
65
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
66
+ const rawVal = e.target.value.replace(/\D/g, '');
67
+ const maskedVal = applyMask(e.target.value, mask);
68
+ onChange(maskedVal, rawVal);
69
+ };
70
+
71
+ return (
72
+ <GlassInput
73
+ value={value}
74
+ onChange={handleInputChange}
75
+ disabled={disabled}
76
+ {...props}
77
+ />
78
+ );
79
+ };
@@ -0,0 +1,413 @@
1
+ import React, { useState, useRef, useEffect } from 'react';
2
+ import { motion, AnimatePresence } from 'framer-motion';
3
+
4
+ export interface MentionUser {
5
+ id: string | number;
6
+ username: string;
7
+ name: string;
8
+ avatarUrl?: string;
9
+ }
10
+
11
+ export interface MentionInputProps {
12
+ value: string;
13
+ onChange: (value: string) => void;
14
+ suggestions: MentionUser[];
15
+ onSearch: (query: string) => void;
16
+ placeholder?: string;
17
+ label?: string;
18
+ disabled?: boolean;
19
+ className?: string;
20
+ as?: 'input' | 'textarea';
21
+ }
22
+
23
+ // Helper to extract plain text from contentEditable div
24
+ const getEditorText = (el: HTMLDivElement): string => {
25
+ let text = '';
26
+ const traverse = (node: Node) => {
27
+ if (node.nodeType === Node.TEXT_NODE) {
28
+ text += node.textContent || '';
29
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
30
+ const element = node as HTMLElement;
31
+ if (element.getAttribute('data-mention') === 'true') {
32
+ text += `@${element.getAttribute('data-username')}`;
33
+ } else if (element.tagName === 'BR') {
34
+ text += '\n';
35
+ } else {
36
+ const style = window.getComputedStyle(element);
37
+ const isBlock = style.display === 'block' || style.display === 'flex';
38
+ if (isBlock && text.length > 0 && !text.endsWith('\n')) {
39
+ text += '\n';
40
+ }
41
+ node.childNodes.forEach(traverse);
42
+ }
43
+ }
44
+ };
45
+ el.childNodes.forEach(traverse);
46
+ // Replace multiple trailing spaces or newlines if browser duplicates them
47
+ return text;
48
+ };
49
+
50
+ // Helper to parse plain text into HTML containing rich chips
51
+ const parseTextToHtml = (text: string, users: MentionUser[]): string => {
52
+ if (!text) return '';
53
+ let escaped = text
54
+ .replace(/&/g, '&amp;')
55
+ .replace(/</g, '&lt;')
56
+ .replace(/>/g, '&gt;');
57
+
58
+ escaped = escaped.replace(/\n/g, '<br>');
59
+
60
+ return escaped.replace(/@([a-zA-Z0-9_]+)/g, (match, username) => {
61
+ const user = users.find((u) => u.username.toLowerCase() === username.toLowerCase());
62
+ if (user) {
63
+ const avatarHtml = user.avatarUrl
64
+ ? `<img src="${user.avatarUrl}" class="w-4 h-4 rounded-full object-cover flex-shrink-0 select-none" />`
65
+ : `<div class="w-4 h-4 rounded-full bg-accent/30 text-accent flex items-center justify-center font-bold text-[9px] flex-shrink-0 select-none">${user.username.slice(0, 2).toUpperCase()}</div>`;
66
+
67
+ return `<span contenteditable="false" data-mention="true" data-username="${user.username}" class="inline-flex items-center gap-1.5 bg-accent/15 border border-accent/25 pl-1 pr-2 py-0.5 rounded-full text-xs font-bold text-accent mx-0.5 align-middle select-none">${avatarHtml}<span>@${user.username}</span></span>`;
68
+ }
69
+ return match;
70
+ });
71
+ };
72
+
73
+ export const MentionInput: React.FC<MentionInputProps> = ({
74
+ value,
75
+ onChange,
76
+ suggestions,
77
+ onSearch,
78
+ placeholder = 'Escribe algo, usa @ para mencionar...',
79
+ label,
80
+ disabled = false,
81
+ className = '',
82
+ as = 'input'
83
+ }) => {
84
+ const [showDropdown, setShowDropdown] = useState(false);
85
+ const [activeSuggestionIdx, setActiveSuggestionIdx] = useState(0);
86
+ const [searchQuery, setSearchQuery] = useState('');
87
+ const editorRef = useRef<HTMLDivElement>(null);
88
+ const dropdownRef = useRef<HTMLUListElement>(null);
89
+
90
+ // Cache suggestions to match usernames that might be filtered out of suggestions prop later
91
+ const knownUsersRef = useRef<MentionUser[]>(suggestions);
92
+
93
+ useEffect(() => {
94
+ suggestions.forEach((user) => {
95
+ if (!knownUsersRef.current.some((u) => u.id === user.id)) {
96
+ knownUsersRef.current.push(user);
97
+ }
98
+ });
99
+ }, [suggestions]);
100
+
101
+ // Sync state to innerHTML only when value changes from outside (not from user input)
102
+ useEffect(() => {
103
+ if (editorRef.current) {
104
+ const currentText = getEditorText(editorRef.current);
105
+ // Normalize non-breaking spaces before comparison
106
+ const normalizedCurrent = currentText.replace(/\u00A0/g, ' ');
107
+ const normalizedValue = value.replace(/\u00A0/g, ' ');
108
+
109
+ if (normalizedCurrent !== normalizedValue) {
110
+ editorRef.current.innerHTML = parseTextToHtml(value, knownUsersRef.current);
111
+ }
112
+ }
113
+ }, [value]);
114
+
115
+ const checkTrigger = (text: string, cursorIndex: number) => {
116
+ const textBeforeCursor = text.slice(0, cursorIndex);
117
+ const lastAtIdx = textBeforeCursor.lastIndexOf('@');
118
+
119
+ if (lastAtIdx === -1) {
120
+ setShowDropdown(false);
121
+ return;
122
+ }
123
+
124
+ const charBeforeAt = lastAtIdx > 0 ? textBeforeCursor[lastAtIdx - 1] : ' ';
125
+ const hasSpaceBefore = /\s/.test(charBeforeAt) || charBeforeAt === '\u00A0';
126
+
127
+ const textAfterAt = textBeforeCursor.slice(lastAtIdx + 1);
128
+ const hasSpaceAfter = /\s/.test(textAfterAt) || textAfterAt.includes('\u00A0');
129
+
130
+ if (hasSpaceBefore && !hasSpaceAfter) {
131
+ setShowDropdown(true);
132
+ setSearchQuery(textAfterAt);
133
+ onSearch(textAfterAt);
134
+ setActiveSuggestionIdx(0);
135
+ } else {
136
+ setShowDropdown(false);
137
+ }
138
+ };
139
+
140
+ const handleInput = () => {
141
+ if (!editorRef.current) return;
142
+ const text = getEditorText(editorRef.current);
143
+ onChange(text);
144
+
145
+ const selection = window.getSelection();
146
+ if (selection && selection.rangeCount > 0) {
147
+ const anchorNode = selection.anchorNode;
148
+ if (anchorNode && editorRef.current.contains(anchorNode)) {
149
+ if (anchorNode.nodeType === Node.TEXT_NODE) {
150
+ checkTrigger(anchorNode.textContent || '', selection.anchorOffset);
151
+ } else {
152
+ setShowDropdown(false);
153
+ }
154
+ } else {
155
+ setShowDropdown(false);
156
+ }
157
+ } else {
158
+ setShowDropdown(false);
159
+ }
160
+ };
161
+
162
+ const handleKeyUp = (e: React.KeyboardEvent<HTMLDivElement>) => {
163
+ if (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Enter') return;
164
+
165
+ const selection = window.getSelection();
166
+ if (selection && selection.rangeCount > 0) {
167
+ const anchorNode = selection.anchorNode;
168
+ if (anchorNode && editorRef.current?.contains(anchorNode)) {
169
+ if (anchorNode.nodeType === Node.TEXT_NODE) {
170
+ checkTrigger(anchorNode.textContent || '', selection.anchorOffset);
171
+ }
172
+ }
173
+ }
174
+ };
175
+
176
+ const handleSelectUser = (user: MentionUser) => {
177
+ if (!editorRef.current) return;
178
+
179
+ const selection = window.getSelection();
180
+ if (selection && selection.rangeCount > 0) {
181
+ const anchorNode = selection.anchorNode;
182
+
183
+ // Ensure the selection is actually inside our editor container!
184
+ if (anchorNode && editorRef.current.contains(anchorNode)) {
185
+ // Use native Selection API to extend the selection backward exactly the length of the trigger
186
+ // This is the most robust way to traverse across complex contentEditable DOM structures
187
+ const charsToDelete = searchQuery.length + 1; // +1 for the '@'
188
+
189
+ for (let i = 0; i < charsToDelete; i++) {
190
+ selection.modify('extend', 'backward', 'character');
191
+ }
192
+
193
+ // Get the newly extended range and delete its contents
194
+ const rangeToReplace = selection.getRangeAt(0);
195
+ rangeToReplace.deleteContents();
196
+
197
+ // Use the current range (which is now collapsed at the deletion point) to insert the chip
198
+ const range = selection.getRangeAt(0);
199
+ const chip = document.createElement('span');
200
+ chip.contentEditable = 'false';
201
+ chip.setAttribute('data-mention', 'true');
202
+ chip.setAttribute('data-username', user.username);
203
+ chip.className = 'inline-flex items-center gap-1.5 bg-accent/15 border border-accent/25 pl-1 pr-2 py-0.5 rounded-full text-xs font-bold text-accent mx-0.5 align-middle select-none';
204
+
205
+ if (user.avatarUrl) {
206
+ const img = document.createElement('img');
207
+ img.src = user.avatarUrl;
208
+ img.className = 'w-4 h-4 rounded-full object-cover flex-shrink-0 select-none';
209
+ chip.appendChild(img);
210
+ } else {
211
+ const fallback = document.createElement('div');
212
+ fallback.className = 'w-4 h-4 rounded-full bg-accent/30 text-accent flex items-center justify-center font-bold text-[9px] flex-shrink-0 select-none';
213
+ fallback.textContent = user.username.slice(0, 2).toUpperCase();
214
+ chip.appendChild(fallback);
215
+ }
216
+
217
+ const labelSpan = document.createElement('span');
218
+ labelSpan.textContent = `@${user.username}`;
219
+ chip.appendChild(labelSpan);
220
+
221
+ // Create non-breaking space text node
222
+ const space = document.createTextNode('\u00A0');
223
+ // Insert space first
224
+ range.insertNode(space);
225
+ // Insert chip second (places the chip before the space)
226
+ range.insertNode(chip);
227
+
228
+ // Move cursor after the space
229
+ range.setStartAfter(space);
230
+ range.setEndAfter(space);
231
+ selection.removeAllRanges();
232
+ selection.addRange(range);
233
+ } else {
234
+ // Fallback: Append chip to the end of editor container
235
+ const chip = document.createElement('span');
236
+ chip.contentEditable = 'false';
237
+ chip.setAttribute('data-mention', 'true');
238
+ chip.setAttribute('data-username', user.username);
239
+ chip.className = 'inline-flex items-center gap-1.5 bg-accent/15 border border-accent/25 pl-1 pr-2 py-0.5 rounded-full text-xs font-bold text-accent mx-0.5 align-middle select-none';
240
+
241
+ if (user.avatarUrl) {
242
+ const img = document.createElement('img');
243
+ img.src = user.avatarUrl;
244
+ img.className = 'w-4 h-4 rounded-full object-cover flex-shrink-0 select-none';
245
+ chip.appendChild(img);
246
+ } else {
247
+ const fallback = document.createElement('div');
248
+ fallback.className = 'w-4 h-4 rounded-full bg-accent/30 text-accent flex items-center justify-center font-bold text-[9px] flex-shrink-0 select-none';
249
+ fallback.textContent = user.username.slice(0, 2).toUpperCase();
250
+ chip.appendChild(fallback);
251
+ }
252
+
253
+ const labelSpan = document.createElement('span');
254
+ labelSpan.textContent = `@${user.username}`;
255
+ chip.appendChild(labelSpan);
256
+
257
+ editorRef.current.appendChild(chip);
258
+
259
+ // Append non-breaking space
260
+ const space = document.createTextNode('\u00A0');
261
+ editorRef.current.appendChild(space);
262
+
263
+ // Move cursor after space
264
+ const newRange = document.createRange();
265
+ newRange.setStartAfter(space);
266
+ newRange.setEndAfter(space);
267
+ selection.removeAllRanges();
268
+ selection.addRange(newRange);
269
+ }
270
+
271
+ // Trigger onChange with new plain text content
272
+ editorRef.current.focus();
273
+ const updatedText = getEditorText(editorRef.current);
274
+ onChange(updatedText);
275
+ }
276
+
277
+ setShowDropdown(false);
278
+ };
279
+
280
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
281
+ if (showDropdown && suggestions.length > 0) {
282
+ if (e.key === 'ArrowDown') {
283
+ e.preventDefault();
284
+ setActiveSuggestionIdx((prev) => (prev + 1) % suggestions.length);
285
+ } else if (e.key === 'ArrowUp') {
286
+ e.preventDefault();
287
+ setActiveSuggestionIdx((prev) => (prev - 1 + suggestions.length) % suggestions.length);
288
+ } else if (e.key === 'Enter') {
289
+ e.preventDefault();
290
+ handleSelectUser(suggestions[activeSuggestionIdx]);
291
+ } else if (e.key === 'Escape') {
292
+ e.preventDefault();
293
+ setShowDropdown(false);
294
+ }
295
+ } else {
296
+ if (e.key === 'Enter' && as === 'input') {
297
+ e.preventDefault(); // Prevent carriage return in input mode
298
+ }
299
+ }
300
+ };
301
+
302
+ // Close dropdown on click outside
303
+ useEffect(() => {
304
+ const handleClickOutside = (e: MouseEvent) => {
305
+ if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
306
+ setShowDropdown(false);
307
+ }
308
+ };
309
+ document.addEventListener('mousedown', handleClickOutside);
310
+ return () => document.removeEventListener('mousedown', handleClickOutside);
311
+ }, []);
312
+
313
+ return (
314
+ <div className={`relative w-full flex flex-col gap-1.5 ${className}`}>
315
+ {label && (
316
+ <span className="text-xs font-bold text-text-muted uppercase tracking-wider px-1">
317
+ {label}
318
+ </span>
319
+ )}
320
+
321
+ {/* Input container wrapper */}
322
+ <div className="relative rounded-xl overflow-visible transition-all duration-300">
323
+
324
+ {/* Glow border ring */}
325
+ <div className="absolute -inset-[1px] bg-gradient-to-r from-accent/50 to-pink-500/50 rounded-xl pointer-events-none z-0 blur-[1px] opacity-20" />
326
+
327
+ <div className={`relative bg-bg-card/60 border border-border-app rounded-xl z-10 flex transition-all duration-300 ${
328
+ disabled ? 'opacity-40 cursor-not-allowed select-none bg-bg-app/10' : ''
329
+ } ${as === 'textarea' ? 'p-1' : 'p-0.5'}`}>
330
+ <div
331
+ ref={editorRef}
332
+ contentEditable={!disabled}
333
+ onInput={handleInput}
334
+ onKeyUp={handleKeyUp}
335
+ onKeyDown={handleKeyDown}
336
+ className={`w-full bg-transparent p-3 text-sm text-text-main focus:outline-hidden outline-hidden ${
337
+ disabled ? 'cursor-not-allowed' : 'cursor-text'
338
+ } ${
339
+ as === 'textarea'
340
+ ? 'resize-y min-h-[100px] overflow-y-auto max-h-60'
341
+ : 'whitespace-nowrap overflow-x-auto overflow-y-hidden min-h-[44px] flex items-center'
342
+ }`}
343
+ style={{
344
+ outline: 'none',
345
+ boxShadow: 'none'
346
+ }}
347
+ />
348
+ </div>
349
+
350
+ {/* Floating placeholder */}
351
+ {(!value || value.length === 0) && (
352
+ <div
353
+ onClick={() => {
354
+ if (!disabled && editorRef.current) editorRef.current.focus();
355
+ }}
356
+ className={`absolute left-4 pointer-events-none text-sm text-text-muted/50 select-none z-15 ${
357
+ as === 'textarea' ? 'top-4' : 'top-1/2 -translate-y-1/2'
358
+ }`}
359
+ >
360
+ {placeholder}
361
+ </div>
362
+ )}
363
+
364
+ {/* Suggestion list popover */}
365
+ <AnimatePresence>
366
+ {showDropdown && suggestions.length > 0 && (
367
+ <motion.ul
368
+ ref={dropdownRef}
369
+ initial={{ opacity: 0, y: 10, scale: 0.95 }}
370
+ animate={{ opacity: 1, y: 4, scale: 1 }}
371
+ exit={{ opacity: 0, y: 10, scale: 0.95 }}
372
+ className="absolute left-0 mt-1 w-64 glass rounded-xl shadow-2xl border border-border-app/50 py-1.5 z-50 max-h-48 overflow-y-auto"
373
+ >
374
+ {suggestions.map((user, idx) => {
375
+ const isActive = idx === activeSuggestionIdx;
376
+ return (
377
+ <li
378
+ key={user.id}
379
+ onMouseDown={(e) => {
380
+ e.preventDefault();
381
+ handleSelectUser(user);
382
+ }}
383
+ className={`px-3.5 py-2 text-xs flex items-center gap-3 transition-colors duration-200 cursor-pointer ${
384
+ isActive
385
+ ? 'bg-accent/15 text-accent font-semibold'
386
+ : 'text-text-main hover:bg-bg-app'
387
+ }`}
388
+ >
389
+ {user.avatarUrl ? (
390
+ <img
391
+ src={user.avatarUrl}
392
+ alt={user.name}
393
+ className="w-6 h-6 rounded-full object-cover border border-border-app/50"
394
+ />
395
+ ) : (
396
+ <div className="w-6 h-6 rounded-full bg-accent/20 text-accent flex items-center justify-center font-bold text-[10px]">
397
+ {user.username.slice(0, 2).toUpperCase()}
398
+ </div>
399
+ )}
400
+ <div className="flex flex-col">
401
+ <span className="font-bold">@{user.username}</span>
402
+ <span className="text-[10px] text-text-muted">{user.name}</span>
403
+ </div>
404
+ </li>
405
+ );
406
+ })}
407
+ </motion.ul>
408
+ )}
409
+ </AnimatePresence>
410
+ </div>
411
+ </div>
412
+ );
413
+ };
@@ -0,0 +1,86 @@
1
+ import React, { useEffect } from 'react';
2
+ import { motion, useMotionValue, useSpring } from 'framer-motion';
3
+
4
+ export interface MorphingSwitchProps {
5
+ checked: boolean;
6
+ onChange: (checked: boolean) => void;
7
+ className?: string;
8
+ disabled?: boolean;
9
+ isInvalid?: boolean;
10
+ }
11
+
12
+ export const MorphingSwitch: React.FC<MorphingSwitchProps> = ({
13
+ checked,
14
+ onChange,
15
+ className = '',
16
+ disabled = false,
17
+ isInvalid = false
18
+ }) => {
19
+ const isMounted = React.useRef(false);
20
+ // Motion values for custom width control to simulate morphing
21
+ const knobWidth = useMotionValue(24);
22
+ const knobX = useMotionValue(checked ? 24 : 0);
23
+
24
+ // Springs for smooth, physics-based movement
25
+ const springKnobX = useSpring(knobX, { stiffness: 600, damping: 35 });
26
+ const springKnobWidth = useSpring(knobWidth, { stiffness: 400, damping: 20 });
27
+
28
+ // Update target positions on checked status changes
29
+ useEffect(() => {
30
+ knobX.set(checked ? 24 : 0);
31
+
32
+ if (!isMounted.current) {
33
+ isMounted.current = true;
34
+ return;
35
+ }
36
+
37
+ // Stretch Knob
38
+ knobWidth.set(36);
39
+
40
+ // Settle back to normal width after a tiny delay
41
+ const timer = setTimeout(() => {
42
+ knobWidth.set(24);
43
+ }, 150);
44
+
45
+ return () => clearTimeout(timer);
46
+ }, [checked, knobX, knobWidth]);
47
+
48
+ const handleToggle = () => {
49
+ if (disabled) return;
50
+ onChange(!checked);
51
+ };
52
+
53
+ const shadowColor = isInvalid
54
+ ? '0 0 12px var(--color-error)'
55
+ : (checked && !disabled ? '0 0 12px var(--color-accent)' : 'none');
56
+
57
+ const getBgClass = () => {
58
+ if (isInvalid) {
59
+ return checked ? 'bg-error border border-error/50' : 'bg-error/15 border border-error/50';
60
+ }
61
+ return checked ? 'bg-accent border-transparent' : 'bg-border-app border-transparent';
62
+ };
63
+
64
+ return (
65
+ <button
66
+ onClick={handleToggle}
67
+ disabled={disabled}
68
+ className={`relative h-8 w-14 rounded-full p-1 border transition-colors duration-300 focus:outline-hidden ${getBgClass()} ${
69
+ disabled ? 'opacity-40 cursor-not-allowed select-none' : 'cursor-pointer'
70
+ } ${className}`}
71
+ style={{ boxShadow: shadowColor }}
72
+ role="switch"
73
+ aria-checked={checked}
74
+ >
75
+ <motion.div
76
+ style={{
77
+ x: springKnobX,
78
+ width: springKnobWidth,
79
+ }}
80
+ className="h-5.5 rounded-full bg-white shadow-md origin-left"
81
+ // Also add a little press-down squeeze effect on hover active
82
+ whileTap={disabled ? undefined : { scaleY: 0.9, scaleX: 1.15 }}
83
+ />
84
+ </button>
85
+ );
86
+ };