# Tree table

A table of nested rows people open, sort, check and page through: organisations, their teams and their people.

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

## Usage

Pass the rows as tree nodes, with your values in `data`, and the columns; the first column holds the tree. Name the table with `aria-label` or `aria-labelledby`.

```tsx
import { TreeTable, type TreeTableColumn } from "@booleanpress/ui/tree-table"
import type { TreeNode } from "@booleanpress/ui/tree"

type Member = { role: string }

const rows: TreeNode<Member>[] = [
  { id: "acme", label: "Acme Hosting", data: { role: "Organisation" }, children: [{ id: "ravi", label: "Ravi Patel", data: { role: "Engineer" } }] },
]

const columns: TreeTableColumn<Member>[] = [
  { id: "name", header: "Name" },
  { id: "role", header: "Role", sortable: true },
]

export function Members() {
  return <TreeTable aria-label="Members" nodes={rows} columns={columns} />
}
```

A cell shows `node.data[column.id]` (the first column falls back to the node's label), or what the column's `cell` draws. A `sortable` column sorts the rows by clicking its heading, ascending, descending, then not at all; siblings sort among themselves at every level, by `sortValue` or the cell's value, numbers by size and text in the provider's locale. The sort is uncontrolled with `defaultSort`, or controlled with `sort` and `onSortChange`.

The open rows and the chosen rows are arrays of ids, as in `Tree`: `defaultExpanded` or `expanded` with `onExpandedChange`; `defaultSelected` or `selected` with `onSelectedChange`. `selectionMode` is `none`, `single`, `multiple` or `checkbox`; with checkboxes, checking a row checks its branch, a partly checked row shows a dash, and the first heading checks or unchecks every row.

`pageSize` pages the top-level rows, their children staying with them, with pagination below (`page` and `onPageChange` to control it). `scrollHeight` scrolls the rows under a header that stays in place. `size` sets the cell padding (the provider's `controlSize` by default), `gridlines` draws every cell's edge and `striped` tints every other row. `loadChildren`, `loading` and `empty` work as in `Tree`; `expandAllButton` puts a button in the first heading that opens or closes every row.

It is drawn with the library's Table parts and shares the Tree's state and keys, so it needs no table library.

## Examples

### Basic

Organisations, their teams and their people; the first column opens a row into its children.

```tsx
import { TreeTable, type TreeTableColumn } from "@booleanpress/ui/tree-table"
import type { TreeNode } from "@booleanpress/ui/tree"

type Member = { role: string; tickets: number }

const person = (id: string, label: string, role: string, tickets: number): TreeNode<Member> => ({ id, label, data: { role, tickets } })

const ORGANISATIONS: TreeNode<Member>[] = [
  {
    id: "northwind",
    label: "Northwind Agency",
    data: { role: "Organisation", tickets: 14 },
    children: [
      {
        id: "nw-support",
        label: "Support",
        data: { role: "Team", tickets: 9 },
        children: [person("amara", "Amara Okafor", "Support lead", 5), person("jonas", "Jonas Berg", "Support agent", 4)],
      },
      { id: "nw-billing", label: "Billing", data: { role: "Team", tickets: 5 }, children: [person("lena", "Lena Fischer", "Accountant", 5)] },
    ],
  },
  {
    id: "acme",
    label: "Acme Hosting",
    data: { role: "Organisation", tickets: 7 },
    children: [{ id: "acme-eng", label: "Engineering", data: { role: "Team", tickets: 7 }, children: [person("ravi", "Ravi Patel", "Engineer", 7)] }],
  },
]

const COLUMNS: TreeTableColumn<Member>[] = [
  { id: "name", header: "Name" },
  { id: "role", header: "Role" },
  { id: "tickets", header: "Open tickets" },
]

export default function TreeTableBasic() {
  return (
    <TreeTable
      aria-label="Organisations"
      nodes={ORGANISATIONS}
      columns={COLUMNS}
      defaultExpanded={["northwind", "nw-support"]}
      className="max-w-3xl"
    />
  )
}
```

### Sizes

`size` sets the cell padding: `sm` 6 × 8 px, `default` 8 × 14 px, `lg` 15 × 20 px.

```tsx
import { useState } from "react"
import { ToggleGroup, ToggleGroupItem } from "@booleanpress/ui/toggle-group"
import { TreeTable, type TreeTableColumn } from "@booleanpress/ui/tree-table"
import type { TreeNode } from "@booleanpress/ui/tree"

type Member = { role: string; tickets: number }

const ORGANISATIONS: TreeNode<Member>[] = [
  {
    id: "northwind",
    label: "Northwind Agency",
    data: { role: "Organisation", tickets: 14 },
    children: [
      { id: "nw-support", label: "Support", data: { role: "Team", tickets: 9 } },
      { id: "nw-billing", label: "Billing", data: { role: "Team", tickets: 5 } },
    ],
  },
  { id: "acme", label: "Acme Hosting", data: { role: "Organisation", tickets: 7 }, children: [{ id: "acme-eng", label: "Engineering", data: { role: "Team", tickets: 7 } }] },
]

const COLUMNS: TreeTableColumn<Member>[] = [
  { id: "name", header: "Name" },
  { id: "role", header: "Role" },
  { id: "tickets", header: "Open tickets" },
]

export default function TreeTableSizes() {
  const [size, setSize] = useState<"sm" | "default" | "lg">("default")

  return (
    <div className="flex w-full max-w-3xl flex-col items-center gap-4">
      <ToggleGroup type="single" variant="outline" value={size} onValueChange={(next) => next && setSize(next as typeof size)} aria-label="Row size">
        <ToggleGroupItem value="sm">Small</ToggleGroupItem>
        <ToggleGroupItem value="default">Default</ToggleGroupItem>
        <ToggleGroupItem value="lg">Large</ToggleGroupItem>
      </ToggleGroup>
      <TreeTable aria-label="Organisations" nodes={ORGANISATIONS} columns={COLUMNS} size={size} defaultExpanded={["northwind"]} />
    </div>
  )
}
```

### Gridlines

`gridlines` draws an edge round every cell; icons and status tags come from the nodes and a column's `cell`.

```tsx
import { Building2Icon, UserIcon, UsersIcon } from "lucide-react"
import { Badge } from "@booleanpress/ui/badge"
import { TreeTable, type TreeTableColumn } from "@booleanpress/ui/tree-table"
import type { TreeNode } from "@booleanpress/ui/tree"

type Member = { kind: "Organisation" | "Team" | "Person"; tickets: number }

const person = (id: string, label: string, tickets: number): TreeNode<Member> => ({ id, label, icon: <UserIcon />, data: { kind: "Person", tickets } })
const team = (id: string, label: string, tickets: number, children: TreeNode<Member>[]): TreeNode<Member> => ({ id, label, icon: <UsersIcon />, data: { kind: "Team", tickets }, children })

const ORGANISATIONS: TreeNode<Member>[] = [
  {
    id: "northwind",
    label: "Northwind Agency",
    icon: <Building2Icon />,
    data: { kind: "Organisation", tickets: 14 },
    children: [team("nw-support", "Support", 9, [person("amara", "Amara Okafor", 5), person("jonas", "Jonas Berg", 4)]), team("nw-billing", "Billing", 5, [person("lena", "Lena Fischer", 5)])],
  },
  { id: "acme", label: "Acme Hosting", icon: <Building2Icon />, data: { kind: "Organisation", tickets: 7 }, children: [team("acme-eng", "Engineering", 7, [person("ravi", "Ravi Patel", 7)])] },
  { id: "globex", label: "Globex Shop", icon: <Building2Icon />, data: { kind: "Organisation", tickets: 2 }, children: [team("globex-ops", "Operations", 2, [person("sam", "Sam Rivera", 2)])] },
]

const COLUMNS: TreeTableColumn<Member>[] = [
  { id: "name", header: "Name" },
  { id: "tickets", header: "Open tickets" },
  {
    id: "kind",
    header: "Type",
    cell: (node) => <Badge variant={node.data?.kind === "Organisation" ? "warning" : node.data?.kind === "Team" ? "info" : "success"}>{node.data?.kind}</Badge>,
  },
]

export default function TreeTableGridlines() {
  return (
    <TreeTable aria-label="Organisations" nodes={ORGANISATIONS} columns={COLUMNS} gridlines defaultExpanded={["northwind"]} className="max-w-3xl" />
  )
}
```

### Striped

`striped` tints every other row shown.

```tsx
import { TreeTable, type TreeTableColumn } from "@booleanpress/ui/tree-table"
import type { TreeNode } from "@booleanpress/ui/tree"

type Log = { status: string; sent: string }

const email = (id: string, label: string, status: string, sent: string): TreeNode<Log> => ({ id, label, data: { status, sent } })

const CAMPAIGNS: TreeNode<Log>[] = [
  {
    id: "october",
    label: "October newsletter",
    data: { status: "Sent", sent: "1 Oct 2026" },
    children: [email("oct-eu", "Europe list", "Sent", "1 Oct 2026"), email("oct-us", "United States list", "Sent", "1 Oct 2026")],
  },
  {
    id: "launch",
    label: "Product launch",
    data: { status: "Partly sent", sent: "3 Oct 2026" },
    children: [email("launch-vip", "VIP customers", "Sent", "3 Oct 2026"), email("launch-all", "All customers", "Queued", "—"), email("launch-trial", "Trial users", "Failed", "3 Oct 2026")],
  },
  { id: "receipts", label: "Order receipts", data: { status: "Sending", sent: "Daily" }, children: [email("receipts-shop", "Shop orders", "Sending", "Daily")] },
]

const COLUMNS: TreeTableColumn<Log>[] = [
  { id: "name", header: "Campaign" },
  { id: "status", header: "Status" },
  { id: "sent", header: "Sent" },
]

export default function TreeTableStriped() {
  return <TreeTable aria-label="Campaigns" nodes={CAMPAIGNS} columns={COLUMNS} striped defaultExpanded={["october", "launch"]} className="max-w-3xl" />
}
```

### Checkbox selection

`selectionMode="checkbox"`: checking a row checks its branch, a partly checked row shows a dash, and the heading checks every row.

```tsx
import { useState } from "react"
import { TreeTable, type TreeTableColumn } from "@booleanpress/ui/tree-table"
import type { TreeNode } from "@booleanpress/ui/tree"

type Member = { role: string; tickets: number }

const person = (id: string, label: string, role: string, tickets: number): TreeNode<Member> => ({ id, label, data: { role, tickets } })

const ORGANISATIONS: TreeNode<Member>[] = [
  {
    id: "northwind",
    label: "Northwind Agency",
    data: { role: "Organisation", tickets: 14 },
    children: [
      { id: "nw-support", label: "Support", data: { role: "Team", tickets: 9 }, children: [person("amara", "Amara Okafor", "Support lead", 5), person("jonas", "Jonas Berg", "Support agent", 4)] },
      { id: "nw-billing", label: "Billing", data: { role: "Team", tickets: 5 }, children: [person("lena", "Lena Fischer", "Accountant", 5)] },
    ],
  },
  { id: "acme", label: "Acme Hosting", data: { role: "Organisation", tickets: 7 }, children: [person("ravi", "Ravi Patel", "Engineer", 7)] },
]

const COLUMNS: TreeTableColumn<Member>[] = [
  { id: "name", header: "Name" },
  { id: "role", header: "Role" },
  { id: "tickets", header: "Open tickets" },
]

export default function TreeTableCheckboxSelection() {
  const [selected, setSelected] = useState(["amara"])

  return (
    <div className="flex w-full max-w-3xl flex-col gap-2">
      <TreeTable
        aria-label="Recipients of the weekly report"
        nodes={ORGANISATIONS}
        columns={COLUMNS}
        selectionMode="checkbox"
        selected={selected}
        onSelectedChange={setSelected}
        defaultExpanded={["northwind", "nw-support"]}
      />
      <p className="text-sm text-muted-foreground">{selected.length} rows checked.</p>
    </div>
  )
}
```

### Sort

`sortable` columns sort by a click on their heading; siblings sort among themselves. Here the busiest come first with `defaultSort`.

```tsx
import { TreeTable, type TreeTableColumn } from "@booleanpress/ui/tree-table"
import type { TreeNode } from "@booleanpress/ui/tree"

type Member = { role: string; tickets: number }

const person = (id: string, label: string, role: string, tickets: number): TreeNode<Member> => ({ id, label, data: { role, tickets } })

const ORGANISATIONS: TreeNode<Member>[] = [
  {
    id: "northwind",
    label: "Northwind Agency",
    data: { role: "Organisation", tickets: 14 },
    children: [person("amara", "Amara Okafor", "Support lead", 5), person("jonas", "Jonas Berg", "Support agent", 4), person("lena", "Lena Fischer", "Accountant", 5)],
  },
  { id: "acme", label: "Acme Hosting", data: { role: "Organisation", tickets: 7 }, children: [person("ravi", "Ravi Patel", "Engineer", 7)] },
  { id: "globex", label: "Globex Shop", data: { role: "Organisation", tickets: 2 }, children: [person("sam", "Sam Rivera", "Store manager", 2)] },
]

const COLUMNS: TreeTableColumn<Member>[] = [
  { id: "name", header: "Name", sortable: true },
  { id: "role", header: "Role", sortable: true },
  { id: "tickets", header: "Open tickets", sortable: true },
]

export default function TreeTableSort() {
  return (
    <TreeTable
      aria-label="Organisations"
      nodes={ORGANISATIONS}
      columns={COLUMNS}
      defaultSort={{ id: "tickets", desc: true }}
      defaultExpanded={["northwind"]}
      className="max-w-3xl"
    />
  )
}
```

### Pagination

`pageSize={3}` shows three sites a page, each with its own rows, and the range below.

```tsx
import { useUiLocale } from "@booleanpress/ui/provider"
import { TreeTable, type TreeTableColumn } from "@booleanpress/ui/tree-table"
import type { TreeNode } from "@booleanpress/ui/tree"

type Site = { plan: string; emails: number }

const site = (id: string, label: string, plan: string, emails: number): TreeNode<Site> => ({
  id,
  label,
  data: { plan, emails },
  children: [
    { id: `${id}-forms`, label: "Contact forms", data: { plan: "", emails: Math.round(emails * 0.3) } },
    { id: `${id}-orders`, label: "Order emails", data: { plan: "", emails: Math.round(emails * 0.7) } },
  ],
})

const SITES: TreeNode<Site>[] = [
  site("shop", "shop.example.com", "Agency", 12400),
  site("blog", "blog.example.com", "Starter", 860),
  site("docs", "docs.example.com", "Starter", 120),
  site("events", "events.example.com", "Pro", 3200),
  site("store", "store.example.org", "Pro", 5100),
  site("news", "news.example.org", "Starter", 940),
  site("jobs", "jobs.example.org", "Starter", 310),
]

export default function TreeTablePagination() {
  const { locale } = useUiLocale()
  const number = new Intl.NumberFormat(locale)
  const columns: TreeTableColumn<Site>[] = [
    { id: "name", header: "Site" },
    { id: "plan", header: "Plan" },
    { id: "emails", header: "Emails this month", cell: (node) => number.format(node.data?.emails ?? 0) },
  ]

  return <TreeTable aria-label="Sites" nodes={SITES} columns={columns} pageSize={3} className="max-w-3xl" />
}
```

### Scroll

`scrollHeight="16rem"` scrolls the rows under a header that stays in place; `expandAllButton` opens or closes every row.

```tsx
import { useUiLocale } from "@booleanpress/ui/provider"
import { TreeTable, type TreeTableColumn } from "@booleanpress/ui/tree-table"
import { getExpandableIds, type TreeNode } from "@booleanpress/ui/tree"

type Folder = { messages: number; size: string }

const folder = (id: string, label: string, messages: number, size: string, children?: TreeNode<Folder>[]): TreeNode<Folder> => ({
  id,
  label,
  data: { messages, size },
  children,
})

const MAILBOX: TreeNode<Folder>[] = [
  folder("inbox", "Inbox", 1240, "84 MB", [
    folder("billing", "Billing", 310, "22 MB", [folder("invoices", "Invoices", 250, "18 MB"), folder("refunds", "Refunds", 60, "4 MB")]),
    folder("support", "Support", 930, "62 MB", [folder("open", "Open", 120, "9 MB"), folder("closed", "Closed", 810, "53 MB")]),
  ]),
  folder("sent", "Sent", 2100, "130 MB", [folder("receipts", "Receipts", 1800, "96 MB"), folder("newsletters", "Newsletters", 300, "34 MB")]),
  folder("archive", "Archive", 5400, "410 MB", [folder("archive-2025", "2025", 3100, "240 MB"), folder("archive-2026", "2026", 2300, "170 MB")]),
  folder("spam", "Spam", 85, "3 MB"),
]

export default function TreeTableScroll() {
  const { locale } = useUiLocale()
  const number = new Intl.NumberFormat(locale)
  const columns: TreeTableColumn<Folder>[] = [
    { id: "name", header: "Folder" },
    { id: "messages", header: "Messages", cell: (node) => number.format(node.data?.messages ?? 0) },
    { id: "size", header: "Size" },
  ]

  return (
    <TreeTable
      aria-label="Mailbox"
      nodes={MAILBOX}
      columns={columns}
      scrollHeight="16rem"
      expandAllButton
      defaultExpanded={getExpandableIds(MAILBOX)}
      className="max-w-3xl"
    />
  )
}
```

### Loading

`loading` dims the rows 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 { TreeTable, type TreeTableColumn } from "@booleanpress/ui/tree-table"
import type { TreeNode } from "@booleanpress/ui/tree"

type Member = { role: string; tickets: number }

const ORGANISATIONS: TreeNode<Member>[] = [
  {
    id: "northwind",
    label: "Northwind Agency",
    data: { role: "Organisation", tickets: 14 },
    children: [
      { id: "nw-support", label: "Support", data: { role: "Team", tickets: 9 } },
      { id: "nw-billing", label: "Billing", data: { role: "Team", tickets: 5 } },
    ],
  },
  { id: "acme", label: "Acme Hosting", data: { role: "Organisation", tickets: 7 }, children: [{ id: "acme-eng", label: "Engineering", data: { role: "Team", tickets: 7 } }] },
]

const COLUMNS: TreeTableColumn<Member>[] = [
  { id: "name", header: "Name" },
  { id: "role", header: "Role" },
  { id: "tickets", header: "Open tickets" },
]

export default function TreeTableLoading() {
  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 max-w-3xl flex-col items-end gap-4">
      <Button size="sm" onClick={() => setLoading(true)}>
        <RefreshCwIcon />
        Refresh
      </Button>
      <TreeTable aria-label="Organisations" nodes={ORGANISATIONS} columns={COLUMNS} loading={loading} defaultExpanded={["northwind"]} />
      <TreeTable aria-label="Organisations, first load" nodes={[]} columns={COLUMNS} loading />
    </div>
  )
}
```

### Empty

`empty` shows your own message and action in place of the rows.

```tsx
import { Building2Icon, PlusIcon } from "lucide-react"
import { Button } from "@booleanpress/ui/button"
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@booleanpress/ui/empty"
import { TreeTable, type TreeTableColumn } from "@booleanpress/ui/tree-table"

const COLUMNS: TreeTableColumn[] = [
  { id: "name", header: "Name" },
  { id: "role", header: "Role" },
  { id: "tickets", header: "Open tickets" },
]

export default function TreeTableEmpty() {
  return (
    <TreeTable
      aria-label="Organisations"
      nodes={[]}
      columns={COLUMNS}
      className="max-w-3xl"
      empty={
        <Empty>
          <EmptyHeader>
            <EmptyMedia variant="icon">
              <Building2Icon />
            </EmptyMedia>
            <EmptyTitle>No organisations yet</EmptyTitle>
            <EmptyDescription>Add an organisation to group its teams and people.</EmptyDescription>
          </EmptyHeader>
          <EmptyContent>
            <Button size="sm">
              <PlusIcon />
              New organisation
            </Button>
          </EmptyContent>
        </Empty>
      }
    />
  )
}
```

### Lazy children

`loadChildren` fetches an organisation's teams the first time it opens, with a spinner in its toggle meanwhile.

```tsx
import { TreeTable, type TreeTableColumn } from "@booleanpress/ui/tree-table"
import type { TreeNode } from "@booleanpress/ui/tree"

type Member = { role: string; tickets: number }

const ORGANISATIONS: TreeNode<Member>[] = [
  { id: "northwind", label: "Northwind Agency", data: { role: "Organisation", tickets: 14 } },
  { id: "acme", label: "Acme Hosting", data: { role: "Organisation", tickets: 7 } },
  { id: "globex", label: "Globex Shop", data: { role: "Organisation", tickets: 2 } },
]

// A fake request: an organisation's teams arrive after 1.2 seconds; a team's people load the same way.
function loadChildren(node: TreeNode<Member>): Promise<TreeNode<Member>[]> {
  const teams = node.data?.role === "Organisation"
  return new Promise((resolve) =>
    setTimeout(
      () =>
        resolve(
          teams
            ? [
                { id: `${node.id}-support`, label: "Support", data: { role: "Team", tickets: 3 } },
                { id: `${node.id}-billing`, label: "Billing", data: { role: "Team", tickets: 1 } },
              ]
            : [{ id: `${node.id}-lead`, label: "Team lead", data: { role: "Person", tickets: 1 }, leaf: true }]
        ),
      1200
    )
  )
}

const COLUMNS: TreeTableColumn<Member>[] = [
  { id: "name", header: "Name" },
  { id: "role", header: "Role" },
  { id: "tickets", header: "Open tickets" },
]

export default function TreeTableLazy() {
  return <TreeTable aria-label="Organisations" nodes={ORGANISATIONS} columns={COLUMNS} loadChildren={loadChildren} className="max-w-3xl" />
}
```

### Single selection

`selectionMode="single"`: a click selects one row and clears the last.

```tsx
import { useState } from "react"
import { TreeTable, type TreeTableColumn } from "@booleanpress/ui/tree-table"
import type { TreeNode } from "@booleanpress/ui/tree"

type Member = { role: string; tickets: number }

const person = (id: string, label: string, role: string, tickets: number): TreeNode<Member> => ({ id, label, data: { role, tickets } })

const ORGANISATIONS: TreeNode<Member>[] = [
  {
    id: "northwind",
    label: "Northwind Agency",
    data: { role: "Organisation", tickets: 14 },
    children: [
      { id: "nw-support", label: "Support", data: { role: "Team", tickets: 9 }, children: [person("amara", "Amara Okafor", "Support lead", 5), person("jonas", "Jonas Berg", "Support agent", 4)] },
      { id: "nw-billing", label: "Billing", data: { role: "Team", tickets: 5 }, children: [person("lena", "Lena Fischer", "Accountant", 5)] },
    ],
  },
  { id: "acme", label: "Acme Hosting", data: { role: "Organisation", tickets: 7 }, children: [person("ravi", "Ravi Patel", "Engineer", 7)] },
]

const COLUMNS: TreeTableColumn<Member>[] = [
  { id: "name", header: "Name" },
  { id: "role", header: "Role" },
  { id: "tickets", header: "Open tickets" },
]

export default function TreeTableSingleSelection() {
  const [selected, setSelected] = useState(["amara"])

  return (
    <div className="flex w-full max-w-3xl flex-col gap-2">
      <TreeTable
        aria-label="Pick the recipient of the weekly report"
        nodes={ORGANISATIONS}
        columns={COLUMNS}
        selectionMode="single"
        selected={selected}
        onSelectedChange={setSelected}
        defaultExpanded={["northwind", "nw-support"]}
      />
      <p className="text-sm text-muted-foreground">Recipient: {selected[0] ?? "nobody"}</p>
    </div>
  )
}
```

### Multiple selection

`selectionMode="multiple"`: each click toggles a row, so several rows stay selected.

```tsx
import { useState } from "react"
import { TreeTable, type TreeTableColumn } from "@booleanpress/ui/tree-table"
import type { TreeNode } from "@booleanpress/ui/tree"

type Member = { role: string; tickets: number }

const person = (id: string, label: string, role: string, tickets: number): TreeNode<Member> => ({ id, label, data: { role, tickets } })

const ORGANISATIONS: TreeNode<Member>[] = [
  {
    id: "northwind",
    label: "Northwind Agency",
    data: { role: "Organisation", tickets: 14 },
    children: [
      { id: "nw-support", label: "Support", data: { role: "Team", tickets: 9 }, children: [person("amara", "Amara Okafor", "Support lead", 5), person("jonas", "Jonas Berg", "Support agent", 4)] },
      { id: "nw-billing", label: "Billing", data: { role: "Team", tickets: 5 }, children: [person("lena", "Lena Fischer", "Accountant", 5)] },
    ],
  },
  { id: "acme", label: "Acme Hosting", data: { role: "Organisation", tickets: 7 }, children: [person("ravi", "Ravi Patel", "Engineer", 7)] },
]

const COLUMNS: TreeTableColumn<Member>[] = [
  { id: "name", header: "Name" },
  { id: "role", header: "Role" },
  { id: "tickets", header: "Open tickets" },
]

export default function TreeTableMultipleSelection() {
  const [selected, setSelected] = useState(["amara", "ravi"])

  return (
    <div className="flex w-full max-w-3xl flex-col gap-2">
      <TreeTable
        aria-label="Recipients of the weekly report"
        nodes={ORGANISATIONS}
        columns={COLUMNS}
        selectionMode="multiple"
        selected={selected}
        onSelectedChange={setSelected}
        defaultExpanded={["northwind", "nw-support"]}
      />
      <p className="text-sm text-muted-foreground">{selected.length} {selected.length === 1 ? "row" : "rows"} selected.</p>
    </div>
  )
}
```

## Accessibility

**Semantics.** A `table` with `role="treegrid"`; each body row has `aria-level`, `aria-setsize`, `aria-posinset`, `aria-expanded` when it has children, and `aria-selected` when rows can be chosen. A sortable heading has `aria-sort` and holds a button. A row checkbox carries the mixed state; a row loading its children is `aria-busy`, and a hidden live region says the provider's `treeLoading` with its label, or `treeLoadFailed` if the load fails (the row closes again; opening it tries again).

**Labels.** Name the table with `aria-label` or `aria-labelledby`. Each row's toggle is named by the provider's `expandNode` or `collapseNode` with the row's label, its checkbox by the label, and the heading checkbox by `selectAll`; the expand-all button by `expandAll` or `collapseAll`.

**Focus.** The rows are one tab stop: the focused row, else the first chosen one, else the first. The arrow keys move between rows; a focused row shows a 1 px `--ring` outline inside it. The headings' buttons are tab stops of their own.

**Known limits.**

- The focus moves by row, not by cell: interactive content in cells is reached with Tab only when it is in the tab order itself.
- It is a tree with columns, not a full table: cell or row editing, column resizing, column reordering, column visibility, column groups, row reordering, a filter row and CSV export are left to Data table, which has each of them. Filter the nodes before passing them.

### Keyboard

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

## API

### TreeTable

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `columns` (required) | `TreeTableColumn<TData>[]` |  | The columns, the first holding the tree. |
| `nodes` (required) | `TreeNode<TData>[]` |  | The rows, as tree nodes: `{ id, label, icon?, children?, leaf?, disabled?, data? }`. |
| `defaultExpanded` | `string[]` |  | The rows open at first, when the table controls them. |
| `defaultSelected` | `string[]` |  | The rows chosen at first, when the table controls them. |
| `defaultSort` | `TreeTableSort \| null` | `null` | The sort at first, when the table controls it. |
| `empty` | `ReactNode` |  | What shows when there are no rows. Defaults to the provider's `noResults` string. |
| `expandAllButton` | `boolean` | `false` | Shows a button in the first heading that opens every row, or closes them all. |
| `expanded` | `string[]` |  | The open rows' ids, when you control them. |
| `gridlines` | `boolean` | `false` | Draws a line round every cell. |
| `loadChildren` | `((node: TreeNode<TData>) => Promise<TreeNode<TData>[]>)` |  | Loads the children of a row the first time it opens, with a spinner in its toggle meanwhile. |
| `loading` | `boolean` | `false` | Dims the rows under a spinner; with no rows yet, shows placeholder rows. |
| `onExpandedChange` | `((expanded: string[]) => void)` |  | Called with the open rows' ids. |
| `onPageChange` | `((page: number) => void)` |  | Called with the page asked for. |
| `onSelectedChange` | `((selected: string[]) => void)` |  | Called with the chosen rows' ids. With checkboxes it lists every checked row. |
| `onSortChange` | `((sort: TreeTableSort \| null) => void)` |  | Called with the new sort when a heading is clicked: ascending, descending, then none. |
| `page` | `number` |  | The page, from 1, when you control it. |
| `pageSize` | `number` |  | Shows this many top-level rows a page, with pagination below; their children stay with them. |
| `scrollHeight` | `string` |  | A CSS height past which the rows scroll under a header that stays in place, such as `"20rem"`. |
| `selected` | `string[]` |  | The chosen (or checked) rows' ids, when you control them. |
| `selectionMode` | `"none" \| "checkbox" \| "single" \| "multiple"` | `none` | How rows are chosen: `none` (default), `single`, `multiple` or `checkbox`. |
| `size` | `"default" \| "sm" \| "lg"` |  | Cell padding: `sm` 6 × 8 px, `default` 8 × 14 px, `lg` 15 × 20 px. Defaults to the provider's `controlSize`. |
| `sort` | `TreeTableSort \| null` |  | The sort, when you control it. |
| `striped` | `boolean` | `false` | Tints every other row. |

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

**Data attributes:** `data-slot="tree-table-header"` (TreeTable), and `data-node-id`, `data-state`, `data-size`.

**Provider strings:** `collapseAll`, `expandAll`, `selectAll`, `noResults`, `collapseNode`, `expandNode`, `loading` (`BooleanUIProvider`'s `strings`).

## Theming

Rows and cells are the Table's: `--border` lines (`--muted` in dark), `--accent` under the pointer, a chosen row `--highlight`. A sorted heading fills `--highlight`; a striped row takes `--subtle` (`--background` in dark). The toggle is `--muted-foreground`, `--accent` under the pointer.

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