EN
Getting started

Form modal

This page shows you how to open a create or edit form in a modal, save it, and use the "unsaved changes" warning.

Note

The form modal is part of the PRO package, which comes installed with Acun themes. In an existing project, follow the steps on the Adding to an existing project page: the styles are in acun-ui-pro.css, and import '@acunsoft/acun-ui-pro' sets up the behavior.

Quick example

app/Livewire/Customers/CustomerForm.php:

namespace App\Livewire\Customers;

use Acun\Ui\Form\Concerns\InteractsWithFormModal;
use App\Models\Customer;
use Illuminate\Validation\ValidationException;
use Livewire\Component;

final class CustomerForm extends Component
{
    use InteractsWithFormModal;

    public bool $showForm = false;

    public string $name = '';

    // Called by the "New customer" button and by the bar's "Add" button.
    public function createNew(): void
    {
        $this->reset('name');
        $this->formCreating('customer-form');
        $this->showForm = true;
    }

    public function save(): void
    {
        $this->formSaving('customer-form');

        try {
            Customer::create($this->validate(['name' => ['required', 'string', 'max:120']]));
        } catch (ValidationException $exception) {
            $this->formSaveFailed('customer-form');
            throw $exception;
        }

        $this->formSaved('customer-form');
        $this->showForm = false;
    }

    public function render()
    {
        return view('livewire.customers.customer-form');
    }
}

resources/views/livewire/customers/customer-form.blade.php:

<div>
    <acun:button wire:click="createNew">New customer</acun:button>

    <acun:form-modal
        id="customer-form"
        title="Customer"
        wire:model="showForm"
        wire:submit="save"
        :mode="$formMode"
        :record-exists="$formRecordExists"
        :has-previous="$formHasPrevious"
        :has-next="$formHasNext"
        :can-delete="$formCanDelete"
    >
        <acun:input wire:model="name" label="Name" required />
    </acun:form-modal>
</div>

Put the component on a page with <livewire:customers.customer-form />. Clicking the button opens the modal. A six-button action bar appears below the form on its own: Previous, Next, Add, Delete, Cancel and Save. The buttons are never hidden; the ones you can't use right now are disabled (disabled and aria-disabled). Save is enabled only after a field changes. If you try to close the modal with changes, a warning appears.

Binding the open state

In the example above, wire:model="showForm" binds the modal's open state to a Livewire property. On every render, the modal opens or closes according to this property. When it's closed in the browser (X, Cancel, Esc, backdrop), the property is set to false.

Without wire:model, the modal is only hidden in the browser. The component's next request shows it again. If you'd rather manage it yourself without binding, listen for the ui:form-close-approved event: x-on:ui:form-close-approved="$wire.showForm = false".

Common options

By default, the action bar (acun:form-actions) calls the previous, next, createNew and delete methods. For other names, put your own bar in the modal's actions slot:

<acun:form-modal id="customer-form" title="Customer" wire:model="showForm" wire:submit="save">
    …
    <acun:slot:actions>
        <acun:form-actions previous="previousCustomer" next="nextCustomer" create="createNew" delete="remove" />
    </acun:slot:actions>
</acun:form-modal>
Prop What it does Default
previous, next The method the Previous and Next buttons call previous, next
create, delete The method of the Add and Delete buttons; Delete asks for confirmation first createNew, delete
delete-confirmation The text of the delete confirmation "Are you sure you want to delete this record?"
cancel The Cancel button; with a method name, it calls that method true
save The method that keeps the Save button disabled while loading (wire:target) save
sticky Keeps the bar at the bottom of the screen false

The Save button always submits the form; the form's wire:submit decides which method runs.

Action bar on a page form

acun:form-actions also works outside a modal. Placed inside a form, it binds itself to that form:

<acun:form id="product-form" wire:submit="save" dirty-check>
    <acun:input wire:model="name" label="Product name" required />

    <acun:form-actions sticky cancel="discard">
        <p class="text-xs text-gray-500">{{ $name }}</p>
    </acun:form-actions>
