Tree

Tree

계층적 트리 구조를 표시하는 컴포넌트예요. 기본 스타일이 최소화되어 있어서 Styles API로 자유롭게 커스터마이즈할 수 있어요.

출처: 문서

본문

사용법 (Usage)

Tree 컴포넌트는 계층적 데이터를 표시하는 데 사용돼요. Tree 컴포넌트는 기본적으로 최소한의 스타일을 가져요. Styles API로 스타일을 커스터마이즈할 수 있어요.

import { FileTextIcon, FolderOpenIcon, FolderSimpleIcon } from '@phosphor-icons/react';
import { Group, RenderTreeNodePayload, Tree } from '@mantine/core';
import { data } from './data';

function Leaf({ node, expanded, hasChildren, elementProps }: RenderTreeNodePayload) {
  return (
    <Group gap={5} {...elementProps}>
      {hasChildren ? (
        expanded ? <FolderOpenIcon size={16} /> : <FolderSimpleIcon size={16} />
      ) : (
        <FileTextIcon size={16} />
      )}
      <span>{node.label}</span>
    </Group>
  );
}

function Demo() {
  return <Tree data={data} renderNode={(payload) => <Leaf {...payload} />} />;
}

Data prop

data prop에 전달된 데이터는 다음 규칙을 따라야 해요.

  • 데이터는 안정적인 참조(메모이즈)여야 해요
  • 데이터는 배열이어야 해요
  • 배열의 각 항목은 트리에서 하나의 노드를 나타내요
  • 각 노드는 value와 label 키가 있는 객체여야 해요
  • 각 노드는 자식 노드 배열이 있는 children 키를 가질 수 있어요
  • 각 노드의 value는 고유해야 해요

유효한 데이터 예시:

// ✅ 유효한 데이터, 모든 값이 고유해요
const data = [
  {
    value: 'src',
    label: 'src',
    children: [
      { value: 'src/components', label: 'components' },
      { value: 'src/hooks', label: 'hooks' },
    ],
  },
  { value: 'package.json', label: 'package.json' },
];

잘못된 데이터 예시:

// ❌ 잘못된 데이터, 값이 고유하지 않아요 (components가 두 번 사용됨)
const data = [
  {
    value: 'src',
    label: 'src',
    children: [{ value: 'components', label: 'components' }],
  },
  { value: 'components', label: 'components' },
];

데이터 타입 (Data type)

트리의 데이터 타입을 정의하려면 TreeNodeData 타입을 임포트할 수 있어요.

import { TreeNodeData } from '@mantine/core';

const data: TreeNodeData[] = [
  {
    value: 'src',
    label: 'src',
    children: [
      { value: 'src/components', label: 'components' },
      { value: 'src/hooks', label: 'hooks' },
    ],
  },
  { value: 'package.json', label: 'package.json' },
];

renderNode

노드 렌더링을 커스터마이즈하려면 renderNode prop을 사용해요. renderNode 함수는 다음 속성이 있는 객체를 단일 인자로 받아요.

export interface RenderTreeNodePayload {
  /** 트리에서의 노드 레벨 */
  level: number;

  /** 노드가 확장되었는지 여부, `children`이 있는 노드에만 적용돼요 */
  expanded: boolean;

  /** 노드에 비어 있지 않은 `children` 배열이 있거나 `hasChildren`이 `true`인지 여부 */
  hasChildren: boolean;

  /** 노드가 선택되었는지 여부 */
  selected: boolean;

  /** 노드의 자식이 현재 로딩 중인지 여부 */
  isLoading: boolean;

  /** 마지막으로 실패한 로드 시도의 오류, 또는 `null` */
  loadError: Error | null;

  /** `Tree`의 `data` prop에서 온 노드 데이터 */
  node: TreeNodeData;

  /** 트리 컨트롤러 인스턴스, `useTree` 훅의 반환값 */
  tree: TreeController;

  /** 루트 노드 요소에 펼쳐서 넣을 props */
  elementProps: {
    className: string;
    style: React.CSSProperties;
    onClick: (event: React.MouseEvent) => void;
    'data-selected': boolean | undefined;
    'data-value': string;
  };

  /** `Tree`에 `withDragHandle`이 설정됐을 때 드래그 핸들 요소에 펼칠 props, 그 외엔 `undefined` */
  dragHandleProps: { onMouseDown: (event: React.MouseEvent) => void } | undefined;
}
import { FileTextIcon, FolderOpenIcon, FolderSimpleIcon } from '@phosphor-icons/react';
import { Group, RenderTreeNodePayload, Tree } from '@mantine/core';
import { data } from './data';

function Leaf({ node, expanded, hasChildren, elementProps }: RenderTreeNodePayload) {
  return (
    <Group gap={5} {...elementProps}>
      {hasChildren ? (
        expanded ? <FolderOpenIcon size={16} /> : <FolderSimpleIcon size={16} />
      ) : (
        <FileTextIcon size={16} />
      )}
      <span>{node.label}</span>
    </Group>
  );
}

