Skip to content

Row Grouping Plugin

The Row Grouping plugin organizes your rows into collapsible hierarchical groups. Perfect for organizing data by category, department, status, or any other dimension—or even multiple dimensions for nested grouping.

import '@toolbox-web/grid/features/grouping-rows';

The groupOn callback receives each row and should return an array representing the group path. For single-level grouping, return a one-element array. For multi-level grouping, return multiple elements (e.g., ['Region', 'Department']).

import { queryGrid } from '@toolbox-web/grid';
const grid = queryGrid('tbw-grid');
grid.gridConfig = {
columns: [
{ field: 'name', header: 'Employee' },
{ field: 'department', header: 'Department' },
{ field: 'salary', header: 'Salary', type: 'currency' }
],
features: {
groupingRows: {
groupOn: (row) => [row.department],
showRowCount: true,
defaultExpanded: false,
},
},
};
grid.rows = employees;
AnimationExpand/collapse animation
ExpandPass a specific key string to `defaultExpanded` to expand only that group initially
Show row countDisplay count of rows in group header
Accordion modeOnly allow one group open at a time
Indent width (px)Indentation per nesting level

Use groupRowRenderer to take full control of the group row’s contents. The renderer receives a GroupRowRenderParams object (key, value, depth, rows, expanded, toggleExpand) and can return an HTMLElement, an HTML string, or void to keep the default rendering. The framework adapters accept their native return types (ReactNode, VNode, or a component Type) and bridge them automatically.

grid.gridConfig = {
features: {
groupingRows: {
groupOn: (row) => [row.department],
groupRowRenderer: (params) => {
const arrow = params.expanded ? '▼' : '▶';
// The `group-toggle` class makes the element a click target for the
// plugin's delegated `closest('.group-toggle')` toggle handler.
return `<button type="button" class="group-toggle">${arrow} <strong>${params.value}</strong> (${params.rows.length})</button>`;
},
},
},
};

See GroupingRowsConfig for the full list of options and their defaults.

The defaultExpanded option controls which groups are expanded on initial render:

// Expand all groups
features: { groupingRows: { defaultExpanded: true, groupOn: ... } }
// Collapse all groups (default)
features: { groupingRows: { defaultExpanded: false, groupOn: ... } }
// Expand group at index 0
features: { groupingRows: { defaultExpanded: 0, groupOn: ... } }
// Expand group with specific key
features: { groupingRows: { defaultExpanded: 'Engineering', groupOn: ... } }
// Expand multiple groups by key
features: { groupingRows: { defaultExpanded: ['Engineering', 'Sales'], groupOn: ... } }

The animation option controls how grouped rows appear/disappear:

// Slide animation (default)
features: { groupingRows: { animation: 'slide', ... } }
// Fade animation
features: { groupingRows: { animation: 'fade', ... } }
// No animation
features: { groupingRows: { animation: false, ... } }

In accordion mode, only one group can be expanded at a time. Expanding a group automatically collapses all sibling groups at the same depth level.

features: {
groupingRows: {
groupOn: (row) => row.department,
accordion: true, // Only one group open at a time
},
},

Pass a function to apply accordion mode only at some depths. It receives the group’s depth (0 = top level):

features: {
groupingRows: {
groupOn: (row) => [row.region, row.department],
accordion: (depth) => depth === 0, // One region open at a time; departments open freely
},
},

Display aggregate values (sum, avg, count, min, max, first, last) in group headers. Works in both full-width and per-column rendering modes.

features: {
groupingRows: {
groupOn: (row) => row.department,
aggregators: {
salary: 'sum', // Sum of salaries in each group
bonus: 'avg', // Average bonus
id: 'count', // Count of rows
},
},
},

Built-in aggregators:

  • sum - Sum of numeric values
  • avg - Average of numeric values
  • count - Number of rows
  • min - Minimum value
  • max - Maximum value
  • first - First row’s value
  • last - Last row’s value

You can also provide a custom aggregator function:

aggregators: {
salary: (rows, field) => {
const total = rows.reduce((sum, r) => sum + r[field], 0);
return `$${total.toLocaleString()}`;
},
}

Return multiple values from groupOn to create nested group hierarchies. Row counts display correctly at every depth level.

features: {
groupingRows: {
groupOn: (row) => [row.region, row.department, row.team],
},
},

Row Grouping combines with the Tree plugin. Grouping then applies to every list of sibling rows: the root rows and the children of each expanded tree node. groupOn receives a second argument describing the list being grouped:

