Dropzone
A drag-and-drop file upload input in three shapes (dashed zone, one-row compact, or a Choose files button), with list or thumbnail-grid previews (images show the picture, PDFs their first page, videos their first frame), already-uploaded material shown alongside and removable, accept + max-size validation, normal multipart form submission, Livewire 3/4 wire:model upload progress, and an optional `endpoint` that uploads each file straight away with live progress, abort and retry.
Preview
Drag & drop files here, or browse
PDF, images, up to 10 MB each
-
Uploaded
PDF or images, up to 10 MB each.
<div class="mx-auto w-full max-w-md">
<x-ui.field name="attachments" label="Upload files" hint="PDF or images, up to 10 MB each.">
<x-ui.dropzone name="attachments" accept=".pdf,image/*" :max-size="10" />
</x-ui.field>
</div>
Installation
php artisan ui:add dropzone
Note
This component ships an Alpine behavior module at
resources/js/ui/dropzone.js. Import it once from your bundle so it registers on alpine:init:
import './dropzone.js';
Registry contract
php artisan ui:add dropzone
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/dropzone/dropzone.blade.php -
resources/js/ui/dropzone.js
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Dropzone (`dropzone`)
A drag-and-drop file upload input in three shapes (dashed zone, one-row compact, or a Choose files button), with list or thumbnail-grid previews (images show the picture, PDFs their first page, videos their first frame), already-uploaded material shown alongside and removable, accept + max-size validation, normal multipart form submission, Livewire 3/4 wire:model upload progress, and an optional `endpoint` that uploads each file straight away with live progress, abort and retry.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add dropzone
```
## Usage
```blade
<div class="mx-auto w-full max-w-md">
<x-ui.field name="attachments" label="Upload files" hint="PDF or images, up to 10 MB each.">
<x-ui.dropzone name="attachments" accept=".pdf,image/*" :max-size="10" />
</x-ui.field>
</div>
```
## Props
- `name` (string|null, default `null`) — Field name for the hidden file input (`[]` is appended when `multiple`). Inherited from a wrapping <x-ui.field name="…"> via @aware.
- `multiple` (bool, default `true`) — Allow more than one file. In single mode a new pick replaces the current one, and the zone, its button and the generated hint speak of one file.
- `accept` (string|null, default `null`) — Accepted types as a comma list of extensions and/or MIME patterns (".pdf,image/*"), applied to the native input and the client-side guard.
- `maxSize` (int, default `10`) — Maximum size per file in MB; larger files are refused with a status message.
- `disabled` (bool, default `false`) — Removes the drop target from the tab order and marks it aria-disabled.
- `variant` (default|compact|button, default `default`) — `default` is a dashed zone, `compact` a one-row strip, `button` a Choose files button that still takes a drop.
- `preview` (list|grid, default `list`) — `list` shows name + size rows, `grid` shows thumbnail tiles (images, PDF first page, video first frame).
- `existing` (array, default `[]`) — Material already on the server: [["id", "name", "size", "url", "type"]]. Removing one posts its id in `<name>_removed[]`.
- `endpoint` (string|null, default `null`) — URL that receives each accepted file as its own multipart POST (field = `fieldName`) with the page CSRF token and credentials, live `upload.onprogress` progress, abort when the file is removed and a Retry control after a failure. The hidden input stays empty so a wrapping form never re-posts the files. Omit it for a plain form input.
- `fieldName` (string, default `file`) — Multipart field name used by the endpoint transport.
- `token` (string|null, default `null`) — Sent as `Authorization: Bearer …` with every endpoint request.
- `hint` (string|null, default `null`) — Replaces the generated "PDF, images, up to 10 MB each" line under the drop target.
- `label` (string|null, default `null`) — Accessible name (`aria-label`) for the drop target when the visible copy is not specific enough.
## Use when
- Use when users must provide an existing file and drag-and-drop or file selection reduces effort.
- A form needs a file input people can drop onto or browse into, with previews and client-side accept/size checks, that still posts as ordinary multipart.
- Files must upload on their own the moment they are chosen: pass `endpoint` and each file POSTs with live progress, abort on remove and retry after a failure.
- Existing material must show beside the new selection and be removable (`existing`), or a Livewire property drives the upload (`wire:model`).
## Avoid when
- Do not require dragging as the only input method; show accepted types, size limits, progress, errors, and a keyboard-accessible file picker.
- Always provide a conventional file-picker path, clear constraints, upload progress, and recoverable errors. Drag-and-drop must remain optional.
- A single plain <input type="file"> inside a <x-ui.field> is enough and no preview, drop target or progress is wanted.
- You need resumable or chunked uploads: the endpoint transport sends each file in one request.
## Anti-patterns
- Requiring drag-and-drop
- Hiding file constraints until validation fails
## Rules
- Use the `<brok:dropzone>` tag (or `<x-ui.dropzone>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/dropzone
- Registry JSON (files, props, contract): https://brokui.dev/r/open/dropzone.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
{{-- A plain Choose files button; dropping a file on it works too. --}}
<div class="mx-auto flex w-full max-w-md flex-col gap-2">
<x-ui.dropzone name="photos" variant="button" preview="grid" accept="image/*" :max-size="10" />
</div>
{{-- One row where space is tight; still a full drop target. --}}
<div class="mx-auto w-full max-w-md">
<x-ui.field name="receipt" label="Receipt">
<x-ui.dropzone name="receipt" variant="compact" :multiple="false" accept=".pdf,image/*" :max-size="5" />
</x-ui.field>
</div>
{{-- Thumbnail tiles: images show their picture, PDFs their first page, videos
their first frame, other files a type tile. Material
already uploaded (`existing`) sits first; removing one posts its id in
`attachments_removed[]`. --}}
@php
$existing = [
['id' => 41, 'name' => 'brand-guide.pdf', 'size' => 1_240_000, 'type' => 'application/pdf', 'url' => 'data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCAzMDAgNDAwXSAvQ29udGVudHMgNCAwIFIgL1Jlc291cmNlcyA8PCAvRm9udCA8PCAvRjEgNSAwIFIgPj4gPj4gPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCAxMTUgPj4Kc3RyZWFtCkJUIC9GMSAyOCBUZiA0MCAzMzAgVGQgKEJyYW5kIGd1aWRlKSBUaiBFVCBCVCAvRjEgMTQgVGYgNDAgMzAwIFRkIChWZXJzaW9uIDMsIDIwMjYpIFRqIEVUIDAuODUgZyA0MCA2MCAyMjAgMjAwIHJlIGYKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDA0MDcgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0NzcKJSVFT0YK'],
['id' => 42, 'name' => 'launch-plan.xlsx', 'size' => 88_000],
['id' => 43, 'name' => 'hero.png', 'size' => 512_000, 'type' => 'image/png', 'url' => 'data:image/svg+xml;utf8,' . rawurlencode('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 120"><rect width="160" height="120" fill="hsl(220 14% 90%)"/><circle cx="52" cy="46" r="18" fill="hsl(220 10% 70%)"/><path d="M0 120 60 64l34 34 22-20 44 42z" fill="hsl(220 10% 60%)"/></svg>')],
];
@endphp
<div class="mx-auto w-full max-w-md">
<x-ui.field name="attachments" label="Project files" hint="Images, PDFs and documents up to 10 MB each.">
<x-ui.dropzone name="attachments" preview="grid" :existing="$existing" accept=".pdf,.doc,.docx,.xlsx,image/*" :max-size="10" />
</x-ui.field>
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| name | string | null | null | Field name for the hidden file input (`[]` is appended when `multiple`). Inherited from a wrapping <x-ui.field name="…"> via @aware. |
| multiple | bool | true | Allow more than one file. In single mode a new pick replaces the current one, and the zone, its button and the generated hint speak of one file. |
| accept | string | null | null | Accepted types as a comma list of extensions and/or MIME patterns (".pdf,image/*"), applied to the native input and the client-side guard. |
| maxSize | int | 10 | Maximum size per file in MB; larger files are refused with a status message. |
| disabled | bool | false | Removes the drop target from the tab order and marks it aria-disabled. |
| variant | default | compact | button | default | `default` is a dashed zone, `compact` a one-row strip, `button` a Choose files button that still takes a drop. |
| preview | list | grid | list | `list` shows name + size rows, `grid` shows thumbnail tiles (images, PDF first page, video first frame). |
| existing | array | [] | Material already on the server: [["id", "name", "size", "url", "type"]]. Removing one posts its id in `<name>_removed[]`. |
| endpoint | string | null | null | URL that receives each accepted file as its own multipart POST (field = `fieldName`) with the page CSRF token and credentials, live `upload.onprogress` progress, abort when the file is removed and a Retry control after a failure. The hidden input stays empty so a wrapping form never re-posts the files. Omit it for a plain form input. |
| fieldName | string | file | Multipart field name used by the endpoint transport. |
| token | string | null | null | Sent as `Authorization: Bearer …` with every endpoint request. |
| hint | string | null | null | Replaces the generated "PDF, images, up to 10 MB each" line under the drop target. |
| label | string | null | null | Accessible name (`aria-label`) for the drop target when the visible copy is not specific enough. |
Slots
default— Unused; the dropzone renders its own target and file list.x-ui.dropzone.dropzone— Installed subcomponent from the registry item.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- A hidden native <input type="file"> is the submit source of truth; drops and removals rewrite its FileList through DataTransfer so a plain form posts exactly what is shown.
- With `endpoint` every accepted file uploads immediately over XMLHttpRequest; the row shows a progress bar that folds away once done, removal aborts an in-flight request, and a failed row offers Retry.
- Dispatches `files-added` ({ files }) for every accepted selection, `file-uploaded` ({ id, name, response }) per successful endpoint upload, and `dropzone-remove-existing` ({ id, name }) when uploaded material is removed.
- Livewire `wire:model` on the root binds the native input and drives the shared upload progress bar from livewire-upload-* events.
- Window-level dragover/drop guards stop the browser from navigating away on a stray drop; they are removed on destroy along with object URLs, timers and requests.
- The client-side file guard's messages go through __(): 'The file :file exceeds the :max MB limit.' and 'The file :file is not an accepted file type.' (:file and :max are filled in the browser); add them to the app's lang JSON to translate them.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.
Guidance
Attach an existing external file.
Use when
- Use when users must provide an existing file and drag-and-drop or file selection reduces effort.
- A form needs a file input people can drop onto or browse into, with previews and client-side accept/size checks, that still posts as ordinary multipart.
- Files must upload on their own the moment they are chosen: pass `endpoint` and each file POSTs with live progress, abort on remove and retry after a failure.
- Existing material must show beside the new selection and be removable (`existing`), or a Livewire property drives the upload (`wire:model`).
Avoid when
- Do not require dragging as the only input method; show accepted types, size limits, progress, errors, and a keyboard-accessible file picker.
- Always provide a conventional file-picker path, clear constraints, upload progress, and recoverable errors. Drag-and-drop must remain optional.
- A single plain <input type="file"> inside a <x-ui.field> is enough and no preview, drop target or progress is wanted.
- You need resumable or chunked uploads: the endpoint transport sends each file in one request.
Use instead
- Direct structured form entry
- Conventional file picker
Anti-patterns
- Requiring drag-and-drop
- Hiding file constraints until validation fails
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- Enter Space
- Focus
managed
- The drop target is a keyboard-operable role="button" (Enter/Space open the picker) that reflects aria-invalid and aria-describedby from a wrapping field error.
- Rejected files and failed uploads announce through role="alert"/role="status" regions; each progress bar is a labelled role="progressbar".
- Remove and Retry controls carry the file name in their accessible name.
- Removing a file moves keyboard focus to the remove button now in its place (or the drop target when no file is left), so focus never falls back to the page.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="dropzone-{{ $record->id }}">
<div class="mx-auto w-full max-w-md">
<x-ui.field name="attachments" label="Upload files" hint="PDF or images, up to 10 MB each.">
<x-ui.dropzone name="attachments" accept=".pdf,image/*" :max-size="10" />
</x-ui.field>
</div>
</div>
Validation
Validation support: laravel-error-bag. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:dropzone
wire:model="value"
:aria-invalid="$errors->has('value') ? 'true' : 'false'"
aria-describedby="value-error"
/>
@error('value')
<p id="value-error" role="alert">{{ $message }}</p>
@enderror
<brok:button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving…</span>
</brok:button>
</form>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
{{-- A wrapping field with a name shares it down via @aware, so the dropzone can
self-wire aria-invalid/aria-describedby off the shared error bag without the
consumer repeating it — exactly like the input primitive. With no field
ancestor (or its own name attribute) this resolves to null and nothing below
changes — fully backward-compatible. --}}
@aware([
'name' => null,
'htmlName' => null,
])
@props([
// Field name for the submitted file input. Inherited from a wrapping field if omitted.
'name' => null,
// Allow selecting more than one file.
'multiple' => true,
// Accepted types, e.g. ".pdf,image/*" — passed to the native input and the JS guard.
'accept' => null,
// Maximum size per file, in MB (client-side guard).
'maxSize' => 10,
'disabled' => false,
// default (a dashed zone), compact (one row), button (a Choose files button
// that still takes a drop).
'variant' => 'default',
// list (name + size rows) or grid (thumbnail tiles: images show their picture,
// PDFs their first page, videos their first frame, from object URLs — a CSP
// needs blob: in img-src, media-src and frame-src for that).
'preview' => 'list',
// Material already uploaded: [['id', 'name', 'size', 'url', 'type']]. Shown
// with the same previews; removing one posts its id in `<name>_removed[]`.
'existing' => [],
// Upload each accepted file straight away as a multipart POST to this URL
// (field = `fieldName`), with live per-file progress, abort on remove and
// retry after a failure. The hidden input then stays empty so a wrapping
// form never posts the files a second time. Omit it for a plain form input.
'endpoint' => null,
'fieldName' => 'file',
// Sent as a Bearer token with every endpoint request (the page CSRF token
// and credentials always go along).
'token' => null,
// Replace the generated "PDF, images, up to 10 MB each" line.
'hint' => null,
// Accessible name for the drop target, when the visible copy is not enough.
'label' => null,
])
@php
// Accept a string, a backed enum or a Stringable for `variant`.
$styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
$variant = $styles['normalizeVariant']($variant);
$styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
// @aware leaves $name undefined with no wrapping field, so normalise it.
$awareName = $name ?? null;
$awareHtmlName = $htmlName ?? null;
// Effective field name: the @aware value from a wrapping field, else an explicit
// `name` attribute set on the dropzone itself.
$fieldName = $awareName ?? $attributes->get('name');
$resolvedHtmlName = $awareName
? ($awareHtmlName ?? collect(explode('.', $awareName))->reduce(
fn ($carry, $segment, $index) => $index === 0 ? $segment : $carry.'['.$segment.']',
''
))
: $attributes->get('name');
// The shared error bag drives invalid state, identically to input/field.
$bag = ($errors ?? null) instanceof \Illuminate\Support\ViewErrorBag ? $errors : null;
$hasBagError = $bag && filled($fieldName) && $bag->has($fieldName);
// Reference the field's error region only when inheriting AND there is an error
// to announce — the field renders that node under the same condition.
$describedBy = (filled($awareName) && $hasBagError) ? $fieldName.'-error' : null;
// Multiple file inputs need the [] suffix so they post as an array.
$inputName = filled($resolvedHtmlName) ? ($multiple ? $resolvedHtmlName.'[]' : $resolvedHtmlName) : null;
$resolvedId = $attributes->get('id', filled($fieldName) ? $fieldName : null);
// wire:model belongs on the native file input, never the visual wrapper.
$wireModelAttributes = $attributes->whereStartsWith('wire:model');
$wireModelValues = $wireModelAttributes->getAttributes();
$wireModelDirective = array_key_first($wireModelValues);
$wireModel = $wireModelDirective ? (string) $wireModelValues[$wireModelDirective] : null;
$variant = in_array($variant, ['default', 'compact', 'button'], true) ? $variant : 'default';
$preview = in_array($preview, ['list', 'grid'], true) ? $preview : 'list';
$existingFiles = collect((array) $existing)->filter(fn ($f) => is_array($f) && filled($f['name'] ?? null))->map(fn ($f) => [
'id' => (string) ($f['id'] ?? $f['name']),
'name' => (string) $f['name'],
'size' => (int) ($f['size'] ?? 0),
'url' => $f['url'] ?? null,
'type' => (string) ($f['type'] ?? ''),
])->values()->all();
$removedName = filled($resolvedHtmlName) ? preg_replace('/\[\]$/', '', $resolvedHtmlName).'_removed[]' : null;
$config = [
'multiple' => (bool) $multiple,
'accept' => (string) ($accept ?? ''),
'maxSize' => (int) $maxSize,
'livewireModel' => $wireModel,
'existing' => $existingFiles,
'endpoint' => filled($endpoint) ? (string) $endpoint : '',
'fieldName' => (string) $fieldName,
'token' => filled($token) ? (string) $token : '',
'labels' => [
'uploadFailed' => __('Upload failed. Try again.'),
'httpError' => __('upload failed (HTTP :status).'),
'networkError' => __('upload failed (network error).'),
// The client-side file guard; :file and :max are filled in the browser.
'tooLarge' => __('The file :file exceeds the :max MB limit.'),
'notAccepted' => __('The file :file is not an accepted file type.'),
],
];
@endphp
<div
data-slot="dropzone"
data-variant="{{ $variant }}"
data-preview="{{ $preview }}"
@if (filled($endpoint)) data-endpoint="true" @endif
x-data="uiDropzone(@js($config))"
@if ($hasBagError) data-invalid="true" @endif
{{ $attributes->except(['name', 'id'])->whereDoesntStartWith('wire:model')->merge(['class' => 'w-full']) }}
>
{{-- Hidden native input — the submit source of truth, kept in sync by the JS. --}}
<input
type="file"
x-ref="input"
@if (filled($inputName)) name="{{ $inputName }}" @endif
@if (filled($resolvedId)) id="{{ $resolvedId }}" @endif
@if ($multiple) multiple @endif
@if (filled($accept)) accept="{{ $accept }}" @endif
@disabled($disabled)
@change="onInputChange($event)"
x-on:livewire-upload-start="onLivewireUploadStart()"
x-on:livewire-upload-progress="onLivewireUploadProgress($event)"
x-on:livewire-upload-finish="onLivewireUploadFinish()"
x-on:livewire-upload-error="onLivewireUploadError()"
x-on:livewire-upload-cancel="onLivewireUploadCancel()"
{{ $wireModelAttributes }}
class="sr-only"
tabindex="-1"
aria-hidden="true"
/>
@if (filled($removedName))
<template x-for="id in removedExisting" :key="id">
<input type="hidden" name="{{ $removedName }}" :value="id" />
</template>
@endif
<div
data-slot="dropzone-upload-progress"
x-show="uploading"
x-cloak
class="mb-3 space-y-2"
role="status"
aria-live="polite"
>
<div class="flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span>{{ __('Uploading…') }}</span>
<span x-text="uploadProgress + '%'" class="tabular-nums"></span>
</div>
<div
class="h-2 overflow-hidden rounded-full bg-muted"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
x-bind:aria-valuenow="uploadProgress"
aria-label="{{ __('Upload progress') }}"
>
<div
class="h-full w-[var(--upload-progress)] rounded-full bg-primary transition-[width] duration-150 ease-out motion-reduce:transition-none"
x-bind:style="'--upload-progress: ' + uploadProgress + '%'"
></div>
</div>
</div>
{{-- Drop target --}}
@php
$targetBase = 'cursor-pointer transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background aria-[invalid=true]:border-destructive aria-[disabled=true]:cursor-not-allowed aria-[disabled=true]:opacity-60 motion-reduce:transition-none';
$targetClass = match ($variant) {
'compact' => 'group flex items-center gap-3 rounded-md border border-dashed border-input bg-background px-3 py-2 text-start hover:border-ring/60 hover:bg-muted/40 data-[dragging=true]:border-primary data-[dragging=true]:bg-primary/5',
'button' => 'inline-flex rounded-md data-[dragging=true]:ring-2 data-[dragging=true]:ring-ring data-[dragging=true]:ring-offset-2 data-[dragging=true]:ring-offset-background',
default => 'group flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-input bg-background px-6 py-10 text-center hover:border-ring/60 hover:bg-muted/40 data-[dragging=true]:border-primary data-[dragging=true]:bg-primary/5',
};
// ".pdf,image/*" reads as "PDF, images"; the raw accept string stays on the input.
$kindWords = ['image/*' => __('images'), 'video/*' => __('videos'), 'audio/*' => __('audio'), 'application/pdf' => 'PDF', 'text/*' => __('text files')];
$accepted = collect(explode(',', (string) $accept))->map(fn ($t) => trim($t))->filter()->map(fn ($t) => $kindWords[strtolower($t)] ?? (str_starts_with($t, '.') ? strtoupper(substr($t, 1)) : $t))->unique()->implode(', ');
// A single-file zone speaks of one file; every string stays translatable.
$hint = filled($hint) ? $hint : match (true) {
$accepted !== '' && $multiple => __(':types, up to :max MB each', ['types' => $accepted, 'max' => $maxSize]),
$accepted !== '' => __(':types, up to :max MB', ['types' => $accepted, 'max' => $maxSize]),
(bool) $multiple => __('Up to :max MB per file', ['max' => $maxSize]),
default => __('Up to :max MB', ['max' => $maxSize]),
};
@endphp
<div
data-slot="dropzone-target"
role="button"
tabindex="{{ $disabled ? '-1' : '0' }}"
@if (filled($label)) aria-label="{{ $label }}" @endif
@if ($hasBagError) aria-invalid="true" @endif
@if (filled($describedBy)) aria-describedby="{{ $describedBy }}" @endif
@if ($disabled) aria-disabled="true" @endif
@click="openPicker()"
@keydown.enter.prevent="openPicker()"
@keydown.space.prevent="openPicker()"
@dragenter.prevent="onDragEnter()"
@dragover.prevent
@dragleave.prevent="onDragLeave()"
@drop.prevent="onDrop($event)"
:data-dragging="dragging"
class="{{ $targetClass.' '.$targetBase }}"
>
@if ($variant === 'button')
<span class="{{ $styles['button']['base'].' '.$styles['button']['variants']['outline'].' '.$styles['button']['sizes']['md'] }}" data-slot="dropzone-button">
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" x2="12" y1="3" y2="15" />
</svg>
{{ $multiple ? __('Choose files') : __('Choose file') }}
</span>
<span class="sr-only">{{ $hint }}</span>
@elseif ($variant === 'compact')
<span class="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground" aria-hidden="true">
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" x2="12" y1="3" y2="15" />
</svg>
</span>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-medium text-foreground">{{ $multiple ? __('Drop files here, or') : __('Drop a file here, or') }} <span class="text-link underline-offset-2 group-hover:underline">{{ __('browse') }}</span></span>
<span class="block truncate text-xs text-muted-foreground">{{ $hint }}</span>
</span>
@else
<span class="flex size-10 items-center justify-center rounded-full bg-muted text-muted-foreground" aria-hidden="true">
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" x2="12" y1="3" y2="15" />
</svg>
</span>
<div class="space-y-1">
<p class="text-sm font-medium text-foreground">
{{ $multiple ? __('Drag & drop files here, or') : __('Drag & drop a file here, or') }}
<span class="text-link underline-offset-2 group-hover:underline">{{ __('browse') }}</span>
</p>
<p class="text-xs text-muted-foreground">{{ $hint }}</p>
</div>
@endif
</div>
{{-- Client-side status (rejected size/type). --}}
<p x-show="status" x-cloak x-text="status" role="alert" class="mt-2 text-sm font-medium text-destructive"></p>
{{-- Files: existing material first, then the new selection. --}}
@php
$kindIcons = [
'pdf' => '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v5h5"/><path d="M9 17v-6h2a2 2 0 0 1 0 4H9"/>',
'document' => '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v5h5"/><path d="M8 13h8M8 17h6"/>',
'spreadsheet' => '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v5h5"/><path d="M8 12h8M8 16h8M12 12v8"/>',
'archive' => '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v5h5"/><path d="M10 12h1M10 14h1M10 16h1M10 18h1"/>',
'media' => '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v5h5"/><path d="m10 12 4 2.5-4 2.5z"/>',
'generic' => '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v5h5"/>',
];
@endphp
@if ($preview === 'grid')
<ul x-show="entries.length" x-cloak data-slot="dropzone-files" class="mt-3 grid grid-cols-3 gap-2 sm:grid-cols-4">
<template x-for="file in entries" :key="file.key">
<li class="group/tile relative min-w-0" :data-existing="file.existing ? 'true' : null">
<div class="relative flex aspect-square items-center justify-center overflow-hidden rounded-md border border-border bg-muted text-muted-foreground">
<template x-if="file.previewKind === 'image'">
<img :src="file.preview" :alt="file.name" class="size-full object-cover" />
</template>
{{-- The browser's own PDF viewer draws page one over the type tile; a
browser without an inline viewer (Android Chrome) leaves the tile
showing. The frame is inert so the tile stays one control. --}}
<template x-if="file.previewKind === 'pdf'">
<iframe :src="file.preview" :title="file.name" tabindex="-1" aria-hidden="true" loading="lazy" class="pointer-events-none absolute inset-0 size-full"></iframe>
</template>
<template x-if="file.previewKind === 'video'">
<video :src="file.preview" muted playsinline preload="metadata" tabindex="-1" aria-hidden="true" class="pointer-events-none absolute inset-0 size-full object-cover"></video>
</template>
<template x-if="file.previewKind !== 'image'">
<span class="flex flex-col items-center gap-1" aria-hidden="true">
@foreach ($kindIcons as $kind => $paths)
<svg x-show="file.kind === '{{ $kind }}'" class="size-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">{!! $paths !!}</svg>
@endforeach
<span class="text-2xs font-medium uppercase tracking-wide" x-text="file.extension"></span>
</span>
</template>
<span x-show="file.existing" x-cloak class="absolute start-1 top-1 rounded-sm bg-background/90 px-1 text-2xs font-medium text-muted-foreground">{{ __('Uploaded') }}</span>
{{-- Endpoint transport: a thin bar along the tile's foot while the file uploads. --}}
<template x-if="endpoint && !file.existing && !file.settled">
<div
class="absolute inset-x-0 bottom-0 h-1 bg-background/80"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
:aria-valuenow="file.progress"
:aria-label="{{ Js::from(__('Upload progress for')) }} + ' ' + file.name"
>
<div
class="h-full w-[var(--upload-progress)] transition-[width] duration-150 ease-out motion-reduce:transition-none"
:class="file.error ? 'bg-destructive' : (file.done ? 'bg-success' : 'bg-primary')"
:style="'--upload-progress: ' + file.progress + '%'"
></div>
</div>
</template>
</div>
{{-- Remove: a small chip on the tile's corner, shown on hover, focus or touch. --}}
<button
type="button"
data-dropzone-remove
x-on:click="removeFile(file.key)"
:aria-label="{{ Js::from(__('Remove')) }} + ' ' + file.name"
class="absolute -end-1 -top-1 z-10 flex size-5 items-center justify-center rounded-full border border-border bg-background text-muted-foreground opacity-0 shadow-sm transition-opacity hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-hover/tile:opacity-100 [@media(hover:none)]:opacity-100 motion-reduce:transition-none"
>
<svg class="size-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
</button>
<p class="mt-1 truncate text-xs text-foreground" x-text="file.name" :title="file.name"></p>
<p class="text-2xs text-muted-foreground" x-text="file.error ? file.errorMessage : (endpoint && !file.existing && !file.settled ? file.progress + '%' : formatSize(file.size))"></p>
<template x-if="file.error">
<button
type="button"
x-on:click="retry(file.key)"
:aria-label="{{ Js::from(__('Retry')) }} + ' ' + file.name"
class="text-2xs font-medium text-link underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>{{ __('Retry') }}</button>
</template>
</li>
</template>
</ul>
@else
<ul x-show="entries.length" x-cloak data-slot="dropzone-files" class="mt-3 space-y-2">
<template x-for="file in entries" :key="file.key">
<li class="flex items-center justify-between gap-3 rounded-md border border-border bg-card px-3 py-2" :data-existing="file.existing ? 'true' : null">
<div class="flex min-w-0 flex-1 items-center gap-2">
<span class="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-sm bg-muted text-muted-foreground" aria-hidden="true">
<template x-if="file.previewKind === 'image'">
<img :src="file.preview" alt="" class="size-full object-cover" />
</template>
<template x-if="file.previewKind !== 'image'">
<span class="contents">
@foreach ($kindIcons as $kind => $paths)
<svg x-show="file.kind === '{{ $kind }}'" class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{!! $paths !!}</svg>
@endforeach
</span>
</template>
</span>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm text-foreground" x-text="file.name"></span>
<span x-show="file.existing" x-cloak class="block text-xs text-muted-foreground">{{ __('Uploaded') }}</span>
{{-- Endpoint transport: the bar fills, holds for a beat, then folds away. --}}
<template x-if="endpoint && !file.existing">
<span class="block">
<span
class="grid transition-[grid-template-rows] duration-300 ease-out motion-reduce:transition-none"
:class="file.settled ? 'grid-rows-[0fr]' : 'grid-rows-[1fr]'"
>
<span class="block min-h-0 overflow-hidden">
<span
class="mt-2 block h-2 w-full overflow-hidden rounded-full bg-muted"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
:aria-valuenow="file.progress"
:aria-label="{{ Js::from(__('Upload progress for')) }} + ' ' + file.name"
>
<span
class="block h-full w-[var(--upload-progress)] rounded-full transition-[width] duration-150 ease-out motion-reduce:transition-none"
:class="file.error ? 'bg-destructive' : (file.done ? 'bg-success' : 'bg-primary')"
:style="'--upload-progress: ' + file.progress + '%'"
></span>
</span>
</span>
</span>
<span x-show="file.error" x-cloak class="mt-2 flex items-center gap-2 text-xs">
<span class="text-destructive" x-text="file.errorMessage"></span>
<button
type="button"
x-on:click="retry(file.key)"
:aria-label="{{ Js::from(__('Retry')) }} + ' ' + file.name"
class="font-medium text-link underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>{{ __('Retry') }}</button>
</span>
</span>
</template>
</span>
</div>
<div class="flex shrink-0 items-center gap-2">
<span class="flex items-center gap-1 text-xs text-muted-foreground">
<svg x-show="file.settled" x-cloak class="size-4 text-success" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>
<span x-text="file.settled ? {{ Js::from(__('Uploaded')) }} : (endpoint && !file.existing && file.progress > 0 && !file.error ? file.progress + '%' : formatSize(file.size))"></span>
</span>
<x-ui.button
type="button"
variant="ghost"
size="icon"
class="size-7"
data-dropzone-remove
x-on:click="removeFile(file.key)"
x-bind:aria-label="{{ Js::from(__('Remove')) }} + ' ' + file.name"
>
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
</x-ui.button>
</div>
</li>
</template>
</ul>
@endif
</div>
/**
* Dropzone behavior (interactive layer for the open-tier dropzone component).
*
* A drag-and-drop file input that stays form-submittable: the visible UI manages
* a list of selected files (with client-side accept + max-size validation), and a
* real hidden `<input type="file">` carrying the consumer's `name` is kept in sync
* via a DataTransfer, so the files post in an ordinary multipart form — no JS
* transport required.
*
* With an `endpoint` the dropzone becomes the transport instead: every accepted
* file is POSTed on its own as multipart (`fieldName` = the file) through an
* XMLHttpRequest with genuine `upload.onprogress` progress, abort-on-remove,
* retry after a failure, the page CSRF token and credentials, and an optional
* Bearer `token`. The hidden input then stays empty so a wrapping form never
* re-posts what the endpoint already received. `files-added` ({ files }) fires
* for every accepted selection in both modes; `file-uploaded` ({ id, name,
* response }) fires per successful endpoint upload.
*
* Window-level `dragover`/`drop` listeners are added in init() so a file dropped
* *outside* the zone doesn't make the browser navigate away; both are removed in
* destroy() so an unmount mid-interaction cannot leak.
*
* Self-registers on `alpine:init` so import order does not matter — import this
* file once from your bundle (e.g. resources/js/ui/index.js).
*/
const KINDS = {
image: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'avif', 'heic', 'bmp'],
pdf: ['pdf'],
document: ['doc', 'docx', 'txt', 'rtf', 'md', 'odt', 'pages'],
spreadsheet: ['xls', 'xlsx', 'csv', 'numbers', 'ods'],
archive: ['zip', 'rar', '7z', 'tar', 'gz'],
media: ['mp4', 'mov', 'webm', 'mkv', 'mp3', 'wav', 'm4a', 'aac', 'ogg'],
};
function extensionOf(name) {
const at = String(name).lastIndexOf('.');
return at === -1 ? '' : String(name).slice(at + 1).toLowerCase();
}
/** What the grid can show live: the picture, the PDF's first page, or the video's first frame. */
function previewKindOf(name, type = '') {
const kind = kindOf(name, type);
if (kind === 'image' || kind === 'pdf') return kind;
const mime = String(type || '').toLowerCase();
if (mime.startsWith('video/') || ['mp4', 'mov', 'webm', 'mkv'].includes(extensionOf(name))) return 'video';
return null;
}
/** Browser PDF viewers honour these fragment flags; the tile shows page one, nothing else. */
function pdfFragment(url) {
return `${url}#page=1&toolbar=0&navpanes=0&scrollbar=0&view=FitH`;
}
/** Icon family for a file, from its MIME type first and its extension second. */
function kindOf(name, type = '') {
const mime = String(type || '').toLowerCase();
if (mime.startsWith('image/')) return 'image';
if (mime === 'application/pdf') return 'pdf';
if (mime.startsWith('video/') || mime.startsWith('audio/')) return 'media';
const ext = extensionOf(name);
return Object.keys(KINDS).find((kind) => KINDS[kind].includes(ext)) ?? 'generic';
}
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiDropzone', (config = {}) => ({
multiple: config.multiple ?? true,
accept: config.accept ?? '',
maxSize: config.maxSize ?? 10, // MB
livewireModel: config.livewireModel ?? '',
endpoint: config.endpoint ?? '',
fieldName: config.fieldName ?? 'file',
token: config.token ?? '',
labels: config.labels ?? {},
files: [],
// Material already on the server, shown with the same previews.
existing: (config.existing ?? []).map((f) => ({
key: `existing-${f.id}`,
id: String(f.id),
name: f.name,
size: Number(f.size) || 0,
existing: true,
kind: kindOf(f.name, f.type),
extension: extensionOf(f.name),
previewKind: f.url ? previewKindOf(f.name, f.type) : null,
preview: f.url ? (previewKindOf(f.name, f.type) === 'pdf' ? pdfFragment(f.url) : f.url) : null,
})),
removedExisting: [],
dragging: false,
status: '',
uploading: false,
uploadProgress: 0,
// Internal bookkeeping.
_seq: 0,
_dragDepth: 0, // counts dragenter/leave so child elements don't flicker `dragging`
_dispatchingChange: false,
_xhrs: {}, // { [key]: XMLHttpRequest } — in-flight endpoint uploads
_settle: {}, // { [key]: timeoutId } — the beat before a finished row folds its bar
/** Existing material first, then the new selection. */
get entries() {
return [...this.existing, ...this.files];
},
init() {
// Prevent the browser from opening a file dropped outside the zone.
this._onWindowDragOver = (e) => e.preventDefault();
this._onWindowDrop = (e) => e.preventDefault();
window.addEventListener('dragover', this._onWindowDragOver);
window.addEventListener('drop', this._onWindowDrop);
},
/** Open the native picker (the hidden <input>) on click / keyboard. */
openPicker() {
this.$refs.input?.click();
},
onInputChange(e) {
if (this._dispatchingChange) return;
// Pull the raw File objects the user picked, then re-sync from scratch:
// the hidden input is the submit source of truth, so addFiles() rewrites
// it. Without resetting we'd double-count on the next sync.
const picked = Array.from(e.target.files || []);
this.addFiles(picked, false);
},
onDragEnter() {
// Only flip on the first enter; subsequent enters are child elements.
if (++this._dragDepth === 1) this.dragging = true;
},
onDragLeave() {
if (--this._dragDepth <= 0) {
this._dragDepth = 0;
this.dragging = false;
}
},
onDrop(e) {
this._dragDepth = 0;
this.dragging = false;
const dt = e.dataTransfer;
if (dt && dt.files) this.addFiles(Array.from(dt.files), true);
},
addFiles(incoming, notifyLivewire = false) {
this.status = '';
if (!incoming || !incoming.length) {
this.syncInput(notifyLivewire);
return;
}
const accepted = [];
for (const file of incoming) {
const error = this.validate(file);
if (error) {
this.status = error;
continue;
}
if (!this.multiple) {
// Single-file mode: replace the current selection.
for (const f of this.files) this.release(f);
this.files = [];
}
const kind = kindOf(file.name, file.type);
const previewKind = previewKindOf(file.name, file.type);
const objectUrl = previewKind && typeof URL.createObjectURL === 'function' ? URL.createObjectURL(file) : null;
this.files.push({
id: ++this._seq,
key: `file-${this._seq}`,
name: file.name,
size: file.size,
file, // raw File, kept so we can rebuild the input's FileList
kind,
extension: extensionOf(file.name),
// An object URL for the live preview, revoked on remove/destroy.
previewKind: objectUrl ? previewKind : null,
objectUrl,
preview: objectUrl ? (previewKind === 'pdf' ? pdfFragment(objectUrl) : objectUrl) : null,
// Endpoint transport state; inert without an endpoint.
progress: 0,
done: false,
settled: false,
error: false,
errorMessage: '',
});
accepted.push(file);
if (this.endpoint) this.upload(`file-${this._seq}`);
if (!this.multiple) break;
}
this.syncInput(notifyLivewire);
// Composing blocks (a PDF workflow, a gallery) react to new files.
if (accepted.length) this.$dispatch('files-added', { files: accepted });
},
validate(file) {
// Size guard (maxSize is in MB).
if (file.size > this.maxSize * 1024 * 1024) {
return this.labels.tooLarge
? this.labels.tooLarge.replace(':file', file.name).replace(':max', String(this.maxSize))
: `${file.name} ${this.tooLargeLabel()}`;
}
// Accept guard: comma-separated extensions and/or MIME patterns.
if (this.accept) {
const patterns = this.accept
.split(',')
.map((p) => p.trim().toLowerCase())
.filter(Boolean);
const name = file.name.toLowerCase();
const type = (file.type || '').toLowerCase();
const ok = patterns.some((p) => {
if (p.startsWith('.')) return name.endsWith(p);
if (p.endsWith('/*')) return type.startsWith(p.slice(0, -1));
return type === p;
});
if (!ok) {
return this.labels.notAccepted
? this.labels.notAccepted.replace(':file', file.name)
: `${file.name} ${this.notAcceptedLabel()}`;
}
}
return null;
},
/**
* Rewrite the hidden <input>'s FileList from our `files` array so a normal
* form submit carries exactly what the user sees. DataTransfer is the only
* standards-compliant way to build a FileList programmatically.
*/
syncInput(notifyLivewire = false) {
const input = this.$refs.input;
if (!input) return;
const dt = new DataTransfer();
// With an endpoint the XHR carries the files; an empty input keeps a
// wrapping form from posting them a second time.
if (!this.endpoint) {
for (const f of this.files) {
dt.items.add(f.file);
}
}
input.files = dt.files;
// Livewire listens for a native change event. A picker already emits
// one; drag/drop and removals are programmatic and need an explicit
// event so the server property matches the visible file list.
if (notifyLivewire && this.livewireModel) {
this._dispatchingChange = true;
input.dispatchEvent(new Event('change', { bubbles: true }));
this._dispatchingChange = false;
}
},
removeFile(key) {
// The focused remove button leaves with its row, so keyboard focus
// moves on (see refocusAfterRemove) instead of falling to <body>.
const index = this.entries.findIndex((f) => f.key === key);
const hadFocus = this.$root.contains(document.activeElement);
const existing = this.existing.find((f) => f.key === key);
if (existing) {
this.existing = this.existing.filter((f) => f.key !== key);
this.removedExisting.push(existing.id);
this.$dispatch('dropzone-remove-existing', { id: existing.id, name: existing.name });
if (hadFocus) this.refocusAfterRemove(index);
return;
}
const entry = this.files.find((f) => f.key === key);
if (entry) this.release(entry);
this.files = this.files.filter((f) => f.key !== key);
this.status = '';
this.syncInput(true);
if (hadFocus) this.refocusAfterRemove(index);
},
/** Focus the remove button now at `index` (or the last one), else the drop target. */
refocusAfterRemove(index) {
const root = this.$root;
const settle = (frames) => {
const buttons = root.querySelectorAll('[data-dropzone-remove]');
// Inside a click handler x-for can still be dropping the removed
// row at this tick (one remove button per entry); wait for it.
if (buttons.length > this.entries.length && frames < 10) {
requestAnimationFrame(() => settle(frames + 1));
return;
}
const next = buttons[Math.min(index, buttons.length - 1)];
(next ?? root.querySelector('[data-slot="dropzone-target"]'))?.focus();
};
this.$nextTick(() => settle(0));
},
/** Drop everything a selection holds: its preview URL, its request, its settle timer. */
release(entry) {
if (entry.objectUrl) URL.revokeObjectURL(entry.objectUrl);
this.abort(entry.key);
if (this._settle[entry.key]) {
clearTimeout(this._settle[entry.key]);
delete this._settle[entry.key];
}
},
// ── Endpoint transport ───────────────────────────────────────────────
upload(key) {
const entry = this.files.find((f) => f.key === key);
if (!entry || !this.endpoint) return;
const xhr = new XMLHttpRequest();
this._xhrs[key] = xhr;
const form = new FormData();
form.append(this.fieldName, entry.file, entry.name);
xhr.open('POST', this.endpoint);
xhr.withCredentials = true;
xhr.setRequestHeader('Accept', 'application/json');
if (this.token) xhr.setRequestHeader('Authorization', `Bearer ${this.token}`);
const csrf = document.querySelector('meta[name="csrf-token"]');
if (csrf) xhr.setRequestHeader('X-CSRF-TOKEN', csrf.getAttribute('content') || '');
xhr.upload.onprogress = (e) => {
if (!e.lengthComputable) return;
const f = this.files.find((x) => x.key === key);
if (f) f.progress = Math.round((e.loaded / e.total) * 100);
};
xhr.onload = () => {
delete this._xhrs[key];
const f = this.files.find((x) => x.key === key);
if (!f) return;
if (xhr.status >= 200 && xhr.status < 300) {
f.progress = 100;
this.finish(f);
this.$dispatch('file-uploaded', { id: f.id, name: f.name, response: xhr.responseText });
} else {
this.fail(f, this.uploadFailedLabel(xhr.status));
}
};
xhr.onerror = () => {
delete this._xhrs[key];
const f = this.files.find((x) => x.key === key);
if (f) this.fail(f, this.uploadFailedLabel(0));
};
xhr.send(form);
},
retry(key) {
const f = this.files.find((x) => x.key === key);
if (!f) return;
f.error = false;
f.errorMessage = '';
f.progress = 0;
f.done = false;
f.settled = false;
this.upload(key);
},
abort(key) {
if (!this._xhrs[key]) return;
this._xhrs[key].abort();
delete this._xhrs[key];
},
// Done: the bar fills for a beat, then folds away so a finished row is
// just the name and a check.
finish(f) {
f.done = true;
f.error = false;
this._settle[f.key] = setTimeout(() => {
delete this._settle[f.key];
if (this.files.includes(f)) f.settled = true;
}, 700);
},
fail(f, message) {
f.error = true;
f.errorMessage = message;
this.status = `${f.name} ${message}`;
},
onLivewireUploadStart() {
this.uploading = true;
this.uploadProgress = 0;
this.status = '';
},
onLivewireUploadProgress(event) {
this.uploadProgress = Math.max(0, Math.min(100, Number(event.detail?.progress ?? 0)));
},
onLivewireUploadFinish() {
this.uploadProgress = 100;
this.uploading = false;
},
onLivewireUploadError() {
this.uploading = false;
this.status = this.uploadFailedLabel();
},
onLivewireUploadCancel() {
this.uploading = false;
this.uploadProgress = 0;
},
formatSize(bytes) {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const value = bytes / Math.pow(1024, i);
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
},
// English fallbacks for the file guard when no translated labels.tooLarge
// or labels.notAccepted is passed (the Blade component passes both).
tooLargeLabel() {
return `exceeds the ${this.maxSize} MB limit.`;
},
notAcceptedLabel() {
return `is not an accepted file type.`;
},
uploadFailedLabel(status) {
if (status === undefined) return this.labels.uploadFailed || 'Upload failed. Try again.';
return status
? (this.labels.httpError || 'upload failed (HTTP :status).').replace(':status', String(status))
: this.labels.networkError || 'upload failed (network error).';
},
destroy() {
window.removeEventListener('dragover', this._onWindowDragOver);
window.removeEventListener('drop', this._onWindowDrop);
for (const f of this.files) this.release(f);
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.3.6 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0