# Tree rows

> Package `@umriss-ui/table`, version 0.40.0. Demo page: <https://romanhaendler.github.io/umriss-ui/table/tree-rows/>. Every page in one line: [llms.txt](https://romanhaendler.github.io/umriss-ui/table/llms.txt); every page in full: [llms-full.txt](https://romanhaendler.github.io/umriss-ui/table/llms-full.txt).

Rows that hold rows: a hierarchy of any number of levels whose every level carries the same figures (also called tree data). Reach for it to drill down an organisation, regions and places or cost centres - levels the application holds, uneven as they are.

```ts
import { useTable } from "@umriss-ui/table";
```

Demo page: https://romanhaendler.github.io/umriss-ui/table/tree-rows/

`childRows` names the children of a row; the rows passed in are the roots. Every row with children gets a fold in the first column, and the next level stands beneath it, indented.

The figures of a row are the application's: the table sums nothing for a branch. The footer sums the top level only, since a parent already holds its children.

Every level sorts on its own, and a child never leaves its parent. A search or a filter shows a match with the rows above it, muted, and leaves the open branches as they were.

The export writes every row of the filtered tree, open or not, with its level first.

## Examples

### A small tree

Name the children of a row with `childRows`: the table shows the roots, and every row with children gets a fold in the first column that opens the next level beneath it.

```tsx
import { useTable } from "@umriss-ui/table";

interface Unit {
  id: string;
  name: string;
  headcount: number;
  children?: Unit[];
}

const UNITS: Unit[] = [
  {
    id: "sales",
    name: "Sales",
    headcount: 42,
    children: [
      { id: "sales-north", name: "Sales North", headcount: 18 },
      { id: "sales-south", name: "Sales South", headcount: 24 },
    ],
  },
  { id: "finance", name: "Finance", headcount: 11 },
  {
    id: "operations",
    name: "Operations",
    headcount: 96,
    children: [
      { id: "site", name: "Site Ashcombe", headcount: 81 },
      { id: "logistics", name: "Logistics", headcount: 15 },
    ],
  },
];

export default function ASmallTree() {
  const { Table, Column } = useTable(UNITS, { rowKey: (u) => u.id, childRows: (u) => u.children });
  return (
    <Table ariaLabel="Organisation">
      <Column value="name" label="Unit" rowHeader />
      <Column value="headcount" label="Headcount" />
    </Table>
  );
}
```

### An uneven organisation

Five levels in one division, one in the staff unit - each unit with figures of its own. The table opens the first level, sorts every level on its own, and the footer sums the top level only: a parent already holds its children. Search for “line” and the matches come with the rows above them, muted.

```tsx
import { Search, Toolbar, ColumnMenu, useTable } from "@umriss-ui/table";

interface Unit {
  id: string;
  name: string;
  budget: number;
  spent: number;
  children?: Unit[];
}

const UNITS: Unit[] = [
  {
    id: "ops",
    name: "Operations",
    budget: 4_800,
    spent: 4_950,
    children: [
      {
        id: "ops-ash",
        name: "Site Ashcombe",
        budget: 3_100,
        spent: 3_320,
        children: [
          {
            id: "ops-ash-a",
            name: "Hall A",
            budget: 1_700,
            spent: 1_910,
            children: [
              {
                id: "ops-ash-a-1",
                name: "Line 1",
                budget: 900,
                spent: 1_080,
                children: [
                  { id: "ops-ash-a-1-press", name: "Press cell", budget: 520, spent: 690 },
                  { id: "ops-ash-a-1-weld", name: "Welding cell", budget: 380, spent: 390 },
                ],
              },
              { id: "ops-ash-a-2", name: "Line 2", budget: 800, spent: 830 },
            ],
          },
          { id: "ops-ash-b", name: "Hall B", budget: 1_400, spent: 1_410 },
        ],
      },
      { id: "ops-log", name: "Logistics", budget: 1_700, spent: 1_630 },
    ],
  },
  {
    id: "sales",
    name: "Sales",
    budget: 2_200,
    spent: 2_050,
    children: [
      { id: "sales-n", name: "Region North", budget: 1_200, spent: 1_150 },
      { id: "sales-s", name: "Region South", budget: 1_000, spent: 900 },
    ],
  },
  { id: "staff", name: "Staff unit", budget: 600, spent: 610 },
];

export default function AnUnevenOrganisation() {
  const { Table, Column } = useTable(UNITS, { rowKey: (u) => u.id, childRows: (u) => u.children, defaultBranches: 1 });
  return (
    <Table ariaLabel="Budget by unit">
      <Toolbar>
        <Search />
        <ColumnMenu />
      </Toolbar>
      <Column value="name" label="Unit" rowHeader searchable />
      <Column value="budget" label="Budget (k€)" aggregate="sum" />
      <Column value="spent" label="Spent (k€)" aggregate="sum" />
      <Column value={(u) => u.spent - u.budget} id="delta" label="Δ (k€)" />
    </Table>
  );
}
```