function Demo() {
  return <Tree data={data} renderNode={(payload) => <Leaf {...payload} />} />;
}

useTree 훅 (useTree hook)

useTree 훅은 트리의 선택(selected) 및 확장(expanded) 상태를 제어하는 데 사용할 수 있어요.

이 훅은 다음 속성을 가진 객체를 받아요.

export interface UseTreeInput {
  /** 모든 노드의 초기 확장 상태, 비제어 상태 */
  initialExpandedState?: TreeExpandedState;

  /** 모든 노드의 확장 상태, 제어 상태 */
  expandedState?: TreeExpandedState;

  /** 트리 확장 상태가 변경될 때 호출돼요 */
  onExpandedStateChange?: (expandedState: TreeExpandedState) => void;

  /** 노드의 초기 선택 상태 */
  initialSelectedState?: string[];

  /** 모든 노드의 선택 상태, 제어 상태 */
  selectedState?: string[];

  /** 트리 선택 상태가 변경될 때 호출돼요 */
  onSelectedStateChange?: (selectedState: string[]) => void;

  /** 노드의 초기 체크 상태 */
  initialCheckedState?: string[];

  /** 모든 노드의 체크 상태, 제어 상태 */
  checkedState?: string[];

  /** 트리 체크 상태가 변경될 때 호출돼요 */
  onCheckedStateChange?: (checkedState: string[]) => void;

  /** 한 번에 여러 노드를 선택할 수 있는지 여부 */
  multiple?: boolean;

  /** 노드가 확장될 때 노드 값과 함께 호출돼요 */
  onNodeExpand?: (value: string) => void;

  /** 노드가 축소될 때 노드 값과 함께 호출돼요 */
  onNodeCollapse?: (value: string) => void;

  /** `hasChildren: true`인 노드가 처음 확장될 때 호출돼요 */
  onLoadChildren?: (nodeValue: string) => Promise<TreeNodeData[]>;

  /** `true`이면 부모를 체크해도 자식에 영향을 주지 않고 그 반대도 마찬가지예요.
   * 각 노드의 체크 상태는 완전히 독립적이 돼요. @default false
   */
  checkStrictly?: boolean;
}

또한 다음 속성을 가진 객체를 반환해요.

export interface UseTreeReturnType {
  /** `true`이면 각 노드의 체크 상태가 독립적이에요 (부모-자식 계단식 없음) */
  checkStrictly: boolean;

  /** 한 번에 여러 노드를 선택할 수 있는지 여부 */
  multiple: boolean;

  /** `node.value`와 노드 확장 상태를 나타내는 불리언 값의 레코드 */
  expandedState: TreeExpandedState;

  /** 선택된 노드 값의 배열 */
  selectedState: string[];

  /** 체크된 노드 값의 배열 */
  checkedState: string[];

  /** 마지막으로 클릭된 노드의 값. 다중 선택에서 선택 범위를 정하는 데 사용돼요 */
  anchorNode: string | null;

  /** 제공된 데이터를 기반으로 트리 상태를 초기화해요, Tree 컴포넌트가 자동 호출해요 */
  initialize: (data: TreeNodeData[]) => void;

  /** 제공된 값을 가진 노드의 확장 상태를 토글해요 */
  toggleExpanded: (value: string) => void;

  /** 제공된 값을 가진 노드를 축소해요 */
  collapse: (value: string) => void;

  /** 제공된 값을 가진 노드를 확장해요 */
  expand: (value: string) => void;

  /** 모든 노드를 확장해요 */
  expandAllNodes: () => void;

  /** 모든 노드를 축소해요 */
  collapseAllNodes: () => void;

  /** 확장 상태를 설정해요 */
  setExpandedState: React.Dispatch<React.SetStateAction<TreeExpandedState>>;

  /** 제공된 값을 가진 노드의 선택 상태를 토글해요 */
  toggleSelected: (value: string) => void;

  /** 제공된 값을 가진 노드를 선택해요 */
  select: (value: string) => void;

  /** 제공된 값을 가진 노드를 선택 해제해요 */
  deselect: (value: string) => void;

  /** 선택 상태를 지워요 */
  clearSelected: () => void;

  /** 선택 상태를 설정해요 */
  setSelectedState: React.Dispatch<React.SetStateAction<string[]>>;

  /** 제공된 값을 가진 노드를 체크해요 */
  checkNode: (value: string) => void;

  /** 제공된 값을 가진 노드를 체크 해제해요 */
  uncheckNode: (value: string) => void;

  /** 모든 노드를 체크해요 */
  checkAllNodes: () => void;

  /** 모든 노드를 체크 해제해요 */
  uncheckAllNodes: () => void;

  /** 체크 상태를 설정해요 */
  setCheckedState: React.Dispatch<React.SetStateAction<string[]>>;

  /** 상태와 함께 모든 체크된 노드를 반환해요 */
  getCheckedNodes: () => CheckedNodeStatus[];