PropertyDescription
parentThe tree row whose children are being grouped, or null for the root rows
depthNesting depth of the rows being grouped (0 = root rows)

Return null for every row of a list to leave that level ungrouped:

features: {
tree: { childrenField: 'children' },
groupingRows: {
// Keep the root rows ungrouped; group each node's children by kind
groupOn: (row, { depth }) => (depth > 0 ? row.kind : null),
},
},

Groups under a tree node get keys prefixed with that node’s tree key ('plan-1::Cargo'), so the same group value under different parents expands independently. defaultExpanded: true also opens the top-level groups of each child list the first time it appears. setGroupOn(fn, true) opens the top-level groups of every list, the root rows and each child list.

Groupchildren: group each plan's rows by kind. every level: also group the plans by region.

With Group set to every level, groupOn returns row.region at depth 0 and row.kind below it, so the plans are grouped too.

The two plugins don’t know about each other. The Hierarchy plugin, which both depend on and which the grid attaches automatically, combines their output.

See GroupingRowsPlugin for the full list of methods.

const plugin = grid.getPluginByName('groupingRows');
plugin.expand('Engineering');
plugin.collapse('Engineering');
plugin.toggle('Engineering');
plugin.expandAll();
plugin.collapseAll();
plugin.isExpanded('Engineering'); // boolean
plugin.getExpandedGroups(); // string[]
plugin.setGroupOn((row) => [row.region, row.department]);
// Optionally pass an initial expansion that resolves against the *new* groups
// (issue #335). Avoids the stale-snapshot race when chaining setGroupOn → expandAll.
plugin.setGroupOn((row) => row.counterparty, true);
plugin.setGroupOn((row) => row.region, ['EMEA', 'AMER']);
plugin.refreshGroups();

The row grouping plugin supports CSS custom properties for theming. Override these on tbw-grid or a parent container:

PropertyDefaultDescription
--tbw-group-indent-width1.25em (~20px)Indentation per group level
--tbw-grouping-rows-bgvar(--tbw-color-panel-bg)Group row background
--tbw-grouping-rows-bg-hovervar(--tbw-color-row-hover)Group row hover
--tbw-grouping-rows-toggle-hovervar(--tbw-color-row-hover)Toggle button hover
--tbw-grouping-rows-count-colorvar(--tbw-color-fg-muted)Count badge color
--tbw-grouping-rows-aggregate-colorvar(--tbw-color-fg-muted)Aggregate value color
--tbw-toggle-size1.25emToggle button size
--tbw-font-size-xs0.7857emCount text size
--tbw-animation-duration200msExpand/collapse animation
--tbw-animation-easingease-outAnimation curve
tbw-grid {
/* Custom row grouping styling */
--tbw-group-indent-width: 1.5em; /* Wider indentation */
--tbw-grouping-rows-bg: #e8f5e9;
--tbw-grouping-rows-bg-hover: #c8e6c9;
--tbw-grouping-rows-count-color: #388e3c;
}

The row grouping plugin uses these class names:

ClassElement
.group-rowGroup header row
.group-toggleExpand/collapse button
.group-labelGroup name and value
.group-countRow count badge
.group-aggregatesContainer for aggregate values
.group-aggregateIndividual aggregate value
.tbw-group-slide-inChild row slide animation
.tbw-group-fade-inChild row fade animation
[data-group-depth="N"]Group nesting level (0-4)
.tbw-row-expandedApplied to a .group-row while it is expanded. Use this for theming expanded group rows instead of [aria-expanded="true"].

Click group headers to expand/collapse groups.

Event Log:
EventDetailDescription
group-toggle{ key, expanded, value, depth }Fired when a group is expanded/collapsed
group-expand{ groupKey, groupPath }Fired when a pre-defined group is expanded
group-collapse{ groupKey, groupPath }Fired when a pre-defined group is collapsed
group-load-error{ groupKey, groupPath, error }Fired when the ServerSide getChildRows() request for a group’s rows fails. See Server-Side Data.

Requires: ServerSidePlugin — see Server-Side Plugin

For server-side scenarios where group structure is fetched from the server and rows are loaded on demand, use ServerSidePlugin alongside Row Grouping. ServerSide returns group definitions as root data; whenever a group is expanded (by the user, toggle(), expand(), expandAll(), or defaultExpanded), the plugin fetches its rows via getChildRows().

