{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"file-dropzone","type":"registry:ui","title":"File Dropzone","description":"Drop files or browse for them, with type, size and count checks and a list of what was picked.","dependencies":["lucide-react"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-files.json","https://dashral.com/r/lib-locale.json","https://dashral.com/r/field.json"],"files":[{"path":"registry/dashral/file-dropzone.tsx","type":"registry:file","target":"components/dashral/file-dropzone.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { UploadCloudIcon, XIcon } from \"lucide-react\"\n\nimport { acceptLabel, accepts, FileThumb, formatBytes } from \"./lib/files\"\nimport { useLocale } from \"./lib/locale\"\nimport { cn } from \"./lib/utils\"\nimport { FieldWrapper, joinIds, splitFieldProps, useFieldControl, type FieldClassNames, type FieldControlProps, type FieldOwnProps } from \"./field\"\n\ntype FileDropzoneProps = FieldOwnProps & {\n  /** Controlled list of files. Omit it (and use defaultValue) for an uncontrolled dropzone. */\n  value?: File[]\n  defaultValue?: File[]\n  onValueChange?: (files: File[]) => void\n  /** Like the `accept` attribute: \"image/*\", \".pdf,.docx\", \"image/png,image/jpeg\". */\n  accept?: string\n  multiple?: boolean\n  maxFiles?: number\n  /** Largest file, in bytes. */\n  maxSize?: number\n  disabled?: boolean\n  required?: boolean\n  /** The main line inside the drop area, e.g. \"Drop your logo here\". */\n  title?: React.ReactNode\n  /** false hides the list of picked files, to show your own (e.g. with upload progress). */\n  showFiles?: boolean\n  /** The line under it, inside the drop area; by default it says what's accepted and how big. */\n  hint?: React.ReactNode\n  id?: string\n  name?: string\n  /** Classes for the drop area and the list of files under it. */\n  className?: string\n  \"aria-invalid\"?: React.AriaAttributes[\"aria-invalid\"]\n  classNames?: FieldClassNames & { dropzone?: string; files?: string }\n}\n\n/**\n * Drop files on it, or click (or press Enter / Space) to pick them. Checks type, size and count in the browser and\n * says what it turned down; the files themselves go wherever `onValueChange` sends them.\n */\nfunction FileDropzone(props: FileDropzoneProps) {\n  const [field, { required: _required, ...control }] = splitFieldProps(props)\n  return (\n    <FieldWrapper {...field}>\n      <FileDropzoneControl {...control} />\n    </FieldWrapper>\n  )\n}\n\nfunction FileDropzoneControl({\n  value,\n  defaultValue = [],\n  onValueChange,\n  accept,\n  multiple = true,\n  maxFiles,\n  maxSize,\n  title,\n  hint: hintProp,\n  showFiles = true,\n  classNames,\n  name,\n  className,\n  ...rest\n}: Omit<FileDropzoneProps, keyof FieldOwnProps | \"required\">) {\n  const { id, disabled, ...aria } = useFieldControl<FieldControlProps>({ id: rest.id, disabled: rest.disabled, \"aria-invalid\": rest[\"aria-invalid\"] })\n  const { t, locale } = useLocale()\n  const [internal, setInternal] = React.useState(defaultValue)\n  const files = value ?? internal\n  const [dragging, setDragging] = React.useState(false)\n  const [errors, setErrors] = React.useState<string[]>([])\n  const inputRef = React.useRef<HTMLInputElement>(null)\n  const hintId = React.useId()\n  const limit = multiple ? (maxFiles ?? Infinity) : 1\n\n  function set(next: File[]) {\n    if (value === undefined) setInternal(next)\n    onValueChange?.(next)\n  }\n\n  function add(list: FileList | null) {\n    if (!list || disabled) return\n    const problems: string[] = []\n    // One file at a time replaces the current one; otherwise new files are added up to the limit.\n    let next = multiple ? [...files] : []\n    for (const file of Array.from(list)) {\n      if (!accepts(file, accept)) problems.push(t(\"fileType\", { name: file.name }))\n      else if (maxSize !== undefined && file.size > maxSize) problems.push(t(\"fileSize\", { name: file.name, size: formatBytes(maxSize, locale) }))\n      else if (next.length >= limit) problems.push(t(\"fileLimit\", { name: file.name, count: limit }))\n      else if (!next.some((f) => f.name === file.name && f.size === file.size)) next = [...next, file]\n    }\n    setErrors(problems)\n    set(next)\n  }\n\n  const hint =\n    hintProp ??\n    [accept && acceptLabel(accept, t), maxSize && t(\"upToSize\", { size: formatBytes(maxSize, locale) }), multiple && maxFiles && t(\"filesMax\", { count: maxFiles })].filter(Boolean).join(\" · \")\n\n  return (\n    <div data-slot=\"file-dropzone\" className={cn(\"grid gap-3\", className)}>\n      <button\n        type=\"button\"\n        id={id}\n        disabled={disabled}\n        aria-describedby={joinIds(hint ? hintId : undefined, aria[\"aria-describedby\"])}\n        aria-invalid={aria[\"aria-invalid\"]}\n        onClick={() => inputRef.current?.click()}\n        onDragOver={(event) => {\n          event.preventDefault()\n          if (!disabled) setDragging(true)\n        }}\n        onDragLeave={() => setDragging(false)}\n        onDrop={(event) => {\n          event.preventDefault()\n          setDragging(false)\n          add(event.dataTransfer.files)\n        }}\n        data-dragging={dragging || undefined}\n        className={cn(\n          \"flex w-full flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-input px-6 py-8 text-center transition-colors outline-none\",\n          \"hover:border-ring/60 hover:bg-accent/40 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50\",\n          \"data-dragging:border-primary data-dragging:bg-primary/5 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive/60\",\n          classNames?.dropzone\n        )}\n      >\n        <span className=\"flex size-10 items-center justify-center rounded-full bg-muted text-muted-foreground\" aria-hidden>\n          <UploadCloudIcon className=\"size-5\" />\n        </span>\n        <span className=\"text-sm font-medium\">\n          {title ?? (\n            <>\n              {/* The message, with its {browse} word underlined like a link. */}\n              {t(multiple ? \"dropFiles\" : \"dropFile\", { browse: \"\\u0000\" })\n                .split(\"\\u0000\")\n                .flatMap((part, i) => (i ? [<span key={i} className=\"text-primary-text underline underline-offset-4\">{t(\"browse\")}</span>, part] : [part]))}\n            </>\n          )}\n        </span>\n        {hint && (\n          <span id={hintId} className=\"text-xs text-muted-foreground\">\n            {hint}\n          </span>\n        )}\n      </button>\n      <input\n        ref={inputRef}\n        type=\"file\"\n        name={name}\n        accept={accept}\n        multiple={multiple}\n        disabled={disabled}\n        className=\"sr-only\"\n        tabIndex={-1}\n        aria-hidden\n        onChange={(event) => {\n          add(event.target.files)\n          event.target.value = \"\"\n        }}\n      />\n      {errors.length > 0 && (\n        <ul role=\"alert\" className=\"grid gap-1 text-xs text-destructive-text\">\n          {errors.map((error) => (\n            <li key={error}>{error}</li>\n          ))}\n        </ul>\n      )}\n      {showFiles && files.length > 0 && (\n        <ul aria-label={t(\"chosenFiles\")} className={cn(\"grid gap-2\", classNames?.files)}>\n          {files.map((file) => (\n            <li key={`${file.name}-${file.size}`} className=\"flex items-center gap-3 rounded-lg border bg-card p-2 pe-1.5 text-sm\">\n              <FileThumb file={file} />\n              <div className=\"min-w-0 flex-1\">\n                <p className=\"truncate font-medium\">{file.name}</p>\n                <p className=\"text-xs text-muted-foreground tabular-nums\">{formatBytes(file.size, locale)}</p>\n              </div>\n              <button\n                type=\"button\"\n                aria-label={t(\"remove\", { name: file.name })}\n                disabled={disabled}\n                className=\"flex size-8 items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50\"\n                onClick={() => set(files.filter((f) => f !== file))}\n              >\n                <XIcon className=\"size-4\" aria-hidden />\n              </button>\n            </li>\n          ))}\n        </ul>\n      )}\n    </div>\n  )\n}\n\nexport { FileDropzone }\n"}],"cssVars":{"theme":{"color-primary-text":"var(--primary-text, var(--primary))","color-destructive-foreground":"var(--destructive-foreground)","color-destructive-text":"var(--destructive-text)"},"light":{"destructive-foreground":"oklch(0.985 0 0)","destructive-text":"#bd2800"},"dark":{"destructive-foreground":"oklch(0.2 0 0)","destructive-text":"#ff8a5c"}}}