마스터-디테일 (Master-Detail)

그리드 행을 펼쳐 중첩된 서브 그리드나 폼, 상세 뷰를 전체 너비로 표시하고 이벤트를 안전하게 격리하는 방법을 설명합니다.

#master-detail#nested-grid#expandable-rows#virtual-scroll#accessibility
지원 버전: v1.15.0+검토일: 2026-09-17
LIVE DEMO마스터-디테일 (Master-Detail) 인터랙티브 예제
인터랙티브 데모를 불러오는 중입니다…
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>
  );
}

행을 펼쳐 상세 뷰 표시하기

마스터-디테일 모드는 주문 목록 아래의 품목 내역, 고객 프로필 아래의 거래 기록처럼 각 행에 종속된 세부 정보나 중첩 <BGrid>를 표시할 때 사용합니다. 기존 <BGrid>의 masterDetail prop으로 활성화합니다.

고유 키 식별을 위해 rowKey가 반드시 지정되어야 합니다.

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: '주문번호', width: 140 },
  { id: 'customer', key: 'customer', label: '고객명', 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>주문 품목 ({item.values.items.length}건)</h4>
        {/* 임의의 리액트 컴포넌트 또는 중첩 BGrid 렌더링 */}
      </div>
    ),
  }}
/>;

expandColumnId를 생략하면 가장 앞쪽에 토글 버튼 전용 컬럼(__bgrid_master_detail__)이 자동으로 추가됩니다. 기존 컬럼 셀 내부에 토글 버튼을 배치하려면 해당 컬럼의 id를 expandColumnId로 지정합니다.

펼침 상태 제어 (Controlled & Uncontrolled)

비제어 모드에서는 defaultExpandedRowKeys로 초기 펼침 행을 지정합니다. 외부 상태로 제어하려면 expandedRowKeys와 onExpandedRowKeysChange를 함께 사용합니다.

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

<BGrid
  {...gridProps}
  rowKey='orderId'
  masterDetail={{
    expandedRowKeys,
    onExpandedRowKeysChange: (nextKeys, event) => {
      console.log('토글된 행:', event.rowKey, '펼침 여부:', event.expanded);
      setExpandedRowKeys(nextKeys);
    },
    detailRender: ({ item }) => <OrderDetailView order={item.values} />,
  }}
/>;

단일 및 다중 펼침 모드 (expandMode)

  • 'multiple' (기본값): 여러 행을 동시에 펼쳐서 비교하거나 확인할 수 있습니다.
  • 'single' (아코디언 모드): 새 행을 펼치면 이전에 펼쳐져 있던 다른 행이 자동으로 접힙니다.
masterDetail={{
  expandMode: 'single',
  detailRender: ({ item }) => <OrderDetailView order={item.values} />,
}}

행별 펼침 조건 (hasDetail)

특정 행에 하위 데이터가 없는 경우 토글 버튼을 숨기거나 비활성화하려면 hasDetail 콜백을 정의합니다. hasDetail이 false를 반환하면 버튼 대신 정렬 간격을 유지하는 투명 스페이서가 렌더링됩니다.

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

전체 너비 렌더링 및 가상 스크롤 격리

틀고정 컬럼(frozenColumnIndex > 0)이 활성화되어 있어도 디테일 패널은 고정 영역과 스크롤 영역을 아우르는 **전체 너비 공통 레이어(MasterDetailLayer)**에 렌더링됩니다.

  • 마스터 행 셀 높이 보존: 디테일 패널이 열려도 부모 행 <tr>의 셀들은 위아래로 늘어나지 않으며 기본 rowHeight를 그대로 유지합니다.
  • 스페이서 행 연동: 본문 테이블에는 디테일 패널 높이만큼의 투명 스페이서 행이 삽입되어 가상 스크롤 오프셋과 마스터 행 간격을 정확히 유지합니다.

이벤트 및 포커스 격리

디테일 뷰 내부에는 중첩된 <BGrid>, 텍스트 입력창, 버튼, 셀렉트 박스 등 임의의 대화형 컴포넌트를 자유롭게 배치할 수 있습니다.

  • 디테일 패널 내부에서 발생하는 클릭, 드래그, 키보드 입력은 부모 테이블의 셀 선택, 행 체크, 셀 편집 진입, 단축키 복사 동작을 트리거하지 않습니다.
  • 포커스가 디테일 패널 내부에 있는 상태에서 행이 접히면 포커스가 해당 행의 펼침 토글 버튼으로 안전하게 복귀합니다.

커스텀 아이콘

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

제한 사항 및 충돌 가이드

  • rowKey가 지정되지 않으면 마스터-디테일 기능이 활성화되지 않으며 개발 모드 콘솔에 경고가 출력됩니다.
  • 계층 구조가 겹치는 tree 모드, pivot 모드, cellMergeOptions, reorder, frozenRowCount > 0과는 동시에 사용할 수 없으며 충돌 시 안전하게 마스터-디테일이 비활성화됩니다.