# Tree

Shows nested items people open, close, choose, check, filter and move: folders, categories, an organisation.

- **Import:** `import { Tree } from "@booleanpress/ui/tree"`
- **APG Tree View:** <https://www.w3.org/WAI/ARIA/apg/patterns/treeview/>
- **Page:** <https://ui.booleanpress.com/components/tree> · @booleanpress/ui 0.2.0

## Usage

Pass the nodes as an array; each node has an `id`, a `label` and, optionally, `children`. Name the tree with `aria-label` or `aria-labelledby`.

```tsx
import { Tree, type TreeNode } from "@booleanpress/ui/tree"

const folders: TreeNode[] = [
  { id: "inbox", label: "Inbox", children: [{ id: "billing", label: "Billing" }, { id: "support", label: "Support" }] },
  { id: "sent", label: "Sent" },
]

export function Folders() {
  return <Tree aria-label="Mail folders" nodes={folders} selectionMode="single" defaultExpanded={["inbox"]} />
}
```

The open nodes and the chosen nodes are arrays of ids. Each is uncontrolled with `defaultExpanded` and `defaultSelected`, or controlled with `expanded` and `onExpandedChange`, `selected` and `onSelectedChange`. `getExpandableIds(nodes)` lists every parent, for an "expand all" button.

`selectionMode` is `none` (the default: a click opens and closes a parent), `single`, `multiple` (a click adds or removes a node) or `checkbox`. With checkboxes, checking a parent checks its whole branch, and a parent shows a dash while only part of its branch is checked; `onSelectedChange` receives every checked node, parents whose branch is all checked included. `onNodeSelect` is called with the node each click, Enter or Space chooses, even one already chosen.

A node can carry an `icon` (and an `expandedIcon` for its open state), `disabled` (it can be focused and opened, not chosen or moved), `leaf` and your own `data`. `renderLabel` draws the label and anything after it, such as an unread count; `expandIcon` and `collapseIcon` replace the chevrons.

`filter` adds a field above the tree that keeps the nodes whose label contains its text, opens their ancestors and keeps their own branch. `loadChildren` loads the children of a node that has none yet the first time it opens, with a spinner in place of its chevron; if its promise rejects, the node closes again, the live region says the provider's `treeLoadFailed`, and opening it tries again. `loading` dims the tree under a spinner, or draws placeholder rows while there are no nodes yet; `empty` is what shows when there are none.

`onNodeMove` makes the nodes movable with Alt and the arrow keys. It receives a move; `moveTreeNode(nodes, move)` returns the new nodes. The node it goes into opens, and the live region says the node's new position with the provider's `itemMoved`.

For pointer dragging as well, use `DraggableTree` from its own entry, `@booleanpress/ui/tree-drag`: it takes every prop of `Tree`, with `onNodeMove` required, and lets people drag a node before, after or onto another: a mouse after 4 px, a finger after a 250 ms press, so a swipe still scrolls the page. It is the only part that needs dnd kit: install `@dnd-kit/core` (6) to use it. `Tree`, `TreeSelect` and `TreeTable` need no drag library.

```tsx
import { moveTreeNode } from "@booleanpress/ui/tree"
import { DraggableTree } from "@booleanpress/ui/tree-drag"

<DraggableTree aria-label="Theme files" nodes={nodes} onNodeMove={(move) => setNodes((current) => moveTreeNode(current, move))} />
```

## Examples

### Basic

Mail folders, the first one open. A click on a parent opens or closes it.

```tsx
import { Tree, type TreeNode } from "@booleanpress/ui/tree"

const FOLDERS: TreeNode[] = [
  {
    id: "inbox",
    label: "Inbox",
    children: [
      { id: "billing", label: "Billing", children: [{ id: "invoices", label: "Invoices" }, { id: "refunds", label: "Refunds" }] },
      { id: "support", label: "Support", children: [{ id: "open", label: "Open tickets" }, { id: "closed", label: "Closed tickets" }] },
    ],
  },
  { id: "sent", label: "Sent", children: [{ id: "receipts", label: "Receipts" }, { id: "newsletters", label: "Newsletters" }] },
  { id: "archive", label: "Archive", children: [{ id: "archive-2025", label: "2025" }, { id: "archive-2026", label: "2026" }] },
]

export default function TreeBasic() {
  return <Tree aria-label="Mail folders" nodes={FOLDERS} defaultExpanded={["inbox"]} className="w-full md:w-120" />
}
```