  /** 제공된 값을 가진 노드가 체크되었는지 `true`를 반환해요 */
  isNodeChecked: (value: string) => boolean;

  /** 제공된 값을 가진 노드가 불확정(indeterminate)인지 `true`를 반환해요 */
  isNodeIndeterminate: (value: string) => boolean;

  /** 노드의 자식이 현재 로딩 중인지 `true`를 반환해요 */
  isNodeLoading: (value: string) => boolean;

  /** 마지막으로 실패한 로드 시도의 오류, 또는 `null`을 반환해요 */
  getNodeLoadError: (value: string) => Error | null;

  /** 노드 자식 로딩을 프로그래밍 방식으로 트리거해요 */
  loadNode: (value: string) => Promise<TreeNodeData[]>;

  /** 노드의 로드된 캐시를 지워 다음 확장 시 다시 가져오게 해요 */
  invalidateNode: (value: string) => void;
}

useTree 훅이 반환한 값을 Tree 컴포넌트의 tree prop에 전달해 트리 상태를 제어할 수 있어요.

import { FileTextIcon, FolderOpenIcon, FolderSimpleIcon } from '@phosphor-icons/react';
import { Button, Group, RenderTreeNodePayload, Tree, useTree } from '@mantine/core';
import { data } from './data';

function Leaf({ node, expanded, hasChildren, elementProps }: RenderTreeNodePayload) {
  return (
    <Group gap={5} {...elementProps}>
      {hasChildren ? (
        expanded ? <FolderOpenIcon size={16} /> : <FolderSimpleIcon size={16} />
      ) : (
        <FileTextIcon size={16} />
      )}
      <span>{node.label}</span>
    </Group>
  );
}

function Demo() {
  const tree = useTree();

  return (
    <>
      <Tree data={data} tree={tree} renderNode={(payload) => <Leaf {...payload} />} />
      <Group mt="md">
        <Button onClick={() => tree.expandAllNodes()}>Expand all</Button>
        <Button onClick={() => tree.collapseAllNodes()}>Collapse all</Button>
      </Group>
    </>
  );
}

체크 상태 (Checked state)

Tree는 체크박스로 체크 상태를 표시하는 데 사용할 수 있어요. 체크 상태를 구현하려면 renderNode 함수에서 Checkbox.Indicator를 렌더링해야 해요.

import { FileTextIcon, FolderOpenIcon, FolderSimpleIcon } from '@phosphor-icons/react';
import { Checkbox, Group, RenderTreeNodePayload, Tree } from '@mantine/core';
import { data } from './data';

const renderTreeNode = ({
  node,
  expanded,
  hasChildren,
  elementProps,
  tree,
}: RenderTreeNodePayload) => {
  const checked = tree.isNodeChecked(node.value);
  const indeterminate = tree.isNodeIndeterminate(node.value);

  return (
    <Group gap={5} {...elementProps}>
      <Checkbox.Indicator
        checked={checked}
        indeterminate={indeterminate}
        onClick={() => (!checked ? tree.checkNode(node.value) : tree.uncheckNode(node.value))}
      />
      <Group gap={5} onClick={() => tree.toggleExpanded(node.value)}>
        {hasChildren ? (
          expanded ? <FolderOpenIcon size={16} /> : <FolderSimpleIcon size={16} />
        ) : (
          <FileTextIcon size={16} />
        )}
        <span>{node.label}</span>
      </Group>
    </Group>
  );
};

노드를 체크/체크 해제하려면 checkAllNodes와 uncheckAllNodes 함수를 사용해요.

import { FileTextIcon, FolderOpenIcon, FolderSimpleIcon } from '@phosphor-icons/react';
import {
  Button,
  Checkbox,
  getTreeExpandedState,
  Group,
  RenderTreeNodePayload,
  Tree,
  useTree,
} from '@mantine/core';
import { data } from './data';

function Demo() {
  const tree = useTree({
    initialExpandedState: getTreeExpandedState(data, '*'),
    initialCheckedState: [
      'node_modules',
      'node_modules/@mantine/core/index.d.ts',
      'node_modules/@mantine/form/package.json',
    ],
  });

  return (
    <>
      <Button onClick={() => tree.checkAllNodes()}>Check all</Button>
      <Button onClick={() => tree.uncheckAllNodes()}>Uncheck all</Button>
    </>
  );
}

엄격한 체크 (Check strictly)

기본적으로 부모 노드를 체크하면 그 모든 자식도 체크돼요(체크 해제도 마찬가지). useTree에 checkStrictly: true를 설정하면 각 노드의 체크 상태가 완전히 독립적이 돼요. 부모를 체크해도 자식에 영향을 주지 않고 그 반대도 마찬가지예요. 이 모드에서는 isNodeIndeterminate가 항상 false를 반환해요.

