Data

Sorting

Multi-column sorting with priority badges, per-type comparators and configurable null ordering. Sorting is a pipeline stage, so it runs after filtering and before windowing.

Basic Usage

Mark a column sortable and its header becomes a button. Clicking walks it through ascending, descending and back to unsorted.

Email (not sortable)
Hoang Kowalski
Design
$127,691.00
hoang.kowalski1@example.com
Bruno Nguyen
Growth
$105,146.00
bruno.nguyen2@example.com
Bruno Dubois
Design
$86,538.00
bruno.dubois3@example.com
Farid Haddad
Growth
$76,443.00
farid.haddad4@example.com
Jonas Yilmaz
Core
$129,812.00
jonas.yilmaz5@example.com
Quyen Tanaka
Growth
$72,011.00
quyen.tanaka6@example.com
Mai Nguyen
Support
$83,226.00
mai.nguyen7@example.com
Bruno Novak
Support
$71,507.00
bruno.novak8@example.com
Grace Dubois
Support
$94,212.00
grace.dubois9@example.com
Tuan Ivanov
Design
$105,520.00
tuan.ivanov10@example.com
Bruno Andersen
Growth
$62,389.00
bruno.andersen11@example.com
Bruno Yilmaz
Core
$139,212.00
bruno.yilmaz12@example.com
12 rows
// sortable turns on click-to-sort for one column
const columns: ColumnDef<Person>[] = [
  { id: 'name', header: 'Name', sortable: true, flex: 1 },
  { id: 'team', header: 'Team', sortable: true, width: 130 },
  { id: 'salary', header: 'Salary', sortable: true, align: 'right', width: 140,
    type: 'currency' },
  { id: 'email', header: 'Email', flex: 1.4 }   // not sortable
];

// The shorthand form registers sorting for you
<DataGrid data={people} {columns} {getRowId} />

Multi-Sort

Shift-click a second header to add it to the sort rather than replace it. A numbered badge appears once more than one column is involved, so the order is visible rather than implied.

Sorting reads the value behind a cell rather than the one on screen, which matters for one thing only: a cellValue gate does not reach it, so a masked column can still be ordered by what it hides. Take sortable off a column you mask.

The sort is stable, so rows every column in it compares as equal keep the order they arrived in. Two people on the same team with the same salary stay in the order the data listed them, and they stay there each time the same sort is applied.

1. team asc 2. salary desc
Email (not sortable)
Bruno Yilmaz
Core
$139,212.00
bruno.yilmaz12@example.com
Jonas Yilmaz
Core
$129,812.00
jonas.yilmaz5@example.com
Hoang Kowalski
Design
$127,691.00
hoang.kowalski1@example.com
Tuan Ivanov
Design
$105,520.00
tuan.ivanov10@example.com
Bruno Dubois
Design
$86,538.00
bruno.dubois3@example.com
Bruno Nguyen
Growth
$105,146.00
bruno.nguyen2@example.com
Farid Haddad
Growth
$76,443.00
farid.haddad4@example.com
Quyen Tanaka
Growth
$72,011.00
quyen.tanaka6@example.com
Bruno Andersen
Growth
$62,389.00
bruno.andersen11@example.com
Grace Dubois
Support
$94,212.00
grace.dubois9@example.com
Mai Nguyen
Support
$83,226.00
mai.nguyen7@example.com
Bruno Novak
Support
$71,507.00
bruno.novak8@example.com
12 rows
import { createDataGrid, sorting } from '@sv5ui/datagrid';

const grid = createDataGrid<Person>({
  data: people,
  columns,
  getRowId,
  features: [
    sorting({
      // Where the grid starts
      initial: [
        { columnId: 'team', direction: 'asc' },
        { columnId: 'salary', direction: 'desc' }
      ]
    })
  ]
});

// Shift-click a header, or press Shift+Enter on a focused header cell, to
// add a column to the sort instead of replacing it. A numbered badge shows
// the priority once more than one column is sorted.

Sort Cycle

The Salary column below starts descending and never returns to unsorted, which suits amounts and dates where an arbitrary order is rarely what anyone wants.

Hoang Kowalski
$127,691.00
Bruno Nguyen
$105,146.00
Bruno Dubois
$86,538.00
Farid Haddad
$76,443.00
Jonas Yilmaz
$129,812.00
Quyen Tanaka
$72,011.00
6 rows
// What a header click walks through. null clears the column.
sorting({ cycle: ['asc', 'desc', null] })   // the default

