Pattern: list report
Who uses it: AP clerks, all day — the screen they live in. What "done" looks like: find the invoices that need action, act on them in bulk, and never lose your place. Composed entirely from shipped components; the data table page documents the table itself, this page documents the screen. Not for a single record's full detail — once a clerk opens one invoice to review or act on it alone, that screen is a record detail (or an object page for a richer one); list-report's job stops at finding and bulk-acting on rows, not deep-diving into one.
The rows and the bulk buttons are one
<form>, which is what makes step 4 below work:
Enter from any row checkbox runs the bulk
action, so a selection thirty rows down is zero keypresses from acting
instead of thirty-two. Only the safe action is
type="submit" — Reject stays
type="button", so implicit submission can never reach a
destructive one.
Filtering: typed tokens, not a filter dialog
ERP users live in a handful of named views and rarely compose filters from scratch. So the ad-hoc case gets one search field that accepts typed tokens, and there is deliberately no adapt-filters dialog:
Zero
new framework surface: the field is a plain
<input type="search"> and each active token is the
.bo-chip that
already ships. Reads as a sentence, stays keyboardable, and survives
being bookmarked because it is all in the URL.
The parse contract is the server's
key:valuewherekeyis one your server recognises; everything else is free text.- An unknown key stays free text — it is not a
token with no matches. A vendor genuinely called
ref:99must still be findable, so guessing is worse than not tokenising. - Multiple tokens are AND; free text searches the columns you
choose. Both live in the single
qparameter. - Each chip's remove control is a plain
<a href>carryingqminus that token — removing a filter needs no JavaScript, and works with the back button.
Working implementation in
examples/po-app:
status:pending vendor:stark narrows to one row, dropping
the status token widens it to six, and the chips track both.
Anatomy
- Header actions — a
cluster split with
bo-cluster--split: the screen's title, and a primary create action plus refresh. Renders whether the table is empty or full — this is the region that was missing (GAP-17): the pattern used to name a primary action only inside the empty state below, so nothing stood above a populated table. - Saved-view switcher — a segmented control of named views inside a GET form (so a view is a URL) plus a dropdown for save/rename/ default/delete. Zero new CSS; the persistence contract is in Data contract below.
- Filter bar — a plain
<form>in a cluster with filter controls. Submits on Apply; Clear is a nativetype="reset". - Toolbar (
.bo-data-table__toolbar) — bulk actions appear only when rows are selected; the selection count is filled byinitDataTables(). - Table (
.bo-data-table--sticky-col) — sticky header, frozen select column,aria-sorton the sorted header, status badges carrying text, not just color. Columns declare their priority: Cost center is--tertiary(drops first as the container narrows), Due is--secondary— the ladder keeps a narrow screen on the columns the row cannot be understood without. - Footer — rows-per-page + pagination, or a load-more button; pick one and keep it (see Scaling).
Data contract
What the server must return for this screen to work — the HTMX/fetch boundary:
| Request | Returns | Swaps into |
|---|---|---|
| GET /invoices?status=&cc=&q=&page= | the <tbody> rows + an out-of-band footer | #invoice-rows |
| POST /invoices/bulk-approve | the affected rows, re-rendered with new badges | #invoice-rows |
| GET /invoices?page=n | next page rows (append) or a full replace | #invoice-rows |
| GET /invoices?view=mine | the server RESOLVES the view name to its filters and renders the list — the view is a saved query living server-side, not a client blob; the response also re-renders the switcher so counts and the selected chip are never stale | full page (a view change is a navigation) |
| POST /views {name, query, scope} | 201 + the re-rendered switcher including the new chip. scope is private or shared: a shared view others depend on cannot be silently redefined, so editing one is a new version with the old URL still resolving | the switcher |
| POST /views — 409 name taken | the form re-renders with the conflict named ("you already have a view called Mine") — never auto-suffix to "Mine (2)", which is how people end up with four views they cannot tell apart | the save form |
| PATCH /views/:id/default | which view a bare GET /invoices resolves to for THIS user; a default is per-user, and a shared link always beats it — the URL a colleague sent must show what they saw | the switcher |
| GET /invoices?view=x — 403 / 404 | fall back to the user's default AND SAY SO ("that view is not shared with you — showing All open"); silently swapping the list is how someone acts on the wrong rows | full page + an alert |
| any request, 5xx or timeout | the Error state above (.bo-alert--danger + retry, last good rows kept); POST /invoices/bulk-approve failing per row is separate — still 200, the failure is the returned row's own data-row-state="error", not a transport-level error | unswapped; alert renders in place |
Rows carry data-row-id so partial
responses can target one row. Selection state is CLIENT state — a swap that
replaces rows clears it, which is the honest behaviour after a bulk action.
Filters belong in the URL query so a filtered list is linkable and survives
reload — and that is exactly what makes a saved view a URL
here rather than a stored client preference. The switcher is a plain GET
form of radios: picking a view navigates to ?view=…, so a
view can be bookmarked, pasted into a ticket, or sent to a colleague and
they see the same rows. That is the one thing the reference
implementations' variant managers cannot do — theirs is a personal client
blob behind a dropdown — and it costs no component: a
segmented control and a
dropdown, both already
shipped. The framework ships no view MANAGER, on purpose: naming,
sharing and permissions are your data model, and the contract rows
above are what that model owes the screen.
States
| State | What ships |
|---|---|
| Loading (first paint) | Skeleton rows — never an empty table that fills in |
| Loading (refetch) | data-loading="true" + aria-busy on the table: dims and blocks interaction mid-swap |
| Empty (no data yet) | the header action stays — it is not only the empty state's action anymore — and a .bo-state repeats it in-place so the reader does not have to look up |
| Empty (filters exclude everything) | the same .bo-state with DIFFERENT words — name the filters that caused it and offer "Clear filters", not "Create". There is no --empty/--filtered modifier; the wording is the whole difference |
| Error | .bo-alert--danger above the table with a retry; keep the last good rows visible |
| Row error | data-row-state="error" + a badge naming the reason (two-channel) |
| Partial failure after bulk | re-render the failed rows in the error state and summarise: "8 approved, 2 failed" |
| No permission | bulk buttons absent (not disabled) — a control you can never use is noise |
| No saved views yet | the switcher is absent entirely, not an empty control — a segmented control with one option is a label pretending to be a choice; the Views menu keeps "Save current filters as a view…" |
| View selected, filters then changed | the chip stays selected but the screen says the view is modified ("Overdue · edited") and the Views menu offers Save / Reset — silently drifting off a named view is how someone reports numbers from a filter they never chose |
| Shared view link the reader can't see | their default renders WITH an alert naming the swap; the rows shown must never be mistaken for the rows sent |
| Shared view deleted by its owner | the URL still resolves, to a stated "this view no longer exists" + the default — an audit trail that dead-ends on a 404 is a support call |
| No JS | row checkboxes and the bulk-action buttons are real inputs inside a native <form>, so picking rows and submitting still works end to end; only initDataTables()'s conveniences are lost — the header checkbox no longer toggles every row (it's just an unwired checkbox), and the "n selected" live region never updates, so a screen-reader user gets no running count of the selection |
Keyboard walkthrough
The path an AP clerk actually takes, with nothing custom bound:
- Tab into the filter bar, type, Enter to apply — a native form submit.
- Tab to the select-all checkbox; Space toggles the page's rows and announces the count via the live region.
- Tab through row checkboxes, Space to pick individual invoices.
- Enter from any row checkbox runs the bulk
action — no journey back to the toolbar at all. That is native implicit
form submission, not framework code: the rows and the buttons are one
<form>, and focus stays on the checkbox you pressed. Without the form the toolbar is one Shift+Tab per row you passed plus two — thirty-two presses from row 30, which is what this page measured before the form was added. - Sorting: the header is a real
<button>, so it is keyboard-reachable and announced as a control — but sorting itself is your code. The framework ships none. Your handler setsaria-sorton the<th>; leaving a hardcodedaria-sorton a header that never sorts announces an order that isn't real.
The table deliberately does NOT take a roving
tabindex — screen-reader table browse mode is worth more than 2-D arrow
navigation on a read-mostly list (data-grid-nav is opt-in per
table when a screen really is grid-first).
Print behaviour
ERP lists get printed and signed. The print layer repeats the table header on every page, never splits a row, drops the toolbar/pagination/filter chrome, and forces status colors so a badge survives a monochrome printer. Print the page to see it — no print-specific markup required.
Scaling notes
- Rows: measured render costs (1k → 20k, including a 4× CPU throttle) are on the data table page. Past ~5k rows, paginate server-side — the framework has no virtualiser and does not pretend to.
- Pagination vs load-more: pagination when clerks cite "page 3" and need a stable position; load-more for scanning feeds. Don't ship both on one screen.
- Columns: past ~10, the frozen select column plus horizontal scroll carries it; consider a column chooser rather than shrinking type.
- Languages: German column labels run ~35% longer — headers wrap by design, so verify at compact density in the longest locale you ship.
Components used
complexity 2 of 4 — read-many-records tier.