Inline editing
Editing values in place, inside a data table: seamless cell inputs, per-row dirty state, Save/Cancel, and the events your app persists with. The component-level how-to — the assembled screen, with its data contract and states, is the editable grid pattern. Not for creating a record — in-place editing assumes the row already exists and that each change is a small correction to it. A new record usually has required fields, validation that spans several of them, and a decision the user can abandon; that belongs in a detail form or a wizard, where Cancel means nothing was created. An empty row waiting to be filled in a grid gives the user no way to say "actually, never mind".
Inline edit — seamless inputs in cells
| Item | Qty |
|---|---|
| Widget A | |
| Widget B |
The cell is always a real input
(.bo-input--seamless): it reads as text until hover/focus, so keyboard
and screen-reader semantics never flip between display and edit modes. Persist with
a form submit or hx-patch on change.
Multi-row inline edit — dirty state + save/cancel
This is the simple starting point of the editable-table path — plain cells, dirty badge, Save/Cancel. The graded continuation (combobox lookup cells, every cell type, realtime totals, save-per-change, a full PO line-items editor) lives on the Editable grid pattern.
| Item | Qty | Actions |
|---|---|---|
| Widget A | ||
| Widget B |
Same seamless input as above, plus
data-row-edit on the table: typing marks the row
data-row-state="dirty" (amber tint, same visual channel the
error row state uses) and reveals that row's badge + Save/Cancel. Cancel
resets the row's inputs to their last-saved value; Save dispatches a
bo:row-save event with the row and its data-row-id
— your code persists it (fetch, hx-patch, form submit), this
behavior only tracks dirty state. Try it: edit a quantity below.
Choosing the editing design
Three editing designs ship, split by what the screen's job is — pick by context, not by taste:
| The screen's job | Design | Why |
|---|---|---|
| Entry — the user came to fill things in | Bordered fields: a form (detail-form) or an editable grid (editable-grid) | The border is the affordance — when entry is the job, every enterable cell should say "you can type here" |
| Reading — corrections are occasional | Seamless cells + row Save/Cancel (this page; detail-form's field-per-row variant) | The value reads as a fact until touched; chrome would shout "form" at a screen that is a record |
| Entry, but it must LOOK like the read view | The editable grid's WYSIWYG variant — every control seamless | Display-identical at rest, affordance on hover/focus; the control never swaps, so keyboard and screen-reader semantics never change |
Per field type (date, number, money, quantity), the field matrix names the exact control for each of these contexts.
Save timing — the second, independent axis
Editing design says what a cell looks like; save timing says when the edit commits. They are chosen separately, and the second one is where screens drift apart. Not a taste question — the rule is what a row IS.
| If a row is… | Use | Because |
|---|---|---|
| one record — a PO line, an item, a timesheet entry | Batch per row — data-row-edit, the default. Save/Cancel appear on the dirty row. | Each row commits alone, so a 422 comes back on the row the user just touched and a conflict on one row is not a screen-wide rollback. |
| one field of one record — a settings list, a vendor master | Form-level save — one Save for the record. Keep data-row-edit for dirty marking only. | Six fields means six requests for one logical edit, and a Cancel that undoes a third of what the user changed. Measured on an early version of detail-form: a Save button 228px from the field it saved. |
| independent, with nothing to cancel as a set | Live — data-row-edit="live". No buttons at all. | Every committed change saves itself. The honest cost: it commits per field, so a half-typed line reaches your validation mid-row, and you owe per-field success/failure feedback. |
The mistake this table exists to prevent is using per-row save on a list of one record's fields — it looks like a grid and is a form. If you cannot name what one row is a record of, you want form-level save.
How a dirty row says so
The framework paints the state; you must not add a fourth statement of it.
A dirty row carries two visual channels — an amber tint and a
3px inset leading edge — and both are deliberate: under
forced-colors the tint disappears and the edge becomes a real
border, so the state survives high-contrast mode.
Colour is never the whole signal. data-row-state is a data attribute and is invisible to assistive
technology, so the programmatic channel is the Save button's accessible
name — it exists only while the row is dirty, so its presence and its
name both carry the meaning. Name it per row
(Save Bearing kit — unsaved changes), never just "Save": a table of
identical "Save" buttons gives a screen-reader user nothing to tell them apart,
and axe cannot catch it because the buttons are named.
A visible "Unsaved" badge is not the
shape to copy — the row already says it twice, and the buttons appearing at all
say it a third time. The [data-row-edit-dirty] hook still works if
your screen genuinely needs a visible word.
Dirty is one of four marks a row can carry, and they do
not stack: which one is right for error, warning, a toned cell or nothing
at all — plus why a row shows at most one leading edge — is set out in
the data table's marker
guideline. Worth reading before you set
data-row-state anywhere by hand.
Markup — multi-row inline edit
<table class="bo-data-table" data-row-edit>
<thead><tr><th scope="col">Item</th><th scope="col" class="bo-data-table__col--numeric">Qty</th><th scope="col"><span class="bo-visually-hidden">Actions</span></th></tr></thead>
<tbody>
<tr data-row-id="WIDGET-A">
<td>Widget A</td>
<td class="bo-data-table__col--numeric">
<input class="bo-input bo-input--seamless bo-input--numeric" value="12" aria-label="Quantity for Widget A" />
</td>
<td class="bo-data-table__row-edit-actions">
<button class="bo-btn bo-btn--sm bo-btn--icon bo-btn--secondary" type="button" data-row-edit-save aria-label="Save Widget A — unsaved changes" hidden><span class="bo-icon bo-icon--save" aria-hidden="true"></span></button>
<button class="bo-btn bo-btn--sm bo-btn--icon bo-btn--ghost" type="button" data-row-edit-cancel aria-label="Discard changes to Widget A" hidden><span class="bo-icon bo-icon--close" aria-hidden="true"></span></button>
</td>
</tr>
</tbody>
</table>
<script type="module">
import { initRowEdit } from "@busy-office/ui/js";
initRowEdit();
document.addEventListener("bo:row-save", (e) => {
// e.detail = { row, rowId } — versioned payload, see JS behaviors → Intent events
});
</script>