Featuring Design System
Layout & Scale

Reordering

컬럼·행 순서 변경은 패키지 내장이 아니라 소비자가 배선하는 레시피입니다.

개요

@featuring-corp/data-gridreorder UI를 내장하지 않습니다. 컬럼 순서 변경과 행 순서 변경 모두 소비자가 직접 배선하는 레시피입니다.

패키지가 제공하는 통합 지점은 두 가지입니다.

  • state.columnOrder + onColumnOrderChange — TanStack Table 네이티브 seam입니다. string[] 배열 하나를 소유하면 그리드가 헤더·바디 컬럼 배치를 자동 반영합니다.
  • onCellPointerDown veto<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[])를 소유하고, useDataGridstate.columnOrder + onColumnOrderChange에 연결합니다. 드래그가 끝나면 arrayMove로 배열만 재배치하면 그리드가 헤더·바디 배치를 자동으로 반영합니다.

DataGrid.HeaderCellDataGrid.Cell은 모두 forwardRef + restProps spread라 useSortablesetNodeRef·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 }),
	);
}

드래그 가능한 헤더 셀

같은 컬럼 iduseSortable을 연결합니다. 헤더 셀에만 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에서 arrayMovecolumnOrder 배열만 재배치하면 됩니다. 그리드는 이 상태를 읽어 헤더·바디 컬럼 배치를 자동 반영합니다.


행 순서 변경

행 reorder는 데이터 배열을 직접 재배치합니다. 그리드는 getRowId로 각 행을 식별하므로, 배열 순서가 바뀌면 곧바로 DOM에 반영됩니다.

DataGrid.RowforwardRef + restProps spread라 useSortablesetNodeRef·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()를 부르지 않으면 그리드 기본 동작이 평소대로 이어집니다 — 핸들 컬럼만 가로채고 나머지 셀은 정상 선택됩니다.

onCellPointerDownonCellKeyDown + 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로 처리합니다.


다음 단계