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 guideColumns 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' },
  ]},
]

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.

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.