# Tree Plugin

> Display hierarchical data as an expandable tree.

The Tree plugin transforms your flat grid into a hierarchical tree view with expandable parent-child relationships. Great for file explorers, organizational charts, nested categories, or any data with a natural hierarchy.

## Installation

```ts
import '@toolbox-web/grid/features/tree';
```

## Basic Usage

Just point the feature at the field containing child items (defaults to `children`) and the grid handles all the expand/collapse behavior, indentation, and icons automatically.

#### TypeScript

```ts
import { queryGrid } from '@toolbox-web/grid';

const grid = queryGrid('tbw-grid');
grid.gridConfig = {
  columns: [
    { field: 'name', header: 'Name' },
    { field: 'type', header: 'Type' },
    { field: 'size', header: 'Size' }
  ],
  features: {
    tree: {
      childrenField: 'children',
      indentWidth: 24,
    },
  },
};

grid.rows = [
  {
    id: 1,
    name: 'Documents',
    type: 'folder',
    children: [
      { id: 2, name: 'Work', type: 'folder', children: [
        { id: 3, name: 'Report.docx', type: 'file', size: '24 KB' }
      ]},
      { id: 4, name: 'Personal', type: 'folder', children: [] }
    ]
  },
];
```

#### React

```tsx
import '@toolbox-web/grid-react/features/tree';
import { DataGrid } from '@toolbox-web/grid-react';

function FileExplorer({ files }) {
  return (
    <DataGrid
      rows={files}
      columns={[
        { field: 'name', header: 'Name' },
        { field: 'type', header: 'Type' },
        { field: 'size', header: 'Size' }
      ]}
      tree={{ childrenField: 'children', indentWidth: 24 }}
    />
  );
}
```

#### Vue

```html
<script setup>
import '@toolbox-web/grid-vue/features/tree';
import { TbwGrid, TbwGridColumn } from '@toolbox-web/grid-vue';

const files = [
  {
    id: 1,
    name: 'Documents',
    type: 'folder',
    children: [
      { id: 2, name: 'Work', type: 'folder', children: [{ id: 3, name: 'Report.docx', type: 'file', size: '24 KB' }] },
      { id: 4, name: 'Personal', type: 'folder', children: [] },
    ],
  },
];
</script>

<template>
  <TbwGrid :rows="files" :tree="{ childrenField: 'children', indentWidth: 24 }">
    <TbwGridColumn field="name" header="Name" />
    <TbwGridColumn field="type" header="Type" />
    <TbwGridColumn field="size" header="Size" />
  </TbwGrid>
</template>
```

#### Angular

```typescript
// Feature import - enables the [tree] input
import { GridTreeDirective } from '@toolbox-web/grid-angular/features/tree';
import { Component } from '@angular/core';
import { Grid } from '@toolbox-web/grid-angular';
import type { ColumnConfig } from '@toolbox-web/grid';

@Component({
  selector: 'app-file-explorer',
  imports: [Grid, GridTreeDirective],
  template: `
    <tbw-grid
      [rows]="files"
      [columns]="columns"
      [tree]="{ childrenField: 'children', indentWidth: 24 }"
      style="height: 400px; display: block;">
    </tbw-grid>
  `,
})
export class FileExplorerComponent {
  files = [/* hierarchical data */];

  columns: ColumnConfig[] = [
    { field: 'name', header: 'Name' },
    { field: 'type', header: 'Type' },
    { field: 'size', header: 'Size' }
  ];
}
```

## Demo

Toggle the controls to explore animation, indentation, and expand behavior.

