Row Grouping Implementation in Vanilla-Grid
This document explains how multi-level, client-side row grouping is implemented in Vanilla-Grid: the group-key encoding, the renderEntries render projection that keeps caption rows out of the data-row contract, availability gating over a partial dataset, the keyboard/focus behavior of a group caption's toggle, and how an applied group level's column leaves the grid in exchange for a chip in the group bar (§ 18).
Grouping is opt-in and additive: a grid with no group column set behaves identically to a grid built before this feature existed, with zero extra allocation on the render path (see § 9).
Scope of this document. Grouping by any number of columns is implemented, applied entirely client-side. setGroupState() is the general-purpose multi-level entry point; groupByColumn()/addGroupLevel() are the two interactive, per-column actions the header menu offers — "Group by this column" (replace the whole state with one entry) and "Add to grouping" (append a trailing level) — see § 5. Applied group levels' columns are removed from the grid and represented as chips in a grid-owned group bar (§ 18); chip drag-reorder and header-to-bar drag-to-group remain possible future additions. Group aggregates are computed by the same scan that builds the projection and render into a group footer row (§ 4, § 7.1); they are configured declaratively through grouping.aggregates, at runtime through setAggregates(), or interactively from the header menu's "Aggregate" submenu (§ 5.2, § 5.3). Server-side grouping strategies are not implemented.
1. Feature Scope
Row grouping supports:
- Grouping by any number of columns, each ascending or descending, nested in the order requested
- A collapsible caption row per distinct value at each level, carrying the value and an exact member count; collapsing a caption hides its entire subtree (nested captions and data rows alike)
- Composing correctly with sorting, filtering, selection, column freeze/reorder/hide, and Excel export
- A group footer row per group per level, carrying each aggregated column's total in that column's own cell — rendered only while an aggregate is configured, and surviving collapse so that collapsing everything turns the grid into a summary table (§ 4, § 7.1)
- Removing each applied group level's column from the grid (its value lives in the caption, not in a column repeating it on every member row) and representing it as a chip in a grid-owned group bar above the header, with its direction and an ungroup action (§ 18)
- Explicit, reason-coded unavailability rather than a silently wrong or missing group — grouping never renders a caption whose count the current data source cannot prove
- Persisting the requested group state across reloads (§ 14)
Relevant options (grouping bag on VanillaGridOptions):
grouping.columns— initial group state; any number of ordered levels, outermost firstgrouping.expandMode—'expanded'(default) or'collapsed'— whether a newly-encountered group starts open or closed, untilexpandAllGroups()/collapseAllGroups()sets a collapse baseline that replaces it (§ 5.1)grouping.showCount— whether a caption's label includes its member count (defaulttrue)
Relevant persistence option (persistence bag on VanillaGridOptions):
persistence.groupState—{ enabled, storageKey }, mirroringpersistence.sortStateexactly (§ 14)
See also: Row Virtualization and Custom Scrollbars for the render pipeline grouping projects onto, and Sorting Implementation for the comparator machinery grouping reuses rather than re-implementing.
2. Internal State Model
features/grouping.feature.js defines a standalone VanillaGridGroupingFeature class — the same shape as VanillaGridSortingFeature: a config-injection object of getter/setter closures, instantiated once by vanilla-grid.js as this._grouping, with a subset of its methods copied onto the grid instance (groupingBindings, mirroring sortingBindings).
Fields owned by the feature instance:
_groupState: Array<{ key, direction }>— the requested grouping, an ordered list of levels (outermost first); any length_collapsedGroupKeys: Set<string>— canonical group identities toggled away from the effective default:_collapseBaselinewhen set, elsegroupExpandMode. Discarded when the last group level goes (D21, § 5.1); nothing else clears it wholesale exceptexpandAllGroups()/collapseAllGroups()_collapseBaseline: null | 'expanded' | 'collapsed'— set byexpandAllGroups()/collapseAllGroups(), which clear_collapsedGroupKeyswith it;nullfollowsgroupExpandMode. See § 5.1_projectionVersion: number— bumped every timerenderEntriesis rebuilt or spliced (§ 4)_projectedCaptionCount: number— the captions in the committed projection, the total the R6 ceiling bounds (§ 11); set by every full build and kept exact by every splice_aggregateExtremesStale: boolean— set by a splice, which does not maintain the per-column aggregate extremes; the nextgetAggregateExtremes()refolds them (§ 4)_lastStrategyResult: { available, strategy, reason }— used to detect an availability transition (D13)
Fields owned by the grid instance:
displayRows— unchanged: the pure, sorted data-row array every existing consumer (selection, export, autofit, callbacks,dataset.vnGridRowIndex) already reads.renderEntries— new. The virtualizer-only projection (§ 4). The same array reference asdisplayRowswhenever grouping is inactive._renderEntryDataIndex— new. The compact data-index mapping (§ 4).nullwhenever grouping is inactive.
3. Group-Key Encoding and Identity Strategy
3.1 Raw value resolution
The raw group value for a row is column._getValue(row) — the same precompiled accessor sort, export, and cell rendering already use (vanilla-grid.js's _compileColumnAccessor). No new value-extraction logic exists.
3.2 Boundary detection reuses the sort comparator
buildRenderEntries() (§ 4) delegates to _scanLevel(), a recursive boundary scan over displayRows (already sorted with every group level leading, § 6, outermost first). At level L, _scanLevel operates on a contiguous range that is already constant on levels 0..L-1 (guaranteed by the effective sort), so scanning column L alone within that range finds level L's boundaries correctly — no re-sorting, no re-scanning of already-fixed columns. Each run either recurses into level L+1 (if one exists and the caption isn't collapsed) or, at the deepest level, pushes its data rows. Traversing a branch and projecting it are separate decisions: _scanLevel() carries an emit flag, and a collapsed branch is recursed into with emit: false — every row visited, nothing pushed to renderEntries, the data-index map or the cardinality counter — when a group aggregate needs the rows that branch hides, since a collapsed group must still show its own total. That traversal happens only on a full build: a collapse-only rebuild (§ 4) reuses the totals already computed and does not enter a collapsed branch. With no aggregate configured, which is the ordinary case, a collapsed branch is not traversed at all and costs exactly one caption, as it always has. Boundary comparisons call the exact same comparator the effective sort used — resolved through VanillaGrid#_effectiveCompareValues(), which hands back the host's custom comparator when one was configured and otherwise the built-in locale-aware defaultCompareValues bound directly to the sorting feature — so a boundary can never disagree with the order the rows were just sorted into. Resolving to the built-in directly, rather than to the public grid.compareValues, matters only for speed and not for semantics: absent a host override grid.compareValues is a delegator defined in vanilla-grid.js that does nothing but forward to that same function, and a full build calls the comparator many times per group across the whole dataset. That hop is pure indirection, and an expensive one in --obfuscate builds, where vanilla-grid.js takes the heavy profile while this file is on build.js's BUNDLE_FAST_PATH (§ below) — measured ~3x on a 300k-row grouped rebuild. In particular, two values the default comparator treats as tied (most notably null and undefined, which sort together — see Sorting Implementation) end up in the same group, by design: grouping never invents an ordering distinction the sort itself doesn't make.
A run's end is found by a galloping search, not a walk. Within the range being scanned, column L's values are non-decreasing under that comparator, so the rows equal to a run's first value are contiguous and _findRunEnd() can locate the first differing row by probing start + 1, start + 2, start + 4, … until one differs, then binary-searching the last gap. A run of length r costs about 2·log₂(r) comparisons instead of r, and a run of one costs exactly one, as before: a low-cardinality level (a dozen countries over a million rows) stops paying for every row, while a high-cardinality one pays what it always did. The accumulate path is unchanged in what it reduces — the deepest level still feeds every row of the run into the open accumulators — it just no longer finds the boundary by comparing each of those rows. Every full build benefits (a group change, sort, filter, search or data change), as does Collapse all. The gallop relies on the precondition the linear walk tacitly relied on too: a host sorting.compareValues must be a consistent ordering — transitive, and the same one the sort used. An inconsistent comparator already leaves the sort undefined; under the walk it split one value into several same-identity groups, under the gallop it may merge or skip rows instead. No runtime monotonicity check guards this: it would cost the O(n) walk the gallop removes. The grid keeps the precondition across a column change too: when setColumns() changes how an applied group level's column orders (its accessor, type, …), it re-applies the effective sort before any projection is built over the rows (06 § 10.3).
A single mutable counter is shared across the entire recursion (every level, every branch), so the R6 cardinality ceiling (§ 11) bounds the total caption count across all levels combined, not per level.
3.3 Canonical identity
Each detected group gets one canonical identity string, computed from the group's full ancestor path (level 0 down to this level) on its first row, and used as the Set key for collapse state and as the groupKey reported on vn-grid-group-expanded/-collapsed:
pathEntries = [{ normalizedColumnKey, rawValue, column }, ...] // level 0 .. this level
identity = JSON.stringify(pathEntries.map(e => [e.normalizedColumnKey, encode(e.rawValue, e.column)]))
Identity is over the whole path, not just the leaf value — the same value at a nested level under two different parents (e.g. the same city name in two different countries) never collides, because each contributes a distinct path prefix.
encode() dispatches by value shape, not by naive String() coercion:
nullandundefinedeach get their own fixed tag — never coalesced with each other or with a real value- Temporal columns (
date/datetime/time) resolve throughVanillaGridDateTimeFeature.parseTemporalValue(...).numeric— aDateobject and an ISO string representing the same instant produce the same identity - Numbers and booleans use their own value directly
- Everything else (
string, and any customvalueGetterresult) locale case-folds viatoLocaleLowerCase()
This is a collision-free typed tuple, not a slash-joined display string: a value containing /, values of different JS types, and null never accidentally collide. One accepted approximation: case-folding alone is coarser than the sort collator's sensitivity: 'base' (which also folds accents) — two values the collator treats as equal but that differ by accent get distinct identities. This never causes an incorrect split (boundary detection, § 3.2, is what decides grouping — identity only affects how stable a collapse key looks across data changes).
Each caption entry also carries path: Array<rawValue> — the raw values from level 0 down to (and including) itself, in the same shape expandGroup()/collapseGroup() accept — so a caption's own toggle (_toggleCaptionEntry) can report the same path shape the public API does, not just its own leaf value.
4. The renderEntries Projection and Compact Data-Index Mapping
renderEntries interleaves caption descriptors and data-row references without ever replacing an entry displayRows would otherwise expose — renderCell(cell, value, rowData, dataIndex), double-click payloads, row-number rendering, and selection all continue to read displayRows directly and never see a caption.
- Data-row position:
renderEntries[i]is the same object reference as the correspondingdisplayRows[dataIndex]— zero extra allocation per row. - Caption position:
renderEntries[i]is a plain object tagged with a module-privateSymbol('vnGridGroupCaption'), never a string property a host row could already own.VanillaGridGroupingFeature.isCaptionEntry(entry)is the exported test. Shape:{ column, columnIndex, rawValue, groupIdentity, count, collapsed, level, dataStartIndex, path }—columnIndexis the level's position inallColumns(not in the visible array, which no longer contains it — § 18.1) —levelis the 0-based nesting depth (drives the CSS indent, § 7),dataStartIndexis where the branch starts indisplayRows(so[dataStartIndex, dataStartIndex + count)is exactly the rows it holds, whether or not it is collapsed — read by the scroll indicator, § 9), andpathis the raw values from level 0 down to this entry, the same shapeexpandGroup()/collapseGroup()accept (§ 3.3). - Footer position:
renderEntries[i]is a plain object tagged with a second module-privateSymbol('vnGridGroupFooter'), tested withVanillaGridGroupingFeature.isFooterEntry(entry). Shape:{ column, rawValue, groupIdentity, level, values, functions, dataStartIndex, count }—valuesis{ columnKey: finalized value }for this group (nullwhere nothing was reduced, never0), andfunctionsis{ columnKey: reducer name }, one object shared by reference across every footer of a build. A footer exists only while an aggregate is configured; with none, the projection is exactly what it always was. _renderEntryDataIndex[i]is the parallel compact mapping: the corresponding index intodisplayRowsfor a data-row position, or-1at a caption or footer position. This givesrenderVisibleRowsO(1) access to both the row object and its data index without a lookup, and lets_resolveNearestDataIndex(entryPosition)(rendering.feature.js) walk a bounded distance to answer "what's the nearest actual data row here" — used by the infinite-scroll prefetch threshold. The scroll-position indicator asks a different question of the same mapping (_countDataRowsThroughEntry(), § 2.10 of 02): not which materialized row is nearest, but how many rows of the dataset — collapsed ones included — lie at or before this entry.
When grouping is inactive or suspended, renderEntries is assigned the exact same reference as displayRows and _renderEntryDataIndex is null — see § 9 for why this is load-bearing, not just tidy.
buildRenderEntries()
Resolves every group level's column, then delegates to the recursive _scanLevel() (§ 3.2) starting at level 0 over the full displayRows range (already sorted with every group level leading, in order, § 6). For each detected run at a level: compute the exact member count (count = runEnd - startIndex — total data rows in that branch, regardless of how many nested levels sit beneath it), look up _isGroupIdentityCollapsed(), push one caption entry, then — if not collapsed — either recurse into the next level or, at the deepest level, push every member row. A fixed, exported ceiling (VanillaGridGroupingFeature.GROUP_CARDINALITY_CEILING) is checked before starting each new group at any level, against one shared running total; crossing it aborts the whole build without allocating past the ceiling's worth of captions (§ 11). A collapsed caption emits nothing for its subtree, which is what makes collapsing a parent hide all of its descendants; and with no aggregate configured it is not traversed either (§ 3.2). When an aggregate is configured, one footer entry is pushed per group after that block — so a collapsed group reads as its caption followed immediately by its own footer, while its subgroups' footers vanish with the subgroups. The position is the rule: nothing tests for collapse to decide it. Footers are deliberately not counted against GROUP_CARDINALITY_CEILING (§ 11), which bounds groups; a footer adds no group.
A collapse-only rebuild reuses the aggregate values. Expanding or collapsing changes which entries are projected, never which rows belong to which group, so every value a recomputation would produce is identical to the one it replaces — and for a reducer like countDistinct on a high-cardinality string column, recomputing costs seconds. expandAllGroups()/collapseAllGroups(), and a single-group toggle whenever it cannot splice (below), therefore call buildRenderEntries({ reuseAggregates: true }). In that mode the scan opens, feeds and finalizes no accumulator, follows the no-aggregate traversal rule (a collapsed branch is not entered), and takes each footer's values from the stored results map (§ 5.2), which already holds every group — hidden children of collapsed parents included — so a group revealed by an expand has its values waiting. The per-column extremes are refolded over the footers the rebuild projects, with the same fold helper a full build uses, because they size a column to what it displays and collapse changes that. The results are reused only while they were computed for the current configured set: a full build records the _aggregates array it ran against by reference, and every change to the set (setAggregates(), the grouping prune, normalizeAggregatesForColumns()) assigns a new array. Every other rebuild — new rows, a reorder, a filter or search, a group change, an aggregate change — is a full build and refreshes them. A projected group missing from the stored results falls back to a full build in the same call, as a correctness backstop. One consequence: a host that mutates row objects in place without setRows() does not see totals change on the next toggle — no more than it sees caption counts change. Like the splice below, a collapse-only rebuild commits through _rerenderInPlace(), so Expand all / Collapse all re-size the scrollbar thumb — and show or hide the vertical track — in the same pass, without waiting for a scroll.
A single-group toggle splices
Toggling one group — a caption's own toggle (_toggleCaptionEntry(entry, positionHint)) or expandGroup()/collapseGroup() on a projected caption — changes exactly one contiguous block of the projection, so _spliceToggle() edits that block instead of rebuilding everything. Its cost is O(the group's block) plus an O(n) pointer copy of the two arrays, with no comparator or accessor call outside the block. At a million rows that takes a toggle from ~0.6 s as a full rebuild to a few tens of ms (Performance Analysis § 6.7). The contract is that the result is identical to buildRenderEntries({ reuseAggregates: true }) for the same state: data rows by reference, captions and footers field by field, and the projected caption count. tests/node/grouping-toggle-splice.test.js checks exactly that after every step of randomized toggle sequences, across one to three levels, with and without aggregates, both groupExpandModes and both baselines.
- Locating the caption. A click passes the entry index it resolved the caption from (
data-vn-grid-entry-index, § 12); it is used only whilerenderEntriesstill holds that entry there. Otherwise — and for the path API — one pass over the projection tests_renderEntryDataIndex[i] === -1and then the caption'sgroupIdentity: no comparator, no value read. The grid injects the map through therenderEntryDataIndexgetter. - Collapse removes the entries after the caption up to the first caption or footer at its level or above. With an aggregate that stop is the group's own footer, which stays, exactly as a full build leaves a collapsed group as caption + footer. The captions removed are subtracted from
_projectedCaptionCount. - Expand runs the ordinary
_scanLevel()over the caption's own range[dataStartIndex, dataStartIndex + count)at the next level (or, at the deepest level, pushes those rows), with the path entries rebuilt from the caption'spathand the reuse aggregate context — so nested captions resolve their state, and nested footers their stored values, exactly as in a full build. The block is inserted right after the caption, ahead of the group's own footer. - Committing. The caption is replaced by a fresh copy with
collapsedflipped; every other entry is reused. The new arrays are built withslice/concat— neversplice(p, 0, ...block), whose spread overflows the argument limit on a large block — and nothing is written until both are complete. Then_setRenderEntries(),_projectionVersion++, and the same in-place re-render a full rebuild runs (_rerenderInPlace()): rebuild the pool, force-render it, and re-size the vertical scrollbar thumb, whichrenderVisibleRows()never touches (see 02 § 3). Without that last step the thumb keeps the size of the previous projection until the nextscrollevent. - Falling back. The splice is an optimisation over the full rebuild, which stays: it is taken whenever grouping is not applied (no data-index map), the caption cannot be found at a trusted position, or the reuse aggregate context is missing or reports
aggregate-cache-miss. - Aggregate extremes are lazy after a splice. Keeping them current eagerly would take a pass over every projected footer per toggle — the dataset-sized term the splice removes. A splice marks them stale instead, and the next
getAggregateExtremes()(auto-fit is its only reader) refolds them from the projected footers with the same_foldAggregateExtreme()rule and in the same order a full build uses. Full builds keep folding eagerly and clear the flag.
5. Public Grouping API
grid.groupByColumn(key, { direction }) // REPLACES the whole state with this one entry ("Group by this column")
grid.addGroupLevel(key, { direction }) // APPENDS a new trailing level ("Add to grouping")
grid.ungroupColumn(key) // remove that column's level, wherever it sits, leaving other levels intact
grid.clearGrouping() // remove grouping unconditionally
grid.getGroupState() // [{ key, direction }, ...] — defensive copy, the REQUESTED state
grid.setGroupState(state) // the general-purpose multi-level entry point: replace the full state, any number of ordered levels
grid.expandGroup(path, { descendants }) / grid.collapseGroup(path, { descendants }) // path: raw values from level 0 down to the target, e.g. ['Italy'] or ['Italy', 'Rome']; descendants (optional): 'collapsed' | 'expanded', applied to every group below it (§ 5.1.1)
grid.expandAllGroups() / grid.collapseAllGroups()
grid.canGroupByColumn(key) // -> { available, strategy, reason } — O(1); can THIS REPLACE the whole state right now?
grid.canAddGroupLevel(key) // -> { available, strategy, reason } — O(1); can THIS BE APPENDED as a new level right now?
grid.getGroupingStatus() // -> { available, strategy, reason } — is the CURRENT requested state APPLIED right now?
grid.getAggregates() // [{ key, fn }, ...] — defensive copy of the configured aggregates
grid.setAggregates(state) // -> { available, reason } — atomically replace them
grid.getGroupAggregates(path) // -> { columnKey: value } — one group's computed totals, same path shape as expandGroup()
grid.canAggregateColumn(key) // -> { available, reason, functions } — O(1); can THIS column carry one right now?
VanillaGrid.registerAggregate(name, reducer) // static; extends the reducer registry by NAME
groupByColumn, addGroupLevel, ungroupColumn, clearGrouping, and setGroupState all return a { available, strategy, reason } capability result reflecting the outcome of the call — including 'too-many-groups' when the requested grouping was rejected (§ 11). getGroupState() reports what was requested, independent of whether it is currently applied; getGroupingStatus() answers the applied half of that question, resolving the strategy fresh with no side effects and no row scan — see § 10 for why those two questions are answered by different calls. The group bar reads it to choose between its normal and suspended presentation (§ 18).
Two interactive actions build multi-level grouping, plus the general-purpose setGroupState(). groupByColumn(key, options) always replaces the entire state with one entry — this is what "Group by this column" does, and it's unavailable ('multi-level-not-yet-supported' via canGroupByColumn()) while grouped by a different column, because replacing would silently discard those other levels. addGroupLevel(key, options) always appends key as a new trailing level instead — this is "Add to grouping", gated by canAddGroupLevel(), which is only available once some grouping already exists and key isn't already one of its levels. Together, these two per-column actions are how a user builds a multi-level grouping through the header menu, one column at a time, with no ambiguity about replace-vs-append at any single click. setGroupState() remains the direct, general-purpose way to set the whole state at once (any number of levels, or to reorder/replace them wholesale) — what a future drag-to-group drop area would call, and what addGroupLevel()/groupByColumn() themselves are built on. ungroupColumn(key) removes exactly that column's level from an N-length state (wherever it sits), leaving every other level in place and in order — with a single-entry state this is identical to clearing grouping entirely.
5.1 Collapse state lives exactly as long as the grouping does (D21)
_collapsedGroupKeys is scoped to the grouping that produced it. setGroupState() clears it whenever the call leaves the grid with no levels at all — clearGrouping(), the bar's "Ungroup all", the header menu's "Ungroup all", or ungroupColumn() on the last remaining level. Ungrouping is the user saying they are done with this grouping; grouping by the same column again afterwards is a fresh start, and must honour groupExpandMode rather than silently resurrecting collapse state the grid has been showing no trace of since the chip left. Identity encodes the column key and the raw value (§ 3.3), so a retained set would match again on the next groupByColumn() of that same column and re-collapse those groups from state the user has no way to see or reach. It would also disagree with a reload, which never restores collapse state (the set is not persisted — § 14).
Expand all / Collapse all set a baseline, not a list. The set cannot express "every group": a collapsed parent projects no nested captions, so the groups under it are not in renderEntries to be listed, and enumerating them would mean a boundary scan of every row. expandAllGroups()/collapseAllGroups() therefore set _collapseBaseline to 'expanded'/'collapsed' and clear the set; _isGroupIdentityCollapsed() resolves a group against _collapseBaseline when it is set and groupExpandMode otherwise, with membership in the set flipping it. That covers every group at every level in O(1), and single-group toggles keep working unchanged against the new default. It also means a group that appears later — new rows, a filter bringing rows back, a re-nest — starts in the baseline state rather than in groupExpandMode: after Expand all, a grid configured 'collapsed' projects like an 'expanded' one. Scrolling cost is unaffected (virtualization renders a fixed pool), and data-change rebuilds cost what they cost on an 'expanded' grid (tests/playwright/scrolling-performance-grouped.spec.js). On a grid with no grouping requested both calls are no-ops, so no baseline can outlive D21 into the next grouping.
A group whose caption is not projected changes state only. expandGroup(['Italy', 'Rome']) while Italy is collapsed flips Rome's entry in the set, fires its event and returns true, but touches neither the projection, _projectionVersion nor the pool: nothing on screen shows Rome. When Italy is later expanded, its splice reads the set and projects Rome in its new state.
A refused expand changes no state. An expand that would cross the R6 ceiling (§ 11) — reachable under a collapsed default — commits nothing, so the set flip (or, for expandAllGroups(), the baseline and the cleared set) is undone with it: the call returns false, fires no event, and the state keeps describing what is on screen.
The baseline follows exactly the set's lifetime: cleared with it by a full ungroup, kept in the three cases below.
The rule is deliberately narrow. Four cases keep the set:
- Removing one level of a multi-level grouping.
Country ▸ City→Countryleaves['Italy']meaning the same group it meant before, so its collapse state is still about something real. - Re-nesting (§ 18.12). The level count is unchanged, so the set survives the call — but every identity in it is invalidated by the new path prefixes anyway, so the captions come back at the expand-mode default regardless.
- A D13 suspension (§ 10.3). The grouping did not go away; the dataset went partial. Nothing calls
setGroupState(), and resume brings every previously-collapsed group back collapsed, by design. - A column retype.
setColumns()never touches the set, but identity encodes the value by type (§ 3.3), so a grouped column goingnumber→stringorphans every collapsed key exactly as a re-nest does: the captions come back at the expand-mode default. Accepted — a retype is a new grouping in all but name.
An empty state resolves to 'disabled' — never to 'no-visible-columns-left' or 'too-many-groups', the two reasons that revert _groupState — so the clear can never run for a call that did not take.
5.1.1 Subtree state: descendants and the caption's Shift-click
expandGroup(path, { descendants }) / collapseGroup(path, { descendants }) set the group at path to the method's state and every group below it, at every depth, to descendants ('collapsed' or 'expanded'). Without descendants only the target changes, exactly as before.
| call | result |
|---|---|
expandGroup(p, { descendants: 'collapsed' }) |
p open, everything below folded — a summary of its child groups |
expandGroup(p, { descendants: 'expanded' }) |
p and everything below open |
collapseGroup(p, { descendants: 'collapsed' }) |
p and everything below folded |
collapseGroup(p, { descendants: 'expanded' }) |
p folded; everything below recorded open, shown when p is next opened |
On a group at the deepest level there is nothing below, so descendants changes only the target.
The caption's Shift-click. Shift-click or Shift+Enter on a caption with child groups always leaves that group open; what it changes is everything below it, decided by what is on screen:
| before | after |
|---|---|
| collapsed | open, everything below folded |
| open, any direct child open | open, everything below folded |
| open, every direct child folded | open, everything below open |
So repeated Shift-clicks on an open group switch its children between all folded and all open, and every one has a visible effect. The direct children decide, not every descendant: if a hidden grandchild still open counted, a group whose children are all folded would get "fold everything" — a click that changes nothing on screen. A caption at the deepest level has no child groups, and there Shift-click is a plain toggle. Alt, Ctrl and Meta are not part of the gesture, so a click with any of them is a plain toggle. The caption's own toggle folds the group itself, as always — its children stay folded, so the next plain click brings the summary back.
Why Shift and not Alt. Alt-click is the Finder / DevTools convention for disclosure triangles, but Linux window managers take Alt+click for moving windows — Xfce by default — so the page never receives it there. Ctrl-click is the context menu on macOS, and Meta-click is taken by several desktops too. Shift reaches the page everywhere and names the same key on every platform, so one hint text serves them all. The grid gives Shift no other meaning on a caption: row selection does not use it, and captions are not selectable rows. A Shift+press also extends the page's text selection to the pointer, which would highlight every row in between once a host makes cell text selectable (--vn-grid-cell-user-select: text), so handlePointerDown cancels a Shift press on a caption toggle; the click still fires.
Each descendant's key is written, rather than a second baseline. The call enumerates the identities of every group below the target and flips each one in _collapsedGroupKeys relative to the effective default, exactly as a single toggle does. A per-subtree baseline would have made the call O(1), like Expand all, but every _isGroupIdentityCollapsed() lookup would then walk the group's ancestors, and a later single toggle inside the subtree would have to be ordered against it. Explicit keys keep one rule for every reader (_scanLevel, _spliceToggle, D21 clearing). The enumeration is an identity-only walk of the target's displayRows range with the same galloping run search as the build (§ 3) — no entries, no aggregates — so it costs O(groups in the subtree × log run). The range comes from the caption when it is projected, and otherwise by descending from level 0 run by run, which is what lets the call reach a group under a collapsed ancestor. Path entries carry each run's own first value, as _scanLevel() does, so the keys written are the ones a build reads.
All or nothing, bounded by the ceiling. A subtree holding more than GROUP_CARDINALITY_CEILING groups is refused for either value of descendants: an open result would be refused by the projection anyway, and a folded one would otherwise write an unbounded number of keys — a million unique leaf values under one group would stall the main thread. Past that, an open result is re-projected through _scanLevel, which enforces the ceiling on the projected caption count as it always has. Either refusal undoes every key the call flipped: it returns false, fires nothing, and the state keeps describing the screen. The call is also refused while the grouping is not applied (a D13 suspension) or a group reorder is still pending: displayRows is not in this grouping's order then, so its runs are not this grouping's groups.
One commit. A target that ends collapsed has its block removed by the ordinary splice. One that ends open has its block re-projected by _spliceToggle() — for an open target (the common Shift-click) that removes the old block and scans the new one in the same commit, so the pool re-renders once, and the descendants appear in their new states. A target that is not projected changes state only, as a single toggle under a collapsed ancestor does. The aggregate reuse context rides along unchanged: no reducer runs.
One event per call — typed by the target's resulting state, with descendants naming what was applied below it (§ 13). A Shift-click on an open group therefore fires vn-grid-group-expanded although the group itself was already open.
5.2 Configuring aggregates
An aggregate is a named reduction over one column's values — { key, fn }, where fn is always a registered reducer name, never a function. Seven functions ship built in — sum, avg, median, min, max, countTrue, countDistinct — each offered on the column types listed in § 5.2.1. That "always a string" property is the one the rest of the surface rests on: the configured set is plain serializable data in every direction, which is what makes the persisted form, the event payload and the .d.ts declarations unremarkable.
There is no cross-column expression language. A reducer's step(acc, value) receives a value, not a row, so price * quantity has nowhere to live — deliberately, since handing it the row would quietly dismantle the single-column type gate and the per-column acquisition model together.
new VanillaGrid({
grouping: {
columns: [{ key: 'country', direction: 'asc' }],
aggregates: [{ key: 'projects', fn: 'sum' }]
}
});
grid.setAggregates([{ key: 'projects', fn: 'sum' }]); // -> { available: true, reason: 'ok' }
grid.getGroupAggregates(['Italy']); // -> { projects: 1204 }
grouping.aggregates sits in the same options bag as grouping.columns rather than on the column definition, because an aggregate belongs to the grouping session, not to the column: it is created when grouping becomes active and discarded when the last level goes. Nesting it under grouping makes that lifetime structural instead of documented, and makes "aggregates without any grouping" visibly incomplete at the call site. Like columns it is a default, not a lock (§ 14), and it is parked until setColumns() resolves the columns — applied after the group state, since there is nothing to attach to before the levels are in force. There is no <vn-grid> attribute for it; the key:fn grammar is kept parallel to key:direction so one could be added later without a redesign.
Every refusal is resolved inside setAggregates(), never at render time. The call is atomic: a malformed entry, an unknown column, an unregistered function or a function the column's declared type does not admit rejects the whole call and leaves the configured set exactly as it was. Two entries naming the same column are not a refusal — the last one wins, the same "a second function replaces the first" rule a per-column menu would apply. Clearing is setAggregates([]), accepted in every state.
reason |
Meaning |
|---|---|
'ok' |
Accepted |
'column-not-found' |
The entry names no column, or is malformed |
'unknown-function' |
fn names no registered reducer |
'column-unsupported' |
The reducer does not accept the column's declared type |
'no-grouping' |
Nothing is grouped; there is no group to reduce |
'disabled' |
A grouping is requested but not currently applied (a § 10.3 suspension) — the existing set is retained, but not editable |
The column-level codes resolve before the grid-level ones: an untyped column in an ungrouped grid is ineligible twice over, and the reason that will not change when the user groups is the one worth reporting. The gate reads the column's declared type, never its runtime values, so the same column admits the same functions on every page of the same dataset — an untyped or valueGetter column offers nothing.
A host reducer is registered by name. VanillaGrid.registerAggregate(name, reducer) extends the registry component-globally with the same shape the built-ins have, so a registered function participates in the type gate rather than bypassing it:
{
init(context) → acc,
step(acc, value) → void, // once per row per OPEN group level
finalize(acc) → value | null, // null renders blank, never 0
types: string[], // the eligibility gate
prepare?(context) → ((value) => value) | null,
resultType?: 'column' | 'count', // default 'column'
}
context = Object.freeze({ column, compare(a, b) })
contextis built once per aggregated column per projection build, in_buildAggregateContext(), never per row.comparewraps the samecompareValuesFn(a, b, column)_scanLevel()splits groups with, so a reducer that needs "equal" means what grouping means.prepare(context)is a factory, not a per-row hook. Called once per column per build, it returns a per-row mapper ornull. The walk folds the mapper into the column's read when it builds the plan —spec.read = mapper ? (row) => mapper(getValue(row)) : getValue— so a column that needs no conversion reads through the compiled accessor itself, with no added call or branch per row, and one that does is converted once per row, before the per-level loop, however deep the grouping is.resultTypenames the domainfinalize()returns a value in.'column'is the column's own: formatted by its formatter and aligned by its type (sum,avg,median,min,max).'count'is a non-negative integer outside the column's domain: formatted withformatting.formatIntegerand right-aligned as a number whatever the column's type (countTrue,countDistinct) — a count shown in adatecolumn would otherwise read as 01/01/1970.- State size is the reducer's business. The walk is streaming —
step()runs once per row per open level — but nothing requires O(1) state; the built-ins are O(1) exceptmedianandcountDistinct. - A malformed
prepare(not a function) orresultType(not one of the two) is refused like any malformed reducer.step()is never handed the column or the row: the column would repeat the parse on every open level, and the row would reopen cross-column formulas.
// Sample standard deviation (Welford), skipping gaps like the built-ins.
VanillaGrid.registerAggregate('stdev', {
init: () => ({ n: 0, mean: 0, m2: 0 }),
step: (acc, v) => {
if (typeof v !== 'number' || Number.isNaN(v)) return;
acc.n++;
const d = v - acc.mean;
acc.mean += d / acc.n;
acc.m2 += d * (v - acc.mean);
},
finalize: (acc) => (acc.n > 1 ? Math.sqrt(acc.m2 / (acc.n - 1)) : null),
types: ['number'],
});
step() runs on the main thread — host code cannot be transferred to the sort Worker, so unlike a sort there is no offload path, and the shimmer is what covers the cost rather than the Worker. finalize() returning null renders a blank cell; a group with nothing to reduce reports null and never 0, which would be indistinguishable from a legitimate zero total.
5.2.1 The built-in functions
Registered in this order, which is also the order the header menu's flyout lists them in:
column.type |
sum |
avg |
median |
min |
max |
countTrue |
countDistinct |
|---|---|---|---|---|---|---|---|
number |
✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ |
date / datetime / time |
— | — | — | ✓ | ✓ | — | ✓ |
string |
— | — | — | — | — | — | ✓ |
boolean |
— | — | — | — | — | ✓ | — |
uuid / uid |
— | — | — | — | — | — | ✓ |
untyped / valueGetter without a type |
— | — | — | — | — | — | — |
The gate still refuses what cannot be computed, not what is not useful — avg over an id is offered. The deliberate gaps: no temporal avg/median (an average birth date almost never means anything, and an even-count median of two dates is a date nobody has), no string min/max, and no countDistinct on boolean (it can only be 0, 1 or 2; countTrue says something useful instead).
sum,avg,median,min,maxonnumberskip non-numbers andNaNrather than coercing them, and the count of values seen — not a starting value — decides emptiness, so a group holding a realInfinitystill reports it. Skipping matters more foravgthan forsum: anullcounted as zero would pull the average down.medianis exact. Each open level keeps its own values (peak memory ≈ largest open group × depth × median columns, released as each group closes), andfinalize()runs a Hoare quickselect in place on them — average O(n), and its equal-to-pivot handling keeps a column of few distinct values from degrading. An approximate median (t-digest, P²) would show a number that is not the median of the rows above it.- Temporal
min/maxare the same two reducers, registered for both families so the menu shows one "Minimum". Theirprepare()returnsnullfor anumbercolumn (today's path, untouched) and, for a temporal one, a mapper through the datetime feature'sparseTemporalValue()into its{ kind, numeric, raw }envelope — the same parse the column's cells and sort use, sosourceFormat,inputPatternand epoch numbers read identically.'null'/'invalid'envelopes are gaps.finalize()returns the winning row's raw value, not its epoch, so the footer is formatted by the same path as that row's cell; an epoch would misformat anepochSecor strict-pattern column. Two speedups keep the mapper cheap: a finite number in adate/datetimecolumn that is notepochSecalready is the epoch the parser would produce and passes straight through with no envelope, and string parses are memoized per build — until the memo holds 10,000 strings, at which point the column has more distinct values than a memo helps with and it is dropped. countTruemaps each value throughVanillaGridColumnAccess.coerceBoolean(value, column), so the column'sbooleanCoerceapplies —'Yes'/'No'count under'loose', a strict column counts only real booleans, exactly as its cells render them.0is a real answer (known values, none true); only a group with no known value is blank.countDistinctcounts with grouping's equality, not===. Grouping splits groups where the comparator returns non-zero, and for strings that is anIntl.Collatorwithsensitivity: 'base'— soRome,romeandRómeare one group, and a===count would report "3 distinct cities" beside a City grouping showing one. ASetcannot express a collator's equality by itself, so the work is split:step()adds each non-missing value (null,undefinedand''are skipped) to an exact-valueSet, andfinalize()sorts only those exact uniques withcontext.compareand counts the boundaries — the collator sees 13 city names however large the group is. When every unique is a finite number in anumber,dateordatetimecolumn,===already matches the comparator and the sort is skipped. Not in atimecolumn, where two epochs on different days can share a time of day.
Every level reduces raw rows, never a child level's result, so these are the true values over each group — an outer avg is not the average of its children's averages, an outer median not the median of their medians, and an outer countDistinct the size of the union, not the sum.
Cost. Per function, one aggregated column over 1M rows adds, on top of the grouped projection build (Node harness, agg-shipped.js-style, VG_MODE=functions): sum, avg, min, max ≈ 30–60 ms; median ≈ 1.2–1.8× sum; countTrue and low-cardinality countDistinct ≈ 60–110 ms; temporal min/max over epoch numbers ≈ sum; countDistinct over 1M unique numbers ≈ +0.1–0.4 s (the comparator sort is skipped). End to end in Chrome, on grid-minimal-js at 1M rows grouped Country ▸ City, a whole aggregate change including the rebuild settles in ≈ 0.8–0.9 s (Median of Height + Distinct count of Department) and ≈ 0.9–1.2 s (Minimum of Hire Date + True count of Active), identically in the source and the --obfuscate build — every file on the walk's path is on BUNDLE_FAST_PATH.
Two cases are accepted as documented rather than engineered away, since each runs only when a user picks that function on such a column, behind the loading skeleton:
countDistinctover a mostly-unique string column — ≈ 23–26 s in Chrome for 1M unique emails at depth 2. The Node harness reports only ≈ +1.4 s (depth 1) / +4.4 s (depth 2) for the same work and understates the browser badly. Profiling in the page shows the cost is almost entirelystep()'s firstSet.addof each distinct string (≈ 6 s per 300k fresh strings, against ≈ 80 ms to add the same strings to a new set a second time — the per-string cost is paid once, on first touch, with a 1M-row heap resident); the collator sort infinalize()is ≈ 170 ms per 83k-value group. The likely fix is to move the string work off the main thread — a per-column collation class id computed in a Worker, turning the count into distinct integers — and is left to a follow-up.- An ISO-string temporal column under
min/maxpays one parse per distinct string (≈ +0.2–0.6 s for a date column's few thousand distinct days; ≈ +1.6–2.1 s for one distinct timestamp per row). Node harness figures; not yet measured in a browser, where the same first-touch effect may make them higher.
The change runs on the reorder bracket. An aggregate change reorders nothing — every row stays exactly where it was — but a first attach or last detach changes the shape of renderEntries (a footer row per group appears or disappears) and any later change re-runs the accumulator walk, both well past a frame at a million rows. So setAggregates() routes through the same runReorderPipeline() bracket setGroupState() uses, on the same sortShimmerThreshold, with an empty apply and no Worker state. Below the threshold it is fully synchronous; at or above it the work runs behind the loading skeleton, which is also the only condition under which one aggregate change can supersede another (§ 13).
A redundant call — the same entries in the same order — is not a change: it re-runs nothing and emits nothing. Order is part of the set, though, since it is what getAggregates() reports and what persists, so reordering two entries is a change even though it computes the same values.
An aggregate and a group level never share a column. An applied level's
column has left the visible array, so its total would have no cell to render
into — which is why the header menu can never offer one (a grouped column's
header is not on screen). The public API reaches what the menu cannot, so the
invariant is enforced at both ends rather than assumed: setAggregates()
refuses an entry naming a group level with 'column-grouped', and a group
change that makes an already-aggregated column a level prunes that
aggregate, with the same settled-state, persistence and single-event semantics
column normalization uses (§ 14.1). Grouping is the call that succeeds, because
the alternative is a grouping that fails for a reason the user has to work out
first. An ineligible column type still outranks the conflict: a string
group level reports 'column-unsupported', the reason that will not change.
An aggregate change never overtakes a grouping transition — it joins it.
Both run on the shared reorder bracket and share its generation token, but only
the grouping transition carries an ordering: setAggregates()'s apply is
empty, because attaching a function moves no row. A change arriving while a
grouping transition is still deferred would therefore supersede the one thing
that had to happen first, and build the new levels' projection over the old row
order — which reads as one caption per contiguous run of a value rather than
one per group. So a deferred transition parks its whole spec, and a later
aggregate change adopts it: the same apply, the same Worker eligibility, and
the transition's own finish, which persists and emits vn-grid-group-changed
exactly as it would have. The parked record is read, never taken: it stays
installed until it settles — its own finish clears it — so a second or third
aggregate change before the transition lands joins the same transition rather
than finding nothing to adopt and projecting over the old order. Whichever
pipeline wins runs that finish once. The Worker's sort chain is re-derived at
adoption rather than borrowed, so a header sort started in between is honoured
by both the in-thread apply (which reads the effective sort live) and the
Worker. The symmetric case is covered by the same parking —
a grouping transition that supersedes a pending aggregate change carries that
change's event, so a set that landed is never silently unannounced.
A reorder grouping did not start settles grouping's pending work too. A
header sort, a setRows() reload and an appendRows() all move the same
generation token, so each can overtake a deferred group transition or aggregate
change. VanillaGridGroupingFeature#settlePendingWork() runs after every
winning reorder's finish (the sorting bracket's onReorderSettled callback)
and at the end of the two row-data paths that supersede without a bracket of
their own — setRows()'s unordered load and appendRows(). It settles, in
order:
- An orphaned group transition — its record is still installed, so its
finishnever ran. The overtaking operation already ordered the rows for the current requested levels, so the record'sfinishonly rebuilds the projection, prunes (DR2a), persists, emitsvn-grid-group-changedand — on too-many-groups — rolls back (§ 6.1). Settling it at that moment also means no latersetAggregates()can adopt it with stale closure state. - A pending aggregate change — installed synchronously and rendered by the
overtaking operation, but not yet persisted or announced. The hook persists
the blob and emits one
vn-grid-aggregates-changed. It runs second because a transition settled in step 1 has already announced the set.
Grouping's own finish callbacks consume both before the hook runs, so on the
ordinary path it does nothing and no event fires twice.
getGroupAggregates(path) reads the computed values back, keyed by column, taking the same raw-value path expandGroup()/collapseGroup() do. The values are held in a map keyed by group identity, rebuilt by every full rebuild of renderEntries and carried unchanged across a collapse-only one (§ 4), deliberately not hung on the caption or footer entry — a method is a narrower promise than an entry shape, and it leaves the accumulator machinery free to change. A collapsed group's values are stored too: they are correct, and a host may ask for a hidden child's totals.
5.3 Acquiring an aggregate from the header menu
The interactive route, mirroring how grouping itself is acquired. A column's right-click menu carries "Aggregate ▸", opening a flyout of the functions that column's type admits, plus "Remove aggregate" while one is set — the same flip the grouping block makes between "Group by this column" and "Remove from grouping". Picking a function on a column that already has one replaces it, which is what keeps the per-column state binary; the menu does it by appending to the set and letting setAggregates()'s last-wins de-duplication resolve it, so every other column keeps its position.
canAggregateColumn(key) is what the entry is gated on: O(1), never scans rows, safe to call on every menu open. It returns { available, reason, functions }, where functions is every registered reducer the column's declared type admits, in registration order.
The entry is present on every column's menu whatever the grid state, and disabled with an explained title when it cannot act. Omitting it until grouping is active would be cleaner in the narrow sense — the menu would never show something inert — but it makes the capability undiscoverable: a user has no reason to group first if nothing ever suggested that aggregates exist. The reason-coded disabled entry already required for ineligible column types makes the discoverable version nearly free.
The flyout's contents are resolved when it is opened, and the replacement set when an item is activated — never captured when the parent menu was built. A context menu outlives the click that opened it, and host code may change aggregates while it is on screen; a snapshot would show a stale check mark and, worse, would be the base the replacement set is rebuilt from, dropping every entry added since.
The reason lands on the container entry, never inside the flyout, so a user is never shown a list of uniformly-unavailable functions; a disabled entry builds no flyout at all. Its codes are a vocabulary of their own rather than a reuse of groupReason* — a column can be ineligible for an aggregate while grouping by it is perfectly available, and the reverse:
reason |
The entry says |
|---|---|
'column-unsupported' |
The column's declared type admits no function |
'no-grouping' |
Nothing is grouped |
'column-grouped' |
The grid is grouped by this column (§ 5.2) — unreachable from the menu, since an applied level's header is off screen, but a host can ask about any key and a suspended level keeps its header |
'partial-dataset' |
A requested grouping is suspended over an incomplete result set (§ 10.3) — the configuration is retained, but not editable |
'column-not-found' |
— |
The column checks run before the grid-state ones. A string column in an ungrouped grid is ineligible twice over, and it reports its type: that is the reason that will not change when the user groups, so it is the one worth showing.
A grouped column needs no enforcement here at all — its column has left the grid (§ 18.1), so its header menu is not on screen.
The submenu mechanism is general, and lives in its own file. features/header-menu-submenu.feature.js knows nothing about aggregates; the menu's other crowding candidates (sort ×3, group ×4) can use it unchanged. _createSubmenuEntry({ label, disabled, title, items }) returns an entry the caller appends wherever it belongs.
The flyout is a sibling of the menu in <body>, not a descendant: it has to escape the menu's bounds, and the menu is position: fixed with its own stacking context. Everything that treats "inside the menu" as a region — outside-click dismissal, Escape, teardown — therefore knows about it explicitly, and Escape closes one layer at a time (the flyout, returning focus to its entry, then the menu).
| Concern | Behaviour |
|---|---|
| Open | Hover after a short dwell (mouse only — a tap raises pointerenter immediately before the click that opens it anyway, so hover-open is gated on pointerType === 'mouse' and the parent menu stays put), click, Enter, Space, and the into-the-submenu arrow: ArrowRight in LTR, ArrowLeft in RTL |
| Close | The out arrow, Escape, hovering a different entry, activating a choice (which closes the whole menu, as every flat entry does), or a pointer that leaves both surfaces for longer than the grace period |
| The diagonal | Travelling from the entry to the flyout passes over the entries below it, so closing on the first pointerout would make the flyout unreachable by any natural mouse movement. A close grace period covers it, cancelled by re-entering either surface — rather than a geometric safe-triangle, which needs continuous pointer tracking on document for a menu holding a handful of items |
| Traversal | ArrowUp/ArrowDown/Home/End within the flyout, wrapping, skipping disabled items. Tab is left to the browser, matching the parent menu — it has no roving focus of its own, so imposing one on the flyout would make the two halves behave differently |
| Focus | A keyboard open focuses the first enabled item; a hover open leaves focus alone. A keyboard close returns focus to the owning entry, which carries aria-haspopup="menu" and aria-expanded |
| Positioning | Anchored to the entry's rect, not the pointer; preferred side first, the opposite side when it would not fit, then the parent menu's own viewport clamp — one shared _placeFloatingMenu() for the menu, the standalone filter panel and the flyout |
| Theming | The flyout carries .vn-grid-header-context-menu as well, so the surface is the parent menu's — already themed by all 10 theme files, with no second token set to keep in step. Only the disclosure triangle and the flyout's stacking order are new, and the triangle is CSS-drawn (border-inline-start, so it mirrors under dir="rtl") rather than a character in the label |
Header badge. The footer is often scrolled out of view, so an aggregated column also says so in its header: .vn-grid-aggregate-badge, holding the footer's own marker (_aggregateMarker(), so a messages.aggregateFunctionMarkers entry replaces the icon in both), as the last child of .vn-grid-header-label, after the secondary header text. It shows exactly when the column's footer cells show a total: the column is in getAggregates() and grouping is applied, read as getGroupingStatus().available. Two tempting alternatives are wrong. isActive() emits vn-grid-group-changed on a transition, so it cannot be called from rendering. getHiddenColumnKeys() !== null is also null on a grid with nowhere to mount the group bar (D25), where grouping is applied and the footers do render.
- Marker. A built-in function's badge holds an empty
.vn-grid-aggregate-iconspan (the same masked icon as the footer, see below); a host text marker replaces it with text. Never empty: when a host hides the footer marker (''), the badge falls back to the built-in icon, and to thesumicon for a registered function with none. The pill's height comes from its line box either way, so an icon badge and a text badge are the same height. - Words. The badge's
titleis the footer tooltip's head ("Average of Height"). The badge isaria-hidden, and the same phrase is added to theth's owntitleafter the secondary text, so the header's accessible name carries it without a second focusable element.renderHeader(),_updateHeaderSortState()and_refreshAggregateBadges()all compose that title through one helper,_composeHeaderTitle(), so a sort never drops the phrase. - Not interactive. A click on the badge is a click on the header: sort, drag.
- Kept current without a header rebuild. A header rebuild (a column-set change, and a suspend/resume, which re-derives the visible columns) renders the badge itself. Every other change goes through
_refreshAggregateBadges(), called fromVanillaGrid#_emitGroupEventfor'aggregates-changed'and'changed', which updates badges and titles in place and leaves eachth's listeners and hover state alone. That is the one place every settled change to the set passes through (setAggregates(), the ungroup discard, the grouping prune,normalizeAggregatesForColumns(), the deferred transition), so the badge changes with the event and never mid-shimmer. The one lag is inherited from the event: when a reorder overtakes a pending aggregate change, the footers render first and the badge follows atsettlePendingWork().
Auto-fit counts the badge as part of the header row (Column Resizing); its theme tokens are in Themes § 4.8.
6. Effective Sort Composition (D1)
applyEffectiveSort() sorts rows fresh by [level0 field, level1 field, ..., levelN-1 field, ...user's own sort fields (minus any column already used as a group field)] and writes the result to displayRows, via VanillaGridSortingFeature#sortRowsByState(rows, sortColumnsState) — a pure sort primitive extracted from applySorting() for exactly this reuse. The user's own sortColumnsState, header sort indicators, and sort persistence are never touched — grouping only reads the current user sort state to build a derived, temporary ordering; getSortState() keeps reporting the user's actual columns throughout.
Client-side grouping always re-derives from the source rows array (never displayRows), the same "never sort in place" rule applySorting() follows, for the same reason: stale tie-breaking from a previous sort must not leak into a re-grouped result.
Ordinary header-sort actions still enter through VanillaGridSortingFeature#applySorting(). While grouping is active, its injected grouping coordinator replaces the plain sort with applyEffectiveSort() and immediately rebuilds renderEntries; the renderer and all geometry consumers therefore observe the same sorted projection. When grouping is inactive, the plain sort's own write to displayRows rebuilds the projection too — the rebuild is wired into the setDisplayRows callback injected into the sorting feature, so it is a single unconditional rule rather than something each sort path has to remember (§ 9: without it, renderEntries would keep pointing at the pre-sort array and the sort would not reach the screen at all).
_computeEffectiveSortState() is split out of applyEffectiveSort() because the chain is also needed as data: the off-thread sort Worker sorts exactly the chain it is handed, so it is handed the effective one. Group levels name their column with a direct column reference rather than a columnIndex — an applied level's column is no longer in the visible array a columnIndex indexes into (§ 18.1) — while the user's own entries keep theirs. getEffectiveSortState() is the side-effect-free accessor the sorting feature reads through an injected closure; it returns null unless grouping is applied.
6.1 A group change is a reorder, and reorders block
Grouping a million rows moves exactly as much data as sorting a million rows, so setGroupState() runs on the same bracket an ordinary sort does — VanillaGridSortingFeature#runReorderPipeline(), injected as a closure. See Sorting Implementation § 7.2.1 for the bracket itself and § 7.3 for the Worker-eligibility gate that keeps a grouped off-thread sort ordering-identical to the main-thread comparator.
What that means here:
Below
sortShimmerThreshold(default 5000 rows) the reorder runs inline, exactly as it always has: by the timesetGroupState()returns, the projection is built and its return value carries the final outcome — including a'too-many-groups'abort.At or above it the reorder is deferred behind the loading skeleton, and offloaded to the Worker when every group level is worker-safe.
setGroupState()then returns the capability that is resolvable before the row scan, and a'too-many-groups'rejection can only be reported on thevn-grid-group-changedthat the deferred continuation emits (§ 10.2).'no-visible-columns-left'(D20) is unaffected — it is pure column arithmetic and stays synchronous in every case.The bar's chips AND the visible column set both settle immediately, before the reorder is dispatched, so the user sees the levels they just asked for and the grouped column already gone instead of watching a multi-second skeleton over a grid still shaped like the old one.
_syncGroupHiddenColumns()renders the bar itself, so the prologue makes one call, not two.Nothing about the exclusion needs the reorder:
getHiddenColumnKeys()(§ 18.1) is a pure function of_groupState, the resolved strategy and whether the bar can mount, and all three are settled before the pipeline is invoked — the late call was only applying the answer late, never computing it. The three paths that do not apply the requested state neutralise themselves: a D20 rejection has already reverted_groupStatein the prologue, and a D17 suspension and a D25 unmountable bar both makegetHiddenColumnKeys()returnnull, so in all three the call re-derives the array it already has, finds it unchanged and returnsfalsewithout touching the DOM.finishkeeps its own call, and must. The R6 too-many-groups abort is discovered by the projection scan insidefinishand reverts_groupStateafter the prologue already removed the column; that call is what puts it back. On the normal path it finds the key arrays equal and returnsfalse, and the branch behind thatfalseis then what rebuilds the pool the shimmer tore down, renders the settled order and re-sizes the scrollbar thumb (_rerenderInPlace(); a re-nest such asCountry ▸ City→City ▸ Countrychanges the caption count without changing the columns or the bar height, so nothing else would) — so the deferred path depends on it too, for a different reason.The cost is one extra render pass.
_applyColumnVisibilityAndRefresh()re-lays out the header, rebuilds the pool and renders one screenful, and on the deferred path the skeleton replaces that screenful in the same task; belowsortShimmerThresholdit is a genuine second render of a small grid per group change. It is one viewport of DOM either way, never a function of row count. Measured at ~63 ms on a 300,000-row grid in Performance Analysis § 6.4, against ~300–1300 ms of a grid that used to look finished and be wrong.A rapid second group action supersedes the first through the shared
_sortGenerationtoken, which sorting and grouping deliberately share: both reorder the same array, so each must be able to discard the other's in-flight work. This is whyrunReorderPipeline(), not_sortViaWorker(), is what stores an off-thread result (see Sorting Implementation § 7.3, "Last-wins sort generation"): the group action that most often supersedes a worker sort — removing the last group level — produces an empty effective sort state, so it passes noworkerSortState, takes the in-thread path, and bumps only the generation. A worker reply that applied itself under its own_sortWorkerSeqcheck therefore stayed "current" and overwrote the plain sort that had already landed, leaving the grid ordered by the grouping the user had just removed.A group change another reorder overtakes still settles. A header sort, a
setRows()reload or anappendRows()that supersedes a deferred group change already orders the rows for the new levels — the effective sort reads_groupStatelive — but it never runs the transition'sfinish.settlePendingWork()(§ 5.2) runs it as soon as the overtaking operation finishes: the new levels are persisted and announced withvn-grid-group-changed, an aggregate on a column that just became a level is pruned and announced, and a'too-many-groups'rejection rolls back to the last settled grouping and is reported on that event. The cost is one extra projection build, only on this race. If the rollback happens after the overtaking sort has rendered, the rejected grouping's order is briefly on screen before the revert.deferToReload: trueskips the reorder entirely.setGroupState(state, { deferToReload: true })establishes the group STATE — chips, the visible column set, persistence,vn-grid-group-changed— and never touches the row order, because the caller is about to reload data and that reload will order and render every row anyway. It is the same contractclearSort({ deferToReload: true })has always offered on the sorting side, and it still moves the shared generation token so a reorder already in flight is superseded rather than left racing the reload. Two consequences:renderEntriesis left as the flat alias ofdisplayRows(a real projection built over rows not yet in the right order would describe nothing), and a'too-many-groups'abort cannot be detected here — there is no projection scan to detect it — so the reload's own build is what reports it. Its only caller isVanillaGrid#clearPersistedSettings({ deferToReload: true }), directly and through the parked state thatsetRows()consumes (§ 14).
Without this, setGroupState() was a second, fully synchronous pipeline: at 1M rows a chip's direction flip froze the tab for ~3.6 s with no feedback at all, while the identical sort on the same data was both shimmered and off-thread.
7. Rendering a Caption Row (D2, D3, D5)
A caption row uses the standard rowHeight — no per-row height table exists or is needed, which keeps every virtualizer index computation (_getVirtualMetrics, spacer heights, scrollbar geometry) valid once their length source switches to renderEntries (§ 9). This invariant runs the other way too: measureActualRowHeight() (rendering.feature.js, 02 § 1.14) — which samples one live pool row to correct rowHeight for CSS-driven differences — deliberately never samples a caption, since a caption's first-paint height (its toggle <button>, not ordinary cell content) can transiently differ from a data row's, most visibly right after a reload that restores an already-grouped state. Sampling one would overwrite rowHeight grid-wide with a value that has nothing to do with ordinary row content.
Captions keep the full colCount <td>s — no colspan, no spanning overlay. This preserves the positional invariant every other feature depends on (cell index i always maps to columns[i]): resize, freeze offsets, and autofit never gain a caption-specific branch. _populateCaptionRow (rendering.feature.js) clears every cell and places a single toggle <button class="vn-grid-group-toggle"> in the first visible, non-internal data column — always, freeze or no freeze.
__vgSelection__ and __rowNumber__ are never eligible placements — captions never render a selection checkbox (D5) and are never keyed for selection.
Freezing does not move the toggle. The rule used to have a freeze-aware first branch — the last frozen non-internal column — so the toggle would stay pinned and visible while scrolling horizontally. It did that, but at a cost that showed up the moment anyone used it: every caption in the grid jumped right by the width of the frozen run as soon as a column was frozen, and jumped back on unfreeze, with the whole group hierarchy detaching from the left edge it had been reading down. The pinning was free anyway. _reorderColumnsForFreeze() makes the frozen columns a contiguous leading run, so whenever a freeze exists the first visible non-internal column is itself frozen — the toggle stays pinned regardless, just at the start of the run rather than its end. The single rule keeps a caption at one x position in both states.
The placement cell is tagged vn-grid-group-toggle-cell, which the CSS uses to raise it above the frozen cells its label overflows across. Ordinary caption cells are transparent (the tint is painted by the row), so they can never hide the label; frozen cells paint their own opaque background at z-index: 3, and after this placement moved to the first column every frozen cell is a later sibling than the one holding the label — without the lift the frozen run repaints over it and the caption is clipped at the first freeze boundary. The rule sets z-index only and deliberately leaves position alone: a frozen placement cell is sticky and must stay that way, and an unfrozen one is static, where the z-index is inert and unnecessary.
The toggle's glyph follows its native state: --vn-grid-group-toggle-icon-expanded is painted when aria-expanded="true" and --vn-grid-group-toggle-icon-collapsed when it is "false" — a masked SVG slot, like the aggregate marker icons: the ::before box is painted in currentColor and shaped by the token's url(), so its weight and centring do not depend on the caption font. It defaults to a down/right chevron pair (the default and Glow themes swap in filled triangles, the Carbon themes a boxed minus/plus), mirrors the collapsed icon in right-to-left layouts, and paints ButtonText in forced-colors mode. See Themes § Caption rows.
A caption with child groups also offers the Shift-click children toggle (§ 5.1.1): its toggle carries aria-keyshortcuts="Shift+Enter" and a title hint from messages.groupCaptionToggleHint ("Shift+Click to fold or unfold the groups below"). A caption at the deepest level carries neither: there Shift-click is a plain toggle, so the hint would be wrong. There is deliberately no "Click to expand / collapse" hint: the marker and aria-expanded already say that. The toggle spans the whole caption label, so the hint shows on hover anywhere over the caption.
The caption label stays on one line but is not constrained to its placement cell: the caption row's cells permit visible overflow and the toggle uses its content width, so it can paint over the row's otherwise-empty cells. The full positional cell structure remains intact for sizing, freezing, and reordering.
Two measurements must therefore ignore captions, not one. The label is the row's content, not the column's, so auto-fit (_measureColumnFitWidth, columns-resize.feature.js, 11 § 14.1) skips every pool row carrying vn-grid-row-caption — exactly as measureActualRowHeight() above does in the height dimension, and off the same marker. Without that guard the toggle column was fitted to hold Country: Australia — 85 items (210px rather than 90px for an ID column in samples/grid-minimal-js/), and the fitted width moved with the scroll offset, since only the materialized captions are visible to a measurement. Only the toggle column was ever affected: _populateCaptionRow clears every other cell in the row.
Nested-level indentation is CSS-only, never a physical column. The toggle's padding-inline-start is set to calc(entry.level * var(--vn-grid-group-indent, 20px)) — a level-0 caption gets no indent, level 1 indents one step, level 2 two steps, and so on. --vn-grid-group-indent is OPTIONAL, like every other --vn-grid-group-* token (Themes Implementation § 4.8) — no theme needs to set it unless overriding the 20px default. Indenting via padding rather than an inserted indent cell per level keeps columns[c]/rowEl.children[c] in sync everywhere (colgroup, reorder indices, freeze offsets, autofit, export) regardless of nesting depth.
Known limitation: because the toggle sits in one real cell rather than a spanning overlay, a grid with no frozen columns shows a caption row with visible content in only one column once the user scrolls right, with the rest of the row visually blank (still correctly a full-width tinted row via .vn-grid-row-caption, just without the label in view). A grid that freezes any column does not have this problem — its first column, and therefore the toggle, is inside the pinned run. This is an accepted, documented limitation for grids that freeze nothing.
The caption band is one uniform tint, and the freeze line stops at it. Two
things fight a caption row when a column is frozen, and both are handled in the
caption rules in vanilla-grid.css:
- The tint used to break at the freeze boundary. Frozen cells paint their own
opaque
--vn-grid-frozen-bgover the row, so the frozen half of a caption came out a different color from the rest — a seam landing exactly on the boundary. The row and its frozen cells now use one identical expression, the caption tint composited over--vn-grid-frozen-bg(the same technique the selected-row rule uses, and it relies on the same theme invariant: frozen-bg == body-bg). They cannot disagree. Compositing also makes the tint opaque even when a theme leaves--vn-grid-group-caption-bgtranslucent, which the guide occlusion below depends on. - A theme's zebra stripe outranked the tint on every second caption. The
:nth-child(even)variants raise specificity above.vn-grid-body-table tbody tr:nth-child(even)(equal specificity, loaded later). Because rows are pooled, a caption's DOM parity changes as it scrolls, so this showed as a caption flickering between its tint and the stripe.
The bar sits above the header, and the container's overlays are positioned
from the header's bottom. .vn-grid-freeze-guide and .vn-grid-resize-guide
both overlay .vn-grid-table-container and start where the body starts. The bar
mounts as an earlier sibling of the header spacer inside that same container
(§ 18.6), so a top taken from the spacer's height rather than its bottom
places them the bar's worth of pixels too high — inside the header, where the
freeze guide paints a full-height line straight over the short handle the header
draws in its resizer zone (§ 4.5 of 15), losing
that treatment exactly when grouping is on. Both now read
_getHeaderBottomOffset() (columns.feature.js), which measures the spacer's
bottom within the container; with nothing above the header it returns the same
number as before, which is why this stayed invisible until the bar shipped.
The freeze indicator is a single overlay (.vn-grid-freeze-guide) spanning the
whole body, not a per-cell border, so it cannot be switched off by a rule scoped
to caption rows. Instead the caption row is given position: relative and a
z-index one above the guide's, and simply occludes it: the line marks the
boundary on data rows and stops across each caption band. A caption is one
logical label spanning the row — the label deliberately overflows its placement
cell across the boundary — so a column separator drawn through it reads as a
rendering artifact. The two z-index values (guide 5, caption row 6) are chosen to
sit above the frozen cells they must clear (3) and below the grid's chrome —
scrollbars and drop indicator at 10, load-more banner and scroll tooltip at 11 —
because a caption row scrolled to the bottom edge overlaps the horizontal
scrollbar track and would otherwise paint over it. grouping-interop.spec.js
pins both halves off the rendered pixels.
Toggle button identity survives content rebuilds. _populateCaptionRow reuses an existing <button> child in place (updating its text/aria-expanded) rather than destroying and recreating it on every repaint — a fresh element on every collapse/expand would drop DOM focus (browsers reset focus to <body> when the focused element is removed), breaking a second consecutive keyboard toggle. A caption's own toggle never moves the caption itself on collapse/expand — only member rows below it appear/disappear — so this is the only in-place-update rule the caption path needs.
A toggle must not move the viewport. Collapsing/expanding rebuilds the projection and then the pool in place — buildRenderEntries() → initVirtualPool() → renderVisibleRows(true) (_rebuildAndRerenderInPlace) — with a live scroll position throughout. Only entries below the toggled caption appear or disappear, so every row above it (and therefore the row under the viewport's top edge) must stay exactly where it was. That is a property of the pool rebuild, not of this feature: initVirtualPool() re-parks the spacers around the offset it found instead of zeroing them, which is what keeps viewport.scrollTop addressing the same render entry across the rebuild, and what clamps it to the new last page when a collapse makes the projection shorter than the current offset. See 02 § 1.4 for the failure this replaced — the toggle used to leave a deep-scrolled grid staring at the bottom spacer, i.e. blank. grouping-pool.spec.js pins both directions.
The label format is "<column label>: <formatted value> — <count> items" (localized: messages.groupItemsSuffix, messages.groupBlankValue for a null/undefined group). The value formatter reuses grid.formatValue — the same type-aware formatter every ordinary cell renders through — so a caption's displayed value for a boolean/date/number column matches how that value would render inside a data cell.
7.1 Rendering a Footer Row
A footer row is a new row type, not a new row shape: _populateFooterRow
(rendering.feature.js) keeps the full colCount <td>s exactly as
_populateCaptionRow does, so it sits at each column's width inside the
colgroup, participates in freeze offsets and horizontal scrolling like any other
row, and costs resize, reorder, freeze, autofit and export no branch of their
own. It differs from a caption in four ways, each deliberate:
- It keeps the type-driven cell classes a caption strips. A caption cell
holds a label; a footer cell holds a value of the column's own type. So an
aggregated cell carries
vn-grid-numeric(andvn-grid-truncate-ellipsis) just as the data cells above it do — which is what makes the total right-align on the same edge, with the same padding, rather than merely both being "right". Non-aggregated cells are empty and carry no type class at all. - An aggregated cell holds two spans, not one string. A
vn-grid-group-footer-marker(aria-hidden, absent when a host suppresses it) followed by avn-grid-group-footer-value. The value is the cell's last child, so the marker pushes left and never moves the digits. The split is what gives themes a marker to paint and auto-fit a probe to measure. For a built-in function the marker is an icon: an empty span that also carriesvn-grid-aggregate-iconanddata-fn="<fn>", painted incurrentColorthrough the masked SVG--vn-grid-aggregate-icon-<fn>(Themes § 4.8). Masked shapes rather than font glyphs, because a glyph's weight, bearings and baseline come from whichever font the theme's stack falls back to for it, andΣ μ ↓ ✓rarely all resolve to the same one. Amessages.aggregateFunctionMarkersentry replaces the icon with that text, which is also the only marker a registered function can have (it has no icon)._aggregateMarker()returns the choice as a descriptor —{ icon },{ text }or null — which the footer, the header badge and auto-fit all consume. - Nothing visible on the row names its group. Each cell carries its own
function marker instead, because a row-level label ("Italy Total") would be
false the moment a second column carried a different function. The row is
instead focusable, through one
vn-grid-group-footer-focuselement in the same column the caption's toggle occupies, carrying a composedaria-labelthat does name the group and reads out every value (Localization Implementation § 3.4). It is an absolutely-positioned overlay of the row withpointer-events: none, so it takes no layout space beside the values and its focus ring reads as "this summary row" rather than "this cell". - Its cells do not permit visible overflow. The caption's
overflow: visiblerule is deliberately not extended: a caption's label is one string spanning the row, while a footer's whole point is that each value stays inside its own column, so a value too wide for its column ellipsizes rather than bleeding into its neighbour.
The focusable element is reused in place across pool recycling, for the same
reason the caption's toggle button is — a fresh element on every rebuild would
drop DOM focus to <body>. Its cell is therefore cleared around it
(textContent = '' would detach it, and detaching a focused element loses focus
even when it is re-appended in the same tick).
Formatting goes through one call site, _formatAggregateValue(column, value, fn),
which routes a 'column' result to the same formatValue every data cell and
caption label uses — so a currency column's total reads as currency under its
own formatOptions, with no per-aggregate override. One consequence: an
average can show fraction digits no data cell above it shows, because an
average of integers is not an integer (Intl.NumberFormat's default of up to
three), while a column declaring maximumFractionDigits: 0 rounds it. A
'count' result (§ 5.2) goes through formatting.formatInteger instead, and
_applyFooterCellTypeClasses(cell, column, hasValue, fn) aligns it as a
number whatever the column's type. A column's renderCell is never
invoked for a footer cell: it takes a row object and a row index, and an
aggregate has neither.
Each aggregated cell carries a tooltip naming its function in full and its
group by its path — <label> of <column> for <path>: <value>, e.g. "Average of
Projects for Italy › Rome: 14.165" — on the whole <td>, not the few-pixel
marker. On an expanded group the footer sits below every row of the group, so
its caption has usually scrolled away and the tooltip is the only thing a mouse
user can read that names the group. It is what lets a marker stay a terse
symbol (μ for Average), and since it carries the value it also reads a
truncated total out whole.
The tooltip and the row's accessible name share their pieces but not their
shape. _composeFooterCellHead(col, fn) ("Average of Projects") and
_formatAggregateValue() build both, so the two readers cannot drift on
function, column or value. _composeFooterCellLabel() is the accessible name's
per-cell segment, without a group clause: _composeFooterAriaLabel names the
group once, up front, and a clause per cell would repeat it for every aggregated
column. _composeFooterCellTitle() adds the clause (aggregateForLabel +
path), composed once per row.
One composer names a group by its full path, _formatGroupPath(entry, spoken), behind the tooltip, the footer's accessible name and a caption's
accessible name. Footers carry the same path array as their caption (built
once per emitted group in _scanLevel); each ancestor's column comes from the
applied group levels, resolved once per projection (_projectionVersion), not
per row. Every value is formatted exactly as its caption shows it
(groupBlankValue included). Two forms:
- visual (tooltip): values only, joined by
aggregatePathSeparator(' › ', plain text, independent of the theme's group-bar glyph). Each ancestor longer than a fixed limit (28 user-perceived characters, counted by grapheme so an emoji is never split) is cut at the end with…, and only when that saves at least two characters; the leaf is never cut. - spoken (accessible names):
Label: valuepairs, since a listener has no group bar, joined bygroupPathAriaSeparator(', '), never cut.
A nested caption's toggle carries an aria-label of the spoken path plus its
count suffix — "Country: Italy, City: Rome — 12 items" — which ends with the
visible text ("City: Rome — 12 items"), so a speech-input user can still
activate it by saying what they see (WCAG 2.5.3). At level 0 both accessible
names are exactly what the caption's visible label reads.
The footer owns its cells' title. The overflow-tooltip pass
(updateTooltips()) rewrites every pool cell's title on a debounce, which
would wipe the footer's within 300 ms, so it skips rows carrying
vn-grid-row-footer — as auto-fit already does. _populateFooterRow sets
title on aggregated cells and removes it from the rest, and when a pool row
stops being a footer (the caption and data branches of
renderVisibleRows), its cells' titles are cleared before the class is
removed — only if it was a footer, one classList.contains on a path that
already calls classList.remove — so a recycled data row never shows a stale
"Average of Projects".
A caption's cells carry no overflow tooltip. The caption label overflows its
cell on purpose (§ 7) and is never cut, so the measurement always reports
overflow and would title every caption cell with the label it already shows in
full ("City: Amsterdam — 5 items"). The pass therefore clears the title of
every cell in a vn-grid-row-caption row instead of measuring it — clearing
rather than skipping, because a pool row recycled from a data row may still
carry a data cell's title. The toggle button's own title (the Shift+Click
hint) is on the button, not the cell, and is untouched.
Auto-fit sizes an aggregated column from the computed totals, not from the
pool. The scan tracks the minimum and maximum finalized value per aggregated
column (getAggregateExtremes(columnKey) → { fn, min, max }, discarded and
rebuilt with the projection — refolded over the projected footers even on a
collapse-only rebuild that reuses the values, and lazily on the first read
after a single-group splice, § 4). _measureColumnFitWidth skips footer rows the way
it skips captions and measures those two values instead, formatted through the
same _formatAggregateValue the cell uses and probed with the footer's own
classes — so the fit is identical from any scroll position, including one with
no footer materialized. See
Column Resizing Implementation § 14.1.
Magnitude is not width. The extremes are tracked by magnitude, but once
fraction digits vary the widest string is often neither of them: for totals
{ 12.333, 13, 9.5 } the extremes render 9.5 and 13, and 12.333 would
clip. So _getAggregateFitStrings probes each extreme x of a 'column'
result on a number column as Math.trunc(x) + Math.sign(x) × 0.4444444444 —
the extreme's own integer digits plus as many non-zero fraction digits as the
column's formatter shows, never rounding up into another integer digit (a
maximumFractionDigits: 0 formatter renders just the integer). O(1) and
deterministic, where formatting every group's value would be exact but cost
thousands of Intl calls inside a fast-path function. A 'count' result is an
integer and is probed as it is. A number is compared into the extremes by
value. A string result is folded by length and the longest is kept as both
extremes: a registered reducer can return text (e.g. the most common value of
a string column), and keeping the first group's string would fit the column to
"HR" and clip "Engineering". Character count is a proxy for width, but a close
one for such words. A temporal min/max reporting a raw ISO string goes
through the same fold, where it changes nothing: a fixed format has a fixed
length. Any other value is recorded from the first group seen and kept. A
month: 'long' date format is the known exception to "fixed format, fixed
width", the same one data cells have.
The extremes are projection-scoped: a collapsed parent's descendants are still walked (their totals have to exist), but their values do not widen the column. The promise auto-fit makes is independence from materialization — which footer rows happen to be in the virtual pool — not from collapse, which is a state the user chose and can see; reserving width for a total nobody is looking at would be the opposite of fitting. The consequence is worth knowing: expanding a collapsed group can reveal a wider total and needs another auto-fit to size for it, exactly as a filter that reveals a wider value does.
Theming is the caption block with -caption- swapped for -footer- and a
border-top added — same gradient composition, same :nth-child(even) and
:hover variants, same position: relative; z-index: 6
(Themes Implementation § 4.8).
8. Change Detection in the Render Pool (D11)
renderVisibleRows's pool-row change-detection key is (entryIndex, projectionVersion), stored as two dataset attributes (data-vn-grid-entry-index, data-vn-grid-projection-version) — not the data index. Two different captions recycled into the same pool <tr> across a scroll must never compare equal to each other just because they land at the same pool position; keying on the data index alone (as the ungrouped path always has) would let a stale caption's label/count/aria-expanded survive a scroll. projectionVersion (bumped once per buildRenderEntries() call) additionally makes correctness independent of every group/collapse/sort/reload call site remembering to pass force: true.
dataset.vnGridRowIndex keeps its existing meaning — "index into displayRows" — completely unchanged (D11). A caption row — and a footer row — omits the attribute entirely rather than carrying a sentinel value. This is what lets interaction-pointer.feature.js's existing !isNaN(dataIndex) guards (click, checkbox toggle, double-click) reject a caption with zero grouping-specific code in that file: parseInt(undefined, 10) is NaN, and every guard was already written to bail on NaN.
9. Display-Count Consumers and the R1 Zero-Cost Guarantee
Every consumer that means "how many rows are on screen" reads renderEntries.length; every consumer that means "which data row is this" keeps reading displayRows. The complete list of sites switched:
| Site | File |
|---|---|
initVirtualPool (pool sizing) |
rendering.feature.js |
_getVirtualMetrics (logical/DOM height, scale factor) |
rendering.feature.js |
renderVisibleRows's local totalRows |
rendering.feature.js |
_updateSpacerHeights |
rendering.feature.js |
| Pool resize on viewport-height change | viewport.feature.js |
_isVerticalScrollNeeded |
viewport.feature.js |
_getScrollbarMetrics |
viewport.feature.js |
_isNearBottomOfLoadedData's fallback path |
interaction.feature.js |
Not switched, by design: the scroll-position indicator (_updateScrollIndicator) keeps reporting data-row positions — positions in the dataset, under the rule that a collapsed caption stands for the rows it hides, so the label still starts at row 1 at the top and reaches the total at the bottom however much of the grid is collapsed. That is what dataStartIndex on the caption entry is for (§ 8): a collapsed branch's rows are in displayRows but not in the projection, so nothing else on the entry says how much of the dataset a user passes by scrolling over that one line. See 02 § 2.10 for the full rationale (D10).
Why the reference-identity trick matters, not just "it's tidy": appendRows() decides whether to push new rows onto displayRows separately (vs. relying on the shared-reference alias to rows) by checking displayRows !== rows. renderEntries is built independently and never participates in that check, but it inherits the same discipline: when grouping is inactive, buildRenderEntries() assigns renderEntries = displayRows (the identical reference, not a copy) — so an ungrouped grid allocates nothing extra on every setRows()/appendRows()/render pass, and grid.renderEntries === grid.displayRows holds as an observable invariant. This is verified directly: tests/node/grouping-feature.test.js and tests/playwright/grouping.spec.js's "R1" cases assert the reference identity, not just equal contents — including after an ordinary (ungrouped) header sort, which must re-alias renderEntries to the freshly sorted displayRows (§ 6).
10. Availability and Degradation
10.1 The strategy gate
_resolveGroupingStrategy(groupState) is grid-local only — it never asks a DataManager anything, and performs no I/O. It accepts any number of levels — 'multi-level-not-yet-supported' is never returned from here (see § 10.2):
!groupState.length -> unavailable, reason: 'disabled'
would leave no visible non-internal column -> unavailable, reason: 'no-visible-columns-left'
!infiniteScroll
|| _hasMoreData === false
|| (Number.isFinite(_totalRowCount) && _loadedRowCount >= _totalRowCount)
-> available, strategy: 'client'
otherwise -> unavailable, reason: 'partial-dataset'
A grid with infiniteScroll on and more pages still to fetch reports 'partial-dataset' — grouping is unavailable rather than computed over an incomplete prefix and silently wrong.
'no-visible-columns-left' guards § 18's column removal: grouping by every non-internal column would leave a grid of caption rows above rows with no cells. It is pure column arithmetic — O(columns), never a row scan — so unlike 'too-many-groups' it is available to the predicates and the header menu can grey the entry out before the user tries it. It counts against the columns that would be visible with nothing grouped, and it counts only columns that are neither __vgSelection__ nor __rowNumber__ — the same internal set the caption-toggle resolver and the Excel export share, deliberately not _countVisibleDataColumns()'s narrower definition (which excludes only __vgSelection__). Unlike 'partial-dataset', it rejects the request rather than suspending it: no later change of dataset can make it applicable, so setGroupState() reverts to the pre-call state exactly as a 'too-many-groups' abort does.
10.2 The predicate/operation split (D12)
canGroupByColumn(key) and canAddGroupLevel(key) are both predicates: O(1), never touch row data, safe to call on every header-menu open to decide whether to grey out a menu item. Between them they can report 'ok', 'disabled', 'partial-dataset', 'no-visible-columns-left', 'column-unsupported', or (from canGroupByColumn only) 'multi-level-not-yet-supported' — never 'too-many-groups', because cardinality is only knowable by actually scanning, which groupByColumn()/addGroupLevel()/setGroupState() do (§ 11). Those three calls are the only ones that can report 'too-many-groups', and when they do, the group state is left exactly as it was before the call.
Where that code is delivered depends on the dataset size (§ 6.1). Below sortShimmerThreshold the scan runs inline and 'too-many-groups' is in the mutating call's return value, as it always has been. At or above it the scan runs behind the shimmer, so the call returns the pre-scan capability and the rejection is delivered on vn-grid-group-changed instead. A host that must handle the rejection on grids of any size should read it from the event, which is authoritative in both cases; the return value is the convenience path for small grids.
'multi-level-not-yet-supported' is specific to canGroupByColumn() — it means "replacing the whole state with just this column isn't available while grouped by another column," not "the engine can't do multi-level." canAddGroupLevel()/addGroupLevel() are how a second (or third, …) level actually gets built through the header menu; setGroupState() is the direct, general-purpose way; see § 5.
10.3 Suspend and resume (D13)
isActive() re-resolves the strategy on every call — setRows()/appendRows() call it before deciding whether to run the effective sort, so a dataset that just became partial (e.g. a filter reload on an infinite-scroll grid) naturally falls through to the plain-sort path instead of projecting stale captions. This is the suspend mechanic: no separate "suspended" flag exists, only the live strategy result.
While suspended: getGroupState() keeps reporting the requested column, the collapse set (_collapsedGroupKeys) is untouched, renderEntries is the flat displayRows reference, and vn-grid-group-changed fires once with { active: false, reason: 'partial-dataset' } — only on the transition, never on every subsequent setRows() call while still suspended. A later reload that restores a complete dataset resumes grouping automatically, with every previously-collapsed group still collapsed.
10.4 Header menu
header-menu.feature.js renders up to two per-column grouping actions, mutually exclusive with a third, plus one state-independent action:
- Already a group level → one "Remove from grouping" button (
ungroupColumn). - Not grouped, nothing grouped yet → one "Group by this column" button (
groupByColumn, gated bycanGroupByColumn). - Not grouped, other levels exist → both "Group by this column" (shown disabled,
canGroupByColumnreports'multi-level-not-yet-supported') and "Add to grouping" (addGroupLevel, gated bycanAddGroupLevel) — the second one is what actually lets a user build multi-level grouping by clicking, one column at a time.
Independently of those three, an "Ungroup all" button (clearGrouping) is appended to the block whenever the group state has at least one level, on every column's menu. It is the grouping counterpart of "Unfreeze all" and follows the same rendering rule — only present when it has something to do, rather than the always-present-but-disabled shape of "Show all columns" — so an ungrouped grid's menu is unchanged. It matters more than the freeze equivalent does: an applied level's column leaves the grid along with its header menu (see the note below), so without this item the header menu offers no route back to a flat grid from any column at all, and the group bar's chips are the only way out.
This is a new UI pattern for this file: unlike hide/freeze (a plain boolean disable with no explanation), an unavailable grouping action gets a title drawn from the messages dictionary, keyed by reason code (messages.groupReasonPartialDataset, messages.groupReasonNoVisibleColumnsLeft, etc.) — the degradation is visible and explained, never a silently-missing menu item.
The first branch is only reachable while grouping is suspended. An applied group level's column leaves the grid (§ 18), taking its header — and therefore its context menu — with it; that is exactly why the group bar exists, and its chip is where an applied level is removed. A suspended grouping (§ 10.3) keeps its columns, so "Remove from grouping" stays reachable on the header menu there too.
11. High-Cardinality Ceiling (R6)
VanillaGridGroupingFeature.GROUP_CARDINALITY_CEILING (a fixed, named, exported constant — not a ratio or a measured byte budget) bounds the worst case: an ID-like, one-value-per-row column would otherwise create one caption object per row on top of the ~2×-length render projection. _scanLevel()'s boundary scan checks the ceiling before starting each new group, at any level, against one running total shared across the entire recursion — the worst case allocates the ceiling's worth of captions total (not per level), never one per row, and the previously-committed renderEntries/group state is left untouched (the caller reverts, § 5). A single-group expand enforces the same total incrementally: its splice seeds the nested scan's counter with _projectedCaptionCount, so it trips exactly where a full build of the new state would, and a refusal leaves the collapse state as it was (§ 5.1). The ceiling counts groups, so footer entries are not checked against it and a branch traversed only to be reduced — a collapsed one, under an active aggregate — consumes none of the budget either: it contributes no caption.
12. Keyboard and Focus Contract (D14, R8)
- Activation.
shouldHandleKeyboardEvent(interaction-keyboard.feature.js) gained abuttontag exemption: the grid stops claiming Space (normally mapped to page-down) when focus is on a<button>inside the viewport, so the caption toggle activates via its native Space/Enter behavior instead of the grid scrolling. This is a shared-keyboard-path change — it applies to any future in-cell button, not only captions — with its own regression coverage proving Space still pages when focus is on the viewport itself. - No listener-based click handling on the button. Toggle activation (mouse click and the synthetic click a keyboard activation dispatches) is resolved by the grid's existing delegated viewport click handler (
handleClick,interaction-pointer.feature.js), which checkse.target.closest('.vn-grid-group-toggle')and looks up the live render entry via the row'sdata-vn-grid-entry-index. A pool row's button never carries its ownaddEventListener— recycling it across many different captions over the grid's lifetime never accumulates stale listeners. - Focus is never stolen on activation.
handleClick's unconditionalviewport.focus()explicitly skips when the click target is a caption toggle — without this, the synthetic click a keyboard Enter/Space dispatches would immediately throw focus to the viewport, making a second consecutive keyboard toggle on the same caption impossible. - Focus survives pool recycling. Before a scroll-driven render pass repopulates or hides a pool row that currently holds the focused element, focus is handed to the viewport first (
rendering.feature.js'spopulateRow, gated on grouping being active so an ungrouped grid pays nothing extra) — otherwise the browser would drop focus to<body>when the focused button's content changes meaning underneath it, silently disabling further keyboard scrolling. Three signals trigger that handoff: the row is about to be hidden, it is rotating onto a different render-entry index, or the entry at its existing index has changed kind — a caption where a footer was, or either where a data row was. The third matters because a collapse-all leaves plenty of indices in place and changes only what lives at them, and the two row kinds' contents are not interchangeable: the row is rebuilt rather than updated, so whatever it held goes with it. - Shift+Enter is handled on keydown, not through the click. The caption's Shift-click (§ 5.1.1) has a keyboard form, Shift+Enter, and it cannot ride the button's native activation the way plain Enter/Space do: Firefox dispatches the click an Enter keypress activates without the held modifier (
shiftKey: false, and likewisealtKey), while Chromium carries it.handleKeydowntherefore checks for Shift+Enter on a focused.vn-grid-group-togglebefore the button exemption above, runs the children toggle, and callspreventDefault()— which cancels the native activation, so Chromium never delivers a second toggle through its click. Shift+Space is not bound: Space activates a button on keyup, and the grid maps Shift+Space to page-up. Focus stays on the toggle: only the block after the caption is replaced. A toggle that offers the gesture carriesaria-keyshortcuts="Shift+Enter"(§ 7). - A footer row is focusable. Its one focusable element (§ 7.1) is what carries the only name its group has for assistive technology, since nothing visible on the row says which group it closes. It is not actionable, so the grid's keyboard scrolling keeps working while it holds focus — and sequential
Tabtraversal reaches only materialized rows, exactly as it does for caption toggles today.
13. Event Contract
Four events, all bubbling + composed, dispatched on the grid container (grid-events.js / vanilla-grid.js's _emitGroupEvent):
vn-grid-group-changed—{ groupState, active, reason }. Fired ongroupByColumn/ungroupColumn/clearGrouping/setGroupState(always) and on anisActive()-detected availability transition (only on the transition). On a large grid the reorder is deferred, so this event fires when the new order has actually landed — not when the call returned — and it is the authoritative place to read a'too-many-groups'rejection (§ 6.1, § 10.2).vn-grid-group-expanded/vn-grid-group-collapsed—{ path, groupKey, all, descendants }. Fired byexpandGroup/collapseGroupand by the caption toggle itself withall: falseand the group's full path; fired once byexpandAllGroups/collapseAllGroupswith{ path: null, groupKey: null, all: true, descendants: null }— one event per gesture, not one per group, because listing every group would cost a scan of every row (§ 5.1). Adescendantscall or the caption's Shift-click (§ 5.1.1) fires one event too, however many groups it changed: the type names the target's resulting state anddescendants('collapsed'/'expanded') what was applied below it, so a Shift-click on an open group firesvn-grid-group-expandedalthough the group was already open.descendantsisnullon every other event. No event from a call that changed nothing.vn-grid-aggregates-changed—{ aggregates, active, reason }. Fired once per settled change to the configured aggregate set (§ 5.2) — whether the host asked for it throughsetAggregates()or the grid settled it itself by discarding the set on an ungroup or pruning it at a column change (§ 14.1). Never one event per dropped entry, and never one carrying a state that still holds an invalid key.
Why the aggregate change is not vn-grid-group-changed. Attaching a function to a column changes no group state at all — same levels, same order, same rows, same captions. A host listens to vn-grid-group-changed to react to structural change, so firing it for a change that alters one cell's contents per footer would wake every such listener for nothing: not a lie about when, but about what.
It means the computed values have rendered, not that the request was accepted. setAggregates() already answers the request half synchronously, in its return value. The event is the other half, and it fires from the same deferred finish the projection is built in — so a host reading getGroupAggregates() inside the handler always gets settled values, never results computed against rows a filter, sort or reload has since replaced.
A superseded recomputation emits nothing of its own. Aggregates share grouping's generation token (the one runReorderPipeline() stamps on the way in, § 6.1), so a second change while the first is still behind the shimmer discards the first's computation rather than racing it — one event, carrying the set that actually landed. "Superseded" means the computation, not the set: setAggregates() installs the set synchronously, so a set that landed is always announced, by whichever operation settles — another aggregate change, a group transition that joined or overtook it, or a sort, reload or append that overtook it (§ 5.2's settle hook). A pending event therefore never outlives the next settle, and an unrelated later group change can never pick one up. The same holds for vn-grid-group-changed: a group change another reorder overtakes is announced when that reorder finishes (§ 6.1). Below sortShimmerThreshold there is nothing to supersede: every call is fully synchronous.
14. Persistence
persistence.groupState ({ enabled, storageKey }) mirrors persistence.sortState exactly — same constructor shape in persistence.feature.js (resolveGroupStateStorageKey(), persistGroupStateToStorage(), loadGroupStateFromStorage(), hasPersistedGroup()), same auto-generated key pattern (vanilla-grid:group-state:<path>:<scope>). The levels and the configured aggregates are what persists — expand/collapse state is per-session and never written, so a reload of a persisted grouping opens at groupExpandMode, the same state an ungroup/regroup round trip lands on within a session (D21, § 5.1).
One key, one flag, one lifetime. The stored value is an object, { levels: [{ key, direction }], aggregates: [{ key, fn }] } — not the bare [{ key, direction }] array getGroupState() hands back. An aggregate belongs to the grouping session (§ 5.2), so persisting it separately would let a grid reload holding totals for a grouping it no longer has; a grid that opts out with persistence: { groupState: { enabled: false } } opts out of both halves together. A value stored before aggregates existed is a bare array and reads back as no aggregates configured — one branch in _parseGroupState(), no version field and no migration. getGroupState()'s public array shape is unchanged; the widening is confined to the stored form. See Local Storage Settings § 6.7.
Both setGroupState() and setAggregates() write the whole blob, each from its own pipeline's finish — so a computation the reorder pipeline supersedes never writes on its own, and whichever half changed persists the other unchanged alongside it. When the superseding operation is a sort, reload or append rather than another group or aggregate change, the settle hook (§ 5.2) persists as well as announces what the overtaken call left pending, so storage and getAggregates()/getGroupState() never drift apart.
setGroupState() persists the final group state (after any too-many-groups revert) unconditionally on every call — the same "persist whatever was actually settled on" behavior sortColumns() already follows for sort. Persistence is skipped while _isRestoringPersistedState is set, which the grid uses both when restoring on load and when clearPersistedSettings() resets grouping — otherwise a restore/clear would immediately write its own result straight back to storage.
14.1 The lifetime of a configured aggregate
Aggregates live and die with the requested group state. Not with the applied one, and not with the footer rows that display them — one rule, four cases, no exceptions:
| what happens | what becomes of the configured set |
|---|---|
The last group level is cleared — clearGrouping(), "Ungroup all", ungroupColumn() on the only level |
Discarded. The requested state is destroyed, and an aggregate has nothing left to reduce over. Exactly one vn-grid-aggregates-changed follows the vn-grid-group-changed, carrying the emptied set, and the discard is what persists |
| Grouping is suspended for a partial dataset (D13, § 10.3) | Retained, with no event and no host action. The requested state was never destroyed — only left unapplied — so resuming recomputes from the set the user already chose. While suspended the set is not editable: setAggregates() refuses with 'disabled', and the header menu's entry is disabled explaining the suspension in its own words ('partial-dataset', § 5.3) |
| A level is removed from a multi-level grouping | Retained. The session is still alive while any level remains — the same narrowness the collapse set's D21 clear has |
| A group change is rejected for too many groups (R6, § 11) and rolls back | Settled against the restored grouping. A restored empty grouping clears the set — an aggregate accepted only because the rejected grouping was provisionally in force cannot outlive it, so a flat rejection persists { levels: [], aggregates: [] }. A restored non-empty grouping keeps the set as it stood — a setAggregates() that joined the transition was told it was accepted — minus any entry that aggregates one of the restored levels (DR2a). One vn-grid-aggregates-changed fires only when the settled set differs from the one the transition started from; landing back on exactly that set announces nothing |
The discard is deliberately as narrow as D21's: it is checked exactly where the collapse set is cleared, on the one condition that an empty requested state resolves to 'disabled' and so can never be a call that failed and reverted.
Column changes prune the set. setColumns() is the single point every column change flows through, so it is where normalizeAggregatesForColumns() revalidates each configured entry against the columns as they now are — through _resolveAggregateEntry(), the same gate a fresh acquisition from the header menu runs through. Removal, rename and retype are one question asked once (does this entry still satisfy the gate?): a renamed column is indistinguishable from a removal plus an addition at this layer, and a column that goes number → string loses its sum by exactly the rule that would have greyed the menu out. The settled set is persisted and announced with one event carrying the final state — never one per dropped entry, and never one carrying a state that still holds an invalid key. Validating here rather than at render time is what keeps a stale key from reaching the walk or a footer cell, and what stops the reducer and the menu disagreeing about what is aggregated.
The call runs after the header layout has settled in setColumns(): it re-renders when it drops anything, and rendering against a column set that call is still rearranging would draw the wrong thing. With no aggregate configured it returns immediately, which is what keeps it off the ordinary path. When the same setColumns() also re-applies the ordering (an ordering column changed how it orders — 06 § 10.3), the prune runs first with { rebuild: false }: it settles, persists and announces the set as usual but skips its own rebuild, which would scan rows still in the old definitions' order (and, above sortShimmerThreshold, run while the reorder is still pending). The reorder's finish does a full build over the settled set instead.
A declared aggregate with no grouping beside it is warned about and ignored, naming the columns. grouping.aggregates is parked and applied after the group state (§ 5.2), so an orphan declaration is refused with 'no-grouping' — and since a silent refusal makes a declaration that quietly does nothing invisible to whoever wrote it, VanillaGrid#_applyInitialAggregates() logs it. A refusal of 'disabled' is treated differently: that is a requested grouping that is merely suspended, which an infinite-scroll grid always is at this point in its lifecycle (no rows have loaded yet), so the request is re-parked and the reload's own consume call retries it once the dataset completes. Dropping it there would lose a persisted set on every reload of a server-paged grid.
Precedence at construction. Persisted group state, when present, wins over the declarative grouping.columns option / group-by attribute — the same precedence persisted hidden/frozen columns already use over their own declarative column-level defaults. Both are parked (_pendingPersistedGroupState, _pendingGroupColumns) until columns are resolved in setColumns(), since setGroupState() needs real column objects to validate against.
A declarative grouping is a default, not a lock. It says how the grid opens until the user decides otherwise; once they change the grouping, their persisted choice wins on every later visit — including when that choice is "no grouping at all". _loadPersistedState() therefore tests key EXISTENCE, not emptiness (hasPersistedGroup(), not state.group.length > 0): removing the last level persists [], which parks as an empty array — truthy, so it wins the precedence || and the grid opens ungrouped instead of re-grouping itself on every reload. This is the same distinction _hasPersistedKey() has always drawn for hidden/frozen defaults.
A grid whose grouping is part of what the data means — a report that is misleading ungrouped — opts out of persisting it instead:
persistence: { groupState: { enabled: false } }
clearPersistedSettings() clears the live group state too (this._grouping.clearGrouping()), not only the storage key — mirroring how it already calls this._sorting.clearSort() for sort state — and then restores the declared grouping, and with it the declared grouping.aggregates, under exactly the same rule and with no aggregate-specific exception. The declarative request is retained in _declarativeGroupColumns (not consumed by the first setColumns()) and re-parked as _pendingGroupColumns before the reset's setColumns(declarative) call, so "reset this grid" means back to the markup for grouping exactly as it already does for widths, order, visibility and freezing. Since the user's persisted choice now outlives the declaration, this call is the only way back to it.
The reset orders the dataset once, and only when it has to. Two shapes, chosen by <vn-grid> from whether the reset actually changed the DataManager's query (§ 6.5 of Local Storage Settings owns that decision):
| shape | what grouping does |
|---|---|
| no reload follows | clearGrouping() and the re-parked declarative grouping both run their ordinary pipelines. The first is superseded by the second through the shared generation token, so only the last one reorders — behind the shimmer, off-thread when every level is worker-safe. |
| a reload follows | clearGrouping({ deferToReload: true }) clears the state without reordering, and setColumns() leaves the declared grouping parked rather than applying it. setRows() consumes the parked state (state only, § 6.1) and its own reorder produces the ordering — one reorder for the whole gesture. |
Before this, the reset reordered a million rows four times and then let the reload throw the answer away and reorder them once more, in-thread and without a shimmer.
15. Web Component Forwarding
vanilla-grid-element.js forwards every method in § 5 as a thin wrapper (the same if (this._grid && typeof this._grid.x === 'function') { return this._grid.x(...) } return <safe default> pattern column-visibility/freeze methods already use), plus:
group-byattribute /groupByproperty — a multi-level grouping declaration, read once atinitializeGrid()time intooptions.grouping.columns(no live push after init — the same "declarative at construction, imperative afterward" contractselection-mode/row-key-fieldalready follow). UsegroupByColumn()/setGroupState()on the live grid for runtime changes.group-by := entry ("," entry)* entry := key ":" direction direction := "asc" | "desc" (case-insensitive)<vn-grid group-by="country:asc, city:desc"></vn-grid>Entry order is meaning — nesting order, outermost level first. Whitespace around entries and around the
:is trimmed; empty entries (a trailing comma) are skipped silently.sort-byuses the identical grammar for the initial sort chain (see Sorting §12.1); the two compose without coordination code, sincesetGroupState()re-derives the effective sort with the group fields leading.The direction is required on every entry. A directionless entry — or one whose direction token is neither
ascnordesc— is skipped with alogger.warnnaming the attribute and the offending entry; the attribute's remaining valid entries still apply. The reason is that a bare key is not "ascending" here, it is undecided:_sanitizeGroupState()records it asexplicitDirection: false, which is exactly the state the D18 sort transfer (§11.1 of the sorting doc) may overwrite — so markup could say one thing and the grid render another.VanillaGridGroupEntry.directionis already non-optional in the published types for the same reason.This rule lives in the attribute parser (
_parseKeyDirectionList()invanilla-grid-element.js) and nowhere else._sanitizeGroupState()stays permissive:groupByColumn()/addGroupLevel()passdirection: undefinedfor an ordinary header-menu click, so tightening the sanitiser would make "Group by this column" a no-op.A level whose key matches no column is skipped with a warning from
_sanitizeGroupState()— covering the persisted and programmatic paths too, which used to drop unresolved keys with no diagnostic at all.Per-column attributes are NOT a declarative form. Grouping and sorting are declared on
<vn-grid>, never on<vn-grid-column>. Because_parseDeclarativeColumns()copies every attribute onto the column object and_validateColumns()is a positive-list validator, agrouped/sorted/group-level/sort-levelattribute would otherwise be silently inert;_parseDeclarativeColumns()warns once per parse when it sees any of them, pointing at the grid-level attribute.
16. Failure Safety and Guardrails
setGroupState/groupByColumn/addGroupLevelnever throw on an invalid column key — they return{ available: false, reason: 'column-unsupported' }and leave state untouched.canGroupByColumn()additionally returns'multi-level-not-yet-supported'for a second, distinct column while already grouped (§ 10.2) — none of the three mutating calls ever return that reason themselves; a rejectedsetGroupState()/addGroupLevel()/groupByColumn()call only ever fails with'column-unsupported'or'too-many-groups'.addGroupLevel(key)is a no-op (state untouched, returns the last strategy result) whenkeyis already a group level — it never duplicates an entry.- A too-many-groups abort (§ 11) reverts to the exact pre-call state rather than leaving a half-built projection.
canGroupByColumn()/canAddGroupLevel()are both guaranteed O(1) — safe to call on every header-menu open regardless of dataset size.- Grouping never issues a network request —
_resolveGroupingStrategyperforms no I/O, and noDataManagermethod is called. - A declarative grouping can still be declined. Two cases are worth knowing when writing
group-by. An applied level's column leaves the grid (§18.1), so naming every column trips'no-visible-columns-left', whichsetGroupState()rejects cleanly and leaves the grid ungrouped. And a high-cardinality column can abort at the first data load with'too-many-groups'(§11); the existing revert leaves the grid ungrouped and firesvn-grid-group-changedcarrying the reason. Both are the ordinary engine behavior — the declarative layer adds no guard of its own — but they are the one place where markup asks for something the grid may refuse, so read the event rather than assuming the attribute took.
17. Example Configuration
<!-- Country groups, cities descending within each. The direction is required. -->
<vn-grid id="myGrid" group-by="country:asc, city:desc"></vn-grid>
const grid = el.initializeGrid({
grouping: {
expandMode: 'expanded',
showCount: true,
},
});
const result = grid.groupByColumn('country', { direction: 'asc' });
console.log(result); // { available: true, strategy: 'client', reason: 'ok' }
grid.collapseGroup(['Italy']);
console.log(grid.getGroupState()); // [{ key: 'country', direction: 'asc' }]
grid.clearGrouping();
Checking availability before offering a "Group by…" control:
const capability = grid.canGroupByColumn('country');
if (!capability.available) {
// capability.reason: 'partial-dataset' | 'multi-level-not-yet-supported'
// | 'no-visible-columns-left' | 'column-unsupported' | 'disabled'
button.disabled = true;
button.title = messages['groupReason' + capitalize(capability.reason)];
}
Multi-level grouping, set all at once via setGroupState() (§ 5):
const result = grid.setGroupState([
{ key: 'country', direction: 'asc' },
{ key: 'city', direction: 'asc' },
]);
console.log(result); // { available: true, strategy: 'client', reason: 'ok' }
grid.collapseGroup(['Italy']); // hides Italy's entire subtree — every nested city caption and row
grid.collapseGroup(['Italy', 'Rome']); // hides just the Rome sub-group under Italy
grid.ungroupColumn('city'); // drops the city level, leaving [{ key: 'country', direction: 'asc' }]
Or built up one column at a time — what the header menu's "Group by this column" / "Add to grouping" do:
grid.groupByColumn('country'); // -> [{ key: 'country', direction: 'asc' }]
grid.canAddGroupLevel('city').available; // -> true, now that some grouping exists
grid.addGroupLevel('city'); // -> [{ key: 'country', ... }, { key: 'city', direction: 'asc' }]
Both grouped columns have now LEFT the grid — their values live in the captions, and their chips in the group bar are where they are removed from (§ 18):
grid.columns.some(c => c.key === 'country'); // -> false while grouped
grid.getHiddenColumns(); // -> user-hidden columns only; never 'country'
grid.showColumn('country'); // -> false; it is grouped, not hidden
grid.getGroupingStatus(); // -> { available: true, strategy: 'client', reason: 'ok' }
grid.ungroupColumn('country'); // column returns at its original position, width, freeze and filter
18. Grouped Columns Leave the Grid, and a Group Bar Replaces Them
An applied group level's column repeats one value on every row of its group while the caption above already carries that value — there is no configuration in which showing it adds information. So it is removed. There is no option controlling this, and the obligation that creates is honoured directly in code: the grid may never remove a column while having nowhere to render the bar (§ 18.6).
Removal and the bar are one feature, not two. The only interactive ungroup affordance a grouped column has is its own header context-menu entry — which leaves with the header. Remove the column without the bar and a grid grouped by Country would have no reachable way back except host code or the console.
18.1 Removal is a derived exclusion, never a hideColumn() call
VanillaGridGroupingFeature#getHiddenColumnKeys() returns the normalized keys of the columns grouping is currently removing, or null when it removes none. computeVisibleColumnsFromAll() (columns-visibility.feature.js) gains exactly one extra condition: a column is excluded if it is in the user hidden set or in that set. allColumns is untouched.
Reusing the user hidden set would have been wrong on three independent counts:
hideColumn()/_canHideColumn()refuse a column that carries an active sort, is frozen, or would leave fewer than two visible data columns — all three reject group removal for reasons that do not apply to it.- The hidden set is persisted and publicly readable through
getHiddenColumns(); group-driven keys written into it would outlive the grouping that caused them. showAllColumns()clears that set wholesale, which would un-hide a grouped column and leave the two states disagreeing.
That single derived condition buys the whole round trip for free. Ungrouping restores the column at its original position (applySavedColumnOrder), at its saved width (saveColumnWidths, called before the column array mutates), still frozen if it was (_reorderColumnsForFreeze), and still filtered (the filter model is keyed by column key, not position) — because none of that state was ever discarded, only filtered out of a derived array. tests/playwright/grouping-column-removal.spec.js asserts all four before and after.
Public contract. getHiddenColumns() and showAllColumns() keep meaning user-hidden only. A grouped column is not "hidden", it is "grouped", and the set of grouped keys is already public through getGroupState(). showColumn() on a group-hidden column returns false — it is not the caller's to show; ungroupColumn() is.
When it happens. In setGroupState()'s prologue, in the same frame as the chip — not after the reorder (§ 6.1). finish calls it a second time, which is what restores the column when an R6 abort reverts the state there.
Reuse. The re-layout path is _applyColumnVisibilityAndRefresh() called verbatim — it already sequences close-menu, save widths, set columns, re-apply saved order, re-apply freeze, remap sort state, refresh header layout, rebuild the pool preserving scrollTop, and re-render. Grouping does not carry a second copy of that sequence. It is idempotent (a no-op returning false when the resulting key list is unchanged), which is what lets it be called unconditionally at both choke points.
The columns feature never references grouping directly. It loads first, so it reaches getHiddenColumnKeys() through an injected getGroupHiddenColumnKeys closure in columnsConfig — the same pattern the six grouping actions already use. A null return means "grouping not loaded / removing nothing" and leaves the function behaving exactly as it did before grouping existed, which is what keeps a partial bundle without grouping.feature.js working unchanged.
Group levels resolve against allColumns, not the visible array. They have to: an applied level's own column is removed from the visible array by this very mechanism, so resolving against it would make an applied grouping un-resolvable the moment it took effect. For the same reason the effective sort (§ 6) names a group level's column with a direct column reference rather than a columnIndex — VanillaGridSortingFeature#sortRowsByState() accepts either, and there is no index left to name a removed column by.
18.2 Removal follows the applied grouping, never the requested one
getHiddenColumnKeys() returns null unless _resolveGroupingStrategy() reports available: true. A suspended grouping (§ 10.3) must not remove its columns: no captions are rendered, so the column would disappear with no visible explanation and its values would become unreachable — the exact "grouping is unavailable rather than wrong" line the feature draws everywhere else. On suspension the columns come back and the bar switches to its suspended presentation; on resume they are removed again. Both transitions run through the same two choke points that emit vn-grid-group-changed: setGroupState() and isActive()'s transition branch.
18.3 Grouping a column that carries a user sort transfers the sort
This is the sharpest edge in the feature. remapSortStateAfterColumnReorder() rebuilds sort state by looking each entry's column up by key in the new visible array and filters out the ones it cannot find — so removing a sorted column from that array would silently delete the user's sort, and ungrouping would not bring it back. (That is precisely why hideColumn() refuses a sorted column in the first place.)
So grouping a sorted column transfers that sort:
- the entry's direction seeds the new group level's
direction— unless the caller passed an explicit'asc'/'desc', which always wins; - the entry is removed from the user sort state, through
sortColumns(remaining, { deferToReload: true })so the removal composes exactly like any other sort mutation:onSortfires (and with itvn-grid-sort-changed, keeping a host that mirrors sort state consistent), the header indicators refresh, and the new state persists.deferToReloadsuppresses only the client-side re-sort, because the effective grouping sort runs over the same rows immediately afterwards; - visible row order does not change, because the effective sort already leads with the group fields (§ 6);
- the direction remains visible and adjustable on that level's chip in the bar.
Ungrouping does not restore the sort entry — the chip is where that direction lives now. The transfer applies only to levels that are new in a given setGroupState() call, so re-issuing an existing level never re-seeds it, and it is gated on the grouping actually being applied (§ 18.2): a suspended grouping leaves its column and its header sort indicator in place, so there is nothing to transfer away from.
18.4 The Excel export re-composes the grouped columns back in
exportToExcel() builds its column list from this.columns — the visible array — so with removal in place a grid grouped by Country would export a sheet with no Country column at all, silently dropping the very field the export is organised by.
The export column list is therefore composed as applied group columns first (in group order), then the visible columns — matching the order the rows are already sorted in, and where a reader of a grouped grid expects to find them in column A. An explicit options.columns allow-list passed by the caller stays authoritative and is not augmented. The frozen-pane prefix count runs against the same composed array, and a prepended group column is never counted into it: group columns sit ahead of the grid's own leading frozen run, so their presence breaks the frozen prefix outright.
Otherwise the export is unchanged: flat, data-only, ordered by displayRows.
18.5 Interactions that need no grouping-specific branch
- Column filters. A filter on a grouped column stays applied and keeps filtering — the filter model is keyed by column key, not position, so nothing needs remapping (unlike sort state, which is index-based, which is why § 18.3 exists and this does not). The filter icon leaves with the header, so while grouped that filter can only be edited by ungrouping first; the toolbar's
hasFilterOrSortpredicate andclearColumnFiltersAndSortingcommand still see and clear it. A documented limitation, not a defect. - Freeze. A frozen column that gets grouped keeps its key in
_getFrozenColumnKeys()and simply stops appearing. The remaining frozen columns are still a contiguous leading run after_reorderColumnsForFreeze(), so no offset math changes and the column returns frozen. - Reorder, resize, autofit, stretch-to-fit operate on the visible array and see a shorter one, exactly as they already do for a user-hidden column.
- Caption toggle placement (§ 7) re-resolves against the new visible array. The "first visible non-internal" rule is unchanged — the grouped column is simply no longer a candidate, which also fixes the odd case where the toggle landed in the very column whose value the caption was already printing.
- Column-visibility UI. "Show all columns" and the hidden-column list keep listing user-hidden columns only.
18.6 The bar: ownership, mount point, and layout
The bar is a grid-owned strip inside .vn-grid-table-container, inserted before .vn-grid-header-spacer, carrying data-vn-grid-ref="group-bar". It is not owned by vanilla-grid-toolbar: that is an optional component which depends on the grid, never the reverse, and a core affordance of the grid cannot live in a component the grid does not require. There is no toolbar-hosted variant — two presentations of one state is the failure this design exists to prevent.
Three mount sources, in order:
- the
groupBarelement passed to theVanillaGridconstructor — what<vn-grid>supplies from its own_ensureMarkup(), created unconditionally alongside the header spacer and viewport, exactly so it can never go missing; - an existing
[data-vn-grid-ref="group-bar"]child of the container (host-authored markup declaring the mount point); - one the grid creates itself.
Only a grid with no resolvable .vn-grid-table-container — hand-built markup with no such ancestor — gets none. That grid logs once and keeps its grouped columns visible: getHiddenColumnKeys() returns null when the bar cannot be mounted, so grouping stays fully functional (captions, collapse, API, header menu) and only the column removal degrades. The same applies to a partial bundle that omits features/grouping-bar.feature.js entirely — the module is additive.
Layout comes for free — but the row snap does its own arithmetic. The container is display: flex; flex-direction: column and the viewport is flex: 1 1 auto, so a flex: 0 0 auto first child shrinks the viewport with no height arithmetic for the flex layout. The snap is the exception: _applyViewportRowSnap() writes an explicit viewport.style.height, which overrides align-items: stretch on the flex item, so the bar's height has to be named rather than inferred. _computeAvailableViewportHeight() (viewport.feature.js, 02 § 1.12) subtracts the bar alongside the header spacer; while it subtracted only the header, a grouped grid was pinned one bar taller than the space left for it, the container's overflow: hidden clipped the excess, and the last row was scrolled to but never seen. The bar is observed by the grid's ResizeObserver (_observeGroupBar(), called from both _initResizeObserver() and _ensureGroupBar() because the bar is created lazily), so appearing, disappearing, and wrapping its chips to a second line all re-run _onViewportResized() — nothing else changes size when the bar does, and a group change does not otherwise reach the snap. That handler is told to preserve the scroll offset whenever the bar is among the resize entries: its near-top reset is right for a window resize and wrong for a group change, which must leave the user where they were (R9). Two placement constraints are load-bearing: the bar must be outside .vn-grid-scrollbar-wrapper (created by wrapping the viewport) or it would scroll away with the body, and it re-renders before the column sync so its appearance has already changed the viewport height by the time the pool is re-sized.
18.7 Bar anatomy, and the invariant that keeps it honest
One chip per group level, ordered outermost → innermost:
- the column's header label, resolved through
getHeaderMainText— the same resolver the header itself renders through, never the rawcolumn.key; - a direction control showing that level's
asc/desc(aria-pressed="true"when descending), flipping it throughsetGroupState(); - a remove button that calls
ungroupColumn(key).
The bar's leading slot carries a localized static label (messages.groupBarLabel, which is also the strip's aria-label). Chips are separated by a logical-direction separator, so RTL needs no special case.
Invariant: the bar holds no state of its own. Every render derives from getGroupState() plus getGroupingStatus(), on every vn-grid-group-changed. Every action it offers goes through the existing public API, so a bar click and a header-menu click are indistinguishable downstream and no new event type is needed. Chips are rebuilt from scratch on every render — the chip count is a handful, so reconciling in place would buy nothing and would reintroduce exactly the stale-content class of bug the caption pool had to solve.
Visibility is automatic, not configurable. The bar occupies space only while a group state exists — applied or suspended. No grouping, no bar, no lost vertical space. There is deliberately no always-visible empty bar: an empty strip is meaningful when it is a drop target and misleading when it is not, so that mode belongs with a future header-to-bar drag phase.
Suspended presentation. When grouping is suspended (§ 18.2) the chips stay visible, the strip gets data-suspended="true" and a title composed from messages.groupBarSuspended plus the reason-coded message. Remove still works — removing a level the grid cannot currently apply must not require first fixing the dataset.
18.8 Keyboard, focus and accessibility
- The bar sits outside the viewport, so
shouldHandleKeyboardEvent()never claims its keys — it already returnsfalsefor abuttontarget (the D14 exemption, § 12) and for any target outside the viewport. - Every control is a native
<button>in natural tab order.role="toolbar"on the strip with a localizedaria-label; each chip is a labelledrole="group", so a screen reader announces "Country, ascending, remove". - Focus handoff on removal, mirroring the caption rule in § 12: removing a chip destroys the focused element, so the target is decided before the state change. Focus moves to the next chip's remove button, or — when the last chip goes and the bar leaves with it — to the viewport. Without this, ungrouping the last level strands focus on
<body>and kills keyboard scrolling, the identical failure the caption path had to fix. Alt+ArrowLeft/Alt+ArrowRight(andCtrl+Shift+arrow, accepted as an equivalent because some Linux window managers swallowAlt+arrow before the page sees it) while focus is inside a chip moves that level one position in the logical direction — the keyboard half of § 18.12, so re-nesting never requires a pointer. The bare arrows keep their native toolbar/browser meaning, and nothing on the shared keyboard path changes: the bar is outside the viewport and the target is a<button>, soshouldHandleKeyboardEvent()never claimed these keys (the D14 exemption above).preventDefault()is called only when a move actually happened. Focus follows the moved level by key, not by index — looking the key up in the settled state is what keeps focus on the chip the user was holding when a'too-many-groups'abort reverts the move.- Six delegated listeners are installed on the strip once and removed in
destroy(), the same discipline_headerDelegatedListenersInstalledfollows:click, the four drag events of § 18.12, andkeydown. Chips are rebuilt on every render, so per-button listeners would have to be re-attached (and could accumulate) on every group action.
18.9 Why the bar is its own module
features/grouping-bar.feature.js is deliberately not folded into features/grouping.feature.js. That file is listed in build.js's BUNDLE_FAST_PATH — it takes the terser-only fast profile precisely because buildRenderEntries()'s boundary scan is a hot O(n) loop over the full dataset. The bar is the opposite kind of code: DOM construction, focus management and localization that runs once per group action. Merging them would drop cold DOM code into the weakest obfuscation profile for no performance gain, and would grow the hottest grouping file with an unrelated concern. The bar takes the default heavy profile like every other cold feature.
It extends VanillaGrid.prototype (the rendering/header-menu shape) rather than the VanillaGridGroupingFeature class, because it needs the grid's DOM refs, messages and logger — which is also why it must load after vanilla-grid.js in BUNDLE_PARTS, next to the other prototype-extension features, rather than beside grouping.feature.js. In the auto-loader array (where every entry runs after vanilla-grid.js has parsed) it is listed next to grouping.feature.js, the state it renders.
18.10 Theming and localization
The bar's appearance is entirely theme-owned. vanilla-grid.css holds only the mechanics of the strip and its chips — flex: 0 0 auto so the ResizeObserver re-sizes the row pool with no height arithmetic, the chips' inline-flex, the buttons' centred hit-area, [hidden], the focus plumbing — and every colour, radius, border, glyph, font and spacing value is a --vn-grid-group-* custom property. Each shipped theme (and the sample's own vn-grid-apple.css) carries one self-contained "Row grouping" block, and they deliberately diverge: squared chips with ↑/↓ in Carbon, capsules in Glow and Apple, a lead-in-less elevated chip row in Material. Themes Implementation § 4.8 has the full token tables, including how a theme makes a chip's direction arrow read as its own column-header sort arrow.
Every token falls back to a neutral value, so a custom theme that never mentions grouping still gets a working bar. That is load-bearing rather than cosmetic: an applied group level's column leaves the header (§ 18.1), so its chip is the only place it can be ungrouped from — an unstyled bar would be a dead end, not a blemish. tests/playwright/grouping-bar-theming.spec.js pins both halves.
New message keys: groupBarLabel, groupBarRemove, groupBarSortAscending, groupBarSortDescending, groupBarSuspended, plus the groupReasonNoVisibleColumnsLeft disabled-menu title. See Localization Implementation.
(There is deliberately no "empty bar" hint message: the bar never renders empty — see § 18.7.)
18.11 Not implemented
Header→bar drag-to-group is a later phase: with the bar shipped, the header menu already covers acquisition, and dragging a header into a strip is the most fragile part of the feature for the least incremental value. _resolveGroupChipDropTarget() (§ 18.12) is where it would plug in.
Touch chip drag is also deferred, and for a reason of its own rather than for scheduling. HTML5 drag events do not fire for touch input, so § 18.12 is pointer-and-keyboard; the keyboard half is what keeps that non-exclusionary. A port of columns-reorder.feature.js's hold-then-drag pointer gesture would land on a target of the wrong geometry — a chip measures about 108 × 26 px with two 20 px buttons flanking a ~60 px handle, well under the 44 px minimum on its short axis, and a press-and-hold landing 10 px off would ungroup the level instead of moving it. If it is ever picked up it should come with a @media (pointer: coarse) widening of the chip, not as a straight port of the header's handlers.
18.12 Re-nesting by dragging a chip
Chips are draggable within the bar, and dropping one on the other side of a sibling re-nests the grouping: dragging Country to the left of City turns City ▸ Country into Country ▸ City, and the captions, the effective sort and the row projection all re-derive. It ships on, always, like every other chip action — and notably it is not gated on columns.behavior.reorderable, which governs whether the user may reorder the grid's columns; group level order is group state, not column order.
A drop is setGroupState() with a spliced array, and nothing else. _moveGroupBarLevel(fromIndex, toIndex, dropBefore) reads getGroupState(), splices, and hands the array back through the public entry point — the same shape _flipGroupBarLevelDirection() has, and the same reason: the bar owns no state, adds no event and adds no public API. getGroupState() resolves each level's direction, so every level carries its own asc/desc across the move with no bookkeeping. A host reordering programmatically calls setGroupState() with a reordered array, which is exactly what this does.
Nothing in features/grouping.feature.js needed changing for it, and that is a property worth stating rather than a coincidence:
_sanitizeGroupState()keeps array order, so the order of the array is the nesting order.- The D18 sort transfer explicitly skips levels already present in the previous state. A reorder introduces no new key, so nothing transfers — correct by construction, with no guard.
- The set of applied group keys is unchanged (D16), so
_syncGroupHiddenColumns()finds nothing to do and the fallback pool rebuild runs, exactly as it does for a direction flip. - A reorder can change the caption count (
Country ▸ CityandCity ▸ Countrydo not produce the same number of groups), so it can legitimately hit the R6'too-many-groups'ceiling and revert. That path already repaints the chips from the reverted state. - Above
sortShimmerThresholdthe work defers (§ 6.1);_renderGroupBar()runs synchronously at the head ofsetGroupState(), so the chips land in their new order immediately and the rows follow behind the shimmer.
Collapse state resets to the expand-mode default after a reorder, and that is the right outcome rather than a gap. _computeGroupIdentity() encodes the whole path (§ 3.3), so every identity in _collapsedGroupKeys is invalidated by a renest — but after a renest the groups are different groups, and "keep Toronto collapsed" has no meaning once Toronto is no longer a top-level group. The stale identities linger in the Set — harmlessly, since nothing can match them again — until the grouping is cleared entirely, which discards the set (D21, § 5.1).
Four delegated drag listeners, and no document-level ones. dragstart resolves the source chip, rejects drags starting on a <button> (mirroring _onHeaderDragStart()'s .vn-grid-col-resizer guard, so the direction and remove controls stay pure click targets) and stashes the source level; dragover resolves the target and calls preventDefault() only when the drop would change the order; drop re-resolves and calls _moveGroupBarLevel(); dragend clears the marker and the drag state unconditionally. The header path needs _onDocDragOver/_onDocDrop because the pointer routinely leaves the header vertically while crossing a table; every chip drag begins and ends inside the strip, so a drag that wanders outside simply never gets a preventDefault()ed dragover, the browser cancels it, and dragend cleans up — the cancel semantic with no code. The drop handler clears the drag state itself as well, because the state change detaches the source chip and its dragend no longer bubbles to the strip.
No click-after-drag guard is needed: a header cell is itself a sort target, a chip is not, and the delegated click handler only ever acts on button[data-vn-grid-group-action] — which dragstart has already excluded as a drag source.
The whole strip is the drop surface, resolved in the same two tiers _resolveDocDragTarget() uses for the header. Fast path: the pointer is over a chip, resolved with e.target.closest() (so a wrapped second row resolves as correctly as the first). Fallback: it is anywhere else inside the bar — a gap, a separator glyph, the "Grouped by" lead-in, the bar's own padding, the empty run before the trailing commands — and _nearestGroupChip() gives it to the nearest chip, weighting vertical distance a thousand times higher than horizontal so a wrapped bar resolves to the row the pointer is actually on. Both distances are zero inside a chip, so the fallback agrees with the fast path wherever they overlap. Without it most of the strip's width is inert: with two chips, one of them being dragged, barely half a chip would accept a drop at all.
Targeting itself is a midpoint split, canonicalized to one marker per boundary. The resolved chip is split at its midpoint, and "before N" then collapses onto "after N−1" — they are the same insertion slot, and without the collapse the marker would jump between the trailing edge of one chip and the leading edge of the next while the resulting order never changed. Only the leading edge of the first chip keeps a true drop-before, because it has no chip to its left. The before/after decision is logical: the bar already flips its separator glyph for RTL, so a drop on the visually-left half of a chip in an RTL bar means "after".
It deliberately does not use _computeDropBeforeWithDeadZone(). That helper returns null for the middle band of the target — on a ~120 px chip an ~80 px dead centre where the pointer is over a chip and nothing at all is indicated. The dead zone earns its keep in the header, where a wide cell is dragged across on the way to somewhere else and a marker flickering on every cell in between is noise; a bar holds two to five chips and every one of them is the destination.
The insertion marker is the header's, not a second visual language: the chip's ::after draws the same pair of facing tips a column-header drop paints, from the same four 45° wedges and the same --vn-grid-drop-tip-* values. Two things differ, both forced by geometry. The shipped themes declare those tokens on .vn-grid-header-table th, and the bar is a sibling of the header rather than a descendant, so a chip cannot inherit them — each value is read through a --vn-grid-group-chip-drop-* token that falls back to the header token and then to the same literal the header block defaults to, which is what keeps the two matching out of the box. (vn-grid-apple.css is the only shipped theme whose header tips are not the shared currentColor mix, so it is the only one that restates the colour in its grouping block.) And the bracket is sized to a chip rather than to a header cell: it is centred on the chip's logical edge — the boundary the drop inserts at — rather than tucked inside a 20 px band, for which a ~108 px chip has no room, and it reaches past the chip's top and bottom. Two base-owned tokens carry that:
--vn-grid-group-chip-drop-reach(default 6 px) is amargin-inlineon the chip, added on top of the theme's--vn-grid-group-bar-gap, so the outer half of a marker centred on the chip's edge lands in whitespace rather than over a neighbour.--vn-grid-group-chip-drop-overhang(default 4 px) is how far the tips reach beyond the chip. Without it they are drawn inside the chip's own box — over its background, at its full height — where they read as decoration rather than as an insertion point. 4 px is the ceiling, not a preference: the strip is a scroll container (overflow-x: autocomputesoverflow-ytoautotoo), and the shipped theme with the least vertical padding in its bar leaves 5 px, so a taller bracket would simply be cut off.
inset-inline flips the whole thing for RTL with no second rule, and the wedge pair is horizontally symmetric so nothing needs mirroring. The dragged chip is dimmed to th.vn-grid-col-dragging's 0.55 so the two drag surfaces read alike.
The drag image is an explicit clone, not the browser's default. A chip's default drag image looks right in principle — it is a snapshot of the chip — but the browser paints it through the ancestor clip chain, and because the strip is a scroll container, a theme whose chip nearly fills the bar's content box loses the chip's bottom edge in the ghost (SAP Fiori is the visible case). _setGroupChipDragGhost() therefore clones the chip, parks it position: fixed off-screen and hands it to setDragImage() with the grab-point offset, so the ghost sits under the cursor exactly where the chip was picked up. It is taken before the source chip is dimmed, so the ghost is the opaque copy and the chip left behind is the faded one. Two details make the clone cheap: it is appended inside the grid container rather than to document.body (where _setDragGhost() puts the header's text ghost), because a chip's entire appearance comes from --vn-grid-group-* tokens the theme declares on the container and staying in the tree inherits them for free — position: fixed is not clipped by an ancestor's overflow, so living inside the bar's scroll container costs nothing; and the cloned buttons get tabIndex = -1, since a clone of a chip is a clone of its two controls. _clearGroupChipDragState() and _removeGroupBarListeners() both remove it, so neither a cancelled drag nor destroy() can strand one. There is no setDragImage() and no _setDragGhost() reuse: a chip's default drag image is a snapshot of the chip — the right size, the right content, already translucent — while the header needs a synthetic ghost only because a dragged <th>'s default image is a full-height, mostly-empty column. The chip carries messages.tooltipReorder as its title (the header's own reorder string — same gesture, same words, no new key), placed on the chip rather than its buttons so each button keeps its own tooltip. Tokens in Themes Implementation § 4.8.
The strip being a scroll container has a second consequence, and it is the theme's to avoid. The browser auto-scrolls the container a drag is over, so any vertical scrollable overflow in the bar — which is sized to its chips and should have none — is silently taken up on the first chip drag and left there, with nothing to scroll it back: the bar's contents end up sitting a few pixels above centre for the rest of the session. Clipping the overflow does not help (an overflow: hidden box still auto-scrolls); the overflow has to not exist. A theme's oversized, translateY-corrected direction glyph is the way it happens in practice, so the base clips that glyph to its button and grouping-bar-theming.spec.js asserts scrollHeight === clientHeight for every theme — see Themes Implementation § 4.8.
Suspended grouping reorders too. Chips stay draggable while getGroupingStatus().available === false, following D17/D23's rule exactly as removal does: the requested state is edited, and the grid applies it when it can. setGroupState() needs no branch. The two needsApplied bar commands stay disabled.
The code lives in features/grouping-bar.feature.js rather than a module of its own — it is the same kind of code the file already holds (cold DOM construction, focus management, event delegation, running once per group action), so splitting it would mean a new entry in two ordered load lists and a second load-order guard to isolate code with identical performance, profile and dependency characteristics to its host. build.js is therefore untouched, and a partial bundle that omits the bar degrades exactly as § 18.6 describes.