Tree Data
Rows that hold rows. Take a nested shape through getChildren or a flat one through getParentId, and a row whose parent a filter removed is lifted to the root rather than dropped.
The Nested Shape
data holds the roots, and getChildren reads the rest off each row. A child never appears in the array, only under its parent, so
no row is reachable twice.
import { createDataGrid, sorting, tree } from '@sv5ui/datagrid';
interface Unit {
id: string;
name: string;
kind: 'Department' | 'Team' | 'Person';
headcount: number;
children?: Unit[];
}
const units: Unit[] = [
{
id: 'eng', name: 'Engineering', kind: 'Department', headcount: 7,
children: [
{
id: 'core', name: 'Core Platform', kind: 'Team', headcount: 3,
children: [
{ id: 'p1', name: 'Ada Kowalski', kind: 'Person', headcount: 1 },
{ id: 'p2', name: 'Bruno Silva', kind: 'Person', headcount: 1 }
]
}
]
}
];
const grid = createDataGrid<Unit>({
columns,
data: units, // roots only
getRowId: (unit) => unit.id,
features: [
sorting<Unit>(),
tree<Unit>({
getChildren: (unit) => unit.children,
defaultExpandedDepth: 1
})
]
});The Flat Shape
The same hierarchy, described from the other end: one row per record, each naming its
parent. This is the shape a database gives you, so nothing has to be reshaped on the way in. data holds
every row here, roots and descendants alike.
interface Unit {
id: string;
name: string;
parent: string | null;
}
const units: Unit[] = [
{ id: 'eng', name: 'Engineering', parent: null },
{ id: 'core', name: 'Core Platform', parent: 'eng' },
{ id: 'p1', name: 'Ada Kowalski', parent: 'core' },
{ id: 'p2', name: 'Bruno Silva', parent: 'core' }
];
const grid = createDataGrid<Unit>({
columns,
data: units, // every row, roots and descendants alike
getRowId: (unit) => unit.id,
features: [
sorting<Unit>(),
tree<Unit>({ getParentId: (unit) => unit.parent, defaultExpandedDepth: 1 })
]
});
// Use getChildren or getParentId, never both.getChildren and getParentId describe the same hierarchy from opposite ends. The feature takes one of them. An Orphan Is Lifted, Not Dropped
Filtering runs before the tree is built, so it can remove a parent while keeping one of its descendants. Rather than dropping that descendant with the branch it hung from, the grid promotes it to a root.
Type Ito below. No Department or Team is called Ito, so every ancestor goes and three people are left standing at the root, one from each of three different teams. A search that found nothing while the row existed would be worse than one that shows it out of context.
const grid = createDataGrid<Unit>({
columns,
data: units,
getRowId: (unit) => unit.id,
features: [
filtering(),
// 99 so the whole hierarchy is open before the filter runs, which is what
// the grid above does. Promotion does not depend on it: a promoted row is
// a root, and roots are shown at any depth.
tree<Unit>({ getParentId: (unit) => unit.parent, defaultExpandedDepth: 99 })
]
});
// No Department or Team is called Ito, so every ancestor is filtered away.
grid.api.setQuickFilter?.('Ito');
grid.preWindowNodes.map((node) => [node.row.name, node.meta?.level]);
// [['Chi Ito', 0], ['Grace Ito', 0], ['Jonas Ito', 0]]
//
// Three people from three different teams, all at the root.The consequence is worth stating plainly: a filtered tree is not always a tree. Rows that were three levels deep can appear at the root, which is the honest answer to which rows matched.
Starting Depth
defaultExpandedDepth is a starting state, not a lock. Whatever the reader opens or closes afterwards is theirs,
and the depth is not re-applied.
It is read once, when the tree is first built, so it is not a live setting: changing it on a grid that already exists does nothing. The three buttons below swap between three grids, each built with its own depth.
tree<Unit>({ getChildren, defaultExpandedDepth: 0 }); // roots only
tree<Unit>({ getChildren, defaultExpandedDepth: 1 }); // and children
tree<Unit>({ getChildren, defaultExpandedDepth: 99 }); // everything
// It is read once, when the tree is first built, so offer a choice of depth
// by building a grid per depth rather than by changing this on a live one.
const grids = [0, 1, 2].map((depth) =>
createDataGrid<Unit>({
columns,
data: units,
getRowId: (unit) => unit.id,
features: [tree<Unit>({ getChildren, defaultExpandedDepth: depth })]
})
);Options
Everything tree() accepts.
| Option | Description |
|---|---|
getChildren | The nested shape: the children held on a row. data holds the roots only, so a row is never reachable twice |
getParentId | The flat shape: the id of a row’s parent, or null for a root. data holds every row |
defaultExpandedDepth | Levels open before the reader touches anything. 0 shows roots only. Read once when the tree is built, so it is not a live setting. Default 0 |
Accessibility
A grid holding a hierarchy is a treegrid,
not a grid. The role changes on its own, and so does what a screen reader is told about each
row. Nothing has to be configured.
What a Row Carries
Set by the grid, on every row of a hierarchy. A grouped grid carries the same, for the same reason.
| Attribute | What it says |
|---|---|
role="treegrid" | Replaces role="grid" on the scroller as soon as the rows form a hierarchy |
aria-level | How deep the row sits, counted from 1 |
aria-expanded | On a row that has children. Absent on a leaf, which is how a reader tells them apart |
aria-setsize / aria-posinset | How many siblings the row has, and which of them it is |
<!-- Nothing is configured for any of this. -->
<div role="treegrid">
<div
role="row"
aria-level="1"
aria-expanded="true"
aria-setsize="2"
aria-posinset="1"
>
Engineering
</div>
<div
role="row"
aria-level="2"
aria-expanded="false"
aria-setsize="3"
aria-posinset="1"
>
Core Platform
</div>
<div role="row" aria-level="2" aria-setsize="3" aria-posinset="2">
Infrastructure
</div>
</div>
<!-- ArrowRight opens a closed row then moves into it, ArrowLeft closes an
open row then moves out to its parent, Enter and Space toggle. -->A Tree the Server Owns
tree() builds a hierarchy out of rows the grid already holds, which means holding all of them. An
org chart of five thousand people is the case where that stops being reasonable, and the
branches have to arrive as they are opened.
This grid starts with five rows and no tree() feature at all. Open a team and its roles are fetched; open a role and its people are
fetched. Each request carries the path it is opening, so what comes back is one level, and
the count beside a closed row is the server's answer for everything behind it.
No data
The last few requests
- Nothing yet. Open a team.
// A tree the browser never holds in full. tree() builds one from rows it
// already has; this builds one from rows it has not asked for yet.
const grid = createDataGrid<ServerRow>({
columns, data: [], getRowId: (row) => String(row.id),
rowModel: 'server',
features: [
virtualization({ rowHeight: 40, overscan: 6 }),
serverRowModel(source, {
mode: 'infinite', blockSize: 100, placeholder,
groupBy: ['team', 'role'],
// Depth and the chevron. A leaf sits one below the deepest branch.
getRowMeta: (row) =>
isNode(row) ? { level: row.level, expandable: true } : { level: 2 },
// Opening a node asks for its children, identified by the whole path
// rather than by its own key: Manager under Design is not Manager
// under Core.
groupKeysOf: (node) => node.row.path,
// And closing it drops whatever is under that path again.
isChildOf: (node) => (row) => startsWith(pathOf(row), node.row.path)
})
]
});
// The endpoint answers whichever level it was asked for, because the depth
// is readable from the request: groupKeys shorter than groupBy means a
// branch was opened, and the two are equal at the leaves.
const { groupBy = [], groupKeys = [] } = await request.json();
if (groupKeys.length < groupBy.length) {
return json({ rows: groupLevel(within(rows, groupBy, groupKeys), groupBy[groupKeys.length]) });
}
return json({ rows: within(rows, groupBy, groupKeys) });Beside Other Row Features
Deep in a long branch, the parents of the row being read are off screen. Sticky Groups keeps them pinned under the header, one line per level, and it follows a tree the same way it follows groups.
tree() cannot be registered beside pivot(). A tree draws a hierarchy out of the very rows a pivot would gather, so the grid refuses to build with both.
Driving It From Code
import { getTree } from '@sv5ui/datagrid';
const treeState = getTree(grid)!;
treeState.expandAll();
treeState.collapseAll();
treeState.expandableIds(); // ids of the rows that have children
treeState.defaultExpandedDepth;
// The two counts the status bar reads. Both walk the hierarchy, so a nested
// tree is counted whole rather than by its roots, and neither moves when a
// branch is folded: collapsing is not filtering.
treeState.totalRows; // 17 here, however much is open
treeState.filteredRows; // 17, and 3 under the Ito filter above
grid.api.expandAllRows?.();
grid.api.collapseAllRows?.();
// Expansion belongs to the kernel rather than the feature, so it travels in
// a snapshot and a saved view reopens the same branches.
const snapshot = grid.api.getState();