Tree

Navigation

드래그 정렬과 가상화를 지원하는 트리.

Usage

계층 항목을 펼치고 접으며 드래그로 순서를 바꾸거나 큰 목록을 탐색할 때 사용한다.

import

import

import { Tree } from '@mildang/design-system/TreeListItem';

예제를 복사해 쓸 때 필요한 준비

@mildang/icons 를 따로 설치한다. DS 패키지에 아이콘 컴포넌트가 포함되지 않는다.

@mildang/styled-system 은 이 저장소에서 Panda 가 생성하는 산출물이다. 저장소 안에서는 turbo run ship 이후 쓸 수 있고, 패키지 소비자는 자기 Panda 산출물이나 다른 레이아웃 수단으로 바꿔야 한다.

API Reference

Tree Props

Prop

Type

Default

items
필수

TreeItems<T>

지정 안 함

renderItem
필수

(props: RenderItemProps<T>) => ReactNode

지정 안 함

canDrag

(item: FlattenedItem<T>) => boolean

지정 안 함

canDrop

(args: CanDropArgs<T>) => boolean

지정 안 함

depthSize

"default" | "compact"

지정 안 함

disableDepthChange

boolean

false

dragAccessibility

TreeDragAccessibility<T>

지정 안 함

expandByClick

boolean

지정 안 함

hideExpand

boolean

지정 안 함

onChange

(items: TreeItems<T>) => void

지정 안 함

onDragCancel

(event: { operation: DragOperationSnapshot<Draggable<Data>, Droppable<Data>>; nativeEvent?: Event; canceled: boolean; suspend(): { resume(): void; abort(): void; }; }, manager: DragDropManager<Data, Draggable<Data>, Droppable<Data>>) => void

지정 안 함

onVirtualScroll

(offset: number) => void

지정 안 함

scrollToId

UniqueIdentifier

지정 안 함

showDepthPipe

boolean

지정 안 함

sortable

boolean

false

virtualized

string | number

지정 안 함

virtualizedRef

MutableRefObject<VListHandle>

지정 안 함

StandardTreeListItem

Prop

Type

Default

actions

ReactNode

지정 안 함

active

boolean

지정 안 함

children

ReactNode

지정 안 함

color

"success" | "info" | "error"

success

count

number

지정 안 함

disabled

boolean

지정 안 함

endAdornment

ReactNode

지정 안 함

hoverActions

ReactNode

지정 안 함

icon

ReactNode

지정 안 함

indeterminate

boolean

지정 안 함

onSelectedChange

(selected: boolean) => void

지정 안 함

selectable

boolean

false

selected

boolean

지정 안 함

showCheckOnHover

boolean

false

size

"sm" | "md"

md

startAdornment

ReactNode

지정 안 함

title

ReactNode

지정 안 함

TreeListItem

Prop

Type

Default

children
필수

React.ReactNode

지정 안 함

active

boolean

지정 안 함

color

"success" | "info" | "error"

success

disabled

boolean

지정 안 함

indeterminate

boolean

지정 안 함

onSelectedChange

(selected: boolean) => void

지정 안 함

selected

boolean

지정 안 함

size

"sm" | "md"

md

renderItem 에 무엇을 반환할까

Tree 는 renderItem 이 반환하는 내용에 관여하지 않는다 — Tree.tsxStandardTreeListItem 을 전혀 import 하지 않고, renderItem 이 돌려준 TreeListItem.Root 앞에 DragHandle·Depth·Expansion 만 주입한다. 행 내용을 고르는 방법은 두 가지다.

  • TreeListItem 조각을 직접 조합Root 아래 Icon·TitleArea·ActionArea 등을 원하는 대로 배치한다. 조합 자유도가 가장 높다.
  • StandardTreeListItem 사용 — 위 조합을 미리 만들어 둔 편의 컴포넌트다. icon·title·count· hoverActions·actions 같은 prop만 채우면 되고, Tree 의 renderItem 안에서 가장 자주 쓰는 조합이다.

둘 다 Tree 없이도 완결되게 렌더되므로(각자 자기 스타일 컨텍스트를 만든다) 상세 페이지도 따로 두지만, 실제로는 Tree 의 renderItem 자리를 채우는 같은 계열의 부품이다.

Tree 는 renderItem 이 돌려준 콘텐츠 앞에 다음을 이 순서로 자동 주입한다.

  1. DragHandle — sortable 일 때만
  2. DepthItem(또는 showDepthPipe 면 DepthPipe) — item.depth 개수만큼
  3. ExpansionItem(자식이 있거나 hasChildren: true) 또는 빈 DepthItem

expandByClick 이 true 면 행 클릭으로 펼치기/접기가 토글되지만, 다음 선택자에 걸리는 대상을 클릭한 경우는 제외한다 — button, input, a, [role="button"], [role="checkbox"], [data-tree-expand-ignore="true"]. DragHandle·Checkbox 등은 이미 이 선택자에 해당해 자동으로 빠진다.

lazy-loading 트리에서는 children 배열이 비어 있어도 나중에 채워질 예정이면 hasChildren: true, 하위 데이터를 불러오는 중이면 isChildrenLoading: true 를 준다 — 이때 caret 대신 spinner 가 표시되고 토글이 막힌다.

기본 사용

Tree 의 renderItem 안에서 TreeListItem 으로 각 행을 그립니다 — 단원·차시처럼 계층이 있는 목록에 씁니다.

import { TreeItems, TreeItem } from '@mildang/design-system/TreeListItem';
import { useState } from 'react';
import { useTreeSelection } from '@mildang/design-system/TreeListItem';
import { Tree } from '@mildang/design-system/TreeListItem';
import { TreeListItem } from '@mildang/design-system/TreeListItem';

interface ItemData {
  title: string;
  type?: 'unit' | 'activity' | 'step';
}

const initialItems: TreeItems<ItemData> = [
  {
    id: 'unit-1',
    data: { title: 'Unit 1', type: 'unit' },
    collapsed: false,
    children: [
      {
        id: 'activity-1',
        data: { title: 'Activity 1', type: 'activity' },
        collapsed: false,
        children: [
          { id: 'step-1', data: { title: 'Step 1', type: 'step' }, children: [] },
          { id: 'step-2', data: { title: 'Step 2', type: 'step' }, children: [] },
        ],
      },
      { id: 'activity-2', data: { title: 'Activity 2', type: 'activity' }, children: [] },
    ],
  },
  {
    id: 'unit-2',
    data: { title: 'Unit 2', type: 'unit' },
    collapsed: true,
    children: [
      {
        id: 'activity-3',
        data: { title: 'Activity 3', type: 'activity' },
        children: [{ id: 'step-3', data: { title: 'Step 3', type: 'step' }, children: [] }],
      },
      { id: 'activity-4', data: { title: 'Activity 4', type: 'activity' }, children: [] },
    ],
  },
];

/**
 * Selection Mode 비교용 데모. `mode` 는 useTreeSelection 옵션.
 * leaf-only(폴더 선택 제외) 같은 제약은 훅 옵션이 아니라 **사용처 가드**로 처리한다 —
 * 여기선 `canSelect` predicate 로 "어떤 노드를 선택 가능하게 둘지"를 소비자가 정하는 예시.
 */