### With icons and counts

`icon` and `expandedIcon` draw a folder that opens with its node; `renderLabel` adds an unread count after the label.

```tsx
import { ArchiveIcon, FolderIcon, FolderOpenIcon, InboxIcon, MailIcon, SendIcon } from "lucide-react"
import { Badge } from "@booleanpress/ui/badge"
import { Tree, type TreeNode } from "@booleanpress/ui/tree"

type Folder = { unread?: number }

const folder = (id: string, label: string, unread: number, children?: TreeNode<Folder>[]): TreeNode<Folder> => ({
  id,
  label,
  icon: children ? <FolderIcon /> : <MailIcon />,
  expandedIcon: children ? <FolderOpenIcon /> : undefined,
  children,
  data: { unread },
})

const FOLDERS: TreeNode<Folder>[] = [
  {
    id: "inbox",
    label: "Inbox",
    icon: <InboxIcon />,
    data: { unread: 12 },
    children: [folder("billing", "Billing", 4, [folder("invoices", "Invoices", 3), folder("refunds", "Refunds", 1)]), folder("support", "Support", 8)],
  },
  { id: "sent", label: "Sent", icon: <SendIcon />, children: [folder("receipts", "Receipts", 0), folder("newsletters", "Newsletters", 0)] },
  { id: "archive", label: "Archive", icon: <ArchiveIcon />, children: [folder("archive-2026", "2026", 0)] },
]

export default function TreeIconsAndCounts() {
  return (
    <Tree
      aria-label="Mail folders"
      nodes={FOLDERS}
      selectionMode="single"
      defaultExpanded={["inbox"]}
      defaultSelected={["support"]}
      className="w-full md:w-120"
      renderLabel={(node) => (
        <>
          <span className="flex-1">{node.label}</span>
          {node.data?.unread ? <Badge count={node.data.unread} severity="secondary" /> : null}
        </>
      )}
    />
  )
}
```

### Custom toggle indicator

`expandIcon` and `collapseIcon` replace the chevrons with a plus and a minus.

```tsx
import { CircleMinusIcon, CirclePlusIcon } from "lucide-react"
import { Tree, type TreeNode } from "@booleanpress/ui/tree"

const FOLDERS: TreeNode[] = [
  {
    id: "inbox",
    label: "Inbox",
    children: [
      { id: "billing", label: "Billing", children: [{ id: "invoices", label: "Invoices" }] },
      { id: "support", label: "Support", children: [{ id: "open", label: "Open tickets" }] },
    ],
  },
  { id: "sent", label: "Sent", children: [{ id: "receipts", label: "Receipts" }] },
  { id: "archive", label: "Archive", children: [{ id: "archive-2026", label: "2026" }] },
]

export default function TreeToggleIndicator() {
  return (
    <Tree
      aria-label="Mail folders"
      nodes={FOLDERS}
      defaultExpanded={["inbox"]}
      expandIcon={<CirclePlusIcon />}
      collapseIcon={<CircleMinusIcon />}
      className="w-full md:w-120"
    />
  )
}
```

### Controlled

`expanded` and `onExpandedChange` keep the open nodes in your state; the buttons open every parent with `getExpandableIds`, or close them all.

```tsx
import { useState } from "react"
import { MinusIcon, PlusIcon } from "lucide-react"
import { Button } from "@booleanpress/ui/button"
import { Tree, getExpandableIds, type TreeNode } from "@booleanpress/ui/tree"

const FOLDERS: TreeNode[] = [
  {
    id: "inbox",
    label: "Inbox",
    children: [
      { id: "billing", label: "Billing", children: [{ id: "invoices", label: "Invoices" }, { id: "refunds", label: "Refunds" }] },
      { id: "support", label: "Support", children: [{ id: "open", label: "Open tickets" }] },
    ],
  },
  { id: "sent", label: "Sent", children: [{ id: "receipts", label: "Receipts" }] },
  { id: "archive", label: "Archive", children: [{ id: "archive-2026", label: "2026" }] },
]

export default function TreeControlled() {
  const [expanded, setExpanded] = useState<string[]>([])

  return (
    <div className="flex w-full flex-col gap-2 md:w-120">
      <div className="flex gap-2">
        <Button onClick={() => setExpanded(getExpandableIds(FOLDERS))}>
          <PlusIcon />
          Expand all
        </Button>
        <Button variant="outline" onClick={() => setExpanded([])}>
          <MinusIcon />
          Collapse all
        </Button>
      </div>
      <Tree aria-label="Mail folders" nodes={FOLDERS} expanded={expanded} onExpandedChange={setExpanded} />
    </div>
  )
}
```

