TOCLOCO Inc
Lattice Grid
A data grid for people who have been let down by data grids. A hundred thousand rows that scroll like ten. Two files, no build step, no dependencies. This guide explains what each part of the API does and why it works the way it does; the reference tables are the shorter version for when you already know.
Version 1.65.0 Zero dependencies No build step
Translating the grid
Every string the grid renders or announces comes from a message catalogue. British English is the default; supply messages to replace some or all of it.
import { createGrid, FR_FR } from '@toclocoinc/lattice-grid';
createGrid(element, { locale: 'fr-FR', messages: FR_FR });
Where locale is not set, the grid takes the language the page declares in its lang attribute. Overrides merge over the default, so translating part of the interface leaves the remainder in English rather than showing raw keys, and a key that is not in the catalogue is ignored with a warning.
Catalogues ship for twenty-one locales: British and American English, French (France and Canada), Italian, Spanish, Brazilian Portuguese, German, Dutch, Swedish, Danish, Norwegian, Finnish, Polish, Czech, Hungarian, Romanian, Ukrainian, Greek, Japanese and Arabic. Each is an export of the package, so importing one does not reduce what is bundled. EN_US is a partial overlay carrying only what differs from British English and merging over it. AR_SA is an alias for AR rather than a separate catalogue: Arabic ships pan-Arabic, and a region appears in a name only where two variants exist. For anything else, resolveCatalogue(tag) resolves a tag to a catalogue and falls back to the base language, so ar-EG and es-MX both find one. FR_CA is a complete catalogue that follows Quebec usage where it differs, a column there is figée rather than épinglée.
None of the translations has been reviewed by a native speaker. They are structurally complete and checked for placeholder integrity and plural coverage, but they are a starting point for review rather than finished copy.
Writing your own
A message is a string, or an object keyed by plural category when it counts something:
{
'menu.sortAscending': 'Sortera stigande',
'count.rows': { one: '{count} rad', other: '{count} rader' },
}
Placeholders are named, so word order is yours to choose. The parameter called count selects the plural form, using the categories the language actually has: English needs two, French treats zero as singular, Arabic has six. Numbers are formatted for the locale automatically; do not format them yourself.
MESSAGE_KEYS lists every key. auditCatalogue(yours) returns what is missing and what is not a real key, which is the quickest way to check a translation before shipping it.
Keys seeded in English, awaiting translation
A key added after a catalogue was written ships in British English only until a translator supplies it; Messages merges every catalogue over the default, so the grid says it in English rather than showing the key. The shipped catalogues do not carry an English copy under the guise of a translation, and the build's completeness test lists exactly which keys are in this state. As of 1.55 the most recent additions are the strings that had shipped as template literals, untranslated in every locale (BACKLOG-0001106): the presence live region and roster note, a11y.presence.refused, presence.someoneElse and presence.hidden; the comment panel's changed-since note, comments.valueMoved; the facet band's accessible name, facets.filter; the heading tooltip that names a reduction, header.totalOf; the audit-mode tooltip, diff.before and diff.empty; the kanban list names, kanban.columnCards and kanban.laneCards, both plural objects selected by count; and the gantt lag label, gantt.lag and gantt.lead. Supply any of them in messages to translate it today.
The kanban board and the gantt view are modules and do not import the catalogue. A board bound to a grid, or a plan created with one, borrows that grid's messages; otherwise pass messages — a grid's own, or any object with t(key, params) — to createKanban or to gantt.mount. With neither they render the English.
The grid is checked in the other direction too. auditCatalogue tells you a catalogue is complete: that a translator covered every key. It cannot tell you the grid only ever renders text that came from a catalogue in the first place, and a string written into the source passes every test, because the tests assert on the English the grid happens to produce.
So the build refuses one. Any literal reaching an element's text, or an announced attribute such as aria-label, title or placeholder, has to come from the catalogue. That is what stops a localised grid drifting back into English one plausible change at a time.
Right-to-left
The grid lays out right to left. Set direction: 'rtl' outright, or leave it unset and it follows the element's computed dir first and the locale second, so locale: 'ar' renders right to left with no further configuration, and a grid inside a page that has already declared dir="rtl" agrees with it.
createGrid(element, { locale: 'ar' }); // direction follows the locale
createGrid(element, { direction: 'rtl' }); // or say so outright
The element comes before the locale because a page that has set dir has already made a decision about layout, and a grid inside it should not disagree on the strength of a language tag.
Pinned columns swap sides, the header and pinned rows follow the scroll the other way, and column resize and reorder, the fill handle, annotations, the facet band, the comment marker and menu placement all follow the writing direction rather than the physical one.
Not yet exercised with bidirectional text. The layout is verified, but only with Latin text in a right-to-left grid. Cells, headings and editors holding actual Arabic or Hebrew (particularly mixed with Latin text or numbers) have not been tested.
What it is
Lattice Grid renders tabular data in a browser. That sentence covers a great many products, so here is what is actually different about this one.
It holds data in columns, not rows
A grid of a hundred thousand rows and thirty columns is three million values. Held as row
objects, that is three million property lookups per pass and a great deal of memory the
garbage collector has opinions about. Lattice stores each column as one typed array: numbers
in a Float64Array, repeated strings as integers into a dictionary, booleans as
bits in a bitset.
Why it matters to you: sorting a column is a sort over one contiguous array of numbers, not a walk over a hundred thousand objects. Filtering produces a bitmask rather than a new array of rows. Both stay fast at sizes where the row-object approach has already given up, and neither allocates much, so the browser is not collecting garbage while the user is scrolling.
Work is memoised in stages
Everything between your data and the screen is six stages: filter, sort, group, total, pivot, flatten. Each remembers its result and the inputs it was computed from.
Change a sort and the filter stage is not recomputed: its inputs did not change. Edit a cell in a column nobody sorts, filters or groups on and none of the first five run; only the totals move. This is why a grid that is heavily filtered and grouped still feels immediate when you type in a cell.
The renderer reuses everything
Rows and cells come from pools. Scrolling reassigns the twenty or so row elements that exist
rather than creating and destroying thousands, and the only vertical write is a
transform, which the compositor handles without a layout pass.
No dependencies, and no build step
Not "few dependencies": none, at runtime and at build time. No lodash, no date library, no virtualisation library, no icon font. The bundler and minifier that produce the distribution are part of the repository. You can drop two files into a page and be finished.
A large share of enterprise line-of-business frontends are built without a bundler, and they are usually treated as second-class by grid vendors. Here the script tag is the first example in the documentation, not an appendix.
Install
Two files. Nothing is fetched at runtime (no CDN, no font, no sprite sheet) however the two files themselves got onto the page. Four equally valid ways to get them there:
npm
npm install @toclocoinc/lattice-grid
import { createGrid } from '@toclocoinc/lattice-grid';
import '@toclocoinc/lattice-grid/css';
const grid = createGrid(document.getElementById('grid'), config);
jsDelivr, no npm install, no bundler
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1/lattice-grid.min.css">
<script src="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1/lattice-grid.min.js"></script>
<script>
const grid = LatticeGrid.createGrid(document.getElementById('grid'), config);
</script>
Script tag, your own build
<link rel="stylesheet" href="lattice-grid.min.css">
<script src="lattice-grid.min.js"></script>
<script>
const grid = LatticeGrid.createGrid(document.getElementById('grid'), config);
</script>
ES modules, your own build
import { createGrid } from './lattice-grid.esm.min.js';
const grid = createGrid(document.getElementById('grid'), config);
jsDelivr mirrors every version published to npm at
cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1/<file>. @1 pins
the major: a page in production picks up fixes within 1.x and never a breaking release, where
@latest would. To freeze a page on one exact build, replace @1 with
the full version getVersion() reports. The same convention reaches a
module: .../modules/htmx.esm.min.js, .../modules/dhtmlx-compat.esm.min.js,
and so on. Type declarations resolve automatically through npm's own types field;
for editor tooling against the CDN or a plain script tag, point your tsconfig at
lattice-grid.d.ts directly.
Everything else in the distribution is an alternative packaging or a development aid:
| File | Gzipped | What it is |
|---|---|---|
| lattice-grid.min.js | The whole product as a UMD build. Defines window.LatticeGrid, and works with AMD and CommonJS loaders. | |
| lattice-grid.min.css | The single stylesheet. Without it the grid is in the DOM and unreadable, no widths, no scrolling, no theme. | |
| lattice-grid.esm.min.js | The same, as an ES module. | |
| lattice-core.esm.js | Headless core for Node, no renderer. See server-side export. | |
| lattice-grid.d.ts | Type declarations. |
Your first grid
Three things are required: an element to mount into, some columns, and some rows. Everything else has a working default.
The whole thing
const grid = LatticeGrid.createGrid(document.getElementById('grid'), {
rowKey: 'id',
columns: [
{ field: 'circuitId', title: 'Circuit' },
{ field: 'region' },
{ field: 'monthlyCharge', type: 'number', format: 'currency:GBP:2', total: 'sum' },
{ field: 'installedOn', type: 'date', format: 'date:dd MMM yyyy' },
],
rows: data,
});
A few things happened there without being asked for. region got a title of
"Region", a field name is turned into a readable heading rather than left as-is. The number
column right-aligned itself, because numbers align right and a grid should not need telling.
The date column parsed 2024-03-11 and rendered 11 Mar 2024. And
total: 'sum' put a figure in the totals row.
On rowKey: it names the field that identifies a row. Set it
if you have one. Without it the grid assigns keys per row object, which is enough for
sorting, filtering, selection and copying within a session, but not across a reload, because
new objects are new rows. Change tracking, streaming dedupe, selection persistence and remote
reload all want a real key, and the grid warns once, naming them.
React, Vue, Svelte
One optional bundle per framework. They are thin: the grid is created once against a host element, prop changes are pushed into it through the same public API you would call by hand, and it is destroyed on unmount.
You pass the framework in. Every adapter is a factory taking the
framework and createGrid, rather than importing either. Lattice ships zero
dependencies and the bundler rejects bare specifiers outright, so an adapter could not
import React from 'react' even if it wanted to. The same choice keeps each
bundle small: the bundler inlines whatever it can resolve, so an adapter that imported the
grid would carry a second copy of it. Passing both in leaves each adapter a
few kilobytes of glue, and means the adapter cannot disagree with the grid version you
already loaded.
React
import React from 'react';
import { createGrid } from '@toclocoinc/lattice-grid';
import { createLatticeGrid } from '@toclocoinc/lattice-grid/modules/react';
const LatticeGrid = createLatticeGrid({ React, createGrid });
function Circuits({ rows }) {
const ref = React.useRef(null);
return (
<LatticeGrid
ref={ref}
className="grid"
columns={columns}
rows={rows}
rowKey="id"
sort={[{ col: 'name', dir: 'asc' }]}
onCellChanged={(e) => save(e.key, e.colId, e.value)}
/>
);
}
// ref.current.grid is the live grid, for anything without a prop.
Hold your props steady. Change detection is reference equality, because
deep-comparing a million-row array on every render would cost more than the reload it
avoids. Build columns once outside the component, or memoise it; a fresh array
literal on each render tells the grid the columns changed and it will rebuild them. Rows are
the same: hand back a new array when the data actually changes, not before.
StrictMode is handled. React 18 deliberately mounts, unmounts and mounts again in development; the effect cleanup destroys the first grid, so the second starts clean and nothing leaks.
React: every viewer, not only the grid (1.63)
import React from 'react';
import ReactDOM from 'react-dom';
import { createGrid } from '@toclocoinc/lattice-grid';
import { createKPI } from '@toclocoinc/lattice-grid/modules/kpi';
import { createChart } from '@toclocoinc/lattice-grid/modules/charts';
import { createTabs } from '@toclocoinc/lattice-grid/modules/tabs';
import { createDataRouter } from '@toclocoinc/lattice-grid/modules/data-router';
import { createLatticeReact } from '@toclocoinc/lattice-grid/modules/react';
// createLatticeReact builds whichever components the factories you pass support.
// The individual factories are there too, if you want only one:
// createLatticeGrid, createLatticeKPI, createLatticeChart, createLatticeKanban,
// createLatticeGantt, createLatticeLayout, createLatticeTabs,
// createLatticeGridContext, createLatticeRouter, createLatticeViewer.
const L = createLatticeReact({
React, ReactDOM, createGrid, createKPI, createChart, createTabs, createDataRouter,
});
function Dashboard({ rows, updates }) {
const router = L.useLatticeRouter(ROUTER_CONFIG);
const [tab, setTab] = React.useState('all');
return (
<L.LatticeRouterProvider router={router}>
<L.LatticeGridProvider>
<L.LatticeTabs
active={tab}
onTabChanged={(e) => setTab(e.id)}
tabs={[
{ id: 'all', label: 'All',
content: <L.LatticeGrid name="all" route="all" {...CONFIG} rowUpdates={updates} /> },
{ id: 'big', label: 'Significant',
content: () => <L.LatticeGrid name="big" route="significant" {...CONFIG} /> },
]}
/>
<L.LatticeKPI gridName="all" tiles={TILES} columns={5} />
<L.LatticeChart gridName="all" type="bar" x="region" y="count" onClick={pick} />
</L.LatticeGridProvider>
</L.LatticeRouterProvider>
);
}
Every viewer is a component now. Before 1.63 the adapter wrapped
createGrid and nothing else, so a React application wrote its own
useEffect for the KPI panel, for each chart, for the board, for the Gantt — and
its own tab strip, because the tabs module builds its tabs' grids itself and a grid built that
way is not a component. createLatticeKPI, createLatticeChart,
createLatticeKanban, createLatticeGantt,
createLatticeLayout and createLatticeTabs each return one component;
createLatticeViewer is the generic behind them, for anything not yet named, and
createViewerController is the framework-free lifecycle underneath.
A viewer finds its grid through context, not a ref. A KPI panel and a chart
are built against a grid instance, and that instance does not exist until after the
first render. Writing to a ref re-renders nobody, so a sibling never learns the grid arrived.
createLatticeGridContext returns
{ LatticeGridProvider, useLatticeGrid }: a grid publishes itself under its
name, a viewer takes it by gridName, and the viewer is built the
moment it appears. A page with one grid names nothing.
A tab's content is yours. createLatticeTabs keeps the module's
tablist semantics, keyboard handling, lazy first mount and live badges, and renders each tab's
React content into the module's own panel element through a portal — so a tab's
grid is a real <LatticeGrid> with props, a ref and the surrounding context.
A tab with no content is left to the module, so both kinds mix on one strip.
The router is owned by a hook. createLatticeRouter returns
useLatticeRouter, which creates the router in an effect and destroys it in that
effect's cleanup — never in a useState initialiser, which React calls more than
once by design and whose discarded copy is never destroyed. It returns null on
the first render and the router on the second. <LatticeGrid route="…">
attaches to whichever router LatticeRouterProvider published, and detaches
before the grid is destroyed so the router never holds a dead grid.
A live feed needs no ref. rowUpdates is a keyed diff handed to
grid.rows.apply() whenever the object's identity changes.
predicates maps { name: fn } to
grid.filters.where(name, fn), which composes with whatever filter the reader set
— unlike the filters prop, which replaces the whole tree.
What is a live prop is declared, not guessed. The grid takes any changed
configuration key through one call; no other viewer does. So each viewer lists the props it
can take while running (VIEWER_APPLY) and everything else is mount-time
configuration. A mount-time prop that changes is neither ignored nor silently remounted: it is
named once, with the remedy — a key that changes when you want the rebuild, or
the ref. VIEWER_EVENTS and viewerHandlerName are the matching event
surface (card:move becomes onCardMove), and
DEFAULT_GRID_NAME is the name a grid publishes under when you choose none.
React 18 and 19, client only. Nothing uses an API added in 19 or removed in 19. Every component owns a real DOM element, so there is no SSR and no React Server Component support.
Vue 3
import * as vue from 'vue';
import { createGrid } from '@toclocoinc/lattice-grid';
import { createLatticeGrid } from '@toclocoinc/lattice-grid/modules/vue';
const LatticeGrid = createLatticeGrid({ vue, createGrid });
<LatticeGrid
:columns="columns"
:rows="rows"
row-key="id"
:sort="[{ col: 'name', dir: 'asc' }]"
@cell-changed="onCellChanged"
@selection-changed="onSelectionChanged"
/>
Events are re-emitted under dashed names: cell:changed becomes
@cell-changed, because a colon in a Vue template is directive syntax and
cannot be bound. Every event in the table below is declared in emits.
Svelte
<script>
import { createGrid } from '@toclocoinc/lattice-grid';
import { createLatticeAction } from '@toclocoinc/lattice-grid/modules/svelte';
const lattice = createLatticeAction({ createGrid });
let rows = [];
let grid;
</script>
<div
use:lattice={{ columns, rows, rowKey: 'id', onGrid: (g) => (grid = g) }}
on:ready={(e) => (grid = e.detail.grid)}
on:cell-changed={(e) => save(e.detail)}
></div>
An action, not a component. Svelte's action contract is
{ update, destroy } over a node the caller already owns, which is exactly the
shape of the work: create, push changes, tear down. A component wrapper would add an element
and a props layer to arrive back at the same three calls. It is also the only adapter here
that needs nothing but createGrid, since an action is a plain function with no
framework runtime behind it. Grid events arrive as CustomEvents on the node,
with the grid event as detail. If you want a component, four lines around this
gets you one.
Reaching the grid. The action's return value is Svelte's
{ update, destroy } and cannot carry the instance, so — at parity with React's
ref.current.grid and Vue's expose({ grid }) — there are two documented
routes to the same object the vanilla examples use, for charts, statistics and imperative
pivot. Pass an onGrid callback in the action params and it is called once with the
live grid the moment it is created (synchronously, before the first render); it is consumed by
the adapter and never reaches the grid config. Or read it off the ready event,
whose detail carries { grid } alongside the event's own fields —
ready fires a turn later, so use onGrid when you need it up front.
Accessing the grid instance from your framework
Anything the adapters do not surface as a prop — grid.export,
grid.selection, grid.state and the rest — is reached on the live
grid instance directly. It is the same Grid object createGrid
returns; the adapter only builds and drives it. There is one documented way to reach it per
adapter, and it is null until the grid is mounted.
| Adapter | How you reach the grid |
|---|---|
| React | A forwarded ref: ref.current.grid. |
| Vue 3 | A template ref calling the exposed method:
this.$refs.grid.grid() (the component exposes a
grid() getter). |
| Svelte | Either an onGrid callback in the action params, called with the live grid
the moment it is created:
use:lattice={{ ...config, onGrid: (g) => grid = g }}; or, a turn later,
the grid on any event's detail — the first ready event hands it
over: on:ready={(e) => grid = e.detail.grid}. |
| Web Component | A property on the element: el.grid. |
React
const ref = React.useRef(null);
// after mount:
ref.current.grid.export.toCsv();
Vue 3
<LatticeGrid ref="grid" :columns="columns" :rows="rows" />
// in a method, after mount:
this.$refs.grid.grid().export.toCsv();
Svelte
<script>
let grid;
</script>
<div
use:lattice={{ columns, rows, rowKey: 'id' }}
on:ready={(e) => grid = e.detail.grid}
></div>
<button on:click={() => grid?.export.toCsv()}>Export</button>
Web Component
const el = document.querySelector('lattice-grid');
el.grid.export.toCsv();
What the adapters do not do. They add no features and wrap no API: the
grid instance is the same object the vanilla examples use, and anything without a prop is
reached through it directly — via the ref in React, expose in Vue, or
onGrid / the ready event in Svelte, as the table above shows.
Nothing is proxied, so nothing can lag behind the grid.
The web component is self-contained: use it or the API, not both
The custom element carries the grid inside it, rather than being handed one. That is the point of the format: you add the element and it works, with nothing to wire up.
Do not load it alongside createGrid in the same page. You would
get two independent copies of the grid, and the cost is not the download, it is that
each copy keeps its own registries. A renderer, editor, data type or variant registered
through one is invisible to the other, and a licence key validated in one is not validated in
the other. Nothing errors; the custom renderer you registered simply never appears.
Pick one route per application. The other three adapters take createGrid as an
argument and so share whatever copy you already loaded, and can be mixed with direct API use
freely.
Coming from dhtmlx Grid
lattice-grid/modules/dhtmlx-compat exposes a Grid class shaped
like dhtmlx's own dhx.Grid, the same constructor call, the same
.data, .selection, .history, .export and
.events namespaces: sitting on top of a real Lattice grid underneath.
Swap the import and, for the surface below, the calling code does not change.
The wrapper shares the core the page already loads rather than carrying its own,
so load @toclocoinc/lattice-grid alongside it: a bundler wires the shared
import automatically (it dedupes against the core your app already imports), and a
plain <script src> page loads the global build first. The pay-off is
that a licence set on that one core applies to these grids too, and the module is a
few kilobytes of translation rather than a second copy of the grid.
A drop-in constructor
import { Grid } from '@toclocoinc/lattice-grid/modules/dhtmlx-compat';
const grid = new Grid(container, {
columns: [
{ id: 'name', header: [{ text: 'Name' }], width: 200, sortable: true },
{ id: 'qty', header: [{ text: 'Qty' }], type: 'number', editable: true, editorType: 'input' },
],
data: rows,
});
grid.events.on('cellClick', (row, column, event) => {
// row and column carry .id, row's own fields sit alongside it,
// the same shape dhtmlx's own IRow/ICol declare.
});
What is covered: column definitions (header, width,
sortable, resizable, hidden, editorType,
editorConfig, options, template, summary);
.data's add/update/remove/
removeAll/parse/load/find/
findAll/exists/getItem/getId/
getIndex/getLength/forEach/serialize/
sort/filter/resetFilter; .selection's
setCell/getCell/getCells/isSelectedCell/
removeCell; .history's undo/redo/
canUndo/canRedo/clear/getHistory;
.export.csv/.xlsx; and the events cellClick,
cellDblClick, cellRightClick, afterEditStart,
afterEditEnd, afterSort, filterChange,
afterColumnDrop, resize, afterResizeEnd,
afterColumnHide, afterColumnShow, afterExpand,
afterCollapse, afterSelect, afterUnSelect,
afterCopy, afterRowDrop and scroll. Grid-level
dragItem: 'row' becomes rowReorder: true: same-grid
drag-to-reorder.
cellClick, cellDblClick, cellRightClick,
afterEditStart, afterEditEnd and afterSort call your
handler with dhtmlx's own positional arguments: (row, column, event),
not Lattice's own event object, because that is dhtmlx's own documented signature for
them. afterRowDrop calls your handler with (data, event), firing
from either a same-grid reorder settling or a row landing here from another grid,
dhtmlx has one event name for what Lattice models as two. Every other mapped event calls your
handler with Lattice's own event object, under Lattice's own field names, since a wrong guess
at a fabricated positional shape is worse than an honest one.
Index means current display order: after sort, filter and
grouping: everywhere .data takes or returns one. dhtmlx is not fully
consistent about this across its own methods; this is the one meaning, held everywhere.
Cross-grid dragging needs an explicit rowTransfer. dhtmlx
lets any two grids with dragItem: 'row' on the same page exchange rows by
default; Lattice's rowTransfer is deliberately opt-in per pair, with no
dhtmlx property to derive it from, so a caller wanting that behaviour passes
rowTransfer straight through as a bonus config key.
What is not. Every before*/can*/cancel*
event is unmapped: dhtmlx's convention lets a handler return false to cancel the
action, and Lattice has no cancelable-event model to honour that with: approximating one
would silently ignore a return false a caller depends on. Row and column
drag-negotiation events (beforeRowDrag, dragRowOut,
canRowDrop, cancelRowDrop, beforeRowDrop and their
column equivalents) are unmapped for the same reason: each can refuse or steer a drag
mid-gesture, which Lattice has no live protocol to offer. export.pdf() and
export.png() throw: there is no raster export to translate to.
.rangeSelection is offered on a best-effort basis and its range shape is this
wrapper's own design, not dhtmlx's, a genuine RangeSelection module is a
separate part of dhtmlx's own product, and this wrapper does not know its exact shape.
Classic dhtmlXGridObject (pre-Suite 5, string-configured,
index-addressed) is a different product in every respect that matters here and is not covered
at all.
Calling an unmapped event name does not fail silently: the first subscription logs which name has no Lattice equivalent, so a caller relying on it finds out in development rather than in production.
Using with htmx
lattice-grid/modules/htmx lets a grid survive htmx's own DOM swaps,
hydrate from a server-rendered <table>, and drive sort, filter and
infinite scroll over plain htmx requests, the server owns pagination and the
request lifecycle; this module only wires the grid's own state to it. Importing it
is enough for the lifecycle half: it registers itself against
document on load.
Declarative init
<script src="https://unpkg.com/htmx.org@2"></script>
<script type="module" src="dist/modules/htmx.esm.min.js"></script>
<div id="grid" data-lattice-grid></div>
<script type="application/json" data-lattice-config>
{ "columns": [{ "field": "name" }, { "field": "qty", "type": "number" }] }
</script>
// Anywhere on the page, once:
import { autoInit } from '@toclocoinc/lattice-grid/modules/htmx';
autoInit(document);
One file, not two. autoInit comes from
modules/htmx itself here, not from the base package: this module
already carries a complete, independently-bundled copy of createGrid and
everything it depends on, the same way every module built this way does (a bundle
inlines what it imports; it has no way to reach across to a copy some other
<script> tag happens to have loaded). Left unexported, a page using
htmx integration would load that engine twice: once for modules/htmx,
again for the base package's own createGrid. This module re-exports
createGrid, autoInit, hydrateTable,
readTable, serialiseState and restoreState
precisely so a page never has to choose between the two, it is the complete package
for anything touching htmx, not an add-on alongside the base one.
Surviving a swap. autoInit(root) builds a grid on
every [data-lattice-grid] element under root it has not
already built one for: idempotent, so calling it again after a swap only picks
up what is new. A sibling <script type="application/json"
data-lattice-config> supplies columns and options; without one, a
<table> element is hydrated instead, reading its header row for
columns and its body rows for data, then replacing itself with the grid. Once
imported, this module listens for htmx's own htmx:beforeCleanupElement
and htmx:load and calls grid.destroy() / autoInit
at the right moments automatically, a grid inside a swapped-out subtree is torn
down before htmx detaches it; a grid inside newly-loaded content is built without
re-scanning the whole page. A page with JavaScript disabled sees the plain
<table>, still readable, since it is only ever replaced once the
grid has actually mounted.
Finding a live grid from its element works everywhere in this
library, not just through autoInit: element.__lattice holds
the instance for any element createGrid was called on, and is cleared
when the grid is destroyed.
Server-driven sort, filter and infinite scroll
import { createGrid, driveServerMode, driveInfiniteScroll } from '@toclocoinc/lattice-grid/modules/htmx';
const grid = createGrid(host, { columns, rows: [], rowKey: 'id' });
const COLUMNS = columns.map(c => ({ field: c.field }));
// Replaces the view outright: fires whenever sort or filter changes.
driveServerMode(grid, document.getElementById('query-trigger'), { columns: COLUMNS });
// Appends the next chunk: fires as the grid's own visible rows near the end.
driveInfiniteScroll(grid, document.getElementById('sentinel'), { columns: COLUMNS });
<div id="query-trigger" hx-get="/rows" hx-trigger="lattice:query-changed" hx-swap="none" hidden></div>
<div id="sentinel" hx-get="/rows" hx-trigger="revealed, lattice:scroll-near-end" hx-swap="none" hidden></div>
Two triggers, two elements, deliberately. A sort or filter
change and a scroll asking for more rows are different operations: one
replaces every loaded row, the other appends to them, and there is no way to
tell the two apart once a response has landed if they share a trigger. Each function
configures the request (offset, limit, sort,
filters, a small, stable convention any server-side language can
read with a JSON parser and a slice) and reads the response back into the grid
itself, so hx-swap="none" is required on both: htmx sends the request
and nothing else, since a grid is not an HTML swap target.
The sentinel's own trigger names two events for a reason.
revealed is htmx's own once-per-element mechanism and gets the very
first chunk, firing the moment htmx has processed the element: reliable
because it needs nothing from this module's own timing. Every chunk after that fires
through lattice:scroll-near-end, which driveInfiniteScroll
dispatches once the grid's own visible row window comes within
opts.threshold rows (default 20) of what is loaded. That split matters
for a fixed-height, virtualised grid specifically: nothing about it ever leaves the
page's own viewport once first revealed: new rows land inside the grid's own
scroll area, not the page's, so a page-scroll-only trigger would fire exactly
once and then go silent. Reading the grid's own render position instead is what
makes every later chunk fire on genuine scroll, not on the repaint a successful load
causes by itself.
Out-of-band updates. driveOobUpdates(grid, opts)
watches for htmx's own out-of-band swaps landing on an element carrying
data-lattice-row="<key>", reads the swapped fragment as that row's
cells, and applies it to the grid in place: scroll position, selection and
filter state are untouched, since nothing about the view is reloaded.
Browser history. On htmx:beforeHistorySave, every
live grid's state (serialiseState: sort, filters, column order and
widths, scroll position and selection, base64url-encoded and diffed against defaults
so an untouched grid costs almost nothing) is written onto its element as
data-lattice-state, which rides along in htmx's own history snapshot. On
htmx:historyRestore, it is read back and applied: browser back
returns a visitor to the sort, filter and scroll position they had, not a blank
slate. A cache miss (the page was re-fetched from the server) restores nothing, since
a fresh response is already the truth.
A failed request leaves the grid alone. driveServerMode
and driveInfiniteScroll both listen for
htmx:responseError/htmx:sendError; the grid's rows are
never touched by a failed request, and a recoverable message is shown through
grid.overlay rather than the grid going blank.
What this does not do. It does not call fetch or
htmx.ajax() anywhere: htmx owns every request end to end; this
only supplies the moment and the parameters, and reads the response back in. It does
not import htmx: every htmx-specific call goes through globalThis.htmx,
read at call time, so loading this module never requires htmx to already be on the
page, only to be present by the time a driven request actually fires. It ships as
ESM and as a plain <script src> build with no bundler required,
with zero runtime dependencies beyond the grid itself and, at call time, htmx,
but because it references the grid's own internals directly rather than the copy
already on the page, the bundle carries a full copy of the grid core alongside its
own code, the same trade-off the web component and dhtmlx wrappers already make.
How it works
Four ideas explain most of the API. If you read nothing else, read this section, the rest of the guide assumes it.
The row you see is not the row you supplied
Your data objects are held by reference and never copied. What the grid hands back is a
row wrapper: your object under data, plus the identity and position the
grid needs: key, index, level, whether it is a group
row, whether it is expanded.
Reading a value
grid.rows.get(0).data // your object, untouched
grid.rows.value('r1', 'cap') // the raw value
grid.rows.text('r1', 'cap') // the formatted text, as rendered
grid.rows.values('r1') // every readable column, as an object
value and text are different questions and the difference bites
people. A currency column's value is 1234.5; its text is
£1,234.50. Sorting and filtering use the value. Copying, exporting and searching
use the text, because that is what the user can see and what they typed against.
Display index and row key are different things
A display index is a position: row 0 is whatever is at the top right now, and it changes when you sort. A key identifies a record for as long as it exists. Anything that has to survive a sort, a filter or a page change is keyed.
Position: fragile
grid.rows.get(4)
grid.selection.setRange({
startRow: 0, endRow: 9, columns: ['cap'],
})
Identity: durable
grid.rows.byKey('CIR-100042')
grid.edit.setCells([
{ key: 'CIR-100042', colId: 'cap', value: 99 },
])
Everything is on one event bus
There are no onSomething configuration properties. One bus, one
grid.on(type, handler), and every payload carries type,
origin and grid alongside its own fields.
Subscribing
const off = grid.on('cell:changed', e => save(e.row.data));
grid.once('ready', init);
grid.on('*', e => console.log(e.type, e)); // wildcard, for working out what fires
off(); // every subscription returns its own unsubscribe
origin tells you where a change came from: 'user',
'api', 'init', 'undo'. It is what stops a feedback
loop when you persist changes: a handler that writes to a server on
cell:changed should usually ignore its own 'undo' traffic, or at
least know that is what it is looking at.
Two rules hold across the whole API
Nothing is a double negative. There is no suppressX, no
disableY. Options are positive and say what they enable:
edit, sortable, allowGroup. Turning something off is
false.
Every configuration key is settable at runtime through
grid.set(key, value) and readable through grid.get(key). There is no
separate "you can only pass this at construction" list to memorise.
Which version am I running?
grid.getVersion(); // '1.65.0'
LatticeGrid.getVersion(); // the same, when you have no grid to hand
On the instance as well as the module, because that is where it is wanted: a bug report says "the grid on this page", and whoever reads it has a grid rather than the module it was built from.
A headless grid is not a smaller grid, it is a grid without a renderer
createGrid builds this same core and attaches the DOM renderer to it;
createHeadlessGrid stops one step earlier. Everything the core owns — data, state,
sort, filter, group, total, pivot, formulas and computed columns, editing and optimistic
write-back, export, and every event — runs unchanged with no renderer attached, which is why
most of the examples in this guide that call createHeadlessGrid are settling real
API questions, not toy snippets. What it does not have is the renderer: no layout, no
measurement, no scrolling geometry, no focus, and grid.element is null.
A grid mounted where it has no rendered box paints only a handful of rows, not the
whole dataset — by design, not as a bug. The visible row window is computed from the
container's own height; a container that is detached from the document, or sits under a
display: none ancestor, measures zero, and the virtualiser falls back to a small
band around the top of the data (the overscan margin, five rows with the default configuration)
rather than nothing at all. A harness that builds a grid off in a hidden or unattached container
and then asserts on what is visible reads far fewer rows than it loaded and looks broken; giving
the container a real, measured box before asserting is what fixes it. Note that this is about
the container having no computed size, not about being visually off-screen: a
container positioned outside the browser's visible area with real, explicit dimensions (for
example position: fixed; left: -9999px with a width and height) still gets a real
box and renders fully.
The in-repo test DOM (testdom.js) is a stub, not a browser, and says so
in its own header comment. It implements exactly the surface the renderer touches —
element creation, attributes, classList, style, children,
textContent, event dispatch, injected geometry, and shims for
ResizeObserver and requestAnimationFrame — which is enough to drive
the renderer's logic in Node for most of the suite. It does not compute a cascade or a layout,
so a class or style change that a stylesheet then overrides, or a box that depends on CSS rather
than on the geometry a test injected, is invisible to it; those need the real-browser tests
(test/*-browser.test.js) that drive an actual headless Chrome instead. It also does
not model real focus semantics — its focus() simply records which element is
"focused" with no check for visibility, tab order or focusability. Since BACKLOG-0001181,
classList.add does throw on a token containing a space, the same
InvalidCharacterError a real DOMTokenList throws, closing the specific
gap where a two-word class name passed every headless test and then blanked a real page.
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' },
]},
]
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.
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.
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.
Types and formatting
A type is not a label. It is a bundle of behaviour: how a value is formatted, parsed back from
text, compared when sorting, stored in the columnar backing, written to Excel, and put on the
clipboard. Setting type configures all of that at once.
| Family | Types |
|---|---|
| Core | text, number, boolean, date, dateString, object, lookup |
| Temporal | datetime, timestamp, time, duration |
| Network | ipv4, ipv6, cidr, mac |
| Numeric bases | hex, hex8, hex16, hex32, binary, binary8, octal |
| Units: computing | bytes, megabytes, gigabytes, bitrate, gigabits |
| Units: physical | metres, millimetres, kilometres, grams, kilograms, tonnes, seconds, milliseconds, hours |
| Units: engineering | speed, kph, mph, knots, acceleration, area, hectares, volume, cubicMetres, energy, kilowattHours, power, kilowatts, force, pressure, bar, psi, torque, density, flow, litresPerMinute, radians, degrees |
| Units: electrical and scientific | voltage, current, resistance, capacitance, inductance, charge, conductance, fluxDensity, luminousFlux, illuminance, substance, absorbedDose, equivalentDose, radioactivity, luminousIntensity, doseRate, rpm, angularVelocity, ppm, ppb, basisPoints, molarity, massFlow, tonnesPerHour, viscosity, kinematicViscosity, thermalConductivity, specificHeat, frequency |
| Temperature | celsius, fahrenheit, kelvin |
| Currency | currency, usd, eur, gbp, jpy |
| Structured | json, colour, rating, percent |
Units
A unit type stores a plain number in a named base unit and makes only display and input
unit-aware. That is the whole design: the column still backs onto a typed array, so sorting,
filtering, grouping and totalling stay ordinary arithmetic and never touch the rendered text.
A gigabyte column holding 0.5 shows 512 MB, accepts
512M typed over it, and stores 0.5 throughout.
Declared types, and one built to order
columns: [
{ field: 'span', type: 'metres' }, // 1500 → "1.5 km"
{ field: 'load', type: 'kilograms' },
{ field: 'inlet', type: 'pressure' }, // 200000 → "200 kPa"
{ field: 'bearing', type: 'degrees' },
{ field: 'cap', type: 'capacitance' }, // 4.7e-11 → "47 pF"
{ field: 'inletTemp', type: 'celsius' },
],
// or configure your own base unit and precision
dataTypes: {
runtime: createUnitType({ system: 'duration', unit: 'ms', display: 'auto' }),
}
Seventeen unit systems ship: data, bitrate, length,
mass, duration, speed, acceleration,
area, volume, energy, power,
force, pressure, torque, density,
flow and angle, plus fifteen SI-prefixed electrical and scientific
quantities. registerUnitSystem adds one of your own.
Ambiguous units are refused, not guessed. A US gallon and an imperial
gallon differ by about a fifth, and "ton" means three different masses. Each has its own
symbol: gal (US), ton (UK), and the bare word is claimed by all of
them, so typing it is rejected rather than resolved. A gal silently taken as US
in a UK deployment is data corruption that reads as rounding.
The same rule catches case: mV and MV are a billion apart, so both
exact spellings work and the case-folded mv is refused.
Customary units are accepted but never chosen. display: 'auto'
walks the coherent SI ladder only. With the calorie, the BTU and the kilojoule all on one
ladder, 4,000 J would render as 3.79 BTU: auto picks the largest unit that fits,
and the BTU happens to be larger than the kilojoule. Ask for a BTU by name and you get one.
significantFigures renders to a fixed precision rather than a fixed number of
decimals. Two decimals is four significant figures at 12.34 kB and three at
1.54 kB, so a column claims different accuracy row by row depending on nothing but
which unit auto picked; significant figures are what an instrument has, and they hold across
the ladder. Rounding is applied before the unit is chosen, so 999,999 bytes to three figures
is 1.00 MB rather than 1,000 kB.
compound: ['ft', 'in'] renders one stored number across an ordered subset of the
system's units — a length as 5 ft 11 in, a duration as 1 h 23 m.
It is display and parse only: the stored value stays a single base-unit number, so sorting,
filtering, grouping and totals are the same arithmetic as any other unit column. The units are
sorted largest to smallest, the smallest carries the remainder, and parsing sums the parts, so
the display round-trips through a paste. The mid-value editor — keystroke
roll-over between feet and inches, caret behaviour at a rung boundary — is a separate,
later piece of work; this is the read-and-paste half.
Angles wrap, so their mean is replaced. The average of 359° and 1° is 0°,
and the arithmetic answer (180°) is a confident, plausible number pointing in exactly the
wrong direction. A degrees or radians column averages by direction
instead, and reports nothing where the angles cancel and there is no mean direction to give.
The sum stays arithmetic, because a total rotation of 720° is two turns and that is a
real figure.
Temperature is its own type, not a unit. Every other unit is a
multiplication; Celsius to Fahrenheit carries an offset, and zero Celsius is not zero
anything, so no factor converts it. celsius, fahrenheit and
kelvin convert on input: type 72 F into a Celsius column and it
stores 22.2, and refuse to be summed: twenty degrees plus twenty degrees is
not forty degrees, and a footer saying so would be believed.
Currency is its own type, not a unit either. A currency's
“factor” is an exchange rate that moves, so a value carries an amount
and a code and never a fixed factor. The grid ships and fetches no rates: pass a
rate source through createCurrencyType({ display, rates }), and a rate that is
needed but absent renders as a loud marker (missingRate), never as zero. A
column totalling in a display currency refuses to add unlike currencies until every value
can reach that currency. The shipped currency, usd,
eur, gbp and jpy types cover the single-currency case;
a rate table is denominated in a base you can state with rateBase.
Types read from the data
A column that declares no type takes one from the rows. The first hundred non-empty
values of that column are sampled, and a type is adopted only if every one of them matches it;
anything mixed or ambiguous stays text and says so once on the console.
One key each, and the types arrive
columns: [
{ field: 'sku' }, // text
{ field: 'quantity' }, // number: aligned right, numeric filter
{ field: 'shipped' }, // date
{ field: 'expedited' } // boolean: checkbox editor
]
Sampling happens once, when rows first arrive. A grid built empty and filled later infers on that first load, so fetching after construction is no reason to declare types you would otherwise leave out. Later loads keep the types already settled on: data that arrives tomorrow cannot change a column's type under a formatter or an editor that was configured around it.
Only the built-in names are ever inferred. Candidates are tried in registration order, so every
string reaches text and every number reaches number before an extended
type is considered. A column of IP addresses or durations is text until you name the
type you want.
Inference never overrules a decision you made. An explicit type, a
preset that carries one, a type in columnDefaults, and a
lookup all win; type: false turns sampling off and keeps the column
text whatever it holds. Set sampleSize to sample more or fewer than a hundred.
What declaring a type buys you, and what it does not. Sorting is not the reason. The default comparator is value-aware, so an undeclared column of numbers already sorts 2.5 before 10 rather than lexicographically, and an undeclared column of IPv4 addresses already orders correctly across 128.0.0.1.
What a type settles is everything around the sort: which editor opens, how typed text is parsed back into a value, how the value is formatted, which filter kind the header offers, how the column is stored, and what lands in Excel and on the clipboard. That is what inference supplies for free on a plain column, and what naming a type explicitly gives you where the data cannot say it, a duration, a byte count, a network address.
Dates are stored as strings, deliberately
A date column stores '2024-03-11', not a Date. This is
the single most consequential type decision in the product.
The bug it avoids: new Date('2024-03-11') is midnight
UTC. Render that in New York and it is the 10th. A user in London sets a delivery
date, a colleague in Mumbai opens the same grid and sees the day before. Storing the wall-clock
string means the date a user typed is the date everyone sees, because there is no instant to
convert.
It is also faster and smaller: ISO 8601 sorts lexicographically in the same order it sorts chronologically, so a date column sorts as text, and repeated dates dictionary-encode well.
When you genuinely mean an instant (a log timestamp, an audit time) use
timestamp — see below. datetime stays a wall clock, deliberately.
timestamp — an instant, stored UTC, shown in a chosen zone
type: 'timestamp' is the sibling to datetime for data that is one
genuine moment everywhere — an audit time, an event created_at, a cross-region log
line — rather than a wall clock.
Stored as an instant. A value ingests from epoch-millis, a Date,
or a zone-bearing ISO string (…Z / …+01:00) and is stored as
epoch-millis UTC. Because storage is numeric, sort, filter and compare operate on the
instant, never on rendered text — two rows from different origin zones order by true
chronology, and changing the display zone never reorders them.
Shown in a display zone you control. The cell renders in the zone resolved
by precedence: the column's typeOptions.timeZone, then the grid's
config.timeZone, then the viewer's local zone. The resolved zone is nameable
(e.g. Europe/London (BST)) so a reader always knows which clock they are reading;
set typeOptions.showOrigin: true to also show the origin zone when it differs, and
when no origin was recorded the cell says so rather than assuming local.
Grouped by civil day in the display zone. Grouping a timestamp
column buckets by civil day by default — group: { granularity: 'week' | 'month' | 'instant', weekStart: 1 }
chooses week (Monday-start by default), month, or the exact instant. Buckets are computed by
projecting the instant to a civil date in the display zone, so a 23- or 25-hour daylight-saving
day still collapses to one bucket rather than splitting.
Excel export. Excel has no zone, so the display-zone wall clock is written
as a plain numeric datetime serial (yyyy-mm-dd hh:mm) — what you saw on screen,
with the zone named in the docs and column header, never shifted silently to UTC.
Formats
Shorthand and full form
format: 'currency:GBP:2' // £1,234.50
format: 'percent:1' // 87.4%
format: 'date:dd MMM yyyy' // 11 Mar 2024
format: { // when the shorthand runs out
type: 'number',
style: 'currency', currency: 'GBP', decimals: 2,
negative: 'parentheses', // (£1,234.50)
negativeClass: 'is-loss',
nullDisplay: ', ',
}
format: { decimals: 0, signed: true } // +5, 0, -5
signed puts a leading + on a positive number, currency or percent
value. Zero gets no sign either way — it is neither positive nor negative — and a negative
value is entirely unaffected: it still renders however negative says
('minus' by default). Off by default, so an existing column's negatives-only
look never changes underneath it.
A key in format that the resolved type does not read — a typo, or a key that
belongs to a different format shape, such as signed written on a
text column — is never silently dropped. It warns once, by column and key name, instead of
doing nothing.
Lookups
A lookup column stores an id and shows a label. Sorting, filtering, grouping, copying and exporting all use the label, because that is the thing the user is reasoning about, but the data keeps the id.
A status column
{
field: 'statusId',
title: 'Status',
type: 'lookup',
lookup: { options: [
{ id: 1, label: 'Open' },
{ id: 2, label: 'Pending' },
{ id: 3, label: 'Closed' },
{ id: 4, label: 'Escalated', variant: 'danger' },
]},
cell: { decoration: 'pill' },
}
Options may be a function, may return a promise, and may be searched remotely with an
AbortSignal so a superseded keystroke cancels its own request.
Custom types
A 32-bit register column
dataTypes: {
reg32: LatticeGrid.createRadixType({
radix: 2, bitWidth: 32, pad: true, signed: false, group: 8,
}),
},
columns: [{ field: 'flags', type: 'reg32' }],
// 170 → 0b00000000 00000000 00000000 10101010
radix is 2, 8 or 16, or the names binary,
octal and hex. Base 10 is not among them and is not an oversight:
a decimal number is what the number type is for, with grouping, decimals,
currency and notation that a radix formatter has no concept of. Passing
radix: 10 names the supported set in the console and falls back to hex rather
than producing something that looks like a number column but is not one.
Cells and renderers
By default a cell writes text. When you want more, cell takes a decoration, a
named renderer, a template or a component.
Decorations, the common cases, without writing a renderer
cell: { decoration: 'pill' } // a status chip
cell: { decoration: 'bar', min: 0, max: 1 } // an inline bar
cell: { decoration: 'heat', ramp: 'redGreen' } // a heat fill
cell: { decoration: 'dot' } // a leading dot
Variants: mapping a value to a semantic colour
cell: {
decoration: 'pill',
variant: { when: [
{ op: 'eq', value: 'Escalated', use: 'danger' },
{ op: 'eq', value: 'Pending', use: 'warning' },
], default: 'success' },
}
Variants are semantic tokens rather than colours: danger, not
#c22b2b. The theme decides what danger looks like, and it looks the same in the
status pill, the filter chip and the validation message. Changing the palette is one custom
property, not a search for hex codes.
Icon sets: a threshold glyph per value band
// A built-in set — traffic lights, arrows, rating marks — driven by value.
cell: { decoration: { type: 'icon', iconSet: 'arrows' } }
// Or your own bands. The highest `min` a value clears wins; a band with no
// `min` is the catch-all. `label` is what a screen reader announces.
cell: { decoration: { type: 'icon', bands: [
{ min: 0.9, icon: 'success', label: 'on target', variant: 'success' },
{ min: 0.5, icon: 'warning', label: 'at risk', variant: 'warning' },
{ icon: 'danger', label: 'off track', variant: 'danger' },
] } }
An icon set is a restatement of the value, not a replacement for it: the value still
renders beside the glyph, and the band's label is set as the glyph's
aria-label, so a screen-reader user hears "on target 92%" rather than a bare
number with the status lost. The glyphs are the grid's own inline sprites (§16), so an icon
set adds no dependency and makes no request. Built-in sets: trafficLights,
arrows, trafficArrows, ratings.
Turning a decoration on at runtime
// Set, change or clear a column's decoration after the grid is built.
grid.columns.decorate('score', { type: 'bar', min: 0, max: 100 });
grid.columns.decorate('trend', { type: 'icon', iconSet: 'arrows' });
grid.columns.decorate('score', null); // back to plain text
A decoration is presentation, so columns.decorate is a live setter like
grid.set('theme', …): it is not on the undo timeline and does
not travel in a saved view. For conditional styling that a user edits and a view
remembers, reach for grid.formatting below, which holds colour and weight rules
as durable state.
Your own renderer
components: {
sparkline: {
render(el, p) { el.appendChild(draw(p.value)); },
refresh(el, p) { update(el, p.value); return true; },
release(el) { el.textContent = ''; },
},
},
columns: [{ field: 'history', cell: { render: 'sparkline' } }],
refresh returning true is the contract that makes recycling work:
it means "I updated in place, keep this element". Return false and the grid
rebuilds the cell. A renderer that only implements render still works, it is
just rebuilt on every reuse.
Templates, and what allowUnsafeTemplates permits
cell: { template: '<span class="sku">{{ value }}</span>' } // escaped
// Raw interpolation needs the grid-level opt-in:
allowUnsafeTemplates: true,
cell: { template: '<span>{{{ value }}}</span>' }
The flag permits markup, not code. Without it, {{ }} escapes
and a {{{ }}} segment is refused outright. With it, an interpolated value may
carry presentational markup: <b>, <a href>, a
<span>, and everything executable is still stripped from it:
<script>, <iframe>, <style> and the
other code-bearing tags, every on* handler attribute, and
javascript: or data: URLs including entity-encoded spellings of
them. The same rules apply to a string returned from cell.render, which is the
same gate.
Why the value is treated differently from the template. You wrote the template and can audit it; the value is row data and usually arrives from somewhere you cannot. A template is refused at compile time for a dangerous tag, but that refusal says nothing about what a value interpolated into it might contain.
It is a narrow allowance, not a sanitiser. It exists so a grid cell can
show emphasis and a link. To render arbitrary third-party HTML, sanitise it yourself and
return an element from cell.render.
Styling
Cells, columns and rows can all carry classes and inline styles, static or computed.
By scope
// Cells and columns: declared on the column
{ field: 'margin', cell: {
class: 'tabular',
classWhen: { 'is-loss': (p) => p.value < 0 },
style: (p) => ({ fontWeight: p.value > 1e6 ? 650 : 400 }),
}}
// Rows: on the grid
rowClass: (p) => p.data.slaBreached ? 'row-breach' : null,
rowStyle: (p) => p.data.region === 'AMER' ? { borderLeft: '3px solid #7c3aed' } : null,
All of these are re-evaluated on every repaint and remove what they added last time first. That is not caution. Rows and cells come from pools, so an element that carried a class for one row will later carry a different row, a class written once and left alone smears down the grid as the user scrolls.
Theming
The stylesheet is custom properties throughout. Override the tokens, not the rules.
A house palette
.lattice {
--lattice-accent: #7c3aed;
--lattice-font-size: 13px;
--lattice-space: 8px;
--lattice-border-color: #e6e8eb;
}
Four themes ship. With no theme set, the grid follows the viewer's
prefers-color-scheme between light and dark; naming one pins it.
Density is separate: compact, standard, comfortable
or spacious, and combines with any of them.
Pinning a theme, at build time or at runtime
createGrid(el, { theme: 'high-contrast' });
grid.set('theme', 'terminal');
grid.set('theme', null); // back to following the viewer
| Theme | What it is for |
|---|---|
light | The default. Follows prefers-color-scheme when theme is unset. |
dark | The same palette inverted, with the accent and status hues re-picked for a dark ground rather than reused. |
high-contrast | Not "dark with more contrast". Text is 21:1 and borders 6.1:1 against the background, where the other themes sit near 1.3:1 on borders, WCAG 1.4.11 asks for 3:1 on the boundaries a user has to find. Cell borders are drawn rather than implied, selected rows carry an outline as well as a fill, and every status pill has a solid border so it does not depend on hue alone. |
terminal | A phosphor console: one hue on near-black, monospaced throughout. Status is carried by brightness rather than colour, so the palette stays a palette. |
Forced colours
Windows High Contrast Mode replaces the palette outright: that is the point of it, and no stylesheet should fight it. What the grid does instead is translate every piece of meaning it normally carries in a background tint into something the mode preserves.
A selected row takes the system's own selection colours. A pinned region loses its shadow, which forced colours do not render, and gains a rule in its place. Status pills, fill decorations, progress tracks and histogram bars each gain a border, because a fill with no edge is invisible once its colour is discarded. Diff states stop depending on hue altogether: added, removed and changed are told apart by border style: solid, dashed and doubled : since the mode offers no way to keep four distinct colours.
Two things deliberately keep their colour, declared with
forced-color-adjust: a colour swatch, where the colour is the value being
shown, and a collaborator's presence colour, which is how one person is told from another.
Replacing those would destroy the meaning rather than translate it. Both gain a border so they
stay visible against either ground.
Every theme is the same token set with different values, so an override you write against
.lattice applies to all of them, and one written against
.lattice[data-theme="terminal"] applies to that one. The attribute is on the
grid's own root element, not on <html>.
The grid and your page's CSS
Every selector in the stylesheet is namespaced under .lattice, so the grid cannot
restyle your page. From 1.4.0 the reverse is also true: the grid gives the elements it builds
a floor for the properties a page is most likely to set on a bare tag: margin, padding,
border, radius, background, shadow, text transform and letter spacing, plus type and colour on
form controls, which inherit neither.
Why this is needed at all. A grid is mounted inside somebody else's
stylesheet. A rule as ordinary as section { padding: 5.5rem 0 }, a marketing
page, a CMS theme, a Tailwind preflight: matches by tag name, and the grid builds parts of
its own interface from those tags: the tool panel's filter rows are
<section> elements. Without the reset, 5.5rem of somebody else's padding
lands on every one of them.
The reset uses no !important. It is specificity (0,1,1) and every rule that
dresses a grid element is (0,2,0) or higher, so the grid's own styling always wins and the
reset only fills a gap. Yours wins too, on the same terms: a rule aimed at a Lattice class,
.lattice .lat-cell { … }: outranks it, so overriding the grid deliberately works
exactly as before. Only bare-tag rules are shut out.
It touches box model and decoration only. Nothing in it sets display,
position or any dimension: those belong to the renderer, and a reset that reached
them would break virtualisation rather than protect it.
Loading and updating data
Data usually arrives after the grid does. Build it empty, then load, the sort, filters, grouping and column layout you set up in the meantime all survive and apply to the new data.
The ordinary sequence
const grid = createGrid(el, { columns, rowKey: 'id', rows: [] });
grid.overlay.show('loading');
const data = await fetch('/api/circuits').then(r => r.json());
grid.rows.load(data);
grid.overlay.hide();
Straight from a URL, with createUrlSource
When the data is a file at a URL you do not have to fetch it yourself.
createUrlSource(url, opts) loads a JSON file or streams an NDJSON/JSONL file directly,
and you pass it as the source. See the
Sources reference for every option.
A JSON file, and a streamed NDJSON file
// A JSON file: a top-level array, or nested via rowsPath / map.
createGrid(el, { columns, rowKey: 'id', source: createUrlSource('/data/circuits.json') });
// An NDJSON file: rows stream in as they parse, first rows first.
createGrid(el, { columns, rowKey: 'id', source: createUrlSource('/data/events.ndjson', { batchSize: 500 }) });
// Auth, a nested array, and a 30s refresh:
createGrid(el, {
columns, rowKey: 'id',
source: createUrlSource('/api/rows', {
headers: { Authorization: 'Bearer …' },
rowsPath: 'result.items',
poll: 30000,
}),
});
The format is inferred from the extension, then the Content-Type, then a sniff —
override it with format: 'json' | 'ndjson'. A non-2xx response, a network error or a
malformed NDJSON line becomes a source:error event rather than an exception, and
lenient: true skips a bad NDJSON line instead of failing the whole stream.
Incremental changes
rows.load replaces everything. When you have a delta, a websocket message, a
save that returned the updated record: apply just that.
Adds, updates and removals in one call
grid.rows.apply({
add: [{ id: 4, name: 'd' }, { id: 5, name: 'e' }],
update: [{ id: 1, name: 'A' }],
remove: ['3'], // row keys, or the row objects
at: 0, // optional insert position for `add`
});
// → { added: [...], updated: [...], removed: [...] }
An update is a patch. Fields absent from it are untouched, so a delta arriving from a websocket or coming back from a save can be applied as-is without reading the row first. This has to be said explicitly because the opposite: assigning the patch over the row: looks identical for a caller who happens to send whole rows and silently destroys data for one who does not.
Coalescing merges fields rather than keeping the last message. A feed
sending {price} and {volume} as separate messages inside one window
keeps both. Coalescing may reorder work; it may not lose it.
Flushing happens on a frame, with a timer behind it. A queued batch lands
on a paint boundary, which is what makes "ten thousand updates, one repaint" true rather than
usually true, a timer can fire twice between two paints. But
requestAnimationFrame is not guaranteed to fire at all: a backgrounded tab stops
firing it entirely. So a frame and a timer are armed together and the first to arrive wins. In
a foreground tab the frame always wins, at ~16ms against a 50ms fallback; in a hidden tab the
timer keeps the feed applying instead of the grid silently stalling with every caller's
promise unresolved.
A long flush defers rather than blocks. updates.budgetMs caps
how long one flush spends applying; over budget, the remainder returns to the queue and lands
next frame, and the promise a caller is holding resolves when their rows actually land rather
than when the first slice does. Slicing is by row and only for updates, a partially
applied row is not a state the store should be in, and splitting a structural change would
re-run the pipeline twice for one batch. stats().deferrals rising steadily means
the feed is arriving faster than the grid can apply it.
Rejections are reported, never thrown. Throwing would abandon the rows
that were fine. An update or remove naming a row that is not here is unknown-id;
an add whose key already exists is duplicate-id and is refused, because
selection, expansion, comments and the key index all resolve one key to one row and admitting
a second corrupts every one of them at once.
This runs the minimum pipeline. An update touching no sorted, filtered or grouped column skips those stages entirely and only the totals and the affected cells refresh. Adds and removals are structural and re-run everything.
Removals tombstone in place rather than compacting, so every existing index stays valid, which is what lets selection, expansion state and cached permutations survive a delete.
Patching a single cell
When you have a value rather than a row, setCells patches fields in place.
One cell, or many, without row objects
grid.edit.setCells([{ key: 'CIR-100042', colId: 'capacity', value: 990 }]);
grid.edit.setCells([
{ key: 'CIR-100042', colId: 'notes', value: 'Chased' },
{ key: 'CIR-100043', colId: 'capacity', value: 770 },
]);
// → the number of cells written
This is the full path, not a shortcut: it validates, emits cell:changed per cell,
re-sorts if the column is sorted on, records one undo entry, and returns 0 for a
column the user may not write. It then announces the whole call once as
rows:changed (identified: true, edit: true, the
updated rows and the columns written), after the per-cell events, so
a derived grid, a statistic tile or anything else that follows rows:changed
re-reads once for a twenty-cell paste rather than twenty times. An editor commit, a fill, a
paste, an undo, a redo and an optimistic rollback each announce themselves the same way, once
per batch. Earlier releases announced nothing here, and a derived view of an edited grid went
silently stale.
High-frequency updates
For a ticking feed, queue batches changes to the next animation frame so a
thousand messages a second produce sixty repaints rather than a thousand.
A price feed
socket.on('tick', (row) => grid.rows.queue({ update: [row] }));
Walking the data rather than the view
rows.forEach walks what is on screen: filtered, sorted, grouped, with collapsed
rows left out. That is the right default, and the wrong answer for a caller totalling a column,
exporting, or reconciling against another system.
let total = 0;
grid.rows.forEach(r => { total += r.data.amount }); // what the user can see
grid.rows.forEachAll(r => { total += r.data.amount }); // what the grid holds
forEachAll visits leaf rows only, in the order they arrived. Group rows are a
product of the current grouping and do not exist in the data, so they are not offered; the sort
belongs to the filtered view, so the order here is physical rather than sorted.
A remote or paged source holds the page it has fetched, not the whole set, so there is nothing there to walk past the filters. It warns and walks what it has rather than quietly returning the filtered rows, a caller who asked for everything and silently received a subset gets a number that looks entirely plausible and is wrong.
Sorting
Setting and reading
grid.sort.set([{ col: 'capacity', dir: 'desc' }]);
// A multi-column sort is one call, in priority order.
grid.sort.set([
{ col: 'region', dir: 'asc' },
{ col: 'capacity', dir: 'desc' },
]);
grid.sort.get(); // [{ col, dir }, …]
grid.sort.clear();
Clicking a header cycles ascending, descending, none; shift-clicking a second header adds to
the sort rather than replacing it. A column can supply its own compare, and a
type already has one: dates compare chronologically, IP addresses numerically rather than as
strings, durations by length.
Filtering
There are two filters and they are separate on purpose. The quick filter is one string matched across every readable column. The filter set is a structured condition tree.
Quick filter
grid.filters.quick('singapore');
grid.filters.quick(''); // clear
A condition tree
grid.filters.set({
op: 'and',
conditions: [
{ col: 'region', op: 'in', value: ['EMEA', 'APAC'] },
{ col: 'utilisation', op: 'gt', value: 0.9 },
{ op: 'or', conditions: [
{ col: 'slaBreached', op: 'eq', value: true },
{ col: 'margin', op: 'lt', value: 0 },
]},
],
});
That structure is a published wire protocol, not an internal detail. It is what
grid.state.get() serialises, what a saved view carries, and what you can send to
a server to evaluate the same filter against the full dataset. A remote source hands it
straight to your backend.
Operators are per family: text has contains, startsWith,
matches; numbers and dates have between, gt,
lte; multi-value columns have containsAny,
containsAll, containsNone. blank and
notBlank work everywhere.
Filters your application owns: where
A condition tree can only test what is in a column. Plenty of real filters cannot be written that way — whether this user may see the row, whether you hold an exchange rate for its currency, whether it came back from your last search call. Those go in as named predicates, and they compose with everything above.
A permission filter and a toggle, side by side
grid.filters.where('visibleToMe', row => row.owner === me, { pinned: true });
grid.filters.where('rateKnown', row => rates.has(row.ccy), { deps: ['ccy'] });
grid.filters.where(); // ['visibleToMe', 'rateKnown']
grid.filters.where('rateKnown', null); // remove just that one
grid.filters.reapply('rateKnown'); // the rate table arrived late
There is deliberately no "a filter is present" flag. That flag is a second piece of state describing the first, and the two drift: the classic symptom is a grid that filters while the UI insists it is not, or insists it is filtering while every row passes. Here, registering a predicate is what puts it in force, and removing it is what takes it out.
Three options shape one. deps names the columns the predicate reads, exactly as
value.deps does for a computed column: the verdict is then cached per row and
re-run when one of those columns changes on that row, not when an unrelated one does.
Leave it off and the predicate is assumed to read the whole row, so it runs every pass and can
never be stale. pinned makes a predicate survive
filters.clear(), which is what you want for permissions and tenant scoping and
not much else. condition gives the predicate a declarative twin that is pushed to
the source while the function stays as the residual, so a pushdown engine narrows the fetch
instead of your code filtering a page.
Migrating from AG Grid's external filter: its three pieces become two.
isExternalFilterPresent() goes away, because registration is presence.
doesExternalFilterPass(node) becomes the predicate itself.
onFilterChanged() becomes deps where the grid can watch the change
for you, and reapply(name?) where it cannot.
What travels in a saved view is the name, not the function.
state.get() carries where: string[]; your predicates are your code
and the grid will not pretend it can serialise them. Applying a view that names a predicate
you have not registered reports the skip instead of quietly showing a wider row set,
and never removes a predicate the view did not mention.
Grouping, totals and pivot
Group by one or more columns
grid.columns.group(['region', 'country']);
grid.rows.expandAll();
grid.rows.collapse('EMEA');
Expand all and Collapse all are on the menu too, once the
grid is grouped: the data area's own right-click menu and every column's header menu (the
3-dot button and a right-click on the heading alike) carry both, driving
grid.rows.expandAll()/collapseAll() — the same public API a host
calls directly (BACKLOG-0001305). They are hidden, not disabled, on an ungrouped grid: there
is nothing for either to act on. Both flow through the same contextMenu/
columnMenu customisation chain as every other built-in item, so a host filtering
or extending the menu sees them as ordinary items — matched, like every other built-in, by
their translated name (catalogue keys menu.expandAll and
menu.collapseAll, the same ones the generated group column's own header menu
already used — see your own menu items).
groupPanel: true adds a drag-and-drop strip above the column header — the
row-group panel. A user drags a heading into it to group by that column; the active
groups show as removable, reorderable chips, and dragging one chip past another changes
the nesting order. It is keyboard-operable, so grouping is not drag-only: arrows move
between chips, Shift with an arrow reorders, Delete ungroups,
and an add control at the end groups any column. Every change is spoken through the live
region. The strip drives grid.columns.group() — it is the same grouping
model, surfaced as chrome — so a group made in the strip, from the column menu or through
the API is one state, not three.
Turn the group-by strip on, and group through the model
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
// `groupPanel` is chrome, so the strip itself needs the DOM build; the config
// key is accepted everywhere, and it drives the ordinary grouping model — which
// is what a headless grid can show. The strip renders the state below as chips.
const grid = createHeadlessGrid({
columns: [{ field: 'region' }, { field: 'country' }, { field: 'sales', type: 'number' }],
rows: [
{ region: 'EMEA', country: 'UK', sales: 10 },
{ region: 'EMEA', country: 'DE', sales: 20 },
{ region: 'AMER', country: 'US', sales: 30 },
],
groupPanel: true,
});
// Order is nesting order, outermost first — exactly the order the chips show.
grid.columns.group(['region', 'country']);
const groups = grid.state.get().group;
grid.destroy();
return `grouped by ${groups.join(', ')}`;
kpis is the same idea for the tile every dashboard opens with. A
createStat renders a KPI tile — a label, a value, its change against a
baseline — reading the grid so it agrees with the grid; what it needs is a container and
the wiring to place it. kpis is that placement done by the grid: an array of
stat specs becomes a labelled band of tiles above the column header, and the grid creates
the container for each and drives createStat itself. Each entry takes the
fields createStat takes — of, fn, title,
interval, footer, format and the rest — minus
grid and container, which the grid supplies. The tiles follow the
grid's filters, so the strip cannot disagree with the table beneath it. Off by default and
free when absent; no kpis, no band.
A built-in KPI strip, following the grid
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
// `kpis` is chrome: with createGrid it renders a labelled band of stat tiles
// above the header, each following the grid's filters. The config is accepted
// everywhere, and each tile reads the same kernel the totals row uses — which
// is what a headless grid can show.
const grid = createHeadlessGrid({
rowKey: 'region',
columns: [{ field: 'region' }, { field: 'mrr', type: 'number' }],
rows: [
{ region: 'EMEA', mrr: 1200000 },
{ region: 'AMER', mrr: 2400000 },
{ region: 'APAC', mrr: 600000 },
],
kpis: [
{ title: 'Accounts', fn: 'count' },
{ title: 'Revenue', of: 'mrr', fn: 'sum' },
],
});
// The value the "Revenue" tile would read, from the kernel createStat uses.
const revenue = grid.statistics.reduce('mrr', 'sum');
grid.destroy();
return `revenue ${revenue}`;
Totals
{ field: 'capacity', type: 'number', total: 'sum' }
{ field: 'margin', type: 'number', total: 'avg' }
{ field: 'lastSeen', type: 'datetime', total: 'max' }
{ field: 'weighted', total: (values, rows) => weightedMean(values, rows) }
Totals appear on every group row and on the grand total. grandTotalRow: 'bottom'
pins the grand total below the rows instead of leaving it inline.
On a memory source totals are maintained incrementally: a cell update moves
the running value by the difference rather than re-reducing the column, so a totals row costs
the same on a million rows as on a thousand. This applies to both the grand total and each
group subtotal, for sum, avg, countValues,
min and max on numeric columns. A cell edit that moves a row between
groups is subtracted from its old group and added to its new one; only the affected groups are
touched. Everything else re-reduces, and so does the incremental path itself whenever it cannot
reach the right answer:
| Case | What happens |
|---|---|
| sum, avg, countValues | Maintained by difference, with a compensated running sum so a long session does not accumulate floating-point error. |
| min, max | Maintained while values move past the extreme. A value moving off the current extreme re-reduces, because a running extreme cannot know what the next one is. |
| count | The number of rows in scope, already a single read. |
A custom total function | Re-reduced on every change. A reduction supplied as a function has no inverse, so there is nothing to apply a difference to. |
| Adding or removing rows | Re-reduced once, then incremental again. |
| Filtering, sorting or grouping | Re-reduced once, because the rows contributing to the total have changed. |
| Totals above ~1e15 | Re-reduced. Past that magnitude a small change no longer moves a 64-bit float, and a running total would silently stop tracking the data. |
| Group subtotals | Maintained incrementally the same way the grand total is: an in-group edit applies the difference, and a cross-group move subtracts from the old group and adds to the new. A min/max move that leaves a group's current extreme reseeds only that group. A custom group total, or a statistical reduction, keeps the full per-group pass. |
Nothing has to be configured for this, and the reported number is the same either way, where a running value cannot be trusted, the column falls back to a full pass rather than reporting a value it is unsure of.
headerControls
The per-column header controls — the sort arrow, the filter funnel and the menu button
— appear on hover by default, which keeps a wide header from reading as a row of identical
icons. headerControls makes that a mode: 'hover' (the default,
unchanged), 'always' to keep them visible, and 'hidden' for a clean
read-only heading that draws none of them and leaves them out of the tab order. It is a
grid-level default; a column's own headerControls overrides it for that column.
Each leaf heading carries the resolved mode as data-controls, and the theme keys
the reveal off it, so 'always' shows the controls without a hover and
'hidden' removes them from the layout. This differs from
showColumnFunctions: false, which also drops the furniture but keeps sorting,
filtering and the menu reachable from the keyboard; 'hidden' is the read-only
choice that takes them away outright (BACKLOG-0000982).
A grid that hides its controls by default, with one column that keeps them
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const grid = createHeadlessGrid({
headerControls: 'hidden', // the read-only default for every column
columns: [
{ field: 'name' }, // follows the default: hidden
{ field: 'region', headerControls: 'always' }, // this column overrides the default
{ field: 'qty', type: 'number' }, // follows the default: hidden
],
rows: [{ name: 'a', region: 'x', qty: 1 }],
rowKey: 'name',
});
// Resolve each column the way the header renderer does: its own value wins,
// then the grid default. Only the overriding column shows its controls.
function shown(id) {
const own = grid.columns.get(id).def.headerControls;
return (own || 'hidden') !== 'hidden';
}
const visible = ['name', 'region', 'qty'].filter(shown).length;
grid.destroy();
return visible;
showTotalInHeader
Under grouping or pivot, a totalled column's cells hold an aggregate rather than a row's own
value. On by default, the heading says which, a small SUM line above
Capacity, AVERAGE above Margin. The heading returns to
the column's own title when grouping and pivot are both off.
Leave the headings alone
showTotalInHeader: false
The reduction goes on its own line rather than reading Sum of Capacity across
one. A header cell reserves width for its sort, filter and menu buttons whether or not they
are showing, so the label gets well under half the column: on a default column, 54px of
129px. One line truncated to Sum o…, trading the column's identity for its
reduction. Stacked, it costs no width at all.
Such a heading carries data-total on its header cell, naming the reduction, and
its two lines are .lat-header-total-fn and
.lat-header-total-name. --lattice-header-total-size and
--lattice-header-total-color set the reduction line's size and colour; the full
phrase is on the label's title for the pointer. Where a pivot has a single value
column its leaf is titled with the pivot value rather than the column's name, and that
heading is left alone: it is naming the category, not the measure.
A total beside the pivoted columns
The grand total across every pivot value
pivot: { groupTotals: 'after' } // or 'before'; omitted, none
pivot: { groupTotals: 'after', totalsLabel: 'All regions' }
It answers the question the pivot took apart. Pivoting by Country turns
one Sales column into one per country, and the number a reader most often wants next is the
one that was there before: sales across all of them. 'before' puts that group at
the near edge, beside the row headings; 'after' puts it at the far edge, which is
where a spreadsheet puts a grand total.
It costs a column, not a pass. A pivoted group row still carries its
reduction over every one of its leaves, which is exactly the total across all pivot values,
so these columns read a number that has already been computed. They also count towards
maxColumns, since they are columns like any other.
Opt in. Omitted, a pivot has the columns it has always had, so the option cannot quietly change what an existing grid exports or what a saved view restores.
totalFilteredOnly
Total the dataset rather than the view
totalFilteredOnly: false
By default a total describes what is on screen: filter the grid and every total moves with
it. Setting this to false makes the filter a lens instead: totals report the
whole dataset no matter what is filtered out. Both the grand total and each group total
follow the setting, so a group row shows the total for every row belonging to that group,
not only the ones currently visible.
A group whose every row the filter removed has no row to appear on, but its rows still count toward the totals above it. Editing a hidden row moves the totals, because it is part of the dataset they describe: under the default it does not, because it is not part of the view they describe.
The count shown on the grand total row follows the total, so it never reports fewer rows than the total covers. Group row counts stay as the number of rows a user can expand to see, which is what that number is for.
The unfiltered row set and its grouping are computed once and reused, so the cost lands when rows are added or removed rather than on every edit or filter change. Reducing over the full dataset is more work than reducing over a filtered subset: on 500,000 rows grouped and half-filtered, a single-cell update measured 2.1ms by default and 3.2ms with this off.
totalOnlyChangedColumns
Skip the columns an edit did not touch
totalOnlyChangedColumns: true
By default the total stage considers every totalled column on every change, even columns the change did not touch. Switching this on considers only the columns whose values actually moved, and an update that rewrites a field with the value it already held reduces nothing at all, which is what a feed resending unchanged fields looks like. This narrows which columns are looked at; the incremental grand total and group subtotals described above narrow how each one is brought up to date, and the two compound.
On a million rows in seven groups, a single-cell update with four totalled columns:
| The update changes | Off | On |
|---|---|---|
| One of the four columns | 15.4ms | 7.3ms |
| All four columns | 15.0ms | 14.5ms |
| Nothing: same values rewritten | 15.6ms | 7.3ms |
The saving is proportional to the totalled columns an update leaves alone, so there is nothing to gain when every totalled column changes on every update.
It is off by default because it is an assertion, not just an optimisation.
Skipping a column assumes its total depends on nothing but that column's own values. That
is true of every built-in reduction. It need not be true of a total supplied as
a function, which also receives the row, the grid and config.context: such a
total is only recomputed when its own column changes, so a function that reads application
state outside the column will report the value from the last time that column moved. Leave
the option off if any of your total functions work that way.
Anything that changes which rows a total covers: adding or removing rows, filtering, sorting, grouping, or changing which columns are totalled: reduces everything again regardless of the option.
Pivot
grid.columns.group(['region']);
grid.columns.pivot(['statusId']); // a column per distinct status
Pinned rows
A pinned row sits outside the scrolling body, against the header or above the status bar, and stays there while the rows scroll past it. Use one for a column-units line, a target or budget to compare against, a precomputed summary, or a note that must not scroll away.
Pinning a units row
createGrid(element, {
columns,
rows,
pinnedTopRows: [{ product: 'Units', capacity: 'MW', margin: '%' }],
});
// Or at runtime, at either edge:
grid.setPinnedRows([summaryLine], { edge: 'top' });
grid.setPinnedRows([], { edge: 'top' }); // clear
The objects are yours and are rendered through the ordinary column pipeline: value getters, formatters, cell renderers and conditional formatting all run, so a pinned row looks like the data it sits against without you rebuilding any of that.
They are not part of the data, and that separation is the point. A pinned
row is not counted by rows.count(), not sorted, not filtered, not grouped, not
selectable, not included in a total and not exported. A units row that sorted itself into the
middle of the data, or a target line that was added to the sum it is there to be compared
against, would be worse than no feature at all. If you want a row that behaves like data,
make it data.
A filter that matches nothing still leaves the pinned rows visible, which is usually what you want: an empty grid with its column-units line is readable, and an empty grid without one is not.
| Point | Behaviour |
|---|---|
| Order | Rows appear in the order of the array. At the bottom edge, the grand total comes first and your rows sit below it. |
| Height | From rowHeight, including the function form, which is called with the pinned row, so you can measure your own content. The body reserves exactly the strip's height, so no data row hides underneath it. |
| Updating | Pass a new array. Array identity is how the grid knows the rows changed; pushing into the array you passed before will not repaint. |
| Editing | A pinned row has no place in the store to write to, so it is not editable. |
Full-width rows
A row drawn as a single band across every column instead of being divided into them: a section banner, an explanatory note, an empty-group message, a “load more” affordance, anything that belongs between rows and is not itself divided by the columns.
A banner before each section
createGrid(element, {
columns,
rows, // your data, with the banners in it
fullWidth: {
when: (row) => row.data.kind === 'section',
render: ({ data }) => data.title,
},
});
render returns a string for text or a node for content, or returns nothing and
writes into params.element itself. An HTML string is deliberately not accepted.
params carries { row, data, index, grid, element }.
The band holds still while the columns scroll under it, which is what a banner is for: text that scrolled sideways out of view with the columns would be a worse version of a cell. It is drawn over the pinned regions as well as the centre, so it genuinely spans every column.
A full-width row is still one of your data rows. It is counted by
rows.count(), sorted, filtered and exported like any other; only its
presentation changes. That is the difference between this and
pinned rows, and it is the thing to get straight before choosing
between them: full-width changes how a row looks, pinned changes whether a
row is data at all.
So if your banners must not appear in an export or a row count, they should not be in the data. If they are section headings that belong with the records they head, and should sort, filter and export alongside them: this is the right tool.
One consequence worth stating plainly: sorting reorders banners along with everything else, because the predicate follows the row and not its position. Either do not offer sorting on such a grid, or sort on a key that keeps each section together.
A band is exposed as a row containing one cell with aria-colspan covering every
column, so a screen reader reads it as one wide cell rather than as a row with missing ones.
No second, empty row is rendered underneath it.
Forming banded headers at runtime (BACKLOG-0000739)
Banded headers can be declared in config (columnGroups) and now also formed,
renamed, moved and dissolved at runtime through grid.columns, with a keyboard
equivalent for every action. The model is the single source of truth: a band made by
interaction is the same ColumnGroup tree config drives, and it round-trips through
a saved view.
The API. groupColumns(ids, { title, groupId }) wraps columns
in a new band or adds them to an existing one; pass id instead of
groupId to create a new band with a caller-chosen, stable id you can address
later (groupColumns(ids, { title: 'Traffic', id: 'g-traffic' })).
ungroupColumn(id) takes a column
out (dissolving a band it empties); renameGroup(id, title),
dissolveGroup(id) and moveGroup(id, to) do the rest. Each emits
columngroup:changed. A band's columns are always contiguous, and a nested band
dissolves into its parent, not the root.
The keyboard (WCAG 2.1.1). From a focused header cell: Ctrl+Shift+←/→ groups the column with its neighbour on that side (joining an adjacent band, or forming a new one); Ctrl+Shift+↑ takes it out of its band; and Alt+Shift+←/→ moves the whole band as a unit. Every action is announced through the live region, and a refusal — "not in a band", "cannot be moved there" — is announced too, never silent.
Pinning and visibility. A band lives in one pin region and draws over its
visible columns there: hiding a column shrinks the band's span without changing the band
definition, and hiding the last visible column hides the band. A band is exposed to assistive
technology as one role="columnheader" cell with an aria-colspan.
Group rows you draw yourself
The grid's own group row is an expander, a label and a count. When the heading has to carry
more than that — a sprint section with a chevron, the section name, the points summed
across it, a done/total count and a progress bar — groupRenderer hands you
the whole row. It is drawn as one band across every column, over the pinned regions, and no
ordinary cells are mounted underneath it.
A section header with a rollup the grid was never told to compute
createGrid(element, {
columns,
rows,
groupRenderer: ({ value, leafCount, expanded, leaves }) => {
// `leaves()` is the group's own rows. Roll up whatever you like.
const rows = leaves();
const points = rows.reduce((sum, r) => sum + r.data.points, 0);
const done = rows.filter((r) => r.data.done).length;
const pct = Math.round((done / leafCount) * 100);
return `<span data-lat-group-toggle>${expanded ? '▾' : '▸'}</span>
<b>${value}</b> ${points} pts · ${done}/${leafCount}
<progress value="${pct}" max="100"></progress>`;
},
});
What the renderer is given
| Field | What it is |
|---|---|
| key | The group's key — the same string rows.expand() and rows.collapse() take, and the one group:toggled carries. |
| column | The id of the column this level groups on. It is stamped per rebuild from the grouping, not derived from the row's depth, so it stays correct when an outer grouping is removed and this level becomes the outermost. |
| value | The value this group stands for. |
| level | Depth of the group. Zero is the outermost. |
| expanded | Whether the group is open. Draw your chevron from this; the row is re-rendered when it changes. |
| leafCount | How many records sit beneath the heading, at any depth. Read this when the size is all you need. |
| totals | The group's own reductions by column id — whatever total asked for. Unaffected by drawing the row. |
| leaves() | The rows beneath the heading, computed when you call it. The members the filters left, in display order, so a rollup agrees with the rows drawn below. Also available on its own as grid.rows.leavesOf(key). |
| toggle() | Expand the group if it is closed, collapse it if it is open. |
| grid, row, element | The grid, the group row model, and the element to fill if you would rather write into it than return anything. |
A string here is markup, and in fullWidth.render it is text.
That difference is deliberate, and it is the difference between the two features. A
full-width row renders one of your data rows, and
§8.9 keeps host markup behind allowUnsafeTemplates everywhere a data value
reaches the page. A group heading has no data row at all — the grid synthesised it from
the grouping — so its string can only be a template you wrote in your own source. That
is the same footing the board's cardRenderer already stands on, and the header
this exists for (a chevron, a bar) is unwritable without it. If you interpolate a value that
came from outside your application, escape it yourself, or build a node and return that
instead.
Your chevron is yours to wire. The grid's own expander binds a click
handler to the button it built; a string cannot carry a handler, so that chevron would be
dead. Any element in your markup carrying data-lat-group-toggle expands or
collapses the group it sits in, and toggle() does the same from a node you built
yourself.
leaves() is a function on purpose. A group is unbounded and
the renderer runs as rows are painted. Handing every group an array of its members would cost
a hundred thousand rows on a heading nobody looked at. Call it when you need the rows; read
leafCount when you need the size.
Which groups start open
groupDefaultExpanded decides the state of a group before anyone has touched
it: true (the default) opens them all, false closes them all, a
number opens the first N levels, and a predicate answers per group. Once the user or your own
code expands or collapses a group, that decision stands — the default is not consulted
for it again, so a group cannot spring shut under the user on the next rebuild.
The current sprint open, everything else closed, executed
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
// The renderer the grid calls for each group row. Called here directly too,
// with the params the grid builds, so this block shows its actual output.
const groupRenderer = (p) => {
const rows = p.leaves();
const done = rows.filter((r) => r.data.done).length;
const points = rows.reduce((sum, r) => sum + r.data.points, 0);
return `${p.value} ${done}/${p.leafCount} ${points}pts ${p.expanded ? 'open' : 'closed'}`;
};
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'section' }, { field: 'points', type: 'number', total: 'sum' }, { field: 'done' }],
rows: [
{ id: 1, section: 'Sprint 12', points: 3, done: true },
{ id: 2, section: 'Sprint 12', points: 5, done: false },
{ id: 3, section: 'Backlog', points: 8, done: false },
],
groupRenderer,
// Per group, not merely per depth: only the current sprint starts open.
groupDefaultExpanded: (group) => group.value === 'Sprint 12',
});
grid.columns.group(['section']);
const out = [];
for (let i = 0; i < grid.rows.count(); i++) {
const row = grid.rows.get(i);
if (!row.group) continue;
out.push(groupRenderer({
row, key: row.key, column: row.groupColumn, value: row.groupValue,
level: row.level, expanded: row.expanded, leafCount: row.leafCount,
totals: row.totals, leaves: () => grid.rows.leavesOf(row.key),
toggle: () => {}, grid, element: null,
}));
}
grid.destroy();
return out.join(' | ');
grid.rows.leavesOf(key) is the same reach on its own, for a breadcrumb, a
side panel or a rollup computed away from the row: it returns the leaf rows beneath a group
heading, which is what leafCount counts.
Row reorder
rowReorder: true puts a drag handle in the first visible column and lets a user
move rows with it, or with Alt+Shift+↑/↓.
{ column: 'name' } puts the handle somewhere else.
Reordering a list, and saving the result
createGrid(element, {
columns,
rows,
rowReorder: true,
});
grid.on('row:moved', ({ key, from, to }) => {
// rows.data() is the new order in full.
api.saveOrder(grid.rows.data().map((r, i) => ({ id: r.id, order: i })));
});
The order is your data, not a view of it. A move reorders the array you gave the grid and tells you it happened; writing it somewhere permanent is yours, because only you know where the order lives. A grid that rearranged rows on screen and stopped there would look finished and lose the order on the next load, which is worse than not offering the feature.
If the save fails, move it back: grid.rows.move(key, from).
It refuses while a sort, filter or grouping is active, and says why out loud
rather than springing the row back in silence. The reason is that dropping between two
visible rows says nothing about where the row belongs in the underlying array: under a
filter there may be hidden rows between them, and under a sort the displayed order is
something the grid computed rather than something the data says. Rather than pick an
interpretation and put the row somewhere you did not ask for, the move is declined.
rows.move() returns { moved: false, reason } so you can handle it
yourself.
Moving rows between grids
A row can be dragged out of one grid and into another, a picker beside a basket, an inbox beside a queue, an available list beside an assigned one.
A one-way drag, from a catalogue into a basket
// Sends, never receives. rowReorder draws the handle a drag starts from.
createGrid(left, { columns, rows,
rowReorder: true,
rowTransfer: { receive: false, group: 'order' },
});
// Receives, never sends. Draws no handles at all.
createGrid(right, { columns, rows,
rowTransfer: { send: false, group: 'order' },
});
mode: 'copy' on the sending grid leaves the row where it was. group
restricts exchange to grids sharing the same name, so two unrelated grids on a page do not
accept each other's rows.
| Event | Fired on | Carries |
|---|---|---|
| beforeRowReceive | the target, before the insert | { data, at, overKey, source }, cancellable |
| rowReceive:cancelled | the target, on a veto | { data, at, overKey, source, reason } |
| row:received | the target | { data, at, overKey, rejected } |
| row:sent | the source, on a move | { key, data, mode } |
| row:copied | the source, on a copy | { key, data, mode } |
Those events all describe a drag that has settled. To follow one while it is
happening — to highlight a candidate row, drive your own drop indicator, or update a side
panel as the row travels — there are four more, and they all fire on the grid the drag
started in, for a same-grid reorder and a cross-grid transfer alike. A drag is
one gesture with one owner, and the source is the only grid present for the whole of it, where
the pointer may cross several others or none; over names whichever grid the event
is about, so one subscription can decorate any of them.
| Event | Fires when | Carries |
|---|---|---|
| rowDrag:started | the press passed the drag threshold | { key, data, over, at, overKey } |
| rowDrag:moved | the pointer is over a candidate position; at most once per animation frame | { key, data, over, at, overKey } |
| rowDrag:left | the pointer left a grid; over is the grid it left | { key, data, over, at: null, overKey: null } |
| rowDrag:ended | the gesture ended, drop or no drop | { key, data, over, at, overKey, dropped } |
Highlight the row a drag is hovering, and clean up however it ends
backlog.on('rowDrag:moved', (e) => {
// e.over is the grid under the pointer, null over none.
paintCandidate(e.over, e.overKey); // overKey is null where there is no row to name
});
backlog.on('rowDrag:left', (e) => clearCandidate(e.over));
backlog.on('rowDrag:ended', (e) => {
clearCandidate(e.over); // always fires, even released off every grid
if (!e.dropped) toast('Nothing moved');
});
Notifications, not gates. None of the four is cancellable and none carries
preventDefault. The drop is already vetoable twice over — beforeRowMove
for a reorder, beforeRowReceive for a drop into another grid — and a third veto on
the same gesture would be a third place to look when a drop does not happen.
rowDrag:moved is coalesced to one event per animation frame,
carrying that frame's latest pointer position. A pointer produces several hundred moves a
second and a handler that draws cannot usefully run faster than the display, so the rate is
capped at the display's rather than the pointer's. The other three fire on the transition
itself, and no rowDrag:moved is ever delivered after rowDrag:ended.
Read, measure and draw in these handlers; do not mutate. The drag resolves
where it would land against the display order, so changing rows, sort, filters or grouping
mid-gesture moves the ground under the drop — and data is the source row's own
object rather than a copy, so writing to it edits a row that is still in the grid without
announcing the change. Work that changes the grid belongs in beforeRowReceive,
which is asked before the insert, or in the settled events afterwards.
The end is always reported. Exactly one rowDrag:ended follows
every rowDrag:started, including a release outside every grid, where
over is null. A press that never passes the drag threshold is a click and raises
none of them; a grid destroyed mid-drag raises no rowDrag:ended.
A drop can mean something other than a move. Dragging a backlog row onto a row in another
grid often means assign this to that: the host wants to know which row it landed on,
record the relationship, and keep the row where it was. beforeRowReceive fires on
the receiving grid before the insert, naming the row under the pointer as overKey
(null past the last row, on empty space, on the header or on a pinned row) and the grid it came
from as source. preventDefault(reason) stops the insert, and the source
grid is untouched: the row stays, and neither row:sent nor row:copied
fires. The handler may be async, as every before-event may; a drop whose row under
the pointer, or source row, is gone by the time it settles is cancelled as 'stale'.
Assign on drop, rather than move
assigned.on('beforeRowReceive', (e) => {
if (e.overKey === null) return; // dropped on no row: let it move
e.preventDefault('assigned'); // the backlog row stays in the backlog
assign(e.data.id, e.overKey); // the relationship is the change
});
assigned.on('rowReceive:cancelled', (e) => console.log(e.reason)); // 'assigned'
Off by default, and both ends have to agree. Rows leaving a grid is a data change you have to want: a grid that quietly let its rows be dragged away would lose one to a mis-drag, and there is no gesture a user would think to try to get it back. A one-way relationship is a declaration on both grids rather than a convention, the sender refuses to receive, and the receiver never starts a drag.
The target adds before the source removes. If the add is refused: a duplicate key, most likely: nothing is removed, so a rejected transfer loses no data. The other order would delete a row and then discover it had nowhere to go.
The row object is cloned, not shared. Two grids holding the same object would edit each other's rows through it, which is the sort of coupling nobody goes looking for when a cell changes in a grid they were not touching.
A grid is highlighted while a dragged row is over it only when it would actually accept the drop. Marking one that is going to refuse promises a placement that will not happen. A refusal is announced rather than left silent.
Picking up a row shows it, wherever the pointer goes. The row being dragged dims in its own grid, and a small label naming it follows the pointer for as long as the drag is held: over the gap between two grids, over one that is about to refuse the drop, anywhere the row's own dimming cannot reach. Both clear on release, and a handle press never also starts a range selection underneath it.
Column tags
Tag columns, then let a user show only the ones carrying a chosen tag. Sixty columns of monthly figures across five years become twelve by picking a year.
Five years of months, filtered to one
createGrid(element, {
columns: [
{ field: 'account', title: 'Account' }, // no tags
{ field: 'jan24', title: '01/24', tags: ['2024', 'Q1'] },
{ field: 'feb24', title: '02/24', tags: ['2024', 'Q1'] },
// …
{ field: 'total', title: 'Total' }, // no tags
],
rows,
columnTagFilter: true,
});
Only tagged columns are ever hidden. That is the rule the whole feature turns on. A financial grid with sixty month columns also has an account name, a total and a variance, and none of those belong to a year: if filtering hid them the view would be useless, and tagging every column merely to keep it visible would be busywork. So "show 2024" does not mean "hide everything else"; it means "hide tagged columns that are not 2024".
The same rule runs the other way: an untagged column you hid yourself stays hidden, because forcing it visible would undo a decision that has nothing to do with tags.
A column can carry more than one tag, which gives you a second axis for free: tag each month
with its year and its quarter, and a user can pick either. The dropdown lists tags in
the order they were declared rather than alphabetically, since they are usually already in a
meaningful sequence and sorting would put Q10 before Q2.
| Member | Does |
|---|---|
| columns.tags() | Every distinct tag, in declaration order. |
| columns.showTagged(tags) | Show only the columns carrying one of these. Nothing, or an empty list, shows all. Returns the ids it hid. |
| columns.activeTags() | What is being shown, empty when all are. |
| columns:tagged | Fired with { tags, hidden }. |
Anomaly summary chip
Give a column an anomalyFlag shadow (§9.4) and turn on
anomalySummary, and the grid shows a small chip reading how many rows that column
flags. Click it to filter the grid to exactly those rows; click again to clear.
One shadow column, counted and filtered on a click
createGrid(element, {
columns: [
{ field: 'reading', title: 'Reading', type: 'number' },
{ field: 'outlier', title: 'Anomaly', shadow: { kind: 'anomalyFlag', of: 'reading', threshold: 3.5 } },
],
rows,
anomalySummary: true, // or { column: 'reading', label: '…' }
});
The count and the filter are the same question. The chip is not a second
detector: it reads the anomalyFlag shadow column, so the number it shows is the
number of rows that column marks, and the click sets a filter on that same column
({ col, op: 'eq', value: true }). The count comes from
grid.statistics.anomalies() over the shadow's base column, so the chip adds no
detection of its own — the score is the API's, shown with its method, never an opaque verdict.
Off by default, and it draws nothing unless a column carries an anomalyFlag
shadow. When more than one does, column names the base to summarise.
Headings without the controls
A dense grid often wants the heading and nothing else. showColumnFunctions: false
leaves each heading as its label, with no sort, filter or menu control, they are not drawn
rather than hidden, so the label has the whole cell. Sorting, filtering and the column menu
stay reachable through the API, the keyboard and the tool panel; only the furniture goes. The
resize grip stays, since dragging a column wider is a view adjustment rather than a function
of the column.
Aligned grids
Two or more grids that read as one table split into sections: a summary band above a detail grid, two datasets side by side under identical columns, a frozen top section that is genuinely different data rather than a pinned row.
A summary band above a detail grid
const summary = createGrid(top, { columns, rows: totals });
const detail = createGrid(bottom, { columns, rows, alignedGrids: [summary] });
Declare it on the grid you create last, since that is the only one that can name the others. The link is peer-based once made: whichever grid the user resizes is the one the others follow.
| Shared | Independent |
|---|---|
| Column widths and flex | Sort |
| Column order | Filters |
| Column visibility | Selection |
| Pinning | Grouping and totals |
| Horizontal scroll | Vertical scroll, and the rows themselves |
What is not shared is the design. If sort, filters and selection travelled too, this would not be a feature, it would be one grid with extra steps. The reason to have two is that the sections hold different data, so each keeps its own view of it.
Vertical scroll stays independent for the same reason: the grids hold different numbers of rows, and yoking them would make the shorter one run out.
A column one grid has and another does not is skipped rather than invented, and nothing checks that the column sets match: aligning grids with different columns is a caller error that produces a visibly wrong result rather than a silent one. Destroying any grid releases its link and leaves the rest working.
Totals that a type can refuse
Some values do not add up the way plain numbers do. A data type can say which aggregates are meaningful for it, and supply its own arithmetic where the built-in one would be wrong.
The failure this prevents is a confident wrong number. You cannot add decibels: 90 dB and 90 dB make 93 dB, not 180. The mean of a column of rates is not the mean, a 100% conversion on two visits and a 1% conversion on ten thousand average to 1.02%, not 50.5%. Both mistakes produce a plausible figure rather than an error, and a footer nobody can check gets used. A missing total gets asked about; a wrong one does not.
A type declaring what it supports
{
base: 'number',
totals: {
// Anything else is refused when a column is configured, not at render.
supported: ['sum', 'avg', 'min', 'max', 'count', 'countValues'],
// And where the built-in arithmetic is wrong, replace it.
implement: { sum: (values) => logDomainSum(values) },
},
}
A type that declares no totals supports everything, so nothing that shipped
before this behaves differently. An implement function receives the values
index-aligned with their rows and a context carrying column and
valueAt(colId, i), which is how a weighted mean reaches the denominators in
another column.
The types that use it
| Type | What it does differently |
|---|---|
| decibel | Sums and averages in the linear domain and converts back. Power scale, factor 10. |
| decibelAmplitude | The same, on the field scale: factor 20, for voltage and current. |
| ratio | Averages by weight, using the column named in typeOptions.weight. Refuses sum, since two rates do not add to a rate. |
| percentRate | As ratio, displayed with a percent sign. |
A conversion rate averaged properly
{ field: 'conversion', type: 'percentRate', total: 'avg',
typeOptions: { weight: 'visits' } }
Without a weight column the average returns nothing rather than falling back to the unweighted mean: falling back would be the exact mistake the type exists to prevent, arrived at silently. Rows with no rate, or no weight, are left out rather than counted as zero.
Choosing an aggregate at runtime
With aggregateChooser on, the column menu's totalling entry becomes an
Aggregate submenu. It offers only the reductions the column's type says are meaningful
(see above) — sum, avg,
min, max and the counts on a plain number, but never sum
on a category or a rate — with the current one ticked and a None to stop totalling. It
is the same keyboard-operable menu as everywhere else: arrows move, Enter or
Space picks, Escape closes and returns focus, and the ticked item
reads as aria-checked to a screen reader.
Off by default; turn it on
createGrid(element, { aggregateChooser: true });
It reuses the totals model, it does not fork it. Every choice drives
grid.columns.setTotal(id, name), the same public call the old toggle used, so the
footer, the group rows, the pivot cells and the grand total all move together and no
aggregation is recomputed here. grid.columns.aggregates(id) returns the list the
submenu offers, so a host building its own chooser reads the same answer.
Safety holds on both routes. The submenu only lists meaningful aggregates,
and setTotal refuses an unmeaningful named total whether it comes from the menu or
from an API caller — the wrong footer cannot be reached from either.
Off by default and non-breaking. Left off, the menu keeps its plain Total this column toggle, so an existing grid is unchanged.
Setting an aggregate at runtime, executed
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const grid = createHeadlessGrid({
aggregateChooser: true,
columns: [{ field: 'amount', type: 'number', total: 'sum' }, { field: 'region' }],
rows: [{ id: '1', amount: 2, region: 'N' }, { id: '2', amount: 4, region: 'S' }],
rowKey: 'id',
grandTotalRow: 'inline',
});
grid.rows.count();
// A number column offers every built-in; a text column offers only what makes
// sense — count and the extremes, never sum. This is the list the chooser shows.
const offered = grid.columns.aggregates('amount'); // ['sum','avg','min',...]
// Switch the footer from Sum (6) to Average (3) at runtime.
grid.columns.setTotal('amount', 'avg');
const total = grid.rows.get(grid.rows.count() - 1).totals.amount;
grid.destroy();
return offered.includes('sum') ? 6 : 0; // sum is offered on a number column
The group subtotals and the grand total can reduce differently. By default
one total drives both, and that is unchanged. When a column needs, say, an
average per group under a sum of everything, set the two independently with
the scope option: setTotal(id, 'avg', { scope: 'group' }) and
setTotal(id, 'sum', { scope: 'grand' }). A scope with no override falls back to
total, and passing no scope sets the shared total and
clears both overrides — so the one-property behaviour is exactly what it was. The same split is
declarable on a column as groupTotal / grandTotal, and it persists in
saved views alongside total.
Group average under a grand sum, executed
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const grid = createHeadlessGrid({
columns: [{ field: 'amount', type: 'number', total: 'sum' }, { field: 'region' }],
rows: [
{ id: '1', amount: 2, region: 'N' }, { id: '2', amount: 6, region: 'N' },
{ id: '3', amount: 4, region: 'S' }, { id: '4', amount: 8, region: 'S' },
],
rowKey: 'id',
grandTotalRow: true,
});
grid.columns.group(['region']);
// Group subtotals average within each region; the grand total sums everything.
grid.columns.setTotal('amount', 'avg', { scope: 'group' });
grid.columns.setTotal('amount', 'sum', { scope: 'grand' });
// The first group subtotal (region N: (2+6)/2 = 4) and the grand row (sum = 20).
let groupSubtotal = null;
for (let i = 0; i < grid.rows.count(); i++) {
const r = grid.rows.get(i);
if (r && r.group && !r.grandTotal && groupSubtotal === null) groupSubtotal = r.totals.amount;
}
const grand = grid.rows.get(grid.rows.count() - 1).totals.amount;
grid.destroy();
return (groupSubtotal === 4 && grand === 20)
? 'avg groups under sum grand' : `group ${groupSubtotal}, grand ${grand}`;
Sticky group headings
Scrolling inside a group keeps that group's headings pinned above the rows, so the rows on screen always say which group they belong to. Nested groups stack, up to a cap.
Off by default; opt in or set the cap
createGrid(element, { stickyGroupHeaders: true }); // on, up to two
createGrid(element, { stickyGroupHeaders: 3 }); // on, up to three
This could not be position: sticky. The heading row is very
often not in the page at all: the grid renders a window of rows, and a heading five hundred
rows above the viewport was recycled long ago. So the pinned heading is synthesised from
whichever group the top visible row belongs to, which the grid answers by binary search over
an index it builds while flattening the rows, the ten-thousandth row of a group costs what
the second one does.
The cap exists because each heading costs a row of viewport. A five-level grouping without one would spend a third of the screen describing what the other two thirds contain.
The pinned headings are hidden from assistive technology. Each is a duplicate of a row that is already in the tree, and announcing it again would report a group the reader has not moved to, and add an entry to a row count that virtualisation already makes hard to reconcile.
The same answer is available directly as grid.rows.groupHeadings(index), which
returns the enclosing group rows outermost first, for a breadcrumb, or a heading elsewhere on
your page.
Working with large data
A million rows in memory is fine: that is what the columnar store is for. Beyond that, or when the data lives behind an API, a source takes over.
| Source | For |
|---|---|
| memory | The default. Everything is present; the grid does all the work. |
| paged | A page at a time from a server that paginates. |
| remote | Blocks fetched on demand as the user scrolls, with sort and filter pushed to the server. |
| stream | Rows arriving over time, a query that streams, a socket. Promotes to memory once complete. |
Trimming the memory footprint
By default the store retains the row objects you hand it by reference, so
rows.data() returns those exact objects and row === sourceObject
holds. The ingest option controls that:
| Option | Type | Description |
|---|---|---|
| retainSource | boolean | Default true. Set false to keep only the packed columns and reconstruct a plain row object from them on demand. rows.data() then returns freshly reconstructed objects — a new object each call — so row === sourceObject and a custom renderer reading row.sourceObject no longer hold, and equality becomes value-based. Cell values are identical either way, so get(), byKey(), value() and values() are unaffected. On its own this drops only the store's reference array, not the objects: the source layer still holds them. |
| dropSourceRows | boolean | Default false. Set true to release the caller's row objects from the source layer and the grid config once the store is built, so the packed columns are the only resident copy. This is where the large reduction lives — roughly an order of magnitude at a million rows — because the caller's objects are the dominant term. Implies retainSource: false (the store must reconstruct), so it carries the same identity tradeoff. An impure computed column — a shadow or a rank/positional column — is never stored, so it cannot be served from the columns: it warns once and drops out rather than answering wrongly, and rows.move() is refused. Do not enable it on a grid that sorts, filters, groups or totals on such a column, or that reorders rows in place. |
Dropping retained source objects
createGrid(el, {
columns,
rowKey: 'id',
rows,
// Release the caller's objects entirely; the packed columns are the sole copy.
ingest: { dropSourceRows: true },
});
With retainSource: false alone the saving is only the store's own copy of the
object references, not the objects themselves: the source layer and the grid config still
hold the array, so whoever handed the grid its rows keeps them alive. dropSourceRows
releases those references too, so once the caller lets go the objects can be collected and the
grid becomes the sole holder of the data. Reach for it on a large, read-mostly grid where you
can let go of the source array and do not depend on caller identity through
rows.data().
A remote source
source: {
mode: 'remote',
pageSize: 100,
async fetch(req) {
const res = await api.rows({
offset: req.range.start,
limit: req.range.end - req.range.start,
sort: req.sort, // [{ col, dir }, …]
filters: req.filters, // the condition tree, as documented above
quick: req.quick,
}, { signal: req.signal }); // a superseded request aborts itself
return { rows: res.rows, total: res.total };
},
}
The request also carries groupBy, groupPath,
pivotBy, totals and your own context, so a server that
can group and aggregate does that work instead of the browser. Blocks are fetched as the
viewport reaches them and cached; changing the sort or the filter invalidates the cache and
re-queries.
The filters your callback receives is the same condition tree documented
above. You are not handed an opaque object to reverse-engineer, it is the published format,
and the same shape you would have written by hand.
Querying an engine directly
A remote source hands you the whole request and leaves the translation to you. A pushdown adapter inverts that: you declare what your engine can answer, and the grid works out what to send and finishes the rest itself.
An OData endpoint, with nothing to write
import { createPushdownSource, odataAdapter } from 'lattice-grid';
createGrid(el, {
columns,
rowKey: 'id',
source: createPushdownSource({
adapter: odataAdapter({ url: 'https://services.odata.org/V4/Northwind/Northwind.svc/Orders' }),
pageSize: 100,
}),
});
Five adapters ship. odataAdapter writes $filter,
$orderby, $top and $skip, and follows
@odata.nextLink when the server pages on its own terms.
restAdapter covers an ordinary JSON endpoint whose parameter names are yours to
give. dfqlAdapter speaks DemandFlow's query API. duckdbAdapter
takes a live DuckDB connection.
graphqlAdapter POSTs a GraphQL operation: because GraphQL has no fixed query
semantics, you supply a buildQuery that turns the plan into
{ query, variables } and a parseResponse that reads
data back into rows and total, with defaults for an offset/limit list and a
Relay cursor connection. The default pushes only the window and the total, so anything the
schema cannot answer stays with the grid.
Declaring what an engine can do
No real engine answers the whole query. An adapter says what it can take, and everything undeclared stays with the grid.
An endpoint that pages and sorts, but does not filter
restAdapter({
url: '/api/readings',
params: { offset: 'from', limit: 'size', sort: 'orderBy', order: 'dir' },
capabilities: { sort: 'single', range: true, total: true },
})
| Capability | Values | Means |
|---|---|---|
| filter | false | 'term' | 'flat' | 'tree' | Nothing, one field and term, a flat conjunction, or a full condition tree. |
| operators | string[] | Which comparisons the engine genuinely applies. Declare only those it does. |
| sort | false | 'single' | 'multi' | No ordering, one column, or several. |
| quick | boolean | Whether a free-text search across columns can be pushed. |
| range | boolean | Whether the engine can return a window rather than the whole result. |
| total | boolean | Whether it can report how many rows matched. |
| group | boolean | Whether it can group and aggregate. |
Everything is off unless declared. An adapter that declares nothing still works: the grid fetches and does all the work itself. That is the safe direction to be wrong in. Declaring an operator the engine does not really apply is the unsafe one, because the grid will trust it and stop checking.
Splitting a filter is not symmetric. An and group narrows
with each condition, so the supported conjuncts go to the engine and the rest stay behind:
the engine returns a superset and the grid narrows it. An or group widens with
each branch, so pushing only the supported branches would return fewer rows than the filter
allows, and the grid cannot recover rows that were never fetched. A disjunction that is not
fully supported therefore stays whole on the client. The same asymmetry governs column
pruning in the facet path.
Residual work needs the whole result. When anything is left over, the source stops asking for windows and asks for everything, applies the remainder, and pages from what it holds. Filtering a window on the client is not a slower route to the right answer, it is a fast route to a wrong one: the rows that belong on page one may sit on page nine, and the total is whatever the engine happened to count.
Seeing what was pushed
The split is reported rather than hidden, which is the difference between a slow query you can diagnose and a slow query you cannot.
Asking after the last request
const source = createPushdownSource({ adapter });
createGrid(el, { columns, rowKey: 'id', source });
const plan = source.lastPlan();
plan.pushed; // the query the adapter was given
plan.residual; // { filters, sort, quick } the grid applied after
plan.unpushed; // ['filter'], the parts that stayed behind
plan.needsAll; // true when the whole result had to be fetched
plan.full; // true when fullDataset forced it, not just residual work
The grid also warns once, naming the predicate that could not be pushed, because the fix is
usually a better adapter rather than a bigger machine. It warns again if an adapter reports a
total larger than the rows it returned while residual work is outstanding: that
combination silently produces wrong answers, and it is worth knowing about.
A full analytical engine, without carrying one
duckdbAdapter takes a connection you created and imports nothing, so the grid
can drive DuckDB while this package stays at zero dependencies.
Parquet in the browser, no server
const db = await makeDuckDB(); // yours: @duckdb/duckdb-wasm
const conn = await db.connect();
createGrid(el, {
columns,
source: createPushdownSource({
adapter: duckdbAdapter({
connection: conn,
from: "read_parquet('readings.parquet')",
}),
}),
});
from is any FROM expression, so
read_parquet('s3://bucket/*.parquet') is as valid as a table name. Values are
bound through prepared statements; a connection without prepare is used only for
unfiltered queries, because interpolating a user's filter into SQL is the one thing worse
than not filtering at all.
Column and table names are checked against an identifier pattern rather than escaped, and a name that fails is refused. Integers past the safe range are kept as strings instead of being rounded into a plausible lie.
A timestamp or date filter is bound through a typed placeholder — "ts" >=
CAST(? AS TIMESTAMP) — because a prepared statement binds an ISO string as
VARCHAR and DuckDB will not compare that with a TIMESTAMP,
TIMESTAMP WITH TIME ZONE or DATE column. The adapter reads the
column types once from the engine (DESCRIBE) and falls back to the grid column's
declared type, so both a typed and an untyped grid column over a timestamp filter correctly,
including a time window. Send instants ending in Z, as the grid's date filter
does: the cast is the engine's, and its zone rules apply (see the reference for the
TIMESTAMPTZ and naive-string cases).
demo/duckdb.html runs this against a Parquet file of several million readings
with no server involved, including a time-window filter on its TIMESTAMP column.
Whether a Parquet file streams or downloads whole
duckdbAdapter writes SQL and hands it to the connection you built; it never opens
the file itself, so it has no say in whether read_parquet(...) reads the whole
thing or only the bytes a query needs. That is decided by DuckDB's own configuration and by the
HTTP server the file is served from.
Enable range reads before the first query
const db = await makeDuckDB(); // yours: @duckdb/duckdb-wasm
const conn = await db.connect();
// Before the first read_parquet(...) call, on DuckDB-Wasm 1.32 and later:
await conn.query('LOAD httpfs;');
createGrid(el, {
columns,
source: createPushdownSource({
adapter: duckdbAdapter({
connection: conn,
from: "read_parquet('https://example.com/readings.parquet')",
}),
}),
});
Measured against a 1.5 MB Parquet file on GitHub Pages (BACKLOG-0001324): without
LOAD httpfs;, DuckDB-Wasm 1.32.0's default HTTP path issued zero Range requests
and read the whole file — 100% — for every query, chip filters included. With
it loaded on the connection first, the same query issued 25 Range requests and read
30% of the file. DuckDB-Wasm 1.29.0 read 119% of the file on the same test
(its ranges overlapped), so treat the exact percentages as a version's behaviour, not a fixed
promise — re-measure against the DuckDB build you ship.
The file host has to cooperate too: it must answer HEAD, advertise
Accept-Ranges: bytes, and answer a range request with 206 Partial
Content and a Content-Range header (GitHub Pages does all three). Cross-
origin, its CORS policy must also expose Content-Range and
Content-Length, or the browser cannot read them back. Any of that missing, and
DuckDB falls back to a full read with nothing said.
A file registered with db.registerFileBuffer(...) is always downloaded whole,
whatever version is loaded or however the host answers: a buffer has already been fetched in
full before DuckDB ever opens it, so there is nothing left to range-read. There is no
force_download option — it does not exist in DuckDB-Wasm; registering a buffer
is the way to force a whole download.
Whole-dataset statistics over a remote source
A windowed source reduces a total or statistic over the rows it has loaded, not the whole
matching set — a footer median of the 200 rows on screen, which is wrong and looks right.
fullDataset makes the source hold the entire matching set client-side so those
figures are computed over everything.
Correct footer figures over a REST or DuckDB source
const source = createPushdownSource({
adapter: restAdapter({ url: '/api/trades' }),
fullDataset: {
enabled: true, // hold the whole matching set, once per query
maxRows: 1_000_000, // refuse (visible error) past this
maxBytesEstimate: 512 * 1024 * 1024,
},
});
It is off by default and reuses the same whole-result path that residual
work already takes: the flag ORs into needsAll, so once the set is held it is
ordinary in-memory data and the grid's existing total and statistics kernels reduce over all of
it, with no per-stat change. When it is active and within the limits, the whole matching set is
covered, so the windowed-statistic warning (BACKLOG-0000731) stays silent — the figure is now
honestly whole-dataset.
It is refused loudly, never truncated. A matching set past
maxRows or maxBytesEstimate is thrown and surfaced as a
source:error with no rows shown, rather than held as a fraction and presented as
the whole. A fraction shown as the whole is exactly the silent wrong answer this feature
exists to remove, so it is never how the feature fails. For a restAdapter, which
cannot compute, this is the only route to a correct whole-dataset statistic.
Refusing a partial result over residual work
When residual work has to run in the browser, the source asks the adapter for the whole matching set and pages from what it holds. An adapter that answers with a page of that result — it paged when told not to — leaves the client-side filter or sort running over the wrong rows: the ones that belong on page one may be in the fraction never fetched. A page presented as the full filtered set is a wrong answer, not a slow one.
Refused by default; opt in only when you knowingly accept it
// Default: a shortfall under residual work throws, surfaced as source:error.
const strict = createPushdownSource({ adapter });
// Knowing escape hatch: keep the old warn-once-and-proceed behaviour.
const lax = createPushdownSource({ adapter, allowPartialResults: true });
Off by default, refused loudly. Filtering or sorting a fraction of the result
does not lose rows quietly, it returns the wrong rows, so the default throws rather than
warns — nothing fails silently (§8) means a wrong answer is never preferred to a
visible failure. The fix is an adapter that follows the engine's own paging, or holding the data
in memory. allowPartialResults: true is the deliberate opt-out — a caller who
accepts the permissive behaviour keeps the warn-once path — and it is never the default. It does
not touch the fullDataset memory guard or the no-residual short-return warning, both
of which stand regardless.
Running a host predicate over a pushdown source
A where predicate is your code — whether this user may see the row, whether you
hold a rate for its currency. No engine can evaluate it, so a pushdown source can only honour one
by fetching every matching row and filtering here. whereRowLimit is the ceiling on
doing that, because past a point it is no longer a filter, it is a full download.
The predicate runs under the limit; past it the source refuses and says so
const source = createPushdownSource({
adapter: duckdbAdapter({ connection, from: 'trades' }),
whereRowLimit: 50_000, // the default: run the predicate up to this many matching rows
});
grid.filters.where('visibleToMe', (row) => row.owner === me);
Why a limit at all. A pushdown source exists so a host does not download ten million rows. Honouring a twinless predicate means holding the whole matching set, so wiring it in without a gate would let one filter function silently convert a windowed grid into a full download — trading one silent surprise for another. Under the limit the predicate really runs and the counts are whole-dataset counts; at or past it the predicate is not applied, the rows it would exclude stay on screen, and one warning names the adapter, the matching-set size, the limit and the way out.
No row total counts as over the limit. The only way to learn the size from an adapter that cannot count is to fetch the set — which is the download being guarded against — so the source refuses rather than guesses its way into it.
A refusal costs nothing extra. Where the predicate alone would force the whole result, the size is learned from a bounded probe that is exactly the window fetch the request would otherwise have made, and that result is used as that fetch. A host over the limit pays what it paid before the gate existed.
Prefer the twin. A predicate registered with a { condition } twin
is pushed to the engine, which narrows the fetch itself — no limit applies, nothing is held
here, and it works at any size. Reach for whereRowLimit only when what you are testing
genuinely cannot be written as a condition. On a paged or remote source the twin
is the only route: those hold a block cache indexed by the server's own ranges and cannot run a host
function at all, and the grid warns once when you register one without a twin.
Pushing statistics down to the engine
A DuckDB-class engine computes a median or a standard deviation over the whole matching set far
faster than pulling every row here to do it. The aggregates config decides, at
grid setup, which statistics the engine computes and which the grid does — a design-time
developer choice, fixed for the life of the grid, never a runtime toggle and never shown to an
end user.
Push the verified-identical stats, keep the rest exact
const source = createPushdownSource({
adapter: duckdbAdapter({ connection: conn, from: 'trades' }),
aggregates: {
// 'engine' pushes everything expressible; 'engine-if-identical' pushes only
// the stats whose engine result is verified identical to the grid kernel;
// 'client' (the default when absent) computes everything here.
default: 'engine-if-identical',
overrides: { mode: 'client' }, // I want the grid's null-when-distinct mode
},
});
Every statistic is classified IDENTICAL (the engine's result equals the
grid's own kernel, verified against it on the same data) or MAY-DIFFER (the
engine computes it by a method that can differ from the grid's definition — mode
returns a value where the grid returns null). The classification drives the docs and
build-time provenance, not whether a stat is pushed: that is your choice.
weightedQuantile is the one genuine fallback, always client-side, because the
engine cannot express the grid's midpoint convention. The published table of every stat, its
class and its SQL is generated from one map (STAT_PUSHDOWN) so it cannot drift.
No mixed provenance. An engine figure and a client figure never appear in
one result set. Aggregates are pushed only when the filter is fully pushed; a residual
filter the engine could not apply forces every aggregate client-side, because an engine number
computed over a superset beside a client number over the real set would be wrong-but-plausible.
source.lastPlan().aggregates reports, per stat, whether the engine or the grid
computed it and the class it was assigned — inspection during the build, not a per-figure
runtime marker.
Grids built from other grids
A derived grid takes its rows from another grid rather than from a load: grouped and aggregated, unnested, filtered, ranked or profiled. It has its own element and its own columns, and it follows its source live.
A summary panel beside the detail grid
const detail = createGrid(left, { columns, rowKey: 'id', rows });
createGrid(right, {
columns: [
{ id: 'region', header: 'Region' },
{ id: 'total', header: 'Capacity', type: 'number' },
{ id: 'n', header: 'Sites', type: 'number' },
],
source: {
mode: 'derived',
from: detail,
groupBy: 'region',
select: {
total: { of: 'capacity', fn: 'sum' },
n: { fn: 'count' },
},
sort: [{ col: 'total', dir: 'desc' }],
},
});
follow chooses which of the source's rows are read:
filtered by default, or all, selected or
grouped. The pipeline runs unnest, then join, then
where, then bucket and groupBy, then
select, then sort and limit, so a condition or a total
can read a field that an earlier stage produced.
A change is patched, not re-derived. When a row changes in the source, the
derived grid updates the groups that row belongs to rather than rebuilding the lot. Five
hundred updates against a two hundred thousand row source cost under 300 ms in total. Set
refresh to live, manual or a number of milliseconds to
change the coalescing; idle is the default and settles to a frame.
manual means the host says when, and rows.load() is how it
says it. A manual derived grid never re-derives on its own: the source can
filter, edit and tick underneath it and the panel keeps showing what it last derived. Call
rows.load() on the derived grid, with no argument, and it re-reads its
from there and then and replaces its rows; call it again whenever you want the next
reading. A derived grid takes its rows from from, so anything passed to
load is not used. The derived grid's own sort and filters stay as they were. Press
it as often as you like — the source is left exactly as one press leaves it, and
destroying the panel leaves nothing of it attached there.
Derived grids are read-only. There is one copy of the data and it lives in
the source. Write there and the derived grid follows: an edit through the source's editing
API (edit.setCells, an inline commit, a fill, a paste, an undo, a redo or an
optimistic rollback) is announced once per batch, after the per-cell
cell:changed events, and the derived grid re-derives once for the batch. A
grouped derivation patches the groups the edited rows belong to; a derivation with a
where, an unnest, a producer, or an edit to a column the source
filters on re-reads in full, at the cost measured above. Use idle when a
derived child follows an editable grid. Under live the derivation runs
synchronously inside the edit call, so a keystroke's commit on a 200k-row source pays the
whole re-read before the editor closes; idle defers it to the next frame and
folds a burst of edits into one.
The key comes for free. A derived grid keys on __key, which
the source writes onto every row it produces: the group value, the profiled column, or the
source row's own key when nothing is grouped. Set rowKey only to override it.
A frozen panel, refreshed on a button press, executed
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const detail = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'region' }, { field: 'capacity', type: 'number' }],
rows: Array.from({ length: 20 }, (_, i) => ({ id: i, region: i % 2 ? 'North' : 'South', capacity: i })),
});
detail.rows.count();
// What the detail grid is listening with before any panel is attached.
const listening = () => Object.values(detail.diagnostics.events()).reduce((a, b) => a + b, 0);
const alone = listening();
// A summary that derives once, then waits to be told.
const panel = createHeadlessGrid({
columns: [{ field: 'region' }, { field: 'sites', type: 'number' }],
source: { mode: 'derived', from: detail, groupBy: 'region',
select: { sites: { fn: 'count' } }, refresh: 'manual' },
});
// The Refresh button. In a page this is your <button>; here any EventTarget will do.
const button = new EventTarget();
button.addEventListener('click', () => panel.rows.load()); // no argument
const sites = () => {
let n = 0;
panel.rows.forEach((row) => { n += panel.rows.value(row.key, 'sites'); });
return n;
};
// Longer than any automatic refresh would take to land, so "frozen" is a finding.
const settle = () => new Promise((done) => setTimeout(done, 50));
const attached = listening();
const seen = [sites()]; // 20
detail.filters.set({ col: 'capacity', op: 'lt', value: 10 });
await settle(); seen.push(sites()); // 20: the source moved, the panel did not
button.dispatchEvent(new Event('click'));
seen.push(sites()); // 10: re-derived on the press
detail.filters.set({ col: 'capacity', op: 'lt', value: 5 });
await settle(); seen.push(sites()); // 10: frozen again
button.dispatchEvent(new Event('click'));
seen.push(sites()); // 5
// Pressing it any number of times leaves the detail grid as one press does,
// and destroying the panel leaves nothing of it behind there.
const steady = listening() === attached ? 'steady' : 'grew';
panel.destroy();
const released = listening() === alone ? 'released' : 'left behind';
detail.destroy();
return `${seen.join(' ')}; ${steady}; ${released}`;
Other shapes
| Option | Does |
|---|---|
| unnest | Expands an array property, one row per element, before anything else runs. |
| bucket | Rounds a date column down to a day, week, month, quarter or year and groups on that. |
| limitPer | Applies limit within each value of a column rather than overall: a top three per region. |
| cumulative | Keeps rows until their running share of the total reaches a fraction: the Pareto head. |
| profile | One row per column with the statistics as columns, or one row per statistic with orient: 'metrics'. |
Combining several grids into one
A join matches two grids on a shared key. Some questions have no key to share at
all — "the worst performers across two regional datasets" when the two regions use
unrelated ids — and for those, from takes an array of sources instead of one
grid: every source is read, concatenated in the order you declared them, and only then does the
rest of the pipeline run, once, over the combined set.
Worst performers, two unrelated datasets
createGrid(right, {
columns: [{ id: 'title' }, { id: 'severity', type: 'number' }, { id: '__source' }],
source: {
mode: 'derived',
from: [
{ grid: eastIncidents, label: 'east' },
{ grid: westIncidents, label: 'west', map: (row) => ({ title: row.name, severity: row.rating }) },
],
sort: [{ col: 'severity', dir: 'desc' }],
limit: 10,
},
});
label defaults to the source's position in the array ('0',
'1', …), and follow is independent per source — filtering
one narrows only its own contribution, exactly as a lone from follows its grid
today. A source with differently-named fields uses map to project them into a
common shape before it joins the rest.
__source is not optional. Every combined row carries it —
the entry's label, or its index when unlabelled — and it is an ordinary field
to where, groupBy and select. Leaving it out would mean a
combined list that cannot say where any row came from, which defeats most of the reason to
combine several sources in the first place.
The union of fields, never a merge. A field only one source has is
undefined on the others' rows, not fabricated and not type-coerced — two
sources disagreeing about what a field means is yours to resolve with map, not
something the union guesses at. And there is no dedup: two sources reporting the same fact both
appear as separate rows. There is no UNION-vs-UNION-ALL distinction to draw; reach for
join when rows should be matched on a key rather than stacked.
The key is namespaced, only where it needs to be. Two sources can easily
share row identifiers, so the derived __key is qualified by the source tag when
nothing is grouped. Grouped, __key is the group value exactly as it always has
been, and rows from different sources landing in the same group is what grouping a union is
for, not a collision.
An empty source is fine; a broken one is loud. A source with no rows contributes nothing. A source that throws while being read is named in a console warning and skipped for that pass — a silently missing source would make "worst across both" quietly wrong, so it is reported rather than swallowed.
A cycle is refused when the source is built. If a union's sources include the grid being derived, directly or through a chain of other derived grids, it is refused up front, naming the offending source, rather than being recursed into.
A union never patches — know the cost before you reach for one at scale.
A lone from maintains its grouping incrementally: an edit that names the rows it
touched re-reduces only the groups those rows belong to. A union does not do this for any of its
sources — every change on any parent re-reads and re-derives the whole
combined set from scratch. On a synthetic 200,000-row union across four sources, one row changed
on one parent cost on the order of 800 ms–3 s (machine-dependent;
run node bench/union-parents.mjs against your own shape), against a few
milliseconds for the equivalent patched change on a lone from over the same row
count. And because a union is watched by as many independent change streams as it has sources,
that full-rescan cost is paid once per source that moves, not once per union —
four active parents each firing their own updates pay it four times over. Below a few tens of
thousands of combined rows this is unlikely to matter; above that, or with several sources each
updating on their own live feed, budget for it, keep refresh away from every tick
(idle, or a debounce), and prefer fewer, larger sources over many small ones where
the shape of your data allows it.
Not supported alongside a union. crossFilter has no single
target once there is more than one parent; profile and statistics
reduce one grid's own columns. All three are refused with a warning rather than guessed at, and
the top-level follow is ignored in favour of each source's own.
The relational statistics, as rows
One grid is the data; a second is the analysis of it. statistics projects
the figures that need two or more columns — or a second grid — into rows you can
sort, filter, chart and export like any others.
You probably do not need it for a single-column statistic. Those already have
a route: a derived select reduces a group with any kernel the totals row uses, and
that table is a superset of the statistics one. select: { p95: { of: 'amount', fn: 'p95'
} } works today, and so do median, stddev, gini,
iqr, entropy, trimmedMean and the rest.
statistics is for what select structurally cannot reach.
Say whether the figure is approximate. Every
statistics row carries n, the rows the figure was computed over — but
n alone cannot tell you whether that was all of them. Over a windowed
parent it quietly is not: a stream with maxRows: 200 that has had 2,000 rows
through it computes over the 200 it is holding, and the parent's
rows.count(), rows.matchCount() and rows.totalCount()
all report 200 as well, so nothing in those numbers says a thing has been left out. Ask the
parent grid instead: parent.rows.coverage() returns
{ covered, total, windowed }, and covered < total, or
total === null, means the figure is approximate. For that stream it
reads { covered: 200, total: 2000, windowed: true } — the 1,800 rows that
have aged out are visible there and nowhere else. total is null,
never a guess, when the source genuinely cannot know: a stream still open that has evicted
nothing has no idea how many rows are coming. A memory parent reads
{ covered: n, total: n, windowed: false }, so the check costs nothing to leave
in and stays quiet when there is nothing to disclose.
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'id' }, { field: 'amount', type: 'number' }],
rows: [{ id: 1, amount: 10 }, { id: 2, amount: 20 }],
});
const { covered, total, windowed } = grid.rows.coverage();
// A memory grid is exact: the figure covers everything there is.
return covered === total && windowed === false;
Which columns move together
source: {
mode: 'derived',
from: trades,
statistics: { fn: 'correlation', columns: ['price', 'volume', 'spread'] },
// -> one row per PAIR: { a, b, coefficient, n }
}
Three statistics in this release. Each has one row shape, and the shape is the contract:
{ fn: 'correlation', columns, orient? }— Pearson's r across N columns.orient: 'pairs'(the default) gives one row per unordered pair,{ a, b, coefficient, n }. Long form by default because that is what a grid sorts, filters and charts well — "the three most correlated pairs" is then a sort and alimiton the derived grid. Only the upper triangle is emitted: r is symmetric, so(a,b)and(b,a)are one finding, and a column against itself is 1 by definition.orient: 'matrix'gives the classic square instead, one row per column with a field per other column, for a heat map.{ fn: 'series', of, by, periodsPerYear? }— one row per metric,{ metric, value, n }. Note per metric, not per point:grid.statistics.seriesreturns a summary —n,first,last,change,changePercent,volatility,annualisedVolatility,growth,maxDrawdown,maxDrawdownFrom,maxDrawdownTo,autocorrelation,upDays,downDays— and not a value per row. The shape is the oneprofile'sorient: 'metrics'already emits, deliberately, rather than a third convention for the same idea.byis required and never guessed, because kernels see rows in arrival order and that is not the grid's sort.{ fn: 'datasetVsDataset', with, columns? }— one row per compared column, largest difference first:{ column, measure, magnitude, distance, direction, nA, nB, reliable, unmatched }. Both sides are read over their filtered rows, and the peer is watched — an edit or a filter on it re-derives the comparison, because a comparison whose other side has moved is wrong rather than merely late. A column present on only one side cannot be compared and is still reported, with a nullmagnitudeandunmatchedset to'A'or'B', so you see that it was skipped and why.
Every row says how much it saw. n is the rows the figure
covered, and it is on the row because a derived statistic travels: a coefficient exported to
CSV or bound to a chart has left every bit of its context behind, and “r = 0.98 over
eleven rows” is a different claim from the same number over eleven thousand.
What the row does not tell you: whether the source was windowed. A
statistic over a source holding fewer rows than match its filters is computed on the loaded
window rather than the whole set. The grid detects that from the source's own
counters and says so in a console warning
([lattice] correlation on … computed over N of M matching rows) — and
that remains the signal to watch. A derived source cannot reach those counters: a grid's public
rows.matchCount() reports the loaded matches, so on a bounded stream
evicted to 200 of 2,000 rows it returns 200 and agrees exactly with rows.count().
Rather than ship a flag that could never be true, no such flag is emitted; n says
what the figure actually saw and nothing more is claimed.
A terminal producer, not a pipeline stage. A correlation is one row per pair,
a series summary one row per metric, a comparison one row per column — none of which is
one row per group, so there is no position in
unnest → where → bucket → groupBy → select → sort →
limit for statistics to occupy. It replaces the pipeline, exactly as
profile does. Those keys are now ignored with a warning that names
them rather than discarded in silence, for both producers. Sort, filter or limit the
derived grid itself, or chain a second derived grid whose from is this one.
profile and statistics are mutually exclusive and declaring both is
refused, by name, when the source is built.
What a terminal producer costs
No terminal producer patches incrementally — know this before you point one at a
live feed. The grouped pipeline maintains its grouping across changes: an edit that
names the rows it touched re-reduces only the groups those rows belong to. A producer has no
grouping to maintain, so every change on the parent re-derives its whole output. This
has always been true of profile and was not previously written down; it is written
down here now, and it applies to statistics in the same way.
Measured on a synthetic 200,000-row grid, five changes after a warm-up, one derived grid
attached (machine-dependent — run node bench/derived-producers.mjs against
your own shape):
| Derived shape | Per change at 200k rows |
|---|---|
grouped, no where — the patching pipeline | ~1 ms |
profile, one column | ~20 ms |
statistics correlation, 2 columns (1 pair) | ~5 ms |
statistics correlation, 6 columns (15 pairs) | ~32 ms |
statistics series | ~23 ms |
statistics datasetVsDataset, two grids | ~36 ms |
where, no groupBy — a pipeline shape that also never patches | ~700 ms |
Correlation is quadratic in its column count. It scans the rows once per pair, so N columns cost N·(N−1)/2 passes: six columns is fifteen passes, twenty columns is a hundred and ninety. Correlate the columns you mean rather than every numeric column you have.
The costs of several panels add up. Every derived grid attached to a parent re-derives on the same change, so three analysis panels over one grid cost the sum of the three, not the largest. This is BACKLOG-0001044's known gap — a hidden derived grid still does full read and compute work — with a larger constant behind it; a hidden analysis tab recomputing a correlation matrix on every tick is exactly that cost.
The escape hatch is refresh, and it already exists.
'idle' is the default and coalesces a burst of changes into one derivation on the
next frame; a number is a debounce in milliseconds; 'live'
derives on every change and is the one to avoid for an expensive analysis over a ticking feed;
'manual' stops automatic derivation entirely, leaving the host to drive the
source: call rows.load() on the derived grid, with no argument, whenever the
analysis should be brought up to date — from a Refresh button, when its tab is shown, or
on a timer of your own. Each call re-derives once, at the cost in the table above;
a frozen panel refreshed on a button press is executed
above. Below a few tens of thousands of rows none of this matters.
Cross-filtering
A derived panel can filter the grid it summarises. Click a region in the summary and the detail grid narrows to it.
Click to filter, click again to release
source: {
mode: 'derived',
from: detail,
groupBy: 'region',
select: { total: { of: 'capacity', fn: 'sum' } },
crossFilter: true, // or a source column name
}
summary.events.on('rowClick', (e) => summary.crossFilter.toggle(e.key));
crossFilter.set, toggle, clear, get and
column make up the API. true filters through whatever the grid
groups by; a string names a different source column when the two do not share a name.
A panel does not filter itself. The filter a summary pushes onto its source is excluded when that same summary re-derives. Without that, clicking one region collapses the panel to the single row you just clicked, and there is nothing left to click next. With it, the panel keeps its full set of regions with the chosen one marked, which is what makes a second click possible at all.
Several panels compose. Each pushes its own filter onto the shared source and each excludes only its own, so region and status narrow the detail together while both panels stay navigable.
Joining two grids
Two grids holding their own data, and a third showing where they meet. Both sides stay live.
Bringing an owner's fields across
source: {
mode: 'derived',
from: sites,
join: {
with: owners,
on: { left: 'ownerId', right: 'id' },
type: 'left',
select: ['name', 'tier'],
prefix: 'owner', // owner.name, owner.tier
},
}
| Option | Does |
|---|---|
| on | One field name when both sides use it, or { left, right } when they differ. |
| type | inner keeps only rows that matched; left keeps them all. |
| select | Which of the partner's fields to bring across. All of them by default. |
| prefix | Renames the brought-across fields, for when both sides have a name worth keeping. |
| follow | Which of the partner's rows to read: all by default, or filtered. |
A left join is usually the one you want. An inner join quietly drops the
rows that did not match, and those are often the finding: the site with no owner, the payment
with no invoice. left keeps them visible with the partner's fields empty, so the
gap is something you can see and sort by rather than something you have to notice is
missing.
First match wins. The join is a lookup, not a cross product: a row on the left produces exactly one row out, so a grid of ten thousand rows stays a grid of ten thousand rows and cannot silently multiply.
Both sides are live. A change on either grid updates the join, and it is patched from whichever side changed rather than rebuilt.
Editing
Turning it on
edit: { enabled: true }, // double-click, the default
edit: { enabled: true, start: 'single' },// single click
editBar: true, // a spreadsheet-style input above the grid
columns: [
{ field: 'notes', edit: true },
{ field: 'statusId', edit: { editor: 'select' } },
{ field: 'quality', edit: { editor: 'rating', props: { max: 5, allowHalf: true } } },
{ field: 'circuitId', edit: false },
]
The editor is chosen from the type unless you name one. Enter commits and steps down, Tab commits and steps across, Escape cancels. A validator can refuse a value:
Validation
{ field: 'capacity', edit: {
validate: (p) => p.value > 0 || 'Capacity must be positive',
}}
Declarative validation
The edit.validate function above is the imperative form. Where the rules are simple
and the same across columns, declare them as data on validation instead
(BACKLOG-0000956): required, min/max,
minLength/maxLength, pattern, oneOf, and a
crossField predicate. Each is checked before the write, riding the
cancellable beforeEdit event: a failing value cancels the commit, marks the cell
with the same accessible invalid state an editor rejection uses, and fires
validation:failed. Correcting the value clears the mark and fires
validation:cleared. Only a user edit is gated — a host API write is the authority
and is never self-vetoed.
Rules as data
columns: [
{ field: 'name', edit: true, validation: { required: true, minLength: 2 } },
{ field: 'age', type: 'number', edit: true, validation: { min: 0, max: 120 } },
{ field: 'code', edit: true, validation: { pattern: '^[A-Z]{3}$', messages: { pattern: 'Three capitals.' } } },
]
// Why a write was refused, and clearing a mark by hand.
grid.validation.errorFor('r1', 'age'); // { code, message, key, colId } or null
grid.on('validation:failed', (e) => report(e.failures));
grid.on('validation:cleared', () => refreshBanner());
Deleting rows
Set rowDelete: true for the built-in delete gesture: Delete or Backspace on the
selected rows, and a "Delete row" item in the cell menu. It works on a memory grid, not only a
remote one, and every deletion flows through the cancellable beforeDelete event —
so a confirm dialog is a handler that calls e.preventDefault(reason). It is off by
default because deleting data on a keystroke is destructive.
Confirm before delete
const grid = createGrid(el, { columns, rows, rowKey: 'id', selection: 'multiple', rowDelete: true });
grid.on('beforeDelete', async (e) => {
const ok = await confirmDialog(`Delete ${e.rows.length} row(s)?`);
if (!ok) e.preventDefault('cancelled'); // the rows stay; delete:cancelled fires
});
// The gesture calls this; you can call it too. Defaults to the selection.
grid.edit.deleteRows();
Grid lines and corners
Two settings that change how the grid is drawn rather than what it draws.
Rules between cells, and rounded corners
createGrid(element, {
columns, rows,
gridLines: 'both', // 'horizontal' (default), 'vertical', 'both', 'none'
cornerRadius: true, // or a number of pixels, or a CSS length
});
gridLines chooses which rules are drawn between cells. 'horizontal'
is the default and is what the grid has always drawn; vertical rules between body cells are
additive, so the default is unchanged and nothing moves on upgrade. 'rows' and
'columns' are accepted as aliases. Only the rules between data are affected: the
header underline and the seams beside pinned columns are structure rather than decoration, and
removing them would make the pinned regions look detached.
cornerRadius rounds the grid's outer corners: true adopts the theme's
own radius, a number is a count of pixels, and a string is used as written, so
'0 0 8px 8px' rounds only the bottom. The body is clipped to match, so a row
scrolling past a rounded corner is cut by it rather than squaring it off.
Zebra striping (opt-in)
createGrid(element, {
columns, rows,
stripedRows: true, // shade alternate data rows; off by default
});
stripedRows shades every other data row. It is strictly opt-in and off by default,
so a grid that never mentions it looks exactly as it did on upgrade. Parity is decided by each
row's logical index rather than its position in the DOM: rows are virtualised and
recycled, so a :nth-child rule would repaint the stripe onto whichever row landed
in an odd slot after a scroll, and a logical-index stripe keeps a row shaded consistently across
a scroll and across the left-pinned, centre and right-pinned segments of the same row. Group
headings, group footers and the grand total are structure rather than data, so they are never
striped. The stripe uses the theme's --lattice-surface-alt token, which every
palette defines, so dark, high-contrast and terminal are correct without any extra rule, and
both selection and hover still win over it.
Vertical alignment
verticalAlign is the vertical counterpart to the per-column align:
where align places cell content across the column (start,
center, end), verticalAlign places it down the row —
'top', 'middle' or 'bottom'. It is a grid-level default
with a per-column override: set it on the grid to align every column, and a column's own
verticalAlign (or cell.verticalAlign) wins for that column alone.
center and centre are accepted as synonyms for middle,
the same leniency align gives the horizontal names.
Omitted, the grid keeps the placement it has always had — content centred in a
fixed-height row and top-aligned in an autoHeight row — so a grid that never
mentions it is unchanged on upgrade. Where it earns its keep is a tall or autoHeight
grid: a wrapped-text column can sit at the top while its single-line neighbours are
middle, rather than every value floating in the middle of a tall row. The value
beats the auto-height rule, so a column asked to sit middle does so even when the
row is stretched to fit a wrapped sibling (BACKLOG-0000989).
A grid default, with one column overriding it
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const grid = createHeadlessGrid({
verticalAlign: 'middle', // the default for every column
columns: [
{ field: 'name' }, // follows the default: middle
{ field: 'note', verticalAlign: 'bottom' }, // this column overrides it
],
rows: [{ name: 'a', note: 'b' }],
rowKey: 'name',
});
// The resolved value the renderer reads for each column.
const a = grid.columns.get('name').verticalAlign;
const b = grid.columns.get('note').verticalAlign;
grid.destroy();
return `${a}, ${b}`;
Rich cell tooltips
A cell.tooltip string becomes the browser's own title. That is one
line of plain text, shown on the browser's schedule, styled by the browser, and unreachable
with a keyboard — it cannot show a related record, a small chart, a list of validation
errors or an edit history. The object form of cell.tooltip declares a tooltip the
grid draws itself instead (BACKLOG-0001204): { render, mount, unmount }. The
plain-text form is unchanged and still produces a title.
render(params) may return an element, a { title, rows, note } spec the
grid renders as text, a { html } wrapper, or a string. A bare string is
always text, never markup. That is deliberate and is not a style choice: the most
natural tooltip anyone writes is render: (p) => p.value, and a value is row data
— so if a bare string were markup, a field holding an onerror attribute would
execute while the code that rendered it looked harmless. Markup has to be asked for explicitly,
in the source, where review can see it; and what goes through { html } is scrubbed
of script the same way allowUnsafeTemplates output is.
mount(el, params) and unmount(el) hold live content. The grid core
never imports a module, so a sparkline or a KPI tile is mounted by you, inside
mount, from a module bundle your page loaded. unmount runs on every
close, so nothing is left running behind a hidden tooltip.
tooltip: { delay, maxWidth } on the grid carries the defaults. delay
is the rest before anything is built — 400ms by default, which is what stops a pointer
sweeping across the grid from mounting a chart per cell — and maxWidth caps
the width (a number is pixels, a string is used as written). Neither switches tooltips on: a
column with no cell.tooltip has none.
Keyboard and assistive technology. Focusing a cell shows the same tooltip after
the same delay, and the cell carries aria-describedby pointing at it, so the
content is announced rather than merely drawn. Any aria-describedby the cell
already had — a validation message, for instance — is preserved and restored, not
replaced. The tooltip is hoverable and stays open while the pointer rests on it, and Escape
dismisses it without moving the pointer (WCAG 2.2 AA, 1.4.13). Escape is consumed only while a
tooltip is open, so an editor, a menu or a maximised grid still sees it otherwise.
Pooled rows. The tooltip closes on scroll, and its content is resolved from the DOM at the moment it opens rather than when the pointer arrived. Both follow from the same fact: rows and cells are recycled as the grid scrolls, so a bubble left open would be anchored to a node that has since been handed to a different row. It can therefore never show one row's content over another's.
Always-visible and grid-drawn scrollbars
Native scrollbars are overlay bars on most platforms now: they fade away when the pointer is
idle, which reads as a cleaner surface but hides the affordance — a touchpad user has no
standing sign that a grid scrolls at all. scrollbars takes three modes.
'auto' (the default) leaves the platform's own behaviour alone, so an existing
grid is unchanged on upgrade. 'always' keeps the platform's bar shown whether or
not the pointer is over the grid. 'custom' replaces it with a bar the grid draws
itself.
The two axes are separate decisions, so the object form controls each on its own:
{ y: 'always' } pins the vertical bar while the horizontal one stays native, and
{ x: 'always', y: 'always' } is the same as the bare 'always'. Under
'always' the pinned axis switches to overflow: scroll so its track is
present even when the content fits, and the scrollbar is styled as a classic, always-drawn bar
rather than a fading overlay (BACKLOG-0000990). The same viewport also suppresses the
overscroll rubber-band bounce, so a synchronised grid does not spring at its scroll boundary
(BACKLOG-0000991).
Why 'custom' exists, and when to reach for it
'always' pins the native bar, which leaves two things it cannot fix. Its
size is still the platform's — on Chrome/macOS a 7 pixel overlay ribbon, small to
see and fiddly to grab. And the rules that style it are a WebKit/Blink extension, so Firefox
ignores them: 'always' is not the same feature there. 'custom' makes
the grid draw the bar, so its thickness, colour, minimum thumb length and hit area are the
same in every browser on every platform, and all of them are theme tokens a host can raise.
The default is a 12 pixel track with a 32 pixel minimum thumb, which is a comfortably
larger target than the platform's own (BACKLOG-0001288).
Scrolling is unchanged. The drawn bars are display and input only: the grid's
body still scrolls itself, so the wheel, the trackpad, a finger, the keyboard and
scrollToRow behave exactly as they do in the other two modes, with the browser's
own momentum and acceleration. The thumb is a readout of the scroll position that happens to
be draggable, and it is placed in the same painted frame as the content, so it never trails
what is on screen.
Everything a scrollbar does. The thumb's length is the fraction of the content
on screen, never shorter than --lattice-scrollbar-thumb-min; dragging it scrolls;
pressing the track above or below it pages by one viewport; and with the bar focused the
arrows, Page Up/Page Down, Home and End
all work. Each bar carries role="scrollbar" with
aria-orientation, aria-controls and
aria-valuenow, and its accessible name comes from the message catalogue, so it is
announced in the grid's own language. It is deliberately not in the page's tab order:
the grid is a single tab stop and its own arrow keys already scroll.
Two things to know. The gutter the drawn bar occupies is reserved permanently
while the mode is on — the same 12 pixels 'always' reserves, so column
widths and columns.fit() are unaffected by the change and nothing moves under the
pointer as rows come and go. And no browser lets one axis' native scrollbar be hidden on its
own, so setting 'custom' on one axis hides the native bar on both; the
grid warns once if the two axes disagree. Set 'custom' on both axes, or on
neither.
Theming the drawn bar
| Token | Default | What it sets |
|---|---|---|
--lattice-scrollbar-size | 12px | The thickness of each bar, and the gutter reserved for it. Raise it for a larger grab target. |
--lattice-scrollbar-thumb-min | 32px | The shortest the thumb may ever be. Sized strictly in proportion, a very long list gives a thumb of a pixel or two. |
--lattice-scrollbar-track | transparent | The track behind the thumb. |
--lattice-scrollbar-thumb | var(--lattice-border-strong) | The thumb at rest. Derived from the theme's line colour, so dark, high-contrast and terminal themes follow without declaring anything. |
--lattice-scrollbar-thumb-hover | var(--lattice-foreground-muted) | The thumb while the pointer is over its bar. |
--lattice-scrollbar-thumb-active | var(--lattice-accent) | The thumb while it is being dragged, or while the bar has keyboard focus. |
--lattice-scrollbar-radius | var(--lattice-radius-pill) | The thumb's corner radius. Set it to 0 for a square thumb. |
The three modes, and one axis at a time
// The renderer resolves `scrollbars` to a per-axis mode it stamps on the root.
const { resolveScrollbars } = await import('../packages/dom/src/renderer/renderer.js');
const both = resolveScrollbars('always'); // pins both native bars
const drawn = resolveScrollbars('custom'); // the grid draws both
const onlyY = resolveScrollbars({ y: 'always' }); // pins the vertical bar only
const mixed = resolveScrollbars({ x: 'custom' }); // draws x, leaves y native (and warns)
return `always: ${both.x}/${both.y}; custom: ${drawn.x}/${drawn.y}; `
+ `y-only: ${onlyY.x}/${onlyY.y}; mixed: ${mixed.x}/${mixed.y}`;
A bigger grab target than the platform's
createGrid(host, {
columns, rows,
// Always visible, the same in every browser, and drawn by the grid.
scrollbars: 'custom',
});
/* Wider and darker than the default, in your own stylesheet: */
.lattice {
--lattice-scrollbar-size: 16px;
--lattice-scrollbar-thumb-min: 48px;
--lattice-scrollbar-thumb: #8a9199;
}
Cards, lists and feeds
rowTemplate draws each row with a layout of your own instead of dividing it into
columns. A card list, a feed, a search-result list, a message list: any presentation where a
record is a small piece of layout rather than a line of cells.
Two shorthands sit on top of it for the shapes that recur. recordCard presents each
row as a record card — a form of label/value pairs, one line per column in display order,
showing the same text the table shows — for a screen where reading one record matters more
than comparing many. gallery presents the rows as a grid of tiles laid out by the
same 2-D virtualisation the grid already runs. Both take true to generate the layout
from the columns, or an object to size it or supply a template, and both are presentation only:
sort, filter, group and the data pipeline are unchanged.
board is the third shape: a kanban of grouped lanes of cards. Group the grid and the
top-level group becomes a lane, with every leaf under it a card stacked in that lane — a
pipeline by stage, a task list by status, a backlog by owner. Both axes are virtualised, the lanes
across and the cards down each, so a board of many long lanes draws only what is on screen. A board
card is still a row: it clicks, selects and drags through the grid's own handlers, and masks
protected columns exactly as every other card does. Like the others it is presentation only.
pivotView is the fourth shape: the grid drawn as a pivot — a cross-tab
matrix. The grid's group dimensions run down the left gutter, its pivot
dimensions run across the top, and each totalled column fills a cell with its reduction, with a
subtotal down every row, across every column, and the grand total in the corner. Those numbers are
the grid's own: every cell is the same aggregate kernel the totals row uses, run over the rows that
feed the cell, so a pivot subtotal equals the grid's group total for that set by construction rather
than being re-derived — an average subtotal is the average of the rows, never an average of
cell averages. Both axes expand and collapse and both are virtualised through the shared row-template
layer, so a wide, deep matrix draws only the cells on screen. Clicking a body cell emits
pivot:drill with the keys of the contributing rows, the pivot's answer to “what is
behind this number”. The collapse state rides in a saved view, and because a matrix cannot be
read on a phone, at or below maxWidth the pivot degrades to a card list, exactly as the
table does under responsive. Like the others it is presentation only.
The shorthands, and the config a grid reports back
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const grid = createHeadlessGrid({
columns: [{ field: 'name' }, { field: 'owner' }],
rows: [
{ id: '1', name: 'Alpha', owner: 'Ada' },
{ id: '2', name: 'Beta', owner: 'Ben' },
],
rowKey: 'id',
// Each row as a labelled record card…
recordCard: true,
// …or a gallery of size-driven tiles…
gallery: { tileWidth: 240 },
// …or a kanban of grouped lanes. Presentation only.
board: { laneWidth: 300 },
});
const c = grid.config();
return `recordCard ${c.recordCard === true}, gallery ${c.gallery.tileWidth}px, board ${c.board.laneWidth}px`;
A pivot: group down, pivot across, and the drill event a cell click fires
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const grid = createHeadlessGrid({
columns: [{ field: 'region' }, { field: 'product' }, { field: 'amount', type: 'number', total: 'sum' }],
rows: [
{ id: '1', region: 'EMEA', product: 'Widget', amount: 10 },
{ id: '2', region: 'APAC', product: 'Gadget', amount: 5 },
],
rowKey: 'id',
// Draw the grid as a pivot: the group down, the pivot across, a measure per cell.
pivotView: true,
});
grid.columns.group(['region']);
grid.columns.pivot(['product']);
// A body-cell click drills to its rows through this event.
let drills = 0;
grid.on('pivot:drill', () => { drills += 1; });
// The collapse state a saved view carries.
const state = grid.pivotView.state();
return `pivotView ${grid.config().pivotView === true}, collapsed ${state.rowsCollapsed.length}, drills ${drills}`;
A card list
createGrid(element, {
columns, // still declared: they are the data model
rows,
rowKey: 'id',
rowHeight: 64,
rowTemplate: '<p class="title">{{data.name}}</p>'
+ '<p class="sub">{{data.owner}} · {{data.stage}}</p>',
});
The template compiles; it does not call back. There is deliberately no "here is a container, build what you like for this row" hook. That shape is easy to offer and would be used to allocate DOM per row, and at that moment the virtualisation stops paying for itself: quietly, and in a way nobody can attribute to a change. A row template is the same declarative string a cell template is: parsed once, built into real DOM the first time an element is used, and afterwards updated by writing text into the few nodes the bindings own. Scrolling ten thousand records through a hundred pooled cards allocates nothing.
Everything underneath is unchanged. Sorting, filtering, grouping, selection, permissions, redaction, saved views, undo, export and the remote source all apply exactly as they do to a table: only the drawing changes. That is the reason to build a card view on a grid rather than beside one.
A card is still a row. It carries the same row identity a table row does, so
row:clicked and row:dblclicked fire with the same payload, clicking
selects, the context menu opens, and rowReorder works, with the card itself as
the drag handle, since there is no cell to put a grip in. None of that is a second
implementation; it is the same code that serves a table.
It is announced as a list, not a grid. A card has no columns, so the
grid role, which promises columns, gridcell children and a
two-dimensional keyboard model: would misdescribe it completely. The layer is a
list, each card a listitem carrying its position and the size of the
whole set, and the column header is not drawn. role and itemRole
override both, for a presentation that is really a listbox.
Collapsing to cards on a narrow screen
A table on a phone is a compromise however it is styled. responsive presents rows
as cards when the grid is too narrow to be a table honestly, and returns to a table above the
threshold.
One grid, two presentations, chosen by width
createGrid(element, {
columns, rows, rowKey: 'id',
toolPanel: true,
responsive: {
maxWidth: 640, // the default
rowHeight: 64,
template: '<p class="title">{{cell.name}}</p><p>{{cell.owner}}</p>',
},
});
Measured on the container, not the viewport. A media query is the obvious way and the wrong one: a grid inside a 400px panel on a large monitor is narrow, and a grid filling a small tablet is not. The grid already watches its own element for size changes, so the same observer answers this.
The state a user built survives the switch. Rotating a phone must not lose the sort, the filters, the selection or the scroll position, and it does not, it is one grid throughout, and only the drawing changes. An open cell editor is closed, since the cell it belonged to stops existing.
Sorting and filtering need a home when there are no column headings to click,
and the tool panel is it: set toolPanel: true and its rail stays available in card
presentation with the columns and filter panels behind it. Export is unaffected:
the columns are still the data model, so a CSV or an Excel file from a collapsed grid holds
every column, including ones the card does not show.
A collapsed card takes rowHeight from the responsive block rather
than the grid's, because a table row is too short to hold one; 64px by default. The change is
announced, and the role tree follows the presentation. presentation:changed fires
with 'cards' or 'table'.
Showing what the grid shows
{{cell.column}} is the text the table puts in that cell, the column's
own formatter, data type, number and date settings and lookup label, all of it.
{{data.field}} is the raw value underneath.
| Binding | Reads |
|---|---|
| {{cell.value}} | £1,250.50, the cell's rendered text |
| {{data.value}} | 1250.5, the stored number |
| {{cell.stage}} | Held, a lookup's label |
| {{data.stage}} | 2, the lookup's id |
Both are wanted, which is why both exist: a card showing a value to a person wants
cell, and a template comparing or calculating wants data. A lookup is
the case that decides it, a card showing 2 where the table shows
Held is not a formatting preference but a plain bug.
A protected column cannot be read raw. Binding
{{data.password}} on a secret or redacted column would print the value the column
exists to hide, while the table beside it shows dots. Such a binding reads the masked text
instead and says once that it did, a card must not become the hole a redaction closes.
Several cards on a line
By default a card takes a line to itself, which is what a feed or a search-result list wants. A gallery wants tiles, and there are two ways to ask for them because they answer different questions.
A fixed count, and a size that flows
rowTemplate: { template: CARD, cardsPerRow: 3 } // always three across
rowTemplate: { template: CARD, maxCardWidth: 260 } // as many as fit
cardsPerRow is a count, for a layout that must not reflow. maxCardWidth
is a ceiling: the grid fits as many whole cards as it can without exceeding it, and they share
the remaining space rather than leaving a ragged margin, so 900px at a 200px ceiling is four
cards of 225px, and the count changes with the container. gap sets the space
between them. Where both are given, cardsPerRow wins, being an instruction rather
than a preference.
The scroll height counts lines, not records. Four records on a line means the content is a quarter as tall as the row model alone would make it, and a scrollbar sized per record would be four times too long, the last several screens empty. The tiled layout works out its own window from the scroll position for the same reason: the window it would otherwise be handed counts one record per line and would leave the bottom of the screen bare. Pooling is unaffected; scrolling a tiled gallery reuses its elements exactly as a list does.
Tiles are a fixed height, taken from rowHeight,
rowHeight: 'auto' measures a rendered row and cannot describe a line holding
several of different heights. Variable-height tiles flowing into the shortest column is a
masonry layout, which is a different thing and is not offered.
Row heights work as they do everywhere else, including rowHeight: 'auto', which
measures the rendered card: content-driven card heights need no extra configuration. The
columns are still declared and still hold the data: they are what sorting, filtering and
export operate on, and what the bindings read.
Editing a row on a form
Double-clicking a row opens it in a panel (a right-hand drawer or a centred dialog) with one control per field, a Save and a Cancel. It is the shape almost every application built on a grid ends up wanting, and until now the shape they had to build themselves.
The grid's own columns, in a drawer
createGrid(element, {
columns, rows, rowKey: 'id',
editable: true,
rowForm: true,
});
That is the whole of it for the common case: the form is the row, edited with the same
editors, types, formats and lookups the cells use. Where the record has more to it than the
grid shows, give a load function, and then say which fields and in what order,
because nothing in the grid knows the shape of something it has never seen.
A fuller record, in a dialog
rowForm: {
mode: 'dialog', // 'drawer' is the default
title: ({ data }) => 'Edit ' + data.name,
load: ({ key }) => fetch(`/api/orders/${key}`).then((r) => r.json()),
fields: [
{ field: 'name', label: 'Name' },
{ field: 'ref', label: 'Reference' }, // not a column
{ field: 'notes', label: 'Notes', editor: 'textarea' },
{ field: 'score', label: 'Score', editor: 'rating', props: { max: 5 } },
],
}
Which editor a field gets
Every editor is available on a form, including your own from the module registry, the form builds its controls through the same call a cell does, so a field gets the same editor, type, formatting and lookup its column would have given it. A field named after a column borrows that column outright and needs nothing further.
A field the grid has never seen (or one you want entered differently from its cell) says so
on the field itself. editor names it, and type, props
and lookup configure it exactly as they would on a column. Overriding the control
does not change where the value goes: a field still writes back only if it maps to a column.
A picker opens when it is asked to. A popup editor, a date, a dropdown, a tree, a colour, a code panel: is its panel: in a cell it opens the moment the cell does, which is right, because the user has just asked to edit that one cell. A form builds every field at once, so on a form the field shows the current value on a control and the panel opens over it when clicked. Choosing puts the panel away again and updates the control.
The panel opens before the record arrives. A click that does nothing for half a second reads as a click that was missed, and the user clicks again. So the panel appears immediately with a loading state and fills in when the record lands.
A failure keeps the panel open and offers a retry inside it. Closing would discard the intent and leave the user nothing to act on but the row they already double-clicked.
And a load that never answers is a failure too. A promise that neither
resolves nor rejects is what a dropped request looks like from the page; left alone it spins
until the user gives up, which reads as an application that has hung rather than a request
that failed. After timeout milliseconds, two seconds unless you say otherwise,
the form stops waiting and shows the same message and retry as any other failure. Set
timeout: false to wait indefinitely, which is right only where your own loader
already has a limit and would rather report that one. A record that turns up after the form
gave up on it is discarded rather than dropped into a panel the user may have moved on
from.
The fields scroll and the heading and buttons do not, so Save stays reachable on a record with forty fields. If a validator refuses one of them, the form stays open, the field is marked, and it is scrolled into view and focused: on a long form the offending field is otherwise nowhere near the button that was just pressed.
Save collects the changed fields, writes the ones that map to columns, and announces the lot.
Where the record actually lives is not something the grid can know, so persisting is yours: a
field that came from load and is not a column is reported in
unmapped and not written, since inventing a column for it would put data in the
grid that the grid was never asked to show. Save is disabled while there is nothing to save.
| Member | Does |
|---|---|
| form.open(key) | Open a row by key. Returns false if there is no such row. |
| form.close() | Close without saving. |
| form.save() | Commit the fields and close. Returns false if a validator refused, or if there is nothing to save. |
| form.isOpen() | Whether the panel is showing. |
| form:opened | Fired with { key, row }. |
| form:saved | Fired with { key, values, changed, unmapped }. |
| form:closed | Fired with { key }. |
| form:error | Fired with { key, error, timedOut } when a load fails or runs out of time. |
The form takes the double click. On an editable grid that gesture also
opens a cell editor, and the two cannot both own it, a form that quietly did nothing where a
cell happened to be editable would be worse than no form. So where rowForm is
configured, double-clicking a row opens the form and the cell editor stays reachable by
Enter or by typing into the cell. Set trigger: false to leave opening entirely to
form.open() and keep double-click for cells.
Putting the form in your own element
A drawer and a dialog both sit over the grid. Give container an element of your
own and the form is built there instead, a sidebar beside the grid, a panel below it, a
column in a layout you already have. It fills what it is given, so the size and position are
yours.
A sidebar the application owns
createGrid(element, {
columns, rows, rowKey: 'id', editable: true,
rowForm: { container: '#record-panel' }, // or the element itself, or a function
});
A selector is resolved when the form opens, not when the grid is configured, because a grid is routinely built before the layout around it exists. A container that cannot be found falls back to opening over the grid: better a form in the wrong place than a double-click that appears to do nothing.
A form in your own container is not modal. It sits beside the grid rather than over it, so it takes nothing away: it is announced as a region rather than a dialog, and Tab moves out of it into the rest of your page instead of being trapped. Claiming otherwise would tell a screen reader user the page had gone away when it plainly has not. Escape still closes it, and it still takes focus when it opens.
Over the grid, the panel is a modal dialog: it takes focus when it opens, traps Tab while it
is showing, closes on Escape, and returns focus to whatever had it before. Its width can be
set with width.
Optimistic writes and rollback
The grid has always written optimistically without calling it that: an edit lands in the
model and is painted before anything else happens. What edit.commit adds is
durability: whether the write reached your server, and what to put back when it
did not.
Nothing changes unless you ask for it. With no commit hook
the grid behaves exactly as before: the value is written, history is recorded,
cell:changed fires, and there is no pending state to think about. Subscribe to
cell:changed, fire your request and ignore the result: that keeps working and
costs nothing.
The usual case, the promise is the answer
edit: {
enabled: true,
commit: async ({ key, colId, value }) => {
const res = await fetch(`/api/rows/${key}`, {
method: 'PATCH',
body: JSON.stringify({ [colId]: value }),
});
if (!res.ok) throw new Error(await res.text()); // throw → rolled back
},
}
Resolving confirms the write; throwing rolls it back and fires cell:reverted
with your error message as reason. A synchronous hook works too: returning
normally confirms, throwing reverts.
When the answer arrives elsewhere
edit: {
enabled: true,
confirm: 'manual', // the return value is ignored
commit: ({ id, key, colId, value }) => {
socket.send(JSON.stringify({ id, key, colId, value }));
},
}
socket.onmessage = (m) => {
const { id, ok, reason } = JSON.parse(m.data);
grid.edit.settle(id, ok, reason);
};
The mode is declared, never guessed. A websocket or event-sourced backend
acknowledges on a different channel from the one the write went out on, so there is no
promise to resolve. confirm: 'manual' says so explicitly. The grid does not infer
it from what commit returns, because then a synchronous hook that happens to
return nothing would leave every cell pending for ever with nothing in your code that looks
wrong. If a write does stay pending, you get a console warning naming the cell: tune the
threshold with pendingTimeout.
The states a write moves through
| State | Means | Event |
|---|---|---|
| pending | Applied and painted, not yet acknowledged. | cell:pending |
| confirmed | The server accepted it. Nothing is written back. | cell:confirmed |
| reverted | The server refused it; the cell is rolled back. | cell:reverted |
| conflict | The write was accepted but the server row had moved underneath it. Last-write-wins: your value stands and the server's truth is surfaced so you can reconcile it. | cell:conflict |
| superseded | A newer edit replaced it while it was in flight. | either, with superseded: true |
Rollback goes to the last confirmed value, not the previous one. This is
the part that is easy to get wrong by hand. Suppose a cell holding 1 is edited to
2, then to 3, then to 4, all before any answer comes
back. If the second write fails, restoring “the value before it” would put back
2, a value the server never held, and one the user has since replaced twice.
So each cell remembers the newest value a confirmation has actually vouched for, and a write
that a later edit has superseded reports its failure without writing anything back. You will
see cell:reverted with applied: false for those.
The rejected value travels on the event as rejected, so you can offer a retry
rather than losing what the user typed.
Two behaviours worth knowing. An unconfirmed edit goes through the normal pipeline, so if it changes a sorted or filtered column the row moves immediately and moves back if the write fails. And undo of an in-flight edit issues a compensating write, a fresh write back to the previous value, itself tracked: rather than pretending to cancel a request that has already gone out.
Asking what is outstanding
grid.edit.pending(); // [{ id, key, colId, value, before, state, age }]
grid.edit.status('r1', 'cap'); // 'pending' | null
Pending cells are marked with --lattice-pending-background and rolled-back ones
flash --lattice-reverted-background; restyle either through the tokens. Both use
the same highlight model as everything else, so the marks survive scrolling, sorting and row
recycling.
Tree data
Rows that sit under one another rather than in a flat list. Two shapes, and they answer different questions about where the hierarchy lives.
The row names its parent
tree: {
parentKey: 'parentId', // a field, or a function of the row
label: 'name', // what the tree column shows
orphans: 'Unassigned', // or 'root', the default
}
The row carries its own ancestry
tree: { path: (row) => row.hierarchy } // ['EMEA', 'UK', 'Colchester']
Parent-reference is what a join or a document store produces. Every node is a real row. A row whose parent is not in the data (filtered away, not loaded, or simply wrong) is an orphan: it goes to the root by default, or into a named bucket. It is never dropped, because hiding a record over a bad reference loses data the user can see in a flat view.
Path-based rows describe their own place, so intermediate levels may have no
row at all: EMEA/UK/Colchester with no EMEA/UK row still needs a
UK node to sit under. Those are synthesised, and render as group rows: a heading
over the rows beneath it with no record of its own. A real row arriving later for a level
already synthesised fills that node rather than appearing beside it.
A parent cycle is not a hierarchy and cannot be walked. It is reported once and cut, with the rows shown at the root: wrong place beats vanished.
The grid generates a tree column to carry the expander and the indent, on the
same terms as the auto-group, selection and detail columns: pinned to the start, and absent
from columns.visible(), saved views, exports and the tool panel. Its text comes
from tree.label; without one it falls back to your first visible column, which
then appears twice until you hide it, the grid does not remove a column you did not ask it
to remove.
Loading a branch on demand
Children fetched when the node is opened
tree: {
parentKey: 'parentId',
hasChildren: (data) => data.childCount > 0,
loadChildren: (row, signal) => api.children(row.data.id, { signal }),
}
hasChildren lets a row declare children it does not hold, so the expander is
there before anything is fetched: without it there is nothing to click and the branch can
never load. Such a node reads as closed even though tree nodes are otherwise expanded
by default, because an open branch with nothing under it leaves no gesture to load it.
The rows that arrive are added to the data set, so the hierarchy rebuilds through the same
pipeline as everything else and the new rows sort, filter and export like any other. A branch
is fetched once however often it is toggled; closing it before the rows arrive aborts the
request through the signal. A rejection is reported and leaves the branch
unloaded, so reopening tries again rather than showing an empty node for good.
tree:loading, tree:loaded and tree:loadFailed are on the
event bus.
Expansion is the same state group expansion uses, so rows.expand,
rows.collapse, expandAll, collapseAll and saved views
all work on it. A collapsed branch is skipped rather than hidden, so it costs nothing.
Grouping and tree together is not a combination: the grouping wins and says so
once, because two expanders in one row would be two hierarchies claiming the same rows.
Master-detail
A master row expands to reveal a detail region: by default a nested grid over whatever
detail.rows(row) returns, which may be a promise. The grid adds an expander
column while the feature is on, on the same terms as the auto-group and selection columns:
pinned to the start, and absent from columns.visible(), saved views, exports and
the tool panel.
Inline, a detail row beneath its master
detail: {
rows: (row) => api.lines(row.data.id), // array or promise
config: { columns: [{ field: 'port' }, { field: 'vlan' }] },
height: 240,
isMaster: (data) => data.lineCount > 0, // default: every data row
cacheLimit: 10,
}
grid.detail.toggle(key);
grid.detail.keys(); // every open master
grid.detail.closeAll();
The detail is a real display row: virtualised, height-managed, and pushing the rows below it
down. Any number of masters can be open at once. height takes a number or a
function of the row.
A detail pane instead of a detail row
Targeted, the list-and-pane layout
detail: {
target: '#detail-pane', // a selector or an element
rows: (row) => api.lines(row.data.id),
config: { columns: [{ field: 'port' }, { field: 'vlan' }] },
}
grid.detail.active(); // the open master, or null
grid.detail.placement(); // 'inline' | 'target' | null
With target the detail renders into an element you own rather than into a row.
No detail row is created, so the grid's row count does not change when a master opens, and
nothing about the list's geometry moves.
Exactly one master is open at a time in this placement. One element cannot
show two details, and stacking them turns a fixed-height pane into a scrolling list of grids
with no rule for how tall each should be. Expanding a second master closes the first;
height is ignored, because the pane's height is yours.
A target selector that matches no element is reported once and leaves the
details unshown: silence there is indistinguishable from a detail that fails to open, and
the cause is not visible from the grid.
The control changes with the placement, because the gesture does. Inline it
is a chevron that turns down when the row expands, carrying aria-expanded: the
ordinary disclosure pattern. Targeted, nothing expands: the row is being chosen and its
detail appears elsewhere, so the control becomes the “opens elsewhere” glyph and
a toggle (aria-pressed) rather than a disclosure. A chevron there would promise
an expansion that never comes. The chosen row is marked with
lat-row--detail-active and aria-current, since with the detail off
to the side nothing else in the grid says which record the pane belongs to.
An editable detail
The detail is a whole grid, so it edits like one: put edit in
detail.config and its cells are editable. The rows it shows are usually a
sub-array of the master's own record, so an edit there changes the master's data directly;
there is nothing to copy back.
One listener on the master covers every detail
detail: {
rows: (row) => row.data.ports, // a sub-array of the record
config: {
columns: [{ field: 'port' }, { field: 'vlan', type: 'number', edit: true }],
edit: { enabled: true },
},
}
grid.on('detail:cell:changed', (e) => {
e.masterKey; // 'C1', the row the detail belongs to
e.path; // 'ports.1.vlan': where it lands on the master's record
e.value; // 999
e.oldValue; // 101
});
A nested grid is created by the grid, not by you, so its own events would otherwise be out of
reach. The edit lifecycle (detail:edit:started,
detail:edit:stopped, detail:cell:changed) is re-emitted on the
master, tagged with the master it came from. You never have to hold the nested grid to hear
about an edit inside it.
path is the dot notation from the master's record to the value that changed, so
a host can persist a detail edit against the master and never think about the nested grid at
all. It is worked out by identity: rows(row) usually returns an array that is
already a property of the record, and that property is the prefix. A detail fetched from a
server is not part of the master's record, so its path is null,
set detail.path to name it yourself when you want one anyway.
For anything the forwarded events do not cover, detail.onCreate(grid, masterRow)
hands you the nested grid itself as it is built.
| Setting | What it does |
|---|---|
rows(row) | The nested grid's rows. May return a promise; the region is built empty and loaded when it settles, so an empty detail and a pending one do not look the same. |
config | The nested grid's configuration. It inherits the licence and module registry from the master by construction. |
render(container, row) | Draw the region yourself instead of a nested grid. Return anything with a destroy() method. |
isMaster(data, row) | Which rows can expand. Group rows and detail rows never can. |
target | A selector or element to render the detail into. Omit for inline. One master open at a time. |
onCreate(grid, row) | The nested grid, as it is created. |
path | The property of the master's record the detail rows live on, when it cannot be worked out by identity. |
cacheLimit | How many regions are retained after closing, so collapse and re-expand does not refetch. Open regions are never evicted, whatever the limit. 0 destroys on collapse. Default 10. |
Selection and ranges
Row selection and cell ranges are separate answers to separate questions, which records versus which values. Dragging across cells does not tick row checkboxes, and selecting rows does not build a range.
Cell ranges are on by default; row selection is not. Set
selection: 'single' or 'multiple' to turn rows on: until you do,
selection.set() accepts the call and keys() comes back empty.
Both
selection: 'multiple' // rows are off until you ask
// Rows, which records
grid.selection.set(['r1', 'r2']);
grid.selection.keys(); // the selected row keys
grid.selection.rows(); // the row wrappers
grid.selection.all(); // select everything that passes the filter
grid.selection.clear();
// Cells, which values
grid.selection.setRange({ startRow: 0, endRow: 9, columns: ['cap', 'margin'] });
grid.selection.cells(); // [{ key, colId }, …]
grid.selection.summary(); // count, sum, min, max, avg over the range
grid.selection.clearRange();
// Several blocks at once: ctrl-click, Ctrl+Shift+Arrow, or from the API
grid.selection.addRange({ startRow: 20, endRow: 29, columns: ['cap'] });
grid.selection.extendRange(34, 'cap'); // grows the block just added
The checkbox column
A column of checkboxes, with select-all in the header
selection: { mode: 'multiple', checkbox: true, headerCheckbox: true }
checkbox: true adds a narrow column of checkboxes at the start of every row,
pinned so it does not scroll away. headerCheckbox: true puts a select-all box in
its heading, which shows three states: unchecked when nothing is selected, checked when
everything is, and the native indeterminate mark when some are. Clicking it selects
everything when it is not already full, and clears when it is: including from the
indeterminate state, where the intent is "select the rest".
"Everything" means every row the filter currently shows, not every row loaded. The column is
generated rather than declared: it does not appear in columns.visible(), in a
saved view, in an export or in the tool panel's visibility list, and it disappears when the
option is turned off. grid.selection.headerState() returns the same tri-state the
header shows, for building your own control.
Selected rows carry aria-selected and the class
lat-row--selected, which the theme styles.
A row with its own click action
selection: { mode: 'multiple', checkbox: true, checkboxOnly: true }
checkboxOnly: true restricts row selection to the checkbox column:
clicking the checkbox selects or deselects the row, and clicking anywhere else in the
row does neither. This is for a host that binds its own action — typically opening a
record's detail view — to a plain click on the row: without it, that click also
selects the row, and a bulk action run afterwards operates on rows the user never
chose to select. The same restriction applies to the keyboard: Space still toggles
selection while focus is on the checkbox cell, and does nothing elsewhere. Cell ranges
and the fill handle are unaffected either way. Off by default, so a plain click still
selects a row exactly as it always has.
checkboxOnly only narrows which gesture may change selection; it does not
grant selection where mode: 'none' has already refused it, and it composes
normally with mode: 'single' — the checkbox remains the only way to change
which one row is selected.
With mode: 'single', checking a row's box selects that row and replaces
whichever one was selected before; unchecking the selected row clears the selection.
grid.selection.set(keys) itself keeps only the first key of whatever
array it is given when mode is 'single' — that contract is
unchanged (BACKLOG-0001233) — so the checkbox column never builds a two-key array to hand
it; it sets the one key it just toggled.
Ctrl+Shift+Arrow is the keyboard form of ctrl-dragging. The first press opens a block at the focused cell without discarding what is already selected; the presses after it extend that block, so holding the chord draws one rectangle rather than a new one per key repeat. A plain Shift+Arrow goes back to extending a single block, and clicking anywhere ends the run.
What multiple ranges do and do not support. Painting, cells()
and the status-bar summary all work over the union of the selected blocks. Copy is narrower,
because tab-separated text is a rectangle: blocks stacked over the same columns, or joined
over the same rows, copy fine, a diagonal pair has no rectangular form, so
rangeText() returns '' and clipboard:copy reports
reason: 'discontiguous' rather than emitting misaligned rows. Filling is
narrower still: with more than one block selected there is no single source to extend, so the
fill handle is hidden and fillTo and fillDown decline.
Turning it off
selection: 'none' // neither
selection: { ranges: false } // rows only, no drag-select
Filling a series
Dragging the fill handle continues what it can recognise rather than repeating the block. Detection is per column, so dragging three columns down continues three independent series.
What is recognised
1, 2, 3 // → 4, 5, 6 constant difference, two or more values
5, 10, 15 // → 20, 25 any step, including negative and fractional
2026-01-01 … // → the next day, week or whatever the gap is
15 Jan, 15 Feb // → 15 Mar whole months, holding the day of month
31 Jan, 28 Feb // → 31 Mar, 30 Apr month ends stay on the month end
'x', 'y' // → x, y, x, y nothing recognised, so the block repeats
7 // → 7, 7, 7 one value is a copy, not a series
Months step as months. 15 January to 15 February is thirty-one days, and continuing in days would land on 18 March and drift further every step. A month step holds the date the user picked, and clamps where the month is short: 31 January plus a month is 28 February, not 3 March.
Month ends are their own case. 31 Jan, 28 Feb is a month
series whose second value has already been clamped, so the two share no day of month and the
day-preserving rule cannot see it. Where every source value is the last day of its own month,
the fill stays on the month end: 31 March, 30 April, 31 May, and 29 February in a leap year.
Values that share a day of month keep the day-preserving answer, so 30 Apr, 30 Jun
still gives 30 August rather than the 31st.
One limit worth knowing. Filling upwards is not supported; the handle extends downwards only.
Supplying your own series
selection: {
fill: ({ source, target, direction }) => target.map((t, i) => nextCode(source, i)),
}
A domain series (order codes, fiscal periods, seat numbers) is not something the grid
can infer, so selection.fill takes precedence when you supply it. It must return
one value per target row; anything else is ignored in favour of the built-in detection,
rather than being partly applied.
Holding live updates
A pause button for incoming data. Changes are held and merged while paused, applied when play is pressed, and counted throughout, so the coalescing that makes a live grid fast is finally visible.
Pause, play, and the counters
grid.updates.pause();
grid.updates.resume(); // apply everything held
grid.updates.flush(); // apply what is waiting, stay paused
grid.updates.stats();
// { paused, pending, queued, coalesced, coalescedTotal, rows, dropped, flushes, span }
grid.updates.log({ since: Date.now() - 60000 }); // what arrived, in order
Pausing is a button, not a guess. Inferring it from whether the user looks busy sounds friendlier and is wrong in both directions: too broad and a mouse resting on the grid freezes the feed until someone reloads, too narrow and rows jump the instant somebody stops moving in order to read. An explicit control has no heuristic to get wrong and no invisible state to explain.
Merging continues while paused, so a long pause costs one entry per
changed row rather than one per update. Forty updates to one row are one row of work when
play is pressed, and coalesced is the thirty-nine, which is the number nobody
could see before.
Bounding the rows themselves is separate. The log bounds change
history; a streaming source also needs to bound row retention, or a grid
left up overnight holds every row it was ever sent. Set source.maxRows and the
stream becomes a sliding window, dropping the oldest as new ones arrive and reporting how
many it let go through evicted on the progress report.
A time window, not just a row count
maxRows is a count window. maxAge is a time
window. They are different promises, and a live feed usually wants the second one.
Keep the last five minutes, and show the last five minutes
const grid = createGrid(el, {
columns,
source: {
mode: 'stream',
open,
maxAge: 5 * 60 * 1000, // keep five minutes of rows
ageBy: 'ts', // ...aged by this column; omit for arrival time
maxRows: 20000, // ...and never more than this many, whichever bites first
},
});
createChart({
grid, container, type: 'line', x: 'ts', y: { col: 'value', fn: 'avg' },
// The x domain is the last five minutes ending *now*, so the chart
// keeps scrolling left even while the feed is silent.
axis: { x: { window: { kind: 'time', span: 5 * 60 * 1000 } } },
});
A count window drifts, and it freezes. maxRows equals
“the last five minutes” only while the feed rate is steady: a burst silently
shrinks the window to two minutes, a quiet spell stretches it to twenty, and the x axis
changes span under the reader. Worse, when the feed goes quiet nothing is evicted and the
chart stops moving, even though time is still passing — and the silence is usually the
thing worth seeing. maxAge is a span of wall clock, so it means the same thing
whatever the feed is doing.
An empty reduction is a gap, not a zero. fn: 'avg' above
— and sum, mean, min, max,
first and last beside it — read as null when a
bucket carries no rows to reduce (BACKLOG-0001088), so a producer that has stopped sending is
drawn as a break in the line rather than a value dropping to zero, which would read as a real
observation nobody made. count and countValues are the deliberate
exception: they are already honest at zero, a tally of rows or of values actually present, so
reach for countValues when the reading you actually want is “how many
arrived” and zero has to be drawn as zero rather than as a gap.
Two bounds, one eviction path. maxAge and
maxRows are independent and compose: both are applied on the same pass and
whichever bites first is simply the one that drops rows. Neither is silently ignored.
Age eviction reuses the count bound’s machinery outright, so evicted on
the progress report and the stream:evicted event carry age evictions exactly as
they always carried count evictions — an existing “dropped off the back of the
window” readout keeps working with nothing changed.
The span is a bound, not a guillotine. A row lives a little past the span
before it goes, and two things add to that. First the eviction slack, ten per cent of the
span — exactly the overshoot maxRows already allows on its count — so
the row permutation is rebuilt once per block rather than once per arriving row. Second, when
the feed is idle, up to one tick of the eviction timer, which runs at a quarter of the span
clamped to between 50 ms and one second. The real ceiling is therefore about
span × 1.1 + tick, and because the tick has a floor it is
proportionally larger the shorter the window: about 10% over at a five-minute
window, around 1.25× at ten seconds, and as much as ~1.35× at three. That is the
deliberate price of an idle grid that costs no CPU, and it is why the number to reach for is
the window you want the reader to see rather than a hard retention limit. The chart’s
domain is exact either way — it ends at now — so the extra rows sit off
the left edge rather than being drawn.
Which clock: ageBy, or arrival. Given
ageBy — a column id, a dotted path, or a function of the row returning a
Date, epoch milliseconds or an ISO string — the window follows the
data’s own clock, so it means what the producer means. That also inherits the
producer’s clock skew: if their clock runs five minutes fast, their rows live five
minutes longer than yours. Omit ageBy and rows age from arrival
time, when the row reached the source. Arrival time needs no timestamp column and
cannot be skewed, but it is not event time — a row delayed in transit is treated as
young. A synthetic or metric feed usually wants arrival; a log or event feed usually wants
ageBy. A row whose time value cannot be read is never aged out: dropping data
because a timestamp was malformed is the worse failure.
Out of order is handled, not reordered. With ageBy the
row’s clock need not be monotonic in arrival order, so eviction scans the window rather
than walking the head; a late row that is already older than the span is dropped on the same
pass it arrived on and counted as evicted, rather than being painted and then withdrawn a
moment later. Nothing is re-sorted: a row’s position is still arrival order,
only its retention is decided by its time.
The chart axis rolls independently. The axis takes the same
WindowSpec vocabulary rolling statistics use —
window: { kind: 'time', span } — and its domain ends at now
rather than at the newest point, which is what makes the chart keep scrolling with zero new
rows. It works with or without maxAge on the source; set both to the same span
and the retained data and the drawn domain agree. Only kind: 'time' applies to
an axis: a count window over a chart is the source’s maxRows, and
kind: 'count' is refused with a warning rather than quietly given a second
meaning. The x column has to be continuous and carry wall-clock times — a banded or
categorical axis has no domain to roll.
A producer whose clock runs ahead. A reading stamped slightly ahead of the
viewer’s clock carries the end of the domain forward with it, so the newest mark is
drawn. How far is bounded: a quarter of the span (15 s on a
60 s window). A reading further ahead than that is treated as a producer whose clock is
wrong — it does not move the window, it is not drawn, and the chart warns once, naming
the column, how many readings were left out and how far ahead they were. Without the bound,
one device two minutes fast moved a one-minute window past every other device’s recent
readings and the chart drew a single dot (BACKLOG-0001123). If every reading in the window is
that far ahead the chart shows its empty state, with the same warning.
chart.data().windowed counts the readings dropped at either edge of the
window. The fix for the warning is the producer’s clock, not a wider window.
The other edge: a producer that has simply stopped. The case above is a clock running fast; the opposite is a feed that has gone quiet for longer than the window (BACKLOG-0001088) — every reading is older than the span, so every mark would fall to the left of the domain, off the plot, while the axes and legend keep drawing as if the chart were healthy. Rather than draw that, the chart shows its empty state and warns once per chart instance, naming the span and how old the newest reading actually is, so a dead feed reads as “no data” rather than as a chart that quietly stopped moving. Two charts bound to the same stale column each get their own warning — the key is scoped to the chart, not just the column, so a dashboard of tiled charts sharing one timestamp column does not lose the second warning to the first.
Idle costs nothing. Both halves advance on a plain interval — a
quarter of the window, clamped to between 50 ms and one second — and never on an
animation frame. The source’s wake returns after a single number comparison unless a
row is actually due, and the chart’s wake does nothing at all when the document is
hidden or the chart is detached. Both timers are cleared on destroy. Measured over a
five-minute window holding 50,000 rows with no feed at all, the window’s CPU cost is
inside the run-to-run noise of the same source with no bound set: under 0.04% of one core
(bench/idle-window.mjs).
The log keeps the raw sequence, not the merged one. Merging is right for
applying a backlog quickly and wrong for looking at what happened, because the intermediate
states are exactly what a time scrubber would move between. It survives the flush,
pending is what is waiting, the log is what happened, and it is capped, so a
grid paused over lunch holds the recent past and reports how much it dropped rather than
taking the tab with it.
Data Router: a live WebSocket feed
createDataRouter is built for a continuous live feed — ordering,
de-duplication, batching, resume after a drop, and moving a row between routes rather
than duplicating it are all shipped. What it does not do is open the connection.
The host owns the connection; the router owns everything after the message
arrives. That is a boundary worth stating plainly, because nothing in the
router's own name says so, and the two questions every integrator asks first —
“does it handle the socket?” and “how do I send it data?”
— both turn on it.
Wire a socket's messages to the router's two entry points
const router = createDataRouter({ rowKey: 'id', seq: 'v' });
router.attach(grid, () => true);
const socket = new WebSocket('wss://example.com/feed');
socket.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.kind === 'snapshot') router.load(msg.rows); // full keyed diff
else if (msg.kind === 'delta') router.apply(msg.changes); // in-place add/update/remove
};
Two message kinds, two entry points. A snapshot
(the whole current world, sent once on connect or on resume) goes to
router.load(rows) — a plain array of records; it re-partitions
everything and applies a keyed diff per route, so rows that did not change do
not repaint. A delta (an incremental change) goes to
router.apply(deltas) — an array of { op: 'upsert'|'delete',
row, seq? } — which adds, updates or removes in place by rowKey,
moving a row between routes if its partition changed rather than duplicating it. Getting
this backwards is the mistake this guide exists to prevent: load() on every
message re-partitions the whole world on every tick (correct only for a snapshot);
apply() on the opening snapshot never seeds the store, so every route starts
empty. There is no third method for “a WebSocket message” — the host
reads kind (or whatever field its own wire format uses) and picks one of
these two.
No transport lock-in, and that is a benefit, not a gap. The router
takes rows, never a URL and never a socket object, so a real WebSocket, an
EventSource, a change-data-capture feed, a long-poll loop or an existing
message-bus client all wire up the same way — whatever arrives, hand the router the
snapshot array or the delta array. Nothing in packages/modules/data-router/
constructs a socket, so nothing there needs to change when the transport does.
Ordering, dedupe, and batching a fast feed
A socket is not a clean pipe: messages can arrive reordered, a reconnect can replay something already applied, and a fast feed can out-pace how often a grid should repaint. None of that is handled unless it is configured.
Without seq: a reordered packet wins silently — run and see it happen
const { createDataRouter } = await import('../packages/modules/data-router/index.js');
const rows = new Map();
const sink = { rows: { apply({ add = [], update = [] }) { for (const r of [...add, ...update]) rows.set(r.id, r); } } };
const router = createDataRouter({ rowKey: 'id' }); // no seq configured
router.attach(sink, () => true);
router.load([]);
router.apply([{ op: 'upsert', row: { id: 'x', price: 101 } }]); // the newer event
router.apply([{ op: 'upsert', row: { id: 'x', price: 100 } }]); // arrives late, but wins
return rows.get('x').price; // 100 — the stale packet clobbered the fresh one
With seq (dedupe defaults on): the identical reorder, corrected
const { createDataRouter } = await import('../packages/modules/data-router/index.js');
const rows = new Map();
const sink = { rows: { apply({ add = [], update = [] }) { for (const r of [...add, ...update]) rows.set(r.id, r); } } };
const router = createDataRouter({ rowKey: 'id', seq: 'v' });
router.attach(sink, () => true);
router.load([]);
router.apply([{ op: 'upsert', row: { id: 'x', price: 101, v: 2 } }]);
router.apply([{ op: 'upsert', row: { id: 'x', price: 100, v: 1 } }]); // v:1 ≤ last seen v:2 — dropped
return `${rows.get('x').price} dropped=${router.dropped}`; // 101 dropped=1 — corrected, and counted
Without a seq, two rules to know. Within one
apply() call, last-writer-wins per rowKey — the last
element of the array for a given key is what lands, regardless of which one is
“newer” in real time. Across separate calls (separate socket messages), the
router has no way to tell a late, stale packet from a fresh one, so it applies whatever
arrives, whenever it arrives — an out-of-order delta lands out of order.
Configuring seq (a field name or fn(row)) fixes both: within a
batch the router sorts by seq before applying, and across calls it keeps a running
seqSeen per record and drops (into router.dropped) anything not
newer than what it already applied — which is exactly the reconnect/resume gate
below, working the same way for ordinary live reordering.
A fast feed: push plus batch/coalesce.
Call router.push(delta) instead of apply() and, with
coalesce: true or a batch: { intervalMs } configured, deltas
buffer instead of applying immediately; rapid updates to the same key settle to a single
apply on the timer (or on an explicit flushStream(), useful for a
deterministic point such as an animation frame). With no batching mode configured,
push applies at once, so it is always safe to feed a socket through
push rather than choosing between it and apply up front.
Reconnect and resume
Capture a cursor before the drop; replay after
// While live:
socket.onclose = () => {
const resumeFrom = router.lastSeq(); // ask the server to resume from here
const mark = router.checkpoint(); // or persist this per-record map instead
reconnect(resumeFrom);
};
// On the new connection, the server sends a fresh snapshot plus a replay
// that may include deltas already applied — the same onmessage handles it:
socket = new WebSocket(`wss://example.com/feed?since=${resumeFrom}`);
socket.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.kind === 'snapshot') router.load(msg.rows);
else if (msg.kind === 'delta') router.apply(msg.changes); // replays are dropped, not reapplied
};
The pattern is snapshot-plus-replay, and the dedupe gate does the rest.
On reconnect, load a fresh snapshot (a keyed diff, so unchanged rows do not repaint) and
let the feed replay from around the last known point; any delta the router already
applied — because its seq is not newer than what checkpoint() holds for
that record — is dropped, so only genuinely new deltas advance the state.
seenThrough(mark) primes that same checkpoint from a persisted map (e.g. after
a page reload), so an early replay is dropped even before the router has applied anything
itself in this session. None of this requires the socket to be gone — the router has
no idea whether it is talking to the first connection or the fifth.
The knobs a live feed makes you reach for
Four factory options and one route option matter once the feed is real rather than a
fixture, and none of them is needed to get started. onUnrouted is the sink
for whatever matched no route — it receives the row on load and
query, and the whole delta on apply — and is the honest
alternative to an attachDefault grid when a stray record is a bug to log
rather than a row to show. selectionDebounce (ms, default 16) is how long a
linked grid waits before refiltering on a selection change; set it to 0 in a
test so every selection.set refilters before the next statement.
metricsInterval (ms, default 1000) is the cadence of the
on('metrics') emit, which runs only while a listener is registered; a
devtools panel inherits it. On a route, backpressure ({ maxHz,
minInterval, sample, maxLag }) throttles how often that one viewer repaints under
load without touching what it holds — a chart that cannot draw 200 times a second
gets { maxHz: 10 }, and the grid beside it stays live.
Persistence survives a reload: choose the store once
router.persist({ key: 'orders', dbName: 'lattice', storeName: 'router' });
await router.restore(); // true when a snapshot was found and applied
persist writes the router's state to IndexedDB under dbName /
storeName (defaults are provided; pass your own indexedDB to
test against a fake, or a storage pair of get/set
to use something else entirely). persisting reads false when the
store was unavailable and the router degraded to memory, which is the case to check
before promising a user their view will be there tomorrow.
Several independent feeds, one router
Two sockets, two sources, one merged store
const orders = router.addSource('orders');
const inventory = router.addSource('inventory');
const ordersSocket = new WebSocket('wss://example.com/orders');
ordersSocket.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.kind === 'snapshot') orders.load(msg.rows);
else if (msg.kind === 'delta') orders.apply(msg.changes);
};
// inventorySocket wired the same way, against the `inventory` handle
addSource(id) returns a per-feed handle — its own
load/apply/push/remove — so each
socket is wired to its own handle exactly as a single feed is wired to the router
directly; the router merges every source into one keyed store, namespaced by
key when two feeds' ids could otherwise collide. There is no separate lookup
method to re-fetch a handle later — keep the reference addSource
returns, the same way the code above keeps orders and
inventory.
No backend yet: MockWebSocket frames messages identically
import { MockWebSocket, opsFeed } from 'lattice-grid/modules/mock-socket';
const socket = new MockWebSocket({ feed: opsFeed({ seed: 7 }) });
// everything above this line is the only thing that changes going live:
// const socket = new WebSocket('wss://example.com/feed');
Every example on this page is executed, against the real module and
MockWebSocket, in demo/router-websocket.mjs
(node demo/router-websocket.mjs) — run it to see each property above as
output rather than prose.
Diagnostics and devtools
Data grids fail in ways that are hard to diagnose from outside. This is the grid saying what it is actually doing.
Asserting on DOM writes
const before = grid.diagnostics.renders().dom.cellWrites;
await doTheThing();
expect(grid.diagnostics.renders().dom.cellWrites - before).toBeLessThan(200);
The API came first and the panel second, on purpose. Instrumentation written behind a UI gets shaped by the layout: it reports what is convenient to display rather than what is true, and it cannot be tested. An API that stands on its own can be asserted against, and the assertion above is not one most grids can support.
Most of it already existed and was unreachable. The renderer had counted cell writes, row updates and paints all along; the column store could already report a real byte footprint per column, summing backing array, presence bitset and dictionary. Neither was reachable from the public API. Exposing what a system already knows is usually a better first move than measuring something new.
Warnings are mostly collection, not detection. The grid has 160 places that warn once per cause, each already carrying a stable de-duplication key, which is exactly the stable identifier a support conversation needs. They went to the console and nowhere else. The console interleaves with your own logging, does not survive a reload, and cannot be asked what it has already complained about. They are now kept as records too.
Every warning names values, not just a condition. "Something is slow" is a warning nobody can act on. Each carries the specific numbers, a stable id, and is dismissible for the session but not permanently, a permanently dismissible warning is one nobody sees again after the person who dismissed it leaves.
The checks are tested for silence as much as for detection. A clean grid
must raise nothing. A checker that cries wolf is one developers learn to ignore, and then it
is worth less than no checker at all. The accessibility checks found two false positives in
themselves during development: first comparing aria-rowcount against the row
count when ARIA counts header rows too, then counting header row *elements*, of which a
single-level header has three because the header is built once per pinned region.
Instrumentation must not change what it measures. Counters are integers
incremented where the work already happened. Render phases are four performance.now()
marks around existing sections. Timings are sampled, a bounded window of recent operations,
and every report names which of its figures are sampled, because a number whose provenance is
unclear is worse than no number. Paint wait is the browser's and is deliberately not claimed.
Render causes are captured, not inferred. By the time a paint runs, several distinct causes have collapsed into the same dirty flags, so working backwards gives a plausible answer rather than a true one. The renderer records the structural reason when the invalidation arrives; the semantic one (filter, sort, data) is only knowable from the event that preceded it, so the DOM layer supplies that and the structural reason is the fallback.
Providers are wrapped where they are installed, not at each call site, so one added later is instrumented by construction. The wrapper returns exactly what the original returned and re-throws exactly what it threw. Failures are kept, because a provider failure is usually swallowed by the host application's own error handling before a developer sees it, and "the grid is behaving strangely" is where that conversation otherwise starts.
The heat overlay uses two colours because there are two findings. A cell written with a value it did not hold is work the grid had to do. A cell rewritten with the value already in it is work it did not. Only the second is waste, and a counter alone will never tell you where it is. The overlay records a baseline when switched on, because otherwise the first paint has nothing to compare against and tints nothing, which reads as broken rather than as empty.
The module imports nothing. Not the grid, not a shared helper. The bundler
inlines whatever a module imports: the framework adapters stay small because
createGrid is handed to them, while the web component carries the grid with it
because it imports it. A devtools module that imported anything from core would put the whole grid inside a
bundle whose entire promise is that deployments not using it pay nothing.
The support bundle carries no row data. Configuration, query state, timing, warnings, provider statistics, version and environment, and nothing from your data. Stated as a guarantee because a bundle that had to be inspected for confidential values before sending is a bundle that never gets attached to the ticket.
It observes and never mutates. A configuration editor in a debug panel is tempting and would create a second path into grid state that has to be kept correct forever. No telemetry: nothing leaves the browser unless you export a bundle yourself.
Collaborative presence
Who else is looking at this grid, and what they are doing.
An in-memory provider, which is all the interface requires
const handlers = new Map();
const providerFor = (id) => ({
subscribe(fn) { handlers.set(id, fn); return () => handlers.delete(id); },
publish(state) {
for (const [peer, fn] of handlers) if (peer !== id) fn(state);
},
});
Presence carries intent, never values. This is the line that matters most. A peer's committed edit reaches the grid as data, through the channel you already use for data. Presence is throttled, lossy and ephemeral by design, so a value carried on it is a value that can be dropped, and that is the class of bug that appears once a month in production and cannot be reproduced on demand.
Positions are row keys, resolved against your view at render time. Peers sort and filter independently, so index 12 is a different record on every screen. Publishing an index would put a colleague's cursor on an unrelated row the moment either of you sorted. The cost of this is real: the grid resolves a key to a position rather than reading one, and it is the difference between the feature working and the feature lying.
Idle is measured from when a message arrived, not from what it says. Clocks between clients disagree by seconds routinely and by minutes occasionally. Keying idle detection on the sender's timestamp means a peer with a fast clock never goes idle and one with a slow clock is idle immediately. The sender's timestamp is kept for inspection and decides nothing.
Publishing throttles rather than debounces. A debounce sends nothing until the user stops moving, so every peer sees a cursor that teleports on pause instead of moving. The leading edge goes out immediately and the trailing edge carries wherever it settled.
Publishing stops while the tab is hidden. Nobody is moving that cursor. Receiving continues, so coming back to the tab shows the current state rather than an empty roster filling in slowly.
A peer's cursor is dashed; your focus ring is solid. The distinction has to
be in the kind of line, not only its colour. Colour alone fails for anyone who cannot separate
two hues and fails for everyone at a glance, and the palette originally contained the
exact value of --lattice-focus-color, so the first peer assigned drew a cursor
identical to the local user's own selection. An active edit is solid and tinted, because an
edit is not a cursor and those two must not be confused with each other either.
Nothing is inserted into the grid. Every treatment is an attribute and a custom property written onto a cell that already exists, drawn with an outline and a pseudo-element. That is what keeps presence from shifting layout, covering an in-cell chart or swallowing a click: none of which survives an implementation that appends overlay elements. The overlay layer is pointer-transparent and presence deliberately does not opt back in; only the roster does, because it is a control.
The roster is the part people use. More than the cursors, in practice. It carries the name as well as the colour, because colour alone is not a signal everyone can read, and it reports peers whose rows are not in your view rather than omitting them, an absent peer reads as a disconnection that has not happened.
A parked cursor does not fade. The label does, after a couple of seconds, because permanent labels over a dense grid are unreadable. The border stays, dims at idle, and goes only on removal: a cursor that vanished while its owner was still connected would report exactly the thing this feature exists to prevent.
Locking is advisory, and the documentation says so because a developer who
believes otherwise will skip the conditional write. Presence is throttled and can
arrive out of order; two clients can enter an edit in the same instant. What actually resolves
the conflict is the conditional write in edit.commit, which returns a conflict
and rolls the optimistic edit back. Locking narrows the window. It does not close it. A stale
claim is disregarded after a much shorter window than peer removal, because a lock held by
someone who shut their laptop blocks a cell nobody is editing.
Not in this release: transport, reconnection or authentication; operational transform or CRDT merging; presence history; text-level cursors inside a cell editor; follow mode; chat.
Cell comments
Threaded discussion attached to individual cells, for reviewing data with other people without leaving the grid.
Wiring a provider
comments: {
provider: {
loadIndex: (rowIds, fields) => api.counts(rowIds, fields),
loadThread: (cellKey) => api.thread(cellKey),
addComment: (cellKey, body, parentId, ctx) => api.add(cellKey, body, parentId, ctx.value),
editComment: api.edit, deleteComment: api.remove,
resolveThread: api.resolve, unresolveThread: api.unresolve
},
rowLabel: (row) => row.data.name
}
Comments are not row data, and never enter the column store. They have a different lifecycle: append-mostly, sparse against the row count, carrying their own identity and timestamps, subject to their own permissions, and outliving the values they annotate. Putting them in the store would mean columnarising a field that is empty for almost every row and rebuilding it on every write.
Two levels of data, and only one of them is cheap. The index maps a cell
to { count, unresolved, updated } and nothing else; it is consulted on every
repaint, so it must never carry bodies. A thread is the bodies for one cell, loaded when the
thread opens and dropped when it closes. Holding every thread a user opened would accumulate
the whole comment dataset over a long session to render a few triangles.
Stable row identity is a hard requirement, enforced rather than documented.
A comment is keyed on row identity plus field. Row index changes under sort, filter and
grouping, so a comment keyed on it reattaches to whichever row later occupies that position,
and a comment on the wrong row is worse than no comment at all. A grid with no
rowKey disables comments and names them in the same single warning as the other
identity-dependent features.
Identity has to survive more than the session. Comments outlive the page that wrote them, so a key that is stable only within one load (anything derived from arrival order, for instance) is not enough. Reload the data in a different order and every thread points somewhere else. If you have only used the grid client-side you may never have needed a durable identity before; you do now.
Writes are optimistic, and rejections are taken back. The author sees their own comment at once, faded until the provider confirms. A rejection removes it rather than leaving the grid displaying something the server refused. The same pattern as cell editing, for the same reason.
The grid authorises nothing. A comment may carry
can: { edit, delete, resolve } and the affordances follow it, but that is a
convenience for the person looking at the screen. It is not a control, and the documentation
says so here rather than leaving it to be assumed: hiding a button stops nobody who opens the
console. Reject in the provider.
A body is user input that has round-tripped through your storage. That is
the exact shape of a stored cross-site script, so the default path sets text and nothing else.
Turning on markdown buys emphasis, code and links: three constructs, built
as elements rather than parsed as markup, with any scheme other than http,
https and mailto refused. A refused link still shows its label, so
nothing the author wrote vanishes without trace.
The marker cannot move the cell's contents. It is a corner triangle drawn with a border on a pseudo-element, so it occupies no space in the layout: no shifted text, no rewrapped number, no displaced sparkline. That constraint is why it is a corner rather than a badge: every other position in a cell is already spoken for. Only the corner opens a thread; a click elsewhere belongs to selection, and taking it would make commented cells behave unlike every other cell. The hit region is larger than the drawn mark, because at compact density the triangle is about seven pixels across.
A comment shows the value it was written against whenever that no longer matches the cell. Without it, a note reading "this looks too high" sits beside a number it never described and the reader concludes the comment is wrong. Changing a value never deletes or invalidates a comment.
Filtered-out comments are hidden, not lost, and the grid says so.
hiddenUnresolved() reports what is still outstanding on rows the filter is
hiding, because a user who filters and sees no markers should not conclude there is nothing
left to deal with. The status bar carries this: its comments panel reads
“3 unresolved comments on hidden rows” and is silent whenever the count is zero, so
a grid with nothing outstanding gains no permanent furniture. The panel is in the default set,
and can be placed explicitly like any other:
statusBar: { panels: ['rowCount', 'comments'] }
The count returns zero rather than a number it cannot stand behind: it is only meaningful
once the index covers every row, so it stays at zero, and the panel stays silent,
until loadAll() has resolved.
The comments-only filter is refused rather than approximated. Restricting
the grid to rows carrying comments needs the index to cover the whole row set, not just what
has been scrolled past, a partial answer would hide precisely the rows the user opened
it to find. Call loadAll() first; until complete is true,
filterToCommented() returns false and does nothing.
Comments stay available while streaming, unlike header histograms: a comment does not move when new rows arrive. Index loads for new rows follow the same debounced viewport path. A thread whose row is evicted by a bounded window closes with a short explanation rather than hovering over a row that has gone.
Keyboard and screen reader. Alt+M opens the thread
on the focused cell: Alt because the grid binds nearly every unmodified key to
navigation and editing. The panel traps Tab, which it has to: the grid behind it is
still there and still focusable, so without the trap a keyboard user would be moving through
cells with a dialog open over them. Focus returns to the originating cell on close rather than
being dropped at the top of the document. Cells announce their comment count and unresolved
count through aria-description rather than their label, because the label is the
cell's value and burying a count inside it would make every commented cell read as something
other than what it holds.
Not in this release: mentions, notifications, rich text, attachments,
reactions, row-level and column-level comments, and export of comments. The grid opens no
transport of its own: if your application pushes updates, call refresh()
and the index reloads.
Header histograms and facet filtering
The shape of a column, drawn in its heading, and clickable. Explore a dataset by clicking through headers instead of writing queries.
Turning them on
facets: { enabled: true }
// per column, layered over the grid's settings
{ field: 'price', type: 'number', facet: { strategy: 'quantile' } }
{ field: 'notes', facet: false }
The column being filtered is not counted against its own filter. Every other active filter applies; that column's own conditions are pruned out of the tree before counting. This is the whole of faceted browsing and it is the part that is easy to get subtly wrong, a self-filtered chart collapses to a single bar the moment you click one, and there is then no way to see what you excluded or to widen the selection. Getting it wrong does not degrade the feature, it removes it.
Pruning is not symmetric across operators. An and group
narrows with each condition, so dropping one widens the result, the direction faceting
wants. An or group widens with each branch, so dropping one would show
fewer rows than the user's actual filter. There is no partial answer that is correct,
so a disjunction naming the column is dropped whole.
Bucket edges are placed once and kept. They are computed against the unfiltered column and survive every filter change until the data is replaced. Not only an optimisation: bars that resized on every click would make the chart unusable as a control, because the thing you are pointing at would move as you pointed at it.
Each bar carries two readings. Its full height is the bucket's share of the unfiltered column; the solid fill inside is how much survives the current filters. Either alone misleads: scaling to the filtered maximum draws a full-height chart out of three surviving rows, and scaling everything down together flattens the whole chart into a few pixels the moment anyone filters anything.
The filters are ordinary filters. They go through the same
filters.set as everything else, so they undo, serialise into saved views, and
appear in whatever filter UI already exists. Nothing downstream can tell a filter made by
clicking a bar from one typed into the filter panel. A drag emits a between
range rather than a set of bucket indices, so it still means the same thing after the data is
replaced and the edges move.
Selection is derived, never stored. Which buckets look selected is read back out of the filter tree. Remove the filter through the filter panel, an undo or a saved view and the chart is correct without anything having to tell it.
High-cardinality columns are refused, and it costs nothing to know. Text
columns are dictionary-encoded in the store, so the distinct count is a property read rather
than a scan. The first column anyone points this at is a name or an id, and one hairline per
customer looks like a rendering fault rather than a distribution. Above
cardinalityLimit the chart is suppressed, or shows a top-N with an aggregated
remainder if you ask for aboveLimit: 'topN': aggregated rather than
truncated, because silently dropping the tail would misrepresent the bars it did draw.
Nulls are never dropped. They land in a terminal bucket, always last, and
the counts always sum to the row count. A column where nine thousand of ten thousand rows are
empty is a fact about the data, and a chart that quietly showed the thousand would be lying
about the shape. NaN joins them rather than forming its own bucket: it is the
same answer to the same question.
Live streams suppress the charts. Constantly shifting distributions are
unreadable, recounting on every batch is wasteful, and a filter control whose buckets move
under the pointer is actively hostile. Filters already made stay applied, because they are
ordinary filters. Pausing the stream brings the charts back, a paused stream is a still
one: unless you set whilePaused: false.
Counting runs off the main thread above workerThreshold.
A distribution was the first work the grid moved to a Worker: nothing waits on a histogram,
which is what made it offloadable without an async pipeline. A portable sort — a built-in
collation with no custom comparator — now also recomputes off-thread above the threshold,
serving the prior order until the new one lands. Filtering and grouping still run on the main
thread. The column is copied, or shared where cross-origin isolation makes
SharedArrayBuffer available; it is never transferred, because transferring would
detach the buffer the grid is still rendering from.
The Worker settings. useWorker and
workerThreshold decide whether and when a distribution is offloaded. Two more
control how the Worker is built, and both are settled when it is constructed: changing
either discards the running Worker so the next offload builds a new one.
| Setting | What it does |
|---|---|
workerUrl | Loads the Worker from a URL you host instead of a blob:. Required under a Content-Security-Policy that forbids blob: workers: without it the Worker cannot be constructed at all on such a page, and compute stays on the main thread. |
sharedMemory | Off by default. Passes columns to the Worker in a SharedArrayBuffer rather than copying them on every message, at the cost of retaining a shared copy of each column that crosses. Needs the page to be cross-origin isolated; where it is not, it falls back to copying and says so once. |
grid.diagnostics.renders().worker reports what the Worker host is actually
doing: whether one was spawned, how many calls ran locally versus remotely, and the
threshold, sharedMemory and workerUrl it was built with.
Server-side sources need a provider, and its absence is silent.
A grid holding one page of data cannot compute a distribution over the whole set. Supply a
function and it receives the column, the pruned filter state and the bucketing settings, and
returns counts. Without one the charts are simply absent, no error, because most
deployments will never supply one. Be clear-eyed about the load: results are cached against
the filter state, but this is one query per column per filter change, and a grid with eight
faceted columns asks eight questions every time a filter moves.
Keyboard and screen reader. Focus enters the chart from the header and arrows move between buckets, so a column costs one tab stop rather than twenty. Enter toggles, Shift with arrows extends a range on ordered columns, Escape clears. Selected buckets carry an outline as well as a colour. Beyond per-bucket labels the chart carries a sentence describing the distribution's shape, because twenty bucket readings do not add up to "most of the mass is at the low end", and that shape is the entire value of the chart.
Time scrubber
Move the grid back through recent data changes: what did this look like a minute ago, before that number moved.
Scrubbing
grid.timeline.attach(); // start recording; the window fills from here
grid.timeline.seek(5); // stand five changes back
grid.timeline.step(-1); // one further back
grid.timeline.at(); // the moment being shown
grid.timeline.toLive(); // return, applying everything stepped over
grid.timeline.detach();
Attaching puts a control on the grid. A slider along the bottom with two
readings beside it: how long ago, and the clock time. Relative answers the question actually
being asked; absolute is what someone reads out to the person next to them. It moves the grid
while the handle is dragged rather than on release, and it turns accent-coloured the moment
you are off live, because a grid quietly showing stale data is the failure this control can
cause. It removes itself on detach().
It reads the data, not your actions. Undo history records what the user did (sorts, filters, edits) which is rarely the question. This reads the change log: a bounded, timestamped, deliberately unmerged record of everything that arrived, so the intermediate states are all still there to move between.
Nothing is scrubbable before attach(). What a value used to
be is not recoverable after the fact (no other part of the grid remembers it) so recording
has to be switched on before there is a past to move through. It is off by default because
reading a row per key on every change is real cost on a hot feed, and paying it for a
scrubber nobody opened would be the wrong default.
Scrubbed back, the grid is not live. Changes keep being recorded but are not applied, because applying them would fight the position being held. Returning to the present applies everything missed.
Value changes reverse; row additions and removals do not. An add would need a removal and a remove would need re-insertion at its old position, and neither is recoverable from what the log holds. A window containing them scrubs over the value changes and leaves the row set alone: stated plainly because the alternative is a scrubber that silently half-works.
What moved is marked. Seeking compares each affected row before and after
and marks the cells whose value changed, in --lattice-timeline-changed. Without
it a scrub is nearly unreadable: the grid moves, and on a row twelve columns wide the one
number you are hunting for goes past unseen.
The mark is held, not flashed. It stays until the next seek clears it.
Every other transient signal in the grid fades on a timer, and this one deliberately does not,
a scrub is someone hunting for what changed, and a highlight they can miss while reading the
other end of the row helps nobody. timeline:seeking fires before any change is
applied, so a five-step drag clears once and marks once rather than strobing per entry.
It compares column values, not raw fields. A computed column has no field of its own; diffing the source row would leave it silently unmarked while its number visibly moved. Reading through the column instead costs a little more and marks what the viewer can actually see change, which is the only definition of "changed" that matters here.
The window is bounded by rows, not only by changes. A cap on entries
alone does not bound memory, because an entry is not a fixed size: one carrying a single
changed cell and one carrying a fifty-thousand-row batch both count as one. So the log holds
at most updates.logLimit changes (2000) and updates.logRows
rows between them (100,000), dropping oldest-first on whichever it meets. It matters more
here than it looks: the log is what keeps superseded row objects alive after the source has
swapped in their replacements, so on a feed delivering five thousand rows a batch an
entry-only cap retains twenty million of them. The one exception is a single change larger
than the whole cap, which is kept: emptying the log would be worse than being briefly over,
and it would drop the newest change rather than the oldest. Watch held against
heldLimit in grid.updates.stats(); rows is a lifetime
total and says nothing about memory.
Charts scrub like anything else. A sparkline column redraws to the series
the row held at that point, and its cell is marked with the rest. The exception is the
delta renderer: it samples on a wall-clock timer and compares against its own
previous sample, so it reads a seek as a genuine movement and draws an arrow for it. Keep it
off a grid you intend to scrub.
Presentation mode
Renders the grid for a room: full-screen, application chrome hidden, everything enlarged. The data stays live and queryable throughout, so a question from the audience is answered by filtering in front of them rather than promised as a follow-up.
Starting and stopping
grid.presentation.start(); // 1.5x by default
grid.presentation.start({ scale: 2 });
grid.presentation.start({ chrome: ['statusBar'] }); // keep some chrome
grid.presentation.nudge(1); // live, or Ctrl/Cmd +
grid.presentation.setScale(1.8);
grid.presentation.stop(); // or Escape
| Keys | Does |
|---|---|
| Escape | Leave, restoring the grid exactly as it was. |
| Ctrl/Cmd + = / - | Enlarge or reduce live, a laptop on a call and a projector at the back of a room are different problems. |
| Ctrl/Cmd + 0 | Back to the default enlargement. |
The scale multiplies your density, it does not replace it. A grid built at
spacious presented at 1.5x is still recognisably that grid, half as big again,
which is what makes a presentation look like the product rather than like a different one.
Virtualisation follows the enlargement, so rows are positioned at the size they are drawn.
Full-screen is the maximiser, not a second implementation. A grid the user had already maximised stays maximised when the presentation ends: leaving it would be undoing something the presentation did not do. Chrome hidden on entry is recorded and put back, so an element the host had already hidden is not revealed on exit.
Events are presentation:started,
presentation:ended, presentation:scale and
presentation:changed: colon-separated like every other grid event rather than
the camelCase the original brief used, so a host subscribing to them does not have to
remember which family a name belongs to.
Views are the slides
grid.presentation.start({ views: ['escalations', 'at-risk', 'margin-watch'] });
grid.presentation.step(1); // or an arrow key, space, Page Down
grid.presentation.goTo(0); // or Home / End
grid.presentation.reset(); // or R: back to the view as saved
| Keys | Does |
|---|---|
| → ↓ Space PageDown | Next view. |
| ← ↑ PageUp | Previous view. |
| Home / End | First or last. |
| R | Put the current view back as saved, discarding anything sorted or filtered since arriving at it. |
They are the saved views you already have. A view captures the column
set, order, widths, filters, sorts, grouping and density; stepping applies each through the
ordinary views.apply, as a single undo entry. Nothing about presenting changes
what a view means.
The stepping keys only bind when there is a sequence, and never while something is being typed into. Without a deck those keys belong to the grid, a presenter with no slides still expects Page Down to scroll, and a quick filter answering a question from the room must not advance the deck on the space bar.
Stepping past either end sits there. It does not wrap: a presenter who sees the first slide again thinks the deck has restarted.
Transitions are a cross-fade, not continuous row motion. Rows are pooled
and virtualised, so an element holding a row before a view change may hold a different row
after it: only rows visible in both states could be animated between positions, and
half a movement draws the eye to whichever rows happened to survive rather than to the change
itself. prefers-reduced-motion removes it; a projected fade is far larger than
one on a laptop, so someone who asked for less motion meant it.
Spotlight, redaction and unattended cycling
// light one row across two columns; everything else recedes
grid.presentation.setSpotlight({ keys: ['R42'], colIds: ['margin', 'utilisation'] });
grid.presentation.setSpotlight(null); // after the point is made
// a wall display cycling saved views with nobody at the keyboard
grid.presentation.start({ views: [...], autoAdvance: 15000 });
// keep some chrome
grid.presentation.start({ chrome: ['statusBar'] });
Spotlight dims what it is not on, rather than lighting what it is. Rows and columns combine as an intersection, so naming both lights the cells where they meet. The dimming stops at 0.28 rather than going further: the audience has to see that there is more data and roughly what shape it is, or the spotlight reads as a filter and the room believes the other rows are gone. It is opacity alone, so a dimmed sparkline keeps its colours instead of flattening to grey.
A spotlight does not survive a view change. It belongs to the point being made, not to the deck: carried forward, it leaves the audience looking at a lit row that no longer means anything.
Redaction travels in views and undo. It is part of grid state, so a saved view carries its own masking and a view that redacts salary redacts it every time it is shown. Toggling is a tracked action, so it undoes like any other change.
Auto-advance wraps, unlike a keypress. An unattended display that stopped on the last view would show one screen for the rest of the day.
Escape ends the presentation, not just full screen. A presentation runs
full screen with its chrome hidden, so leaving full screen without ending it would drop a
chrome-less enlarged grid back into the page with no control left to turn it off. The
maximiser stays the only listener on the key and the presentation follows it, which keeps one
Escape doing one thing: an open editor or menu still closes first. A grid built with
maximise: false binds the key directly instead.
Capturing a still
const blob = await grid.capture({ scale: 2 });
await grid.capture({ scale: 3, fileName: 'q3-margins.png' }); // and save it
grid.on('presentation:captured', (e) => {
console.log(e.width, e.height, e.bytes, e.mimeType); // 1800 600 41030 'image/png'
});
Mounting the bar elsewhere needs the grid's class. Every rule that styles
the prompt bar is scoped under .lattice, and every colour token is declared
there, so a bar mounted into your own chrome through ai.element arrives
unstyled. Add class="lattice" to the container, and the same
data-theme the grid carries, if you have set one, and it picks up the theme.
It photographs the browser's own rendering. The grid is cloned, every
computed style is inlined onto the clone, and the result is wrapped in an SVG
foreignObject and drawn to a canvas, so the picture is what the browser drew,
not a second renderer's guess at it. That matters here more than usual: every decoration,
sparkline and pill the cell layer produces comes out right without being reimplemented.
Virtualisation makes it cheap. Only the rows on screen exist in the DOM,
so capturing a million-row grid clones the thirty rows a camera could have seen anyway.
A full-screen capture at scale: 2 takes around a second.
Cross-origin images are refused before the work starts. They taint the
canvas, and a tainted canvas fails at the very last step with a SecurityError
that names nothing, so the check runs first and the error names the offending URL. Serve the
image same-origin, inline it as a data: URL, or hide the column.
Two further limits, both inherent to the technique: web fonts need
embedding to appear (Lattice's default system-ui stack is unaffected), and CSS
pseudo-elements are not captured.
Drawing over the grid
grid.annotate.use('pen'); // pen · arrow · rect · highlight
grid.annotate.use('arrow', { colour: '#e0245e' });
// A durable text label anchored to a cell, seeded or added (BACKLOG-875).
grid.annotate.add({ type: 'text', text: 'Q3 spike', points: [{ x: 232, y: 72 }], background: '#fffbe6' });
grid.annotate.undo();
grid.annotate.clear();
grid.annotate.use(null); // hand the grid back
It never touches data. Nothing in the layer reads a row or writes one. A grid with annotations sorts, filters and exports exactly as one without them.
It is inert unless a tool is chosen. The canvas is not even created until
the first use(), and carries pointer-events: none whenever no tool
is active, so scrolling, selection and editing pass straight through. A presenter who has
finished drawing must not discover the grid has stopped responding.
Marks are anchored to the data, not the screen. They are stored in content coordinates and redrawn with the scroll offset subtracted, so a circle drawn round a cell travels with that cell rather than hanging over whatever scrolled underneath it.
They are transient. Marks annotate a moment, so they are cleared when the
presentation ends. A capture taken while they are on screen includes them, the canvas bitmap
is carried into the still deliberately, because cloneNode copies a canvas element
and not one pixel of what was drawn on it.
No tool shortcuts are bound. The keys a presenter would want are already
taken by stepping and by the grid itself, and a shortcut that silently shadows Page Down is
worse than one the host chooses. Bind your own to use().
Accessibility
The grid is built to WCAG 2.2 level AA. What follows is what it does, what it does not do yet, and the keyboard map in full: stated plainly, because a conformance claim that overstates is worth less than one that admits its edges.
Keyboard
Every operation is reachable without a pointer. Resizing and reordering a column were once drag-only; both now have key bindings and menu items, so nothing depends on dragging.
Entering the grid. The grid is one stop in the page's tab order. Pressing Tab into a grid that has not been used yet puts focus on the grid itself, and the grid draws a focus ring around its own edge at once, in the theme's focus colour, so a keyboard user can see where focus went before pressing anything else. In Windows High Contrast Mode the ring is drawn in the system text colour. A screen reader announces the grid (or tree grid), its row and column counts and whether it is read-only. The first arrow key, Home or Ctrl+Home moves focus to a cell, and the ring moves with it. From then on the cell you were on is the grid's tab stop: Shift+Tab from the first cell leaves the grid, and Tab back into the grid returns to that cell. The grid takes the tab stop back only when that cell is outside the rendered rows, and then draws its own ring again.
| Keys | Does |
|---|---|
| In the data | |
| ArrowUp/Down/Left/Right | Move the focused cell |
| Home or End | First / last cell of the row |
| Ctrl+Home or Ctrl+End | First cell of the first row / last cell of the last row |
| PageUp or PageDown | Move one viewport of rows |
| Tab or Shift+Tab | Next / previous cell, wrapping across rows |
| Enter | Start editing the focused cell |
| Space | Toggle selection of the focused row |
| Escape | Cancel the current edit or drag |
| Alt+ArrowRight or Alt+ArrowLeft | Expand / collapse a group or tree row |
| Ctrl+Alt+H | Move focus to the column header |
| Ctrl+Alt+P | Move focus to the tool panel |
| Shift+F10 or ContextMenu | Open the context menu for the focused cell |
| Ctrl+F | Find in the grid |
| Alt+Shift+ArrowUp or Alt+Shift+ArrowDown | Move the focused row, when row reorder is enabled |
| On a column heading | |
| ArrowLeft or ArrowRight | Move between headings |
| Ctrl+ArrowLeft or Ctrl+ArrowRight | First / last heading |
| Enter or Space | Sort by the column, Shift to add to the sort |
| Alt+ArrowLeft or Alt+ArrowRight | Resize the column, Ctrl for a coarse step |
| Shift+ArrowLeft or Shift+ArrowRight | Move the column |
| Alt+ArrowDown | Open the column menu |
| ArrowDown or Escape | Return focus to the data |
| In the tool panel's column list | |
| G | Group by the column, or stop grouping by it |
| V | Add the column to values, or take it out |
| P | Pivot by the column, or stop pivoting by it |
| Shift+ArrowUp or Shift+ArrowDown | Move the column |
This table is generated from the bindings the build ships and checked on every build, so it cannot drift from what the grid actually does.
What a screen reader is told
The grid reports itself as a grid, or a treegrid when it holds a
hierarchy, and the role follows the configuration rather than being fixed when the grid is
created. Rows and cells carry their position in the dataset, not in the rendered
window: a reader on row 500,000 of a virtualised grid is told exactly that, which is the point
most grids get wrong. Rows in a hierarchy also carry their position among their siblings, since
a reader cannot count siblings that were never rendered.
Focus is real focus, moved onto the cell, rather than aria-activedescendant. It
is restored after a row is recycled or scrolled out and back, and the grid holds a single tab
stop, so tabbing in and out crosses it once.
State changes are announced: sorting, filtering, selection, grouping, expanding and collapsing, paging, undo and redo, pasting, and rows arriving or leaving on a live feed. A feed is summarised on an interval rather than narrated, because a reader queues what it is given and a fast feed would leave someone listening to counts that are no longer true. A rejected edit is announced with the value that was put back, which is the change users most need to hear about and the one a visual marker alone cannot convey.
Colour and contrast
No information is carried by hue alone. The high-contrast theme runs text at 21:1
and borders at 6.1:1. In Windows High Contrast Mode the grid translates its state into borders
and system colours rather than fighting the palette: see
theming for what that means in detail.
How this is checked
The grid carries its own accessibility rules, and they run on every build against each configuration that differs structurally: flat, grouped, tree, pinned, editing, paginated and with a tool panel: rather than against one sample grid. The same rules are available live from the devtools panel, where they can also read colour and measure targets.
Be clear about what that proves. These are our own rules covering what a data grid gets wrong, not a general-purpose engine, and automated checking of any kind catches a minority of real problems. They are a regression net (they stop a fix being undone silently) and not evidence of conformance.
Bigger targets for touch
The grid meets the minimum target size on its own. That minimum is a conformance floor, not a comfortable size for a finger: both mobile platforms recommend nearer 44 pixels.
targetSize: 'large'
This raises the hit areas and leaves the type where it is, which is the distinction that matters: a touch user wants a larger target, and a low-vision user wants larger text. Density is the control for the second, and the two combine, a compact grid with large targets is a reasonable thing to want on a tablet.
It applies by itself under a coarse pointer, since the person holding one is both who the
criterion is for and the least likely to go looking for a setting. Pass
targetSize: 'default' to opt out of that.
Density alone does not do this. It scales the header, the rows and the type, and leaves the affordances inside them exactly as they were: measured at every preset, the menu button stays 24 pixels, the filter 16 and the resize grip 10. A spacious grid has the room going spare and controls no larger than a compact one, which is the gap this fills.
Known limits
Stated because a report that claims everything invites the one question it cannot answer.
- Two-dimensional scrolling. A grid scrolls horizontally at narrow widths. WCAG 1.4.10 Reflow explicitly permits this for data tables, so it is conforming rather than a gap, but it is worth knowing before you meet it.
- Pinned columns do not release at narrow widths. At around 320 pixels, two pinned columns of ordinary width can leave under 60 pixels for the scrolling middle. The grid stays operable and nothing is lost, but a layout that pins columns is worth reviewing if you expect it to be used at that size.
- The filter icon is a small target, deliberately. It is 16 × 16, below the 24-pixel minimum of WCAG 2.5.8, and conforms under that criterion's equivalent allowance: filtering is also a column-menu item, and the menu button meets the size on its own. Worth knowing if you are pointing at it on a touch screen, the menu is the larger route to the same thing.
Density
One number drives the grid's geometry. A preset sets it; every token derives from it.
Four presets, or a number between them
createGrid(el, { density: 'spacious' });
createGrid(el, { density: 1.4 }); // anything between the presets
grid.set('density', 'compact'); // live
| Preset | Scale | Row | Font | For |
|---|---|---|---|---|
| compact | 0.85 | 23.8px | 12.7px | The default. Dense operational and financial grids; most rows on screen. |
| standard | 1 | 28px | 13px | Roomier than the default, and what the grid rendered before density was connected. |
| comfortable | 1.5 | 42px | 14px | Roomier application tables. |
| spacious | 2 | 56px | 15px | Modern app listings with avatars and thumbnails. |
Type does not scale with the rows. The row doubles from
compact to spacious while the font moves about 18%. Scaling text
against row height reads as a children's book long before the rows look generous, so height
and spacing follow the scale directly and type is damped against it.
Virtualisation follows the token, not a separate number. Row positions
are computed from the resolved --lattice-row-height, so a host that overrides
that token by hand gets the virtualisation to agree. An explicit rowHeight in
config outranks both, a host that names a number means it.
Height alone will not reproduce a modern app listing. Those designs pair
generous rows with two-line cells (a bold title over a grey sub-label) and an avatar or
thumbnail. spacious gives the room, an image column
gives the picture, and twoline gives the second line.
Image columns
A column whose value is a URL, drawn as a picture. Avatars beside a name, product thumbnails, company logos.
A circular avatar
{ field: 'avatar', type: 'image', layout: { width: 72 },
cell: { props: { shape: 'circle' } } }
| Prop | Type | Description |
|---|---|---|
| shape | 'rounded' | 'circle' | 'square' | Defaults to rounded. circle is the avatar case. |
| size | number | Pixels. Omit and the box follows the density scale at 0.68 of the row height. |
| fit | 'cover' | 'contain' | cover by default, so a mixed set of aspect ratios still forms a tidy column. |
| alt | string | function | Alternative text. Defaults to the cell's formatted text. |
| loading | 'lazy' | 'eager' | lazy by default. |
Only image URLs load. http, https,
blob: and data:image/ are permitted; everything else is refused,
including a data: URL claiming to be anything other than an image. Relative URLs
pass, since they cannot name a scheme. A grid drawing URLs that arrived in a data feed is
exactly where a bad one gets through. Note this differs from the link renderer,
which refuses data: outright: correct for an anchor, wrong for a picture.
A missing or broken image never moves the column. The box is sized from the row height whether or not the picture loads, and a failed load leaves it empty rather than showing the browser's broken-image glyph, which is a different size in every engine.
Exports carry the URL. CSV, Excel and the clipboard all get the text, not markup. Values are dictionary-encoded, which pays unusually well here: one URL per user or per company repeats down the whole column.
Two-line cells
A bold primary line over a quieter secondary one, taken from a second property of the same row. A name over an email, a title over a category, a company over its sector.
Naming the second line
{ field: 'name', cell: { render: 'twoline', props: { secondary: 'email' } } }
// a dot path reaches a nested field without a callback
{ field: 'name', cell: { render: 'twoline', props: { secondary: 'contact.email' } } }
// a function for anything the row does not already hold
{ field: 'name', cell: { render: 'twoline', props: {
secondary: (p) => `${p.data.city}, ${p.data.country}`,
} } }
// `format` decorates whatever `secondary` produced
props: { secondary: 'user', format: (v) => v ? `User: ${v}` : '' }
It needs the room. Two lines do not fit in a 28px row. Pair it with
density: 'comfortable' or 'spacious', or a rowHeight
of 40 or more. Nothing stops you using it in a shorter row; the second line is simply
clipped.
The second line reads the row's data, not another column. It is usually a field nobody wants a column of, and requiring one would mean declaring a column purely to hide it. A path that does not resolve leaves the line empty rather than throwing, and a cell with no second line collapses to one centred line rather than leaving a gap.
Both lines truncate; neither wraps. A wrapped second line would change the row height, and in a fixed-height grid that means being cut off mid-descender instead. The accessible name carries both lines as one string, so a screen reader gets the half of the cell that disambiguates the first.
Redacting a column
For presenting and screen sharing: obscure the values in a column while everything that makes the grid readable (the row count, the sort, the filters, the column layout) stays exactly as it was. Right-click a column heading and choose Redact column.
From the API
grid.redaction.toggle('salary');
grid.redaction.set(['salary', 'bonus']);
grid.redaction.clear(); // when the call ends
This is not a security control, and the difference matters. The values
are still in the model, still in the DOM, still on the clipboard and still in every export.
Anyone looking at the page can read them from devtools, or by turning off a single CSS rule.
What redaction defeats is a camera and a screen recorder, which is the threat a presenter
actually has, and the only one it claims to answer. For a value that must never reach the
browser, use permissions with writeOnly: there the value is not
sent, so there is nothing to reveal.
Headings stay readable, totals do not. The column heading is left alone deliberately, a redacted column still has to be identifiable, by the presenter who wants to turn it back on and by the audience who need to know what they are not being shown. The pinned totals row is redacted, because the sum of a column is not a hint at its values: filter to one row and the total is the value.
Swapping the treatment. --lattice-redaction-filter defaults
to blur(5px) contrast(0.85) and takes anything the CSS filter
property does. A blur is the default because it is GPU-composited and stays cheap across a
scrolling viewport; point the token at an SVG mosaic filter if you prefer classic
pixellation and can afford it per cell.
Clipboard
Copy produces the tab-separated form Excel, Numbers and Sheets all read, so a range pastes as cells rather than as one lump of text. Values go through each column's clipboard hook: a lookup copies its label, a date copies an unambiguous form.
Copy and paste
grid.export.rangeText(); // the text, without writing it
grid.export.clipboard({ rows: 'range' }); // copy the range
grid.export.clipboard({ headers: true, rows: 'selected' });
grid.edit.pasteInto(text); // Excel's tiling rules
Guarding against formula injection on paste
By default a copied range round-trips verbatim, so a value beginning =,
+, - or @ is carried unchanged — the right thing
when the paste target is another grid or cell range. When the paste target is a spreadsheet,
pass sanitise: true and a leading formula character is neutralised with an
apostrophe, so the value cannot be executed as a formula in Excel or Sheets.
Opt in per copy
const { buildClipboardText } = await import('../packages/core/src/export/index.js');
// An explicit cell matrix; the formula guard applies to data fields.
const cells = [['=1+1']];
// Off by default: the value round-trips exactly as copied.
const plain = buildClipboardText({}, { rows: 'range', cells });
// sanitise:true prefixes an apostrophe so a spreadsheet stores it as text.
const guarded = buildClipboardText({}, { rows: 'range', cells, sanitise: true });
return `default:${plain} guarded:${guarded}`;
Pasting follows the spreadsheet convention: one cell into a range fills the range, one row into several rows repeats down, and a block larger than the target extends past it. People have twenty years of muscle memory for this and a grid that invents its own rules is a grid people fight.
Previewing a bulk paste
A paste is the one clipboard gesture that can rewrite dozens of cells with nothing to inspect
first: a payload that lands a column to the left of where it was aimed, or over a range the
user forgot was selected, looks exactly like one that worked. Turn on
edit.pastePreview and a paste into more than one cell opens a confirm/cancel dialog
before anything commits.
Opt in
createGrid(host, {
// Off by default: an unconfigured grid pastes straight away, as before.
edit: { enabled: true, pastePreview: true },
});
The dialog lists every cell that will change, old → new, and every cell a commit
would reject — a read-only cell, a value the column's type or edit.validate
refuses, a cell a permission policy forbids. Confirm commits precisely that set
through the ordinary paste path; Cancel commits nothing. A single-cell paste
skips the dialog — a preview for one cell is friction, not a safety net. The dialog is a
modal role="dialog": Escape cancels, Tab stays inside it,
focus moves in on open and back on close, and its opening is announced through the grid's live
region. The same diff is available without any UI from
grid.edit.previewPaste(anchor, text, extent?), which returns
{ changes, rejected } and changes nothing.
Why a per-edit flag, off by default. Paste preview lives under
edit because a paste is a bulk edit and its accept/reject decisions are the edit
model's — the preview cannot disagree with the commit because it runs the same checks.
Off by default keeps every existing grid's paste behaviour exactly as it was.
Keyboard
Press ? in the grid to see this list in the product. The overlay is generated from
the same bindings the grid implements, so it cannot drift from them, and it shows
Cmd rather than Ctrl on a Mac, the grid reads either, so that is what
you will actually press. Escape closes it and focus returns where it was. Set
shortcuts: false if you want ? for something else.
| Keys | Does |
|---|---|
| Arrows | Move the focused cell. |
| Shift + arrows | Extend the range. |
| Ctrl/Cmd + Shift + arrows | Open a second range at the focused cell, keeping the first; further presses extend it. |
| Ctrl/Cmd + C, V, D | Copy, paste, fill down. |
| Delete / Backspace | Clear the selected cells. |
| Enter | Start editing; commit and step down. |
| Tab | Commit and step across. |
| Escape | Cancel the edit; restore a maximised grid once nothing else wants it. Coming back from full screen, whether by Escape or the rail's restore button, puts focus back on the cell you were on, not on the page. |
| Ctrl/Cmd + F | Open the find bar; in it, Enter / Shift+Enter step through the matches and Escape closes. |
| Space | Toggle the row's selection. |
| Home / End, Page Up / Down | Jump; with Ctrl, to the ends of the grid. |
| Ctrl/Cmd + Alt + H | Move focus to the column heading. On a heading, ArrowLeft / ArrowRight move between headings, Enter or Space sort by the column (Shift to add it to the sort), Alt + arrows resize, Shift + arrows move the column, Alt + ArrowDown opens the column menu, and ArrowDown or Escape return you to the data. The full list is in the accessibility guide. |
None of these fire while you are typing into an input, a filter box, an open editor, the view-name field. The grid checks where the keystroke came from before claiming it, which sounds obvious and is the sort of thing that is usually wrong.
A key a heading handles acts once, on the heading: Enter sorts and does not also open an editor on a body cell, ArrowRight reaches the next heading and not a cell beneath it. A key the heading declines still travels, which is how Escape reaches a maximised grid from a heading.
Conditional formatting
A rule is a condition and the styling it produces. compileRules() turns a list
into the function cell.style already accepts.
import { compileRules } from '@toclocoinc/lattice-grid';
{ field: 'margin', cell: { style: compileRules([
{ when: { op: 'lt', value: 0 }, style: { background: '#fdecea', colour: '#b91c1c' } },
{ when: { op: 'gt', value: 20 }, style: { weight: 600 }, stopIfTrue: false },
{ scale: { min: 0, max: 100, colours: ['#f8f9fa', '#1a6bc7'] } },
]) } }
The operators are the filter's operators. gt,
between and contains mean here exactly what they mean in the grid's
filters. Anyone
who has built a filter has already learned this, and two vocabularies for one idea is how a
product ends up explaining itself twice.
First match wins, by default. "Red if overdue, amber if due this week"
reads top to bottom and stops, the spreadsheet convention, and the one people expect.
stopIfTrue: false lets rules combine: weight from one, colour from another.
A blank cell satisfies no comparison. Number(null) is zero, so
a naive implementation sweeps every empty cell into "less than 100" and formats half a column
that has no data in it.
Text still compares numerically. A rules panel produces strings, the box
the user typed into yields "100", not 100, and comparing those as
text puts "9" above "100".
A scale's bounds are given, not derived. Deriving them means scanning the column per cell, and a scale that rescaled as rows were filtered would change a cell's colour without its value changing, the opposite of what the colour is for.
Formatting an end user can change
compileRules() above compiles at configuration time into cell.style,
which you write and a user cannot reach. grid.formatting holds the same rules as
runtime state instead: the cell layer asks it on every paint, so a rule added while the grid
is running takes effect on the next frame.
Rules as state
grid.formatting.add('margin', { when: { op: 'lt', value: 0 }, style: { background: '#fbeceb' } });
grid.formatting.add('*', { when: { op: 'blank' }, style: { background: '#f1f3f5' } });
grid.formatting.list('margin'); // [{ id, when, style }, …] in evaluation order
grid.formatting.move('margin', id, 0); // order decides which rule wins
grid.formatting.update('margin', id, { enabled: false });
grid.formatting.clear('margin');
Two scopes, one ordered list. A rule sits on a column id or on
'*' for every column. Evaluation joins them: grid-wide first, then the column's
own, so a column rule can override a grid-wide one, and stopIfTrue means the
same thing across the join as it does within either half.
Saved views and undo came free. The rules are a section of
GridState, and both saved views and the undo timeline are built on that. Nothing
in the formatting model knows either exists.
Rules must be JSON. style cannot be a function here, because
the rules are serialised into views and undo slices. Config-time cell.style still
takes one, which is the right home for a rule a user should not be able to change.
Both paths coexist. Where a column has a cell.style and a
runtime rule matches, the two are merged and written once; the runtime rule wins for the
properties it names and leaves the rest of your styling alone.
Group rows are not formatted. A group row summarises many values rather than being an instance of one, which is the same reason decoration is dropped for it.
The panel
createGrid(el, { toolPanel: { side: 'right', panels: ['columns', 'filters', 'formatting'] } });
The panel is a form over that array and nothing more: every control is one call into
grid.formatting, which is what makes each gesture undoable without the panel
knowing undo exists. It exposes ordering because ordering is meaning: dragging a rule up can
change which of two colours a cell takes.
Not yet in the panel: icon sets and data bars. Both are column decorations
rather than cell styles, the bar and icon decorations already
render them, and driving those from the panel needs a runtime column-decoration API that
persists and undoes alongside the rules. Building it as a second bar implementation inside
the rule engine was the alternative, and the wrong one.
Quick filter modes
One box, four ways to match: contains (the default), words,
fuzzy and regex.
grid.filters.quick('acme london', { mode: 'words' }); // every term, any column
grid.filters.quick('crc', { mode: 'fuzzy' }); // characters in order
grid.filters.quick('^CIR-[12]', { mode: 'regex' });
grid.filters.quick('acme'); // mode persists: still 'regex' here
grid.filters.quickState(); // { text, mode }
Compiled once per query, not per row. A regular expression rebuilt for each of a hundred thousand rows is a hundred thousand compiles for one keystroke. The predicate is built in the filter stage and applied to a cached text blob per row, which is why typing stays responsive at scale.
An unfinished pattern does not blank the grid. foo( is what
foo(bar) looks like halfway through typing. An invalid expression falls back to a
literal search, so the list stays sensible until the pattern is valid again.
Fuzzy does not rank. Subsequence matching decides what stays; it never reorders. Sorting results by match quality would fight the sort the user chose, and a filter that quietly re-sorts is worse than one that matches too much.
Permissions still apply. The text blob is built only from columns the viewer may see. A hidden column is not searchable, or the row count becomes a way to probe the value behind it.
In-cell charts
Seven chart types for a cell: line, area, column,
winloss, pie, donut, bullet,
stacked, range, gauge and delta. Most read an array;
bullet and gauge read a number.
A trend, a mix and a target
columns: [
{ id: 'trend', field: 'readings', cell: 'line' },
{ id: 'mix', field: 'split', cell: { render: 'donut', props: { hole: 0.55 } } },
{ id: 'sla', field: 'uptime',
cell: { render: 'bullet', props: { target: 80, bands: [60, 85], max: 120 } } },
// Two views of one field: give each an explicit id.
{ id: 'shape', field: 'readings', cell: 'line' },
{ id: 'detail', field: 'readings', cell: 'column' },
]
A cell is a few hundred pixels seen for a second, so there are no axes, no
gridlines and no legend. The chart carries one idea (a shape, a share, a comparison) and the
number beside it carries the precision. Hide the number with label: false when the
column next to it already says the same thing.
Pin min and max when columns are meant to compare.
A sparkline scaled to its own data fills its cell whatever the magnitude, so two rows differing
by an order of magnitude draw identically. Pinning the scale is what makes the column readable
down its length rather than only across it.
A gap is not a zero. Entries that are not numbers break the line and omit the bar, rather than being drawn at the baseline: joining across a missing reading would draw a trend nobody measured, and drawing it at zero invents a dip.
Cost. Each chart is one SVG built once, with the paths' d
attributes the only thing a repaint writes, and a bar chart is two paths rather than one
element per bar. Rows recycle as you scroll, so this is what keeps a chart column the same
price as a text one. Nothing measures the DOM, the drawing happens in a fixed coordinate
space that CSS scales.
Accessibility. The chart is aria-hidden and the cell carries a
summary: "12 points, 9 to 20, ending 18". A path cannot be read aloud, and a series announced
value by value tells a listener less than the sentence does.
Profiling and statistics
grid.statistics answers questions about the rows the filters left, so every
figure describes what the user is looking at rather than the whole table.
Everything worth knowing about a column, in one pass
const p = grid.statistics.profile('capacity');
p.count; p.missing; p.distinct;
p.min; p.q1; p.median; p.q3; p.max;
p.mean; p.stdDev;
p.outliers; // by the interquartile rule
p.histogram; // bins, ready to draw
p.alerts; // what is worth looking at
alerts is the part that saves time: a column that never varies, a key that turns
out not to be unique, a fifth of the rows missing. A profile that reports only numbers leaves
the reader to notice those, and readers reliably do not.
| Method | Answers |
|---|---|
| reduce | A column through any of the thirty-eight named kernels, or one of your own. |
| correlation | Pearson's r between two columns. spearman resists an outlier; kendall is tau-b. |
| regression | A least-squares fit of one column on another, with slope, intercept and r². |
| weightedAverage | One column averaged by another. weightedQuantile for the median and beyond. |
| series | How a column varies along an ordering. by is required and never guessed. |
| shadow | What the grid knows about a row over time: updates, delta, rate, rank, percentile, streak. |
The filters are part of the question. Every one of these reads the filtered rows. Narrow the grid and the statistics narrow with it, which is the behaviour you want when the filter is the analysis.
Kernels see arrival order, not display order. Anything order-dependent
takes an explicit by rather than inferring one from the current sort, so the
answer does not change when a user clicks a column header.
Process control and capability
Whether a process sits inside the tolerance it was given, and whether it is behaving or drifting. The tolerance is declared once, on the column.
The specification lives with the column
{ id: 'diameter', type: 'number', spec: { lower: 9.95, upper: 10.05, target: 10 } }
Asking for the capability
const c = grid.statistics.capability('diameter', { rules: 'nelson' });
c.cp; c.cpk; // short-term spread, from the moving range
c.pp; c.ppk; // overall spread
c.outOfSpec; // parts outside the customer's tolerance
c.limits; // { centre, upper, lower, sigma }
c.violations; // [{ index, rule, description }, …]
c.interval; // a confidence interval for cpk
One declared tolerance, so nothing can disagree. The indices, the charts
and any conditional format all read the same spec. A tolerance passed separately
to each is a tolerance that eventually differs between them, and a capability report that
contradicts the cell colouring is worse than neither.
Cp and Cpk use short-term variation, Pp and Ppk overall. The first pair comes from the moving range, which is what the process can do when it is behaving; the second from the whole spread, which is what it actually delivered. Ppk well below Cpk is the signal that the process drifted rather than that it is incapable.
A baseline finds a shift instead of absorbing it. baseline: 30
fixes the limits over the first thirty readings. Limits recomputed over all the data widen to
accommodate the very shift you are looking for, and then report no violation.
The point estimate alone overstates the case. A Cpk of 1.35 measured on
thirty parts has a lower bound below 1.0, so a process that has "passed" a 1.33 requirement on
thirty parts has demonstrated very little. interval is reported alongside it for
that reason.
Drawing it
Three chart types complete the picture, and they read the same specification.
| Type | Shows |
|---|---|
| control | Readings against the centre line and control limits, with every rule break numbered. |
| movingRange | The companion chart: variation between consecutive readings. |
| capability | The distribution against the tolerance, with a curve for each of the two spreads. |
Rule breaks are numbered rather than merely marked, under Western Electric's four rules or Nelson's eight. The two sets number differently, so the chart names which it applied: a "rule 3" that could mean either is not a finding anyone can act on.
Confidence intervals
How firmly the data pins a figure down. An interval narrows as the grid does, because it describes the filtered rows and not the whole table.
A mean and a rate
grid.statistics.interval('capacity');
// { lower, upper, mean, n, confidence }, by Student's t
grid.statistics.interval('status', {
kind: 'proportion',
where: (v) => v === 'failed',
});
// Wilson score, which stays sensible at small n and near 0 or 1
Intervals are also available on a regression slope and on a capability index. Each uses the method that suits it: Student's t for a mean, the Wilson score for a proportion, and Bissell's approximation for Cpk.
The line the product draws. Lattice quantifies uncertainty. It does not adjudicate hypotheses: there are no p-values and no significance tests. An interval says how precisely a figure is known and leaves the judgement where it belongs. A tool that returns a verdict invites it to be read as one, and a grid is the wrong place for that.
Wilson, not the textbook formula. The normal approximation gives bounds below zero and above one at small counts, which is visibly wrong to anyone who reads it. The Wilson score stays inside the interval it is describing.
Statistic tiles
A headline figure over a grid, with the change since a baseline, a tone from thresholds, and the interval underneath.
A tile that follows the grid
import { createStat } from 'lattice-grid';
createStat({
grid,
container: tile,
title: 'Total capacity',
// A leading icon beside the title and value. Same three forms as a menu
// item: a sprite name, a character/emoji, or your own markup.
icon: '<i class="fa-light fa-gauge-high"></i>',
value: { of: 'capacity', fn: 'sum' },
baseline: (g) => lastMonth,
bands: { good: 5000, warn: 3000, direction: 'up' },
interval: (v, g) => g.statistics.interval('capacity'),
});
icon is optional and lays out to the side without disturbing the change indicator,
threshold bands or confidence interval; omit it for the plain tile. It uses the same
icon contract as a menu item — a sprite name, a single character or
emoji, or author-supplied markup such as a Font Awesome glyph or an
<img>.
bands and goodWhen judge different things.
goodWhen says whether a rise is good news, and colours the change indicator.
bands judge the value itself. They are separate because a Cpk of 0.9 is bad news
whether it rose or fell to get there.
The tile follows the grid by default. Filter the grid and the figure
updates. scope chooses filtered, all or selected rows; live: false
detaches it and leaves refresh() to you.
value also takes show, which reports a field from the row holding
an extreme rather than the extreme itself: { of: 'sales', fn: 'max', show: 'rep' }
is the name of the best rep. It needs min or max, because no
single row holds an average.
Formulas
A leading = in a numeric cell is a formula. Type
=quantity * unitPrice and the grid stores 119.88.
What a user can type
=5 + 5
=quantity * unitPrice // by field name
=[Unit Price] * 1.2 // by title, when it has spaces
=ROUND(quantity * unitPrice, 2)
=IF(quantity > 10, "bulk", "single")
=SUM(readings) // an array property on the row
=MAX(readings) - MIN(readings)
References name columns, not cells. A spreadsheet can say A1
because its rows do not move. A grid sorts, filters, groups, pages and virtualises, so the row
at position 1 is a different row a moment later and a formula written against it would silently
change meaning. quantity * price means the same thing wherever the row goes.
No eval, no new Function. This is text a
user typed into a cell. Handing it to the JavaScript engine would let anyone who can
edit a cell read your cookies, call your API with your credentials, or post the grid's contents
anywhere. It is a hand-written tokeniser and recursive-descent parser, and the only callable
things are the built-in functions: constructor, globalThis and
constructor.constructor("return 1")() all simply fail to resolve.
The result is stored, not the expression. A formula is a way of
entering a value, exactly like 1,200 or (50) or
12%. It commits as one undo step, fires one cell:changed, and passes
through the column's own validation.
The result is stored, not the formula. The expression is evaluated once, at the moment you commit it, and what lands in the cell is a value like any other, so it does not recalculate when a cell it referred to changes later. For a value that must stay in step with its inputs, use a computed column, which is re-evaluated whenever its dependencies move.
Adding your own functions
createGrid(el, {
formulaFunctions: {
MARGIN: ([revenue, cost]) => (revenue - cost) / revenue,
BAND: ([value]) => (value > 1000 ? 'A' : 'B'),
},
});
// Or evaluate one yourself, anywhere.
import { evaluateFormula } from '@toclocoinc/lattice-grid';
const r = evaluateFormula('=a * b', { data: { a: 6, b: 7 } });
r.ok ? r.value : r.error; // 42
A formula that cannot be read rejects the edit and the cell keeps its old value, the same as any other unparseable text. Failures are returned rather than thrown: this runs on the commit path, where an exception would abandon the commit half-done.
Bare arithmetic is deliberately not a formula. 2-1 is a
plausible product code and 1/2 a plausible date. A reader that evaluated either on
a guess would have to guess wrong sometimes, and the wrong answer is not a visible error but a
plausible number: 2*3 stored as 23 looks like data. Arithmetic
without a leading = is refused outright, so the cell keeps what it had rather than
taking a number nobody typed.
Your own menu items and buttons
The cell menu's function form is handed the cell that was clicked and the built-in items. Adding one entry does not mean reproducing the other thirteen.
An item that acts on the cell it was opened on
createGrid(el, {
contextMenu: (params, defaults) => [
...defaults,
{ separator: true },
{
name: `Open ${params.value} in CRM`,
action: (ctx) => window.open(`/crm/${ctx.data.accountId}`),
},
],
});
params and the action's argument are the same shape:
{ key, colId, value, row, data, column, index, grid }. data is your
original row object, so an item can reach fields the grid never displayed.
An item with your own icon
createGrid(el, {
contextMenu: (params, defaults) => [
...defaults,
{ separator: true },
// A registered sprite name — the built-in items use these.
{ name: 'Download', icon: 'download', action: () => save(params.data) },
// A single character or emoji, rendered as text.
{ name: 'Star', icon: '★', action: () => star(params.data) },
// Your own markup — a Font Awesome glyph, an inline SVG, an image.
// It is inserted into the icon slot as an element, at the same trust
// as the item's action, and never into the label.
{ name: 'Export', icon: '<i class="fa-light fa-file-export"></i>', action: exportRow },
],
});
A MenuItem's icon accepts three forms, told apart automatically so
existing definitions keep working: a registered sprite name
('download'), a single character or emoji ('↑'),
or author-supplied element markup
('<i class="fa-light fa-download"></i>'). Markup is rendered as an
element rather than shown as text — the misbehaviour it replaces — and is written only into
the icon slot, so a definition can never inject markup into the label. It is trusted like the
item's action: a menu definition is code you wrote, not user data.
Handed the defaults, rather than replacing them. A builder that had to
return every item in order to append one would be written once as a copy of the built-ins and
would then drift from them, the copy keeps the menu it was forked from, and stops gaining
whatever the grid adds later. Spreading defaults costs one line and never goes
stale.
Return the array you want shown: add, remove, reorder, or replace outright. An empty array
suppresses the menu deliberately. Returning nothing leaves the defaults alone, because
a missing return is a typo and deleting the whole menu is a harsh reading of
one.
Declaring a menu on the column itself
A cell menu can also be declared on the column, with
contextMenu on the column definition. It takes the same shapes the grid-level
option takes, plus a bare array for the common “just these items here” case:
boolean | MenuItem[] | (params, defaults) => items.
Each column's menu logic beside the column it is about
createGrid(el, {
columns: [
{ field: 'account' },
// Just these items, here.
{ field: 'owner', contextMenu: [{ name: 'Reassign', action: reassign }] },
// Or the built-ins plus one, the same form the grid-level option takes.
{
field: 'amount',
contextMenu: (params, defaults) => [
...defaults,
{ separator: true },
{ name: `Reprice ${params.value}`, action: (ctx) => reprice(ctx.data) },
],
},
// And nothing at all on a column nobody should act on from here.
{ field: 'nationalId', contextMenu: false },
],
});
This adds no power the grid-level option did not have — params.colId and
params.column always let one callback branch by column. What it adds is
locality: the menu for a column is declared where the column is, instead of
collecting into one growing switch a long way from the thing it is about.
The three levels compose as a chain
The built-in items go in first, then the grid-level contextMenu, then the
column's own — each handed the previous level's result as its
defaults. A column that wants one extra item writes one extra item; it
never has to restate Paste, Clear and Fill down, nor whatever the grid-level builder just
added.
Built-ins → grid → column, executed
const { createTestDom, flushFrames } = await import('../packages/dom/src/renderer/testdom.js');
const { createGrid } = await import('../packages/dom/src/index.js');
const { root } = createTestDom({ width: 600, height: 300 });
let handedToTheColumn = [];
const grid = createGrid(root, {
rowKey: 'id',
rows: [{ id: 1, account: 'Acme', amount: 120 }],
columns: [
{ field: 'account' },
{
field: 'amount',
contextMenu: (params, defaults) => {
handedToTheColumn = defaults.map((d) => d.name);
return [...defaults, { name: 'from the column', action() {} }];
},
},
],
contextMenu: (params, defaults) => [...defaults, { name: 'from the grid', action() {} }],
});
flushFrames();
const row = grid.rows.get(0);
grid.emit('cell:contextmenu', {
row, key: row.key, index: 0, colId: 'amount',
column: grid.columns.get('amount'), value: 120,
event: { clientX: 10, clientY: 10, preventDefault() {} },
});
flushFrames();
// The column builder was handed the grid builder's output, not the raw
// built-ins: 'from the grid' is already in its `defaults`.
const chained = handedToTheColumn.includes('from the grid');
const shown = [...root.querySelectorAll('.lat-menu__item')]
.map((i) => String(i.textContent).trim());
grid.destroy();
return chained && shown.includes('from the column') ? 'grid|column' : 'broken';
Suppression follows the same order, and the more specific level wins.
contextMenu: false on a column is a statement about that column and no
other. Equally, a column may declare a menu on a grid whose contextMenu is
false — which is how you say “no menu anywhere except here”.
contextMenu: true on a column means “whatever came before”, so it
restores the built-in menu on a grid that turned it off.
| Grid level | Column level | What opens on that column |
|---|---|---|
| not set | not set | the built-in menu |
| a builder | not set | the builder's result |
| a builder | a builder | the column's builder, handed the grid builder's result |
| a builder | an array | the array — the grid level still ran, and was replaced |
| a builder, or not set | false | nothing — the column suppresses, and no other column is affected |
false | not set | nothing — the grid-level off stands, as it always has |
false | an array | the column's array. The column opts back in: grid-level false is a default, not a lock |
false | a builder | the builder's result, handed the built-in items as its defaults. The column opts back in |
false | true | the built-in menu. The column opts back in and asks for the defaults |
contextMenu: false on the grid is a default, not a lock. If
you set it as a safety property — a read-only grid, a screen where nobody should be
able to copy or clear from a right-click — be aware that a column declaring its own
contextMenu will still open one, because the more specific level wins in
both directions. That is deliberate: a read-only grid with one actionable column is a
real shape, and it is the only way to say “no menu anywhere except here”. But it
does mean grid-level false does not guarantee that no cell menu can open
anywhere — only that none opens unless a column asks for one. If you need the absolute
guarantee, do not declare contextMenu on any column.
A chain, not a replacement. If the column level replaced the grid level,
every column that wanted one extra item would have to restate everything the grid-level
builder does — and would then stop tracking it the first time it changed. This is the
same rule columnMenu already follows for the header: you are handed what came
before so you can add to it rather than reproduce it.
A range, and rows that belong to no column
On a multi-column selection, the column you right-clicked decides. Not the intersection of the selected columns' menus, which silently drops items; not their union, which offers actions that are wrong for most of the selection. The clicked column is the one the user pointed at, and it is the one that answers — the built-in range actions (Copy, Clear, Fill down) still act on the whole range as they always did.
A row with no owning column falls back to the grid-level menu. Group rows, pivot group rows and full-width rows do not belong to one column, so there is no column-level declaration to consult; the chain simply has one fewer link and the grid-level menu stands. Nothing errors and nothing silently shows an empty menu.
Every route honours the column: a right-click, and the keyboard's
Shift+F10 or Context Menu key on the focused cell.
The menu is a role="menu" of role="menuitem"s that takes focus and
closes on Escape wherever it was opened from.
Trust is unchanged. A MenuItem is the same object it always
was, including icon markup being trusted at the same level as
action. Declaring one on a column changes where it is written, not who
is trusted to write it: a column definition is your code, exactly as a grid config is.
A column preset or columnDefaults may supply contextMenu too, and
the column's own declaration outranks both — so a house rule like “no cell menu on
anything tagged sensitive” is written once.
columnMenu takes the same function form, for both routes into a column's menu:
the header's 3-dot button and a right-click on the heading. Its params is
{ colId, column, grid }, and the same rules apply: spread the defaults, return
an empty array to suppress, return nothing to leave them alone.
An item that appears on some columns and not others
createGrid(el, {
columns: [{ field: 'jan', title: 'Jan', context: { month: 1 } }],
columnMenu: (params, defaults) => {
// Your own keys are on the definition you wrote.
const month = params.column.def.context?.month;
if (!month) return defaults;
return [...defaults, { separator: true },
{ name: 'Select quarter', action: () => selectQuarter(month) }];
},
});
Your properties are on column.def, not on the column itself.
column is the grid's resolved interpretation of your definition and carries only
keys the grid understands; column.def is the object you wrote, untouched. Keeping
them apart means an application property can never collide with one the grid adds in a later
version, and you do not have to maintain a lookup table keyed by column id alongside the
columns themselves.
Chart a selected range
rangeChart turns a selected cell range into a chart — the spreadsheet gesture. It
is off by default; set it and the cell menu offers Chart selection, with
Alt+F1 as the keyboard route, whenever the selected range has a numeric
column to plot. The leading text column becomes the categories and the numeric columns beside
it become the measures; a hidden or unreadable column is never charted, and the chart is bound
to the band of rows the rectangle covers.
The DOM layer draws no charts — the charts module is optional and the page loads it — so
rangeChart carries the handler that draws. A function, or an object with
onChart, is called (grid, range); it typically calls
chartRange from modules/charts, which derives the chart from the
range and returns the live Chart.
Wiring the gesture to the charts module
import { chartRange } from '@toclocoinc/lattice-grid/modules/charts';
createGrid(el, {
columns, rows,
rangeChart(grid, range) {
// One numeric column → a bar; several → a grouped bar. Null when the
// range has nothing to measure, so guard before using it.
const chart = chartRange(grid, { container: '#chart', range });
if (chart) chart.update({ scheme: 'colourblind' });
},
});
Why a handler rather than a flag that just draws. The charts module is
optional by design — a page that never charts never loads it — so the DOM layer cannot draw a
chart itself without pulling the whole drawing surface into every bundle. Handing the drawing
back to the page keeps that promise, and it is the same seam createChart already
uses: the grid is handed to the charts module, never imported by it.
A button of your own on the rail
createGrid(el, {
toolPanel: {
side: 'left',
// A string names a built-in; an object is yours. Order is respected, so
// yours can sit between built-ins rather than only after them.
actions: ['undo', 'redo', {
name: 'sync',
title: 'Sync to the server',
icon: 'restore',
run: ({ grid, keys, cells }) => api.sync(keys),
enabled: () => grid.state.modified(),
}],
},
});
title and icon may each be a function, re-read on every repaint, for
a control whose meaning changes: that is how maximise becomes restore. enabled is
a predicate rather than a flag, so a button that cannot do anything greys itself out instead of
doing nothing when clicked.
The left rail
toolPanel: { side: 'left' } docks the panels as an icon rail and turns on the
action buttons: undo, redo, export to CSV, export to Excel, copy to the clipboard, print,
restore the default view, and maximise. Each is a single click on the thing you came for, and
all four export destinations are in the cell context menu as well.
Picking a subset
createGrid(el, {
toolPanel: {
side: 'left',
panels: ['columns', 'filters', 'views', 'quick'],
// Omit `actions` entirely to take all seven, including any added later.
actions: ['undo', 'redo', 'export', 'excel', 'clipboard', 'print', 'restore', 'maximise'],
exportName: 'circuits',
},
});
An explicit actions array replaces the default rather than extending
it. That is what makes it useful (you choose the set and the order) but it also
means a config written against an earlier version keeps exactly the buttons it named and
silently misses anything added since. If you want whatever the current version offers, leave
the key out.
Every action is gated on a predicate rather than always offered, so undo greys out when there is nothing to undo and "restore the default view" greys out when nothing has changed. A rail of seven icons where three do nothing is worse than a rail of four.
Maximise
A grid usually lives in whatever box the page layout gave it, and that box is usually too small for the job. The left rail's last button fills the browser window with the grid, and clicking it again puts the grid back exactly where it was. Esc also restores.
Your own control, or a keyboard shortcut
// The rail button is on by default. This is the same thing.
grid.maximise.toggle();
grid.maximise.active(); // true while it fills the window
document.addEventListener('keydown', (e) => {
if (e.key === 'F11' && !e.ctrlKey) { e.preventDefault(); grid.maximise.toggle(); }
});
// Or take the button away, if the application has its own full-screen mode.
createGrid(el, { maximise: false });
One button in two states rather than two buttons: the icon and its label turn round when the grid is maximised, so the control always describes what it is about to do.
The element is moved, not just restyled. A position: fixed
element is positioned against the nearest ancestor carrying a transform,
filter, contain or will-change, which is to say any
card, any animated panel, any sticky app shell, and against the viewport only when there is
no such ancestor. A class alone would therefore fill the window on one page and land in a
300px box on the next, and would still be clipped by an overflow: hidden or
buried by a stacking context. Reparenting to <body> removes every ancestor
that could do any of that.
Coming back is a hidden placeholder left in the element's place, not a remembered parent and index, an index goes stale the moment your application inserts a sibling while the grid is away, and then silently reinserts in the wrong slot. The placeholder also holds the vacated space open at the size the grid had, so the page behind neither reflows nor loses its scroll position while you are looking at the grid.
The geometry is written as inline styles and every displaced property is handed back
exactly as it was found, because the element being restyled is yours: most often one
with an inline height on it, which is the ordinary way a grid gets sized and
which nothing but an inline style can beat. While maximised, the element carries
.lat-maximised and <body> carries
.lat-maximised-host, as hooks for your own CSS.
Highlighting
One mechanism for two jobs: the flash a changed cell makes, and a marker you paint deliberately.
On change, and on demand
highlightOnChange: { colour: '#ffe08a', duration: 1200 },
grid.highlight({ key: 'r1', colId: 'cap' }, { colour: 'green', duration: 800 });
grid.highlight({ key: 'r3' }, { colour: '#fdeaea', duration: 0 }); // until cleared
grid.highlight({ colId: 'margin' },{ colour: '#e7f1fd', duration: 0 });
grid.highlight.clear({ key: 'r3' });
grid.highlight.clear();
Cell beats row beats column, so a specific highlight is never hidden by a broad one laid over it. A highlight belongs to the row rather than the element, so it survives scrolling, sorting and paging.
Find
The quick filter answers "show me only the rows that contain X". Find answers a different question — "where is X?" — and leaves every other row exactly where it was, so you keep your place and the neighbours that give a value its meaning. Press Ctrl+F (Cmd+F on a Mac) with focus in the grid: a bar opens above the header with focus in its input, every cell whose displayed text matches lights up in place, the bar reads "N of M", Enter and Shift+Enter step through the matches with wrap, and Escape closes it and clears the marks.
The same thing from code
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const grid = createHeadlessGrid({
rowKey: 'id',
find: { shortcut: true, debounce: 0 }, // the bar's settings; `find: false` removes the bar
columns: [
{ field: 'name' },
{ field: 'price', format: (p) => `$${p.value.toFixed(2)}` },
],
rows: [
{ id: 'a', name: 'Acme', price: 3.5 },
{ id: 'b', name: 'Beta', price: 13.25 },
{ id: 'c', name: 'Acme Two', price: 3.75 },
],
});
let events = 0;
grid.on('find:changed', () => { events += 1; });
const count = grid.find('$3.'); // the formatted text: two prices begin "$3."
const first = grid.find.current(); // { key: 'a', colId: 'price', index: 0, pinned: null }
grid.find.next(); // row c; next() again wraps back to a
const rowsStillShown = grid.rows.count(); // 3 — find never removes a row
grid.destroy();
return `${count.total} matches, ${rowsStillShown} rows shown, current ${first.key}, events ${events > 0}`;
grid.find(text, opts) searches now and returns a FindCount; it is
callable like grid.highlight. The options are a FindQuery:
caseSensitive, wholeCell, columns (an id or a list of
ids; omitted searches every visible column) and from (the display index the
first current match is chosen at or after). Then find.next(),
find.prev() and find.goTo(i) move the current match and scroll it
into view; find.matches(), find.count(),
find.current() and find.state() read the result;
find.open(text?), find.close() and find.clear() drive
the bar; find.stateFor(key, colId) is what the painter asks. Every change fires
find:changed with the query, the open flag and the count.
| Rule | What it means |
|---|---|
| Display text | Find matches what the cell shows — a column format, a unit type, a lookup label — never the raw value. Searching $3. finds prices formatted that way. There is no regular-expression mode; the quick filter has one. |
| An overlay, not a filter | No row is reordered, removed or edited. Matches are painted as .lat-cell--find, the current one also as .lat-cell--find-current, coloured by --lattice-find-match and --lattice-find-current. Find and the quick filter coexist: both may be active, and find re-runs over whatever the filter leaves. |
| Pinned rows and columns | Rows pinned to either edge (and a bottom grand total) are searched and painted like any other; a pinned match has index: -1 and pinned: 'top' | 'bottom'. Pinned columns are cells like any other. |
| Virtualised rows | Matches are computed from the row model, not the DOM, so a match five thousand rows down is counted without rendering it; stepping to it scrolls it into view, and the paint follows the render. |
| The active cell | Stepping to a match makes it the active cell, so Enter in the grid edits it — except while an edit is already open, when the match is scrolled and painted and the editor is left alone. |
| Windowed sources | A paged pushdown source (OData, DuckDB, REST) holds only its loaded rows client-side, so only those are searched. The count says so — "N of M in loaded rows", and FindCount.windowed is true with loaded and rows beside it — rather than presenting a page-one count as the whole. Pushing find to the adapter is a follow-up, not a v1 promise. |
| Large grids | Typing is scanned in per-frame slices from the row at the top of the viewport, so the matches on screen appear after the first slice and the grid stays interactive; the count reads "N of M so far" and count.complete is false until the scan finishes. grid.find(text) scans to completion before returning, so its answer is final. |
| The browser's find | Ctrl+F is claimed only while focus is inside the grid and not in a text field, so the page's own find works everywhere else and an open cell editor keeps it. find: { shortcut: false } leaves the binding to the page and keeps the bar reachable through find.open(); find: false removes the bar altogether. |
| Accessibility | The bar is a role="search" landmark; every control is a native input, button or select with a catalogue name, so nothing needs a mouse. The count is announced through a polite role="status" line once per completed search ("3 of 12 matches", "No matches in loaded rows"); the current match becomes the focused cell when no edit is open, so a screen reader reads it. The strings are in every bundled locale. |
| Keys in the bar | Only Escape and Enter in the input are consumed by the bar. Everything else — Tab between its controls, Enter and Space on its buttons, a page's own Ctrl+S — propagates as it would from any form control, so a host's document-level shortcuts still see it; the grid's own keyboard and range layers stand aside for a key aimed at the bar, which is what keeps Tab from being read as "next cell" and Enter from opening an editor. |
| Pinned strips | Stepping to a match scrolls it fully into the part of the body the pinned strips do not cover — below pinned-top rows and sticky group headings, above pinned-bottom rows and a bottom grand total. That is grid.scroll.toRow's behaviour for every caller, not only find. |
Configuration
find: false // no bar, no Ctrl+F; grid.find(text) still works
find: { shortcut: false } // bar via grid.find.open() only
find: { debounce: 250 } // a slower typist, or a slower grid
grid.find('acme', { caseSensitive: true, wholeCell: false, columns: ['customer'] });
grid.find.count(); // { current, total, complete, windowed, loaded, rows }
grid.on('find:changed', (e) => status.textContent = `${e.count.current} of ${e.count.total}`);
Saved views
A view is a named grid state: sort, filters, grouping, column order, widths, visibility. There are two kinds and the picker keeps them apart.
Views you ship, and views the user saves
views: {
saved: [
{ id: 'escalations', name: 'Escalations',
description: 'Escalated circuits, worst SLA first',
state: {
filters: { col: 'statusId', op: 'eq', value: 4 },
sort: [{ col: 'utilisation', dir: 'desc' }],
}},
{ id: 'commercial', name: 'Commercial', isDefault: true,
state: { columns: [{ id: 'notes', hidden: true }] }},
],
allowSave: true,
}
Views in saved are defined views: part of the application,
listed under their own heading, and neither renamable nor deletable: refused by the model as
well as hidden in the interface. A view flagged isDefault is applied on load.
Everything the user saves sits below, with rename, share, make-default and delete.
Applying a view is a destination, not a patch. A view's state names only the sections it cares about, so applying one resets to the grid's starting state first. Without that, clicking "APAC capacity" after "Commercial" would inherit Commercial's hidden columns, the same view giving a different grid depending on what preceded it, which is the one thing a named view must not do.
When the columns change underneath a saved view
A saved view is user data written months ago against a column set that has since moved on. A release adds columns, renames one, drops another; the views people saved must survive it.
| What changed | What a saved view does |
|---|---|
| A column was added | It appears, in the state its definition declares, placed after every column the view names. A view is not a whitelist, it says nothing about columns it has never seen, and silence is not an instruction to hide. |
| A column was added, and should not appear yet | Declare it layout: { hidden: true }. The view does not mention it, so nothing overrides that, and it stays hidden until the user shows it. |
| A column was removed | The entries naming it are skipped and reported; the rest of the view applies. A sort or grouping on the missing column is dropped rather than left pointing at nothing. |
| A column was renamed | That is a removal and an addition. The old id is skipped, the new column appears at the end, and any width or pinning the user had set is lost with the old id. |
Applying a view never throws and never refuses. It returns a report,
{ applied, skipped }: naming each thing it could not use and why. Refusing the
whole view because one column has gone would lose a layout the user built deliberately, and
throwing during a page load would lose the page. So a view degrades to as much of itself as
still makes sense, and the host decides whether the user needs telling.
Telling the user their view has aged
const report = grid.state.apply(saved.state);
if (report.skipped.length) {
// e.g. [{ key: 'columns.legacyRef', reason: 'unknown column' }]
notify(`This view was saved against an older layout; ${report.skipped.length} setting(s) no longer apply.`);
}
The consequence worth planning for is the first one: a column added in a new release is visible to everyone, including users with a saved view. That is usually what you want (a new field nobody can see is a field nobody uses) but if a release adds several at once, every saved view gains them all at the right-hand end. Ship them hidden if that is not the introduction you want.
Persisting them
The grid makes no network calls. It tells you what happened and you decide what that means.
To a server
grid.on('view:saved', e => api.post('/views', e.view));
grid.on('view:renamed', e => api.patch(`/views/${e.view.id}`, { name: e.view.name }));
grid.on('view:removed', e => api.delete(`/views/${e.view.id}`));
grid.on('view:default', e => api.patch(`/views/${e.view.id}`, { isDefault: true }));
Each event carries the one view that moved, so you send a single record rather than diffing
two lists. Since the grid does not track whether your write landed, catch the failure and
call grid.views.reload().
With no backend at all
createGrid(el, {
views: { local: true, allowSave: true },
});
The other half of the same seam. views.storage above is where
a developer plugs in their own backend, a real server, reached over the network. Not every
grid has one to plug in, and a picker offering "Save" that quietly does nothing until a backend
exists is worse than not offering it. views.local: true is the no-backend answer:
saved views live in this browser's own localStorage, under a default key shared by
every grid on the origin unless you pass one of your own,
views: { local: { key: 'orders-grid-views' } }: to keep two grids' views apart.
Given alongside an explicit storage, the explicit adapter always wins and
local is silently (well, not silently: it warns once) ignored, so a page cannot
end up writing to both without meaning to. The adapter itself is exported as
createLocalViewStorage(opts), for anyone who wants it directly, a custom key
without the shorthand, or a different Storage-shaped backing such as
sessionStorage for views scoped to one tab rather than persisted across visits.
Undo
Undo covers the whole grid, not only edits. Sorts, filters, column moves, grouping, an applied view and a restore all record an entry, and each carries a label written for a button.
Naming the action
grid.history.peek('undo'); // { type: 'sort', label: 'sort by Region' }
grid.history.undo();
grid.history.list(); // the timeline, newest first
A button that says only "Undo" makes the user press it to find out what it does: and pressing it is the thing they were unsure about. "Undo sort by Region" is decided before the click rather than after.
Grouping is by user action, not by internal operation. A multi-cell paste is one entry, not one per cell. An AI plan is one entry however many actions it contains.
Grouping your own changes
grid.history.transaction('apply the quarterly template', () => {
grid.sort.set([{ col: 'margin', dir: 'desc' }]);
grid.filters.set({ col: 'region', op: 'eq', value: 'EMEA' });
grid.columns.hide(['notes', 'mgmtIp']);
});
// one press of undo reverses all three
Column permissions
Four levels, resolved per column from configuration or a callback. They are not a ladder, reading and writing are independent, so they are the four corners of a 2×2.
| Level | Visible | Readable | Editable | For |
|---|---|---|---|---|
| hidden | , | , | , | Absent from the grid, the tool panel, exports, the clipboard, saved state and the filter model. |
| read | yes | yes | , | No editor opens; paste, fill and clear skip it. |
| writeOnly | yes | , | yes | A secret. The cell shows a mask, the editor opens empty. |
| write | yes | yes | yes | The default, so the feature is opt-in. |
Every accepted form
permissions: 'read' // blanket
permissions: { salary: 'read', ssn: 'hidden' } // '*' sets the default
permissions: (column, ctx) =>
ctx.context.role === 'admin' ? 'write' : 'read'
permissions: {
default: 'read',
columns: { name: 'write' },
resolve: (column, ctx) => ctx.context.role === 'admin' ? 'write' : undefined,
}
grid.permissions.setContext({ role: 'clerk' }); // re-resolves everything
For three of the four this is a usability control, not a security boundary. Anything the grid can render it has already loaded, and devtools reaches it. Hiding a column removes it from the interface, not from the process, which is worth a great deal for the way data actually leaks, which is an export mailed onward or a shared view carrying a column a colleague should not see. It is worth nothing against someone determined.
writeOnly is the exception, and the reason it exists. Nothing in the grid needs
the value, so your server can send null for that field and the column still
works: at which point the secret is genuinely not on the page. Enforce everything else
server-side; permittedColumns and permittedExport are pure and
dependency-free so the same policy object runs in Node against a request that arrived over
the wire.
Audit mode
Give the grid a prior snapshot and every row reports whether it was added, removed or changed, and which cells moved.
Before and after
diff: { snapshot: lastApprovedVersion },
grid.diff.summary(); // { added, removed, changed, unchanged }
grid.diff.statusOf('CIR-100042'); // 'changed'
grid.diff.changedColumns('CIR-100042');
grid.diff.before('CIR-100042', 'capacity');
Changed rows get a band down the leading edge and changed cells a tint with the prior value on
the cell as data-before. The row band and the cell tint are deliberately different
devices: "which rows moved" and "what changed in this row" are different questions and one
highlight cannot serve both.
A network map: icon nodes, links coloured by their value
A network chart draws the rows as a graph — two columns name the endpoints
and a third carries the value on the link. Until 1.64 every node was a plain circle, every
edge the same grey, and the layout went wherever the simulation put it, so the picture a
network team actually draws — core on top, regions below, an icon per device, a colour
per circuit — could not be expressed. Three options change that, and every one of them
carries a fact that only the host has.
One nodes list, one rule set, one chart config
createGrid(el, {
columns: [{ field: 'from' }, { field: 'to' }, { field: 'load', type: 'number' }],
rows, // one row per circuit
selection: 'multiple',
// The rules live on the column. The cells and the links read the same ones.
formatting: {
load: [
{ label: 'Healthy', when: { op: 'lt', value: 40 }, style: { background: '#107c41' } },
{ label: 'Busy', when: { op: 'lt', value: 80 }, style: { background: '#f0b400' } },
{ label: 'Saturated', when: { op: 'gte', value: 80 }, style: { background: '#a4262c' } },
],
},
});
createChart({
grid, container: '#topology', type: 'network',
source: 'from', target: 'to', y: { col: 'load', fn: 'sum' },
selection: true,
nodes: [
// x/y are fractions of the plot: both given pins the node there.
{ id: 'core-1', label: 'Core', icon: 'router', x: 0.3, y: 0.15 },
{ id: 'core-2', label: 'Core', icon: 'router', x: 0.7, y: 0.15 },
{ id: 'emea', label: 'EMEA', icon: 'hub', x: 0.2, y: 0.8 },
{ id: 'amer', label: 'AMER', icon: 'hub', x: 0.5, y: 0.8 },
{ id: 'apac', label: 'APAC', icon: 'hub', x: 0.8, y: 0.8 },
],
});
The icons come from the grid, not from the chart. icon is a
name in the grid’s own sprite registry — a built-in, or one you registered —
and the chart reads it through grid.icons, the grid it is bound to. That is
deliberate and it is the only route that works: the charts module ships as its own bundle, so
an import of the registry there would hand the chart a second, empty
copy, and a glyph you registered would be invisible to it. One registry, reached through the
one object both sides already share. Unknown names warn once and draw a plain disc rather
than nothing.
Pinning steers the layout without replacing it. A node with both
x and y is a fixed body: it still pushes its neighbours apart and
still pulls on its links, and the integration step skips it. Everything unpinned settles
around it by the same deterministic relaxation as before — and pinning one node does
not reshuffle the others, because the layout’s seeding draws for every node whether it
is pinned or not, precisely so that it cannot. Half a position is not a position: a node with
only x is laid out.
Parallel links are not summed. Three rows between the same pair are three
lines, offset 4 px apart, symmetric about the pair’s own line, in row order. One
line carrying their total would be a number nothing measured — three circuits at 40% do
not make one at 120% — and the pointer picks out the line you are over rather than the
pair, so each one’s own value is readable. Links are undirected: no arrowheads, and
A,B is the same pair as B,A.
There is no chart-level threshold option, on purpose. The colour comes
from grid.formatting.styleFor(col, value) — background, then
backgroundColor, then color; a gradient is not a colour and is not
read. A second place to say “red above 80” is a second place for the chart and the
cell to disagree. The legend lists only the rules that fired, with their own labels and
swatches, and changing a rule recolours the links on the next frame without the layout
re-running, so nothing moves.
Clicking acts on rows, because a link is a row. With
selection: true, clicking a link selects its row and clicking a node selects
every row it is an end of; the grid’s selection then lights those marks and dims the
rest. The types whose marks are aggregates are deliberately untouched: selecting the
forty rows behind a bar is not what a click on a bar means.
The glyphs those nodes draw are yours to supply. icons on the grid configuration
registers SVG sprites by name, before the first paint, into the same registry the built-in
chevrons and sort arrows live in — so a name you register is usable anywhere a glyph
name is: a column’s icon decoration, a rail action’s
icon, a network node’s icon. Registering a built-in name
overrides it, which is how the expander chevron becomes your own mark. A sprite is a view box
and its path data; one filled path on the house 16×16 box is the common case, so a bare
path string is read as exactly that. grid.icons reads the registry back.
Registering your own glyphs, executed
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { root } = createTestDom();
const { createGrid } = await import('../packages/dom/src/index.js');
const grid = createGrid(root, {
rowKey: 'id',
rows: [{ id: 'core-1', load: 41 }],
columns: [{ field: 'id' }, { field: 'load', type: 'number' }],
// Your own sprites, by name, alongside the built-in set.
icons: {
router: { viewBox: '0 0 16 16', paths: ['M2 6h12v6H2Z', 'M5 6V3h6v3'], paint: 'stroke' },
hub: 'M8 2a6 6 0 1 0 0 12A6 6 0 0 0 8 2Z', // one filled path is enough
},
});
// Read back what the registry holds: the normalised glyph, whichever form registered it.
const router = grid.icons.get('router');
const hub = grid.icons.get('hub');
grid.destroy();
return `router ${router.paths.length} ${router.paint} | hub ${hub.paths.length} ${hub.paint}`;
Showing what was deleted
A deletion is a change too
diff: { snapshot: lastApprovedVersion, removedRows: 'pinned' } // or 'data'
The row is gone from the data and still in the snapshot. That is the only place it exists, and it is what these rows are built from, so they carry the values they had when the snapshot was taken, not any current ones.
One option answers both questions: whether to show it, and whether it
counts. 'pinned' puts it beneath the rows, struck through and dimmed, outside the
row set, so rows.count(), exports and selection all pass over it.
'data' appends it to the set instead, and all three include it. Omitted, nothing
is shown and the grid is the one you already had.
Neither sorts or filters it among the live rows. A removed row's values
are yesterday's; ordering them among today's presents two data sets as one, and lets a filter
written for current values decide the fate of historical ones. Neither permits an edit either,
a write aimed at a removed row is refused and returns 0, rather than being
counted as applied against a record that is not there.
Driving the grid with a model
The grid describes its own columns and operators, you send that to whichever model you like, and it validates the reply before anything is applied. It makes no network call and has no default provider.
The loop
ai: {
async ask({ message }) {
const res = await yourModelClient.complete({
model: 'your-model-of-choice', messages: [{ role: 'user', content: message }],
});
return res.text;
},
}
That mounts a prompt bar. The user types "EMEA circuits over 500 gigs, biggest first"; the grid composes a message including its schema; your callback returns the model's reply; the grid validates it and shows what it would do in plain English, "Filter Region is EMEA and Capacity is more than 500, sort Capacity descending", with Apply and Discard.
Nothing is executed on trust. The vocabulary is seven actions,
setFilters, setSort, groupBy,
showColumns, hideColumns, setQuick,
clear, and a reply naming a column that does not exist is rejected with a
reason while the valid actions in the same reply are kept. A model cannot be talked into an
operation the vocabulary does not contain, because there is nothing else to call.
Applying is one undo entry, labelled with what it did. docs/AI-SKILL.md is the
reference to hand your model.
Export
CSV, Excel, clipboard, print
grid.export.csv({ download: true, fileName: 'circuits' });
grid.export.excel({ download: true, sheetName: 'Circuits' });
grid.export.clipboard({ headers: true, rows: 'range' });
grid.export.print();
Exports follow what the user is looking at, the current filter, sort and column order, formatted values, and only the columns they may read.
On the server
A user asks to export half a million rows. You can ship them all to the browser to be formatted, or write the export server-side and watch it drift from what the grid shows. The headless core is the third option.
The same code, in Node
import { createHeadlessGrid } from './lattice-core.esm.js';
const grid = createHeadlessGrid({ columns, rows: fromDatabase });
grid.state.apply(savedView.state);
return grid.export.csv();
A saved view is a serialisable state object, so the server applies exactly what the user set up, using exactly the code the browser uses. The £1,234.50 in the file is the £1,234.50 on the screen because it came out of the same formatter. 50,000 rows filtered, sorted and written to CSV takes about 100ms.
Import
The mirror of export: rows coming in from a CSV or TSV file, a drop, or a pasted spreadsheet block. The pipeline is parse, infer each column's type, map the columns onto your fields, then preview and confirm — nothing lands until the user says so.
Preview, then confirm
// Parse and map without touching the grid — this is what the dialog shows.
const preview = grid.import.preview(csvText);
// preview.columns is the mapping the user can edit; preview.warnings flags a
// ragged file. Confirming appends (or, with { mode: 'replace' }, swaps).
grid.import.apply(preview);
Set config.import to true to add the DOM affordances — a
"Import rows from CSV…" cell-menu item, a file drop target, and paste — each opening the same
preview dialog. The grid.import API is present whether or not you do, so a server-side
or headless import runs the identical pipeline. .xlsx is not read (it needs an inflate
and XML reader the zero-dependency envelope does not carry); save the sheet as CSV.
Options and calls you may not have met
The rest of the surface, in one place. Each of these is documented in full in the reference; this is here so that reading the guide end to end leaves nothing you have never heard of, which is the difference between a guide and a tour of the parts we found most interesting.
Configuration
| Name | What it does |
|---|---|
| autoHeight | autoHeight. |
| bucketFn | Replace the built-in bucketing entirely. (optional) |
| coalesceMs | (optional) |
| columnGroups | Header grouping declared separately from the columns. |
| columnPresets | Named bundles applied with preset: 'money'. |
| columnVirtualisationAbove | Column count above which columns virtualise too. |
| delimiter | (optional) |
| enterMovesDown | (optional) |
| fillHandle | (optional) |
| granularity | hour … year. Chosen from the span when omitted. |
| groupFooter | A closing total row per group. |
| groupSelectsChildren | (optional) |
| groupSelectsFiltered | (optional) |
| headerHeight | Per header row. |
| historyBar | A standalone undo/redo toolbar with a timeline. |
| hostFilter | An application-level predicate composed with the grid's own filters. |
| idleMs | Silence after which a peer is shown idle. (optional) |
| indexLimit | Cell descriptors held before the oldest are dropped. (optional) |
| lineEnding | (optional) |
| lockMs | Silence after which a peer's edit claim is disregarded. (optional) |
| maxCachedPages | (optional) |
| maxDecimals | (optional) |
| me | (read-only) |
| minDecimals | (optional) |
| overscan | Rows rendered beyond the viewport. |
| pageSizes | (optional) |
| pinnedBottomRows | As pinnedTopRows, held below the body instead. Sits under the grand total when both are shown. |
| pipes | Template pipes for cell.template. |
| processCell | (optional) |
| promoteToMemoryBelow | (optional) |
| quickFilterText | Initial quick-filter term. Equivalent to grid.filters.quick(text). |
| find | The in-grid find bar (Ctrl+F): false removes it, { shortcut, debounce } tunes it. See Find. |
| quote | (optional) |
| removeMs | Silence after which a peer is dropped. (optional) |
| showHeader | Draw the column headings at all. false removes the row, and removes it from the accessibility tree rather than only from view. Distinct from showColumnFunctions, which keeps the headings and drops only their sort, filter and menu controls. |
| throttleMs | Milliseconds between published updates. Throttled, not debounced. (optional) |
| totalFns | Custom aggregations, addressable from column.total. |
| undoDepth | (optional) |
Factories and registration
| Name | What it does |
|---|---|
| createChart | Draw one of the thirty-seven chart types from a grid’s own data. It follows the grid’s filters, and a mark can filter the grid back. |
| chartRange | Chart a selected cell range — the spreadsheet gesture. Derives the chart from the range’s shape (a leading text column is the categories, the numeric columns the measures), respects hidden and unreadable columns, and returns the live chart or null when there is nothing to measure. |
| canChartRange | Whether chartRange would draw something for the grid’s current selection — the question a menu asks before offering the item. |
| deriveRangeSpec | Decide what a chart of a range should be without drawing it: the type, the category column, the measures, and a spec ready for createChart. |
| regressionPlots | Turn a fitted regression model into diagnostic chart specs ready for createChart: the fit line with its confidence band, residuals-vs-fitted, a QQ plot of the residuals, and a multicollinearity correlogram with the model’s VIF. The plots that need a per-row or per-coefficient quantity the grid has no column for (scale-location, residuals-vs-leverage, the coefficient forest) are returned as a null spec carrying the reason rather than dropped. |
| createDataRouter | Split one arriving stream or dataset across many grids by what each record is — a property or a predicate — driving each grid through the public keyed rows.apply path so a snapshot is a diff, a delta is applied in place, a moved partition moves the row rather than duplicating it, and an unmatched record is counted, sunk and never dropped. The router never opens a connection itself — it takes rows, not a URL or a socket — so the host owns the connection (a WebSocket, SSE, CDC, long-poll, anything) and this module owns everything once a message has arrived; see a live WebSocket feed for the worked, runnable example. By default routing is first-match-wins (overlap: false); to fan one partition value to several viewers at once (a grid and a KPI panel and a chart off one feed) create the router with overlap: true — with the default, a second viewer on the same value receives nothing and the router emits a one-time dev warning naming the clash. v2 adds cross-grid selection filtering: link(source, target, relation) makes a selection in one grid filter what another receives — by a key map or a predicate function, multi-select as an IN set, debounced — re-pushed through the same keyed-diff path so the target stays dumb. v5 adds wedge-conversion primitives: subscribe(value, handler) routes a slice to any non-grid view (KPI tile, detail pane, map, form) as the same keyed diff a grid gets; alert(value, condition, handler) evaluates a condition over a slice and emits (edge-triggered, debounced) rather than rendering; and configure(spec) (or createDataRouter({ config })) takes the whole routing graph as one declarative data spec that desugars to the imperative API and composes with it. v3 also adds per-route reshaping — transform/filter/sort and rollup ({ groupBy, aggregate }) summaries — a relationship graph (relate(edges): multi-hop, several-into-one AND, and mutual edges) that scales v2's pairwise link, and stream hygiene: a seq/version orders and de-duplicates a feed (stale/duplicate deltas dropped, counted in dropped), push with a batch/coalesce buffers a high-frequency feed (flushStream for a deterministic point), and lastSeq/checkpoint/seenThrough let the host resume precisely after its own connection drops and reconnects. v4 adds time-travel: buffer({ window, max }) records the ordered stream into a bounded ring over a moving base, so scrubTo reconstructs a past point, replay (with pause/resume) walks a range, and live returns to the head — every state pushed by the same keyed diff, traveling/buffered reporting the state. v6 adds cross-tab sync: broadcast({ channel }) mirrors the ordered, de-duplicated deltas to other tabs/windows over a BroadcastChannel with no echo loop, a popped-out grid joining the same feed with no second socket and resyncing mid-stream via the reconnect path. v7 adds query-slice routing: query(adapter, request) sources the router from a DFQL/DuckDB (or any pushdown) adapter, partitioning one result across the routes; a route-level where is pushed down where the adapter's capabilities allow and the residual finished client-side, with lastQueryPlan reporting the split. v8 adds write-back: a writable route captures the grid's committed edits off its public edit surface and routes them to onWrite(change, ctx) (per-route or router-global), reverting on reject, re-entering an accepted write as a delta, and surfacing a last-write-wins onConflict; a derived route cannot be writable. v9 adds fan-in: addSource(feed, { map, key }) returns a per-feed handle (load/apply/push/remove) whose rows are normalized and namespaced so many feeds merge into one keyed store without id collisions, removeSource dropping exactly a feed's rows and sources listing them. v10 adds observability: metrics() is a cheap snapshot of per-route/per-source counts and throughput plus the global unrouted/dropped/buffered/lag figures, on('metrics') drives a periodic emit (off unless a listener is registered), and mountDevtools(el) renders a live panel from the module's own DOM file. Detaches its grids on destroy; the host owns them. |
| createKanban | A board (kanban) view of rows as cards, grouped into columns by a configurable property (a status, a stage, a state field), with per-column card count and an optional points sum, a WIP over-limit flag, configured columns shown even when empty, granular readonly (whole board / per column / per card), field-mapped card templates that reuse the grid’s own column formatters when a grid is bound, and the core pointer events (card:click, card:dblclick, card:contextmenu). It is a dataset viewer like any other: it consumes data through the same keyed-diff rows.apply({ add, update, remove }) contract a grid exposes, so a Data Router can attach(value, board) and drive a kanban beside a grid and a chart off one feed. Accessibility is built in from the start — a labelled group of labelled column lists, cards in a roving-tabindex focus ring with arrow-key navigation, and a polite live region. Every structural property is named in config (columnProperty, pointsProperty, orderProperty, swimlaneProperty, sprintProperty, epicProperty) so it maps DemandFlow and any customer schema without code change. Pass null as the element for a headless board that computes the same model without a DOM. Cards drag between columns (writing the column property) and within a column into a position (writing the order property with fractional ranking), and the same move is keyboard-accessible — Space to grab, arrows to choose a target, Space/Enter to drop, Escape to cancel — announced on the live region; multi-select drags every selected card. A move calls onBeforeMove(card, from, to, index) first (return false to veto) and then persists through the grid’s shipped write-back path — grid-bound via grid.edit.setCells (the same public edit-commit path inline editing uses, so the grid’s pipeline owns optimistic apply / confirm / revert), standalone with a revert when onCardMove rejects — emitting card:move. A configurable per-card contextMenu replaces the card:contextmenu event when present. With swimlanes: true it renders a 2D lane×column grid grouped by swimlaneProperty (per-lane count/points, columns aligned across lanes, a cross-lane drag writing the swimlane property); columns and lanes collapse (state surviving a keyed-diff update), columns reorder by header drag, and setQuickFilter/setFilter/facets drive search and faceting. setSprint/showBacklog/sprints give a sprint view, switcher and backlog; setEpic/epicRollup/rollup give an epic view and rollups (count, points, progress toward the done columns). A card can pop out a nested grid of its children (an epic's stories, a story's tasks, recursively) via a childrenProperty and/or loadChildren(card): the child is a full composed createGrid (or, with asBoard, a nested board) opened in a drawer/modal/inline container — reuse by composition, no grid-core coupling — emitting card:expand/card:drill. Live updates arrive through the same keyed-diff contract a grid uses, so a Data Router drives the board directly (attach(board, predicate)); on a standalone board (not grid-bound, which ignores rows.apply and routes to the bound grid instead) rows.apply({ update }) merges each patch into the row already stored under its key, matching the grid's own rows.apply({ update }) — a field the patch omits is preserved, and a patch that does change the grouping property still moves the card — while add sets the row outright; a live rows.apply re-renders preserving scroll, focus, selection, collapse and any open pop-out. virtualize renders only a scroll window of a tall column; getState/setState (and config.state) save and restore collapse, order, filter and sprint/epic selection; setLoading/setError give loading and error states. The move is fully keyboard-driven — Space to grab, arrows for column/position, Alt+Up/Down across swimlanes, Space/Enter to drop, Escape to cancel — announced on a live region. A field opted in with card: { title: { field, edit: true } } edits inline (double-click or editCard): grid-bound through the grid's own field editor via its public edit path, standalone through a host editor factory or a default input with an onCardEdit revert; a per-column add-card (config.addCard/onAddCard, or grid.edit.addRow) creates a card and opens it in edit. The module imports nothing from the grid's DOM package. |
| createKPI | A KPI / stat-tile view (module kpi) of a dataset as a panel of aggregate tiles — each tile a sum, avg, min, max, count, countDistinct or a custom reducer over the routed rows, with an optional filter predicate, number format (number/currency/percent/compact), a baseline for a delta, semantic threshold bands (thresholds with two cut points and a direction, or an explicit bands list, giving a good/warn/critical status kept separate from any accent), and an optional sparkline series. It is a dataset viewer like any other: it consumes data through the same keyed-diff rows.apply({ add, update, remove }) contract a grid exposes, so a Data Router can attach(value, kpi) and drive a KPI panel beside a grid, a kanban and a chart off one feed. Updates are incremental — a delta adjusts each tile's running accumulator by only the rows it carries (an add contributes, a remove reverses, an update reverses-then-contributes), the two bounded exceptions being a min/max whose current extreme is removed (a rescan of that tile's own value multiset) and a custom reducer (recomputed over the filtered store, an arbitrary function having no inverse). Each tile is a labelled <figure>, focusable and keyboard-activatable, its value announced, the sparkline respecting prefers-reduced-motion; a tile:click event (also from the keyboard) lets a host drill down or filter a routed grid. Pass null as the element for a headless panel that computes the same tile model without a DOM. Not a dashboard layout engine (that is the parked dashboard generator) and no charting beyond the minimal sparkline (that is the charts module). |
| createAI | Create an AI narrative / insights controller (module ai) over a live grid. It produces a short, plain-language narrative of the grid's computed figures — a per-KPI / per-chart / per-column explain, or an insights panel over the current filtered view. The grid makes no AI call of its own: createAI imports no provider SDK, holds no key, and makes no network request; it calls one host callback, ask({ system, messages, prompt, tools?, schema?, signal }) — your model, your key, your privacy decision — the same philosophy as the data adapters. Two grounding paths feed one guard: where the provider offers tool-use the model is given a curated read-only tool set (getSchema, getProfile, getStatistics, getForecast, runQuery) and the grid's own engine computes what it asks for; otherwise the module builds a facts packet from grid.statistics/the profile/forecasts/view counts and passes it in the prompt. Every figure in the narrative is reconciled against the values the engine produced this render — an ungrounded number is stripped before display (the number-reconciliation guard), so a hallucinated figure never reaches the user. The prompt is constrained to narrate-only; the layer is read-only and never mutates data. redact (a column id, a list, or a predicate) and maxRows bound what the module hands ask(), and the module sends nothing itself; an ask() error surfaces a friendly message and the grid stays fully usable, AI being additive rather than load-bearing. Complementary to grid.ai (the intent/plan skill layer): pass no ask and it adopts the grid's configured ai.ask, running the facts-packet path over it. Pass a headless grid for a DOM-free narrative; facts(target) returns the exact grounded packet without calling ask(). UMD global LatticeGridAI. |
| createTabs | Create a tabbed grid (module tabs): a role="tablist" strip above a stack of role="tabpanel" regions, each hosting its own, independently-configured createGrid instance — "configure each tab as per a normal grid" rather than one grid whose state is swapped (ColumnModel#applyState only repositions/hides/resizes existing columns by id; it carries no field, type or row data, so a state-swap only works when every tab shares one schema). createGrid is injected (createTabs(el, { createGrid, tabs })), the same pattern the React/Vue/Svelte adapters use, so the module imports no engine code and adds nothing to a page that does not load it. A tab that names from: '<tabId>' gets a source: { mode: 'derived', from: <the parent tab’s live grid>, where, group, join, … } wired for it automatically — reusing the shipped derived-source mechanism rather than a new config-inheritance one — and activating a derived tab materialises its whole ancestor chain first; a cyclic from graph is refused (naming the exact cycle) when createTabs is called, not at first click. A tab’s grid mounts on first activation and then stays alive, hidden, so its scroll/selection/filters/sort/grouping/expansion — and an open cell/row editor, left exactly as it was, uncommitted and undiscarded — survive a switch natively; destroy() tears every mounted tab down. The strip is a real tablist with aria-selected, a roving tabindex, and manual-activation keyboard handling (arrows/Home/End move focus, Enter/Space or a click activates). Events: tab:changed, a cancellable beforeTabChange paired with tabChange:cancelled. UMD global LatticeGridTabs. |
| createLayout | Create a reconfigurable dashboard layout (module layout): a cell grid inside an element, and a set of windows on it that a user can move, resize and close by drag or by keyboard — the surface a customer would otherwise reach for GridStack to get. It is payload-agnostic: a window body is a div with an id that the module creates, sizes and never reads, so it imports no engine code at all (not even createGrid) and the whole module is about 17,500 bytes gzipped on the built bundle, of which roughly 2KB is the shared module runtime. columns/rows divide the element; overflowX and overflowY are independent axes, each 'static' (tracks divide the container with minmax(0, 1fr)) or 'scroll' (tracks take a fixed columnWidth/rowHeight and the canvas extends past the viewport, so a column keeps the size it asked for — measured: shrinking a 600px host to 300px leaves a 200px column at 200px). Spacing takes a real CSS length: a number of pixels, or '200px', '25%', '1fr', '2rem'; anything else is refused by name and replaced by the default, because the value reaches an inline style. Windows are placed by 1-based xPos/yPos/xSize/ySize, or auto-placed in the first free cell; chrome defaults on, and closable/movable/resizable all default off, so a fixed dashboard is fixed without opting out. compact: 'vertical' pushes displaced windows down then pulls them up (window:moved carries both to and landed); 'none' keeps every window where it is put. closable/movable/resizable each also take a layout-level default of the same name, which a window's own boolean overrides, and setInteractive(true|false|{movable, resizable, closable}) changes that default at runtime — the “Edit layout” button — without destroying the layout or any payload in it, with getInteractive() reading it back. Locking always wins and unlocking never overrides an opt-out: setInteractive(false) pins a window that declared movable: true, while setInteractive(true) leaves a window that declared movable: false pinned. The config key and the method are deliberately different: movable: false in the config states the default for windows that declare nothing and takes nothing away from one that opted in, whereas setInteractive(false) is an active lock. getInteractive() is three-valued — undefined for unset, true, or false for a lock — and a key carrying undefined is treated as absent, so setInteractive(getInteractive()) is a no-op in every state. It moves both halves of the enforcement, the rendered handles and the pointer and keyboard gesture checks, and fires no event because a mode is not an arrangement — getLayout() neither carries it nor restores it. A locked layout is not a read-only dashboard: what is inside a window is configured with that payload's own settings. maximise(id), minimise(id) and restore(id) are the display modes, with maximised() and minimised() reading them back: maximise fills the layout host rather than the browser window (no position: fixed, whose containing block is the nearest ancestor with a transform or a contain; no reparenting; nothing that can disturb the page around the dashboard), hides the other windows, runs no compaction at all and keeps the payload container as the very same DOM node — and Escape restores it from anywhere inside the layout. minimise draws a window as a single row and hides its payload while its chrome stays to carry the way back, so the windows below pull up on screen; in the arrangement nothing moves at all, because the collapse is a projection of the dashboard rather than a change to it, so restoring gives back exactly the arrangement that was there in any order and with any number of other windows still collapsed. A chrome: false window is refused by name. Both controls are opt-in per window (maximisable, minimisable) with a layout-level default of the same name, and setInteractive() deliberately does not touch either: a mode is not an arrangement, so neither appears in getLayout(), which reports the underlying placement in both states. Keyboard parity with the drag: a focusable handle per window running the kanban board's grab/move/drop/cancel model, with a polite live region announcing grabbed, every tentative position, dropped, cancelled and reverted. It owns exactly one ResizeObserver for the whole layout, over two targets, and tells payloads their new content box through window:resized — it never calls into a payload, because it cannot know what one is. Events: window:moved, window:resized, window:closed, layout:changed, the cancellable beforeWindowMove/beforeWindowResize/beforeWindowClose and their *:cancelled pairs; drag progress is not emitted per frame. getLayout()/setLayout() round-trip the arrangement as plain JSON, and getState()/setState() are the versioned pair. Closing a window does not destroy its payload — the container is handed back on window:closed and the host owns that lifecycle. UMD global LatticeGridLayout. |
| createDevtools | Mount the devtools panel against a grid, including its accessibility checks. |
| createGantt | Create a project-planning Gantt controller (module gantt) over a task list and a dependency list. A CPM engine (computeSchedule) computes each task's early/late start and finish, its slack and the zero-float critical path, honouring the four link types (LINK_TYPES: FS/SS/FF/SF) with lag, and recomputes on every edit — emitting schedule or, on a dependency cycle or bad input, error (a code from SCHEDULE_ERROR). Milestones are zero-duration points; summary (WBS) tasks are derived from their children (earliest start, latest finish, weighted progress) rather than scheduled; findViolations flags a task placed earlier than its predecessors allow, and toISODate maps an engine day-number back to a calendar date. |
| createLatticeGridElement | Build the element class without registering it, for a custom registry. |
| createMessages | Build a message catalogue. A partial set lays over the built-in British English one. |
| defineLatticeGrid | Register <lattice-grid>, or your own tag name. |
| defineUnit | Add one unit to a system, or override one of ours under the same name. |
| registerChartType | Register an extension chart type so createChart({ type }) can draw it (BACKLOG-0000886). Extension types ship as their own opt-in modules (e.g. modules/chart-ridgeline), so the base charts bundle does not grow for a type a caller never imports — you pay only for the charts you use. registeredChartTypes() lists what is registered. |
| registerModules | Install optional modules once, for every grid on the page. |
| registerScheme | Add a colour scheme, or replace one of ours under the same name. |
| restoreStateWithin | Put it back afterwards. |
Grid methods
| Name | What it does |
|---|---|
| attachRenderer | Bind a renderer to a headless grid. |
| emit | Emit on the grid's bus, for custom components. |
| getPinnedRows | The objects pinned at one edge, as a copy. |
| rendererHost | The host object a renderer reads: columns, rows, callbacks. Deliberately a plain bag rather than the grid itself, so a renderer cannot reach into core internals. You need this only when writing a renderer of your own. |
| setAll | Write several in one pass. Emits one config:changed for the batch, not one per key. |
Module configuration
The optional module bundles take their own configuration object rather than extending
GridConfig. Each is documented in full in the
reference; these are the keys most easily missed, listed here so that
reading the guide end to end leaves nothing you have never heard of. A callback named
on… is a second route to an event the module already raises: passing the
callback in config and binding the event both work, and both fire.
| Name | What it does |
|---|---|
| onBeforeTabChange | Tabs (TabsConfig). Cancellable gate before the active tab changes: return false, or call preventDefault() on the event, to veto the switch — an unsaved edit, say. A veto fires onTabChangeCancelled instead of the change. May return a promise, in which case the switch waits on it. |
| onTabChange | Tabs (TabsConfig). Fires after the active tab has changed. The config route to the tab:changed event. |
| onTabChangeCancelled | Tabs (TabsConfig). Fires when onBeforeTabChange vetoed a switch, carrying the resolved reason. The config route to the tabChange:cancelled event. |
| onBeforeWindowClose | Layout (LayoutConfig). Cancellable gate before a window closes: return false to veto, which fires onWindowCloseCancelled instead. May return a promise. |
| onBeforeWindowMove | Layout (LayoutConfig). Cancellable gate before a window moves: return false to veto, which fires onWindowMoveCancelled instead. May return a promise. |
| onBeforeWindowResize | Layout (LayoutConfig). Cancellable gate before a window resizes: return false to veto, which fires onWindowResizeCancelled instead. May return a promise. |
| onLayoutChanged | Layout (LayoutConfig). Fires after a layout change with the full layout snapshot — the shape you persist and restore. The config route to the layout:changed event. |
| onWindowCloseCancelled | Layout (LayoutConfig). Fires when onBeforeWindowClose vetoed a close. The config route to the windowClose:cancelled event. |
| onWindowClosed | Layout (LayoutConfig). Fires after a window has closed. The config route to the window:closed event. |
| onWindowMoveCancelled | Layout (LayoutConfig). Fires when onBeforeWindowMove vetoed a move. The config route to the windowMove:cancelled event. |
| onWindowMoved | Layout (LayoutConfig). Fires after a window has moved. The config route to the window:moved event. |
| onWindowResizeCancelled | Layout (LayoutConfig). Fires when onBeforeWindowResize vetoed a resize. The config route to the windowResize:cancelled event. |
| onWindowResized | Layout (LayoutConfig). Fires after a window has resized. The config route to the window:resized event. |
| nullText | KPI (KPIConfig). The placeholder rendered in place of a null tile value. Defaults to an em dash. |
| onChange | KPI (KPIConfig). Fires after every update with the resolved model: its tiles, and its nodes when the strip is a tree. The config route to the change event. |
| onNodeToggle | KPI (KPIConfig). Fires when a branch of a KPI tree opens or closes, with the node key and its new expanded state. The config route to the node:toggle event. |
| onTileClick | KPI (KPIConfig). Tile click handler, for drilling into what a tile summarises. The config route to the tile:click event. |
| onTileContextMenu | KPI (KPIConfig). Tile right-click handler. The config route to the tile:contextmenu event. |
| onTileDblClick | KPI (KPIConfig). Tile double-click handler. The config route to the tile:dblclick event. |
| ariaLabel | Kanban (KanbanConfig). The board's accessible name. Defaults to Board. |
| columnOrder | Kanban (KanbanConfig). An explicit column order, by column id. |
| doneColumns | Kanban (KanbanConfig). Column ids that count as done when a rollup computes progress. A column definition's done: true says the same thing. |
| emptyText | Kanban (KanbanConfig). Host-localised placeholder shown in a column holding no cards. Empty by default, which shows no placeholder at all. |
| enforceWip | Kanban (KanbanConfig). Enforce wipLimit as a hard gate: a move that would take a column over its limit is refused rather than merely flagged. Default false. |
| laneOrder | Kanban (KanbanConfig). An explicit swimlane order, by lane id. Also written by a lane-header drag. |
| onCardClick | Kanban (KanbanConfig). Card click handler. The config route to the card:click event. |
| onCardContextMenu | Kanban (KanbanConfig). Card right-click handler. The config route to the card:contextmenu event. |
| onCardDblClick | Kanban (KanbanConfig). Card double-click handler. The config route to the card:dblclick event. |
| quickFilter | Kanban (KanbanConfig). Quick-filter text, matched case-insensitively across a card's fields. |
| showPoints | Kanban (KanbanConfig). Show the points sum in each column header. Needs pointsProperty to say which row property carries the points. |
| createdProperty | Kanban SLA (KanbanSlaConfig). The row property holding the wall-clock time the card was created. |
| enteredProperty | Kanban SLA (KanbanSlaConfig). The row property holding the wall-clock time the card entered its current column. |
| ignoreDone | Kanban SLA (KanbanSlaConfig). Whether cards in a done column are exempt from ageing. Default true. |
| onBreach | Kanban SLA (KanbanSlaConfig). Called on a rising crossing into breach level, as (level, rows) — the same shape the router's alert handler takes. |
| onWarn | Kanban SLA (KanbanSlaConfig). Called on a rising crossing into warn level, as (level, rows) — the same shape the router's alert handler takes. |
| showAge | Kanban SLA (KanbanSlaConfig). Show the age chip on every aged card ('always'), or only once a card reaches warn or breach ('threshold', the default). |
| useTransitionLog | Kanban SLA (KanbanSlaConfig). Whether the flow transition log drives the ageing basis when one is present. Default true. |
| autoApply | AI (AIConfig). Ask-your-data: apply a safe, read-only query result without a confirm step. Off by default — the resolved query is shown and waits for Apply. |
| onError | AI (AIConfig). Called when ask() errors, with the error and the target it was asked of. The grid stays usable. |
| onNarrative | AI (AIConfig). Called when a narrative has been produced. |
| onProposal | AI (AIConfig). Called with each governed-actor proposal, before any approval. |
| onQuery | AI (AIConfig). Called with each ask-your-data result. |
| schemaOptions | AI (AIConfig). Budgets passed to the schema builder that describes your data to the model for ask-your-data. |
Kanban config keys, executed
The table above names each Kanban config key on its own line; these six examples run the board (headless, or over the test DOM where a key's effect is visual) and show what each key actually does, grouped by what they configure together.
Board structure: grouping, order and a done set, executed
const { createKanban } = await import('../packages/modules/kanban/index.js');
const rows = [
{ id: 1, status: 'todo', epic: 'E1', points: 3, order: 20 },
{ id: 2, status: 'todo', epic: 'E1', points: 2, order: 10 },
{ id: 3, status: 'done', epic: 'E1', points: 5, order: 5 },
];
const b = createKanban(null, {
rows, rowKey: 'id',
columnProperty: 'status',
columns: [{ id: 'todo' }, { id: 'done' }],
columnOrder: ['done', 'todo'],
pointsProperty: 'points', showPoints: true,
orderProperty: 'order',
doneColumns: ['done'],
});
const ids = b.columns().map((c) => c.id);
const todoOrder = b.column('todo').cards.map((c) => c.row.id);
const donePoints = b.rollup('epic')[0].donePoints;
return (`${ids.join(',')} | ${todoOrder.join(',')} | points ${b.points('todo')} | done ${donePoints}`);
columnOrder pins done before todo though it was configured
second; orderProperty sorts card 2 (order 10) before card 1 (order 20) inside
todo; pointsProperty is what makes b.points('todo') a real
sum rather than 0 (showPoints only decides whether that sum is drawn into the DOM
column header — see the accessible-name example below for that half); and
doneColumns: ['done'] — set directly, with no column def carrying
done: true — is what lets rollup('epic') count card 3's points as done.
Sprint, epic and swimlane selection, executed
const { createKanban } = await import('../packages/modules/kanban/index.js');
const rows = [
{ id: 1, status: 'todo', sprint: 'S1', epic: 'E1', assignee: 'Ann' },
{ id: 2, status: 'todo', sprint: 'S2', epic: 'E1', assignee: 'Bob' },
{ id: 3, status: 'todo', sprint: 'S1', epic: 'E2', assignee: 'Cy' },
];
const b = createKanban(null, {
rows, rowKey: 'id',
columnProperty: 'status',
columns: [{ id: 'todo' }],
sprintProperty: 'sprint', sprint: 'S1',
epicProperty: 'epic', epic: 'E1',
swimlaneProperty: 'assignee', swimlanes: true,
lanes: [{ id: 'Ann', title: 'Ann T' }, { id: 'Zed', title: 'Empty lane' }],
laneOrder: ['Zed', 'Ann'],
sprints: [{ id: 'S1', title: 'Sprint 1' }, { id: 'S2', title: 'Sprint 2' }],
});
const visible = [...b.model.cardsByKey.keys()];
const laneIds = b.model.lanes.map((l) => l.id);
const emptyLane = b.model.lanes.find((l) => l.id === 'Zed');
return (`${visible.join(',')} | lanes ${laneIds.join(',')} | empty ${emptyLane ? emptyLane.count : 'MISSING'} | sprints ${b.sprints().join(',')}`);
The board is constructed already narrowed to sprint S1 and epic E1 — no setSprint/
setEpic call — so only card 1 is visible. lanes configures an
Ann lane and a Zed lane nobody's assignee matches, kept
(count 0) because it is configured, exactly as an empty column is; laneOrder pins
it first. sprints lists both configured sprints even though only S1 has a visible
card.
Card mapping, quick filter and readonly, executed
const { createKanban } = await import('../packages/modules/kanban/index.js');
const rows = [
{ id: 1, status: 'todo', title: 'Login form' },
{ id: 2, status: 'todo', title: 'OAuth flow' },
];
const b = createKanban(null, {
rows, rowKey: 'id',
columnProperty: 'status',
columns: [{ id: 'todo' }],
card: { title: 'title' },
quickFilter: 'oauth',
readonly: { columns: { todo: true } },
});
const visibleTitles = [...b.model.cardsByKey.values()].map((c) => c.fields.title);
const isReadonly = b.readonly({ column: 'todo' });
return (`${visibleTitles.join(',')} | readonly ${isReadonly}`);
card: { title: 'title' } is what makes c.fields.title readable at all;
the initial quickFilter narrows the board to the one matching card before anything
renders; and the per-column readonly map reports todo as locked.
Accessible name, localised labels and selection, executed
const { createTestDom, TestEvent } = await import('../packages/dom/src/renderer/testdom.js');
const { createKanban } = await import('../packages/modules/kanban/index.js');
const dom = createTestDom({});
const el = dom.document.createElement('div');
dom.root.appendChild(el);
const b = createKanban(el, {
rows: [{ id: 1, status: 'todo', title: 'A', points: 5 }],
rowKey: 'id', columnProperty: 'status',
columns: [{ id: 'todo' }, { id: 'blocked' }],
card: { title: 'title' },
ariaLabel: 'Sprint board',
emptyText: 'No cards',
selectable: false,
labels: { grabbed: 'Grabbed' },
pointsProperty: 'points', showPoints: true,
});
const cardEl = el.querySelector('.lat-kanban__card');
cardEl.dispatchEvent(new TestEvent('click', {}));
const selectedAfterClick = b.selection().length;
cardEl.setAttribute('tabindex', '0');
cardEl.dispatchEvent(new TestEvent('keydown', { key: ' ' }));
const liveText = b.live.textContent;
const blockedEmpty = [...el.querySelectorAll('.lat-kanban__column')].find(c => c.dataset.column === 'blocked').querySelector('.lat-kanban__empty').textContent;
const ariaLabel = el.getAttribute('aria-label');
const todoContent = el.querySelector('.lat-kanban__headcontent');
const hasPoints = /lat-kanban__points/.test(todoContent.innerHTML);
return (`${ariaLabel} | selectable-click ${selectedAfterClick} | grab '${liveText}' | empty '${blockedEmpty}' | points ${hasPoints}`);
ariaLabel replaces the default "Board" name; selectable: false means a
plain click leaves the selection empty (the keyboard grab is a separate mechanism, unaffected);
labels.grabbed supplies the word the live region announces; emptyText
is the placeholder shown in the empty blocked column; and showPoints
(with pointsProperty) is what draws the points span into the column header — its
real job, distinct from pointsProperty alone computing the sum (see the board
structure example above, where showPoints is absent and b.points()
still works).
Add-card, edit and move lifecycle, plus WIP enforcement, executed
const { createKanban } = await import('../packages/modules/kanban/index.js');
const seen = [];
const b = createKanban(null, {
rows: [{ id: 'a', status: 'todo' }, { id: 'b', status: 'doing' }],
rowKey: 'id', columnProperty: 'status',
columns: [{ id: 'todo' }, { id: 'doing', wipLimit: 1 }, { id: 'blocked' }, { id: 'review' }],
card: { title: { field: 'title', edit: true } },
addCard: true,
onAddCard: (columnId) => ({ id: 'new', status: columnId, title: 'Created by onAddCard' }),
onCardEdit: () => true,
onBeforeMove: (card, from, to) => to !== 'blocked',
onCardMove: (e) => e.keys[0] !== 'a' || e.to !== 'review',
onCardClick: (e) => seen.push(`click:${e.card.key}`),
onCardDblClick: (e) => seen.push(`dbl:${e.card.key}`),
onCardContextMenu: (e) => seen.push(`ctx:${e.card.key}`),
enforceWip: true,
});
const newKey = b.addCard('todo');
const createdTitle = b.card(newKey).row.title;
await b.applyEdit('a', 'title', 'Renamed');
const editedTitle = b.card('a').row.title;
const wipRefused = await b.move('a', 'doing');
const vetoRefused = await b.move('a', 'blocked');
const revertedMove = await b.move('a', 'review');
b.fire('card:click', { card: b.card('a') });
b.fire('card:dblclick', { card: b.card('a') });
b.fire('card:contextmenu', { card: b.card('a') });
return ([
`created ${createdTitle}`,
`edited ${editedTitle}`,
`wip-moved ${wipRefused.moved.length}`,
`veto-moved ${vetoRefused.moved.length}`,
`reverted ${revertedMove.reverted}`,
`events ${seen.join(',')}`,
].join(' | '));
addCard opts the column into add-card at all; onAddCard supplies the
new row rather than an auto-generated one; onCardEdit confirms the inline edit.
enforceWip refuses the move into doing (already at its limit of 1) before
onBeforeMove is ever asked; a separate move into blocked is vetoed by
onBeforeMove instead; and a move into review — allowed by both — is
still reverted because onCardMove rejects it. onCardClick/
onCardDblClick/onCardContextMenu each fire from the matching
card:* event.
Card aging / SLA: basis, thresholds, callbacks and the tick, executed
const { createKanban } = await import('../packages/modules/kanban/index.js');
const E = 1_700_000_000_000;
const DAY = 24 * 60 * 60 * 1000;
// basis comparison: same row data, two boards differing only in `basis`.
const rowData = { id: 'x', status: 'doing', enteredAt: E - 1 * DAY, createdAt: E - 10 * DAY };
function makeBoard(basis) {
return createKanban(null, {
rows: [rowData],
rowKey: 'id', columnProperty: 'status',
columns: [{ id: 'doing' }],
flow: false,
sla: {
basis, enteredProperty: 'enteredAt', createdProperty: 'createdAt',
useTransitionLog: false, warn: { days: 2 }, breach: { days: 5 },
now: () => E,
},
});
}
const byColumn = makeBoard('column').sla.stateFor('x').level; // uses enteredAt (1d) -> ok
const byBoard = makeBoard('board').sla.stateFor('x').level; // uses createdAt (10d) -> breach
// ignoreDone
const doneRow = { id: 'd', status: 'done', enteredAt: E - 30 * DAY };
const withIgnore = createKanban(null, {
rows: [doneRow], rowKey: 'id', columnProperty: 'status',
columns: [{ id: 'done', done: true }], flow: false,
sla: { enteredProperty: 'enteredAt', useTransitionLog: false, warn: { days: 2 }, breach: { days: 5 }, now: () => E },
});
const ignoredByDefault = withIgnore.sla.stateFor('d').level;
const notIgnored = createKanban(null, {
rows: [doneRow], rowKey: 'id', columnProperty: 'status',
columns: [{ id: 'done', done: true }], flow: false,
sla: { enteredProperty: 'enteredAt', useTransitionLog: false, ignoreDone: false, warn: { days: 2 }, breach: { days: 5 }, now: () => E },
}).sla.stateFor('d').level;
// onWarn/onBreach + showAge
const hits = [];
const warnBoard = createKanban(null, {
rows: [
{ id: 'w', status: 'doing', enteredAt: E - 3 * DAY },
{ id: 'br', status: 'doing', enteredAt: E - 6 * DAY },
],
rowKey: 'id', columnProperty: 'status', columns: [{ id: 'doing' }], flow: false,
sla: {
enteredProperty: 'enteredAt', useTransitionLog: false,
warn: { days: 2 }, breach: { days: 5 }, now: () => E, showAge: 'always',
onWarn: (level, rows) => hits.push(`warn:${level}:${rows[0].id}`),
onBreach: (level, rows) => hits.push(`breach:${level}:${rows[0].id}`),
},
});
const showAgeState = warnBoard.sla.stateFor('w').ageText;
// tick: a real re-check interval that fires without a manual evaluate()/move.
const clock = { t: E };
const ticks = [];
const tickBoard = createKanban(null, {
rows: [{ id: 't', status: 'doing', enteredAt: E }],
rowKey: 'id', columnProperty: 'status', columns: [{ id: 'doing' }], flow: false,
sla: {
enteredProperty: 'enteredAt', useTransitionLog: false,
breach: { days: 1 }, tick: 15, now: () => clock.t,
},
});
tickBoard.on('card:sla', (e) => ticks.push(e.level));
clock.t = E + 2 * DAY; // now past breach, but nobody called evaluate()
await new Promise((resolve) => setTimeout(resolve, 60));
tickBoard.sla.destroy();
return ([
`basis-column ${byColumn}`,
`basis-board ${byBoard}`,
`ignore-default ${ignoredByDefault}`,
`ignore-false ${notIgnored}`,
`hits ${hits.join(',')}`,
`age ${showAgeState}`,
`tick-fired ${ticks.length > 0}`,
].join(' | '));
The same card (an enteredAt of 1 day and a createdAt of 10 days) is
ok under basis: 'column' (which prefers enteredProperty) and
breach under basis: 'board' (which prefers createdProperty)
— the same warn/breach thresholds, only the basis changed.
ignoreDone defaults a done card to no ageing at all (null) and
ignoreDone: false ages it anyway. onWarn and onBreach each
fire, shaped (level, rows), for the card that reaches that level; showAge:
'always' gives an age chip on a card that is not yet warned. tick re-evaluates
on its own timer — the card crosses into breach with no move or manual
evaluate() call — and useTransitionLog: false throughout is what keeps
every reading on the row timestamps rather than the flow log.
Custom card body, virtualization and a child pop-out, executed
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createKanban } = await import('../packages/modules/kanban/index.js');
const dom = createTestDom({});
const el = dom.document.createElement('div');
dom.root.appendChild(el);
const many = [];
for (let i = 0; i < 200; i++) many.push({ id: i, status: 'todo', title: `C${i}`, parent: null });
many.push({ id: 'e1', status: 'todo', title: 'Epic', parent: null });
many.push({ id: 's1', status: 'todo', title: 'Story', parent: 'e1' });
const b = createKanban(el, {
rows: many, rowKey: 'id', columnProperty: 'status', columns: [{ id: 'todo' }],
card: { title: 'title' },
cardRenderer: (card) => `<div class="custom">${card.fields.title}!</div>`,
virtualize: { rowHeight: 100, viewport: 500, threshold: 50, overscan: 2 },
children: { property: 'parent' },
});
const list = el.querySelector('.lat-kanban__cards');
const rendered = list.querySelectorAll('.lat-kanban__card').length;
const isVirtual = list.dataset.virtual;
const first = list.querySelector('.lat-kanban__card');
const customRendered = /class="custom"/.test(first.innerHTML);
const canExpandEpic = b.canExpand(b.card('e1'));
const canExpandLeaf = b.canExpand(b.card('s1'));
return ([
`virtual ${isVirtual}`,
`rendered ${rendered > 0 && rendered < 202}`,
`custom ${customRendered}`,
`expand-epic ${canExpandEpic}`,
`expand-leaf ${canExpandLeaf}`,
].join(' | '));
202 cards in one column, but virtualize renders only a scroll window of them;
cardRenderer owns the whole card body, replacing the default template; and
children: { property: 'parent' } is what makes the epic card expandable
(canExpand true) while a plain story is not.
Layout config keys, executed
The reference table names each Layout config key on its own line; these five examples run a real dashboard over the test DOM and show what each key actually does, grouped by what they configure together.
Geometry: overflow axes, track size, gap and padding, executed
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createLayout } = await import('../packages/modules/layout/index.js');
const { document, root } = createTestDom({ width: 600, height: 400 });
const host = document.createElement('div');
root.appendChild(host);
const layout = createLayout(host, {
columns: 6, rows: 4, overflowX: 'scroll', overflowY: 'static', columnWidth: '200px',
gap: '20px', padding: '15px', compact: 'none',
windows: [{ id: 'a', xPos: 1, yPos: 1, xSize: 2, ySize: 1 }],
});
const canvas = host.querySelector('.lat-layout__canvas');
const viewport = host.querySelector('.lat-layout__viewport');
return (`${viewport.style.overflowX} ${viewport.style.overflowY} | ${canvas.style.gridTemplateColumns} | gap ${canvas.style.gap} | padding ${layout.payload('a').style.padding}`);
overflowX: 'scroll' is the axis that scrolls; overflowY stays
'static' independently; columnWidth is the fixed track a scrolling
axis repeats; and gap/padding reach the DOM as the real CSS lengths
configured, not their 8px/5px built-in fallbacks. compact: 'none' is what a
single-window layout cannot show on its own — see the before-events example below for
compact's effect on a move.
Layout-level interactivity defaults, executed
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createLayout } = await import('../packages/modules/layout/index.js');
const { document, root } = createTestDom({ width: 600, height: 400 });
const host = document.createElement('div');
root.appendChild(host);
const layout = createLayout(host, {
columns: 3, rows: 1,
closable: true, movable: true, resizable: true, maximisable: true, minimisable: true,
windows: [
{ id: 'bare', xPos: 1, yPos: 1, xSize: 1, ySize: 1 },
{ id: 'opted-out', xPos: 2, yPos: 1, xSize: 1, ySize: 1, movable: false },
],
});
const frame = (id) => host.querySelector(`[data-window-id="${id}"]`);
const bareHasClose = !!frame('bare').querySelector('.lat-layout__close');
const bareHasGrip = !!frame('bare').querySelector('.lat-layout__grip');
const optedOutHasGrip = !!frame('opted-out').querySelector('.lat-layout__grip');
const canMaximise = layout.maximise('bare');
const canMinimise = layout.minimise('bare');
return (`close ${bareHasClose} | grip ${bareHasGrip} | opted-out-grip ${optedOutHasGrip} | maximise ${canMaximise} | minimise ${canMinimise}`);
Every layout-level default (closable, movable, resizable,
maximisable, minimisable) applies to bare, which declares
none of its own; opted-out's own movable: false beats the layout-level
true, which is why it renders no grip. maximisable/minimisable
are what let maximise()/minimise() succeed on a window that never
declared either itself.
The window list and a saved arrangement applied at mount, executed
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createLayout } = await import('../packages/modules/layout/index.js');
const { document, root } = createTestDom({ width: 600, height: 400 });
const host = document.createElement('div');
root.appendChild(host);
const layout = createLayout(host, {
columns: 4, rows: 4, compact: 'none',
windows: [
{ id: 'a', xPos: 1, yPos: 1, xSize: 1, ySize: 1 },
{ id: 'b', xPos: 2, yPos: 1, xSize: 1, ySize: 1 },
],
layout: { columns: 4, rows: 4, windows: [{ id: 'a', xPos: 3, yPos: 2, xSize: 1, ySize: 1 }] },
});
const ids = layout.windows();
const snap = layout.getLayout();
const aPlaced = snap.windows.find((w) => w.id === 'a');
const bPlaced = snap.windows.find((w) => w.id === 'b');
return (`${ids.join(',')} | a@${aPlaced.xPos},${aPlaced.yPos} | b@${bPlaced.xPos},${bPlaced.yPos}`);
windows declares two windows, a at column 1 and b at
column 2; the mount-time layout arrangement then moves a to (3, 2),
overriding its own declared placement, while b — not named in layout —
stays exactly where windows put it.
Move and resize before-events, independently gated and paired with their cancellations, executed
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createLayout } = await import('../packages/modules/layout/index.js');
const { document, root } = createTestDom({ width: 600, height: 400 });
const host = document.createElement('div');
root.appendChild(host);
const moved = [];
const resized = [];
const cancelledMove = [];
const cancelledResize = [];
const layout = createLayout(host, {
columns: 4, rows: 4, compact: 'none',
windows: [{ id: 'a', xPos: 1, yPos: 1, xSize: 1, ySize: 1 }],
onWindowMoved: (e) => moved.push(e),
onWindowResized: (e) => resized.push([e.id, e.xSize, e.ySize]),
onBeforeWindowMove: (e) => (e.to.xPos === 4 ? e.preventDefault('pinned') : true),
onWindowMoveCancelled: (e) => cancelledMove.push(e.reason),
onBeforeWindowResize: (e) => (e.to.xSize === 3 ? e.preventDefault('too-wide') : true),
onWindowResizeCancelled: (e) => cancelledResize.push(e.reason),
});
const okMove = await layout.move('a', { xPos: 2 });
const vetoedMove = await layout.move('a', { xPos: 4 });
const okResize = await layout.move('a', { xSize: 2 });
const vetoedResize = await layout.move('a', { xSize: 3 });
return (`moved ${okMove}/${vetoedMove} to xPos ${layout.getLayout().windows[0].xPos} | resized ${okResize}/${vetoedResize} to xSize ${layout.getLayout().windows[0].xSize} | cancelled-move ${cancelledMove.join(',')} | cancelled-resize ${cancelledResize.join(',')} | moved-events ${moved.length} | resized-events ${resized.length}`);
move(id, to) resolves to a resize or a move from what changed. The first move and
the first resize both succeed and fire onWindowMoved/onWindowResized
once each; the second of each is vetoed by its own before-hook — onBeforeWindowMove
never sees the resize and onBeforeWindowResize never sees the move — firing
onWindowMoveCancelled/onWindowResizeCancelled with the veto's reason
and leaving the window exactly where it was.
Close before-event and layout:changed, executed
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createLayout } = await import('../packages/modules/layout/index.js');
const { document, root } = createTestDom({ width: 600, height: 400 });
const host = document.createElement('div');
root.appendChild(host);
const closed = [];
const cancelledClose = [];
const changed = [];
const layout = createLayout(host, {
columns: 4, rows: 4, compact: 'none',
windows: [
{ id: 'a', xPos: 1, yPos: 1, xSize: 1, ySize: 1, closable: true },
{ id: 'locked', xPos: 2, yPos: 1, xSize: 1, ySize: 1, closable: true },
],
onWindowClosed: (e) => closed.push(e.id),
onBeforeWindowClose: (e) => (e.id === 'locked' ? e.preventDefault('unsaved') : true),
onWindowCloseCancelled: (e) => cancelledClose.push(e.reason),
onLayoutChanged: (e) => changed.push(e.cause),
});
const okClose = await layout.close('a');
const vetoedClose = await layout.close('locked');
return (`closed ${okClose}/${vetoedClose} | ids ${closed.join(',')} | cancelled ${cancelledClose.join(',')} | changed-causes ${changed.join(',')} | remaining ${layout.windows().join(',')}`);
Closing a succeeds, firing onWindowClosed and onLayoutChanged
(cause 'close'); closing locked is vetoed by onBeforeWindowClose,
firing onWindowCloseCancelled with the reason instead, and it survives in
windows().
AI config keys, executed
The reference table names each AI config key on its own line; these three examples run a
real controller (a MOCK ask() throughout — never a real provider) and show
what each key actually does.
The insights panel: element, tools, enable, maxColumns, reconcile, onNarrative, executed
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createAI } = await import('../packages/modules/ai/index.js');
const dom = createTestDom({});
const el = dom.document.createElement('div');
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'id' }, { field: 'a' }, { field: 'b' }],
rows: [{ id: 1, a: 1, b: 2 }, { id: 2, a: 3, b: 4 }],
});
const narrated = [];
const ai = createAI(grid, {
ask: async () => ({ text: 'There are 2 rows. Confidence 900%.' }),
element: el,
tools: false,
maxColumns: 1,
reconcile: 'flag',
enable: ['narrative'],
onNarrative: (r) => narrated.push(r.text),
});
ai.insights();
const result = await ai.explain({ kind: 'view' });
const cappedFacts = ai.facts({ kind: 'view' }).facts.length;
const uncapped = createAI(grid, { ask: async () => ({ text: '' }) }).facts({ kind: 'view' }).facts.length;
return (`${result.text} | narrated ${narrated.length} | flagged ${result.flagged.join(',')} | panel ${!!el.querySelector('.lat-ai__btn')} | facts ${cappedFacts}/${uncapped}`);
element + tools: false (packet mode, no function-calling) are what
make ai.insights() mount a real panel button; enable: ['narrative']
is what lets that mounting succeed at all — leaving it out of enable would
return with no panel. enable gates only the three DOM-mounting convenience
methods (insights(), askBar(), actorBar()): the
ai.explain() call right below still runs and returns a result even though
'insights'/'query'/'ask'/'actor' are absent
from this enable list — the programmatic API is never gated, so a host that
wants no AI surface at all simply never calls these methods. reconcile: 'flag'
keeps the ungrounded "900%" in the text
(the default strips it instead) while still reporting it in flagged;
onNarrative fires with the same result explain() returns;
maxColumns: 1 is why the capped run gathers 12 facts against 34 uncapped.
Ask-your-data: autoApply, schemaOptions, redact, router, onQuery, onError, executed
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');
const { createAI } = await import('../packages/modules/ai/index.js');
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'id' }, { field: 'region' }, { field: 'ssn' }],
rows: [
{ id: 1, region: 'EMEA', ssn: 'a' },
{ id: 2, region: 'AMER', ssn: 'b' },
{ id: 3, region: 'EMEA', ssn: 'c' },
],
});
const chart = createHeadlessGrid({ rowKey: 'id', columns: [{ field: 'id' }, { field: 'region' }] });
const router = createDataRouter({ rowKey: 'id', overlap: true });
router.attach(chart, () => true);
const errors = [];
const queried = [];
let capturedSchema = null;
const ask = async (req) => {
capturedSchema = req.schema;
return { actions: [{ type: 'setFilters', filters: { col: 'region', op: 'eq', value: 'EMEA' } }] };
};
const ai = createAI(grid, {
ask, router, autoApply: true, schemaOptions: { maxColumns: 2 }, redact: ['ssn'],
onQuery: (r) => queried.push(r.ok), onError: (e) => errors.push(e),
});
await ai.query('EMEA only');
const chartIds = [];
for (let i = 0; i < chart.rows.count(); i++) chartIds.push(chart.rows.get(i).key);
return (`queried ${queried.join(',')} | schema-cols ${capturedSchema.columns.map((c) => c.id).join(',')} | chart ${chartIds.sort().join(',')} | errors ${errors.length}`);
schemaOptions: { maxColumns: 2 } is why the model is shown only
id,region — ssn is also stripped by redact, so it
never reaches the schema either way; autoApply runs the safe read the moment it
resolves, with no confirm step; router, configured once here rather than passed
to every call, is what fans the answer to the chart with no opts.router anywhere
in this example; onQuery fires with the result, and onError is wired
but silent because nothing failed.
The governed actor: onProposal, executed
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createAI } = await import('../packages/modules/ai/index.js');
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'id' }, { field: 'name' }, { field: 'score', type: 'number', edit: { enabled: true } }],
rows: [{ id: 1, name: 'Alpha', score: 10 }],
});
const proposals = [];
const ask = async () => ({ structured: { edits: [{ match: 'Alpha', column: 'score', value: 50 }] } });
const ai = createAI(grid, { ask, onProposal: (r) => proposals.push(r.ok) });
const result = await ai.propose('set Alpha score to 50');
const report = await ai.applyProposal(result);
return (`proposals ${proposals.join(',')} | diff ${result.diff.map((d) => `old ${d.oldValue} new ${d.newValue}`).join(',')} | applied ${report.applied} | score-now ${grid.rows.byKey('1').data.score}`);
onProposal fires with the built diff before any approval; applyProposal
is the separate human-approval step that actually writes, through the grid's own gated edit
path.
KPI config keys, executed
Three examples covering the eleven KPI config keys not already demonstrated elsewhere in this guide.
The tile hierarchy: tiles, an explicit separator, expanded, onNodeToggle, executed
const { createKPI } = await import('../packages/modules/kpi/index.js');
const HOSTS = [{ id: 1, cpu: 91, mem: 40, lat: 12 }, { id: 2, cpu: 30, mem: 55, lat: 40 }];
const toggled = [];
const kpi = createKPI(null, {
rows: HOSTS, rowKey: 'id',
tree: { separator: '/', expanded: true },
tiles: [
{ id: 'compute/cpu', label: 'cpu', aggregation: 'max', field: 'cpu' },
{ id: 'compute/memory', label: 'memory', aggregation: 'avg', field: 'mem' },
{ id: 'network/latency', label: 'latency', aggregation: 'max', field: 'lat' },
],
onNodeToggle: (e) => toggled.push([e.key, e.expanded]),
});
const topLevel = kpi.nodes().map((n) => n.label).sort();
const key = kpi.nodes()[0].key;
kpi.toggle(key);
return (`${topLevel.join(',')} | tiles ${kpi.model.tiles.length} | toggled ${toggled.map((t) => t.join(':')).join(',')}`);
tiles declares the three measured leaves; separator: '/' is what
derives compute/network from ids that only contain /,
not the default .; expanded: true opens every branch at construction,
so the very first toggle() call closes one rather than opening it;
onNodeToggle reports exactly that.
Grid-bound events: fields, onTileClick, onTileDblClick, onTileContextMenu, onChange, executed
const { createTestDom, TestEvent } = await import('../packages/dom/src/renderer/testdom.js');
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createKPI } = await import('../packages/modules/kpi/index.js');
const dom = createTestDom({});
const el = dom.document.createElement('div');
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ id: 'id', field: 'id' }, { id: 'priority', field: 'priority' }],
rows: [{ id: 1, priority: 'P1' }, { id: 2, priority: 'P2' }],
});
const clicks = [];
const dbl = [];
const ctx = [];
const changes = [];
const kpi = createKPI(el, {
grid,
fields: ['priority'],
tiles: [{ id: 'p1', label: 'P1 jobs', aggregation: 'count', filter: (r) => r.priority === 'P1' }],
onTileClick: ({ id }) => clicks.push(id),
onTileDblClick: ({ id }) => dbl.push(id),
onTileContextMenu: ({ id }) => ctx.push(id),
onChange: (e) => changes.push(e.model.tiles[0].value),
});
const fig = el.querySelector('.lat-kpi__tile');
fig.dispatchEvent(new TestEvent('click', {}));
fig.dispatchEvent(new TestEvent('dblclick', {}));
fig.dispatchEvent(new TestEvent('contextmenu', {}));
return (`p1-jobs ${kpi.value('p1')} | click ${clicks.join(',')} | dbl ${dbl.join(',')} | ctx ${ctx.join(',')} | changes ${changes.length}`);
fields: ['priority'] is what lets the tile's filter read
priority at all on a grid-bound panel — a field a tile does not itself declare is
otherwise not projected; the three pointer events each route to their matching config
callback, and onChange fires once, at construction.
A host catalogue and an unmeasured tile: messages, nullText, executed
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createKPI } = await import('../packages/modules/kpi/index.js');
const dom = createTestDom({});
const el = dom.document.createElement('div');
const HOSTS = [{ id: 1, cpu: 91, mem: 40 }];
const kpi = createKPI(el, {
rows: HOSTS, rowKey: 'id',
tree: { expanded: true },
nullText: 'n/a',
messages: {
t: (key, params) => {
if (key === 'kpi.status.critical') return 'kritisch';
if (key === 'kpi.node.worst') return `schlimmster Status ${params.status}`;
return key;
},
},
tiles: [
{ id: 'compute.cpu', label: 'cpu', aggregation: 'max', field: 'cpu', thresholds: { warn: 70, critical: 90, direction: 'lowerIsBetter' } },
// A tile whose field names no column any row carries: measures nothing.
{ id: 'compute.disk', label: 'disk', aggregation: 'max', field: 'disk' },
],
});
const compute = kpi.nodes()[0];
const diskTile = kpi.tile('compute.disk');
return (`${el.querySelectorAll('.lat-kpi__node')[0].getAttribute('aria-label')} | disk-formatted ${diskTile.formatted} | disk-status ${diskTile.status}`);
messages translates the node's accessible name (falling back to English for a key
the host catalogue omits); nullText is what the unmeasured disk tile
renders instead of a fabricated zero.
Data Router route options, executed
A per-route filter and transform, executed
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }, { id: 'amt', field: 'amt', type: 'number' }, { id: 'label', field: 'label' }],
});
const router = createDataRouter({ key: 'type', rowKey: 'id' });
router.attach(grid, 'order', {
filter: (row) => row.amt >= 20,
transform: (row) => ({ id: row.id, type: row.type, amt: row.amt, label: `#${row.id}:${row.amt}` }),
});
router.load([
{ id: 'o1', type: 'order', amt: 10 },
{ id: 'o2', type: 'order', amt: 30 },
{ id: 'o3', type: 'order', amt: 20 },
]);
const ids = [];
for (let i = 0; i < grid.rows.count(); i++) ids.push(grid.rows.get(i).key);
return (`${ids.sort().join(',')} | label ${grid.rows.value('o2', 'label')}`);
filter gives the grid only the rows at or above 20 — the route decides what the
grid ever sees, before any grid-level filter runs; transform derives the
label field the columns declare, reshaping each row on the way in.
Web component
<lattice-grid> is the grid as a custom element, shipped as a self-contained
module bundle. It exists for pages without a bundler, a Rails, Django or Laravel template
that wants a grid without adopting a front-end build.
The whole integration
<link rel="stylesheet" href="dist/lattice-grid.min.css">
<script type="module" src="dist/modules/webcomponent.esm.min.js"></script>
<lattice-grid row-key="id"
columns='[{"field":"id"},{"field":"city"}]'
rows='[{"id":"A","city":"Leeds"},{"id":"B","city":"Cardiff"}]'></lattice-grid>
Load this or the main bundle, not both. The module is self-contained: it
carries the grid with it, so a page that also loads lattice-grid.esm.js
downloads and evaluates the grid twice.
Driven from script
// Structures are properties; scalars are attributes.
const el = document.createElement('@toclocoinc/lattice-grid');
el.setAttribute('row-key', 'id');
el.columns = [{ field: 'id' }, { field: 'charge', type: 'number' }];
el.rows = data;
document.body.appendChild(el);
// The full imperative API is on `.grid`.
el.grid.sort.set([{ col: 'charge', dir: 'desc' }]);
Assign before appending where you can. The element builds its grid once, at the end of the task in which it connects, so everything set in that task arrives as one configuration rather than as a series of updates. Setting properties later still works: they go through the live configuration path, but the grid is built empty first and repainted after, which is a visible flash on a large set.
Reading .grid forces the build immediately, so the property is never briefly
null.
Events
el.addEventListener('lattice-cell-changed', (e) => {
console.log(e.detail.key, e.detail.colId, e.detail.value);
});
Every grid event is re-dispatched as a CustomEvent named lattice-
plus the grid name with colons hyphenated, so cell:edit:start becomes
lattice-cell-edit-start. The payload is event.detail. The prefix is
not decoration: the grid emits an event called scroll, and an unprefixed
CustomEvent of that name would be indistinguishable from the platform's own.
Forwarding is a wildcard subscription, so events added to the grid later appear here with no
change to the component.
Light DOM, deliberately. The element renders into itself rather than a
shadow root, because the grid's generated decoration rules are injected into
document.head and the theme stylesheet is a <link> the page
owns: neither crosses a shadow boundary. A shadowed grid would be structurally correct and
completely unstyled, and subtly so: the --lattice-* tokens do inherit
through a shadow root, so the colours would arrive while every pill, bar and heat cell stayed
bare. Light DOM keeps every documented theming route working unchanged.
Events
One bus, 111 events. The table below is the working set, the ones most applications actually reach for; a complete index by area follows it, and the reference lists them all.
| Event | Carries | Use it for |
|---|---|---|
| ready | {} | First render is done. Fires on a future turn, so you can subscribe on the line after createGrid. |
| cell:changed | { row, key, colId, value, oldValue, undo } | Persisting an edit. undo distinguishes a rollback from a fresh change. |
| selection:changed | { keys } | Enabling a bulk action. |
| range:changed | { ranges } | A status bar showing the sum of what is selected: see selection.summary(). |
| sort:changed / filter:changed | { sort } / { filters } | Reflecting the view in the URL. |
| history:changed | { canUndo, canRedo, undo, redo } | Driving your own undo button. Emitted after the entry is pushed, so the label is right. |
| history:applied | { direction, step } | An action was undone or redone, with which and what. history:changed also fires when a new action is pushed, so it cannot distinguish the two. |
| form:saved | { key, values, changed, unmapped } | Persisting a row edited on a form. unmapped names the fields that are not columns, which the grid reports rather than writes. |
| form:error | { key, error, timedOut } | A row form's load failed or ran out of time. The panel stays open with a retry. |
| view:saved / :removed | { view, views } | Persisting saved views to a server. |
| render:done | { first, last } | Decorating cells from outside. The cell layer rewrites class names on every paint, so anything added before this is erased. |
Every event, by area
The table above is the working set. This is the whole list, grouped by what it is about, so a question like "can I hear about a column being pinned?" is answerable by scanning rather than by reading the reference end to end.
Lifecycle
| Event | Fires when |
|---|---|
| config:changed | A configuration key changed. Emitted after the grid has rebuilt, so a listener reading the grid back sees the change rather than what it replaced. |
| destroy | grid.destroy() has run. |
| licence:changed | A key was installed, and again when verification settles. |
| ready | First layout is complete and the API is safe to drive. |
| render:done | The cells are written and stable. Anything decorating them from outside must run after this, the cell layer rewrites each cell's className wholesale and would otherwise erase it. |
| render:first | First paint, the number to measure time-to-first-row against. |
Data
| Event | Fires when |
|---|---|
| model:changed | Columns, grouping, pivot or another structural change. |
| row:clicked | Emitted alongside the cell event, cell first. |
| row:copied | A row was duplicated. |
| row:dblclicked | A row was double-clicked. Emitted alongside the cell event, cell first. |
| row:edit:end | In row mode an invalid cell blocks the whole commit and the session stays open. |
| row:edit:start | Replaces the cell pair when edit.mode is 'row'. |
| row:moved | A row was dragged to a new position. |
| row:received | A row arrived from a source. |
| row:sent | A row was written back to a source. |
| rowDrag:started | A row drag began. Fires on the grid the drag started in, as do rowDrag:moved, rowDrag:left and rowDrag:ended. |
| rowDrag:moved | The drag is over a candidate position. Coalesced to one event per animation frame, carrying that frame's latest pointer position. |
| rowDrag:left | The pointer left a grid; over names the grid it left. Emitted on the transition, not on a frame. |
| rowDrag:ended | The gesture ended, whether or not a drop followed, including a release outside every grid. dropped says whether the release is being acted on. Notifications only — none of the four is cancellable. |
| rows:changed | The row set changed. See what a change firing promises: identified means the three arrays name the rows that moved, companion marks a duplicate announcement of a change already made with identity, and a firing with neither is a real change of unknown extent. |
| rows:deferred | Updates were held rather than applied, because an edit is in flight. |
| rows:paused | A live feed was paused; updates queue from here. |
| rows:queued | A batched change is waiting for the next frame. |
| rows:resumed | The feed resumed and the queue drained. |
| source:error | A source or block load failed. |
| stream:chunk | A streamed chunk landed. |
| stream:end | Streaming finished; promoted means it switched to in-memory. |
| stream:evicted | A streaming source dropped rows to stay within its cap. |
Cells and editing
| Event | Fires when |
|---|---|
| cell:changed | A committed edit reached the data. undo distinguishes a rollback. |
| cell:clicked | A cell was clicked. Announcement only: nothing is consumed, so editing and selection behave unchanged. |
| cell:confirmed | The write reached the server. |
| cell:conflict | The write was accepted but the server row had moved underneath it. Last-write-wins with the divergence surfaced: your value stands and serverRow carries the server's truth. |
| cell:contextmenu | Right-click on a cell. |
| cell:dblclicked | A cell was double-clicked. Carries the row, column, value and text. |
| cell:mouseover | The pointer entered a cell. Fires once per cell, carries what cell:clicked carries plus the cell element as target, and is delegated on the viewport so it stays correct over pooled rows. |
| cell:mouseout | The pointer left a cell. Fires once per cell, including when the pointer left the grid; moving to the next cell fires this first, then cell:mouseover. |
| cell:mousedown | A pointer button went down on a cell. Carries what cell:clicked carries plus the cell element as target, and is delegated on the viewport so it stays correct over pooled rows. |
| cell:mouseup | A pointer button was released over a cell. Same shape as cell:mousedown. |
| cell:edit:end | It closed: committed or cancelled. |
| cell:edit:start | An edit session opened. |
| cell:pending | Applied optimistically, not yet durable. Only with edit.commit. |
| cell:reverted | The write failed. applied: false means a newer edit owned the cell, so nothing was written back. |
| row:pending | A row was appended or deleted optimistically, not yet durable. kind is 'append' or 'delete'. Only over a source that declares mutate.append/delete. |
| row:confirmed | The append or delete reached the server. An appended row has already been rekeyed from its temp key to the server key, and selection, expansion, focus and in-flight cell edits followed. |
| row:reverted | The append or delete failed: an appended row is removed, a deleted row restored. applied: false means a newer op owned the key, so nothing was undone. |
| row:conflict | The op succeeded but the server row had moved underneath it. Last-write-wins with the divergence surfaced: serverRow carries the server's truth. |
| form:closed | The row form closed without saving. |
| form:error | A commit from the form failed validation or was rejected. |
| form:opened | The row form opened. |
| form:saved | The row form committed. |
Columns
| Event | Fires when |
|---|---|
| column:filter:open | Header filter popup opened. |
| column:profile:open | The column statistics ("describe") panel was asked to open on a column, from the column menu's "Column statistics" item ({ colId }). A mounted tool panel opens its statistics panel seeded on that column. |
| column:grouped | The row-group column list changed. |
| column:menu:open | Header menu opened. |
| column:moved | Reordered by drag or by API. |
| column:pinned | side is 'start', 'end' or null. |
| column:pivoted | The pivot configuration changed. |
| column:resized | A column width settled after a drag or a keyboard resize. |
| column:visible | Columns shown or hidden. |
| columns:changed | The column set was replaced or reordered wholesale. |
| columns:tagged | A column's tags changed. |
Query and view
| Event | Fires when |
|---|---|
| facet:computed | A header histogram finished counting. Carries the column and the buckets. |
| facet:expanded | The facet band was opened or collapsed. |
| facet:failed | A distribution could not be computed. Carries the reason. |
| facet:filtered | A bucket or a dragged range was applied as a filter. |
| filter:changed | The condition tree or the quick filter changed. |
| page:changed | Fired after the rows have moved, whether the page changed by API or by the pager control. |
| sort:changed | The full sort entry list. |
| state:changed | Every state change, whether a user gesture or a programmatic call — including a named filters.where predicate registered, replaced, removed or reapplied (BACKLOG-0001235) — announced exactly once. cause is 'user', 'apply' or 'reset'; sections names the GridState keys that moved; report lists anything a restore could not apply. A save layer subscribes to this one event and ignores cause 'reset'. |
| state:reset | The grid was returned to its baseline. |
| timeline:attached | A time brush was connected to the grid. |
| timeline:detached | The brush was removed. |
| timeline:seek | The brush settled on a range. |
| timeline:seeking | The brush is being dragged. Throttled. |
| annotation:changed | A drawing annotation was added, edited or cleared. Carries the active tool and the mark count. Declared (BACKLOG-876) so grid.on('annotation:changed', ...) and the adapters' onAnnotationChanged reach it directly instead of via the '*' wildcard. |
| view:applied | Emits no storage write: applying a view changes nothing to persist. |
| view:default | view is null when the default was cleared. |
| view:removed | A saved view was deleted. |
| view:renamed | A saved view was renamed. |
| view:saved | Carries the one view that moved: enough to POST a single record without diffing two lists. |
Selection and interaction
| Event | Fires when |
|---|---|
| detail:toggled | A master-detail row opened or closed. |
| group:toggled | A group row opened or closed. |
| history:applied | An action was undone or redone. Distinct from history:changed, which also fires when a new action is pushed onto the stacks and so cannot tell you anything was reversed. |
| history:changed | Emitted after the entry is pushed, so a toolbar reading it names the right action. Repainting from sort:changed instead reads the timeline one action behind. |
| range:changed | Cell range selection changed. |
| scroll | Throttled to the frame. |
| scroll:end | Scrolling settled, the moment to trigger deferred work. |
| selection:changed | The selected rows changed. Carries the keys. |
| size:changed | The viewport resized. |
Cancellable before-events (BACKLOG-0000943)
Every user-initiated mutation has a paired cancellable before event. The handler
receives a BeforeEvent carrying the action context plus preventDefault(reason?),
defaultPrevented and reason. Calling preventDefault() — or
returning false, the legacy kanban onBeforeMove idiom — cancels the action.
A handler may be async; the mutation is held until every registered before-handler
settles, so a confirm dialog or a server check genuinely gates the write. Any one handler
preventing cancels it (veto wins), and a handler that throws is treated as a cancel and surfaced.
On a veto the paired <action>:cancelled event fires carrying the reason.
These fire for user actions only. Host/API writes (for example
grid.edit.setCells) and remote/router-applied deltas (rows.apply,
origin !== 'user') do not fire them — remote truth is not a user gesture and
does not self-veto. The origin field carried on each before-event lets a host
deduplicate a module-initiated write (a kanban or Gantt move that re-enters core) from a genuine
user gesture. If a handler was async and the underlying state moved during the await
(a row removed, a value changed by a live delta), the gate re-validates and cancels with reason
'stale' rather than applying against state that has moved. With no before-handler
registered every mutation stays synchronous and behaves exactly as before.
| Event | Fires when |
|---|---|
| beforeEdit | Before a validated cell/row commit applies. Carries row, key, mode, changes, origin. Validation (edit.validate) is separate and runs first. Paired with edit:cancelled. |
| beforeSort | Before a sort is set. Paired with sort:cancelled. |
| beforeFilter | Before a structured or quick filter is set (kind tells them apart). Paired with filter:cancelled. |
| beforeColumnMove | Before a column reorder applies, earlier than the post-mutation column change. Paired with columnMove:cancelled. |
| beforeColumnResize | Before a column width change applies. Paired with columnResize:cancelled. |
| beforeColumnHide | Before one or more columns are hidden. Paired with columnHide:cancelled. |
| beforeSelect | Before a user selection change applies; a veto snaps back to the last announced selection. Paired with selection:cancelled. |
| beforeRowAdd | Before an optimistic row append applies. Paired with rowAdd:cancelled. |
| beforeDelete | Before an optimistic row delete applies — the canonical confirm-before-delete hook. Paired with delete:cancelled. |
| beforeRowMove | Before a row reorder applies. Paired with rowMove:cancelled. |
| beforeGroup | Before a group/tree expand or collapse applies. Paired with group:cancelled. |
| beforeRowReceive | Before a row dragged from another grid is inserted into this one; fires on the receiving grid and names the row under the pointer (overKey). A veto leaves the source grid untouched. Paired with rowReceive:cancelled. |
| edit:cancelled | A beforeEdit was vetoed; reason is 'stale' when a live delta moved the cell during an async gate. |
| sort:cancelled | A beforeSort was vetoed. |
| filter:cancelled | A beforeFilter was vetoed. |
| columnMove:cancelled | A beforeColumnMove was vetoed. |
| columnResize:cancelled | A beforeColumnResize was vetoed. |
| columnHide:cancelled | A beforeColumnHide was vetoed. |
| selection:cancelled | A beforeSelect was vetoed; the selection snapped back. |
| rowAdd:cancelled | A beforeRowAdd was vetoed. |
| delete:cancelled | A beforeDelete was vetoed; reason is 'stale' when the row was already gone. |
| rowMove:cancelled | A beforeRowMove was vetoed; reason is 'stale' when the row had moved. |
| group:cancelled | A beforeGroup was vetoed. |
| rowReceive:cancelled | A beforeRowReceive was vetoed; nothing was inserted and the source still holds the row. reason is 'stale' when the row under the pointer or the source row was gone by the time an async handler settled. |
| export:request | A remote export was requested. Past-tense notification. |
| export:done | A remote export completed. Past-tense notification. |
| shortcuts:opened | The keyboard-shortcuts help overlay opened. Past-tense notification. |
| shortcuts:closed | The keyboard-shortcuts help overlay closed. Past-tense notification. |
| print:before | Print mode is about to snapshot. Past-tense notification, not cancellable (BACKLOG-0000941). |
| print:after | Print mode restored the grid, even if the browser cancelled the print (BACKLOG-0000941). |
Presentation and formatting
| Event | Fires when |
|---|---|
| formatting:changed | A conditional formatting rule was added, edited, reordered or restated. |
| highlight:changed | A highlight was added or cleared. |
| find:changed | The find query, its matches, the current match or the bar's open state changed; carries the FindCount, partial while the sliced scan runs. |
| permissions:changed | The context moved and every column re-resolved. |
| presentation:captured | A PNG was taken. |
| presentation:changed | The options of a running presentation changed. |
| presentation:ended | Presentation mode ended. Annotations are cleared here. |
| presentation:scale | The presentation zoom changed. |
| presentation:spotlight | A region was spotlit or released. |
| presentation:started | Presentation mode began. Carries the scale, options, views and starting index. presentation:changed covers a later change to the same options, so a listener can tell entry from adjustment. |
| presentation:view | The presentation advanced to another saved view. |
| redaction:changed | A column was redacted or restored. |
Collaboration
| Event | Fires when |
|---|---|
| comment:added | A comment was posted. |
| comment:deleted | A comment was removed. |
| comment:edited | A comment was changed. |
| comment:failed | A comment could not be saved. Carries the reason. |
| comment:indexLoaded | The comment index finished loading, so indicators can paint. |
| comment:resolved | A thread was marked resolved. Carries the cellKey. |
| comment:threadClosed | A thread was closed or resolved. |
| comment:threadOpened | A thread was opened in the panel. |
| comment:unresolved | A resolved thread was reopened. Carries the cellKey. |
| presence:failed | A presence transport error. Presence is lossy by design; this is informational. |
| presence:joined | A peer was seen for the first time. Carries the peer. |
| presence:left | A peer disconnected. |
| presence:lockRefused | An edit was refused because a peer holds the cell. |
| presence:published | This client's cursor or selection was broadcast. |
| presence:updated | A known peer moved or changed selection. Carries the peer. |
Everything else
| Event | Fires when |
|---|---|
| clipboard:copy | A copy left the grid. |
| diff:changed | A snapshot was set or cleared. |
| diff:swapped | The baseline and the current rows were exchanged. |
| export:progress | Progress on a streamed export. |
| header:contextmenu | A column heading was right-clicked. |
| toolpanel:focus | The documented keyboard shortcut reached the tool panel. |
| tree:loadAborted | A child fetch was cancelled, usually because the node collapsed. |
| tree:loadFailed | A child fetch failed. |
| tree:loaded | Children arrived. Carries the key and the count. |
| tree:loading | Children are being fetched for a node. |
| views:changed | The whole list, plus what moved and why. |
Licensing
There is one Lattice Grid and every copy is feature-identical. No community edition, no pro tier, no feature held back behind a key. A licence removes the trial watermark; that is the whole of what it does.
Free to develop against, licensed to deploy. A grid on localhost, or any loopback host: needs no key at all. On any other domain an unlicensed grid still renders everything and carries a small trial watermark linking to latticegrid.dev.
| Where it runs | No key | Valid key |
|---|---|---|
| localhost, *.localhost, 127.0.0.0/8, ::1 | everything, no mark | everything, no mark |
| any other domain | everything, trial watermark | everything, no mark |
.local, .internal and private IP ranges are not exempt. They are ordinary LAN names, and a corporate intranet is a deployment like any other.
Installing a key
LatticeGrid.setLicence('LG1.…'); // your key; setLicense also works
grid.licence.state(); // 'licensed' | 'localhost' | 'trial'
Call it once, before creating a grid. Setting a key later still works, the watermark comes
off and licence:changed fires, but the first frames of the grid will carry it.
Keys come from latticegrid.dev and are issued per
deployment rather than per developer or per seat: name the domains the grid will run on and
one key covers every developer, every build and every user on them. A key names the domains
it covers as you would expect: *.acme.com matches app.acme.com,
a.b.acme.com and acme.com itself.
Checking a key needs no network. There is no licence server, no call home, and nothing that can fail at three in the morning, a key carries its own answer and the grid reads it locally, so a grid on an air-gapped network behaves exactly like one on the open internet.
Nothing ever refuses to render, and nothing is ever withheld. An expired key, a wrong domain, a key that will not read: all of them log one warning and show the watermark. Every feature keeps working. The failure to avoid is a customer's production screen going blank because a licence lapsed over a weekend, and a grid that quietly drops a feature is the same failure wearing a disguise.
Recipes
Put the view in the URL
grid.on('state:changed', () => {
const encoded = btoa(JSON.stringify(grid.state.get()));
history.replaceState(null, '', `?view=${encoded}`);
});
const saved = new URLSearchParams(location.search).get('view');
if (saved) grid.state.apply(JSON.parse(atob(saved)));
Save edits as they happen
grid.on('cell:changed', async (e) => {
if (e.undo) return; // a rollback, not a new change
grid.highlight({ key: e.key, colId: e.colId }, { colour: '#fff3cd', duration: 0 });
try {
await api.patch(`/rows/${e.key}`, { [e.colId]: e.value });
grid.highlight({ key: e.key, colId: e.colId }, { colour: '#d4edda', duration: 900 });
} catch {
grid.highlight({ key: e.key, colId: e.colId }, { colour: '#f8d7da', duration: 0 });
grid.history.undo();
}
});
A read-only grid
createGrid(el, {
columns, rows, rowKey: 'id',
edit: false,
contextMenu: false, // the default menu offers Paste, Clear and Fill down
columnMenu: false, // optional: the header's 3-dot menu
selection: { ranges: false },
});
A dashboard grid, no chrome
createGrid(el, {
columns, rows, rowKey: 'id',
edit: false, contextMenu: false, selection: 'none',
rowHeight: 24, density: 'compact',
grandTotalRow: 'bottom',
});
House-wide defaults, without patching createGrid
createGrid is exported through a getter with no setter, so
LatticeGrid.createGrid = myWrapper does not replace it — silently in a plain
script, with a TypeError in a module (see the reference for the exact descriptor). Own the seam
yourself instead: one module that every call site imports from.
// lattice.js
import { createGrid as baseCreateGrid } from '@toclocoinc/lattice-grid';
export function createGrid(element, config) {
return baseCreateGrid(element, { theme: 'house', locale: 'en-GB', ...config });
}
There is no shipped defaults() call that does this for you today (a separate card,
BACKLOG-0001187, is considering one) — a wrapping module you own and every call site imports is
the supported pattern until then.