function SelectionModeDemo({
  title,
  description,
  mode,
  canSelect,
}: {
  title: string;
  description: string;
  mode: 'single' | 'multiple';
  /** 사용처 가드 예시(훅 API 아님) — 선택 가능한 노드를 소비자가 결정. 미지정 시 전부 선택 가능. */
  canSelect?: (item: TreeItem<ItemData>) => boolean;
}) {
  const [items, setItems] = useState<TreeItems<ItemData>>(initialItems);
  const { selectedIds, getSelectionState, handleSelectedChange } = useTreeSelection({ items, mode });

  return (
    <div style={{ width: 280 }}>
      <strong style={{ fontSize: '13px' }}>{title}</strong>
      <p style={{ fontSize: '11px', color: '#666', margin: '4px 0 8px' }}>{description}</p>
      <Tree
        items={items}
        onChange={setItems}
        renderItem={({ item }) => {
          const selectable = canSelect ? canSelect(item) : true;
          const { checked, indeterminate } = getSelectionState(item);

          return (
            <TreeListItem.Root
              selected={checked}
              indeterminate={indeterminate}
              onSelectedChange={(isChecked) => {
                if (!selectable) return; // 선택 제외 노드는 사용처가 결정
                handleSelectedChange(item, isChecked);
              }}
            >
              {selectable && <TreeListItem.Checkbox />}
              <TreeListItem.TitleArea title={item.data?.title ?? String(item.id)} />
            </TreeListItem.Root>
          );
        }}
      />
      <pre
        style={{
          marginTop: '8px',
          padding: '8px',
          background: '#f5f5f5',
          borderRadius: '4px',
          fontSize: '11px',
        }}
      >
        selected: {JSON.stringify(Array.from(selectedIds))}
      </pre>
    </div>
  );
}

const TreeSelectionModesExample = () => (
    <div style={{ display: 'flex', gap: '24px', alignItems: 'flex-start' }}>
      <SelectionModeDemo
        title="multiple (cascade)"
        description="부모 선택 시 하위 전부 선택, 부분 선택은 indeterminate"
        mode="multiple"
      />
      <SelectionModeDemo title="single" description="폴더·리프 아무 노드나 1개만 선택" mode="single" />
      <SelectionModeDemo
        title="single + leaf-only"
        description="리프(자식 없는 노드)만 선택 · 폴더엔 체크박스 없음 (사용처 가드)"
        mode="single"
        canSelect={(item) => (item.children?.length ?? 0) === 0}
      />
    </div>
  );

export default TreeSelectionModesExample;

드래그 앤 드롭 동작

onChange 를 넘겨야 실제로 순서가 바뀐다 — 드래그가 취소되지 않았고 canDrop 이 허용했고 원위치가 아니면 onChange(updatedTree) 가 호출된다. onChange 없이 onDragEnd 만 넘기면 데이터 갱신은 직접 구현해야 한다.

  • canDrag(item) 이 false 를 반환하면 그 아이템의 DragHandle 이 숨김(hide) 처리된다 — 자리는 남지만 드래그는 시작되지 않는다.
  • canDrop(args) 이 false 를 반환하면 드롭이 무시되고 원위치로 되돌아간다.
  • disableDepthChange 가 true 면 좌우 이동·키보드 방향키로도 depth 가 바뀌지 않는다 — 순서만 바뀌는 Flat List 에 쓴다.
canDrop 인자의미
activeItem드래그 중인 아이템
overItem현재 hover 중인 아이템
projected.depth / projected.parentId놓였을 때 적용될 depth·부모 id
items평탄화된 전체 아이템 목록

depth 변경은 마우스 드래그의 가로 이동량, 또는 키보드 좌우 방향키로 계산되며 원래 depth 근처로 돌아오면 스냅되는 구간이 있다.

드래그 중 시각 피드백은 포인터 드래그와 키보드 드래그가 다르다.

  • 포인터 드래그 — DragOverlay 가 드래그 시작 시점 아이템의 모습을 그대로 띄우고, 목록 안 원래 자리에는 옅은 placeholder 가 남아 예상 도착 위치(변경된 depth 포함)를 보여준다.
  • 키보드 드래그 — DragOverlay 자체가 뜨지 않는다. 대신 목록 안 placeholder 이동과 접근성 안내 문구로만 위치를 확인한다.

기본 사용

Tree 의 renderItem 안에서 TreeListItem 으로 각 행을 그립니다 — 단원·차시처럼 계층이 있는 목록에 씁니다.

import { TreeItems, TreeItem } from '@mildang/design-system/TreeListItem';
import { useState } from 'react';
import { useTreeSelection } from '@mildang/design-system/TreeListItem';
import { Tree } from '@mildang/design-system/TreeListItem';
import { TreeListItem } from '@mildang/design-system/TreeListItem';

interface ItemData {
  title: string;
  type?: 'unit' | 'activity' | 'step';
}

const initialItems: TreeItems<ItemData> = [
  {
    id: 'unit-1',
    data: { title: 'Unit 1', type: 'unit' },
    collapsed: false,
    children: [
      {
        id: 'activity-1',
        data: { title: 'Activity 1', type: 'activity' },
        collapsed: false,
        children: [
          { id: 'step-1', data: { title: 'Step 1', type: 'step' }, children: [] },
          { id: 'step-2', data: { title: 'Step 2', type: 'step' }, children: [] },
        ],
      },
      { id: 'activity-2', data: { title: 'Activity 2', type: 'activity' }, children: [] },
    ],
  },
  {
    id: 'unit-2',
    data: { title: 'Unit 2', type: 'unit' },
    collapsed: true,
    children: [
      {
        id: 'activity-3',
        data: { title: 'Activity 3', type: 'activity' },
        children: [{ id: 'step-3', data: { title: 'Step 3', type: 'step' }, children: [] }],
      },
      { id: 'activity-4', data: { title: 'Activity 4', type: 'activity' }, children: [] },
    ],
  },
];

/**
 * Selection Mode 비교용 데모. `mode` 는 useTreeSelection 옵션.
 * leaf-only(폴더 선택 제외) 같은 제약은 훅 옵션이 아니라 **사용처 가드**로 처리한다 —
 * 여기선 `canSelect` predicate 로 "어떤 노드를 선택 가능하게 둘지"를 소비자가 정하는 예시.
 */
function SelectionModeDemo({
  title,
  description,
  mode,
  canSelect,
}: {
  title: string;
  description: string;
  mode: 'single' | 'multiple';
  /** 사용처 가드 예시(훅 API 아님) — 선택 가능한 노드를 소비자가 결정. 미지정 시 전부 선택 가능. */
  canSelect?: (item: TreeItem<ItemData>) => boolean;
}) {
  const [items, setItems] = useState<TreeItems<ItemData>>(initialItems);
  const { selectedIds, getSelectionState, handleSelectedChange } = useTreeSelection({ items, mode });

  return (
    <div style={{ width: 280 }}>
      <strong style={{ fontSize: '13px' }}>{title}</strong>
      <p style={{ fontSize: '11px', color: '#666', margin: '4px 0 8px' }}>{description}</p>
      <Tree
        items={items}
        onChange={setItems}
        renderItem={({ item }) => {
          const selectable = canSelect ? canSelect(item) : true;
          const { checked, indeterminate } = getSelectionState(item);

          return (
            <TreeListItem.Root
              selected={checked}
              indeterminate={indeterminate}
              onSelectedChange={(isChecked) => {
                if (!selectable) return; // 선택 제외 노드는 사용처가 결정
                handleSelectedChange(item, isChecked);
              }}
            >
              {selectable && <TreeListItem.Checkbox />}
              <TreeListItem.TitleArea title={item.data?.title ?? String(item.id)} />
            </TreeListItem.Root>
          );
        }}
      />
      <pre
        style={{
          marginTop: '8px',
          padding: '8px',
          background: '#f5f5f5',
          borderRadius: '4px',
          fontSize: '11px',
        }}
      >
        selected: {JSON.stringify(Array.from(selectedIds))}
      </pre>
    </div>
  );
}