```ts
// TreeDefaultDemo.astro
import '@toolbox-web/grid';
import { queryGrid } from '@toolbox-web/grid';
import '@toolbox-web/grid/features/tree';

const container = document.getElementById('tree-default-demo');
if (container) {
  const fileSystemData = [
    {
      name: 'Documents', type: 'folder', size: '-',
      children: [
        { name: 'Resume.pdf', type: 'file', size: '2.4 MB' },
        { name: 'Cover Letter.docx', type: 'file', size: '156 KB' },
        {
          name: 'Projects', type: 'folder', size: '-',
          children: [
            { name: 'Project A', type: 'folder', size: '-', children: [{ name: 'notes.txt', type: 'file', size: '12 KB' }] },
            { name: 'Project B', type: 'folder', size: '-' },
          ],
        },
      ],
    },
    {
      name: 'Pictures', type: 'folder', size: '-',
      children: [
        { name: 'vacation.jpg', type: 'file', size: '4.2 MB' },
        { name: 'family.png', type: 'file', size: '3.1 MB' },
      ],
    },
    { name: 'readme.md', type: 'file', size: '1 KB' },
  ];
  const columns = [
    { field: 'name', header: 'Name' },
    { field: 'type', header: 'Type' },
    { field: 'size', header: 'Size' },
  ];

  const grid = queryGrid('tbw-grid', container)!;

  function rebuild(opts: Record<string, unknown>) {
    grid.gridConfig = {
      columns,
      features: {
        tree: {
          animation: opts.animation === 'false' ? false : (opts.animation as string) ?? 'slide',
          childrenField: 'children',
          defaultExpanded: opts.defaultExpanded as boolean ?? false,
          indentWidth: (opts.indentWidth as number) ?? 20,
          showExpandIcons: opts.showExpandIcons as boolean ?? true,
        } as any,
      },
    };
    grid.rows = fileSystemData;
  }

  rebuild({ animation: 'slide', defaultExpanded: false, indentWidth: 20, showExpandIcons: true });

  container.addEventListener('control-change', ((e: CustomEvent) => {
    rebuild(e.detail.allValues);
  }) as EventListener);
}
```

### With Pinned Columns

Use `treeColumn` to control which column displays the tree toggle when combining tree with pinned (sticky) columns. By default the toggle appears on the first visible column — set `treeColumn` to explicitly target a wider column like `'name'`.

```ts
// TreePinnedColumnsDemo.astro
import '@toolbox-web/grid';
import { queryGrid } from '@toolbox-web/grid';
import '@toolbox-web/grid/features/pinned-columns';
import '@toolbox-web/grid/features/tree';

const container = document.getElementById('tree-pinned-columns-demo');
if (container) {
  const orgData = [
    {
      id: 1, name: 'Engineering', department: 'Tech', headcount: 42, budget: '$2.1M',
      children: [
        {
          id: 2, name: 'Frontend', department: 'Tech', headcount: 18, budget: '$900K',
          children: [
            { id: 3, name: 'Alice Chen', department: 'Tech', headcount: 1, budget: '$120K' },
            { id: 4, name: 'Bob Lee', department: 'Tech', headcount: 1, budget: '$115K' },
            { id: 5, name: 'Carol Wu', department: 'Tech', headcount: 1, budget: '$110K' },
          ],
        },
        {
          id: 6, name: 'Backend', department: 'Tech', headcount: 24, budget: '$1.2M',
          children: [
            { id: 7, name: 'Dan Kim', department: 'Tech', headcount: 1, budget: '$130K' },
            { id: 8, name: 'Eve Park', department: 'Tech', headcount: 1, budget: '$125K' },
          ],
        },
      ],
    },
    {
      id: 10, name: 'Marketing', department: 'Business', headcount: 15, budget: '$800K',
      children: [
        { id: 11, name: 'Frank Diaz', department: 'Business', headcount: 1, budget: '$95K' },
        { id: 12, name: 'Grace Ito', department: 'Business', headcount: 1, budget: '$90K' },
      ],
    },
    {
      id: 20, name: 'Sales', department: 'Business', headcount: 20, budget: '$1.5M',
      children: [
        { id: 21, name: 'Hank Novak', department: 'Business', headcount: 1, budget: '$100K' },
        { id: 22, name: 'Ivy Shah', department: 'Business', headcount: 1, budget: '$105K' },
      ],
    },
  ];

  const grid = queryGrid('tbw-grid', container)!;

  function rebuild(opts: Record<string, unknown>) {
    const treeColumn = opts.treeColumn === '(first)' ? undefined : (opts.treeColumn as string);
    grid.gridConfig = {
      columns: [
        { field: 'id', header: 'ID', width: 60, pinned: opts.pinId ? 'left' : undefined },
        { field: 'name', header: 'Name', width: 180, pinned: opts.pinName ? 'left' : undefined },
        { field: 'department', header: 'Department', width: 120 },
        { field: 'headcount', header: 'Headcount', width: 100, type: 'number' },
        { field: 'budget', header: 'Budget', width: 100 },
      ],
      features: {
        tree: {
          childrenField: 'children',
          defaultExpanded: true,
          indentWidth: (opts.indentWidth as number) ?? 20,
          treeColumn,
        } as any,
        pinnedColumns: true,
      },
    };
    grid.rows = orgData;
  }

  rebuild({ treeColumn: 'name', indentWidth: 20, pinId: true, pinName: true });

  container.addEventListener('control-change', ((e: CustomEvent) => {
    rebuild(e.detail.allValues);
  }) as EventListener);
}
```