import { FileTextIcon, FolderOpenIcon, FolderSimpleIcon } from '@phosphor-icons/react';
import { Checkbox, Group, RenderTreeNodePayload, Tree, useTree } from '@mantine/core';
import { data } from './data';

function Demo() {
  const tree = useTree({ checkStrictly: true });
  return (
    <Tree
      data={data}
      tree={tree}
      renderNode={(payload) => {
        const { node } = payload;
        const checked = tree.isNodeChecked(node.value);
        return (
          <Group gap={5} {...payload.elementProps}>
            <Checkbox.Indicator
              checked={checked}
              onClick={() => (checked ? tree.uncheckNode(node.value) : tree.checkNode(node.value))}
            />
            <span>{node.label}</span>
          </Group>
        );
      }}
    />
  );
}

초기 확장 상태 (Initial expanded state)

확장 상태는 node.value와 노드 확장 상태를 나타내는 불리언 값의 객체예요. 초기 확장 상태를 바꾸려면 useTree 훅에 initialExpandedState를 전달해요. 데이터에서 확장 상태를 생성하려면 getTreeExpandedState 함수를 사용할 수 있어요. 이 함수는 데이터와 확장할 노드 값의 배열을 받아 확장 상태 객체를 반환해요.

getTreeExpandedState의 두 번째 인자로 '*'를 전달하면 모든 노드가 확장돼요.

import { getTreeExpandedState } from '@mantine/core';

// 주어진 두 노드 확장
getTreeExpandedState(data, ['src', 'src/components']);

// 모든 노드 확장
getTreeExpandedState(data, '*');
import { FileTextIcon, FolderOpenIcon, FolderSimpleIcon } from '@phosphor-icons/react';
import { getTreeExpandedState, Group, RenderTreeNodePayload, Tree, useTree } from '@mantine/core';
import { data } from './data';

function Demo() {
  const tree = useTree({
    initialExpandedState: getTreeExpandedState(data, ['src', 'src/components']),
  });

  return <Tree data={data} tree={tree} renderNode={(payload) => <Leaf {...payload} />} />;
}

비동기 로딩 (Async loading)

Tree는 자식의 지연 로딩(lazy loading)을 지원해요. children을 제공하지 않고 노드에 hasChildren: true를 설정하면, 노드가 처음 확장될 때 useTree에 전달된 onLoadChildren 콜백이 호출돼요. mergeAsyncChildren 유틸리티를 사용해 로드된 자식을 데이터에 끼워 넣어요.

import { mergeAsyncChildren, Tree, TreeNodeData, useTree } from '@mantine/core';

function Demo() {
  const [data, setData] = useState<TreeNodeData[]>([
    { label: 'Documents', value: 'documents', hasChildren: true },
  ]);

  const tree = useTree({
    onLoadChildren: async (value) => {
      const children = await fetchChildren(value);
      setData((prev) => mergeAsyncChildren(prev, value, children));
    },
  });

  return <Tree data={data} tree={tree} />;
}

renderNode 페이로드에는 로딩 인디케이터나 오류 메시지를 표시하는 데 사용할 수 있는 isLoading과 loadError 필드가 있어요. tree.invalidateNode(value)를 사용해 노드의 캐시를 지우고 다음 확장 시 다시 가져오게 해요.

import { useState } from 'react';
import { FileTextIcon, FolderOpenIcon, FolderSimpleIcon } from '@phosphor-icons/react';
import {
  Group,
  mergeAsyncChildren,
  RenderTreeNodePayload,
  Tree,
  TreeNodeData,
  useTree,
} from '@mantine/core';

const initialData: TreeNodeData[] = [
  { label: 'Documents', value: 'documents', hasChildren: true },
  { label: 'Photos', value: 'photos', hasChildren: true },
  { label: 'README.md', value: 'readme' },
];

// API 호출을 시뮬레이션해 자식을 로드해요
async function fetchChildren(parentValue: string): Promise<TreeNodeData[]> {
  await new Promise((resolve) => setTimeout(resolve, 1000));
  return [
    { label: `${parentValue}/file-1.txt`, value: `${parentValue}/file-1.txt` },
    { label: `${parentValue}/file-2.txt`, value: `${parentValue}/file-2.txt` },
    {
      label: `${parentValue}/subfolder`,
      value: `${parentValue}/subfolder`,
      hasChildren: true,
    },
  ];
}

function Demo() {
  const [data, setData] = useState(initialData);
  const tree = useTree({
    onLoadChildren: async (value) => {
      const children = await fetchChildren(value);
      setData((prev) => mergeAsyncChildren(prev, value, children));
    },
  });

  return <Tree data={data} tree={tree} renderNode={(payload) => <Leaf {...payload} />} />;
}

검색과 필터 (Search and filter)

Tree에는 내장 검색 컨트롤이 포함되지 않아요. 검색 입력과 필터링 로직은 항상 외부에 있어요. filterTreeData 유틸리티를 사용해 검색어에 기반해 트리 데이터를 필터링할 수 있어요. 이 함수는 트리 데이터, 쿼리 문자열, 선택적인 커스텀 필터 함수를 받아요.

