Skip to content

Datagrid sort

Header clicks are the fast path for sorting and they stay. What they cannot do is answer what the current sort set is — so this recipe gives sort the same treatment conditions got: one surface that is both the read-out and the editor. The trigger’s label is the read-out, rendered by the server from the applied set; the panel is an ordered list you can reorder, flip, extend and trim. The wire is unchanged (?sort=-ship,order), so a saved view captures the sort like any other part of the question.

Open the sort button: drag a key, or focus a handle and press Space then then Space. Flip a direction, remove a key, or add Warehouse — a column the grid does not even show, which is exactly the case a header click cannot serve. Apply answers the sorted grid, and the trigger and panel come back out of band, so the two surfaces cannot drift.

Loading…

The header canThe header cannot
sort by the column you can see, in one clicksay what the whole set is, in order
flip a directionlet you re-order the keys without re-clicking in sequence
sort by a column that is scrolled out of view or not shown at all
teach anyone that Shift-click adds a second key

With thirty columns the sorted one is usually off-screen, so aria-sort and data-sort-index are answering a question nobody can see.

<button class="hc-button" type="button" id="sort-trigger" popovertarget="sort-panel">
Sort (2): Ship date ↓, Order ↑
</button>
<div class="hc-popover" id="sort-panel" popover
data-side="bottom" data-align="start" aria-labelledby="sort-trigger">
<form action="/orders" method="get"
data-hx-get="/orders"
data-hx-include="#filters"
data-hx-target="#orders"
data-hc-close-popover-on-success>
<!-- The server owns this region; add and remove answer it. It opts
OUT of the close-on-success glue, so editing the sort does not
dismiss the panel being edited. -->
<div class="hc-popover__body" id="sort-keys"
data-hc-close-popover-on-success="false">
<ul class="hc-stack" data-hc-sortable data-hc-sort-list="sort">
<li class="hc-item" data-hc-sortable-id="ship" data-hc-sort-key="ship">
<button class="hc-button" data-variant="ghost" type="button"
data-hc-sortable-handle aria-label="Reorder Ship date"></button>
<span class="hc-item__title">Ship date</span>
<select class="hc-select" name="dir-ship" aria-label="Ship date direction">
<option value="asc">Ascending</option>
<option value="desc" selected>Descending</option>
</select>
<button class="hc-button" data-variant="ghost" data-size="sm" type="submit"
name="drop" value="ship" formaction="/orders/sort"
data-hx-get="/orders/sort?drop=ship"
data-hx-include="closest form"
data-hx-target="#sort-keys" data-hx-swap="outerHTML"
aria-label="Remove Ship date from the sort">Remove</button>
</li>
<!-- …one row per key, in order… -->
</ul>
<!-- …the add control: a labelled <select name="add"> of the
columns not yet in the set + an Add submit, wired like
Remove (formaction="/orders/sort",
data-hx-target="#sort-keys")… -->
</div>
<footer class="hc-popover__footer">
<button class="hc-button" data-variant="primary" type="submit">Apply</button>
</footer>
</form>
</div>

The order of the rows is the order of the keys. Nothing duplicates that state: installSortable() reorders the nodes (pointer and keyboard), and that is the model.

?sort=-ship,order ship date descending, then order ascending

installSortList() joins the ordered rows into that one param on the formdata event — the hook htmx and a native submit both fire — in the position the first of them held, so the same sort serializes identically whether or not the behavior ran. That is what lets a saved view compare querystrings to decide whether it has been modified.

It is the same format the grid’s own header sorting mirrors into input[data-hc-datagrid-sort]. One format, two surfaces.

An empty list sends no sort param at all — no sort is not a sort, and the server’s default ordering returns.

CaseResponse
GET /orders?sort=-ship,order&<conditions>200 + the sorted grid, header cells carrying the matching aria-sort / data-sort-index, plus OOB re-renders of the trigger and the panel region
GET /orders/sort?add=<col>200 + the panel region with that key appended, the column gone from the add list
GET /orders/sort?drop=<col>200 + the panel region without that key, the column offered again
an unknown or unsortable keyignore that key, keep the rest — never 500, never silently sort by something else
no sort at allthe default ordering, and the trigger says so (Sort: default)

Add and remove are server round trips because which columns are available is the server’s knowledge — it changes with permissions, with the column set, and with the data. The client never invents a row.

Both render from the same server-side sort set. A header click marks the instruction and mirrors the wire into input[data-hc-datagrid-sort]; the response returns the sorted page and the re-rendered trigger and panel. Never render the panel from client state — if the two can drift, the read-out is worthless.

  • A saved view captures it: “overdue shipments, oldest first” is one question.
  • Paging does not change it, and changing the sort resets to page 1 — page 7 of a different ordering is a different set of rows.
  • Sorting happens on the whole result set, server-side. Client-side sorting of the loaded page reorders forty rows out of five thousand and looks exactly like the real thing. (The datagrid’s data-sortable="client" opt-in exists for small, fully-loaded tables; it is not this.)
  • Ties must break deterministically — append the primary key as a final, invisible key — or paging a low-cardinality sort repeats and drops rows between requests.

The panel is a plain <form method="get">: Apply navigates, the per-key controls carry the order, and Remove / Add are submit buttons with formaction. Reordering by drag needs installSortable(), so keep a no-JS route to the same result (remove and re-add in the order wanted, or a per-row “move up” submit). Header links (?sort=…) remain the zero-JS fast path.

  • Each handle is a real <button> whose accessible name includes the column (“Reorder Ship date”) — it is the keyboard interface: Space grabs, arrows move, Space drops, Esc cancels.
  • Direction controls are labelled per key (“Ship date direction”), so the label still says what it means out of context.
  • Remove buttons name their key.
  • Committed reorders announce through the shared role="status" region (i18n keys sortable.*).
  • The grid keeps aria-sort on its header cells — that is what a screen reader reads on the table itself.
  • Datagridaria-sort, data-sort-index, and the header-click fast path
  • Sortable — the reordering behavior this list is built on
  • Datagrid filter — the conditions the sort rides with
  • Saved views — where a sort set is stored with its question