{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"prompt-input-attachments","type":"registry:ui","title":"Prompt Input + Attachments","description":"A chat input that accepts file uploads — attachment chips with upload progress, type icons, and one-click removal.","author":"Scrim UI (https://scrimui.dev)","categories":["prompt-input"],"docs":"https://scrimui.dev/components/prompt-input-attachments","dependencies":[],"registryDependencies":[],"files":[{"path":"src/showcase/prompt-input-attachments/prompt-input-attachments.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\n\n/* ------------------------------------------------------------------ */\n/* Types                                                               */\n/* ------------------------------------------------------------------ */\n\nexport type PendingFile = {\n  id: string;\n  name: string;\n  size?: string;\n  type?: \"image\" | \"file\";\n  status?: \"uploading\" | \"done\" | \"error\";\n  progress?: number;\n};\n\nexport type PromptInputAttachmentsProps = {\n  files?: PendingFile[];\n  placeholder?: string;\n  disabled?: boolean;\n  onSubmit?: (value: string) => void;\n  onAttach?: () => void;\n  onRemove?: (id: string) => void;\n  onRetry?: (id: string) => void;\n  className?: string;\n};\n\n/* ------------------------------------------------------------------ */\n/* Icons                                                               */\n/* ------------------------------------------------------------------ */\n\nfunction PlusIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" width=\"16\" height=\"16\" {...props}>\n      <path d=\"M12 5v14M5 12h14\" />\n    </svg>\n  );\n}\n\nfunction ArrowUpIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" width=\"16\" height=\"16\" {...props}>\n      <path d=\"M12 19V5M5 12l7-7 7 7\" />\n    </svg>\n  );\n}\n\nfunction FileIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" width=\"14\" height=\"14\" {...props}>\n      <path d=\"M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2Z\" />\n      <path d=\"M14 2v6h6\" />\n    </svg>\n  );\n}\n\nfunction ImageIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" width=\"14\" height=\"14\" {...props}>\n      <rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\" />\n      <circle cx=\"9\" cy=\"9\" r=\"2\" />\n      <path d=\"m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21\" />\n    </svg>\n  );\n}\n\nfunction XIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" width=\"12\" height=\"12\" {...props}>\n      <path d=\"M18 6 6 18M6 6l12 12\" />\n    </svg>\n  );\n}\n\nfunction AlertIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" width=\"13\" height=\"13\" {...props}>\n      <circle cx=\"12\" cy=\"12\" r=\"10\" />\n      <path d=\"M12 8v4M12 16h.01\" />\n    </svg>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* File chip                                                           */\n/* ------------------------------------------------------------------ */\n\nfunction FileChip({\n  file,\n  onRemove,\n  onRetry,\n}: {\n  file: PendingFile;\n  onRemove?: (id: string) => void;\n  onRetry?: (id: string) => void;\n}) {\n  const status = file.status ?? \"done\";\n  return (\n    <span\n      className={`inline-flex max-w-full items-center gap-1.5 rounded-lg border py-1 pl-2 pr-1 text-xs ${\n        status === \"error\"\n          ? \"border-red-200 bg-red-50 text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-300\"\n          : \"border-zinc-200 bg-zinc-50 text-zinc-700 dark:border-zinc-800 dark:bg-zinc-800/60 dark:text-zinc-300\"\n      }`}\n    >\n      {file.type === \"image\" ? <ImageIcon /> : <FileIcon />}\n      <span className=\"max-w-36 truncate\">{file.name}</span>\n      {/* zinc-600 / red-700: an errored attachment row is drawn on red-50,\n          where zinc-500 is 4.41:1 and red-600 is 4.36:1 — both under AA. */}\n      {file.size && <span className=\"text-zinc-600 dark:text-zinc-400\">{file.size}</span>}\n\n      {status === \"uploading\" && file.progress !== undefined && (\n        <>\n          <span className=\"h-1 w-10 overflow-hidden rounded-full bg-zinc-200 dark:bg-zinc-700\">\n            <span\n              className=\"block h-full rounded-full bg-blue-500 transition-all\"\n              style={{ width: `${file.progress}%` }}\n            />\n          </span>\n          <span className=\"tabular-nums text-zinc-500 dark:text-zinc-400\">{file.progress}%</span>\n        </>\n      )}\n\n      {status === \"error\" ? (\n        <>\n          <AlertIcon />\n          {onRetry && (\n            <button\n              type=\"button\"\n              onClick={() => onRetry(file.id)}\n              className=\"rounded px-1.5 py-0.5 font-medium text-red-700 transition-colors hover:bg-red-100 dark:text-red-300 dark:hover:bg-red-900/40\"\n            >\n              Retry\n            </button>\n          )}\n        </>\n      ) : (\n        onRemove && (\n          <button\n            type=\"button\"\n            aria-label={`Remove ${file.name}`}\n            onClick={() => onRemove(file.id)}\n            className=\"rounded p-0.5 text-zinc-400 transition-colors hover:bg-zinc-200 hover:text-zinc-700 dark:hover:bg-zinc-700 dark:hover:text-zinc-200\"\n          >\n            <XIcon />\n          </button>\n        )\n      )}\n    </span>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* PromptInputAttachments                                              */\n/* ------------------------------------------------------------------ */\n\nexport function PromptInputAttachments({\n  files = [],\n  placeholder = \"Ask anything…\",\n  disabled = false,\n  onSubmit,\n  onAttach,\n  onRemove,\n  onRetry,\n  className = \"\",\n}: PromptInputAttachmentsProps) {\n  const [value, setValue] = React.useState(\"\");\n  const textareaRef = React.useRef<HTMLTextAreaElement>(null);\n  const canSubmit = value.trim().length > 0 && !disabled;\n\n  React.useEffect(() => {\n    const el = textareaRef.current;\n    if (!el) return;\n    el.style.height = \"0px\";\n    el.style.height = `${Math.min(el.scrollHeight, 160)}px`;\n  }, [value]);\n\n  function submit() {\n    if (!canSubmit) return;\n    onSubmit?.(value.trim());\n    setValue(\"\");\n  }\n\n  return (\n    <div className={`w-full ${className}`}>\n      <div\n        className={`rounded-2xl border bg-white shadow-sm transition-colors dark:bg-zinc-900 ${\n          disabled\n            ? \"border-zinc-200 opacity-60 dark:border-zinc-800\"\n            : \"border-zinc-200 focus-within:border-zinc-400 dark:border-zinc-800 dark:focus-within:border-zinc-600\"\n        }`}\n      >\n        {files.length > 0 && (\n          <div className=\"flex flex-wrap gap-2 px-3 pt-3\">\n            {files.map((f) => (\n              <FileChip key={f.id} file={f} onRemove={onRemove} onRetry={onRetry} />\n            ))}\n          </div>\n        )}\n\n        <textarea\n          ref={textareaRef}\n          rows={1}\n          value={value}\n          onChange={(e) => setValue(e.target.value)}\n          onKeyDown={(e) => {\n            if (e.key === \"Enter\" && !e.shiftKey) {\n              e.preventDefault();\n              submit();\n            }\n          }}\n          placeholder={placeholder}\n          disabled={disabled}\n          aria-label=\"Prompt\"\n          className=\"block w-full resize-none bg-transparent px-4 pt-3.5 pb-1.5 text-[15px] leading-6 text-zinc-900 outline-none placeholder:text-zinc-400 dark:text-zinc-100 dark:placeholder:text-zinc-500\"\n        />\n\n        <div className=\"flex items-center justify-between px-2.5 pb-2.5\">\n          <button\n            type=\"button\"\n            onClick={onAttach}\n            disabled={disabled}\n            aria-label=\"Add attachment\"\n            className=\"inline-flex h-8 w-8 items-center justify-center rounded-lg text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-900 disabled:pointer-events-none disabled:opacity-40 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100\"\n          >\n            <PlusIcon />\n          </button>\n          <span className=\"text-[11px] text-zinc-500 dark:text-zinc-400\">Enter to send · Shift+Enter for newline</span>\n          <button\n            type=\"button\"\n            onClick={submit}\n            disabled={!canSubmit}\n            aria-label=\"Send message\"\n            className=\"inline-flex h-8 w-8 items-center justify-center rounded-lg bg-zinc-900 text-white transition-opacity hover:opacity-80 disabled:opacity-30 dark:bg-zinc-100 dark:text-zinc-900\"\n          >\n            <ArrowUpIcon />\n          </button>\n        </div>\n      </div>\n    </div>\n  );\n}\n","type":"registry:ui","target":"components/ui/prompt-input-attachments.tsx"}]}