{
  "$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<React.ComponentProps<\"div\">, \"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  <motion.div\n    animate={{ opacity: 1, scale: 1 }}\n    className=\"group/tile relative shrink-0 cursor-default\"\n    exit={{ opacity: 0, scale: 0.9, transition: { duration: 0.06 } }}\n    initial={{ opacity: 0, scale: 0.9 }}\n    layout\n    transition={{ bounce: 0, duration: 0.08, type: \"spring\" }}\n  >\n    <FileThumbnail file={file} size={size} />\n    <button\n      aria-label={`Remove ${file.name}`}\n      className=\"absolute top-1 right-1 flex size-5 items-center justify-center rounded-full bg-primary text-primary-foreground opacity-0 outline-none transition-opacity duration-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring/50 group-hover/tile:opacity-100\"\n      onClick={(e) => {\n        e.stopPropagation();\n        onRemove();\n      }}\n      type=\"button\"\n    >\n      <CrossSmallIcon className=\"size-3\" />\n    </button>\n  </motion.div>\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<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {\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<HTMLDivElement>) => {\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<HTMLInputElement>(selector);\n      if (!el) {\n        return;\n      }\n      if (overrideAccept) {\n        el.accept = overrideAccept;\n        el.click();\n        queueMicrotask(() => {\n          const current = document.querySelector<HTMLInputElement>(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<InputMessageSlotContext>(\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<HTMLDivElement>) => {\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<HTMLDivElement>) => {\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<HTMLDivElement>) => {\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<HTMLInputElement>) => {\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    <div\n      className={cn(\n        \"flex flex-col gap-1 rounded-[var(--field-radius)] border bg-surface p-2 shadow-input transition-colors\",\n        dragOver ? \"border-ring ring-2 ring-ring/20\" : \"border-input focus-within:border-ring\",\n        clickToFocus && !disabled && \"cursor-text\",\n        disabled && \"pointer-events-none opacity-50\",\n        className,\n      )}\n      data-slot=\"input-message\"\n      onDragLeave={handleDragLeave}\n      onDragOver={handleDragOver}\n      onDrop={handleDrop}\n      onMouseDown={handleContainerMouseDown}\n      ref={ref}\n      role=\"presentation\"\n      {...props}\n    >\n      {supportsFiles && (\n        <input\n          accept={accept}\n          aria-hidden=\"true\"\n          className=\"hidden\"\n          id={fileInputId}\n          multiple={allowsMultipleFiles(maxFiles)}\n          onChange={handleFileInputChange}\n          tabIndex={-1}\n          type=\"file\"\n        />\n      )}\n\n      <AnimatePresence initial={false}>\n        {filesArr.length > 0 && (\n          <motion.div\n            animate={{ height: \"auto\", opacity: 1 }}\n            className=\"overflow-hidden\"\n            exit={{ height: 0, opacity: 0 }}\n            initial={{ height: 0, opacity: 0 }}\n            key=\"preview-row\"\n            transition={{ bounce: 0, duration: 0.16, type: \"spring\" }}\n          >\n            <div className=\"flex flex-wrap gap-2 pb-1\">\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                {filesArr.map((file, i) => (\n                  <FilePreviewTile\n                    file={file}\n                    key={`${file.name}-${file.size}-${file.lastModified}`}\n                    onRemove={() => removeFile(i)}\n                    size={filePreviewSize}\n                  />\n                ))}\n              </AnimatePresence>\n            </div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n\n      <TextareaAutosize\n        aria-label={textareaProps?.[\"aria-label\"] ?? \"Message\"}\n        className=\"w-full resize-none bg-transparent px-2 py-2 text-foreground text-sm outline-none placeholder:text-muted-foreground\"\n        disabled={disabled}\n        maxRows={maxRows}\n        minRows={minRows}\n        onChange={(e) => 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      <div className=\"flex items-center justify-between gap-2\">\n        <div className=\"flex min-w-0 items-center gap-1.5\">{leftContent}</div>\n        <div className=\"flex shrink-0 items-center gap-1.5\">\n          {rightContent}\n          <Button\n            aria-label={sendLabel}\n            disabled={!canSend}\n            onClick={handleSend}\n            size=\"icon-sm\"\n            type=\"button\"\n          >\n            <ArrowUpIcon />\n          </Button>\n        </div>\n      </div>\n    </div>\n  );\n};\n\nexport { InputMessage };\nexport type { InputMessageProps, InputMessageSlotContext };\n",
      "type": "registry:ui",
      "target": ""
    }
  ],
  "type": "registry:ui"
}
