Data table data SpriteControllerEndpoint
A server-rendered data table on a real <table>, all HTML over the wire: search, sortable headers, filters, row selection (one page or every matching row), bulk actions, pagination, and deep-linkable URL-synced state. Optional extensions add column visibility, column resize, and inline cell editing. See How to use it and the DOM contract on this page for wiring.
Layer: L2 host · Recipe: list-region-host — server-driven list / data table host · Refuses: combobox. Curriculum: AGENTS.md; pick matrix: docs/agent/pick-a-surface.md; blast radius: CONSUMER_MAP.md.
No customers found
Adjust the filters to widen your search.
Copy this
<!-- icons: include the icon sheet once per page (see the Setup section, #setup) -->
<div class="hm-stack">
<div class="table" data-grid data-grid-url data-bulk-count="0" data-grid-page="1" data-grid-edit-url="/mock/grid">
<div class="filter-bar">
<div class="filter-cell">
<label class="filter-label" for="hm-grid-search">Search</label>
<input class="filter-input" id="hm-grid-search" type="search" data-grid-search name="q" placeholder="Name or plan…">
</div>
<div class="filter-cell">
<label class="filter-label" for="hm-grid-filter-plan">Plan</label>
<select class="filter-select" id="hm-grid-filter-plan" data-grid-filter="plan">
<option value="">Any plan</option>
<option value="Free">Free</option>
<option value="Pro">Pro</option>
<option value="Team">Team</option>
<option value="Enterprise">Enterprise</option>
</select>
</div>
<!-- status is a filter-only field (no column): filters can narrow on any server field, not just displayed columns -->
<div class="filter-cell">
<label class="filter-label" for="hm-grid-filter-status">Status</label>
<select class="filter-select" id="hm-grid-filter-status" data-grid-filter="status">
<option value="">Any status</option>
<option value="Active">Active</option>
<option value="Trialing">Trialing</option>
<option value="Churned">Churned</option>
</select>
</div>
<div class="filter-cell">
<label class="filter-label" for="hm-grid-page-size">Per page</label>
<select class="filter-select" id="hm-grid-page-size" data-grid-page-size>
<option value="2">2</option>
<option value="4" selected>4</option>
<option value="8">8</option>
</select>
</div>
<details class="table-col-menu">
<summary class="table-col-menu-trigger" aria-label="Toggle column visibility">Columns</summary>
<div class="table-col-menu-panel">
<label class="table-col-menu-item"><input type="checkbox" checked class="table-col-menu-checkbox" data-grid-col-toggle="first" aria-label="Show First name column"><span>First name</span></label>
<label class="table-col-menu-item"><input type="checkbox" checked class="table-col-menu-checkbox" data-grid-col-toggle="last" aria-label="Show Last name column"><span>Last name</span></label>
<label class="table-col-menu-item"><input type="checkbox" checked class="table-col-menu-checkbox" data-grid-col-toggle="plan" aria-label="Show Plan column"><span>Plan</span></label>
<label class="table-col-menu-item"><input type="checkbox" checked class="table-col-menu-checkbox" data-grid-col-toggle="signed" aria-label="Show Signed up column"><span>Signed up</span></label>
<button type="button" class="table-col-menu-reset" data-grid-cols-reset>Show all columns</button>
</div>
</details>
</div>
<div class="bulk-actions"><span aria-live="polite" aria-atomic="true"><span data-bulk-count-target>0</span> selected</span><button type="button" class="bulk-matching" data-grid-select-all-matching title="Select every row that matches the current search and filters (including other pages) — not only the rows on this page" aria-label="Select all results matching current search and filters">Select all <span data-grid-matching-total>…</span> results</button><button type="button" class="bulk-delete" data-grid-bulk-action="delete" data-grid-bulk-refresh hx-swap="none" hx-post="/mock/grid/bulk" hx-confirm="Delete the selected customers? This cannot be undone.">Delete</button><button type="button" class="bulk-clear" data-grid-clear>Clear</button></div>
<div class="table-scroll">
<div class="table-loading" aria-hidden="true"><span class="table-loading-spinner"><svg class="icon" aria-hidden="true"><use href="#i-loader-circle"/></svg></span></div>
<div class="table-scroll-x">
<table class="table-grid">
<colgroup>
<col class="table-col-select">
<col data-col="first">
<col data-col="last">
<col data-col="plan">
<col data-col="signed">
</colgroup>
<thead>
<tr>
<th class="table-th-select"><input type="checkbox" data-grid-select-all aria-label="Select all rows"></th>
<th class="table-th" data-col="first" aria-sort="none"><button type="button" class="table-sort-button" data-grid-sort="first">First name<span class="table-sort-icon" aria-hidden="true"><svg class="icon" aria-hidden="true"><use href="#i-chevron-up"/></svg></span></button><span class="table-resize-handle" data-grid-resize="first" aria-hidden="true"></span></th>
<th class="table-th" data-col="last" aria-sort="none"><button type="button" class="table-sort-button" data-grid-sort="last">Last name<span class="table-sort-icon" aria-hidden="true"><svg class="icon" aria-hidden="true"><use href="#i-chevron-up"/></svg></span></button><span class="table-resize-handle" data-grid-resize="last" aria-hidden="true"></span></th>
<th class="table-th" data-col="plan" aria-sort="none"><button type="button" class="table-sort-button" data-grid-sort="plan">Plan<span class="table-sort-icon" aria-hidden="true"><svg class="icon" aria-hidden="true"><use href="#i-chevron-up"/></svg></span></button><span class="table-resize-handle" data-grid-resize="plan" aria-hidden="true"></span></th>
<th class="table-th" data-col="signed" aria-sort="none"><button type="button" class="table-sort-button" data-grid-sort="signed">Signed up<span class="table-sort-icon" aria-hidden="true"><svg class="icon" aria-hidden="true"><use href="#i-chevron-up"/></svg></span></button><span class="table-resize-handle" data-grid-resize="signed" aria-hidden="true"></span></th>
</tr>
</thead>
<tbody class="table-body" data-grid-body data-grid-src="/mock/grid/rows" hx-get="/mock/grid/rows" hx-trigger="load, grid:refresh" hx-swap="innerMorph">
<tr class="tr-row" aria-hidden="true">
<td class="tr-checkbox-cell"><span class="skeleton" data-shape="text"></span></td>
<td class="tr-cell"><span class="skeleton" data-shape="text"></span></td>
<td class="tr-cell"><span class="skeleton" data-shape="text"></span></td>
<td class="tr-cell"><span class="skeleton" data-shape="text"></span></td>
<td class="tr-cell"><span class="skeleton" data-shape="text"></span></td>
</tr>
<tr class="tr-row" aria-hidden="true">
<td class="tr-checkbox-cell"><span class="skeleton" data-shape="text"></span></td>
<td class="tr-cell"><span class="skeleton" data-shape="text"></span></td>
<td class="tr-cell"><span class="skeleton" data-shape="text"></span></td>
<td class="tr-cell"><span class="skeleton" data-shape="text"></span></td>
<td class="tr-cell"><span class="skeleton" data-shape="text"></span></td>
</tr>
<tr class="tr-row" aria-hidden="true">
<td class="tr-checkbox-cell"><span class="skeleton" data-shape="text"></span></td>
<td class="tr-cell"><span class="skeleton" data-shape="text"></span></td>
<td class="tr-cell"><span class="skeleton" data-shape="text"></span></td>
<td class="tr-cell"><span class="skeleton" data-shape="text"></span></td>
<td class="tr-cell"><span class="skeleton" data-shape="text"></span></td>
</tr>
</tbody>
</table>
<div class="table-empty">
<span class="table-empty-icon"><svg class="icon" aria-hidden="true"><use href="#i-inbox"/></svg></span>
<p class="table-empty-title">No customers found</p>
<p class="table-empty-hint">Adjust the filters to widen your search.</p>
</div>
</div>
</div>
<span class="grid-announce" data-grid-announce aria-live="polite" aria-atomic="true"></span>
<nav class="pagination" data-grid-pagination aria-label="Pagination"></nav>
</div>
</div>Server exchange
When the client affordance finishes (click, confirm, keystroke…), htmx issues this request. Your API must return the response fragment in the table — usually HTML, not JSON (unless the partial says otherwise). Dazzle often renders these routes from the app model; a standalone HTMX4 app implements them explicitly. The Envelope column is the exchange envelope (part of the Swap contract) — what the response may re-emit relative to the persistent slot.
Do not reimplement the gallery. Flash toasts (e.g. “Deleted (demo).”), /mock/* paths, and other static-site scaffolding are demo-only (MOCK_HTMX in site/build_site.py). They are not Hyperpart surface and not a product API. If an agent is stuck “making the toast work,” stop — implement the exchange row below instead.
| Request | Trigger | Response fragment | Swap | Envelope | States |
|---|---|---|---|---|---|
GET /app/{region}/rows?q=&sort=&dir=&page=&page_size= | the tbody, on `load` and on `grid:refresh` (fired by a sort click, a filter change, a debounced search keystroke, or a page control) — with `page=` added for pagination | the current page's `<tr>` rows for the query — each a `tr-row` carrying a stable `id` (the idiomorph morph key) plus `data-grid-row-id` (the bulk-action payload anchor) — plus the repainted pagination footer (via an OOB `<nav>` or a wrapping region swap); a zero-result query returns an empty tbody so the `:has(tbody tr td)`-driven empty-state shows | innerMorph of the tbody (`[data-grid-body]`) — idiomorph keys on each row's `id`, so a live selection follows its row across a re-sort — PLUS an out-of-band update of the pagination footer: append `<nav data-grid-pagination data-grid-total="N" hx-swap-oob="true">…</nav>` to the response (the stamped total feeds the all-matching affordance) (or target a wrapping region that contains both the tbody and the footer in one swap). The footer's current-page button carries `aria-current="page"` — the client reads it back as the authoritative (possibly server-clamped) page | body_only | loading empty populated error |
POST /app/{region}/bulk | a bulk-action button (e.g. Delete), after the user approves its confirm dialog; the controller injects the selection on `htmx:configRequest` | the server RE-VALIDATES permissions and RE-SCOPES the action to the echoed query (never trusting the client `selected_ids` alone) and applies it. Two patterns: with `data-grid-bulk-refresh` on the button (this demo), the response swaps NOTHING (JSON/204) and the controller re-fetches rows + footer via the normal GET; without it, put `hx-target` on the button and return the refreshed `<tr>` rows directly. When `all_matching_selected=true`, the action applies to the WHOLE matched query minus `excluded_ids` — the server re-runs the echoed query itself, and MUST strip `page`/`page_size` first (they window the display, not the matched set — re-running them verbatim would apply the action to one page only); `selected_ids` is informational (visible state) only. NB form encoding: with no exclusions the `excluded_ids` key is ABSENT from the POST (not sent empty) — default it to the empty list | innerMorph of the tbody (`[data-grid-body]`) plus the OOB footer (its `data-grid-total` re-stamps the matched total) | body_only | populated empty error |
PUT /app/{entity}/{id} | the inline-edit extension (grid-edit.js): dblclick an editable cell's display span opens an in-cell editor; Enter (or a change, for bool/select/date) commits a raw fetch PUT to `{data-grid-edit-url}/{rowId}` — NOT an htmx exchange | this is the entity's STANDARD update route, not a bespoke field endpoint: the body is a single-field JSON object (`{"plan": "Pro"}`), so an all-optional update schema + exclude-unset semantics make it a partial update, and the full update gate (permissions, scoping, validation) applies. Return 2xx JSON on success; any non-2xx keeps the editor open with the response text as its error. The controller then fires `grid:refresh` on the tbody, so the committed value renders SERVER-side (badges/dates re-render; no client patching) | none (raw fetch) — the follow-up `grid:refresh` re-fetches rows + footer via the tbody's normal GET | none | populated error |
Swap contract
Agent-visible HTMX topology (ADR-0054 / decision 0012). exchange envelope = what the response may re-emit relative to the persistent slot (body_only | outer | none | host_owned | document). dual-lock validates part markup only — not this envelope. Stem: stems/morph-safe-hypermedia.md; decision: docs/decisions/0012-swap-identity-contract.md.
Gallery mocks may approximate morph with innerHTML — production follows the Swap + Envelope columns in Server exchange.
Exchanges (swap · envelope)
GET /app/{region}/rows?q=&sort=&dir=&page=&page_size=→ innerMorph of the tbody (`[data-dz-grid-body]`) — idiomorph keys on each row's `id`, so a live selection follows its row across a re-sort — PLUS an out-of-band update of the pagination footer: append `<nav data-dz-grid-pagination data-dz-grid-total="N" hx-swap-oob="true">…</nav>` to the response (the stamped total feeds the all-matching affordance) (or target a wrapping region that contains both the tbody and the footer in one swap). The footer's current-page button carries `aria-current="page"` — the client reads it back as the authoritative (possibly server-clamped) page · envelope=body_onlyPOST /app/{region}/bulk→ innerMorph of the tbody (`[data-dz-grid-body]`) plus the OOB footer (its `data-dz-grid-total` re-stamps the matched total) · envelope=body_onlyPUT /app/{entity}/{id}→ none (raw fetch) — the follow-up `dz-grid:refresh` re-fetches rows + footer via the tbody's normal GET · envelope=none
Envelope rules
body_only— innerHTML / innerMorph into a slot; response is interior only (no re-wrap of slot id / nesteddata-dz-region).outer— outerHTML / outerMorph; response may carry identity.none— no HTML swap (JSON/204/bytes; client or OOB companion).host_owned— swap target/mode chosen by the host button’shx-target/hx-swap.document— full navigation / document load (not a fragment).- Slot owns stable
id/ domain keys; state in DOM, not Alpine.
Envelope response examples
What the server returns for each exchange on Data table. Match the exchange envelope; dual-lock still applies to interior markup.
GET /app/{region}/rows?q=&sort=&dir=&page=&page_size= · envelope=body_only
Correct response for body_only into [data-dz-grid-body] (innerHTML / innerMorph). Wrong: re-wrapping the slot.
Do — correct response body
<!-- envelope=body_only → <tr> rows (+ optional OOB footer), not a table -->
<tr class="dz-tr-row" id="row-42" data-dz-grid-row-id="42">
<td>…</td><td>…</td>
</tr>
<tr class="dz-tr-row" id="row-43" data-dz-grid-row-id="43">
<td>…</td><td>…</td>
</tr>
<!-- optional: -->
<!-- <nav data-dz-grid-pagination data-dz-grid-total="N" hx-swap-oob="true">…</nav> -->
Don’t — violates body_only
<!-- WRONG: full table / tbody root into [data-dz-grid-body] -->
<table class="dz-table" data-dz-grid>
<tbody>…</tbody>
</table>
POST /app/{region}/bulk · envelope=body_only
Correct response for body_only into [data-dz-grid-body] (innerHTML / innerMorph). Wrong: re-wrapping the slot.
Do — correct response body
<!-- envelope=body_only → <tr> rows (+ optional OOB footer), not a table -->
<tr class="dz-tr-row" id="row-42" data-dz-grid-row-id="42">
<td>…</td><td>…</td>
</tr>
<tr class="dz-tr-row" id="row-43" data-dz-grid-row-id="43">
<td>…</td><td>…</td>
</tr>
<!-- optional: -->
<!-- <nav data-dz-grid-pagination data-dz-grid-total="N" hx-swap-oob="true">…</nav> -->
Don’t — violates body_only
<!-- WRONG: full table / tbody root into [data-dz-grid-body] -->
<table class="dz-table" data-dz-grid>
<tbody>…</tbody>
</table>
PUT /app/{entity}/{id} · envelope=none
Correct response for none (raw fetch / no HTML swap).
Do — correct response body
// envelope=none — no HTML swap (JSON/204; client or OOB companion)
// HTTP 204 No Content
// or:
{ "ok": true }
// Application/json; status 200
// Optional: separate OOB HTML fragments if the host declares them
Don’t — violates none
<!-- WRONG: HTML body when hx-swap is none / raw fetch expects JSON|204 -->
<div class="dz-alert">Deleted</div>
How to use it
Seams
- column visibility: grid-cols.js projects the hidden set onto [data-col] cells after every swap — no per-cell bindings
- column resize: grid-resize.js rides the header cells
- inline edit: grid-edit.js reads the [data-grid-edit] display span (kind/value/label/options) — contract in contracts/grid_edit.py
- kind=date cells open a Field date group (native type=date + ISO companion) — leftover ISO must not invent a PUT of the previous date
- kind=time cells open a Field time group (native type=time / datetime-local + ISO companion) — leftover ISO must not invent a PUT of the previous clock (datetime columns map here, not date)
- kind=number cells open a Field number group (native type=number + decimal companion) — leftover junk must not invent a PUT of the previous number (number columns map here, not text)
- kind=select cells open a bare native <select> editor — NOT the combobox Hyperpart (dense row, morph-safe, commit-on-change PUT)
- row identity: a row's id IS the idiomorph morph key and encodes data-row-id (the bulk payload anchor)
Do / Don't
| Do | Don't |
|---|---|
| keep selection state in the DOM (.checked on the row checkbox) | mirror selection into a JS array a tbody swap would orphan |
| return full row fragments from the grid endpoint | return cell deltas the client must splice in |
| use bare select for in-cell enum edit (current contract) | assume grid dogfoods combobox because both have 'select' UX |
| innerMorph the tbody; give each row a stable `id` (morph key) + `data-grid-row-id` (bulk anchor) | innerHTML-replace the tbody without stable row ids (selection follows DOM position, not the entity) |
| use `hx-swap=none` + `grid:refresh` for bulk when a full re-fetch is clearer than splicing | morph a flash/toast region that should fully reset |
| park in-flight edit buffer on the grid root (outside the morph path) | store open-cell edit state only in a JS object the tbody morph drops |
Pitfalls
- edit state in JS objects dies on morph — the typed buffer lives on the grid root (root._dzEdit) with before/after-swap hooks
- select options must be JSON [[value,label],…] — producers with dicts/tuples/bare strings normalise at ONE boundary (#1573)
- never patch committed values client-side — commit fires grid:refresh so the server re-renders badges/dates
- leftover ISO junk (zzz / 2025-06-20zzz) must not PUT the previous date — Enter/Tab/change refuse while the companion is invalid
- leftover clock ISO (zzz / 14:30zzz / 2026-07-16T01:30zzz) must not PUT the previous time — datetime leftover must not invent a date
- leftover number junk (zzz / 12abc / 1e2) must not PUT the previous number — parseFloat leftover must not invent a value
- leftover page / page_size (2abc / zzz) must not invent a window — parseInt leftover must not deep-link to page 2
- ownedKeys / buildQuery must echo leftover-honest include_closed / as_of — dropping them invents open-only / current after refresh
- do not mount data-combobox inside a grid cell expecting grid-edit to drive it — that is a future composition, not current seam
Keyboard / AT
- Enter commits (text/date), Escape cancels an open editor
- Tab / Shift-Tab commit then advance to the next/previous editable cell, wrapping to the adjacent row
- row checkboxes carry aria-label 'Select {row}'
Related parts
Does not compose
Local primitives that look like another Hyperpart. Declared in the registry; CI-locked. See CONSUMER_MAP.md.
- does not use
combobox(kind=select in-cell editor): bare <select class=inline-edit-select> for density, morph survival (root._dzEdit + before/after-swap), and commit-on-change PUT — not the progressive-enhancement combobox overlay — spikedocs/spikes/combobox-in-grid-cell.md
DOM contract
4 dual-lock modules for this part (contracts/grid.py, contracts/grid_edit.py, contracts/grid_cols.py, contracts/grid_resize.py). Read top-to-bottom: core root first, then extensions. Each Exemplar render() live box is CI fixture output for that module — not a second demo of the whole Hyperpart. What the tables require is the emitted HTML; Python under contracts/ is package-internal dual-lock (not an app route). Request/response wiring: Server exchange.
contracts/grid.py
Required in the DOM: root [data-grid] (part grid). Emit only these attributes — inventing extras is fine only if controllers ignore them; omitting required ones fails CI (tests/test_contracts.py).
Root-only contract — the root selector must match; no per-node attribute list.
Module source
Import path is monorepo/package-local (from contracts._kit import …). Source-token form often uses data-*; gallery demos above are unprefixed. Do not copy this into app routes.
"""HYPERPART: grid — root contract (thin). The base grid's structural
root attributes; the data-bearing seams live in extension contracts
(grid_edit). Root-only: no ingestion model, no exemplars.
Leftover honesty (cycle 2157): URL ``?page=2abc`` / ``?page_size=2abc``
must not invent a window. ``parseInt("2abc", 10) === 2`` is leftover
junk — same class as PDF leftover page (2151). Empty / invalid
restores the server default. Valid whole numbers still window.
Rest-state gallery is unchanged (oral #33).
Leftover honesty (cycle 2170): ``ownedKeys`` / ``buildQuery`` must
echo leftover-honest ``include_closed`` / ``as_of``. Dropping them
from hx-get invented open-only / current after a refresh (page URL
foreign params survived; all-matching echo then invented). Leftover
junk (``zzz``, ``2abc``, ``maybe``, ``not-a-date``) must not invent.
Valid ``true`` / YYYY-MM-DD still ride hx-get. Not leftover list
include_closed / related-tab as_of / DETAIL as_of onto the edit form.
"""
from contracts._kit import DomContract
DOM_CONTRACT = DomContract(
part="grid",
root="[data-dz-grid]",
nodes=(),
)
__all__ = ["DOM_CONTRACT"]
contracts/grid_edit.py
Required in the DOM: root [data-grid][data-grid-edit-url] (part grid-edit). Emit only these attributes — inventing extras is fine only if controllers ignore them; omitting required ones fails CI (tests/test_contracts.py).
| Node | Attr | Constraint |
|---|---|---|
[data-grid-edit] | data-edit-kind | one of ['text', 'date', 'time', 'number', 'bool', 'select'] |
[data-grid-edit] | data-edit-value | present (any value) |
[data-grid-edit] | data-edit-label | present (any value) |
[data-grid-edit] | data-edit-options | JSON [[value, label], …]; required when {'data-edit-kind': 'select'} |
Ingestion model GridEditCell
Server-side shape before render — one normalisation boundary for producers.
| Field | Type | Required |
|---|---|---|
col | string | required |
kind | string ∈ ['text', 'date', 'time', 'number', 'bool', 'select'] | required |
value | string | required |
label | string | required |
options | array | null | optional |
Exemplar render()
Executable in CI: the Python below is render(); the boxed preview is render(EXEMPLARS[0]) — the first fixture the dual-lock tests emit, not a separate widget and not gallery mock data. Sample model: value=Fix the door, label=Title, col=title, kind=text.
def render(cell: GridEditCell) -> str:
"""Model → conforming display-span fragment (the seam the controller reads)."""
opts = ""
if cell.kind == "select" and cell.options is not None:
pairs = json.dumps([[v, label] for v, label in cell.options])
opts = f' data-dz-edit-options="{html.escape(pairs, quote=True)}"'
return (
f'<span class="dz-tr-cell-display" '
f'data-dz-grid-edit="{html.escape(cell.col, quote=True)}" '
f'data-dz-edit-kind="{cell.kind}" '
f'data-dz-edit-value="{html.escape(cell.value, quote=True)}" '
f'data-dz-edit-label="{html.escape(cell.label, quote=True)}"{opts}>'
f"{html.escape(cell.value)}</span>"
)
Live output of render(EXEMPLARS[0]) — fixture markup the dual-lock validates (sample field values only).
FastAPI feed example — grid-edit exemplar — how a server feeds the inline-edit seam
Package exemplar for feeding this seam (not the gallery mock). Prefer the part's Server exchange server_example when present — that is the product handler shape. Avoid from __future__ import annotations in real FastAPI route files (ADR-0014).
@app.get("/rows", response_class=HTMLResponse)
def rows() -> str:
"""A tbody fragment: what a real endpoint returns to fill the grid.
Mirrors Dazzle's shape: the grid ROOT (with data-dz-grid-edit-url)
is page furniture; this endpoint returns rows whose editable cells
carry the seam spans."""
cells = "".join(f"<td>{render(c)}</td>" for c in EXEMPLARS[:3])
return f'<tr id="row-1">{cells}</tr>'
contracts/grid_cols.py
Required in the DOM: root [data-grid] (part grid-cols). Emit only these attributes — inventing extras is fine only if controllers ignore them; omitting required ones fails CI (tests/test_contracts.py).
| Node | Attr | Constraint |
|---|---|---|
[data-grid-col-toggle] | data-grid-col-toggle | present (any value) |
[data-col] | data-col | present (any value) |
[data-grid-cols-reset] | — | — |
Module source
Import path is monorepo/package-local (from contracts._kit import …). Source-token form often uses data-*; gallery demos above are unprefixed. Do not copy this into app routes.
"""HYPERPART: grid (extension: dz-grid-cols) — column visibility seam."""
from contracts._kit import DomContract, Node, Present
DOM_CONTRACT = DomContract(
part="grid-cols",
root="[data-dz-grid]",
nodes=(
Node("[data-dz-grid-col-toggle]", attrs={"data-dz-grid-col-toggle": Present()}),
Node("[data-dz-col]", attrs={"data-dz-col": Present()}),
Node("[data-dz-grid-cols-reset]", attrs={}),
),
)
__all__ = ["DOM_CONTRACT"]
contracts/grid_resize.py
Required in the DOM: root [data-grid] (part grid-resize). Emit only these attributes — inventing extras is fine only if controllers ignore them; omitting required ones fails CI (tests/test_contracts.py).
| Node | Attr | Constraint |
|---|---|---|
[data-grid-resize] | data-grid-resize | present (any value) |
col[data-col], [data-col] | data-col | present (any value) |
Module source
Import path is monorepo/package-local (from contracts._kit import …). Source-token form often uses data-*; gallery demos above are unprefixed. Do not copy this into app routes.
"""HYPERPART: grid (extension: dz-grid-resize) — column resize seam."""
from contracts._kit import DomContract, Node, Present
DOM_CONTRACT = DomContract(
part="grid-resize",
root="[data-dz-grid]",
nodes=(
Node("[data-dz-grid-resize]", attrs={"data-dz-grid-resize": Present()}),
Node("col[data-dz-col], [data-dz-col]", attrs={"data-dz-col": Present()}),
),
)
__all__ = ["DOM_CONTRACT"]
Notes
hx-get on load fetches the rows, and innerMorph swaps them in. Each row carries a stable id (the idiomorph morph key) so a selection follows its row — not its DOM position — across a re-sort or paginate; data-grid-row-id stays the bulk-action payload anchor, and the id encodes it so the two agree. Loading is pure-CSS (.htmx-request → the overlay, #972 — no controller flag idiomorph could strip). Selection is delegated + state-in-DOM: grid.js counts the checked [data-grid-select] boxes, writes the total to data-bulk-count, and the CSS reveals the .bulk-actions bar; the count / select-all tri-state re-sync on change and on htmx:afterSwap. Sorting is delegated + state-in-DOM too: a header button ([data-grid-sort]) cycles its column none → ascending → descending → none (state on the th's aria-sort, one active column), rebuilds the tbody's request query, and fires grid:refresh so the server returns the re-ordered rows — no client-side row rendering. Filters and search ride the same seam: a [data-grid-filter] select (on change) and the [data-grid-search] box (on input, debounced) each rebuild the query and compose with the active sort — all read from the DOM into one query; an empty result reveals the empty-state. Note the Status filter is a teaching case: the table renders no Status column, yet the filter narrows on it — filters (like scopes) can target any queryable server field, not only what's displayed (here only Plan is both shown and filtered). Bulk actions post the selection safely: the [data-grid-bulk-action] Delete button (behind its confirm dialog) sends the action + selected ids + the current query — so the server re-scopes and re-validates rather than trusting client ids (§15). Select all N results escalates a page selection to the whole result set for the current query — search + filters + sort scope, including rows on other pages (not “visually similar” rows). State on the root: data-grid-all-matching + a data-grid-excluded JSON list of unchecked exceptions) — rows on other pages arrive selected, the count shows the server-stamped matched total (the footer's data-grid-total), and a bulk action sends all_matching_selected=true + excluded_ids so the server applies it to the matched set minus exclusions. A filter or search change drops the mode (the matched set changed); sort and paging keep it. The footer is server-rendered: the client intercepts a page click, adds page= to the query, and the server returns that page's rows plus the repainted footer (row slice + total from one query, so they can't disagree); sort / filter / search reset to page 1. The Per page select is a windowing control on the same seam ([data-grid-page-size] → page_size=): it re-pages the same matched set, resets to page 1, and — unlike a filter/search change — keeps an all-matching selection. State is URL-synced (data-grid-url, opt-in): the grid's query mirrors into the address bar as the same human-readable params the server sees — deep links restore on load (before the hydration fetch, so no double fetch), discrete actions push history entries (Back walks grid states), the debounced search replaces, and foreign URL params survive (the grid only touches its own keys). The all-matching selection is ephemeral and deliberately NOT in the URL. The three extensions are opt-in per grid, keyed off their own seams. Column visibility (grid-cols.js): the Columns <details> menu's checkboxes ([data-grid-col-toggle]) project a hidden set onto every [data-col] cell — header, hydrated tds, and the colgroup's <col> — persisted per grid id in localStorage; re-fetched rows re-hide on swap; stale keys prune at init. Column resize (grid-resize.js): a pointer drag on the in-th handle ([data-grid-resize]) widens col[data-col] live (snap-8, clamp 80–800px), persists per grid, and never fires the header's sort; the table stays table-layout:auto, so a width is a strong hint. Inline edit (grid-edit.js): dblclick a cell's display span ([data-grid-edit] + data-edit-kind/-value/-label/-options) to open an in-cell editor; Enter commits, Escape cancels, Tab advances — the commit is a single-field JSON PUT to the entity's standard update route (data-grid-edit-url on the root; no bespoke field endpoint), and a grid:refresh re-renders the row server-side. An in-flight edit survives a tbody swap: the buffer lives on the grid root, outside the morph path. (The gallery mock approximates the innerMorph swap with an innerHTML replace — copy the snippet into a real htmx4 app, with the idiomorph extension for hx-swap="innerMorph", for true morph-preserved selection.)Source files
One logical Hyperpart, 12 code items (CSS layered, JS bundled). Bound by HYPERPART: grid — python tools/hyperpart.py grid lists them.
site/registry.py · contracts/grid.py · contracts/grid_edit.py · contracts/grid_cols.py · contracts/grid_resize.py · components/table.css:1 · components/table.css:263 · controllers/dz-grid.js · controllers/dz-grid-cols.js · controllers/dz-grid-resize.js · controllers/dz-grid-edit.js · mock /mock/grid