busy-office-ui
Menu

Pattern: multi-column detail form

Who uses it: whoever owns the record — a buyer editing a PO, an admin fixing vendor details; occasional, high-stakes edits where losing work is unacceptable. What "done" looks like: every change saved (or explicitly discarded) with no silent loss. The edit-screen counterpart to record detail: a full purchase-order edit form, composed entirely from existing components — .bo-form-section fieldsets, the .bo-form-row auto-fit column grid, the line-items data table, and a sticky .bo-form-actions bar. Nothing new to learn; .bo-form-row already collapses to one column on narrow screens by construction (auto-fit, not a fixed column count) — resize the window to see it. Not for reading a record — a form styles fields for ENTRY, and rendering read-only values as inputs tells the user they can change things they cannot. Read surfaces are record detail and object page.

Purchase order PO-88213 Draft Last saved 10 Aug 2026 09:14
Header
Payment & delivery
Line items
Item Qty Unit price Amount
SKU-3391 · Standing desk 302.00 1,208.00
SKU-1180 · Monitor arm 115.50 924.00

Requested delivery date — the days you cannot pick

Requested delivery date — September 2026
MoTuWeThFrSaSu
31
1234

This grid starts its week on Monday (ISO 8601), which suits a European buyer. Where the week starts is a property of your data, not a setting on the component — see calendar for why the framework ships no data-week-start, and generate the month accordingly.

The plain <input type="date"> above is right for order date — any day is valid, and the native picker is already good. A requested delivery date is a different question: the dock is shut at weekends and closed for the company shutdown on the 7th, so some days are not offers. Those days are real <button disabled>s, so the constraint is enforced by the control rather than by a validation message after the fact — and each carries a visually-hidden reason, because a day that is merely greyed out tells a screen-reader user nothing. This is the one thing a native date input cannot express: a set of marked days.

Variant: field-per-row layout (master-data maintenance)

Some one-record edits read better as a table than a form — master-data maintenance, the SM30 case: a back-office user opens one vendor and corrects a handful of fields (a name typo, a credit limit, a date), a few times a week, with each field given the input its type deserves. One row per field, still one record, still this pattern — not the editable grid, which is many ROWS, one record per row.

Vendor master data, one row per field
FieldValue
Vendor name
Legal entity as registered
Credit limit
Per open order
Active
Blocks new orders when off

One Save at the bottom, not one per row. These rows are the FIELDS OF ONE RECORD, not a list of records — a Save per field means six requests for one logical edit, a Cancel that undoes a third of what the user changed, and, measured on the first version of this variant, a button sitting 228px from the field it saves in an actions column taking 37% of the table. Per-row Save belongs to the editable grid, where each row really is a record. Seamless (--seamless) on every field, including money — a screen where some fields read as text and others as boxes tells the user the boxed ones are editable and the rest are not, backwards from the truth. Zero new CSS: the data table with data-row-edit, ordinary typed inputs — the page's own line items table above stays plain (no dirty tracking, by this page's own design); this variant is the one place on the page that wires initRowEdit().

Anatomy

  1. Sections — real <fieldset>s (.bo-form-section); the legend is the group's accessible name, so a screen reader announces "Header, group" before the fields.
  2. Rows — .bo-form-row is an auto-fit grid, so columns collapse by available width with no breakpoint to maintain.
  3. Line items — a plain table here (one seamless numeric cell per row, no dirty tracking, kept simple for this page's own scope); nest the fully-wired editable grid — data-row-edit, per-row dirty state independent of the header fields — where line items need Save/Cancel of their own.
  4. Delivery calendar — calendar for the one field whose valid days are constrained; closed and holiday days are disabled submit buttons, not styling.
  5. Action bar — sticky .bo-form-actions; the framework sets scroll-padding-block-end so focus never lands under it.
  6. Field-per-row variant (below) — inline editing on <th scope="row"> labels instead of fieldsets, for the master-data case where the record IS the table.

Data contract

RequestReturnsOn failure
GET /po/:id/editthe full form, values filled server-side404 → the not-found screen
POST /po/:idredirect to the record on success422 → the SAME form re-rendered with aria-invalid + messages, values preserved
POST /po/:id/linesone row fragment (editable-grid save)422 → that row with its cell error

Server-side validation is the contract behind aria-invalid: re-render with the user's values, never a blank form, and point aria-describedby at the message. Pair with the validation summary when more than two fields can fail at once.

States

StateWhat ships
PristineSave enabled (a no-op save is harmless and simpler than tracking every field)
Dirtyan unsaved marker near the action bar; warn on navigate-away via beforeunload — app code, four lines
Savingaction bar buttons disabled + aria-busy on the form
Field erroraria-invalid + message, focus moved to the first invalid field
Read-only (viewer role)render key-value facts instead of inputs — not disabled inputs
Conflict (someone else saved)409 → banner naming who and when, with reload-or-overwrite; never silently clobber
No JSthe top PO form has no script of its own — its Cancel is type="button" (already inert, with or without JS) and Save is a native submit, so nothing changes without JS. The field-per-row variant's inputs stay natively editable, and its Cancel (type="reset") and Save (type="submit") both work via plain HTML with no JS; only initRowEdit()'s live "Unsaved changes" badge and initMoneyField()'s currency-precision reformatting are lost

Keyboard walkthrough & print

  1. Tab runs header fields → line items → action bar; the sticky bar never hides the focused field.
  2. Inside line items, each cell is a real control — focus IS edit (see editable grid).
  3. Enter in a text field submits the form natively; keep Save as the form's submit button so that stays true.
  4. After a failed save, move focus to the first invalid field — the browser does this for native validation; do it yourself for server errors.

Print: the print layer drops the action bar and renders fields as values, so a printed edit screen reads as a document rather than a form.

Components used

complexity 2 of 4

↑↓ move Enter open Esc close