Reordering
컬럼·행 순서 변경은 패키지 내장이 아니라 소비자가 배선하는 레시피입니다.
개요
@featuring-corp/data-grid는 reorder UI를 내장하지 않습니다. 컬럼 순서 변경과 행 순서 변경 모두 소비자가 직접 배선하는 레시피입니다.
패키지가 제공하는 통합 지점은 두 가지입니다.
state.columnOrder+onColumnOrderChange— TanStack Table 네이티브 seam입니다.string[]배열 하나를 소유하면 그리드가 헤더·바디 컬럼 배치를 자동 반영합니다.onCellPointerDownveto —<DataGrid.Root>의 prop입니다. 드래그 핸들 위에서 시작한 pointerdown이 그리드 기본 동작(셀 선택 시작)을 가로채지 않도록 끄는 seam입니다.
드래그 인터랙션 자체(@dnd-kit 센서·autoscroll·키보드 접근성)는 소비자가 직접 설치·배선합니다. @dnd-kit/*은 @featuring-corp/data-grid의 의존성이 아닙니다.
# 소비자 프로젝트에 직접 설치
pnpm add @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities드래그가 어려운 환경(키보드·스크린리더·터치)을 위해 위/아래 버튼 방식의 대안도 함께 제공하는 것을 권장합니다. 접근성 대안을 참고하세요.
컬럼 순서 변경
ColumnOrderState(string[])를 소유하고, useDataGrid의 state.columnOrder + onColumnOrderChange에 연결합니다. 드래그가 끝나면 arrayMove로 배열만 재배치하면 그리드가 헤더·바디 배치를 자동으로 반영합니다.
DataGrid.HeaderCell과 DataGrid.Cell은 모두 forwardRef + restProps spread라 useSortable의 setNodeRef·attributes·listeners를 그대로 호스팅할 수 있습니다.
공통 센서 설정
클릭(정렬 등)과 드래그를 구분하려면 거리 기반 활성화(8 px)를 권장합니다.
import { PointerSensor, KeyboardSensor, useSensor, useSensors } from '@dnd-kit/core';
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
function useReorderSensors() {
return useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
}드래그 가능한 헤더 셀
같은 컬럼 id로 useSortable을 연결합니다. 헤더 셀에만 listeners를 달고, 바디 셀은 transform만 공유해 컬럼 전체가 한 덩어리로 움직입니다.
import type { Header, CellContext, ColumnOrderState } from '@featuring-corp/data-grid';
import { DataGrid, useDataGrid, getCoreRowModel } from '@featuring-corp/data-grid';
import { DndContext, closestCenter, type DragEndEvent } from '@dnd-kit/core';
import { SortableContext, arrayMove, horizontalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
// 헤더 — 드래그 핸들. listeners를 여기에만 달면 클릭은 정상 동작하고 8px 이상 끌어야 드래그 활성.
function SortableHeaderCell({ header, children }: { header: Header<Row, unknown>; children: React.ReactNode }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: header.column.id,
});
return (
<DataGrid.HeaderCell
header={header}
ref={setNodeRef}
{...attributes}
{...listeners}
style={{
transform: CSS.Translate.toString(transform),
transition,
cursor: 'grab',
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 10 : undefined,
}}
>
{children}
</DataGrid.HeaderCell>
);
}
// 바디 셀 — 같은 컬럼 id의 transform만 공유. listeners는 없음(헤더에서만 드래그 시작).
function DragAlongCell({ cell, children }: { cell: CellContext<Row, unknown>['cell']; children: React.ReactNode }) {
const { setNodeRef, transform, transition, isDragging } = useSortable({ id: cell.column.id });
return (
<DataGrid.Cell
cell={cell}
ref={setNodeRef}
style={{
transform: CSS.Translate.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 1 : undefined,
}}
>
{children}
</DataGrid.Cell>
);
}그리드 조합
const COLUMN_IDS = ['name', 'platform', 'category'] as const;
function ColumnReorderGrid({ data }: { data: Row[] }) {
const [columnOrder, setColumnOrder] = useState<ColumnOrderState>([...COLUMN_IDS]);
const columns = useMemo<ColumnDef<Row>[]>(() => [
{
id: 'name',
accessorKey: 'name',
header: ({ header }) => <SortableHeaderCell header={header}>이름</SortableHeaderCell>,
cell: (info) => <DragAlongCell cell={info.cell}>{info.getValue<string>()}</DragAlongCell>,
},
// ... 나머지 컬럼도 동일 패턴
], []);
const grid = useDataGrid<Row>({
data,
columns,
state: { columnOrder },
onColumnOrderChange: setColumnOrder,
getCoreRowModel: getCoreRowModel(),
getRowId: (r) => r.id,
});
const sensors = useReorderSensors();
const onDragEnd = (e: DragEndEvent): void => {
const { active, over } = e;
if (over && active.id !== over.id) {
setColumnOrder((prev) =>
arrayMove(prev, prev.indexOf(active.id as string), prev.indexOf(over.id as string)),
);
}
};
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext items={columnOrder} strategy={horizontalListSortingStrategy}>
<DataGrid.Root table={grid} size="md">
<DataGrid.Header />
<DataGrid.Body />
</DataGrid.Root>
</SortableContext>
</DndContext>
);
}onDragEnd에서 arrayMove로 columnOrder 배열만 재배치하면 됩니다. 그리드는 이 상태를 읽어 헤더·바디 컬럼 배치를 자동 반영합니다.
행 순서 변경
행 reorder는 데이터 배열을 직접 재배치합니다. 그리드는 getRowId로 각 행을 식별하므로, 배열 순서가 바뀌면 곧바로 DOM에 반영됩니다.
DataGrid.Row도 forwardRef + restProps spread라 useSortable의 setNodeRef·attributes를 그대로 받습니다. 드래그 핸들(listeners)은 별도 컬럼의 <Box>에만 달아 클릭이 있는 다른 셀과 충돌하지 않게 합니다.
import type { TanstackRow } from '@featuring-corp/data-grid';
import { DataGrid, flexRender } from '@featuring-corp/data-grid';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
const DRAG_COL = '__drag';
function SortableRow({ row }: { row: TanstackRow<Row> }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: row.id });
return (
<DataGrid.Row
row={row}
ref={setNodeRef}
{...attributes}
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
position: isDragging ? 'relative' : undefined,
zIndex: isDragging ? 10 : undefined,
}}
>
{row.getVisibleCells().map((cell) =>
cell.column.id === DRAG_COL ? (
<DataGrid.Cell key={cell.id} cell={cell}>
{/* 핸들 — listeners는 이 요소에만. aria-label은 소비자 책임. */}
<Box
{...listeners}
style={{ display: 'flex', cursor: 'grab', userSelect: 'none', touchAction: 'none' }}
aria-label="Drag to reorder row"
>
<IconArrowsVerticalOutline size={16} />
</Box>
</DataGrid.Cell>
) : (
<Fragment key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</Fragment>
),
)}
</DataGrid.Row>
);
}onDragEnd에서 데이터 배열을 arrayMove로 재배치합니다.
const onDragEnd = (e: DragEndEvent): void => {
const { active, over } = e;
if (over && active.id !== over.id) {
setData((prev) =>
arrayMove(
prev,
prev.findIndex((d) => d.id === active.id),
prev.findIndex((d) => d.id === over.id),
),
);
}
};<DataGrid.Body>의 children render prop으로 각 행을 SortableRow로 대체합니다.
<DataGrid.Body>
{(row) => <SortableRow key={row.id} row={row as TanstackRow<Row>} />}
</DataGrid.Body>셀 드래그 가로채기 — onCellPointerDown veto
행 reorder와 셀 선택을 같은 그리드에서 함께 쓸 때 문제가 생깁니다. 핸들을 누른 pointerdown이 그리드 기본 동작(셀 선택 시작)도 함께 실행해 드래그가 깨집니다.
<DataGrid.Root>의 onCellPointerDown으로 특정 셀의 pointerdown을 가로채 그리드 기본 동작을 끌 수 있습니다. ctx.preventGridDefault()를 호출하면 그 pointerdown에 대한 셀 선택 시작·드래그·grid focus를 전부 취소합니다.
// DataGridRootProps (Root.tsx)
onCellPointerDown?: (
e: React.PointerEvent<HTMLDivElement>,
coord: DataGridCellCoord,
ctx: DataGridPointerDownContext,
) => void;
// DataGridPointerDownContext (Root.tsx)
interface DataGridPointerDownContext {
/** 호출하면 이 pointerdown에 대한 grid 내장 처리(셀 선택 시작·드래그·grid focus)를 전부 취소. */
preventGridDefault: () => void;
}드래그 핸들 컬럼(DRAG_COL)에서 시작한 pointerdown만 veto하고, 나머지 셀은 그리드 기본 동작(셀 선택)을 그대로 허용하는 예입니다.
const handleCellPointerDown = (
_e: React.PointerEvent<HTMLDivElement>,
coord: DataGridCellCoord,
ctx: DataGridPointerDownContext,
): void => {
if (coord.columnId === DRAG_COL) ctx.preventGridDefault();
};
<DataGrid.Root
table={grid}
size="md"
selection={selection}
onCellPointerDown={handleCellPointerDown}
>
<DataGrid.Header />
<DataGrid.Body>
{(row) => <SortableRow key={row.id} row={row as TanstackRow<Row>} />}
</DataGrid.Body>
</DataGrid.Root>preventGridDefault()를 부르지 않으면 그리드 기본 동작이 평소대로 이어집니다 — 핸들 컬럼만 가로채고 나머지 셀은 정상 선택됩니다.
onCellPointerDown은 onCellKeyDown + preventGridDefault와 대칭인 pointer 버전입니다. veto 패턴 전체 카탈로그는 Extending을 참고하세요.
Prop
Type
접근성 대안 — 버튼 reorder
드래그가 어려운 환경(키보드·스크린리더·터치)을 위한 명시적 위/아래 이동 버튼을 함께 제공하는 것을 권장합니다. onCellPointerDown veto가 필요 없고, 상태 모델(데이터 배열 arrayMove)이 드래그 레시피와 동일하므로 둘을 병행해도 됩니다.
{
id: '__move',
size: 96,
header: ({ header }) => <DataGrid.HeaderCell header={header} aria-label="Move column" />,
cell: (info) => {
const rows = info.table.getRowModel().rows;
const rowIndex = rows.findIndex((r) => r.id === info.row.id);
return (
<DataGrid.Cell cell={info.cell}>
<HStack>
<IconButton
size="sm"
variant="tertiary"
aria-label="Move row up"
disabled={rowIndex === 0}
onClick={() => moveRow(info.row.id, -1)}
>
<IconArrowUpOutline size={16} />
</IconButton>
<IconButton
size="sm"
variant="tertiary"
aria-label="Move row down"
disabled={rowIndex === rows.length - 1}
onClick={() => moveRow(info.row.id, 1)}
>
<IconArrowDownOutline size={16} />
</IconButton>
</HStack>
</DataGrid.Cell>
);
},
}경계 행(첫 행의 "위", 마지막 행의 "아래")은 disabled로 처리합니다.
다음 단계
- Columns — 컬럼 정의·
meta·고정(pinning)·표시/숨김. - Extending —
onCellKeyDown/onCellPointerDownveto 패턴 전체 카탈로그. - Selection model — anchor·focused·ranges 다중 사각형 모델·정책 seam.
- 실제 동작 예제: Storybook — DataGrid/Reorder