` props are forwarded to the wrapper element.
diff --git a/package.json b/package.json
index f9ca16f..1da1948 100644
--- a/package.json
+++ b/package.json
@@ -40,6 +40,7 @@
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
+ "react-dropzone": "^15.0.0",
"tailwind-merge": "^3.5.0"
}
}
\ No newline at end of file
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9db1fd9..b1bb46a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -38,6 +38,9 @@ importers:
react-dom:
specifier: ^19.2.4
version: 19.2.4(react@19.2.4)
+ react-dropzone:
+ specifier: ^15.0.0
+ version: 15.0.0(react@19.2.4)
tailwind-merge:
specifier: ^3.5.0
version: 3.5.0
@@ -1978,6 +1981,10 @@ packages:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
+ attr-accept@2.2.5:
+ resolution: {integrity: sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==}
+ engines: {node: '>=4'}
+
available-typed-arrays@1.0.7:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
@@ -2616,6 +2623,10 @@ packages:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
+ file-selector@2.1.2:
+ resolution: {integrity: sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==}
+ engines: {node: '>= 12'}
+
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
@@ -3932,6 +3943,12 @@ packages:
peerDependencies:
react: ^19.2.4
+ react-dropzone@15.0.0:
+ resolution: {integrity: sha512-lGjYV/EoqEjEWPnmiSvH4v5IoIAwQM2W4Z1C0Q/Pw2xD0eVzKPS359BQTUMum+1fa0kH2nrKjuavmTPOGhpLPg==}
+ engines: {node: '>= 10.13'}
+ peerDependencies:
+ react: '>= 16.8 || 18.0.0'
+
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -6518,6 +6535,8 @@ snapshots:
async-function@1.0.0: {}
+ attr-accept@2.2.5: {}
+
available-typed-arrays@1.0.7:
dependencies:
possible-typed-array-names: 1.1.0
@@ -7334,6 +7353,10 @@ snapshots:
dependencies:
flat-cache: 4.0.1
+ file-selector@2.1.2:
+ dependencies:
+ tslib: 2.8.1
+
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
@@ -8974,6 +8997,13 @@ snapshots:
react: 19.2.4
scheduler: 0.27.0
+ react-dropzone@15.0.0(react@19.2.4):
+ dependencies:
+ attr-accept: 2.2.5
+ file-selector: 2.1.2
+ prop-types: 15.8.1
+ react: 19.2.4
+
react-is@16.13.1: {}
react-medium-image-zoom@5.4.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
diff --git a/registry.json b/registry.json
index b57b179..edf4ea6 100644
--- a/registry.json
+++ b/registry.json
@@ -95,6 +95,27 @@
}
],
"style": "radix-nova"
+ },
+ {
+ "name": "filepicker-dropzone",
+ "type": "registry:component",
+ "title": "Filepicker Dropzone",
+ "description": "A shadcn-styled file picker dropzone with translatable labels.",
+ "registryDependencies": [
+ "@shadcn/field"
+ ],
+ "dependencies": [
+ "react-dropzone",
+ "lucide-react"
+ ],
+ "files": [
+ {
+ "path": "registry/radix-nova/filepicker-dropzone.tsx",
+ "type": "registry:component",
+ "target": "components/c-ui/filepicker-dropzone.tsx"
+ }
+ ],
+ "style": "radix-nova"
}
]
-}
\ No newline at end of file
+}
diff --git a/registry/radix-nova/filepicker-dropzone.tsx b/registry/radix-nova/filepicker-dropzone.tsx
new file mode 100644
index 0000000..d2ab151
--- /dev/null
+++ b/registry/radix-nova/filepicker-dropzone.tsx
@@ -0,0 +1,285 @@
+"use client"
+
+import { FileUpIcon } from "lucide-react"
+import {
+ useDropzone,
+ type Accept,
+ type FileRejection,
+ type FileWithPath,
+} from "react-dropzone"
+
+import { cn } from "@/lib/utils"
+import {
+ Field,
+ FieldContent,
+ FieldDescription,
+ FieldError,
+ FieldLabel,
+} from "@/components/ui/field"
+import { useCallback, useEffect, useEffectEvent, useMemo } from "react"
+
+export interface FilepickerDropzoneLabels {
+ upload: string
+ drag: string
+ active: string
+ reject: string
+ rejectInvalidType: string
+ rejectTooLarge: string
+ rejectTooSmall: string
+ rejectTooMany: string
+ selectedCount: string
+}
+
+export interface FilepickerDropzoneProps
+ extends Omit
, "children" | "onChange"> {
+ id: string
+ name?: string
+ label?: React.ReactNode
+ description?: React.ReactNode
+ error?: React.ReactNode
+ accept?: Accept
+ multiple?: boolean
+ disabled?: boolean
+ required?: boolean
+ maxFiles?: number
+ maxSize?: number
+ minSize?: number
+ icon?: React.ReactNode
+ labelClassName?: string
+ contentClassName?: string
+ descriptionClassName?: string
+ errorClassName?: string
+ dropzoneClassName?: string
+ labels?: Partial
+ onChange?: (files: readonly FileWithPath[]) => void
+ onDropRejected?: (fileRejections: FileRejection[]) => void
+}
+
+const defaultLabels: FilepickerDropzoneLabels = {
+ upload: "Click to pick file",
+ drag: "or drag and drop",
+ active: "Drop files here",
+ reject: "Some files are not allowed",
+ rejectInvalidType: "One or more files have an invalid type.",
+ rejectTooLarge: "One or more files exceed the maximum size.",
+ rejectTooSmall: "One or more files are below the minimum size.",
+ rejectTooMany: "Too many files selected.",
+ selectedCount: "{{count}} files selected",
+}
+
+function interpolateCount(template: string, count: number) {
+ return template.replace(/\{\{\s*count\s*\}\}|\{count\}/g, String(count))
+}
+
+function FilepickerDropzone({
+ id,
+ name,
+ label,
+ description,
+ error,
+ accept,
+ multiple = false,
+ disabled = false,
+ required = false,
+ maxFiles,
+ maxSize,
+ minSize,
+ icon,
+ className,
+ labelClassName,
+ contentClassName,
+ descriptionClassName,
+ errorClassName,
+ dropzoneClassName,
+ labels,
+ onChange,
+ onDropRejected,
+ ...props
+}: FilepickerDropzoneProps) {
+ const mergedLabels = { ...defaultLabels, ...labels }
+ const descriptionId = description ? `${id}-description` : undefined
+
+ const onAcceptedFilesChange = useEffectEvent(
+ (files: readonly FileWithPath[]) => {
+ onChange?.(files)
+ }
+ )
+
+ const onRejectedFiles = useCallback(
+ (rejections: FileRejection[]) => {
+ onDropRejected?.(rejections)
+ },
+ [onDropRejected]
+ )
+
+ const {
+ acceptedFiles,
+ fileRejections,
+ getRootProps,
+ getInputProps,
+ isDragActive,
+ isDragReject,
+ isFocused,
+ } = useDropzone({
+ accept,
+ multiple,
+ disabled,
+ maxFiles,
+ maxSize,
+ minSize,
+ onDropRejected: onRejectedFiles,
+ })
+
+ const rejectionMessages = useMemo(() => {
+ if (fileRejections.length === 0) {
+ return []
+ }
+
+ const codes = new Set(
+ fileRejections.flatMap((rejection) =>
+ rejection.errors.map((dropzoneError) => dropzoneError.code)
+ )
+ )
+
+ const messages: string[] = []
+
+ if (codes.has("file-invalid-type")) {
+ messages.push(mergedLabels.rejectInvalidType)
+ }
+
+ if (codes.has("file-too-large")) {
+ messages.push(mergedLabels.rejectTooLarge)
+ }
+
+ if (codes.has("file-too-small")) {
+ messages.push(mergedLabels.rejectTooSmall)
+ }
+
+ if (codes.has("too-many-files")) {
+ messages.push(mergedLabels.rejectTooMany)
+ }
+
+ if (messages.length === 0) {
+ messages.push(mergedLabels.reject)
+ }
+
+ return messages
+ }, [
+ fileRejections,
+ mergedLabels.reject,
+ mergedLabels.rejectInvalidType,
+ mergedLabels.rejectTooLarge,
+ mergedLabels.rejectTooSmall,
+ mergedLabels.rejectTooMany,
+ ])
+
+ const hasError = Boolean(error) || rejectionMessages.length > 0
+ const errorId = hasError ? `${id}-error` : undefined
+ const describedBy = [descriptionId, errorId].filter(Boolean).join(" ") || undefined
+
+ useEffect(() => {
+ onAcceptedFilesChange(acceptedFiles)
+ }, [acceptedFiles])
+
+ const selectedCountLabel = interpolateCount(
+ mergedLabels.selectedCount,
+ acceptedFiles.length
+ )
+
+ return (
+
+ {label && (
+
+ {label}
+
+ )}
+
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+
+
+
+ {icon ?? }
+
+
+ {isDragReject && (
+
+ {mergedLabels.reject}
+
+ )}
+ {!isDragReject && isDragActive && (
+ {mergedLabels.active}
+ )}
+ {!isDragReject && !isDragActive && acceptedFiles.length > 0 && (
+ {selectedCountLabel}
+ )}
+ {!isDragReject && !isDragActive && acceptedFiles.length === 0 && (
+ <>
+
+ {mergedLabels.upload}
+ {" "}
+ {mergedLabels.drag}
+ >
+ )}
+
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : rejectionMessages.length > 0 ? (
+ ({ message }))}
+ />
+ ) : null}
+
+
+ )
+}
+
+export { FilepickerDropzone }