Skip to content
Brok UI

Loading…

No results

Form

Open source Core

A Laravel form wrapper with CSRF, validation, idempotency and record-version fields, plus safe draft and failed-request recovery through Form Guard.

Version
v1.4.4
Stability
stable
License
MIT
Related
Field
Fieldset
Button
Alert

Preview

previews.components.form.default.blade.php 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="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

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

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

  • blade resources/views/components/ui/form/form.blade.php
  • blade resources/views/components/ui/form/guard.blade.php
  • js resources/js/ui/form.js
Registry dependencies
alert
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.

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

hidden-fields.blade.php Blade
{{-- Hidden fields without raw inputs: `hidden-fields` renders one escaped
     hidden input per value. A list posts as name[] and a keyed array as
     name[key]; the visible note is all the person fills in. --}}
<x-ui.form
    action="#"
    method="POST"
    class="mx-auto w-full max-w-md"
    :hidden-fields="[
        'decision' => 'dismiss',
        'return_to' => 'inbox',
        'labels' => ['billing', 'follow-up'],
    ]"
>
    <x-ui.field name="note" :label="__('Note')" :hint="__('Optional. Saved with the decision.')">
        <x-ui.textarea rows="3" />
    </x-ui.field>
    <x-ui.button type="submit">{{ __('Dismiss') }}</x-ui.button>
</x-ui.form>
validation.blade.php Blade
@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

manifest knowledge + registry-derived coverage

Props

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

form form-error-focus form-error-summary form-resilience-status {{ $resolvedSlot }}

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

Form structure and labeling

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
form root validation summary default slot actions
Theming hooks
validation summary form spacing

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-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

Safe

Livewire can update this component through forwarded wire:* attributes.

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

resources/views/components/ui/form/form.blade.php Blade
@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>
resources/views/components/ui/form/guard.blade.php Blade
@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 }}>
resources/js/ui/form.js JS
/**
 * 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