### Server-Side Data

> **Requires:** `ServerSidePlugin` — [see Server-Side Plugin](/grid/plugins/server-side.md)

For large hierarchical datasets, use `ServerSidePlugin` to fetch top-level tree nodes in blocks as the user scrolls. TreePlugin claims the incoming data and renders the hierarchy. Children can be embedded in the response or fetched lazily via `getChildRows()`.

#### TypeScript

```ts
import { queryGrid } from '@toolbox-web/grid';
import '@toolbox-web/grid/features/tree';
import '@toolbox-web/grid/features/server-side';

const grid = queryGrid('tbw-grid');
grid.gridConfig = {
  columns: [
    { field: 'name', header: 'Name' },
    { field: 'role', header: 'Role' },
  ],
  features: {
    serverSide: {
      pageSize: 50,
      dataSource: {
        async getRows(params) {
          // params: { startNode, endNode, sortModel, filterModel }
          const res = await fetch(`/api/tree?start=${params.startNode}&end=${params.endNode}`);
          const data = await res.json();
          return {
            rows: data.nodes,             // Top-level nodes with embedded children
            totalNodeCount: data.total,   // Total top-level nodes on server
          };
        },
      },
    },
    tree: { childrenField: 'children' },
  },
};
```

#### React

```tsx
import '@toolbox-web/grid-react/features/tree';
import '@toolbox-web/grid-react/features/server-side';
import { DataGrid } from '@toolbox-web/grid-react';

const dataSource = {
  async getRows(params) {
    const res = await fetch(`/api/tree?start=${params.startNode}&end=${params.endNode}`);
    return res.json();
  },
};

function ServerTree() {
  return (
    <DataGrid
      columns={[
        { field: 'name', header: 'Name' },
        { field: 'role', header: 'Role' },
      ]}
      serverSide={{ pageSize: 50, dataSource }}
      tree={{ childrenField: 'children' }}
    />
  );
}
```

#### Vue

```html
<script setup>
import '@toolbox-web/grid-vue/features/tree';
import '@toolbox-web/grid-vue/features/server-side';
import { TbwGrid, TbwGridColumn } from '@toolbox-web/grid-vue';

const serverSideConfig = {
  pageSize: 50,
  dataSource: {
    async getRows(params) {
      const res = await fetch(`/api/tree?start=${params.startNode}&end=${params.endNode}`);
      return res.json();
    },
  },
};
</script>

<template>
  <TbwGrid :server-side="serverSideConfig" :tree="{ childrenField: 'children' }">
    <TbwGridColumn field="name" header="Name" />
    <TbwGridColumn field="role" header="Role" />
  </TbwGrid>
</template>
```

#### Angular

