# Sorting

> Single and multi-column sort, sort priority, custom comparators, controlled sort state and aria-sort.

Page: https://svelte-lean.vercel.app/table/sorting · Markdown: https://svelte-lean.vercel.app/table/sorting.md · Index: https://svelte-lean.vercel.app/llms.txt

Sorting is a list of rules in the state, applied by the engine with a locale-aware comparator. The header button cycles a column; Shift adds it to the list; the position in the list is its precedence.

- **Source**: core.ts (toggleSort, prepareRows)table.svelte.ts
- **Tests**: core.test.tsdata-table.dom.test.tstable.test.tstable.spec.ts (browser)
- **Fixture**: fixtures/table-basic
- **Benchmark**: table.bench.spec.ts

The default comparator handles numbers, dates and text: numbers by value, dates by time, text through an `Intl.Collator` built with the `locale` prop and numeric collation, so "item 10" sorts after "item 9". Empty values (`null`, `undefined`, an empty string or `NaN`) sort last in both directions. A column supplies `compare` when values have a domain order, and the direction is applied to its result.

Because sorting is state, it can be set before the first render, persisted, or driven from outside the table. A sort change resets the page index so the user is not left on an empty page. The sorted header carries `aria-sort`; with more than one rule, the precedence number is exposed to assistive technology after the `sortOrder` message.

## Single and multi-column sort

Single and multi-column sorting with visible priority and controlled state.

Sorting.svelte

```svelte
<script lang="ts">
	import { DataTable, defaultState, type Column, type TableState } from '@svelte-lean/table';
	import { memberColumns } from '$lib/demo/columns';
	import { people, type Person } from '$lib/demo/data';

	const data = people(12);
	const ORDER = ['Active', 'Away', 'Invited'];
	const columns: Column<Person>[] = memberColumns().map((column) =>
		column.id === 'status'
			? { ...column, compare: (a, b) => ORDER.indexOf(String(a)) - ORDER.indexOf(String(b)) }
			: column.id === 'joined'
				? { ...column, sortable: false }
				: column
	);
	let tableState = $state<Partial<TableState>>(defaultState({ sorting: [{ id: 'revenue', desc: true }] }));
</script>

<!-- Click a header to cycle none, ascending, descending; Shift+click adds a second column. -->
<DataTable {data} {columns} getRowId={(row) => row.id} bind:state={tableState} pagination={false} label="Members" />

<pre>{JSON.stringify(tableState.sorting)}</pre>
```

The table opens sorted by revenue, descending, from the initial state. Status sorts by a custom comparator in the order Active, Away, Invited rather than alphabetically; Joined has sortable: false and renders as plain text. The pre element prints state.sorting.

- Shift+click adds or cycles a column without clearing the others; a plain click replaces the list.
- In manual mode the engine applies no sorting; the state is sent to the server as it is.
- With `groupBy()`, rows stay sorted inside each group and groups appear in the order of their first sorted row.

## Custom sort controls

