{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chat-attachment",
  "title": "Chat Attachment",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "File chips and image tiles for composers and messages, with type detection, sizes, upload progress, failed-upload retry, remove and open actions, and animated list changes.",
  "dependencies": [
    "motion@^12",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/chat-attachment.tsx",
      "content": "// Ballmac UI: Chat Attachment. https://ui.ballmac.com/components/chat-attachment\n\"use client\"\n\nimport * as React from \"react\"\nimport {\n  AlertCircle,\n  Archive,\n  AudioLines,\n  FileCode2,\n  FileSpreadsheet,\n  FileText,\n  Film,\n  Image as ImageIcon,\n  File as FileIcon,\n  RotateCw,\n  X,\n} from \"lucide-react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useMessages, defineMessage, type Message } from \"@/lib/ballmac/i18n\"\n\ntype AttachmentKind = \"image\" | \"pdf\" | \"code\" | \"sheet\" | \"doc\" | \"archive\" | \"audio\" | \"video\" | \"file\"\n\nconst KIND_META: Record<AttachmentKind, { label: Message; icon: React.ComponentType<{ className?: string }>; tone: string }> = {\n  image: { label: defineMessage(\"chat-attachment.KIND_META.image\", \"Image\"), icon: ImageIcon, tone: \"bg-chart-2/15 text-chart-2\" },\n  pdf: { label: defineMessage(\"chat-attachment.KIND_META.pdf\", \"PDF\"), icon: FileText, tone: \"bg-destructive/15 text-destructive\" },\n  code: { label: defineMessage(\"chat-attachment.KIND_META.code\", \"Code\"), icon: FileCode2, tone: \"bg-chart-1/15 text-chart-1\" },\n  sheet: { label: defineMessage(\"chat-attachment.KIND_META.sheet\", \"Sheet\"), icon: FileSpreadsheet, tone: \"bg-chart-2/15 text-chart-2\" },\n  doc: { label: defineMessage(\"chat-attachment.KIND_META.doc\", \"Doc\"), icon: FileText, tone: \"bg-chart-1/15 text-chart-1\" },\n  archive: { label: defineMessage(\"chat-attachment.KIND_META.archive\", \"Archive\"), icon: Archive, tone: \"bg-chart-3/15 text-chart-3\" },\n  audio: { label: defineMessage(\"chat-attachment.KIND_META.audio\", \"Audio\"), icon: AudioLines, tone: \"bg-chart-4/15 text-chart-4\" },\n  video: { label: defineMessage(\"chat-attachment.KIND_META.video\", \"Video\"), icon: Film, tone: \"bg-chart-5/15 text-chart-5\" },\n  file: { label: defineMessage(\"chat-attachment.KIND_META.file\", \"File\"), icon: FileIcon, tone: \"bg-muted text-muted-foreground\" },\n}\n\nconst EXT: Record<string, AttachmentKind> = {\n  png: \"image\", jpg: \"image\", jpeg: \"image\", gif: \"image\", webp: \"image\", svg: \"image\", avif: \"image\", heic: \"image\",\n  pdf: \"pdf\",\n  ts: \"code\", tsx: \"code\", js: \"code\", jsx: \"code\", py: \"code\", go: \"code\", rs: \"code\", json: \"code\", html: \"code\", css: \"code\", sh: \"code\", sql: \"code\", yml: \"code\", yaml: \"code\",\n  csv: \"sheet\", xls: \"sheet\", xlsx: \"sheet\", tsv: \"sheet\",\n  doc: \"doc\", docx: \"doc\", txt: \"doc\", md: \"doc\", rtf: \"doc\", pages: \"doc\",\n  zip: \"archive\", gz: \"archive\", tar: \"archive\", rar: \"archive\", \"7z\": \"archive\",\n  mp3: \"audio\", wav: \"audio\", m4a: \"audio\", ogg: \"audio\", flac: \"audio\",\n  mp4: \"video\", mov: \"video\", webm: \"video\", mkv: \"video\",\n}\n\n/** Works out the kind of file from its MIME type, then its extension. */\nfunction attachmentKind(name: string, type?: string): AttachmentKind {\n  if (type) {\n    if (type.startsWith(\"image/\")) return \"image\"\n    if (type.startsWith(\"audio/\")) return \"audio\"\n    if (type.startsWith(\"video/\")) return \"video\"\n    if (type === \"application/pdf\") return \"pdf\"\n  }\n  const ext = name.includes(\".\") ? name.split(\".\").pop()!.toLowerCase() : \"\"\n  return EXT[ext] ?? \"file\"\n}\n\n/** 1,536 → \"1.5 KB\". Uses 1024 steps and a fixed \".\" so server and browser agree. */\nfunction formatFileSize(bytes: number) {\n  if (!Number.isFinite(bytes) || bytes < 0) return \"\"\n  if (bytes < 1024) return `${Math.round(bytes)} B`\n  const units = [\"KB\", \"MB\", \"GB\", \"TB\"]\n  let value = bytes / 1024\n  let unit = 0\n  while (value >= 1024 && unit < units.length - 1) {\n    value /= 1024\n    unit++\n  }\n  return `${value >= 10 || Number.isInteger(value) ? Math.round(value) : value.toFixed(1)} ${units[unit]}`\n}\n\ntype ChatAttachmentProps = Omit<React.ComponentProps<\"div\">, \"onClick\" | \"children\"> & {\n  /** File name, including its extension. */\n  name: string\n  /** Size in bytes. */\n  size?: number\n  /** MIME type, such as `image/png`. Improves the icon and image detection. */\n  type?: string\n  /** Image URL (or object URL) shown as the thumbnail for images. */\n  previewUrl?: string\n  /** \"ready\" is normal, \"uploading\" shows progress, \"error\" shows the message and a retry button. */\n  status?: \"ready\" | \"uploading\" | \"error\"\n  /** Upload progress from 0 to 100. Leave out for an indeterminate bar. */\n  progress?: number\n  /** Shown in the error state. */\n  error?: string\n  /** \"chip\" is a compact row for the composer, \"tile\" is a square preview for messages and galleries. */\n  variant?: \"chip\" | \"tile\"\n  /** Adds a remove button. Its label is \"Remove <name>\". */\n  onRemove?: () => void\n  /** Adds a retry button in the error state. */\n  onRetry?: () => void\n  /** Makes the attachment a button, for example to open a viewer. */\n  onOpen?: () => void\n}\n\nfunction ChatAttachment({\n  name,\n  size,\n  type,\n  previewUrl,\n  status = \"ready\",\n  progress,\n  error = \"Upload failed\",\n  variant = \"chip\",\n  onRemove,\n  onRetry,\n  onOpen,\n  className,\n  ...props\n}: ChatAttachmentProps) {\n  const msg = useMessages()\n  const reduce = useReducedMotion()\n  const kind = attachmentKind(name, type)\n  const meta = KIND_META[kind]\n  const Icon = meta.icon\n  const isImage = kind === \"image\" && !!previewUrl\n  const uploading = status === \"uploading\"\n  const failed = status === \"error\"\n  const pct = progress === undefined ? undefined : Math.min(100, Math.max(0, Math.round(progress)))\n  const sizeText = size === undefined ? \"\" : formatFileSize(size)\n  const detail = failed ? error : uploading ? (pct === undefined ? msg(\"chat-attachment.uploadingEllipsis\", \"Uploading…\") : msg(\"chat-attachment.uploadingPercent\", \"Uploading {percent}%\", { percent: pct })) : [msg.of(meta.label), sizeText].filter(Boolean).join(\" · \")\n\n  const enter = {\n    layout: (reduce ? false : true) as boolean,\n    initial: reduce ? { opacity: 0 } : { opacity: 0, scale: 0.92 },\n    animate: { opacity: 1, scale: 1 },\n    exit: reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9 },\n    transition: { type: \"spring\", stiffness: 420, damping: 34 } as const,\n  }\n\n  const progressBar = uploading && (\n    <span\n      role=\"progressbar\"\n      aria-label={msg(\"chat-attachment.uploading\", \"Uploading {name}\", { name })}\n      aria-valuemin={0}\n      aria-valuemax={100}\n      aria-valuenow={pct}\n      className=\"absolute inset-x-0 bottom-0 h-0.5 overflow-hidden bg-foreground/10\"\n    >\n      <span\n        className={cn(\n          \"block h-full bg-foreground transition-[width] duration-200 motion-reduce:transition-none\",\n          pct === undefined && \"w-1/3 animate-pulse motion-reduce:animate-none\"\n        )}\n        style={pct === undefined ? undefined : { width: `${pct}%` }}\n      />\n    </span>\n  )\n\n  const removeButton = onRemove && (\n    <button\n      type=\"button\"\n      onClick={onRemove}\n      aria-label={msg(\"chat-attachment.remove\", \"Remove {name}\", { name })}\n      className={cn(\n        \"inline-flex shrink-0 items-center justify-center rounded-full text-muted-foreground outline-none transition-colors hover:bg-foreground hover:text-background focus-visible:ring-[3px] focus-visible:ring-ring/50\",\n        variant === \"chip\" ? \"size-6\" : \"absolute top-1.5 end-1.5 size-6 bg-background/90 text-foreground shadow-sm backdrop-blur\"\n      )}\n    >\n      <X aria-hidden=\"true\" className=\"size-3.5\" />\n    </button>\n  )\n\n  const retryButton = failed && onRetry && (\n    <button\n      type=\"button\"\n      onClick={onRetry}\n      aria-label={msg(\"chat-attachment.retryUploading\", \"Retry uploading {name}\", { name })}\n      className={cn(\n        \"inline-flex shrink-0 items-center gap-1 rounded-md text-xs font-medium text-foreground outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50\",\n        variant === \"chip\" ? \"h-6 px-1.5\" : \"h-7 px-2\"\n      )}\n    >\n      <RotateCw aria-hidden=\"true\" className=\"size-3\" />\n      {msg(\"chat-attachment.retry\", \"Retry\")}\n    </button>\n  )\n\n  const body = (\n    <>\n      {variant === \"chip\" ? (\n        <>\n          <span\n            aria-hidden=\"true\"\n            className={cn(\n              \"relative flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-lg\",\n              failed ? \"bg-destructive/15 text-destructive\" : isImage ? \"bg-muted\" : meta.tone\n            )}\n          >\n            {isImage && !failed ? (\n              // eslint-disable-next-line @next/next/no-img-element\n              <img src={previewUrl} alt=\"\" className=\"size-full object-cover\" />\n            ) : failed ? (\n              <AlertCircle className=\"size-4\" />\n            ) : (\n              <Icon className=\"size-4\" />\n            )}\n          </span>\n          <span className=\"grid min-w-0 flex-1 text-start\">\n            <span className=\"truncate text-[13px] leading-5 font-medium text-foreground\">{name}</span>\n            <span className=\"truncate text-xs leading-4 text-muted-foreground tabular-nums\">{detail}</span>\n          </span>\n        </>\n      ) : (\n        <>\n          {isImage && !failed ? (\n            // eslint-disable-next-line @next/next/no-img-element\n            <img src={previewUrl} alt=\"\" aria-hidden=\"true\" className=\"absolute inset-0 size-full object-cover\" />\n          ) : (\n            <span\n              aria-hidden=\"true\"\n              className={cn(\n                \"absolute inset-0 flex items-start p-3\",\n                failed ? \"bg-destructive/10 text-destructive\" : meta.tone\n              )}\n            >\n              {failed ? <AlertCircle className=\"size-6\" /> : <Icon className=\"size-6\" />}\n            </span>\n          )}\n          <span\n            className={cn(\n              \"absolute inset-x-0 bottom-0 grid gap-px px-2.5 pb-2 text-start\",\n              isImage && !failed ? \"bg-gradient-to-t from-black/70 via-black/45 to-transparent pt-7\" : \"pt-2\"\n            )}\n          >\n            <span className={cn(\"truncate text-xs leading-4 font-medium\", isImage && !failed ? \"text-white\" : \"text-foreground\")}>{name}</span>\n            <span className={cn(\"truncate text-[11px] leading-4 tabular-nums\", isImage && !failed ? \"text-white/80\" : \"text-foreground/80\")}>{detail}</span>\n          </span>\n        </>\n      )}\n    </>\n  )\n\n  const shell =\n    variant === \"chip\"\n      ? cn(\n          \"relative flex h-14 w-72 max-w-full items-center gap-2.5 overflow-hidden rounded-xl border bg-card py-2 pe-2 ps-2 text-card-foreground shadow-xs\",\n          failed && \"border-destructive/50\"\n        )\n      : cn(\n          \"group/tile relative aspect-square w-28 overflow-hidden rounded-xl border bg-card shadow-xs\",\n          failed && \"border-destructive/50\"\n        )\n\n  // The open action covers the tile; remove and retry stay siblings, so there are no nested buttons.\n  return (\n    <motion.div\n      data-slot=\"chat-attachment\"\n      data-variant={variant}\n      data-status={status}\n      role=\"listitem\"\n      className={cn(shell, className)}\n      {...enter}\n      {...(props as object)}\n    >\n      {onOpen && !failed && !uploading ? (\n        <button\n          type=\"button\"\n          onClick={onOpen}\n          aria-label={msg(\"chat-attachment.open\", \"Open {name}\", { name })}\n          className={cn(\n            \"absolute inset-0 z-0 flex items-center rounded-[inherit] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50\",\n            variant === \"chip\" ? \"gap-2.5 py-2 pe-9 ps-2 hover:bg-accent/50\" : \"\"\n          )}\n        >\n          {variant === \"chip\" ? body : <>{body}</>}\n        </button>\n      ) : (\n        <>{body}</>\n      )}\n      {progressBar}\n      {variant === \"chip\" ? (\n        <span className=\"relative z-10 ms-auto flex shrink-0 items-center gap-0.5\">\n          {retryButton}\n          {removeButton}\n        </span>\n      ) : (\n        <>\n          {removeButton && <span className=\"opacity-100 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-focus-within/tile:opacity-100 [@media(hover:hover)]:group-hover/tile:opacity-100\">{removeButton}</span>}\n          {retryButton && <span className=\"absolute top-1.5 start-1.5 rounded-md bg-background/90 backdrop-blur\">{retryButton}</span>}\n        </>\n      )}\n    </motion.div>\n  )\n}\n\ntype ChatAttachmentListProps = React.ComponentProps<\"div\"> & {\n  /** Accessible name of the list. */\n  label?: string\n}\n\n/** A wrapping row of attachments. Removing one animates the others into place. */\nfunction ChatAttachmentList({ label, className, children, ...props }: ChatAttachmentListProps) {\n  const msg = useMessages()\n  label ??= msg(\"chat-attachment.label\", \"Attachments\")\n  return (\n    <div\n      data-slot=\"chat-attachment-list\"\n      role=\"list\"\n      aria-label={label}\n      className={cn(\"flex flex-wrap gap-2\", className)}\n      {...props}\n    >\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {children}\n      </AnimatePresence>\n    </div>\n  )\n}\n\nexport {\n  ChatAttachment,\n  ChatAttachmentList,\n  attachmentKind,\n  formatFileSize,\n  type ChatAttachmentProps,\n  type ChatAttachmentListProps,\n  type AttachmentKind,\n}\n",
      "type": "registry:ui",
      "target": "@components/ballmac/chat-attachment.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "ai",
      "chat",
      "attachment",
      "file",
      "upload",
      "image"
    ],
    "version": "1.0.0",
    "updated": "2026-09-30",
    "ai": {
      "summary": "<ChatAttachmentList> holds <ChatAttachment name size type previewUrl status progress onRemove onRetry onOpen variant='chip'|'tile'>. Kind and icon come from the MIME type or extension. Also exports formatFileSize and attachmentKind.",
      "whenToUse": [
        "Files staged in a prompt input before sending",
        "Attachments shown inside a sent message"
      ],
      "whenNotToUse": [
        "A drag and drop upload area",
        "Generic file tables"
      ],
      "composesWith": [
        "prompt-input",
        "ai-message"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "Reaches open, retry and remove for each file"
        },
        {
          "keys": "Enter / Space",
          "action": "Runs the focused action"
        },
        {
          "keys": "Screen readers",
          "action": "List of attachments, buttons named 'Remove report.pdf', and a progressbar per upload"
        }
      ],
      "customization": [
        "variant: chip | tile",
        "status: ready | uploading | error",
        "progress 0 to 100",
        "error text"
      ]
    },
    "examples": [
      "chat-attachment-demo",
      "chat-attachment-tiles"
    ],
    "url": "https://ui.ballmac.com/components/chat-attachment"
  },
  "categories": [
    "ai"
  ],
  "type": "registry:ui"
}