const TreeSelectionModesExample = () => (
    <div style={{ display: 'flex', gap: '24px', alignItems: 'flex-start' }}>
      <SelectionModeDemo
        title="multiple (cascade)"
        description="부모 선택 시 하위 전부 선택, 부분 선택은 indeterminate"
        mode="multiple"
      />
      <SelectionModeDemo title="single" description="폴더·리프 아무 노드나 1개만 선택" mode="single" />
      <SelectionModeDemo
        title="single + leaf-only"
        description="리프(자식 없는 노드)만 선택 · 폴더엔 체크박스 없음 (사용처 가드)"
        mode="single"
        canSelect={(item) => (item.children?.length ?? 0) === 0}
      />
    </div>
  );

export default TreeSelectionModesExample;

드래그 접근성 안내

Tree 는 dnd-kit 의 Accessibility 플러그인을 별도 설정 없이 내부에서 구성한다 — 드래그 핸들에 포커스한 뒤 키보드로 드래그를 시작하면 스크린리더 안내가 자동으로 나온다.

기본 안내 문구는 각 아이템의 data.title 을 우선 읽고, title 이 없으면 id 로 대체한다.

시점기본 문구
드래그 시작"현재 항목은 {title}입니다."
드래그 중 이동"현재 {title} 위로 이동 중입니다."
종료 · 취소됨"{title} 이동이 취소되었습니다."
종료 · 제자리"{title}을(를) 현재 위치에 놓았습니다."
종료 · 이동됨"{title}을(를) {대상 title} 위에 놓았습니다."

dragAccessibility 로 조정 가능한 범위는 disabled, screenReaderInstructions.draggable, announcements.{dragstart,dragover,dragend} 뿐이다. manager 를 직접 주입하면 이 설정은 적용되지 않는다 — Tree 가 내부 plugin 구성을 보장할 수 없기 때문이다.

기본 사용

Tree 의 renderItem 안에서 TreeListItem 으로 각 행을 그립니다 — 단원·차시처럼 계층이 있는 목록에 씁니다.

import { TreeItems, TreeItem } from '@mildang/design-system/TreeListItem';
import { useState } from 'react';
import { useTreeSelection } from '@mildang/design-system/TreeListItem';
import { Tree } from '@mildang/design-system/TreeListItem';
import { TreeListItem } from '@mildang/design-system/TreeListItem';

interface ItemData {
  title: string;
  type?: 'unit' | 'activity' | 'step';
}

const initialItems: TreeItems<ItemData> = [
  {
    id: 'unit-1',
    data: { title: 'Unit 1', type: 'unit' },
    collapsed: false,
    children: [
      {
        id: 'activity-1',
        data: { title: 'Activity 1', type: 'activity' },
        collapsed: false,
        children: [
          { id: 'step-1', data: { title: 'Step 1', type: 'step' }, children: [] },
          { id: 'step-2', data: { title: 'Step 2', type: 'step' }, children: [] },
        ],
      },
      { id: 'activity-2', data: { title: 'Activity 2', type: 'activity' }, children: [] },
    ],
  },
  {
    id: 'unit-2',
    data: { title: 'Unit 2', type: 'unit' },
    collapsed: true,
    children: [
      {
        id: 'activity-3',
        data: { title: 'Activity 3', type: 'activity' },
        children: [{ id: 'step-3', data: { title: 'Step 3', type: 'step' }, children: [] }],
      },
      { id: 'activity-4', data: { title: 'Activity 4', type: 'activity' }, children: [] },
    ],
  },
];

/**
 * Selection Mode 비교용 데모. `mode` 는 useTreeSelection 옵션.
 * leaf-only(폴더 선택 제외) 같은 제약은 훅 옵션이 아니라 **사용처 가드**로 처리한다 —
 * 여기선 `canSelect` predicate 로 "어떤 노드를 선택 가능하게 둘지"를 소비자가 정하는 예시.
 */
function SelectionModeDemo({
  title,
  description,
  mode,
  canSelect,
}: {
  title: string;
  description: string;
  mode: 'single' | 'multiple';
  /** 사용처 가드 예시(훅 API 아님) — 선택 가능한 노드를 소비자가 결정. 미지정 시 전부 선택 가능. */
  canSelect?: (item: TreeItem<ItemData>) => boolean;
}) {
  const [items, setItems] = useState<TreeItems<ItemData>>(initialItems);
  const { selectedIds, getSelectionState, handleSelectedChange } = useTreeSelection({ items, mode });

  return (
    <div style={{ width: 280 }}>
      <strong style={{ fontSize: '13px' }}>{title}</strong>
      <p style={{ fontSize: '11px', color: '#666', margin: '4px 0 8px' }}>{description}</p>
      <Tree
        items={items}
        onChange={setItems}
        renderItem={({ item }) => {
          const selectable = canSelect ? canSelect(item) : true;
          const { checked, indeterminate } = getSelectionState(item);

          return (
            <TreeListItem.Root
              selected={checked}
              indeterminate={indeterminate}
              onSelectedChange={(isChecked) => {
                if (!selectable) return; // 선택 제외 노드는 사용처가 결정
                handleSelectedChange(item, isChecked);
              }}
            >
              {selectable && <TreeListItem.Checkbox />}
              <TreeListItem.TitleArea title={item.data?.title ?? String(item.id)} />
            </TreeListItem.Root>
          );
        }}
      />
      <pre
        style={{
          marginTop: '8px',
          padding: '8px',
          background: '#f5f5f5',
          borderRadius: '4px',
          fontSize: '11px',
        }}
      >
        selected: {JSON.stringify(Array.from(selectedIds))}
      </pre>
    </div>
  );
}

const TreeSelectionModesExample = () => (
    <div style={{ display: 'flex', gap: '24px', alignItems: 'flex-start' }}>
      <SelectionModeDemo
        title="multiple (cascade)"
        description="부모 선택 시 하위 전부 선택, 부분 선택은 indeterminate"
        mode="multiple"
      />
      <SelectionModeDemo title="single" description="폴더·리프 아무 노드나 1개만 선택" mode="single" />
      <SelectionModeDemo
        title="single + leaf-only"
        description="리프(자식 없는 노드)만 선택 · 폴더엔 체크박스 없음 (사용처 가드)"
        mode="single"
        canSelect={(item) => (item.children?.length ?? 0) === 0}
      />
    </div>
  );

export default TreeSelectionModesExample;

useTreeSelection 으로 선택 상태 연결

useTreeSelection 은 Tree 의 selected/indeterminate 를 계산해 주는 훅이다 — 같은 바렐에서 함께 import 한다.

옵션타입설명
itemsTreeItems&lt;T&gt;트리 데이터(필수)
defaultSelectedIdsUniqueIdentifier[]초기 선택 ID
mode'single' | 'multiple'(기본 'multiple')multiple 은 부모/자식 cascade + indeterminate, single 은 cascade 없는 단일 선택
반환값설명
selectedIds선택된 id Set
selectedItems선택된 아이템 객체 배열
getSelectionState(item){ checked, indeterminate } 조회
handleSelectedChange(item, checked)선택 토글(자식/부모 자동 반영)
setSelectedIdsselectedIds 직접 설정

