Cell Merge
Learn how to visually merge identical values in adjacent rows to create readable, grouped report tables.
import * as React from 'react';
import { BGrid, BGridColumn, BGridDataItem } from 'beautiful-grid';
import { useContainerSize } from '../hooks/useContainerSize';
import DataGridContainer from '../components/DataGridContainer';
interface InventoryItem {
mainCategory: string;
subCategory: string;
itemCode: string;
itemName: string;
unitPrice: number;
stockQty: number;
warehouse: string;
}
const inventoryRows: InventoryItem[] = [
{ mainCategory: '가전/디지털', subCategory: '컴퓨터 주변기기', itemCode: 'IT-001', itemName: '프리미엄 무선 키보드', unitPrice: 129000, stockQty: 84, warehouse: 'A-01' },
{ mainCategory: '가전/디지털', subCategory: '컴퓨터 주변기기', itemCode: 'IT-002', itemName: '인체공학 마우스', unitPrice: 69000, stockQty: 46, warehouse: 'A-01' },
{ mainCategory: '가전/디지털', subCategory: '컴퓨터 주변기기', itemCode: 'IT-003', itemName: 'USB-C 멀티 허브', unitPrice: 89000, stockQty: 31, warehouse: 'A-02' },
{ mainCategory: '가전/디지털', subCategory: '모니터/디스플레이', itemCode: 'IT-004', itemName: '27인치 QHD 모니터', unitPrice: 389000, stockQty: 18, warehouse: 'B-01' },
{ mainCategory: '가전/디지털', subCategory: '모니터/디스플레이', itemCode: 'IT-005', itemName: '32인치 4K 모니터', unitPrice: 629000, stockQty: 9, warehouse: 'B-01' },
{ mainCategory: '가구/인테리어', subCategory: '사무용 가구', itemCode: 'FN-001', itemName: '모션 데스크 1400', unitPrice: 459000, stockQty: 22, warehouse: 'C-01' },
{ mainCategory: '가구/인테리어', subCategory: '사무용 가구', itemCode: 'FN-002', itemName: '인체공학 메시 의자', unitPrice: 329000, stockQty: 37, warehouse: 'C-01' },
{ mainCategory: '가구/인테리어', subCategory: '수납 가구', itemCode: 'FN-003', itemName: '이동식 서랍장', unitPrice: 119000, stockQty: 41, warehouse: 'C-02' },
{ mainCategory: '가구/인테리어', subCategory: '수납 가구', itemCode: 'FN-004', itemName: '5단 철제 선반', unitPrice: 149000, stockQty: 26, warehouse: 'C-02' },
{ mainCategory: '생활/주방', subCategory: '홈카페', itemCode: 'KT-001', itemName: '전자동 커피머신', unitPrice: 749000, stockQty: 12, warehouse: 'D-01' },
{ mainCategory: '생활/주방', subCategory: '홈카페', itemCode: 'KT-002', itemName: '온도조절 전기포트', unitPrice: 99000, stockQty: 53, warehouse: 'D-01' },
{ mainCategory: '생활/주방', subCategory: '조리도구', itemCode: 'KT-003', itemName: '스테인리스 팬 세트', unitPrice: 189000, stockQty: 29, warehouse: 'D-02' },
];
const data: BGridDataItem<InventoryItem>[] = inventoryRows.map(values => ({ values }));
function CellMergeExample() {
const [columns, setColumns] = React.useState<BGridColumn<InventoryItem>[]>([
{ key: 'mainCategory', label: '대분류', width: 130, align: 'center' },
{ key: 'subCategory', label: '중분류', width: 150, align: 'center' },
{ key: 'itemCode', label: '품목코드', width: 100, align: 'center' },
{ key: 'itemName', label: '품목명', width: 220 },
{
key: 'unitPrice',
label: '단가',
width: 120,
align: 'right',
itemRender: ({ values }) => <>{values.unitPrice.toLocaleString()}원</>,
},
{ key: 'stockQty', label: '재고', width: 80, align: 'right', itemRender: ({ values }) => <>{values.stockQty}개</> },
{ key: 'warehouse', label: '창고', width: 90, align: 'center' },
]);
const containerRef = React.useRef<HTMLDivElement>(null);
const { width, height } = useContainerSize(containerRef);
return (
<DataGridContainer ref={containerRef}>
<BGrid<InventoryItem>
showLineNumber
frozenColumnIndex={2}
width={width}
height={height}
data={data}
columns={columns}
rowKey='itemCode'
onChangeColumns={(_columnIndex, { columns }) => setColumns(columns)}
cellMergeOptions={{
columnsMap: {
0: { mergeBy: 'mainCategory' },
1: { mergeBy: 'subCategory' },
},
}}
variant='vertical-bordered'
/>
</DataGridContainer>
);
}
export default CellMergeExample;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,
};
}1. When and why should you use cell merging?
Management dashboards, settlement reports, and inventory summaries often repeat the same values—such as category, subcategory, or owner—across several rows. That repetition can make a table harder to scan.
BeautifulGrid’s cell merge feature lets you:
- Detect consecutive adjacent rows with the same value and display their cells as a visual
rowspan. - Apply the same
columnsMapmerge rules to regular and frozen columns. - Configure each merge rule with
mergeByso that it follows the sort order of the data.
2. Complete example: sales items grouped by category
The following example merges cells in the Category and Subcategory columns:
import React, { useState } from 'react';
import { BGrid, type BGridColumn, type BGridDataItem } from 'beautiful-grid';
interface CategoryItem {
mainCategory: string;
subCategory: string;
itemCode: string;
itemName: string;
unitPrice: number;
stockQty: number;
}
export default function CategoryMergeGrid() {
const [data] = useState<BGridDataItem<CategoryItem>[]>([
{ values: { mainCategory: 'Electronics', subCategory: 'Computer accessories', itemCode: 'IT-01', itemName: 'Wireless keyboard', unitPrice: 45000, stockQty: 120 } },
{ values: { mainCategory: 'Electronics', subCategory: 'Computer accessories', itemCode: 'IT-02', itemName: 'Gaming mouse', unitPrice: 38000, stockQty: 85 } },
{ values: { mainCategory: 'Electronics', subCategory: 'Monitors and displays', itemCode: 'IT-03', itemName: '27-inch 4K monitor', unitPrice: 420000, stockQty: 30 } },
{ values: { mainCategory: 'Electronics', subCategory: 'Monitors and displays', itemCode: 'IT-04', itemName: '32-inch curved monitor', unitPrice: 580000, stockQty: 15 } },
{ values: { mainCategory: 'Furniture and interiors', subCategory: 'Office furniture', itemCode: 'FN-01', itemName: '1400 mm sit-stand desk', unitPrice: 350000, stockQty: 25 } },
{ values: { mainCategory: 'Furniture and interiors', subCategory: 'Office furniture', itemCode: 'FN-02', itemName: 'Ergonomic mesh chair', unitPrice: 280000, stockQty: 40 } },
]);
const columns: BGridColumn<CategoryItem>[] = [
{ key: 'mainCategory', label: 'Category', width: 140, align: 'center' },
{ key: 'subCategory', label: 'Subcategory', width: 160, align: 'center' },
{ key: 'itemCode', label: 'Item code', width: 100, align: 'center' },
{ key: 'itemName', label: 'Item name', width: 200 },
{
key: 'unitPrice',
label: 'Unit price',
width: 120,
align: 'right',
itemRender: ({ values }) => `KRW ${values.unitPrice.toLocaleString('en-US')}`,
},
{
key: 'stockQty',
label: 'In stock',
width: 90,
align: 'right',
itemRender: ({ values }) => `${values.stockQty} units`,
},
];
return (
<div>
<BGrid<CategoryItem>
width={810}
height={320}
columns={columns}
data={data}
rowKey="itemCode"
frozenColumnIndex={2} // Freeze Category and Subcategory on the left
// Configure cell merging
cellMergeOptions={{
columnsMap: {
0: { mergeBy: 'mainCategory' }, // Merge Category cells with the same mainCategory value
1: { mergeBy: 'subCategory' }, // Merge Subcategory cells with the same subCategory value
},
}}
headerHeight={34}
itemHeight={30}
/>
</div>
);
}
3. cellMergeOptions specification
type CellMergeOptions = {
columnsMap: {
[columnIndex: number]: BGridCellMergeColumn;
};
};
interface BGridCellMergeColumn {
wordWrap?: boolean;
mergeBy: string | string[]; // Data key used to determine equality
}
columnIndex: The zero-based index of the column to merge.mergeBy: The data field whose values are compared between adjacent rows.
4. Practical tips and caveats
[!IMPORTANT] Sort the data before merging: Cells merge only when consecutive adjacent rows have the same value. If a row with
mainCategory: 'Furniture'appears between rows whosemainCategoryis'Electronics', the merge is interrupted. Sort the data by the merge keys before passing it to the grid so that each group remains contiguous.