{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-upload",
  "title": "File Upload",
  "description": "A real drop zone and file input with type, size and count limits, clear errors and a progress list. It never touches the network: you upload and pass the state back.",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://miseui.junsu4621.workers.dev/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/file-upload.tsx",
      "content": "import type * as React from \"react\"\nimport { useState } from \"react\"\nimport {\n  DEFAULT_MESSAGES,\n  uniqueMessages,\n  validateFiles,\n  type FileMessages,\n  type FileRejection,\n} from \"@/registry/miseui/ui/file-upload-math\"\nimport { FileList, type FileUploadItem, type FileUploadLabels } from \"@/registry/miseui/ui/file-upload-row\"\nimport { AvatarArea, ButtonArea, CompactArea, DropzoneArea } from \"@/registry/miseui/ui/file-upload-zones\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype FileUploadVariant = \"dropzone\" | \"button\" | \"compact\" | \"avatar\"\ntype FileUploadEffect = \"materialize\"\n\nconst DEFAULT_LABELS: FileUploadLabels = {\n  remove: (name) => `Remove ${name}`,\n  retry: (name) => `Retry ${name}`,\n  uploading: (name) => `Uploading ${name}`,\n  queued: \"Waiting\",\n  done: \"Uploaded\",\n  failed: \"Upload failed\",\n}\n\nconst DEFAULT_ACTION: Record<FileUploadVariant, string> = {\n  dropzone: \"Drop files here, or browse\",\n  button: \"Choose files\",\n  compact: \"Browse\",\n  avatar: \"Change photo\",\n}\n\ntype RootAttributes = Omit<React.ComponentProps<\"div\">, \"children\" | \"onDrop\" | \"defaultValue\">\n\ninterface FileUploadProps extends RootAttributes {\n  /** `dropzone` a large target, `button` one action, `compact` a form field, `avatar` a round picture. */\n  variant?: FileUploadVariant\n  /** `materialize` lets rows arrive and leave with scale, blur and height together. */\n  effect?: FileUploadEffect\n  /** What you are tracking. This component never touches the network: you upload and pass the state back. */\n  items?: readonly FileUploadItem[]\n  /** Files that passed the limits. Start your uploads here. */\n  onFilesAccepted?: (files: File[]) => void\n  onFilesRejected?: (rejections: FileRejection[]) => void\n  onRemove?: (id: string) => void\n  onRetry?: (id: string) => void\n  /** The same syntax as the input's accept attribute. */\n  accept?: string\n  /** Bytes. */\n  maxSize?: number\n  /** Total, counting what is already in `items`. `avatar` is always one. */\n  maxFiles?: number\n  multiple?: boolean\n  disabled?: boolean\n  /** Names the input and is the visible action. */\n  label?: string\n  /** The limits in words: \"PNG or PDF, up to 5 MB\". */\n  hint?: string\n  labels?: Partial<FileUploadLabels>\n  messages?: Partial<FileMessages>\n}\n\ntype IntakeOptions = Pick<FileUploadProps, \"onFilesAccepted\" | \"onFilesRejected\" | \"accept\" | \"maxSize\" | \"maxFiles\" | \"messages\"> & {\n  disabled: boolean\n  avatar: boolean\n  itemCount: number\n}\n\nfunction useFileIntake({ onFilesAccepted, onFilesRejected, accept, maxSize, maxFiles, messages, disabled, avatar, itemCount }: IntakeOptions) {\n  const [dragging, setDragging] = useState(false)\n  const [errors, setErrors] = useState<readonly string[]>([])\n  const receive = (files: readonly File[]) => {\n    if (disabled || files.length === 0) return\n    // An avatar replaces what is there, so the one in the list does not count against the limit.\n    const limits = { accept, maxSize, maxFiles: avatar ? 1 : maxFiles, existing: avatar ? 0 : itemCount }\n    const { accepted, rejected } = validateFiles(files, limits, { ...DEFAULT_MESSAGES, ...messages })\n    setErrors(uniqueMessages(rejected))\n    if (rejected.length) onFilesRejected?.([...rejected])\n    if (accepted.length) onFilesAccepted?.([...accepted])\n  }\n  const dragHandlers: React.ComponentProps<\"label\"> = {\n    onDragEnter: (event) => {\n      event.preventDefault()\n      if (!disabled) setDragging(true)\n    },\n    // Without preventDefault here the browser opens the dropped file and leaves the page.\n    onDragOver: (event) => event.preventDefault(),\n    onDragLeave: (event) => {\n      // Moving onto a child also fires dragleave; only leaving the zone itself counts.\n      if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDragging(false)\n    },\n    onDrop: (event) => {\n      event.preventDefault()\n      setDragging(false)\n      receive(Array.from(event.dataTransfer.files))\n    },\n  }\n  return { dragging, errors, receive, dragHandlers }\n}\n\nfunction FileUpload({\n  variant = \"dropzone\", effect, items = [], onFilesAccepted, onFilesRejected, onRemove, onRetry, accept, maxSize, maxFiles,\n  multiple, disabled = false, label, hint, labels, messages, className, ...props\n}: FileUploadProps) {\n  const avatar = variant === \"avatar\"\n  const mergedLabels = { ...DEFAULT_LABELS, ...labels }\n  const action = label ?? DEFAULT_ACTION[variant]\n  const { dragging, errors, receive, dragHandlers } = useFileIntake({\n    onFilesAccepted, onFilesRejected, accept, maxSize, maxFiles, messages, disabled, avatar, itemCount: items.length,\n  })\n  const input = (\n    <input\n      type=\"file\"\n      data-slot=\"file-upload-input\"\n      aria-label={action}\n      accept={accept}\n      multiple={avatar ? false : multiple}\n      disabled={disabled}\n      className=\"sr-only\"\n      onChange={(event) => {\n        receive(Array.from(event.target.files ?? []))\n        // Cleared so choosing the same file again, after removing it, still fires change.\n        event.target.value = \"\"\n      }}\n    />\n  )\n  const zone = { input, dragging, disabled, label: action, hint, dragHandlers }\n  return (\n    <div data-slot=\"file-upload\" data-variant={variant} data-effect={effect} className={cn(\"grid w-full min-w-0 gap-3\", className)} {...props}>\n      {variant === \"dropzone\" ? <DropzoneArea {...zone} /> : null}\n      {variant === \"button\" ? <ButtonArea {...zone} /> : null}\n      {variant === \"compact\" ? <CompactArea {...zone} /> : null}\n      {avatar ? <AvatarArea {...zone} item={items[0]} labels={mergedLabels} onRemove={onRemove} /> : null}\n      {errors.length ? (\n        <ul role=\"alert\" data-slot=\"file-upload-errors\" className=\"grid gap-1 text-[13px] text-destructive\">\n          {errors.map((error) => <li key={error} className=\"break-words\">{error}</li>)}\n        </ul>\n      ) : null}\n      {avatar ? null : (\n        <FileList items={items} materialize={effect === \"materialize\"} compact={variant === \"compact\"} disabled={disabled} labels={mergedLabels} onRemove={onRemove} onRetry={onRetry} />\n      )}\n    </div>\n  )\n}\n\nexport {\n  FileUpload,\n  type FileUploadProps,\n  type FileUploadItem,\n  type FileUploadLabels,\n  type FileUploadVariant,\n  type FileUploadEffect,\n  type FileRejection,\n  type FileMessages,\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/file-upload-math.ts",
      "content": "type RejectionReason = \"type\" | \"size\" | \"count\"\n\ninterface FileRejection {\n  file: File\n  reason: RejectionReason\n  message: string\n}\n\ninterface FileLimits {\n  /** The same syntax as the input's accept attribute: \".pdf,image/*\". */\n  accept?: string\n  /** Bytes. */\n  maxSize?: number\n  /** Total, counting what is already in the list. */\n  maxFiles?: number\n  /** How many are already in the list. */\n  existing?: number\n}\n\ninterface FileMessages {\n  type: (file: File, accept: string) => string\n  size: (file: File, maxSize: number) => string\n  count: (file: File, maxFiles: number) => string\n}\n\nconst BYTE_UNITS = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\"] as const\nconst KILO = 1024\n\nfunction formatBytes(bytes: number) {\n  if (!Number.isFinite(bytes) || bytes <= 0) return \"0 B\"\n  const unit = Math.min(Math.floor(Math.log(bytes) / Math.log(KILO)), BYTE_UNITS.length - 1)\n  const value = bytes / KILO ** unit\n  return `${value >= 10 ? Math.round(value) : Number(value.toFixed(1))} ${BYTE_UNITS[unit]}`\n}\n\n/**\n * Picking through the dialog filters by accept, but dropping does not, so drops have to be checked by hand.\n * Some systems report an empty type, so an extension rule is the only one that can match those.\n */\nfunction matchesAccept(file: File, accept?: string) {\n  if (!accept) return true\n  const name = file.name.toLowerCase()\n  const type = file.type.toLowerCase()\n  return accept.split(\",\").some((rule) => {\n    const clean = rule.trim().toLowerCase()\n    if (!clean) return false\n    if (clean.startsWith(\".\")) return name.endsWith(clean)\n    if (clean.endsWith(\"/*\")) return type.startsWith(clean.slice(0, -1))\n    return type === clean\n  })\n}\n\nconst DEFAULT_MESSAGES: FileMessages = {\n  type: (file) => `${file.name} is not an accepted file type.`,\n  size: (file, maxSize) => `${file.name} is larger than ${formatBytes(maxSize)}.`,\n  count: (_file, maxFiles) => (maxFiles === 1 ? \"Only one file can be added.\" : `Only ${maxFiles} files can be added.`),\n}\n\nfunction reasonFor(file: File, limits: FileLimits, acceptedSoFar: number): RejectionReason | null {\n  if (!matchesAccept(file, limits.accept)) return \"type\"\n  if (limits.maxSize !== undefined && file.size > limits.maxSize) return \"size\"\n  if (limits.maxFiles !== undefined && (limits.existing ?? 0) + acceptedSoFar >= limits.maxFiles) return \"count\"\n  return null\n}\n\nfunction messageFor(reason: RejectionReason, file: File, limits: FileLimits, messages: FileMessages) {\n  if (reason === \"type\") return messages.type(file, limits.accept ?? \"\")\n  if (reason === \"size\") return messages.size(file, limits.maxSize ?? 0)\n  return messages.count(file, limits.maxFiles ?? 0)\n}\n\nfunction validateFiles(files: readonly File[], limits: FileLimits, messages: FileMessages = DEFAULT_MESSAGES) {\n  return files.reduce<{ accepted: readonly File[]; rejected: readonly FileRejection[] }>(\n    (result, file) => {\n      const reason = reasonFor(file, limits, result.accepted.length)\n      if (!reason) return { ...result, accepted: [...result.accepted, file] }\n      const rejection = { file, reason, message: messageFor(reason, file, limits, messages) }\n      return { ...result, rejected: [...result.rejected, rejection] }\n    },\n    { accepted: [], rejected: [] },\n  )\n}\n\n/** Several files over the count limit all say the same sentence; once is enough. */\nfunction uniqueMessages(rejected: readonly FileRejection[]) {\n  return [...new Set(rejected.map((rejection) => rejection.message))]\n}\n\nexport {\n  DEFAULT_MESSAGES,\n  formatBytes,\n  matchesAccept,\n  uniqueMessages,\n  validateFiles,\n  type FileLimits,\n  type FileMessages,\n  type FileRejection,\n  type RejectionReason,\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/file-upload-row.tsx",
      "content": "import { AlertCircle, Check, File as FileIcon, RotateCcw, X } from \"lucide-react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\nimport { SPRING_DEFAULT } from \"@/registry/miseui/lib/motion\"\nimport { formatBytes } from \"@/registry/miseui/ui/file-upload-math\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype FileStatus = \"queued\" | \"uploading\" | \"done\" | \"error\"\n\ninterface FileUploadItem {\n  id: string\n  name: string\n  /** Bytes. */\n  size: number\n  status?: FileStatus\n  /** 0 to 100. Leave it out while the size of the job is unknown. */\n  progress?: number\n  /** The sentence to show when `status` is `error`. */\n  error?: string\n  /** An image to show in the `avatar` variant. */\n  previewUrl?: string\n}\n\ninterface FileUploadLabels {\n  remove: (name: string) => string\n  retry: (name: string) => string\n  uploading: (name: string) => string\n  queued: string\n  done: string\n  failed: string\n}\n\nconst ICON_BUTTON_CLASS = cn(\n  \"flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none pointer-coarse:size-10\",\n  \"transition-colors duration-(--duration-fast) ease-(--ease-mise) hover:bg-accent hover:text-foreground\",\n  \"focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-4\",\n)\n\nconst clampPercent = (value: number) => Math.min(100, Math.max(0, value))\n\nfunction statusText(item: FileUploadItem, labels: FileUploadLabels) {\n  if (item.status === \"error\") return item.error ?? labels.failed\n  if (item.status === \"done\") return labels.done\n  if (item.status === \"queued\") return labels.queued\n  return item.status === \"uploading\" && item.progress !== undefined ? `${Math.round(clampPercent(item.progress))}%` : \"\"\n}\n\nfunction StatusIcon({ status }: { status?: FileStatus }) {\n  // Fixed hues: success and failure must read the same when the accent is itself green or red.\n  if (status === \"done\") return <Check aria-hidden=\"true\" className=\"size-4 text-emerald-600 dark:text-emerald-400\" />\n  if (status === \"error\") return <AlertCircle aria-hidden=\"true\" className=\"size-4 text-destructive\" />\n  return <FileIcon aria-hidden=\"true\" className=\"size-4 text-muted-foreground\" />\n}\n\nfunction ProgressLine({ item, label }: { item: FileUploadItem; label: string }) {\n  const known = item.progress !== undefined\n  const percent = clampPercent(item.progress ?? 0)\n  return (\n    <div\n      role=\"progressbar\"\n      aria-label={label}\n      aria-valuemin={0}\n      aria-valuemax={100}\n      aria-valuenow={known ? Math.round(percent) : undefined}\n      className=\"mt-1 h-0.5 overflow-hidden rounded-full bg-muted\"\n    >\n      <div\n        className={cn(\n          \"h-full origin-left rounded-full bg-mise transition-transform duration-(--duration-base) ease-(--ease-mise) motion-reduce:transition-none\",\n          !known && \"w-1/3 motion-safe:animate-pulse\",\n        )}\n        style={known ? { transform: `scaleX(${percent / 100})` } : undefined}\n      />\n    </div>\n  )\n}\n\ninterface FileRowProps {\n  item: FileUploadItem\n  compact: boolean\n  disabled: boolean\n  labels: FileUploadLabels\n  onRemove?: (id: string) => void\n  onRetry?: (id: string) => void\n}\n\nfunction FileRow({ item, compact, disabled, labels, onRemove, onRetry }: FileRowProps) {\n  const failed = item.status === \"error\"\n  const status = statusText(item, labels)\n  const detail = failed ? status : [compact ? \"\" : formatBytes(item.size), status].filter(Boolean).join(\", \")\n  return (\n    <div className={cn(\"flex min-w-0 items-center gap-2\", compact ? \"min-h-8 py-1\" : \"rounded-md border px-3 py-2\")}>\n      <StatusIcon status={item.status} />\n      <div className=\"min-w-0 flex-1\">\n        <p className={cn(\"truncate\", compact ? \"text-[13px]\" : \"text-sm\")} title={item.name}>{item.name}</p>\n        {detail ? <p className={cn(\"text-[13px] break-words\", failed ? \"text-destructive\" : \"text-muted-foreground\")}>{detail}</p> : null}\n        {item.status === \"uploading\" && !compact ? <ProgressLine item={item} label={labels.uploading(item.name)} /> : null}\n      </div>\n      {failed && onRetry ? (\n        <button type=\"button\" aria-label={labels.retry(item.name)} disabled={disabled} className={ICON_BUTTON_CLASS} onClick={() => onRetry(item.id)}>\n          <RotateCcw aria-hidden=\"true\" />\n        </button>\n      ) : null}\n      {onRemove ? (\n        <button type=\"button\" aria-label={labels.remove(item.name)} disabled={disabled} className={ICON_BUTTON_CLASS} onClick={() => onRemove(item.id)}>\n          <X aria-hidden=\"true\" />\n        </button>\n      ) : null}\n    </div>\n  )\n}\n\ntype FileListProps = Omit<FileRowProps, \"item\"> & {\n  items: readonly FileUploadItem[]\n  materialize: boolean\n}\n\nconst ROW_HIDDEN = { opacity: 0, height: 0, scale: 0.96, filter: \"blur(4px)\" }\nconst ROW_SHOWN = { opacity: 1, height: \"auto\", scale: 1, filter: \"blur(0px)\" }\n\nfunction FileList({ items, materialize, ...row }: FileListProps) {\n  const reduceMotion = useReducedMotion() ?? false\n  if (items.length === 0) return null\n  const listClass = row.compact ? \"grid min-w-0 divide-y\" : \"grid min-w-0 gap-2\"\n  if (!materialize || reduceMotion) {\n    return (\n      <ul data-slot=\"file-upload-list\" className={listClass}>\n        {items.map((item) => <li key={item.id} data-slot=\"file-upload-row\" className=\"min-w-0\"><FileRow item={item} {...row} /></li>)}\n      </ul>\n    )\n  }\n  return (\n    <ul data-slot=\"file-upload-list\" className={listClass}>\n      <AnimatePresence initial={false}>\n        {items.map((item) => (\n          <motion.li\n            key={item.id}\n            data-slot=\"file-upload-row\"\n            className=\"min-w-0 overflow-hidden\"\n            initial={ROW_HIDDEN}\n            animate={ROW_SHOWN}\n            exit={ROW_HIDDEN}\n            transition={SPRING_DEFAULT}\n          >\n            <FileRow item={item} {...row} />\n          </motion.li>\n        ))}\n      </AnimatePresence>\n    </ul>\n  )\n}\n\nexport { FileList, clampPercent, type FileStatus, type FileUploadItem, type FileUploadLabels }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/file-upload-zones.tsx",
      "content": "import type * as React from \"react\"\nimport { Camera, Paperclip, Upload, X } from \"lucide-react\"\nimport { clampPercent, type FileUploadItem, type FileUploadLabels } from \"@/registry/miseui/ui/file-upload-row\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ninterface ZoneProps {\n  /** The real file input. It stays in the label, so a click, Enter and Space all work with no script. */\n  input: React.ReactNode\n  dragging: boolean\n  disabled: boolean\n  label: string\n  hint?: string\n  dragHandlers: Pick<React.ComponentProps<\"label\">, \"onDragEnter\" | \"onDragOver\" | \"onDragLeave\" | \"onDrop\">\n}\n\n/** The ring follows the hidden input, because that is what actually holds keyboard focus. */\nconst FOCUS_RING_CLASS = \"focus-within:ring-[3px] focus-within:ring-ring/50\"\nconst DISABLED_CLASS = \"has-[:disabled]:pointer-events-none has-[:disabled]:opacity-50\"\n\nfunction DropzoneArea({ input, dragging, label, hint, dragHandlers }: ZoneProps) {\n  return (\n    <label\n      data-slot=\"file-upload-zone\"\n      data-dragging={dragging ? \"true\" : undefined}\n      className={cn(\n        \"flex cursor-pointer flex-col items-center gap-2 rounded-lg border border-dashed px-4 py-8 text-center\",\n        \"transition-colors duration-(--duration-fast) ease-(--ease-mise) hover:bg-accent/50\",\n        dragging && \"border-solid border-foreground bg-accent\",\n        FOCUS_RING_CLASS,\n        DISABLED_CLASS,\n      )}\n      {...dragHandlers}\n    >\n      {input}\n      <Upload aria-hidden=\"true\" className=\"size-4 text-muted-foreground\" />\n      <span className=\"text-sm font-medium\">{label}</span>\n      {hint ? <span className=\"text-[13px] text-muted-foreground\">{hint}</span> : null}\n    </label>\n  )\n}\n\nfunction ButtonArea({ input, dragging, label, hint, dragHandlers }: ZoneProps) {\n  return (\n    <div className=\"flex flex-wrap items-center gap-x-3 gap-y-1\">\n      <label\n        data-slot=\"file-upload-zone\"\n        data-dragging={dragging ? \"true\" : undefined}\n        className={cn(\n          \"inline-flex h-9 cursor-pointer items-center gap-2 rounded-md border px-3 text-sm font-medium shadow-xs pointer-coarse:h-10\",\n          \"transition-colors duration-(--duration-fast) ease-(--ease-mise) hover:bg-accent\",\n          dragging && \"border-foreground bg-accent\",\n          FOCUS_RING_CLASS,\n          DISABLED_CLASS,\n        )}\n        {...dragHandlers}\n      >\n        {input}\n        <Upload aria-hidden=\"true\" className=\"size-4\" />\n        {label}\n      </label>\n      {hint ? <span className=\"text-[13px] text-muted-foreground\">{hint}</span> : null}\n    </div>\n  )\n}\n\nfunction CompactArea({ input, dragging, label, hint, dragHandlers }: ZoneProps) {\n  return (\n    <label\n      data-slot=\"file-upload-zone\"\n      data-dragging={dragging ? \"true\" : undefined}\n      className={cn(\n        \"flex h-9 min-w-0 cursor-pointer items-center gap-2 rounded-md border border-input px-3 shadow-xs pointer-coarse:h-10\",\n        \"transition-colors duration-(--duration-fast) ease-(--ease-mise) hover:bg-accent/50\",\n        dragging && \"border-foreground bg-accent\",\n        FOCUS_RING_CLASS,\n        DISABLED_CLASS,\n      )}\n      {...dragHandlers}\n    >\n      {input}\n      <Paperclip aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />\n      {hint ? <span className=\"truncate text-sm text-muted-foreground\">{hint}</span> : null}\n      <span className=\"ml-auto shrink-0 text-[13px] font-medium\">{label}</span>\n    </label>\n  )\n}\n\nconst RING_RADIUS = 46\nconst RING_LENGTH = 2 * Math.PI * RING_RADIUS\n\nfunction AvatarProgress({ item, label }: { item: FileUploadItem; label: string }) {\n  const percent = clampPercent(item.progress ?? 0)\n  return (\n    <svg\n      role=\"progressbar\"\n      aria-label={label}\n      aria-valuemin={0}\n      aria-valuemax={100}\n      aria-valuenow={item.progress === undefined ? undefined : Math.round(percent)}\n      viewBox=\"0 0 100 100\"\n      className=\"pointer-events-none absolute inset-0 -rotate-90\"\n    >\n      <circle cx=\"50\" cy=\"50\" r={RING_RADIUS} fill=\"none\" strokeWidth=\"4\" className=\"stroke-background/70\" />\n      <circle\n        cx=\"50\"\n        cy=\"50\"\n        r={RING_RADIUS}\n        fill=\"none\"\n        strokeWidth=\"4\"\n        strokeLinecap=\"round\"\n        strokeDasharray={RING_LENGTH}\n        strokeDashoffset={RING_LENGTH * (1 - percent / 100)}\n        className=\"stroke-mise transition-[stroke-dashoffset] duration-(--duration-base) ease-(--ease-mise) motion-reduce:transition-none\"\n      />\n    </svg>\n  )\n}\n\ntype AvatarAreaProps = ZoneProps & {\n  item?: FileUploadItem\n  labels: FileUploadLabels\n  onRemove?: (id: string) => void\n}\n\nfunction AvatarArea({ input, dragging, label, hint, dragHandlers, item, labels, onRemove, disabled }: AvatarAreaProps) {\n  return (\n    <div className=\"flex min-w-0 items-center gap-4\">\n      <label\n        data-slot=\"file-upload-zone\"\n        data-dragging={dragging ? \"true\" : undefined}\n        className={cn(\n          \"group/avatar relative block size-24 shrink-0 cursor-pointer overflow-hidden rounded-full bg-muted\",\n          dragging && \"ring-2 ring-foreground\",\n          FOCUS_RING_CLASS,\n          DISABLED_CLASS,\n        )}\n        {...dragHandlers}\n      >\n        {input}\n        {item?.previewUrl ? <img src={item.previewUrl} alt=\"\" className=\"size-full object-cover\" /> : null}\n        <span\n          aria-hidden=\"true\"\n          className={cn(\n            \"absolute inset-0 flex items-center justify-center transition-colors duration-(--duration-fast) ease-(--ease-mise)\",\n            item?.previewUrl ? \"text-transparent group-hover/avatar:bg-black/40 group-hover/avatar:text-white\" : \"text-muted-foreground\",\n          )}\n        >\n          <Camera className=\"size-4\" />\n        </span>\n        {item?.status === \"uploading\" ? <AvatarProgress item={item} label={labels.uploading(item.name)} /> : null}\n      </label>\n      <div className=\"grid min-w-0 justify-items-start gap-1\">\n        <span className=\"text-sm font-medium\">{label}</span>\n        {item?.status === \"error\" ? (\n          <span className=\"text-[13px] text-destructive\">{item.error ?? labels.failed}</span>\n        ) : hint ? (\n          <span className=\"text-[13px] text-muted-foreground\">{hint}</span>\n        ) : null}\n        {item && onRemove ? (\n          <button\n            type=\"button\"\n            disabled={disabled}\n            className=\"-ml-2 inline-flex h-8 items-center gap-1 rounded-md px-2 text-[13px] text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 pointer-coarse:h-10\"\n            onClick={() => onRemove(item.id)}\n          >\n            <X aria-hidden=\"true\" className=\"size-4\" />\n            {labels.remove(item.name)}\n          </button>\n        ) : null}\n      </div>\n    </div>\n  )\n}\n\nexport { AvatarArea, ButtonArea, CompactArea, DropzoneArea, type ZoneProps }\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "forms"
  ],
  "type": "registry:ui"
}