Merged Cell Editing

Learn how editing works for merged cells in normal layouts and across frozen row and column boundaries, including atomic updates to every underlying row.

#cell-merge#editing#virtual-scroll#frozen-row#frozen-column#atomic-commit
Last reviewed: 2026-08-29
LIVE DEMOMerged Cell Editing interactive example
Loading the interactive demo…
GitHub
import { t } from './i18n';
import * as React from 'react';
import { BGrid, type BGridColumn, type BGridDataItem } from 'beautiful-grid';
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: t('서울상사', 'Seoul Sangsa'), customerGrade: 'VIP' },
  { customerCode: 'C002', customerName: t('한빛물산', 'Hanbit Mulsan'), customerGrade: t('우수', 'Excellent') },
  { customerCode: 'C003', customerName: 'Northwind', customerGrade: t('일반', 'General') },
  { customerCode: 'C004', customerName: 'AxisJ Studio', customerGrade: t('우수', 'Excellent') },
  { customerCode: 'C005', customerName: t('대한유통 장기 고객사명 샘플', 'Daehan Distribution Long-term Client Name Sample'), customerGrade: 'VIP' },
  { customerCode: 'C006', customerName: 'Blue Ocean Trading', customerGrade: t('일반', 'General') },
  { customerCode: 'C007', customerName: t('새봄물류', 'Saebom Logistics'), customerGrade: t('우수', 'Excellent') },
  { customerCode: 'C008', customerName: 'Global Partners Korea', customerGrade: 'VIP' },
];

const statuses: EditingOrder['status'][] = [t('접수', 'Receipt'), t('진행', 'In Progress'), t('완료', 'Completed')];
const notes = [
  t('오전 배송', 'Morning Delivery'),
  t('담당자 확인', 'Assignee Confirmation'),
  t('행 높이를 늘리지 않는 긴 메모 내용이 말줄임으로 표시됩니다.', 'Long memo content that does not increase row height is truncated.'),
];

