Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,7 @@ export const categories: ComponentCategory[] = [
{ name: "comp-552" },
{ name: "comp-553" },
{ name: "comp-554" },
{ name: "comp-600" },
],
},
{
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"react-aria-components": "^1.10.1",
"react-day-picker": "^9.7.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.59.0",
"react-intersection-observer": "^9.16.0",
"react-payment-inputs": "^1.2.0",
"react-phone-number-input": "^3.4.12",
Expand Down
19 changes: 19 additions & 0 deletions registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -10943,6 +10943,25 @@
"tags": ["navbar, navigation"],
"colSpan": 3
}
},
{
"name": "comp-600",
"type": "registry:component",
"registryDependencies": ["https://originui.com/r/button.json", "https://originui.com/r/form.json"],
"files": [
{
"path": "registry/default/components/comp-600.tsx",
"type": "registry:component"
},
{
"path": "registry/default/hooks/use-file-upload.ts",
"type": "registry:hook"
}
],
"meta": {
"tags": ["upload", "file", "image", "drag and drop", "shadcn form", "zod file"],
"colSpan": 3
}
}
]
}
177 changes: 177 additions & 0 deletions registry/default/components/comp-600.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
"use client"

import { useEffect } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { ImageIcon, UploadIcon, XIcon } from "lucide-react"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod/v4"

import { useFileUpload } from "@/registry/default/hooks/use-file-upload"
import { Button } from "@/registry/default/ui/button"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/registry/default/ui/form"

const maxSizeMB = 2
const maxSize = maxSizeMB * 1024 * 1024 // 2MB default
const imageTypes = "image/svg+xml,image/png,image/jpeg,image/jpg,image/gif"

const formSchema = z.object({
image: z
.file()
.max(maxSize, { error: `Image can only be max size of ${maxSizeMB}MB` })
.mime(imageTypes.split(","))
.nullable(),
})

export default function Component() {
const [
{ files, isDragging },
{
handleDragEnter,
handleDragLeave,
handleDragOver,
handleDrop,
openFileDialog,
removeFile,
getInputProps,
},
] = useFileUpload({ accept: imageTypes })

const previewUrl = files[0]?.preview || null

useEffect(() => {
const imageFile = files[0] ? files[0].file : null
form.setValue("image", imageFile as File)
}, [files])

const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues: {
image: null,
},
})

function onSubmit({ image }: z.infer<typeof formSchema>) {
image
? toast(`You submitted the image of name "${image.name}"`)
: toast("You did not sumbit the form with value `null`")
}

return (
<div className="flex flex-col gap-2">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="relative">
<FormField
name="image"
control={form.control}
render={({ field: { name, value } }) => (
<FormItem>
<FormLabel>Avatar</FormLabel>
<div
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
data-dragging={isDragging || undefined}
className="border-input data-[dragging=true]:bg-accent/50 has-[input:focus]:border-ring has-[input:focus]:ring-ring/50 relative flex min-h-52 flex-col items-center justify-center overflow-hidden rounded-xl border border-dashed p-4 transition-colors has-[input:focus]:ring-[3px]"
>
<FormControl>
<input
name={name}
className="sr-only"
{...getInputProps()}
/>
</FormControl>
{previewUrl ? (
<div className="absolute inset-0 flex items-center justify-center p-4">
<img
src={previewUrl}
alt={files[0]?.file?.name || "Uploaded image"}
className="mx-auto max-h-full rounded object-contain"
/>
</div>
) : (
<div className="flex flex-col items-center justify-center px-4 py-3 text-center">
<div
className="bg-background mb-2 flex size-11 shrink-0 items-center justify-center rounded-full border"
aria-hidden="true"
>
<ImageIcon className="size-4 opacity-60" />
</div>
<p className="mb-1.5 text-sm font-medium">
Drop your image here
</p>
<p className="text-muted-foreground text-xs">
SVG, PNG, JPG or GIF (max. {maxSizeMB}MB)
</p>
<Button
type="button"
variant="outline"
className="mt-4"
onClick={openFileDialog}
>
<UploadIcon
className="-ms-1 size-4 opacity-60"
aria-hidden="true"
/>
Select image
</Button>
</div>
)}

{previewUrl && (
<div className="absolute top-4 right-4">
<button
type="button"
className="focus-visible:border-ring focus-visible:ring-ring/50 z-50 flex size-8 cursor-pointer items-center justify-center rounded-full bg-black/60 text-white transition-[color,box-shadow] outline-none hover:bg-black/80 focus-visible:ring-[3px]"
onClick={() => {
removeFile(files[0]?.id)
form.setValue("image", null)
form.clearErrors("image")
}}
aria-label="Remove image"
>
<XIcon className="size-4" aria-hidden="true" />
</button>
</div>
)}
</div>
<div className="flex items-start justify-between">
<div className="flex items-start gap-1">
<FormMessage className="text-xs" />
</div>
<Button type="submit" size="sm" variant="outline">
Submit
</Button>
</div>
</FormItem>
)}
/>
</form>
</Form>

<p
aria-live="polite"
role="region"
className="text-muted-foreground mt-2 text-center text-xs"
>
Image uploader with shadcn form ∙ (by{" "}
<a
href="https://github.com/alihamasdev"
className="hover:text-foreground underline"
>
Ali Hamas
</a>
)
</p>
</div>
)
}
167 changes: 167 additions & 0 deletions registry/default/ui/form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
"use client"

import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"

import { cn } from "@/registry/default/lib/utils"
import { Label } from "@/registry/default/ui/label"

const Form = FormProvider

type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}

const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)

const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}

const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)

if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}

const { id } = itemContext

return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}

type FormItemContextValue = {
id: string
}

const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)

function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()

return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}

function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()

return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}

function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()

return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}

function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()

return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}

function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children

if (!body) {
return null
}

return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}

export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}