트리 폴딩 (Tree Folding)
평면 데이터의 부모 키 관계를 트리로 표시하고 행을 안전하게 접고 펼치는 방법을 설명합니다.
import * as React from 'react';
import { ChevronDown, ChevronRight, File } from 'lucide-react';
import { BGrid, type BGridColumn, type BGridDataItem, type BGridDataQuery } from 'beautiful-grid';
import DataGridContainer from '../components/DataGridContainer';
import { useContainerSize } from '../hooks/useContainerSize';
import { exampleMsg, t } from './i18n';
interface OrganizationRow {
id: string;
parentId: string | null;
name: string;
type: string;
owner: string;
}
const data: BGridDataItem<OrganizationRow>[] = [
{
values: {
id: 'platform',
parentId: null,
name: t('플랫폼 본부', 'Platform'),
type: t('본부', 'Division'),
owner: 'Mina',
},
},
{
values: {
id: 'frontend',
parentId: 'platform',
name: t('프론트엔드 팀', 'Frontend'),
type: t('팀', 'Team'),
owner: 'Alex',
},
},
{ values: { id: 'grid', parentId: 'frontend', name: 'BeautifulGrid', type: t('프로젝트', 'Project'), owner: 'Jin' } },
{
values: {
id: 'design',
parentId: 'frontend',
name: t('디자인 시스템', 'Design System'),
type: t('프로젝트', 'Project'),
owner: 'Sora',
},
},
{
values: {
id: 'backend',
parentId: 'platform',
name: t('백엔드 팀', 'Backend'),
type: t('팀', 'Team'),
owner: 'Noah',
},
},
{
values: {
id: 'commerce',
parentId: null,
name: t('커머스 본부', 'Commerce'),
type: t('본부', 'Division'),
owner: 'Yuna',
},
},
{
values: { id: 'orders', parentId: 'commerce', name: t('주문 팀', 'Orders'), type: t('팀', 'Team'), owner: 'Liam' },
},
];
// Keep the demo large enough to exercise hierarchy projection and virtual scrolling together.
const initialExpandedRowKeys: React.Key[] = ['platform', 'frontend', 'division-1', 'division-1-team-1'];
for (let divisionIndex = 1; divisionIndex <= 50; divisionIndex++) {
const divisionId = `division-${divisionIndex}`;
data.push({
values: {
id: divisionId,
parentId: null,
name: t(`사업 본부 ${divisionIndex}`, `Business division ${divisionIndex}`),
type: t('본부', 'Division'),
owner: `Owner ${divisionIndex}`,
},
});
for (let teamIndex = 1; teamIndex <= 10; teamIndex++) {
const teamId = `${divisionId}-team-${teamIndex}`;
data.push({
values: {
id: teamId,
parentId: divisionId,
name: t(`팀 ${divisionIndex}-${teamIndex}`, `Team ${divisionIndex}-${teamIndex}`),
type: t('팀', 'Team'),
owner: `Lead ${divisionIndex}-${teamIndex}`,
},
});
for (let projectIndex = 1; projectIndex <= 20; projectIndex++) {
data.push({
values: {
id: `${teamId}-project-${projectIndex}`,
parentId: teamId,
name: t(
`프로젝트 ${divisionIndex}-${teamIndex}-${projectIndex}`,
`Project ${divisionIndex}-${teamIndex}-${projectIndex}`,
),
type: t('프로젝트', 'Project'),
owner: `Member ${projectIndex}`,
},
});
}
}
}
const columns: BGridColumn<OrganizationRow>[] = [
{
id: 'name',
key: 'name',
label: t('조직·프로젝트', 'Organization & project'),
width: 260,
toolbox: true,
filter: { type: 'text' },
},
{ id: 'type', key: 'type', label: t('구분', 'Type'), width: 120, toolbox: true, filter: { type: 'values' } },
{ id: 'owner', key: 'owner', label: t('담당자', 'Owner'), width: 120, toolbox: true, filter: { type: 'text' } },
];
export default function TreeFoldingExample() {
const containerRef = React.useRef<HTMLDivElement>(null);
const { width, height } = useContainerSize(containerRef);
const [expandedRowKeys, setExpandedRowKeys] = React.useState<React.Key[]>(() => [...initialExpandedRowKeys]);
const [query, setQuery] = React.useState<BGridDataQuery>({ sortParams: [], filterParams: [] });
return (
<DataGridContainer ref={containerRef}>
<BGrid
width={width}
height={height}
columns={columns}
data={data}
rowKey='id'
msg={exampleMsg}
tree={{
parentRowKey: 'parentId',
treeColumnId: 'name',
expandedRowKeys,
onExpandedRowKeysChange: setExpandedRowKeys,
icons: {
expanded: <ChevronDown size={14} />,
collapsed: <ChevronRight size={14} />,
leaf: <File size={12} />,
},
}}
dataControl={{ mode: 'client', query, onChange: setQuery }}
status={{
content: ({ visibleItems }) =>
t(
`원본 ${data.length.toLocaleString()}행 · 현재 ${visibleItems.toLocaleString()}행`,
`${data.length.toLocaleString()} source rows · ${visibleItems.toLocaleString()} visible`,
),
}}
variant='vertical-bordered'
/>
</DataGridContainer>
);
}import * as React from 'react';
import './DataGridContainer.css';
interface DataGridContainerProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
}
/**
* Keeps a DataGrid in a measured, fixed layout box.
*
* BGrid's rendered root is absolutely positioned within this relative
* container. This makes a ResizeObserver measurement authoritative when a
* surrounding flex or grid layout shrinks as well as when it expands.
*/
const DataGridContainer = React.forwardRef<HTMLDivElement, DataGridContainerProps>(
({ className, ...rest }, ref) => (
<div ref={ref} className={`data-grid-container ${className ?? ''}`.trim()} {...rest} />
),
);
DataGridContainer.displayName = 'DataGridContainer';
export default DataGridContainer;.data-grid-container {
position: relative;
width: 100%;
height: 400px;
overflow: hidden;
font-size: 13px;
}
.data-grid-container > .bgrid-root {
position: absolute;
inset: 0;
}import * as React from 'react';
export function useContainerSize(ref: React.MutableRefObject<HTMLElement | null>, additionalDeps: unknown[] = []) {
const [width, setWidth] = React.useState(0);
const [height, setHeight] = React.useState(0);
const resizeObserver = React.useRef(
new ResizeObserver(entries => {
if (entries.length !== 1) {
throw new Error('Invalid Container length');
}
const [entry] = entries;
const { width, height } = entry.contentRect;
setWidth(width);
setHeight(height);
}),
);
React.useEffect(() => {
if (!ref.current) return;
const observer = resizeObserver.current;
const element = ref.current;
setWidth(element.clientWidth);
setHeight(element.clientHeight);
observer.observe(element);
return () => {
observer.unobserve(element);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...additionalDeps, ref]);
return {
width,
height,
};
}평면 데이터를 트리로 표시하기
트리 모드는 별도 컴포넌트가 아니라 기존 <BGrid>의 tree prop으로 활성화합니다. rowKey는 현재 행의 고유 키 필드를 지정하고, tree.parentRowKey는 부모의 rowKey 값이 들어 있는 필드를 지정합니다.
import { BGrid, type BGridColumn, type BGridDataItem } from 'beautiful-grid';
interface Row {
id: string;
parentId: string | null;
name: string;
}
const columns: BGridColumn<Row>[] = [{ id: 'name', key: 'name', label: '이름', width: 240 }];
const data: BGridDataItem<Row>[] = [
{ values: { id: 'root', parentId: null, name: '전자제품' } },
{ values: { id: 'laptop', parentId: 'root', name: '노트북' } },
];
<BGrid
width={640}
height={360}
columns={columns}
data={data}
rowKey='id'
tree={{
parentRowKey: 'parentId',
treeColumnId: 'name',
}}
/>;
tree.treeColumnId는 데이터 필드 key가 아니라 BGridColumn.id를 참조합니다. 이 컬럼의 각 셀에 단계별 들여쓰기와 펼침·접힘 아이콘이 표시됩니다. 생략하거나 현재 숨겨진 컬럼을 지정하면 첫 번째 표시 컬럼을 사용합니다.
상단 라이브 데모는 3단계, 10,557개의 원본 행을 사용하는 예제입니다. 일부 가지를 펼친 초기 상태로 접기·펼치기 반응을 쉽게 확인할 수 있으며, 더 많은 행이 표시되어도 가상 스크롤이 실제 DOM을 현재 화면에 필요한 행으로 제한합니다.
펼침 상태 제어
defaultExpandedRowKeys는 그리드가 상태를 소유하는 초기값입니다. 외부에서 상태를 제어하려면 expandedRowKeys와 onExpandedRowKeysChange를 함께 사용합니다.
const [expandedRowKeys, setExpandedRowKeys] = useState<React.Key[]>(['root']);
<BGrid
{...gridProps}
rowKey='id'
tree={{
parentRowKey: 'parentId',
expandedRowKeys,
onExpandedRowKeysChange: setExpandedRowKeys,
}}
/>;
callback 이벤트에는 토글한 rowKey, 다음 expanded 상태, 원본 item, 접힘 상태와 무관하게 유지되는 sourceIndex가 전달됩니다.
아이콘과 간격
tree={{
parentRowKey: 'parentId',
treeColumnId: 'name',
indentSize: 20,
icons: {
expanded: <ChevronDown />,
collapsed: <ChevronRight />,
leaf: <File />,
},
}}
leaf 아이콘을 생략해도 텍스트 정렬을 위한 동일 너비의 빈 공간은 유지됩니다. 아이콘 버튼의 클릭과 키보드 동작, aria-expanded는 그리드가 처리합니다.
정렬·필터와 원본 인덱스
클라이언트 정렬은 부모와 자식을 분리하지 않고 같은 부모의 형제 안에서만 적용됩니다. 필터에 자식이 일치하면 그 자식까지의 조상 경로를 임시로 함께 표시하지만 controlled 펼침 배열은 변경하지 않습니다.
화면의 visibleIndex는 폴딩에 따라 달라질 수 있습니다. 행 체크와 편집 callback은 원본 평면 배열의 sourceIndex를 유지하며, 편집의 meta.tree.path에는 루트부터 해당 행까지의 row key 경로가 전달됩니다.
제한 사항
- 트리 모드에서는 행 재배치와 셀 병합이 비활성화됩니다.
- Pivot 모드가 활성화되면 트리 모드는 비활성화됩니다.
- 비동기 자식 로딩과 부모 선택의 자식 자동 선택은 현재 지원하지 않습니다.
- 부모를 찾을 수 없는 행은 루트로 표시합니다.
- 누락·중복 row key 또는 순환 관계가 발견되면 개발 환경에서 경고하고 평면 그리드로 안전하게 전환합니다.