### Regions and places

One country has states, another goes straight to its cities: the tree stands as deep as each branch is. A column only places carry stays empty on a country - one column set for every level, absent where a level has no value.

```tsx
import { useTable } from "@umriss-ui/table";

interface Place {
  id: string;
  name: string;
  sites: number;
  /** Only places have a postcode. */
  postcode?: string;
  children?: Place[];
}

const PLACES: Place[] = [
  {
    id: "de",
    name: "Germany",
    sites: 5,
    children: [
      {
        id: "de-by",
        name: "Bavaria",
        sites: 3,
        children: [
          { id: "de-by-muc", name: "Munich", sites: 2, postcode: "80331" },
          { id: "de-by-nue", name: "Nuremberg", sites: 1, postcode: "90402" },
        ],
      },
      { id: "de-hh", name: "Hamburg", sites: 2, postcode: "20095" },
    ],
  },
  {
    id: "nl",
    name: "Netherlands",
    sites: 2,
    children: [
      { id: "nl-ams", name: "Amsterdam", sites: 1, postcode: "1012" },
      { id: "nl-rtm", name: "Rotterdam", sites: 1, postcode: "3011" },
    ],
  },
  { id: "lu", name: "Luxembourg", sites: 1, postcode: "1009" },
];

export default function RegionsAndPlaces() {
  const { Table, Column } = useTable(PLACES, { rowKey: (p) => p.id, childRows: (p) => p.children, defaultBranches: 2 });
  return (
    <Table ariaLabel="Sites by region">
      <Column value="name" label="Region" rowHeader />
      <Column value="sites" label="Sites" />
      <Column value="postcode" label="Postcode" />
    </Table>
  );
}
```

### Employees in the detail

When the bottom of the hierarchy is records of another kind, the tree carries the summarising columns and a team's `RowDetail` holds its employees - a table of their own, with every field they have and their own sort. In a tree the team's fold opens them: they hang from it a level deeper, on the table's surface, without a box.

```tsx
import { useTable } from "@umriss-ui/table";

interface Employee {
  id: string;
  name: string;
  role: string;
  since: string;
  hours: number;
}

interface Team {
  id: string;
  name: string;
  headcount: number;
  hours: number;
  employees?: Employee[];
  children?: Team[];
}

const TEAMS: Team[] = [
  {
    id: "it",
    name: "IT",
    headcount: 5,
    hours: 196,
    children: [
      {
        id: "it-ops",
        name: "IT Operations",
        headcount: 3,
        hours: 118,
        employees: [
          { id: "e1", name: "Ada Brenner", role: "Lead", since: "2016", hours: 40 },
          { id: "e2", name: "Jonas Kessler", role: "Administrator", since: "2021", hours: 40 },
          { id: "e3", name: "Mira Otto", role: "Administrator", since: "2024", hours: 38 },
        ],
      },
      {
        id: "it-dev",
        name: "Development",
        headcount: 2,
        hours: 78,
        employees: [
          { id: "e4", name: "Lena Vogt", role: "Developer", since: "2019", hours: 40 },
          { id: "e5", name: "Tom Weiss", role: "Developer", since: "2023", hours: 38 },
        ],
      },
    ],
  },
];

function Employees({ team }: { team: Team }) {
  const { Table, Column } = useTable(team.employees ?? [], { rowKey: (e) => e.id, defaultSort: { column: "name", direction: "asc" } });
  return (
    <Table ariaLabel={`Employees of ${team.name}`} density="compact">
      <Column value="name" label="Employee" rowHeader />
      <Column value="role" label="Role" />
      <Column value="since" label="Since" />
      <Column value="hours" label="Hours" />
    </Table>
  );
}

export default function EmployeesInTheDetail() {
  const { Table, Column, RowDetail } = useTable(TEAMS, { rowKey: (t) => t.id, childRows: (t) => t.children, defaultBranches: 1 });
  return (
    <Table ariaLabel="Teams">
      <Column value="name" label="Team" rowHeader />
      <Column value="headcount" label="Headcount" />
      <Column value="hours" label="Hours a week" />
      <RowDetail>{(team) => (team.employees ? <Employees team={team} /> : null)}</RowDetail>
    </Table>
  );
}
```

