# Tree

Package: @commenda/pathfinder@0.1.0
Page: /docs/components/tree
Figma: https://www.figma.com/design/dn7BkGYEfIyP0o2zfesfVh/The-System?node-id=818-2

```tsx
import { useState } from 'react';

import { FilePdfIcon } from '@commenda/pathfinder/file-types/FilePdfIcon';
import { USFlag } from '@commenda/pathfinder/flags/USFlag';
import { Tree, type TreeNode } from '@commenda/pathfinder/tree';

const documentNodes: TreeNode[] = [
  {
    kind: 'branch',
    id: 'entity',
    label: 'Commenda Technologies Inc.',
    icon: <USFlag size={16} />,
    children: {
      status: 'loaded',
      nodes: [
        {
          kind: 'branch',
          id: 'formation',
          label: 'Formation',
          children: {
            status: 'loaded',
            nodes: [
              {
                kind: 'leaf',
                id: 'certificate',
                label: 'Certificate of incorporation.pdf',
                icon: <FilePdfIcon size={16} />,
              },
              {
                kind: 'leaf',
                id: 'bylaws',
                label: 'Bylaws.pdf',
                icon: <FilePdfIcon size={16} />,
              },
            ],
          },
        },
        {
          kind: 'branch',
          id: 'tax',
          label: 'Tax',
          count: 12,
          children: { status: 'loaded', nodes: [] },
        },
        {
          kind: 'branch',
          id: 'payroll',
          label: 'Payroll',
          count: 4,
          children: { status: 'loaded', nodes: [] },
        },
        {
          kind: 'branch',
          id: 'ownership',
          label: 'Ownership',
          children: { status: 'loaded', nodes: [] },
        },
        {
          kind: 'branch',
          id: 'board',
          label: 'Board minutes',
          children: { status: 'loaded', nodes: [] },
        },
        {
          kind: 'branch',
          id: 'contracts',
          label: 'Contracts',
          children: { status: 'loading' },
        },
      ],
    },
  },
];

export const TreeDefault = () => {
  const [selected, setSelected] = useState<string | null>('payroll');
  const [message, setMessage] = useState('');

  return (
    <div style={{ width: 256, maxWidth: '100%' }}>
      <Tree
        label="Entity documents"
        nodes={documentNodes}
        selectionConfig={{ value: selected, onValueChange: setSelected }}
        expansionConfig={{
          defaultValue: ['entity', 'formation', 'board', 'contracts'],
        }}
        onCreateFolder={(id) => {
          setMessage(`Create folder under ${id}`);
        }}
        menuConfig={(node) => ({
          groups: [
            {
              id: 'item',
              items: [
                {
                  type: 'action',
                  id: 'rename',
                  label: 'Rename',
                  onClick: () => {
                    setMessage(`Rename ${node.label}`);
                  },
                },
              ],
            },
          ],
        })}
      />
      <output>{message}</output>
    </div>
  );
};
```


Import `Tree` and its types from `@commenda/pathfinder/tree`.

## API reference [#api-reference]

| Prop | Type | Requirement | Behavior |
| --- | --- | --- | --- |
| `label` | `string` | required | Required tree name |
| `nodes` | `readonly TreeNode[]` | required | Globally unique IDs, acyclic immutable data |
| `selectionConfig` | `SelectionProps<string \| null>` | optional | Controlled value/onValueChange or defaultValue; single selection |
| `expansionConfig` | `SelectionProps<readonly string[]>` | optional | Controlled/default expanded branch IDs |
| `onLoadChildren` | `((id: string) => void)` | optional | App request when an unloaded branch opens or error retries |
| `menuConfig` | `((node: TreeNode) => DropdownMenu)` | optional | App actions appended after built-in tree actions |
| `onCreateFolder` | `((parentId: string) => void)` | optional | Enables New folder control and menu action |
| `moveConfig` | `TreeMoveConfig` | optional | Enables pointer dragging, Move menu and keyboard move dialog |
| `emptyContent` | `ReactNode` | optional | Defaults to No items |
| `className` | `string` | optional | Root wrapper classes |

### selectionConfig fields

Choose one shape; excluded fields cannot be combined.

#### Option 1

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `value` | `string \| null` | Yes |  |
| `onValueChange` | `(value: string \| null) => void` | Yes |  |

Do not supply: `defaultValue`.

#### Option 2

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `defaultValue` | `string \| null` | No |  |
| `onValueChange` | `((value: string \| null) => void)` | No |  |

Do not supply: `value`.

### expansionConfig fields