// Never leave the column unsorted
sorting({ cycle: ['asc', 'desc'] })

// Start with the largest first, which suits amounts and dates
sorting({ cycle: ['desc', 'asc', null] })

// A cycle with no direction at all falls back to the default.

Null Ordering

Both grids are sorted by Country ascending. The only difference is where the blanks go.

nulls: 'first'

Name
Hoang Kowalski
—
Farid Haddad
—
Mai Nguyen
—
Bruno Nguyen
Brazil
Quyen Tanaka
France
Bruno Dubois
Nigeria
Bruno Novak
Poland
Jonas Yilmaz
Vietnam
8 rows

nulls: 'last'

Name
Bruno Nguyen
Brazil
Quyen Tanaka
France
Bruno Dubois
Nigeria
Bruno Novak
Poland
Jonas Yilmaz
Vietnam
Hoang Kowalski
—
Farid Haddad
—
Mai Nguyen
—
8 rows
// Where blank values land, regardless of direction
sorting({ nulls: 'first' })   // the default
sorting({ nulls: 'last' })

// Blank is null, undefined or the empty string: the same set the blank
// filter operator matches and the renderers show as empty. A column that
// means something by '' and wants it ordered as a value needs a sortFn.

// Under rowModel: 'server' the choice travels, so the same grid does not
// reorder itself when the rows start coming from a database:
toSortRequest(getSorting(grid)!.sort, grid.columns.defs, getSorting(grid)!.nulls);
// [{ field: 'country', direction: 'desc', nulls: 'last' }]
//
// Written as the side the blanks actually land on rather than the side the
// option names: on the client a blank sorts as the smallest value, so
// nulls: 'first' arrives as 'last' once the direction is descending, while
// SQL's NULLS FIRST means first either way. Passing it straight into
// ORDER BY ... NULLS LAST is what keeps the two orderings identical.

Dates Sort as Dates

A column typed date or datetime orders by the date, not by the text of whatever the row happens to hold. The five rows below carry three different forms of the same idea, and the column still reads chronologically.

Without the type there is nothing to say these are dates, so a Date object next to an ISO string would be compared as text and put June before January.

Reference
What the row holds
A-1005
Dec 31, 2023
Date object
A-1002
Jan 5, 2024
Date object
A-1001
Mar 14, 2024
date-only string
A-1004
May 2, 2024
date-only string
A-1003
Jul 22, 2024
epoch number
5 rows
// A date or datetime column orders by the date behind the cell, so the
// form a row happens to hold does not decide the order.
const columns: ColumnDef<Shipment>[] = [
  { id: 'ref', header: 'Reference', width: 130 },
  { id: 'shippedAt', header: 'Shipped', sortable: true, width: 150,
    type: 'date', typeOptions: { locale: 'en-US' } }
];

const shipments = [
  { id: 1, ref: 'A-1001', shippedAt: '2024-03-14' },                    // date-only string
  { id: 2, ref: 'A-1002', shippedAt: new Date(2024, 0, 5) },            // Date object
  { id: 3, ref: 'A-1003', shippedAt: new Date(2024, 6, 22).getTime() }, // epoch number
  { id: 4, ref: 'A-1004', shippedAt: '2024-05-02' },
  { id: 5, ref: 'A-1005', shippedAt: new Date(2023, 11, 31) }
];

// Without type: 'date' these are compared as text, which puts a Date
// object's "Fri Jul 22 2024" before an ISO string's "2024-01-05".
//
// A date-only string names a calendar day rather than UTC midnight, so it
// keeps its day west of Greenwich as well as east of it.

Custom Comparators

sortFn replaces the default value comparison. Compare ascending and let the grid apply the direction, otherwise descending inverts your intent twice.

It receives whole rows, which is also what it costs: a plain column reads its value once per row and compares the keys, while a sortFn is called on every comparison, on the order of 1.7 million times for 100k rows. Worth it for an order that has meaning; not worth it for one a sortField or a type already describes. It is also client-only: under a server row model the request carries the column and the direction, and your backend decides what they mean.

Name
Hoang Kowalski
invited
$127,691.00
Bruno Nguyen
invited
$105,146.00
Bruno Dubois
active
$86,538.00
Farid Haddad
suspended
$76,443.00
Jonas Yilmaz
active
$129,812.00
Quyen Tanaka
suspended
$72,011.00
Mai Nguyen
invited
$83,226.00
Bruno Novak
active
$71,507.00
Grace Dubois
active
$94,212.00
Tuan Ivanov
active
$105,520.00
Bruno Andersen
active
$62,389.00
Bruno Yilmaz
suspended
$139,212.00
12 rows
// A custom comparator. The direction factor is applied on top of the
// result, so always compare ascending here.
const rank = { suspended: 0, invited: 1, active: 2 };

