Column groups and header bands
Build nested column groups with validated descriptors, configurable header bands, shared renderers, and accessible markup in React, Vue, and Angular grids.
Column headers can nest in groups: a group has a stable id and ordered children, each a nested group or a leaf column id. The hierarchy is caller-supplied, of any finite depth within configurable budgets, and the flat column API is unchanged. The header is a stack of bands with configured heights, shared by every pin region.
@gp-grid/core owns validation, the leaf order, the band layout, the header
runs and the anchor a band change preserves; a wrapper renders the published
fragments and bands and computes nothing.
Descriptors
interface ColumnGroupDefinition {
groupId: string; // distinct from every other group id and every column id
headerName?: string; // default: groupId
wrapHeaderText?: boolean; // default: false
headerRenderer?: string | ((params: ColumnGroupHeaderParams) => unknown);
children: readonly ColumnGroupChild[];
}
type ColumnGroupChild = ColumnGroupDefinition | string; // a string is a leaf ColumnIdEvery column of the schema, hidden ones included, is referenced exactly once, and an ungrouped column is a string at the root. Branches can have different depths, and the root can hold several groups. The grid never mutates the descriptors.
const columns = [
{ field: "id", cellDataType: "number", width: 80 },
{ field: "name", cellDataType: "text", width: 160 },
{ field: "age", cellDataType: "number", width: 80 },
{ field: "city", cellDataType: "text", width: 140 },
{ field: "salary", cellDataType: "number", width: 120 },
];
const columnGroups: ColumnGroupChild[] = [
"id",
{
groupId: "person",
headerName: "Person",
children: [{ groupId: "basics", headerName: "Basics", children: ["name", "age"] }, "city"],
},
{ groupId: "pay", headerName: "Pay", children: ["salary"] },
];That hierarchy has three bands: Person and Pay in band 0, Basics in band 1,
name and age in band 2, while id spans bands 0–2 and city and salary
span bands 1–2.
// React
<Grid columns={columns} columnGroups={columnGroups} headerBandHeights={[40]}
onColumnSchemaRejected={(error) => console.warn(error.message)} /><!-- Vue -->
<GpGrid :columns="columns" :column-groups="columnGroups" :header-band-heights="[40]"
:on-column-schema-rejected="(error) => console.warn(error.message)" /><!-- Angular: AngularColumnGroupChild[] -->
<gp-grid [columns]="columns" [columnGroups]="columnGroups" [headerBandHeights]="[40]"
(onColumnSchemaRejected)="onRejected($event)" />// Core
const core = new GridCore({ columns, dataSource, rowHeight: 32, columnGroups, headerBandHeights: [40] });Validation and errors
A hierarchy is validated in one iterative depth-first pass over an explicit
stack, so deep input never overflows the call stack. The pass returns the first
error it meets in traversal order. A group is checked for malformed, cycle,
multipleParents, duplicateGroup and idCollision, in that order; a leaf for
unknownLeaf or repeatedLeaf. missingLeaf is checked after the pass.
| Code | Meaning |
|---|---|
malformed | The root is not an array, or a child is neither a string nor an object with a non-empty string groupId and an array children. A group with no children is accepted. |
cycle | A group contains itself. |
multipleParents | The same group object appears under two parents. |
duplicateGroup | Two groups share a groupId. |
idCollision | A groupId equals a column id. |
unknownLeaf | A leaf names no column of the schema. |
repeatedLeaf | A column is referenced more than once. |
missingLeaf | A column of the schema is not referenced. |
limit | A budget is exceeded (see below). |
A rejected change returns { status: "rejected", error } with
ColumnSchemaError = { code, source, id?, limit?, message }. source is the
command it came from ("groups", "move", "pin" or "state"), id the group
or column the error names, limit the budget's name, and message the label
labels.columnSchemaErrors[code] formatted with {id} and {limit}. The
columns, the hierarchy, the layout and the bands stay as they were, the core
calls onColumnSchemaRejected(error) and publishes the message through the
polite live region it shares with the frozen-rows announcement.
At creation a rejected columnGroups leaves the grid flat and warns once
([gp-grid] columnGroups rejected, the grid stays flat: <message>), with no
callback and no announcement.
The core keeps the last valid hierarchy, but a wrapper keeps the prop it was
given and re-applies it with every later columns change, each time with another
callback and announcement. Restore a valid hierarchy after
onColumnSchemaRejected.
Budgets
columnGroupLimits bounds the work and the metadata a hierarchy can cost. Each
value is a positive safe integer, otherwise the core throws
RangeError("Invalid columnGroupLimits.<field>: <value>"). It is read at
creation.
| Budget | Default | Counts |
|---|---|---|
maxDepth | 64 | Groups above any group or column, so the band count is at most maxDepth + 1. |
maxNodes | 100,000 | Groups and column references in one hierarchy. |
maxFragments | 100,000 | Header runs across every band and region, after a change is laid out. |
maxDepth and maxNodes are checked as each node is visited, before anything is
stored, so an over-budget hierarchy is rejected after at most maxNodes + 1
visits and allocates nothing sized by it. maxFragments is checked against the
runs of the resulting layout. The accepted hierarchy is kept as a node table plus
the parent and depth of each leaf, proportional to the hierarchy.
A guarded command that would exceed maxFragments returns limit and changes
nothing. A change that is not guarded (a viewport width that admits a pin, a
width command, a fit or setLayout) and exceeds it keeps the band count and
publishes no fragments until the runs fit again.
The pre-mount seed (createInitialState, used for the server render and the
first client frame) adopts initialColumnGroups under the default budgets,
so with custom budgets the seed and the core can disagree about a hierarchy near
a limit until the core's first batch.
Bands and their heights
bandCount is 1 + the greatest depth of a visible leaf, taken from the whole
visible hierarchy, so scrolling never changes it. A leaf at depth d starts in
band d and spans to the last band, so every leaf header ends on the body
boundary. A group with no visible leaf renders nothing, and a hidden leaf
occupies no span.
Band b is headerBandHeights[b], else headerHeight; entries past the band
count are ignored. headerBandHeights is a GridCore option and a prop/input of
every wrapper, applied at runtime through core.header:
core.header | Effect |
|---|---|
getBands() | HeaderBandLayout = { count, heights, offsets, totalHeight }; offsets[b] is the top of band b. One band of headerHeight while flat; reused while unchanged. |
setBandHeights(heights) | Replace the whole list. A value-equal list emits nothing. A height that is not finite and > 0 throws RangeError("Invalid headerBandHeights[<band>]: <value>") and applies nothing. A no-op after destroy(). |
Every band change, of the heights or of the band count, is one atomic update: the
core captures the row at the clip top, lowers the body height by the header's
growth, refreshes the geometry and restores the anchor, so the first visible row
keeps its offset below the header. One batch carries SET_HEADER_BANDS,
SET_CONTENT_SIZE, SET_ROW_REGIONS, the slot instructions and any SCROLL_TO.
A header that grows until the frozen rows no longer fit lowers the effective
frozen count with limit: "viewport".
Wrapper surface
| Core | React | Vue | Angular | |
|---|---|---|---|---|
| Hierarchy | columnGroups option, columns.set, columns.setGroups | columnGroups prop | columnGroups prop | columnGroups input |
| Budgets | columnGroupLimits option | columnGroupLimits prop | columnGroupLimits prop | columnGroupLimits input |
| Band heights | headerBandHeights option, header.setBandHeights | headerBandHeights prop | headerBandHeights prop | headerBandHeights input |
| Rejection | onColumnSchemaRejected option | onColumnSchemaRejected prop | onColumnSchemaRejected prop | (onColumnSchemaRejected) output |
| Header wrap | wrapHeaderText on a column or a group | same | same | same |
columnGroups and headerBandHeights are reactive; columnGroupLimits is
creation-only. A custom adapter renders state.headerBands (from
SET_HEADER_BANDS, BatchChangeSetters.setHeaderBands) and
state.columnWindow.groups, never one from the other, and takes the header
height from headerBands.totalHeight. DisplayedColumn.headerBand is a leaf's
first band and ColumnLayoutSnapshot.bandCount the band count.
Limits
- Validation is O(nodes) per
columns.setorsetGroups; run building is O(displayed leaves × depth) once per layout; a scroll sample does a binary search per band over the center runs. - Depth is bounded only by
maxDepth; arbitrary depth does not promise constant work per visible column. - The run count is not public;
maxFragmentscounts runs, not mounted fragments. columnGroupLimitsandautoFitare creation-only.
For the complete API contracts and advanced examples, see the upstream column groups and header bands guide.