Rows

Master Detail

A panel under any row, drawn by a snippet of your own. Rows without one keep no chevron, so the grid never offers to expand something empty.

Basic Usage

The panel is a full-width row, drawn by the fullWidthRow snippet. It is a real row of the grid, so it sits in the scroll flow and in the keyboard matrix rather than floating over them.

Order
Customer
Placed
Status
Total
SO-2100
Meridian Labs
Jan 4, 2026
paid
$0.00
SO-2103
Peralta Foods
Apr 7, 2026
paid
$0.00
SO-2106
Solvang Freight
Jan 10, 2026
paid
$0.00
8 rows

The grid is holding 8 order rows and 0 open panels. Counted live with isDataRow, which is what to walk with when a panel must not be counted as data.

<script lang="ts">
  import {
    createDataGrid, DataGrid, masterDetail, sorting,
    type DataGridFullWidthContext
  } from '@sv5ui/datagrid';

  const money = new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD'
  });

  const grid = createDataGrid<Order>({
    columns,
    data: orders,
    getRowId: (order) => order.id,
    features: [
      sorting<Order>(),
      masterDetail<Order>({ hasDetail: (order) => order.lines.length > 0 })
    ]
  });
</script>

{#snippet lines({ row }: DataGridFullWidthContext<Order>)}
  <div class="border-s-2 border-primary/40 bg-surface-container/40 px-4 py-3">
    <p class="mb-2 text-xs font-medium text-on-surface">
      {row.id}
      <span class="font-normal text-on-surface/60">
        &middot; {row.lines.length} lines
      </span>
    </p>

    <table class="w-full text-xs">
      <thead>
        <tr class="border-b border-on-surface/15 text-on-surface/60">
          <th class="w-1/2 py-1.5 text-start font-medium">SKU</th>
          <th class="py-1.5 text-end font-medium">Qty</th>
          <th class="py-1.5 text-end font-medium">Unit</th>
          <th class="py-1.5 text-end font-medium">Line total</th>
        </tr>
      </thead>
      <tbody>
        {#each row.lines as line (line.sku)}
          <tr class="border-b border-on-surface/5 last:border-0">
            <td class="py-1.5 font-mono">{line.sku}</td>
            <td class="py-1.5 text-end tabular-nums">{line.qty}</td>
            <td class="py-1.5 text-end tabular-nums">{money.format(line.price)}</td>
            <td class="py-1.5 text-end font-medium tabular-nums">
              {money.format(line.qty * line.price)}
            </td>
          </tr>
        {/each}
      </tbody>
      <tfoot>
        <tr class="border-t border-on-surface/15">
          <td class="py-1.5 text-on-surface/60" colspan="3">Order total</td>
          <td class="py-1.5 text-end font-semibold tabular-nums">
            {money.format(row.total)}
          </td>
        </tr>
      </tfoot>
    </table>
  </div>
{/snippet}

<DataGrid {grid} fullWidthRow={lines} />

<!-- row is the master row, not one of its own: a detail node points back at
     the row it opened under, so the footer above can read row.total and
     match the Total column of the grid row it hangs under. -->

The snippet is handed the master row, not a row of its own: a detail node points back at the row it opened under, so row is the same object the grid row above it drew.

Rows That Cannot Open

hasDetail decides which rows offer a panel. Three of the orders above have no lines yet, and they carry no chevron at all rather than opening onto nothing: SO-2100, SO-2103, SO-2106.

masterDetail<Order>({
  hasDetail: (order) => order.lines.length > 0
});

const detail = getMasterDetail(grid)!;
detail.hasDetail(orders[0]);   // false: no lines, so no chevron

// Without hasDetail every row can open, which is wrong the moment one has
// nothing to show.

One Panel at a Time

single: true closes the last panel when the next one opens. Open two rows below and the first shuts on its own.

Order
Customer
Placed
Status
Total
SO-2100
Meridian Labs
Jan 4, 2026
paid
$0.00
SO-2103
Peralta Foods
Apr 7, 2026
paid
$0.00
SO-2106
Solvang Freight
Jan 10, 2026
paid
$0.00
8 rows
masterDetail<Order>({ hasDetail, single: true });

const detail = getMasterDetail(grid)!;
detail.toggle('SO-2101');
detail.toggle('SO-2102');   // SO-2101 closes on its own
detail.closeAll();

A Panel That Fetches

A panel is mounted when it opens and unmounted when it closes, so a request belongs inside the snippet rather than beside the grid.

The grid below is that, running. Both halves are requests: the five teams were counted and totalled by the server, and a team's best paid people are not asked for until its row is opened. Watch the log underneath while you open one, then another.

POST /datagrid/api/people single: true 0 teams
Team
People
Payroll

No data

0 rows

The last few requests

  • Nothing yet. Open a team.
{#snippet lines({ row }: DataGridFullWidthContext<Order>)}
  <div class="border-s-2 border-primary/40 bg-surface-container/40 px-4 py-3">
    {#await loadLines(row.id)}
      <p class="text-xs text-on-surface/60">Loading...</p>
    {:then lines}
      <LineTable {lines} />
    {:catch error}
      <p class="text-xs text-error">{error.message}</p>
    {/await}
  </div>
{/snippet}

<!-- A panel is mounted when it opens and unmounted when it closes, so the
     fetch belongs in the snippet. Pair it with single: true, or opening five
     rows starts five requests and closing them cancels none. -->

Options

Everything masterDetail() accepts.

OptionDescription
hasDetailWhich rows can open a panel. A row that answers false keeps no chevron, so the grid never offers to expand something empty
singleOnly one panel open at a time. Worth it when a panel is tall, or fetches its own data

Reading It Back

What getMasterDetail(grid) exposes. toggle and closeAll are also on grid.api.

MemberWhat it answers
toggle(id)Open or close the panel under that row
closeAll()Close every open panel
hasDetail(row)Whether that row can open at all
isDetailRow(node)Whether a node is a panel rather than a row
masterOf(node)The row id a panel hangs under
isDetailNode(id)The same question from a bare row id, with no node and no feature to ask. Exported on its own

Not Beside a Pivot

A detail panel opens under a row from the data. A pivot row is a total over many of them, so there is no one row for a panel to describe, and the grid refuses to build with masterDetail() and pivot() registered together. What a reader wants from a pivot cell is the rows behind it, which rowsBehind answers.

Driving It From Code

import { getMasterDetail, isDataRow, isDetailNode } from '@sv5ui/datagrid';

const detail = getMasterDetail(grid)!;

detail.toggle('SO-2101');
detail.closeAll();
detail.hasDetail(order);      // whether that row can open
detail.isDetailRow(node);     // whether a node is a panel
detail.masterOf(node);        // the row id a panel hangs under
detail.single;

grid.api.toggleDetail?.('SO-2101');
grid.api.closeAllDetails?.();

isDetailNode(id);             // the same question from a bare row id

let dataRows = 0;
for (const node of grid.preWindowNodes) if (isDataRow(node)) dataRows += 1;
// 8 order rows, however many panels are open