Attachment

Upload one file or a sortable gallery, with drag and drop, progress, cancel, retry and undo.

Preview

Pick a file and watch the progress bar. Hit × mid-upload to cancel.

Usage

AutoFormFile takes one file, AutoFormFiles takes many. The form stores file ids: a number for one file, an array of numbers for many.

import { z } from 'zod'
import { AutoForm } from '@vitnode/core/components/form/auto-form'
import { AutoFormFile } from '@vitnode/core/components/form/fields/file'
import { AutoFormFiles } from '@vitnode/core/components/form/fields/files'

const formSchema = z.object({
  avatar: z.number().nullable().default(null),
  gallery: z.array(z.number()).max(4).default([]),
})
<AutoForm
  formSchema={formSchema}
  fields={[
    {
      id: 'avatar',
      component: (props) => (
        <AutoFormFile
          {...props}
          allowedExtensions={['.png', '.jpg', '.webp']}
          label="Avatar"
          maxBytes={5 * 1024 * 1024}
          onUpload={uploadFile}
        />
      ),
    },
    {
      id: 'gallery',
      component: (props) => (
        <AutoFormFiles
          {...props}
          label="Gallery"
          maxBytes={5 * 1024 * 1024}
          maxItems={4}
          onUpload={uploadFile}
        />
      ),
    },
  ]}
/>

Uploading

onUpload(file, { onProgress, signal }) uploads the picked File and returns the stored file ({ id, name, size, url, mimeType? }). The field saves its id.

  • Call onProgress(fraction) with 0 to 1 to show a percentage. Skip it and the card shimmers instead.
  • Pass signal to your request. Cancelling from the card aborts it, and any late response is ignored.
  • Throw to fail. The file stays as an error card with a Try again button.

fetch can't report upload progress, so use XMLHttpRequest for the bar:

import type {
  AutoFormFileValue,
  FileUploadOptions,
} from '@vitnode/core/components/form/fields/file'

const uploadFile = async (
  file: File,
  { onProgress, signal }: FileUploadOptions,
): Promise<AutoFormFileValue> =>
  await new Promise((resolve, reject) => {
    const body = new FormData()
    body.append('file', file)

    const xhr = new XMLHttpRequest()
    xhr.open('POST', '/api/my-plugin/uploads')
    xhr.responseType = 'json'
    xhr.upload.onprogress = (event) => {
      if (event.lengthComputable) onProgress(event.loaded / event.total)
    }
    xhr.onload = () =>
      xhr.status < 300
        ? resolve(xhr.response)
        : reject(new Error('Upload failed'))
    xhr.onerror = () => reject(new Error('Upload failed'))
    signal.addEventListener('abort', () => xhr.abort())
    xhr.send(body)
  })

Removing and existing files

Removing a file from AutoFormFiles shows a toast with Undo, which puts it back in the same spot. Misclicks shouldn't cost a re-upload.

Editing a record with files? Pass file={existing} or files={existing} to show them.

Limits

Files are checked before upload, so nobody waits on a 2 GB video just to hear it's too big.

PropFieldExample
maxBytesboth, required5 * 1024 * 1024
allowedExtensionsboth['.pdf', '.png'] (lowercase)
allowedMimeTypesboth['image/png']
maxItemsfiles, required4
minItemsfiles1
orderedfilesfalse turns off reordering

Files reorder by drag or keyboard. Check the same limits on your server: the browser check can't stop someone determined.

States

Set state on Attachment: idle, uploading, processing, done (default) or error. In error, say what went wrong.

Layouts

  • size: default, sm or xs for chat composers and tight lists.
  • orientation="vertical" turns it into a card with a big preview.
  • AttachmentGroup makes a horizontal scroll row that fades at the edges.
  • AttachmentTrigger makes the whole attachment clickable. Render it as a link to open the file. Focus shows one ring on the attachment, not two.
  • AttachmentTitle keeps the extension visible and truncates the rest: quarterly-rep….pdf.

Accessibility

  • Give every AttachmentAction an aria-label naming the file: "Remove report.pdf". Plain "Remove" is a riddle when there are five files.
  • Previews sit next to the file name, so alt="" is right.
  • The fields announce reordering and list each rejected file with the reason.
  • The whole drop zone is clickable. It only lights up when the drag carries files.
  • On touch screens, actions and drag handles grow to a 40px hit area.