Master-Detail

Expand grid rows to reveal nested subgrids, forms, or detail panels spanning the full width with complete event isolation.

#master-detail#nested-grid#expandable-rows#virtual-scroll#accessibility
Available since: v1.14.0+Last reviewed: 2026-09-17
LIVE DEMOMaster-Detail interactive example
Loading the interactive demo…
GitHub
import * as React from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { BGrid, type BGridColumn, type BGridDataItem, type BGridMasterDetailExpandMode } from 'beautiful-grid';
import DataGridContainer from '../components/DataGridContainer';
import { useContainerSize } from '../hooks/useContainerSize';
import { exampleMsg, t } from './i18n';
import './masterDetail.css';
import { Button, Select } from 'antd';

interface OrderItem {
  itemCode: string;
  productName: string;
  unitPrice: number;
  quantity: number;
  subtotal: number;
}

interface OrderRow {
  orderId: string;
  customer: string;
  orderDate: string;
  status: string;
  itemCount: number;
  totalAmount: number;
  notes?: string;
  items: OrderItem[];
}

const sampleItemsPool: Omit<OrderItem, 'quantity' | 'subtotal'>[] = [
  { itemCode: 'PRD-101', productName: 'Ergonomic Keyboard', unitPrice: 129000 },
  { itemCode: 'PRD-102', productName: 'Wireless Vertical Mouse', unitPrice: 65000 },
  { itemCode: 'PRD-103', productName: '4K Ultra-wide Monitor 34"', unitPrice: 680000 },
  { itemCode: 'PRD-104', productName: 'Noise Cancelling Headset', unitPrice: 249000 },
  { itemCode: 'PRD-105', productName: 'USB-C Multi-hub Dock', unitPrice: 89000 },
  { itemCode: 'PRD-106', productName: 'Desk Mat Large (Black)', unitPrice: 25000 },
  { itemCode: 'PRD-107', productName: 'Aluminum Laptop Stand', unitPrice: 42000 },
];

const customerNames = [
  'Alice Kim',
  'Bob Lee',
  'Charlie Park',
  'David Choi',
  'Emma Jung',
  'Frank Kang',
  'Grace Yoon',
  'Henry Shin',
  'Isabella Han',
  'Jack Song',
];

const statusOptions = ['Processing', 'Shipped', 'Delivered', 'Cancelled'];

const generateOrders = (count: number): BGridDataItem<OrderRow>[] => {
  const list: BGridDataItem<OrderRow>[] = [];
  for (let i = 1; i <= count; i++) {
    const idNum = String(i).padStart(4, '0');
    const orderId = `ORD-${idNum}`;
    const customer = customerNames[(i - 1) % customerNames.length];
    const status = statusOptions[(i - 1) % statusOptions.length];
    const day = String((i % 28) + 1).padStart(2, '0');
    const orderDate = `2026-03-${day}`;

    // 0 to 4 line items (some orders have 0 items to demonstrate hasDetail: false)
    const itemCount = i % 7 === 0 ? 0 : (i % 4) + 1;
    const items: OrderItem[] = [];
    let total = 0;

    for (let j = 0; j < itemCount; j++) {
      const sample = sampleItemsPool[(i - 1 + j) % sampleItemsPool.length];
      const qty = (j % 3) + 1;
      const subtotal = sample.unitPrice * qty;
      total += subtotal;
      items.push({
        ...sample,
        quantity: qty,
        subtotal,
      });
    }

    list.push({
      values: {
        orderId,
        customer,
        orderDate,
        status,
        itemCount,
        totalAmount: total,
        notes: itemCount > 0 ? `Please handle with care. Ship via express.` : undefined,
        items,
      },
    });
  }
  return list;
};

const ordersData = generateOrders(60);

