# TreeView

Hierarchical navigation over a flattened row model, virtualized and walked entirely from the keyboard.

```slint
import { TreeNode, TreeView } from "@glint/components/tree-view.slint";
import { Tokens } from "@glint/theme/tokens.slint";

export component Demo inherits Window {
    width: 420px;
    height: 340px;
    background: Tokens.color-background;

    in-out property <bool> src-open: true;
    in-out property <bool> components-open: false;

    // The three shapes this tree can take. Flattening is the consumer's job —
    // a collapsed branch's children are absent from `nodes`, not hidden in it.
    property <[TreeNode]> closed: [
        { label: "src", id: "src", depth: 0, parent: -1, expandable: true },
        { label: "README.md", id: "readme", depth: 0, parent: -1 },
    ];
    property <[TreeNode]> src-only: [
        { label: "src", id: "src", depth: 0, parent: -1,
          expandable: true, expanded: true },
        { label: "components", id: "components", depth: 1, parent: 0,
          expandable: true },
        { label: "main.slint", id: "main", depth: 1, parent: 0 },
        { label: "README.md", id: "readme", depth: 0, parent: -1 },
    ];
    property <[TreeNode]> all-open: [
        { label: "src", id: "src", depth: 0, parent: -1,
          expandable: true, expanded: true },
        { label: "components", id: "components", depth: 1, parent: 0,
          expandable: true, expanded: true },
        { label: "button.slint", id: "button", depth: 2, parent: 1 },
        { label: "card.slint", id: "card", depth: 2, parent: 1 },
        { label: "main.slint", id: "main", depth: 1, parent: 0 },
        { label: "README.md", id: "readme", depth: 0, parent: -1 },
    ];

    VerticalLayout {
        padding: 24px;

        Rectangle {
            border-width: 1px;
            border-color: Tokens.color-border-hairline;
            border-radius: Tokens.radius-md;

            VerticalLayout {
                padding: 8px;

                TreeView {
                    accessible-label: "Project files";
                    nodes: root.src-open
                        ? (root.components-open ? root.all-open : root.src-only)
                        : root.closed;
                    selected: "main";
                    toggled(id, expanded) => {
                        if (id == "src") { root.src-open = expanded; }
                        if (id == "components") { root.components-open = expanded; }
                    }
                }
            }
        }
    }
}
```

## Usage

```slint
import { TreeNode, TreeView } from "@glint/components/tree-view.slint";
import { Tokens } from "@glint/theme/tokens.slint";

export component AppWindow inherits Window {
    width: 360px;
    height: 400px;
    background: Tokens.color-background;

    // Every node whose ancestors are all expanded, in display order.
    in property <[TreeNode]> visible-rows;
    in-out property <string> selected-id;

    callback set-expanded(string, bool);
    callback open(string);

    TreeView {
        accessible-label: "Project files";
        nodes: root.visible-rows;
        selected <=> root.selected-id;
        toggled(id, expanded) => { root.set-expanded(id, expanded); }
        activated(id) => { root.open(id); }
    }
}
```

**A tree here is not nested elements.** Slint cannot instantiate a component recursively, and a virtualized row cannot own a slot, so the tree is a flat array of rows — each carrying the `depth` that draws its indent and the `parent` that `Left` climbs to. The rows you hand over are exactly the rows the tree draws: a collapsed branch’s descendants are absent from `nodes`, not hidden inside it, because a virtualized list pays for the rows it is given (ADR-0016). Flattening is therefore yours, the same trade [DataTable](/docs/components/data-table) makes for sorting and paging.

`TreeNode` is a data type rather than a component:

| Field        | What it is                                                      |
| ------------ | --------------------------------------------------------------- |
| `label`      | What the row shows and what a screen reader announces           |
| `id`         | What `selected`, `toggled` and `activated` speak in             |
| `depth`      | Nesting level, `0` at the top — it draws the indent             |
| `parent`     | Index of the row this one hangs under (`-1` at the top level)   |
| `expandable` | Marks a branch: it gets a chevron and answers the expand action |
| `expanded`   | Whether its children follow it in the model                     |

`selected` is the state and `activated` is the event that changed it — the tree keeps a selection the way `Sidebar` keeps an `active` row, rather than firing a one-shot pick the way [Command](/docs/components/command)’s `selected(id)` does. The tree never flips `expanded` itself either: the shape of the model stays yours.

## Examples

### Selection

A click, `Enter` or `Space` commits the highlighted row into the selection: `selected` updates first, then `activated(id)` fires. Compare `selected` against `nodes[i].id`.