multiple 모드의 선택 로직(bubble up/down):

  • 부모를 선택하면 모든 자식이 함께 선택된다.
  • 부모를 해제하면 모든 자식이 함께 해제된다.
  • 자식 일부만 선택되면 부모는 indeterminate, leaf 기준으로 자식이 전부 선택되면 부모도 checked 로 올라간다.

single 모드는 이 cascade 가 없다 — 새 항목을 선택하면 기존 선택이 자동으로 해제될 뿐이다. leaf-only(폴더 선택 제외)나 라디오식(해제 불가) 같은 제약은 훅이 강제하지 않으므로 onSelectedChange 쪽에서 직접 가드해야 한다.

기본 사용

Tree 의 renderItem 안에서 TreeListItem 으로 각 행을 그립니다 — 단원·차시처럼 계층이 있는 목록에 씁니다.

import { TreeItems, TreeItem } from '@mildang/design-system/TreeListItem';
import { useState } from 'react';
import { useTreeSelection } from '@mildang/design-system/TreeListItem';
import { Tree } from '@mildang/design-system/TreeListItem';
import { TreeListItem } from '@mildang/design-system/TreeListItem';

interface ItemData {
  title: string;
  type?: 'unit' | 'activity' | 'step';
}

const initialItems: TreeItems<ItemData> = [
  {
    id: 'unit-1',
    data: { title: 'Unit 1', type: 'unit' },
    collapsed: false,
    children: [
      {
        id: 'activity-1',
        data: { title: 'Activity 1', type: 'activity' },
        collapsed: false,
        children: [
          { id: 'step-1', data: { title: 'Step 1', type: 'step' }, children: [] },
          { id: 'step-2', data: { title: 'Step 2', type: 'step' }, children: [] },
        ],
      },
      { id: 'activity-2', data: { title: 'Activity 2', type: 'activity' }, children: [] },
    ],
  },
  {
    id: 'unit-2',
    data: { title: 'Unit 2', type: 'unit' },
    collapsed: true,
    children: [
      {
        id: 'activity-3',
        data: { title: 'Activity 3', type: 'activity' },
        children: [{ id: 'step-3', data: { title: 'Step 3', type: 'step' }, children: [] }],
      },
      { id: 'activity-4', data: { title: 'Activity 4', type: 'activity' }, children: [] },
    ],
  },
];

/**
 * Selection Mode 비교용 데모. `mode` 는 useTreeSelection 옵션.
 * leaf-only(폴더 선택 제외) 같은 제약은 훅 옵션이 아니라 **사용처 가드**로 처리한다 —
 * 여기선 `canSelect` predicate 로 "어떤 노드를 선택 가능하게 둘지"를 소비자가 정하는 예시.
 */
function SelectionModeDemo({
  title,
  description,
  mode,
  canSelect,
}: {
  title: string;
  description: string;
  mode: 'single' | 'multiple';
  /** 사용처 가드 예시(훅 API 아님) — 선택 가능한 노드를 소비자가 결정. 미지정 시 전부 선택 가능. */
  canSelect?: (item: TreeItem<ItemData>) => boolean;
}) {
  const [items, setItems] = useState<TreeItems<ItemData>>(initialItems);
  const { selectedIds, getSelectionState, handleSelectedChange } = useTreeSelection({ items, mode });

  return (
    <div style={{ width: 280 }}>
      <strong style={{ fontSize: '13px' }}>{title}</strong>
      <p style={{ fontSize: '11px', color: '#666', margin: '4px 0 8px' }}>{description}</p>
      <Tree
        items={items}
        onChange={setItems}
        renderItem={({ item }) => {
          const selectable = canSelect ? canSelect(item) : true;
          const { checked, indeterminate } = getSelectionState(item);

          return (
            <TreeListItem.Root
              selected={checked}
              indeterminate={indeterminate}
              onSelectedChange={(isChecked) => {
                if (!selectable) return; // 선택 제외 노드는 사용처가 결정
                handleSelectedChange(item, isChecked);
              }}
            >
              {selectable && <TreeListItem.Checkbox />}
              <TreeListItem.TitleArea title={item.data?.title ?? String(item.id)} />
            </TreeListItem.Root>
          );
        }}
      />
      <pre
        style={{
          marginTop: '8px',
          padding: '8px',
          background: '#f5f5f5',
          borderRadius: '4px',
          fontSize: '11px',
        }}
      >
        selected: {JSON.stringify(Array.from(selectedIds))}
      </pre>
    </div>
  );
}

const TreeSelectionModesExample = () => (
    <div style={{ display: 'flex', gap: '24px', alignItems: 'flex-start' }}>
      <SelectionModeDemo
        title="multiple (cascade)"
        description="부모 선택 시 하위 전부 선택, 부분 선택은 indeterminate"
        mode="multiple"
      />
      <SelectionModeDemo title="single" description="폴더·리프 아무 노드나 1개만 선택" mode="single" />
      <SelectionModeDemo
        title="single + leaf-only"
        description="리프(자식 없는 노드)만 선택 · 폴더엔 체크박스 없음 (사용처 가드)"
        mode="single"
        canSelect={(item) => (item.children?.length ?? 0) === 0}
      />
    </div>
  );

export default TreeSelectionModesExample;

예제

Default

import type { ComponentProps } from 'react';

import { useState } from 'react';
import { type TreeItems } from '@mildang/design-system/TreeListItem';
import { Tree } from '@mildang/design-system/TreeListItem';
import { TreeListItem } from '@mildang/design-system/TreeListItem';
import MoreHorizIcon from '@mildang/icons/react/more-horiz';

interface ItemData {
  title: string;
  type?: 'unit' | 'activity' | 'step';
}

const initialItems: TreeItems<ItemData> = [
  {
    id: 'unit-1',
    data: { title: 'Unit 1', type: 'unit' },
    collapsed: false,
    children: [
      {
        id: 'activity-1',
        data: { title: 'Activity 1', type: 'activity' },
        collapsed: false,
        children: [
          { id: 'step-1', data: { title: 'Step 1', type: 'step' }, children: [] },
          { id: 'step-2', data: { title: 'Step 2', type: 'step' }, children: [] },
        ],
      },
      { id: 'activity-2', data: { title: 'Activity 2', type: 'activity' }, children: [] },
    ],
  },
  {
    id: 'unit-2',
    data: { title: 'Unit 2', type: 'unit' },
    collapsed: true,
    children: [
      {
        id: 'activity-3',
        data: { title: 'Activity 3', type: 'activity' },
        children: [{ id: 'step-3', data: { title: 'Step 3', type: 'step' }, children: [] }],
      },
      { id: 'activity-4', data: { title: 'Activity 4', type: 'activity' }, children: [] },
    ],
  },
];

const STORY_DEFAULT_ARGS = { ...({}), ...({}) } as ComponentProps<typeof Tree>;