import { filterTreeData } from '@mantine/core';

// 기본 대소문자 구분 없는 라벨 매칭으로 필터
const filtered = filterTreeData(data, 'button');

// 커스텀 함수로 필터
const filtered = filterTreeData(data, 'btn', (query, node) =>
  node.value.includes(query)
);

기본 필터는 쿼리를 node.label(문자열일 때) 또는 대체로 node.value와 비교해요. 매칭되는 노드와 그 조상들은 결과에 유지돼요. 더 고급 매칭(예: fuse.js로 퍼지 검색)을 위해 커스텀 TreeNodeFilter 함수를 제공할 수 있어요.

매칭 노드 강조 (Highlight matching nodes)

이 예시에서는 모든 노드가 보이고, 매칭되는 텍스트가 renderNode 안의 Highlight 컴포넌트로 강조돼요. 매칭 노드의 조상 노드는 자동으로 확장돼요.

import { useState } from 'react';
import { FileTextIcon, FolderOpenIcon, FolderSimpleIcon } from '@phosphor-icons/react';
import {
  defaultTreeNodeFilter,
  getTreeExpandedState,
  Group,
  Highlight,
  TextInput,
  Tree,
  TreeNodeData,
  useTree,
} from '@mantine/core';

const data: TreeNodeData[] = [
  {
    label: 'src',
    value: 'src',
    children: [
      {
        label: 'components',
        value: 'src/components',
        children: [
          { label: 'Accordion.tsx', value: 'src/components/Accordion.tsx' },
          { label: 'Tree.tsx', value: 'src/components/Tree.tsx' },
          { label: 'Button.tsx', value: 'src/components/Button.tsx' },
        ],
      },
    ],
  },
  { label: 'package.json', value: 'package.json' },
];

function getMatchingAncestors(nodes: TreeNodeData[], query: string): string[] {
  const result: string[] = [];
  for (const node of nodes) {
    const childMatches = node.children ? getMatchingAncestors(node.children, query) : [];
    if (defaultTreeNodeFilter(query, node) || childMatches.length > 0) {
      result.push(node.value, ...childMatches);
    }
  }
  return result;
}

function Demo() {
  const [search, setSearch] = useState('');
  const tree = useTree({ initialExpandedState: getTreeExpandedState(data, []) });

  const handleSearchChange = (value: string) => {
    setSearch(value);
    if (value.trim()) {
      tree.setExpandedState(getTreeExpandedState(data, getMatchingAncestors(data, value)));
    } else {
      tree.collapseAllNodes();
    }
  };

  return (
    <>
      <TextInput value={search} onChange={(event) => handleSearchChange(event.currentTarget.value)} />
      <Tree
        data={data}
        tree={tree}
        renderNode={(payload) => {
          const label = typeof payload.node.label === 'string' ? payload.node.label : payload.node.value;
          return (
            <Group gap={5} {...payload.elementProps}>
              <span><Highlight highlight={search}>{label}</Highlight></span>
            </Group>
          );
        }}
      />
    </>
  );
}

일치하지 않는 노드 필터링 (Filter non-matching nodes)

이 예시에서는 filterTreeData로 일치하지 않는 브랜치를 트리에서 제거해요. 필터링된 트리는 getTreeExpandedState(filteredData, '*')로 자동 확장돼요.

import { useMemo, useState } from 'react';
import { FileTextIcon, FolderOpenIcon, FolderSimpleIcon } from '@phosphor-icons/react';
import {
  filterTreeData,
  getTreeExpandedState,
  Group,
  RenderTreeNodePayload,
  TextInput,
  Tree,
  TreeNodeData,
  useTree,
} from '@mantine/core';

function Demo() {
  const [search, setSearch] = useState('');
  const tree = useTree();

  const filteredData = useMemo(() => filterTreeData(data, search), [search]);

  const handleSearchChange = (value: string) => {
    setSearch(value);
    if (value.trim()) {
      const next = filterTreeData(data, value);
      tree.setExpandedState(getTreeExpandedState(next, '*'));
    } else {
      tree.collapseAllNodes();
    }
  };

  return (
    <>
      <TextInput value={search} onChange={(event) => handleSearchChange(event.currentTarget.value)} />
      <Tree data={filteredData} tree={tree} renderNode={(payload) => <Leaf {...payload} />} />
    </>
  );
}

fuse.js로 퍼지 검색 (Fuzzy search with fuse.js)

퍼지 매칭을 위해 filterTreeData에 커스텀 필터 함수를 전달할 수 있어요. 이 예시는 fuse.js를 사용해요.

import { useMemo, useState } from 'react';
import { FileTextIcon, FolderOpenIcon, FolderSimpleIcon } from '@phosphor-icons/react';
import Fuse from 'fuse.js';
import {
  filterTreeData,
  getTreeExpandedState,
  Group,
  RenderTreeNodePayload,
  TextInput,
  Tree,
  TreeNodeData,
  TreeNodeFilter,
  useTree,
} from '@mantine/core';

