Featuring Design System
Grouping & Hierarchy

Tree Data

getSubRows + getExpandedRowModel로 계층 행을 만들고, data-depth 들여쓰기와 treegrid ARIA로 노출합니다.

개요

Tree Data는 자기 참조 계층 데이터(캠페인 > 크리에이터 > 콘텐츠 등)를 행으로 펼치는 방식입니다. TanStack의 getSubRows + getExpandedRowModeluseDataGrid 옵션에 조합하면 완성됩니다. 라이브러리는 들여쓰기 픽셀·토글 아이콘·레이아웃에 무의견이며, 그것들은 소비자 셀 렌더 책임입니다.

  • ./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

Rowdata-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.tsxisTreegrid 게이트). 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 — 같은 행 아래 디테일 패널을 여는 방식.
  • Accessibilityrole 선택 기준, treegrid 계층 ARIA 전체 규격, WCAG 매핑.
  • 라이브 스토리: Tree Data stories