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 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.
Keyboard
| Key | Action |
|---|---|
Enter |
Confirm |
Cmd/Ctrl + Enter |
Confirm — works in every control type |
Escape |
Cancel and restore the previous value |
Tab |
Reach the trigger in every affordance style |
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.
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 |
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.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
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 unlabelled 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 |
control |
string |
'text' |
text · textarea · number · select |
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) |
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 unlabelled 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 |
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.