const TreeDefaultExampleRender = function Render(args: ComponentProps<typeof Tree>) {
    const [items, setItems] = useState<TreeItems<ItemData>>(initialItems);

    return (
      <div style={{ width: '400px' }}>
        <Tree
          items={items}
          onChange={setItems}
          sortable={args.sortable}
          hideExpand={args.hideExpand}
          showDepthPipe={args.showDepthPipe}
          expandByClick={args.expandByClick}
          disableDepthChange={args.disableDepthChange}
          renderItem={({ item }) => (
            <TreeListItem.Root data-testid={item.id}>
              <TreeListItem.TitleArea title={item.data?.title ?? String(item.id)} />
              <TreeListItem.ActionArea>
                <TreeListItem.ShowOnHover>
                  <TreeListItem.IconButton>
                    <MoreHorizIcon />
                  </TreeListItem.IconButton>
                </TreeListItem.ShowOnHover>
              </TreeListItem.ActionArea>
            </TreeListItem.Root>
          )}
        />
      </div>
    );
  };

export default function TreeDefaultExample(props: Partial<ComponentProps<typeof Tree>>) {
  const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Tree>;
  return TreeDefaultExampleRender(mergedProps);
}

FlatList

import type { ComponentProps } from 'react';

import { useState } from 'react';
import { type TreeItems } from '@mildang/design-system/TreeListItem';
import { Tree } from '@mildang/design-system/TreeListItem';
import { TreeListItem } from '@mildang/design-system/TreeListItem';

interface ItemData {
  title: string;
  type?: 'unit' | 'activity' | 'step';
}

const flatListItems: TreeItems<ItemData> = [
  { id: 'item-1', data: { title: 'Item 1' } },
  { id: 'item-2', data: { title: 'Item 2' } },
  { id: 'item-3', data: { title: 'Item 3' } },
  { id: 'item-4', data: { title: 'Item 4' } },
  { id: 'item-5', data: { title: 'Item 5' } },
];

const STORY_DEFAULT_ARGS = { ...({}), ...({
    disableDepthChange: true,
  }) } as ComponentProps<typeof Tree>;

const TreeFlatListExampleRender = function Render(args: ComponentProps<typeof Tree>) {
    const [items, setItems] = useState<TreeItems<ItemData>>(flatListItems);

    return (
      <div style={{ width: '400px' }}>
        <Tree
          items={items}
          onChange={setItems}
          sortable={args.sortable}
          hideExpand={args.hideExpand}
          showDepthPipe={args.showDepthPipe}
          expandByClick={args.expandByClick}
          disableDepthChange={args.disableDepthChange}
          renderItem={({ item }) => (
            <TreeListItem.Root data-testid={item.id}>
              <TreeListItem.TitleArea title={item.data?.title ?? String(item.id)} />
            </TreeListItem.Root>
          )}
        />
      </div>
    );
  };

export default function TreeFlatListExample(props: Partial<ComponentProps<typeof Tree>>) {
  const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Tree>;
  return TreeFlatListExampleRender(mergedProps);
}

NonSortable

import type { ComponentProps } from 'react';

import { useState } from 'react';
import { type TreeItems } from '@mildang/design-system/TreeListItem';
import { Tree } from '@mildang/design-system/TreeListItem';
import { TreeListItem } from '@mildang/design-system/TreeListItem';

interface ItemData {
  title: string;
  type?: 'unit' | 'activity' | 'step';
}

const initialItems: TreeItems<ItemData> = [
  {
    id: 'unit-1',
    data: { title: 'Unit 1', type: 'unit' },
    collapsed: false,
    children: [
      {
        id: 'activity-1',
        data: { title: 'Activity 1', type: 'activity' },
        collapsed: false,
        children: [
          { id: 'step-1', data: { title: 'Step 1', type: 'step' }, children: [] },
          { id: 'step-2', data: { title: 'Step 2', type: 'step' }, children: [] },
        ],
      },
      { id: 'activity-2', data: { title: 'Activity 2', type: 'activity' }, children: [] },
    ],
  },
  {
    id: 'unit-2',
    data: { title: 'Unit 2', type: 'unit' },
    collapsed: true,
    children: [
      {
        id: 'activity-3',
        data: { title: 'Activity 3', type: 'activity' },
        children: [{ id: 'step-3', data: { title: 'Step 3', type: 'step' }, children: [] }],
      },
      { id: 'activity-4', data: { title: 'Activity 4', type: 'activity' }, children: [] },
    ],
  },
];

const STORY_DEFAULT_ARGS = { ...({}), ...({
    sortable: false,
  }) } as ComponentProps<typeof Tree>;

const TreeNonSortableExampleRender = function Render(args: ComponentProps<typeof Tree>) {
    const [items, setItems] = useState<TreeItems<ItemData>>(initialItems);

    return (
      <div style={{ width: '400px' }}>
        <Tree
          items={items}
          onChange={setItems}
          sortable={args.sortable}
          hideExpand={args.hideExpand}
          showDepthPipe={args.showDepthPipe}
          expandByClick={args.expandByClick}
          disableDepthChange={args.disableDepthChange}
          renderItem={({ item }) => (
            <TreeListItem.Root data-testid={item.id}>
              <TreeListItem.TitleArea title={item.data?.title ?? String(item.id)} />
            </TreeListItem.Root>
          )}
        />
      </div>
    );
  };

export default function TreeNonSortableExample(props: Partial<ComponentProps<typeof Tree>>) {
  const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Tree>;
  return TreeNonSortableExampleRender(mergedProps);
}

WithCheckbox

import type { ComponentProps } from 'react';

import { useState } from 'react';
import { type TreeItems } from '@mildang/design-system/TreeListItem';
import { useTreeSelection } from '@mildang/design-system/TreeListItem';
import { Tree } from '@mildang/design-system/TreeListItem';
import { TreeListItem } from '@mildang/design-system/TreeListItem';

interface ItemData {
  title: string;
  type?: 'unit' | 'activity' | 'step';
}

const initialItems: TreeItems<ItemData> = [
  {
    id: 'unit-1',
    data: { title: 'Unit 1', type: 'unit' },
    collapsed: false,
    children: [
      {
        id: 'activity-1',
        data: { title: 'Activity 1', type: 'activity' },
        collapsed: false,
        children: [
          { id: 'step-1', data: { title: 'Step 1', type: 'step' }, children: [] },
          { id: 'step-2', data: { title: 'Step 2', type: 'step' }, children: [] },
        ],
      },
      { id: 'activity-2', data: { title: 'Activity 2', type: 'activity' }, children: [] },
    ],
  },
  {
    id: 'unit-2',
    data: { title: 'Unit 2', type: 'unit' },
    collapsed: true,
    children: [
      {
        id: 'activity-3',
        data: { title: 'Activity 3', type: 'activity' },
        children: [{ id: 'step-3', data: { title: 'Step 3', type: 'step' }, children: [] }],
      },
      { id: 'activity-4', data: { title: 'Activity 4', type: 'activity' }, children: [] },
    ],
  },
];

const STORY_DEFAULT_ARGS = { ...({}), ...({}) } as ComponentProps<typeof Tree>;

