material-data-table

Progressive enhancement over a server-rendered <table> (Django / Unpoly fragment swaps / any framework). Server-driven sort links, form-posting row selection, sticky header, row links — plus v2: virtual rows, column resize/reorder, pinned columns, row grouping, inline cell editing. M2 anatomy redrawn with M3 tokens.

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>

0 selected
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
1–4 of 132

client-side sorting — th[data-sort] with a <button> (no link) emits materialSort; the page reorders rows and sets aria-sort itself

Bolt M61,204
Anchor plate88
Washer 12mm470
Clamp12

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

CustomerOwner
Acme Corp M. Fowler
Globex Ltd D. Kim

activated:

sticky-header + a max-height on the host (it is the scroll container)

#EventDuration

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

CodeQty
SKU-00114
SKU-0023
SKU-003151
InvoiceAmount
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

OrderCustomerTotal
West region · 2 orders $15,580.50
SO-1041Acme Corp$12,400.00
SO-1038Globex Ltd$3,180.50
SO-1043Initech$940.00
SO-1042Stark Industries$67,000.00
SO-1036Wayne 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

SKUQtyStatus
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.

0 selected
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-0142Acme Corp2026-05-05 M. FowlerNet 30WH-NorthPending $12,400.00
PO-0141Globex Ltd2026-05-04 D. KimNet 60WH-SouthApproved $3,180.50
PO-0140Initech2026-05-02 P. GibbonsPrepaidWH-NorthReceived $940.00
PO-0139Stark Industries2026-04-28 V. PottsNet 30WH-EastDraft $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.

Container:
Order Customer Owner Date Total Status
SO-1041Acme CorpM. Fowler 2026-05-05$12,400.00Pending
SO-1038Globex LtdD. Kim 2026-05-04$3,180.50Approved
SO-1036Wayne Ent.V. Potts 2026-04-28$300.00Draft
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>