```typescript
import { GridTreeDirective } from '@toolbox-web/grid-angular/features/tree';
import { GridServerSideDirective } from '@toolbox-web/grid-angular/features/server-side';
import { Component } from '@angular/core';
import { Grid } from '@toolbox-web/grid-angular';
import type { ColumnConfig } from '@toolbox-web/grid';

@Component({
  selector: 'app-server-tree',
  imports: [Grid, GridTreeDirective, GridServerSideDirective],
  template: `
    <tbw-grid
      [columns]="columns"
      [serverSide]="serverSideConfig"
      [tree]="{ childrenField: 'children' }"
      style="height: 400px; display: block;">
    </tbw-grid>
  `,
})
export class ServerTreeComponent {
  columns: ColumnConfig[] = [
    { field: 'name', header: 'Name' },
    { field: 'role', header: 'Role' },
  ];

  serverSideConfig = {
    pageSize: 50,
    dataSource: {
      async getRows(params: any) {
        const res = await fetch(`/api/tree?start=${params.startNode}&end=${params.endNode}`);
        return res.json();
      },
    },
  };
}
```

```ts
// TreeLazyLoadingDemo.astro
import '@toolbox-web/grid';
import { queryGrid } from '@toolbox-web/grid';
import '@toolbox-web/grid/features/server-side';
import '@toolbox-web/grid/features/tree';

const container = document.getElementById('tree-lazy-loading-demo');
if (container) {
  const statusEl = container.querySelector('.load-status')!;

  // Generate a large hierarchical dataset on the client to simulate a server
  function generateTreeData(count: number) {
    const departments = ['Engineering', 'Marketing', 'Sales', 'HR', 'Finance'];
    const data = [];
    for (let i = 0; i < count; i++) {
      const dept = departments[i % departments.length];
      const teamSize = 2 + (i % 4);
      const children = [];
      for (let j = 0; j < teamSize; j++) {
        children.push({
          id: `${i + 1}-${j + 1}`,
          name: `${dept} Member ${j + 1}`,
          role: j === 0 ? 'Lead' : 'Member',
          department: dept,
        });
      }
      data.push({
        id: `${i + 1}`,
        name: `${dept} Team ${Math.floor(i / departments.length) + 1}`,
        role: 'Manager',
        department: dept,
        children,
      });
    }
    return data;
  }

  const allTopLevelNodes = generateTreeData(200);

  // Simulated data source — returns a page of top-level nodes with children embedded
  function createDataSource(pageSize: number) {
    return {
      async getRows(params: any) {
        // Simulate network latency
        await new Promise((r) => setTimeout(r, 300));

        let data = [...allTopLevelNodes];
        if (params.sortModel?.length) {
          const { field, direction } = params.sortModel[0];
          data.sort((a: any, b: any) => {
            const cmp = a[field] < b[field] ? -1 : a[field] > b[field] ? 1 : 0;
            return direction === 'asc' ? cmp : -cmp;
          });
        }

        const page = data.slice(params.startNode, params.endNode);
        return {
          rows: page,
          totalNodeCount: data.length,
        };
      },
    };
  }

  const columns = [
    { field: 'name', header: 'Name', sortable: true },
    { field: 'role', header: 'Role' },
    { field: 'department', header: 'Department', sortable: true },
  ];

  const grid = queryGrid('tbw-grid', container)!;

  function rebuild(opts: Record<string, unknown>) {
    const pageSize = (opts.pageSize as number) ?? 20;
    grid.gridConfig = {
      columns,
      features: {
        serverSide: { pageSize },
        tree: {
          childrenField: 'children',
          animation: opts.animation === 'false' ? false : (opts.animation as string) ?? 'slide',
          defaultExpanded: opts.defaultExpanded as boolean ?? false,
        },
      },
    };

    grid.ready().then(() => {
      const serverSide = grid.getPluginByName('serverSide');
      serverSide?.setDataSource(createDataSource(pageSize));
      statusEl.textContent = 'Scroll down to load more nodes…';
    });
  }

  rebuild({ pageSize: 20, animation: 'slide', defaultExpanded: false });

  grid.on('datasource:loading' as any, (e: any) => {
    if (e.detail?.loading) {
      statusEl.textContent = 'Loading…';
    } else {
      const serverSide = grid.getPluginByName('serverSide') as any;
      if (serverSide) {
        const tree = grid.getPluginByName('tree') as any;
        const loaded = tree?.getFlattenedRows?.()?.length ?? '?';
        statusEl.textContent = `Loaded ${loaded} rows (${serverSide.getTotalRowCount() ?? '?'} top-level nodes total)`;
      }
    }
  });

  container.addEventListener('control-change', ((e: CustomEvent) => {
    rebuild(e.detail.allValues);
  }) as EventListener);
}
```

