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.

PNG or JPEG, 2 MB at most.

With a drop zone

One variant covers the button and the drop surface; both write the same input.

Drop files here
Drop them in, or browse.

source

conform-file-trigger.tsx

Copy 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>
  )
}