### Single selection

`selectionMode="single"`: one node at a time is chosen, filled with `--highlight`.

```tsx
import { useState } from "react"
import { FileTextIcon, FolderIcon } from "lucide-react"
import { Tree, type TreeNode } from "@booleanpress/ui/tree"

const TEMPLATES: TreeNode[] = [
  {
    id: "transactional",
    label: "Transactional",
    icon: <FolderIcon />,
    children: [
      { id: "welcome", label: "Welcome", icon: <FileTextIcon /> },
      { id: "password-reset", label: "Password reset", icon: <FileTextIcon /> },
    ],
  },
  { id: "marketing", label: "Marketing", icon: <FolderIcon />, children: [{ id: "launch", label: "Product launch", icon: <FileTextIcon /> }] },
  { id: "receipts", label: "Receipts", icon: <FolderIcon />, children: [{ id: "order", label: "Order receipt", icon: <FileTextIcon /> }] },
]

export default function TreeSingleSelection() {
  const [selected, setSelected] = useState(["password-reset"])

  return (
    <div className="flex w-full flex-col gap-2 md:w-120">
      <Tree
        aria-label="Email templates"
        nodes={TEMPLATES}
        selectionMode="single"
        selected={selected}
        onSelectedChange={setSelected}
        defaultExpanded={["transactional"]}
      />
      <p className="text-sm text-muted-foreground">Editing: {selected[0] ?? "nothing"}</p>
    </div>
  )
}
```

### Multiple selection

`selectionMode="multiple"`: a click, Enter or Space adds a node or takes it away; Shift and an arrow key extends the choice.

```tsx
import { FileTextIcon, FolderIcon } from "lucide-react"
import { Tree, type TreeNode } from "@booleanpress/ui/tree"

const TEMPLATES: TreeNode[] = [
  {
    id: "transactional",
    label: "Transactional",
    icon: <FolderIcon />,
    children: [
      { id: "welcome", label: "Welcome", icon: <FileTextIcon /> },
      { id: "password-reset", label: "Password reset", icon: <FileTextIcon /> },
    ],
  },
  { id: "marketing", label: "Marketing", icon: <FolderIcon />, children: [{ id: "launch", label: "Product launch", icon: <FileTextIcon /> }] },
  { id: "receipts", label: "Receipts", icon: <FolderIcon />, children: [{ id: "order", label: "Order receipt", icon: <FileTextIcon /> }] },
]

export default function TreeMultipleSelection() {
  return (
    <Tree
      aria-label="Templates to export"
      nodes={TEMPLATES}
      selectionMode="multiple"
      defaultExpanded={["transactional"]}
      defaultSelected={["welcome", "password-reset"]}
      className="w-full md:w-120"
    />
  )
}
```

### Checkbox selection

`selectionMode="checkbox"`: checking a parent checks its branch, and a partly checked branch shows a dash.

```tsx
import { useState } from "react"
import { Tree, type TreeNode } from "@booleanpress/ui/tree"

const EVENTS: TreeNode[] = [
  {
    id: "delivery",
    label: "Delivery",
    children: [
      { id: "sent", label: "Sent" },
      { id: "delivered", label: "Delivered" },
      { id: "bounced", label: "Bounced" },
    ],
  },
  { id: "engagement", label: "Engagement", children: [{ id: "opened", label: "Opened" }, { id: "clicked", label: "Clicked" }] },
  { id: "complaints", label: "Spam complaints" },
]

export default function TreeCheckboxSelection() {
  const [checked, setChecked] = useState(["delivered"])

  return (
    <div className="flex w-full flex-col gap-2 md:w-120">
      <Tree
        aria-label="Webhook events"
        nodes={EVENTS}
        selectionMode="checkbox"
        selected={checked}
        onSelectedChange={setChecked}
        defaultExpanded={["delivery"]}
      />
      <p className="text-sm text-muted-foreground">Sending {checked.length} events to the webhook.</p>
    </div>
  )
}
```

### Filter

`filter` keeps the nodes that contain the text typed, with their ancestors opened; Down Arrow moves from the field into the tree.

