Cell merging
colSpan / rowSpan — anchor가 N칸을 점유하되 좌표 공간은 dense하게 유지하는 Excel 시맨틱.
개요
셀 병합은 column.meta의 두 콜백으로 표현합니다. 둘 다 anchor 셀이 차지할 칸 수를 반환합니다(1 = 병합 없음, 기본).
declare module '@tanstack/react-table' {
interface ColumnMeta<TData, TValue> {
/** 가로 병합 — 이 body 셀이 차지할 컬럼 수. */
colSpan?: (info: CellContext<TData, TValue>) => number;
/** 세로 병합 — 이 body 셀이 차지할 행 수. */
rowSpan?: (info: CellContext<TData, TValue>) => number;
}
}colSpan— anchor 셀이 N개 컬럼 너비를 합쳐 차지하고, 가려지는 N−1개 셀은 렌더되지 않습니다.rowSpan— anchor 셀이 N개 행 높이를 absolute 오버레이로 차지하고, 아래 N−1개 셀 자리에는 정렬 유지용 빈 placeholder만 남습니다.
핵심 원칙 — 좌표 공간은 dense
병합의 가장 중요한 결정은 논리 좌표 공간을 dense하게 유지한다는 것입니다. 화면에서 두 셀이 하나로 합쳐 보여도, 선택·복사·키보드 내비게이션의 좌표는 합쳐지지 않습니다. 가려진 좌표는 anchor가 흡수합니다(Excel 시맨틱).
시각적 표현 논리 좌표(dense)
┌───────────┬─────┐ (r0,c0)=anchor (r0,c1)=anchor가 흡수 (r0,c2)
│ 병합 셀 │ A │ (r1,c0) (r1,c1) (r1,c2)
├─────┬─────┼─────┤
│ B │ C │ D │ 클릭/내비/복사는 모두 anchor로 resolve —
└─────┴─────┴─────┘ 선택 모델은 병합을 "모르고" 정상 동작이 설계 덕분에 병합과 셀 선택·클립보드·자동 채우기가 서로를 몰라도 충돌 없이 함께 동작합니다. 병합은 순수하게 렌더 레이어의 관심사이고, 상호작용은 dense 좌표 위에서 돌아갑니다.
colSpan — 가로 병합
아래 그리드에서 📌 헤드라인 행의 첫 셀이 meta.colSpan으로 전체 너비를 차지합니다. 가려지는 칸은 렌더되지 않습니다.
const FEED_COLS = 3; // name, platform, cost const data = [ { id: 'h1', kind: 'headline', name: '📌 6월 신규 섭외 — 협상 진행 중', platform: '', cost: '' }, { id: 'c1', kind: 'creator', name: '뷰티by지은', platform: 'Instagram', cost: '₩3,200,000' }, { id: 'c2', kind: 'creator', name: '데일리룩 수아', platform: 'Instagram', cost: '₩1,400,000' }, { id: 'h2', kind: 'headline', name: '📌 재계약 검토 — 단가 인상 요청', platform: '', cost: '' }, { id: 'c3', kind: 'creator', name: '준호의 먹방로그', platform: 'YouTube', cost: '₩5,800,000' }, { id: 'c4', kind: 'creator', name: '여행하는 서연', platform: 'YouTube', cost: '₩720,000' }, ]; const columns = [ { accessorKey: 'name', size: 280, // headline 행이면 첫 셀이 전체(3칸)를 가로 병합. meta: { colSpan: (info) => (info.row.original.kind === 'headline' ? FEED_COLS : 1) }, header: ({ header }) => <DataGrid.HeaderCell header={header}>크리에이터</DataGrid.HeaderCell>, cell: (info) => { const isHeadline = info.row.original.kind === 'headline'; return ( <DataGrid.Cell cell={info.cell} $css={isHeadline ? { fontWeight: '$fontWeight-bold', backgroundColor: '$background-3' } : undefined} > {info.getValue()} </DataGrid.Cell> ); }, }, { accessorKey: 'platform', size: 160, header: ({ header }) => <DataGrid.HeaderCell header={header}>플랫폼</DataGrid.HeaderCell>, cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue()}</DataGrid.Cell>, }, { accessorKey: 'cost', size: 160, header: ({ header }) => <DataGrid.HeaderCell header={header}>단가</DataGrid.HeaderCell>, cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue()}</DataGrid.Cell>, }, ]; function HeadlineGrid() { const table = useDataGrid({ data, columns, getCoreRowModel: getCoreRowModel(), getRowId: (row) => row.id, }); return ( <DataGrid.Root table={table} size="md"> <DataGrid.Header /> <DataGrid.Body /> </DataGrid.Root> ); } render(<HeadlineGrid />);
anchor가 차지할 너비는 인접 컬럼들의 getSize() 합으로 계산되며, 고정(pinned)·control 경계에서 clamp됩니다
(clampColSpan이 단일 진실). 경계를 넘는 span은 잘려서 경계까지만 차지하고, 개발 모드에서 경고합니다.
rowSpan — 세로 병합
정렬된 데이터에서 같은 카테고리의 첫 행만 anchor로 잡고, runLength로 그룹 크기만큼 세로 병합합니다. 나머지 행은 1을 반환해 가려집니다.
// 같은 그룹의 첫 행이면 그룹 길이(run-length)를 반환, 아니면 1(가려짐). function runLength(rows, index, key) { if (index > 0 && key(rows[index - 1]) === key(rows[index])) return 1; let n = 1; while (index + n < rows.length && key(rows[index + n]) === key(rows[index])) n++; return n; } const data = [ { id: '1', category: '뷰티', name: '김민준', platform: 'Instagram' }, { id: '2', category: '뷰티', name: '이서연', platform: 'Instagram' }, { id: '3', category: '뷰티', name: '박도윤', platform: 'YouTube' }, { id: '4', category: '테크', name: '최하은', platform: 'YouTube' }, { id: '5', category: '테크', name: '정시우', platform: 'YouTube' }, { id: '6', category: '푸드', name: '강지우', platform: 'Instagram' }, { id: '7', category: '푸드', name: '윤서준', platform: 'TikTok' }, { id: '8', category: '푸드', name: '임하준', platform: 'YouTube' }, ]; const columns = [ { accessorKey: 'category', size: 120, // 카테고리 컬럼 — 연속 같은 카테고리를 run-length로 세로 병합. 그룹 첫 행만 anchor. meta: { rowSpan: (info) => runLength(data, info.row.index, (r) => r.category) }, header: ({ header }) => <DataGrid.HeaderCell header={header}>카테고리</DataGrid.HeaderCell>, cell: (info) => ( <DataGrid.Cell cell={info.cell} $css={{ fontWeight: '$fontWeight-semiBold', backgroundColor: '$background-2', display: 'flex', alignItems: 'center' }} > {info.getValue()} </DataGrid.Cell> ), }, { accessorKey: 'name', size: 200, header: ({ header }) => <DataGrid.HeaderCell header={header}>채널</DataGrid.HeaderCell>, cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue()}</DataGrid.Cell>, }, { accessorKey: 'platform', size: 140, header: ({ header }) => <DataGrid.HeaderCell header={header}>플랫폼</DataGrid.HeaderCell>, cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue()}</DataGrid.Cell>, }, ]; function GroupByGrid() { const table = useDataGrid({ data, columns, getCoreRowModel: getCoreRowModel(), getRowId: (row) => row.id, }); return ( <DataGrid.Root table={table} size="md"> <DataGrid.Header /> <DataGrid.Body /> </DataGrid.Root> ); } render(<GroupByGrid />);
anchor는 N개 행 높이를 합친 absolute 오버레이로 렌더되고, 가려지는 아래 셀 자리에는 가로 정렬 유지를 위한 빈 placeholder가 남습니다.
v1 제약 (의도된 PoC 경계)
병합은 의도적으로 좁은 보장 범위로 시작합니다. 경계를 벗어나면 clamp(또는 가상화 자동 비활성) + 개발 경고로 "보이고 무해"하게 강등합니다.
| colSpan | rowSpan | |
|---|---|---|
| 적용 대상 | body 셀(헤더 미적용) | body 셀 |
| 고정(pinned) 컬럼 | 경계에서 clamp + dev 경고 | 미지원 → clamp to 1 + dev 경고 |
| 행 높이 | — | 고정 행 높이 가정 |
| 가상화 | 열 가상화 자동 비활성(전체 렌더) + dev 경고 | 행·열 가상화 자동 비활성(전체 렌더) + dev 경고 |
| 자동 채우기 | fill × merge는 의도적으로 미변경(현행 동작 유지) | 동일 |
병합과 고정/가상화는 모두 "어느 셀을 렌더하느냐"를 다르게 계산하므로 v1에서 함께 보장하지 않습니다. 셀 병합이 정의되면 열 가상화는 자동 비활성되고,
rowSpan이 있으면 행 가상화도 자동 비활성되어 전체 렌더로 fallback합니다(span은 그대로 유지). 경계를 넘는 span은 silent failure 대신 clamp 또는 자동 비활성 + 경고로 드러납니다.
TypeTable
Prop
Type
라이브 예제는 Storybook DataGrid/Cell Merging 스토리에서 확인하세요.