Appearance
Master / detail
Rows can show additional content below their cells. The expand button is shown in the first column.
Detail content
The detail area can contain any content. Use it for information which doesn't fit into columns, e.g. contact data or a description. hasDetails defines which rows can be expanded – here only employees of the engineering department.
Source
vue
<template>
<Datagrid :columns="columns" :rows="rows" :options="options">
<template #detail="{ data }">
<div class="employee-detail">
<div>
<strong>{{ data.firstName }} {{ data.lastName }}</strong><br />
{{ data.role }} · {{ data.department }}<br />
{{ data.city }}, {{ data.country }}
</div>
<dl>
<dt>Email</dt>
<dd><a :href="`mailto:${data.email}`">{{ data.email }}</a></dd>
<dt>Since</dt>
<dd>{{ data.startDate }}</dd>
<dt>Remote</dt>
<dd>{{ data.remote ? "yes" : "no" }}</dd>
</dl>
</div>
</template>
</Datagrid>
</template>
<script setup lang="ts">
import { Datagrid, type ColumnConfig, type GridOptions } from "@datagrid/vue-ui";
import { createEmployees, type Employee } from "./data";
const rows = createEmployees(100);
const columns: Array<ColumnConfig<Employee>> = [
{ field: "firstName", text: "First name", width: 160 },
{ field: "lastName", text: "Last name" },
{ field: "department", text: "Department" },
{ field: "salary", text: "Salary", type: "number" },
];
const options: GridOptions<Employee> = {
// optional: only rows of the engineering department have details
hasDetails: (row) => row.data.department === "Engineering",
defaultColumn: { flex: 1 },
};
</script>
<style>
.employee-detail {
display: flex;
gap: 48px;
padding: 4px 24px;
}
.employee-detail dl {
display: grid;
grid-template-columns: auto auto;
gap: 2px 12px;
margin: 0;
}
.employee-detail dt {
color: var(--ut-muted-color);
}
.employee-detail dd {
margin: 0;
}
</style>tsx
import { Datagrid, type ColumnConfig, type GridOptions } from "@datagrid/react-ui";
import { createEmployees, type Employee } from "../data";
const rows = createEmployees(100);
const columns: Array<ColumnConfig<Employee>> = [
{ field: "firstName", text: "First name", width: 160 },
{ field: "lastName", text: "Last name" },
{ field: "department", text: "Department" },
{ field: "salary", text: "Salary", type: "number" },
];
const options: GridOptions<Employee> = {
// optional: only rows of the engineering department have details
hasDetails: (row) => row.data.department === "Engineering",
defaultColumn: { flex: 1 },
};
function EmployeeDetail({ data }: { data: Employee }) {
return (
<div className="employee-detail">
<div>
<strong>
{data.firstName} {data.lastName}
</strong>
<br />
{data.role} · {data.department}
<br />
{data.city}, {data.country}
</div>
<dl>
<dt>Email</dt>
<dd>
<a href={`mailto:${data.email}`}>{data.email}</a>
</dd>
<dt>Since</dt>
<dd>{data.startDate}</dd>
<dt>Remote</dt>
<dd>{data.remote ? "yes" : "no"}</dd>
</dl>
</div>
);
}
export default function MasterDetail() {
return (
<>
<Datagrid
columns={columns}
rows={rows}
options={options}
renderDetail={({ data }) => <EmployeeDetail data={data} />}
/>
<style>{`
.employee-detail {
display: flex;
gap: 48px;
padding: 4px 24px;
}
.employee-detail dl {
display: grid;
grid-template-columns: auto auto;
gap: 2px 12px;
margin: 0;
}
.employee-detail dt {
color: var(--ut-muted-color);
}
.employee-detail dd {
margin: 0;
}
`}</style>
</>
);
}Configuration
| Option | Description |
|---|---|
#detail slot (Vue) / renderDetail prop (React) | content of the detail area, receives row, data and api |
hasDetails (grid option) | (row, data) => boolean – which rows have details (default: all rows if the slot / renderDetail is used) |
renderDetails (grid option) | alternative to the slot: (row, data) => component |
The height of the detail area is measured automatically, so it can contain anything, even another grid.
ts
api.toggleDetails(row); // row.showDetails is true while the details are shown@cell-click etc. are not emitted for clicks inside of the detail area and the keyboard navigation of the grid is not active there.
Nested grid
A typical master / detail view: customers with their orders. The detail area contains a second grid with its own columns, sorting and selection. A grid fills its parent, so the nested grid needs a container with a fixed height.
Source
vue
<template>
<Datagrid :columns="columns" :rows="rows" :options="options">
<template #detail="{ data }">
<!-- the nested grid needs a height -->
<div class="customer-orders">
<Datagrid :columns="orderColumns" :rows="data.orders" :options="orderOptions" />
</div>
</template>
</Datagrid>
</template>
<script setup lang="ts">
import { Datagrid, formatters, type ColumnConfig, type GridOptions } from "@datagrid/vue-ui";
import { createCustomers, type Customer, type Order } from "./data";
const rows = createCustomers(40);
const currency = formatters.currency("EUR", { maximumFractionDigits: 0 });
const columns: Array<ColumnConfig<Customer>> = [
{ field: "company", text: "Company", width: 220 },
{ field: "contact", text: "Contact" },
{ field: "city", text: "City" },
{ id: "orders", text: "Orders", type: "number", valueGetter: ({ data }) => data.orders.length },
{
id: "revenue",
text: "Revenue",
type: "number",
valueGetter: ({ data }) => data.orders.reduce((sum, order) => sum + order.total, 0),
valueFormatter: currency,
},
];
const options: GridOptions<Customer> = {
rowId: (data) => data.id,
defaultColumn: { flex: 1, sortable: true },
};
const orderColumns: Array<ColumnConfig<Order>> = [
{ field: "id", text: "Order", type: "number", width: 90 },
{ field: "date", text: "Date", type: "date" },
{ field: "status", text: "Status" },
{ id: "items", text: "Items", type: "number", valueGetter: ({ data }) => data.lines.length },
{ field: "total", text: "Total", type: "number", valueFormatter: currency },
];
const orderOptions: GridOptions<Order> = {
rowId: (data) => data.id,
defaultColumn: { flex: 1, sortable: true },
};
</script>
<style>
.customer-orders {
height: 200px;
padding-left: 32px;
}
</style>tsx
import { Datagrid, formatters, type ColumnConfig, type GridOptions } from "@datagrid/react-ui";
import { createCustomers, type Customer, type Order } from "../data";
const rows = createCustomers(40);
const currency = formatters.currency("EUR", { maximumFractionDigits: 0 });
const columns: Array<ColumnConfig<Customer>> = [
{ field: "company", text: "Company", width: 220 },
{ field: "contact", text: "Contact" },
{ field: "city", text: "City" },
{ id: "orders", text: "Orders", type: "number", valueGetter: ({ data }) => data.orders.length },
{
id: "revenue",
text: "Revenue",
type: "number",
valueGetter: ({ data }) => data.orders.reduce((sum, order) => sum + order.total, 0),
valueFormatter: currency,
},
];
const options: GridOptions<Customer> = {
rowId: (data) => data.id,
defaultColumn: { flex: 1, sortable: true },
};
const orderColumns: Array<ColumnConfig<Order>> = [
{ field: "id", text: "Order", type: "number", width: 90 },
{ field: "date", text: "Date", type: "date" },
{ field: "status", text: "Status" },
{ id: "items", text: "Items", type: "number", valueGetter: ({ data }) => data.lines.length },
{ field: "total", text: "Total", type: "number", valueFormatter: currency },
];
const orderOptions: GridOptions<Order> = {
rowId: (data) => data.id,
defaultColumn: { flex: 1, sortable: true },
};
export default function MasterDetailGrid() {
return (
<>
<Datagrid
columns={columns}
rows={rows}
options={options}
renderDetail={({ data }) => (
// the nested grid needs a height
<div className="customer-orders">
<Datagrid columns={orderColumns} rows={data.orders} options={orderOptions} />
</div>
)}
/>
<style>{`
.customer-orders {
height: 200px;
padding-left: 32px;
}
`}</style>
</>
);
}Editing in the detail area
The detail area can contain a form to edit fields which are not shown as columns or need more space than a cell. The slot / renderDetail receives the api, so the form can close its own row with api.toggleDetails(row).
On save the task is replaced by a changed copy (immutable update). Because of the rowId the grid recognizes the row and the detail area stays open.
Source
vue
<template>
<div class="demo">
<div class="demo-log">{{ log }}</div>
<Datagrid :columns="columns" :rows="tasks" :options="options">
<template #detail="{ row, data, api }">
<form class="task-form" @submit.prevent="save(data)">
<label>Title <input v-model="draft(data).title" /></label>
<label>
Status
<select v-model="draft(data).status">
<option v-for="s in statuses" :key="s">{{ s }}</option>
</select>
</label>
<label>Due <input v-model="draft(data).due" type="date" /></label>
<button type="submit">Save</button>
<button type="button" @click="api.toggleDetails(row)">Close</button>
</form>
</template>
</Datagrid>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from "vue";
import { Datagrid, type ColumnConfig, type GridOptions } from "@datagrid/vue-ui";
import { createTasks, type Task } from "./data";
const tasks = ref(createTasks(30));
const log = ref("Open a row, change the values and save");
const statuses: Array<Task["status"]> = ["todo", "in progress", "review", "done"];
// the form edits a copy of the task until it is saved
const drafts = new Map<number, Task>();
function draft(task: Task): Task {
if (!drafts.has(task.id)) drafts.set(task.id, reactive({ ...task }));
return drafts.get(task.id)!;
}
function save(task: Task) {
const changed = { ...draft(task) };
drafts.delete(task.id);
// replace the object, the row keeps its state (open details) because of the rowId
tasks.value = tasks.value.map((t) => (t.id === task.id ? changed : t));
log.value = `Saved task ${task.id}: ${changed.title} (${changed.status}, due ${changed.due})`;
}
const columns: Array<ColumnConfig<Task>> = [
{ field: "title", text: "Task", width: 220 },
{ field: "assignee", text: "Assignee" },
{ field: "status", text: "Status" },
{ field: "due", text: "Due", type: "date" },
];
const options: GridOptions<Task> = {
rowId: (data) => data.id,
defaultColumn: { flex: 1, sortable: true },
};
</script>
<style>
.task-form {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 8px 16px;
padding: 4px 0 4px 32px;
}
.task-form label {
display: flex;
flex-direction: column;
font-size: 12px;
color: var(--ut-muted-color);
}
.task-form input,
.task-form select,
.task-form button {
height: 30px;
padding: 0 8px;
border: 1px solid var(--ut-border-color);
border-radius: 4px;
background: var(--ut-background);
color: var(--ut-color);
font-size: 14px;
}
</style>tsx
import { useState, type FormEvent } from "react";
import { Datagrid, type ColumnConfig, type GridOptions } from "@datagrid/react-ui";
import { createTasks, type Task } from "../data";
const initialTasks = createTasks(30);
const statuses: Array<Task["status"]> = ["todo", "in progress", "review", "done"];
const columns: Array<ColumnConfig<Task>> = [
{ field: "title", text: "Task", width: 220 },
{ field: "assignee", text: "Assignee" },
{ field: "status", text: "Status" },
{ field: "due", text: "Due", type: "date" },
];
const options: GridOptions<Task> = {
rowId: (data) => data.id,
defaultColumn: { flex: 1, sortable: true },
};
export default function MasterDetailForm() {
const [tasks, setTasks] = useState(initialTasks);
const [log, setLog] = useState("Open a row, change the values and save");
function save(task: Task, e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = new FormData(e.currentTarget);
const changed: Task = {
...task,
title: String(form.get("title")),
status: form.get("status") as Task["status"],
due: String(form.get("due")),
};
// replace the object, the row keeps its state (open details) because of the rowId
setTasks((tasks) => tasks.map((t) => (t.id === task.id ? changed : t)));
setLog(`Saved task ${task.id}: ${changed.title} (${changed.status}, due ${changed.due})`);
}
return (
<div className="demo">
<div className="demo-log">{log}</div>
<Datagrid
columns={columns}
rows={tasks}
options={options}
renderDetail={({ row, data, api }) => (
<form className="task-form" onSubmit={(e) => save(data, e)}>
<label>
Title <input name="title" defaultValue={data.title} />
</label>
<label>
Status
<select name="status" defaultValue={data.status}>
{statuses.map((s) => (
<option key={s}>{s}</option>
))}
</select>
</label>
<label>
Due <input name="due" type="date" defaultValue={data.due} />
</label>
<button type="submit">Save</button>
<button type="button" onClick={() => api.toggleDetails(row)}>
Close
</button>
</form>
)}
/>
<style>{`
.task-form {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 8px 16px;
padding: 4px 0 4px 32px;
}
.task-form label {
display: flex;
flex-direction: column;
font-size: 12px;
color: var(--ut-muted-color);
}
.task-form input,
.task-form select,
.task-form button {
height: 30px;
padding: 0 8px;
border: 1px solid var(--ut-border-color);
border-radius: 4px;
background: var(--ut-background);
color: var(--ut-color);
font-size: 14px;
}
`}</style>
</div>
);
}Opening details with the API
Instead of the expand button, the details can be opened from anywhere with api.toggleDetails(row) – here by clicking a row, and for all rows with the buttons. The height of each detail area depends on its content (the number of order lines), no height has to be configured.
Source
vue
<template>
<div class="demo">
<div class="demo-toolbar">
<button @click="openAll">Open all</button>
<button @click="closeAll">Close all</button>
<span>Click a row to show its order lines</span>
</div>
<Datagrid
:columns="columns"
:rows="rows"
:options="options"
@ready="api = $event"
@cell-click="({ row }) => api?.toggleDetails(row)"
>
<template #detail="{ data }">
<!-- the height of the detail area follows the number of order lines -->
<div class="order-lines">
<template v-for="(line, i) in data.lines" :key="i">
<span>{{ line.quantity }} ×</span>
<span>{{ line.product }}</span>
<span>{{ currency(line.quantity * line.price) }}</span>
</template>
</div>
</template>
</Datagrid>
</div>
</template>
<script setup lang="ts">
import { shallowRef } from "vue";
import { Datagrid, type ColumnConfig, type Core, type GridOptions } from "@datagrid/vue-ui";
import { createOrders, type Order } from "./data";
const rows = createOrders(60);
const api = shallowRef<Core>();
const currency = (value: number) => value.toLocaleString("en", { style: "currency", currency: "EUR" });
// toggleDetails switches row.showDetails
function openAll() {
api.value?.getAllRows().forEach((row) => row.showDetails || api.value!.toggleDetails(row));
}
function closeAll() {
api.value?.getAllRows().forEach((row) => row.showDetails && api.value!.toggleDetails(row));
}
const columns: Array<ColumnConfig<Order>> = [
{ field: "id", text: "Order", type: "number", width: 110 },
{ field: "customer", text: "Customer", flex: 1 },
{ field: "date", text: "Date", type: "date" },
{ field: "status", text: "Status" },
{ field: "total", text: "Total", type: "number", valueFormatter: ({ value }) => currency(value) },
];
const options: GridOptions<Order> = {
rowId: (data) => data.id,
defaultColumn: { width: 130, sortable: true },
};
</script>
<style>
.order-lines {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 2px 16px;
max-width: 420px;
padding-left: 32px;
}
</style>tsx
import { Fragment, useRef } from "react";
import { Datagrid, type ColumnConfig, type DatagridHandle, type GridOptions } from "@datagrid/react-ui";
import { createOrders, type Order } from "../data";
const rows = createOrders(60);
const currency = (value: number) => value.toLocaleString("en", { style: "currency", currency: "EUR" });
const columns: Array<ColumnConfig<Order>> = [
{ field: "id", text: "Order", type: "number", width: 110 },
{ field: "customer", text: "Customer", flex: 1 },
{ field: "date", text: "Date", type: "date" },
{ field: "status", text: "Status" },
{ field: "total", text: "Total", type: "number", valueFormatter: ({ value }) => currency(value) },
];
const options: GridOptions<Order> = {
rowId: (data) => data.id,
defaultColumn: { width: 130, sortable: true },
};
export default function MasterDetailApi() {
const grid = useRef<DatagridHandle>(null);
// toggleDetails switches row.showDetails
function openAll() {
const api = grid.current!.api;
api.getAllRows().forEach((row) => row.showDetails || api.toggleDetails(row));
}
function closeAll() {
const api = grid.current!.api;
api.getAllRows().forEach((row) => row.showDetails && api.toggleDetails(row));
}
return (
<div className="demo">
<div className="demo-toolbar">
<button onClick={openAll}>Open all</button>
<button onClick={closeAll}>Close all</button>
<span>Click a row to show its order lines</span>
</div>
<Datagrid
ref={grid}
columns={columns}
rows={rows}
options={options}
onCellClick={({ row }) => grid.current?.api.toggleDetails(row)}
renderDetail={({ data }) => (
// the height of the detail area follows the number of order lines
<div className="order-lines">
{data.lines.map((line, i) => (
<Fragment key={i}>
<span>{line.quantity} ×</span>
<span>{line.product}</span>
<span>{currency(line.quantity * line.price)}</span>
</Fragment>
))}
</div>
)}
/>
<style>{`
.order-lines {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 2px 16px;
max-width: 420px;
padding-left: 32px;
}
`}</style>
</div>
);
}