const TreeWithCheckboxExampleRender = function Render(args: ComponentProps<typeof Tree>) {
    const [items, setItems] = useState<TreeItems<ItemData>>(initialItems);
    const { selectedIds, selectedItems, getSelectionState, handleSelectedChange } = useTreeSelection({
      items,
    });

    return (
      <div style={{ width: '400px' }}>
        <Tree
          items={items}
          onChange={setItems}
          sortable={args.sortable}
          hideExpand={args.hideExpand}
          showDepthPipe={args.showDepthPipe}
          expandByClick={args.expandByClick}
          disableDepthChange={args.disableDepthChange}
          renderItem={({ item }) => {
            const { checked, indeterminate } = getSelectionState(item);

            return (
              <TreeListItem.Root
                data-testid={item.id}
                selected={checked}
                indeterminate={indeterminate}
                onSelectedChange={(isChecked) => handleSelectedChange(item, isChecked)}
              >
                <TreeListItem.Checkbox />
                <TreeListItem.TitleArea title={item.data?.title ?? String(item.id)} />
              </TreeListItem.Root>
            );
          }}
        />
        <div style={{ marginTop: '16px', padding: '12px', background: '#f5f5f5', borderRadius: '4px' }}>
          <strong>Selected IDs</strong>
          <p style={{ fontSize: '12px' }}>{JSON.stringify(Array.from(selectedIds), null, 2)}</p>
        </div>
        <div style={{ marginTop: '8px', padding: '12px', background: '#e8f5e9', borderRadius: '4px' }}>
          <strong>Selected Items</strong>
          <p style={{ fontSize: '12px' }}>
            {JSON.stringify(
              selectedItems.map((item) => ({ id: item.id, title: item.data?.title })),
              null,
              2,
            )}
          </p>
        </div>
      </div>
    );
  };

export default function TreeWithCheckboxExample(props: Partial<ComponentProps<typeof Tree>>) {
  const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Tree>;
  return TreeWithCheckboxExampleRender(mergedProps);
}

DndCustomExample

import type { ComponentProps } from 'react';

import { useState } from 'react';
import { type TreeItems, type CanDropArgs } from '@mildang/design-system/TreeListItem';
import { Tree } from '@mildang/design-system/TreeListItem';
import { TreeListItem } from '@mildang/design-system/TreeListItem';
import FolderFillIcon from '@mildang/icons/react/folder-fill';
import { css } from '@mildang/styled-system/css';
import AssignmentIcon from '@mildang/icons/react/assignment';

interface FolderFileData {
  title: string;
  type: 'folder' | 'file';
}

const folderFileItems: TreeItems<FolderFileData> = [
  {
    id: 'folder-0',
    data: { title: 'Folder 0', type: 'folder' },
    collapsed: false,
    children: [
      {
        id: 'folder-1',
        data: { title: 'Folder 1', type: 'folder' },
        collapsed: false,
        children: [
          { id: 'file-1', data: { title: 'File 1', type: 'file' }, children: [] },
          { id: 'file-2', data: { title: 'File 2', type: 'file' }, children: [] },
        ],
      },
      {
        id: 'folder-2',
        data: { title: 'Folder 2', type: 'folder' },
        children: [],
      },
    ],
  },
  {
    id: 'folder-3',
    data: { title: 'Folder 3', type: 'folder' },
    collapsed: false,
    children: [{ id: 'file-3', data: { title: 'File 3', type: 'file' }, children: [] }],
  },
];

const STORY_DEFAULT_ARGS = { ...({}), ...({}) } as ComponentProps<typeof Tree>;

const TreeDndCustomExampleExampleRender = function Render(args: ComponentProps<typeof Tree>) {
    const [items, setItems] = useState<TreeItems<FolderFileData>>(folderFileItems);

    // file은 드래그 불가
    const canDrag = (item: { data?: FolderFileData }) => {
      return item.data?.type === 'folder';
    };

    // file이 자식으로 있는 folder 안으로는 드롭 불가
    const canDrop = ({ activeItem, projected, items }: CanDropArgs<FolderFileData>) => {
      if (projected.parentId === null) return true;
      const parent = items.find((item) => item.id === projected.parentId);
      if (!parent) return true;
      if (activeItem.parentId === projected.parentId) return true;

      const parentIndex = items.findIndex((item) => item.id === projected.parentId);
      const hasFileChildren = items
        .slice(parentIndex + 1)
        .some((child) => child.parentId === projected.parentId && child.data?.type === 'file');

      if (hasFileChildren) return false;
      return true;
    };

    return (
      <div style={{ width: '400px' }}>
        <div style={{ marginBottom: '10px', fontSize: '14px', color: '#666' }}>
          <p>
            <strong>canDrag:</strong> file은 드래그 불가
          </p>
          <p>
            <strong>canDrop:</strong> file이 자식으로 있는 folder 안으로는 드롭 불가
          </p>
        </div>
        <Tree
          items={items}
          onChange={setItems}
          canDrag={canDrag}
          canDrop={canDrop}
          sortable={args.sortable}
          hideExpand={args.hideExpand}
          showDepthPipe={args.showDepthPipe}
          expandByClick={args.expandByClick}
          disableDepthChange={args.disableDepthChange}
          renderItem={({ item }) => (
            <TreeListItem.Root data-testid={item.id}>
              <TreeListItem.Icon>
                {item.data?.type === 'folder' ? (
                  <FolderFillIcon className={css({ color: 'warning.icon.base' })} />
                ) : (
                  <AssignmentIcon />
                )}
              </TreeListItem.Icon>
              <TreeListItem.TitleArea title={String(item.id)} />
            </TreeListItem.Root>
          )}
        />
      </div>
    );
  };

export default function TreeDndCustomExampleExample(props: Partial<ComponentProps<typeof Tree>>) {
  const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Tree>;
  return TreeDndCustomExampleExampleRender(mergedProps);
}

DragAccessibility

import type { ComponentProps } from 'react';

import { useState } from 'react';
import { type TreeItems } from '@mildang/design-system/TreeListItem';
import { Tree } from '@mildang/design-system/TreeListItem';
import { TreeListItem } from '@mildang/design-system/TreeListItem';
import MoreHorizIcon from '@mildang/icons/react/more-horiz';

interface ItemData {
  title: string;
  type?: 'unit' | 'activity' | 'step';
}

const initialItems: TreeItems<ItemData> = [
  {
    id: 'unit-1',
    data: { title: 'Unit 1', type: 'unit' },
    collapsed: false,
    children: [
      {
        id: 'activity-1',
        data: { title: 'Activity 1', type: 'activity' },
        collapsed: false,
        children: [
          { id: 'step-1', data: { title: 'Step 1', type: 'step' }, children: [] },
          { id: 'step-2', data: { title: 'Step 2', type: 'step' }, children: [] },
        ],
      },
      { id: 'activity-2', data: { title: 'Activity 2', type: 'activity' }, children: [] },
    ],
  },
  {
    id: 'unit-2',
    data: { title: 'Unit 2', type: 'unit' },
    collapsed: true,
    children: [
      {
        id: 'activity-3',
        data: { title: 'Activity 3', type: 'activity' },
        children: [{ id: 'step-3', data: { title: 'Step 3', type: 'step' }, children: [] }],
      },
      { id: 'activity-4', data: { title: 'Activity 4', type: 'activity' }, children: [] },
    ],
  },
];

const STORY_DEFAULT_ARGS = { ...({}), ...({}) } as ComponentProps<typeof Tree>;

