Skip to content

File selector

Picks files with an Upload Files button or by dropping them on the field. The app uploads the files it picks.

Installation

pnpm dlx shadcn@latest add @wisty/file-selector

The CLI installs the Radix or Base UI file to match your project, with what it builds on: button.

If your project doesn't have Wisty UI's theme yet, add it once with npx shadcn@latest add @wisty/cosmos. Until then, components keep shadcn's default look. Installation has the details.

When to use

Use it

  • People attach a file in a form, like a document on a case or a signed contract on an opportunity.
  • People pick files with the Upload Files button, or drop them from their desktop onto the field.
  • Only some types are allowed, like PDFs or images. Pass accept, and dropped files that don't match are rejected.
  • A whole area should take dropped files, like a Files card or a composer. Use variant="integrated".
  • It replaces lightning-input with type="file", and the file picking of lightning-file-upload.

Don't use it

  • You need the upload itself: progress, a server, a record to attach to. The file selector only chooses files. Send them from onFilesChange or with the form.
  • You need to show an uploaded file as a card with a preview. That's SLDS's file card, which this library doesn't have yet.
  • Nothing is ever dropped, like a field in a narrow dialog. A plain Input with type="file" is enough.
  • People pick a record, not a file. Use Lookup.

Usage

import {
  FileSelector,
  FileSelectorBody,
  FileSelectorButton,
  FileSelectorDropzone,
  FileSelectorIcon,
  FileSelectorInput,
  FileSelectorText,
} from "@/components/wisty/file-selector"
FileSelector
└── FileSelectorDropzone
    ├── FileSelectorInput
    └── FileSelectorBody
        ├── FileSelectorIcon
        ├── FileSelectorButton
        └── FileSelectorText

FileSelector holds the input's settings (id, name, accept, multiple, disabled, required) and reports what changes; the other parts only draw. id is the input's, for a FieldLabel's htmlFor. FileSelectorIcon goes in the integrated drop zone's body.

onFilesChange gets the files the selector holds now: a pick replaces them, as a native file input does, and a drop brings its accepted files. onFilesReject gets the files it didn't take and why. To keep a list, pass files and append:

<FileSelector
  id={id}
  multiple
  accept=".pdf"
  files={files}
  onFilesChange={(chosen) => setFiles((current) => [...current, ...chosen])}
  onFilesReject={(rejections) => setError(rejections[0].reason)}
>

reason is "accept" for a type accept doesn't allow, and "multiple" for a drop of several files when multiple isn't set; then every file of that drop is rejected. With name, the input submits the files with a form, dropped ones included.

Examples

Several files

Several files in a controlled `files` list: `onFilesChange` appends what's chosen, and each file's remove button takes it out.

Image

An account logo with `variant="images"`: a larger drop zone with the text under the button.

Drop on a card

A whole Files card as a drop target with `variant="integrated"`, shown while files are dragged, and the card's own selector picks them.

Files (1)

States

A required field showing "Complete this field.", or a type error from `onFilesReject`, and a `disabled` one that takes no drop.

API reference

PartBuilt on
FileSelector<div> (opens in a new tab)
FileSelectorDropzone<div> (opens in a new tab)
FileSelectorInput<input> (opens in a new tab)
FileSelectorBody<label> (opens in a new tab)
FileSelectorButton<span> (opens in a new tab)
FileSelectorText<span> (opens in a new tab)
FileSelectorIcon<span> (opens in a new tab)

FileSelector

PropTypeDefaultDescription
variant"files" | "images" | "integrated""files"
idstringThe input's id, for a FieldLabel's htmlFor.
namestringThe input's name, to submit the files with a form.
acceptstringFile types to take, as the native input's accept: .pdf,image/*.
multiplebooleanfalseTake several files at once.
disabledbooleanfalse
requiredbooleanfalse
formstringThe id of a form the input belongs to, outside it.
filesFile[]The files the input holds, controlled.
onFilesChange(files: File[], details: FileSelectorChangeDetails) => voidCalled with what a pick chose, replacing the input's files, or the accepted files of a drop.
onFilesReject( rejections: FileSelectorRejection[], details: FileSelectorChangeDetails ) => voidFiles a pick or a drop brought that the selector didn't take.

FileSelectorSourcetype

type FileSelectorSource = "picker" | "drop"

FileSelectorChangeDetailstype

MemberTypeDefaultDescription
source*FileSelectorSourceWhether the files came from the file picker or a drop.

FileSelectorRejectiontype

MemberTypeDefaultDescription
file*File
reason*"accept" | "multiple""accept": the type doesn't match accept; "multiple": several files came without multiple.

SLDS mapping

The file selector is SLDS's file selector as lightning-input with type="file" draws it. FileSelector is slds-file-selector with slds-file-selector_files, _images or _integrated. FileSelectorDropzone is slds-file-selector__dropzone, with slds-has-drag-over as data-drag-over and slds-has-drag as data-dragging. FileSelectorInput is slds-file-selector__input slds-assistive-text, and FileSelectorBody is the slds-file-selector__body label. FileSelectorButton is slds-file-selector__button slds-button_neutral with the upload icon, FileSelectorText is slds-file-selector__text, and FileSelectorIcon is slds-file-selector__body-icon.

Known gaps:

  • The upload icons are lucide's, where SLDS draws utility:upload.
  • No "Dragover with error" look: a drop of the wrong type is only reported when it lands, since a drag doesn't tell the page the files' names.
  • No file card (slds-file) for files already chosen. List them with Item.
PropValueSLDSNotes
variantfiles (added)slds-file-selector slds-file-selector_filesThe default: the Upload Files button with the drop text beside it, in a dashed drop zone.
variantimages (added)slds-file-selector slds-file-selector_imagesA larger drop zone with the text under the button, for one image.
variantintegrated (added)slds-file-selector slds-file-selector_integrated, slds-file-selector__dropzone_integrated, slds-file-selector__body_integratedAn invisible drop zone over its container that shows while files are dragged over the window. Drop only: its input isn't a tab stop.
(dragging over)data-drag-over (added)slds-has-drag-overOn FileSelectorDropzone while files are dragged over it: a 2px solid accent border.
(dragging in the window)data-dragging (added)slds-has-dragOn an integrated FileSelector and its drop zone while files are dragged anywhere over the window.
disabledtrue (added)slds-file-selector__input[disabled]The body and button dim, and the drop zone takes no drop.
aria-invalidtrue (added)slds-has-error (on the form element)On FileSelectorInput. SLDS draws no error on the drop zone; FieldError shows the message.

Accessibility

  • Name the field with a FieldLabel whose htmlFor is FileSelector's id. The body is a second label, so the input's name is the field label followed by "Upload Files or Drop Files".
  • The input is the control: Tab reaches it, its focus rings the button, and Space opens the file picker. The drop zone is never focusable, so dropping is never the only way to pick.
  • An integrated drop zone is drop only and hidden from assistive technology. Keep a files or images selector, or another way to pick, in its container.
  • For an error, set aria-invalid and aria-describedby on FileSelectorInput, as for any field. The drop zone doesn't change; the message is the error.

Responsive behavior

  • Below 768px the drop text is hidden and only the button shows, as on a phone nothing is dropped.

Rules

  • Keep FileSelectorInput and FileSelectorBody inside FileSelectorDropzone, the input first: the button's focus and disabled looks come from the input beside it.
  • Keep the button a FileSelectorButton inside the body. It isn't a button: the body is the label that opens the picker, and a real button inside a label would take the click.
  • Check files again where they're uploaded. accept only filters what people pick and drop here.
  • Never pass asChild or render.