Tree rows

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.

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

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.

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.

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.

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.

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.

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.

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.

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

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.

NameTypeDefaultDescription
density"regular" | "compact"the density of the UmrissProvider, else "regular"How tightly the rows stand.
childrenReactNode—Columns, RowDetail, RowActions and the unbound parts.

Accessibility

NameTypeDefaultDescription
ariaLabelstring—Accessible name of the table (not of the outer frame).

From TableOptions — the full table stands on First table.

NameTypeDefaultDescription
rowKeyrequired(row: Z) => string—A stable key per row – the basis of selection and expansion.
defaultSort| SortLevel
| readonly SortLevel[]
| null
nullThe application's sort; it is the default that view leaves out.

From TableSnapshot — the full table stands on First table.

NameTypeDefaultDescription
branchesrequiredreadonly string[]—Tree rows: the keys of the open branches.
toggleBranchrequired(key: string) => void—Tree rows: opens a branch or closes it.
unfoldAllBranchesrequired() => void—Tree rows: opens every branch that has children.
foldAllBranchesrequired() => void—Tree rows: closes every branch.

From FieldColumn — the full table stands on Column.

NameTypeDefaultDescription
valuerequiredK—A field of the row. Supplies the column's id at the same time.

From ColumnBase — the full table stands on Column.

NameTypeDefaultDescription
labelrequiredstring—Names the column in its header, in the column menu and in the header row of the export.
rowHeaderboolean—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.
searchablebooleantrue for text, false otherwiseTakes part in the search.

From AggregateOptions — the full table stands on Aggregate.

NameTypeDefaultDescription
aggregateAggregateFor<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.

NameTypeDefaultDescription
childrenrequired(row: Z) => ReactNode—What stands underneath an expanded row.

From ToolbarProps — the full table stands on Toolbar.

NameTypeDefaultDescription
childrenReactNode—Search, column menu, export – on the left in the table toolbar, before the conditions.

Known limits

What umriss deliberately does not build, and why: ADR-0032.

Every page of @umriss-ui/table

Getting started

Columns

Finding rows

Grouping

Rows

Around the table

Many rows

Editing

Limits and alarms

API index