aura-glass
Version:
A comprehensive glassmorphism design system for React applications with 142+ production-ready components
439 lines (436 loc) • 16.5 kB
JavaScript
'use client';
import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
import { cn } from '../../lib/utilsComprehensive.js';
import { Image, Video, Mic, File, X, Paperclip, Square, Bold, Italic, Code, Link, Smile, Plus, Send } from 'lucide-react';
import { useState, useRef, useEffect, useCallback } from 'react';
import '../../primitives/GlassCore.js';
import '../../primitives/glass/GlassAdvanced.js';
import '../../primitives/OptimizedGlassCore.js';
import '../../primitives/glass/OptimizedGlassAdvanced.js';
import '../../primitives/MotionNative.js';
import { MotionFramer } from '../../primitives/motion/MotionFramer.js';
import { GlassButton } from '../button/GlassButton.js';
import '../button/GlassFab.js';
import '../button/GlassMagneticButton.js';
import { CardContent } from '../card/index.js';
import { GlassCard } from '../card/GlassCard.js';
/**
* GlassChatInput component
* A rich chat input with attachments, voice recording, and formatting
*/
const GlassChatInput = ({
placeholder = "Type a message...",
enableAttachments = true,
enableVoice = false,
enableEmoji = true,
enableFormatting = false,
enableMentions = false,
maxLength,
maxFileSize = 10 * 1024 * 1024,
// 10MB
acceptedFileTypes = "image/*,video/*,audio/*,.pdf,.doc,.docx,.txt",
showCharCount = false,
autoResize = true,
disabled = false,
loading = false,
onSendMessage,
onTyping,
onAttachmentAdd,
onAttachmentRemove,
onVoiceRecording,
className,
...props
}) => {
const [message, setMessage] = useState("");
const [attachments, setAttachments] = useState([]);
const [isRecording, setIsRecording] = useState(false);
const [recordingTime, setRecordingTime] = useState(0);
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const [showFormatting, setShowFormatting] = useState(false);
const [isTyping, setIsTyping] = useState(false);
const textareaRef = useRef(null);
const fileInputRef = useRef(null);
const recordingIntervalRef = useRef();
const typingTimeoutRef = useRef();
// Auto-resize textarea
useEffect(() => {
if (autoResize && textareaRef.current) {
textareaRef.current.style.height = "auto";
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px`;
}
}, [message, autoResize]);
// Handle typing indicator
useEffect(() => {
if (!onTyping) return;
if (message.length > 0 && !isTyping) {
setIsTyping(true);
onTyping(true);
}
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
typingTimeoutRef.current = setTimeout(() => {
if (isTyping) {
setIsTyping(false);
onTyping(false);
}
}, 2000);
return () => {
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
};
}, [message, isTyping, onTyping]);
// Handle input change
const handleInputChange = useCallback(e => {
let value = e.target.value;
if (maxLength && value.length > maxLength) {
value = value.substring(0, maxLength);
}
setMessage(value);
}, [maxLength]);
// Handle key press
const handleKeyPress = useCallback(e => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
}, []);
// Handle send message
const handleSendMessage = useCallback(() => {
if (!message.trim() && attachments.length === 0) return;
if (disabled || loading) return;
onSendMessage?.(message.trim(), attachments);
// Reset state
setMessage("");
setAttachments([]);
setIsTyping(false);
onTyping?.(false);
// Reset textarea height
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
}
}, [message, attachments, disabled, loading, onSendMessage, onTyping]);
// Handle file attachment
const handleFileSelect = useCallback(() => {
fileInputRef.current?.click();
}, []);
const handleFileChange = useCallback(e => {
const files = Array.from(e.target.files || []);
files.forEach(file => {
if (file.size > maxFileSize) {
console.warn(`File ${file.name} is too large`);
return;
}
const attachment = {
id: `${Date.now()}-${Math.random()}`,
file,
type: file.type.startsWith("image/") ? "image" : file.type.startsWith("video/") ? "video" : file.type.startsWith("audio/") ? "audio" : "file",
size: file.size
};
// Create preview for images
if (attachment.type === "image") {
const reader = new FileReader();
reader.onload = () => {
attachment.preview = reader.result;
setAttachments(prev => [...prev, attachment]);
onAttachmentAdd?.(attachment);
};
reader.readAsDataURL(file);
} else {
setAttachments(prev => [...prev, attachment]);
onAttachmentAdd?.(attachment);
}
});
e.target.value = "";
}, [maxFileSize, onAttachmentAdd]);
// Remove attachment
const handleRemoveAttachment = useCallback(attachmentId => {
setAttachments(prev => prev.filter(att => att.id !== attachmentId));
onAttachmentRemove?.(attachmentId);
}, [onAttachmentRemove]);
// Handle voice recording
const handleVoiceToggle = useCallback(() => {
if (isRecording) {
// Stop recording
setIsRecording(false);
setRecordingTime(0);
if (recordingIntervalRef.current) {
clearInterval(recordingIntervalRef.current);
}
// Here you would typically get the recorded blob and call onVoiceRecording
// For now, we'll just simulate it
const mockBlob = new Blob(["mock audio data"], {
type: "audio/wav"
});
onVoiceRecording?.(mockBlob);
} else {
// Start recording
setIsRecording(true);
recordingIntervalRef.current = setInterval(() => {
setRecordingTime(prev => prev + 1);
}, 1000);
}
}, [isRecording, onVoiceRecording]);
// Format recording time
const formatRecordingTime = useCallback(seconds => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, "0")}`;
}, []);
// Handle emoji selection
const handleEmojiSelect = useCallback(emoji => {
setMessage(prev => prev + emoji);
setShowEmojiPicker(false);
}, []);
// Handle formatting
const handleFormatting = useCallback(format => {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = message.substring(start, end);
let formattedText = "";
switch (format) {
case "bold":
formattedText = `**${selectedText}**`;
break;
case "italic":
formattedText = `*${selectedText}*`;
break;
case "code":
formattedText = `\`${selectedText}\``;
break;
case "link":
formattedText = `[${selectedText}](url)`;
break;
case "quote":
formattedText = `> ${selectedText}`;
break;
case "list":
formattedText = `- ${selectedText}`;
break;
}
const newMessage = message.substring(0, start) + formattedText + message.substring(end);
setMessage(newMessage);
// Reset cursor position
setTimeout(() => {
textarea.focus();
textarea.setSelectionRange(start + formattedText.length, start + formattedText.length);
}, 0);
}, [message]);
// Get file type icon
const getFileIcon = useCallback(type => {
if (type.startsWith("image/")) return jsx(Image, {
className: 'w-4 h-4'
});
if (type.startsWith("video/")) return jsx(Video, {
className: 'w-4 h-4'
});
if (type.startsWith("audio/")) return jsx(Mic, {
className: 'w-4 h-4'
});
return jsx(File, {
className: 'w-4 h-4'
});
}, []);
// Format file size
const formatFileSize = useCallback(bytes => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}, []);
return jsx(MotionFramer, {
"data-glass-component": true,
preset: "fadeIn",
className: "glass-w-full",
children: jsx(GlassCard, {
className: cn("overflow-hidden", className),
...props,
children: jsxs(CardContent, {
className: "glass-p-4",
children: [attachments.length > 0 && jsx("div", {
className: 'mb-4 glass-gap-2',
role: "list",
"aria-label": "Attached files",
children: attachments.map(attachment => jsxs("div", {
role: "listitem",
className: "glass-flex glass-items-center glass-gap-3 glass-p-3 glass-surface-subtle/10 glass-radius-lg",
children: [jsx("div", {
className: "glass-flex-shrink-0",
children: attachment.preview ? jsx("img", {
src: attachment.preview,
alt: attachment.file.name,
className: 'w-10 h-10 glass-radius-md object-cover'
}) : jsx("div", {
className: 'w-10 h-10 glass-radius-md glass-surface-subtle/20 glass-flex glass-items-center glass-justify-center',
children: getFileIcon(attachment.file.type)
})
}), jsxs("div", {
className: "glass-flex-1 glass-min-w-0",
children: [jsx("p", {
className: 'text-primary glass-text-sm truncate',
children: attachment.file.name
}), jsx("p", {
className: 'text-primary/60 glass-text-xs',
children: formatFileSize(attachment.size)
})]
}), jsx(GlassButton, {
variant: "ghost",
size: "sm",
onClick: e => handleRemoveAttachment(attachment.id),
className: "glass-p-1",
children: jsx(X, {
className: 'w-4 h-4'
})
})]
}, attachment.id))
}), jsxs("div", {
className: "glass-flex glass-items-end glass-gap-3",
children: [enableAttachments && jsx(GlassButton, {
variant: "ghost",
size: "sm",
onClick: handleFileSelect,
disabled: disabled || loading,
"aria-label": "Attach file",
className: "glass-p-2 glass-flex-shrink-0",
children: jsx(Paperclip, {
className: 'w-4 h-4',
"aria-hidden": "true"
})
}), enableVoice && jsx(GlassButton, {
variant: isRecording ? "destructive" : "ghost",
size: "sm",
onClick: handleVoiceToggle,
disabled: disabled || loading,
"aria-label": isRecording ? `Stop recording (${formatRecordingTime(recordingTime)})` : "Start voice recording",
"aria-pressed": isRecording,
className: "glass-p-2 glass-flex-shrink-0",
children: isRecording ? jsxs(Fragment, {
children: [jsx(Square, {
className: 'w-4 h-4 glass-mr-2',
"aria-hidden": "true"
}), jsx("span", {
className: "glass-text-xs",
"aria-hidden": "true",
children: formatRecordingTime(recordingTime)
})]
}) : jsx(Mic, {
className: 'w-4 h-4',
"aria-hidden": "true"
})
}), enableFormatting && showFormatting && jsxs("div", {
className: 'glass-flex glass-gap-1 glass-p-2 glass-surface-subtle/10 glass-radius-lg mb-2',
children: [jsx(GlassButton, {
variant: "ghost",
size: "sm",
onClick: e => handleFormatting("bold"),
className: "glass-p-1",
children: jsx(Bold, {
className: 'w-3 h-3'
})
}), jsx(GlassButton, {
variant: "ghost",
size: "sm",
onClick: e => handleFormatting("italic"),
className: "glass-p-1",
children: jsx(Italic, {
className: 'w-3 h-3'
})
}), jsx(GlassButton, {
variant: "ghost",
size: "sm",
onClick: e => handleFormatting("code"),
className: "glass-p-1",
children: jsx(Code, {
className: 'w-3 h-3'
})
}), jsx(GlassButton, {
variant: "ghost",
size: "sm",
onClick: e => handleFormatting("link"),
className: "glass-p-1",
children: jsx(Link, {
className: 'w-3 h-3'
})
})]
}), jsxs("div", {
className: 'glass-flex-1 relative',
children: [jsx("textarea", {
ref: textareaRef,
id: "chat-message-input",
value: message,
onChange: handleInputChange,
onKeyPress: handleKeyPress,
placeholder: isRecording ? "Recording..." : placeholder,
disabled: disabled || loading || isRecording,
"aria-label": "Chat message",
"aria-describedby": showCharCount && maxLength ? "char-count" : undefined,
"aria-multiline": "true",
className: cn("w-full bg-glass-fill ring-1 ring-white/10 glass-radius-lg glass-px-4 glass-py-3 glass-text-primary placeholder-white/50", "focus:outline-none focus:ring-white/30 resize-none min-h-[44px] max-h-[120px]", "disabled:opacity-50 disabled:cursor-not-allowed"),
rows: 1
}), showCharCount && maxLength && jsxs("div", {
id: "char-count",
className: 'absolute bottom-2 right-3 glass-text-xs text-primary/50',
"aria-live": "polite",
children: [message.length, "/", maxLength]
})]
}), jsxs("div", {
className: "glass-flex glass-gap-1 glass-flex-shrink-0",
children: [enableEmoji && jsx(GlassButton, {
variant: "ghost",
size: "sm",
onClick: e => setShowEmojiPicker(!showEmojiPicker),
disabled: disabled || loading,
className: "glass-p-2",
children: jsx(Smile, {
className: 'w-4 h-4'
})
}), enableFormatting && jsx(GlassButton, {
variant: showFormatting ? "primary" : "ghost",
size: "sm",
onClick: e => setShowFormatting(!showFormatting),
disabled: disabled || loading,
className: "glass-p-2",
children: jsx(Plus, {
className: 'w-4 h-4'
})
}), jsx(GlassButton, {
variant: "primary",
size: "sm",
onClick: handleSendMessage,
disabled: !message.trim() && attachments.length === 0 || disabled || loading || isRecording,
className: "glass-p-2",
children: loading ? jsx("div", {
className: 'w-4 h-4 glass-border-2 glass-border-white/20 glass-border-t-white glass-radius-full animate-spin'
}) : jsx(Send, {
className: 'w-4 h-4'
})
})]
})]
}), showEmojiPicker && jsx("div", {
className: 'mt-3 glass-p-3 glass-surface-subtle/10 glass-radius-lg',
children: jsx("div", {
className: "glass-grid glass-grid-cols-8 glass-gap-2",
children: ["😀", "😂", "😊", "😍", "🤔", "😎", "👍", "👎", "❤️", "🔥", "⭐", "🎉", "🙌", "✨", "💯", "🚀"].map(emoji => jsx("button", {
onClick: e => handleEmojiSelect(emoji),
className: 'w-8 h-8 hover:glass-surface-subtle/20 glass-radius-md transition-colors glass-text-lg',
children: emoji
}, emoji))
})
}), jsx("input", {
ref: fileInputRef,
type: "file",
multiple: true,
onChange: handleFileChange,
className: 'hidden glass-touch-target glass-contrast-guard',
accept: acceptedFileTypes
})]
})
})
});
};
export { GlassChatInput, GlassChatInput as default };
//# sourceMappingURL=GlassChatInput.js.map