const TreeDragAccessibilityExampleRender = function Render(args: ComponentProps<typeof Tree>) {
    const [items, setItems] = useState<TreeItems<ItemData>>(initialItems);

    return (
      <div style={{ width: '400px' }}>
        <Tree
          items={items}
          onChange={setItems}
          sortable={args.sortable}
          hideExpand={args.hideExpand}
          showDepthPipe={args.showDepthPipe}
          expandByClick={args.expandByClick}
          disableDepthChange={args.disableDepthChange}
          dragAccessibility={{
            announcements: {
              dragstart: ({ source }) =>
                source ? `현재 항목은 ${source.item?.data?.title ?? String(source.id)}입니다.` : undefined,
              dragover: ({ target }) =>
                target
                  ? `현재 ${target.item?.data?.title ?? String(target.id)} 위로 이동 중입니다.`
                  : undefined,
              dragend: ({ source, target, canceled }) => {
                if (!source) return undefined;
                const sourceLabel = source.item?.data?.title ?? String(source.id);
                if (canceled) return `${sourceLabel} 이동이 취소되었습니다.`;
                if (!target || source.id === target.id) return `${sourceLabel}을(를) 현재 위치에 놓았습니다.`;
                const targetLabel = target.item?.data?.title ?? String(target.id);
                return `${sourceLabel}이(가) ${targetLabel} 위에 놓였습니다.`;
              },
            },
          }}
          renderItem={({ item }) => (
            <TreeListItem.Root data-testid={item.id}>
              <TreeListItem.TitleArea title={item.data?.title ?? String(item.id)} />
              <TreeListItem.ActionArea>
                <TreeListItem.ShowOnHover>
                  <TreeListItem.IconButton>
                    <MoreHorizIcon />
                  </TreeListItem.IconButton>
                </TreeListItem.ShowOnHover>
              </TreeListItem.ActionArea>
            </TreeListItem.Root>
          )}
        />
      </div>
    );
  };

export default function TreeDragAccessibilityExample(props: Partial<ComponentProps<typeof Tree>>) {
  const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Tree>;
  return TreeDragAccessibilityExampleRender(mergedProps);
}

Virtualized

import type { ComponentProps } from 'react';

import { useState } from 'react';
import { type TreeItems } from '@mildang/design-system/TreeListItem';
import { Tree } from '@mildang/design-system/TreeListItem';
import { TreeListItem } from '@mildang/design-system/TreeListItem';
import MoreHorizIcon from '@mildang/icons/react/more-horiz';

interface ItemData {
  title: string;
  type?: 'unit' | 'activity' | 'step';
}

const generateLargeTreeData = (
  unitCount: number,
  activitiesPerUnit: number,
  stepsPerActivity: number,
): TreeItems<ItemData> => {
  const items: TreeItems<ItemData> = [];

  for (let u = 1; u <= unitCount; u++) {
    const activities: TreeItems<ItemData> = [];

    for (let a = 1; a <= activitiesPerUnit; a++) {
      const steps: TreeItems<ItemData> = [];

      for (let s = 1; s <= stepsPerActivity; s++) {
        steps.push({
          id: `unit-${u}-activity-${a}-step-${s}`,
          data: { title: `Unit ${u} > Activity ${a} > Step ${s}`, type: 'step' },
          children: [],
        });
      }
      activities.push({
        id: `unit-${u}-activity-${a}`,
        data: { title: `Unit ${u} > Activity ${a}`, type: 'activity' },
        children: steps,
      });
    }
    items.push({
      id: `unit-${u}`,
      data: { title: `Unit ${u}`, type: 'unit' },
      children: activities,
    });
  }

  return items;
};

const STORY_DEFAULT_ARGS = { ...({}), ...({}) } as ComponentProps<typeof Tree>;

const TreeVirtualizedExampleRender = function Render(args: ComponentProps<typeof Tree>) {
    const largeTreeItems = generateLargeTreeData(20, 5, 5);
    const [items, setItems] = useState<TreeItems<ItemData>>(largeTreeItems);

    return (
      <div style={{ width: '500px' }}>
        <Tree
          items={items}
          onChange={setItems}
          sortable={args.sortable}
          hideExpand={args.hideExpand}
          showDepthPipe={args.showDepthPipe}
          expandByClick={args.expandByClick}
          disableDepthChange={args.disableDepthChange}
          virtualized={400}
          renderItem={({ item }) => (
            <TreeListItem.Root>
              <TreeListItem.TitleArea title={item.data?.title ?? String(item.id)} />
              <TreeListItem.ActionArea>
                <TreeListItem.ShowOnHover>
                  <TreeListItem.IconButton>
                    <MoreHorizIcon />
                  </TreeListItem.IconButton>
                </TreeListItem.ShowOnHover>
              </TreeListItem.ActionArea>
            </TreeListItem.Root>
          )}
        />
      </div>
    );
  };

export default function TreeVirtualizedExample(props: Partial<ComponentProps<typeof Tree>>) {
  const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Tree>;
  return TreeVirtualizedExampleRender(mergedProps);
}

HideExpand

import type { ComponentProps } from 'react';

import { useState } from 'react';
import { type TreeItems } from '@mildang/design-system/TreeListItem';
import { Tree } from '@mildang/design-system/TreeListItem';
import { StandardTreeListItem } from '@mildang/design-system/TreeListItem';
import FolderFillIcon from '@mildang/icons/react/folder-fill';
import AssignmentIcon from '@mildang/icons/react/assignment';

interface ItemData {
  title: string;
  type?: 'unit' | 'activity' | 'step';
}

const initialItems: TreeItems<ItemData> = [
  {
    id: 'unit-1',
    data: { title: 'Unit 1', type: 'unit' },
    collapsed: false,
    children: [
      {
        id: 'activity-1',
        data: { title: 'Activity 1', type: 'activity' },
        collapsed: false,
        children: [
          { id: 'step-1', data: { title: 'Step 1', type: 'step' }, children: [] },
          { id: 'step-2', data: { title: 'Step 2', type: 'step' }, children: [] },
        ],
      },
      { id: 'activity-2', data: { title: 'Activity 2', type: 'activity' }, children: [] },
    ],
  },
  {
    id: 'unit-2',
    data: { title: 'Unit 2', type: 'unit' },
    collapsed: true,
    children: [
      {
        id: 'activity-3',
        data: { title: 'Activity 3', type: 'activity' },
        children: [{ id: 'step-3', data: { title: 'Step 3', type: 'step' }, children: [] }],
      },
      { id: 'activity-4', data: { title: 'Activity 4', type: 'activity' }, children: [] },
    ],
  },
];

const STORY_DEFAULT_ARGS = { ...({}), ...({
    hideExpand: true,
  }) } as ComponentProps<typeof Tree>;

const TreeHideExpandExampleRender = function Render(args: ComponentProps<typeof Tree>) {
    const [items, setItems] = useState<TreeItems<ItemData>>(initialItems);

    return (
      <div style={{ width: '400px' }}>
        <Tree
          items={items}
          onChange={setItems}
          sortable={args.sortable}
          hideExpand={args.hideExpand}
          showDepthPipe={args.showDepthPipe}
          expandByClick={args.expandByClick}
          disableDepthChange={args.disableDepthChange}
          renderItem={({ item }) => (
            <StandardTreeListItem
              data-testid={item.id}
              icon={item.children && item.children.length > 0 ? <FolderFillIcon /> : <AssignmentIcon />}
              title={item.data?.title ?? String(item.id)}
            />
          )}
        />
      </div>
    );
  };

export default function TreeHideExpandExample(props: Partial<ComponentProps<typeof Tree>>) {
  const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Tree>;
  return TreeHideExpandExampleRender(mergedProps);
}

ExpandByClick

import type { ComponentProps } from 'react';

import { useState } from 'react';
import { type TreeItems } from '@mildang/design-system/TreeListItem';
import { Tree } from '@mildang/design-system/TreeListItem';
import { StandardTreeListItem } from '@mildang/design-system/TreeListItem';
import FolderFillIcon from '@mildang/icons/react/folder-fill';
import AssignmentIcon from '@mildang/icons/react/assignment';

