Tree Folding
Render flat parent-key data as a hierarchy and safely expand or collapse rows.
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,
};
}Render flat data as a tree
Tree mode is enabled through the existing <BGrid> component’s tree prop. rowKey identifies the current row, while tree.parentRowKey names the field containing its parent’s rowKey value.
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: 'Name', width: 240 }];
const data: BGridDataItem<Row>[] = [
{ values: { id: 'root', parentId: null, name: 'Electronics' } },
{ values: { id: 'laptop', parentId: 'root', name: 'Laptops' } },
];
<BGrid
width={640}
height={360}
columns={columns}
data={data}
rowKey='id'
tree={{
parentRowKey: 'parentId',
treeColumnId: 'name',
}}
/>;
tree.treeColumnId refers to BGridColumn.id, not the data field in key. The selected column renders indentation and a folding icon in every tree row. If it is omitted or currently hidden, the first visible column is used.
The live demo above uses 10,557 source rows in a three-level hierarchy. Its initial state expands a representative branch for responsive folding checks, while virtual scrolling limits the DOM to the rows needed for the current viewport as more branches are opened.
Control expansion
Use defaultExpandedRowKeys as an initial value when the grid owns expansion state. Use expandedRowKeys with onExpandedRowKeysChange for controlled state.
const [expandedRowKeys, setExpandedRowKeys] = useState<React.Key[]>(['root']);
<BGrid
{...gridProps}
rowKey='id'
tree={{
parentRowKey: 'parentId',
expandedRowKeys,
onExpandedRowKeysChange: setExpandedRowKeys,
}}
/>;
The callback event includes the toggled rowKey, the next expanded state, the original row item, and a stable sourceIndex that does not change when branches fold.
Customize icons and spacing
tree={{
parentRowKey: 'parentId',
treeColumnId: 'name',
indentSize: 20,
icons: {
expanded: <ChevronDown />,
collapsed: <ChevronRight />,
leaf: <File />,
},
}}
When the leaf icon is omitted, the grid retains an equal-width spacer so labels stay aligned. The grid owns button input behavior and aria-expanded; supplied icons are visual content only.
Sorting, filtering, and source indexes
Client sorting stays within sibling groups, so parents and children are never separated. When a child matches a filter, its ancestor path is temporarily revealed without changing the controlled expansion array.
The on-screen visibleIndex can change as branches fold. Row selection and edit callbacks retain the flat input array’s sourceIndex, and edit metadata exposes the root-to-row key path as meta.tree.path.
Limitations
- Row reordering and cell merging are disabled in tree mode.
- Pivot mode takes precedence and disables tree mode.
- Lazy child loading and cascading parent-to-child selection are not supported yet.
- A row whose parent cannot be found is treated as a root.
- Missing or duplicate row keys and cycles produce a development warning and safely fall back to flat rows.