const masterColumns: BGridColumn<OrderRow>[] = [
  {
    id: 'orderId',
    key: 'orderId',
    label: t('주문 번호', 'Order ID'),
    width: 130,
  },
  {
    id: 'customer',
    key: 'customer',
    label: t('고객명', 'Customer'),
    width: 140,
  },
  {
    id: 'orderDate',
    key: 'orderDate',
    label: t('주문 일자', 'Order Date'),
    width: 120,
    align: 'center',
  },
  {
    id: 'status',
    key: 'status',
    label: t('상태', 'Status'),
    width: 120,
    align: 'center',
    itemRender: ({ value }) => {
      const colorClass =
        value === 'Delivered'
          ? 'text-emerald-600'
          : value === 'Shipped'
          ? 'text-blue-600'
          : value === 'Processing'
          ? 'text-amber-600'
          : 'text-red-600';
      return <span className={`font-semibold ${colorClass}`}>{value}</span>;
    },
  },
  {
    id: 'itemCount',
    key: 'itemCount',
    label: t('품목 수', 'Item Count'),
    width: 100,
    align: 'right',
    itemRender: ({ value }) => `${value} ${t('개', 'items')}`,
  },
  {
    id: 'totalAmount',
    key: 'totalAmount',
    label: t('주문 총액', 'Total Amount'),
    width: 150,
    align: 'right',
    itemRender: ({ value }) => `₩${(value as number).toLocaleString()}`,
  },
];

const itemColumns: BGridColumn<OrderItem>[] = [
  { id: 'itemCode', key: 'itemCode', label: t('품목 코드', 'Item Code'), width: 110 },
  { id: 'productName', key: 'productName', label: t('상품명', 'Product Name'), width: 220 },
  {
    id: 'unitPrice',
    key: 'unitPrice',
    label: t('단가', 'Unit Price'),
    width: 110,
    align: 'right',
    itemRender: ({ value }) => `₩${(value as number).toLocaleString()}`,
  },
  { id: 'quantity', key: 'quantity', label: t('수량', 'Quantity'), width: 80, align: 'right' },
  {
    id: 'subtotal',
    key: 'subtotal',
    label: t('소계', 'Subtotal'),
    width: 120,
    align: 'right',
    itemRender: ({ value }) => `₩${(value as number).toLocaleString()}`,
  },
];

function OrderItemsGrid({ items }: { items: OrderItem[] }) {
  const containerRef = React.useRef<HTMLDivElement>(null);
  const { width } = useContainerSize(containerRef);
  const nestedData: BGridDataItem<OrderItem>[] = React.useMemo(() => items.map(it => ({ values: it })), [items]);

  return (
    <div ref={containerRef} className='master-detail-order-items h-[130px] overflow-hidden border'>
      <BGrid
        className='master-detail-child-grid'
        headerHeight={25}
        itemHeight={15}
        width={width > 0 ? width : 640}
        height={130}
        columns={itemColumns}
        data={nestedData}
        rowKey='itemCode'
        msg={exampleMsg}
        // variant='vertical-bordered'
        showLineNumber
        scrollbar={{
          variant: 'native',
        }}
        status={{ visible: false }}
      />
    </div>
  );
}

