Featuring Design System
Selection & Editing

Selection model

anchor/focused/ranges 다중 사각형 모델 — 쿼리·뮤테이터·정책 seam·controlled 모드.

모델 — anchor · focused · ranges

선택 상태는 언제나 비연속 다중 사각형(multi-rectangle) 입니다. Excel·Google Sheets와 동일한 모델이라, 한 개의 셀도 1×1 사각형 하나로 표현됩니다. 특별한 "단일 셀" 모드는 없습니다.

interface DataGridSelectionState {
	anchor: DataGridCellCoord | null; // shift·드래그 확장의 기준점
	focused: DataGridCellCoord | null; // 키보드 포커스 셀(active) — range의 한쪽 끝
	ranges: DataGridCellRange[]; // 선택된 직사각형들(비연속 다중)
}

interface DataGridCellCoord {
	rowId: string;
	columnId: string;
}

interface DataGridCellRange {
	start: DataGridCellCoord;
	end: DataGridCellCoord;
}

세 필드의 역할이 다릅니다.

  • anchor — 확장의 고정점입니다. shift+클릭이나 드래그가 시작된 셀로, 확장 중에는 움직이지 않습니다.
  • focused — 지금 키보드 포커스가 있는 셀(흰 활성 셀)입니다. 보통 확장 중인 사각형의 반대쪽 끝입니다.
  • ranges — 선택된 직사각형들입니다. cmd/ctrl+클릭으로 사각형을 더 얹으면 배열이 길어집니다.

좌표는 인덱스가 아니라 rowId/columnId 입니다. 정렬·필터·페이지 전환으로 행 순서가 바뀌어도 같은 데이터를 가리키고, 보이지 않게 된 좌표는 자동으로 비워집니다(아래 enabled · state 정리 참고).

제스처별로 상태가 어떻게 바뀌는지 정리하면 다음과 같습니다.

제스처결과
클릭단일 1×1 사각형으로 교체. anchor=focused=클릭한 셀
shift+클릭 / 드래그active(마지막) 사각형을 확장. anchor 유지, focused만 이동
cmd/ctrl+클릭기존 사각형 유지 + 새 1×1 사각형 추가(비연속)
cmd/ctrl+클릭(이미 선택된 셀)그 셀을 선택에서 빼기(사각형 분할)

쿼리 API

셀 컴포넌트는 두 술어로 자기 상태를 읽습니다. 둘 다 DataGridCellCoord를 받아 boolean을 돌려줍니다.

selection.isCellActive(coord); // 이 셀이 focused(활성 흰 셀)인가
selection.isCellInRange(coord); // 이 셀이 어떤 ranges에 속하는가

isCellActivefocused 한 셀만 true입니다. isCellInRange는 모든 사각형을 검사하므로 비연속 선택 전체를 덮습니다. 보통 이 둘은 직접 호출하지 않고, <DataGrid.Cell>이 내부에서 호출해 [data-cell-active]·[data-cell-in-range] 속성으로 노출합니다. CSS는 그 속성을 셀렉터로 잡습니다. (data-* 상태 표면)

뮤테이터

선택을 프로그래밍으로 바꾸는 공개 API입니다. 모두 단일 commit 경로를 거치므로 정책 seam과 controlled onChange가 일관되게 적용됩니다.

selection.setActive(coord, { extend, additive }); // 핵심 뮤테이터
selection.selectAll(); // 모든 visible 셀
selection.selectColumn(colId, { extend, additive }); // 컬럼 전체
selection.selectRow(rowId, { extend, additive }); // 행 전체
selection.removeArea(start, end); // 직사각형 영역 빼기
selection.clear(); // 해제

setActive

선택의 핵심입니다. 옵션이 세 가지 동작을 가릅니다.

  • 옵션 없음 — 단일 1×1 사각형으로 교체합니다(일반 클릭).
  • extend: trueanchor를 유지하고 active(마지막) 사각형을 coord까지 확장합니다(shift·드래그). 나머지 사각형은 보존합니다.
  • additive: true — 기존 사각형을 모두 유지하고 새 1×1 사각형을 추가합니다(cmd/ctrl+클릭). anchor/focused는 새 셀로 옮깁니다.

같은 셀로 재진입하는 경우는 결과가 불변일 때만 dedup해 re-render를 절감합니다. 단, 멀티셀 선택 상태에서 focused 셀을 일반 클릭하면 1×1로 collapse해야 하므로 그 경우는 dedup하지 않습니다.

