Sorting and pagination
| Person 1 | Engineering | Junior | $50,000 | 2015-01-01 |
| Person 7 | Sales | Junior | $97,514 | 2021-07-07 |
| Person 13 | Engineering | Junior | $55,028 | 2018-01-13 |
| Person 19 | Sales | Junior | $102,542 | 2015-07-19 |
| Person 25 | Engineering | Junior | $60,056 | 2021-01-25 |
| Person 31 | Sales | Junior | $107,570 | 2018-07-03 |
| Person 37 | Engineering | Junior | $65,084 | 2015-01-09 |
| Person 43 | Sales | Junior | $112,598 | 2021-07-15 |
| Person 49 | Engineering | Junior | $70,112 | 2018-01-21 |
| Person 55 | Sales | Junior | $117,626 | 2015-07-27 |
<script setup lang="ts">
import { shallowRef } from 'vue'
import { DataTable, useLocalDataSource, useTableState, type ColumnDef } from '@brillliand/vue-table-chad'
import '@brillliand/vue-table-chad/style.css'
type Person = { id: number; name: string; department: string; role: string; salary: number; hiredAt: string }
// Not alphabetical, so the default text sort would put "Junior" above
// "Senior". A `comparator` takes over sorting for that column entirely; the
// `type` still decides the filter operators and the editor.
const SENIORITY = ['Junior', 'Mid', 'Senior', 'Staff', 'Principal', 'Manager']
const columns: ColumnDef<Person>[] = [
{ id: 'name', header: 'Name', type: 'text', pinned: 'left' },
{ id: 'department', header: 'Department', type: 'enum', options: ['Engineering', 'Design', 'Sales', 'Support'] },
{
id: 'role',
header: 'Role',
type: 'enum',
options: SENIORITY,
comparator: (a, b) => SENIORITY.indexOf(String(a)) - SENIORITY.indexOf(String(b)),
},
{
id: 'salary',
header: 'Salary',
type: 'number',
align: 'right',
format: (v) => (v == null ? '—' : `$${Number(v).toLocaleString()}`),
},
{ id: 'hiredAt', header: 'Hired', type: 'date' },
]
function makePeople(count: number): Person[] {
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
name: `Person ${i + 1}`,
department: ['Engineering', 'Design', 'Sales', 'Support'][i % 4]!,
role: SENIORITY[(i * 5) % SENIORITY.length]!,
salary: 50_000 + ((i * 7919) % 90_000),
hiredAt: new Date(2015 + (i % 9), i % 12, 1 + (i % 28)).toISOString().slice(0, 10),
}))
}
const rows = shallowRef<Person[]>(makePeople(300))
// Click a header to sort, shift-click a second one to sort within the first.
const state = useTableState({
pageSize: 10,
initialSort: [{ columnId: 'role', direction: 'asc' }],
})
const source = useLocalDataSource(rows, columns, state.query)
</script>
<template>
<DataTable :columns="columns" :source="source" :state="state" />
</template>Both are page arithmetic and comparator plumbing — no rows, and no component required to use either.
Sorting
QueryState.sort is SortRule[], ordered — a table sorted by department then salary carries both rules, and the first one wins ties:
interface SortRule {
columnId: string
direction: 'asc' | 'desc'
}useTableState owns it through three calls:
state.toggleSort('salary') // asc -> desc -> off, replacing whatever else was sorted
state.toggleSort('salary', true) // same cycle, but appended — multi-sort
state.setSort('salary', 'desc') // set a direction directly, no cyclingSortTrigger calls toggleSort, additive on shift-click — that is the entire mechanism behind clicking a header and shift-clicking a second one. The whole <th> is the target, not only the trigger inside it: a left click anywhere in the cell sorts, and shift, ctrl or cmd makes it additive there too. Clicks that came from a control the cell contains — the sort button itself, the filter popover, the resize handle — belong to that control alone, so nothing fires twice. The one exception is a column the rows are currently grouped by: its header carries no sort trigger at all and folds that grouping level instead. See Grouping.
The header says which it will do before the click. A cell that sorts carries data-sortable, one with a filter carries data-filterable, and the preset paints cursor: pointer on either — a grouped column carries data-grouped instead and gets the same cursor for its fold. Style your own against those attributes rather than against the sort button, which is only part of the box.
Column type decides the comparator, and comparator overrides it. role in employeeColumns is the reason the override exists: SENIORITY — ['Junior', 'Mid', 'Senior', 'Staff', 'Principal', 'Manager'] — is not alphabetical, so the default text comparator would put 'Junior' ahead of 'Senior'. The column supplies its own:
{
id: 'role',
type: 'enum',
options: SENIORITY,
comparator: (a, b) => SENIORITY.indexOf(String(a)) - SENIORITY.indexOf(String(b)),
}A comparator takes over sorting entirely for that column; the type still decides the filter operators and the editor.
Blanks sink to the bottom, in both directions. sortRows's SortOptions.nullsLast defaults to true, and it is applied outside the direction flip on purpose: flipping blanks to the top on desc is the behaviour every reader reports as a bug, because "descending" reads as "biggest first," not "least-null first." Pass nullsLast: false to sortRows directly if a page genuinely wants the opposite; useTableState does not expose the flag, because no view in this repo has needed to override it.
sortRows derives each row's sort key once, not inside the comparator — comparisons run O(n log n) times, and a column's cells number n. See CLAUDE.md, "Derive per row, not per comparison."
Pagination
usePagination is pure page arithmetic over three numbers, with no rows in it at all:
function usePagination(
page: MaybeRefOrGetter<number>,
pageSize: MaybeRefOrGetter<number>,
total: MaybeRefOrGetter<number>,
options?: { siblingCount?: MaybeRefOrGetter<number>; onChange?: (page: number) => void },
): UsePaginationIt hands back page, pageCount, firstRow, lastRow, canPrev, canNext, the navigation calls (go, prev, next, first, last), and items: PageItem[] — page numbers with 'ellipsis' gaps already computed, ready to render as buttons:
type PageItem = number | 'ellipsis'TablePagination is this composable wearing a toolbar: page-size select, prev/next, the numbered buttons items describes, and a "X–Y of Z" summary. Every input is also a prop, so it works as a standalone pager for any list, table or not:
<TablePagination :page="page" :page-size="20" :total="count" @update:page="page = $event" />Paging redoes nothing. Changing page or pageSize never re-runs the filter or sort pass — see CLAUDE.md, "Paging redoes nothing" — because those stages depend on the query's filters and sort fields, not on the whole QueryState object. tests/invalidation.spec.ts holds the pipeline to it.
Live: the Everything at once tab of pnpm demo (#overview). Back to the docs index.