useFileUpload
Selecting files, checking them, and uploading each one with its own progress.
The example below adds and validates files but does not call upload() — this site has no upload endpoint, and a button that always failed would teach the wrong thing. Everything up to the POST is real.
vue
<script setup lang="ts">
import { useFileUpload } from '@shared/composables/useFileUpload'
import { Badge } from '@shared/components/ui/badge'
import { Button } from '@shared/components/ui/button'
import { Progress } from '@shared/components/ui/progress'
const { files, uploading, add, remove } = useFileUpload({
endpoint: '/uploads',
maxSize: 2 * 1024 * 1024,
acceptedTypes: ['image/*', '.pdf'],
multiple: true,
maxFiles: 4,
})
function onPick(event: Event): void {
const input = event.target as HTMLInputElement
if (input.files)
add(input.files)
input.value = ''
}
function onDrop(event: DragEvent): void {
if (event.dataTransfer?.files)
add(event.dataTransfer.files)
}
</script>
<template>
<div class="flex flex-col gap-4">
<label
class="flex cursor-pointer flex-col items-center gap-2 rounded-xl border border-dashed border-border p-6 text-center"
@dragover.prevent
@drop.prevent="onDrop"
>
<span class="sa-text-sm font-medium">Drop files here, or choose some</span>
<span class="sa-text-xs text-muted-foreground">Images or PDF, up to 2 MB each, four at most</span>
<input type="file" multiple class="sr-only" @change="onPick">
</label>
<ul v-if="files.length" class="flex flex-col gap-2">
<li
v-for="item in files"
:key="item.id"
class="flex flex-col gap-2 rounded-lg border border-border p-3"
>
<div class="flex items-center justify-between gap-3">
<span class="truncate sa-text-sm">{{ item.file.name }}</span>
<div class="flex shrink-0 items-center gap-2">
<Badge :variant="item.status === 'error' ? 'destructive' : 'outline'">
{{ item.status }}
</Badge>
<Button size="sm" variant="ghost" @click="remove(item)">
Remove
</Button>
</div>
</div>
<Progress v-if="item.status === 'uploading'" :model-value="item.progress" />
<p v-if="item.error" class="sa-text-xs text-destructive">
{{ item.error }}
</p>
</li>
</ul>
<p class="sa-text-sm text-muted-foreground">
{{ files.length }} selected · uploading {{ uploading }}
</p>
</div>
</template>Import
ts
import { useFileUpload } from '@shared/composables/useFileUpload'Signature
ts
function useFileUpload(config: FileUploadConfig): {
files: Ref<UploadFile[]>
uploading: Ref<boolean>
validate: (file: File) => string | null
add: (incoming: File[] | FileList) => void
remove: (target: UploadFile) => void
upload: () => Promise<void>
}FileUploadConfig
| Key | Type | Description |
|---|---|---|
endpoint | string | Where each file is POSTed. Required. |
maxSize | number | Maximum bytes per file. |
acceptedTypes | string[] | MIME (image/png), wildcard (image/*) or extension (.pdf). |
multiple | boolean | false keeps one file, replacing what was there. |
maxFiles | number | The cap. Extra files are ignored, not rejected. |
Returns
| Member | Type | Description |
|---|---|---|
files | Ref<UploadFile[]> | Everything added, valid or not. |
uploading | Ref<boolean> | An upload round is in flight. |
validate | (file) => string | null | The error message, or null when the file passes. |
add | (File[] | FileList) => void | Validates as it adds. |
remove | (target) => void | Drops one entry. |
upload | () => Promise<void> | Uploads every pending file, in parallel. |
UploadFile
| Key | Type | Description |
|---|---|---|
id | string | A UUID. |
file | File | The original. |
progress | number | 0 to 100. |
status | 'pending' | 'uploading' | 'success' | 'error' | Where it is. |
error | string | Why it failed validation or upload. |
Example
vue
<script setup lang="ts">
import { useFileUpload } from '@shared/composables/useFileUpload'
const { files, uploading, add, remove, upload } = useFileUpload({
endpoint: '/uploads',
maxSize: 5 * 1024 * 1024,
acceptedTypes: ['image/*', '.pdf'],
multiple: true,
maxFiles: 5,
})
function onPick(event: Event) {
const input = event.target as HTMLInputElement
if (input.files)
add(input.files)
}
</script>
<template>
<div class="space-y-3">
<input type="file" multiple @change="onPick">
<ul>
<li v-for="item in files" :key="item.id">
{{ item.file.name }} — {{ item.status }} {{ item.progress }}%
<span v-if="item.error">{{ item.error }}</span>
<button type="button" @click="remove(item)">
Remove
</button>
</li>
</ul>
<button type="button" :disabled="uploading" @click="upload()">
Upload
</button>
</div>
</template>Notes
- Validation runs on add, not on upload. A file that fails lands in the list with
status: 'error'and its reason, so the reader sees what is wrong before pressing anything. acceptedTypesmatches three ways: a wildcard checks the MIME prefix, a leading dot checks the file name's ending, and anything else is an exact MIME match.maxFilesstops adding; it does not reject. Once the list is full the remaining files in a drop are silently ignored.- With
multiple: falsethe list is cleared first, so picking a second file replaces the first rather than sitting beside it. upload()only sendspendingfiles, so pressing it twice does not re-upload what already succeeded, and a file that failed validation is never sent.- Each file is its own multipart POST under the field name
file, with its own progress handler. They run in parallel throughPromise.all. - A failed upload sets that file's status to
errorand leaves the rest alone. The promise still resolves. - The whole feature sits behind
config.features.fileUpload.
Related
- File upload — the drop zone built on this.
- Schema — the
filefield type and itsuploadoptions.