```tsx
import { Tree, type TreeNode } from "@booleanpress/ui/tree"

const HELP: TreeNode[] = [
  {
    id: "getting-started",
    label: "Getting started",
    children: [
      { id: "install", label: "Install the plugin" },
      { id: "connect", label: "Connect a mailer" },
    ],
  },
  {
    id: "mailers",
    label: "Mailers",
    children: [
      { id: "smtp", label: "Other SMTP" },
      { id: "api", label: "API mailers", children: [{ id: "api-keys", label: "Create an API key" }, { id: "domains", label: "Verify a domain" }] },
    ],
  },
  { id: "logs", label: "Delivery logs", children: [{ id: "retention", label: "Log retention" }, { id: "resend", label: "Resend an email" }] },
]

export default function TreeFilter() {
  return <Tree aria-label="Help articles" nodes={HELP} filter filterPlaceholder="Search articles" className="w-full md:w-120" />
}
```

### Lazy loading

`loadChildren` fetches a folder's contents the first time it opens; a spinner shows in place of its chevron meanwhile.

```tsx
import { FileTextIcon, FolderIcon, FolderOpenIcon } from "lucide-react"
import { Tree, type TreeNode } from "@booleanpress/ui/tree"

const folder = (id: string, label: string): TreeNode => ({ id, label, icon: <FolderIcon />, expandedIcon: <FolderOpenIcon /> })

const SITES: TreeNode[] = [folder("shop", "shop.example.com"), folder("blog", "blog.example.com"), folder("docs", "docs.example.com")]

// A fake request: the children of any folder arrive after 1.2 seconds.
function loadChildren(node: TreeNode): Promise<TreeNode[]> {
  return new Promise((resolve) =>
    setTimeout(
      () =>
        resolve([
          folder(`${node.id}-forms`, "Contact forms"),
          folder(`${node.id}-orders`, "Order emails"),
          { id: `${node.id}-log`, label: "error.log", icon: <FileTextIcon />, leaf: true },
        ]),
      1200
    )
  )
}

export default function TreeLazy() {
  return <Tree aria-label="Sites" nodes={SITES} loadChildren={loadChildren} className="w-full md:w-120" />
}
```

### Loading

`loading` dims the nodes under a spinner while they refresh, or draws placeholder rows before the first load.

```tsx
import { useEffect, useState } from "react"
import { RefreshCwIcon } from "lucide-react"
import { Button } from "@booleanpress/ui/button"
import { Tree, type TreeNode } from "@booleanpress/ui/tree"

const FOLDERS: TreeNode[] = [
  { id: "inbox", label: "Inbox", children: [{ id: "billing", label: "Billing" }, { id: "support", label: "Support" }] },
  { id: "sent", label: "Sent", children: [{ id: "receipts", label: "Receipts" }] },
  { id: "archive", label: "Archive", children: [{ id: "archive-2026", label: "2026" }] },
]

export default function TreeLoading() {
  const [loading, setLoading] = useState(false)

  // A pretend refresh that answers after 1.5 seconds; leaving the page cancels it.
  useEffect(() => {
    if (!loading) return
    const timer = setTimeout(() => setLoading(false), 1500)
    return () => clearTimeout(timer)
  }, [loading])

  return (
    <div className="flex w-full flex-col gap-4 md:flex-row">
      <div className="flex flex-1 flex-col items-end gap-2">
        <Button size="sm" onClick={() => setLoading(true)}>
          <RefreshCwIcon />
          Refresh
        </Button>
        <Tree aria-label="Mail folders" nodes={FOLDERS} defaultExpanded={["inbox"]} loading={loading} className="w-full" />
      </div>
      <Tree aria-label="Mail folders, first load" nodes={[]} loading className="flex-1" />
    </div>
  )
}
```

### Empty

`empty` shows your own message and action when there are no nodes.

```tsx
import { FolderIcon, PlusIcon } from "lucide-react"
import { Button } from "@booleanpress/ui/button"
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@booleanpress/ui/empty"
import { Tree } from "@booleanpress/ui/tree"

export default function TreeEmpty() {
  return (
    <Tree
      aria-label="Mail folders"
      nodes={[]}
      className="w-full md:w-120"
      empty={
        <Empty>
          <EmptyHeader>
            <EmptyMedia variant="icon">
              <FolderIcon />
            </EmptyMedia>
            <EmptyTitle>No folders yet</EmptyTitle>
            <EmptyDescription>Create a folder to start sorting incoming email.</EmptyDescription>
          </EmptyHeader>
          <EmptyContent>
            <Button size="sm">
              <PlusIcon />
              New folder
            </Button>
          </EmptyContent>
        </Empty>
      }
    />
  )
}
```

