{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dropzone",
  "title": "Dropzone",
  "dependencies": [
    "@phosphor-icons/react"
  ],
  "registryDependencies": [
    "https://ui.hotfix.jobs/r/card.json",
    "https://ui.hotfix.jobs/r/progress.json",
    "https://ui.hotfix.jobs/r/recipes.json",
    "https://ui.hotfix.jobs/r/tokens.json",
    "https://ui.hotfix.jobs/r/utils.json"
  ],
  "files": [
    {
      "path": "registry/blocks/dropzone/index.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useRef, useState } from \"react\";\nimport type * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { focusRing } from \"@/lib/recipes\";\nimport { Progress } from \"@/components/ui/progress\";\nimport { Card } from \"@/components/ui/card\";\nimport { File, Upload, X } from \"@phosphor-icons/react/dist/ssr\";\nexport interface DropzoneProps\n  extends Omit<\n    React.HTMLAttributes<HTMLDivElement>,\n    \"onDrop\" | \"onChange\" | \"title\" | \"defaultValue\"\n  > {\n  /** Controlled file list. */\n  value?: File[];\n  /** Initial files when uncontrolled. */\n  defaultValue?: File[];\n  /** Called when the file list changes (add or remove). */\n  onValueChange?: (files: File[]) => void;\n  /** `accept` attribute passed to the underlying file input. */\n  accept?: string;\n  /** Allow multiple files. Default true. */\n  multiple?: boolean;\n  /** Max number of files. */\n  maxFiles?: number;\n  /** Max size per file in bytes. */\n  maxSize?: number;\n  /** Disable interaction. */\n  disabled?: boolean;\n  /** Render the file list below the drop area. Default true. */\n  showFileList?: boolean;\n  /**\n   * Validate a file before adding it. Return false (or a rejection reason)\n   * to refuse. Defaults to size + accept checks.\n   */\n  validate?: (file: File, currentFiles: File[]) => boolean | string;\n  /** Custom render function for each file row. */\n  renderFile?: (\n    file: File,\n    index: number,\n    onRemove: () => void,\n  ) => React.ReactNode;\n  /** Visual title rendered inside the drop area. */\n  title?: React.ReactNode;\n  /** Helper text rendered below the title. */\n  description?: React.ReactNode;\n  /** Uploading state: number 0-100 for determinate, `null` for indeterminate. */\n  progress?: number | null;\n  /** Visual size. `md` (default) — spacious drop area for standalone\n   *  upload surfaces. `sm` — tighter padding and icon for inline use\n   *  inside forms/sheets alongside other fields. */\n  size?: \"sm\" | \"md\";\n}\n\nconst DROP_AREA_SIZE: Record<\"sm\" | \"md\", string> = {\n  sm: \"px-4 py-5 gap-2\",\n  md: \"px-6 py-10 gap-3\",\n};\nconst DROP_ICON_SIZE: Record<\"sm\" | \"md\", string> = {\n  sm: \"size-5\",\n  md: \"size-6\",\n};\n\nfunction formatBytes(bytes: number): string {\n  if (bytes < 1024) return `${bytes} B`;\n  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;\n  if (bytes < 1024 * 1024 * 1024)\n    return `${(bytes / 1024 / 1024).toFixed(1)} MB`;\n  return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;\n}\n\n/** Drag-and-drop file upload with file list. */\nexport function Dropzone({\n  value: controlledValue,\n  defaultValue,\n  onValueChange,\n  accept,\n  multiple = true,\n  maxFiles,\n  maxSize,\n  disabled,\n  showFileList = true,\n  validate,\n  renderFile,\n  title = \"Drop files here or click to browse\",\n  description,\n  progress,\n  size = \"md\",\n  className,\n  ...props\n}: DropzoneProps): React.ReactElement {\n  const isUploading = progress !== undefined;\n  const interactionDisabled = disabled || isUploading;\n  const [uncontrolled, setUncontrolled] = useState<File[]>(defaultValue ?? []);\n  const files = controlledValue ?? uncontrolled;\n  const setFiles = useCallback(\n    (next: File[]) => {\n      if (controlledValue === undefined) setUncontrolled(next);\n      onValueChange?.(next);\n    },\n    [controlledValue, onValueChange],\n  );\n\n  const [isDragOver, setIsDragOver] = useState(false);\n  const inputRef = useRef<HTMLInputElement>(null);\n\n  const defaultValidate = useCallback(\n    (file: File): boolean | string => {\n      if (maxSize != null && file.size > maxSize) {\n        return `File exceeds ${formatBytes(maxSize)}`;\n      }\n      return true;\n    },\n    [maxSize],\n  );\n\n  const accept_files = useCallback(\n    (incoming: FileList | File[]) => {\n      const arr = Array.from(incoming);\n      const out: File[] = [...files];\n      for (const f of arr) {\n        if (maxFiles != null && out.length >= maxFiles) break;\n        const fn = validate ?? defaultValidate;\n        const result = fn(f, out);\n        if (result !== true) continue;\n        out.push(f);\n      }\n      setFiles(out);\n    },\n    [files, validate, defaultValidate, maxFiles, setFiles],\n  );\n\n  const removeAt = useCallback(\n    (index: number) => {\n      setFiles(files.filter((_, i) => i !== index));\n    },\n    [files, setFiles],\n  );\n\n  return (\n    <div\n      data-slot=\"dropzone-root\"\n      className={cn(\"flex w-full flex-col gap-3\", className)}\n      {...props}\n    >\n      <div\n        role=\"button\"\n        tabIndex={interactionDisabled ? -1 : 0}\n        aria-disabled={interactionDisabled || undefined}\n        data-slot=\"dropzone\"\n        data-drag-over={isDragOver || undefined}\n        onClick={() => {\n          if (interactionDisabled) return;\n          inputRef.current?.click();\n        }}\n        onKeyDown={(e) => {\n          if (interactionDisabled) return;\n          if (e.key === \"Enter\" || e.key === \" \") {\n            e.preventDefault();\n            inputRef.current?.click();\n          }\n        }}\n        onDragOver={(e) => {\n          if (interactionDisabled) return;\n          e.preventDefault();\n          if (!isDragOver) setIsDragOver(true);\n        }}\n        onDragLeave={(e) => {\n          if (e.currentTarget.contains(e.relatedTarget as Node)) return;\n          setIsDragOver(false);\n        }}\n        onDrop={(e) => {\n          e.preventDefault();\n          setIsDragOver(false);\n          if (interactionDisabled) return;\n          if (e.dataTransfer.files.length > 0) {\n            accept_files(e.dataTransfer.files);\n          }\n        }}\n        className={cn(\n          \"relative flex w-full flex-col items-center justify-center rounded-[var(--radius-6)] border border-dashed border-hairline\",\n          DROP_AREA_SIZE[size],\n          \"text-center transition-colors duration-[var(--duration-state)] ease-[var(--ease-standard)]\",\n          \"hover:border-hairline-tertiary hover:bg-layer-hover\",\n          \"data-[drag-over]:border-primary data-[drag-over]:bg-fill-1\",\n          disabled && \"pointer-events-none opacity-50\",\n          isUploading && \"pointer-events-none\",\n          focusRing,\n        )}\n      >\n        <Upload aria-hidden className={cn(DROP_ICON_SIZE[size], \"text-ink-muted\")} />\n        <div className=\"flex flex-col gap-1\">\n          <p className=\"text-small font-medium text-ink\">\n            {title}\n          </p>\n          {description && (\n            <p className=\"text-mini text-ink-muted\">\n              {description}\n            </p>\n          )}\n        </div>\n        <input\n          ref={inputRef}\n          type=\"file\"\n          accept={accept}\n          multiple={multiple}\n          disabled={interactionDisabled}\n          onChange={(e) => {\n            if (e.target.files) accept_files(e.target.files);\n            e.target.value = \"\";\n          }}\n          className=\"sr-only\"\n          data-slot=\"dropzone-input\"\n        />\n      </div>\n\n      {showFileList && files.length > 0 && (\n        <ul\n          data-slot=\"dropzone-file-list\"\n          className=\"flex flex-col gap-1\"\n        >\n          {files.map((f, i) =>\n            renderFile ? (\n              <li key={`${f.name}-${i}`}>{renderFile(f, i, () => removeAt(i))}</li>\n            ) : (\n              <li\n                key={`${f.name}-${i}`}\n                data-slot=\"dropzone-file\"\n                data-state={isUploading ? \"uploading\" : undefined}\n              >\n                <Card\n                  className=\"flex items-center gap-3 px-3 py-2\"\n                >\n                  <File className=\"size-4 shrink-0 text-ink-muted\" />\n                  <div className=\"flex min-w-0 flex-1 flex-col gap-1\">\n                    <span className=\"truncate text-small text-ink\">\n                      {f.name}\n                    </span>\n                    {isUploading ? (\n                      <Progress\n                        value={progress}\n                        size=\"sm\"\n                        label={`Uploading ${f.name}`}\n                      />\n                    ) : (\n                      <span className=\"text-mini text-ink-muted\">\n                        {formatBytes(f.size)}\n                      </span>\n                    )}\n                  </div>\n                  {!isUploading && (\n                    <button\n                      type=\"button\"\n                      onClick={() => removeAt(i)}\n                      aria-label={`Remove ${f.name}`}\n                      className=\"inline-flex size-6 shrink-0 items-center justify-center rounded-full text-ink-muted transition-colors duration-[var(--duration-state)] ease-[var(--ease-standard)] hover:bg-layer-hover hover:text-ink\"\n                    >\n                      <X className=\"size-3.5\" aria-hidden />\n                    </button>\n                  )}\n                </Card>\n              </li>\n            ),\n          )}\n        </ul>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/blocks/dropzone/index.tsx"
    }
  ],
  "type": "registry:block"
}