Pattern: editable grid
Who uses it: whoever enters or corrects line items — a buyer building a PO, a warehouse clerk keying a pick list. What "done" looks like: every row either saved or still marked unsaved, never silently dropped. Line-item entry — a PO, an invoice, a pick list — composed entirely from existing pieces, presented as a graded path. Simple: plain text/number cells with dirty-state + Save/Cancel — that's the data-table page's multi-row inline edit, start there. Medium (first demo below): a combobox lookup cell, cell-level validation, add/remove lines. Money & unit cells: currency and unit as real separate fields inside a row. Advanced: every cell type, realtime totals, and the save-per-change mode. The full picture: a composite PO line-items editor putting all of it in one table. Not for a table that's read far more often than edited — a plain data table costs nothing and doesn't ship Save/Cancel machinery nobody triggers. Zero new CSS at every step — the grid is the same primitives, composed harder.
Medium — combobox lookup cells, validation, add/remove lines
| Item | Qty | Unit price | Actions |
|---|---|---|---|
| |
- Steel bracket, 40mm
- Steel bracket, 60mm
Where "Add a line" goes, and why it is
not in the toolbar. A row-adding action sits below the
table, in the page's own stack — because the row it creates is appended at
the end, and a control at the top produces a result the reader cannot see.
.bo-data-table__toolbar is for acting on rows that already
exist (bulk actions, filter, the selection count); this creates one that
does not. That is the whole rule, and it needs no new part: a
cluster under the table, and the
section's own gap does the spacing. On a document entry screen this is the
most-pressed control on the page, so it is worth being consistent about.
Try it: the Qty cell above starts invalid (exceeds
on-hand). The row says so without being touched — the tint and the
3px leading edge — and clicking into the Qty cell reveals why.
The message is a normal .bo-form-field__message at cell
granularity, but out of flow and shown on focus, because a message that sits
in the row’s flow grows the row and shifts every other cell in it
(measured: 53px → 75px, and the two untouched inputs moved 11px).
Nothing grows or shrinks when the cell gains or loses focus either, so
pressing Add straight from the invalid cell adds the line instead of
missing a button that moved.
Pick an option in the Item combobox: it marks the row
dirty exactly like typing does (2026-08-15 fix — committing an option now
fires a real input event, not just bo:combobox-select,
so it composes with any generic field listener). Add a line: the new row's
combobox and inline edit work immediately, no re-init call.
Removing a row: where focus goes, and what the button is called
Both of these are yours, not the framework's — add and remove are app logic here, so the recipe above has to get them right rather than assume a behavior will. Two rules:
- Focus goes to the next surviving row's Remove button; if the removed row was last, the previous row's; if it was the only row, the grid's own Add button — which is all that is left once the table is empty, and keeps the keyboard user on the one control that can undo the emptiness. The destinations are read before the row is detached, because afterwards there is no sibling to ask. Focus only moves when the removed row actually contained it: deleting some other row must not pull the reader out of the cell they were typing in.
- Every Remove button names its own row. One generic
“Remove line” repeated down the column tells a screen-reader
user nothing about which line they are deleting, and after an add or a
removal the positions shift — so the label is derived from the row's
stable identity (
Remove LINE-2), never typed once into the markup and left there. Deliberately not the item text: that cell is editable, so a name copied from it is wrong the moment someone retypes it, and keeping the two in step would mean listening to every edit to repair a label nobody touched. The same rule names the row's icon-only Save and Cancel, which arehiddenuntil the row is dirty and would otherwise appear with no accessible name at all.
When the pressed button no longer
exists — which is every successful removal — the browser drops
focus to <body> unless something moves it. That is why
the destination is computed first and applied after
.remove(); there is no framework behavior doing it for you
here.
The status sentence is yours too. Moving focus is not an announcement. If removing a line should be spoken (“LINE-2 removed, 2 lines remaining”), write it into your own live region from the same handler — along with whatever persistence the removal implies, which is application data and stays yours. What is verified on this page is where focus lands, the order of those steps, and that each label matches its row, in a real browser with real key presses; whether a screen reader speaks the change is a separate question these checks do not answer.
Money & unit cells — currency/unit as real separate fields
An editable line often needs the currency or unit editable next
to the number, not baked into a label. Both compose into a
data-row-edit row with zero special wiring — and their
precision behaviors keep working: switch this row's currency to JPY and
the price reformats to 0 decimals, marks the row dirty, and
Cancel restores the currency, the value, and the
precision together.
| Item | Qty | Unit price | Actions |
|---|---|---|---|
| Hydraulic pump, 20 bar | | |
Under the hood: the money/unit behaviors
dispatch a real input event when they reformat, so
initRowEdit()'s dirty tracking sees precision changes the
same as typing; Cancel re-fires change on a reset select so
the precision re-derives from the restored currency/unit instead of
sticking at the abandoned one.
Advanced — every cell type + realtime totals
All the common ERP column types in one editable table: text, a
native <select>, date, checkbox, and a tag-input
cell — plus a footer total that recomputes in realtime
(initTableSum(), declarative data-sum-of).
Edit the Qty fields and watch the total; toggle, retag, then Cancel —
native fields restore themselves, and the bo:row-cancel
hook restores the chips.
| Line | Qty | Status | Needed by | Rush | Cost centers | Actions |
|---|---|---|---|---|---|---|
| CC-4021 | ||||||
| CC-1180 | ||||||
| Total qty | 7.50 | |||||
Subtotals come in both modes:
declarative auto-sum (data-sum-of="qty", shown above — a
deliberate, documented exception to "you do the data") or listen to
bo:cell-change (rowId/field/value
on every committed edit) and do custom math — qty × price line totals,
weighted sums, whatever the auto mode can't express. Tag chips are
consumer-rendered, so Cancel can't restore them natively —
bo:row-cancel fires after the native fields reset,
exactly for that. Announce totals on COMMITTED change, not per
keystroke: the visible cells update in realtime for sighted
users, while a visually-hidden aria-live="polite" status
(see this page's script) speaks one summary per committed edit —
change fires on blur for text, immediately for
select/checkbox/stepper — instead of a queue of announcements on every
keystroke. The summary includes the edited line's total, giving line
totals the same announcement parity as the grand total.
WYSIWYG — display-identical until you touch it
Every control seamless: at rest the table is pixel-comparable to a
plain read-only data table — no borders, no chevrons, no field
chrome. Hover a cell and its affordance appears; focus it and the
field wakes fully. .bo-input--seamless has done this for
text/number/date since the first editable grid; this adds the same
setting to the remaining cell types —
.bo-select--seamless and
.bo-tag-input--seamless (a checkbox already reads
identically in both modes; a combobox cell is
.bo-input--seamless on its input). Deliberately
not a display↔editor swap engine: the control never
changes, so the keyboard path and screen-reader semantics never
change either — that's the whole trick. Scope: the
compound cells stay chromed on purpose — Money/Quantity's
currency/unit selects share an edge with their input, and stepper
buttons are actions, not values; a compound control without its
boundaries stops reading as one control. WYSIWYG covers the
single-control cell types shown below.
| Line | Qty | Status | Cost centers | Actions |
|---|---|---|---|---|
| CC-2205 | ||||
| CC-1180 |
Keyboard path: there is no
"Enter to edit" — every cell is a real control, so Tab (or a click)
focuses it and it's immediately editable; commit is the native
change (blur for text, immediate for select/checkbox),
Save/Cancel are the next Tab stops in the row, and inside a composite
widget Escape returns focus to the cell when
data-grid-nav is in play. Everything composes unchanged:
bo:cell-change, dirty tracking, bo:row-save,
live mode — this is a skin on the same machinery, not a mode.
Two channels: the hover/focus reveal is the visible
affordance; the always-real controls are the programmatic one (a
screen reader announces "edit text", "combo box" at rest exactly as in
the chromed version).
Save-per-change — data-row-edit="live"
The second save model: no Save/Cancel buttons, every committed
change (blur for text, immediate for select/checkbox/tag) dispatches
bo:row-save at once and re-baselines — wire the listener
to a fetch/hx-patch and edits persist as
they happen. Same markup minus the actions cell. Trade-off to
know: this commits per field (a half-typed new line
hits your server validation mid-row); many real ERPs commit per
row-exit instead — if that's your model, keep batch mode and trigger
Save from your own row-blur logic. Optimistic-update/rollback on a
failed PATCH is your code's job either way; this demo only logs.
| Line | Status | Rush |
|---|---|---|
No saves yet — change the status or toggle Rush.
The full picture — a PO line-items editor
Everything above in one table, the generic shape of real ERP line
entry: product dropdown, quantity with a unit select, price with a
currency select, computed line totals, tag cost centers — dirty
tracking, Cancel, and both subtotal modes all working together. Line
totals are custom math (qty × price via
bo:cell-change, formatted with the exported
currencyDecimals()); the Qty column total is
auto-sum (data-sum-of). Grand total shows
"mixed" if row currencies differ — a totals decision the framework
correctly leaves to you.
Two real-ERP notes before
copying this shape: currency normally lives on the document
(vendor/PO header), not per line — per-line selects here exist to demo
the behavior; multi-currency documents need an FX rate to a document
currency, and "mixed" should be a validation error in most systems.
And unit prices often carry more precision than the
currency's amount decimals (JPY prices quoted at 2-4 dp are normal) —
put data-decimals on the price cell's option or container
where your price master disagrees with ISO 4217.
| Product | Qty | Unit price | Line total | Cost centers | Actions |
|---|---|---|---|---|---|
| | | 49.60 | CC-4021 | |
| | | 1720.00 | CC-1180 | |
| Total qty | 6 | Grand total | 1769.60 | ||
Try it: step a quantity or switch a currency — the line total re-derives per that row's currency decimals, the grand total follows, and Cancel walks everything back including the computed cells (restores announce themselves, so the same listener recomputes).
What composes, and what doesn't (out of the box)
- Combobox in a cell — no special markup; it's the same
.bo-comboboxas anywhere else.initCombobox()andinitRowEdit()are independent document-level delegations — call both once, in any order. - Cell-level error — wrap the cell's input + message in
.bo-form-field(a plain div; nothing form-specific about it) and it gets the exact same:has([aria-invalid])reveal as a standalone field.margin-block-end: 0cancels the field wrapper's normal stacking gap, which fights a dense cell. data-grid-nav— deliberately NOT used above. It pulls every cell's input out of the Tab sequence (roving tabindex, one cell tabbable at a time; Enter re-enters the widget) — the right model for a mostly-read, occasionally-edited grid, but it fights rapid sequential entry, which wants every input directly tabbable. Combining them is possible (an input is stillFOCUSABLEbydata-grid-nav's definition, so Enter-to-focus works) but changes the interaction model app-wide — pick one per table, don't mix without a reason.- Add/remove line is plain app code, not a behavior —
shown here as a
<template>clone; a real app might fetch the row markup from the server instead (HTMXhx-get+hx-swap="beforeend"). Either way, delegation means the new row works with zero re-init.
Markup
<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" class="bo-data-table__col--numeric">Unit price</th>
<th scope="col"><span class="bo-visually-hidden">Actions</span></th>
</tr></thead>
<tbody>
<tr data-row-id="LINE-1" data-row-state="error">
<td>
<!-- Combobox in a cell: no special wiring, just the normal markup -->
<div class="bo-combobox">
<input class="bo-input" role="combobox" id="line-1-item"
aria-expanded="false" aria-controls="line-1-list" aria-autocomplete="list"
autocomplete="off" aria-label="Item" value="Steel bracket, 40mm" />
<ul class="bo-combobox__listbox" id="line-1-list" role="listbox" popover>
<li class="bo-combobox__option" role="option" data-value="SKU-4021">Steel bracket, 40mm</li>
<li class="bo-combobox__option" role="option" data-value="SKU-4022">Steel bracket, 60mm</li>
</ul>
</div>
</td>
<td class="bo-data-table__col--numeric">
<!-- Cell-level error: .bo-form-field wraps the input + message pair
(the SAME contract as a standalone form field — no new CSS).
The input's own id matters: a validation summary builds each
error link from the input's id, so an id-less input can't be
linked to. -->
<div class="bo-form-field" style="margin-block-end: 0">
<input class="bo-input bo-input--numeric" type="number" id="line-1-qty"
value="450" aria-invalid="true" aria-describedby="line-1-qty-err"
aria-label="Quantity" />
<span class="bo-form-field__message" id="line-1-qty-err">Exceeds on-hand (200)</span>
</div>
</td>
<td class="bo-data-table__col--numeric">
<input class="bo-input bo-input--numeric" type="number"
step="0.01" value="3.40" aria-label="Unit price" />
</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 LINE-1 — 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 LINE-1" hidden><span class="bo-icon bo-icon--close" aria-hidden="true"></span></button>
<button class="bo-btn bo-btn--sm bo-btn--danger-ghost" type="button" data-line-remove aria-label="Remove LINE-1">✕</button>
</td>
</tr>
</tbody>
</table>
<button class="bo-btn bo-btn--secondary" type="button" data-line-add>+ Add line</button>
<!-- The row source the Add button clones. It ships WITH this recipe: the
script below reads it by id, so a copy without it adds nothing. -->
<template id="line-template">
<tr data-row-id="">
<td>
<div class="bo-combobox">
<input class="bo-input" role="combobox" aria-expanded="false"
aria-controls="" aria-autocomplete="list" autocomplete="off" aria-label="Item" />
<ul class="bo-combobox__listbox" role="listbox" popover>
<li class="bo-combobox__option" role="option" data-value="SKU-4021">Steel bracket, 40mm</li>
<li class="bo-combobox__option" role="option" data-value="SKU-4022">Steel bracket, 60mm</li>
</ul>
</div>
</td>
<td class="bo-data-table__col--numeric">
<input class="bo-input bo-input--numeric" type="number" aria-label="Quantity" />
</td>
<td class="bo-data-table__col--numeric">
<input class="bo-input bo-input--numeric" type="number" step="0.01" aria-label="Unit price" />
</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 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 hidden><span class="bo-icon bo-icon--close" aria-hidden="true"></span></button>
<button class="bo-btn bo-btn--sm bo-btn--danger-ghost" type="button" data-line-remove>✕</button>
</td>
</tr>
</template>
<script type="module">
import { initRowEdit, initCombobox } from "@busy-office/ui/js";
initRowEdit(); initCombobox(); // once — delegation covers rows added later
// Add/remove a line is plain app logic, not a framework behavior — the
// framework has no opinion on WHERE new rows come from (a <template>
// clone here; your app might fetch a fragment from the server instead).
const table = document.querySelector("[data-row-edit]");
const addBtn = document.querySelector("[data-line-add]");
// Every row action names the row it is IN. One generic "Remove line"
// repeated down the column tells a screen-reader user nothing about which
// line they are deleting, and positions shift on every add or removal.
// Names come from the row's STABLE identity, never from the item text: the
// item is editable, so a name copied from it is wrong the moment someone
// retypes the cell, and keeping the two in sync would mean listening to
// every edit to fix a label nobody changed.
function nameRowActions(tr) {
const id = tr.dataset.rowId;
tr.querySelector("[data-line-remove]")?.setAttribute("aria-label", `Remove ${id}`);
// Save keeps the "— unsaved changes" phrase: the Unsaved BADGE was
// removed and its programmatic state moved into this name (owner
// decision 157.1), so the name is the only channel carrying the row's
// dirty state. Renaming it to just "Save LINE-2" silently deletes that.
tr.querySelector("[data-row-edit-save]")?.setAttribute("aria-label", `Save ${id} — unsaved changes`);
tr.querySelector("[data-row-edit-cancel]")?.setAttribute("aria-label", `Discard changes to ${id}`);
}
let n = 1;
addBtn.addEventListener("click", () => {
n += 1;
const tbody = table.querySelector("tbody");
const row = document.getElementById("line-template").content.cloneNode(true);
const tr = row.querySelector("tr");
tr.dataset.rowId = `LINE-${n}`;
// Combobox listbox ids must be unique per row — reusing one id across
// cloned rows is the exact collision bug fixed 2026-08-15 (widget #2
// silently driving widget #1). Give each clone its own pair.
const list = row.querySelector("ul");
list.id = `line-${n}-list`;
row.querySelector('[role="combobox"]').setAttribute("aria-controls", list.id);
// Save/Cancel are icon-only and start hidden; without this they reveal
// themselves on first edit with no accessible name at all.
nameRowActions(tr);
tbody.appendChild(row);
tbody.lastElementChild.querySelector("input").focus();
// No re-init call: initRowEdit()/initCombobox() are document-level
// delegation — the new row's input and combobox work immediately.
});
// Scoped to THIS table, not the document: a second grid on the page would
// otherwise have its rows removed by this handler too.
table.addEventListener("click", (e) => {
const btn = e.target.closest("[data-line-remove]");
if (!btn) return;
const tr = btn.closest("tr");
// Where focus goes is decided BEFORE the row leaves the DOM — afterwards
// there is no sibling to ask. Next row, else previous row, else the Add
// button, which is the only thing left when the table empties.
const next = tr.nextElementSibling?.querySelector("[data-line-remove]");
const prev = tr.previousElementSibling?.querySelector("[data-line-remove]");
const moveFocus = tr.contains(document.activeElement);
tr.remove();
// Only when the removed row HELD focus. Removing some other row must not
// yank the user out of whatever they were typing in.
if (moveFocus) (next || prev || addBtn).focus();
});
</script> Anatomy
- Header row — column types decide the cell control: text, number, select, date, checkbox, tag.
- Editable cells — always real controls;
--seamlessmakes them read as display text until hovered or focused. - Row actions — the dirty badge plus Save/Cancel, revealed per row by
initRowEdit(). - Totals footer —
data-sum-offor declarative sums, or your own math onbo:cell-change.
Data contract
| Request | Returns / on failure |
|---|---|
| PATCH /po/:id/lines/:lineId | the saved row re-rendered (authoritative values — rounding, derived fields); 422 → the same row with cell errors |
| POST /po/:id/lines | a new row fragment appended to the tbody |
| DELETE /po/:id/lines/:lineId | 204 and remove the row client-side; 409 if the line was already invoiced |
Every request above is per row, so a failure comes back on the row the user just acted on — no summary needed. A document-level submit (posting or approving the whole PO) is a different job: it can reject several rows at once, including rows scrolled out of view, and nothing on this screen would bring the user back to them. That case gets a validation summary at the top of the form — focus lands on the summary and each entry is a link that focuses its field, which scrolls the row into view.
States
| State | What ships |
|---|---|
| Clean | no badge, no buttons — the row looks like a read-only row |
| Dirty | the Unsaved badge plus Save/Cancel; Cancel restores last-saved values and re-fires input events so totals revert |
| Saving (live mode) | saves are coalesced per row per tick; a row removed in the same tick is never saved |
| Cell error | aria-invalid on the input and data-row-state="error" on the row — the row tint and its 3px leading edge carry the fact while nothing is focused, so the error is visible without colour alone. The message is out of flow and appears on focus: a validation message may not change a grid row’s height, because every other cell in the row re-centres when it does. aria-describedby carries the reason to a screen reader continuously, focused or not. Cost: scanning shows you which cell is wrong; reading why needs focus |
| Conflict | 409 → re-render that row with the server values and say who changed it; never merge silently |
| Read-only line | render the value as text — an invoiced line that looks editable is a support call |
| No JS | every field is a real, focusable, typable input — but initRowEdit() is what reveals Save/Cancel, so a row can be edited yet never submitted; a genuinely no-JS row needs a native <form> per row with its own submit button instead of this behavior |
Components used
complexity 4 of 4