### Drag and drop

`DraggableTree` from `@booleanpress/ui/tree-drag`, with `moveTreeNode`: drag a file before, after or into a folder, or move the focused node with Alt and the arrow keys.

```tsx
import { useState } from "react"
import { FileIcon, FolderIcon } from "lucide-react"
import { moveTreeNode, type TreeNode } from "@booleanpress/ui/tree"
import { DraggableTree } from "@booleanpress/ui/tree-drag"

const file = (id: string, label: string): TreeNode => ({ id, label, icon: <FileIcon />, leaf: true })

const INITIAL: TreeNode[] = [
  {
    id: "templates",
    label: "templates",
    icon: <FolderIcon />,
    children: [file("welcome", "welcome.html"), file("receipt", "receipt.html")],
  },
  { id: "partials", label: "partials", icon: <FolderIcon />, children: [file("header", "header.html"), file("footer", "footer.html")] },
  file("styles", "styles.css"),
  file("readme", "README.md"),
]

export default function TreeDragAndDrop() {
  const [nodes, setNodes] = useState(INITIAL)

  return (
    <DraggableTree
      aria-label="Theme files"
      nodes={nodes}
      defaultExpanded={["templates", "partials"]}
      onNodeMove={(move) => setNodes((current) => moveTreeNode(current, move))}
      className="w-full md:w-120"
    />
  )
}
```

### Disabled nodes

A `disabled` node is dimmed: it can be focused and opened, but not checked, chosen or moved.

```tsx
import { Tree, type TreeNode } from "@booleanpress/ui/tree"

const ROLES: TreeNode[] = [
  {
    id: "administrators",
    label: "Administrators",
    children: [
      { id: "owner", label: "Site owner", disabled: true },
      { id: "admins", label: "Admins" },
    ],
  },
  { id: "editors", label: "Editors", children: [{ id: "authors", label: "Authors" }, { id: "contributors", label: "Contributors" }] },
  { id: "subscribers", label: "Subscribers", disabled: true },
]

export default function TreeDisabled() {
  return (
    <Tree
      aria-label="Roles that get the report"
      nodes={ROLES}
      selectionMode="checkbox"
      defaultSelected={["owner"]}
      defaultExpanded={["administrators"]}
      className="w-full md:w-120"
    />
  )
}
```

### Move without dragging

A menu on every node calls `moveTreeNode` for Move up, Move down and Move into, so a pointer that cannot drag can reorder the tree; the keyboard moves the focused node with Alt and the arrows (`onNodeMove`).

