Grid Sorting

Sorting

Ordered multi-column sort: header cycles, priority badges, controlled state, and typed row-model queries.

Every column is sortable by default. Click a header to sort by that column; shift-click other headers to build an ordered multi-column cascade — later keys break ties left by earlier ones. Set sortable: false on a column to opt it out: clicks and shift-clicks on it are no-ops.

Multi-column sort cascade

Click a header to sort by it, shift-click others to build an ordered cascade — each sorted header grows a priority badge, and shift-clicking a middle key back to unsorted renumbers the rest.

.md

The sort model

Sort state is an ordered list of entries, not a single column:

ts
interface PretableSortEntry {
  columnId: string;
  direction: "asc" | "desc";
}
 
// query.sort and snapshot.query.sort are both:
type Sort = PretableSortEntry[];
  • Index = priority. sort[0] is the primary key, sort[1] breaks its ties, and so on.
  • [] means unsorted — rows appear in source order.
  • Sorting is stable. Rows tied on every key keep their source order.
  • Comparison keys on the values. When every cell value in a column is a number, the column compares numerically; otherwise values are stringified and compared locale-aware (numeric-aware, case-insensitive).
  • Unknown column ids are rejected with a typed invalid-query error. The surface never emits sort entries for sortable: false columns; clicks on those headers are no-ops.

Under row grouping the sort is applied to the whole row set first and then bucketed, so rows sort within their group; sibling groups order by their key value, ascending unless a sort entry on that level's column says otherwise.

Header interactions

Plain click cycles a single-column sort: unsorted → descasc → unsorted. It always replaces the whole list — clicking a header while a multi-column cascade is active collapses the sort to just that column's next cycle step. (Edge case: if that column was already asc, the next step is unsorted, so the whole sort clears.)

Shift-click edits the list in place, one column at a time — this is the gesture the example above walks you through:

  • On an unsorted column: appends { columnId, direction: "desc" } to the end of the list (lowest priority).
  • On a column already sorted desc: flips that entry to asc without moving it.
  • On a column already sorted asc: removes just that entry — the other entries keep their relative order, and their priorities renumber.

So shift-click walks the same desc → asc → none cycle as a plain click, scoped to one entry of the list.

By keyboard, both gestures are the same two keys on a focused header: from the first data row puts the cursor on that column's header, and Enter or Space there is the plain click while Shift + Enter is the shift-click. The header cell is a real <button>, so these run its native activation — the identical onClick and therefore the identical cycles above, not a parallel implementation. See Keyboard § The column header.

Priority badges

When two or more columns are sorted, each sorted header shows its 1-based priority next to the direction indicator — the small number that appeared on Status, then Region, then Total as you built the cascade above, and that renumbered when a middle entry was removed. The badge is a <span data-pretable-sort-priority> whose text is the priority number — style or query it via that attribute. With a single sorted column no badge renders.

Each sorted header also carries aria-sort="ascending" or "descending" ("none" otherwise), keyed to that column's own entry.

Controlling the query

Sort is uncontrolled when the query pair is omitted. To seed, observe, or persist it, own the complete typed query and publish every returned query — the example above does exactly this, which is how its caption can echo the live sort array as you click:

tsx
import { useState } from "react";
import type { PretableQueryFor } from "@pretable/core";
import { PretableSurface } from "@pretable/react";
 
function OrdersGrid() {
  const [query, setQuery] = useState<PretableQueryFor<typeof columns>>({
    filters: [],
    sort: [
      { columnId: "status", direction: "asc" },
      { columnId: "total", direction: "desc" },
    ],
    rowGroups: [],
  });
 
  return (
    <PretableSurface
      ariaLabel="Orders"
      columns={columns}
      rows={rows}
      getRowId={(row) => row.id}
      query={query}
      onQueryChange={setQuery}
      viewportHeight={360}
    />
  );
}

sort: [] is explicitly unsorted. To return ownership to the local row model, omit both query and onQueryChange. Explicit-model mode changes sorting through rowModel.setQuery(...), not surface props.

Headless

Sorting is one slice of the row model's typed query:

ts
const snapshot = rowModel.getState().snapshot;
const transition = rowModel.setQuery({
  ...snapshot.query,
  sort: [
    { columnId: "status", direction: "asc" },
    { columnId: "total", direction: "desc" },
  ],
});
await transition.finished;

The transition publishes the new order atomically. Use sort: [] to restore source order, and read the required output window with snapshot.range(start, end). See the headless API reference.

See also