alexui 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -0
- package/components/ActionTable.tsx +307 -0
- package/components/AlertBanner.tsx +124 -0
- package/components/AnimatedAccordion.tsx +95 -0
- package/components/Autocomplete.tsx +144 -0
- package/components/Avatar.tsx +123 -0
- package/components/Badge.tsx +80 -0
- package/components/Breadcrumb.tsx +74 -0
- package/components/Calendar.tsx +340 -0
- package/components/Card3D.tsx +117 -0
- package/components/Carousel3D.tsx +193 -0
- package/components/CascadeSelect.tsx +232 -0
- package/components/ChartShowcase.tsx +700 -0
- package/components/Checkbox.tsx +212 -0
- package/components/ChipsInput.tsx +152 -0
- package/components/CircularKnob.tsx +240 -0
- package/components/CodeVisualizer.tsx +67 -0
- package/components/Collapsible.tsx +72 -0
- package/components/ColorThemeManager.tsx +458 -0
- package/components/CommandMenu.tsx +191 -0
- package/components/ConfirmDialog.tsx +152 -0
- package/components/ContextMenu.tsx +192 -0
- package/components/DashboardLayout.tsx +115 -0
- package/components/DatePicker.tsx +108 -0
- package/components/Divider.tsx +67 -0
- package/components/Dock.tsx +93 -0
- package/components/DragDropLists.tsx +160 -0
- package/components/Drawer.tsx +161 -0
- package/components/DropdownPlus.tsx +304 -0
- package/components/EmptyState.tsx +49 -0
- package/components/ErrorPage.tsx +62 -0
- package/components/FileDropzone.tsx +206 -0
- package/components/ForgotPassword.tsx +137 -0
- package/components/FormField.tsx +81 -0
- package/components/GlassButton.tsx +56 -0
- package/components/GlassCard.tsx +82 -0
- package/components/GlassInput.tsx +96 -0
- package/components/GlassmorphicModal.tsx +108 -0
- package/components/GlowInput.tsx +111 -0
- package/components/GlowSelect.tsx +203 -0
- package/components/GlowTextArea.tsx +105 -0
- package/components/HorizontalTimeline.tsx +121 -0
- package/components/HoverCard.tsx +105 -0
- package/components/ImageLightbox.tsx +259 -0
- package/components/InputGroup.tsx +118 -0
- package/components/InputOTP.tsx +147 -0
- package/components/InteractiveNavbar.tsx +266 -0
- package/components/InteractiveSidebar.tsx +211 -0
- package/components/Kbd.tsx +51 -0
- package/components/LiteYouTube.tsx +118 -0
- package/components/LoaderCollection.tsx +368 -0
- package/components/LoginForm.tsx +192 -0
- package/components/MagneticButton.tsx +101 -0
- package/components/MaskedInput.tsx +79 -0
- package/components/MentionInput.tsx +413 -0
- package/components/MorphingSwitch.tsx +86 -0
- package/components/MultiSelect.tsx +158 -0
- package/components/NumberInput.tsx +203 -0
- package/components/Panel.tsx +104 -0
- package/components/PasswordInput.tsx +203 -0
- package/components/Popover.tsx +91 -0
- package/components/PricingTable.tsx +113 -0
- package/components/ProgressBar.tsx +152 -0
- package/components/RadioButton.tsx +211 -0
- package/components/Rating.tsx +82 -0
- package/components/ResizablePanel.tsx +114 -0
- package/components/ScrollPanel.tsx +103 -0
- package/components/SettingsPage.tsx +154 -0
- package/components/SignupForm.tsx +182 -0
- package/components/Skeleton.tsx +41 -0
- package/components/Slider.tsx +95 -0
- package/components/SlidingTabs.tsx +54 -0
- package/components/SortableList.tsx +91 -0
- package/components/SpeedDial.tsx +134 -0
- package/components/Spinner.tsx +40 -0
- package/components/Stepper.tsx +124 -0
- package/components/TabMenu.tsx +72 -0
- package/components/TableControls.tsx +77 -0
- package/components/TablePagination.tsx +88 -0
- package/components/TextEditor.tsx +329 -0
- package/components/TextReveal.tsx +99 -0
- package/components/ThemeSwitcher.tsx +133 -0
- package/components/TimelineGSAP.tsx +164 -0
- package/components/ToastSystem.tsx +110 -0
- package/components/ToggleButton.tsx +79 -0
- package/components/Tooltip.tsx +121 -0
- package/components/Tree.tsx +138 -0
- package/dist/commands/add.d.ts +7 -0
- package/dist/commands/add.js +110 -0
- package/dist/commands/init.d.ts +5 -0
- package/dist/commands/init.js +76 -0
- package/dist/commands/list.d.ts +1 -0
- package/dist/commands/list.js +32 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +60 -0
- package/dist/registry.d.ts +6 -0
- package/dist/registry.js +38 -0
- package/dist/tui/browse.d.ts +3 -0
- package/dist/tui/browse.js +139 -0
- package/dist/tui/format.d.ts +11 -0
- package/dist/tui/format.js +52 -0
- package/dist/tui/main.d.ts +1 -0
- package/dist/tui/main.js +86 -0
- package/dist/tui/panels.d.ts +9 -0
- package/dist/tui/panels.js +50 -0
- package/dist/tui/theme.d.ts +28 -0
- package/dist/tui/theme.js +76 -0
- package/dist/types.d.ts +28 -0
- package/dist/types.js +1 -0
- package/dist/utils/config.d.ts +6 -0
- package/dist/utils/config.js +24 -0
- package/dist/utils/copy.d.ts +9 -0
- package/dist/utils/copy.js +43 -0
- package/dist/utils/cwd.d.ts +6 -0
- package/dist/utils/cwd.js +30 -0
- package/dist/utils/deps.d.ts +1 -0
- package/dist/utils/deps.js +19 -0
- package/dist/utils/project.d.ts +5 -0
- package/dist/utils/project.js +30 -0
- package/dist/utils/theme.d.ts +1 -0
- package/dist/utils/theme.js +24 -0
- package/package.json +52 -0
- package/registry.json +1133 -0
- package/templates/theme.css +81 -0
|
@@ -0,0 +1,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, '&')
|
|
55
|
+
.replace(/</g, '<')
|
|
56
|
+
.replace(/>/g, '>');
|
|
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
|
+
};
|