```tsx
import { useState } from "react"
import { FileIcon, FolderIcon, MoreHorizontalIcon } from "lucide-react"
import { Button } from "@booleanpress/ui/button"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@booleanpress/ui/dropdown-menu"
import { moveTreeNode, Tree, type TreeNode } from "@booleanpress/ui/tree"

const file = (id: string, label: string): TreeNode => ({ id, label, icon: <FileIcon />, leaf: true })

const INITIAL: TreeNode[] = [
  { id: "templates", label: "templates", icon: <FolderIcon />, children: [file("welcome", "welcome.html"), file("receipt", "receipt.html")] },
  { id: "partials", label: "partials", icon: <FolderIcon />, children: [file("header", "header.html"), file("footer", "footer.html")] },
  file("styles", "styles.css"),
  file("readme", "README.md"),
]

// The siblings of a node and the folder that holds it (`null` at the top).
function place(nodes: TreeNode[], id: string, parent: TreeNode | null = null): { siblings: TreeNode[]; parent: TreeNode | null } | undefined {
  if (nodes.some((node) => node.id === id)) return { siblings: nodes, parent }
  for (const node of nodes) {
    const found = node.children ? place(node.children, id, node) : undefined
    if (found) return found
  }
  return undefined
}

export default function TreeMoveWithoutDragging() {
  const [nodes, setNodes] = useState(INITIAL)
  const move = (id: string, targetId: string, position: "before" | "after" | "inside") =>
    setNodes((current) => moveTreeNode(current, { id, targetId, position }))

  return (
    <Tree
      aria-label="Theme files"
      nodes={nodes}
      // The keyboard moves the focused node with Alt and the arrows; the menu is the same moves for a pointer.
      onNodeMove={(next) => setNodes((current) => moveTreeNode(current, next))}
      defaultExpanded={["templates", "partials"]}
      className="w-full md:w-120"
      renderLabel={(node) => {
        const here = place(nodes, node.id)
        if (!here) return node.label
        const index = here.siblings.findIndex((sibling) => sibling.id === node.id)
        const previous = here.siblings[index - 1]
        const next = here.siblings[index + 1]
        const folders = nodes.filter((folder) => folder.children && folder.id !== node.id && folder.id !== here.parent?.id)
        return (
          <>
            <span className="min-w-0 flex-1 truncate">{node.label}</span>
            {/* The menu belongs to the row but not to its selection: its keys and clicks stay inside it. */}
            <span role="presentation" className="ms-auto" onClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}>
              <DropdownMenu>
                <DropdownMenuTrigger asChild>
                  {/* Out of the tab order: the tree is one tab stop, and the keyboard has Alt and the arrows. */}
                  <Button variant="ghost" size="icon-xs" tabIndex={-1} aria-label={`Actions for ${node.label}`}>
                    <MoreHorizontalIcon />
                  </Button>
                </DropdownMenuTrigger>
                <DropdownMenuContent align="end" className="w-48">
                  <DropdownMenuItem disabled={!previous} onSelect={() => previous && move(node.id, previous.id, "before")}>
                    Move up
                  </DropdownMenuItem>
                  <DropdownMenuItem disabled={!next} onSelect={() => next && move(node.id, next.id, "after")}>
                    Move down
                  </DropdownMenuItem>
                  {folders.length > 0 ? <DropdownMenuSeparator /> : null}
                  {folders.map((folder) => (
                    <DropdownMenuItem key={folder.id} onSelect={() => move(node.id, folder.id, "inside")}>
                      Move into {folder.label}
                    </DropdownMenuItem>
                  ))}
                </DropdownMenuContent>
              </DropdownMenu>
            </span>
          </>
        )
      }}
    />
  )
}
```

## Accessibility

**Semantics.** A `ul` with `role="tree"`; each node is an `li` with `role="treeitem"`, `aria-level`, `aria-setsize` and `aria-posinset`, and `aria-expanded` when it has children; its children are a `ul` with `role="group"`. Chosen nodes carry `aria-selected`, checked ones `aria-checked` (`true`, `false` or `mixed`); with several, the tree has `aria-multiselectable`. A node loading its children is `aria-busy`. A hidden live region says the provider's `treeLoading` with its label while it loads, `treeLoadFailed` if the load fails, and `itemMoved` with the new position after a move.

**Labels.** Name the tree with `aria-label` or `aria-labelledby`. A node's name is its label (with whatever `renderLabel` adds). The filter field is named by the provider's `filterTree` string.

**Focus.** The tree is one tab stop: the focused node, else the first chosen one, else the first. The arrow keys move the focus; a focused node shows a 1 px `--ring` outline inside its row. When a branch closes over the focus, the focus moves to its parent.

**Known limits.**

- The chevrons and checkboxes are drawn for the pointer and hidden from assistive technology: the node's own `aria-expanded` and `aria-checked` carry the state.
- Dragging (`DraggableTree`) needs a mouse, or a finger held for 250 ms; the keyboard moves nodes with Alt and the arrows. A pointer that cannot drag has no move of its own: offer a menu that calls `moveTreeNode` (WCAG 2.5.7).
- `moveTreeNode` moves the nodes you pass. Children that `loadChildren` brought in are kept by the tree until you add them to `nodes`, so moving one of them needs your own update.
- Every node shown is drawn: no rows are virtual. A branch of thousands of nodes is best loaded with `loadChildren` or narrowed with the filter.
- With a filter, type-ahead and the arrows move among the nodes shown.

### Keyboard

| Key | Behaviour |
| --- | --- |
| ↓ | Moves to the next node shown. |
| ↑ | Moves to the previous node shown. |
| → | Opens a closed node; on an open node, moves to its first child. Left Arrow in a right-to-left page. |
| ← | Closes an open node; otherwise moves to its parent. Right Arrow in a right-to-left page. |
| Home | Moves to the first node. |
| End | Moves to the last node shown. |
| Enter | Chooses the node, or checks it with checkboxes; with no selection, opens or closes it. |
| Space | As Enter. |
| * | Opens every sibling of the focused node. |
| A–Z | Moves to the next node whose label starts with the letters typed. |
| Shift + ↓ | With `multiple`, moves to the next node and adds it to the choice or takes it away. Up Arrow likewise. |
| Ctrl + A | With `multiple`, chooses every node shown, or none when all are chosen. |
| Alt + ↑ | With `onNodeMove`, moves the node before its previous sibling. Down Arrow moves it after the next. The live region says its new position. |
| Alt + → | With `onNodeMove`, moves the node into its previous sibling, as its last child. Alt and Left Arrow moves it out, after its parent. Flipped in a right-to-left page. |

