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에 속하는가isCellActive는 focused 한 셀만 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: true—anchor를 유지하고 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가 맨 아래로 튑니다.selectColumn—focused를 컬럼 최상단(near corner)에 둡니다. 뷰포트가 세로로 튀지 않습니다.selectRow—focused를 행 최좌측(near corner)에 둡니다. 가로로 튀지 않습니다.
Excel/Sheets 표준과 같습니다. 컬럼 헤더를 클릭해도 활성 셀이 화면 밖으로 점프하지 않습니다. range(선택 영역)와 focused(활성 셀)를 분리해서 얻은 동작입니다.
selectColumn/selectRow는 extend/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/isCellSelectable이 onChange 통지 전에 적용되므로,
거부된 제안은 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.
- Accessibility —
aria-selected·절대 좌표·로케일 중립 정책. - Extending — 모든 override seam과 veto 패턴 한눈에.
- 라이브 스토리 — 셀 선택·키보드 내비·Excel식 다중 range를 Storybook에서 직접 실행.