```slint
import { TreeNode, TreeView } from "@glint/components/tree-view.slint";
import { Tokens } from "@glint/theme/tokens.slint";

export component Demo inherits Window {
    width: 440px;
    height: 320px;
    background: Tokens.color-background;

    in-out property <string> selected: "billing";
    property <string> opened: "Nothing opened yet.";

    VerticalLayout {
        padding: 24px;
        spacing: 12px;

        Rectangle {
            border-width: 1px;
            border-color: Tokens.color-border-hairline;
            border-radius: Tokens.radius-md;

            VerticalLayout {
                padding: 8px;

                TreeView {
                    accessible-label: "Settings";
                    selected <=> root.selected;
                    nodes: [
                        { label: "Account", id: "account", depth: 0, parent: -1,
                          expandable: true, expanded: true },
                        { label: "Profile", id: "profile", depth: 1, parent: 0 },
                        { label: "Billing", id: "billing", depth: 1, parent: 0 },
                        { label: "Workspace", id: "workspace", depth: 0, parent: -1 },
                    ];
                    activated(id) => { root.opened = "Opened " + id + "."; }
                }
            }
        }

        Text {
            text: root.opened;
            color: Tokens.color-muted-foreground;
            font-size: Tokens.typography-body-sm-size;
        }
    }
}
```

### Indent and row height

`indent` is the horizontal offset one nesting level adds, and `row-height` is both the height of a row and the step the highlight scrolls by. The chevron slot is held whether or not a row has one, so leaves and branches line up down a level.

```slint
import { TreeNode, TreeView } from "@glint/components/tree-view.slint";
import { Tokens } from "@glint/theme/tokens.slint";

export component Demo inherits Window {
    width: 440px;
    height: 320px;
    background: Tokens.color-background;

    VerticalLayout {
        padding: 24px;

        Rectangle {
            border-width: 1px;
            border-color: Tokens.color-border-hairline;
            border-radius: Tokens.radius-md;

            VerticalLayout {
                padding: 8px;

                TreeView {
                    accessible-label: "Taxonomy";
                    indent: 32px;
                    row-height: 40px;
                    selected: "mammals";
                    nodes: [
                        { label: "Animals", id: "animals", depth: 0, parent: -1,
                          expandable: true, expanded: true },
                        { label: "Mammals", id: "mammals", depth: 1, parent: 0,
                          expandable: true, expanded: true },
                        { label: "Bats", id: "bats", depth: 2, parent: 1 },
                        { label: "Whales", id: "whales", depth: 2, parent: 1 },
                        { label: "Birds", id: "birds", depth: 1, parent: 0 },
                    ];
                }
            }
        }
    }
}
```

### A tree taller than its slot

