Master-Detail Plugin
The Master-Detail plugin lets you create expandable detail rows that reveal additional content beneath each master row. Perfect for order/line-item UIs, employee/department views, or any scenario where you need to show related data without navigating away.
Installation
Section titled “Installation”import '@toolbox-web/grid/features/master-detail';Basic Usage
Section titled “Basic Usage”The key configuration is detailRenderer - a function that receives the row data and returns either an HTML string or a DOM element. This gives you complete control over what appears in the expanded detail area.
import { queryGrid } from '@toolbox-web/grid';
const grid = queryGrid('tbw-grid');grid.gridConfig = { columns: [ { field: 'orderId', header: 'Order ID' }, { field: 'customer', header: 'Customer' }, { field: 'total', header: 'Total', type: 'currency' } ], features: { masterDetail: { detailRenderer: (row) => ` <div class="order-details"> <h4>Order Items</h4> <ul> ${row.items.map(item => `<li>${item.name} - $${item.price}</li>`).join('')} </ul> </div> `, }, },};import '@toolbox-web/grid-react/features/master-detail';import { DataGrid, GridDetailPanel } from '@toolbox-web/grid-react';
function OrderGrid({ orders }) { return ( <DataGrid rows={orders} columns={[ { field: 'orderId', header: 'Order ID' }, { field: 'customer', header: 'Customer' }, { field: 'total', header: 'Total', type: 'currency' } ]} style={{ height: '400px' }} > <GridDetailPanel> {({ row }) => ( <div className="order-details"> <h4>Order Items</h4> <ul> {row.items.map(item => ( <li key={item.id}>{item.name} - ${item.price}</li> ))} </ul> </div> )} </GridDetailPanel> </DataGrid> );}<script setup>import '@toolbox-web/grid-vue/features/master-detail';import { TbwGrid, TbwGridColumn, TbwGridDetailPanel } from '@toolbox-web/grid-vue';
const orders = [ { orderId: 'ORD-001', customer: 'Alice', total: 150, items: [{ id: 1, name: 'Widget', price: 50 }, { id: 2, name: 'Gadget', price: 100 }] }, { orderId: 'ORD-002', customer: 'Bob', total: 75, items: [{ id: 3, name: 'Tool', price: 75 }] },];</script>
<template> <TbwGrid :rows="orders" master-detail> <TbwGridColumn field="orderId" header="Order ID" /> <TbwGridColumn field="customer" header="Customer" /> <TbwGridColumn field="total" header="Total" type="currency" />
<TbwGridDetailPanel v-slot="{ row }"> <div class="order-details"> <h4>Order Items</h4> <ul> <li v-for="item in row.items" :key="item.id"> {{ item.name }} - ${{ item.price }} </li> </ul> </div> </TbwGridDetailPanel> </TbwGrid></template>// Feature imports - GridMasterDetailDirective owns the [masterDetail] input,// GridDetailView is the structural directive used inside <tbw-grid> for templating.import { GridMasterDetailDirective, GridDetailView,} from '@toolbox-web/grid-angular/features/master-detail';
import { Component } from '@angular/core';import { Grid } from '@toolbox-web/grid-angular';import type { ColumnConfig } from '@toolbox-web/grid-angular';
@Component({ selector: 'app-order-grid', imports: [Grid, GridMasterDetailDirective, GridDetailView], template: ` <tbw-grid [rows]="orders" [columns]="columns" [masterDetail]="true" style="height: 400px; display: block;"> <ng-template tbwDetailView let-row let-toggle="toggle"> <div class="order-details"> <h4>Order Items</h4> <ul> <li *ngFor="let item of row.items"> {{ item.name }} - \${{ item.price }} </li> </ul> <button (click)="toggle()">Close</button> </div> </ng-template> </tbw-grid> `,})export class OrderGridComponent { orders = [/* order data */];
columns: ColumnConfig[] = [ { field: 'orderId', header: 'Order ID' }, { field: 'customer', header: 'Customer' }, { field: 'total', header: 'Total', type: 'currency' } ];}Use the GridDetailView directive
(<ng-template tbwDetailView>) for Angular-idiomatic detail panels with
template context (let-row, let-toggle="toggle"). For non-Angular consumers
of the same plugin, an inline detailRenderer: (row) => HTMLElement | string
is also accepted in gridConfig.masterDetail.
Default Master-Detail
Section titled “Default Master-Detail”<tbw-grid style="height: 400px;"></tbw-grid>import '@toolbox-web/grid';import { queryGrid } from '@toolbox-web/grid';import '@toolbox-web/grid/features/master-detail';
const generateOrderData = (count: number) => { const customers = ['Alice Corp', 'Bob Inc', 'Carol LLC', 'Delta Co', 'Echo Ltd', 'Foxtrot GmbH', 'Golf SA', 'Hotel AG']; const products = ['Widget A', 'Widget B', 'Gadget X', 'Gadget Y', 'Service Plan', 'Support Pack', 'License', 'Hardware Kit']; return Array.from({ length: count }, (_, i) => { const itemCount = 1 + (i % 4); const items = Array.from({ length: itemCount }, (_, j) => ({ name: products[(i + j) % products.length], qty: 1 + ((i + j) % 5), price: 25 + ((i * 7 + j * 13) % 200), })); return { id: 1001 + i, customer: customers[i % customers.length], date: `2024-${String(1 + (i % 12)).padStart(2, '0')}-${String(1 + (i % 28)).padStart(2, '0')}`, total: items.reduce((sum, item) => sum + item.qty * item.price, 0), items, }; });};
const columns = [ { field: 'id', header: 'Order ID', type: 'number' }, { field: 'customer', header: 'Customer' }, { field: 'date', header: 'Date' }, { field: 'total', header: 'Total', type: 'number', format: (v: number) => `$${v.toFixed(2)}` },];
const detailRenderer = (row: { items: { name: string; qty: number; price: number }[] }) => { const div = document.createElement('div'); div.style.cssText = 'padding: 16px;'; div.innerHTML = ` <h4 style="margin: 0 0 8px;">Order Items</h4> <table style="width: 100%; border-collapse: collapse;"> <thead><tr><th style="padding: 4px 8px; text-align: left;">Item</th><th style="padding: 4px 8px; text-align: right;">Qty</th><th style="padding: 4px 8px; text-align: right;">Price</th></tr></thead> <tbody>${row.items.map((item) => `<tr><td style="padding: 4px 8px;">${item.name}</td><td style="padding: 4px 8px; text-align: right;">${item.qty}</td><td style="padding: 4px 8px; text-align: right;">$${item.price.toFixed(2)}</td></tr>`).join('')}</tbody> </table>`; return div;};
const grid = queryGrid('tbw-grid');const orderData = generateOrderData(100);
function rebuild(opts: Record<string, unknown>) { const heightVal = opts.detailHeight ?? 'auto'; grid.gridConfig = { columns, features: { masterDetail: { animation: (opts.animation) ?? 'slide', detailHeight: heightVal === 'auto' ? 'auto' : Number(heightVal), expandOnRowClick: opts.expandOnRowClick ?? false, detailRenderer, }, }, }; grid.rows = orderData;}
rebuild({ animation: 'slide', expandOnRowClick: false, detailHeight: 'auto' });Configuration Options
Section titled “Configuration Options”See MasterDetailConfig for the full list of options and their defaults.
Animation Options
Section titled “Animation Options”The animation option controls how detail rows appear/disappear:
// Slide animation (default)features: { masterDetail: { animation: 'slide', ... } }
// Fade animationfeatures: { masterDetail: { animation: 'fade', ... } }
// No animationfeatures: { masterDetail: { animation: false, ... } }Server-Side Data
Section titled “Server-Side Data”Requires:
ServerSidePlugin— see Server-Side Plugin
When ServerSidePlugin is loaded, master rows are lazily loaded via virtual scrolling (infinite scroll) or pagination — the same way flat data, Tree, or Row Grouping work. MasterDetail does not claim root data, so master rows flow through ServerSide’s block cache unchanged.
Detail data can be fetched on demand or embedded in the master row:
- Async detail data: On detail expand, MasterDetailPlugin queries
datasource:fetch-children. ServerSide callsgetChildRows()and delivers the result. - Embedded detail data: If detail data is already in the master row object (e.g.
row.items),detailRendereraccesses it directly — nogetChildRows()needed.
When ServerSide is not present, detailRenderer works synchronously from the row object.
import '@toolbox-web/grid/features/master-detail';import '@toolbox-web/grid/features/server-side';import { queryGrid } from '@toolbox-web/grid';
const grid = queryGrid('tbw-grid');grid.gridConfig = { columns: [ { field: 'orderId', header: 'Order ID' }, { field: 'customer', header: 'Customer' }, { field: 'total', header: 'Total', type: 'currency' }, ], features: { serverSide: { pageSize: 50, dataSource: { async getRows(params) { const res = await fetch(`/api/orders?start=${params.startNode}&end=${params.endNode}`); const data = await res.json(); return { rows: data.orders, totalNodeCount: data.total }; }, async getChildRows(params) { const { row } = params.context; const res = await fetch(`/api/orders/${row.id}/items`); return { rows: await res.json() }; }, }, }, masterDetail: { detailRenderer: (row, rowIndex) => { const plugin = grid.getPluginByName('masterDetail');
if (plugin.isDetailLoading(rowIndex)) { return '<div class="loading">Loading order items…</div>'; }
const items = plugin.getDetailData(rowIndex); if (!items) { return '<div class="loading">Loading…</div>'; }
return ` <div class="order-details"> <h4>Order Items</h4> <ul> ${items.map((item: any) => `<li>${item.name} — $${item.price}</li>`).join('')} </ul> </div> `; }, }, },};Data flow:
- ServerSide fetches master rows in blocks (virtual scroll or pagination) → renders normally (MasterDetail does not claim root data)
- User scrolls → ServerSide loads more master rows on demand
- On detail expand — expander click, or
expand()/toggle()/expandAll()— MasterDetailPlugin queriesdatasource:fetch-childrenwith{ source: 'master-detail', row, rowIndex }when the row’s data is neither cached nor already loading. Loading placeholders (rows whose block has not loaded yet) are never expanded, fetched, or passed todetailRenderer. - ServerSide calls
getChildRows()→ broadcastsdatasource:children - MasterDetailPlugin stores the data and re-renders the detail panel
detailRendererusesgetDetailData(rowIndex)to access the async data
expandAll() with lazy blocks: expandAll() makes expansion the default for every row and stays in effect until collapseAll(). Rows that appear later therefore render expanded straight away: blocks that replace loading placeholders, and Tree / Row Grouping children — whether loaded through getChildRows() or embedded in a block’s payload. Their detail data is fetched once they scroll into view, with their display rowIndex. Collapsing a single row does not end this mode; that row stays collapsed.
Errors and cancellation: If getChildRows() rejects, the row leaves its loading state, its detail panel re-renders (isDetailLoading() is false and getDetailData() is undefined), and the grid fires detail-load-error with { rowIndex, row, error }. Collapsing and re-expanding the row retries the fetch — either individually or through collapseAll() / expandAll(). Collapsing a row while its detail data is loading aborts the request (params.signal) and discards the result. The same happens when the data source replaces the master rows (a block load, page change, or refresh()): pending detail requests for the discarded rows are aborted, so repeated refreshes never accumulate obsolete requests. Rows that a hierarchy plugin merely hides (a collapsed Tree or Row Grouping parent) keep their in-flight request.
detail-expand is fired only for click and keyboard expansions, and always before the fetch starts — so a detail-load-error for that expansion can never precede it, even when the request fails synchronously, and collapsing the row from the detail-expand listener skips the fetch entirely. The programmatic API (expand(), toggle(), expandAll()) and rows that expandAll() expands later fire no detail-expand, so a programmatic retry must clear the marker itself:
const plugin = grid.getPluginByName('masterDetail');let failed = new WeakSet<object>();grid.on('detail-load-error', ({ row, error }) => { failed.add(row); console.error('Order items failed to load', error);});// Click/keyboard re-expansion retries the fetch, so clear the markergrid.on('detail-expand', ({ row, expanded }) => { if (expanded) failed.delete(row);});// Programmatic retries fire no detail-expand — clear the marker explicitlyfunction retryDetail(rowIndex: number) { plugin.collapse(rowIndex); failed.delete(grid.rows[rowIndex]); plugin.expand(rowIndex);}function retryAllDetails() { failed = new WeakSet(); plugin.collapseAll(); plugin.expandAll();}// In detailRenderer: if (failed.has(row)) return '<div class="error">Could not load order items.</div>';Async API:
const plugin = grid.getPluginByName('masterDetail');plugin.getDetailData(rowIndex); // Get fetched detail data (or undefined if not loaded)plugin.isDetailLoading(rowIndex); // Check if detail data is currently loadingNested Grid Example
Section titled “Nested Grid Example”features: { masterDetail: { detailRenderer: (row) => { const childGrid = document.createElement('tbw-grid'); childGrid.style.height = '200px'; childGrid.gridConfig = { columns: [...] }; childGrid.rows = row.items || []; return childGrid; }, },},The child grid is a fully independent instance: the parent ignores clicks, keyboard navigation, drags, right-clicks, and editor focus changes that happen inside it — including when the parent uses EditingPlugin({ mode: 'grid' }) — and never touches its focus or selection markers. Events the child emits (row-click, cell-click, …) still bubble through the parent host in the DOM — filter on event.target if you listen on the parent for both.
Programmatic API
Section titled “Programmatic API”See MasterDetailPlugin for the full list of methods.
const plugin = grid.getPluginByName('masterDetail');
plugin.expand(rowIndex);plugin.collapse(rowIndex);plugin.toggle(rowIndex);plugin.expandAll();plugin.collapseAll();plugin.isExpanded(rowIndex); // booleanStyling
Section titled “Styling”The master-detail plugin supports CSS custom properties for theming. Override these on tbw-grid or a parent container:
CSS Custom Properties
Section titled “CSS Custom Properties”| Property | Default | Description |
|---|---|---|
--tbw-master-detail-bg | var(--tbw-color-row-alt) | Detail row background |
--tbw-master-detail-border | var(--tbw-color-border) | Detail row border |
--tbw-detail-padding | 1em | Detail content padding |
--tbw-detail-max-height | 31.25rem (~500px) | Max height for animation |
--tbw-animation-duration | 200ms | Expand/collapse animation |
--tbw-animation-easing | ease-out | Animation curve |
Example
Section titled “Example”tbw-grid { /* Custom master-detail styling */ --tbw-master-detail-bg: #f8f9fa; --tbw-master-detail-border: #dee2e6; --tbw-detail-padding: 1.5rem; --tbw-animation-duration: 300ms;}CSS Classes
Section titled “CSS Classes”The master-detail plugin uses these class names:
| Class | Element |
|---|---|
.master-detail-expander | Expander cell container |
.master-detail-toggle | Expand/collapse icon |
.master-detail-row | Detail row container |
.master-detail-row.tbw-expanding | Row during expand animation |
.master-detail-row.tbw-collapsing | Row during collapse animation |
.master-detail-cell | Detail content wrapper |
.tbw-row-expanded | Applied to a master .data-grid-row while its detail panel is showing. Use this for theming expanded master rows instead of [aria-expanded="true"] (which lives on the toggle button, not the row). |
Events
Section titled “Events”Click the expand arrow to open/close row details.
| Event | Detail | Description |
|---|---|---|
detail-expand | { rowIndex, row, expanded } | Fired when a detail row is expanded/collapsed by click or keyboard (not by the programmatic API) |
detail-load-error | { rowIndex, row, error } | Fired when the ServerSide getChildRows() request for a row’s detail data fails. See Server-Side Data. |
See DetailExpandDetail and DetailLoadErrorDetail for the payload types.
See Also
Section titled “See Also”- Tree — Hierarchical tree data
- Row Grouping — Group rows by field values; master-detail toggles appear on data rows within groups
- Selection — Row and cell selection