selectAll · selectColumn · selectRow — focused가 튀지 않는 이유

세 축 선택은 공통된 결정이 하나 있습니다. focused(활성 셀)를 사각형의 먼 모서리에 두지 않습니다.

  • selectAll — 기존 focused를 그대로 둡니다(없으면 좌상단). bottom-right로 옮기면 scroll-to-active가 맨 아래로 튑니다.
  • selectColumnfocused를 컬럼 최상단(near corner)에 둡니다. 뷰포트가 세로로 튀지 않습니다.
  • selectRowfocused를 행 최좌측(near corner)에 둡니다. 가로로 튀지 않습니다.

Excel/Sheets 표준과 같습니다. 컬럼 헤더를 클릭해도 활성 셀이 화면 밖으로 점프하지 않습니다. range(선택 영역)와 focused(활성 셀)를 분리해서 얻은 동작입니다.

selectColumn/selectRowextend/additive도 받습니다. shift+헤더는 anchor 컬럼부터 폭 전체를, ctrl+헤더는 기존 선택에 새 컬럼/행 사각형을 더합니다.

control 컬럼은 선택에 들어가지 않습니다. column.meta.columnRole === 'control'(행 번호·체크박스·확장 토글 등 chrome)은 어떤 사각형에도 포함되지 않습니다. 드래그·화살표는 데이터 컬럼 경계에서 clamp되고, 생성되는 사각형은 항상 데이터 컬럼만 덮습니다. 명시적 opt-out이라 시스템이 컬럼 성격을 추론하지 않습니다.

removeArea · clear

removeArea(start, end)는 직사각형 영역을 선택에서 빼고, 겹치는 모든 사각형을 분할합니다(컬럼/행 통째 해제에 사용). clear()는 전체를 비웁니다(Escape 키와 동일).

정책 seam — transformSelection · isCellSelectable

선택 가능 범위를 제약하는 두 옵션이 있습니다. 모든 선택 변경(클릭·드래그·키보드·공개 API·내부 fill/paste 글루)이 commit되기 직전 단일 지점에서 평가됩니다. 빼기 연산까지 같은 지점을 지나므로 전 경로에 일관 적용됩니다.

transformSelection — 만능 seam

제안된 상태를 받아 치환하거나 거부합니다. per-row lock, 같은-컬럼 제약, max-N 셀 등 어떤 정책이든 이 하나로 표현합니다.

transformSelection?: (
	proposed: DataGridSelectionState,
	prev: DataGridSelectionState,
	meta: DataGridSelectionTransformMeta,
) => DataGridSelectionState | null;
  • 반환값이 새 상태proposed를 그것으로 치환합니다.
  • 반환값이 **null**이면 제안을 거부합니다. 직전 유효 상태(prev)가 그대로 유지되어 anchor/focused가 움직이지 않습니다.
  • onChange 통지 에 적용되므로 controlled 모드와도 정합합니다.

meta.gesture로 어떤 제스처가 변경을 유발했는지 알 수 있습니다.

interface DataGridSelectionTransformMeta {
	gesture: 'pointer' | 'keyboard' | 'api';
}

같은-컬럼만 허용하는 예입니다. 제안된 사각형이 여러 컬럼에 걸치면 active 사각형을 첫 컬럼으로 깎습니다.

const selection = useDataGridSelection(table, {
	transformSelection: (proposed) => {
		const active = proposed.ranges[proposed.ranges.length - 1];
		if (!active) return proposed;
		// 시작 컬럼과 끝 컬럼이 다르면 → 시작 컬럼으로 폭을 1칸으로 깎는다.
		if (active.start.columnId !== active.end.columnId) {
			const clamped = {
				start: active.start,
				end: { rowId: active.end.rowId, columnId: active.start.columnId },
			};
			return {
				...proposed,
				ranges: [...proposed.ranges.slice(0, -1), clamped],
			};
		}
		return proposed;
	},
});

드래그 확장처럼 고빈도 경로에서도 셀 전환당 1회만 호출됩니다(현 setActive 비용과 동급). 콜백은 throw하면 안 됩니다 — 동기 throw 시 그 변경을 거부로 강등하고 dev에서 1회 경고합니다.

isCellSelectable — 거부형 sugar

단순히 "이 셀을 선택할 수 있는가"만 필요하면 transformSelection의 거부형 합성판인 isCellSelectable이 더 간단합니다.

isCellSelectable?: (coord: DataGridCellCoord) => boolean;

