<x-widget.file-upload>
File upload
File uploads on a real file input: a drag-and-drop dropzone (single or multiple, with a file list and remove), a compact button, and an image or avatar picker with preview. Checks type and size in the browser from accept and max-size, and with upload-url uploads each file straight away with a progress bar and submits the stored ids. Works with Livewire wire:model.
php artisan larawell:add file-upload
- Also adds
- Icon
Usage
Validation
The browser's checks are for convenience; validate the same limits in your Form Request.
use Illuminate\Validation\Rules\File;
public function rules(): array
{
return [
'contract' => ['required', File::types(['pdf'])->max('5mb')],
// multiple: the array, then each file in it.
'attachments' => ['array', 'max:5'],
'attachments.*' => [File::types(['pdf', 'png', 'jpg', 'jpeg', 'webp'])->max('10mb')],
'avatar' => ['nullable', File::image()->max('2mb')],
'remove_avatar' => ['boolean'],
];
}
Direct upload route
upload-url: a route that stores one file (the request field is "file") and answers with its id. A 422 with errors.file shows that message on the file's row. Authorize it like any other write.
Route::post('/uploads', StoreUploadController::class)->middleware('auth')->name('uploads.store');
final class StoreUploadController
{
// StoreUploadRequest: ['file' => ['required', File::default()->max('10mb')]], and authorize() for who may upload.
public function __invoke(StoreUploadRequest $request): JsonResponse
{
$upload = $request->user()->uploads()->create([
'path' => $request->file('file')->store('uploads'),
'name' => $request->file('file')->getClientOriginalName(),
'size' => $request->file('file')->getSize(),
]);
return response()->json(['id' => $upload->id]);
}
}
// The form then submits documents[] with those ids: check each belongs to the user before attaching it,
// and delete uploads nobody claimed after a day (a scheduled job), since a picked file may never be submitted.
Livewire
In a Livewire component that uses WithFileUploads, bind with wire:model; no name is needed. Each file goes to Livewire's temporary uploads as it's picked or dropped, and removing one from the list removes it from the property, so it always holds exactly what the list shows: one file, or an array with multiple. The list, name and preview survive every render; reset the property after saving ($this->reset('photo')) and they empty too. Livewire's livewire-upload-start, -progress and -finish events fire on the input. Not with upload-url, which sends files to your own route instead.
<form wire:submit="save" class="space-y-6">
<x-widget.file-upload label="Attachments" multiple max-size="10MB" wire:model="attachments" />
<x-widget.file-upload.image label="Photo" :src="$user->avatar_url" wire:model="photo" />
<button type="submit">Save</button>
</form>
Examples
Dropzone
One file: drag it onto the area or click to browse. accept and max-size are checked as soon as it's picked, and say themselves in the hint; the Form Request checks them again (see Usage). Without JavaScript it's a plain file input.
Show code Hide code
<form method="POST" enctype="multipart/form-data" class="max-w-xl">
@csrf
<x-widget.file-upload name="contract" label="Signed contract" accept=".pdf" max-size="5MB" required />
</form>
Multiple
multiple with max-files: each file joins the list with its size and a remove button, and a file that's too big or the wrong type is listed with the reason instead of being added. The form sends the files still in the list.
Show code Hide code
<form method="POST" enctype="multipart/form-data" class="max-w-xl">
@csrf
<x-widget.file-upload name="attachments[]" label="Attachments" multiple max-files="5" accept="image/*,.pdf" max-size="10MB" />
</form>
Direct upload
upload-url sends each file to your route as soon as it's picked, with a progress bar and retry. The route stores it and returns its id, and the form submits the ids, not the files. Submitting waits for uploads still running. Uses an uploads.store route from your app; see Usage for it.
Show code Hide code
<form method="POST" class="max-w-xl">
@csrf
<x-widget.file-upload name="documents[]" label="Documents" multiple :upload-url="route('uploads.store')" max-size="10MB" />
</form>
Image
A profile photo or logo: pass the current one as src, pick a new one to preview it, or remove it. remove-name submits remove_avatar=1 when the current photo is removed. shape="square" suits logos; size is sm, md or lg. The preview needs img-src blob: in a Content Security Policy.
Show code Hide code
<form method="POST" enctype="multipart/form-data" class="flex flex-wrap gap-10">
@csrf
<x-widget.file-upload.image name="avatar" label="Profile photo" max-size="2MB" remove-name="remove_avatar" />
<x-widget.file-upload.image name="logo" label="Company logo" shape="square" size="lg" accept="image/png,image/svg+xml" max-size="1MB" />
</form>
Props
Other attributes, such as autocomplete or data-*, are passed through to the element. class styles the component's outer wrapper.
<x-widget.file-upload>
| Prop | Default | Description |
|---|---|---|
| name |
null
|
|
| id |
null
|
|
| label |
null
|
|
| error |
null
|
|
| info |
null
|
|
| bag |
'default'
|
|
| disabled |
false
|
|
| multiple |
false
|
|
| accept |
null
|
|
| max-size |
null
|
|
| max-files |
null
|
|
| upload-url |
null
|
|
| uploaded |
[]
|
|
| prompt |
'Drag files here or'
|
|
| browse |
'browse'
|
|
| messages |
[]
|
<x-widget.file-upload.button>
| Prop | Default | Description |
|---|---|---|
| name |
null
|
|
| id |
null
|
|
| label |
null
|
|
| error |
null
|
|
| info |
null
|
|
| bag |
'default'
|
|
| disabled |
false
|
|
| multiple |
false
|
|
| accept |
null
|
|
| max-size |
null
|
|
| button-label |
'Choose file'
|
|
| empty-text |
'No file chosen'
|
|
| messages |
[]
|
<x-widget.file-upload.image>
| Prop | Default | Description |
|---|---|---|
| name |
null
|
|
| id |
null
|
|
| label |
null
|
|
| error |
null
|
|
| info |
null
|
|
| bag |
'default'
|
|
| disabled |
false
|
|
| accept |
'image/*'
|
|
| max-size |
null
|
|
| src |
null
|
|
| alt |
''
|
|
| shape |
'circle'
|
|
| size |
'md'
|
|
| remove-name |
null
|
|
| choose-label |
'Upload'
|
|
| change-label |
'Change'
|
|
| remove-label |
'Remove'
|
|
| messages |
[]
|
Source
What larawell:add file-upload writes to your app with the default namespaces. Prefer to copy by hand? Take these files, plus the ones from
the components it also adds and Field, and the theme and base CSS.
resources/views/components/widget/file-upload/button.blade.php Show
@props([
'name' => null,
'id' => null,
'label' => null,
'error' => null,
'info' => null,
'bag' => 'default',
'disabled' => false,
'multiple' => false,
'accept' => null,
'maxSize' => null,
'buttonLabel' => 'Choose file',
'emptyText' => 'No file chosen',
'messages' => [],
])
@php
$field = \App\View\Widget\FormField::make($name, $id, $errors ?? null, $error, $bag, 'file', attributes: $attributes);
$id = $field->id;
$limits = \App\View\Widget\UploadLimits::from($maxSize, $accept);
$hint = $limits->hint();
$hintId = $hint ? "{$id}-hint" : null;
$inputName = $name === null ? null : ($multiple ? preg_replace('/\[\]$/', '', $name).'[]' : $name);
$messages = [
'tooBig' => ':name is larger than :size.',
'wrongType' => ':name isn\'t a file type this accepts.',
'count' => ':count files',
...$messages,
];
// wire:model: Livewire's renders are kept off the name and error the script writes (see the dropzone).
$live = str_starts_with((string) array_key_first(\App\View\Widget\FormField::binding($attributes)), 'wire:model');
[$found, $bound] = $field->fromLivewire();
$liveFiles = $live && $found ? (is_countable($bound) ? count($bound) : (int) filled($bound)) : null;
@endphp
{{-- Compact: a button and the chosen file's name beside it, for tight forms and table rows. Same checks as the dropzone. --}}
<x-widget.field
:required="$attributes->has('required')"
:id="$id"
:label="$label"
:error="$field->errors"
:info="$info"
:disabled="$disabled"
bare
data-file-upload="button"
:data-max-bytes="$limits->maxBytes ?? false"
:data-accept="$accept ?? false"
:data-messages="json_encode($messages)"
:data-livewire-files="$liveFiles === null ? false : (string) $liveFiles"
:class="$attributes->get('class')"
>
<div @if ($live) wire:ignore @endif class="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2">
{{-- The input sits inside the button-look label, so the label's text names it and its focus rings the label. --}}
<label @class([
'bg-field text-foreground hover:bg-line has-[:focus-visible]:ring-primary relative inline-flex shrink-0 items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-offset-2',
'cursor-pointer' => ! $disabled,
'cursor-not-allowed opacity-60' => $disabled,
])>
<x-widget.icon name="upload" class="size-4" />
{{ $buttonLabel }}
<input
type="file"
id="{{ $id }}"
@if ($inputName) name="{{ $inputName }}" @endif
@if ($multiple) multiple @endif
@if ($accept) accept="{{ $accept }}" @endif
@disabled($disabled)
{{ $field->controlAttributes($attributes->merge(['aria-describedby' => $hintId]), (bool) $info)->class(['sr-only']) }}
>
</label>
<span data-file-name data-empty="{{ $emptyText }}" class="text-foreground/75 min-w-0 truncate text-sm">{{ $emptyText }}</span>
<button type="button" data-file-clear aria-label="Clear the chosen file" hidden class="text-foreground/60 hover:text-foreground hover:bg-field focus-visible:ring-primary -ms-1 grid size-8 shrink-0 place-items-center rounded-full outline-none focus-visible:ring-2">
<x-widget.icon name="x" class="size-4" />
</button>
</div>
@if ($hint)
<p id="{{ $hintId }}" class="text-foreground/60 mt-1.5 text-xs">{{ $hint }}</p>
@endif
<p data-file-error @if ($live) wire:ignore @endif role="alert" class="text-error mt-1 text-xs empty:hidden"></p>
</x-widget.field>
resources/views/components/widget/file-upload/image.blade.php Show
@props([
'name' => null,
'id' => null,
'label' => null,
'error' => null,
'info' => null,
'bag' => 'default',
'disabled' => false,
'accept' => 'image/*',
'maxSize' => null,
'src' => null,
'alt' => '',
'shape' => 'circle',
'size' => 'md',
'removeName' => null,
'chooseLabel' => 'Upload',
'changeLabel' => 'Change',
'removeLabel' => 'Remove',
'messages' => [],
])
@php
$field = \App\View\Widget\FormField::make($name, $id, $errors ?? null, $error, $bag, 'file', attributes: $attributes);
$id = $field->id;
$limits = \App\View\Widget\UploadLimits::from($maxSize, $accept);
$hint = $limits->hint();
$hintId = $hint ? "{$id}-hint" : null;
$shapes = ['circle' => 'rounded-full', 'square' => 'rounded-2xl'];
$sizes = ['sm' => 'size-14', 'md' => 'size-20', 'lg' => 'size-28'];
$messages = [
'tooBig' => ':name is larger than :size.',
'wrongType' => ':name isn\'t an image this accepts.',
...$messages,
];
// wire:model: Livewire's renders are kept off the preview, buttons and error the script draws (see the dropzone).
$live = str_starts_with((string) array_key_first(\App\View\Widget\FormField::binding($attributes)), 'wire:model');
[$found, $bound] = $field->fromLivewire();
$liveFiles = $live && $found ? (is_countable($bound) ? count($bound) : (int) filled($bound)) : null;
@endphp
{{--
A profile photo or logo: the current image (src, from your app), a button to choose a new one, and Remove.
The preview is a blob: URL, so a Content Security Policy needs img-src blob: for it. remove-name="remove_avatar"
submits remove_avatar=1 when the current image is removed, so the controller knows to delete it.
--}}
<x-widget.field
:required="$attributes->has('required')"
:id="$id"
:label="$label"
:error="$field->errors"
:info="$info"
:disabled="$disabled"
bare
data-file-upload="image"
:data-max-bytes="$limits->maxBytes ?? false"
:data-accept="$accept ?? false"
:data-messages="json_encode($messages)"
:data-choose-label="$chooseLabel"
:data-change-label="$changeLabel"
:data-livewire-files="$liveFiles === null ? false : (string) $liveFiles"
{{-- The preview is wire:ignore'd, so after PHP empties the property the script shows the current src from here. --}}
:data-src="$live ? (string) $src : false"
:class="$attributes->get('class')"
>
<div @if ($live) wire:ignore @endif class="flex items-center gap-4">
<div data-file-preview @class(['bg-field border-line text-muted grid shrink-0 place-items-center overflow-hidden border', $shapes[$shape] ?? $shapes['circle'], $sizes[$size] ?? $sizes['md']])>
<img data-file-image @if ($src) src="{{ $src }}" @else hidden @endif alt="{{ $alt }}" class="size-full object-cover">
<x-widget.icon name="image" data-file-placeholder :hidden="(bool) $src" class="size-1/3" />
</div>
<div class="flex min-w-0 flex-col gap-1.5">
<div class="flex flex-wrap items-center gap-2">
<label @class([
'bg-field text-foreground hover:bg-line has-[:focus-visible]:ring-primary relative inline-flex items-center rounded-xl px-4 py-2 text-sm font-medium transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-offset-2',
'cursor-pointer' => ! $disabled,
'cursor-not-allowed opacity-60' => $disabled,
])>
<span data-file-choose>{{ $src ? $changeLabel : $chooseLabel }}</span>
<input
type="file"
id="{{ $id }}"
@if ($name) name="{{ $name }}" @endif
accept="{{ $accept }}"
@disabled($disabled)
{{ $field->controlAttributes($attributes->merge(['aria-describedby' => $hintId]), (bool) $info)->class(['sr-only']) }}
>
</label>
<button type="button" data-file-clear @if (! $src) hidden @endif @disabled($disabled) class="text-error hover:bg-error/10 focus-visible:ring-primary rounded-xl px-3 py-2 text-sm font-medium outline-none focus-visible:ring-2">{{ $removeLabel }}</button>
</div>
@if ($hint)
<p id="{{ $hintId }}" class="text-foreground/60 text-xs">{{ $hint }}</p>
@endif
<p data-file-error role="alert" class="text-error text-xs empty:hidden"></p>
</div>
</div>
@if ($removeName)
<input type="hidden" name="{{ $removeName }}" value="0" data-file-remove-flag {{ $attributes->only(['form']) }}>
@endif
</x-widget.field>
resources/views/components/widget/file-upload/index.blade.php Show
@props([
'name' => null,
'id' => null,
'label' => null,
'error' => null,
'info' => null,
'bag' => 'default',
'disabled' => false,
'multiple' => false,
'accept' => null,
'maxSize' => null,
'maxFiles' => null,
'uploadUrl' => null,
'uploaded' => [],
'prompt' => 'Drag files here or',
'browse' => 'browse',
'messages' => [],
])
@php
$field = \App\View\Widget\FormField::make($name, $id, $errors ?? null, $error, $bag, 'file', attributes: $attributes);
$id = $field->id;
$limits = \App\View\Widget\UploadLimits::from($maxSize, $accept);
$hint = $limits->hint();
$maxFiles = $maxFiles !== null ? max(1, (int) $maxFiles) : null;
$multiple = $multiple || ($maxFiles !== null && $maxFiles > 1);
$baseName = $name !== null ? preg_replace('/\[\]$/', '', $name) : null;
// upload-url: each file is sent there as soon as it's picked, and the form submits what the route returns (an
// id per file) from hidden inputs. The file input itself then has no name, so the files aren't sent twice.
$direct = $uploadUrl !== null;
$inputName = $direct || $baseName === null ? null : ($multiple ? "{$baseName}[]" : $baseName);
$valueName = $baseName === null ? null : ($multiple ? "{$baseName}[]" : $baseName);
// Files already stored (after a failed submit, or when editing): [['id' => 7, 'name' => 'a.pdf', 'size' => 1024], …].
$uploaded = collect($uploaded)->map(static fn (mixed $file): array => [
'id' => (string) (data_get($file, 'id') ?? ''),
'name' => (string) (data_get($file, 'name') ?? ''),
'size' => is_numeric(data_get($file, 'size')) ? \App\View\Widget\UploadLimits::size((int) data_get($file, 'size')) : '',
])->filter(static fn (array $file): bool => $file['id'] !== '')->values();
// Plain English like every component; pass messages="[…]" to reword or translate any of them.
$messages = [
'tooBig' => ':name is larger than :size.',
'wrongType' => ':name isn\'t a file type this accepts.',
'tooMany' => 'You can add up to :count files.',
'failed' => 'Couldn\'t upload :name.',
'waiting' => 'Wait for the uploads to finish.',
'added' => ':count added.',
'removed' => ':name removed.',
'uploadedOne' => ':name uploaded.',
...$messages,
];
$hintId = $hint ? "{$id}-hint" : null;
// wire:model: the script uploads through Livewire whenever the list changes. Livewire's renders would wipe the list
// the script draws, so it's left out of them, and data-livewire-files says how many files the property holds, so
// the script can tell when PHP emptied it ($this->reset()) and empty the list too.
$live = str_starts_with((string) array_key_first(\App\View\Widget\FormField::binding($attributes)), 'wire:model');
[$found, $bound] = $field->fromLivewire();
$liveFiles = $live && $found ? (is_countable($bound) ? count($bound) : (int) filled($bound)) : null;
@endphp
{{--
A real <input type="file">, so it submits without JavaScript too. resources/js/widget/file-upload adds drag and
drop, the file list with remove buttons, the size and type checks (the server must still validate), and, with
upload-url, uploading each file straight away with a progress bar.
--}}
<x-widget.field
:required="$attributes->has('required')"
:id="$id"
:label="$label"
:error="$field->errors"
:info="$info"
:disabled="$disabled"
bare
data-file-upload
:data-max-bytes="$limits->maxBytes ?? false"
:data-accept="$accept ?? false"
:data-max-files="$maxFiles ?? false"
:data-multiple="$multiple ? 'true' : false"
:data-upload-url="$uploadUrl ?? false"
:data-csrf="$direct ? csrf_token() : false"
:data-value-name="$direct ? $valueName : false"
:data-messages="json_encode($messages)"
:data-livewire-files="$liveFiles === null ? false : (string) $liveFiles"
:class="$attributes->get('class')"
>
{{-- The input covers the whole area, so a click anywhere opens the picker and files dropped on it land in it,
even before the script runs. The text is for the eye; screen readers get the label and the hint. --}}
<div
data-file-drop
@class([
'border-line-strong bg-field relative flex flex-col items-center justify-center gap-2 rounded-[20px] border-2 border-dashed px-6 py-8 text-center transition-colors',
'hover:border-primary has-[:focus-visible]:border-primary data-dragging:border-primary data-dragging:bg-primary/5' => ! $disabled,
'group-data-invalid/field:border-error group-data-invalid/field:bg-error/5',
'opacity-60' => $disabled,
])
>
<x-widget.icon name="upload" class="text-muted size-7" />
<p aria-hidden="true" class="text-foreground text-sm">
{{ $prompt }} <span class="text-primary font-medium underline underline-offset-2">{{ $browse }}</span>
</p>
@if ($hint)
<p id="{{ $hintId }}" class="text-foreground/60 text-xs">{{ $hint }}</p>
@endif
<input
type="file"
id="{{ $id }}"
@if ($inputName) name="{{ $inputName }}" @endif
@if ($multiple) multiple @endif
@if ($accept) accept="{{ $accept }}" @endif
@disabled($disabled)
{{ $field->controlAttributes($attributes->merge(['aria-describedby' => $hintId]), (bool) $info)->class([
'absolute inset-0 size-full cursor-pointer opacity-0 disabled:cursor-not-allowed',
]) }}
>
</div>
<ul data-file-list @if ($live) wire:ignore @endif aria-label="{{ $label ? $label.': ' : '' }}chosen files" class="mt-3 grid gap-2 empty:hidden">
@foreach ($uploaded as $file)
<li data-file-item data-state="done" class="group/file border-line bg-surface flex items-center gap-3 rounded-2xl border px-4 py-3">
<x-widget.icon name="file" class="text-muted size-5" />
<div class="min-w-0 flex-1">
<p class="flex min-w-0 items-baseline gap-2">
<span data-file-name class="truncate text-sm font-medium">{{ $file['name'] }}</span>
<span data-file-size class="text-foreground/60 shrink-0 text-xs">{{ $file['size'] }}</span>
</p>
</div>
@if ($valueName)
<input type="hidden" name="{{ $valueName }}" value="{{ $file['id'] }}" {{ $attributes->only(['form']) }}>
@endif
{{-- Uploaded (with upload-url): a check, so a stored file doesn't look like one still waiting. --}}
<x-widget.icon name="check" aria-hidden="true" class="text-success hidden size-4 shrink-0 group-data-[state=done]/file:block" />
<button type="button" data-file-remove aria-label="Remove {{ $file['name'] }}" class="text-foreground/60 hover:text-foreground hover:bg-field focus-visible:ring-primary grid size-8 shrink-0 place-items-center rounded-full outline-none focus-visible:ring-2">
<x-widget.icon name="x" class="size-4" />
</button>
</li>
@endforeach
</ul>
{{-- One file row, filled in by the script. It's here rather than in the JS so you can restyle it in the Blade you own. --}}
<template data-file-template>
<li data-file-item class="group/file border-line bg-surface flex items-center gap-3 rounded-2xl border px-4 py-3 data-[state=error]:border-error/40 data-[state=error]:bg-error/5">
<x-widget.icon name="file" class="text-muted size-5" />
<div class="min-w-0 flex-1">
<p class="flex min-w-0 items-baseline gap-2">
<span data-file-name class="truncate text-sm font-medium"></span>
<span data-file-size class="text-foreground/60 shrink-0 text-xs"></span>
</p>
<p data-file-error class="text-error mt-0.5 text-xs" hidden></p>
<div data-file-progress-track class="bg-field mt-2 h-1 overflow-hidden rounded-full" hidden>
<div data-file-progress class="bg-primary h-full w-0 transition-[width] duration-200 motion-reduce:transition-none"></div>
</div>
</div>
{{-- Uploaded (with upload-url): a check, so a stored file doesn't look like one still waiting. --}}
<x-widget.icon name="check" aria-hidden="true" class="text-success hidden size-4 shrink-0 group-data-[state=done]/file:block" />
<button type="button" data-file-retry aria-label="Retry" hidden class="text-foreground/60 hover:text-foreground hover:bg-field focus-visible:ring-primary grid size-8 shrink-0 place-items-center rounded-full outline-none focus-visible:ring-2">
<x-widget.icon name="refresh-cw" class="size-4" />
</button>
<button type="button" data-file-remove aria-label="Remove" class="text-foreground/60 hover:text-foreground hover:bg-field focus-visible:ring-primary grid size-8 shrink-0 place-items-center rounded-full outline-none focus-visible:ring-2">
<x-widget.icon name="x" class="size-4" />
</button>
</li>
</template>
{{-- Always on the page, so what the script says here is read out: files added, removed, rejected, uploaded. --}}
<p data-file-status role="status" class="sr-only"></p>
</x-widget.field>
resources/js/widget/file-upload/index.js Show
// Drives <x-widget.file-upload>, .button and .image. Without JS each is a plain file input that still submits.
// This adds drag and drop, a list of the chosen files with remove, the size and type checks from max-size and
// accept (convenience only: the Form Request must validate the same), and with upload-url, uploading each file
// as soon as it's picked, with progress, retry and the stored ids submitted instead of the files.
// Everything is delegated from `document`, so file inputs added to the page later work too.
import { onLivewireMorph } from '../field';
const ROOT = '[data-file-upload]';
const state = new WeakMap();
const messagesOf = (root) => {
try {
return JSON.parse(root.dataset.messages ?? '{}');
} catch {
return {};
}
};
const say = (template, values) => Object.entries(values).reduce((text, [key, value]) => text.replaceAll(`:${key}`, value), template ?? '');
// Same wording as the server's UploadLimits::size(): 1024-based, one decimal at most.
function size(bytes) {
for (const [unit, factor] of [['GB', 1024 ** 3], ['MB', 1024 ** 2], ['KB', 1024]]) {
if (bytes >= factor) {
return `${Number((bytes / factor).toFixed(1))} ${unit}`;
}
}
return `${bytes} bytes`;
}
// accept=".pdf,image/*": an extension matches the name's end, type/* the MIME type's group, anything else exactly.
function accepted(file, accept) {
const entries = (accept ?? '').split(',').map((entry) => entry.trim().toLowerCase()).filter(Boolean);
if (entries.length === 0) {
return true;
}
const name = file.name.toLowerCase();
const type = (file.type || '').toLowerCase();
return entries.some((entry) => (entry.startsWith('.') ? name.endsWith(entry) : entry.endsWith('/*') ? type.startsWith(entry.slice(0, -1)) : type === entry));
}
// Why a file can't be taken, in the widget's own words; null when it can.
function problem(root, file) {
const messages = messagesOf(root);
const maxBytes = Number(root.dataset.maxBytes || 0);
if (!accepted(file, root.dataset.accept)) {
return say(messages.wrongType, { name: file.name });
}
if (maxBytes && file.size > maxBytes) {
return say(messages.tooBig, { name: file.name, size: size(maxBytes) });
}
return null;
}
const inputOf = (root) => root.querySelector('input[type="file"]');
function announce(root, text) {
const status = root.querySelector('[data-file-status]');
if (!status) {
return;
}
// Emptied first and filled a moment later, so the same message twice in a row is read twice.
status.textContent = '';
setTimeout(() => { status.textContent = text; }, 50);
}
// --- Dropzone ---------------------------------------------------------------------------------------------
function dropzone(root) {
if (!state.has(root)) {
state.set(root, { entries: [] });
}
return state.get(root);
}
const direct = (root) => Boolean(root.dataset.uploadUrl);
// The files the form will send, rebuilt from the list: the browser's own FileList can't be edited in place.
function syncInput(root) {
const input = inputOf(root);
const entries = dropzone(root).entries;
if (!direct(root)) {
const transfer = new DataTransfer();
entries.filter((entry) => entry.state === 'ready').forEach((entry) => transfer.items.add(entry.file));
input.files = transfer.files;
} else {
// Direct upload: the input only picks. Required means "at least one uploaded", which its own required
// would get wrong (it's always empty), so it's only required while nothing has been uploaded.
if (input.dataset.required === undefined) {
input.dataset.required = input.required ? 'true' : 'false';
}
const done = root.querySelectorAll('[data-file-item][data-state="done"]').length;
input.required = input.dataset.required === 'true' && done === 0;
input.value = '';
}
}
function row(root, file, error) {
const item = root.querySelector('[data-file-template]').content.firstElementChild.cloneNode(true);
item.querySelector('[data-file-name]').textContent = file.name;
item.querySelector('[data-file-size]').textContent = size(file.size);
const remove = item.querySelector('[data-file-remove]');
remove.setAttribute('aria-label', `${remove.getAttribute('aria-label')} ${file.name}`);
if (error) {
item.dataset.state = 'error';
const text = item.querySelector('[data-file-error]');
text.textContent = error;
text.hidden = false;
}
return item;
}
function addFiles(root, files) {
const list = root.querySelector('[data-file-list]');
const store = dropzone(root);
const messages = messagesOf(root);
const multiple = root.dataset.multiple === 'true';
const maxFiles = Number(root.dataset.maxFiles || 0) || (multiple ? Infinity : 1);
let picked = [...files];
// A single-file dropzone takes the newest pick in place of the old one.
if (!multiple) {
store.entries.forEach((entry) => removeEntry(root, entry, { quiet: true }));
root.querySelectorAll('[data-file-item][data-state="done"]').forEach((item) => item.remove());
picked = picked.slice(0, 1);
}
let added = 0;
for (const file of picked) {
const kept = store.entries.filter((entry) => entry.state !== 'error').length + root.querySelectorAll('[data-file-item][data-state="done"]:not([data-entry])').length;
const error = problem(root, file) ?? (kept >= maxFiles ? say(messages.tooMany, { count: maxFiles }) : null);
const entry = { file, state: error ? 'error' : 'ready', el: row(root, file, error), xhr: null };
entry.el.dataset.entry = '';
store.entries.push(entry);
list.append(entry.el);
entry.el.querySelector('[data-file-remove]').addEventListener('click', () => removeEntry(root, entry));
entry.el.querySelector('[data-file-retry]').addEventListener('click', () => upload(root, entry));
if (error) {
announce(root, error);
} else {
added++;
if (direct(root)) {
upload(root, entry);
}
}
}
syncInput(root);
if (added > 0 && !direct(root)) {
announce(root, say(messages.added, { count: added }));
}
}
function removeEntry(root, entry, { quiet = false } = {}) {
const store = dropzone(root);
entry.xhr?.abort();
const next = entry.el.nextElementSibling?.querySelector('[data-file-remove]') ?? entry.el.previousElementSibling?.querySelector('[data-file-remove]');
const hadFocus = entry.el.contains(document.activeElement);
entry.el.remove();
store.entries = store.entries.filter((other) => other !== entry);
syncInput(root);
if (!quiet) {
tellLivewire(root);
announce(root, say(messagesOf(root).removed, { name: entry.file.name }));
// Focus stays in the list, or goes back to the picker when the list is empty, not to <body>.
if (hadFocus) {
(next ?? inputOf(root)).focus();
}
}
}
// upload-url: one request per file, with the CSRF token, so progress and failure are per file. The route answers
// JSON with the stored file's id (and a 422 with errors.file for a file it refuses); the id goes in a hidden input.
function upload(root, entry) {
const messages = messagesOf(root);
const track = entry.el.querySelector('[data-file-progress-track]');
const bar = entry.el.querySelector('[data-file-progress]');
const error = entry.el.querySelector('[data-file-error]');
const retry = entry.el.querySelector('[data-file-retry]');
entry.state = 'uploading';
entry.el.dataset.state = 'uploading';
error.hidden = true;
retry.hidden = true;
track.hidden = false;
bar.style.width = '0%';
const body = new FormData();
body.append('file', entry.file);
const xhr = new XMLHttpRequest();
entry.xhr = xhr;
xhr.open('POST', root.dataset.uploadUrl);
xhr.setRequestHeader('Accept', 'application/json');
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.setRequestHeader('X-CSRF-TOKEN', root.dataset.csrf ?? '');
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
bar.style.width = `${Math.round((event.loaded / event.total) * 100)}%`;
}
});
const fail = (message) => {
entry.state = 'error';
entry.el.dataset.state = 'error';
track.hidden = true;
error.textContent = message;
error.hidden = false;
retry.hidden = false;
announce(root, message);
};
xhr.addEventListener('load', () => {
entry.xhr = null;
let json = null;
try {
json = JSON.parse(xhr.responseText);
} catch {
// Not JSON (an HTML error page): the generic message below says it failed.
}
if (xhr.status >= 200 && xhr.status < 300 && json?.id !== undefined) {
entry.state = 'done';
entry.el.dataset.state = 'done';
track.hidden = true;
const hidden = Object.assign(document.createElement('input'), { type: 'hidden', name: root.dataset.valueName ?? '', value: String(json.id) });
const form = inputOf(root).getAttribute('form');
if (form) {
hidden.setAttribute('form', form);
}
entry.el.append(hidden);
syncInput(root);
announce(root, say(messages.uploadedOne, { name: entry.file.name }));
return;
}
fail(json?.errors?.file?.[0] ?? json?.message ?? say(messages.failed, { name: entry.file.name }));
});
xhr.addEventListener('error', () => {
entry.xhr = null;
fail(say(messages.failed, { name: entry.file.name }));
});
xhr.send(body);
}
// A file picked or dropped on a dropzone joins the list. The browser's pick replaces its FileList, so it's read
// here and the input is rebuilt from the whole list (syncInput).
document.addEventListener('change', (event) => {
const input = event.target;
const root = input.closest?.(ROOT);
if (!root || input.type !== 'file' || !input.matches('input[type="file"]')) {
return;
}
const kind = root.dataset.fileUpload;
if (kind === 'button') {
pickedButton(root, input);
} else if (kind === 'image') {
pickedImage(root, input);
} else if (input.files.length > 0) {
addFiles(root, input.files);
}
});
// The drop area highlights while files are dragged over it; a drop goes through addFiles like a pick.
for (const type of ['dragenter', 'dragover']) {
document.addEventListener(type, (event) => {
const drop = event.target.closest?.('[data-file-drop]');
if (drop && event.dataTransfer?.types.includes('Files') && !inputOf(drop.closest(ROOT)).disabled) {
event.preventDefault();
drop.setAttribute('data-dragging', '');
}
});
}
document.addEventListener('dragleave', (event) => {
const drop = event.target.closest?.('[data-file-drop]');
if (drop && !drop.contains(event.relatedTarget)) {
drop.removeAttribute('data-dragging');
}
});
document.addEventListener('drop', (event) => {
const drop = event.target.closest?.('[data-file-drop]');
if (!drop) {
return;
}
drop.removeAttribute('data-dragging');
const root = drop.closest(ROOT);
if (event.dataTransfer?.files.length && !inputOf(root).disabled) {
event.preventDefault();
addFiles(root, event.dataTransfer.files);
tellLivewire(root);
}
});
// Removing a file the server rendered (already uploaded) takes its hidden id input with it.
document.addEventListener('click', (event) => {
const remove = event.target.closest?.('[data-file-item]:not([data-entry]) [data-file-remove]');
if (!remove) {
return;
}
const root = remove.closest(ROOT);
const item = remove.closest('[data-file-item]');
const next = item.nextElementSibling?.querySelector('[data-file-remove]') ?? inputOf(root);
const name = item.querySelector('[data-file-name]')?.textContent ?? '';
item.remove();
syncInput(root);
announce(root, say(messagesOf(root).removed, { name }));
next.focus();
});
// A direct upload still running would submit the form without that file's id: hold the submit and say why.
// Capture phase, like the required check in widget/field, so the submit guard sees defaultPrevented.
document.addEventListener('submit', (event) => {
const busy = [...event.target.querySelectorAll?.(`${ROOT} [data-file-item][data-state="uploading"]`) ?? []];
if (busy.length === 0) {
return;
}
event.preventDefault();
const root = busy[0].closest(ROOT);
announce(root, messagesOf(root).waiting);
inputOf(root).focus();
}, true);
// --- Button -----------------------------------------------------------------------------------------------
function pickedButton(root, input) {
const shown = root.querySelector('[data-file-name]');
const clear = root.querySelector('[data-file-clear]');
const error = root.querySelector('[data-file-error]');
const files = [...input.files];
const why = files.map((file) => problem(root, file)).find(Boolean);
error.textContent = why ?? '';
if (why) {
input.value = '';
}
const kept = why ? [] : files;
shown.textContent = kept.length === 0 ? shown.dataset.empty : kept.length === 1 ? `${kept[0].name} (${size(kept[0].size)})` : say(messagesOf(root).count, { count: kept.length });
clear.hidden = kept.length === 0;
}
document.addEventListener('click', (event) => {
const clear = event.target.closest?.('[data-file-upload="button"] [data-file-clear]');
if (!clear) {
return;
}
const root = clear.closest(ROOT);
const input = inputOf(root);
input.value = '';
pickedButton(root, input);
tellLivewire(root);
input.focus();
});
// --- Image ------------------------------------------------------------------------------------------------
const previews = new WeakMap();
function showImage(root, url) {
const image = root.querySelector('[data-file-image]');
const placeholder = root.querySelector('[data-file-placeholder]');
const old = previews.get(root);
if (old) {
URL.revokeObjectURL(old);
previews.delete(root);
}
if (url) {
image.src = url;
} else {
image.removeAttribute('src');
}
image.hidden = !url;
placeholder.toggleAttribute('hidden', Boolean(url));
root.querySelector('[data-file-clear]').hidden = !url;
root.querySelector('[data-file-choose]').textContent = url ? root.dataset.changeLabel : root.dataset.chooseLabel;
}
function pickedImage(root, input) {
const file = input.files[0];
const error = root.querySelector('[data-file-error]');
if (!file) {
return;
}
const why = problem(root, file);
error.textContent = why ?? '';
if (why) {
input.value = '';
return;
}
const url = URL.createObjectURL(file);
showImage(root, url);
previews.set(root, url);
const flag = root.querySelector('[data-file-remove-flag]');
if (flag) {
flag.value = '0';
}
}
document.addEventListener('click', (event) => {
const clear = event.target.closest?.('[data-file-upload="image"] [data-file-clear]');
if (!clear) {
return;
}
const root = clear.closest(ROOT);
const input = inputOf(root);
input.value = '';
root.querySelector('[data-file-error]').textContent = '';
showImage(root, null);
tellLivewire(root);
// Tells the controller to delete the current image; picking a new one sets it back to 0.
const flag = root.querySelector('[data-file-remove-flag]');
if (flag) {
flag.value = '1';
}
input.focus();
});
// A direct-upload dropzone that already lists stored files (after a failed submit, or editing) mustn't also
// require a new pick.
document.querySelectorAll(`${ROOT}[data-upload-url]`).forEach(syncInput);
// --- Livewire ---------------------------------------------------------------------------------------------
// Bound with wire:model, the widget uploads the files itself rather than leaving it to Livewire's change handler,
// which only sees the latest pick: in Livewire 4 a multiple input adds each pick to the property, so after a remove
// or a drop the property and the list would disagree. Here the property is replaced with what the list holds, every
// time it changes, and the livewire-upload-* events still fire on the input. Not with upload-url, which sends itself.
const bindingOf = (input) => [...input.attributes].find((attribute) => attribute.name.startsWith('wire:model'))?.value;
const wireOf = (input) => window.Livewire?.find(input.closest('[wire\\:id]')?.getAttribute('wire:id'));
function tellLivewire(root) {
const input = inputOf(root);
const property = bindingOf(input);
const wire = property && !direct(root) ? wireOf(input) : null;
if (!wire) {
return;
}
const files = [...input.files];
const multiple = input.multiple;
if (files.length === 0) {
wire.set(property, multiple ? [] : null);
return;
}
const fire = (name, detail = {}) => input.dispatchEvent(new CustomEvent(`livewire-upload-${name}`, { bubbles: true, detail: { property, ...detail } }));
const callbacks = [
() => fire('finish'),
() => fire('error'),
(event) => fire('progress', { progress: Math.round((event.loaded * 100) / event.total) }),
() => fire('cancel'),
];
fire('start');
if (multiple) {
wire.$uploadMultiple(property, files, ...callbacks, false);
} else {
wire.$upload(property, files[0], ...callbacks);
}
}
// Capture: runs before Livewire's own listener on the input, which then never sees the pick.
document.addEventListener('change', (event) => {
const input = event.target;
const root = input.closest?.(ROOT);
if (!root || input.type !== 'file' || !bindingOf(input) || direct(root) || !wireOf(input)) {
return;
}
event.stopPropagation();
const kind = root.dataset.fileUpload;
if (kind === 'button') {
pickedButton(root, input);
} else if (kind === 'image') {
pickedImage(root, input);
} else if (input.files.length > 0) {
addFiles(root, input.files);
}
tellLivewire(root);
}, true);
// The list, name and preview are wire:ignore'd, so a render never empties them. When the property goes from files to
// none, PHP emptied it ($this->reset('photo') after saving): empty them to match. data-livewire-files is its count.
const liveCounts = new WeakMap();
onLivewireMorph((scope) => {
const roots = [...(scope.matches?.(ROOT) ? [scope] : []), ...(scope.querySelectorAll?.(`${ROOT}[data-livewire-files]`) ?? [])];
for (const root of roots) {
const now = Number(root.dataset.livewireFiles ?? 0);
const before = liveCounts.get(root);
liveCounts.set(root, now);
if (!(before > 0 && now === 0)) {
continue;
}
const input = inputOf(root);
const kind = root.dataset.fileUpload;
input.value = '';
if (kind === 'button') {
pickedButton(root, input);
} else if (kind === 'image') {
// Back to the image the server has now (a just-saved one), or none.
showImage(root, root.dataset.src || null);
} else {
dropzone(root).entries.forEach((entry) => removeEntry(root, entry, { quiet: true }));
}
}
});
app/View/Widget/ElementIds.php Show
<?php
declare(strict_types=1);
namespace App\View\Widget;
use Illuminate\Container\Attributes\Scoped;
use LogicException;
/**
* Keeps element ids unique within one response, so labels, aria-describedby and #fragments
* always point at the right element. Scoped: a fresh set per request (and per Octane/queue cycle).
*/
#[Scoped]
final class ElementIds
{
/** @var array<string, true> */
private array $used = [];
/**
* Reserves an id for this response.
*
* A derived id (built from a field name) gets a -2, -3 … suffix when already taken. An explicit
* id is one the caller chose and may reference from JS or CSS, so silently renaming it would
* break that reference; a duplicate throws instead, which surfaces in development and tests.
*/
public function claim(string $id, bool $explicit = false): string
{
if (!isset($this->used[$id])) {
return $this->reserve($id);
}
if ($explicit) {
throw new LogicException("Duplicate element id [{$id}] on this page. Give one of the widgets a different id or name.");
}
$suffix = 2;
while (isset($this->used["{$id}-{$suffix}"])) {
$suffix++;
}
return $this->reserve("{$id}-{$suffix}");
}
private function reserve(string $id): string
{
$this->used[$id] = true;
return $id;
}
}
app/View/Widget/FormField.php Show
<?php
declare(strict_types=1);
namespace App\View\Widget;
use Illuminate\Contracts\Support\MessageBag;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Illuminate\Support\ViewErrorBag;
use Illuminate\View\ComponentAttributeBag;
/**
* Server-side state of one form widget: its dot-notation key, a valid id, its validation
* messages and its old input. Every <x-widget.input.*> and the date picker resolve through
* here, so array names (items[0][date]) and named error bags behave the same everywhere.
*/
final class FormField
{
/**
* @param list<string> $errors
*/
private function __construct(
public readonly ?string $name,
public readonly string $id,
public readonly ?string $key,
public readonly array $errors,
// The property a wire:model or x-model attribute binds it to, if any.
public readonly ?string $bound = null,
) {}
/**
* @param mixed $errorBag the view's shared $errors (absent outside a web request)
* @param string|array<int, string>|null $error an explicit message from the caller; overrides the bag
*/
public static function make(
?string $name,
?string $id,
mixed $errorBag,
string|array|null $error = null,
string $bag = 'default',
string $idPrefix = 'field',
?ComponentAttributeBag $attributes = null,
): self {
// With no name, a Livewire or Alpine binding (wire:model="email") names the field. Its errors are filed under
// that property, and its id stays the same on every render, which Livewire's morph needs to keep the element
// (it matches elements by id: a random one makes it swap in a new field, dropping focus mid-typing).
$bound = $attributes === null ? null : self::boundTo($attributes);
$key = match (true) {
$name !== null && $name !== '' => self::key($name),
$bound !== null => self::key($bound),
default => null,
};
$messages = match (true) {
$error !== null => Arr::wrap($error),
$key !== null && $errorBag instanceof ViewErrorBag => self::messagesFor($errorBag->getBag($bag), $key),
default => [],
};
return new self(
$name,
app(ElementIds::class)->claim(
$id ?? ($key !== null ? self::idFrom($key) : $idPrefix.'-'.Str::random(6)),
explicit: $id !== null,
),
$key,
array_values(array_filter($messages, static fn (mixed $message): bool => is_string($message) && $message !== '')),
$bound,
);
}
/**
* The field's own messages, plus those Laravel files per item for a list of values: a 'tags.*' rule
* reports a bad second choice under tags.1, which a multiple select named tags must still show. Only
* numbered children count, so a field named address doesn't take errors meant for address[city].
*
* @return list<string>
*/
private static function messagesFor(MessageBag $bag, string $key): array
{
$items = array_filter(
$bag->getMessages(),
static fn (string $name): bool => preg_match('/^'.preg_quote($key, '/').'\.\d+$/', $name) === 1,
ARRAY_FILTER_USE_KEY,
);
return array_values(array_unique([...$bag->get($key), ...array_merge(...array_values($items))]));
}
/**
* items[0][date] → items.0.date and tags[] → tags: the key Laravel files errors and old input under.
*/
public static function key(string $name): string
{
return trim((string) preg_replace('/\[([^\]]*)\]/', '.$1', $name), '.');
}
/**
* The id a field named $name gets when it's the first of that name on the page, for links to it
* (the error summary). A later duplicate gets a -2 suffix, which links can't know about.
*/
public static function idFor(string $name): string
{
return self::idFrom(self::key($name));
}
/**
* The property a wire:model or x-model attribute (any modifiers) binds the field to; null without one.
*/
public static function boundTo(ComponentAttributeBag $attributes): ?string
{
return array_values(self::binding($attributes))[0] ?? null;
}
private static function idFrom(string $key): string
{
return trim((string) preg_replace('/[^A-Za-z0-9_-]+/', '-', $key), '-');
}
public function hasError(): bool
{
return $this->errors !== [];
}
public function errorId(): string
{
return $this->id.'-error';
}
public function infoId(): string
{
return $this->id.'-info';
}
/**
* Old input after a failed validation, falling back to the widget's value prop, or with none, to the bound Livewire
* property: a re-render then draws the field as it is, which Livewire morphs onto the page.
*/
public function old(mixed $default = null): mixed
{
if ($default === null) {
[$found, $live] = $this->fromLivewire();
$default = $found ? $live : null;
}
return $this->key === null ? $default : old($this->key, $default);
}
/**
* The bound property's value while Livewire renders the component that holds it: Livewire shares that component
* with every view as $__livewire. Livewire isn't a dependency; it's only looked for. [false, null] otherwise.
* $key reads inside it: a range bound to period reads period.start.
*
* @return array{0: bool, 1: mixed}
*/
public function fromLivewire(?string $key = null): array
{
$component = $this->bound === null ? null : view()->shared('__livewire');
return is_object($component) ? [true, data_get($component, $key === null ? $this->bound : "{$this->bound}.{$key}")] : [false, null];
}
/**
* The binding attribute as written (wire:model.live => period), to put on the inputs that carry the value: a range
* picker binds period.start and period.end with the same modifiers. Empty without one.
*
* @return array<string, string>
*/
public static function binding(ComponentAttributeBag $attributes): array
{
foreach ($attributes->getAttributes() as $attribute => $value) {
if (is_string($value) && $value !== '' && (str_starts_with($attribute, 'wire:model') || str_starts_with($attribute, 'x-model'))) {
return [$attribute => $value];
}
}
return [];
}
/**
* Whether a checkbox or switch renders ticked. An unticked box isn't in the request at all, so after a
* failed submit "no old value" means unticked, but only when that submit was this box's own form. A page
* with a second form (or a disabled box, which is never sent) would otherwise lose every `checked`.
*
* @param bool $alwaysSent it has an unchecked-value, so its form always sends something under its name
* @param mixed $errorBag the view's shared $errors
*/
public function checked(mixed $value, bool $default, bool $disabled, bool $alwaysSent, mixed $errorBag, string $bag = 'default'): bool
{
// Bound to a Livewire property: that says, true/false, or for a list of boxes, whether it holds this value.
[$found, $live] = $this->fromLivewire();
if ($found) {
return is_array($live) ? in_array(self::text($value), array_map(self::text(...), $live), true) : (bool) $live;
}
if ($this->key === null || $disabled || !session()->hasOldInput()) {
return $default;
}
$old = old($this->key);
if ($old !== null) {
return in_array(self::text($value), array_map(self::text(...), Arr::wrap($old)), true);
}
// Nothing under this name, though this box always sends something: its form wasn't the one submitted.
if ($alwaysSent) {
return $default;
}
// A form with its own error bag: no errors in that bag means the failed submit was a different form.
if ($bag !== 'default') {
return $errorBag instanceof ViewErrorBag && $errorBag->getBag($bag)->isNotEmpty() ? false : $default;
}
// One form, or forms sharing the default bag: there's no telling them apart, so trust the old input.
return false;
}
/** Values cast to backed enums (Plan::Pro) compare as their backing value. */
private static function text(mixed $value): string
{
return (string) ($value instanceof \BackedEnum ? $value->value : $value);
}
/**
* What the caller passed, minus `class` (that styles the wrapper) and the aria attributes
* this widget manages itself. Goes on the element that is actually submitted.
*/
public function forwarded(ComponentAttributeBag $attributes): ComponentAttributeBag
{
return $attributes->except(['class', 'aria-invalid', 'aria-describedby']);
}
/**
* aria-invalid plus one aria-describedby that joins the error, the hint and the caller's own
* ids. Two separate aria-describedby attributes would make the browser silently drop one.
*/
public function aria(ComponentAttributeBag $attributes, bool $hasInfo = false): ComponentAttributeBag
{
$describedBy = array_filter([
$this->hasError() ? $this->errorId() : null,
$hasInfo ? $this->infoId() : null,
$attributes->get('aria-describedby'),
]);
return new ComponentAttributeBag([
'aria-invalid' => $this->hasError() ? 'true' : null,
'aria-describedby' => $describedBy === [] ? null : implode(' ', $describedBy),
]);
}
/**
* For a widget whose visible control isn't the submitted input (grouped number, phone): what belongs on the
* hidden input that carries the value. Which form it's in, and Livewire and Alpine bindings, go with the value.
*/
public function bindings(ComponentAttributeBag $attributes): ComponentAttributeBag
{
return $attributes->filter(static fn (mixed $value, string $key): bool => self::isBinding($key));
}
/**
* The other side of bindings(): everything else the caller passed (required, autofocus, aria-label,
* placeholder…) goes on the visible control, where the browser validates it and screen readers hear it.
*/
public function visibleAttributes(ComponentAttributeBag $attributes, bool $hasInfo = false): ComponentAttributeBag
{
return $this->controlAttributes($attributes->filter(static fn (mixed $value, string $key): bool => !self::isBinding($key)), $hasInfo);
}
private static function isBinding(string $key): bool
{
return $key === 'form' || str_starts_with($key, 'wire:model') || str_starts_with($key, 'x-model');
}
/**
* forwarded() and aria() together, for widgets whose visible control is also the submitted one.
*/
public function controlAttributes(ComponentAttributeBag $attributes, bool $hasInfo = false): ComponentAttributeBag
{
return $this->forwarded($attributes)->merge($this->aria($attributes, $hasInfo)->getAttributes());
}
}
app/View/Widget/UploadLimits.php Show
<?php
declare(strict_types=1);
namespace App\View\Widget;
/**
* What an <x-widget.file-upload.*> accepts, read once from its props: the largest file in bytes and the types,
* plus the hint that says both in words ("PDF or PNG, up to 5 MB"). The browser checks the same limits as the
* user picks files; the Form Request must still validate them (see the widget's usage notes).
*/
final readonly class UploadLimits
{
private const array UNITS = ['KB' => 1024, 'MB' => 1024 ** 2, 'GB' => 1024 ** 3];
/**
* @param list<string> $accept the accept attribute's entries: ".pdf", "image/png", "image/*"
*/
private function __construct(
public ?int $maxBytes,
public array $accept,
) {}
/**
* @param int|string|null $maxSize "5MB", "500 KB", "2GB", or a number of kilobytes, as Laravel's max: rule counts
*/
public static function from(int|string|null $maxSize, ?string $accept): self
{
return new self(self::bytes($maxSize), array_values(array_filter(array_map('trim', explode(',', (string) $accept)))));
}
/** "PDF, PNG or JPG, up to 5 MB"; null when there is nothing to say. */
public function hint(): ?string
{
$types = array_values(array_unique(array_map(self::typeName(...), $this->accept)));
$parts = array_filter([
match (count($types)) {
0 => null,
1 => $types[0],
default => implode(', ', array_slice($types, 0, -1)).' or '.end($types),
},
$this->maxBytes !== null ? 'up to '.self::size($this->maxBytes) : null,
]);
return $parts === [] ? null : ucfirst(implode(', ', $parts));
}
/** 5242880 → "5 MB", 512000 → "500 KB". */
public static function size(int $bytes): string
{
foreach (array_reverse(self::UNITS, true) as $unit => $factor) {
if ($bytes >= $factor) {
return rtrim(rtrim(number_format($bytes / $factor, 1, '.', ''), '0'), '.').' '.$unit;
}
}
return "{$bytes} bytes";
}
private static function bytes(int|string|null $maxSize): ?int
{
if ($maxSize === null || $maxSize === '') {
return null;
}
if (is_int($maxSize) || ctype_digit($maxSize)) {
return (int) $maxSize * 1024;
}
if (preg_match('/^\s*(\d+(?:\.\d+)?)\s*(KB|MB|GB)\s*$/i', $maxSize, $match) !== 1) {
throw new \InvalidArgumentException("max-size=\"{$maxSize}\" isn't a size. Use 500KB, 5MB, 2GB, or kilobytes as a number.");
}
return (int) round((float) $match[1] * self::UNITS[strtoupper($match[2])]);
}
/** ".pdf" → "PDF", "image/png" → "PNG", "image/svg+xml" → "SVG", "image/*" → "images". */
private static function typeName(string $entry): string
{
if (str_starts_with($entry, '.')) {
return strtoupper(substr($entry, 1));
}
[$group, $kind] = array_pad(explode('/', $entry, 2), 2, '*');
// image/svg+xml → SVG: the +xml (or +json) suffix is how the type is written down, not what people call it.
return $kind === '*' ? "{$group}s" : strtoupper((string) preg_replace('/\+\w+$/', '', $kind));
}
}