function flattenTreeData(nodes: TreeNodeData[]): TreeNodeData[] {
  return nodes.reduce((acc, node) => {
    acc.push(node);
    if (node.children) {
      acc.push(...flattenTreeData(node.children));
    }
    return acc;
  }, [] as TreeNodeData[]);
}

function createFuzzyFilter(nodes: TreeNodeData[]): TreeNodeFilter {
  const flatNodes = flattenTreeData(nodes);
  const fuse = new Fuse(flatNodes, { keys: ['label'], threshold: 0.3 });

  return (query, node) => {
    const results = fuse.search(query);
    return results.some((result) => result.item.value === node.value);
  };
}

function Demo() {
  const [search, setSearch] = useState('');
  const tree = useTree();
  const fuzzyFilter = useMemo(() => createFuzzyFilter(data), []);

  const filteredData = useMemo(() => filterTreeData(data, search, fuzzyFilter), [search, fuzzyFilter]);

  const handleSearchChange = (value: string) => {
    setSearch(value);
    if (value.trim()) {
      const next = filterTreeData(data, value, fuzzyFilter);
      tree.setExpandedState(getTreeExpandedState(next, '*'));
    } else {
      tree.collapseAllNodes();
    }
  };

  return (
    <>
      <TextInput value={search} onChange={(event) => handleSearchChange(event.currentTarget.value)} />
      <Tree data={filteredData} tree={tree} renderNode={(payload) => <Leaf {...payload} />} />
    </>
  );
}

드래그 앤 드롭 (Drag and drop)

Tree 컴포넌트는 노드의 드래그 앤 드롭 재정렬을 지원해요. 활성화하려면 onDragDrop 콜백을 제공해요. 콜백은 draggedNode(드래그된 노드의 값), targetNode(드롭된 노드의 값), position('before', 'after', 'inside') 속성을 가진 객체를 받아요.

드래그 앤 드롭 결과에 기반해 데이터를 갱신하려면 moveTreeNode 유틸리티 함수를 사용해요.

import { moveTreeNode, Tree, TreeNodeData } from '@mantine/core';

function Demo() {
  const [data, setData] = useState(initialData);

  return (
    <Tree
      data={data}
      onDragDrop={(payload) =>
        setData((current) => moveTreeNode(current, payload))
      }
    />
  );
}

노드 위로 드래그할 때 드롭 위치는 커서 위치에 따라 결정돼요.

  • 상단 영역 – 대상 노드 앞에 드롭(위에 선으로 표시)
  • 중간 영역 – 대상 노드 안에 자식으로 드롭(배경 강조로 표시, 자식이 있는 노드에만)
  • 하단 영역 – 대상 노드 뒤에 드롭(아래에 선으로 표시)

노드는 자신의 하위 노드(descendants) 위에 드롭할 수 없어요.

드롭 대상 제한 (Restricting drop targets)

allowDrop prop을 사용해 특정 드롭을 금지할 수 있어요. 콜백은 onDragDrop과 같은 페이로드(draggedNode, targetNode, position)를 받고, 드롭을 거부하려면 false를 반환해야 해요. false를 반환하면 드롭 인디케이터가 숨겨지고 브라우저가 "not-allowed" 커서를 표시하므로, 사용자가 마우스를 놓기 전에 시각적 피드백을 받아요.

import { useState } from 'react';
import { FileTextIcon, FolderOpenIcon, FolderSimpleIcon } from '@phosphor-icons/react';
import { Group, moveTreeNode, RenderTreeNodePayload, Tree, TreeNodeData } from '@mantine/core';

const data: TreeNodeData[] = [
  {
    label: 'Pages',
    value: 'pages',
    children: [
      { label: 'index.tsx', value: 'pages/index.tsx' },
      { label: 'about.tsx', value: 'pages/about.tsx' },
    ],
  },
  {
    label: 'Components (locked)',
    value: 'components',
    children: [
      { label: 'Header.tsx', value: 'components/Header.tsx' },
      { label: 'Footer.tsx', value: 'components/Footer.tsx' },
    ],
  },
  { label: 'package.json', value: 'package.json' },
];

const isLocked = (value: string) => value === 'components' || value.startsWith('components/');

function Demo() {
  const [treeData, setTreeData] = useState(data);

  return (
    <Tree
      data={treeData}
      allowDrop={({ draggedNode, targetNode, position }) => {
        if (isLocked(draggedNode)) return false;
        if (targetNode === 'components' && position === 'inside') return false;
        return !targetNode.startsWith('components/');
      }}
      onDragDrop={(payload) => setTreeData((current) => moveTreeNode(current, payload))}
      renderNode={(payload) => <Leaf {...payload} />}
    />
  );
}

드래그 핸들 (Drag handle)

