Expandable detail rows
| Person 1 | Engineering | $50,000 | |
| Person 2 | Design | $57,919 | |
| Person 3 | Sales | $65,838 | |
| Person 4 | Support | $73,757 | |
| Person 5 | Engineering | $81,676 | |
| Person 6 | Design | $89,595 |
<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; email: string; salary: number }
const columns: ColumnDef<Person>[] = [
{ id: 'name', header: 'Name', type: 'text' },
{ id: 'department', header: 'Department', type: 'enum', options: ['Engineering', 'Design', 'Sales', 'Support'] },
{ id: 'salary', header: 'Salary', type: 'number', align: 'right', format: (v) => `$${Number(v).toLocaleString()}` },
]
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]!,
email: `person${i + 1}@example.com`,
salary: 50_000 + ((i * 7919) % 90_000),
}))
}
const rows = shallowRef<Person[]>(makePeople(60))
const state = useTableState({ pageSize: 6 })
const source = useLocalDataSource(rows, columns, state.query)
</script>
<template>
<!-- `expandable` puts the disclosure column in and lets the table own which
rows are open; the `#detail` slot is what fills the panel. -->
<DataTable :columns="columns" :source="source" :state="state" expandable>
<template #detail="{ row }">
<p class="detail">
<strong>{{ row.name }}</strong> — {{ row.email }}, {{ row.department }}
</p>
</template>
</DataTable>
</template>
<style scoped>
.detail { margin: 0; font-size: 13px; }
</style>A row opens to show what hangs off it: the child entities it owns, or its own fields laid out with more room than a cell has. Two props and one slot:
<DataTable :columns="columns" :source="source" expandable>
<template #detail="{ row, index, depth }">…</template>
</DataTable>expandable adds the disclosure column and lets the table own which rows are open. The panel is rendered by the #detail slot, which is handed the row it belongs to, that row's index in the page, its depth when the table is grouped, and — when the children are fetched rather than read off the row — the detail state and a reload for it.
The panel is a row
It is a real <tr> after the row it describes, spanning every column — not a <div> tucked inside the row above. That is a deliberate shape rather than an implementation detail:
- A
<tr>cannot contain another<tr>, so nesting was never available. - A body that renders a window instead of the whole list counts the rows it handed out against the rows that came back. A panel smuggled in beside its row breaks that count, and measurement switches off silently. As a line of its own it is windowed, measured and scrolled like anything else.
data-expanded lands on the open row and the panel itself is .vt-detail-row, with its single cell .vt-detail-cell and the element inside it .vt-detail-inner. The disclosure button is .vt-detail-toggle and its caret .vt-detail-caret.
Driving it from outside
Hand the table an expansion of your own when something else needs to open or read the panels:
import { useRowExpansion } from '@brillliand/vue-table-chad'
const expansion = useRowExpansion<Employee>({
getRowId: (row) => row.id, // the default — `row.id`, as everywhere else
initial: [42], // rows that arrive open
})<DataTable :columns="columns" :source="source" :expansion="expansion">It carries expanded (a writable Ref<RowId[]>, so the open set can be saved and restored), isExpanded(row), toggle(row, next?), expandAll(rows), collapseAll(), and — for the async case below — detailFor(row) and reload(row). expandAll takes its rows as an argument rather than reading a dataset of its own — the composable holds no reference to your data at all, which is what keeps opening a panel out of the filter, sort and group passes entirely.
Ids, not row objects: a refetch hands back freshly allocated rows, and a panel the user opened has to survive one. It survives a page turn for the same reason.
DataTable exposes whichever of the two it ended up with on its template ref:
<DataTable ref="table" … expandable />table.value?.expansion?.collapseAll()Keyboard
With cell-cursor on, Alt + ↓ opens the cursor's row and Alt + ↑ shuts it. The direction is explicit rather than a toggle, so a held key does not flap the panel. No other cursor gesture uses Alt, which is why that pair is the one this spends.
The disclosure button is a real button, reachable by Tab and announcing its state through aria-expanded. Its accessible name comes from the expandRow / collapseRowlabels, so it translates with everything else.
Children fetched when the row opens
The children usually are not in the row. loadDetail is where they come from:
const expansion = useRowExpansion<Employee, Assignment[]>({
loadDetail: (row) => fetch(`/api/employees/${row.id}/assignments`).then((r) => r.json()),
})<DataTable :columns="columns" :source="source" :expansion="expansion">
<template #detail="{ row, detail, reload }">
<p v-if="detail.status === 'loading'">Loading…</p>
<p v-else-if="detail.status === 'error'">
{{ detail.error.message }}
<button @click="reload()">Retry</button>
</p>
<AssignmentsTable v-else :key="row.id" :rows="detail.data" :columns="assignmentColumns" />
</template>
</DataTable>detail is { status, data?, error? } with status one of idle, loading, ready or error. With no loadDetail at all it reads ready with no data, so a panel built from the row itself takes the same branch and there is one code path rather than two.
What the rules are:
- The fetch is on the expand transition, once per row id. Shutting a panel keeps what arrived, so reopening one is instant, and an explicit
toggle(row, true)on an already-open row is not a transition. reload(row)asks again, cache or no cache. That is the Retry button above, and it is also what to call when the children changed underneath an open panel.- An overtaken response is discarded. Each request carries a token per row id, and one that is no longer the current token for its row never reaches the cache — so a slow first answer cannot land on top of the retry that replaced it.
expandAll(rows)fetches for each row it opens that has nothing cached. Every panel it opens is on screen, so every one of them needs its children; opening thousands of rows is asking for thousands of requests.- None of it touches the table's own data. The fetch is yours, the cache is a
Mapkeyed by row id held in ashallowRefand replaced rather than mutated, so one row's arrival re-renders that panel and not the table.tests/invalidation.spec.tsasserts zero filter, sort, group and aggregate passes for both the expand that starts a load and the response that settles it.
A subtable of child entities
The panel is ordinary markup, so a nested DataTable goes in it like anything else. It needs its own DataSource, and a source is a composable — which a template cannot call — so the subtable is always one small component:
<!-- AssignmentsTable.vue -->
<script setup lang="ts">
import { toRef } from 'vue'
import { DataTable, useLocalDataSource, useTableState, type ColumnDef } from '@brillliand/vue-table-chad'
const props = defineProps<{ rows: Assignment[]; columns: ColumnDef<Assignment>[] }>()
const state = useTableState({ pageSize: 5 })
const source = useLocalDataSource(toRef(props, 'rows'), props.columns, state.query)
</script>
<template>
<DataTable :columns="props.columns" :source="source" :state="state" />
</template><DataTable :columns="columns" :source="source" expandable>
<template #detail="{ row }">
<AssignmentsTable :key="row.id" :rows="assignmentsFor(row)" :columns="assignmentColumns" />
</template>
</DataTable>The :key matters: without it the nested table is reused between panels and keeps the previous row's sort and page.
A plain panel
Nothing says the panel has to be a table, and often it should not be. The row's own fields, a form, a chart — the slot's content is yours:
<template #detail="{ row }">
<dl class="fields">
<div><dt>Email</dt><dd>{{ row.email }}</dd></div>
<div><dt>Location</dt><dd>{{ row.location.city }}, {{ row.location.country }}</dd></div>
</dl>
</template>Under virtual
A windowed table needs measure-rows when panels can open:
<DataTable :columns="columns" :source="source" expandable virtual measure-rows />Windowing assumes one row height until it is told otherwise, and a panel is taller than the rows around it. Without measurement the space the window stands in is short by the difference, and the rows below drift — a gap that grows as you scroll. The table warns about this combination in development rather than letting you find it by scrolling.
Building it by hand
Below DataTable the two pieces are separate, and both are exported. withDetailRows interleaves the panels into a display list; TableDetailRow renders one:
import { withDetailRows } from '@brillliand/vue-table-chad'
const lines = computed(() =>
withDetailRows(
rows.value.map((row, index) => ({ kind: 'row' as const, row, index, depth: 0 })),
expansion.isExpanded,
),
)<TableDetailRow v-if="line.kind === 'detail'" :row="line.row" :columns="cols" :leading="1">
<template #default="{ row }">…</template>
</TableDetailRow>
<TableRow v-else :row="line.row" :columns="cols" :index="line.index" :expanded="…" />withDetailRows returns the array it was given, by reference, when nothing is open — so a table nobody has expanded propagates nothing downstream. TableDetailRow owns its <td> and its colspan, never the slot: a row one cell short of the <colgroup> above it drags every column after it out of place, which is why the count includes the leading disclosure column (:leading="1") and the trailing actions column.
detailToggleFor(event) is the keyboard half — it returns 'expand', 'collapse' or undefined for a key event, so a hand-assembled table gets the same Alt + ↓/↑ without reimplementing the decision.
Live: the Detail rows tab of pnpm demo (#detail). Back to the docs index.