Unsaved Hint
A form that holds what its server does not have yet says so. Put x-wk-unsaved on the element
around the fields, a <form> or a card, and <x-wirekit:: beside
the save button. While a field holds a value that is not saved, the field is framed in blue and
the hint shows its sentence. Saving brings both back.
Basic Usage
The preview shows the moment after the reader changed the name. In your application the attributes
that draw it are set for you: x-wk-unsaved compares each field and marks the ones that differ.
What Counts as Unsaved
x-wk-unsaved compares each field inside it with the value the field was last saved with, at
first the one the server rendered. It tracks every field whose wire:model has no modifier that
sends the value by itself. Unlike wire:dirty, which clears as soon as a request has sent the
value, a field stays unsaved until a request saves it.
| The field | Tracked |
|---|---|
wire:model="name" |
Yes |
wire:model.number="quantity", or any modifier that changes how the value is read |
Yes |
wire:model.live, .blur, .change or .lazy |
No: the value reaches the server on its own |
| A file field | No |
Anything inside an element with data-wk-unsaved-off |
No |
A component whose wire:model sits on its own root, such as a combobox or a
multi-select, is tracked the same way. Its value lives in the
component's state rather than in the field, and that state is what is compared.
A request of the component that comes back without a validation error counts every field it sent
as saved. A request that comes back with a validation error saves nothing: after a save the server
refused, the fields stay framed, the hint stays, and .confirm still asks. A value the server
writes into a field itself, when it loads, resets or rewrites one, counts as saved at once.
Any request sends every deferred change with it, not only the save: a button that adds a row, a
field with wire:model.live and a poll send them too, and the fields count as saved from then on,
although nothing stored them. To count only the save itself, use
.until-saved.
Counting Only a Save
x-wk-unsaved. counts a field as saved only after a save, which is one of two things:
- a
wire:submitof the form around the field that comes back without a validation error; wirekit:saved, dispatched by the component in the request that saved.
Every other request leaves the fields unsaved, the button that adds a contact included:
<form wire:submit="save" x-wk-unsaved.until-saved.confirm>
<x-wirekit::input label="Supplier name" wire:model="name" />
<x-wirekit::button wire:click="addContact">Add a contact</x-wirekit::button>
<x-wirekit::row justify="end" gap="sm" align="center">
<x-wirekit::unsaved-hint />
<x-wirekit::button type="submit">Save</x-wirekit::button>
</x-wirekit::row>
</form>
A save that is not a wire:submit, such as a button with wire:click, says so with
wirekit:saved. Give the scope a name, and dispatch the event with that name once the save has
gone through:
<div x-wk-unsaved.until-saved="draft">
<x-wirekit::input label="Title" wire:model="title" />
<x-wirekit::row justify="end" gap="sm" align="center">
<x-wirekit::unsaved-hint />
<x-wirekit::button wire:click="storeDraft">Save draft</x-wirekit::button>
</x-wirekit::row>
</div>
public function storeDraft(): void
{
// 1. A validation error ends the action here, and the field stays unsaved
$this->validate(['title' => 'required|max:120']);
// 2. Store the title on the post this component edits
$this->post->update(['title' => $this->title]);
// 3. Count the fields of the scope named "draft" as saved
$this->dispatch('wirekit:saved', scope: 'draft');
}
What the event counts as saved depends on what it names:
wirekit:saved with |
Counts as saved |
|---|---|
scope: 'draft' |
The fields of the scope named draft |
paths: ['title', 'body'] |
The fields bound to those properties |
| Both | The fields of that scope bound to those properties |
| Neither | Every field |
A dispatch of a component counts that component's fields. The page can send the event as well:
from Alpine, $dispatch('wirekit:saved', { scope: 'draft' }) counts inside the component around
the element, and from a script, an event sent to window counts on the whole page:
window.dispatchEvent(new CustomEvent('wirekit:saved', { detail: { paths: ['title'] } }));
wirekit:saved counts in a scope without .until-saved too, and also in a response with a
validation error, for a save that stored part of a form.
The Field
An unsaved field carries data-wk-unsaved, and the stylesheet gives it the border
--color-wk-border-unsaved, a blue, so it reads as neither an error nor a success. A field
with a prefix, a clear button or pills draws the color on the frame around them. The edge is
drawn once more as an outline inside the border, so it shows under a theme whose border width
is 0px, such as the Material and Minimal presets, and the field does not move when it changes.
An invalid field keeps its error border:
Enter a complete email address.
The scope carries data-wk-has-unsaved while any field in it is unsaved, for a style of your own,
such as a save button that stands out:
[data-wk-has-unsaved] .save-button {
box-shadow: 0 0 0 2px var(--color-wk-border-unsaved);
}
Asking Before the Reader Leaves
x-wk-unsaved. asks before the page is left with an unsaved field. A reload, a closed tab, a
typed address and an ordinary link get the browser's own question, in the browser's words. A
wire:navigate link never unloads the page, so the browser does not ask; there the scope asks the
hint's question, in the reader's language. A step back or forward through the history is not held.
<form wire:submit="save" x-wk-unsaved.confirm>
<x-wirekit::input label="Supplier name" wire:model="name" />
<x-wirekit::row justify="end" gap="sm" align="center">
<x-wirekit::unsaved-hint />
<x-wirekit::button type="submit">Save</x-wirekit::button>
</x-wirekit::row>
</form>
A save that redirects does not ask: the response is weighed before the redirect runs, so its fields count as saved by then. A scope without a hint asks in English unless it names its own question:
<div x-wk-unsaved.confirm data-wk-unsaved-confirm="{{ __('Discard the changes to this supplier?') }}">
Your Own Sentence
text replaces the sentence, and the slot replaces it with markup:
The Directive
x-wk-unsaved takes two modifiers, which combine (x-wk-unsaved.), and an
optional name:
| Directive | On | Does |
|---|---|---|
x-wk-unsaved |
the element around the fields | Marks each unsaved field with data-wk-unsaved, itself with data-wk-has-unsaved, and shows the hints inside it |
x-wk-unsaved.confirm |
the same element | Also asks before the page is left with an unsaved field |
x-wk-unsaved.until-saved |
the same element | Counts a field as saved only after a wire:submit of its form or a wirekit:saved |
x-wk-unsaved="draft" |
the same element | Names the scope, for wirekit:saved with scope: 'draft'. The name is read as written, not evaluated |
Props
| Prop | Type | Default | Description |
|---|---|---|---|
text |
string|null | null |
The sentence shown while a field in the scope is unsaved. Without it the hint reads "Unsaved changes", in the reader's language |
question |
string|null | null |
What x-wk-unsaved.confirm asks before a wire:navigate visit. Without it: "You have unsaved changes. Leave this page anyway?", in the reader's language |
scope |
string|null | null |
Scoped personalization name |
Accessibility
- The hint is a polite live region (
role="status") that is always in the page. When the first field becomes unsaved, its sentence is written again, so a screen reader announces it once; it says nothing when a save clears it. - While nothing is unsaved, the hint takes no room and its sentence is out of the accessibility tree, so a screen reader does not find a stale "Unsaved changes" in the page.
- The frame of an unsaved field is a cue beside the sentence, not the only one: the hint says in words what the color shows (WCAG 1.4.1). The default border is above 3:1 against the field in the light and the dark theme (WCAG 1.4.11).
- The dot before the sentence is decorative (
aria-hidden).
Keyboard Interaction
The hint takes no focus and has nothing to operate: it is a sentence. The fields around it keep their own keys, and nothing about the keyboard changes when a field turns unsaved. With .confirm, the question before a wire:navigate visit is the browser's own dialog, answered with Enter or Escape.
Pitfalls
- The hint belongs inside the scope. A hint outside any
x-wk-unsavedstays hidden. - Without
.until-saved, any request that comes back without a validation error counts as saving. See What Counts as Unsaved: a field withwire:model.livein the same component makes the others count as saved with every request it sends..until-savedwaits for the save. - A save that fails without a validation error looks like one that went through. A conflict or a refused action that only shows a toast leaves no error in the response, so a scope without
.until-saved, or awire:submitunder it, counts the fields as saved. Put the reason into the error bag,$this->add, and they stay unsaved.Error('title', __('Someone else saved this post first.')) - Under
.until-saved, a save withoutwire:submitneedswirekit:saved. Without the event the fields stay unsaved after the save, and.confirmasks before the redirect that follows it. - A browser asks a reload in its own words. The text of that question cannot be set by a page;
questionreaches thewire:navigatecase only.
Design Tokens
| Token | Used for |
|---|---|
--color-wk-border-unsaved |
The border and the inner outline of an unsaved field or of its frame, and the dot before the sentence |
--color-wk-text-muted |
The sentence |
--text-wk-sm |
The sentence's size |
--font-wk-sans |
The sentence's font |