</acun:form>
  • id: Give the form an id so that Livewire calls such as formSaved('product-form') can find it. You don't need to add data-ui-dirty-form or data-form-id.
  • Cancel: On a page, resets the form to its last clean values. This includes the wire:model values and select boxes, tag inputs, ratings and OTP inputs. In a modal, Cancel closes the modal.
  • cancel="discard": Cancel calls that Livewire method instead. The values rendered after the method runs count as the new clean state. This is handy for also clearing validation errors.
  • sticky: The bar stays at the bottom of the screen. Because it reserves its own space at the end of the form, nothing on the page shifts when it sticks. It appears as a shadowed card over the theme background.
  • Slot: If provided, it's shown as a status line at the start of the bar.
  • dirty-check: Turns on the leave-page warning. The warning uses the clean values captured by the bar as its baseline; you don't need to send form-saved separately.

Livewire component

To edit and delete records, add a record ID and these methods to the component from the quick example:

use Livewire\Attributes\Locked;

// Only the server sets it; a $wire.set('recordId', …) from the browser is rejected.
#[Locked]
public ?int $recordId = null;

public function edit(Customer $customer): void
{
    $this->authorize('view', $customer);

    $this->name = $customer->name;
    $this->recordId = $customer->id;
    $this->showForm = true;
    $this->formRecordLoaded('customer-form', canDelete: auth()->user()->can('delete', $customer));
}

public function save(): void
{
    $customer = $this->recordId ? Customer::findOrFail($this->recordId) : new Customer;
    $this->authorize($customer->exists ? 'update' : 'create', $customer->exists ? $customer : Customer::class);

    $this->formSaving('customer-form');

    try {
        $customer->fill($this->validate(['name' => ['required', 'string', 'max:120']]))->save();
    } catch (ValidationException $exception) {
        $this->formSaveFailed('customer-form');
        throw $exception;
    }

    $this->recordId = $customer->id;
    $this->formSaved('customer-form', canDelete: auth()->user()->can('delete', $customer));
}

public function delete(): void
{
    // $formCanDelete only enables the button; the policy is checked again here.
    $customer = Customer::findOrFail($this->recordId);
    $this->authorize('delete', $customer);
    $customer->delete();

    $this->createNew();
}

This save() keeps the modal open after saving; Add and Delete become available in the bar. Also reset the ID in createNew(): $this->reset(['name', 'recordId']);. A button that opens a record: wire:click="edit({{ $customer->id }})".

The trait's methods tell the browser the state of the buttons. The first parameter is the form's id:

Method When to call it
formCreating($form) After resetting the fields for a new record
formRecordLoaded($form, hasPrevious:, hasNext:, canDelete:) After loading a record into the form
formSaving($form) Right before saving; the buttons are locked
formSaved($form, hasPrevious:, hasNext:, canDelete:) After a successful save; the form counts as clean
formSaveFailed($form) On a validation error; the form stays changed

You write the previous() and next() methods for Previous and Next yourself. After loading the record, pass hasPrevious: and hasNext: to formRecordLoaded().

Warning

The record ID (recordId) must be #[Locked]. Otherwise, a user could pass another record's ID from the browser and save or delete that record.

Public methods such as edit() can be called from the browser with any ID. So check authorization on the server with a policy every time you load, save or delete. The trait's formMode, formRecordExists, formHasPrevious, formHasNext and formCanDelete properties are also #[Locked]. They only determine the state of the buttons; never allow a server-side operation based on them.

Unsaved changes warning

A clean form closes directly with X, Cancel, Esc or a click on the backdrop. If the form has changes, a warning dialog opens first:

  • Stay: Returns to the form; focus stays where it was.
  • Discard changes: Resets the fields to their last clean values, then closes the modal or continues navigating.

While there are changes, the browser shows its own warning if the user refreshes the page, closes the tab or uses the browser's back and forward buttons. wire:navigate links open the same warning dialog.