Choose one shape; excluded fields cannot be combined.

#### Option 1

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `value` | `readonly string[]` | Yes |  |
| `onValueChange` | `(value: readonly string[]) => void` | Yes |  |

Do not supply: `defaultValue`.

#### Option 2

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `defaultValue` | `readonly string[]` | No |  |
| `onValueChange` | `((value: readonly string[]) => void)` | No |  |

Do not supply: `value`.

### moveConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `canMove` | `(request: TreeMove) => boolean` | Yes |  |
| `onMove` | `(request: TreeMove) => void \| Promise<void>` | Yes |  |

Every node has `id`, `label`, optional `icon`, `count`, and `disabled`. A leaf has `kind: 'leaf'`. A branch has `kind: 'branch'` and explicit `children`:

```ts
// Undefined does not mean an empty folder.
type TreeChildren =
  | { status: 'unloaded' | 'loading' }
  | { status: 'loaded'; nodes: readonly TreeNode[] }
  | { status: 'error'; message?: string };
```

Entity roots use flags. Branches default to the exact open/closed Folder artwork; pass a matching FileType icon for files. The component preserves labels for assistive technology and exposes the full truncated label on hover. Duplicate/empty IDs render a clear error instead of creating unstable navigation.

## Loading and retry [#loading-and-retry]


```tsx
import { useEffect, useRef, useState } from 'react';

import { FilePdfIcon } from '@commenda/pathfinder/file-types/FilePdfIcon';
import { Tree, type TreeNode } from '@commenda/pathfinder/tree';

export const TreeAsync = () => {
  const [nodes, setNodes] = useState<TreeNode[]>([
    {
      kind: 'branch',
      id: 'remote',
      label: 'Archived contracts',
      children: { status: 'unloaded' },
    },
  ]);
  const attempts = useRef(0);
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
  useEffect(
    () => () => {
      if (timer.current) clearTimeout(timer.current);
    },
    []
  );

  return (
    <Tree
      label="Remote documents"
      nodes={nodes}
      onLoadChildren={() => {
        attempts.current += 1;
        setNodes([
          {
            kind: 'branch',
            id: 'remote',
            label: 'Archived contracts',
            children: { status: 'loading' },
          },
        ]);
        timer.current = setTimeout(() => {
          setNodes([
            {
              kind: 'branch',
              id: 'remote',
              label: 'Archived contracts',
              children:
                attempts.current === 1
                  ? { status: 'error', message: 'Couldn’t load this folder' }
                  : {
                      status: 'loaded',
                      nodes: [
                        {
                          kind: 'leaf',
                          id: 'contract',
                          label: 'Signed contract.pdf',
                          icon: <FilePdfIcon size={16} />,
                        },
                      ],
                    },
            },
          ]);
        }, 500);
      }}
    />
  );
};
```


The example deliberately fails its first request. Retry succeeds. The app owns fetching, cancellation and replacing nodes; mark loading while pending, distinguish loaded-empty from unloaded, and supply readable failures. Known ancestor paths are revealed for a newly selected node; callers must provide expansion IDs for ancestors that have not been loaded yet.

## Moving items [#moving-items]