export default function MasterDetailExample() {
  const containerRef = React.useRef<HTMLDivElement>(null);
  const { width, height } = useContainerSize(containerRef);
  const [expandMode, setExpandMode] = React.useState<BGridMasterDetailExpandMode>('multiple');
  const [expandedRowKeys, setExpandedRowKeys] = React.useState<React.Key[]>(['ORD-0001']);
  const [orderMemos, setOrderMemos] = React.useState<Record<string, string>>({
    'ORD-0001': 'Deliver before 2 PM if possible.',
  });

  const handleExpandAll = React.useCallback(() => {
    if (expandMode === 'single') {
      const firstExpandable = ordersData.find(d => d.values.itemCount > 0);
      setExpandedRowKeys(firstExpandable ? [firstExpandable.values.orderId] : []);
    } else {
      setExpandedRowKeys(ordersData.filter(d => d.values.itemCount > 0).map(d => d.values.orderId));
    }
  }, [expandMode]);

  const handleCollapseAll = React.useCallback(() => {
    setExpandedRowKeys([]);
  }, []);

  const detailRender = React.useCallback(
    ({ item }: { item: BGridDataItem<OrderRow> }) => {
      const order = item.values;
      const memo = orderMemos[order.orderId] ?? order.notes ?? '';

      return (
        <div className='box-border flex h-full flex-col gap-2 px-4 py-3'>
          <div className='flex items-center justify-between'>
            <div className='master-detail-title text-[13px] font-semibold'>
              {t('주문 상세 품목', 'Order Items')} — {order.orderId} ({order.customer})
            </div>
            <span className='master-detail-count text-xs'>
              {t(`총 ${order.items.length}개 품목`, `${order.items.length} line items`)}
            </span>
          </div>

          <OrderItemsGrid items={order.items} />
        </div>
      );
    },
    [orderMemos],
  );

  return (
    <div className='master-detail-example flex h-full flex-col gap-3'>
      <div className='flex flex-wrap items-center gap-3'>
        <label className='inline-flex items-center gap-2 font-medium'>
          <span>{t('펼침 모드', 'Expand Mode')}</span>
          <Select<BGridMasterDetailExpandMode>
            className='min-w-[210px]'
            value={expandMode}
            options={[
              { value: 'multiple', label: t('다중 (Multiple)', 'Multiple') },
              { value: 'single', label: t('단일 (Accordion)', 'Single (Accordion)') },
            ]}
            onChange={v => {
              setExpandMode(v);
              if (v === 'single' && expandedRowKeys.length > 1) {
                setExpandedRowKeys(expandedRowKeys.slice(0, 1));
              }
            }}
          />
        </label>

        <Button size='small' onClick={handleExpandAll}>
          {t('모두 펼치기', 'Expand All')}
        </Button>

        <Button size='small' onClick={handleCollapseAll}>
          {t('모두 접기', 'Collapse All')}
        </Button>

        <span className='text-xs text-slate-500'>
          {t(
            `펼쳐진 행: ${expandedRowKeys.length}개 (일부 행은 품목이 없어 펼침 불가)`,
            `Expanded: ${expandedRowKeys.length} (Rows with 0 items cannot be expanded)`,
          )}
        </span>
      </div>

      <div className='min-h-0 flex-1'>
        <DataGridContainer ref={containerRef}>
          <BGrid
            width={width}
            height={height}
            columns={masterColumns}
            data={ordersData}
            rowKey='orderId'
            msg={exampleMsg}
            variant='vertical-bordered'
            showLineNumber
            masterDetail={{
              expandMode,
              expandedRowKeys,
              onExpandedRowKeysChange: setExpandedRowKeys,
              hasDetail: item => item.values.itemCount > 0,
              detailRowHeight: 200,
              detailRender,
              icons: {
                expanded: <ChevronDown size={14} />,
                collapsed: <ChevronRight size={14} />,
              },
            }}
          />
        </DataGridContainer>
      </div>
    </div>
  );
}

Expand Rows to Display Detail Views

Master-Detail mode is designed for scenarios where each row owns secondary details or a nested <BGrid>, such as purchase order line items or customer transaction history. Enable it via the masterDetail prop on <BGrid>.

A unique rowKey is required to track expansion state accurately across sorting, filtering, and virtualization.

import { BGrid, type BGridColumn, type BGridDataItem } from 'beautiful-grid';

interface OrderItem {
  itemCode: string;
  name: string;
  qty: number;
  price: number;
}

interface Order {
  orderId: string;
  customer: string;
  items: OrderItem[];
}

const columns: BGridColumn<Order>[] = [
  { id: 'orderId', key: 'orderId', label: 'Order ID', width: 140 },
  { id: 'customer', key: 'customer', label: 'Customer', width: 160 },
];

