{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "input-message",
  "title": "Input Message",
  "description": "An auto-growing chat composer with drag-and-drop file attachments, image/PDF preview tiles, and Enter-to-send.",
  "dependencies": [
    "motion",
    "pdfjs-dist"
  ],
  "files": [
    {
      "path": "components/ui/input-message.tsx",
      "type": "registry:ui",
      "content": "'use client'\n\nimport {\n  forwardRef,\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type ChangeEvent,\n  type DragEvent as ReactDragEvent,\n  type HTMLAttributes,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type MouseEvent as ReactMouseEvent,\n  type ReactNode,\n  type TextareaHTMLAttributes,\n} from 'react'\nimport { AnimatePresence, motion, useReducedMotion } from 'motion/react'\nimport { cn } from '@/lib/utils'\nimport { springs } from '@/lib/motion-tokens'\n\nconst useIsoLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect\n\nconst DEFAULT_ACCEPT = 'image/png,image/jpeg,application/pdf'\n\nfunction ArrowUpIcon({ className }: { className?: string }) {\n  return (\n    <svg\n      width={16}\n      height={16}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={2}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden=\"true\"\n      className={className}\n    >\n      <path d=\"M12 19V5\" />\n      <path d=\"M5 12l7-7 7 7\" />\n    </svg>\n  )\n}\n\nfunction XIcon({ className }: { className?: string }) {\n  return (\n    <svg\n      width={12}\n      height={12}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={2.5}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden=\"true\"\n      className={className}\n    >\n      <path d=\"M18 6L6 18\" />\n      <path d=\"M6 6l12 12\" />\n    </svg>\n  )\n}\n\nfunction FileIcon({ className }: { className?: string }) {\n  return (\n    <svg\n      width={20}\n      height={20}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={1.5}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden=\"true\"\n      className={className}\n    >\n      <path d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z\" />\n      <path d=\"M14 2v6h6\" />\n    </svg>\n  )\n}\n\ninterface InputMessageSlotContext {\n  openFilePicker: (acceptOverride?: string) => void\n  files: File[]\n}\n\ntype InputMessageSlot = ReactNode | ((ctx: InputMessageSlotContext) => ReactNode)\n\nexport interface InputMessageProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {\n  value: string\n  onValueChange: (value: string) => void\n  onSend?: (value: string, files: File[]) => void\n  placeholder?: string\n  leftSlot?: InputMessageSlot\n  rightSlot?: InputMessageSlot\n  disabled?: boolean\n  minRows?: number\n  maxRows?: number\n  clickToFocus?: boolean\n  sendLabel?: string\n  files?: File[]\n  onFilesChange?: (files: File[]) => void\n  accept?: string\n  maxFiles?: number\n  filePreviewSize?: number\n  textareaLabel?: string\n  removeLabel?: string\n  textareaProps?: Omit<\n    TextareaHTMLAttributes<HTMLTextAreaElement>,\n    'value' | 'onChange' | 'onKeyDown' | 'disabled' | 'placeholder'\n  >\n}\n\ntype PdfjsModule = typeof import('pdfjs-dist')\nlet pdfjsPromise: Promise<PdfjsModule> | null = null\n\nasync function loadPdfjs(): Promise<PdfjsModule> {\n  if (!pdfjsPromise) {\n    pdfjsPromise = import('pdfjs-dist').then((mod) => {\n      if (!mod.GlobalWorkerOptions.workerSrc) {\n        mod.GlobalWorkerOptions.workerSrc = `https://cdn.jsdelivr.net/npm/pdfjs-dist@${mod.version}/build/pdf.worker.min.mjs`\n      }\n      return mod\n    })\n  }\n  return pdfjsPromise\n}\n\nasync function renderPdfFirstPage(file: File, targetWidth: number): Promise<string> {\n  const pdfjs = await loadPdfjs()\n  const buffer = await file.arrayBuffer()\n  const pdf = await pdfjs.getDocument({ data: buffer }).promise\n  const page = await pdf.getPage(1)\n  const baseViewport = page.getViewport({ scale: 1 })\n  const scale = (targetWidth * 2) / baseViewport.width\n  const viewport = page.getViewport({ scale })\n  const canvas = document.createElement('canvas')\n  canvas.width = viewport.width\n  canvas.height = viewport.height\n  await page.render({ canvas, viewport }).promise\n  return canvas.toDataURL('image/png')\n}\n\ninterface FilePreviewTileProps {\n  file: File\n  onRemove: () => void\n  size: number\n  removeLabel: string\n}\n\nfunction FilePreviewTile({ file, onRemove, size, removeLabel }: FilePreviewTileProps) {\n  const reduceMotion = useReducedMotion()\n  const isImage = file.type.startsWith('image/')\n  const isPdf = file.type === 'application/pdf'\n  const imageUrl = useMemo(() => (isImage ? URL.createObjectURL(file) : null), [isImage, file])\n  useEffect(() => {\n    if (!imageUrl) return\n    return () => URL.revokeObjectURL(imageUrl)\n  }, [imageUrl])\n  const [pdfUrl, setPdfUrl] = useState<string | null>(null)\n  useEffect(() => {\n    if (!isPdf) return\n    let cancelled = false\n    renderPdfFirstPage(file, size)\n      .then((url) => {\n        if (!cancelled) setPdfUrl(url)\n      })\n      .catch(() => {})\n    return () => {\n      cancelled = true\n    }\n  }, [file, isPdf, size])\n  const previewUrl = imageUrl ?? pdfUrl\n  const showsIcon = !previewUrl && !isImage && !isPdf\n  return (\n    <motion.div\n      layout={!reduceMotion}\n      initial={{ opacity: 0, scale: 0.9 }}\n      animate={{ opacity: 1, scale: 1 }}\n      exit={{ opacity: 0, scale: 0.9, transition: { duration: 0.06 } }}\n      transition={reduceMotion ? { duration: 0 } : springs.press}\n      className=\"relative shrink-0 overflow-hidden rounded-lg squircle-corners bg-muted border border-border cursor-default group/tile\"\n      style={{ width: size, height: size }}\n    >\n      {previewUrl ? (\n        // eslint-disable-next-line @next/next/no-img-element\n        <img\n          src={previewUrl}\n          alt={file.name}\n          className=\"absolute inset-0 w-full h-full object-cover\"\n        />\n      ) : showsIcon ? (\n        <div className=\"absolute inset-0 flex items-center justify-center text-muted-foreground\">\n          <FileIcon />\n        </div>\n      ) : (\n        <div className=\"absolute inset-0 flex items-center justify-center\">\n          <div\n            className=\"w-6 h-6 rounded-full border-2 border-border border-t-muted-foreground animate-spin motion-reduce:animate-none\"\n            aria-label=\"Loading preview\"\n            role=\"status\"\n          />\n        </div>\n      )}\n      <button\n        type=\"button\"\n        onClick={(e) => {\n          e.stopPropagation()\n          onRemove()\n        }}\n        onPointerDown={(e) => e.preventDefault()}\n        aria-label={`${removeLabel} ${file.name}`}\n        className=\"absolute top-1 inset-e-1 w-5 h-5 rounded-full bg-foreground text-background opacity-0 group-hover/tile:opacity-100 transition-opacity duration-(--motion-dur-fast) motion-reduce:transition-none flex items-center justify-center cursor-pointer outline-none focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-(--color-accent)\"\n      >\n        <XIcon />\n      </button>\n    </motion.div>\n  )\n}\n\nexport const InputMessage = forwardRef<HTMLDivElement, InputMessageProps>(\n  (\n    {\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      textareaLabel = 'Message',\n      removeLabel = 'Remove',\n      textareaProps,\n      className,\n      ...props\n    },\n    ref,\n  ) => {\n    const reduceMotion = useReducedMotion()\n    const textareaRef = useRef<HTMLTextAreaElement>(null)\n    const fileInputRef = useRef<HTMLInputElement>(null)\n    const [focusVisible, setFocusVisible] = useState(false)\n    const [dragOver, setDragOver] = useState(false)\n\n    const filesArr = useMemo(() => files ?? [], [files])\n    const supportsFiles = onFilesChange !== undefined\n\n    useIsoLayoutEffect(() => {\n      const el = textareaRef.current\n      if (!el) return\n      el.style.height = 'auto'\n      const computed = getComputedStyle(el)\n      const lineHeight = parseFloat(computed.lineHeight)\n      if (Number.isNaN(lineHeight)) return\n      const min = lineHeight * minRows\n      const max = lineHeight * maxRows\n      const next = Math.min(Math.max(el.scrollHeight, min), max)\n      el.style.height = `${next}px`\n      el.style.overflowY = el.scrollHeight > max ? 'auto' : 'hidden'\n    }, [value, minRows, maxRows])\n\n    const trimmed = value.trim()\n    const canSend = !disabled && (trimmed.length > 0 || filesArr.length > 0)\n\n    const handleSend = useCallback(() => {\n      if (!canSend) return\n      onSend?.(trimmed, filesArr)\n    }, [canSend, onSend, trimmed, filesArr])\n\n    const handleKeyDown = useCallback(\n      (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {\n        if (e.nativeEvent.isComposing) return\n        if (e.key === 'Enter' && !e.shiftKey) {\n          e.preventDefault()\n          handleSend()\n        }\n      },\n      [handleSend],\n    )\n\n    const handleContainerMouseDown = useCallback(\n      (e: ReactMouseEvent<HTMLDivElement>) => {\n        if (!clickToFocus || disabled) return\n        const target = e.target as HTMLElement\n        if (target === textareaRef.current) return\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    const acceptTokens = useMemo(\n      () =>\n        accept\n          .split(',')\n          .map((s) => s.trim())\n          .filter(Boolean),\n      [accept],\n    )\n\n    const matchesAccept = useCallback(\n      (file: File) =>\n        acceptTokens.some((token) => {\n          if (token.endsWith('/*')) return file.type.startsWith(token.slice(0, -1))\n          if (token.startsWith('.')) return file.name.toLowerCase().endsWith(token.toLowerCase())\n          return file.type === token\n        }),\n      [acceptTokens],\n    )\n\n    const addFiles = useCallback(\n      (incoming: File[]) => {\n        if (!onFilesChange) return\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)) continue\n          const fp = fingerprint(f)\n          if (existing.has(fp)) continue\n          existing.add(fp)\n          accepted.push(f)\n        }\n        if (!accepted.length) return\n        const next = [...filesArr, ...accepted]\n        onFilesChange(maxFiles != null ? next.slice(0, maxFiles) : next)\n      },\n      [onFilesChange, filesArr, matchesAccept, maxFiles],\n    )\n\n    const removeFile = useCallback(\n      (idx: number) => {\n        if (!onFilesChange) return\n        onFilesChange(filesArr.filter((_, i) => i !== idx))\n      },\n      [onFilesChange, filesArr],\n    )\n\n    const openFilePicker = useCallback(\n      (overrideAccept?: string) => {\n        const el = fileInputRef.current\n        if (!el) return\n        if (overrideAccept) {\n          el.accept = overrideAccept\n          el.click()\n          queueMicrotask(() => {\n            if (fileInputRef.current) fileInputRef.current.accept = accept\n          })\n          return\n        }\n        el.click()\n      },\n      [accept],\n    )\n\n    const slotCtx = useMemo<InputMessageSlotContext>(\n      () => ({ openFilePicker, files: filesArr }),\n      [openFilePicker, filesArr],\n    )\n    // eslint-disable-next-line react-hooks/refs -- ref read is deferred to the consumer's own event handler, not executed during this render\n    const leftContent = typeof leftSlot === 'function' ? leftSlot(slotCtx) : leftSlot\n    // eslint-disable-next-line react-hooks/refs -- see leftContent above\n    const rightContent = typeof rightSlot === 'function' ? rightSlot(slotCtx) : rightSlot\n\n    const handleDragOver = useCallback(\n      (e: ReactDragEvent<HTMLDivElement>) => {\n        if (!supportsFiles || disabled) return\n        if (!Array.from(e.dataTransfer.types).includes('Files')) return\n        e.preventDefault()\n        e.dataTransfer.dropEffect = 'copy'\n        setDragOver(true)\n      },\n      [supportsFiles, disabled],\n    )\n\n    const handleDragLeave = useCallback((e: ReactDragEvent<HTMLDivElement>) => {\n      const wrapper = e.currentTarget\n      const next = e.relatedTarget as Node | null\n      if (next && wrapper.contains(next)) return\n      setDragOver(false)\n    }, [])\n\n    const handleDrop = useCallback(\n      (e: ReactDragEvent<HTMLDivElement>) => {\n        e.preventDefault()\n        setDragOver(false)\n        if (!supportsFiles || disabled) return\n        addFiles(Array.from(e.dataTransfer.files))\n      },\n      [supportsFiles, disabled, addFiles],\n    )\n\n    const handleFileInputChange = useCallback(\n      (e: ChangeEvent<HTMLInputElement>) => {\n        if (!e.target.files) return\n        addFiles(Array.from(e.target.files))\n        e.target.value = ''\n      },\n      [addFiles],\n    )\n\n    return (\n      <div\n        ref={ref}\n        onMouseDown={handleContainerMouseDown}\n        onDragOver={handleDragOver}\n        onDragLeave={handleDragLeave}\n        onDrop={handleDrop}\n        className={cn(\n          'flex flex-col gap-1 p-2 rounded-xl squircle-corners bg-card border border-transparent shadow-sm',\n          'transition-colors duration-(--motion-dur-fast) motion-reduce:transition-none',\n          clickToFocus && !disabled && 'cursor-text',\n          dragOver && 'border-(--color-accent)',\n          !dragOver && focusVisible && 'border-foreground/20',\n          !dragOver && !focusVisible && clickToFocus && !disabled && 'hover:border-border',\n          disabled && 'opacity-50 pointer-events-none',\n          className,\n        )}\n        {...props}\n      >\n        {supportsFiles && (\n          <input\n            ref={fileInputRef}\n            type=\"file\"\n            accept={accept}\n            multiple={maxFiles == null || maxFiles > 1}\n            className=\"hidden\"\n            onChange={handleFileInputChange}\n            aria-hidden=\"true\"\n            tabIndex={-1}\n          />\n        )}\n\n        <AnimatePresence initial={false}>\n          {filesArr.length > 0 && (\n            <motion.div\n              key=\"preview-row\"\n              initial={{ height: 0, opacity: 0 }}\n              animate={{ height: 'auto', opacity: 1 }}\n              exit={{ height: 0, opacity: 0 }}\n              transition={reduceMotion ? { duration: 0 } : { ...springs.settle, bounce: 0 }}\n              className=\"overflow-hidden\"\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                      key={`${file.name}-${file.size}-${file.lastModified}`}\n                      file={file}\n                      onRemove={() => removeFile(i)}\n                      size={filePreviewSize}\n                      removeLabel={removeLabel}\n                    />\n                  ))}\n                </AnimatePresence>\n              </div>\n            </motion.div>\n          )}\n        </AnimatePresence>\n\n        <textarea\n          ref={textareaRef}\n          value={value}\n          onChange={(e) => onValueChange(e.target.value)}\n          onKeyDown={handleKeyDown}\n          onFocus={(e) => {\n            if (e.target.matches(':focus-visible')) setFocusVisible(true)\n          }}\n          onBlur={() => setFocusVisible(false)}\n          placeholder={dragOver && supportsFiles ? 'Drop files here to add to chat' : placeholder}\n          disabled={disabled}\n          rows={minRows}\n          aria-label={textareaProps?.['aria-label'] ?? textareaLabel}\n          className=\"w-full resize-none bg-transparent outline-none text-[14px] font-normal text-foreground placeholder:text-muted-foreground px-2 py-2\"\n          {...textareaProps}\n        />\n        <div className=\"flex items-center justify-between gap-2\">\n          <div className=\"flex items-center gap-1.5 min-w-0\">{leftContent}</div>\n          <div className=\"flex items-center gap-1.5 shrink-0\">\n            {rightContent}\n            <button\n              type=\"button\"\n              onClick={handleSend}\n              onPointerDown={(e) => e.preventDefault()}\n              disabled={!canSend}\n              aria-label={sendLabel}\n              className={cn(\n                'flex items-center justify-center size-8 rounded-full squircle bg-foreground text-background cursor-pointer outline-none',\n                'transition-[opacity,transform] duration-(--motion-dur-fast) motion-reduce:transition-none',\n                'active:scale-95 motion-reduce:active:scale-100 disabled:opacity-40 disabled:pointer-events-none',\n                'focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 focus-visible:ring-offset-background',\n              )}\n            >\n              <ArrowUpIcon />\n            </button>\n          </div>\n        </div>\n      </div>\n    )\n  },\n)\n\nInputMessage.displayName = 'InputMessage'\n\nexport function InputMessagePreview() {\n  const [value, setValue] = useState('')\n  return (\n    <div className=\"flex h-full w-full min-h-50 items-center justify-center px-6 pt-6 pb-24 sm:px-8 sm:pt-8 sm:pb-28\">\n      <div className=\"w-full max-w-2xl\">\n        <InputMessage\n          value={value}\n          onValueChange={setValue}\n          onSend={() => setValue('')}\n          placeholder=\"Ask me anything…\"\n          minRows={2}\n          maxRows={6}\n        />\n      </div>\n    </div>\n  )\n}\n"
    },
    {
      "path": "lib/motion-tokens.ts",
      "type": "registry:lib",
      "content": "export const durations = {\n  instant: '50ms',\n  fast: '120ms',\n  base: '200ms',\n  slow: '320ms',\n} as const\n\nexport const easings = {\n  out: 'cubic-bezier(0.22, 1, 0.36, 1)',\n  inOut: 'cubic-bezier(0.65, 0, 0.35, 1)',\n} as const\n\nexport const springs = {\n  fast: { type: 'spring', stiffness: 400, damping: 30, mass: 0.8 },\n  press: { type: 'spring', stiffness: 700, damping: 32, mass: 1 },\n  moderate: { type: 'spring', stiffness: 300, damping: 24, mass: 1 },\n  settle: { type: 'spring', stiffness: 260, damping: 26, mass: 1 },\n} as const\n"
    }
  ],
  "type": "registry:ui"
}