## API

### Tree

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `nodes` (required) | `TreeNode<TData>[]` |  | The nodes to show. |
| `collapseIcon` | `ReactNode` |  | The mark on an open node, in place of the chevron. |
| `defaultExpanded` | `string[]` |  | The nodes open at first, when the tree controls them. |
| `defaultSelected` | `string[]` |  | The nodes chosen at first, when the tree controls them. |
| `empty` | `ReactNode` |  | What shows when there are no nodes. Defaults to the provider's `noResults` string. |
| `expanded` | `string[]` |  | The open nodes' ids, when you control them. |
| `expandIcon` | `ReactNode` |  | The mark on a closed node, in place of the chevron. |
| `filter` | `boolean` | `false` | Shows a field above the tree that keeps the nodes whose label contains its text, with their ancestors. |
| `filterPlaceholder` | `string` |  | The filter's placeholder. Defaults to the provider's `filterTree` string. |
| `filterValue` | `string` |  | The filter's text, when you control it. |
| `loadChildren` | `((node: TreeNode<TData>) => Promise<TreeNode<TData>[]>)` |  | Loads the children of a node that has none yet and is not a `leaf`, the first time it opens; a spinner shows meanwhile. |
| `loading` | `boolean` | `false` | Dims the nodes under a spinner; with no nodes yet, shows placeholder rows. |
| `onExpandedChange` | `((expanded: string[]) => void)` |  | Called with the open nodes' ids when a node opens or closes. |
| `onFilterValueChange` | `((value: string) => void)` |  | Called with the filter's text as it is typed. |
| `onNodeMove` | `((move: TreeMove) => void)` |  | Makes nodes movable by Alt and the arrow keys; called with each move. Apply it with `moveTreeNode`. For pointer dragging as well, use `DraggableTree` from `@booleanpress/ui/tree-drag`. |
| `onNodeSelect` | `((node: TreeNode<TData>) => void)` |  | Called with the node a click, Enter or Space chooses or checks, even when it was chosen already. |
| `onSelectedChange` | `((selected: string[]) => void)` |  | Called with the chosen nodes' ids. With checkboxes it lists every checked node, parents whose branch is all checked included. |
| `renderLabel` | `((node: TreeNode<TData>, state: TreeNodeState) => ReactNode)` |  | Draws a node's label and anything after it, such as a count. Defaults to the label. |
| `selected` | `string[]` |  | The chosen (or, with checkboxes, checked) nodes' ids, when you control them. |
| `selectionMode` | `"none" \| "checkbox" \| "single" \| "multiple"` | `none` | How nodes are chosen: `none` (default), `single`, `multiple` or `checkbox`. |

**Also exported:** `getExpandableIds`, a helper the parts use; `moveTreeNode`, a helper the parts use; `useTreeState`, a hook for the parts' shared state; call it inside the component's provider.

Every part takes `className`, merged with its defaults by `cn()`, and `ref`, which reaches the element it renders.

**Data attributes:** `data-slot="tree-group"` (Tree), and `data-node-id`.

**Provider strings:** `treeLoadFailed`, `itemMoved`, `treeLoading`, `noResults`, `filterTree`, `loading` (`BooleanUIProvider`'s `strings`).

## Theming

Rows are `--foreground` on the `--card` surface; a node that can be chosen takes `--accent` under the pointer, and a chosen one `--highlight` with `--highlight-foreground`; focus is a 1 px `--ring` outline inside the row. In a `DraggableTree`, a drop line is `--primary`, and the node being dragged has a dashed `--primary` outline.

| Token | Used for |
| --- | --- |
| `--accent` | background |
| `--accent-foreground` | text |
| `--card` | background |
| `--control-hover` | text |
| `--foreground` | text |
| `--highlight` | background |
| `--highlight-foreground` | text |
| `--muted-foreground` | text |
| `--primary` | outline, background, text |
| `--ring` | outline |
