Skip to content

Row detail

The most-used interaction on a business list, and the one every app reinvents: open this record, work on it, come back, open the next one. Most reinventions lose the same three things — the middle-click, the Back button, and the place in the list.

Also known as: master-detail, drill-down.

Open a record from its identity cell, then walk with Previous / Next — the sequence is the result set, and the server resolves neighbours by re-running the list query, so Next crosses the page boundary between rows 3 and 4 without the client knowing pages exist.

Now tick a few rows and press Open selected: the same shape, a different sequence. A record that disappears mid-walk renders as a tombstone step with Next still working — this demo’s snapshot token spells out its ids, so you can provoke it by splicing a missing id such as 4999 into the seq= token in a record link’s URL. Mangle the token beyond recognition instead and the snapshot fails closed: a 410 alert with a way back to the list, never a silent fallback to walking everything.

Open a record — or tick a few rows and press Open selectedto walk just those.

Section titled “The link is a real link, in the identity cell”
<tr class="hc-datagrid__row" id="row-4903" data-row-no="137">
<td class="hc-datagrid__cell">
<input class="hc-checkbox" type="checkbox" name="ids" value="4903"
aria-label="Select order SO-4903">
</td>
<th class="hc-datagrid__cell" scope="row">
<a href="/orders/4903" data-hc-row-link>SO-4903</a>
</th>
<!-- …the data cells… -->
</tr>

The identity column carries it — the thing the row is, not a verb appended to it. Being an ordinary anchor buys middle-click, ⌘-click, copy-address, Back, the keyboard and the no-JS path; a click handler re-implements all of that badly.

installRowLink() adds the only thing an anchor cannot do by itself: Enter anywhere on the row, which is what a grid user expects after arrowing to it. Editing wins where it applies (the datagrid cancels the event before opening an editor), a control that owns its Enter keeps it, and a modifier means the user asked for something else.

The list URL already carries the conditions, the sort, the columns and the page. What Back cannot restore is which row — so the detail’s Back to list is that URL plus #row-<id>:

/orders?f-ship=@week-start..&sort=-ship,order&page=4#row-4903

installDatagrid() lands the active cell there and scrolls the grid’s own scrollport, so the keyboard user arrives where the eye does.

StateRestored by
conditions, sort, columns, pagethe list URL
which row#row-<id>
selection ticksonly when the trip started from a selection
scroll offsetnothing, deliberately — focus beats a pixel offset, which points at a different row after any insert
an in-progress editnothing — unsaved changes warns before leaving

The detail needs to know that URL: either the app knows the canonical list, or the link carries ?from=<encoded url>validated server-side (same origin, known route) before being echoed into a link, because an unvalidated from is an open redirect.

CaseReturn
nothing changedBack — the browser’s own history is the cheapest correct restore
something was saved303 to the list URL + #row-<id>

The redirect is not ceremony. A restored snapshot shows the data as it was before the user’s own edit — the one stale value they are guaranteed to notice — and the pager totals go with it.

<a href="/orders/4903" data-hc-row-link
data-hx-get="/orders/4903?peek=1"
data-hx-target="#record-dialog"
data-hx-swap="innerHTML">SO-4903</a>

The href stays canonical, so JavaScript failing means a full page and not a dead row. The overlay is the remote-dialog recipe, and it contains a link to the full page — a peek that traps you is worse than no peek.

A modal is not the answer to “where does the detail go” — it is one of three, and the page is the default, because it is the only one with a URL:

ShapeWhenWhy
a page (default)the record has fields, sub-grids, its own actions; the user is in itit is the only rendering that can be shared, bookmarked, opened in a second tab, or reached without JavaScript
a docked pane (split)the work is comparing record and list, over and over (triage, matching)both stay live; nothing is hidden behind an overlay
a peek (modal)glance and go — check one field, approve, close — on a short recordkeeps the list’s place. The moment it needs to scroll, or grows tabs or a sub-grid, it wanted to be a page

A modal is the least common of the three in business software, and that is not an accident. Opening a record replaces the screen in Gmail; SAP Fiori splits list and record into columns; Salesforce and ServiceNow give the record a page. Modals in those products are for short, self-contained tasks — create one thing, confirm, edit a field — not for “the record”, because a record is where the work happens and work needs room, a URL and its own error surfaces.

The failure mode to avoid is specific: a modal with no URL. If the address bar still says list while the user is looking at a record, then Back closes something they did not open, the link they send a colleague is the wrong screen, and refreshing loses their place. A modal that pushes a URL and restores itself from one is a legitimate rendering; a modal that does not is a dead end wearing a detail screen’s clothes.

Two rules keep the peek honest, and both are failures if broken:

  • The row’s href is the page, and the peek is layered on top of it with data-hx-get. A record reachable only through a dialog cannot be linked, which is the thing colleagues ask for most.
  • The peek contains a link to the page. A peek that traps you is worse than no peek.

Opening one record is half the job; business work is a queue. The detail carries prev / next, and the sequence is whatever the user was looking at:

SequenceWire
the result set (default)?seq=list&i=<ordinal> — the server resolves neighbours by re-running the list query, so “next” crosses a page boundary without the client knowing pages exist
the selection“Open selected (12)” submits the ids checkboxes → POST /orders/selections303 with a token

The token names an ordered snapshot and the screen says so (Record 3 of 12 selected at 14:32). A missing record is a tombstone step, not a wall — aborting at the first gap makes the feature untrustworthy exactly when data is moving — and an expired token fails closed (410 + a link back), never a silent fallback to walking everything.

Where the walk goes — and why not the bottom

Section titled “Where the walk goes — and why not the bottom”

Prev / next live in the record’s header, beside its identity and Back to list. They are not pagination of the content below them:

  • The decision to move on is usually made before reading to the bottom — glance, judge, next. A control you must scroll to reach turns a two-second judgement into a scroll, every time.
  • The detail body scrolls, so a bottom control either scrolls away — useless exactly when the queue is long — or needs a second fixed strip, which is chrome bought for a rarely-reached position.
  • After a save the 303 lands the user at the top, so the next move should be where they already are.

A long detail may repeat them at the bottom as a secondary copy; both are the same links, so there is no state to keep in sync, and the header stays primary.

Within that header, the arrangement carries meaning too, and it is the one every mail client already taught people:

← Back to list …record identity… 1 / 15,129 ‹ ›
  • the exit goes at the start, because that is where a person looks to get out;
  • the walk — the position, then prev / next — goes at the end, where the pointer already is and where moving on is the frequent act.

Same rule as the list’s navigation strip: where you are and where you go at the end, the way out at the start.

Detail screens hold grids of their own — lines, shipments, history — and each pages directly under itself, never in a page-level footer. A page-level pager on a screen with three grids cannot say which grid it pages; the list template’s rule — navigation under the data it moves through — is about that data, not about the page.

Only the grid carrying hc-fill takes the remaining height; the others keep their own caps, their own pagers and their own empty states.

So the bottom of a detail screen carries its actions — Save, Cancel — not navigation.