component — conform
Conform File Trigger
The quebi FileTrigger and DropZone wired to Conform. Binds a file field through a hidden file input, so FormData carries real File objects.
- form
- conform
- file
- upload
- dropzone
- validation
02 examples
Bound to a Conform form
Submit with nothing chosen to see the validation error wired from field metadata.
With a drop zone
One variant covers the button and the drop surface; both write the same input.
source
conform-file-trigger.tsxCopy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.
"use client"
import type { FieldMetadata } from "@conform-to/react"
import { BaseControl, useControl } from "@conform-to/react/future"
import { useRef } from "react"
import { cn } from "@/lib/utils"
import { DropZone } from "@/components/drop-zone"
import {
describedBy,
Description,
Field,
FieldError,
focusFirstControl,
Label,
} from "@/components/field"
import { FileTrigger, type FileTriggerProps } from "@/components/file-trigger"
export interface ConformFileTriggerProps
extends Omit<FileTriggerProps, "onSelect" | "ref"> {
/**
* A file input bound to a `File` form value; `allowsMultiple` makes it
* `File[]`. The two are a union of metadata objects rather than metadata of
* a union: Conform maps a field's shape into its own type, and `File` has
* properties an array does not.
*/
field: FieldMetadata<File> | FieldMetadata<File[]>
label?: string
description?: string
/** Also accept files dropped onto a DropZone surface. */
hasDropZone?: boolean
/** The text inside the drop surface. */
dropZoneLabel?: string
className?: string
}
/**
* ConformFileTrigger — FileTrigger (and an optional DropZone) wired to Conform.
*
* Binds a file Conform field through a registered hidden `<input type="file">`,
* which is where the real `File` objects live: the control writes them with a
* DataTransfer, so `FormData.get(name)` returns a File, not a filename.
*
* The hidden input is required. `FileTriggerProps` has no `name` — react-aria's
* `filterDOMProps` drops one silently if you pass it — and the picker it opens
* is an implementation detail with no form value of its own. Same for DropZone,
* which is a drop target, not a form control.
*
* That input is hidden by CSS rather than by the `hidden` attribute, and
* carries no React `value` prop; both are load-bearing and both fail silently
* — see `conform-time-field`.
*/
export function ConformFileTrigger({
field,
label,
description,
hasDropZone = false,
dropZoneLabel = "Drop files here",
className,
...props
}: ConformFileTriggerProps) {
const fieldRef = useRef<HTMLDivElement>(null)
const control = useControl<File[]>({
// Conform focuses the first errored field after a failed submit; that is
// the registered control, which nobody can see — hand it to the visible one.
onFocus() {
focusFirstControl(fieldRef.current)
},
})
const hasErrors = !field.valid && !!field.errors
const isRequired = field.required ?? false
const files = control.files ?? []
const select = (list: FileList | null) => control.change(list ? Array.from(list) : [])
return (
<Field ref={fieldRef} className={cn(className)}>
{/* First, before the label: the stack is spaced with `label + control`
selectors, and an `sr-only` element between the two is still an
element the selector cannot see past. */}
<BaseControl
type="file"
name={field.name}
form={field.formId}
multiple={props.allowsMultiple}
ref={control.register}
aria-describedby={describedBy(
hasErrors && field.errorId,
description && field.descriptionId,
)}
hidden={false}
tabIndex={-1}
className="sr-only"
/>
{label && (
<Label className={cn(hasErrors && "text-quebi-danger")}>
{label}
{isRequired && <span className="ml-1 text-quebi-fg">*</span>}
</Label>
)}
{/* The picker and the list of what it has picked are one control: the
list is part of the field's answer, not a hint under it, so it stays
on the control row of a `FieldRow` rather than in the hint row. */}
<div data-slot="control" className="flex flex-col gap-1.5">
{hasDropZone ? (
<DropZone
onDrop={async (event) => {
const dropped = await Promise.all(
event.items
.filter((item) => item.kind === "file")
.map((item) => (item as { getFile: () => Promise<File> }).getFile()),
)
if (dropped.length > 0) {
control.change(props.allowsMultiple ? [...files, ...dropped] : [dropped[0]])
}
}}
className={cn("flex-col gap-3", hasErrors && "border-quebi-danger")}
>
<span>{dropZoneLabel}</span>
<FileTrigger {...props} onSelect={select} />
</DropZone>
) : (
<FileTrigger {...props} onSelect={select} />
)}
{files.length > 0 && (
<ul className="flex flex-col gap-1 text-quebi-caption text-quebi-fg-muted">
{files.map((file) => (
<li key={`${file.name}-${file.size}-${file.lastModified}`}>{file.name}</li>
))}
</ul>
)}
</div>
{/* These ids are ours to set: the control above is not a react-aria
field, so nothing generates them and its aria-describedby is their
only reference. */}
{description && <Description id={field.descriptionId}>{description}</Description>}
{hasErrors && <FieldError id={field.errorId}>{field.errors?.join(", ")}</FieldError>}
</Field>
)
}