```tsx
import { useState } from 'react';

import { Button } from '@commenda/pathfinder/button';
import { FilePdfIcon } from '@commenda/pathfinder/file-types/FilePdfIcon';
import { Tree, type TreeMove, type TreeNode } from '@commenda/pathfinder/tree';

const relocateNodes = (
  nodes: readonly TreeNode[],
  request: TreeMove
): TreeNode[] => {
  let source: TreeNode | undefined;

  const remove = (items: readonly TreeNode[]): TreeNode[] =>
    items.flatMap((node) => {
      if (node.id === request.sourceId) {
        source = node;

        return [];
      }

      return node.kind === 'branch' && node.children.status === 'loaded'
        ? [
            {
              ...node,
              children: {
                status: 'loaded',
                nodes: remove(node.children.nodes),
              },
            },
          ]
        : [node];
    });

  const remaining = remove(nodes);
  if (!source) return [...nodes];
  const moved = source;

  const insert = (items: readonly TreeNode[]): TreeNode[] =>
    items.flatMap((node) => {
      if (node.id === request.targetId) {
        if (request.position === 'before') return [moved, node];
        if (request.position === 'after') return [node, moved];
        if (node.kind === 'branch' && node.children.status === 'loaded')
          return [
            {
              ...node,
              children: {
                status: 'loaded',
                nodes: [...node.children.nodes, moved],
              },
            },
          ];
      }

      return node.kind === 'branch' && node.children.status === 'loaded'
        ? [
            {
              ...node,
              children: {
                status: 'loaded',
                nodes: insert(node.children.nodes),
              },
            },
          ]
        : [node];
    });

  return insert(remaining);
};

export const TreeMoving = () => {
  const [nodes, setNodes] = useState<TreeNode[]>([
    {
      kind: 'branch',
      id: 'source',
      label: 'Formation',
      children: {
        status: 'loaded',
        nodes: [
          {
            kind: 'leaf',
            id: 'certificate',
            label: 'Certificate.pdf',
            icon: <FilePdfIcon size={16} />,
          },
        ],
      },
    },
    {
      kind: 'branch',
      id: 'destination',
      label: 'Tax',
      children: { status: 'loaded', nodes: [] },
    },
  ]);
  const [fail, setFail] = useState(false);

  return (
    <>
      <p>Drag a row, choose Move from its menu, or press Ctrl/⌘ + Shift + M.</p>
      <Button
        variant="tertiary"
        onClick={() => {
          setFail(!fail);
        }}
      >
        {fail ? 'Simulating API failure' : 'Simulate API failure'}
      </Button>
      <Tree
        label="Movable documents"
        nodes={nodes}
        expansionConfig={{ defaultValue: ['source', 'destination'] }}
        moveConfig={{
          canMove: () => true,
          onMove: (request) => {
            if (fail) return Promise.reject(new Error('Example API failure'));
            setNodes((current) => relocateNodes(current, request));
          },
        }}
      />
    </>
  );
};
```


`TreeMove` is `{ sourceId, targetId, position: 'before' | 'after' | 'inside' }`. Self and descendant targets are always rejected; inside requires a branch. `canMove` adds application permission and business rules and must be pure. `onMove` returns void or a promise; the application updates `nodes` only after accepting the change. No optimistic data mutation occurs inside Tree. Pending requests cannot duplicate; rejection keeps the dialog open and announces an error for retry.

Pointer drop zones are the top quarter (before), middle half (inside), and bottom quarter (after). Invalid targets do not accept drops. Touch or keyboard users choose Move from the menu and select a destination/placement. `Ctrl/⌘ + Shift + M` opens the same move dialog for the focused item; `Shift + F10` or the context-menu key opens its actions. The example includes an API-failure toggle.

## Keyboard [#keyboard]

Focus is separate from selection. Up/Down move among visible nodes; Right expands or enters children, Left collapses or returns to parent (mirrored in RTL); Home/End jump to edges; typing finds matching names. Enter/Space selects. Chevron activation expands without selecting. Row actions remain discoverable on focus/touch, and the menu includes New folder/Retry/Move alternatives. Removing the focused row recovers focus to a surviving tree item.

## Scope deep folders [#scope-deep-folders]


```tsx
import { useState } from 'react';

import { Button } from '@commenda/pathfinder/button';
import { FilePdfIcon } from '@commenda/pathfinder/file-types/FilePdfIcon';
import { USFlag } from '@commenda/pathfinder/flags/USFlag';
import { Tree, type TreeNode } from '@commenda/pathfinder/tree';

const documentNodes: TreeNode[] = [
  {
    kind: 'branch',
    id: 'entity',
    label: 'Commenda Technologies Inc.',
    icon: <USFlag size={16} />,
    children: {
      status: 'loaded',
      nodes: [
        {
          kind: 'branch',
          id: 'formation',
          label: 'Formation',
          children: {
            status: 'loaded',
            nodes: [
              {
                kind: 'leaf',
                id: 'certificate',
                label: 'Certificate of incorporation.pdf',
                icon: <FilePdfIcon size={16} />,
              },
              {
                kind: 'leaf',
                id: 'bylaws',
                label: 'Bylaws.pdf',
                icon: <FilePdfIcon size={16} />,
              },
            ],
          },
        },
        {
          kind: 'branch',
          id: 'tax',
          label: 'Tax',
          count: 12,
          children: { status: 'loaded', nodes: [] },
        },
        {
          kind: 'branch',
          id: 'payroll',
          label: 'Payroll',
          count: 4,
          children: { status: 'loaded', nodes: [] },
        },
        {
          kind: 'branch',
          id: 'ownership',
          label: 'Ownership',
          children: { status: 'loaded', nodes: [] },
        },
        {
          kind: 'branch',
          id: 'board',
          label: 'Board minutes',
          children: { status: 'loaded', nodes: [] },
        },
        {
          kind: 'branch',
          id: 'contracts',
          label: 'Contracts',
          children: { status: 'loading' },
        },
      ],
    },
  },
];

export const TreeScoped = () => {
  const [scope, setScope] = useState<TreeNode | null>(null);
  const nodes =
    scope?.kind === 'branch' && scope.children.status === 'loaded'
      ? scope.children.nodes
      : documentNodes;

  return (
    <>
      <nav aria-label="Folder scope">
        <Button
          variant="link"
          onClick={() => {
            setScope(null);
          }}
        >
          All documents
        </Button>
        {scope && <span> / {scope.label}</span>}
      </nav>
      <Tree
        key={scope?.id ?? 'all'}
        label={scope ? `${scope.label} contents` : 'All documents'}
        nodes={nodes}
        expansionConfig={{ defaultValue: ['entity', 'formation'] }}
        menuConfig={(node) => ({
          groups:
            node.kind === 'branch' && node.children.status === 'loaded'
              ? [
                  {
                    id: 'scope',
                    items: [
                      {
                        id: 'focus',
                        type: 'action',
                        label: 'Focus this folder',
                        onClick: () => {
                          setScope(node);
                        },
                      },
                    ],
                  },
                ]
              : [],
        })}
      />
    </>
  );
};
```


