병합 셀 편집 (Merged Cell Editing)
틀고정 없는 병합 셀과 frozen row·column 경계의 병합 셀을 편집하고 모든 실제 행을 함께 변경하는 규칙을 설명합니다.
import * as React from 'react';
import { AXDataGrid, type AXDGColumn, type AXDGDataItem } from '@axboot/datagrid';
import { Select } from 'antd';
import { Pencil } from 'lucide-react';
import DataGridContainer from '../components/DataGridContainer';
import { useContainerSize } from '../hooks/useContainerSize';
import {
applyEditingDataChange,
type EditingOrder,
withEditingCellClasses,
} from './editing/shared';
const customerGroups: Array<
Pick<EditingOrder, 'customerCode' | 'customerName' | 'customerGrade'>
> = [
{ customerCode: 'C001', customerName: '서울상사', customerGrade: 'VIP' },
{ customerCode: 'C002', customerName: '한빛물산', customerGrade: '우수' },
{ customerCode: 'C003', customerName: 'Northwind', customerGrade: '일반' },
{ customerCode: 'C004', customerName: 'AxisJ Studio', customerGrade: '우수' },
{ customerCode: 'C005', customerName: '대한유통 장기 고객사명 샘플', customerGrade: 'VIP' },
{ customerCode: 'C006', customerName: 'Blue Ocean Trading', customerGrade: '일반' },
{ customerCode: 'C007', customerName: '새봄물류', customerGrade: '우수' },
{ customerCode: 'C008', customerName: 'Global Partners Korea', customerGrade: 'VIP' },
];
const statuses: EditingOrder['status'][] = ['접수', '진행', '완료'];
const notes = [
'오전 배송',
'담당자 확인',
'행 높이를 늘리지 않는 긴 메모 내용이 말줄임으로 표시됩니다.',
];
const createMergedEditingOrders = (): AXDGDataItem<EditingOrder>[] =>
customerGroups.flatMap((customer, groupIndex) =>
Array.from({ length: 3 }, (_, groupRowIndex) => {
const rowIndex = groupIndex * 3 + groupRowIndex;
const quantity = (rowIndex % 5) + 1;
const unitPrice = 9000 + (rowIndex % 4) * 3000;
return {
values: {
id: `MERGED-ORDER-${String(rowIndex + 1).padStart(3, '0')}`,
orderCode: `ORD-${2601 + rowIndex}`,
...customer,
status: statuses[rowIndex % statuses.length],
deliveryDate: `2026-09-${String((rowIndex % 28) + 1).padStart(2, '0')}`,
quantity,
unitPrice,
amount: quantity * unitPrice,
note: notes[rowIndex % notes.length],
mergeGroup: `CUSTOMER-${String(groupIndex + 1).padStart(2, '0')}`,
},
};
}),
);
export default function MergedCellEditingExample() {
const [data, setData] = React.useState(createMergedEditingOrders);
const [frozenColumnIndex, setFrozenColumnIndex] = React.useState(0);
const [frozenRowCount, setFrozenRowCount] = React.useState(0);
const [changedRows, setChangedRows] = React.useState<number[]>([]);
const containerRef = React.useRef<HTMLDivElement>(null);
const { width, height } = useContainerSize(containerRef);
const hasFrozenBoundary = frozenColumnIndex > 0 || frozenRowCount > 0;
const layoutMode = hasFrozenBoundary ? 'frozen-boundary' : 'standard';
const columns = React.useMemo<AXDGColumn<EditingOrder>[]>(
() => withEditingCellClasses<EditingOrder>([
{ key: 'orderCode', label: '주문 코드', width: 145, editable: false },
{
key: 'customerName',
label: '병합 고객명 · 편집',
width: 240,
editable: true,
editTrigger: 'click',
editor: { type: 'text' },
editorIcon: { render: <Pencil aria-hidden='true' size={14} strokeWidth={1.8} />, ariaLabel: '병합 고객명 편집' },
},
{ key: 'status', label: '상태', width: 120, editable: false },
{ key: 'note', label: '메모', width: 300, editable: false },
]),
[],
);
return (
<div className='flex min-h-0 flex-col gap-3'>
<div className='rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm leading-6 text-slate-700'>
<div className='flex flex-wrap items-center justify-between gap-3'>
<p className='m-0'>
24개 행을 3개씩 병합해 세로 스크롤과 일반 병합·고정 경계 병합 편집을 함께 확인합니다.
</p>
<div className='flex flex-wrap items-center gap-3' aria-label='병합 셀 틀고정 옵션'>
<label className='inline-flex items-center gap-2 font-medium'>
<span>고정할 선행 컬럼 수</span>
<Select<number>
aria-label='고정할 컬럼 수'
style={{ width: 84 }}
value={frozenColumnIndex}
options={[0, 1, 2].map(count => ({ value: count, label: `${count}개` }))}
onChange={setFrozenColumnIndex}
/>
</label>
<label className='inline-flex items-center gap-2 font-medium'>
<span>고정할 선행 행 수</span>
<Select<number>
aria-label='고정할 행 수'
style={{ width: 84 }}
value={frozenRowCount}
options={[0, 1, 2, 3].map(count => ({ value: count, label: `${count}개` }))}
onChange={setFrozenRowCount}
/>
</label>
</div>
</div>
<p className='mb-0 mt-2 text-xs text-slate-600'>
{hasFrozenBoundary ? (
<>
<strong>고정 경계 병합</strong> · <code>frozenColumnIndex={frozenColumnIndex}</code>,{' '}
<code>frozenRowCount={frozenRowCount}</code>. 병합 그룹이 행 고정 경계를 가로지르면 각 영역 안에서 다시
병합되며, 어느 조각을 편집해도 세 행이 함께 변경됩니다.
</>
) : (
<>
<strong>일반 병합</strong> · 틀고정 없이 하나의 병합 셀을 직접 편집하며, 병합 그룹의 세 실제 행이 함께
변경됩니다.
</>
)}
</p>
<output aria-live='polite' className='mt-1 block text-xs text-blue-700'>
마지막 트랜잭션의 변경 행: {changedRows.length ? changedRows.map(index => index + 1).join(', ') : '없음'}
</output>
</div>
<DataGridContainer ref={containerRef} style={{ height: 360 }} data-merge-layout={layoutMode}>
<AXDataGrid<EditingOrder>
key={`${frozenColumnIndex}:${frozenRowCount}`}
width={width}
height={height}
data={data}
columns={columns}
rowKey='id'
editable
variant='vertical-bordered'
itemHeight={22}
itemPadding={4}
frozenColumnIndex={frozenColumnIndex}
frozenRowCount={frozenRowCount}
cellMergeOptions={{ columnsMap: { 1: { mergeBy: 'mergeGroup' } } }}
onChangeData={(sourceIndex, _columnIndex, values, _column, meta) => {
setData(current => applyEditingDataChange(current, sourceIndex, values, meta));
if (meta) setChangedRows([...meta.transaction.sourceIndexes]);
}}
/>
</DataGridContainer>
</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.
*
* AXDataGrid'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 > .axdg-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,
};
}import type { AXDGChangeDataMeta, AXDGColumn, AXDGDataItem } from '@axboot/datagrid';
import './editingExamples.css';
export interface EditingOrder {
id: string;
orderCode: string;
customerCode: string;
customerName: string;
customerGrade: '일반' | '우수' | 'VIP';
status: '접수' | '진행' | '완료';
deliveryDate: string;
quantity: number;
unitPrice: number;
amount: number;
note: string;
mergeGroup: string;
}
export const editingOrders: AXDGDataItem<EditingOrder>[] = [
{
values: {
id: 'ORDER-001',
orderCode: 'ORD-2601',
customerCode: 'C001',
customerName: '서울상사',
customerGrade: 'VIP',
status: '접수',
deliveryDate: '2026-08-25',
quantity: 2,
unitPrice: 12000,
amount: 24000,
note: '오전 배송',
mergeGroup: 'A',
},
},
{
values: {
id: 'ORDER-002',
orderCode: 'ORD-2602',
customerCode: 'C001',
customerName: '서울상사',
customerGrade: 'VIP',
status: '진행',
deliveryDate: '2026-08-26',
quantity: 3,
unitPrice: 18000,
amount: 54000,
note: '담당자 확인',
mergeGroup: 'A',
},
},
{
values: {
id: 'ORDER-003',
orderCode: 'ORD-2603',
customerCode: 'C002',
customerName: '한빛물산',
customerGrade: '우수',
status: '완료',
deliveryDate: '2026-08-28',
quantity: 1,
unitPrice: 32000,
amount: 32000,
note: '',
mergeGroup: 'B',
},
},
{
values: {
id: 'ORDER-004',
orderCode: 'ORD-2604',
customerCode: 'C003',
customerName: 'Northwind',
customerGrade: '일반',
status: '접수',
deliveryDate: '2026-09-01',
quantity: 5,
unitPrice: 9000,
amount: 45000,
note: '영문 송장',
mergeGroup: 'C',
},
},
];
export const cloneEditingOrders = () =>
editingOrders.map(item => ({
...item,
values: { ...item.values },
editedColumnIds: item.editedColumnIds ? [...item.editedColumnIds] : undefined,
changedKeys: item.changedKeys ? [...item.changedKeys] : undefined,
}));
export const applyEditingDataChange = <T,>(
current: AXDGDataItem<T>[],
sourceIndex: number,
values: T,
meta?: AXDGChangeDataMeta<T>,
): AXDGDataItem<T>[] =>
current.map((item, index) =>
index === sourceIndex ? meta?.dataItem ?? { ...item, values } : item,
);
export const withEditingCellClasses = <T,>(columns: AXDGColumn<T>[]): AXDGColumn<T>[] =>
columns.map(column => ({
...column,
className: [
column.className,
column.editable === false ? 'editing-example-cell-readonly' : 'editing-example-cell-editable',
]
.filter(Boolean)
.join(' '),
}));.editing-example-cell-editable {
--editing-example-bg: #ffffff;
--editing-example-hover-bg: #dbeafe;
}
.editing-example-cell-readonly {
--editing-example-color: #525252;
--editing-example-bg: #f5f5f5;
--editing-example-hover-bg: #e5e5e5;
}
.axdg-body-table
td:is(.editing-example-cell-editable, .editing-example-cell-readonly):not(:is(.axdg-cell-selected, .axdg-cell-edited, .axdg-cell-value-changed, .axdg-cell-editing)) {
color: var(--editing-example-color, inherit);
background-color: var(--editing-example-bg);
}
.axdg-body-table tr.axdg-row-hover
> td:is(.editing-example-cell-editable, .editing-example-cell-readonly):not(:is(.axdg-cell-selected, .axdg-cell-edited, .axdg-cell-value-changed, .axdg-cell-editing)) {
background-color: var(--editing-example-hover-bg);
}병합 셀을 편집하면 화면에 보이는 anchor 행만 바꾸지 않고 병합 그룹에 속한 모든 실제 행에 같은 변경을 적용합니다. editor와 icon callback은 병합 여부를 직접 계산할 필요가 없습니다. 라이브 예제는 24개 행을 사용하므로 세로 스크롤 중에도 병합 범위가 유지되는지 확인할 수 있으며, 기본 상태는 틀고정 없는 일반 병합 편집입니다.
틀고정 없는 병합 편집
frozenRowCount와 frozenColumnIndex가 모두 0이어도 병합 셀의 편집 트랜잭션은 동일하게 동작합니다. 라이브 예제에서 두 틀고정 값을 0개로 선택한 일반 병합 상태에서 하나의 고객명을 수정하면 같은 mergeBy 값을 가진 세 실제 행이 함께 변경됩니다.
<AXDataGrid<Order>
editable
cellMergeOptions={{
columnsMap: {
1: { mergeBy: 'customerGroup' },
},
}}
/>
라이브 예제의 고정할 선행 컬럼 수와 고정할 선행 행 수는 행·컬럼 틀고정 예제와 같은 방식으로 직접 선택할 수 있습니다. 예를 들어 frozenRowCount=1, frozenColumnIndex=2를 선택하면 첫 병합 그룹이 고정 경계를 가로지릅니다.
논리 셀과 DOM 조각
frozen row 경계나 frozen column 레이어 때문에 하나의 병합 셀이 여러 td로 나뉠 수 있습니다. 3행 병합 그룹을 frozenRowCount=1로 나누면 고정 영역에는 1행 셀, 스크롤 영역에는 다시 병합된 2행 셀이 렌더링됩니다. 이 조각들은 렌더링 단위일 뿐 다음 상호작용에서는 같은 논리 셀입니다.
- 어느 조각을 클릭해도 같은 canonical anchor가 활성화됩니다.
itemRender, editor,editorIconcallback은 canonical 행의index,item,values,value를 받습니다.- editor는 사용자가 상호작용한 조각 한 곳에만 마운트됩니다.
- commit이 끝나면 모든 병합 행과 화면 조각이 함께 갱신됩니다.
트랜잭션 범위
병합 범위는 편집 세션을 시작할 때 전체 visible data의 연속된 mergeBy 값으로 snapshot됩니다. 가상 스크롤의 현재 렌더 범위로 제한하지 않습니다.
onChangeValue: async ({ changes, rows, commit }) => {
console.log(rows.map(row => row.sourceIndex));
await commit(changes);
}
origin 컬럼의 병합 규칙만 행 범위를 결정합니다. 변경 목록에 다른 병합 컬럼이 포함되어도 범위를 연쇄 확장하지 않으며, 병합 key 자체를 바꾸더라도 저장 도중 범위를 다시 계산하지 않습니다. 대상 행 중 하나라도 유효하지 않으면 전체를 취소합니다.
표시 목적의 일반 셀 병합 구성은 셀 병합 가이드, frozen 레이아웃 설정은 고정 컬럼과 행에서 확인하세요.