The rows are virtualized through [`ListView`](/docs/components/data-table#listview): the tree is content-sized while the surrounding layout leaves it free, and scrolls once the layout constrains its height — the large-model case. Walking past the bottom edge with `Down` scrolls the highlight into view rather than losing it.

```slint
import { TreeNode, TreeView } from "@glint/components/tree-view.slint";
import { Tokens } from "@glint/theme/tokens.slint";

export component Demo inherits Window {
    width: 440px;
    height: 300px;
    background: Tokens.color-background;

    VerticalLayout {
        padding: 24px;
        alignment: stretch;

        Rectangle {
            border-width: 1px;
            border-color: Tokens.color-border-hairline;
            border-radius: Tokens.radius-md;

            VerticalLayout {
                padding: 8px;

                TreeView {
                    accessible-label: "Modules";
                    // The slot is shorter than the rows, so the tree scrolls.
                    vertical-stretch: 1;
                    selected: "auth";
                    nodes: [
                        { label: "packages", id: "packages", depth: 0, parent: -1,
                          expandable: true, expanded: true },
                        { label: "auth", id: "auth", depth: 1, parent: 0 },
                        { label: "billing", id: "billing", depth: 1, parent: 0 },
                        { label: "core", id: "core", depth: 1, parent: 0 },
                        { label: "email", id: "email", depth: 1, parent: 0 },
                        { label: "search", id: "search", depth: 1, parent: 0 },
                        { label: "storage", id: "storage", depth: 1, parent: 0 },
                        { label: "telemetry", id: "telemetry", depth: 1, parent: 0 },
                        { label: "ui", id: "ui", depth: 1, parent: 0 },
                        { label: "LICENSE", id: "license", depth: 0, parent: -1 },
                    ];
                }
            }
        }
    }
}
```

### Starting the keyboard on the selection

`highlighted-index` is the row the keyboard stands on; committing it is what selects. Slint cannot search `nodes` for an id, so the tree cannot start the keyboard on a selection it was handed — you built the array, so you know the index. Set the two together.

```slint
import { Button, ButtonVariant } from "@glint/components/button.slint";
import { TreeNode, TreeView } from "@glint/components/tree-view.slint";
import { Tokens } from "@glint/theme/tokens.slint";

export component Demo inherits Window {
    width: 440px;
    height: 320px;
    background: Tokens.color-background;

    in-out property <string> selected: "core";
    in-out property <int> highlighted: 3;

    VerticalLayout {
        padding: 24px;
        spacing: 12px;

        Button {
            variant: ButtonVariant.outline;
            text: "Jump to “auth”";
            clicked => {
                root.selected = "auth";
                root.highlighted = 1;
            }
        }

        Rectangle {
            border-width: 1px;
            border-color: Tokens.color-border-hairline;
            border-radius: Tokens.radius-md;

            VerticalLayout {
                padding: 8px;

                TreeView {
                    accessible-label: "Modules";
                    selected <=> root.selected;
                    highlighted-index <=> root.highlighted;
                    nodes: [
                        { label: "packages", id: "packages", depth: 0, parent: -1,
                          expandable: true, expanded: true },
                        { label: "auth", id: "auth", depth: 1, parent: 0 },
                        { label: "billing", id: "billing", depth: 1, parent: 0 },
                        { label: "core", id: "core", depth: 1, parent: 0 },
                    ];
                }
            }
        }
    }
}
```

## API Reference

### Properties

| Property            | Type            | Default             | Description                                                                                                                                                                                                                                                                                                                                                  |
| ------------------- | --------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `nodes`             | `in [TreeNode]` | no default          | The visible rows, in display order: every node whose ancestors are all expanded. The consumer owns the flattening.                                                                                                                                                                                                                                           |
| `selected`          | `in-out string` | no default          | Two-way; id of the selected row. Compare against `nodes[i].id`.                                                                                                                                                                                                                                                                                              |
| `highlighted-index` | `in-out int`    | `0`                 | Row the keyboard stands on. Committing it (Enter, Space, a click) is what selects. A click brings it along, so the one time to set it is alongside a `selected` the consumer assigns itself: Slint cannot search `nodes` for an id, so the tree cannot start the keyboard on a selection it was handed — the consumer, who built the array, knows the index. |
| `row-height`        | `in length`     | `32px`              | Height of one row; also the step the highlight scrolls by.                                                                                                                                                                                                                                                                                                   |
| `indent`            | `in length`     | `Tokens.spacing-lg` | Horizontal offset one nesting level adds.                                                                                                                                                                                                                                                                                                                    |

### Callbacks

| Callback                | Description                                                                                                                               |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `toggled(string, bool)` | Fired with a row's id and the expansion it asks for. The tree never flips `expanded` itself: the shape of the model stays the consumer's. |
| `activated(string)`     | Fired with a row's id when the user selects it; `selected` updates first.                                                                 |

## Accessibility

- **Tree role.** The root carries Slint’s `tree` role and the whole row count; naming it is the call site’s, so set `accessible-label`.
- **Rows are selectable, expandable items.** Each row is a `list-item` with its index, its label, and — on a branch — `accessible-expandable` and `accessible-expanded`. Selecting it is its default action and opening or closing it is its expand action, so assistive technology can do either without doing both.
- **The level is spelled out.** Slint 1.17 has no accessible level, so each row carries “Level 2” as its description — otherwise the hierarchy the indent draws would be visual only.
- **Where the platform stops.** The `tree` role does reach both real backends (winit maps it to AccessKit’s `Role::Tree`, Qt to `QAccessible::Tree`). What is missing is below the root: there is no tree-item role, so a row can only be a list item. A screen reader therefore hears a tree of expandable list items that each say which level they are on, rather than a tree whose items carry their level structurally.
- **Keyboard.** The tree is one tab stop. `Up` / `Down` walk the rows and `Home` / `End` reach the ends, all clamped — a tree does not wrap. `Right` opens a closed branch and, once open, steps into it; `Left` closes an open branch and otherwise climbs to the parent. `Enter` and `Space` commit the highlight into the selection.
- **The highlight is not the selection.** A tree nobody has focused does not sit there with row 0 lit up competing with the selection: the highlight is drawn only while the tree holds the keyboard. A click brings it along.
- **Keyboard-only focus ring**, around the row the arrows landed on rather than around the whole tree.