### Own staff and teams

A unit can have people of its own and teams beneath it. Its fold opens both: first its own staff on the rail, then its teams a level deeper - one chevron for everything that stands under the row. A unit without staff of its own opens only its teams; a team without teams only its staff.

```tsx
import { useTable } from "@umriss-ui/table";

interface Employee {
  id: string;
  name: string;
  role: string;
  hours: number;
}

interface Unit {
  id: string;
  name: string;
  headcount: number;
  hours: number;
  /** The people the unit has itself - not those of its teams. */
  staff?: Employee[];
  children?: Unit[];
}

const UNITS: Unit[] = [
  {
    id: "it",
    name: "IT",
    headcount: 6,
    hours: 236,
    staff: [{ id: "e0", name: "Nora Albers", role: "Head of IT", hours: 40 }],
    children: [
      {
        id: "it-ops",
        name: "IT Operations",
        headcount: 3,
        hours: 118,
        staff: [
          { id: "e1", name: "Ada Brenner", role: "Lead", hours: 40 },
          { id: "e2", name: "Jonas Kessler", role: "Administrator", hours: 40 },
          { id: "e3", name: "Mira Otto", role: "Administrator", hours: 38 },
        ],
      },
      {
        id: "it-dev",
        name: "Development",
        headcount: 2,
        hours: 78,
        staff: [
          { id: "e4", name: "Lena Vogt", role: "Developer", hours: 40 },
          { id: "e5", name: "Tom Weiss", role: "Developer", hours: 38 },
        ],
      },
    ],
  },
  {
    id: "fin",
    name: "Finance",
    headcount: 2,
    hours: 72,
    children: [
      {
        id: "fin-ctl",
        name: "Controlling",
        headcount: 2,
        hours: 72,
        staff: [
          { id: "e6", name: "Paula Hartmann", role: "Controller", hours: 40 },
          { id: "e7", name: "Ben Lindner", role: "Analyst", hours: 32 },
        ],
      },
    ],
  },
];

function Staff({ unit }: { unit: Unit }) {
  const { Table, Column } = useTable(unit.staff ?? [], { rowKey: (e) => e.id });
  return (
    <Table ariaLabel={`Staff of ${unit.name}`} density="compact">
      <Column value="name" label="Employee" rowHeader />
      <Column value="role" label="Role" />
      <Column value="hours" label="Hours" />
    </Table>
  );
}

export default function OwnStaffAndTeams() {
  const { Table, Column, RowDetail } = useTable(UNITS, { rowKey: (u) => u.id, childRows: (u) => u.children, defaultBranches: ["it"] });
  return (
    <Table ariaLabel="Units with their staff">
      <Column value="name" label="Unit" rowHeader />
      <Column value="headcount" label="Headcount" />
      <Column value="hours" label="Hours a week" />
      <RowDetail>{(unit) => (unit.staff ? <Staff unit={unit} /> : null)}</RowDetail>
    </Table>
  );
}
```

### From a flat list

Data from a database often comes flat, each record with its parent's id. A few lines nest it; the table takes the nested rows.

```tsx
import { useMemo } from "react";
import { useTable } from "@umriss-ui/table";

interface CostCentre {
  id: string;
  parent: string | null;
  name: string;
  amount: number;
}

const FLAT: CostCentre[] = [
  { id: "1000", parent: null, name: "Administration", amount: 820 },
  { id: "1100", parent: "1000", name: "Accounting", amount: 310 },
  { id: "1110", parent: "1100", name: "Payroll", amount: 120 },
  { id: "1200", parent: "1000", name: "Legal", amount: 190 },
  { id: "2000", parent: null, name: "Production", amount: 4_300 },
  { id: "2100", parent: "2000", name: "Assembly", amount: 2_600 },
];

type Nested = CostCentre & { children?: Nested[] };

/** Nests a flat list by its parent ids - the roots are those without one. */
function nest(flat: readonly CostCentre[]): Nested[] {
  const byId = new Map<string, Nested>(flat.map((c) => [c.id, { ...c }]));
  const roots: Nested[] = [];
  for (const c of byId.values()) {
    const parent = c.parent === null ? undefined : byId.get(c.parent);
    if (parent) (parent.children ??= []).push(c);
    else roots.push(c);
  }
  return roots;
}

export default function FromAFlatList() {
  const rows = useMemo(() => nest(FLAT), []);
  const { Table, Column } = useTable(rows, { rowKey: (c) => c.id, childRows: (c) => c.children, defaultBranches: 1 });
  return (
    <Table ariaLabel="Cost centres">
      <Column value="name" label="Name" rowHeader />
      <Column value="id" label="Cost centre" />
      <Column value="amount" label="Amount (k€)" />
    </Table>
  );
}
```

