Inplace Edit
FreeEdit one field where it is displayed — a value that reads as text until you click it, then becomes an input, then goes back. Text, textarea and select modes, with a save lifecycle that keeps the draft when the write fails.
import { SvInplaceEditComponent } from '@styloviz/forms';Demo & usage
Basic usage
Usage example — open the gallery for the full interactive demo.
An interactive demo for this component lives in the gallery.
Open live demoMore examples
Host-owned save, with the failure handed back
One-way `[value]` plus `(save)` is the pessimistic wiring: the host writes the value only once the server agrees. A non-null `error` re-opens the editor with the draft intact, so a 500 costs a retry rather than the typing.
<sv-inplace-edit label="Display name" [value]="name()" [saving]="pending()" [error]="failure()" (save)="persist($event)" />
Status cell in a table
<sv-inplace-edit label="Status" mode="select" [options]="STATUSES" [(value)]="row.status" (save)="patch(row.id, $event)" />
Multi-line note — Enter adds a line, Ctrl/Cmd+Enter commits
<sv-inplace-edit label="Notes" mode="textarea" [rows]="4" [(value)]="notes" />
Two people, one field
Two people editing one field is the normal case in any collaborative app, and the two usual answers are both wrong: last-write-wins destroys someone's work and tells nobody, while refusing the save leaves the user holding text with nowhere to put it — so they paste it over the top anyway, having now lost the other version as well. Setting `conflict` shows both and lets the person who is actually there decide. `(conflictResolved)` reports which won, and the winning value is already written through.
<sv-inplace-edit label="Project name" [(value)]="name" [conflict]="conflict()" (saveRequest)="save($event)" (conflictResolved)="resolve($event)" /> save(e: InplaceEditSave): void { this.api.rename(e.value, e.requestKey).subscribe({ error: (err) => { // A 409 is not a failure to report — it is a question to ask. if (err.status === 409) { this.conflict.set({ theirs: err.error.current, by: err.error.by }); return; } e.rollback(); this.error.set('Could not save that.'); }, }); }
A field the user may not edit
The alternative is worse than a disabled field: letting them type and failing on save costs them the edit as well as the answer. `lockReason` renders the value as text with the reason beside it, linked by `aria-describedby`, and offers no way in. Named to match `sv-toggle-switch`'s input of the same name — the same question deserves the same API.
<sv-inplace-edit label="Workspace ID" value="ws_8f21c4" lockReason="Only an administrator can change this" />
A write, with a key and a way back
`(saveRequest)` is the additive sibling of `(save)`: the same commit, carrying an idempotency key so a retried request cannot write twice, and a `rollback()` that refuses if the user has edited again since. `(save)` still fires for a field that writes only to local state.
<sv-inplace-edit label="Display name" [(value)]="name" [saving]="pending()" [error]="failure()" (saveRequest)="persist($event)" />
When to use
- A read-mostly field the user occasionally corrects — a display name, a note, a status cell.
- You want the page to stay a page rather than becoming a form with a Save button at the bottom.
- For a screenful of fields being filled in together, use a real form; inline editing is for changing one thing.
Async states
- empty
- error
Server writes
- Writes
- saveRequest
Each payload carries a
requestKey— send it with the request so a retry cannot apply the same change twice.- Rollback
- Call
event.rollback()when the save fails — the change is already on screen, and this is what puts it back. - While saving
- Bind
saving— with two writes outstanding there is no single state to roll back to, so the action stays locked until the first one lands. - On failure
- Pass the failure to
errorto show it in place — without taking away what the user was working on.
Inputs
| Name | Type | Default | Description |
|---|---|---|---|
| value | string (two-way) | '' | The field's value. Two-way bindable. `[(value)]` is the optimistic wiring: the component writes the new value through on commit and the host persists in the background. `[value]` one-way plus `(save)` is the pessimistic wiring: the host writes it only once the server has agreed, and pushes the old value back if it has not. |
| label | string | '' | Accessible name of the field, e.g. `Display name`. Not decoration. The read view is a button whose text is the VALUE, so without this a screen reader announces "Ada Lovelace, button" and never says what pressing it edits. |
| mode | InplaceEditMode | 'text' | What the editor is. |
| options | readonly InplaceEditOption[] | [] | Choices for `select` mode. Ignored in the other modes. |
| size | InplaceEditSize | 'md' | Type scale and padding. |
| trigger | InplaceEditTrigger | 'auto' | How the read view offers itself. - `auto` (default) — a pencil button that is **always visible on a touch screen** and appears on hover or focus on a mouse-driven one. - `click` — the value alone, with a hover underline. ⚠️ **On touch this has no affordance at all**: Tailwind compiles `hover:` to `@media (hover: hover)`, so the underline never renders on a phone and nothing indicates the text can be edited. Kept because it is the right density inside a table of many fields, but choose it deliberately. - `button` — the pencil, always visible everywhere. |
| placeholder | string | '' | Placeholder inside the editor. |
| emptyText | string | 'Empty' | Read-view text when the value is empty. It has to be SOMETHING. An empty value renders an empty button, and a zero-width target cannot be clicked or seen — the field silently becomes uneditable exactly when it most needs editing. |
| rows | number | 3 | Rows for `textarea` mode. |
| maxlength | number | 0 | Native `maxlength` on the editor. 0 leaves it off. |
| required | boolean | false | Refuse to commit an empty value, and say why. |
| requiredMessage | string | 'This field cannot be empty' | Message shown when a `required` field is committed empty. |
| disabled | boolean | false | Not editable — the read view renders as plain text. |
| showActions | boolean | true | Show the save / cancel buttons. Never shown in `select` mode. |
| commitOnBlur | boolean | true | Commit when the editor loses focus. `false` makes an outside click discard the edit — safer for a destructive field, and surprising everywhere else, which is why it defaults on. |
| customClass | string | '' | Extra classes on the root element. |
| saving | boolean | false | The host is persisting the value — the editor stays open and goes busy. |
| error | SvDataError | string | null | null | The save failed. Re-opens the editor with the draft intact. Accepts an `SvDataError` or a plain string, so an `HttpErrorResponse` normalized with `toSvDataError()` can be bound without unwrapping it. |
| lockReason | string | '' | Why this field cannot be edited — a permission, a plan, a workflow state. Non-empty renders the value as static text with the reason beside it, and no way in. Distinct from `disabled`, deliberately, and named to match `sv-toggle-switch`'s input of the same name: a disabled field tells the user "not you, not now" and leaves them to guess, and the guess they most often make is that the page is broken. The alternative it replaces is worse — letting them type, then failing on save, which costs them the edit as well as the answer. |
| conflict | InplaceEditConflict | null | null | Someone else changed this field while it was open. Setting it puts the editor into a two-value choice: their text, the user's text, and a button each. Clear it (back to `null`) when the choice has been made — `(conflictResolved)` tells you which one won and has already written it through to `value`. |
| editing | boolean (two-way) | false | Whether the editor is open. Two-way bindable, so a host can open it. |
Outputs
| Event | Payload type | Description |
|---|---|---|
| save | string | A new value was committed. Not emitted when the value did not change. |
| saveRequest | InplaceEditSave | The same commit, carrying what a server write needs: an idempotency key and a guarded way back. Additive, not a replacement — `save` still fires, and a field that writes only to local state has no use for either of these. Both are emitted for one commit, in this order. |
| conflictResolved | InplaceEditConflictChoice | The user chose which side of a conflict to keep. The winning value has already been written through to `value`; persist it, and clear `conflict`. |
| cancel | void | The user backed out — Escape, or the Cancel button. |
| valueChange | string | Emits the new `value` whenever it changes — the output half of `[(value)]`. |
| editingChange | boolean | Emits the new `editing` whenever it changes — the output half of `[(editing)]`. |
Customization & theming
- Pick the editor with `mode` (text · textarea · select), size it with `size` and `rows`, and constrain it with `maxlength` and `required` / `requiredMessage`.
- Choose how the read view offers itself with `trigger`: `auto` (default) shows a pencil on hover or focus and always on a touch screen; `click` is the value alone with a hover underline, right for a dense table but with no affordance at all on a phone; `button` shows the pencil everywhere. Word the blank state with `emptyText`.
- Drive the save lifecycle with `saving` and `error`, and handle `(saveRequest)` for a `{ value, previous, requestKey, rollback }` payload when a server has to agree; two-way bind `[(editing)]` to open the editor from a row action of your own.
- `conflict` turns the editor into a two-value choice when someone else changed the field; `lockReason` renders it as text with an explanation and no way in.
- `commitOnBlur` decides whether an outside click saves or discards.
Best practices
- Do: Pass `label` — the read view is a button whose text is the VALUE, so without it a screen reader announces the value and never says what pressing it edits.
- Do: Hand `error` back on a failed save rather than showing only a toast; the editor re-opens with what the user typed.
- Do: Word `emptyText` for the field ("Add a bio"), since that is the only thing to click when the value is blank.
- Do: Bind `[saving]` for the whole round-trip. It is the lock that makes `rollback()` unambiguous: with two writes outstanding there is no single previous state to return to.
- Do: Answer a 409 with `[conflict]`, not with `[error]`. A conflict is a question for the user, not a failure to report — and the two need different UI because they need different decisions.
- Do: Say why a field is locked with `lockReason` rather than reaching for `disabled`. A disabled field is a dead end, and the guess users most often make about one is that the page is broken.
- Tip: Set `commitOnBlur=false` for a field where an accidental outside click should not save.
- Don't: Don't use `select` mode for a long list — it is a native picker, not a searchable dropdown. Use Select Dropdown in a form for that.
Accessibility
- The read view is a real `<button>`, named `Edit <label>` so the announcement says what pressing it does — not just the value it happens to contain.
- Enter commits and Escape reverts; in `textarea` mode Enter inserts a newline and Ctrl/Cmd+Enter commits, so a multi-line field can hold multiple lines.
- Escape stops propagating, so an inline edit inside a dialog or menu cancels the edit rather than closing what surrounds it.
- Leaving edit mode returns focus to the trigger when the editor still had it — and leaves focus alone when the user clicked another field, so it is never yanked back.
- A refused `required` commit renders a `role="alert"` message that the editor points at via `aria-describedby`, with `aria-invalid` set.
- The default `trigger="auto"` renders its pencil ALWAYS on a coarse pointer. Tailwind compiles `hover:` to `@media (hover: hover)`, so a hover-only affordance does not render faintly on a phone — it does not render at all, and the field is indistinguishable from plain text. The pencil is `aria-hidden`: the value button beside it already carries the name and offers the same action, and two tab stops onto one field is worse than none.
- A conflict is a labelled `role="group"` with a `role="alert"` sentence naming who changed it and when, then both values as text with a button each. Nothing is auto-selected — the whole point is that only the person present can decide.
- A `lockReason` is linked to the value with `aria-describedby`, so the explanation is announced with the field rather than found by accident.
Feedback on Inplace Edit
Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.