const createMergedEditingOrders = (): BGridDataItem<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')}`,
          approved: rowIndex % 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<BGridColumn<EditingOrder>[]>(
    () => withEditingCellClasses<EditingOrder>([
      { key: 'orderCode', label: t('주문 코드', 'Order Code'), width: 145, editable: false },
      {
        key: 'customerName',
        label: t('병합 고객명 · 편집', 'Merged Customer Name · Edit'),
        width: 240,
        editable: true,
        editTrigger: 'click',
        editor: { type: 'text' },
        editorIcon: { render: <Pencil aria-hidden='true' size={14} strokeWidth={1.8} />, ariaLabel: t('병합 고객명 편집', 'Edit Merged Customer Name') },
      },
      { key: 'status', label: t('상태', 'Status'), width: 120, editable: false },
      { key: 'note', label: t('메모', 'Memo'), 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'>
            {t('24개 행을 3개씩 병합해 세로 스크롤과 일반 병합·고정 경계 병합 편집을 함께 확인합니다.', 'Merge 24 rows in groups of 3 to check both vertical scrolling and regular/frozen-boundary merged cell editing.')}
          </p>
          <div className='flex flex-wrap items-center gap-3' aria-label={t('병합 셀 틀고정 옵션', 'Merged Cell Freeze Option')}>
            <label className='inline-flex items-center gap-2 font-medium'>
              <span>{t('왼쪽 고정 컬럼 수', 'Number of frozen left columns')}</span>
              <Select<number>
                aria-label={t('고정할 컬럼 수', 'Number of Columns to Freeze')}
                aria-describedby='merged-frozen-column-help'
                style={{ width: 84 }}
                value={frozenColumnIndex}
                options={[0, 1, 2].map(count => ({ value: count, label: t(`${count}개`, `${count} items`) }))}
                onChange={setFrozenColumnIndex}
              />
            </label>
            <label className='inline-flex items-center gap-2 font-medium'>
              <span>{t('위쪽 고정 행 수', 'Number of frozen top rows')}</span>
              <Select<number>
                aria-label={t('고정할 행 수', 'Number of Rows to Freeze')}
                aria-describedby='merged-frozen-row-help'
                style={{ width: 84 }}
                value={frozenRowCount}
                options={[0, 1, 2, 3].map(count => ({ value: count, label: t(`${count}개`, `${count} items`) }))}
                onChange={setFrozenRowCount}
              />
            </label>
          </div>
        </div>
        <div className='mt-2 grid gap-1 text-xs text-slate-600'>
          <p id='merged-frozen-column-help' className='m-0'>
            <strong>{t('왼쪽 고정 컬럼 수', 'Number of frozen left columns')}</strong>{t('는 가로 스크롤 중에도 왼쪽에 남겨 둘 컬럼 수입니다. 1개는 ‘주문 코드’만, 2개는 ‘주문 코드’와 편집 대상인 ‘병합 고객명’을 고정합니다.', ' is the number of columns to keep on the left during horizontal scrolling. 1 freezes only "Order Code", and 2 freezes both "Order Code" and "Merged Customer Name" which is the target for editing.')}
          </p>
          <p id='merged-frozen-row-help' className='m-0'>
            <strong>{t('위쪽 고정 행 수', 'Number of frozen top rows')}</strong>{t('는 세로 스크롤 중에도 위쪽에 남겨 둘 실제 데이터 행 수입니다. 1~2개는 첫 3행 병합 셀을 고정·스크롤 영역으로 나누고, 3개는 첫 병합 그룹 전체를 고정합니다. 나뉜 어느 조각을 편집해도 같은 3행이 함께 변경됩니다.', ' is the number of actual data rows to keep on top during vertical scrolling. 1-2 splits the first 3-row merged cell into frozen/scroll areas, and 3 freezes the entire first merged group. Editing any split piece modifies the same 3 rows together.')}
          </p>
        </div>
        <p className='mb-0 mt-2 text-xs text-slate-600'>
          {hasFrozenBoundary ? (
            <>
              <strong>{t('고정 경계 병합', 'Frozen Boundary Merge')}</strong> · <code>frozenColumnIndex={frozenColumnIndex}</code>,{' '}
              <code>frozenRowCount={frozenRowCount}</code>. {t('병합 그룹이 행 고정 경계를 가로지르면 각 영역 안에서 다시 병합되며, 어느 조각을 편집해도 세 행이 함께 변경됩니다.', 'If a merged group crosses the row freeze boundary, it is merged again within each area, and editing any piece modifies all three rows together.')}
            </>
          ) : (
            <>
              <strong>{t('일반 병합', 'Regular Merge')}</strong> · {t('틀고정 없이 하나의 병합 셀을 직접 편집하며, 병합 그룹의 세 실제 행이 함께 변경됩니다.', 'Directly edit a single merged cell without frozen panes, and all three actual rows of the merged group are modified together.')}
            </>
          )}
        </p>
        <output aria-live='polite' className='mt-1 block text-xs text-blue-700'>
          {t('마지막 트랜잭션의 변경 행:', 'Modified rows in the last transaction:')} {changedRows.length ? changedRows.map(index => index + 1).join(', ') : t('없음', 'None')}
        </output>
      </div>
      <DataGridContainer ref={containerRef} style={{ height: 360 }} data-merge-layout={layoutMode}>
        <BGrid<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>
  );
}

Editing a merged cell applies the same change to every underlying row in the merge group, not just the anchor row visible on screen. Editor and icon callbacks do not need to calculate the merge range themselves. The live example uses 24 rows so you can verify that the range remains intact while scrolling vertically. It starts with ordinary merged editing and no frozen rows or columns.

Editing merged cells without frozen panes

The merged-cell edit transaction works the same when both frozenRowCount and frozenColumnIndex are 0. In the live example, set both frozen-pane controls to 0 for the Standard merge state. Editing a customer name then updates all 3 underlying rows with the same mergeBy value.

<BGrid<Order>
  editable
  cellMergeOptions={{
    columnsMap: {
      1: { mergeBy: 'customerGroup' },
    },
  }}
/>

In the live example, Frozen columns on the left controls how many columns remain visible while scrolling horizontally. Select 1 to freeze only the order code, or 2 to freeze both the order code and the editable merged customer-name column. Frozen rows at the top controls how many underlying data rows remain visible while scrolling vertically. Selecting 1 or 2 splits the first 3-row merged cell between the frozen and scrollable regions; selecting 3 freezes the entire first merge group. Editing any fragment updates all 3 underlying rows in that group. See Frozen Rows and Columns for the basic frozen-pane behavior.

Logical cells and DOM fragments

A frozen-row boundary or frozen-column layer can split one merged cell into multiple td elements. With frozenRowCount=1, for example, a 3-row merge group renders as a 1-row cell in the frozen region and a merged 2-row cell in the scrollable region. These fragments are separate rendering units but behave as one logical cell during interaction.

  • Clicking any fragment activates the same canonical anchor.
  • itemRender, editor, and editorIcon callbacks receive the canonical row’s index, item, values, and value.
  • The editor mounts only in the fragment the user interacted with.
  • After commit, every row in the merge group and every rendered fragment updates together.

Transaction scope

When an edit session starts, the Grid snapshots the full contiguous range of matching mergeBy values in the visible data. The range is not limited to the rows currently rendered by virtual scrolling.

onChangeValue: async ({ changes, rows, commit }) => {
  console.log(rows.map(row => row.sourceIndex));
  await commit(changes);
}

Only the merge rule of the originating column determines the row range. Adding another merged column to the change list does not expand the range transitively, and changing the merge key itself does not cause the range to be recalculated during the save. If any target row is invalid, the entire transaction is canceled.

Copying and pasting merged cells

A merged-cell selection spans several backing rows for layout purposes, but copy treats it as one logical cell. Copying only that cell writes the canonical anchor’s getClipboardText result once. If the selection also includes ordinary neighboring cells, continuation rows contain an empty TSV slot for the merged column so every other row and column stays aligned.

Paste is atomic at the merge-group level. Pasting one clipboard value runs parseClipboardText once and assigns that same result to every backing row. This prevents arrays or objects used as mergeBy values from splitting because each row received a different parsed instance. If several clipboard rows map to the same merged target, identical strings collapse to one value. Any conflict reports mergedCellConflict through onPasteError and leaves the entire logical cell unchanged.

For display-only merge configuration, see Cell Merging. For frozen layout settings, see Frozen Rows and Columns.

[!TIP] Frozen columns and merged cells on mobile: Increasing the number of frozen columns on mobile screens reduces the visible editable area. Set frozenColumnIndex to 1 or 0 based on screen width to ensure sufficient editing space on narrow devices.