Tree Data
getSubRows + getExpandedRowModel로 계층 행을 만들고, data-depth 들여쓰기와 treegrid ARIA로 노출합니다.
개요
Tree Data는 자기 참조 계층 데이터(캠페인 > 크리에이터 > 콘텐츠 등)를 행으로 펼치는 방식입니다.
TanStack의 getSubRows + getExpandedRowModel을 useDataGrid 옵션에 조합하면 완성됩니다.
라이브러리는 들여쓰기 픽셀·토글 아이콘·레이아웃에 무의견이며, 그것들은 소비자 셀 렌더 책임입니다.
./grouping(컬럼 값 그루핑)과 다릅니다. Grouping은 평면 행을 컬럼 값 기준으로 자동 묶는 것입니다. Tree Data는 데이터 자체가 이미 부모-자식 관계를 가집니다../expansion(행 디테일 패널)과 다릅니다. Expansion은 같은 행 아래에 별도 패널(상세 정보)을 여는 것입니다. Tree Data는 자식 행 자체가 나타납니다.
라이브 스토리: Tree Data stories
기본 사용법
useDataGrid에 세 가지를 추가합니다.
const data = [ { id: 'beauty', name: '뷰티', followers: 251600, children: [ { id: '1', name: '김민준', followers: 128000 }, { id: '2', name: '이서연', followers: 84500 }, { id: '7', name: '윤서준', followers: 39100 }, ], }, { id: 'game', name: '게임', followers: 376900, children: [ { id: '3', name: '박도윤', followers: 233000 }, { id: '4', name: '최하은', followers: 45200 }, { id: '8', name: '임하준', followers: 98700 }, ], }, ]; function TreeToggle({ row }) { // leaf 행(자식 없음)은 같은 너비의 spacer로 정렬을 맞춥니다. if (!row.getCanExpand()) { return <Box $css={{ width: '24px', flexShrink: 0 }} />; } const expanded = row.getIsExpanded(); return ( <IconButton size="sm" variant="tertiary" aria-label={expanded ? 'Collapse row' : 'Expand row'} aria-expanded={expanded} onClick={row.getToggleExpandedHandler()} onPointerDown={(e) => e.stopPropagation()} > <IconChevronRightOutline size={16} style={{ transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)', transition: 'transform 0.15s ease', }} /> </IconButton> ); } const columns = [ { accessorKey: 'name', header: ({ header }) => <DataGrid.HeaderCell header={header}>채널</DataGrid.HeaderCell>, cell: (info) => { const row = info.row; return ( <DataGrid.Cell cell={info.cell}> {/* 들여쓰기는 row.depth × 픽셀을 소비자가 결정합니다. */} <HStack $css={{ alignItems: 'center', gap: '$spacing-100' }} style={{ paddingLeft: row.depth * 20 }}> <TreeToggle row={row} /> <Typo variant="$body-2">{String(info.getValue() ?? '')}</Typo> </HStack> </DataGrid.Cell> ); }, }, { accessorKey: 'followers', header: ({ header }) => <DataGrid.HeaderCell header={header}>팔로워</DataGrid.HeaderCell>, cell: (info) => <DataGrid.Cell cell={info.cell}>{Number(info.getValue() ?? 0).toLocaleString()}</DataGrid.Cell>, }, ]; function TreeGrid() { const [expanded, setExpanded] = useState(true); // 처음부터 전체 펼침 const table = useDataGrid({ data, columns, getCoreRowModel: getCoreRowModel(), // 1. 자식 행 모델 활성 getExpandedRowModel: getExpandedRowModel(), // 2. 어떤 필드가 자식 배열인지 알려줌 getSubRows: (row) => row.children, getRowId: (row) => row.id, // 3. 컨트롤드 펼침 상태 state: { expanded }, onExpandedChange: setExpanded, }); const selection = useDataGridSelection(table); return ( // 4. 계층 데이터 → role="treegrid" 명시 <DataGrid.Root table={table} selection={selection} role="treegrid" size="md"> <DataGrid.Header /> <DataGrid.Body /> </DataGrid.Root> ); } render(<TreeGrid />);
셀 렌더에서 토글 만들기
들여쓰기와 토글 버튼은 소비자가 row.depth·row.getCanExpand()·row.getToggleExpandedHandler()로 직접 그립니다.
import { Box, HStack, IconButton, Typo } from '@featuring-corp/components';
import { IconChevronRightOutline } from '@featuring-corp/icons';
import type { CellContext } from '@featuring-corp/data-grid';
function TreeToggle<T>({ row }: { row: CellContext<T, unknown>['row'] }) {
// leaf 행(자식 없음)은 같은 너비의 spacer로 정렬을 맞춥니다.
if (!row.getCanExpand()) {
return <Box $css={{ width: '24px', flexShrink: 0 }} />;
}
const expanded = row.getIsExpanded();
return (
<IconButton
size="sm"
variant="tertiary"
aria-label={expanded ? 'Collapse row' : 'Expand row'}
aria-expanded={expanded}
onClick={row.getToggleExpandedHandler()}
// 셀 선택 드래그(grid pointerdown)와 토글 클릭을 분리합니다.
onPointerDown={(e) => e.stopPropagation()}
>
<IconChevronRightOutline
size={16}
style={{
transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)',
transition: 'transform 0.15s ease',
}}
/>
</IconButton>
);
}
// name 컬럼 cell 렌더
cell: (info) => {
const row = info.row;
return (
<DataGrid.Cell cell={info.cell}>
{/* 들여쓰기는 row.depth × 픽셀을 소비자가 결정합니다. */}
<HStack
$css={{ alignItems: 'center', gap: '$spacing-100' }}
style={{ paddingLeft: row.depth * 20 }}
>
<TreeToggle row={row} />
<Typo variant="$body-2">{String(info.getValue() ?? '')}</Typo>
</HStack>
</DataGrid.Cell>
);
},전체 펼침 / 접힘
TanStack table 인스턴스의 메서드로 제어합니다.
// 전체 토글(컨트롤드 state.expanded에 true가 들어가면 전부 펼침)
grid.toggleAllRowsExpanded();
// 전체 펼침 여부 확인
const allExpanded = grid.getIsAllRowsExpanded();
// 전부 접기 — state를 빈 객체로 교체
setExpanded({});들여쓰기 — data-depth / row.depth
Row는 data-depth 속성(0-based)을 항상 DOM에 노출하고, data-expanded는 확장된 행에만 붙습니다.
// Row.tsx 내부 extraProps
'data-depth': row.depth, // 0-based 깊이
'data-expanded': state.isExpanded ? '' : undefined,DataGridRowState에도 같은 값이 담겨 state callback으로 쓸 수 있습니다.
interface DataGridRowState {
isSelected: boolean;
isSomeSelected: boolean;
isExpanded: boolean;
depth: number; // 0-based
}깊이별 배경 강조 — style state callback
깊이는 런타임 상태이므로 정적 $css가 아니라 style state callback으로 줍니다.
콜백 안은 진짜 CSS이므로 토큰은 CSS 변수(var(--global-colors-...))로 참조합니다.
<DataGrid.Body<Node>>
{(row, rowIndex) => (
<DataGrid.Row
row={row}
rowIndex={rowIndex}
style={(state) => ({
backgroundColor:
state.depth === 0
? 'var(--global-colors-gray-20)'
: state.depth === 1
? 'var(--global-colors-gray-10)'
: 'transparent',
fontWeight: state.depth === 0 ? 700 : state.depth === 1 ? 600 : 400,
})}
>
{row.getVisibleCells().map((cell) => (
<Fragment key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</Fragment>
))}
</DataGrid.Row>
)}
</DataGrid.Body>패키지 CSS는 @layer ft-components이므로 소비자 override가 항상 우선합니다.
접근성 — role="treegrid"
왜 treegrid인가
WAI-ARIA 명세에서 부모-자식 행을 가진 격자는 treegrid입니다.
role="treegrid"를 줄 때만 Row가 계층 ARIA를 additive로 부착합니다(Row.tsx의 isTreegrid 게이트).
role="grid"로 두면 이 속성들이 전부 undefined가 되어 AT는 계층을 인식하지 못합니다.
부착되는 ARIA 속성
| 속성 | 의미 | 부착 조건 |
|---|---|---|
aria-level | 행의 깊이 (1-based, row.depth + 1) | role="treegrid"인 모든 행 |
aria-expanded | 확장 상태 | 확장 가능한 행만(row.getCanExpand() = true) — leaf는 미부착 |
aria-posinset | 형제 집합 내 1-based 위치 (row.index + 1) | 중첩 행만 (부모가 있는 행) |
aria-setsize | 형제 집합의 크기 (treeParent.subRows.length) | 중첩 행만 (부모가 있는 행) |
루트 행에는
aria-posinset/aria-setsize를 붙이지 않습니다. 루트 형제 수는 행마다 O(n) 계산이 필요한데,aria-rowindex+aria-level=1로 위치 안내가 충분합니다.
// Row.tsx 내부 (요지)
const isTreegrid = role === 'treegrid';
const canExpand = isTreegrid && row.getCanExpand();
const treeParent = isTreegrid ? row.getParentRow() : undefined;
// 'aria-level': isTreegrid ? row.depth + 1 : undefined
// 'aria-expanded': canExpand ? state.isExpanded : undefined // 확장 가능 행만
// 'aria-posinset': treeParent ? row.index + 1 : undefined // 중첩 행만
// 'aria-setsize': treeParent ? treeParent.subRows.length : undefined선택 · 키보드 내비게이션
선택·화살표 내비·복사는 getRowModel().rows(펼쳐진 행의 평면 목록) 기준으로 동작합니다.
트리 행도 일반 행과 동일하게 참여합니다 — 계층을 "아는" 것은 ARIA뿐이고, 인터랙션은 평면입니다.
자세한 접근성 설계 원칙과 WCAG 매핑은 Accessibility를 참고하세요.
가상화 + render-prop에서 rowIndex
render-prop으로 행을 직접 그리면서 가상화까지 켜면, 화면 밖 행이 DOM에서 빠집니다.
이때 <DataGrid.Row rowIndex={rowIndex}>로 표시 위치(0-based)를 넘겨야 aria-rowindex가 정확합니다.
<DataGrid.Body />(auto-render)는 이 번호를 자동 처리합니다.
<DataGrid.Body<Node>>
{/* 두 번째 인자가 보이는 행 목록 내 0-based 위치입니다. */}
{(row, rowIndex) => (
<DataGrid.Row row={row} rowIndex={rowIndex}>
{row.getVisibleCells().map((cell) => (
<Fragment key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</Fragment>
))}
</DataGrid.Row>
)}
</DataGrid.Body>API
Prop
Type
다음 단계
- Grouping — 컬럼 값 기준 자동 그루핑 (Tree Data와 다른 모델).
- Expansion — 같은 행 아래 디테일 패널을 여는 방식.
- Accessibility —
role선택 기준, treegrid 계층 ARIA 전체 규격, WCAG 매핑. - 라이브 스토리: Tree Data stories