제안된 모든 사각형의 셀을 검사해, 하나라도 false면 제안 전체를 거부합니다(=prev 유지). 잠긴 행을 선택 불가로 만드는 예입니다.

const lockedRows = new Set(['row-3', 'row-7']);

const selection = useDataGridSelection(table, {
	isCellSelectable: (coord) => !lockedRows.has(coord.rowId),
});

둘 다 주면 합성됩니다. sugar가 먼저 검사하고(위반 시 거부), 통과한 제안만 transformSelection으로 넘어갑니다.

controlled 모드

state + onChange로 선택 상태를 외부에서 소유할 수 있습니다.

const [sel, setSel] = useState<DataGridSelectionState>({
	anchor: null,
	focused: null,
	ranges: [],
});

const selection = useDataGridSelection(table, {
	state: sel,
	onChange: setSel,
});
  • state를 지정하면 그 값이 source of truth가 됩니다. 내부 useState를 쓰지 않고 변경은 onChange로만 통지하므로, 소비자가 받은 상태를 자기 store에 반영해야 합니다.
  • onChange변경이 있을 때만 호출됩니다. 결과가 직전과 동일한 no-op이면 fire하지 않습니다(equality bailout). 무한 루프를 막습니다.
  • state를 생략하면 uncontrolled입니다. 훅이 내부에서 상태를 관리하고, onChange는 알림 용도로만 호출됩니다.

정책 seam은 controlled에서도 동일하게 동작합니다. transformSelection/isCellSelectableonChange 통지 전에 적용되므로, 거부된 제안은 onChange 자체가 호출되지 않습니다.

enabled · state 정리

enabled: false(기본 true)면 모든 인터랙션이 no-op이 되고 기존 상태도 비워집니다. 읽기 전용 그리드에서 선택을 끌 때 씁니다.

행·컬럼이 바뀌어(페이지 전환·정렬·필터) focused/anchor가 더 이상 visible이 아니면 상태를 자동으로 비웁니다. stale 좌표가 엉뚱한 셀을 활성으로 매치하지 않게 하기 위함입니다.

Root에 주입

훅은 인스턴스를 반환만 하고 table에 부착하지 않습니다. 반환값을 <DataGrid.Root selection={...}>에 넘겨야 Root·Body·Cell이 context로 읽습니다(필수 경로). 주입하지 않으면 조용히 비활성되고 dev에서 1회 경고합니다.

const data = [
{ id: '1', name: '김민준', category: '뷰티', followers: 128000 },
{ id: '2', name: '이서연', category: '패션', followers: 84500 },
{ id: '3', name: '박도윤', category: '게임', followers: 233000 },
{ id: '4', name: '최하은', category: '푸드', followers: 45200 },
{ id: '5', name: '정시우', category: '여행', followers: 67800 },
];

const columns = [
{
  accessorKey: 'name',
  header: ({ header }) => <DataGrid.HeaderCell header={header}>채널</DataGrid.HeaderCell>,
  cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue()}</DataGrid.Cell>,
},
{
  accessorKey: 'category',
  header: ({ header }) => <DataGrid.HeaderCell header={header}>카테고리</DataGrid.HeaderCell>,
  cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue()}</DataGrid.Cell>,
},
{
  accessorKey: 'followers',
  header: ({ header }) => <DataGrid.HeaderCell header={header}>팔로워</DataGrid.HeaderCell>,
  cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue().toLocaleString()}</DataGrid.Cell>,
},
];

function Grid() {
const table = useDataGrid({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
  getRowId: (row) => row.id,
});
const selection = useDataGridSelection(table);
return (
  <DataGrid.Root table={table} selection={selection} size="md">
    <DataGrid.Header />
    <DataGrid.Body />
  </DataGrid.Root>
);
}

render(<Grid />);

API

Prop

Type

다음 단계

  • Clipboard & Fill — 선택 range를 TSV로 복사·붙여넣기 타일링·자동 채우기.
  • Cell merging — colSpan/rowSpan에서 선택·키보드 내비가 병합을 한 셀로 다루는 방식.
  • Editing model — 활성 셀에서 편집 진입·commit·undo/redo.
  • Accessibilityaria-selected·절대 좌표·로케일 중립 정책.
  • Extending — 모든 override seam과 veto 패턴 한눈에.
  • 라이브 스토리 — 셀 선택·키보드 내비·Excel식 다중 range를 Storybook에서 직접 실행.