Lattice Grid Buy a licence

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: 'site.address.postcode', title: 'Postcode', type: 'text', width: 110 }

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 through rows.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 with rows.refresh({ rows, columns, force: true }). A sort, a filter, a state restore or an edit to a column outside its deps does not re-run it. An in-place cell edit to one of its deps does not currently re-run it either.
  • Naming a column re-runs what depends on it: refresh({ columns: ['p'], force: true }) recomputes a q whose deps include p; refresh({ columns: ['q'] }) does not recompute p.
  • pure: false guarantees 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 targeted refresh({ rows, columns }) behaves differently by store mode, and which one you get flips at columnarBelow. 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 pass force: 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. Pass force: true when 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'.