File upload
A drop zone with image previews, per-file validation and per-file upload progress.
<script setup lang="ts">
import type { UploadFile } from '@shared/composables/useFileUpload'
import FileUpload from '@shared/components/common/FileUpload.vue'
import { ref } from 'vue'
const attachments = ref<UploadFile[]>([])
</script>
<template>
<div class="max-w-lg space-y-3">
<FileUpload v-model="attachments" endpoint="/api/attachments" multiple />
<p class="text-sm text-muted-foreground">
{{ attachments.length }} file(s) selected
</p>
</div>
</template>TIP
The previews on this page validate and list files, and show the Upload button, but there is no server behind them. Selecting a file is enough to see everything except the progress bar.
Usage
endpoint is the only required prop. It is where upload() POSTs each pending file as multipart form data, one request per file, with the field name file.
v-model is the list of UploadFile, which is { id, file, progress, status, error? }. It is read-only in practice: the component owns the list, and the model is how you read it.
Files are validated the moment they are added, not on upload. A file that fails lands in the list with status: 'error' and the reason beside it, so the reader can remove it and try another rather than finding out after pressing a button.
Uploading is explicit. Nothing leaves the browser until the Upload button is pressed, and that button only appears while at least one file is pending.
Examples
Types, size and a count limit
acceptedTypes takes a MIME type (image/png), a wildcard (image/*) or an extension (.pdf), and is also set as the picker's accept. maxSize is bytes per file. maxFiles caps the list; files beyond it are dropped rather than added with an error.
<script setup lang="ts">
import type { UploadFile } from '@shared/composables/useFileUpload'
import FileUpload from '@shared/components/common/FileUpload.vue'
import { ref } from 'vue'
const documents = ref<UploadFile[]>([])
</script>
<template>
<div class="max-w-lg">
<FileUpload
v-model="documents"
endpoint="/api/documents"
:accepted-types="['image/*', '.pdf']"
:max-size="2 * 1024 * 1024"
:max-files="3"
multiple
/>
</div>
</template>One file at a time
:multiple="false" replaces the list on every pick instead of adding to it, which is what an avatar field wants.
<script setup lang="ts">
import type { UploadFile } from '@shared/composables/useFileUpload'
import FileUpload from '@shared/components/common/FileUpload.vue'
import { ref } from 'vue'
const avatar = ref<UploadFile[]>([])
</script>
<template>
<div class="max-w-lg">
<FileUpload
v-model="avatar"
endpoint="/api/avatar"
:accepted-types="['image/png', 'image/jpeg']"
:multiple="false"
/>
</div>
</template>API
Source: src/shared/components/common/FileUpload.vue, src/shared/composables/useFileUpload.ts
Props
| Prop | Type | Default | Description |
|---|---|---|---|
endpoint | string | required | Where upload() POSTs each file, through the shared axios instance. |
maxSize | number | — | Maximum bytes per file. Over it, the file is listed with "File exceeds N MB". |
acceptedTypes | string[] | — | MIME types, wildcards or extensions. Also the picker's accept, and printed under the drop zone. |
multiple | boolean | — | Allows more than one file. false replaces the list on each pick. |
maxFiles | number | — | The most files the list will hold. |
modelValue | UploadFile[] | — | The current list. |
Emits
| Event | Payload | Description |
|---|---|---|
update:modelValue | files: UploadFile[] | Fires whenever the list or any file's status or progress changes. |
Slots
None.
UploadFile
| Field | Type | Description |
|---|---|---|
id | string | From crypto.randomUUID(). |
file | File | The browser's own file object. |
progress | number | 0 to 100, from the upload progress events. |
status | 'pending' | 'uploading' | 'success' | 'error' | Starts error when validation fails, otherwise pending. |
error | string | The validation message, or "Upload failed". |
Feature flag
features.fileUpload gates this control inside the form engine. With the flag off, a file field renders a note in place of the drop zone rather than disappearing, so a required field cannot hide and block the form. See Global config.
Accessibility
- The drop zone is a
role="button"withtabindex="0"and answers toEnter, so the picker opens from the keyboard. - Each file's remove control is named "Remove <file name>".
- An image preview is decorative and carries an empty
alt, because the file name is already beside it. - A failed file shows its reason as text next to the file, not only as a color.