Forms
File Upload Form code
Copy the implementation or inspect every file that belongs to this component unit.
resources/views/components/ui/file-upload-form.blade.php
@props([
'action' => '#',
'method' => 'POST',
'name' => 'files[]',
'title' => 'Upload files',
'description' => null,
'accept' => null,
'multiple' => true,
'hint' => 'PNG, JPG, PDF or ZIP up to 10MB',
'icon' => 'cloud-arrow-up',
'maxSize' => null,
'maxFiles' => null,
'submitLabel' => 'Upload',
'cancelHref' => null,
'cancelLabel' => 'Cancel',
'disabled' => false,
'simulate' => false,
])
@php
/*
| Modern, self-contained file upload form. A drag-and-drop dropzone backed by
| a real native file input, a queue with image thumbnails, per-file progress,
| size/type/count validation, a total-size summary and submit/cancel actions.
|
| Selected files are synced into the hidden <input type="file"> via a
| DataTransfer, so the surrounding <form> submits them as a normal multipart
| request. Set `simulate` to animate per-file progress for demos/previews
| without a backend.
|
| Usage:
| <x-ui.file-upload-form :action="route('media.store')" accept="image/*" :max-size="5120" :max-files="6" />
| <x-ui.file-upload-form title="Import data" name="imports[]" accept=".csv,.xlsx" simulate />
*/
$method = strtoupper($method);
$formMethod = in_array($method, ['GET', 'POST'], true) ? $method : 'POST';
@endphp
<form
@if ($action) action="{{ $action }}" @endif
method="{{ $formMethod }}"
enctype="multipart/form-data"
x-data="{
files: [],
errors: [],
dragging: false,
nextId: 1,
disabled: @js((bool) $disabled),
multiple: @js((bool) $multiple),
accept: @js($accept),
maxSize: @js($maxSize ? (int) $maxSize : null),
maxFiles: @js($maxFiles ? (int) $maxFiles : null),
simulate: @js((bool) $simulate),
uploading: false,
get hasFiles() { return this.files.length > 0; },
get totalSize() { return this.files.reduce((sum, file) => sum + file.size, 0); },
get allComplete() { return this.hasFiles && this.files.every((file) => file.status === 'done'); },
addFiles(fileList) {
this.errors = [];
const incoming = Array.from(fileList);
incoming.forEach((file) => {
const error = this.validate(file);
if (error) { this.errors.push(error); return; }
if (this.maxFiles && this.files.length >= this.maxFiles) {
this.errors.push(`You can upload at most ${this.maxFiles} file${this.maxFiles === 1 ? '' : 's'}.`);
return;
}
const entry = {
id: this.nextId++,
raw: file,
name: file.name,
size: file.size,
type: file.type,
status: 'ready',
progress: 0,
icon: this.iconFor(file),
previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : null,
};
if (this.multiple) {
this.files.push(entry);
} else {
this.reset();
this.files = [entry];
}
});
this.syncInput();
},
validate(file) {
if (this.maxSize && file.size > this.maxSize * 1024) {
return `${file.name} is larger than ${this.humanSize(this.maxSize * 1024)}.`;
}
return null;
},
remove(id) {
const entry = this.files.find((file) => file.id === id);
if (entry?.previewUrl) { URL.revokeObjectURL(entry.previewUrl); }
this.files = this.files.filter((file) => file.id !== id);
this.syncInput();
},
reset() {
this.files.forEach((file) => { if (file.previewUrl) { URL.revokeObjectURL(file.previewUrl); } });
this.files = [];
this.errors = [];
this.syncInput();
},
syncInput() {
const transfer = new DataTransfer();
this.files.forEach((file) => transfer.items.add(file.raw));
this.$refs.input.files = transfer.files;
},
submit(event) {
if (! this.hasFiles || this.disabled) { return; }
if (! this.simulate) { return; }
event.preventDefault();
this.uploading = true;
this.files.forEach((file) => this.upload(file));
},
upload(file) {
file.status = 'uploading';
file.progress = 0;
const timer = setInterval(() => {
file.progress = Math.min(100, file.progress + Math.random() * 18);
if (file.progress >= 100) {
file.progress = 100;
file.status = 'done';
clearInterval(timer);
this.uploading = this.files.some((item) => item.status === 'uploading');
}
}, 220);
},
iconFor(file) {
const type = file.type || '';
const ext = file.name.split('.').pop().toLowerCase();
if (type.startsWith('image/')) { return 'image'; }
if (type.startsWith('video/')) { return 'file-video'; }
if (type.startsWith('audio/')) { return 'file-audio'; }
if (type === 'application/pdf' || ext === 'pdf') { return 'file-pdf'; }
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext)) { return 'file-zip'; }
if (['csv'].includes(ext)) { return 'file-csv'; }
if (['xls', 'xlsx'].includes(ext)) { return 'file-xls'; }
if (['doc', 'docx'].includes(ext)) { return 'file-doc'; }
if (['js', 'ts', 'json', 'php', 'css', 'html', 'vue', 'py'].includes(ext)) { return 'file-code'; }
if (['txt', 'md'].includes(ext)) { return 'file-text'; }
return 'file';
},
humanSize(bytes) {
if (bytes < 1024) { return bytes + ' B'; }
if (bytes < 1048576) { return (bytes / 1024).toFixed(1) + ' KB'; }
return (bytes / 1048576).toFixed(1) + ' MB';
},
}"
@submit="submit($event)"
{{ $attributes->merge(['class' => 'overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900']) }}
>
@if (! in_array($method, ['GET', 'POST'], true))
@csrf
@method($method)
@elseif ($formMethod === 'POST')
@csrf
@endif
@if ($title || $description)
<div class="border-b border-gray-200 px-5 py-4 dark:border-gray-800 sm:px-6">
@if ($title)
<h3 class="text-base font-semibold text-gray-900 dark:text-white">{{ $title }}</h3>
@endif
@if ($description)
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ $description }}</p>
@endif
</div>
@endif
<div class="space-y-4 p-5 sm:p-6">
{{-- Dropzone --}}
<label
@dragover.prevent="if (! disabled) dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="dragging = false; if (! disabled) { addFiles($event.dataTransfer.files); }"
:class="dragging
? 'border-brand-500 bg-brand-50/60 dark:bg-brand-950/30'
: 'border-gray-300 dark:border-gray-700'"
class="flex flex-col items-center justify-center gap-3 rounded-2xl border-2 border-dashed bg-gray-50/60 px-6 py-10 text-center transition dark:bg-gray-900/40 {{ $disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:border-gray-400 dark:hover:border-gray-600' }}"
>
<span class="flex h-12 w-12 items-center justify-center rounded-2xl bg-white text-brand-500 shadow-sm dark:bg-gray-800 dark:text-brand-400">
<x-ui.icon :name="$icon" size="2xl" />
</span>
<span class="text-sm text-gray-600 dark:text-gray-300">
<span class="font-semibold text-brand-600 dark:text-brand-400">Click to upload</span> or drag and drop
</span>
@if ($hint)
<span class="text-xs text-gray-400 dark:text-gray-500">{{ $hint }}</span>
@endif
<input
type="file"
x-ref="input"
name="{{ $name }}"
@if ($accept) accept="{{ $accept }}" @endif
@if ($multiple) multiple @endif
@disabled($disabled)
@change="addFiles($event.target.files)"
class="sr-only"
>
</label>
{{-- Validation errors --}}
<ul x-show="errors.length" x-cloak class="space-y-1 text-sm text-red-600 dark:text-red-400">
<template x-for="error in errors" :key="error">
<li class="flex items-center gap-1.5">
<x-ui.icon name="warning-circle" weight="fill" size="sm" />
<span x-text="error"></span>
</li>
</template>
</ul>
{{-- File queue --}}
<div x-show="hasFiles" x-cloak class="space-y-3">
<div class="flex items-center justify-between text-xs font-medium text-gray-500 dark:text-gray-400">
<span>
<span x-text="files.length"></span>
<span x-text="files.length === 1 ? 'file' : 'files'"></span>
selected
</span>
<span x-text="humanSize(totalSize)" class="tabular-nums"></span>
</div>
<ul class="space-y-2">
<template x-for="file in files" :key="file.id">
<li class="flex items-center gap-3 rounded-xl border border-gray-200 bg-white px-3 py-2.5 dark:border-gray-800 dark:bg-gray-900">
<template x-if="file.previewUrl">
<img :src="file.previewUrl" alt="" class="h-11 w-11 shrink-0 rounded-lg object-cover ring-1 ring-gray-200 dark:ring-gray-800">
</template>
<template x-if="! file.previewUrl">
<span class="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400">
<i class="ph text-xl leading-none" :class="'ph-' + file.icon" aria-hidden="true"></i>
</span>
</template>
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-3">
<span class="truncate text-sm font-medium text-gray-700 dark:text-gray-200" x-text="file.name"></span>
<span class="shrink-0 text-xs text-gray-400 tabular-nums dark:text-gray-500" x-text="humanSize(file.size)"></span>
</div>
{{-- Per-file progress while uploading --}}
<div x-show="file.status === 'uploading'" class="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-800">
<div class="h-full rounded-full bg-brand-600 transition-all duration-200" :style="`width: ${file.progress}%`"></div>
</div>
<div x-show="file.status === 'done'" x-cloak class="mt-1 flex items-center gap-1 text-xs font-medium text-green-600 dark:text-green-400">
<x-ui.icon name="check-circle" weight="fill" size="sm" />
<span>Uploaded</span>
</div>
</div>
<button
type="button"
x-show="file.status !== 'uploading'"
@click="remove(file.id)"
class="shrink-0 rounded-lg p-1.5 text-gray-400 transition hover:bg-gray-100 hover:text-red-600 dark:hover:bg-gray-800 dark:hover:text-red-400"
aria-label="Remove file"
>
<x-ui.icon name="x" weight="bold" size="sm" />
</button>
</li>
</template>
</ul>
</div>
</div>
{{-- Footer actions --}}
<div class="flex items-center justify-between gap-3 border-t border-gray-200 bg-gray-50/60 px-5 py-4 dark:border-gray-800 dark:bg-gray-900/40 sm:px-6">
<button
type="button"
x-show="hasFiles"
x-cloak
@click="reset()"
class="text-sm font-medium text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
Clear all
</button>
<span x-show="! hasFiles" class="text-sm text-gray-400 dark:text-gray-500">No files selected</span>
<div class="flex items-center gap-2">
@if ($cancelHref)
<x-ui.button :href="$cancelHref" variant="ghost" size="sm">{{ $cancelLabel }}</x-ui.button>
@endif
<x-ui.button
type="submit"
size="sm"
icon="cloud-arrow-up"
::disabled="! hasFiles || disabled || uploading"
>
<span x-show="! uploading">{{ $submitLabel }}</span>
<span x-show="uploading" x-cloak>Uploading…</span>
</x-ui.button>
</div>
</div>
</form>