Skip to content

CRUD list page

An hc-shell app frame whose main region composes the standard list-page tooling: a paged datagrid, a selection toolbar for bulk actions, a “New item…” button that opens a remote dialog, and rows you can delete with undo.

One honesty note up front: each region talks to its own recipe namespace of the demo API, so the preview composes them side by side instead of pretending one endpoint serves all four contracts. In your app one /products resource would typically implement the pager and bulk and undo contracts against the same grid — the contracts compose; the demo data sets don’t.

  • Catalog (paged) — page through 5,000 fake products; each pager link swaps just its rows into the <tbody> while the pager and status line arrive out-of-band.
  • New item… — opens the remote-dialog namespace’s canned edit item dialog (items/123/edit; a real app would serve GET /items/new with the same fragment shape). Saving with a blank name gets the 422 error-state dialog back; a valid save closes it and raises a toast.
  • Bulk actions — check some rows and the toolbar appears. Archive or delete the selection; including Anvil (101) shows the partial-failure warning toast (it is protected server-side).
  • Delete with undo — delete a row (no confirmation), then press Undo in the toast within the grace period to restore it in place.
Acme Admin

Products

Catalog (paged)

IDNameUnit price

Loading…

Bulk actions

IDProductStatus

6 products

Delete with undo

ItemActions

The complete page, with real-app-shaped URLs — one /products resource implementing the pager + bulk contracts, /items for dialog editing and undo-delete. Render page 1’s rows server-side so the grid is full before any JavaScript runs. No toast markup is needed: installToast() creates its own region.

<body>
<div class="hc-shell">
<header class="hc-shell__header">
<button class="hc-button" data-variant="ghost"
data-hc-shell-toggle aria-label="Open navigation" type="button"></button>
<strong>Acme Admin</strong>
</header>
<nav class="hc-shell__sidebar" aria-label="Primary">
<a href="/dashboard">Dashboard</a>
<a href="/products" aria-current="page">Products</a>
<a href="/settings">Settings</a>
</nav>
<main class="hc-shell__main hc-stack" style="--hc-stack-gap: 1.5rem;">
<!-- Page toolbar: title + remote-dialog trigger. -->
<div class="hc-cluster" style="justify-content: space-between; align-items: center;">
<h1>Products</h1>
<button class="hc-button" data-variant="primary" type="button"
data-hx-get="/items/new"
data-hx-target="#dialog-root"
data-hx-swap="innerHTML"
data-hx-disabled-elt="this">New item…</button>
</div>
<!-- The fetched <dialog class="hc-dialog"> fragment swaps in here;
installRemoteDialog showModal()s it. -->
<div id="dialog-root" data-hc-remote-dialog-root></div>
<!-- One form wraps the selection toolbar, the grid, and the status
line: checked row ids travel by native form serialization. -->
<form method="post" action="/products/bulk">
<div class="hc-toolbar" role="toolbar" aria-label="Bulk actions"
data-hc-datagrid-actions="#products-grid" hidden>
<span data-hc-datagrid-count></span>
<button class="hc-button" type="submit" name="action" value="archive"
data-hx-post="/products/bulk"
data-hx-target="#products-rows" data-hx-swap="innerHTML"
data-hx-disabled-elt="this">Archive</button>
<button class="hc-button" data-variant="error" type="submit"
name="action" value="delete"
data-hc-confirm="Delete the selected products? This cannot be undone."
data-hc-confirm-title="Delete products"
data-hx-trigger="hc:confirmed"
data-hx-post="/products/bulk"
data-hx-target="#products-rows" data-hx-swap="innerHTML"
data-hx-disabled-elt="this">Delete</button>
</div>
<div class="hc-datagrid" id="products-grid">
<div class="hc-datagrid__scroll">
<table class="hc-datagrid__table">
<thead class="hc-datagrid__head">
<tr>
<th class="hc-datagrid__headcell" data-frozen scope="col">
<!-- Select-all: no name attribute — must never serialize. -->
<input type="checkbox" class="hc-checkbox" aria-label="Select all" />
</th>
<th class="hc-datagrid__headcell" data-frozen data-frozen-edge scope="col">ID</th>
<th class="hc-datagrid__headcell" scope="col">Name</th>
<th class="hc-datagrid__headcell" scope="col">Unit price</th>
</tr>
</thead>
<tbody class="hc-datagrid__body" id="products-rows">
<!-- Page 1's rows, rendered by the server. Per the undo-delete
contract, each Delete button swaps its own row for the
tombstone the server returns:
<tr id="item-101" class="hc-datagrid__row">
<td class="hc-datagrid__cell" data-frozen>
<input type="checkbox" class="hc-checkbox" name="ids" value="101"
aria-label="Select Anvil" />
</td>
<th class="hc-datagrid__cell" data-frozen data-frozen-edge scope="row">101</th>
<td class="hc-datagrid__cell">Anvil</td>
<td class="hc-datagrid__cell">
$1,200
<button class="hc-button" data-size="sm" type="button"
data-hx-delete="/items/101"
data-hx-target="closest tr" data-hx-swap="outerHTML"
data-hx-disabled-elt="this">Delete</button>
</td>
</tr>
-->
</tbody>
</table>
</div>
</div>
<div class="hc-cluster" style="justify-content: space-between; inline-size: 100%;">
<p id="products-status" aria-live="polite">1–100 / 5,000</p>
<!-- The server re-renders the pager out-of-band with every page of
rows; links carry href="?page=N" as the no-JS path. -->
<nav class="hc-pagination" id="products-pager" aria-label="Pagination">
<a class="hc-pagination__item" aria-current="page" href="?page=1"
data-hx-get="/products?page=1" data-hx-target="#products-rows"
data-hx-swap="innerHTML">1</a>
<a class="hc-pagination__item" href="?page=2"
data-hx-get="/products?page=2" data-hx-target="#products-rows"
data-hx-swap="innerHTML">2</a>
<!-- … -->
</nav>
</div>
</form>
</main>
</div>
</body>
RegionComponent / blockRecipeContract
App framehc-shell— (pure CSS; installShell() powers mobile nav)
“New item…” dialoghc-dialogremote-dialogremote-dialog/contract.md
Rows + pagerhc-datagrid + hc-paginationdatagrid-pagerdatagrid-pager/contract.md
Bulk-actions toolbarhc-toolbardatagrid-bulk-actionsdatagrid-bulk-actions/contract.md
Row delete + undohc-toastundo-deleteundo-delete/contract.md
  • Rename the endpoints. /products and /items are placeholders — each region only needs its linked contract implemented; they compose freely on one resource.
  • Real CSRF. The bulk POST, dialog save, and DELETEs all mutate — add <meta name="csrf-token"> + installCsrfHeader(), plus the hidden field on the no-JS form — see CSRF tokens.
  • Real auth. Gate the resource with your framework’s session auth — see the server integration guides.
  • Undo vs. confirm. Per-row deletes use undo-delete (no dialog); the rarer bulk delete keeps a confirm-action gate. Never stack both on one action.