**Data flow:**
1. ServerSide fetches a block of top-level nodes → broadcasts `datasource:data`
2. TreePlugin claims the data and flattens it into the row model
3. Expand/collapse works locally for nodes with embedded children
4. For lazy children: TreePlugin queries `datasource:fetch-children` → ServerSide calls `getChildRows()` → broadcasts `datasource:children` → TreePlugin renders children

:::note
Child rows are fetched as a single batch — no pagination. If a tree node has many children, limit the response server-side.
:::

## Configuration Options

See [`TreeConfig`](./Interfaces/TreeConfig/) for the full list of options and defaults.

### Animation Options

The `animation` option controls how child nodes appear/disappear:

```ts
// Slide animation (default)
features: { tree: { animation: 'slide', ... } }

// Fade animation
features: { tree: { animation: 'fade', ... } }

// No animation
features: { tree: { animation: false, ... } }
```

:::note
Animation respects the grid-level `animation.mode` setting.
:::

### Tree Column

By default, the tree toggle and indentation appear on the first visible column. Use `treeColumn` to target a specific column — useful when combining with pinned columns or when the first column is narrow:

```ts
features: {
  tree: {
    treeColumn: 'name',      // Show tree toggle on the 'name' column
    childrenField: 'children',
  },
  pinnedColumns: true,
}
```

## Programmatic API

```ts
const plugin = grid.getPluginByName('tree');

plugin.expand(key);              // Expand a node by key
plugin.collapse(key);            // Collapse a node by key
plugin.toggle(key);              // Toggle a node's expanded state
plugin.expandAll();              // Expand all nodes
plugin.collapseAll();            // Collapse all nodes
plugin.expandToKey(key);         // Expand all ancestors to reveal a node
const keys = plugin.getExpandedKeys();   // Get currently expanded node keys
const expanded = plugin.isExpanded(key); // Check if a node is expanded
const rows = plugin.getFlattenedRows();  // Get flattened tree rows with metadata
const row = plugin.getRowByKey(key);     // Find a row by its key
```

## Events

```ts
// TreeEventsDemo.astro
import '@toolbox-web/grid';
import { queryGrid } from '@toolbox-web/grid';
import '@toolbox-web/grid/features/tree';

const container = document.getElementById('tree-events-demo');
if (container) {
  const grid = queryGrid('tbw-grid', container);

  grid.gridConfig = {
    columns: [
      { field: 'name', header: 'Name' },
      { field: 'type', header: 'Type', width: 100 },
    ],
    features: {
      tree: { childrenField: 'children' },
    },
  };

  grid.rows = [
    {
      name: 'Engineering', type: 'Dept',
      children: [
        { name: 'Alice Johnson', type: 'Employee' },
        { name: 'Bob Smith', type: 'Employee' },
      ],
    },
    {
      name: 'Marketing', type: 'Dept',
      children: [
        { name: 'Carol White', type: 'Employee' },
      ],
    },
    {
      name: 'Sales', type: 'Dept',
      children: [
        { name: 'David Brown', type: 'Employee' },
        { name: 'Eve Davis', type: 'Employee' },
      ],
    },
  ];

  const log = container.querySelector('#tree-events-log');
  const clearBtn = container.querySelector('#clear-tree-events-log');

  function addLog(type: string, detail: string) {
    if (!log) return;
    const msg = document.createElement('div');
    msg.innerHTML = `<span class="event-type">[${type}]</span> ${detail}`;
    log.insertBefore(msg, log.firstChild);
    while (log.children.length > 15) log.lastChild?.remove();
  }

  clearBtn?.addEventListener('click', () => { if (log) log.innerHTML = ''; });

  grid.on('tree-expand', (d) => {
    addLog('tree-expand', `row ${d.rowIndex}, expanded: ${d.expanded}`);
  });
}
```

