Foundations

Sorting a view

Showing records in another order than they are kept in, without moving one, and without a row jumping as it is edited.

Markdown

Sorting shows records in the order of one of their fields. It changes what people see, not what is stored: the records stay as they are kept, and the view remembers where each one is.

Best practices

Sort the view, not the data. Sorting is a way of looking. It does not reorder the records, is not a change to undo, and is not saved with them unless the application chooses to remember it.

Keep equal records in the order they had. Sorting is stable: records with the same value stay in the order they are kept in, so that sorting by one column and then another gives what people expect.

Put nothing last. A record with no value for the field sorted by comes after every record that has one — ascending and descending alike. Missing values are not the smallest values; they are not values.

Order each kind as it is understood. Numbers by size; dates by time; text as a person would file it, without regard to case or accent and with 2 before 10; a yes-or-no with no before yes; a choice in the order its options are offered, which is the order that means something, not the order of their names.

Do not move a row as it is edited. When the value a row is sorted by is changed, the row stays where it is until the view is sorted again. A row that jumps away as it is typed in takes the person's place with it. Likewise a record that is added is shown where it was added.

Put back what is undone where it was shown. Undoing a removal returns the rows to the places they were shown in, under the sort they were removed under.

Keep people's place. Sorting keeps the active cell on its record, wherever the record goes.

Say what the order is. The column sorted by shows it, and which way; the change is said to those who cannot see it.

Sort afresh when the data is replaced. New records from elsewhere are sorted as they arrive.

Conventions

IntentPointerKeyboard
Sort ascendingPress the column's sort buttonAlt-Shift-Up Arrow in the column
Sort descendingPress it againAlt-Shift-Down Arrow in the column
Stop sortingPress it a third timeThe same key again

See also Data grid.

Behaviour

Sorting

Records are shown in the order of the field sorted by, and every record is shown once
fc.assert(
  fc.property(values, fc.constantFrom<1 | -1>(1, -1), (ns, direction) => {
    const rows = itemsOf(ns);
    const order = sortedOrder(rows, "n", number, direction);
    const sorted = Array.from(order, (place) => rows[place]!.n).filter((n) => n !== null);
    const inOrder = sorted.every(
      (n, index) => index === 0 || direction * (n - sorted[index - 1]!) >= 0,
    );
    return inOrder && new Set(order).size === rows.length;
  }),
);
Records that compare equal keep the order they are kept in, whichever way the rest are sorted
const rows = itemsOf([2, 1, 2, 1, 2]);
expect(shown(rows, sortedOrder(rows, "n", number, 1))).toEqual([1, 3, 0, 2, 4]);
expect(shown(rows, sortedOrder(rows, "n", number, -1))).toEqual([0, 2, 4, 1, 3]);
A record with nothing in the field comes last, ascending and descending alike
const rows = itemsOf([null, 3, null, 1]);
expect(shown(rows, sortedOrder(rows, "n", number, 1))).toEqual([3, 1, 0, 2]);
expect(shown(rows, sortedOrder(rows, "n", number, -1))).toEqual([1, 3, 0, 2]);
Each kind is put in the order that means something for it
const text = compareFor({ kind: "text" }, "en");
expect(["item 10", "Item 2", "étude", "eta"].toSorted(text)).toEqual([
  "eta",
  "étude",
  "Item 2",
  "item 10",
]);
const date = compareFor({ kind: "date" });
expect(["2026-10-13", "2025-12-31"].toSorted(date)).toEqual(["2025-12-31", "2026-10-13"]);
const flag = compareFor({ kind: "boolean" });
expect([true, false, true].toSorted(flag)).toEqual([false, true, true]);
const choice = compareFor({
  kind: "choice",
  options: [
    { value: "low", label: "Low" },
    { value: "high", label: "High" },
  ],
});
// As the choices are offered, not as their names are spelled.
expect(["high", "low"].toSorted(choice)).toEqual(["low", "high"]);
Where each record is shown is the order turned about
fc.assert(
  fc.property(values, (ns) => {
    const order = sortedOrder(itemsOf(ns), "n", number);
    const places = placesIn(order);
    return order.every((place, row) => places[place] === row);
  }),
);

Through changes

A change to a value moves nothing, even a value that is sorted by
const rows = itemsOf([3, 1, 2]);
const order = sortedOrder(rows, "n", number);
expect(orderAfter(order, { kind: "cells", cells: [{ row: 1, key: "n", value: 9, previous: 1 }] })).toBe(
  order,
);
Records added at the end are shown at the end, whatever they hold
const rows = itemsOf([3, 1, 2]);
const change = { kind: "insert" as const, at: 3, rows: [{ id: 3, n: 0 }] };
const order = orderAfter(sortedOrder(rows, "n", number), change);
expect(shown(applyChange(rows, change), order)).toEqual([1, 2, 0, 3]);
Records put in among others are shown in front of the one they were put in front of
const rows = itemsOf([3, 1, 2]);
const change = {
  kind: "insert" as const,
  at: 1,
  rows: [
    { id: 7, n: 0 },
    { id: 8, n: 0 },
  ],
};
const order = orderAfter(sortedOrder(rows, "n", number), change);
// Shown were 1, 2, 0: the new ones go in front of the record that was at place 1.
expect(shown(applyChange(rows, change), order)).toEqual([7, 8, 1, 2, 0]);
After any insertion or removal, every record still there is shown once, in the order it was in
fc.assert(
  fc.property(
    values.filter((ns) => ns.length > 0),
    fc.array(fc.nat(29), { maxLength: 6 }),
    fc.nat(30),
    fc.boolean(),
    (ns, picks, at, removing) => {
      const rows = itemsOf(ns);
      const order = sortedOrder(rows, "n", number);
      const change = removing
        ? { kind: "remove" as const, runs: runsOf(rows, picks) }
        : { kind: "insert" as const, at: Math.min(at, rows.length), rows: [{ id: 100, n: null }] };
      const after = applyChange(rows, change);
      const next = orderAfter(order, change);
      const now = shown(after, next);
      const kept = shown(rows, order).filter((id) => after.some((row) => row.id === id));
      return (
        next.length === after.length &&
        new Set(next).size === after.length &&
        JSON.stringify(now.filter((id) => id !== 100)) === JSON.stringify(kept)
      );
    },
  ),
);