기본적으로 노드의 어디에서든 드래그를 시작할 수 있어요. Tree에 withDragHandle을 설정하면 renderNode 페이로드의 dragHandleProps를 펼친 요소에서만 드래그를 시작하도록 제한할 수 있어요. 노드에 (입력, 버튼 같은) 상호작용 컨트롤이 있어 드래그를 방해할 때 유용해요.

import { useState } from 'react';
import {
  DotsSixVerticalIcon,
  FileTextIcon,
  FolderOpenIcon,
  FolderSimpleIcon,
} from '@phosphor-icons/react';
import { Group, moveTreeNode, RenderTreeNodePayload, Tree, TreeNodeData } from '@mantine/core';
import classes from './Demo.module.css';

function Leaf({ node, expanded, hasChildren, elementProps, dragHandleProps }: RenderTreeNodePayload) {
  return (
    <Group gap={5} {...elementProps}>
      <DotsSixVerticalIcon {...dragHandleProps} className={classes.dragHandle} />
      {hasChildren ? (
        expanded ? <FolderOpenIcon size={16} /> : <FolderSimpleIcon size={16} />
      ) : (
        <FileTextIcon size={16} />
      )}
      <span>{node.label}</span>
    </Group>
  );
}

function Demo() {
  const [treeData, setTreeData] = useState(data);

  return (
    <Tree
      data={treeData}
      withDragHandle
      onDragDrop={(payload) => setTreeData((current) => moveTreeNode(current, payload))}
      renderNode={(payload) => <Leaf {...payload} />}
    />
  );
}

연결선 (Connecting lines)

withLines prop을 설정하면 부모-자식 관계를 보여주는 연결선을 표시해요. 선은 levelOffset 간격에 자동으로 맞춰져요.

import { FileTextIcon, FolderOpenIcon, FolderSimpleIcon } from '@phosphor-icons/react';
import { getTreeExpandedState, Group, RenderTreeNodePayload, Tree, useTree } from '@mantine/core';
import { data } from './data';

function Demo() {
  const tree = useTree({ initialExpandedState: getTreeExpandedState(data, '*') });

  return <Tree data={data} tree={tree} withLines renderNode={(payload) => <Leaf {...payload} />} />;
}

가상화 (Virtualization)

Tree는 어떤 가상화 라이브러리에도 의존하지 않아요. 직접 하나를 제공하면 돼요. flattenTreeData 유틸리티를 사용해 현재 확장 상태에 기반해 계층적 데이터를 보이는 노드의 평면 목록으로 변환한 다음, Tree의 스타일, aria 속성, 클릭/키보드 핸들러, renderNode 지원을 제공하는 FlatTreeNode로 각 노드를 렌더링해요.

import { FlatTreeNode, flattenTreeData, useTree } from '@mantine/core';

const tree = useTree();
const flatList = flattenTreeData(data, tree.expandedState);
// flatList는 FlattenedTreeNodeData[] – 각 항목을 FlatTreeNode에 펼쳐 넣어요

FlatTreeNode는 Tree와 같은 동작 props(expandOnClick, selectOnClick, expandOnSpace, checkOnSpace, renderNode)와 가상화 배치용 style prop을 받아요. 컨테이너 요소에는 키보드 내비게이션이 동작하도록 data-tree-root와 role="tree" 속성이 있어야 해요.

import { useMemo, useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { FlatTreeNode, flattenTreeData, getTreeExpandedState, TreeNodeData, useTree } from '@mantine/core';

const ITEM_HEIGHT = 30;

const largeData = generateTreeData(2000);
const initialExpandedState = getTreeExpandedState(largeData, '*');

function Demo() {
  const tree = useTree({ initialExpandedState });

  const flatList = useMemo(
    () => flattenTreeData(largeData, tree.expandedState),
    [tree.expandedState]
  );

  const scrollParentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: flatList.length,
    getScrollElement: () => scrollParentRef.current,
    estimateSize: () => ITEM_HEIGHT,
    overscan: 20,
  });

  return (
    <div ref={scrollParentRef} style={{ height: 400, overflow: 'auto' }}>
      <div
        data-tree-root
        role="tree"
        style={{ height: virtualizer.getTotalSize(), position: 'relative' }}
      >
        {virtualizer.getVirtualItems().map((virtualItem) => (
          <FlatTreeNode
            key={virtualItem.key}
            data={flatList[virtualItem.index]}
            tree={tree}
            style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: ITEM_HEIGHT, transform: `translateY(${virtualItem.start}px)` }}
          />
        ))}
      </div>
    </div>
  );
}

예시: 파일 트리 (Example: files tree)

파일 아이콘과 폴더 구조를 보여주는 파일 트리 예시예요.

import { FolderSimpleIcon, FolderOpenIcon } from '@phosphor-icons/react';
import cx from 'clsx';
import { Group, RenderTreeNodePayload, Tree } from '@mantine/core';
import { CssIcon, NpmIcon, TypeScriptCircleIcon } from '@mantinex/dev-icons';
import { data, dataCode } from './data';
import classes from './Demo.module.css';

