Architecture
DataGridInstance, runtime registry, context 전파, compound 트리 — 그리드가 내부적으로 어떻게 짜였는가.
DataGridInstance — TanStack Table + 얇은 확장
useDataGrid(options)는 TanStack의 Table<TData>를 만든 뒤 그 위에 그리드가 필요로 하는 필드 몇 개만 얹어
DataGridInstance<TData, TError>를 돌려줍니다.
interface DataGridInstance<TData, TError = unknown> extends Table<TData> {
loading: boolean; // 외부 fetch 결과 (undefined → false)
error: TError | null; // (undefined → null)
getSkeletonRowCount: () => number; // skeletonRows ?? pagination.pageSize ?? 0
debug?: DataGridDebug; // 영역별 콘솔 로그
virtualization?: DataGridVirtualization; // 행/열 윈도잉
runtime: DataGridRuntime; // 내부 registry (아래)
}핵심은 TanStack 인스턴스를 대체하지 않고 확장한다는 점입니다. 정렬·필터·페이지네이션·visibility·pinning·grouping·expanding은 모두 TanStack의 표준 API와 상태로 동작합니다. 그리드가 더하는 것은 뷰 상태(loading/error)와 상호작용 인프라(selection/editing/fill)뿐입니다.
loading/error는Root의 prop이 아니라useDataGrid의 옵션입니다. 뷰 상태는 데이터 인스턴스에 속한다는 결정 — fetch 결과를 그대로 흘려 넣습니다. (View state 참고)
runtime registry — 렌더 중 mutation을 제거한 안정 객체
가상화에서 스크롤-투-액티브는 "지금 화면 밖 행으로 스크롤"하기 위해 virtualizer 인스턴스를 알아야 합니다. virtualizer는 Body가
렌더 중 생성하는데, 이를 TanStack 인스턴스 필드에 직접 박으면(table.x = …) 렌더 순서 해저드가 생깁니다.
그래서 useDataGrid가 인스턴스 생성 시 항상 존재하는 안정 객체 runtime을 1회 부여합니다.
interface DataGridRuntime {
gridId?: string; // Root가 기록 — 셀 DOM id 접두, 이 그리드로 조회 범위 한정
rootEl?: HTMLElement | null; // Root가 기록 — 그리드 내부 DOM 조회의 시작점
rowVirtualizer: Virtualizer<HTMLElement, Element> | null; // Body가 기록
columnVirtualizer: Virtualizer<HTMLElement, Element> | null; // Body가 기록
}Body가 렌더 중 virtualizer를 여기에 기록(ref 시맨틱 — setState 아님, 1프레임 지연 없음)하고, useDataGridScrollToActive가
읽습니다. identity가 불변이라 "임의 시점 부착"이 "항상 존재하는 객체에 대한 ref 쓰기"로 바뀌어 순서 해저드가 사라집니다.
rootEl은 그리드 내부에서 DOM을 찾을 때의 출발점입니다. document에서 시작하면 안 됩니다. rowId/columnId는
그리드 사이에서 유일하지 않으니까요 — 서로 다른 테이블이 id: "1"을 쓰는 건 정상입니다. 한 페이지에 그리드가 여럿이면
document.querySelector('[data-cell][data-row-id="1"]')는 언제나 문서의 첫 그리드를 집습니다.
rootEl과 스크롤 주인은 다른 개념입니다. 실제로 스크롤하는 요소는 Root 자신일 수도, 자손(ScrollContainer·소비자 박스)일 수도, 조상(바깥 소비자 박스)일 수도 있고 그건resolveScrollers가 축별로 따로 해석합니다. 반면 셀은 어떤 스크롤 구성에서도 항상 Root 안에 있고, 키보드 포커스를 받는 것도 Root입니다(selection이나editing이 주입돼tabIndex=0이 붙었을 때).
context 전파 — Root가 DI 허브
<DataGrid.Root>는 table/selection/editing/fillHandle/size/role을 받아 두 개의 context로 전파합니다.
DataGridContext— table, size, selection, editing, fillHandle, gridId, columnIndexMap 등 그리드 전역.DataGridCellContext— 현재 셀(cell).CellDisplay/CellEditor/useDataGridCellEdit이 읽습니다.
Header/Body/Row/Cell은 이 context에서 인스턴스를 읽어 동작합니다. 그래서 소비자는 selection/editing을 셀까지 prop으로
내려보낼 필요가 없습니다 — Root에 한 번 주입하면 됩니다.
<DataGrid.Root table selection editing fillHandle size role> ─┐ DataGridContext
<DataGrid.Header> │ (table·size·selection·editing·…)
<DataGrid.HeaderRow> <DataGrid.HeaderCell header|column/> … │
<DataGrid.Body> │
<DataGrid.Row row rowIndex> │
<DataGrid.Cell cell|column> ──┘ DataGridCellContext (cell)
<DataGrid.CellDisplay/> ← edit 아닐 때
<DataGrid.CellEditor> ← edit 시 mount
<DataGrid.TextEditor/> 또는 커스텀(useDataGridCellEdit)
<DataGrid.Empty/> <DataGrid.Loading/> <DataGrid.Error/> ← 뷰 상태 슬롯compound 트리 — 무엇이 무엇을 책임지나
| 컴포넌트 | 책임 | 모드 |
|---|---|---|
Root | DI 허브·키/포인터 라우팅·role·size | — |
ScrollContainer | 스크롤 영역([data-scroll-container]) — pinned/sticky 기준 | — |
Header | 헤더 그룹 렌더 | 자동 / (headerGroup) => … |
HeaderRow / HeaderCell | 헤더 행·셀(header 또는 column 유니온) | — |
Body | 행 렌더·로딩 시 skeleton·가상화 윈도잉 | 자동 / (row, rowIndex) => … |
Row / Cell | 행·셀(cell 또는 column 유니온)·data-* 상태 노출 | — |
CellDisplay / CellEditor | edit 아님 / edit 시 mount 분기 | — |
TextEditor | 패키지 기본 인라인 텍스트 에디터(IME·키·blur 내장) | opt-in |
Empty / Loading / Error | 뷰 상태 슬롯 | — |
SortArrow / ResizeHandle | 정렬 인디케이터 / 리사이즈 핸들(명시 부착) | — |
Header/Body는 children이 없으면 자동 렌더하고, 함수 children이면 render-prop으로 전환되어 행·셀을 소비자가 직접 구성합니다.
Cell/HeaderCell의 cell/column 유니온은 일반 모드(cell)와 skeleton/동적 모드(column)를 구분합니다. (Composition & DI)
data-* 상태 표면 — 스타일링/테스트 계약
셀·헤더·행은 상태를 data-* 속성과 state-callback으로 노출합니다. CSS는 data-* 셀렉터로,
프로그램은 state callback(className/style)으로, 테스트는 data-* 셀렉터로 잡습니다.
| 속성 | 의미 |
|---|---|
[data-grid] | 그리드 루트. 값은 그 인스턴스의 gridId — 한 페이지 다중 그리드에서 자기 그리드를 지목할 때 씁니다 |
[data-cell] / [data-row] | 셀 / 행 루트 (+ data-row-id/data-column-id) |
[data-cell-active] / [data-cell-in-range] | 활성 셀 / 선택 범위 포함 |
[data-cell-editing] / [data-cell-readonly] | 편집 중 / 읽기 전용 |
[data-cell-selectable] | 셀 커서를 받을 수 있음(= 복사 대상). readonly와 다른 축 — 읽기 전용 셀도 긁어 복사할 수 있습니다 |
[data-pinned] / [data-pinned-last] | 고정(left/right) / 고정 그룹의 마지막 |
[data-sortable] / [data-sorted] | 정렬 가능(배선됨) / 정렬됨(asc·desc) |
[data-header-cell] / [data-header-row] | 헤더 셀 / 헤더 행 |
[data-copied] | 복사·잘라내기 범위에 포함 |
[data-copied-t] [data-copied-r] [data-copied-b] [data-copied-l] | 그 변이 복사 범위의 바깥 경계 — 셀들이 이어 그려 점선 사각형이 됩니다 |
[data-clipboard-mode] | copy 또는 cut |
[data-fill-preview] + -t/-r/-b/-l | 채우기 드래그 미리보기 범위 / 그 변이 바깥 경계 |
[data-fill-clear] | 미리보기가 비우기 밴드(핸들을 소스 안쪽으로 되끈 경우) |
상태 기반 스타일은
$css가 아니라className/stylestate callback으로 줍니다.$css(rainbow-sprinkles)는 atomic이라 콜백·상태·중첩 셀렉터를 받지 않습니다.
컬럼 순서 — 좌표계의 단일 진실
그리드에는 컬럼 순서가 하나뿐입니다: 화면에 보이는 순서(좌측고정 → center → 우측고정).
렌더(헤더·행), 병합(span resolver), 좌표계(선택·복사·붙여넣기·채우기·aria-colindex)가 전부 이 순서를 씁니다.
TanStack은 두 순서를 동시에 노출하므로 소비자 코드에서 주의가 필요합니다.
| API | 순서 | 고정 반영 |
|---|---|---|
displayLeafColumns(table) | 표시 순서 | O — 그리드 좌표계가 쓰는 순서 |
row.getVisibleCells() | 표시 순서 | O |
table.getHeaderGroups() | 표시 순서 | O |
table.getVisibleLeafColumns() | 정의 순서 | X |
고정이 없거나 정의 순서의 앞/뒤 컬럼만 고정하면 두 순서가 일치합니다. 가운데 컬럼을 고정할 때만 갈라집니다.
직접 컬럼 인덱스를 계산한다면 displayLeafColumns를 쓰세요 — Pinning — 컬럼 순서.
겹침 순서 — 무엇이 무엇을 덮나
겹침 순서를 한 곳에서 관리합니다. 다만 하나의 사다리가 아닙니다. Root와 Body가 각각 isolation: isolate로
자기 stacking context를 만들어 판이 셋으로 나뉩니다.
| 판 | 무엇을 가두나 |
|---|---|
Root | 그리드 내부 z 전체. 내부 값이 페이지로 새지 않습니다 |
Body | 셀·행 층. 헤더 그룹과 숫자가 겹쳐도 서로 경쟁하지 않습니다 |
| 헤더 그룹 | 헤더 셀·고정 헤더·리사이즈 핸들 |
소비자에게 필요한 건 숫자가 아니라 순서입니다.
| 규칙 | 결과 |
|---|---|
| 헤더 > 바디 | 헤더는 positioned 레이어라 바디 내부 값과 무관하게 항상 위입니다 |
| 고정 셀 > 활성 셀 · 세로병합 · 일반 컬럼 편집기 | 시트의 frozen pane 규약. 스크롤해서 고정 컬럼 밑으로 들어간 셀은 가려집니다 |
| 고정 컬럼의 편집기 > 전부 | 고정 셀 안에서 편집할 때만 최상단으로 올라옵니다 |
| 리사이즈 핸들 > 고정 헤더 경계 | 경계 그림자 위에서 잡힙니다 |
선택 표시는 이 순서에 없습니다. 복사 점선·채우기 미리보기는 셀이 직접 그리고, 채우기 핸들·터치 선택 핸들은 셀의 자식이라 셀의 sticky·z·클리핑을 그대로 물려받습니다. 좌표로 띄우는 오버레이는 세로병합 하나뿐입니다.
가려짐은 고정 셀이 불투명하다는 전제 위에 있습니다. 행 배경을 투명하게 바꾸면 고정 컬럼 밑으로 스크롤된 셀이 비쳐 보입니다.
z 값 자체는 내부 구현입니다. 토큰으로 노출하지 않고, 소비자가 특정 숫자에 기대야 하는 상황을 만들지 않는 것이 목표입니다. 겹침이 어긋난다면 z를 덮어쓰기보다 먼저 알려 주세요.
자기완결(self-contained) 패키지
data-grid는 컴포넌트 패키지에 런타임 의존하지 않도록 src/system/에 자체 렌더 인프라(useRenderComponent, rainbow-sprinkles
설정, theme 계약, typo)를 복제 소유합니다. 빌드는 Rollup으로 ESM/CJS dual 출력, CSS는 @layer ft-components로 래핑되고,
브랜드 프리셋(dist/preset/featuring.css·dataEffect.css)이 레이어 순서·리셋·토큰을 묶습니다. 덕분에 미래에 별도 시스템 패키지로
승격하기 쉽습니다.