busy-office-ui
Menu

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.

Invoices
14 invoices
Vendor Cost center Due Amount Status
INV-10234 Acme Supply Co. CC-4021 2026-08-12 $17,329.42 Pending
INV-10235 Globex Industrial CC-1180 2026-08-13 $18,685.95 Approved
INV-10236 Initech GmbH CC-2205 2026-08-14 $3,322.40 Rejected
INV-10237 Umbrella Logistics CC-4021 2026-08-15 $15,636.05 Pending
INV-10238 Stark Components CC-1180 2026-08-16 $19,678.49 Approved
INV-10239 Acme Supply Co. CC-2205 2026-08-17 $6,088.31 Rejected
INV-10240 Globex Industrial CC-4021 2026-08-18 $13,639.73 Pending
INV-10241 Initech GmbH CC-1180 2026-08-19 $20,287.16 Approved
INV-10242 Umbrella Logistics CC-2205 2026-08-20 $8,742.37 Rejected
INV-10243 Stark Components CC-4021 2026-08-21 $11,380.42 Pending
INV-10244 Acme Supply Co. CC-1180 2026-08-22 $20,499.80 Approved
INV-10245 Globex Industrial CC-2205 2026-08-23 $11,231.46 Rejected
INV-10246 Initech GmbH CC-4021 2026-08-24 $8,903.34 Pending
INV-10247 Umbrella Logistics CC-1180 2026-08-25 $20,312.15 Approved

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:

Filtering by status: pending × vendor: acme ×

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:value where key is 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:99 must 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 q parameter.
  • Each chip's remove control is a plain <a href> carrying q minus 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

  1. 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.
  2. 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.
  3. Filter bar — a plain <form> in a cluster with filter controls. Submits on Apply; Clear is a native type="reset".
  4. Toolbar (.bo-data-table__toolbar) — bulk actions appear only when rows are selected; the selection count is filled by initDataTables().
  5. Table (.bo-data-table--sticky-col) — sticky header, frozen select column, aria-sort on 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.
  6. 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:

RequestReturnsSwaps into
GET /invoices?status=&cc=&q=&page=the <tbody> rows + an out-of-band footer#invoice-rows
POST /invoices/bulk-approvethe affected rows, re-rendered with new badges#invoice-rows
GET /invoices?page=nnext page rows (append) or a full replace#invoice-rows
GET /invoices?view=minethe 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 stalefull 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 resolvingthe switcher
POST /views — 409 name takenthe 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 apartthe save form
PATCH /views/:id/defaultwhich 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 sawthe switcher
GET /invoices?view=x — 403 / 404fall 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 rowsfull page + an alert
any request, 5xx or timeoutthe 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 errorunswapped; 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

StateWhat 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 errordata-row-state="error" + a badge naming the reason (two-channel)
Partial failure after bulkre-render the failed rows in the error state and summarise: "8 approved, 2 failed"
No permissionbulk buttons absent (not disabled) — a control you can never use is noise
No saved views yetthe 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 changedthe 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 seetheir default renders WITH an alert naming the swap; the rows shown must never be mistaken for the rows sent
Shared view deleted by its ownerthe 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 JSrow 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:

  1. Tab into the filter bar, type, Enter to apply — a native form submit.
  2. Tab to the select-all checkbox; Space toggles the page's rows and announces the count via the live region.
  3. Tab through row checkboxes, Space to pick individual invoices.
  4. 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.
  5. 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 sets aria-sort on the <th>; leaving a hardcoded aria-sort on 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.

↑↓ move Enter open Esc close