{ "$schema": "https://blode.co/ui/schema/registry-item.json", "name": "input-message", "title": "Input Message", "author": "Matthew Blode", "description": "A chat composer with an auto-resizing textarea, action slots, a send button, and drag-and-drop attachments.", "dependencies": ["motion", "react-textarea-autosize"], "registryDependencies": ["button", "file-thumbnail"], "files": [ { "path": "ui/input-message.tsx", "content": "\"use client\";\n\nimport { ArrowUpIcon, CrossSmallIcon } from \"blode-icons-react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport * as React from \"react\";\nimport TextareaAutosize from \"react-textarea-autosize\";\n\nimport { cn } from \"@/lib/utils\";\nimport { Button } from \"@/components/ui/button\";\nimport { FileThumbnail } from \"@/components/ui/file-thumbnail\";\n\nconst DEFAULT_ACCEPT = \"image/png,image/jpeg,application/pdf\";\n\ninterface InputMessageSlotContext {\n /** Opens the native file picker. Pass `acceptOverride` (e.g. `\"image/*\"`) to\n * scope the picker to a subset of the accept types for this invocation. */\n openFilePicker: (acceptOverride?: string) => void;\n /** Currently-attached files (controlled). */\n files: File[];\n}\n\ntype InputMessageSlot = React.ReactNode | ((ctx: InputMessageSlotContext) => React.ReactNode);\n\ninterface InputMessageProps extends Omit, \"onChange\"> {\n /** Controlled textarea value. */\n value: string;\n /** Called with the new value on every textarea change. */\n onValueChange: (value: string) => void;\n /** Fired on submit (Enter or send button) with the trimmed value + files. */\n onSend?: (value: string, files: File[]) => void;\n /** Placeholder shown when empty. Swaps to a drop hint while dragging files. */\n placeholder?: string;\n /** Bottom-left action area. May be a render fn receiving `{ openFilePicker, files }`. */\n leftSlot?: InputMessageSlot;\n /** Bottom-right action area, before the built-in send button. Same render-fn shape. */\n rightSlot?: InputMessageSlot;\n /** Disables the textarea, send button, and drag-and-drop. */\n disabled?: boolean;\n /** Minimum visible rows before the textarea grows. Defaults to 1. */\n minRows?: number;\n /** Maximum visible rows before the textarea scrolls. Defaults to 8. */\n maxRows?: number;\n /** When false, clicking the container won't refocus the textarea. */\n clickToFocus?: boolean;\n /** Accessible label for the send button. */\n sendLabel?: string;\n /** Controlled attached files. When undefined, attachment behavior is disabled. */\n files?: File[];\n /** Called when files are added (drag-drop or picker) or removed. */\n onFilesChange?: (files: File[]) => void;\n /** Accepted MIME types as a comma-separated string. Defaults to PNG / JPEG / PDF. */\n accept?: string;\n /** Maximum number of files. Extra files beyond the limit are dropped. */\n maxFiles?: number;\n /** Side of each preview tile in pixels. Defaults to 80. */\n filePreviewSize?: number;\n /** Extra props forwarded to the underlying textarea. */\n textareaProps?: Omit<\n React.ComponentProps<\"textarea\">,\n \"value\" | \"onChange\" | \"onKeyDown\" | \"disabled\" | \"placeholder\" | \"rows\" | \"style\"\n >;\n}\n\n// ─── File preview tile ──────────────────────────────────────────────────────\ninterface FilePreviewTileProps {\n file: File;\n onRemove: () => void;\n size: number;\n}\n\nconst FilePreviewTile = ({ file, onRemove, size }: FilePreviewTileProps) => (\n \n \n {\n e.stopPropagation();\n onRemove();\n }}\n type=\"button\"\n >\n \n \n \n);\n\nconst renderSlot = (slot: InputMessageSlot, ctx: InputMessageSlotContext) =>\n typeof slot === \"function\" ? slot(ctx) : slot;\n\nconst allowsMultipleFiles = (maxFiles?: number) => maxFiles === undefined || maxFiles > 1;\n\n// ─── InputMessage ───────────────────────────────────────────────────────────\nconst InputMessage = ({\n value,\n onValueChange,\n onSend,\n placeholder = \"Ask me anything…\",\n leftSlot,\n rightSlot,\n disabled,\n minRows = 1,\n maxRows = 8,\n clickToFocus = true,\n sendLabel = \"Send\",\n files,\n onFilesChange,\n accept = DEFAULT_ACCEPT,\n maxFiles,\n filePreviewSize = 80,\n textareaProps,\n className,\n ref,\n ...props\n}: InputMessageProps) => {\n const textareaRef = React.useRef(null);\n const fileInputId = React.useId();\n const [dragOver, setDragOver] = React.useState(false);\n\n const filesArr = React.useMemo(() => files ?? [], [files]);\n const supportsFiles = onFilesChange !== undefined;\n\n const trimmed = value.trim();\n const canSend = !disabled && (trimmed.length > 0 || filesArr.length > 0);\n\n const handleSend = React.useCallback(() => {\n if (!canSend) {\n return;\n }\n onSend?.(trimmed, filesArr);\n }, [canSend, onSend, trimmed, filesArr]);\n\n const handleKeyDown = React.useCallback(\n (e: React.KeyboardEvent) => {\n if (e.nativeEvent.isComposing) {\n return;\n }\n if (e.key === \"Enter\" && !e.shiftKey) {\n e.preventDefault();\n handleSend();\n }\n },\n [handleSend],\n );\n\n const handleContainerMouseDown = React.useCallback(\n (e: React.MouseEvent) => {\n if (!clickToFocus || disabled) {\n return;\n }\n const target = e.target as HTMLElement;\n if (target === textareaRef.current) {\n return;\n }\n if (\n target.closest('button, a, input, select, textarea, [contenteditable], [role=\"button\"]')\n ) {\n return;\n }\n e.preventDefault();\n textareaRef.current?.focus();\n },\n [clickToFocus, disabled],\n );\n\n // ── File helpers ──────────────────────────────────────────────────────────\n const acceptTokens = React.useMemo(\n () =>\n accept\n .split(\",\")\n .map((s) => s.trim())\n .filter(Boolean),\n [accept],\n );\n\n const matchesAccept = React.useCallback(\n (file: File) =>\n acceptTokens.some((token) => {\n if (token.endsWith(\"/*\")) {\n return file.type.startsWith(token.slice(0, -1));\n }\n if (token.startsWith(\".\")) {\n return file.name.toLowerCase().endsWith(token.toLowerCase());\n }\n return file.type === token;\n }),\n [acceptTokens],\n );\n\n const addFiles = React.useCallback(\n (incoming: File[]) => {\n if (!onFilesChange) {\n return;\n }\n // name + size + lastModified is a unique-enough identity to dedupe\n // \"dropped the same file twice\" without colliding distinct files.\n const fingerprint = (f: File) => `${f.name}-${f.size}-${f.lastModified}`;\n const existing = new Set(filesArr.map(fingerprint));\n const accepted: File[] = [];\n for (const f of incoming) {\n if (!matchesAccept(f)) {\n continue;\n }\n const fp = fingerprint(f);\n if (existing.has(fp)) {\n continue;\n }\n existing.add(fp);\n accepted.push(f);\n }\n if (accepted.length === 0) {\n return;\n }\n const next = [...filesArr, ...accepted];\n onFilesChange(maxFiles === undefined ? next : next.slice(0, maxFiles));\n },\n [onFilesChange, filesArr, matchesAccept, maxFiles],\n );\n\n const removeFile = React.useCallback(\n (idx: number) => {\n onFilesChange?.(filesArr.filter((_, i) => i !== idx));\n },\n [onFilesChange, filesArr],\n );\n\n const openFilePicker = React.useCallback(\n (overrideAccept?: string) => {\n const selector = `#${CSS.escape(fileInputId)}`;\n const el = document.querySelector(selector);\n if (!el) {\n return;\n }\n if (overrideAccept) {\n el.accept = overrideAccept;\n el.click();\n queueMicrotask(() => {\n const current = document.querySelector(selector);\n if (current) {\n current.accept = accept;\n }\n });\n return;\n }\n el.click();\n },\n [accept, fileInputId],\n );\n\n // ── Slot rendering ────────────────────────────────────────────────────────\n const slotCtx = React.useMemo(\n () => ({ files: filesArr, openFilePicker }),\n [openFilePicker, filesArr],\n );\n const leftContent = renderSlot(leftSlot, slotCtx);\n const rightContent = renderSlot(rightSlot, slotCtx);\n\n // ── Drag-and-drop ─────────────────────────────────────────────────────────\n const handleDragOver = React.useCallback(\n (e: React.DragEvent) => {\n if (!supportsFiles || disabled) {\n return;\n }\n if (![...e.dataTransfer.types].includes(\"Files\")) {\n return;\n }\n e.preventDefault();\n e.dataTransfer.dropEffect = \"copy\";\n setDragOver(true);\n },\n [supportsFiles, disabled],\n );\n\n const handleDragLeave = React.useCallback((e: React.DragEvent) => {\n const wrapper = e.currentTarget;\n const next = e.relatedTarget as Node | null;\n if (next && wrapper.contains(next)) {\n return;\n }\n setDragOver(false);\n }, []);\n\n const handleDrop = React.useCallback(\n (e: React.DragEvent) => {\n e.preventDefault();\n setDragOver(false);\n if (!supportsFiles || disabled) {\n return;\n }\n addFiles([...e.dataTransfer.files]);\n },\n [supportsFiles, disabled, addFiles],\n );\n\n const handleFileInputChange = React.useCallback(\n (e: React.ChangeEvent) => {\n if (!e.target.files) {\n return;\n }\n addFiles([...e.target.files]);\n e.target.value = \"\";\n },\n [addFiles],\n );\n\n return (\n \n {supportsFiles && (\n \n )}\n\n \n {filesArr.length > 0 && (\n \n
\n \n {filesArr.map((file, i) => (\n removeFile(i)}\n size={filePreviewSize}\n />\n ))}\n \n
\n \n )}\n
\n\n onValueChange(e.target.value)}\n onKeyDown={handleKeyDown}\n placeholder={dragOver && supportsFiles ? \"Drop files here to add to chat\" : placeholder}\n ref={textareaRef}\n value={value}\n {...textareaProps}\n />\n\n
\n
{leftContent}
\n
\n {rightContent}\n \n \n \n
\n
\n \n );\n};\n\nexport { InputMessage };\nexport type { InputMessageProps, InputMessageSlotContext };\n", "type": "registry:ui", "target": "" } ], "type": "registry:ui" }