### Open branches from controls of your own

`t.unfoldAllBranches` and `t.foldAllBranches` open and close the whole tree; `t.toggleBranch` opens one branch by its key, and `t.branches` holds the keys open now - here to lead from a warning down to its unit.

```tsx
import { Button, Stack, Text } from "@umriss-ui/core";
import { useTable } from "@umriss-ui/table";

interface Unit {
  id: string;
  name: string;
  budget: number;
  spent: number;
  children?: Unit[];
}

const UNITS: Unit[] = [
  {
    id: "ops",
    name: "Operations",
    budget: 3_400,
    spent: 3_560,
    children: [
      {
        id: "ops-ash",
        name: "Site Ashcombe",
        budget: 2_600,
        spent: 2_790,
        children: [
          { id: "ops-ash-press", name: "Press shop", budget: 1_500, spent: 1_720 },
          { id: "ops-ash-paint", name: "Paint shop", budget: 1_100, spent: 1_070 },
        ],
      },
      { id: "ops-log", name: "Logistics", budget: 800, spent: 770 },
    ],
  },
  {
    id: "sales",
    name: "Sales",
    budget: 1_200,
    spent: 1_150,
    children: [
      { id: "sales-north", name: "Sales North", budget: 700, spent: 690 },
      { id: "sales-south", name: "Sales South", budget: 500, spent: 460 },
    ],
  },
  { id: "finance", name: "Finance", budget: 400, spent: 390 },
];

/** The warning the controlling system raised, with the branches above its unit. */
const WARNING = { unit: "Press shop", path: ["ops", "ops-ash"] };

export default function OpenBranchesFromControls() {
  const t = useTable(UNITS, { rowKey: (u) => u.id, childRows: (u) => u.children });
  const { Table, Column } = t;
  const closed = WARNING.path.filter((key) => !t.branches.includes(key));

  return (
    <Stack gap={3}>
      <Stack direction="row" gap={2} align="center" wrap>
        <Button size="sm" onClick={t.unfoldAllBranches}>
          Open all
        </Button>
        <Button size="sm" onClick={t.foldAllBranches}>
          Close all
        </Button>
        <Text size="sm" tone="secondary">
          {WARNING.unit} is over budget.
        </Text>
        <Button size="sm" variant="ghost" disabled={closed.length === 0} onClick={() => closed.forEach((key) => t.toggleBranch(key))}>
          Show it
        </Button>
      </Stack>
      <Table ariaLabel="Budget by unit">
        <Column value="name" label="Unit" rowHeader />
        <Column value="budget" label="Budget (k€)" />
        <Column value="spent" label="Spent (k€)" />
      </Table>
    </Stack>
  );
}
```

## When to use something else

- Rows that share a value - a customer, a month → Grouping
- A hierarchy without figures → `TreeView` from @umriss-ui/core
- Records of another kind beneath the bottom level → RowDetail

## Keyboard

| Key | Action |
|---|---|
| `→ / ←` | On a fold: opens or closes its branch; ← on a closed one goes to the parent's fold. |
| `Alt + → / Alt + ←` | On a fold: opens or closes the branch and all its siblings. |

## Props on this page

