{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"attachments","title":"Attachments","description":"Composable file attachments for chat inputs and messages with preview, variants, and upload wiring","dependencies":["@radix-ui/react-slot","class-variance-authority","@hugeicons/react","@hugeicons/core-free-icons"],"files":[{"path":"components/nexus-ui/attachments.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { Slot } from \"@radix-ui/react-slot\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport {\n  Cancel01Icon,\n  Image02Icon,\n  File02Icon,\n  Video02Icon,\n  MusicNote02Icon,\n  Upload01Icon,\n} from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { useOnChange } from \"@/lib/use-on-change\";\n\nimport { cn } from \"@/lib/utils\";\n\n// ——— Metadata schema (single source) ———\n\n/** Upload or message attachment metadata. */\nexport interface AttachmentMeta {\n  type: \"image\" | \"file\" | \"video\" | \"audio\";\n  name?: string;\n  url?: string;\n  /** Raster preview URL (e.g. PDF first page). When unset, preview uses the icon for `type`. */\n  thumbnailUrl?: string;\n  mimeType?: string;\n  size?: number;\n  width?: number;\n  height?: number;\n  /** Binary payload when not using `url` (browser-friendly; prefer `Blob`). */\n  data?: Blob | ArrayBuffer;\n  /**\n   * **`\"paste\"`** when created from clipboard (e.g. long text → **`File`**). Use with **`Attachment`** **`variant=\"pasted\"`**.\n   */\n  source?: \"paste\";\n}\n\n/** Files not appended by the picker: oversized, over `maxFiles`, extra when `multiple` is false, or outside `accept`. */\nexport type AttachmentsRejectedFiles = {\n  tooLarge: File[];\n  /** Within size but did not fit under `maxFiles`. */\n  overMaxFiles: File[];\n  /** Ignored because `multiple` is false. */\n  truncatedByMultiple: File[];\n  /** Did not match the root `accept` string (drop path or permissive pickers). */\n  notAccepted: File[];\n};\n\nexport function toAttachmentMeta(\n  file: File,\n  options?: { objectUrl?: string; source?: AttachmentMeta[\"source\"] },\n): AttachmentMeta {\n  const mime = file.type?.toLowerCase() ?? \"\";\n  let kind: AttachmentMeta[\"type\"] = \"file\";\n  if (mime.startsWith(\"image/\")) kind = \"image\";\n  else if (mime.startsWith(\"video/\")) kind = \"video\";\n  else if (mime.startsWith(\"audio/\")) kind = \"audio\";\n\n  return {\n    type: kind,\n    name: file.name,\n    url: options?.objectUrl,\n    mimeType: file.type || undefined,\n    size: file.size,\n    ...(options?.source != null ? { source: options.source } : {}),\n  };\n}\n\n/** Optional second argument to **`appendFiles`** (from **`useAttachments`**). */\nexport type AppendFilesOptions = {\n  /** Sets **`AttachmentMeta.source`** to **`\"paste\"`** for every appended item. */\n  paste?: boolean;\n};\n\n/**\n * `File`s from a `DataTransfer` (paste **`clipboardData`** or drop **`dataTransfer`**).\n * Prefer **`items`** so pasted screenshots and copied images resolve reliably; falls back to **`files`**.\n */\nexport function filesFromDataTransfer(data: DataTransfer | null): File[] {\n  if (!data) return [];\n  const out: File[] = [];\n  if (data.items?.length) {\n    for (const item of data.items) {\n      if (item.kind !== \"file\") continue;\n      const f = item.getAsFile();\n      if (f) out.push(f);\n    }\n  }\n  if (out.length > 0) return out;\n  if (data.files?.length) return Array.from(data.files);\n  return [];\n}\n\n/** Best-effort match for an HTML `accept` attribute (comma tokens: `.pdf`, `image/*`, exact MIME). */\nfunction fileMatchesAccept(file: File, accept: string): boolean {\n  const trimmed = accept.trim();\n  if (!trimmed || trimmed === \"*/*\") return true;\n  const tokens = trimmed\n    .split(\",\")\n    .map((s) => s.trim())\n    .filter(Boolean);\n  const type = (file.type ?? \"\").toLowerCase();\n  const name = file.name ?? \"\";\n  const extWithDot =\n    name.lastIndexOf(\".\") > 0\n      ? name.slice(name.lastIndexOf(\".\")).toLowerCase()\n      : \"\";\n\n  for (const token of tokens) {\n    const t = token.toLowerCase();\n    if (t === \"*/*\") return true;\n    if (t.startsWith(\".\")) {\n      if (extWithDot === t) return true;\n      continue;\n    }\n    if (t.endsWith(\"/*\")) {\n      const prefix = t.slice(0, -1);\n      if (type.startsWith(prefix)) return true;\n      continue;\n    }\n    if (type && type === t) return true;\n  }\n  return false;\n}\n\nfunction formatBytes(bytes?: number): string | undefined {\n  if (bytes == null || !Number.isFinite(bytes)) return undefined;\n  const units = [\"B\", \"KB\", \"MB\", \"GB\"] as const;\n  let v = bytes;\n  let i = 0;\n  while (v >= 1024 && i < units.length - 1) {\n    v /= 1024;\n    i += 1;\n  }\n  const rounded = i === 0 ? Math.round(v) : Math.round(v * 10) / 10;\n  return `${rounded} ${units[i]}`;\n}\n\n/** Uppercased file extension for the detailed subtitle (no leading dot); `undefined` if there is no usable extension. */\nfunction kindLabel(item: AttachmentMeta): string | undefined {\n  const name = item.name?.trim();\n  if (!name) return undefined;\n  const dot = name.lastIndexOf(\".\");\n  if (dot <= 0 || dot >= name.length - 1) return undefined;\n  const ext = name.slice(dot + 1).toLowerCase();\n  if (!ext || ext.length > 16) return undefined;\n  return ext.toUpperCase();\n}\n\nfunction iconForAttachmentType(type: AttachmentMeta[\"type\"]) {\n  switch (type) {\n    case \"image\":\n      return Image02Icon;\n    case \"video\":\n      return Video02Icon;\n    case \"audio\":\n      return MusicNote02Icon;\n    default:\n      return File02Icon;\n  }\n}\n\nfunction inferDetailedSubtitleMode(\n  attachment: AttachmentMeta,\n): \"size\" | \"kind\" {\n  if (\n    attachment.size != null &&\n    Number.isFinite(attachment.size) &&\n    attachment.size > 0\n  ) {\n    return \"size\";\n  }\n  return \"kind\";\n}\n\nconst attachmentVariants = cva(\n  \"group relative cursor-default overflow-hidden rounded-sm border border-muted bg-secondary text-muted-foreground\",\n  {\n    variants: {\n      variant: {\n        compact: \"relative flex size-15 shrink-0 items-center justify-center\",\n        inline:\n          \"relative flex h-8 w-auto min-w-0 max-w-[200px] shrink-0 items-center justify-start p-1 pr-2\",\n        detailed:\n          \"relative flex h-15 w-auto min-w-[200px] max-w-[250px] shrink-0 items-center justify-start p-2 pr-3\",\n        pasted:\n          \"relative flex w-[156px] h-[144px] shrink-0 flex-col items-center justify-center overflow-hidden rounded-sm p-2 gap-2\",\n      },\n    },\n    defaultVariants: {\n      variant: \"compact\",\n    },\n  },\n);\n\ntype AttachmentVariant = NonNullable<\n  VariantProps<typeof attachmentVariants>[\"variant\"]\n>;\n\n// ——— Context ———\n\n/** Public context value from **`useAttachments()`** (also used internally by **`Attachments`**). */\nexport type AttachmentsContextValue = {\n  inputRef: React.RefObject<HTMLInputElement | null>;\n  /** Stable id on the hidden file input (labels, tests, or custom `aria-*`). */\n  inputId: string;\n  openPicker: () => void;\n  /**\n   * Append files with the same limits and `onFilesRejected` behavior as the native picker.\n   * For drag-and-drop or paste, call this from your handler.\n   */\n  appendFiles: (files: File[], options?: AppendFilesOptions) => void;\n  /** True while a file drag is active over the document (when `windowDrop` is enabled). */\n  isDraggingFile: boolean;\n  attachments: AttachmentMeta[];\n  onAttachmentsChange: (next: AttachmentMeta[]) => void;\n  accept?: string;\n  multiple: boolean;\n  maxFiles?: number;\n  maxSize?: number;\n  disabled: boolean;\n};\n\nconst AttachmentsContext = React.createContext<AttachmentsContextValue | null>(\n  null,\n);\n\nfunction useAttachmentsContext(component: string) {\n  const ctx = React.useContext(AttachmentsContext);\n  if (!ctx) {\n    throw new Error(`${component} must be used within <Attachments>`);\n  }\n  return ctx;\n}\n\ntype AttachmentItemContextValue = {\n  variant: AttachmentVariant;\n  attachment: AttachmentMeta;\n  onRemove?: () => void;\n  /** When true, remove controls are hidden (see `Attachment` `readOnly`). */\n  readOnly?: boolean;\n};\n\nconst AttachmentItemContext =\n  React.createContext<AttachmentItemContextValue | null>(null);\n\nfunction useAttachmentItemContext(component: string) {\n  const ctx = React.useContext(AttachmentItemContext);\n  if (!ctx) {\n    throw new Error(`${component} must be used within <Attachment>`);\n  }\n  return ctx;\n}\n\nexport type AttachmentsProps = {\n  attachments: AttachmentMeta[];\n  onAttachmentsChange: (attachments: AttachmentMeta[]) => void;\n  accept?: string;\n  /** @default true */\n  multiple?: boolean;\n  maxFiles?: number;\n  /** Maximum file size per file in bytes */\n  maxSize?: number;\n  disabled?: boolean;\n  /** Fires after the internal file handler (same event; `target.files` still available). */\n  onFileInputChange?: React.ChangeEventHandler<HTMLInputElement>;\n  /**\n   * Called when some selected files are not appended (oversized, over `maxFiles`, or trimmed because `multiple` is false).\n   */\n  onFilesRejected?: (detail: AttachmentsRejectedFiles) => void;\n  /**\n   * Register `dragover` / `drop` on `document` so files can be dropped anywhere.\n   * Pair with **`AttachmentsDropOverlay`** or **`useAttachments().appendFiles`** if you build a custom drop target.\n   * @default false\n   */\n  windowDrop?: boolean;\n  children?: React.ReactNode;\n};\n\nfunction Attachments({\n  attachments,\n  onAttachmentsChange,\n  accept,\n  multiple = true,\n  maxFiles,\n  maxSize,\n  disabled = false,\n  onFileInputChange,\n  onFilesRejected,\n  windowDrop = false,\n  children,\n}: AttachmentsProps) {\n  const inputRef = React.useRef<HTMLInputElement | null>(null);\n  const inputId = React.useId();\n  const managedBlobUrlsRef = React.useRef<Set<string>>(new Set());\n  const [isDraggingFile, setIsDraggingFile] = React.useState(false);\n\n  React.useLayoutEffect(() => {\n    const inUse = new Set<string>();\n    for (const a of attachments) {\n      if (a.url) inUse.add(a.url);\n      if (a.thumbnailUrl) inUse.add(a.thumbnailUrl);\n    }\n    for (const url of [...managedBlobUrlsRef.current]) {\n      if (!inUse.has(url)) {\n        URL.revokeObjectURL(url);\n        managedBlobUrlsRef.current.delete(url);\n      }\n    }\n  }, [attachments]);\n\n  React.useEffect(() => {\n    const managedBlobUrls = managedBlobUrlsRef.current;\n    return () => {\n      for (const url of managedBlobUrls) {\n        URL.revokeObjectURL(url);\n      }\n      managedBlobUrls.clear();\n    };\n  }, []);\n\n  const openPicker = React.useCallback(() => {\n    if (disabled) return;\n    inputRef.current?.click();\n  }, [disabled]);\n\n  const appendFilesFromList = React.useCallback(\n    (rawFiles: File[], appendOptions?: AppendFilesOptions) => {\n      if (disabled || rawFiles.length === 0) return;\n\n      let incoming = [...rawFiles];\n      const notAccepted =\n        accept != null && accept !== \"\" && accept !== \"*/*\"\n          ? incoming.filter((f) => !fileMatchesAccept(f, accept))\n          : [];\n      if (accept != null && accept !== \"\" && accept !== \"*/*\") {\n        incoming = incoming.filter((f) => fileMatchesAccept(f, accept));\n      }\n\n      let truncatedByMultiple: File[] = [];\n      if (!multiple && incoming.length > 1) {\n        truncatedByMultiple = incoming.slice(1);\n        incoming = incoming.slice(0, 1);\n      }\n\n      const tooLarge =\n        maxSize != null ? incoming.filter((f) => f.size > maxSize) : [];\n\n      const withinSize =\n        maxSize != null ? incoming.filter((f) => f.size <= maxSize) : incoming;\n\n      const room =\n        maxFiles != null\n          ? Math.max(0, maxFiles - attachments.length)\n          : Number.POSITIVE_INFINITY;\n\n      const take =\n        room === Number.POSITIVE_INFINITY\n          ? withinSize\n          : withinSize.slice(0, room);\n\n      const overMaxFiles =\n        room === Number.POSITIVE_INFINITY ? [] : withinSize.slice(room);\n\n      if (\n        notAccepted.length > 0 ||\n        tooLarge.length > 0 ||\n        overMaxFiles.length > 0 ||\n        truncatedByMultiple.length > 0\n      ) {\n        onFilesRejected?.({\n          notAccepted,\n          tooLarge,\n          overMaxFiles,\n          truncatedByMultiple,\n        });\n      }\n\n      const newMetas = take.map((file) => {\n        const objectUrl = URL.createObjectURL(file);\n        managedBlobUrlsRef.current.add(objectUrl);\n        return toAttachmentMeta(file, {\n          objectUrl,\n          source: appendOptions?.paste ? \"paste\" : undefined,\n        });\n      });\n\n      if (newMetas.length > 0) {\n        onAttachmentsChange([...attachments, ...newMetas]);\n      }\n    },\n    [\n      disabled,\n      accept,\n      multiple,\n      maxSize,\n      maxFiles,\n      attachments,\n      onAttachmentsChange,\n      onFilesRejected,\n    ],\n  );\n\n  const handleInputChange = React.useCallback(\n    (e: React.ChangeEvent<HTMLInputElement>) => {\n      const list = e.target.files;\n      if (!list?.length || disabled) {\n        onFileInputChange?.(e);\n        e.target.value = \"\";\n        return;\n      }\n\n      appendFilesFromList(Array.from(list));\n      onFileInputChange?.(e);\n      e.target.value = \"\";\n    },\n    [disabled, appendFilesFromList, onFileInputChange],\n  );\n\n  const dragAndDropEnabled = !disabled && windowDrop;\n\n  useOnChange(dragAndDropEnabled, (enabled) => {\n    if (!enabled) setIsDraggingFile(false);\n  });\n\n  React.useEffect(() => {\n    if (!dragAndDropEnabled) return;\n\n    const hasFiles = (e: DragEvent) =>\n      Boolean(\n        e.dataTransfer?.types?.length &&\n        [...e.dataTransfer.types].includes(\"Files\"),\n      );\n\n    const onDragEnter = (e: DragEvent) => {\n      if (!hasFiles(e)) return;\n      setIsDraggingFile(true);\n    };\n\n    const onDragLeave = (e: DragEvent) => {\n      if (!hasFiles(e)) return;\n      const next = e.relatedTarget as Node | null;\n      if (next && document.contains(next)) return;\n      setIsDraggingFile(false);\n    };\n\n    const onDragOver = (e: DragEvent) => {\n      if (!hasFiles(e)) return;\n      e.preventDefault();\n      e.dataTransfer!.dropEffect = \"copy\";\n    };\n\n    const onDrop = (e: DragEvent) => {\n      e.preventDefault();\n      setIsDraggingFile(false);\n      const list = e.dataTransfer?.files;\n      if (!list?.length) return;\n      appendFilesFromList(Array.from(list));\n    };\n\n    document.addEventListener(\"dragenter\", onDragEnter);\n    document.addEventListener(\"dragleave\", onDragLeave);\n    document.addEventListener(\"dragover\", onDragOver);\n    document.addEventListener(\"drop\", onDrop);\n    return () => {\n      document.removeEventListener(\"dragenter\", onDragEnter);\n      document.removeEventListener(\"dragleave\", onDragLeave);\n      document.removeEventListener(\"dragover\", onDragOver);\n      document.removeEventListener(\"drop\", onDrop);\n    };\n  }, [dragAndDropEnabled, appendFilesFromList]);\n\n  const value = React.useMemo<AttachmentsContextValue>(\n    () => ({\n      inputRef,\n      inputId,\n      openPicker,\n      appendFiles: appendFilesFromList,\n      isDraggingFile,\n      attachments,\n      onAttachmentsChange,\n      accept,\n      multiple,\n      maxFiles,\n      maxSize,\n      disabled,\n    }),\n    [\n      inputId,\n      appendFilesFromList,\n      isDraggingFile,\n      attachments,\n      onAttachmentsChange,\n      accept,\n      multiple,\n      maxFiles,\n      maxSize,\n      disabled,\n      openPicker,\n    ],\n  );\n\n  return (\n    <AttachmentsContext.Provider value={value}>\n      <input\n        ref={inputRef}\n        id={inputId}\n        type=\"file\"\n        data-slot=\"attachments-input\"\n        className=\"sr-only\"\n        aria-hidden\n        tabIndex={-1}\n        accept={accept}\n        multiple={multiple}\n        disabled={disabled}\n        onChange={handleInputChange}\n      />\n      {children}\n    </AttachmentsContext.Provider>\n  );\n}\n\n/** Access **`Attachments`** context: **`appendFiles`**, **`isDraggingFile`**, **`openPicker`**, controlled state, and limits. Must be used under **`Attachments`**. */\nexport function useAttachments(): AttachmentsContextValue {\n  return useAttachmentsContext(\"useAttachments\");\n}\n\nexport type AttachmentsDropOverlayProps = Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"children\"\n> & {\n  /**\n   * `fullscreen` portals to `document.body` and covers the viewport.\n   * `contained` fills the nearest positioned ancestor — wrap a **`relative`** container (e.g. prompt shell).\n   * @default \"fullscreen\"\n   */\n  variant?: \"fullscreen\" | \"contained\";\n  children?: React.ReactNode;\n};\n\nexport function AttachmentsDropOverlay({\n  variant = \"fullscreen\",\n  className,\n  children,\n  ...props\n}: AttachmentsDropOverlayProps) {\n  const { isDraggingFile, disabled, maxSize } = useAttachmentsContext(\n    \"AttachmentsDropOverlay\",\n  );\n  const canUseDOM = typeof document !== \"undefined\";\n  const open = canUseDOM && !disabled && isDraggingFile;\n\n  React.useEffect(() => {\n    if (!open || variant !== \"fullscreen\") return;\n    const previousOverflow = document.body.style.overflow;\n    document.body.style.overflow = \"hidden\";\n    return () => {\n      document.body.style.overflow = previousOverflow;\n    };\n  }, [open, variant]);\n\n  if (!open) return null;\n\n  const maxSizeLabel = maxSize != null ? formatBytes(maxSize) : undefined;\n\n  const inner = (\n    <div\n      data-slot=\"attachments-drop-overlay\"\n      role=\"presentation\"\n      aria-hidden\n      className={cn(\n        \"pointer-events-none bg-background/50 backdrop-blur-sm\",\n        variant === \"fullscreen\"\n          ? \"fixed inset-0 z-50 flex items-center justify-center\"\n          : \"absolute inset-0 z-10 flex items-center justify-center rounded-[inherit]\",\n        className,\n      )}\n      {...props}\n    >\n      {children ?? (\n        <div className=\"flex flex-col items-center gap-3\">\n          <HugeiconsIcon icon={Upload01Icon} className=\"size-5 text-primary\" />\n\n          <div className=\"flex flex-col items-center gap-1\">\n            <p className=\"text-sm font-[350] text-primary\">\n              Drop files here to add as attachment\n            </p>\n            {maxSizeLabel ? (\n              <p className=\"text-xs font-[350] text-muted-foreground\">\n                Maximum {maxSizeLabel} per file\n              </p>\n            ) : null}\n          </div>\n        </div>\n      )}\n    </div>\n  );\n\n  if (variant === \"fullscreen\") {\n    return createPortal(inner, document.body);\n  }\n  return inner;\n}\n\ntype AttachmentTriggerProps = React.ComponentProps<\"button\"> & {\n  asChild?: boolean;\n};\n\nfunction AttachmentTrigger({\n  asChild = false,\n  className,\n  children,\n  onClick,\n  disabled: disabledProp,\n  ...props\n}: AttachmentTriggerProps) {\n  const { openPicker, disabled: rootDisabled } =\n    useAttachmentsContext(\"AttachmentTrigger\");\n  const disabled = Boolean(rootDisabled || disabledProp);\n\n  const handleClick = React.useCallback(\n    (e: React.MouseEvent<HTMLButtonElement>) => {\n      if (disabled) return;\n      onClick?.(e);\n      openPicker();\n    },\n    [disabled, onClick, openPicker],\n  );\n\n  const triggerClassName = cn(\n    disabled && \"cursor-not-allowed opacity-50\",\n    className,\n  );\n\n  if (asChild) {\n    return (\n      <Slot\n        {...props}\n        data-slot=\"attachment-trigger\"\n        className={triggerClassName}\n        aria-disabled={disabled}\n        onClick={handleClick as React.MouseEventHandler<HTMLElement>}\n      >\n        {children}\n      </Slot>\n    );\n  }\n\n  return (\n    <button\n      {...props}\n      type=\"button\"\n      data-slot=\"attachment-trigger\"\n      className={triggerClassName}\n      disabled={disabled}\n      onClick={handleClick}\n    >\n      {children}\n    </button>\n  );\n}\n\ntype AttachmentListProps = React.HTMLAttributes<HTMLDivElement>;\n\nfunction AttachmentList({ className, role, ...props }: AttachmentListProps) {\n  return (\n    <div\n      data-slot=\"attachment-list\"\n      role={role ?? \"list\"}\n      className={cn(\n        \"flex w-full max-w-full min-w-0 scrollbar-thin [scrollbar-color:var(--scrollbar-thumb)_transparent] flex-wrap items-end justify-center gap-2.5 overflow-x-auto overscroll-x-contain pb-0.5 [&::-webkit-scrollbar-thumb]:border-transparent [&::-webkit-scrollbar-track]:bg-transparent\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\ntype AttachmentOverflowFadeLayerProps = React.HTMLAttributes<HTMLDivElement> & {\n  variant: \"inline\" | \"detailed\";\n};\n\nfunction AttachmentOverflowFadeLayer({\n  className,\n  variant,\n  ...props\n}: AttachmentOverflowFadeLayerProps) {\n  return (\n    <div\n      aria-hidden\n      data-slot=\"attachment-overflow-fade\"\n      className={cn(\n        \"pointer-events-none absolute top-1/2 right-0 w-10 -translate-y-1/2 bg-linear-to-l from-secondary from-65% to-transparent transition-opacity group-hover:opacity-100 sm:opacity-0\",\n        variant === \"detailed\" ? \"h-15\" : \"h-8\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\ntype AttachmentProps = Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"children\"\n> & {\n  variant?: AttachmentVariant;\n  /** Attachment metadata; drives preview and properties. */\n  attachment: AttachmentMeta;\n  /** Upload progress 0–100; shows a bottom bar when set */\n  progress?: number;\n  onRemove?: () => void;\n  /**\n   * When true, hides remove controls and the progress bar (e.g. message history).\n   * @default false\n   */\n  readOnly?: boolean;\n  /** Detailed layout: second line; inferred from `attachment.size` when omitted. */\n  detailedSubtitle?: \"size\" | \"kind\";\n  /**\n   * When set, replaces the default layout.\n   */\n  children?: React.ReactNode;\n  /**\n   * `pasted` only: maximum characters in the preview line (remainder as ellipsis).\n   * @default 220\n   */\n  pastedExcerptMaxChars?: number;\n};\n\nfunction Attachment({\n  className,\n  variant = \"compact\",\n  attachment,\n  progress,\n  onRemove,\n  readOnly = false,\n  detailedSubtitle: detailedSubtitleProp,\n  pastedExcerptMaxChars = 220,\n  children,\n  ...props\n}: AttachmentProps) {\n  const detailedSubtitle =\n    variant === \"detailed\"\n      ? (detailedSubtitleProp ?? inferDetailedSubtitleMode(attachment))\n      : undefined;\n\n  const ctxValue = React.useMemo<AttachmentItemContextValue>(\n    () => ({\n      variant: variant ?? \"compact\",\n      attachment,\n      onRemove,\n      readOnly,\n    }),\n    [variant, attachment, onRemove, readOnly],\n  );\n\n  const showProgress =\n    !readOnly && progress != null && Number.isFinite(progress);\n\n  const defaultLayout =\n    variant === \"pasted\" ? (\n      <>\n        <AttachmentPreview pastedExcerptMaxChars={pastedExcerptMaxChars} />\n\n        {!readOnly ? (\n          <div className=\"flex h-6 w-full items-center justify-between gap-2 rounded-sm bg-card pr-1 pl-2\">\n            <span className=\"text-xs leading-4 font-[350] text-muted-foreground uppercase\">\n              Pasted\n            </span>\n            <AttachmentRemove\n              position=\"inline\"\n              className=\"bg-transparent text-muted-foreground hover:bg-muted dark:bg-transparent dark:hover:bg-muted\"\n            />\n          </div>\n        ) : null}\n      </>\n    ) : variant === \"compact\" ? (\n      <>\n        <AttachmentRemove />\n        <AttachmentPreview />\n      </>\n    ) : variant === \"detailed\" ? (\n      <>\n        <AttachmentRemove />\n        <div className=\"flex min-w-0 items-center gap-2\">\n          <AttachmentPreview />\n          <AttachmentInfo>\n            <AttachmentProperty as=\"name\" />\n            <AttachmentProperty\n              as={detailedSubtitle === \"size\" ? \"size\" : \"kind\"}\n            />\n          </AttachmentInfo>\n        </div>\n      </>\n    ) : (\n      <>\n        <AttachmentRemove />\n        <div className=\"flex min-w-0 items-center gap-1\">\n          <AttachmentPreview />\n          <AttachmentProperty as=\"name\" />\n        </div>\n      </>\n    );\n\n  return (\n    <AttachmentItemContext.Provider value={ctxValue}>\n      <div\n        data-slot=\"attachment\"\n        data-variant={variant}\n        role=\"listitem\"\n        className={cn(attachmentVariants({ variant }), className)}\n        {...props}\n      >\n        {variant === \"inline\" || variant === \"detailed\" ? (\n          <AttachmentOverflowFadeLayer variant={variant} />\n        ) : null}\n        {children ?? defaultLayout}\n        {showProgress && variant !== \"pasted\" ? (\n          <AttachmentProgress value={progress} />\n        ) : null}\n      </div>\n    </AttachmentItemContext.Provider>\n  );\n}\n\nconst attachmentPreviewVariants = cva(\n  \"flex shrink-0 items-center justify-center overflow-hidden bg-card text-muted-foreground\",\n  {\n    variants: {\n      variant: {\n        compact:\n          \"absolute inset-0 size-full rounded-[inherit] border-0 bg-transparent\",\n        inline: \"size-6 rounded-xs border border-input\",\n        detailed: \"size-11 rounded-sm border border-input\",\n        pasted:\n          \"min-h-0 w-full flex-1 shrink self-stretch items-start justify-start border-0 bg-transparent p-0\",\n      },\n    },\n    defaultVariants: {\n      variant: \"compact\",\n    },\n  },\n);\n\ntype AttachmentPreviewProps = Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"children\"\n> &\n  Partial<VariantProps<typeof attachmentPreviewVariants>> & {\n    /**\n     * `pasted` only: max characters before ellipsis (from **`Attachment`** **`pastedExcerptMaxChars`**).\n     * @default 220\n     */\n    pastedExcerptMaxChars?: number;\n  };\n\nfunction AttachmentPreview({\n  className,\n  variant: variantProp,\n  pastedExcerptMaxChars = 220,\n  ...props\n}: AttachmentPreviewProps) {\n  const { variant, attachment } = useAttachmentItemContext(\"AttachmentPreview\");\n  const v = variantProp ?? variant;\n  const [pastedRawText, setPastedRawText] = React.useState(\"\");\n\n  React.useEffect(() => {\n    if (v !== \"pasted\") return;\n\n    let cancelled = false;\n\n    const run = async () => {\n      if (attachment.data instanceof Blob) {\n        const t = await attachment.data.text();\n        if (!cancelled) setPastedRawText(t);\n        return;\n      }\n      if (attachment.data instanceof ArrayBuffer) {\n        const t = new TextDecoder().decode(attachment.data);\n        if (!cancelled) setPastedRawText(t);\n        return;\n      }\n      const url = attachment.url;\n      if (url?.startsWith(\"blob:\") || url?.startsWith(\"data:\")) {\n        try {\n          const res = await fetch(url);\n          const t = await res.text();\n          if (!cancelled) setPastedRawText(t);\n        } catch {\n          if (!cancelled) setPastedRawText(\"\");\n        }\n        return;\n      }\n      if (!cancelled) setPastedRawText(\"\");\n    };\n\n    void run();\n    return () => {\n      cancelled = true;\n    };\n  }, [v, attachment.data, attachment.url]);\n\n  const pastedExcerpt = React.useMemo(() => {\n    if (v !== \"pasted\") return \"\";\n    const normalized = pastedRawText.replace(/\\s+/g, \" \").trim();\n    const max = pastedExcerptMaxChars;\n    if (normalized.length <= max) return normalized;\n    return `${normalized.slice(0, max).trimEnd()}…`;\n  }, [v, pastedRawText, pastedExcerptMaxChars]);\n\n  const rasterSrc =\n    attachment.thumbnailUrl ??\n    (attachment.type === \"image\" && attachment.url\n      ? attachment.url\n      : undefined);\n  const videoSrc =\n    !attachment.thumbnailUrl && attachment.type === \"video\" && attachment.url\n      ? attachment.url\n      : undefined;\n  const showRaster = Boolean(rasterSrc);\n  const showVideo = Boolean(videoSrc);\n  const inlinePlainIcon =\n    v === \"inline\" && !showRaster && !showVideo\n      ? \"border-0 bg-transparent dark:bg-transparent\"\n      : \"\";\n\n  const iconClass = v === \"inline\" ? \"size-5\" : \"size-7\";\n\n  const content = (() => {\n    if (v === \"pasted\") {\n      return (\n        <p\n          data-slot=\"attachment-preview-excerpt\"\n          className=\"my-0! line-clamp-6 text-xs leading-4 font-[350] text-ring\"\n        >\n          {pastedExcerpt.length > 0 ? pastedExcerpt : \"\\u00a0\"}\n        </p>\n      );\n    }\n    if (rasterSrc) {\n      return (\n        <>\n          <div className=\"absolute inset-0 z-0 size-full animate-pulse bg-input\" />\n          <img\n            src={rasterSrc}\n            alt=\"\"\n            className=\"relative z-1 size-full object-cover\"\n          />\n        </>\n      );\n    }\n    if (videoSrc) {\n      return (\n        <>\n          <div className=\"absolute inset-0 z-0 size-full animate-pulse bg-input\" />\n          <video\n            src={videoSrc}\n            muted\n            playsInline\n            preload=\"metadata\"\n            aria-hidden\n            className=\"relative z-1 size-full object-cover\"\n          />\n        </>\n      );\n    }\n    return (\n      <HugeiconsIcon\n        icon={iconForAttachmentType(attachment.type)}\n        strokeWidth={1.5}\n        className={iconClass}\n        aria-hidden\n      />\n    );\n  })();\n\n  return (\n    <div\n      data-slot=\"attachment-preview\"\n      className={cn(\n        attachmentPreviewVariants({ variant: v }),\n        inlinePlainIcon,\n        v !== \"pasted\" && \"relative\",\n        className,\n      )}\n      {...props}\n    >\n      {content}\n    </div>\n  );\n}\n\nconst removeButtonVariants = cva(\n  \"z-10 flex size-4.5 cursor-pointer items-center justify-center rounded-full bg-secondary text-muted-foreground sm:opacity-0 transition-all group-hover:opacity-100 hover:bg-border hover:text-primary active:scale-[0.97]\",\n  {\n    variants: {\n      position: {\n        corner: \"absolute top-1 right-1\",\n        \"center-end\": \"absolute top-1/2 right-1 -translate-y-1/2\",\n        /** Inline footer (e.g. **`variant=\"pasted\"`**): always visible. */\n        inline: \"relative shrink-0 opacity-100 sm:opacity-100\",\n      },\n    },\n    defaultVariants: {\n      position: \"corner\",\n    },\n  },\n);\n\ntype AttachmentRemoveProps = React.ComponentProps<\"button\"> &\n  VariantProps<typeof removeButtonVariants> & {\n    asChild?: boolean;\n  };\n\nfunction AttachmentRemove({\n  className,\n  asChild = false,\n  position: positionProp,\n  children,\n  type: _type,\n  onClick,\n  \"aria-label\": ariaLabelProp,\n  ...props\n}: AttachmentRemoveProps) {\n  const { variant, attachment, onRemove, readOnly } =\n    useAttachmentItemContext(\"AttachmentRemove\");\n  const position =\n    positionProp ?? (variant === \"inline\" ? \"center-end\" : \"corner\");\n\n  const ariaLabel =\n    ariaLabelProp ?? `Remove ${attachment.name ?? \"attachment\"}`;\n\n  const handleClick = React.useCallback(\n    (e: React.MouseEvent<HTMLButtonElement>) => {\n      onClick?.(e);\n      onRemove?.();\n    },\n    [onClick, onRemove],\n  );\n\n  if (readOnly) {\n    return null;\n  }\n\n  if (asChild) {\n    return (\n      <Slot\n        className={cn(removeButtonVariants({ position }), className)}\n        aria-label={ariaLabel}\n        onClick={handleClick as React.MouseEventHandler<HTMLElement>}\n        {...props}\n      >\n        {children}\n      </Slot>\n    );\n  }\n\n  return (\n    <button\n      type=\"button\"\n      data-slot=\"attachment-remove\"\n      className={cn(removeButtonVariants({ position }), className)}\n      aria-label={ariaLabel}\n      onClick={handleClick}\n      {...props}\n    >\n      {children ?? (\n        <HugeiconsIcon\n          icon={Cancel01Icon}\n          strokeWidth={2.5}\n          className=\"size-3\"\n          aria-hidden\n        />\n      )}\n    </button>\n  );\n}\n\ntype AttachmentInfoProps = React.HTMLAttributes<HTMLDivElement>;\n\nfunction AttachmentInfo({ className, ...props }: AttachmentInfoProps) {\n  return (\n    <div\n      data-slot=\"attachment-info\"\n      className={cn(\"flex min-w-0 flex-col gap-0\", className)}\n      {...props}\n    />\n  );\n}\n\ntype AttachmentPropertyAs = \"name\" | \"size\" | \"kind\";\n\ntype AttachmentPropertyProps = Omit<\n  React.HTMLAttributes<HTMLParagraphElement>,\n  \"children\"\n> & {\n  as: AttachmentPropertyAs;\n};\n\nfunction AttachmentProperty({\n  as: mode,\n  className,\n  ...props\n}: AttachmentPropertyProps) {\n  const { attachment } = useAttachmentItemContext(\"AttachmentProperty\");\n  let text: string;\n  if (mode === \"name\") {\n    text = attachment.name ?? \"\";\n  } else if (mode === \"size\") {\n    text = formatBytes(attachment.size) ?? \"—\";\n  } else {\n    text = kindLabel(attachment) ?? \"—\";\n  }\n\n  const isTitle = mode === \"name\";\n  return (\n    <p\n      data-slot=\"attachment-property\"\n      data-as={mode}\n      className={cn(\n        isTitle\n          ? \"my-0! truncate text-sm leading-6 font-[450] text-primary\"\n          : \"my-0! text-xs font-[350] text-muted-foreground\",\n        className,\n      )}\n      {...props}\n    >\n      {text}\n    </p>\n  );\n}\n\ntype AttachmentProgressProps = React.HTMLAttributes<HTMLDivElement> & {\n  /** 0–100 */\n  value: number;\n};\n\nfunction AttachmentProgress({\n  className,\n  value,\n  ...props\n}: AttachmentProgressProps) {\n  const clamped = Math.min(100, Math.max(0, value));\n  return (\n    <div\n      data-slot=\"attachment-progress\"\n      className={cn(\n        \"pointer-events-none absolute inset-x-0 bottom-0 h-0.5 bg-border/90\",\n        className,\n      )}\n      {...props}\n    >\n      <div\n        className=\"h-full bg-foreground transition-[width] duration-200 dark:bg-primary\"\n        style={{ width: `${clamped}%` }}\n      />\n    </div>\n  );\n}\n\nAttachments.displayName = \"Attachments\";\nAttachmentsDropOverlay.displayName = \"AttachmentsDropOverlay\";\nAttachmentTrigger.displayName = \"AttachmentTrigger\";\nAttachmentList.displayName = \"AttachmentList\";\nAttachment.displayName = \"Attachment\";\nAttachmentPreview.displayName = \"AttachmentPreview\";\nAttachmentRemove.displayName = \"AttachmentRemove\";\nAttachmentInfo.displayName = \"AttachmentInfo\";\nAttachmentProperty.displayName = \"AttachmentProperty\";\nAttachmentProgress.displayName = \"AttachmentProgress\";\n\nexport {\n  Attachments,\n  AttachmentTrigger,\n  AttachmentList,\n  Attachment,\n  AttachmentPreview,\n  AttachmentRemove,\n  AttachmentInfo,\n  AttachmentProperty,\n  AttachmentProgress,\n};\n","type":"registry:file","target":"~/components/nexus-ui/attachments.tsx"},{"path":"lib/use-on-change.ts","content":"\"use client\";\n\nimport * as React from \"react\";\n\n/**\n * Runs `onChange` when `value` changes (compared against previous render).\n */\nexport function useOnChange<T>(\n  value: T,\n  onChange: (current: T, previous: T) => void,\n  isUpdated: (previous: T, current: T) => boolean = Object.is,\n) {\n  const previousRef = React.useRef(value);\n\n  React.useEffect(() => {\n    const previous = previousRef.current;\n    if (!isUpdated(previous, value)) {\n      onChange(value, previous);\n    }\n    previousRef.current = value;\n  }, [value, onChange, isUpdated]);\n}\n","type":"registry:file","target":"~/lib/use-on-change.ts"}],"type":"registry:ui"}