Grouping rows
| $268,785 | 2015-10-10 | ||
| Person 2 | Design | $57,919 | 2016-02-02 |
| Person 6 | Design | $89,595 | 2020-06-06 |
| Person 10 | Design | $121,271 | 2015-10-10 |
| $245,028 | 2015-01-01 | ||
| Person 1 | Engineering | $50,000 | 2015-01-01 |
| Person 5 | Engineering | $81,676 | 2019-05-05 |
| Person 9 | Engineering | $113,352 | 2023-09-09 |
| $163,352 | 2017-03-03 | ||
| Person 3 | Sales | $65,838 | 2017-03-03 |
| Person 7 | Sales | $97,514 | 2021-07-07 |
| $179,190 | 2018-04-04 | ||
| Person 4 | Support | $73,757 | 2018-04-04 |
| Person 8 | Support | $105,433 | 2022-08-08 |
| Total | $856,355 | 2015-01-01 | |
<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; salary: number; hiredAt: string }
const columns: ColumnDef<Person>[] = [
{ id: 'name', header: 'Name', type: 'text', pinned: 'left' },
{ id: 'department', header: 'Department', type: 'enum', options: ['Engineering', 'Design', 'Sales', 'Support'] },
{
id: 'salary',
header: 'Salary',
type: 'number',
align: 'right',
// `aggregate` is what fills both the band totals and the whole-table
// footer below.
aggregate: 'sum',
format: (v) => (v == null ? '—' : `$${Number(v).toLocaleString()}`),
// `format` wants a row and a sum has none, so a total is dressed separately.
aggregateFormat: (result) => (result.value == null ? '—' : `$${Number(result.value).toLocaleString()}`),
},
{ id: 'hiredAt', header: 'Hired', type: 'date', aggregate: 'min' },
]
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]!,
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(500))
const state = useTableState({ pageSize: 10, initialGroupBy: ['department'] })
const source = useLocalDataSource(rows, columns, state.query)
</script>
<template>
<DataTable :columns="columns" :source="source" :state="state" show-footer />
</template><DataTable :columns="columns" :source="source" :initial-group-by="['department']" />Or from the toolbar's Group by menu, which is on by default (:show-group-menu="false" to drop it). Pick a second column to nest inside the first — groupBy is an ordered array, exactly like sort. The panel leads with its global actions — expand all, collapse all, clear grouping — and while grouping is active a + / − pair sits beside the trigger, calling the same expandAll() / collapseAll() without opening the panel.
A grouped column's own header cell changes job: it stops being a sort control — sorting stays on the columns below it — and a left click anywhere in it folds every band that column produced, at that level only, leaving any level nested inside it as the user left it. The cell carries data-grouped and, once folded, data-groups-collapsed; the button inside it is .vt-th-fold.
Group headers are collapsible, count their rows, and name the blank bucket rather than rendering an empty band:
▾ DEPARTMENT Engineering 2
Ada Lovelace 120,000
Grace Hopper 145,000
▸ DEPARTMENT Research 2
▾ DEPARTMENT Blank 1
Barbara Liskov 150,000Per column:
{
id: 'hiredAt',
type: 'date',
groupable: true, // default; false to keep it out of the menu
groupValue: (row) => row.hiredAt?.slice(0, 7), // group by month, not by day
groupLabel: (value) => `Hired ${value}`, // header text for the band
}groupValue defaults to the cell run through toFilterValue, so null, undefined and '' land in one bucket rather than three. Define groupLabel whenever you define groupValue — format is deliberately skipped then, since it describes a cell and the bucket is no longer one.
Who does the grouping — groupMode
<DataTable :columns="columns" :source="source" group-mode="client" /> <!-- the default -->
<DataTable :columns="columns" :source="source" group-mode="server" />'client' (default) — group the loaded data. The table bands the rows the source already returned. Nothing enters QueryState, so no refetch is triggered and a server never hears about it; query.groupBy stays empty however you group. Bands are gathered client-side, so a group is whole within the page even when the rows arrived interleaved, and its count is the rows you can see. A group larger than the page shows the part that is loaded, and the rest appears on later pages under their own header.
'server' — put it in the request. The grouping goes into QueryState.groupBy, and the data source performs it:
useServerDataSourceincludes it in the request, refetches when it changes, and your fetcher receivesquery.groupBy.useLocalDataSourcesorts the whole dataset by it.
Groups then stay whole across pages, and counts describe the entire group rather than the visible slice. Grouping also resets to page 1, since it decides which rows land on which page.
The prop is bound through on every change, so it governs a state you built yourself too. Leave it unset and the state keeps whatever it was constructed with — useTableState({ groupMode: 'server' }).
Two mechanics behind that split:
- The grouped columns sort first.
groupedSort(sort, groupBy)prepends them to the sort rules, which is what makes a group contiguous. The local source applies it in'server'mode; a server fetcher should apply the same helper (it is exported) or sort byquery.groupBybeforequery.sort. In'client'mode the table appliesgroupSortRulesto the page instead — the grouped keys only, so the order inside a band is left exactly as the source produced it and a server's own collation is never fought client-side. - Counts follow the mode.
groupCountsis optional onDataSourceand implemented byuseLocalDataSource. It is consulted only in'server'mode, where the source ordered the whole set; in'client'mode a band counts the rows it actually holds, so the number never contradicts what is on screen. Either way it reaches the header asgroup.totalCount, withgroup.countalways describing the loaded rows.
Aggregates
A column declares what its group rows should show, and the value lands under that column:
const money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })
{ id: 'salary', header: 'Salary', type: 'number',
aggregate: 'sum',
aggregateFormat: (r) => money.format(Number(r.value)) },
{ id: 'rating', header: 'Rating', type: 'number', aggregate: 'avg' },
{ id: 'hiredAt', header: 'Hired', type: 'date', aggregate: 'min' },▾ DEPARTMENT Engineering 2 │ │ $265,000 │ 4.4 ★ │
Ada Lovelace │ Canada │ $120,000 │ 4.1 ★ │
Grace Hopper │ USA │ $145,000 │ 4.7 ★ │
▾ DEPARTMENT Research 2 │ │ $130,000 │ 3.9 ★ │sum and avg coerce cells through toNumber and skip whatever will not coerce — a null salary is not a zero, so it changes neither the total nor the average's denominator (result.sampleCount is what the mean was divided by). A group with nothing aggregable reports null, not 0.
min and max use the column's comparator — its own comparator if it has one, otherwise the one its type implies — and report the winning cell's own value, so a date column yields a date rather than a timestamp. They also carry the row they came from, which is why they need no aggregateFormat: format can render them in context. A sum has no row to hand format, so a column that needs its totals dressed up declares aggregateFormat instead.
The group row splits into cells only as far as it must: the label spans everything up to the first aggregated column, and columns after it get a cell each. Declare no aggregates and the group row is the plain single-cell banner it has always been.
Whole-table totals
<DataTable :columns="columns" :source="source" show-footer footer-label="All staff" />Off by default — declaring an aggregate should not add a row nobody asked for. The <tfoot> uses the same per-column declarations, works with grouping switched off, and sticks to the bottom of the scroll box when you give it a height. The label yields its cell to a first column that aggregates something of its own.
Where the numbers come from
Same rule as the group counts, for the same reason:
groupMode: 'client'— a band aggregates the rows loaded under it. What you see is what was added up.groupMode: 'server'— figures come fromDataSource.groupAggregates, so a group split across a page boundary still totals its whole self.useLocalDataSourceimplements it; a server source that does not falls back to the loaded rows.
Pure functions underneath, usable with no component at all: aggregateValue, aggregateRow, aggregateGroups (keyed like RowGroup.key, with the whole set under ROOT_GROUP_KEY), and formatAggregate.
Headless
useRowGrouping owns the collapse state and the flattening, and TableRoot exposes both:
<TableRoot v-slot="{ displayRows, grouping }" :columns="columns" :source="source">
<tbody>
<template v-for="item in displayRows">
<TableGroupRow v-if="item.kind === 'group'" :key="item.group.key" :group="item.group" />
<tr v-else :key="item.row.id"><!-- your cells --></tr>
</template>
</tbody>
</TableRoot>useRowGrouping also gathers the rows it is handed into bands (orderedRows), which is what makes 'client' mode work — pass it the active sort so the bands come out the way the grouped column is sorted. grouping.toggle(key), grouping.toggleColumn(columnId), grouping.collapseAll() and grouping.expandAll() drive collapse — toggleColumn is one grouping level, folded in a single write, and grouping.isColumnCollapsed(columnId) answers whether it already is; collapsedByDefault (:groups-collapsed on the preset) flips the starting state, and applies to groups that only appear later — after a filter change, or on page 4 — rather than only to the ones visible at mount.
Below that sit the pure functions, usable with no component at all: groupedSort, groupSortRules, flattenGroups, countGroups, groupValueOf, groupPathKey.
Styling hooks: .vt-th[data-grouped][data-groups-collapsed], .vt-th-fold, .vt-group-row[data-depth][data-collapsed], .vt-group-cell, .vt-group-toggle, .vt-group-label, .vt-group-count, plus --vtc-group-bg and --vtc-group-indent-step.
Live: the Grouping tab of pnpm demo (#grouping). Back to the docs index.