developer guide
Custom Group Row Renderer for a Data Grid
A group renderer hands you the group key, its totals and its leaf count, and lets you draw the heading row your product needs. grid.rows.leavesOf() reaches the rows behind it, your own control can expand and collapse, and groupDefaultExpanded decides what is already open when the grid appears.
Developer guide › Columns and cell rendering › Custom Group Row Renderer for a Data Grid
Group rows you draw yourself
The grid's own group row is an expander, a label and a count. When the heading has to carry
more than that - a sprint section with a chevron, the section name, the points summed
across it, a done/total count and a progress bar - groupRenderer hands you
the whole row. It is drawn as one band across every column, over the pinned regions, and no
ordinary cells are mounted underneath it.
A section header with a rollup the grid was never told to compute
createGrid(element, {
columns,
rows,
groupRenderer: ({ value, leafCount, expanded, leaves }) => {
// `leaves()` is the group's own rows. Roll up whatever you like.
const rows = leaves();
const points = rows.reduce((sum, r) => sum + r.data.points, 0);
const done = rows.filter((r) => r.data.done).length;
const pct = Math.round((done / leafCount) * 100);
return `<span data-lat-group-toggle>${expanded ? '▾' : '▸'}</span>
<b>${value}</b> ${points} pts · ${done}/${leafCount}
<progress value="${pct}" max="100"></progress>`;
},
});
What the renderer is given
| Field | What it is |
|---|---|
| key | The group's key - the same string rows.expand() and rows.collapse() take, and the one group:toggled carries. |
| column | The id of the column this level groups on. It is stamped per rebuild from the grouping, not derived from the row's depth, so it stays correct when an outer grouping is removed and this level becomes the outermost. |
| value | The value this group stands for. |
| level | Depth of the group. Zero is the outermost. |
| expanded | Whether the group is open. Draw your chevron from this; the row is re-rendered when it changes. |
| leafCount | How many records sit beneath the heading, at any depth. Read this when the size is all you need. |
| totals | The group's own reductions by column id - whatever total asked for. Unaffected by drawing the row. |
| leaves() | The rows beneath the heading, computed when you call it. The members the filters left, in display order, so a rollup agrees with the rows drawn below. Also available on its own as grid.rows.leavesOf(key). |
| toggle() | Expand the group if it is closed, collapse it if it is open. |
| grid, row, element | The grid, the group row model, and the element to fill if you would rather write into it than return anything. |
A string here is markup, and in fullWidth.render it is text.
That difference is deliberate, and it is the difference between the two features. A
full-width row renders one of your data rows, and keeps host markup behind allowUnsafeTemplates everywhere a data value
reaches the page. A group heading has no data row at all - the grid synthesised it from
the grouping - so its string can only be a template you wrote in your own source. That
is the same footing the board's cardRenderer already stands on, and the header
this exists for (a chevron, a bar) is unwritable without it. If you interpolate a value that
came from outside your application, escape it yourself, or build a node and return that
instead.
Your chevron is yours to wire. The grid's own expander binds a click
handler to the button it built; a string cannot carry a handler, so that chevron would be
dead. Any element in your markup carrying data-lat-group-toggle expands or
collapses the group it sits in, and toggle() does the same from a node you built
yourself.
leaves() is a function on purpose. A group is unbounded and
the renderer runs as rows are painted. Handing every group an array of its members would cost
a hundred thousand rows on a heading nobody looked at. Call it when you need the rows; read
leafCount when you need the size.
Which groups start open
groupDefaultExpanded decides the state of a group before anyone has touched
it: true (the default) opens them all, false closes them all, a
number opens the first N levels, and a predicate answers per group. Once the user or your own
code expands or collapses a group, that decision stands - the default is not consulted
for it again, so a group cannot spring shut under the user on the next rebuild.
The current sprint open, everything else closed, executed
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
// The renderer the grid calls for each group row. Called here directly too,
// with the params the grid builds, so this block shows its actual output.
const groupRenderer = (p) => {
const rows = p.leaves();
const done = rows.filter((r) => r.data.done).length;
const points = rows.reduce((sum, r) => sum + r.data.points, 0);
return `${p.value} ${done}/${p.leafCount} ${points}pts ${p.expanded ? 'open' : 'closed'}`;
};
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'section' }, { field: 'points', type: 'number', total: 'sum' }, { field: 'done' }],
rows: [
{ id: 1, section: 'Sprint 12', points: 3, done: true },
{ id: 2, section: 'Sprint 12', points: 5, done: false },
{ id: 3, section: 'Backlog', points: 8, done: false },
],
groupRenderer,
// Per group, not merely per depth: only the current sprint starts open.
groupDefaultExpanded: (group) => group.value === 'Sprint 12',
});
grid.columns.group(['section']);
const out = [];
for (let i = 0; i < grid.rows.count(); i++) {
const row = grid.rows.get(i);
if (!row.group) continue;
out.push(groupRenderer({
row, key: row.key, column: row.groupColumn, value: row.groupValue,
level: row.level, expanded: row.expanded, leafCount: row.leafCount,
totals: row.totals, leaves: () => grid.rows.leavesOf(row.key),
toggle: () => {}, grid, element: null,
}));
}
grid.destroy();
return out.join(' | ');
grid.rows.leavesOf(key) is the same reach on its own, for a breadcrumb, a
side panel or a rollup computed away from the row: it returns the leaf rows beneath a group
heading, which is what leafCount counts.