const data: BGridDataItem<Order>[] = [
  {
    values: {
      orderId: 'ORD-001',
      customer: 'Alice',
      items: [{ itemCode: 'ITEM-1', name: 'Keyboard', qty: 1, price: 120000 }],
    },
  },
];

<BGrid
  width={800}
  height={400}
  columns={columns}
  data={data}
  rowKey='orderId'
  masterDetail={{
    detailRowHeight: 180,
    detailRender: ({ item }) => (
      <div style={{ padding: 12 }}>
        <h4>Order Items ({item.values.items.length})</h4>
        {/* Render any React component or nested BGrid */}
      </div>
    ),
  }}
/>;

When expandColumnId is omitted, an expansion toggle column (__bgrid_master_detail__) is prepended to the columns list automatically. To place the toggle button inside an existing column cell instead, specify that column’s id as expandColumnId.

Expansion State Control (Controlled & Uncontrolled)

Use defaultExpandedRowKeys when the grid manages expansion state internally. To control expansion from outside, use expandedRowKeys paired with onExpandedRowKeysChange.

const [expandedRowKeys, setExpandedRowKeys] = useState<React.Key[]>(['ORD-001']);

<BGrid
  {...gridProps}
  rowKey='orderId'
  masterDetail={{
    expandedRowKeys,
    onExpandedRowKeysChange: (nextKeys, event) => {
      console.log('Toggled row:', event.rowKey, 'Expanded:', event.expanded);
      setExpandedRowKeys(nextKeys);
    },
    detailRender: ({ item }) => <OrderDetailView order={item.values} />,
  }}
/>;

Single and Multiple Expand Modes (expandMode)

  • 'multiple' (default): Multiple rows can be opened simultaneously for side-by-side inspection.
  • 'single' (Accordion mode): Expanding a row automatically collapses previously opened rows.
masterDetail={{
  expandMode: 'single',
  detailRender: ({ item }) => <OrderDetailView order={item.values} />,
}}

Row-Level Expansion Filter (hasDetail)

To suppress expansion for rows that lack detail records, pass a hasDetail callback. When hasDetail returns false, a blank spacer is rendered instead of a toggle button to preserve column alignment without broken affordances.

masterDetail={{
  hasDetail: item => item.values.items.length > 0,
  detailRender: ({ item }) => <OrderDetailView order={item.values} />,
}}

Full-Width Layer & Virtual Scroll Layout Isolation

Even when frozen columns (frozenColumnIndex > 0) are active, the detail view is rendered across the entire grid width inside a dedicated sticky layer (MasterDetailLayer).

  • Master Cell Height Preservation: Master row <tr> cells never stretch vertically when the detail view expands; they keep their natural rowHeight.
  • Transparent Spacer Rows: A matching transparent spacer row is rendered in the body table to offset succeeding master rows accurately within the virtual scroll engine.

Complete Event and Focus Isolation

The detail container ([data-bgrid-detail-owner]) accommodates nested <BGrid> instances, input fields, dropdowns, and buttons.

  • Pointer clicks, drags, and keystrokes occurring inside the detail view never bubble up to parent grid cell selection, row checking, cell editing, or copy-paste shortcuts.
  • If focus is inside a detail view when that row is collapsed, focus returns safely to that row’s toggle button.

Custom Icons

masterDetail={{
  icons: {
    expanded: <ChevronDown size={14} />,
    collapsed: <ChevronRight size={14} />,
  },
  detailRender: ({ item }) => <OrderDetailView order={item.values} />,
}}

Compatibility & Constraints

  • A valid rowKey is strictly required. A development warning is emitted if omitted.
  • Master-detail cannot be combined with tree, pivot, cellMergeOptions, reorder, or frozenRowCount > 0. Conflicts are gracefully resolved by disabling master-detail and logging a development warning.