For a form that isn't in a modal, add dirty-check to the acun:form component:

<acun:form wire:submit="save" dirty-check>
    …
</acun:form>

If the user tries to leave with wire:navigate, a confirmation dialog (acun:form-guard-dialog) opens. The theme's layouts already include this dialog.

Events

The form dispatches browser events as its state changes. For example, for an "Unsaved changes" label:

<acun:form id="product-form" wire:submit="save" dirty-check
    x-data="{ dirty: false }" x-on:ui:dirty="dirty = true" x-on:ui:clean="dirty = false">
    …
    <acun:form-actions sticky>
        <span wire:ignore x-text="dirty ? 'Unsaved changes' : 'All changes saved'"></span>
    </acun:form-actions>
</acun:form>
Event When
ui:form-mounted When the form is first registered
ui:dirty, ui:clean When the form changes; when it's clean again
ui:record-loaded, ui:form-reset When a record is loaded; when a new record starts
ui:form-mode-changed When the state changes, e.g. while saving
ui:form-saved, ui:form-save-failed When a save succeeds or fails
ui:form-discard When the changes are discarded
ui:form-close-request You send it to close the modal
ui:form-close-approved When the modal has closed

The events bubble up; their detail holds the form's id and its state. When you close the modal from your own code, go through the same guard:

document.querySelector('[data-form-id="customer-form"]')
    .dispatchEvent(new CustomEvent('ui:form-close-request', { bubbles: true }));

If there are changes, the warning still appears.

Advanced

When the buttons are enabled

The form is always in one state: initial (first opened), viewing (a saved record), editing (the record is being changed), creating (a new record) or saving. The buttons are enabled based on this state and on whether the form has changes:

Button Enabled when
Previous, Next A saved record is clean and there is a previous or next record
Add A saved record is clean
Delete A saved record is clean and the user may delete it
Save The form has changes
Cancel Always

Add is never enabled until the first record has been saved successfully. While saving, every button, including Cancel, is disabled.

How changes are detected

The form compares its current values with its last clean values. If the user changes a value and then changes it back, the form becomes clean again.

  • Supported fields: text, email, number, date, datetime-local, time, hidden, checkbox, radio, textarea, select, multiple select and file.
  • Fields that share a name (such as tags[]) are compared as a list.
  • Components without a real field (acun:combobox, acun:tag-input, acun:rating, acun:input-otp), when bound with wire:model, keep their values in hidden fields named after the property. They dispatch change when the user changes them. Their search and text boxes and OTP inputs carry data-dirty-ignore.
  • For file fields, only the name, size, MIME type and last-modified time are compared.
  • Fields added later and repeater rows are tracked too (MutationObserver).

To exclude a field or a container from tracking, add data-dirty-ignore. The acun:form dirty-check guard also respects this attribute:

<input type="search" data-dirty-ignore>

The clean values are captured again at these moments: first open, a successful save, a reset or discard, and a record load. While Livewire updates the page (morph), the clean values are kept by the form's id.

The clean values are captured after Livewire has placed the new values in the fields. The ui:record-loaded, ui:form-saved and ui:form-reset events arrive right after the update, and the wire:model values a moment later. On first page load, it also waits until Alpine has filled in the fields. On a validation or network error, the clean values don't change and the form stays changed. Each form on the same page is managed separately by its id.

JavaScript API

The API is deliberately small. The manager is a separate chunk that loads only on pages with a form modal or an action bar. Get it in your own code with loadDirtyForms():

import { loadDirtyForms } from '@acunsoft/acun-ui-pro';

const { initDirtyForms, UiDirtyFormManager, deriveActionState } = await loadDirtyForms();
const manager = initDirtyForms();
manager.hasDirtyForms();
manager.requestNavigation('/target');

Learn more

  • A working example: in the demo app, Forms › Form layouts › Form with sticky buttons.
  • List pages: listing records and opening the form from the list.
  • Frequently asked questions: what to check if the warning doesn't appear.
Acun UIDesigned for people.