React

Install

npx shadcn@latest add https://ui.publictheta.com/r/view-order.json
Source

src/primitives/view-order.ts

/**
 * The order records are shown in, where that is not the order they are kept in:
 * sorted by a column, say. The order is a list of places — for each row of the
 * view, where its record is in the data — in one typed array, so that sorting a
 * hundred thousand records moves numbers and no record.
 *
 * - Sorting is stable: records that compare equal keep the order they are kept in.
 * - Nothing is last: a record with no value for what is sorted by comes after
 *   every record that has one, whichever way the rest are sorted.
 * - An edit does not move anything. When a value that is sorted by is changed,
 *   the row stays where it is until the view is sorted again: a row that jumps
 *   away as it is typed in takes the person's place with it.
 * - Records added are shown where they were added, and records removed leave
 *   the rest in their order.
 */
import type { AnyRecord, RecordChange } from "./record-changes.ts";
import type { Schema } from "./schema.ts";

/** For each row of the view, the place of its record in the data. */
export type ViewOrder = Uint32Array;

/** A comparison of two values, neither of them absent: negative when the first comes first. */
export type Compare = (a: unknown, b: unknown) => number;

const absent = (value: unknown) => value === null || value === undefined || value === "";

/** How values of a schema's kind are put in order. */
export function compareFor(schema: Schema, locale?: string): Compare {
  switch (schema.kind) {
    case "number":
      return (a, b) => (a as number) - (b as number);
    case "boolean":
      // No before yes.
      return (a, b) => Number(a) - Number(b);
    case "choice": {
      // In the order the choices are offered, which is the order that means something.
      const place = new Map(schema.options.map((option, index) => [option.value, index]));
      const last = schema.options.length;
      return (a, b) => (place.get(a as string) ?? last) - (place.get(b as string) ?? last);
    }
    case "date":
      // ISO dates are in order as text.
      return (a, b) => (a === b ? 0 : (a as string) < (b as string) ? -1 : 1);
    default: {
      // As people sort names: without regard to case or accent, and with "2" before "10".
      const collator = new Intl.Collator(locale, { numeric: true, sensitivity: "base" });
      return (a, b) => collator.compare(String(a), String(b));
    }
  }
}

/**
 * The order that shows `rows` sorted by one field. `direction` is 1 for
 * ascending and -1 for descending; records with nothing in the field come last
 * either way.
 */
export function sortedOrder(
  rows: readonly AnyRecord[],
  key: string,
  compare: Compare,
  direction: 1 | -1 = 1,
): ViewOrder {
  const order = new Uint32Array(rows.length);
  for (let index = 0; index < order.length; index += 1) order[index] = index;
  // Typed arrays sort in place, and stably: equal records keep the order they are kept in.
  // oxlint-disable-next-line unicorn/no-array-sort
  return order.sort((i, j) => {
    const a = rows[i]![key];
    const b = rows[j]![key];
    const none = Number(absent(a)) - Number(absent(b));
    if (none !== 0 || absent(a)) return none;
    return direction * compare(a, b);
  });
}

/** For each record in the data, the row of the view it is shown in: the order, turned about. */
export function placesIn(order: ViewOrder): Uint32Array {
  const places = new Uint32Array(order.length);
  for (let row = 0; row < order.length; row += 1) places[order[row]!] = row;
  return places;
}

/**
 * The order after a change to the records, with every row that is still there
 * where it was. Records inserted are shown before the row that held the record
 * they were put in front of — at the end, when they were put at the end.
 */
export function orderAfter(order: ViewOrder, change: RecordChange): ViewOrder {
  switch (change.kind) {
    case "cells":
      return order;
    case "insert": {
      const added = change.rows.length;
      const next = new Uint32Array(order.length + added);
      // The row that showed the record now pushed along: the new ones go in front of it.
      let at = order.length;
      for (let row = 0; row < order.length; row += 1) {
        if (order[row] === change.at) {
          at = row;
          break;
        }
      }
      for (let row = 0, to = 0; row <= order.length; row += 1) {
        if (row === at) for (let index = 0; index < added; index += 1) next[to++] = change.at + index;
        if (row < order.length) next[to++] = order[row]! >= change.at ? order[row]! + added : order[row]!;
      }
      return next;
    }
    case "remove": {
      // For each place in the data, how many removed records came before it — or that it was one.
      const gone = new Uint8Array(order.length);
      for (const run of change.runs) gone.fill(1, run.at, Math.min(run.at + run.rows.length, order.length));
      const before = new Uint32Array(order.length);
      let removed = 0;
      for (let place = 0; place < order.length; place += 1) {
        before[place] = removed;
        removed += gone[place]!;
      }
      const next = new Uint32Array(order.length - removed);
      for (let row = 0, to = 0; row < order.length; row += 1) {
        const place = order[row]!;
        if (gone[place] === 0) next[to++] = place - before[place]!;
      }
      return next;
    }
  }
}