server-rendered markup — checkboxes are form controls
(request.POST.getlist('selected')), sorting is plain
<a href="?o=…"> links marked with
aria-sort (Unpoly swaps the fragment; the links here just
change the hash), pagination is a plain <footer>
|
|
PO # | Vendor | Date | Total | Status |
|---|---|---|---|---|---|
| PO-2026-0142 | Acme Corp | 2026-05-05 | $12,400.00 | Pending | |
| PO-2026-0141 | Globex Ltd | 2026-05-04 | $3,180.50 | Approved | |
| PO-2026-0140 | Initech | 2026-05-02 | $940.00 | Received · locked | |
| PO-2026-0139 | Stark Industries | 2026-04-28 | $67,000.00 | Draft |
client-side sorting — th[data-sort] with a
<button> (no link) emits materialSort;
the page reorders rows and sets aria-sort itself
| Bolt M6 | 1,204 |
| Anchor plate | 88 |
| Washer 12mm | 470 |
| Clamp | 12 |
tr[data-row-link] — clicking dead space proxies to the
row's first link (Unpoly attrs on it keep working); clicks on
controls inside the row are left alone
| Customer | Owner | |
|---|---|---|
| Acme Corp | M. Fowler | |
| Globex Ltd | D. Kim |
activated: —
sticky-header + a max-height on the host (it is the
scroll container)
| # | Event | Duration |
|---|
Unpoly-style fragment swap — replacing the tbody resets
the selection and re-syncs the header checkbox via MutationObserver;
loading dims the body during the round-trip
| Task | |
|---|---|
| Ship build | |
| Review PR |
selection events: —
dense rows · empty state is a server-rendered
td.empty
| Code | Qty |
|---|---|
| SKU-001 | 14 |
| SKU-002 | 3 |
| SKU-003 | 151 |
| Invoice | Amount |
|---|---|
| No invoices match the current filters | |
row grouping — the server renders tr.row-group headers
with the aggregates already computed; clicking (or Enter) folds the
member rows and emits materialGroupToggle
| Order | Customer | Total |
|---|---|---|
| West region · 2 orders | $15,580.50 | |
| SO-1041 | Acme Corp | $12,400.00 |
| SO-1038 | Globex Ltd | $3,180.50 |
| East region · 3 orders | $68,240.00 | |
| SO-1043 | Initech | $940.00 |
| SO-1042 | Stark Industries | $67,000.00 |
| SO-1036 | Wayne Ent. | $300.00 |
toggled: —
inline editing — ordinary form controls with class
cell-edit read as text until focused and post with the
page form (Django formsets); changes are relayed as
materialCellEdit
| SKU | Qty | Status |
|---|---|---|
| SKU-001 | ||
| SKU-002 |
edited: —
RTL — text alignment, selection column, numeric cells and sort arrows mirror with the reading direction
| المورد | الإجمالي | |
|---|---|---|
| شركة أكمي | 12,400 | |
| جلوبكس المحدودة | 3,180 |
virtual — 3,000 server-rendered rows, only the on-screen
window is displayed (two spacer rows keep the scrollbar honest); rows
never leave the DOM so the checkbox selection and select-all sweep
keep working. Pair with sticky-header + a max-height.
Rows use material-checkbox; the selection protocol equally
accepts native <input type="checkbox"> (tinted via
accent-color) — worth switching to beyond a few thousand
rows, where hydrating one custom element per row starts to cost
noticeable load time.
| Invoice | Customer | Amount |
|---|
resizable reorderable sticky-start="2" — drag a header
edge to resize, drag a header sideways to reorder (a plain click still
sorts), first two columns stay pinned during horizontal scroll.
materialColumnResize / materialColumnReorder
let the server persist the layout.
| PO # | Vendor | Date | Buyer | Terms | Warehouse | Status | Total |
|---|---|---|---|---|---|---|---|
| PO-0142 | Acme Corp | 2026-05-05 | M. Fowler | Net 30 | WH-North | Pending | $12,400.00 |
| PO-0141 | Globex Ltd | 2026-05-04 | D. Kim | Net 60 | WH-South | Approved | $3,180.50 |
| PO-0140 | Initech | 2026-05-02 | P. Gibbons | Prepaid | WH-North | Received | $940.00 |
| PO-0139 | Stark Industries | 2026-04-28 | V. Potts | Net 30 | WH-East | Draft | $67,000.00 |
layout events: —
mobile-friendly columns — th[data-hide-below="720"] hides a
column while the container (not the viewport) is narrower than
the threshold; a "…" button per row folds out the hidden fields inline.
No card stacking, no horizontal scroll needed.
Sample usage (Django / Unpoly)
<form method="post" action="{% url 'po-bulk' %}">
<material-data-table sticky-header class="max-h-[70vh]">
<table up-target="table">
<thead><tr>
<th class="cell-select">
<material-checkbox data-select-all aria-label="Select all"></material-checkbox>
</th>
<th aria-sort="{{ sort.number }}"><a href="?o=number" up-target="table">PO #</a></th>
<th class="numeric"><a href="?o=total" up-target="table">Total</a></th>
</tr></thead>
<tbody>
{% for po in page %}
<tr data-row-link>
<td class="cell-select">
<material-checkbox name="selected" value="{{ po.pk }}"
aria-label="Select {{ po }}"></material-checkbox>
</td>
<td><a href="{{ po.get_absolute_url }}" up-layer="new">{{ po.number }}</a></td>
<td class="numeric">{{ po.total }}</td>
</tr>
{% empty %}
<tr><td class="empty" colspan="3">No purchase orders</td></tr>
{% endfor %}
</tbody>
</table>
<footer>{% include "pagination.html" %}</footer>
</material-data-table>
<button>Archive selected</button> <!-- request.POST.getlist('selected') -->
</form>
<!-- v2: virtual rows / resize / reorder / pinned columns / groups / editing -->
<material-data-table virtual sticky-header class="max-h-[70vh]"
resizable reorderable sticky-start="2">
<table>
<thead><tr>
<th>Order</th>
<th data-hide-below="720">Date</th> <!-- responsive: hidden in narrow containers,
reachable via the per-row "…" fold-out -->
</tr></thead>
<tbody>
<tr class="row-group{% if g.folded %} collapsed{% endif %}" data-group="{{ g.key }}">
<td colspan="3">{{ g.title }} · {{ g.count }}</td>
<td class="numeric">{{ g.total }}</td> <!-- aggregates: server-side -->
</tr>
<tr>…
<td class="numeric">
<input class="cell-edit" name="qty-{{ item.pk }}" value="{{ item.qty }}">
</td>
</tr>
</tbody>
</table>
</material-data-table>
<script>
table.addEventListener('materialSelectionChange', (e) => e.detail); // {values, count, allSelected}
table.addEventListener('materialSort', (e) => e.detail); // {column, direction} (client-side mode)
table.addEventListener('materialColumnResize', (e) => e.detail); // {column, width} — persist per user
table.addEventListener('materialColumnReorder', (e) => e.detail); // {column, from, to}
table.addEventListener('materialGroupToggle', (e) => e.detail); // {group, collapsed}
table.addEventListener('materialCellEdit', (e) => e.detail); // {name, value}
</script>