const columns: ColumnDef<Person>[] = [
  {
    id: 'status',
    header: 'Status',
    sortable: true,
    width: 140,
    type: 'badge',
    sortFn: (a, b) => rank[a.status] - rank[b.status]
  }
];

Sorting by Another Field

A name column showing the full name but ordering by the surname. Sort the first column and watch the second: it is what the order follows.

lastName
Hoang Kowalski
Kowalski
Design
Bruno Nguyen
Nguyen
Growth
Bruno Dubois
Dubois
Design
Farid Haddad
Haddad
Growth
Jonas Yilmaz
Yilmaz
Core
Quyen Tanaka
Tanaka
Growth
Mai Nguyen
Nguyen
Support
Bruno Novak
Novak
Support
Grace Dubois
Dubois
Support
Tuan Ivanov
Ivanov
Design
Bruno Andersen
Andersen
Growth
Bruno Yilmaz
Yilmaz
Core
12 rows
// A column that shows one thing and orders by another: the cell reads
// "Ada Lovelace" while the sort follows the surname.
{
  id: 'name',
  header: 'Name',
  sortable: true,
  sortField: 'lastName'
}

// On the client sortField names a row property. Under rowModel: 'server'
// it is what toSortRequest puts on the wire, so a column id that is a UI
// concern never has to be one your database recognises.
// sortFn wins over sortField when both are set.

Driving the Sort

getSorting(grid) returns the feature state, or undefined when sorting is not registered.

Nothing here touches the headers. The grid below starts unsorted and every button is a call you could make from anywhere: a toolbar, a saved view, a keyboard shortcut of your own.

unsorted
Email (not sortable)
Hoang Kowalski
Design
$127,691.00
hoang.kowalski1@example.com
Bruno Nguyen
Growth
$105,146.00
bruno.nguyen2@example.com
Bruno Dubois
Design
$86,538.00
bruno.dubois3@example.com
Farid Haddad
Growth
$76,443.00
farid.haddad4@example.com
Jonas Yilmaz
Core
$129,812.00
jonas.yilmaz5@example.com
Quyen Tanaka
Growth
$72,011.00
quyen.tanaka6@example.com
Mai Nguyen
Support
$83,226.00
mai.nguyen7@example.com
Bruno Novak
Support
$71,507.00
bruno.novak8@example.com
8 rows
import { getSorting } from '@sv5ui/datagrid';

const sorting = getSorting(grid);

sorting?.sort;                       // SortState[], reactive
sorting?.directionOf('salary');      // 'asc' | 'desc' | undefined
sorting?.priorityOf('salary');       // 1-based, or null below two columns

// Replace the sort outright
sorting?.setSort([{ columnId: 'name', direction: 'asc' }]);

// Walk one column through the cycle; append keeps the rest of the sort
sorting?.toggleSort('team');
sorting?.toggleSort('salary', { append: true });

// The same two, merged into the grid api
grid.api.setSort?.([{ columnId: 'name', direction: 'asc' }]);
grid.api.toggleSort?.('team');

// Every change emits
grid.events.on('sortChanged', ({ sort }) => console.log(sort));

sorting() Options

OptionDefault
initial[]
nulls'first'
cycle['asc', 'desc', null]

Column Fields

PropertyDefault
sortablefalse
sortFn-
sortField-

Sorting State

Everything getSorting(grid) exposes. setSort and toggleSort are also merged into grid.api.

MemberDescription
sortThe active sort, in priority order
nullsWhere blanks land, as the feature was registered. Read it back to send it with a server request
cycleWhat a header click walks through, as the feature was registered
setSort(sort)Replaces the sort outright and emits sortChanged
toggleSort(id, opts)Walks one column through the cycle; append keeps the rest
directionOf(id)The direction in force for one column
priorityOf(id)1-based position, or null when fewer than two columns are sorted

Keyboard

KeysAction
Enter or SpaceSorts by the focused header column, replacing the sort
Shift + EnterAdds the focused column to the sort instead
ClickWalks the column through the configured cycle
Shift + ClickAdds the column to the sort, keeping the rest