Form
A Laravel form wrapper with CSRF, validation, idempotency and record-version fields, plus safe draft and failed-request recovery through Form Guard.
Preview
<x-ui.form.guard :persist-draft="true" draft-key="form-preview" class="mx-auto w-full max-w-md">
<x-ui.form action="#" method="POST" :summary="__('Please correct the errors below.')" data-persisted-form>
<x-ui.field name="full_name" label="Name">
<x-ui.input placeholder="Ada Lovelace" />
</x-ui.field>
<x-ui.field name="email" label="Email">
<x-ui.input type="email" placeholder="ada@example.com" />
</x-ui.field>
<x-ui.button type="submit" x-bind:disabled="submitting" x-bind:aria-busy="submitting">Save changes</x-ui.button>
</x-ui.form>
</x-ui.form.guard>
Installation
php artisan ui:add form
Note
This component ships an Alpine behavior module at
resources/js/ui/form.js. Import it once from your bundle so it registers on alpine:init:
import './form.js';
Registry contract
php artisan ui:add form
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/form/form.blade.php -
resources/views/components/ui/form/guard.blade.php -
resources/js/ui/form.js
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Form (`form`)
A Laravel form wrapper with CSRF, validation, idempotency and record-version fields, plus safe draft and failed-request recovery through Form Guard.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add form
```
## Usage
```blade
<x-ui.form.guard :persist-draft="true" draft-key="form-preview" class="mx-auto w-full max-w-md">
<x-ui.form action="#" method="POST" :summary="__('Please correct the errors below.')" data-persisted-form>
<x-ui.field name="full_name" label="Name">
<x-ui.input placeholder="Ada Lovelace" />
</x-ui.field>
<x-ui.field name="email" label="Email">
<x-ui.input type="email" placeholder="[email protected]" />
</x-ui.field>
<x-ui.button type="submit" x-bind:disabled="submitting" x-bind:aria-busy="submitting">Save changes</x-ui.button>
</x-ui.form>
</x-ui.form.guard>
```
## Props
- `method` (GET|POST|PUT|PATCH|DELETE, default `POST`) — HTTP method; non-GET/POST verbs use Laravel method spoofing.
- `action` (string|null, default `null`) — Submission URL.
- `hasFiles` (bool, default `false`) — Declared by @props in the shipped Blade source.
- `summary` (string|null, default `null`) — Heading of an error summary (role=alert) that lists the validation errors above the form body while the error bag has any. A custom errorSummary slot wins; give it the data-form-error-summary attribute so the error focus finds it.
- `idempotencyKey` (string|null, default `null`) — Stable operation key for a server-enforced idempotent write.
- `recordVersion` (string|null, default `null`) — Version value that the server compares before an update.
- `recordVersionName` (string, default `_version`) — Declared by @props in the shipped Blade source.
- `focusErrors` (bool, default `true`) — With wire:submit: after a Livewire submit that returns validation errors, move focus to the error summary, else to the first invalid field (aria-invalid="true"), and scroll it to the middle of the view. Set false to leave focus where it is.
- `hiddenFields` (array, default `[]`) — Hidden inputs as ['name' => 'value'], so a template never writes a raw <input type="hidden">. A list of values posts as name[] (one input each), a keyed array as name[key], nested to any depth; true/false post as 1/0, a backed enum as its value, and null skips the field. Values are escaped. CSRF, _method, idempotency_key and the record version keep their own props.
- `enabled` (bool, default `true`) — Declared by @props in the registry Blade source.
- `message` (string, default `You have unsaved changes. Leave this page?`) — Declared by @props in the registry Blade source.
- `persistDraft` (bool, default `false`) — Declared by @props in the registry Blade source.
- `draftKey` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `offlineMessage` (string, default `You are offline. Your changes are saved in this tab. Reconnect and try again.`) — Declared by @props in the registry Blade source.
- `failureMessage` (string, default `The request did not finish. Your changes are still here. Try again.`) — Declared by @props in the registry Blade source.
- `expiredMessage` (string, default `Your session expired. Your changes are saved in this tab. Sign in and try again.`) — Declared by @props in the registry Blade source.
- `restoredMessage` (string, default `Your unfinished changes were restored.`) — Declared by @props in the registry Blade source.
- `as` (string, default `div`) — Declared by @props in the registry Blade source.
## Use when
- Use to collect, group, and clearly label related input so users understand what is being asked and can recover from errors.
- Submitting user input through Laravel routes with CSRF protection.
- A form needs method spoofing, file-upload encoding, or a validation summary.
## Avoid when
- Do not create dense unlabeled forms; start from one thing per page or one conceptual question per step when the task is unfamiliar or high stakes.
- Ask only for information the task needs. Start with one thing per page for unfamiliar or complex flows, and merge questions only when research supports it.
- The markup is not a submission boundary; use a structural container instead.
- Nested inside another form, which is invalid HTML.
## Anti-patterns
- Dense unlabeled forms
- Collecting information without a task need
## Rules
- Use the `<brok:form>` tag (or `<x-ui.form>`) 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/form
- Registry JSON (files, props, contract): https://brokui.dev/r/open/form.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
Validation
@php
$previewErrors = new \Illuminate\Support\ViewErrorBag;
$previewErrors->put('default', new \Illuminate\Support\MessageBag([
'email' => [__('Enter a valid email address.')],
]));
view()->share('errors', $previewErrors);
@endphp
<x-ui.form.guard class="mx-auto w-full max-w-md">
<x-ui.form
action="#"
method="POST"
:summary="__('Please correct the errors below.')"
data-persisted-form
>
<x-ui.field name="email" label="Email">
<x-ui.input type="email" value="ada@" />
</x-ui.field>
<x-ui.button type="submit">Save changes</x-ui.button>
</x-ui.form>
</x-ui.form.guard>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| method | GET | POST | PUT | PATCH | DELETE | POST | HTTP method; non-GET/POST verbs use Laravel method spoofing. |
| action | string | null | null | Submission URL. |
| hasFiles | bool | false | Declared by @props in the shipped Blade source. |
| summary | string | null | null | Heading of an error summary (role=alert) that lists the validation errors above the form body while the error bag has any. A custom errorSummary slot wins; give it the data-form-error-summary attribute so the error focus finds it. |
| idempotencyKey | string | null | null | Stable operation key for a server-enforced idempotent write. |
| recordVersion | string | null | null | Version value that the server compares before an update. |
| recordVersionName | string | _version | Declared by @props in the shipped Blade source. |
| focusErrors | bool | true | With wire:submit: after a Livewire submit that returns validation errors, move focus to the error summary, else to the first invalid field (aria-invalid="true"), and scroll it to the middle of the view. Set false to leave focus where it is. |
| hiddenFields | array | [] | Hidden inputs as ['name' => 'value'], so a template never writes a raw <input type="hidden">. A list of values posts as name[] (one input each), a keyed array as name[key], nested to any depth; true/false post as 1/0, a backed enum as its value, and null skips the field. Values are escaped. CSRF, _method, idempotency_key and the record version keep their own props. |
| enabled | bool | true | Declared by @props in the registry Blade source. |
| message | string | You have unsaved changes. Leave this page? | Declared by @props in the registry Blade source. |
| persistDraft | bool | false | Declared by @props in the registry Blade source. |
| draftKey | mixed | null | null | Declared by @props in the registry Blade source. |
| offlineMessage | string | You are offline. Your changes are saved in this... | Declared by @props in the registry Blade source. |
| failureMessage | string | The request did not finish. Your changes are sti... | Declared by @props in the registry Blade source. |
| expiredMessage | string | Your session expired. Your changes are saved in... | Declared by @props in the registry Blade source. |
| restoredMessage | string | Your unfinished changes were restored. | Declared by @props in the registry Blade source. |
| as | string | div | Declared by @props in the registry Blade source. |
Slots
default— Fields, groups, and form actions.errorSummary— Custom validation summary markup that controls its own visibility.x-ui.form.form— Installed subcomponent from the registry item.x-ui.form.guard— Installed subcomponent from the registry item.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Emits CSRF protection for state-changing methods and Laravel's hidden _method field when required.
- hiddenFields renders one escaped hidden input per value after the CSRF and _method fields, flattening nested arrays into the bracket names Laravel parses back into arrays.
- Form Guard tracks changes, blocks repeat submits, and protects document and cancelable client-router navigation.
- Optional safe drafts exclude hidden fields, passwords, and file values; failed requests, offline submits, expired sessions, and back-forward-cache restoration keep the form retryable.
- On a wire:submit form a hidden uiFormErrors() helper follows the Livewire commit of that submit; when the response brings validation errors, focus moves to the error summary (tabindex=-1), else to the first invalid field. A submit without errors moves nothing, and nothing runs without Livewire.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, responsive, rtl, darkMode, localized, interactive, behaviorTest.
Guidance
Collect and organize related user input.
Use when
- Use to collect, group, and clearly label related input so users understand what is being asked and can recover from errors.
- Submitting user input through Laravel routes with CSRF protection.
- A form needs method spoofing, file-upload encoding, or a validation summary.
Avoid when
- Do not create dense unlabeled forms; start from one thing per page or one conceptual question per step when the task is unfamiliar or high stakes.
- Ask only for information the task needs. Start with one thing per page for unfamiliar or complex flows, and merge questions only when research supports it.
- The markup is not a submission boundary; use a structural container instead.
- Nested inside another form, which is invalid HTML.
Use instead
- Focused question pages
- A smaller set of directly labeled fields
Anti-patterns
- Dense unlabeled forms
- Collecting information without a task need
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- native
- Focus
managed
- Keep one clear submit action and move focus to invalid fields or the error summary after failure.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Livewire can update this component through forwarded wire:* attributes.
Validation
Validation support: laravel-error-bag. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:form
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.
@props([
// HTTP verb the form represents. POST/GET pass straight through to the element;
// PUT/PATCH/DELETE are method-spoofed (the element uses POST + a hidden _method
// field), matching Laravel's @method directive.
'method' => 'POST',
// The form target URL.
'action' => null,
// Set true to send files: switches the form to multipart/form-data.
'hasFiles' => false,
// Optional error-summary heading. When set, a role=alert region listing the
// validation errors renders above the form body whenever the shared $errors bag
// is non-empty. For fully custom summary markup, fill the `errorSummary` slot
// instead — it wins over this prop. Give a custom summary the
// data-form-error-summary attribute so the error focus finds it.
'summary' => null,
// Stable key for one user intent. The server must enforce uniqueness.
'idempotencyKey' => null,
// Version of the record that the user loaded. The server must compare it
// inside the write transaction before it changes the record.
'recordVersion' => null,
'recordVersionName' => '_version',
// With wire:submit: after a Livewire submit that returns validation
// errors, move focus to the error summary, else to the first invalid
// field. Set false to leave focus where it is.
'focusErrors' => true,
// Hidden inputs as ['name' => 'value'], so a template never writes raw
// <input type="hidden">. A list of values posts as name[] (one input per
// entry), a keyed array as name[key]; nesting goes any depth. true/false
// post as 1/0, a backed enum as its value, null skips the field. Values
// are escaped.
'hiddenFields' => [],
])
@php
// Normalise the verb. Spoofed verbs ride on a POST element + hidden _method.
$verb = strtoupper((string) $method);
$spoofed = in_array($verb, ['PUT', 'PATCH', 'DELETE'], true);
$formMethod = $verb === 'GET' ? 'GET' : 'POST';
// CSRF protection is needed for any non-GET submission.
$needsCsrf = $verb !== 'GET';
// The shared ViewErrorBag may be absent in a bare static render with no session
// middleware, so resolve defensively — an empty bag means "no errors".
$bag = ($errors ?? null) instanceof \Illuminate\Support\ViewErrorBag ? $errors : null;
$hasErrors = $bag && $bag->any();
// Flatten nested arrays into the bracket names PHP and Laravel parse back.
$flattenHiddenFields = function (array $fields, ?string $prefix = null) use (&$flattenHiddenFields): array {
$inputs = [];
$isList = array_is_list($fields);
foreach ($fields as $key => $value) {
// A scalar in a list appends (name[]); anything else keeps its key
// (name[key], name[0][key]) so nested rows stay together.
$name = $prefix === null ? (string) $key : ($isList && ! is_array($value) ? $prefix.'[]' : $prefix.'['.$key.']');
if (is_array($value)) {
array_push($inputs, ...$flattenHiddenFields($value, $name));
} elseif ($value !== null) {
$inputs[] = [$name, match (true) {
is_bool($value) => $value ? '1' : '0',
$value instanceof \BackedEnum => (string) $value->value,
default => (string) $value,
}];
}
}
return $inputs;
};
$hiddenInputs = is_array($hiddenFields) ? $flattenHiddenFields($hiddenFields) : [];
// A Livewire form (wire:submit, with any modifier) gets the error focus helper.
$livewireSubmit = collect(array_keys($attributes->getAttributes()))
->contains(fn (string $name): bool => str_starts_with($name, 'wire:submit'));
@endphp
<form
data-slot="form"
method="{{ $formMethod }}"
@if (filled($action)) action="{{ $action }}" @endif
@if ($hasFiles) enctype="multipart/form-data" @endif
{{ $attributes->merge(['class' => 'space-y-6']) }}
>
@if ($focusErrors && $livewireSubmit)
<div hidden data-slot="form-error-focus" x-data="uiFormErrors()"></div>
@endif
@if ($needsCsrf)
@csrf
@endif
@if ($spoofed)
@method($verb)
@endif
@foreach ($hiddenInputs as [$hiddenName, $hiddenValue])
<input type="hidden" name="{{ $hiddenName }}" value="{{ $hiddenValue }}" />
@endforeach
@if (filled($idempotencyKey))
<input type="hidden" name="idempotency_key" value="{{ $idempotencyKey }}" />
@endif
@if (filled($recordVersion))
<input type="hidden" name="{{ $recordVersionName }}" value="{{ $recordVersion }}" />
@endif
@isset($errorSummary)
{{-- Consumer-supplied summary markup controls its own visibility. --}}
{{ $errorSummary }}
@else
@if ($hasErrors && filled($summary))
{{-- Default destructive error summary keyed off the heading prop. --}}
<x-ui.alert
variant="destructive"
data-slot="form-error-summary"
data-form-error-summary
aria-live="assertive"
tabindex="-1"
>
<p class="text-sm font-medium">{{ __($summary) }}</p>
<ul class="mt-2 list-disc space-y-1 ps-5 text-sm">
@foreach ($bag->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</x-ui.alert>
@endif
@endisset
{{ $slot }}
</form>
@props([
'enabled' => true,
'message' => 'You have unsaved changes. Leave this page?',
'persistDraft' => false,
'draftKey' => null,
'offlineMessage' => 'You are offline. Your changes are saved in this tab. Reconnect and try again.',
'failureMessage' => 'The request did not finish. Your changes are still here. Try again.',
'expiredMessage' => 'Your session expired. Your changes are saved in this tab. Sign in and try again.',
'restoredMessage' => 'Your unfinished changes were restored.',
'as' => 'div',
])
@php
$tag = in_array($as, ['div', 'section', 'main'], true) ? $as : 'div';
$resolvedSlot = $attributes->get('data-slot', 'form-guard');
@endphp
<{{ $tag }}
data-slot="{{ $resolvedSlot }}"
data-form-guard
data-dirty-guard
x-data="{
enabled: @js((bool) $enabled),
persistDraft: @js((bool) $persistDraft),
configuredDraftKey: @js(filled($draftKey) ? (string) $draftKey : null),
dirty: false,
submitting: false,
online: navigator.onLine,
submitter: null,
submitterField: null,
statusMessage: '',
statusTone: 'default',
message: @js(__($message)),
offlineMessage: @js(__($offlineMessage)),
failureMessage: @js(__($failureMessage)),
expiredMessage: @js(__($expiredMessage)),
restoredMessage: @js(__($restoredMessage)),
storageKey() {
if (this.configuredDraftKey) return `brok:form-draft:${this.configuredDraftKey}`
const form = this.$root.querySelector('form')
const action = form?.getAttribute('action') || window.location.pathname
return `brok:form-draft:${window.location.pathname}:${action}`
},
draftControls() {
const form = this.$root.querySelector('form')
if (! form) return []
return Array.from(form.elements).filter((control) => {
const type = String(control.type || '').toLowerCase()
const name = String(control.name || '')
return name !== ''
&& ! name.startsWith('_')
&& ! control.disabled
&& ! ['password', 'file', 'submit', 'button', 'reset', 'hidden'].includes(type)
})
},
saveDraft() {
if (! this.persistDraft) return
const values = this.draftControls().map((control) => ({
name: control.name,
type: control.type,
value: control.value,
checked: Boolean(control.checked),
}))
try {
sessionStorage.setItem(this.storageKey(), JSON.stringify({ values }))
} catch (_) {
// Storage can be unavailable in private or restricted contexts.
}
},
restoreDraft() {
if (! this.persistDraft) return false
let draft
try {
draft = JSON.parse(sessionStorage.getItem(this.storageKey()) || 'null')
} catch (_) {
return false
}
if (! Array.isArray(draft?.values) || draft.values.length === 0) return false
const saved = [...draft.values]
this.draftControls().forEach((control) => {
const index = saved.findIndex((value) => value.name === control.name && value.type === control.type)
if (index < 0) return
const value = saved.splice(index, 1)[0]
if (['checkbox', 'radio'].includes(String(control.type).toLowerCase())) {
control.checked = value.checked
} else {
control.value = value.value
}
})
this.dirty = true
this.setStatus(this.restoredMessage)
return true
},
clearDraft() {
if (! this.persistDraft) return
try {
sessionStorage.removeItem(this.storageKey())
} catch (_) {
// Storage can be unavailable in private or restricted contexts.
}
},
setStatus(message = '', tone = 'default', focus = false) {
this.statusMessage = message
this.statusTone = tone
if (focus && message) this.$nextTick(() => this.$refs.status?.focus({ preventScroll: true }))
},
markDirty() {
if (! this.enabled || this.submitting) return
this.dirty = true
this.saveDraft()
},
beginSubmit(event) {
const form = event.target
if (! this.enabled || typeof form?.checkValidity !== 'function' || ! form.checkValidity()) return
if (this.submitting) {
event.preventDefault()
return
}
this.saveDraft()
if (! navigator.onLine) {
event.preventDefault()
this.dirty = true
this.setStatus(this.offlineMessage, 'destructive', true)
return
}
this.submitting = true
this.setStatus('')
this.submitter = event.submitter || null
if (event.submitter) {
if (event.submitter.name) {
this.submitterField = document.createElement('input')
this.submitterField.type = 'hidden'
this.submitterField.name = event.submitter.name
this.submitterField.value = event.submitter.value
form.append(this.submitterField)
}
event.submitter.disabled = true
event.submitter.setAttribute('aria-busy', 'true')
}
},
releaseSubmitter() {
this.submitterField?.remove()
this.submitterField = null
if (this.submitter) {
this.submitter.disabled = false
this.submitter.removeAttribute('aria-busy')
}
this.submitter = null
},
finishRequest() {
if (! this.submitting) return
this.submitting = false
this.releaseSubmitter()
this.setStatus('')
},
reset() {
this.dirty = false
this.submitting = false
this.releaseSubmitter()
this.clearDraft()
this.setStatus('')
},
fail(message) {
this.submitting = false
this.dirty = true
this.releaseSubmitter()
this.saveDraft()
this.setStatus(message || this.failureMessage, 'destructive', true)
},
requestFailed(event) {
if (event.detail?.draftKey && event.detail.draftKey !== this.configuredDraftKey) return
this.fail(event.detail?.message || this.failureMessage)
},
sessionExpired(event) {
if (event.detail?.draftKey && event.detail.draftKey !== this.configuredDraftKey) return
this.fail(event.detail?.message || this.expiredMessage)
},
connectionChanged(online) {
this.online = online
if (! online && this.submitting) {
this.fail(this.offlineMessage)
return
}
this.setStatus(online ? @js(__('Connection restored. You can try again.')) : this.offlineMessage, online ? 'default' : 'destructive')
},
restorePage(event) {
const wasSubmitting = this.submitting
this.submitting = false
this.releaseSubmitter()
const restored = this.restoreDraft()
if (wasSubmitting && ! restored) {
this.dirty = true
this.setStatus(this.failureMessage, 'destructive')
}
},
warn(event) {
if (! this.enabled || ! this.dirty || this.submitting) return
event.preventDefault()
event.returnValue = ''
},
beforeNavigate(event) {
if (! this.enabled || ! this.dirty) return
const confirmNavigation = event.detail?.confirm
const allowed = typeof confirmNavigation === 'function'
? confirmNavigation(this.message)
: window.confirm(this.message)
if (! allowed) {
event.preventDefault()
return
}
this.reset()
},
}"
x-on:input="markDirty()"
x-on:change="markDirty()"
x-on:brok:form-dirty="markDirty()"
x-on:submit.capture="beginSubmit($event)"
x-on:beforeunload.window="warn($event)"
x-on:brok:before-navigate.window="beforeNavigate($event)"
x-on:brok:form-clean="reset()"
x-on:brok:request-succeeded="finishRequest()"
x-init="restoreDraft()"
x-on:online.window="connectionChanged(true)"
x-on:offline.window="connectionChanged(false)"
x-on:pageshow.window="restorePage($event)"
x-on:brok:request-failed="requestFailed($event)"
x-on:brok:session-expired="sessionExpired($event)"
{{ $attributes->except('data-slot')->merge(['class' => 'min-w-0']) }}
>
{{ $slot }}
<p
x-cloak
x-show="statusMessage"
x-text="statusMessage"
x-ref="status"
x-bind:role="statusTone === 'destructive' ? 'alert' : 'status'"
x-bind:data-state="statusTone"
data-slot="form-resilience-status"
x-bind:aria-live="statusTone === 'destructive' ? 'assertive' : 'polite'"
tabindex="-1"
class="mt-3 min-w-0 break-words rounded-md border border-border bg-muted px-4 py-3 text-sm text-foreground"
></p>
</{{ $tag }}>
/**
* Form error focus (<x-ui.form wire:submit="…">).
*
* After a Livewire submit that comes back with validation errors, focus moves
* to the error summary (`data-form-error-summary`: the `summary` prop, or a
* custom `errorSummary` slot that carries the attribute), else to the
* first invalid field (`aria-invalid="true"`). On a long form the submit
* button sits below the fold, so without this a sighted keyboard user has to
* scroll back to find what went wrong.
*
* The form renders a hidden `uiFormErrors()` element for it; nothing runs
* without Livewire. A submit that succeeds leaves no invalid field and moves
* nothing.
*/
/** A rendered element that can take focus now. */
function focusable(el) {
return el.getClientRects().length > 0 && !el.closest('[inert]') && !el.disabled;
}
/** Focus the error summary, else the first invalid field; true when focus moved. */
function focusFirstError(form) {
const summary = Array.from(form.querySelectorAll('[data-form-error-summary], [data-slot="form-error-summary"]')).find(focusable);
const target = summary ?? Array.from(form.querySelectorAll('[aria-invalid="true"]')).find(focusable);
if (!target) return false;
if (target === summary && !summary.hasAttribute('tabindex')) summary.setAttribute('tabindex', '-1');
const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
target.focus({ preventScroll: true });
target.scrollIntoView({ block: 'center', behavior: reduce ? 'auto' : 'smooth' });
return document.activeElement === target;
}
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiFormErrors', () => {
// Outside the reactive data: set by the submit, read by the commit hook.
let pending = false;
let form = null;
let unhook = null;
const onSubmit = () => {
pending = true;
};
return {
init() {
form = this.$el.closest('form');
const livewire = window.Livewire;
if (!form || typeof livewire?.hook !== 'function') return;
// Capture: runs before Livewire's own wire:submit listener.
form.addEventListener('submit', onSubmit, true);
unhook = livewire.hook('commit', ({ component, succeed, fail }) => {
if (!pending || !component?.el?.contains(form)) return;
// succeed() runs after the response is morphed into the page.
succeed(() => {
pending = false;
requestAnimationFrame(() => {
if (form.isConnected) focusFirstError(form);
});
});
fail(() => {
pending = false;
});
});
},
destroy() {
form?.removeEventListener('submit', onSubmit, true);
if (typeof unhook === 'function') unhook();
unhook = null;
form = null;
},
};
});
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.4.4 - 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