Editable Cell
Inline-editable value whose edit mode looks like view — optimistic saves, a delayed spinner, rollback with a Retry toast, and table-cell and heading variants.
- Status
- Since
0.4.0- Accessibility pattern
- button-to-editor disclosure
Last updated
Click, or Tab then Enter or F2. Only the caret shows it is editing. Enter or blur saves; Escape cancels.
Install
Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Editable Cell before installation. Retain the digest printed by this command.
pnpm exec vegastack-design verify editable-cellInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/editable-cellRun the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.
The same command installs the registry items it composes: @vegastack/auto-save-input, @vegastack/button, @vegastack/date-picker, @vegastack/select, @vegastack/spinner, @vegastack/toast, @vegastack/use-announcer, @vegastack/use-inline-edit, @vegastack/use-overflow, @vegastack/date-time.
Usage
import { EditableCell } from "@/components/ui/editable-cell";
<EditableCell
value={deal.name}
label="Deal name"
onSave={(name) => api.updateDeal({ name })}
/>;Edit looks like view. There is no border, ring or background change when the cell edits: the field inherits the font, size, weight, line height, tracking and colour of its surroundings, and only the caret and the selection show it is being edited. The display and the field share one box — a grid cell holding the value text with the field laid over it — so the field grows with its content, wraps when the value wraps, and entering or leaving edit changes neither the width nor the line box.
Saving. Blur or a click outside saves; Enter saves a single-line value and
⌘/Ctrl+Enter a multiline one; Escape
cancels and restores. Nothing is saved when the value is unchanged. When
onSave returns a promise the display updates at once; a subtle spinner trails
the value only if the save takes longer than 300ms; on failure the value rolls
back, the failure is announced and a toast offers Retry (the app mounts
Toaster). onCommit is the older name of onSave and still works.
Affordance. No fill at rest, on hover or while editing — the text cursor and an optional
tooltip are the only pointer affordances. Keyboard focus on the resting value shows the system's
focus tint; Tab reaches the cell and Enter or
F2 starts editing.
In a property list. Inside a PropertyValue the inline cell's box turns compact — 6px and 2px
of padding hung outside by equal negative margins — so its text lines up exactly (left edge,
baseline, row height) with the read-only values beside it, in every state; the box keeps its hit
area but paints no fill. See Property List.
Scope
| Behaviour | Where it lives |
|---|---|
| Persistence, conflict check | Host — onSave fires once per changed value; a rejected promise rolls back and offers Retry |
| Debounced auto-save while typing | AutoSaveInput |
| The grid's roving focus model | The grid host — focusMode="managed" removes this tab stop; onNavigate reports Enter and Tab |
| Date / actor / currency editors | Apps, via the open custom editor contract |
Anatomy
Examples
Table cell
variant="cell" fills the table cell, so the field is as wide as the column and the column never
resizes. Only the text starts an edit: a click beside it goes to the row (a row link keeps
working). With onNavigate, Enter and Tab save and hand the host a
direction to move to the next cell (Shift goes back).
| Deal | Owner | Amount |
|---|---|---|
| Acme renewal | Asha Rao | $12,400 |
| Globex pilot | Kiran Mehta | $4,800 |
| Initech expansion | Asha Rao | $31,000 |
Page title
variant="heading" keeps the heading's size and weight and wraps naturally, in view and in edit.
It has no box padding, so the title is edited exactly where it is rendered — idle and editing sit at
the same position, lined up with the lines below, and only the text cursor marks it editable. Put the
cell inside the heading element rather than restyling its parts.
Give a stored name its limit with editor={{ type: "text", maxLength }}: the field stops there, a
paste keeps what fits and says so, and a quiet n/max count shows under the field from 90% of the
limit — so what is typed or pasted never runs past what the store accepts. (A value that is already
longer is shown and kept as it is; maxLength only limits what is added.)
<h1 className="text-2xl font-semibold">
<EditableCell
variant="heading"
required
label="Meeting title"
value={title}
onSave={rename}
/>
</h1>Weekly product sync with the platform, design and customer success teams
Tuesday, 10:00 · 45 min
Single-line
The default. A long value truncates with an ellipsis and offers the full value as its title;
opening the editor shows all of it. wrap breaks it onto more lines instead.
Click, or Tab then Enter or F2. Only the caret shows it is editing. Enter or blur saves; Escape cancels.
Multiline
multiline edits several lines: Enter adds a line, ⌘/Ctrl+Enter
or blur saves.
Required
With required, clearing the value saves nothing: the previous value comes back and
requiredMessage (default {label} can't be empty) is announced and toasted.
Clear it and press Enter: the old value comes back.
Save error
A rejected onSave rolls the value back, announces it and raises an error toast whose Retry runs
the same save again. saveErrorToast={false} leaves the toast to the host.
Every save fails: the value rolls back, the failure is announced and a toast offers Retry.
Slow save
The value updates optimistically; the trailing spinner appears only after 300ms, so fast saves never flash it.
Parity
The same value in view and in edit mode, over a 20px baseline grid: the text sits on the same line, at the same x, with the same width.
Whole-cell triggers
In a list, the WHOLE cell is the click target — a priority's flag and its text, a status, an
assignee, a date — never just the icon. The date and select editors already fill a cell
variant cell; an app-owned editor (a status, priority or assignee menu) puts
InlineEditTrigger behind its DropdownMenuTrigger or PopoverTrigger. Both carry no border at
rest and the properties-panel tint on hover, keyboard focus and while open, and both open with
Enter or Space. Mark the column interactive, and render
a plain value where the viewer may not edit.
| Task | Priority | Due |
|---|---|---|
| Send the lighting schedule |
Select editor
Real inline editors are per-type: a stage field edits with a popover list, not a text box. The
select editor renders a Select whose popover is the editor; the save
fires on selection.
Number editor
editor={{ type: "number", unit, min, max, step }} edits a spec value. The value is still a
string: it displays formatted in the reader's locale with the unit muted after it (1,250 mm),
and edits as the raw number with a decimal keyboard and the unit trailing the caret inside the
same box. A draft that is not a number, falls outside min/max or is off step does not save:
the field stays open with the draft, marked aria-invalid, and the rule is announced. Grouping
(1,250) is accepted and saved without it; an empty draft saves "".
Below, each editor type inside a PropertyList variant="inline" — text, number with a unit, and
select — lines up with the read-only value above it at rest, on hover and while editing.
- SKU
- Name
- Width
- Weight
- Finish
Custom editor with a label
When the value is an id — an owner, a customer, a project — pass renderValue so the cell shows the
name a person reads instead of the stored id. It is used while displaying, for every editor type;
the editor still works on the raw value, and an empty value keeps the placeholder.
States
Managed focus (grid hosts)
Inside a grid the grid's roving focus model owns reachability. focusMode="managed" removes the
cell's own tab stop, and the host opens the editor from its keyboard model through the controlled
editing / onEditingChange pair:
<EditableCell
variant="cell"
value={cell.value}
label={column.header}
focusMode="managed"
editing={isEditingThisCell}
onEditingChange={(editing) => grid.setEditingCell(editing ? cellId : null)}
onNavigate={(direction) => grid.moveEditor(direction)}
status={grid.cellStatus(rowId, column.key)}
onSave={(next) => grid.commitCell(rowId, column.key, next)}
/>API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
value* | string | — | The persisted value. The cell displays it, edits a draft of it, and reverts to it on a failed commit. |
className | string | — | Extra classes merged onto the cell root. |
disabled | boolean | false | Blocks editing; the display is dimmed and out of the tab order. |
editing | boolean | — | Controlled edit mode, forwarded to the underlying editor. Required in
practice for managed hosts (the grid opens the editor on Enter/F2); omit
for the built-in click / <kbd>Enter</kbd> / <kbd>Space</kbd> activation. |
editor | EditableCellEditor | { type: "text" } | The editor to open. See EditableCellEditor. |
editSeed | string | — | The draft the text and number editors start from when a host opens them through editing,
instead of value — type-to-edit, where the printable key that opened the editor replaces the
value. A custom editor receives it as seed. Ignored by the date and select editors and
by a click or Enter open. |
emptyText | string | the editor's `placeholder`, else "Edit value" | What an EMPTY value shows at rest (text, number and custom editors). "" shows nothing — a
grid's empty cells stay blank and the editor's placeholder appears only once the cell edits. |
flush | boolean | false | Pull the display box back by its own inline-start padding, so the value's text lines up with the lines above and below it (a page title over its description). The box (and its hit area) extends into the gutter instead; the text does not move when the editor opens. |
focusMode | "managed" | "standalone" | "standalone" | Focus policy. standalone (a card, a property list) gives the cell its own
tab stop. managed removes it — the host's roving focus model owns
reachability and opens the editor through editing/onEditingChange. |
label | string | — | Accessible name for the value being edited (e.g. "Deal amount"). Falls
back to the placeholder, then to a generic phrase; the editor is never unnamed. |
multiline | boolean | false | Edit several lines. The field wraps, <kbd>Enter</kbd> inserts a newline and <kbd>⌘</kbd>/<kbd>Ctrl</kbd>+<kbd>Enter</kbd> saves. |
onCommit | ((next: string) => void | Promise<void>) | — | The original name of onSave, kept so existing hosts (the DataGrid) keep working. onSave
wins when both are passed. |
onEditingChange | ((editing: boolean) => void) | — | Called when the cell wants to enter (true) or leave (false) edit mode. |
onNavigate | ((direction: "next" | "previous", via: "tab" | "enter") => void) | — | Table navigation. When set, <kbd>Tab</kbd> / <kbd>Shift</kbd>+<kbd>Tab</kbd> and (single-line)
<kbd>Enter</kbd> / <kbd>Shift</kbd>+<kbd>Enter</kbd> save and then call it, so the host can
move to the next or previous cell. via says which key did it, so a grid moves down on
<kbd>Enter</kbd> and across on <kbd>Tab</kbd>. A custom editor reaches it through
commit(next, move). |
onSave | ((next: string) => void | Promise<void>) | — | Save callback. Return a promise to engage the async layer: the display updates
optimistically, a subtle trailing spinner appears if the save takes longer than 300ms, and
on reject the value **rolls back to value**, the failure is announced, and an error toast
offers Retry (see saveErrorToast). Return void for synchronous hosts. Fires only when the
value actually changed. |
previousStatus | AutoSaveStatus | the first `status` | The status before this cell mounted, for a host that mounts it in the middle of a change (a
grid's resting cell becoming the full one when its status moves): a move from it to
status is announced as a live change would be. |
readOnly | boolean | false | Renders the value as plain non-interactive text with no edit affordance. |
ref | React.Ref<HTMLSpanElement> | — | Ref forwarded to the cell's root <span> (data-slot="editable-cell"). |
renderValue | ((value: string) => React.ReactNode) | — | What display mode shows for a non-empty value — for a value that is an id ("u_7"), the
label a person reads ("Asha Rao"), or any rich node (an avatar and a name). Used by every
editor type while displaying; editing still works on the raw value, and an empty value
keeps the placeholder. |
required | boolean | false | An empty value is not saved: the previous value is restored and requiredMessage is
announced and toasted. |
requiredMessage | string | "{label} can't be empty" | The message shown when a required value is cleared. |
saveErrorToast | boolean | true | Raise an error toast with a Retry action when onSave rejects. false leaves the failure to
the announcement and the rollback (a grid that reports errors itself). |
status | AutoSaveStatus | — | Controlled status override. Omit it to let the cell derive status from the
onCommit promise; pass it when the host owns the write lifecycle (a
grid's cellStatus). Uses AutoSaveStatus — the system's one vocabulary
for async field writes. |
tooltip | string | — | A hint shown on hover while displaying (the native tooltip), e.g. "Click to rename". A truncated value shows the full value instead. |
variant | "cell" | "heading" | "inline" | "inline" | Layout of the text editor. inline sizes the box to its value; cell fills the table cell
(the row link still takes clicks outside the text); heading keeps the surrounding size and
weight, wraps naturally and has no box padding — a page or record title edited in place, idle
and editing at the identical position. |
wrap | boolean | false | Let a long value wrap onto more lines instead of ending in an ellipsis — a page title that must be read whole. The display grows taller; the editor is unchanged. |
Data attributes and CSS variables on EditableCell
| Attribute | Values |
|---|---|
data-flush | "" |
data-focus-mode | mirrors a prop or state value |
data-slot | "editable-cell" | "editable-cell-status" |
data-status | mirrors a prop or state value |
data-variant | mirrors a prop or state value |
data-wrap | "" |
InlineEditTrigger
| Prop | Type | Default | Description |
|---|---|---|---|
layout | "cell" | "chip" | "cell" | cell fills its container — a list cell, where the WHOLE cell (icon and text) opens the
editor, its padding hung outside so the value's text starts where a plain value does. chip
sizes to its value — a board card field. |
loading | boolean | — | Shows a spinner over the label, blocks activation and sets aria-busy. The label keeps its
box at opacity: 0, so the button's width does not move and its accessible name survives
(API-5, A11Y-12). |
Data attributes and CSS variables on InlineEditTrigger
| Attribute | Values |
|---|---|
data-layout | mirrors a prop or state value |
data-slot | "inline-edit-trigger" |
EditableCellRest
What DataGrid renders for a resting cell whose column has a custom editor: the display's
button and value, accessible name and description, and the same box, with no state, effects or
live region. A click on the value (not on a link chip inside it) or Enter, F2
or Space on it calls onStartEditing; the grid then mounts EditableCell for that
cell. A clipped value gets its full text as the title when the pointer or focus reaches it.
| Prop | Type | Default | Description |
|---|---|---|---|
onStartEditing* | () => void | — | The reader asked to edit: a click on the value, or Enter, F2 or Space on the display. |
value* | string | — | The persisted value, as text. |
className | string | — | Extra classes for the root. |
emptyText | string | "Edit value" | What an empty value shows ("": nothing), as EditableCell's emptyText. |
label | string | "value" | The field's name, for the display's accessible name ("Edit Stage"). |
ref | React.Ref<HTMLSpanElement> | — | The display element (a callback ref may return a cleanup). |
renderValue | ((value: string) => React.ReactNode) | — | Renders a non-empty value (tags, chips, avatars) instead of its text. |
Data attributes and CSS variables on EditableCellRest
| Attribute | Values |
|---|---|
data-rest | "" |
data-slot | "editable-cell-display" | "editable-cell-text" |
data-variant | "inline" |
Editor contract
The custom editor receives EditableCellEditorProps: the value, the seed (the printable
key that opened it, through editSeed), commit(next, move?) and cancel. A move — down,
up, right or left — is handed to onNavigate after the commit, so an editor follows the
spreadsheet keys (Enter down, Shift+Enter up, Tab right,
Shift+Tab left). An editor that saves a typed value itself
leaves the cell with commit(value, move): an unchanged commit saves nothing.
| Prop | Type | Default | Description |
|---|---|---|---|
cancel* | () => void | — | Leave edit mode without committing. |
commit* | (next: string, move?: EditableCellMove) => void | — | Commit next and leave edit mode, then — with move — hand the move to the host's
onNavigate, so a grid steps to the next cell. No-ops the async layer when unchanged, so an
editor that saves a typed value itself leaves (and moves) with commit(value, move). |
value* | string | — | The value being edited (the optimistic value while a commit is in flight). |
seed | string | — | The printable character that opened the editor (type-to-edit, the cell's editSeed), or
undefined for a click, <kbd>Enter</kbd> or <kbd>F2</kbd> open. A text-like editor starts its
draft from it, replacing the value; a picker starts its search from it. |
Accessibility
- Read mode is a
role="button"labelledEdit {label}, described by the value; edit mode is a text field labelledlabel(thenplaceholder, then a generic name). - Saves and errors are announced through a visually hidden polite live region — "Saved",
Couldn't save {label} — value restored, and therequiredmessage. The spinner is decorative. - Escape cancels, restores the prior value and returns focus to the display.
- In
focusMode="managed"the cell has no tab stop of its own — the host grid owns reachability. InsideDataGridits announcements are spoken through the grid's one live region rather than a region per cell.
| Key | Action |
|---|---|
| Enter / F2 / Space | Start editing from the focused display. |
| Enter | Save a single-line value (and move on, with onNavigate). |
| ⌘/Ctrl+Enter | Save a multiline value. |
| Tab | With onNavigate: save and move to the next cell. |
| Shift+Tab | With onNavigate: save and move to the previous cell. |
| Escape | Cancel and restore the prior value. |
| Contract | States tested |
|---|---|
| Behaviour | display, editing, saving, saved, error, disabled, read-only, managed-focus |
| Accessibility | keyboard, labeled, status-announcement, semantic-html |
| Visual | default, hover, saving, saved, error, disabled |