A header can host its own sort control. `headerCell` receives a `HeaderContext`: the column, its current `sort`, the whole `sorting` list, `toggleSort()` (the column's own cycle) and `setSorting()`, which replaces the list with any sort ids and resets the page. With `sortable: false` the header renders no sort button, so the snippet's controls are not nested in one. `sortKeys` lists the further ids that count as this column's sort, so `aria-sort` and the context's `sort` follow whichever of them is active.

HeaderSort.svelte

```svelte
<script lang="ts">
	import { DataTable, type Column, type HeaderContext, type TableState } from '@svelte-lean/table';
	import { memberColumns, pick } from '$lib/demo/columns';
	import { people, type Person } from '$lib/demo/data';

	const data = people(12);
	const FIELDS = [
		{ id: 'joined', label: 'Joined date' },
		{ id: 'progress', label: 'Progress' }
	];
	const columns: Column<Person>[] = [
		...pick(memberColumns(), ['name', 'team']),
		{ ...memberColumns().find((c) => c.id === 'revenue')!, sortDescFirst: true },
		{
			id: 'activity',
			header: 'Activity',
			accessor: (row) => `${row.joined} · ${row.progress}%`,
			width: 190,
			sortable: false,
			sortKeys: FIELDS.map((field) => field.id),
			headerCell: activity
		},
		// Hidden columns give the client row model values to sort the two keys by.
		{ id: 'joined', header: 'Joined' },
		{ id: 'progress', header: 'Progress' }
	];
	let tableState = $state<Partial<TableState>>({ hidden: ['joined', 'progress'] });
</script>

{#snippet activity({ column, sort, setSorting }: HeaderContext<Person>)}
	<span class="head">
		{column.header}
		<button
			type="button"
			class="trigger"
			popovertarget="activity-sort"
			aria-label="Sort {column.header}"
			data-active={sort ? '' : undefined}
		>
			<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"
				><path d="M2.5 4h11M4.5 8h7M6.5 12h3" /></svg
			>
		</button>
	</span>
	<div id="activity-sort" popover data-slean="popover" data-slean-side="bottom" data-slean-align="end">
		<label>
			Order by
			<select
				value={sort?.id ?? ''}
				onchange={(e) =>
					setSorting(
						e.currentTarget.value ? [{ id: e.currentTarget.value, desc: sort?.desc ?? true }] : []
					)}
			>
				<option value="">Unsorted</option>
				{#each FIELDS as field (field.id)}<option value={field.id}>{field.label}</option>{/each}
			</select>
		</label>
		<label>
			Direction
			<select
				value={sort?.desc === false ? 'asc' : 'desc'}
				disabled={!sort}
				onchange={(e) =>
					sort && setSorting([{ id: sort.id, desc: e.currentTarget.value === 'desc' }])}
			>
				<option value="desc">Newest or highest first</option>
				<option value="asc">Oldest or lowest first</option>
			</select>
		</label>
	</div>
{/snippet}

<DataTable
	{data}
	{columns}
	getRowId={(row) => row.id}
	bind:state={tableState}
	pagination={false}
	label="Members: custom sort control"
/>
<pre class="state" aria-label="Sort state">{JSON.stringify(tableState.sorting ?? [])}</pre>
```

Activity is not sortable itself: its header opens a native popover with two selects that call setSorting() with joined or progress, either direction. Both ids are the column's sortKeys, so the Activity header carries aria-sort. Two hidden columns give the client model the values to sort by; in manual mode the ids go to the server as they are. Revenue has sortDescFirst: its first click sorts descending.

- `sortDescFirst` suits amounts, dates and counts, where the largest or newest value is the one users look for first.
- A sort id that names no column is kept in the state and ignored by the client row model; declare a hidden column with that id to sort by it on the client.

## Keyboard

| Key | When | Result |
| --- | --- | --- |
| `Tab` | in the header | Moves between the sort buttons and resize handles. |
| `Enter`/`Space` | on a sort button | Cycles the column: ascending, descending, none (descending first with sortDescFirst). |
| `Shift+Enter`/`Shift+Space` | on a sort button | Cycles the column while keeping the other sorted columns. |

## Engine

The renderer calls `table.toggleSort(id, multi)`; the pure function underneath is exported for application code that builds its own controls.

`toggleSort`

```ts
import { toggleSort } from '@svelte-lean/table';

// none → ascending → descending → none; multi keeps the other rules.
toggleSort([], 'revenue'); // [{ id: 'revenue', desc: false }]
toggleSort([{ id: 'revenue', desc: false }], 'revenue'); // [{ id: 'revenue', desc: true }]
toggleSort([{ id: 'revenue', desc: true }], 'revenue'); // []
toggleSort([{ id: 'team', desc: false }], 'revenue', true); // [{ id: 'team', desc: false }, { id: 'revenue', desc: false }]
// descFirst (a column with sortDescFirst): none → descending → ascending → none.
toggleSort([], 'revenue', false, true); // [{ id: 'revenue', desc: true }]
```

## API

| Name | Of | Type | Default | Description |
| --- | --- | --- | --- | --- |
| [`sorting`](https://svelte-lean.vercel.app/table/api.md#state-sorting) | TableState | `Sort[]` | `[]` | Ordered sort rules; the array order is the multi-sort precedence. |
| [`sortable`](https://svelte-lean.vercel.app/table/api.md#column-sortable) | Sort, filter and aggregate | `boolean` | `true` | Header sorting; empty values sort last in both directions. |
| [`compare`](https://svelte-lean.vercel.app/table/api.md#column-compare) | Sort, filter and aggregate | `(a: unknown, b: unknown, aRow: T, bRow: T) => number` | `numbers, dates, then a locale collator` | Custom comparator; the sort direction is applied to its result. |
| [`sortDescFirst`](https://svelte-lean.vercel.app/table/api.md#column-sortdescfirst) | Sort, filter and aggregate | `boolean` | `false` | The first activation sorts descending: descending, ascending, none. |
| [`sortKeys`](https://svelte-lean.vercel.app/table/api.md#column-sortkeys) | Sort, filter and aggregate | `string[]` | `undefined` | Further sort ids that count as this column sorted (aria-sort, the icon), for a headerCell that sorts by several fields. |
| [`headerCell`](https://svelte-lean.vercel.app/table/api.md#column-headercell) | Data and content | `Snippet<[HeaderContext<T>]>` | `undefined` | Custom leaf header content. Receives the column, its sort, the sort list, toggleSort() and setSorting(); with sortable: false it can host a sort control of its own. |
| [`HeaderContext<T>`](https://svelte-lean.vercel.app/table/api.md#type-headercontext-t-) | Row model | `{ column; sort: { id; desc; index } \| undefined; sorting: Sort[]; toggleSort(multi?); setSorting(sorting) }` | – | Argument of headerCell snippets. |
| [`locale`](https://svelte-lean.vercel.app/table/api.md#prop-locale) | Presentation | `string` | `undefined` | Locale of the collator used for sorting and of case folding in search and filters. |
| [`manual`](https://svelte-lean.vercel.app/table/api.md#prop-manual) | Controlled state | `boolean` | `false` | Treats data as already sorted, filtered and paged by the application; the client model applies none of them. |
| [`sortOrder`](https://svelte-lean.vercel.app/table/api.md#message-sortorder) | TableMessages | `string` | `'Sort order'` | Read to assistive technology before the multi-sort precedence number. |
| [`toggleSort`](https://svelte-lean.vercel.app/table/api.md#export-togglesort) | Root entry | `(sorting: Sort[], id: string, multi?: boolean, descFirst?: boolean) => Sort[]` | – | Pure sort cycle none, ascending, descending, none; descFirst starts descending. |
| [`toggleSort`](https://svelte-lean.vercel.app/table/api.md#table-togglesort) | Table | `(id: string, multi?: boolean) => void` | – | Cycles none, ascending, descending, none (descending first with sortDescFirst); multi keeps other sorted columns. Resets the page. |
| [`sortOf`](https://svelte-lean.vercel.app/table/api.md#table-sortof) | Table | `(column: string \| Column<T>) => { id: string; desc: boolean; index: number } \| undefined` | – | Sort rule, direction and precedence of a column; given a column, a rule on one of its sortKeys counts. |
| [`Sort`](https://svelte-lean.vercel.app/table/api.md#type-sort) | Events and positions | `{ id: string; desc: boolean }` | – | One sort rule. |

## Related

- [Filtering](https://svelte-lean.vercel.app/table/filtering.md)
- [Server data](https://svelte-lean.vercel.app/table/server-data.md)
- [Accessibility](https://svelte-lean.vercel.app/table/accessibility.md)
