File Fields
File fields are a type of input field that allows users to submit or upload files.
Features
- Uses
input
element. - Labeling, Descriptions, and error messages are automatically linked to their corresponding elements.
- Validation support with native HTML constraint validation or Standard Schema validation.
- Support for
v-model
binding. - Basic upload handling support.
- Multiple file selection support.
- Drag and drop support for “dropzone” components.
- Auto previews for images and videos.
Anatomy
Building a File Field Component
You can start by importing the useFileField
composable and using it in your file field component.
The useFileField
composable returns binding objects for the elements shown in the anatomy. You will use v-bind
to bind them to the corresponding DOM elements.
<script setup lang="ts">import { useFileField, type FileFieldProps, FileEntry } from '@formwerk/core';
const props = defineProps<FileFieldProps>();
const { inputProps, triggerProps, entries, errorMessageProps, errorMessage, remove, removeButtonProps, isUploading,} = useFileField(props);</script>
<template> <div class="field"> <input v-bind="inputProps" class="sr-only" />
<button v-bind="triggerProps" class="trigger"> <svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 256 256" > <path v-if="isUploading" class="icon-uploading" d="M236,128a108,108,0,0,1-216,0c0-42.52,24.73-81.34,63-98.9A12,12,0,1,1,93,50.91C63.24,64.57,44,94.83,44,128a84,84,0,0,0,168,0c0-33.17-19.24-63.43-49-77.09A12,12,0,1,1,173,29.1C211.27,46.66,236,85.48,236,128Z" ></path> <path v-else d="M248,128a56.06,56.06,0,0,1-56,56H48a40,40,0,0,1,0-80H192a24,24,0,0,1,0,48H80a8,8,0,0,1,0-16H192a8,8,0,0,0,0-16H48a24,24,0,0,0,0,48H192a40,40,0,0,0,0-80H80a8,8,0,0,1,0-16H192A56.06,56.06,0,0,1,248,128Z" ></path> </svg> Choose a file </button>
<p v-if="!entry" class="empty-state">No file selected</p>
<div v-else class="file-entry"> <span>{{ entry.file.name }}</span>
<button v-bind="removeButtonProps" class="delete-button" @click="remove"> <svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 256 256" > <path d="M208.49,191.51a12,12,0,0,1-17,17L128,145,64.49,208.49a12,12,0,0,1-17-17L111,128,47.51,64.49a12,12,0,0,1,17-17L128,111l63.51-63.52a12,12,0,0,1,17,17L145,128Z" ></path> </svg> </button> </div>
<div v-if="errorMessage" v-bind="errorMessageProps" class="error"> {{ errorMessage }} </div> </div></template>
<style scoped>.field { --color-primary: #10b981; --color-text-primary: #333; --color-text-secondary: #666; --color-border: #d4d4d8; --color-focus: var(--color-primary); --color-error: #f00;
display: flex; flex-direction: column; gap: 0.5rem;}
.trigger { display: flex; align-items: center; gap: 0.5rem; width: max-content; padding: 0.5rem 1rem; font-size: 14px; font-weight: 500; color: white; background-color: var(--color-primary); border: none; border-radius: 6px; cursor: pointer; transition: background-color 0.3s ease;
&:disabled { opacity: 0.5; cursor: not-allowed; }}
.trigger:hover { background-color: color-mix(in srgb, var(--color-primary) 85%, black);}
.trigger:focus { outline: none; box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary) 25%, white);}
.icon { width: 16px; height: 16px; fill: currentColor;}
.empty-state { font-size: 13px; color: var(--color-text-secondary); margin: 0;}
.file-entry { font-size: 13px; color: var(--color-text-primary); padding: 0.25rem 0; display: flex; align-items: center; gap: 0.25rem;}
.error { font-size: 13px; color: var(--color-error);}
.delete-button { background: none; border: none; cursor: pointer; padding: 0; margin: 0; color: var(--color-text-secondary);}
.delete-button:hover { color: var(--color-error);}
.icon-uploading { animation: spin 1s linear infinite; transform-origin: center;}
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); }}</style>
Building a Dropzone Component
You can use useFileField
to build a “dropzone” component that allows users to drag and drop files to upload them.
In this case, we can use the FileEntry
component to render the file entry, the FileEntry
component exposes various slot props to manage, and preview the file entry if it is an image or a video.
<script setup lang="ts">import { useFileField, type FileFieldProps, FileEntry } from '@formwerk/core';
const props = defineProps<FileFieldProps>();
const { inputProps, dropzoneProps, triggerProps, entries, errorMessageProps, errorMessage,} = useFileField(props);</script>
<template> <div v-bind="dropzoneProps" class="dropzone"> <input v-bind="inputProps" class="sr-only" />
<div v-if="entries.length === 0" class="empty-state"> <button v-bind="triggerProps" class="trigger"> <svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 256 256" > <path d="M248,128a56.06,56.06,0,0,1-56,56H48a40,40,0,0,1,0-80H192a24,24,0,0,1,0,48H80a8,8,0,0,1,0-16H192a8,8,0,0,0,0-16H48a24,24,0,0,0,0,48H192a40,40,0,0,0,0-80H80a8,8,0,0,1,0-16H192A56.06,56.06,0,0,1,248,128Z" ></path> </svg> Choose a file </button>
<p class="empty-message">No file selected</p> </div>
<div v-else class="file-grid"> <ul> <FileEntry as="li" v-for="entry in entries" v-bind="entry" class="file-entry" v-slot="{ removeButtonProps, previewProps, hasPreview }" > <component :is="previewProps.as" v-bind="previewProps" class="preview" />
<button v-bind="removeButtonProps" class="delete-button"> <svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 256 256" > <path d="M208.49,191.51a12,12,0,0,1-17,17L128,145,64.49,208.49a12,12,0,0,1-17-17L111,128,47.51,64.49a12,12,0,0,1,17-17L128,111l63.51-63.52a12,12,0,0,1,17,17L145,128Z" ></path> </svg> </button>
<span v-if="!hasPreview" class="filename">{{ entry.file.name }}</span> </FileEntry> </ul> </div>
<div v-if="errorMessage" v-bind="errorMessageProps" class="error"> {{ errorMessage }} </div> </div></template>
<style scoped>.dropzone { --color-primary: #10b981; --color-text-primary: #333; --color-text-secondary: #666; --color-border: #d4d4d8; --color-focus: var(--color-primary); --color-error: #f00; --color-background: #fff; --color-background-hover: color-mix( in srgb, var(--color-background) 97%, black );
display: flex; flex-direction: column; align-items: center; gap: 0.5rem; width: 100%; max-width: 32rem; padding: 1rem; border: 2px dashed var(--color-border); border-radius: 0.5rem; transition: all 0.3s ease;}
.dropzone:hover { border-color: var(--color-primary); background-color: var(--color-background-hover);}
.empty-state { display: flex; flex-direction: column; align-items: center; gap: 0.25rem; margin-top: 0.75rem;}
.trigger { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 1rem; font-size: 14px; font-weight: 500; color: white; background-color: var(--color-primary); border: none; border-radius: 6px; cursor: pointer; transition: background-color 0.3s ease;}
.trigger:hover { background-color: color-mix(in srgb, var(--color-primary) 85%, black);}
.trigger:focus { outline: none; box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary) 25%, white);}
.icon { width: 16px; height: 16px; fill: currentColor;}
.empty-message { font-size: 13px; color: var(--color-text-secondary); margin: 0;}
.file-grid ul { display: flex; flex-wrap: wrap; gap: 0.5rem; list-style: none; padding: 0; margin: 0;}
.file-entry { position: relative; width: 4rem; height: 4rem; border: 1px solid var(--color-border); border-radius: 0.375rem;}
.preview { width: 100%; height: 100%; object-fit: cover; border-radius: 0.375rem;}
.delete-button { position: absolute; top: -0.5rem; right: -0.5rem; display: flex; align-items: center; justify-content: center; width: 1.5rem; height: 1.5rem; padding: 0.25rem; color: white; background-color: var(--color-primary); border: none; border-radius: 9999px; cursor: pointer; transition: background-color 0.3s ease;}
.delete-button:hover { background-color: var(--color-error);}
.filename { position: absolute; bottom: 0; left: 0; right: 0; padding: 0.25rem; font-size: 12px; color: var(--color-text-primary); background-color: rgba(255, 255, 255, 0.8); border-radius: 0 0 0.375rem 0.375rem; text-overflow: ellipsis; overflow: hidden; white-space: nowrap;}
.error { font-size: 13px; color: var(--color-error);}</style>
Validation
HTML Constraints
You can use the following properties to validate the file field with native HTML constraint validation:
Name | Type | Description |
---|---|---|
required | boolean | Whether the file field is required. |
Here is an example of how to use the required
property to make the file field required.
<script setup lang="ts">import FileField from './FileField.vue';</script>
<template> <FileField label="File" required /></template>
Standard Schema
You can use the useFileField
composable to validate the file field with Standard Schema.
In this example, we are validating the file size to be 1MB maximum.
<script setup lang="ts">import { z } from 'zod';import FileField from './FileField.vue';
const schema = z.object({ size: z.number().max(1 * 1024 * 1024, 'Size must be less than 1MB'),});</script>
<template> <FileField label="File" :schema="schema" /></template>
Mixed Validation
All file fields created with Formwerk support mixed validation, which means you can use both HTML constraints and Standard Schema validation to validate the field, and they work seamlessly together.
Note that HTML constraints are validated first, so any errors from the HTML constraints will be displayed first. Then, once all HTML constraints are satisfied, the Standard Schema is validated.
Here we are mixing both the required
HTML constraint and the Zod size validation.
<script setup lang="ts">import { z } from 'zod';import FileField from './FileField.vue';
const schema = z.object({ size: z.number().max(1 * 1024 * 1024, 'Size must be less than 1MB'),});</script>
<template> <FileField label="File" :schema="schema" required /></template>
Usage
Disabled
Use disabled
to mark fields as non-interactive. Disabled fields are not validated and are not submitted.
<script setup lang="ts">import FileField from './FileField.vue';</script>
<template> <FileField label="Disabled Field" disabled /></template>
One important thing is to no forget the dropzoneProps
binding object, which contains the listeners needed to make the dropzone work.
You can also build your own FileEntry
component with the useFileEntry
composable.
This dropzone component isn’t multiple by default, but you can use the multiple
prop to allow users to select multiple files.
Multiple Files
You can use the multiple
prop to allow users to select multiple files.
<script setup lang="ts">import Dropzone from './Dropzone.vue';</script>
<template> <Dropzone label="Multiple Files" multiple /></template>
Allowing Picking Directories
You can use the allowDirectory
prop to allow users to pick directories if the file is multiple
.
<script setup lang="ts">import Dropzone from './Dropzone.vue';</script>
<template> <Dropzone label="Allow Directory" multiple allow-directory /></template>
Uploading Files
The useFileField
composable accepts an onUpload
handler that is called with the file to be uploaded. The onUpload
handler receives a FileUploadContext
object with the following properties:
Name | Type | Description |
---|---|---|
file | The file that was just picked. | |
key | The key of the entry containing the file. | |
signal | A signal that can be used to abort the upload. |
The upload handler should return a string value that will be swapped with the file in the form when it submits. The string value is usually an identifier that your server can use to identify the file.
<script setup lang="ts">import { ref } from 'vue';import { type FileUploadContext } from '@formwerk/core';
import FileField from './FileField.vue';
function onUpload({ file }: FileUploadContext) { return new Promise((resolve) => { setTimeout(() => { resolve(Date.now().toString()); }, 1000); });}
const value = ref<string>();</script>
<template> Field value: {{ value }}
<FileField label="File" v-model="value" @upload="onUpload" /></template>
Initial Data
At the moment, useFileField
doesn’t support initial data because you cannot really set a “file” value to an input
element due to security restrictions. There are a few ideas we have like:
- Given a URL, we reconstruct the file object and set it as the initial value.
- Create a “fake” file object that we can set as the initial value.
We are open to suggestions, join our Discord server or open an issue if you have any ideas!
API
useFileField
Props
Name | Type | Description |
---|---|---|
accept | The file types that are accepted (e.g. "image/*", "application/pdf"). | |
allowDirectory | Whether the field allows directory selection. | |
disabled | Whether the field is disabled. | |
label | The label of the field. | |
modelValue | The model value for the field. | |
multiple | Whether the field allows multiple files to be selected. | |
name | The name of the field. | |
onUpload | (context: FileUploadContext) => Promise<string> | Handles the file upload, this function is called when the user selects a file, and is called for new picked files. |
removeButtonLabel | The label for the remove file button. | |
required | Whether the field is required. | |
schema | The schema for the field. | |
value | The value of the field. |
Returns
Name | Type | Description |
---|---|---|
clear | Clear the files, aborts any pending uploads. | |
displayError | Display the error message for the field. | |
dropzoneProps | Ref<{ onDragenter(evt: DragEvent): void; onDragover(evt: DragEvent): void; onDragleave(evt: DragEvent): void; onDrop(evt: DragEvent): void; onClick(e: MouseEvent): void; role: string; 'data-dragover': boolean; 'aria-label': string; }> | The props for the dropzone element, usually the root element. |
entries | The file entries that are currently picked. | |
entry | Ref<{ id: string; file: { readonly lastModified: number; readonly name: string; readonly webkitRelativePath: string; readonly size: number; readonly type: string; arrayBuffer: () => | The file entry that is currently picked. |
errorMessage | The error message for the field. | |
errorMessageProps | Ref<{ 'aria-live': "polite"; 'aria-atomic': boolean; id: string; }> | Props for the error message element. |
errors | The errors for the field. | |
fieldValue | The value of the field. | |
inputEl | The captured input element. | |
inputProps | The props for the input element. | |
isDirty | Whether the field is dirty. | |
isDisabled | Whether the field is disabled. | |
isDragging | Whether the dropzone element has items being dragged over it. | |
isTouched | Whether the field is touched. | |
isUploading | Whether the field is uploading, if multiple files are picked, this will be true if any of the files are uploading. | |
isValid | Whether the field is valid. | |
remove | Remove a an entry from the list, if no key is provided, the last entry will be removed. | |
removeButtonProps | Ref<{ [x: string]: unknown; type: "button"; role: string; tabindex: string; }> | The props for the remove file button. |
setErrors | (messages: Arrayable<string>) => void | Sets the errors for the field. |
setTouched | Sets the touched state for the field. | |
setValue | Sets the value for the field. | |
showPicker | (opts?: FilePickerOptions) => Promise<void> | Shows the file picker with the given options. Useful for a picker-type implementations. |
submitErrorMessage | The error message for the field from the last submit attempt. | |
submitErrors | The errors for the field from the last submit attempt. | |
triggerProps | Ref<{ [x: string]: unknown; type: "button"; role: string; tabindex: string; }> | The props for the trigger element. |
validate | (mutate?: boolean) => Promise<ValidationResult<unknown>> | Validates the field. |
validityDetails | Validity details for the input element. |
useFileEntry
Props
Name | Type | Description |
---|---|---|
file | The file object. | |
id | The id of the file entry. | |
isUploading | Whether the file is being uploaded. | |
uploadResult | The result of the upload. |
Returns
Name | Type | Description |
---|---|---|
isUploaded | ||
name | ||
previewProps | Ref<{ as: string; src: string; alt: string; 'aria-label'?: undefined; controls?: undefined; muted?: undefined; loop?: undefined; autoplay?: undefined; playsinline?: undefined; } | { as: string; 'aria-label': string; ... 6 more ...; alt?: undefined; } | { ...; }> | |
remove | ||
removeButtonProps | Ref<{ [x: string]: unknown; type: "button"; role: string; tabindex: string; }> |