Use Focus this folder to show a subtree and a return path. Tree accepts arbitrary depth but caps visual indentation after four levels to keep labels usable in narrow panels; `aria-level` and hierarchy remain accurate. Prefer scoped browsing for deeper structures and load large branches incrementally. The stress fixture covers 1,000 rendered leaves; it is not a virtualization or unbounded-scale guarantee. Read the [hierarchy rules](/docs/components/tree#design-rules).

## Design rules [#design-rules]

Figma guidance and reviewed implementation decisions for this component. Rule IDs remain stable for source comments and AI references.

### Tree [#tree]

- **TREE-001:** Rows 28px, radius 6, horizontal inset 8 and gap 6, indent 16 per visual level; 12px chevron in a reserved 16px slot even on leaves. Exact Folder artwork, flags at entity roots, file-type icons for files, Label/13, optional count; New folder/More on hover, focus and touch. Hover pearl200, selected pearl300 without a weight change, drop target blue100/blue600.
- **TREE-002:** Semantic nested treeitem/group hierarchy and one roving row tab stop. Focus is independent from selection; arrows, Home/End, typeahead and Enter/Space work. RTL mirrors expand/collapse arrows. Full accessible labels preserve truncated text. Loaded selected ancestors reveal; app supplies unknown remote ancestors. Removal recovers focus.
- **TREE-003:** Explicit unloaded/loading/loaded/error states. Empty folder is not an unknown folder. Loading uses a skeleton/status; errors have Retry and a keyboard menu alternative. API requests, cancellation and immutable data replacement are app-owned.
- **TREE-004:** Every move uses the same validation: no self, no descendant, inside only a branch, then application canMove. Drag before/inside/after, menu Move and Ctrl/⌘+Shift+M share one commit path. The app updates data after success; duplicate pending moves are blocked; failure is announced and retryable. Permissions must also be enforced by the application/API.

### Scale and scope [#scale-and-scope]

Do not silently remove deep nodes. Keep semantic depth accurate; visual indentation stops growing after four levels. Compose a focused subtree with a return/breadcrumb path for deeper work. Load large folders incrementally; this component does not promise virtualization. Keep worst-case fixtures in Storybook, not enterprise. Test long non-Latin names, huge counts, empty/single/large branches, disabled nodes, data replacement, RTL, keyboard and pointer moves, and async rejection.

### Reviewed accessibility and geometry adjustments [#reviewed-accessibility-and-geometry-adjustments]

Tree prose describes a 4px inset, while the reviewed item 818:274 uses 8px horizontal padding and a 6px gap. The implementation follows the component and the full tree 820:211, including its root spacer and 2px row gaps.

### Explicit differences and boundaries [#explicit-differences-and-boundaries]

| Reference                                                                                                 | Coverage                                                                                                                                                                                                                                                                                                                                                     |
| --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [Cell master 430:13537](https://www.figma.com/design/dn7BkGYEfIyP0o2zfesfVh/The-System?node-id=430-13537) | Text/description/icon/badge, people/groups/email, entities, files, tags, boolean, progress, copy/link, numbers/units/currency/trends, loading and totals compose TableCell, TableNumberCell and TableRowActions. Tree parent/child/grandchild behavior belongs to the recursive DataGrid examples. Hover and selection share the row tint in both densities. |
