Chapter 38 of 39

Master/detail: an order and its items

100,000 orders – open one and its items appear right below it as a second table, also while you scroll far away and back.

  • ⏱ 5 min
  • ▶ Video episode 038
  • git tag ep-038
  • needs @guiexpert/table@1.1.97

After this chapter you can

  • show a detail row with any content below a row
  • nest a second GuiExpert table without leaks
  • choose a fixed or a measured height
  • know why the detail survives scrolling and sorting
  • use the keyboard, events and framework components

One row, more to say

An order has items, a customer has contacts, a ticket has a history. Showing all of it as columns makes the table unreadable; a dialog hides the other rows. Master/detail opens a row: right below it, over the full width of the table, a detail row shows whatever belongs to it – here a second GuiExpert table with the items. Since @guiexpert/table@1.1.97 this is one option – MIT-licensed; in ag-Grid master/detail is an Enterprise feature.

Live 100,000 orders with their items
Screenshot: 100,000 orders with their items Screenshot: 100,000 orders with their items
Click ▸ or press Enter on a row. Scroll far down and back up: the open detail keeps its scroll position.

One option, one renderer

TableOptionsIf.masterDetail needs a detailRenderer: a function that gets an empty container, the row and a context (DetailRenderContextIf). For a nested table, DetailRenderContextIf.createTable() does the rest – including destroy() when the detail row goes away:

src/order-items.ts
/** The detail: the items of the order as a nested table (destroyed with the detail row). */
const itemTable: DetailRenderFnType<Order> = (_container, order, ctx) => {
ctx.createTable({
tableModel: TableFactory.createTableModel({
rows: itemsOf(order),
columnDefs: [
ColumnDef.create({ property: "sku", headerLabel: "SKU", width: px120 }),
ColumnDef.create({ property: "product", headerLabel: "Product", width: px200 }),
ColumnDef.create({ property: "quantity", headerLabel: "Qty", width: px100 }),
ColumnDef.create({ property: "price", headerLabel: "Price", width: px100 })
]
})
});
};
const tableOptions: TableOptionsIf = {
...new TableOptions(),
getFocusModel: () => focusModel,
getSelectionModel: () => selectionModel,
masterDetail: {
// px per order: header + one row per item, padding and borders – or measured ("auto")
detailHeight: form ? "auto" : (order: Order) => (itemsOf(order).length + 1) * 34 + 18,
detailRenderer: form ? noteForm : itemTable
}
};
api.setDetailExpanded(1, true); // display index – or the row object: api.setDetailExpanded(orders[1], true)

Why the detail survives scrolling

Virtual scrolling (chapter 2 (Virtual scrolling)) rebuilds the visible rows in every frame. A nested table living inside such a row would be destroyed and created again all the time – with its scroll position, its selection and every input. So the detail rows live on their own layer above the rows:

  1. the content is created when the detail row becomes visible for the first time – opening 100,000 rows costs nothing in advance;
  2. scrolled out of view, it is only hidden (content-visibility: hidden) – back in view it is the same DOM, with the same scroll position;
  3. it is destroyed when the row is closed, when the table is destroyed (the wrappers do it on a new model or unmount) and when more than maxRenderedDetails (50) contents exist – then the one out of view the longest goes first and comes back as new (context.recreated).

The heights of the detail rows sit between the row heights. Since 1.1.97 the table keeps the row positions as prefix sums (RowLayout): the first visible row is found by a binary search, so scrolling stays fast with open details – and for variable row heights in general (2 million rows: as fast as with one fixed height). Sorting and filtering keep each detail with its row (the row object, or getRowId).

Fixed or measured height

  • detailHeight: 220 – px; the container scrolls if the content is taller. Best for nested tables.
  • detailHeight: "auto" (the default) – measured with a ResizeObserver; if the content grows (a textarea is resized), the rows below move. Until the first measurement estimatedDetailHeight (200) is used.
  • A function per row – e.g. the number of items × the row height.

The form of the demo (switch “form instead of items”) has a measured height:

/** Another detail: a form – texts via textContent/value, never innerHTML. Its height is measured ("auto"). */
const noteForm: DetailRenderFnType<Order> = (container, order, ctx) => {
const label = document.createElement("label");
label.textContent = `Note for order ${order.id} (${order.customer})`;
const note = document.createElement("textarea");
note.rows = 2;
note.style.cssText = "display:block; width:min(480px, 100%); margin-top:4px";
note.addEventListener("change", () => ctx.tableApi.announce("Note saved"), { signal: ctx.signal });
label.append(note);
container.append(label);
};

Events, keyboard, screen readers

const listener = new EventAdapter();
listener.onDetailExpandedChanged = (evt: GeDetailEvent<Order>) =>
ctx.info(`order ${evt.row.id} ${evt.expanded ? "opened" : "closed"} (${evt.source})`);
  • EventListenerIf.onDetailExpandedChanged() – GeDetailEvent with row, index, expanded and source (mouse, keyboard, api, state); in the wrappers detailExpandedChanged.
  • Clicks, keys and drags inside a detail belong to the detail: arrow keys in the item table do not move the focus of the orders. The mouse wheel scrolls the item table first, then the orders.
  • Enter opens and closes the focused row, Tab moves into its open detail, Shift+Tab back. Screen readers hear “expanded” (aria-expanded, the grid becomes a treegrid) and “Details expanded” (chapter 35 (Accessibility)).
  • The open orders can be remembered: autoRestoreDetailState (chapter 33 (Persist state)).
  • Copy and Excel export the orders only.

Framework components as detail

Every wrapper has a DetailComponentRendererWrapper – the component gets row, rowIndex, tableApi and context, and keeps its state while you scroll:

function OrderItems({ row }: DetailComponentProps<Order>) {
const model = useMemo(() => TableFactory.createTableModel({ rows: row.items, properties: ["sku", "qty"] }), [row]);
return <div style={{ height: 180 }}><GuiexpertTable tableModel={model} /></div>;
}
const tableOptions = { ...new TableOptions(),
masterDetail: { detailHeight: 200, detailRenderer: new DetailComponentRendererWrapper(OrderItems) } };

Vue, Svelte 4/5, Solid and Preact work the same way; in Angular a component implements DetailComponentRendererIf and inject(DetailRenderWrapperFactory).create(OrderItemsComponent) creates the renderer.

Next

Computed columns for the same orders – net, gross, shares and formulas: chapter 39 (Calculated columns & formulas).

API used in this chapter

Showcases