developer guide
Data Grid Column Definitions
A column names the field it reads and the heading above it. Give one a fixed width or let flex share out the space that is left, hold it between min and max so it never collapses, and size every column to its real contents with grid.columns.fit().
Developer guide › Columns and cell rendering › Data Grid Column Definitions
Defining columns
A column is an object with a field (the path to read from your data) and
whatever else it needs. Everything except field or id is optional.
field reads a dot path, so nested data needs no flattening. The column's
id defaults to the field, which is what you use everywhere else: in
setCells, in filters, in saved state.
Grouped headers
Nest columns to get a spanning header row.
Two levels
columns: [
{ field: 'circuitId', title: 'Circuit' },
{ title: 'Location', children: [
{ field: 'region' },
{ field: 'country' },
{ field: 'site.address.postcode', title: 'Postcode' },
]},
]
Bands that open, close and stay together
A band is a ColumnGroup: a title, the columns under it, and four
options that decide how it behaves once the user starts moving things.
One summary column when the band is shut, the detail when it is open
{ title: 'Q3', collapsible: true, openByDefault: false, marryChildren: true, columns: [
{ field: 'q3Total', showWhen: 'closed' },
{ field: 'q3Jul', showWhen: 'open' },
{ field: 'q3Aug', showWhen: 'open' },
{ field: 'q3Sep', showWhen: 'open' },
]}
collapsible gives the band a control that opens and closes it. It is off by
default, and a band that is not collapsible passes its ancestor’s open state straight
down. openByDefault: false starts it closed; once the user has toggled it their
choice stands, and a saved view keys the state by the band’s id.
showWhen is what makes the pair useful. 'closed' shows a node only
while the enclosing band is shut, 'open' only while it is open, and
'always' - the default - regardless. A leaf column declares it
exactly as a band does, so tying one column to a band’s open state needs no wrapper
band of its own.
marryChildren keeps the band’s columns together: a drag, a keyboard move
or a column-menu move that would take one of them out of the band’s run, or drop a
column from outside into it, is refused with a [lattice] warning. Off by
default, in which case any of those may separate them.
Computed columns
A column can compute its value instead of reading one. Declare what it depends on and the
grid builds a dependency graph, so editing cost invalidates margin
and nothing else.
Derived values
{
id: 'margin',
title: 'Margin',
type: 'number',
format: 'currency:GBP:2',
value: {
deps: ['monthlyCharge', 'cost'],
compute: (deps) => deps.monthlyCharge - deps.cost,
},
}
// compute(deps, ctx): deps holds the resolved values of the columns you
// named; ctx carries { data, row, column, grid, context } when you need more.
compute is handed the values it declared rather than the whole row, which is
what lets the grid memoise it: the same inputs give the same answer, so the result is cached
until one of them changes. Reach for ctx.data when you genuinely need the rest of
the row, and set pure: false if the result depends on something the grid cannot
see.
Declaring deps is what makes this cheap. Without it the grid would have to
assume any change might affect any computed column and recompute all of them on every edit.
A cycle is caught at compile time with the full path named, rather than becoming a stack
overflow at render time.
When a computed value is re-run, stated plainly:
- A pure compute (the default) runs at ingest and is cached. It runs again when its row is
replaced through
rows.apply({ update })or the data throughrows.load()- unconditionally, since a value derived from data that is gone is stale by definition; when the grid a derived grid follows changes; and when you ask withrows.refresh({ rows, columns, force: true }). A sort, a filter, a state restore or an edit to a column outside itsdepsdoes not re-run it. An in-place cell edit to one of itsdepsdoes not currently re-run it either. - Naming a column re-runs what depends on it:
refresh({ columns: ['p'], force: true })recomputes aqwhosedepsincludep;refresh({ columns: ['q'] })does not recomputep. pure: falseguarantees the compute is re-evaluated on every read and every paint. It is never served from a cache.- Whenever a compute re-runs,
rows.text()and the painted cell show the new result, and so do a sort or filter on the column: every cache the grid keeps for that cell - the one behind the text and the paint, and the one sort and filter handles read - is invalidated together. - Without
force, a targetedrefresh({ rows, columns })behaves differently by store mode, and which one you get flips atcolumnarBelow. On a grid below that row count the named cell is re-run on its next read; on a columnar one the stored value stands until you passforce: true. This is behaviour to plan for, not a tuning detail: the same call on the same data recomputes or does not purely according to how many rows arrived. Passforce: truewhen you want the same answer whatever the row count.
An answer that arrives later
// A lookup the grid cannot see: show a placeholder, fill the table, then
// tell the grid which cells to recompute. The compute stays pure, so it is
// not re-run on every paint - only when you say the answer changed.
const names = new Map();
const column = {
id: 'owner', title: 'Owner',
value: {
deps: ['ownerId'],
compute: (deps) => names.get(deps.ownerId) ?? 'Loading…',
},
};
const missing = [...new Set(grid.rows.data().map((r) => r.ownerId))].filter((id) => !names.has(id));
const resolved = await fetchNames(missing); // { id: name }
for (const id of missing) names.set(id, resolved[id]);
const rows = grid.rows.data().filter((r) => missing.includes(r.ownerId)).map((r) => r.id);
grid.rows.refresh({ rows, columns: ['owner'], force: true });
// Or declare the column `pure: false` and it re-reads `names` on every
// paint; then a plain grid.rows.refresh() after the fetch is enough.
Sizing and pinning
Layout
{ field: 'circuitId', layout: { width: 130, pin: 'start' } }
{ field: 'notes', layout: { flex: 1, min: 200 } }
{ field: 'isActive', layout: { width: 90, pin: 'end' } }
Pinned columns are rendered in their own region and do not scroll horizontally.
grid.columns.fit() distributes the viewport width across visible columns, and
autoSize measures content.
Double-click a heading to autosize
Double-clicking a column heading - or the resize edge on its right - sizes the column to
its content, the same result as grid.columns.autoSize(id): the
heading is measured with the rows the grid has mounted, and the column's min and
max clamp it. A column with layout.resizable: false ignores it. The
keyboard form is Alt+Enter on a focused heading. The width goes through the
resize gate, so beforeColumnResize can veto it; it is on the undo timeline and in the
saved state like any resize, and the column:resized event carries
autoSize: true.
The sort does not change. A single click on a heading still sorts at once - there is no timer to wait out. The first click of a double-click sorts as usual and the grid
remembers the sort as it stood before it; the second click does nothing; the
dblclick then restores that sort. So a double-click ends with the sort exactly as it
was (none, ascending or descending), though sort:changed fires twice on the way.
A host that wants the old behaviour turns the gesture off per column, or for every column through
columnDefaults:
Turn it off
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
// On by default; false in columnDefaults.layout turns it off everywhere,
// and a column's own layout wins over the default.
const grid = createHeadlessGrid({
columnDefaults: { layout: { autoSizeOnDoubleClick: false } },
columns: [{ field: 'a' }, { field: 'b', layout: { autoSizeOnDoubleClick: true } }],
rows: [{ a: 1, b: 2 }],
});
const on = (id) => grid.columns.get(id).layout.autoSizeOnDoubleClick !== false;
return `a ${on('a')} | b ${on('b')}`;
The width fit() distributes is the space the cells actually occupy: the body
viewport’s client width, read when you call it. When the grid has enough rows to
scroll vertically, that width excludes the scrollbar, so the columns end flush with it
instead of running under it; when there is no vertical scrollbar, it is the full inner
width. Rows you passed to createGrid or grid.rows.load() before
the call are counted, so calling it straight after either works.
Every column the grid draws counts toward that width, not only the ones fit()
sizes. It sizes your resizable columns. A column it does not size keeps its width, and that
width comes off the target first: a column declared resizable: false, and the
grid’s own selection checkbox, detail expander, group and tree columns. Your resizable
columns then share what is left in proportion to their current widths, within each
min and max; a column held at a bound stays there and the others
share the rest, so all the drawn columns together still come to the viewport width
exactly. Under a pivot every column drawn is one the grid generates, so fit()
has nothing to size and leaves the widths as they are.
If what is left is less than those columns’ minimums, which happens when the columns
fit() does not size already take the width, each resizable column is set to its
min (40px when it declares none). None goes below its minimum, and none is
squeezed to nothing: the grid scrolls horizontally instead, and a [lattice]
warning in the console names the widths that ran out.
fit() is one-shot. It sets a fixed width on each column once, including a
flex column, and does not follow the grid afterwards. If the width changes
later, because the container is resized or because rows that arrive afterwards bring a
vertical scrollbar in, call it again. A column that should keep tracking the width by
itself wants flex instead of fit().
Both are also on the column menu: Move left, Move right, Move to start, Move to end, and a Width submenu, and bound to the keyboard with a heading focused: Alt with a left or right arrow resizes, Shift with one moves the column. Neither operation depends on dragging.
resizePreview: 'deferred' changes what a column-border drag shows while
it is in progress: the default, 'live', resizes the column as the pointer moves;
'deferred' draws a guide line at the pointer instead and commits the new width
only on release. The keyboard resize above is unaffected either way - there is nothing
to defer when a keypress sets the final width directly. Worth setting on a grid wide enough
that a live resize repaints more than the drag is worth.
A pinned region holds the edge of the viewport only while there is something to scroll. Where
the columns are narrower than the grid: fixed widths, or a flex column that
has reached its max: nothing scrolls, so the pinned columns sit directly
after the centre ones and the spare width falls beyond them all, at the right of the grid.
To take that space up rather than leave it, give a column flex and no
max, or call grid.columns.fit(). Note that removing a column’s
width does not do it: a column with neither width nor
flex takes the default 150 rather than a share of what is free. Absorbing space is
what flex is for, and min only ever sets a floor.
Locking a column down
Refusing a resize, refusing a move and refusing a hide are three separate decisions, so they are three separate keys.
An identifier column that stays where it is, and stays visible
{ field: 'circuitId', layout: { width: 130, resizable: false, movable: false,
lockVisible: true, lockPosition: true } }
lockVisible refuses to hide the column: grid.columns.hide(), the
column menu and the tool panel all leave it alone and warn once, and the AI layer is told the
column cannot be hidden. lockPosition holds it where it is - no drag, no
keyboard move, and no being pulled into a header band. It locks a column; it does not place
one, so put a column at an edge by ordering columns, or pin it with
layout.pin.
What the user may do with a column
The interface offers grouping, pivoting and totals on every column that admits them. These keys are how a column opts out, or tells the grid something about its own values.
A reference code nobody should pivot on or total
{ field: 'reference', allowGroup: false, allowPivot: false, allowTotal: false, nullable: true }
{ field: 'tags', group: { explode: true } }
{ field: 'closedAt', sort: { nullsFirst: true } }
allowPivot and allowTotal keep the column out of the pivot and
aggregate choosers the way allowGroup keeps it out of the grouping one. They
govern what the interface offers; your own code may still group, pivot or total the column.
nullable says an empty value is a legitimate value rather than a gap, so a blank
is not read as missing data.
sort: { nullsFirst: true } puts empty values before the rest instead of after
them, whichever direction the column is sorted in. group: { explode: true } is
for a multi-value cell: it gives the row one group per value rather than one group for the
whole combination, so a row tagged ['a', 'b'] is counted under both.
Cells that cover more than one row or column
Two forms, and they are not the same mechanism. rowSpan and
colSpan on the column merge cells in the grid’s own merge pass;
cell.spanRows and cell.spanColumns draw one spanned cell in a layer
of its own.
A merged category column, and a full-width note row
{ field: 'cat', rowSpan: (p) => (p.index === 0 ? 3 : 1) }
{ field: 'item', colSpan: (p) => (p.data.item === 'note' ? 3 : 1) }
{ field: 'span', cell: { spanRows: (p) => p.rows, spanColumns: (p) => p.cols } }
rowSpan and colSpan are called with the same
CellParams bag a renderer or a formatter gets, and return how many rows or
columns the cell covers; 1 or less is no merge, and a grid where no column declares either
never runs the merge pass at all. The cell is painted once at its origin, the covered cells
are not painted, a merge stops at a group heading and at a detail panel, and CSV, clipboard
and Excel write the value at the origin and blanks in the rest.
cell.spanRows and cell.spanColumns take a
SpanParams bag instead and are floored and clamped to what remains - spanColumns to the columns to the cell’s right. A spanned cell is drawn in
its own layer so row recycling cannot clip it. The renderer looks back 50 rows above the
viewport for a span’s origin: a taller span is reported once and drawn from the first
row in range.
The options a shadow’s model needs
A shadow column is a value the grid maintains about a column’s own history
rather than a field in the data. Most kinds need nothing but kind and
of; the statistical ones need to be told what to fit.
A seasonal decomposition, a smoothing model, a regression, a spec status
{ shadow: { kind: 'tsSeasonal', of: 'reading', orderBy: 'at', period: 7, decomposition: 'multiplicative' } }
{ shadow: { kind: 'tsSmoothed', of: 'reading', orderBy: 'at', smoothing: 'holt', alpha: 0.4, beta: 0.1 } }
{ shadow: { kind: 'fitPredicted', predictors: ['area', 'age'], response: 'price', method: 'wls' } }
{ shadow: { kind: 'specStatus', of: 'reading', lower: 9, upper: 11, warnLower: 9.5, warnUpper: 10.5 } }
period is the season length and is required, since there is no auto-detection:
7 for a weekly cycle in daily data, 12 for a monthly cycle in monthly data, and at least 2.
decomposition picks the classical model - additive by default, or
'multiplicative', which is undefined on a non-positive series, so those rows
report null and warn.
beta is Holt’s trend factor in [0, 1], and applies only to
smoothing: 'holt'. Omit it to have it fitted by minimising in-sample error; the
value chosen is reported by a tsSmoothingBeta column, exactly as
alpha is reported by tsSmoothingAlpha.
predictors and response give a fit* shadow its
regression directly, where you would otherwise pass a whole model.
warnLower and warnUpper are the inner thresholds of a
specStatus shadow: a reading inside lower and upper
but beyond one of them is still in spec and is marked 'WARN'.