{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-upload",
  "title": "File Upload",
  "description": "File selection and drag and drop with validation, progress, retry, and removal.",
  "dependencies": [
    "class-variance-authority",
    "lucide-react"
  ],
  "registryDependencies": [
    "button",
    "progress",
    "https://uaiblocks.dev/r/uai-theme.json",
    "https://uaiblocks.dev/r/uai-utils.json"
  ],
  "files": [
    {
      "path": "src/registry/uai/components/file-upload.tsx",
      "content": "\"use client\";\n\nimport { cva } from \"class-variance-authority\";\nimport { Upload } from \"lucide-react\";\nimport { type ComponentProps, createContext, useContext, useId, useRef, useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Progress } from \"@/components/ui/progress\";\nimport { cn } from \"@/lib/uai-utils\";\n\nexport const FILE_UPLOAD_VARIANTS = [\"dropzone\", \"inline\", \"compact\"] as const;\nexport type FileUploadVariant = (typeof FILE_UPLOAD_VARIANTS)[number];\nexport type FileUploadRejection = { file: File; reason: string };\nexport type FileUploadProps = ComponentProps<\"div\"> & {\n  variant?: FileUploadVariant;\n  accept?: string;\n  maxSize?: number;\n  maxFiles?: number;\n  fileCount?: number;\n  disabled?: boolean;\n  multiple?: boolean;\n  onFilesAccepted?: (files: File[]) => void;\n  onFilesRejected?: (files: FileUploadRejection[]) => void;\n};\ntype UploadContext = {\n  id: string;\n  variant: FileUploadVariant;\n  disabled: boolean;\n  accept?: string;\n  multiple: boolean;\n  inputRef: React.RefObject<HTMLInputElement | null>;\n  receive: (files: File[]) => void;\n};\nconst Context = createContext<UploadContext | null>(null);\nconst buttonBase =\n  \"cursor-pointer rounded-full border-0 py-0 font-medium [transition:background-color_120ms_ease-out,color_120ms_ease-out,scale_140ms_cubic-bezier(0.23,1,0.32,1)] not-disabled:active:scale-[0.97] focus-visible:ring-0 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring focus-visible:outline-solid disabled:pointer-events-auto disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none\";\nconst raisedButton =\n  \"bg-secondary text-foreground hover:bg-secondary not-disabled:hover:bg-[color-mix(in_oklab,var(--secondary)_85%,var(--foreground))]\";\nconst actionClass = \"h-6.5 justify-self-start px-2.5 text-[12px] has-[>svg]:px-2.5\";\nconst dangerText = \"text-destructive-ink\";\nfunction useUpload() {\n  const context = useContext(Context);\n  if (!context) throw new Error(\"FileUpload children must be used within FileUpload\");\n  return context;\n}\nfunction acceptsFile(file: File, accept?: string) {\n  if (!accept) return true;\n  return accept.split(\",\").some((entry) => {\n    const rule = entry.trim().toLowerCase();\n    if (rule.startsWith(\".\")) return file.name.toLowerCase().endsWith(rule);\n    if (rule.endsWith(\"/*\")) return file.type.toLowerCase().startsWith(rule.slice(0, -1));\n    return file.type.toLowerCase() === rule;\n  });\n}\nexport function FileUpload({\n  variant = \"dropzone\",\n  accept,\n  maxSize = Infinity,\n  maxFiles = Infinity,\n  fileCount = 0,\n  disabled = false,\n  multiple = true,\n  onFilesAccepted,\n  onFilesRejected,\n  children,\n  className,\n  ...props\n}: FileUploadProps) {\n  const id = useId();\n  const inputRef = useRef<HTMLInputElement>(null);\n  const [errors, setErrors] = useState<FileUploadRejection[]>([]);\n  const receive = (files: File[]) => {\n    if (disabled) return;\n    const accepted: File[] = [];\n    const rejected: FileUploadRejection[] = [];\n    for (const file of files) {\n      let reason = \"\";\n      if (!acceptsFile(file, accept)) reason = \"File type is not supported.\";\n      else if (file.size > maxSize) reason = \"File exceeds the size limit.\";\n      else if (accepted.length + fileCount >= Math.min(maxFiles, multiple ? Infinity : 1))\n        reason = \"File count limit reached.\";\n      if (reason) rejected.push({ file, reason });\n      else accepted.push(file);\n    }\n    setErrors(rejected);\n    if (accepted.length) onFilesAccepted?.(accepted);\n    if (rejected.length) onFilesRejected?.(rejected);\n  };\n  return (\n    <Context.Provider value={{ id, variant, accept, multiple, disabled, inputRef, receive }}>\n      <div\n        data-slot=\"file-upload\"\n        className={cn(\"grid min-w-0 gap-2.5 text-[13px]/[18px] text-foreground\", className)}\n        {...props}\n        data-variant={variant}\n      >\n        {children}\n        <div role=\"alert\" id={`${id}-errors`} className=\"grid gap-1\">\n          {errors.map((error) => (\n            <p\n              key={`${error.file.name}-${error.file.size}-${error.file.lastModified}`}\n              className={cn(\n                \"m-0 animate-in text-xs/4 wrap-anywhere duration-200 ease-out-quint fade-in-0 slide-in-from-top-1 motion-reduce:animate-none\",\n                dangerText,\n              )}\n            >\n              {error.file.name}: {error.reason}\n            </p>\n          ))}\n        </div>\n      </div>\n    </Context.Provider>\n  );\n}\nconst dropzoneVariants = cva(\n  \"group m-0 flex min-w-0 flex-wrap items-center border border-border-strong [transition:border-color_120ms_ease-out,background-color_120ms_ease-out] not-disabled:hover:border-[color-mix(in_oklab,var(--border-strong)_60%,var(--foreground))] data-dragging:border-primary data-dragging:bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))] motion-reduce:transition-none\",\n  {\n    variants: {\n      variant: {\n        dropzone: \"flex-col justify-center gap-2.5 rounded-[14px] border-dashed bg-card px-4 py-7\",\n        inline: \"flex-row justify-start gap-3 rounded-[14px] border-solid bg-card py-3 pr-3 pl-3.5\",\n        compact:\n          \"flex-row justify-start gap-3 rounded-xl border-solid bg-background py-2 pr-2 pl-2.5\",\n      },\n    },\n  },\n);\nexport function FileUploadDropzone({\n  children,\n  className,\n  onDragOver,\n  onDragLeave,\n  onDrop,\n  ...props\n}: ComponentProps<\"fieldset\">) {\n  const context = useUpload();\n  const [dragging, setDragging] = useState(false);\n  return (\n    <fieldset\n      data-slot=\"file-upload-dropzone\"\n      className={cn(\n        dropzoneVariants({ variant: context.variant }),\n        context.disabled && \"opacity-55\",\n        className,\n      )}\n      {...props}\n      aria-label=\"Upload files\"\n      data-dragging={dragging || undefined}\n      onDragOver={(event) => {\n        onDragOver?.(event);\n        event.preventDefault();\n        if (!context.disabled) setDragging(true);\n      }}\n      onDragLeave={(event) => {\n        onDragLeave?.(event);\n        if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDragging(false);\n      }}\n      onDrop={(event) => {\n        onDrop?.(event);\n        const cancelled = event.defaultPrevented;\n        event.preventDefault();\n        setDragging(false);\n        if (!cancelled) context.receive(Array.from(event.dataTransfer.files));\n      }}\n    >\n      <span\n        aria-hidden=\"true\"\n        className={cn(\n          \"grid shrink-0 place-items-center rounded-full bg-muted text-muted-foreground [transition:color_120ms_ease-out,translate_180ms_cubic-bezier(0.23,1,0.32,1)] group-data-dragging:-translate-y-0.5 group-data-dragging:text-primary-ink motion-reduce:transition-none\",\n          context.variant === \"compact\" ? \"size-7\" : \"size-9\",\n        )}\n      >\n        <Upload size={context.variant === \"compact\" ? 14 : 16} strokeWidth={1.75} />\n      </span>\n      {children}\n    </fieldset>\n  );\n}\nexport function FileUploadInput({\n  onChange,\n  className,\n  ...props\n}: Omit<ComponentProps<\"input\">, \"type\" | \"multiple\" | \"accept\" | \"disabled\" | \"id\">) {\n  const context = useUpload();\n  return (\n    <input\n      aria-label=\"Choose files\"\n      data-slot=\"file-upload-input\"\n      className={cn(\"sr-only\", className)}\n      {...props}\n      type=\"file\"\n      id={context.id}\n      ref={context.inputRef}\n      tabIndex={-1}\n      accept={context.accept}\n      multiple={context.multiple}\n      disabled={context.disabled}\n      aria-describedby={`${context.id}-errors`}\n      onChange={(event) => {\n        onChange?.(event);\n        if (!event.defaultPrevented) context.receive(Array.from(event.target.files ?? []));\n        event.target.value = \"\";\n      }}\n    />\n  );\n}\nexport function FileUploadTrigger({\n  children = \"Choose files\",\n  onClick,\n  className,\n  ...props\n}: ComponentProps<\"button\">) {\n  const context = useUpload();\n  return (\n    <Button\n      data-slot=\"file-upload-trigger\"\n      variant=\"secondary\"\n      className={cn(\n        buttonBase,\n        raisedButton,\n        \"shrink-0 px-3.5 text-[12.5px] has-[>svg]:px-3.5\",\n        context.variant === \"compact\" ? \"h-7\" : \"h-7.5\",\n        className,\n      )}\n      {...props}\n      type=\"button\"\n      disabled={context.disabled || props.disabled}\n      onClick={(event) => {\n        onClick?.(event);\n        if (!event.defaultPrevented) context.inputRef.current?.click();\n      }}\n    >\n      {children}\n    </Button>\n  );\n}\nexport function FileUploadList({ className, ...props }: ComponentProps<\"ul\">) {\n  return (\n    <ul\n      data-slot=\"file-upload-list\"\n      className={cn(\"m-0 grid list-none gap-1.5 p-0\", className)}\n      {...props}\n    />\n  );\n}\ntype FileItemContext = {\n  status: \"uploading\" | \"complete\" | \"error\";\n  progress: number;\n  disabled: boolean;\n};\nconst ItemContext = createContext<FileItemContext | null>(null);\nfunction useItem() {\n  const context = useContext(ItemContext);\n  if (!context) throw new Error(\"FileUpload item children must be used within FileUploadItem\");\n  return context;\n}\nconst statusTextClass: Record<FileItemContext[\"status\"], string> = {\n  uploading: \"shimmer-text motion-reduce:text-subtle-foreground\",\n  complete: \"text-success-ink\",\n  error: dangerText,\n};\nexport function FileUploadItem({\n  status = \"uploading\",\n  progress = 0,\n  children,\n  className,\n  ...props\n}: ComponentProps<\"li\"> & { status?: FileItemContext[\"status\"]; progress?: number }) {\n  const context = useUpload();\n  return (\n    <ItemContext.Provider\n      value={{\n        status,\n        progress: Number.isFinite(progress) ? Math.max(0, Math.min(100, progress)) : 0,\n        disabled: context.disabled,\n      }}\n    >\n      <li\n        data-slot=\"file-upload-item\"\n        className={cn(\n          \"flex animate-in flex-wrap items-center gap-x-2.5 gap-y-1.5 rounded-[10px] duration-240 ease-out-quint wrap-anywhere fade-in-0 slide-in-from-bottom-1 fill-mode-both motion-reduce:animate-none *:first:order-[-1] *:first:min-w-0 *:first:flex-auto\",\n          context.variant === \"compact\" ? \"py-1.5 pr-1.5 pl-3\" : \"py-2 pr-2 pl-3\",\n          status === \"error\"\n            ? \"bg-[color-mix(in_oklab,var(--destructive)_8%,var(--card))] shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--destructive)_28%,transparent)]\"\n            : \"bg-card shadow-[inset_0_0_0_1px_var(--border)]\",\n          className,\n        )}\n        {...props}\n        aria-busy={status === \"uploading\"}\n        data-status={status}\n      >\n        {children}\n        <span role=\"status\" className={cn(\"order-[-1] text-[11.5px]/4\", statusTextClass[status])}>\n          {status === \"uploading\"\n            ? \"Uploading…\"\n            : status === \"complete\"\n              ? \"Upload complete\"\n              : \"Upload failed\"}\n        </span>\n      </li>\n    </ItemContext.Provider>\n  );\n}\nexport function FileUploadProgress({\n  className,\n  ...props\n}: Omit<ComponentProps<typeof Progress>, \"value\" | \"max\">) {\n  const context = useItem();\n  if (context.status !== \"uploading\") return null;\n  return (\n    <Progress\n      aria-label=\"Upload progress\"\n      data-slot=\"file-upload-progress\"\n      className={cn(\n        \"order-10 block h-1 w-full basis-full overflow-hidden rounded-full border-0 bg-foreground/10 [&_[data-slot=progress-indicator]]:rounded-full [&_[data-slot=progress-indicator]]:bg-foreground [&_[data-slot=progress-indicator]]:[transition:transform_240ms_cubic-bezier(0.23,1,0.32,1)] motion-reduce:[&_[data-slot=progress-indicator]]:transition-none\",\n        className,\n      )}\n      {...props}\n      max={100}\n      value={context.progress}\n      // The shadcn Progress keeps `value` for the indicator only, so expose it to assistive tech here.\n      aria-valuenow={context.progress}\n      aria-valuetext={`${Math.round(context.progress)}%`}\n    />\n  );\n}\nexport function FileUploadRetry({\n  children = \"Retry upload\",\n  className,\n  ...props\n}: ComponentProps<\"button\">) {\n  const context = useItem();\n  if (context.status !== \"error\") return null;\n  return (\n    <Button\n      data-slot=\"file-upload-retry\"\n      variant=\"secondary\"\n      className={cn(buttonBase, raisedButton, actionClass, className)}\n      {...props}\n      type=\"button\"\n      disabled={context.disabled || props.disabled}\n    >\n      {children}\n    </Button>\n  );\n}\nexport function FileUploadRemove({\n  children = \"Remove file\",\n  className,\n  ...props\n}: ComponentProps<\"button\">) {\n  const context = useItem();\n  return (\n    <Button\n      data-slot=\"file-upload-remove\"\n      variant=\"ghost\"\n      className={cn(\n        buttonBase,\n        \"bg-transparent text-muted-foreground hover:bg-transparent hover:text-muted-foreground not-disabled:hover:bg-accent not-disabled:hover:text-foreground dark:hover:bg-transparent\",\n        actionClass,\n        className,\n      )}\n      {...props}\n      type=\"button\"\n      disabled={context.disabled || props.disabled}\n    >\n      {children}\n    </Button>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@ui/uai/file-upload.tsx"
    }
  ],
  "categories": [
    "forms"
  ],
  "type": "registry:ui"
}