From `TableProps` — the full table stands on [First table](https://romanhaendler.github.io/umriss-ui/table/first-table/#type-TableProps).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`density`](https://romanhaendler.github.io/umriss-ui/table/first-table/#TableProps-density) | `"regular" \| "compact"` | the density of the `UmrissProvider`, else `"regular"` | How tightly the rows stand. |
| [`children`](https://romanhaendler.github.io/umriss-ui/table/first-table/#TableProps-children) | `ReactNode` | — | Columns, `RowDetail`, `RowActions` and the unbound parts. |

#### Accessibility

| Prop | Type | Default | Description |
|---|---|---|---|
| [`ariaLabel`](https://romanhaendler.github.io/umriss-ui/table/first-table/#TableProps-ariaLabel) | `string` | — | Accessible name of the table (not of the outer frame). |

From `TableOptions` — the full table stands on [First table](https://romanhaendler.github.io/umriss-ui/table/first-table/#type-TableOptions).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`rowKey`](https://romanhaendler.github.io/umriss-ui/table/first-table/#TableOptions-rowKey) *required* | `(row: Z) => string` | — | A stable key per row – the basis of selection and expansion. |
| [`defaultSort`](https://romanhaendler.github.io/umriss-ui/table/first-table/#TableOptions-defaultSort) | [`SortLevel`](https://romanhaendler.github.io/umriss-ui/table/first-table/#type-SortLevel)`  \| readonly  `[`SortLevel`](https://romanhaendler.github.io/umriss-ui/table/first-table/#type-SortLevel)`[] \| null` | `null` | The application's sort; it is the default that `view` leaves out. |

From `TableSnapshot` — the full table stands on [First table](https://romanhaendler.github.io/umriss-ui/table/first-table/#type-TableSnapshot).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`branches`](https://romanhaendler.github.io/umriss-ui/table/first-table/#TableSnapshot-branches) *required* | `readonly string[]` | — | Tree rows: the keys of the open branches. |
| [`toggleBranch`](https://romanhaendler.github.io/umriss-ui/table/first-table/#TableSnapshot-toggleBranch) *required* | `(key: string) => void` | — | Tree rows: opens a branch or closes it. |
| [`unfoldAllBranches`](https://romanhaendler.github.io/umriss-ui/table/first-table/#TableSnapshot-unfoldAllBranches) *required* | `() => void` | — | Tree rows: opens every branch that has children. |
| [`foldAllBranches`](https://romanhaendler.github.io/umriss-ui/table/first-table/#TableSnapshot-foldAllBranches) *required* | `() => void` | — | Tree rows: closes every branch. |

From `FieldColumn` — the full table stands on [Column](https://romanhaendler.github.io/umriss-ui/table/column/#type-FieldColumn).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`value`](https://romanhaendler.github.io/umriss-ui/table/column/#FieldColumn-value) *required* | `K` | — | A field of the row. Supplies the column's `id` at the same time. |

From `ColumnBase` — the full table stands on [Column](https://romanhaendler.github.io/umriss-ui/table/column/#type-ColumnBase).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`label`](https://romanhaendler.github.io/umriss-ui/table/column/#ColumnBase-label) *required* | `string` | — | Names the column in its header, in the column menu and in the header row of the export. |
| [`rowHeader`](https://romanhaendler.github.io/umriss-ui/table/column/#ColumnBase-rowHeader) | `boolean` | — | This column names the row: row header cells, never hideable, the column `stickyRowHeader` pins, and the name by which the row's selection, expander and actions are read out. At most one per table. |
| [`searchable`](https://romanhaendler.github.io/umriss-ui/table/column/#ColumnBase-searchable) | `boolean` | `true` for text, `false` otherwise | Takes part in the search. |

From `AggregateOptions` — the full table stands on [Aggregate](https://romanhaendler.github.io/umriss-ui/table/aggregate/#type-AggregateOptions).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`aggregate`](https://romanhaendler.github.io/umriss-ui/table/aggregate/#AggregateOptions-aggregate) | [`AggregateFor`](https://romanhaendler.github.io/umriss-ui/table/aggregate/#type-AggregateFor)`<W, Z>` | — | What the column's values come to: in the footer over the filtered set, in a group's header over its rows. `"sum"`, `"avg"` for numbers; `"min"`, `"max"` for numbers and points in time; `"range"` for points in time; `"count"`, `"distinct"` for every value with a text form - or a function of one's own. Always from the values, never from other aggregates; absent values count towards nothing. |

From `RowDetailProps` — the full table stands on [RowDetail](https://romanhaendler.github.io/umriss-ui/table/rowdetail/#type-RowDetailProps).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`children`](https://romanhaendler.github.io/umriss-ui/table/rowdetail/#RowDetailProps-children) *required* | `(row: Z) => ReactNode` | — | What stands underneath an expanded row. |

From `ToolbarProps` — the full table stands on [Toolbar](https://romanhaendler.github.io/umriss-ui/table/toolbar/#type-ToolbarProps).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`children`](https://romanhaendler.github.io/umriss-ui/table/toolbar/#ToolbarProps-children) | `ReactNode` | — | Search, column menu, export – on the left in the table toolbar, before the conditions. |

## Known limits

- Not with grouping or pagination: both would build or cut levels a second way. A large tree uses virtualisation.
- Not in [server mode](https://romanhaendler.github.io/umriss-ui/table/server-mode/), and no branches loaded on demand yet.
- One column set for every level; a level without a value shows it as absent.

What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/).
