busy-office-ui
Menu

Pattern: master-detail

M2 of the four data-maintenance patterns (and the most common one in practice): a list of records, and clicking a row opens that record's fields in a panel beside the list or a dialog over it. A back-office user uses this all day — find the vendor, fix two fields, move on — and "done" means the list stays put, keeping their scroll position, filters and sort, while only the detail changes. Not when one record deserves the whole screen — the split exists so a user can move ALONG the list without losing their place; if they are working one record end to end, the panel is just a narrower object page.

The screen — side panel

Vendors — select one to see its detail
VendorCountryStatus
Umbrella Logistics GmbH DE Active
Globex Industrial US Active
Acme Supply Co. GB Blocked

The list keeps its scroll, filters and sort while the panel changes — that is the point of the pattern, and it is why the detail is a swap target rather than a navigation.

Which row is open is carried by aria-current on its link, and the framework ships no tint for it. That is a real gap, stated rather than papered over: data-row-state covers dirty, error and warning — the states the framework owns — and a "currently open record" is application state, so there is no data-row-state="selected" to reach for. Add your own rule against [aria-current] if you want the row tinted; the announcement works either way, which is the half that must not be left to CSS.

The same record on a narrow screen — a drawer

Below the shell breakpoint a side panel has nowhere to sit, so the detail arrives as a full-width drawer over the list. This page used to say that and show nothing — here it is, at any width, so the promise is inspectable:

Umbrella Logistics GmbH

Vendor no.
V-1001
Country
Germany
Payment terms
Net 30

It is not full-width, deliberately: .bo-offcanvas is min(18rem, 85vw), so a sliver of the list stays visible behind it and the user keeps their place. Same record, same fields, same initDialogs() wiring — only the container changes. It is a real <dialog>, so Escape closes it and focus returns to the trigger without any code of ours. Selection survives closing it, which is the whole reason the list is not re-rendered.

The same record in a dialog

When the detail is an edit rather than a read, a modal keeps the user from losing it behind the list. Same record, same fields, different container — and the same initDialogs() wiring as every other dialog.

Edit V-1001

Zero new CSS. This pattern is the data table, a card as the panel, key-value facts for the read view, and dialog or offcanvas as the container. There is no .bo-master-detail, and there should not be.

Which container — and this is the decision that goes wrong

UseWhenWhy not the others
Side panel reading and light edits, and the user compares records keeps the list visible, so "next record" costs one click and no context
Dialog a focused edit that must be finished or abandoned modality is the feature — it stops a half-edited record being forgotten behind the list
Full page the record has its own tabs, attachments and audit trail a panel that needs to scroll independently and hold tabs is a page wearing a costume — use record detail
Inline row edit one or two fields, and the user is scanning the list opening anything at all is too much ceremony — use M1

On narrow screens the choice collapses: below the shell's breakpoint a side panel has nowhere to sit, so it becomes a drawer over the list. Design the panel so that works — one column, no side-by-side fields.

Anatomy

  1. Master list — data table; the row's first cell is a <th scope="row"> holding a real link, so the record is reachable, bookmarkable and openable in a new tab.
  2. Selection state — aria-selected on the <tr>, which is the one channel the framework ships. There is no data-row-state="selected" — see the note below the demo above for why, and add your own tint rule against [aria-selected="true"] if you want one.
  3. Detail container — a card beside the list, or dialog/offcanvas over it.
  4. Detail body — key-value facts to read, a detail form to write.

Data contract

RequestReturns / on failure
GET /vendorsthe master list; the detail region renders empty until a row is chosen
GET /vendors/:idthe detail fragment only, swapped into the panel. Never re-render the list — that is what loses scroll position and selection
PATCH /vendors/:idthe updated detail fragment, and the one changed row, so the list stays truthful without a reload (htmx: an out-of-band swap)
404the record vanished under the user — replace the panel with an empty state that says so and clears the selection

No AI-manages/human-monitors split here, stated rather than left silent: this is a human browsing and correcting master data one record at a time, not oversight of something automated running behind the list. There is no monitoring signal to name.

States

StateWhat ships
Nothing selectedthe panel holds a one-line prompt, not a blank box — this is the screen's first impression
Loading the detailaria-busy on the panel only; the list stays interactive so the user can pick a different row
Empty listthe DIFFERENT empty when filters exclude everything: say which filter, and offer to clear it
Detail 404panel becomes an empty state; the row is deselected rather than left highlighted pointing at nothing
Save conflict409 in the panel with both values — see concurrency; nothing auto-merges
No permission to editthe panel reads; the Edit control is absent, not disabled
Narrow screenthe panel becomes a drawer over the list — capped at 85vw, so a sliver of the list stays visible and the user keeps their place; selection survives closing it
No JSthe primary side-panel view (facts, badges) is plain markup and reads fully without JS. The drawer and edit-dialog variants depend on initDialogs()'s click delegation to call the native showModal() — without it, neither <dialog> ever opens, so "Open the detail as a drawer" and "Edit vendor V-1001" do nothing and those two views are unreachable

Components used

complexity 2 of 4

↑↓ move Enter open Esc close