### tree-expand

Fired when a node is expanded or collapsed (via click, keyboard, `toggle()`, `expandAll()`, or `collapseAll()`):

```ts
grid.on('tree-expand', ({ key, row, expanded, depth, expandedKeys }) => {
  console.log(`${expanded ? 'Expanded' : 'Collapsed'} node ${key} at depth ${depth}`);
  console.log(`${expandedKeys?.length ?? 0} total nodes expanded`);
});
```

See [`TreeExpandDetail`](./Interfaces/TreeExpandDetail/) for the full event payload type.

## Styling

The tree plugin supports CSS custom properties for theming. Override these on `tbw-grid` or a parent container:

### CSS Custom Properties

| Property | Default | Description |
| --- | --- | --- |
| `--tbw-tree-toggle-size` | `1.25em` (~20px) | Toggle icon and spacer width |
| `--tbw-tree-indent-width` | `var(--tbw-tree-toggle-size)` | Indentation per level (must be ≥ toggle size) |
| `--tbw-tree-accent` | `var(--tbw-color-accent)` | Toggle icon hover color |
| `--tbw-animation-duration` | `200ms` | Expand/collapse animation |
| `--tbw-animation-easing` | `ease-out` | Animation curve |

:::note
`--tbw-tree-indent-width` defaults to `--tbw-tree-toggle-size` to ensure leaf nodes align with parent nodes. If you override indent width, ensure it's at least as wide as the toggle icon.
:::

### Example

```css
tbw-grid {
  /* Custom tree styling */
  --tbw-tree-toggle-size: 1.5em; /* Larger toggle icons */
  --tbw-tree-indent-width: 1.75em; /* Slightly wider indent */
  --tbw-tree-accent: #10b981;
  --tbw-animation-duration: 150ms;
}
```

### CSS Classes

The tree plugin uses these class names:

| Class | Element |
| --- | --- |
| `.tree-cell-wrapper` | Cell content wrapper with indentation |
| `.tree-expander` | Expander cell container |
| `.tree-toggle` | Expand/collapse icon |
| `.tree-spacer` | Indent spacer element |
| `.tbw-tree-slide-in` | Child row slide animation |
| `.tbw-tree-fade-in` | Child row fade animation |
| `.tbw-row-expanded` | Applied to a parent `.data-grid-row` while its children are visible. Use this for theming expanded rows instead of `[aria-expanded="true"]`. |

## Plugin Compatibility

:::caution[Incompatible Plugins]
The Tree plugin **cannot** be used with the following plugins:

- **[Row Grouping](/grid/plugins/grouping-rows.md)** — Both plugins transform the entire row model. Tree flattens nested hierarchies while Row Grouping groups flat rows with synthetic headers. Use one approach per grid.
- **[Pivot](/grid/plugins/pivot.md)** — Pivot replaces the entire row and column structure with aggregated pivot data. Tree hierarchy cannot coexist with pivot aggregation.
:::

A development-mode warning is shown if incompatible plugins are loaded together.

## See Also

- [Master-Detail](/grid/plugins/master-detail.md) — Expand rows to show nested detail grids
- [Server-Side Plugin](/grid/plugins/server-side.md) — Lazy-load child nodes from the server