import '@toolbox-web/grid/features/grouping-rows';
import '@toolbox-web/grid/features/server-side';
import { queryGrid } from '@toolbox-web/grid';
const grid = queryGrid('tbw-grid');
grid.gridConfig = {
columns: [
{ field: 'name', header: 'Employee' },
{ field: 'department', header: 'Department' },
{ field: 'salary', header: 'Salary', type: 'currency' },
],
features: {
serverSide: {
pageSize: 50,
dataSource: {
async getRows(params) {
// Return group definitions as root-level nodes
const res = await fetch(`/api/groups?start=${params.startNode}&end=${params.endNode}`);
const data = await res.json();
return { rows: data.groups, totalNodeCount: data.total };
},
async getChildRows(params) {
// Return rows for an expanded group
const { groupKey } = params.context;
const res = await fetch(`/api/groups/${groupKey}/rows`);
return { rows: await res.json() };
},
},
},
groupingRows: true,
},
};

Data flow:

  1. ServerSide fetches block → broadcasts datasource:data
  2. GroupingRowsPlugin claims data as pre-defined groups, merging each block at its node offset (startNode)
  3. On group expand (any path, including defaultExpanded and expandAll()) → fires datasource:fetch-children with { source: 'grouping-rows', groupKey }
  4. ServerSide calls getChildRows() → broadcasts datasource:children
  5. GroupingRowsPlugin receives rows and renders them under the group

More groups than pageSize: Top-level groups load in blocks, like flat ServerSide rows. Groups whose block has not loaded yet show as loading rows and take up scroll space. Scrolling to them (or loadThreshold prefetching) requests their block, and each block is added to the groups already loaded. Loaded groups keep their rows and expanded state while more blocks arrive. With defaultExpanded: true, groups that have not been shown before open as their block loads, while groups the user collapsed stay collapsed, even after a refetch. A server sort/filter change, refresh(), or purgeCache() starts over from a fresh set of groups.

Errors and cancellation: If getChildRows() rejects, the group leaves its loading state (it stays expanded, with no rows) and the grid fires group-load-error with { groupKey, groupPath, error }. Collapsing and re-expanding the group retries the fetch. A request that is still in flight is aborted (params.signal) and its result discarded when new group definitions arrive (a server sort/filter change, refresh(), or setGroups()), and when setGroupRows() / clearGroupRows() replaces or clears that group’s rows. After a server refetch, every group that is still expanded and had its rows loaded (or loading) requests them again under the new sort/filter, so it does not end up expanded and empty. A group that is expanded without rows (for example after a failed fetch) gets one automatic attempt per set of group definitions, so a failing group is not re-requested on every render. Collapsing a group does not cancel its request: the rows are cached for the next expand.

grid.on('group-load-error', ({ groupKey, error }) => {
console.error(`Rows for group ${groupKey} failed to load`, error);
});

If ServerSidePlugin is not loaded, use the imperative API to provide groups and rows directly:

import '@toolbox-web/grid/features/grouping-rows';
import { queryGrid } from '@toolbox-web/grid';
const grid = await queryGrid('tbw-grid', true);
grid.gridConfig = {
columns: [
{ field: 'name', header: 'Employee' },
{ field: 'department', header: 'Department' },
{ field: 'salary', header: 'Salary', type: 'currency' },
],
features: {
groupingRows: true,
},
};
const grouping = grid.getPluginByName('groupingRows');
// Lazy-load rows when a group is expanded
grid.on('group-expand', async ({ groupKey }) => {
grouping.setGroupLoading(groupKey, true);
const rows = await fetchGroupRows(groupKey);
grouping.setGroupRows(groupKey, rows); // also clears loading state
});
// Load groups asynchronously
const groups = await fetch('/api/groups').then(r => r.json());
grouping.setGroups(groups);
MethodDescription
setGroups(groups)Replace groups with an external structure
getGroups()Get the current group definitions
setGroupRows(key, rows)Populate rows for an expanded group
setGroupLoading(key, loading)Toggle loading indicator for a group
clearGroupRows(key?)Clear cached rows (specific group or all)

Groups can be nested using the children property:

const groups: GroupDefinition[] = [
{
key: 'engineering',
value: 'Engineering',
rowCount: 150,
children: [
{ key: 'frontend', value: 'Frontend', rowCount: 60 },
{ key: 'backend', value: 'Backend', rowCount: 90 },
],
},
];

A development-mode warning is shown if incompatible plugins are loaded together. Row Grouping does combine with Tree — see Grouping Tree Children.