Inline Edit
Turns a displayed value into an editor in place and back again. The reader stays where they are — no dialog, no separate form page — and nothing is written until they confirm.
Not for a yes/no value. Reach for toggle there: the control is already its own read state, so the confirm/cancel machinery buys nothing and costs two extra tab stops per row. This component earns its weight when the value is text a reader has to type — and only then.
Basic Usage
The previews on this page cannot save, and that is the component working correctly. Try it: type a new title, confirm, and the value snaps back. There is no Livewire component behind a documentation preview, so nothing answers the confirmation — and this component only shows a value as stored once something tells it so. After about ten seconds it stops waiting and reports the save as unconfirmed, keeping your text in the field rather than discarding it.
That is the whole contract in one interaction. In your app the value stays because your handler says so, not because the editor closed.
The component does not save anything. It emits an event when the reader confirms, and your Livewire component decides what that means:
<x-wirekit::inline-edit
name="title"
label="Project title"
:value="$post->title"
:context="$post->reference"
wire:wirekit:inline-edit-confirmed="save($event.detail.value)" />
public function save(string $value): void
{
$this->post->update(['title' => $value]);
// Tell the component it worked. It does NOT infer this from the round trip
// ending — a validation failure ends one too.
$this->dispatch('wirekit:inline-edit-saved', name: 'title');
}
Livewire compiles any non-reserved wire:<event> into an Alpine listener, so no wrapping element is needed.
The editor stays open until the save is confirmed
This is the part worth reading twice. When the reader confirms, the component enters a saving state and waits. It closes when you say the save succeeded, and not before.
That is deliberate: the end of a round trip is not evidence of success. A validation failure completes a request too, and a component that closed there would discard the error message you just rendered, leaving the reader looking at their old value with no explanation.
Three ways the outcome is reported:
| Event | Meaning |
|---|---|
wirekit:inline-edit-saved |
Written. The editor closes and focus returns to the trigger |
wirekit:inline-edit-failed |
Rejected. The editor stays open, focus does not move, so the reader is still looking at what they must fix |
neither, within saveTimeout |
No answer arrived. The editor gives up rather than staying busy forever, and says the state is unknown |
Filter by name in the event detail when a page holds several editors.
Livewire
The component owns the interaction; your component owns the write. It emits wirekit:inline-edit-confirmed with name, value and previous, and waits.
wire:model.blur does not save in Livewire 4. .live became its own modifier, so what used to mean "send on blur" is now spelled wire:model.live.blur. Every older guide — and muscle memory — says otherwise, and the failure is silent: the field looks bound and nothing reaches the server. Do not reach for it here in any case; this component commits explicitly, which is the point of it.
Do not bind the draft two-way. A wire:model on the control writes on every keystroke, which leaves nothing to confirm and nothing for cancel to restore. The draft stays local until the reader commits — that is the whole design, not an optimization.
Validation errors
A validation failure comes back as re-rendered HTML with a filled error bag, not as a transport error. The component reads the bag under the field's name, keeps the editor open, links the message with aria-describedby, and announces it:
public function save(string $value): void
{
$this->validate(['title' => 'required|max:120'], [], ['title' => $value]);
// On failure Livewire re-renders and the bag is filled — nothing else to do.
$this->post->update(['title' => $value]);
$this->dispatch('wirekit:inline-edit-saved', name: 'title');
}
Surviving a morph
A Livewire morph must not close an open editor or lose a draft. Three rules make that hold, and all three are in the component already:
- The mode switch uses
x-show, notx-if. Livewire stops patching attributes while a visibility state differs, sox-showsurvives;x-ifwould tear the open editor down mid-edit. - Nothing server-rendered sits on the element that toggles. An id or an
aria-busythere would stop being updated by the morph. - There is no
wire:ignoreon the root — it would cut the error text and the displayed value off from updates too.
Add wire:key when you render these in a loop, so a morph keeps each editor attached to its own row.
Several editors at once
exclusive (default true) closes any other open editor when one opens. Without it a reader can carry ten unsaved drafts, and a wire:navigate takes all ten with it. If you turn it off, the browser still warns before a navigation that would discard an open draft.
Affordance
Two independent choices: what the reader sees, and what opens the editor.
always costs a tab stop per field. Twenty editable rows are twenty extra stops before anyone reaches the content below them. In a dense table hover or focus-only is usually the kinder choice — but pick it for that reason, not because the pencil looks untidy.
There is deliberately no way to remove the button. Plain text is not focusable, so a value with no button cannot be reached by keyboard at all. focus-only gives the quiet appearance without that defect: nothing is shown until the reader tabs to it, and then it is a real, visible, full-size button.
openOnValueClick (default true) makes the value itself clickable. It is a separate switch because it answers a different question, and the value never becomes the button — a button named Jane Doe tells a screen-reader user nothing about what it does, and giving a control a name that is not its visible text breaks voice control.
Two behaviors follow from that clickable value, and both only show up on a real page:
- Dragging across the value selects text, it does not open the editor. Selecting requires moving the mouse, so a movement beyond a few pixels is read as a selection.
- A link inside the value follows the link. Otherwise the link would be unreachable by mouse.
When nothing answers
The confirmation is an event. If your handler never dispatches
wirekit:inline-edit-saved or wirekit:inline-edit-failed — you forgot the paired
event, the request died, the tab lost its connection — the editor would otherwise stay
busy forever.
It does not. After save-timeout milliseconds (default 10000) the component stops
waiting and reports, in its live region, that the save was not confirmed. It says
exactly that, and deliberately not that it failed: a missing acknowledgment is not
evidence that the write did not happen, and telling a reader it failed invites them to
edit a value that was already stored.
The editor stays open with the text still in it. Nothing the reader typed is thrown away on the strength of a guess.
| Prop | Default | Effect |
|---|---|---|
save-timeout |
10000 |
Milliseconds to wait for an answer. 0 waits indefinitely — only sensible when the answer is guaranteed. |
unknown-message |
a built-in sentence | What the live region says when the bound is reached. |
announce-error="false" silences this report too — the give-up message shares the error
message's live region, so a page that opts out of being told about deviations never
hears about this one either. That is one decision rather than two, and it is easy to make
by accident while thinking only about validation text.
Where focus goes
Focus is decided by how the reader left, not by whether the save worked:
| Leaving by | Focus |
|---|---|
| Confirming with the keyboard | Back to the trigger |
Canceling / Escape |
Back to the trigger |
| Confirming with the mouse | Back to the trigger |
| Clicking away without confirming | Stays where the reader clicked — pulling them back would undo the move they just made |
| A save that failed | Does not move — the error is here, and moving away hides it |
Clicking away also discards the draft
Leaving the control without confirming closes the editor and drops what was typed, restoring the value the field had before. It is the same outcome as Escape, reached a different way.
That is the reason commitOn exists. The default explicit means nothing is written until the reader confirms — so a click into the next field cannot save half a thought, and the price of that guarantee is that the half-thought is not kept either. Set commitOn="blur" when moving away should save instead of discard.
A round trip is not a departure. A wire:poll, a save elsewhere on the page, or any other morph blurs a focused control for an instant while the DOM is patched, and the editor stays open with the draft intact — see Surviving a morph. Only a reader who really moves focus somewhere else loses it.
A draft only ever lives in the browser. Nothing about it reaches the server before the reader confirms, so a reload, a wire:navigate, or a closed tab discards it too. Before a navigation that would throw one away the browser asks first — that prompt is the only warning there is, and it cannot appear for a click inside the same page.
What you can edit
Four built-in types, chosen with control. Each one edits in place the same way — only the control inside the open editor differs.
control |
Editor | Reach for it when | Note |
|---|---|---|---|
text (default) |
Single-line input | Titles, names, labels, short free text | — |
textarea |
Multi-line input | Descriptions, notes, anything wrapping | The editor grows downward, and the buttons stay at the top edge next to where you are typing |
number |
Numeric input | Quantities, prices, counts | Pass min, max and step straight through |
select |
Dropdown | A value from a fixed set | Needs options as key => label, and displayValue for what read mode shows |
Anything else goes through the editor slot — a date picker, a tags input, a combobox — so an uncovered type never has to wait for a release.
select stores a key and shows a label, so read mode needs displayValue — otherwise it prints review where you meant In review. The same applies whenever the stored value is not the readable one: an amount stored as 1299, a date stored as an ISO string.
Bringing your own control
The editor slot covers everything the four types above do not, so an uncovered type never has to wait for a release:
<x-wirekit::inline-edit name="tags" label="Tags" :value="$post->tags">
<x-slot:editor>
<x-wirekit::tags-input x-ref="control" x-model="draft" />
</x-slot:editor>
</x-wirekit::inline-edit>
The slot compiles inside the component's own Alpine scope, so draft, commit() and cancel() resolve with nothing threaded in. In exchange there is a contract, and it is checked rather than assumed — in development a missing ref is reported by name:
- Bind
x-model="draft". That is the value the component commits. - Set
x-ref="control". Focus placement and the accessibility wiring both aim at it; without it they simply do nothing. - Handle
Escapeyourself if your control is composite (a combobox, a multi-select). Two handlers for one key make one keypress do two things — the firstEscapeshould close your list, the second should cancel the edit. - Label it once. Either your control carries the label or this component does. Both means a duplicate announcement.
Do not drop this into a data-grid cell. Inside a role="grid" the arrow keys belong to the grid's own navigation, so a control that claims them fights the table and one keypress does two things. That conflict must be resolved before the component belongs in a grid cell.
Error state
An error re-opens the editor, which is why the preview below is already in edit mode
with nothing clicked. A rejected save arrives as a Livewire morph, and a morph replaces the
open editor with read mode — so without this the reader is dropped back to the value they
tried to change, holding an error about a field they can no longer see. The message is
linked with aria-describedby, so it reaches the control rather than sitting beside it.
The error can come from either side: the error prop, or the validation bag under the
field's name. Livewire reports a failed save() through the bag, so both are read.
Click the value to edit it in place.
A project with that title already exists.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
required |
bool | false |
Marks the field as required. Forwarded to the composed control, which emits the native attribute, and renders the label indicator (*). |
name |
string |
null |
Field name, carried in every event detail so several editors can be told apart |
label |
string|null |
null |
Rendered in both modes, so the field is never unlabeled while reading |
value |
string |
'' |
The current value |
context |
string|null |
null |
Names the record in the trigger's accessible name |
trigger |
string |
'always' |
always · hover · focus-only |
openOnValueClick |
bool |
true |
Clicking the value opens the editor |
exclusive |
bool |
true |
Opening this editor closes any other open one |
hint |
string|null |
null |
Help text, linked via aria-describedby |
error |
string|null |
null |
Error text, linked via aria-describedby. A non-empty value opens the editor — see Error state |
control |
string |
'text' |
text · textarea · number · select |
rows |
string|int |
'auto' |
Textarea height. auto sizes to content, so the editor opens as tall as the text it replaces and grows while typing; a row count pins a fixed height. Ignored by every other control |
saveTimeout |
int |
10000 |
Milliseconds to wait for a saved / failed answer before giving up. 0 waits indefinitely |
unknownMessage |
string|null |
null |
What the live region says when that bound is reached. Defaults to a sentence reporting the save as unconfirmed |
options |
array |
[] |
Key => label pairs for control="select" |
displayValue |
string|null |
null |
What read mode shows when the stored value is not the readable one. Resolves itself for a select |
emptyText |
string|null |
null |
Read-mode text when there is no value |
commitOn |
string |
'explicit' |
explicit · blur. Forced to explicit for a select |
actions |
bool |
true |
Show confirm/cancel. Cannot be disabled for a textarea |
size |
string |
'md' |
sm · md · lg |
loading |
bool |
false |
Hold the saving state from the server |
loadingTarget |
string|null |
null |
Scope the loading state to one action (loadingTarget, not target) |
announceError |
bool|null | null |
Render the error message in an aria-live="polite" region so a validation error that appears after render reaches a screen reader. Precedence: this prop, then the parent <x-wirekit::form :announce-errors>, then config('wirekit.a11y.announce_error'). Set false when the page runs its own live region. |
width |
string |
'full' |
full takes the container width so read and edit boxes match; auto measures the content |
scope |
string|null |
null |
Scoped personalization key |
context matters more than it looks. In a list of twenty rows, twenty triggers named Edit title are twenty identical entries in a screen reader's control list. context="Q3 planning" makes each one say which record it belongs to.
Accessibility
- The label renders in both modes and sits outside the part that toggles, so the field is never unlabeled while reading and the label does not jump into place on open
- The trigger is a real
<button type="button">with a 44×44 touch target - The value is a
role="presentation"container, never a button — so its accessible name is not the value text aria-describedbycomposes the hint and error ids, and is never emitted empty- Confirm and cancel use
aria-disabledwhile saving, neverdisabled— a disabled button leaves the tab order mid-interaction, which loses the reader's place exactly while they wait to find out whether it worked - A second
Enterduring a save does not submit again - The save outcome is announced through a polite live region
Keyboard Interaction
| Key | Action |
|---|---|
Tab |
Move to the trigger, then between confirm and cancel while editing |
Enter |
Confirm |
Cmd/Ctrl + Enter |
Confirm |
Escape |
Cancel, restoring the previous value |
In a textarea Enter writes a line, so it does not commit — Cmd/Ctrl + Enter does. That is also why the confirm and cancel buttons cannot be switched off for that control: without them and without knowing the shortcut, a reader would have no way to save at all, and the remaining exit discards.
Both Enter and Escape stop where they are handled. Without that, Escape inside a modal would close the modal as well as canceling the edit, and Enter inside a <form wire:submit> would submit the whole form instead of committing one field.
Pitfalls
- Do not bind
wire:modelto the draft. A two-way binding writes on every keystroke, which leaves nothing to confirm and nothing for cancel to restore. The draft is local until the reader commits. - Do not infer success from the request finishing. Dispatch the saved event; a validation failure completes a request too.
- Add
wire:keywhen rendering these in a loop, so a morph keeps each editor attached to its own row.
Design Tokens
| Token | Purpose |
|---|---|
--color-wk-bg-input |
Editor background |
--color-wk-border-strong |
Editor border |
--color-wk-text |
Value and label text |
--color-wk-text-muted |
Trigger, hint, and the empty-value placeholder |
--color-wk-danger-text |
Error text |
--color-wk-success-text |
Confirm control |
--radius-wk-md |
Editor corner radius |
--ring-wk-width |
Focus ring |
Usage & Conventions
The component owns the editing interaction and nothing else. Saving, validation, authorization and optimistic updates stay in your Livewire component, which is why the event carries name, value and previous and then gets out of the way.