interface FileIconProps {
  name: string;
  isFolder: boolean;
  expanded: boolean;
}

function FileIcon({ name, isFolder, expanded }: FileIconProps) {
  if (name.endsWith('package.json')) {
    return <NpmIcon size={16} />;
  }
  if (name.endsWith('.ts') || name.endsWith('.tsx') || name.endsWith('tsconfig.json')) {
    return <TypeScriptCircleIcon size={16} />;
  }
  if (name.endsWith('.css')) {
    return <CssIcon size={16} />;
  }
  if (isFolder) {
    return expanded ? <FolderOpenIcon size={16} /> : <FolderSimpleIcon size={16} />;
  }
  return null;
}

function Leaf({ node, expanded, hasChildren, elementProps }: RenderTreeNodePayload) {
  return (
    <Group gap={5} {...elementProps}>
      <FileIcon name={node.value} isFolder={hasChildren} expanded={expanded} />
      <span>{node.label}</span>
    </Group>
  );
}

function Demo() {
  return <Tree data={data} renderNode={(payload) => <Leaf {...payload} />} />;
}

예시: 문서 내비게이션 편집기 (Example: docs navigation editor)

아래 예시는 드래그 앤 드롭, Highlight로 검색, 단일 선택, 호버 시 드러나는 액션 메뉴, 폴더 페이지 개수 배지, withLines을 하나의 문서 내비게이션 편집기로 결합해요. renderNode 콜백이 컴포넌트가 노출하는 모든 페이로드 필드를 받으므로, 대부분의 애플리케이션 레벨 UX를 이 프리미티브들로 조립할 수 있어요.

import { useMemo, useState } from 'react';
import {
  DotsThreeIcon,
  FileTextIcon,
  FolderOpenIcon,
  FolderSimpleIcon,
  MagnifyingGlassIcon,
  PencilSimpleIcon,
  TrashIcon,
} from '@phosphor-icons/react';
import cx from 'clsx';
import {
  ActionIcon,
  Badge,
  filterTreeData,
  getTreeExpandedState,
  Group,
  Highlight,
  Menu,
  moveTreeNode,
  RenderTreeNodePayload,
  TextInput,
  Tree,
  TreeNodeData,
  useTree,
} from '@mantine/core';
import { data } from './data';
import classes from './Demo.module.css';

function countPages(node: TreeNodeData): number {
  if (!node.children) return 1;
  return node.children.reduce((sum, child) => sum + countPages(child), 0);
}

function Leaf({ node, expanded, hasChildren, elementProps, search, tree }: LeafProps) {
  const label = typeof node.label === 'string' ? node.label : node.value;
  return (
    <Group gap={5} {...elementProps}>
      {hasChildren ? (
        expanded ? <FolderOpenIcon size={16} /> : <FolderSimpleIcon size={16} />
      ) : (
        <FileTextIcon size={16} />
      )}
      <span><Highlight highlight={search}>{label}</Highlight></span>
      {hasChildren && <Badge size="xs">{countPages(node)}</Badge>}
      <Menu trigger="hover" position="right">
        <Menu.Target>
          <ActionIcon size="sm" variant="subtle" onClick={(event) => event.stopPropagation()} draggable={false} aria-label={`Actions for ${label}`}>
            <DotsThreeIcon size={16} />
          </ActionIcon>
        </Menu.Target>
        <Menu.Dropdown>
          <Menu.Item leftSection={<PencilSimpleIcon size={14} />}>Rename</Menu.Item>
          <Menu.Item color="red" leftSection={<TrashIcon size={14} />}>Delete</Menu.Item>
        </Menu.Dropdown>
      </Menu>
    </Group>
  );
}

function Demo() {
  const [treeData, setTreeData] = useState(data);
  const [search, setSearch] = useState('');
  const tree = useTree({
    initialSelectedState: ['components/inputs/button'],
    initialExpandedState: getTreeExpandedState(data, ['components', 'components/inputs']),
  });

  const filteredData = useMemo(
    () => (search.trim() ? filterTreeData(treeData, search) : treeData),
    [treeData, search]
  );

  const handleSearchChange = (value: string) => {
    setSearch(value);
    if (value.trim()) {
      tree.setExpandedState(getTreeExpandedState(filterTreeData(treeData, value), '*'));
    }
  };

  return (
    <>
      <TextInput placeholder="Search docs..." mb="sm" value={search} onChange={(event) => handleSearchChange(event.currentTarget.value)} leftSection={<MagnifyingGlassIcon size={16} />} />
      <Tree
        data={filteredData}
        tree={tree}
        expandOnClick={false}
        onDragDrop={(payload) => setTreeData((current) => moveTreeNode(current, payload))}
        renderNode={(payload) => <Leaf {...payload} search={search} />}
      />
    </>
  );
}

더 알아보기 (Learn more)