TreeGrid
A DataGrid whose rows nest — accounts and sub-accounts, folders and files, a bill of materials — with expand/collapse on the row header and the grid's cell navigation everywhere else. DataGrid plus a hierarchy, not a new grid.
Basic usage
Install the package:
pnpm add @design-schema/react @design-schema/tokens Import the stylesheet once for the app, then the component:
import '@design-schema/tokens/calm-precise/css';
import '@design-schema/react/index.css';
import { TreeGrid } from '@design-schema/react'; Examples
import { type DataGridColumn, TreeGrid, type TreeGridRow } from '@design-schema/react';
const COLUMNS: DataGridColumn[] = [
{ key: 'account', header: 'Account', isRowHeader: true, width: 240 },
{ key: 'code', header: 'Code', width: 100 },
{ key: 'balance', header: 'Balance', align: 'end', sortable: true },
];
const DATA: TreeGridRow[] = [
{
id: 'assets',
account: 'Assets',
code: '1000',
balance: 1400,
children: [
{ id: 'cash', account: 'Cash', code: '1010', balance: 400 },
{
id: 'stock',
account: 'Stock',
code: '1020',
balance: 1000,
children: [
{ id: 'raw', account: 'Raw materials', code: '1021', balance: 600 },
{ id: 'finished', account: 'Finished goods', code: '1022', balance: 400 },
],
},
],
},
{
id: 'liabilities',
account: 'Liabilities',
code: '2000',
balance: 300,
children: [{ id: 'payable', account: 'Accounts payable', code: '2010', balance: 300 }],
},
{ id: 'equity', account: 'Equity', code: '3000', balance: 1100 },
];
<TreeGrid
caption="Chart of accounts"
columns={COLUMNS}
data={DATA}
defaultExpanded={['assets']}
/>Props
20 rows| Prop | Type | Default | Description |
|---|---|---|---|
| caption required | string | — | What the tree grid holds ("Chart of accounts"). Accessibility: aria-labelledby the caption / accessibilityLabel. |
| captionLevel | 2 | 3 | 4 | 2 | As DataGrid. |
| hideCaption | boolean | false | Visually hide the caption; it remains the accessible name. |
| columns required | array DataGridColumn[] | — | DataGrid's column model. The `isRowHeader` column is required here: it carries the indent and the expand button, so it must exist and come first after the selection column — never pinned, never reordered, because the guide lines are measured from its start. |
| data required | array TreeRow[] where TreeRow = { id: string; children?: TreeRow[] | "lazy"; [key: string]: unknown } | — | Nested rows. `children: "lazy"` marks a row whose children are loaded on expand through `onExpand`; the row shows the expand button and a loading state until `data` is updated. `children: []` is a leaf (no expand button, no aria-expanded). |
| expanded | array string[] | — | Controlled ids of expanded rows. A still-`"lazy"` id here is held collapsed until the user opens it, exactly as in `defaultExpanded`; the id stays in the caller's array and in what onExpandChange reports, and no onExpand fires for it. |
| defaultExpanded | array string[] | — | Initially expanded ids. `["*"]` expands every row whose `children` is a non-empty array, including rows loaded later, and never a `"lazy"` row (that would fire onExpand without a user act); `"*"` is honoured the same way in controlled `expanded`, and the first user toggle resolves it to concrete ids, which is what onExpandChange reports. A lazy id listed explicitly stays collapsed until the user opens it, in `defaultExpanded` and in the controlled `expanded` alike — so a lazy row cannot be opened programmatically at all; the caller opens it by replacing its `children`. The `*` key is a user act and does open lazy siblings, firing onExpand for each; the native expandAll action does the same over every loaded row. |
| sort | object { column: string; direction: "ascending" | "descending" } | — | Sort applies within each level; siblings are ordered, hierarchy is kept. |
| defaultSort | object { column: string; direction: "ascending" | "descending" } | — | As DataGrid. |
| selectable | none | row | cell | none | As DataGrid without `range` (rectangles across levels are not meaningful). `row` selection of a parent does not select its children unless `selectChildren`. Select-all (Ctrl+A, the select-all Checkbox) covers every loaded row at every level, expanded or not, with or without `selectChildren`. |
| selected | array string[] | — | As DataGrid. |
| defaultSelected | array string[] | — | Initially selected row ids. |
| selectChildren | boolean | false | Toggling a parent row sets or clears its own id and every loaded descendant; a parent's shown state is derived from its loaded descendants (checked when all, even if its own id is absent; indeterminate when some; else its own id), and toggling a row shown checked clears its id and descendants while any other state sets them. An indeterminate row reports aria-selected="false". Space, Enter on the select cell, a click on its Checkbox and Ctrl/Cmd+click are all the row's own toggle and cascade; only Shift+Space and Shift+click ranges do not — the keyboard block's Shift+Space fallback, a plain toggle of the focused row when the anchor is no longer visible, is one of those ranges and stays non-cascading. A `"lazy"` subtree contributes nothing until loaded. |
| editable | boolean | false | As DataGrid. |
| density | compact | comfortable | compact | As DataGrid, its rule for a grid with a selection column included: with `selectable: "row"` the rows are comfortable at both values, because the select Checkbox is a full minimum target and a compact row cannot hold one. |
| height | content | viewport | fixed | viewport | As DataGrid: `viewport` and `fixed` both size the whole component, and the scroll region takes what the caption and status bar leave. |
| loading | boolean | false | As DataGrid. |
| showStatusBar | boolean | true | As DataGrid. |
| stickyHeader | boolean | true | As DataGrid. |
| emptyMessage | string | — | As DataGrid. |
Events
7 rows| Event | Type | Description |
|---|---|---|
| onExpandChange | onExpandChange / expand-change | Fired with the new array of expanded ids (the bare array, as Tree; not wrapped in an object). |
| onExpand | onExpand / expand | Fired with its id (bare string) each time a row whose `children` is still `"lazy"` is expanded, so a failed load can retry; once the caller replaces `children` it never fires again for that row. It fires before the onExpandChange of the same act; `*` fires one onExpand per newly opened lazy row, then one onExpandChange, and nothing when no row opens. |
| onSortChange | onSortChange / sort-change | As DataGrid. |
| onSelectionChange | onSelectionChange / selection-change | As DataGrid (row ids or one cell). |
| onCellChange | onCellChange / cell-change | As DataGrid. |
| onEditStart | onEditStart / edit-start | As DataGrid. |
| onColumnResize | onColumnResize / column-resize | As DataGrid. |
Accessibility
Decisions this component leaves to you
Supply an accessible name. This component has no text of its own to fall back on, so without one a screen reader announces its role and nothing else.
Required props that carry accessibility meaning: caption. Each is described in the props table above.
Implicit ARIA role: treegrid.
Anatomy
What generation guarantees
Accessible name — Every instance carries a programmatic name. Where the component has no text of its own to use, the name is a prop the consumer supplies.
Keyboard operable — Everything the pointer can do here, the keyboard can do — see the keyboard section.
Arrow navigation — Arrow keys move between items; Home and End jump to the ends.
Roving tabindex — The group is a single tab stop; the arrow keys move within it.
Expanded state — Open and closed are exposed programmatically (`aria-expanded`), not only visually.
Visible focus — Keyboard focus is always drawn, from the theme’s focus tokens, and is never removed.
Selected state — Selection is exposed programmatically (`aria-selected` / `aria-checked`).
Live region — Changes are announced through a live region, without moving focus.
Contrast — Text and meaningful non-text contrast meets WCAG AA; the pairs are listed below.
Target size — Interactive targets are at least 24×24 CSS pixels (WCAG 2.2 Target Size, minimum).
Escape dismisses — Escape closes it from anywhere inside.
This component does not animate, so it has no reduced-motion behaviour to describe.