busy-office-ui
Menu

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

ItemQty
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.

ItemQtyActions
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>
↑↓ move Enter open Esc close