interface ItemData {
  title: string;
  type?: 'unit' | 'activity' | 'step';
}

const initialItems: TreeItems<ItemData> = [
  {
    id: 'unit-1',
    data: { title: 'Unit 1', type: 'unit' },
    collapsed: false,
    children: [
      {
        id: 'activity-1',
        data: { title: 'Activity 1', type: 'activity' },
        collapsed: false,
        children: [
          { id: 'step-1', data: { title: 'Step 1', type: 'step' }, children: [] },
          { id: 'step-2', data: { title: 'Step 2', type: 'step' }, children: [] },
        ],
      },
      { id: 'activity-2', data: { title: 'Activity 2', type: 'activity' }, children: [] },
    ],
  },
  {
    id: 'unit-2',
    data: { title: 'Unit 2', type: 'unit' },
    collapsed: true,
    children: [
      {
        id: 'activity-3',
        data: { title: 'Activity 3', type: 'activity' },
        children: [{ id: 'step-3', data: { title: 'Step 3', type: 'step' }, children: [] }],
      },
      { id: 'activity-4', data: { title: 'Activity 4', type: 'activity' }, children: [] },
    ],
  },
];

const STORY_DEFAULT_ARGS = { ...({}), ...({
    expandByClick: true,
  }) } as ComponentProps<typeof Tree>;

const TreeExpandByClickExampleRender = function Render(args: ComponentProps<typeof Tree>) {
    const [items, setItems] = useState<TreeItems<ItemData>>(initialItems);

    return (
      <div style={{ width: '400px' }}>
        <Tree
          items={items}
          onChange={setItems}
          sortable={args.sortable}
          hideExpand={args.hideExpand}
          showDepthPipe={args.showDepthPipe}
          expandByClick={args.expandByClick}
          disableDepthChange={args.disableDepthChange}
          renderItem={({ item }) => (
            <StandardTreeListItem
              data-testid={item.id}
              icon={item.children && item.children.length > 0 ? <FolderFillIcon /> : <AssignmentIcon />}
              title={item.data?.title ?? String(item.id)}
            />
          )}
        />
      </div>
    );
  };

export default function TreeExpandByClickExample(props: Partial<ComponentProps<typeof Tree>>) {
  const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Tree>;
  return TreeExpandByClickExampleRender(mergedProps);
}

ShowDepthPipe

import type { ComponentProps } from 'react';

import { useState } from 'react';
import { type TreeItems } from '@mildang/design-system/TreeListItem';
import { Tree } from '@mildang/design-system/TreeListItem';
import { StandardTreeListItem } from '@mildang/design-system/TreeListItem';
import FolderFillIcon from '@mildang/icons/react/folder-fill';
import AssignmentIcon from '@mildang/icons/react/assignment';

interface ItemData {
  title: string;
  type?: 'unit' | 'activity' | 'step';
}

const initialItems: TreeItems<ItemData> = [
  {
    id: 'unit-1',
    data: { title: 'Unit 1', type: 'unit' },
    collapsed: false,
    children: [
      {
        id: 'activity-1',
        data: { title: 'Activity 1', type: 'activity' },
        collapsed: false,
        children: [
          { id: 'step-1', data: { title: 'Step 1', type: 'step' }, children: [] },
          { id: 'step-2', data: { title: 'Step 2', type: 'step' }, children: [] },
        ],
      },
      { id: 'activity-2', data: { title: 'Activity 2', type: 'activity' }, children: [] },
    ],
  },
  {
    id: 'unit-2',
    data: { title: 'Unit 2', type: 'unit' },
    collapsed: true,
    children: [
      {
        id: 'activity-3',
        data: { title: 'Activity 3', type: 'activity' },
        children: [{ id: 'step-3', data: { title: 'Step 3', type: 'step' }, children: [] }],
      },
      { id: 'activity-4', data: { title: 'Activity 4', type: 'activity' }, children: [] },
    ],
  },
];

const STORY_DEFAULT_ARGS = { ...({}), ...({
    showDepthPipe: true,
  }) } as ComponentProps<typeof Tree>;

const TreeShowDepthPipeExampleRender = function Render(args: ComponentProps<typeof Tree>) {
    const [items, setItems] = useState<TreeItems<ItemData>>(initialItems);

    return (
      <div style={{ width: '400px' }}>
        <Tree
          items={items}
          onChange={setItems}
          sortable={args.sortable}
          hideExpand={args.hideExpand}
          showDepthPipe={args.showDepthPipe}
          expandByClick={args.expandByClick}
          disableDepthChange={args.disableDepthChange}
          renderItem={({ item }) => (
            <StandardTreeListItem
              data-testid={item.id}
              icon={item.children && item.children.length > 0 ? <FolderFillIcon /> : <AssignmentIcon />}
              title={item.data?.title ?? String(item.id)}
            />
          )}
        />
      </div>
    );
  };

export default function TreeShowDepthPipeExample(props: Partial<ComponentProps<typeof Tree>>) {
  const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Tree>;
  return TreeShowDepthPipeExampleRender(mergedProps);
}

LazyLoading

import type { ComponentProps } from 'react';

import { useState } from 'react';
import { type TreeItems } from '@mildang/design-system/TreeListItem';
import { Tree } from '@mildang/design-system/TreeListItem';
import { StandardTreeListItem } from '@mildang/design-system/TreeListItem';
import FolderFillIcon from '@mildang/icons/react/folder-fill';
import AssignmentIcon from '@mildang/icons/react/assignment';

interface ItemData {
  title: string;
  type?: 'unit' | 'activity' | 'step';
}

const STORY_DEFAULT_ARGS = { ...({}), ...({
    sortable: false,
  }) } as ComponentProps<typeof Tree>;

const TreeLazyLoadingExampleRender = function Render(args: ComponentProps<typeof Tree>) {
    const [items, setItems] = useState<TreeItems<ItemData>>([
      {
        id: 'unit-lazy',
        data: { title: 'Lazy Unit', type: 'unit' },
        collapsed: true,
        hasChildren: true,
        isChildrenLoading: false,
        children: [],
      },
      {
        id: 'unit-loading',
        data: { title: 'Loading Unit', type: 'unit' },
        collapsed: false,
        hasChildren: true,
        isChildrenLoading: true,
        children: [],
      },
    ]);

    return (
      <div style={{ width: '400px' }}>
        <Tree
          items={items}
          onChange={(nextItems) => {
            setItems(nextItems);
            setTimeout(() => {
              setItems((prev) =>
                prev.map((item) =>
                  item.id === 'unit-lazy'
                    ? {
                        ...item,
                        isChildrenLoading: true,
                      }
                    : item,
                ),
              );
            }, 0);
          }}
          sortable={args.sortable}
          hideExpand={args.hideExpand}
          showDepthPipe={args.showDepthPipe}
          expandByClick={args.expandByClick}
          disableDepthChange={args.disableDepthChange}
          renderItem={({ item }) => (
            <StandardTreeListItem
              data-testid={item.id}
              icon={
                item.hasChildren ?? (item.children?.length ?? 0) > 0 ? <FolderFillIcon /> : <AssignmentIcon />
              }
              title={item.data?.title ?? String(item.id)}
            />
          )}
        />
      </div>
    );
  };

export default function TreeLazyLoadingExample(props: Partial<ComponentProps<typeof Tree>>) {
  const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Tree>;
  return TreeLazyLoadingExampleRender(mergedProps);
}