gp-grid-logo
Guides

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 ColumnId

Every 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.

CodeMeaning
malformedThe 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.
cycleA group contains itself.
multipleParentsThe same group object appears under two parents.
duplicateGroupTwo groups share a groupId.
idCollisionA groupId equals a column id.
unknownLeafA leaf names no column of the schema.
repeatedLeafA column is referenced more than once.
missingLeafA column of the schema is not referenced.
limitA 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.

BudgetDefaultCounts
maxDepth64Groups above any group or column, so the band count is at most maxDepth + 1.
maxNodes100,000Groups and column references in one hierarchy.
maxFragments100,000Header 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.headerEffect
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

CoreReactVueAngular
HierarchycolumnGroups option, columns.set, columns.setGroupscolumnGroups propcolumnGroups propcolumnGroups input
BudgetscolumnGroupLimits optioncolumnGroupLimits propcolumnGroupLimits propcolumnGroupLimits input
Band heightsheaderBandHeights option, header.setBandHeightsheaderBandHeights propheaderBandHeights propheaderBandHeights input
RejectiononColumnSchemaRejected optiononColumnSchemaRejected proponColumnSchemaRejected prop(onColumnSchemaRejected) output
Header wrapwrapHeaderText on a column or a groupsamesamesame

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.set or setGroups; 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; maxFragments counts runs, not mounted fragments.
  • columnGroupLimits and autoFit are creation-only.

For the complete API contracts and advanced examples, see the upstream column groups and header bands guide.

On this page