Skip to content
Brok UI

Loading…

No results

Dropzone

Open source

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.

Version
v1.3.6
Stability
stable
License
MIT
Related
File Thumbnail
Input

Preview

Uploading…

Drag & drop files here, or browse

PDF, images, up to 10 MB each

PDF or images, up to 10 MB each.

Disabled
Variant
previews.components.dropzone.default.blade.php 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>
Default Current
Compact Current
Button Current

Installation

terminal
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:

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/dropzone/dropzone.blade.php
  • js resources/js/ui/dropzone.js
Registry dependencies
button
Packages
composer: jml/brok:^0.2
npm: alpinejs

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.

dropzone.md
# 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

button.blade.php Blade
{{-- 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>
compact.blade.php Blade
{{-- 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>
grid.blade.php Blade
{{-- 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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

dropzone dropzone-button dropzone-files dropzone-target dropzone-upload-progress

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

File input and upload

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
dropzone dropzone-upload-progress dropzone-target dropzone-button dropzone-files
Theming hooks
button

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<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.

livewire-form.blade.php Blade
<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.

resources/views/components/ui/dropzone/dropzone.blade.php Blade
{{-- 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>
resources/js/ui/dropzone.js JS
/**
 * 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