마스터-디테일 (Master-Detail)
그리드 행을 펼쳐 중첩된 서브 그리드나 폼, 상세 뷰를 전체 너비로 표시하고 이벤트를 안전하게 격리하는 방법을 설명합니다.
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>
);
}import * as React from 'react';
import './DataGridContainer.css';
interface DataGridContainerProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
}
/**
* Keeps a DataGrid in a measured, fixed layout box.
*
* BGrid's rendered root is absolutely positioned within this relative
* container. This makes a ResizeObserver measurement authoritative when a
* surrounding flex or grid layout shrinks as well as when it expands.
*/
const DataGridContainer = React.forwardRef<HTMLDivElement, DataGridContainerProps>(
({ className, ...rest }, ref) => (
<div ref={ref} className={`data-grid-container ${className ?? ''}`.trim()} {...rest} />
),
);
DataGridContainer.displayName = 'DataGridContainer';
export default DataGridContainer;.data-grid-container {
position: relative;
width: 100%;
height: 400px;
overflow: hidden;
font-size: 13px;
}
.data-grid-container > .bgrid-root {
position: absolute;
inset: 0;
}import * as React from 'react';
export function useContainerSize(ref: React.MutableRefObject<HTMLElement | null>, additionalDeps: unknown[] = []) {
const [width, setWidth] = React.useState(0);
const [height, setHeight] = React.useState(0);
const resizeObserver = React.useRef(
new ResizeObserver(entries => {
if (entries.length !== 1) {
throw new Error('Invalid Container length');
}
const [entry] = entries;
const { width, height } = entry.contentRect;
setWidth(width);
setHeight(height);
}),
);
React.useEffect(() => {
if (!ref.current) return;
const observer = resizeObserver.current;
const element = ref.current;
setWidth(element.clientWidth);
setHeight(element.clientHeight);
observer.observe(element);
return () => {
observer.unobserve(element);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...additionalDeps, ref]);
return {
width,
height,
};
}행을 펼쳐 상세 뷰 표시하기
마스터-디테일 모드는 주문 목록 아래의 품목 내역, 고객 프로필 아래의 거래 기록처럼 각 행에 종속된 세부 정보나 중첩 <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과는 동시에 사용할 수 없으며 충돌 시 안전하게 마스터-디테일이 비활성화됩니다.