ui
ui copied to clipboard
Type Errors When Using File Input in Form
Hi,
I am using the <Form /> component with a file input inside of it. I am running into various type errors however, primarily with the deconstructed field values on the input saying type File cannot be assigned as its value. Here is my code:
useForm hook:
const form = useForm<z.infer<typeof formValidator>>({
resolver: zodResolver(formValidator),
defaultValues: {
name: "",
bio: "",
tag: "",
photo: new File([], ""),
},
});
Validator:
const formValidator = newTeamValidator.merge(
z.object({
photo: z.instanceof(File).refine((f) => f.size < c.maxProfilePhotoSizeInBytes),
})
);
Input:
<FormField
control={form.control}
name="photo"
render={({ field }) => (
<FormItem>
<FormLabel>Team Photo</FormLabel>
<FormControl>
<Input
accept="image/png, image/jpeg"
multiple={false}
className="dark:bg-transparent cursor-pointer file:cursor-pointer file:text-primary dark:border-primary dark:ring-offset-primary"
{...field}
/>
</FormControl>
<FormDescription>If you do not select a photo one will be generated.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
The error:
This is due to react-hook-form uses the field value by default, you need to update the onChange to use theFile object.
For example:
In the form schema:
media: z.instanceof(File)
the default value:
media: new File([], "")
then the input field:
<FormField
control={form.control}
name="media"
render={({ field }) => (
<FormItem>
<FormLabel>
Media
</FormLabel>
<FormControl>
<Input
accept=".jpg, .jpeg, .png, .svg, .gif, .mp4"
type="file"
onChange={(e) =>
field.onChange(e.target.files ? e.target.files[0] : null)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
onChange={(e) => field.onChange(e.target.files ? e.target.files[0] : null) }
Have you find a solution to this?
Anyone has any idea on how to style the file upload as a button without hacking the entire world? I feel dirty.
My current code below works... but the button is not accessible. I have a working solution using a Button without the asChild and with Input with sr-only classname. It supports single and multiple files and it works great, but it relies on having an innerRef that can be programmatically clicked when the button is clicked or being able to pass an explicity ref to the component from the consumer and since hook-form does not provide an easy access to the ref being passed to Form.Input I can't use that solution...
That's how I got to the nasty code below, which works and integrates well with React Hook Form and respective Zod validation, but is: 1) not accessible, 2) not very scalable (flimsy implementation) , 3) only supports one file
Edit: I was able to make the div that contains the input "focusable" by adding tabIndex={0} role="button" but I still find this ugly AF. There must be a better way.
// Implementation that works with React Hook Form but is not accessible
<Form.Field
control={form.control}
name="media"
render={({ field }) => (
<Form.Item>
<Form.Label>Media</Form.Label>
<Form.Control>
<Button asChild size="icon" variant="outline">
<div
tabIndex={0}
role="button"
className={cn("relative flex w-full items-center space-x-2", {
hidden: field.value !== null,
})}
>
<PlusIcon />
<span>Choose an image</span>
<Form.Input
className="absolute inset-0 cursor-pointer opacity-0"
accept="image/png, image/jpeg"
type="file"
onChange={(e) => {
field.onChange(e.target.files ? e.target.files[0] : null);
}}
/>
</div>
</Button>
</Form.Control>
{field.value !== null ? (
<div className="flex items-center gap-x-2 rounded bg-red-50">
<Button size="icon" variant="destructive" onClick={handleFileRemoval}>
<Cross1Icon />
</Button>
<span className="truncate py-2.5 text-xs">{field.value.name}</span>
</div>
) : null}
<Form.FieldMessage />
</Form.Item>
)}
/>
// Implementation that does not work with React Hook Form but is accessible
const FileUpload = forwardRef<FileUploadElement, FileUploadProps>(
(
{
children,
className,
id,
invalid,
multiple,
label,
onFileChanges,
size = 'md',
variant = 'secondary',
...props
},
ref,
) => {
/**
* Fallback `ref` for when consumer of `FileUpload` does not provide their own `ref`
*/
const innerRef = useRef<HTMLInputElement>(null)
const [innerFiles, setInnerFiles] = useState<File[]>([])
/**
* Notify consumers for file changes. Could be done without `useEffect` if changes
* came only from the `input` element, but since we can remove files through `chips`
* click useEffect becomes easier to handle for the consumers, since remove routine
* is handled internally and consumers only get the "full source".
*
* The wrapping button default event is prevented, allowing it's safe usage within
* forms.
*/
useEffect(() => {
onFileChanges(innerFiles)
}, [innerFiles, onFileChanges])
const hasLabel = !!label
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const newFiles = Array.from(e.currentTarget.files ?? [])
setInnerFiles((currentFiles) => [...currentFiles, ...newFiles])
}
function handleClick() {
if (ref !== null && typeof ref === 'object') {
console.info('File upload interaction handled with consumer forwarded ref')
ref.current?.click()
} else {
console.info('File upload interaction handled with inner ref consumer')
innerRef.current?.click()
}
}
function handleRemove(
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
fileName: string,
) {
e.preventDefault()
setInnerFiles((current) => current.filter((f) => f.name !== fileName))
}
return (
<div>
{hasLabel && <Label htmlFor={id}>{label}</Label>}
{!multiple && innerFiles.length > 0 ? null : (
<Button
className={cn(className, 'flex items-center', { 'mt-1': hasLabel })}
size={size}
variant={variant}
onClick={handleClick}
>
{children}
<input
accept="image/png, image/jpeg"
data-invalid={invalid ? '' : undefined}
id={id}
{...props}
className="sr-only"
onChange={handleChange}
ref={ref ?? innerRef}
type="file"
/>
</Button>
)}
{innerFiles.length > 0 ? (
<div className="mt-1 space-y-1">
{innerFiles.map((file) => (
<div
className="flex items-center gap-x-2 rounded bg-red-50"
key={file.name}
>
<Button
size="icon"
variant="destructive"
onClick={(e) => handleRemove(e, file.name)}
>
<Cross1Icon />
</Button>
<span className="truncate py-2.5 text-xs">{file.name}</span>
</div>
))}
</div>
) : null}
</div>
)
},
)
FileUpload.displayName = 'FileUpload'
Facing same issue ith this simple code
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import * as z from "zod"
import * as React from "react"
import { Button } from "@/components/ui/button"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
// Updated form schema to include file input
const formSchema = z.object({
username: z.string().min(2, {
message: "Username must be at least 2 characters.",
}),
file: z.instanceof(File, {
message: "Please upload a file.",
}),
})
const FileUpload = () => {
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
username: "",
file: "",
},
})
// Updated submit handler to include file handling
function onSubmit(values) {
console.log(values)
// Handle file upload logic here
}
return (
<div>
<Card className="w-[350px]">
<CardHeader>
<CardTitle>Upload</CardTitle>
<CardDescription>Generate </CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form className="space-y-8" onSubmit={form.handleSubmit(onSubmit)}>
{/* New File Input Field */}
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="shadcn" {...field} />
</FormControl>
<FormDescription>
This is your public display name.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="file"
render={({ field }) => (
<FormItem>
<FormLabel>File</FormLabel>
<FormControl>
<Input accept=".pdf, .docx" type="file" {...field} />
</FormControl>
<FormDescription>
Upload your syllabus here.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
</CardContent>
<CardFooter className="flex justify-between">
</CardFooter>
</Card>
</div>
)
}
export default FileUpload
@tkejr I don't recommend to use {...field} in
<FormControl>
<Input accept=".pdf, .docx" type="file" {...field} />
</FormControl>
It caused me a lot of pain. Do it like below
<FormControl>
<Input
accept=".pdf, .docx"
type="file"
onChange={(e) => field.onChange(e.target.files?.[0])}
/>
</FormControl>
Then, if you want to keep the validation tidy, I might recommend to remove also accept=".pdf, .docx" and implement the your formSchema like
const MAX_MB = 10
const MAX_UPLOAD_SIZE = 1024 * 1024 * MAX_MB;
const ACCEPTED_FILE_TYPES = [
'application/pdf',
'...', // other MIME types
];
const formSchema = z.object({
// ...
file: z
.instanceof(File)
.refine((file) => {
return !file || file.size <= MAX_UPLOAD_SIZE;
}, `File size must be less than ${MAX_MB}MB`)
.refine((file) => {
return ACCEPTED_FILE_TYPES.includes(file.type);
}, 'File must be a ...'),
})
Don't forget to initialise the file default value in the correct way
export const formConfig = {
resolver: zodResolver(formSchema),
defaultValues: {
// ...
file: new File([], ''), // <--- Typescript will appreciate
}
}
This issue has been automatically closed because it received no activity for a while. If you think it was closed by accident, please leave a comment. Thank you.