Line items
The order form, the quote, the invoice: a header plus N detail rows, each with a quantity, a price, and a line total, footed by subtotal / tax / total. This recipe is that table as a hypermedia contract — one endpoint, one shape: POST the whole form, get the whole form back re-rendered. The client never computes a number; rounding is business truth and has exactly one implementation, on the server.
Also known as: detail rows, order lines, repeating table form.
Live demo
Section titled “Live demo”Change a quantity or price (leave the field to fire change), add and
remove rows — every mutation is the same POST of the whole form, and
the totals only ever come back computed by the server (the 10% tax is
floored server-side). Type 3..5 into a quantity to see the 422
shape: the raw value echoed back, the input marked, the totals dashed.
The endpoint is a namespaced demo implementation of the contract under
api/recipes/line-items/.
Loading quote…
The markup
Section titled “The markup”<form method="post" action="/quotes/42/recalc" id="quote" data-hc-line-items> <table class="hc-table"> <thead>…Item / Qty / Unit price / Line total / (remove)…</thead> <tbody> <tr> <td><input class="hc-input" name="item" value="Widget" aria-label="Item"></td> <td><input class="hc-input" name="qty" value="3" inputmode="numeric" aria-label="Quantity" data-hx-post="/quotes/42/recalc" data-hx-trigger="change" data-hx-target="#quote" data-hx-swap="outerHTML"></td> <td><input class="hc-input" name="price" value="1200" inputmode="numeric" aria-label="Unit price" data-hx-post="/quotes/42/recalc" data-hx-trigger="change" data-hx-target="#quote" data-hx-swap="outerHTML"></td> <td data-cell="line-total">¥3,600</td> <td><button class="hc-button" data-variant="ghost" type="submit" name="remove-row" value="1" data-hx-post="/quotes/42/recalc" data-hx-target="#quote" data-hx-swap="outerHTML">Remove</button></td> </tr> </tbody> <tfoot>…Subtotal / Tax / Total, all server-rendered…</tfoot> </table> <button class="hc-button" type="submit" name="add" value="1" data-hx-post="/quotes/42/recalc" data-hx-target="#quote" data-hx-swap="outerHTML">Add row</button> <!-- Save is a different endpoint — formaction is the no-JS path --> <button class="hc-button" data-variant="primary" type="submit" formaction="/quotes/42" data-hx-post="/quotes/42" data-hx-target="#quote" data-hx-swap="outerHTML">Save quote</button></form>Why each piece is the way it is:
- Rows align positionally by repeated names. Each row contributes
one
item, oneqty, oneprice; tree-order serialization keeps the triples aligned — the same spec guarantee the sortable list and the datagrid snapshot pager rest on. Noitems[0].qtyindexing, so adding and removing rows never renumbers anything. - Every mutation is the same request.
qty/pricefire onchange; Add and Remove are submit buttons whose name/value is the verb. One endpoint branches on the pressed button; the native submit does the same with JavaScript off. - The whole form swaps back (
outerHTML). The response is the only calculator — line totals, subtotal, tax, total all arrive rendered.hc validateerrors on any other swap. data-hc-line-itemsis a contract marker only — no behavior attaches.
Validation (422)
Section titled “Validation (422)”Numbers validate per field; the response is the same re-rendered form
with the bad raw value echoed back (never silently coerced — the
user must see what the server saw), aria-invalid="true" on the
input, the message in the row, and the totals rendered as ”—”
while any row is invalid — never a stale or partial number. Status
422, with the standard one-line beforeSwap allowance from
mutating form.
Focus — the one real trade-off
Section titled “Focus — the one real trade-off”A whole-form swap after change re-renders the input the user just
left; a keyboard user tabbing through the table loses focus at swap
time. The contract’s base position: accept it — change fires on
blur and one source of truth beats focus preservation in most entry
screens. The documented narrowed variant keeps the same response but
swaps only the derived cells (data-hx-select-oob="#totals" plus
per-row line-total ids) — focus survives, at the cost of ids on every
derived cell. For keyboard-heavy screens consider the
datagrid edit errors
grid instead.
Server response contract
Section titled “Server response contract”| Request | Response |
|---|---|
POST /quotes/42/recalc (rows ± add / remove-row=N) | 200 + the whole form re-rendered |
POST /quotes/42/recalc (invalid numbers) | 422 + the same form, raw values echoed, totals ”—” |
POST /quotes/42 (Save — the button’s formaction) | validates like recalc; success per mutating form |
Progressive enhancement
Section titled “Progressive enhancement”All mutation controls are native submits with name/value; without
htmx the form POSTs to action and the server renders the full page
with the same re-rendered form. Save’s formaction keeps the two
endpoints honest with JS off.
Accessibility
Section titled “Accessibility”- Inputs carry
aria-labels — the column headers label columns, not controls. - Error messages live in the row, next to the input they name, with
aria-invalidon the input. - Name the remove button’s row when rows have identity (“Remove Widget”).
- Drafts compose with autosave; the dirty guard with unsaved changes.
- Reordering rows is the sortable list
applied to the
<tbody>— moving a row moves its inputs, and the positional contract picks up the new order for free. - Concurrency: two people editing one quote wants
edit conflict’s
hidden
versionriding this same form.
Related
Section titled “Related”- Transfer — the same whole-form-swap shape for a dual listbox.
- Datagrid edit errors — cell-level editing in a grid, when the table outgrows a form.
- Field errors — the error-marking vocabulary this recipe reuses per row.