Lattice Grid Buy a licence

api reference

The grid

Constructing a grid, every configuration property, the column definition, each grid.* sub-API, the sources rows come from, and every event the grid raises.

API reference › The grid

All 13 pages Everything on one page → Developer guide →

Construction

Two files are all you need: a stylesheet and a script. Nothing is fetched at runtime (no CDN, no font, no icon sprite) however the two files themselves got there.

<!-- Script tag, from your own build. Everything is on one global. -->
<link rel="stylesheet" href="dist/lattice-grid.min.css">
<script src="dist/lattice-grid.min.js"></script>

<script>
  const grid = LatticeGrid.createGrid(document.getElementById('grid'), config);
</script>

Or straight from jsDelivr, no npm install, no bundler, no local copy at all:

<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>

Or as modules, importing the bundle by path: your own build, npm, or jsDelivr:

// With a renderer, in a browser. Any of:
import { createGrid } from './dist/lattice-grid.esm.js';
// import { createGrid } from '@toclocoinc/lattice-grid';
// import { createGrid } from 'https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1/lattice-grid.esm.min.js';
const grid = createGrid(document.getElementById('grid'), config);

// Headless: the same API without a renderer. Data, filters, sort,
// grouping, totals, formatting and export all work; grid.element is
// null and the DOM-only chrome is simply absent.
// Runs in Node, for tests and server-side export.
import { createHeadlessGrid } from '@toclocoinc/lattice-grid';
const grid = createHeadlessGrid(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 any module: .../modules/htmx.esm.min.js, .../modules/react.esm.min.js, and so on.

Configuration properties

Every key is settable at runtime through grid.set(key, value). Keys marked dom are read by createGrid and ignored by a headless grid.

Data and structure

PropertyTypeDefaultDescription
columns(Column | ColumnGroup)[], Column definitions. Groups may nest.
columnGroupsColumnGroup[], Header grouping declared separately from the columns.
rowsunknown[], Row objects. The array is copied on ingest; the row objects in it are not. The grid keeps a shallow copy of the array you pass here (and of source.rows, and of the array given to rows.load()), so your array is never written to: after rows.apply, a sort, a group or an edit it holds exactly what it held when you passed it, and two grids built from one array are independent. The objects inside it are still yours - row.data is the object you supplied and rows.data() returns those same objects, so identity round-trips (see ingest.retainSource). rows.apply({ update }) does not write through either: it merges into a new object, which replaces that slot in the grid's copy only, so row.data for an updated row is a new object and the one you passed is untouched. An in-place cell edit does: edit.setCells, or typing in a cell, writes the new value into the shared object, which is the other face of row === sourceObject. ingest.retainSource: false and ingest.dropSourceRows opt out of sharing altogether.
rowKeystring | (row) => string, Stable row identity. Without it the grid assigns a key per row object and warns: enough for sorting, filtering, selection and copying within a session, but change tracking, streaming dedupe, selection persistence and remote reload all switch off, because new objects are new rows. If it is configured but resolves to nothing for some rows - a field absent, or present on some rows only - those rows collapse onto one key and the grid warns once, naming the field(s) and how many rows were affected, on rows.load() as well as at construction.
sourceSourceConfigmemoryWhere rows come from: memory, paged, remote or stream. See Sources.
ingestIngestConfig, { retainSource, dropSourceRows }. How rows enter the column store. retainSource defaults to true: the caller's row objects are held by reference so rows.data() returns them unchanged and row === sourceObject holds. Set it false to stop the store retaining them and reconstruct a row on demand - but the source layer and grid config still hold the array, so the resident footprint does not actually fall. dropSourceRows: true closes that gap: it releases the objects from the source layer too, so the packed columns become the only copy and the footprint drops by roughly an order of magnitude at scale. Either way rows.data() then returns freshly reconstructed objects, so identity checks and row.sourceObject no longer hold and equality becomes value-based. Cell values are unchanged.
treeTreeConfig, { path } or { parentKey }, plus label, orphans. Rows form a hierarchy. See Tree data.
detailDetailConfig, { rows, config, render, isMaster, height, cacheLimit, target }. A master row expands into a nested grid, inline or into an element you supply. See Master-detail.
contextunknown, Arbitrary value passed to every callback, so formatters and renderers need no closures over app state.

Defaults and registries

PropertyTypeDescription
columnDefaultsColumnMerged under every column before its own definition.
columnPresetsRecord<string, Column>Named bundles applied with preset: 'money'.
dataTypesRecord<string, DataType>Custom types. Registered ahead of the built-ins, so a name here overrides one of ours.
sampleSizenumberValues read per undeclared column when inferring its type. Default 100.
targetSize'default' | 'large' TargetSizeRaises every interactive target to a comfortable size for touch, leaving the type alone. Applied automatically on a coarse pointer; 'default' opts out of that.
componentsRecord<string, Ctor>Renderers, editors and filters addressable by name.
pipesRecord<string, fn>Template pipes for cell.template.
totalFnsRecord<string, TotalFn>Custom aggregations, addressable from column.total.
variantsRecord<string, VariantDefinition>Semantic colour tokens for decorations.

Behaviour

PropertyTypeDefaultDescription
selectionSelectionConfig | 'single' | 'multiple' | 'none', Object form adds checkbox (a pinned column of row checkboxes), headerCheckbox (tri-state select-all in its heading), checkboxOnly (only that column may change selection - for a row with its own click action), groupSelectsChildren, ranges, fillHandle, fill. See Selection and ranges.
editEditConfig | boolean, { enabled, mode: 'cell' | 'row', start: 'single' | 'double' | 'key', enterMovesDown, undoDepth, commit, confirm, pendingTimeout, pastePreview }. commit/confirm/pendingTimeout turn on optimistic writes; pastePreview (default off) shows a confirm/cancel diff before a bulk paste commits.
paginationPaginationConfig | boolean, Local or remote paging.
quickFilterTextstring, Initial quick-filter term. Equivalent to grid.filters.quick(text).
hostFilter{ active(), passes(row) }, An application-level predicate composed with the grid's own filters.
pivotobject, { enabled, groupTotals, totalsLabel, maxColumns, separator }. groupTotals: 'before' | 'after' adds a column group totalling every value column across all pivot values, at the near or far edge; omitted, it adds none. totalsLabel heads it, defaulting to Total. maxColumns defaults to 500, counts the totals group, and fails with a message rather than locking the browser.
grandTotalRowboolean | 'bottom'falsetrue puts it inline at the end of the rows; 'bottom' pins it above the status bar. Maintained incrementally on a memory source: see Grouping, totals and pivot.
pinnedTopRowsobject[], Rows held above the scrolling body. Rendered through the ordinary column pipeline, but not part of the data: not counted, sorted, filtered, grouped, selectable or exported. See Pinned rows.
pinnedBottomRowsobject[], As pinnedTopRows, held below the body instead. Sits under the grand total when both are shown.
fullWidth{ when, render }, Draw matching rows as one band across every column instead of dividing them into columns, a section banner, a note, a “load more” affordance. when(row) picks them, render(params) fills them. Still ordinary data rows in every other respect. See Full-width rows.
groupRenderer(params) => string | Node | void, Draw the group row yourself - a section header with a chevron, a rollup, a count, a progress bar - instead of the grid's expander-and-label. The row is drawn as one band across every column and no ordinary cells are mounted underneath it. Return an HTML string, a node, or write into params.element. A string is inserted as markup here, unlike fullWidth.render, because a group heading is synthesised by the grid and has no data row: the string can only be your own template, the same contract the board's cardRenderer has. The renderer is handed the group key, the grouped column id, the value, the level, the expanded state, leafCount, the group's totals and leaves() for the rows themselves. Mark any element in your markup data-lat-group-toggle to make it expand and collapse the group. See Group rows you draw yourself.
groupDefaultExpandedboolean | number | (group) => boolean, Which groups start open before anyone has touched one. true (the default) opens every group, false closes every group, a number opens the first N levels (0 closes everything, a negative opens every level), and a predicate answers per group - the current sprint open while the rest start closed. It is handed { key, column, value, level, path }. Only ever consulted for a group nobody has expanded or collapsed: once the user or your code decides, that decision stands. See Group rows you draw yourself.
groupFooterbooleanfalseA closing total row per group.
totalFilteredOnlybooleantrueTotals reduce the filtered set. false totals the whole dataset, group totals included. See Grouping, totals and pivot.
totalOnlyChangedColumnsbooleanfalseReduce only the totalled columns an edit actually changed. Off by default, it asserts that each total depends on nothing but its own column. See Grouping, totals and pivot.
showTotalInHeaderbooleantrueUnder grouping or pivot, a totalled column's heading names its reduction on a line above the column name. See Grouping, totals and pivot.
aggregateChooserbooleanfalseLet the user pick a column's reduction from the column menu. On, the totalling entry becomes an Aggregate submenu offering only the aggregates the column's type says are meaningful (sum, average, min, max, count and so on - never sum on a category column), with the current one ticked and a None to stop totalling; it is keyboard-operable through the standard menu and drives grid.columns.setTotal(), reusing the existing reduction model. Off by default and non-breaking: the menu keeps its plain Total this column toggle. See Grouping, totals and pivot.
allowUnsafeTemplatesbooleanfalseOff by default. Templates are escaped unless this is explicitly set. When set, an interpolated value may contain presentational markup, but script is still removed from it: <script>, <iframe> and the other executable tags, on* handler attributes, and javascript: URLs. The flag permits markup, not code.
licencestring, Signed licence key. Removes the trial watermark; unlocks nothing, because nothing is locked.

Presentation

PropertyTypeDefaultDescription
messagesobjecten-GBReplaces the grid's own text: labels, menus and screen-reader announcements. A partial catalogue laid over the built-in British English one, so anything you leave out stays in English. Twenty catalogues are bundled: EN_GB, EN_US, FR_FR, FR_CA, IT_IT, ES_ES, PT_BR, DE_DE, NL_NL, SV_SE, DA_DK, NB_NO, FI_FI, PL_PL, CS_CZ, HU_HU, RO_RO, UK_UA, EL_GR, JA_JP and AR. They are exports of the package, not separate files, so importing one does not reduce what is bundled. EN_US is a partial overlay carrying only what differs from British English. AR_SA is an alias for AR: the Arabic catalogue is pan-Arabic, and a region appears in a name only where two variants ship. resolveCatalogue(tag) finds the catalogue for any tag, so resolveCatalogue('es-MX') returns the Spanish one. Every key is listed in MESSAGE_KEYS; auditCatalogue() reports what a catalogue of your own is missing.
localestringruntimeBCP-47. Drives every formatter and one shared Intl.Collator.
direction'ltr' | 'rtl' | 'auto' DirectionautoWriting direction. Left unset, it follows the element's computed dir and then the locale, so locale: 'ar' renders right to left without further configuration. Set it explicitly to override both.
theme'light' | 'dark' | 'high-contrast' | 'terminal' Theme, Stamped as data-theme on the grid's root. Unset follows the viewer's prefers-color-scheme. See Theming.
density'compact' | 'standard' | 'comfortable' | 'spacious' | number Density'compact'One scale that every geometry token derives from: row height, spacing, decoration sizes, and type at a damped rate. Row heights are 23.8 / 28 / 42 / 56px. A number scales 28px, so 1.4 gives 39.2px for anything between the presets. Virtualisation follows it; an explicit rowHeight overrides it.
rowHeightnumber | (row) => number28A function enables variable-height rows.
headerHeightnumber32Per header row.
titlestring, A caption drawn above the column headings. Inside the grid rather than an element placed above it, so it scrolls with the grid, sits in the region a screen reader announces, and is kept by image capture and print.
showHeaderbooleantrueDraw 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.
overscannumber4Rows rendered beyond the viewport.
autoHeightboolean | 'visible', Size rows to their content: cells wrap instead of ellipsising, and each row takes the height its tallest cell needs. Only rendered rows are measured either way, the difference is that true stops measuring above 10,000 rows and returns to fixed heights, while 'visible' keeps measuring at any size and accepts a scrollbar that shifts as rows are measured on the way past.
columnVirtualisationAbovenumber30Column count above which columns virtualise too.
stateGridState, Restore a saved view at construction.

Performance

PropertyTypeDefaultDescription
useWorkerbooleantrueCompute column distributions off the main thread. Sorting, filtering and grouping run on the main thread.
workerThresholdnumber50000Row count above which a distribution is sent to the Worker.
workerUrlstring, External worker file, for a CSP that forbids blob:. Settled when the Worker is built; changing it rebuilds one.
sharedMemorybooleanfalsePass columns to the worker in a SharedArrayBuffer instead of copying them, where the page is cross-origin isolated. Retains a shared copy of each column that crosses.

Chrome dom

PropertyTypeDescription
statusBarboolean | { panels }Composable panels along the bottom. Default set: rowCount, selectedCount, aggregation, comments, updates, progress. Each is silent when it has nothing to report.
maximisebooleantruefalse removes the rail button and grid.maximise, for an application with its own full-screen mode.
toolPanelboolean | objectSide dock. panels: columns, filters, views, quick, formatting, statistics, regression. side: 'left' makes it the icon rail, which also turns on actions (undo, redo, pause, restore, maximise, then the export group: export, excel, clipboard, print: nine in all, and an array takes these names rather than the button labels) and icons. An explicit array replaces that list rather than extending it; a bare '-' in it renders a divider between groups. exportName names the CSV. annotate is a three-state option, not a boolean flag. annotate: true adds the native annotation tools - pen, arrow, rect, highlight - to the rail as toggle buttons (pressed while in use, pressed again to exit), and they stay put whether or not a presentation is running. annotate: false opts OUT: none of the four tools are ever added, presentation or not. Omitting annotate keeps the default: the tools are off until a presentation starts, appear for its duration, and leave when it ends.
groupPanelboolean | objectA drag-and-drop group-by strip above the column header - the row-group panel. Drag a heading into it to group by that column; the active groups show as removable, reorderable chips, and reordering the chips changes the nesting order. It is keyboard-operable - arrows move between chips, Shift with an arrow reorders, Delete ungroups, and an add control groups any column - and every change is announced through the live region. Off by default and non-breaking; it drives the same model as grid.columns.group() and reimplements nothing. The object form takes hint, the placeholder shown while nothing is grouped.
kpisStatConfig[]A built-in KPI/stat strip: a labelled band of stat tiles the grid places for you above the column header. Each entry is a createStat spec - of, fn, title, interval, footer, format and the rest, minus grid and container, which the grid supplies - so a strip tile and a hand-placed one are the same object. The tiles follow the grid's filters, recomputing on every change like a stand-alone stat does. Off by default and non-breaking; it reuses createStat and reimplements no compute.
timeZonestringAn IANA zone every date column formats and parses in, so a grid shows one zone whatever the viewer's machine says. Individual columns may override it.
formulaFunctionsobjectYour own functions, added to the formula language by name. The built-in list is closed on purpose; this is the one way in, and a function you add is called exactly as a built-in is.
formattingobjectConditional formatting rules to seed, keyed by column id or '*'. The same shape grid.formatting.all() returns, so a saved view can be handed straight back.
facetsboolean | objectHeader histograms that double as a filter. collapsed, height, and per-column strategy and buckets.
updatesobjectHow a live feed behaves: batching, the queue that holds while paused, and the highlight a changed cell flashes.
commentsobjectThreaded cell comments: storage, the current author, and whether the indicator shows on an unread thread.
presenceobjectLive cursors, selections and edit locks. Carries intent and never values; see grid.presence.
environmentfunctionExtra fields for the diagnostics bundle: build number, tenant, region. Called when a bundle is taken, never on the render path.
contextMenuboolean | (p) => MenuItem[]Right-click menu. The function form is (params, defaults) => items: see custom items. false suppresses it: what a read-only grid wants, since the default menu offers Paste, Clear and Fill down. A column takes its own contextMenu (also accepting a bare MenuItem[], which is appended after the grid-level items), which composes onto this one as a chain and outranks it on suppression.
columnMenuboolean | (p) => MenuItem[]The header's 3-dot menu, and a right-click on a column heading. The function form is (params, defaults) => items, with params carrying colId, column and grid: see custom items. false suppresses it.
rangeChartfn | { onChart } | booleanOff by default. Offers Chart selection in the cell menu and binds Alt+F1 when a selected range has a number to plot. The DOM layer draws no charts, so the handler you give - a function, or { onChart }, called (grid, range) - is where the page wires in chartRange from the charts module.
shortcutsbooleantrueThe ? keyboard shortcut overlay. false suppresses it, for a host that wants ? for itself. See Keyboard.
findboolean | FindConfigtrueThe in-grid find bar: Ctrl+F (Cmd+F) with focus in the grid opens it; typing highlights every matching cell in place without filtering a row away; Enter / Shift+Enter step through the matches. { shortcut, debounce }: shortcut: false keeps the bar reachable through grid.find.open() only; debounce is the typing quiet period in ms (120). false removes the bar and the binding; grid.find(text) still searches. See Find.
rowReorderboolean | { column }, Let a user reorder rows by dragging a handle or with Alt+Shift+arrows. The handle goes in the first visible column unless column names another. Refused, with a reason announced, while a sort, filter or grouping is active. See Row reorder.
rowTransferboolean | { send, receive, mode, group }, Let rows be dragged between grids. Off by default. send and receive are both on when present, so one-way is { receive: false } or { send: false }. mode: 'copy' leaves the row behind; group restricts which grids may exchange. See Moving rows between grids.
alignedGridsGrid[], Other grids to stay column-aligned with. Widths, order, visibility, pinning and horizontal scroll are shared; sort, filters, selection and rows stay independent. Declare it on the grid created last. See Aligned grids.
stickyGroupHeadersboolean | number | { depth }falseKeep the enclosing group headings pinned above the viewport while scrolling inside a group. Off by default; true turns it on and stacks at most two, a number sets the cap, and each costs a row of viewport. See Sticky group headings.
gridLinesboolean | 'both' | 'horizontal' | 'vertical' | 'none''horizontal'Which rules are drawn between cells. Horizontal is what the grid has always drawn; vertical rules are additive. 'rows' and 'columns' are accepted aliases. Only the rules between data are affected, the header underline and pinned seams are structure.
cornerRadiusboolean | number | string, Round the grid's outer corners. true adopts the theme's radius, a number is pixels, a string is used as written.
stripedRowsbooleanfalseShade alternate data rows (zebra striping). Strictly opt-in, so an existing grid is unchanged on upgrade. Parity follows each row's logical index, so a stripe survives a scroll; group headings, footers and the grand total are never striped; selection and hover still win. Uses the theme's --lattice-surface-alt, so dark, high-contrast and terminal come for free.
verticalAlign'top' | 'middle' | 'bottom' VAlign, Vertical alignment of cell content within a row, as a default for every column - the vertical counterpart to the per-column align. A column's own verticalAlign (or cell.verticalAlign) overrides it. Omitted, the grid keeps its historical placement (centred in a fixed-height row, top in an autoHeight row), so an existing grid is unchanged on upgrade. Setting a value aligns every column uniformly, including auto-height rows, unless a column opts out. See Vertical alignment.
tooltipTooltipConfig, { delay, maxWidth } - grid-level defaults for the rich cell tooltip. delay is how long the pointer or the keyboard cursor must rest on a cell before anything is built, 400ms by default; maxWidth is how wide the tooltip may grow (a number is pixels, a string is used as written). Defaults only: it switches nothing on, and a grid whose columns declare no cell.tooltip has no tooltips whatever is set here. See Rich cell tooltips.
scrollbars'auto' | 'always' | 'custom' | { x, y }'auto'How the scroll viewport's scrollbars are drawn. 'auto' is the platform's native behaviour, where overlay scrollbars fade when idle; 'always' keeps that native bar shown whether or not the pointer is over the grid; 'custom' makes the grid draw its own bar instead - always visible, the same in every browser, and sized by the --lattice-scrollbar-* tokens rather than by the platform, for a target bigger than a 7px overlay ribbon. Scrolling itself is unchanged in every mode. The object form { x, y } sets each axis on its own, so { y: 'always' } keeps the vertical bar while the horizontal one stays native; note that 'custom' on one axis hides the native bar on both, and the grid warns once when the two disagree. Omitted, the grid is unchanged on upgrade. See Always-visible and grid-drawn scrollbars.
columnTagFilterboolean | { multiple, label }, A bar above the headings for showing only the columns carrying a chosen tag. Draws nothing unless some column has tags. See Column tags.
rowTemplatestring | { template, cardsPerRow, maxCardWidth, gap, className, role, itemRole }, Draw each row with a template instead of dividing it into columns, a card list, a feed, a search-result list. Compiles once; binds with {{data.field}}. cardsPerRow or maxCardWidth puts several on a line. The pipeline underneath is unchanged. See Cards, lists and feeds.
galleryboolean | { template, tileWidth, tileHeight, cardsPerRow, gap, className, role, itemRole }, Present rows as a gallery of tiles, laid out by the same 2-D virtualisation the grid runs. true generates a tile per row from the columns; tileWidth sizes them and the count across follows the container, or cardsPerRow fixes it. Presentation only - sort, filter, group and export are unchanged. See Cards, lists and feeds.
recordCardboolean | { template, cardHeight, className, role, itemRole }, Present 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. true generates the form from the columns. A card list underneath, so it inherits the virtualisation and every card interaction. Presentation only. See Cards, lists and feeds.
boardboolean | { template, laneWidth, cardHeight, laneGap, gap, className, role, itemRole }, Present rows as a board - a kanban of grouped lanes of cards. The top-level group becomes a lane and every leaf under it becomes a card stacked in it; group the grid to give the board its lanes. true generates a card per row from the columns. 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. Presentation only - sort, filter, group and export are unchanged. See Cards, lists and feeds.
responsive{ maxWidth, template, rowHeight }, Collapse to cards when the container is at or below maxWidth (640 by default), and return to a table above it. Sorting, filtering and export keep working. Emits presentation:changed. See Cards, lists and feeds.
rowFormboolean | { mode, load, fields, title, width, trigger, timeout, container }, Open a row for editing on a form. mode: 'drawer' (default) or 'dialog'; without load the fields are the grid's own columns. A field entry is { field, label, editor, type, props, lookup }: any editor, including your own. Takes double-click on the row unless trigger: false. A load that has not answered within timeout milliseconds (2000; false waits indefinitely) is reported as a failure with a retry. container builds the form in an element of your own instead of over the grid. See Editing a row on a form.
showColumnFunctionsbooleantruefalse leaves each heading as its label, with no sort, filter or menu control. Those remain reachable through the API, the keyboard and the tool panel.
headerControls'hover' | 'always' | 'hidden' | 'none' HeaderControlsVisibility'hover'When the per-column header controls - the sort arrow, the filter funnel and the menu button - are shown, as a default for every column. 'hover' reveals them on hover or keyboard focus (the historical behaviour); 'always' keeps them visible; 'hidden' draws none of them for a clean read-only heading, leaves them out of the tab order, but still shows a read-only sort badge on a column that is actually sorted; 'none' goes further and shows the title only, whatever the grid's state - not even 'hidden''s sort badge, though aria-sort keeps reporting the truth. A column's own headerControls overrides this default for that column. Distinct from showColumnFunctions: false, which also drops the furniture but keeps the functions reachable from the keyboard; 'hidden' and 'none' are the read-only choices.
significantFiguresnumber, On a unit column, render to this many significant figures rather than a fixed number of decimals, so precision is the same on every rung of the ladder. Rounding is applied before the unit is chosen. Set inside the unit configuration a data type is built from. See Units.
typeOptionsobject, Per-column options a data type reads. ratio and percentRate use { weight } to name the column their average is weighted by. See Aggregate safety.
highlightOnChangeboolean | string | objectFlash a cell when its value changes. { colour, duration }; duration: 0 stays until cleared.
rowClassstring | string[] | (p) => …A class, or classes, for every row. Re-evaluated on each repaint.
rowStyleCellStyle | (p) => CellStyleInline styles for every row. Camel-case or hyphenated property names.
viewsobjectsaved, allowSave, storage. See grid.views.
permissionsstring | object | fnPer-column access. See grid.permissions.
diffobject{ snapshot } turns on audit mode.
historyBarboolean | objectA standalone undo/redo toolbar with a timeline.
aiobject{ ask } mounts the prompt bar. Your ask receives { prompt, schema, schemaText, message, context } and returns the model's reply.
dataTypesobjectCustom types by name. createRadixType and createUnitType are exported for building them.
editBarbooleanA spreadsheet-style input above the header. When on, it hosts the column's real editor and inline editing is suppressed.
paginationboolean | objectRenders the pager control: page size, a summary, first/previous/next/last, and a page number you can type into and press Enter to jump.

Column definition

Everything is optional. A column with only field infers its type from sampled data and takes every default from there.

Inferring a Date, or an ISO string. Inference walks boolean, number, date, dateString, datetime, text, object and takes the first type that matches every sampled value. A Date whose local wall clock reads exactly 00:00:00.000 infers as date and stores YYYY-MM-DD; a Date carrying any time of day infers as datetime and stores YYYY-MM-DDTHH:mm (with seconds when they are non-zero), because a date column would discard the clock on ingest and nothing downstream could recover it. An ISO string follows the same rule: a date-only string (YYYY-MM-DD) infers as date; a string with a time part (T plus a time, with or without a zone offset - '2026-09-12T14:30:00Z') infers as datetime and keeps the time, and a column mixing both forms infers datetime rather than falling back to text. A timestamp such as '2026-09-12T14:30:00Z' therefore keeps its 14:30 on ingest, rather than being read as the calendar day '2026-09-12' with nothing to say that a time had been dropped. This is a heuristic with one stated blind spot: a genuine timestamp that lands on exactly local midnight - a nightly batch stamped 00:00:00.000, or a bare '2026-09-12' string that really meant an instant - is indistinguishable from a date-only value and is still inferred as date, so its time of day is still discarded. Sub-second resolution is never retained by datetime. If you group by such a column, declare it: an undeclared Date column groups by the instant, which is one group per row, where a type: 'date' column groups into day buckets. Date filters are unaffected either way - they compare on the day and return the same rows. Declare type and none of this applies: 'date' truncates on purpose, 'datetime' keeps a wall clock, and 'timestamp' keeps the instant to the millisecond. rows.value() returns that stored form - and the same one on every path: the rows you passed at construction, rows.load(), rows.apply({ add }) and rows.apply({ update }), edit.setCells and a typed cell edit, a stream chunk and a stream re-send, a store-backed or columnar store, off-thread ingest, a bound KPI tile and a chart binding all answer the same shape for the same instant, so a host can do arithmetic on it without testing what it got. rows.text() is the formatted form, and row.data is always the raw value you supplied, unconverted. For millisecond arithmetic use type: 'timestamp', which answers the epoch number on every one of those paths. A column of ISO timestamps that must stay a calendar day opts out with an explicit type: 'date' - no warning is logged for that column, because the value is preserved (declared, not narrowed) and there is nothing to disclose.

PropertyTypeDescription
idstringDefaults to field. Required when there is no field.
fieldstringDotted paths supported: 'site.address.postcode'.
titlestringHeader text. Defaults to a humanised field.
typeTypeName | falseA data type bundles format, parse, compare, storage, editor, filter, renderer and Excel behaviour. false disables inference. 'image' treats the value as a URL and draws it: see image columns.
presetstring | string[]Named bundles from columnPresets.
tagsstring | string[], Labels grouping columns together, used by the column tag bar. A bare string is accepted for one tag.
formatFormatSpec | stringShorthand strings like 'percent:1' or 'date:dd MMM yyyy'.
lookupLookupSpecId-to-label mapping. Nested children are flattened, so a tree-shaped list resolves labels everywhere.
valueColumnValueSpecComputed values and the value lifecycle.
cellColumnCellSpec | stringA bare string is a renderer name.
editColumnEditSpec | boolean | stringA bare string is an editor name.
sortColumnSortSpec | boolean
filterColumnFilterSpec | boolean | FilterName
groupobject | boolean{ enabled, index, explode }.
pivotobject | boolean{ enabled, index }.
totalTotalName | TotalFnOne property drives the group row, the tree node, the pivot cell and the grand total. Split it per scope with groupTotal / grandTotal when the subtotals and the grand total should reduce differently.
groupTotalTotalName | TotalFnThe reduction for group subtotals - group footers, tree-node rollups and pivot cells - where it should differ from the grand total. Overrides total for those scopes only; omitted, total applies.
grandTotalTotalName | TotalFnThe reduction for the pinned grand-total row, where it should differ from the subtotals. Overrides total for the grand total only; omitted, total applies.
layoutColumnLayoutSpec | numberA bare number is the width.
headerColumnHeaderSpec | string
exportColumnExportSpec{ lookup: 'label' | 'value' | 'columns', csv, excel }.
allowGroup / allowPivot / allowTotalbooleanWhether the tool panel offers the column for that zone.
nullablebooleanAffects storage choice and null ordering.

Column sub-specs

value

KeyTypeDescription
compute(deps, ctx) => unknownDerived value. Receives only its declared dependencies. A pure result is computed at ingest and cached; it is re-run when its row is replaced by rows.apply({ update }) or rows.load(), when the grid a derived grid follows changes, and when the host asks with rows.refresh({ rows, columns, force: true }). An in-place cell edit to one of its deps does not currently re-run it. For an answer that arrives later (an id-to-name lookup, a rate table), return a placeholder, then call rows.refresh({ rows, columns: [id], force: true }) once it resolves - or declare pure: false.
depsstring[] | '*'Declared dependencies. Cycles are caught at compile time, not at render. An edit to a column outside deps does not re-run a pure compute.
purebooleanDefault true: the result is cached and served until a dependency changes or a refresh forces it. false guarantees the compute is re-evaluated on every read and every paint - never served from a cache - and is the right declaration for a value that depends on something the grid cannot see. A DEV-mode proxy flags pure computes that read outside their deps.
format(p) => stringOverrides the type's formatter.
parse(p) => unknownEditor output to value. Always called, whatever the editor emitted.
apply(p) => booleanWrites the value back into the row object.
key(p) => stringGroup key override.
compareComparatorOverrides the type's comparator.
quickFilterText(p) => stringWhat the quick filter matches against.

cell

KeyTypeDescription
renderRendererName | RenderFn | CtorRenderer name or component. The built-in names are listed under built-in renderers.
propsobjectPassed to the renderer.
decorationDecorationName | specpill, bar, fill, dot, edge.
variantVariantSpecMaps a value to a semantic token: { map }, or { when: [...], default }.
templatestringEscaped unless allowUnsafeTemplates is set.
classstring | string[] | (p) => …Classes for this column's cells.
classWhen{ [class]: (p) => boolean }A class per predicate, re-evaluated as values change.
style / cssCellStyle | (p) => CellStyleInline styles, static or computed.
tooltipstring | (p) => string | ColumnTooltipSpecA string or a function is the plain-text case and becomes the browser's own title. An object is a tooltip the grid draws itself - { render, mount, unmount } - which can carry structure, markup or live content, is shown on keyboard focus as well as hover, and can be dismissed with Escape. See Rich cell tooltips.
align'start' | 'center' | 'end' | 'left' | 'right'Horizontal alignment; also accepted at the top level of the column. start, center and end are logical: they follow the writing direction, so an end-aligned number column sits on the right edge in a left-to-right grid and on the left edge in a right-to-left one (direction). left and right are physical: they name an edge and keep it in both directions. centre is accepted for center. Omitted, the column takes its data type's default (numbers end, booleans center, text start). The heading follows the cell unless header.align says otherwise.
wrap / autoHeight, Presentation flags.
spanColumns / spanRows(p) => numberSpanned cells render in their own layer so row recycling cannot clip them.

Custom CSS, by scope. Cells: cell.class, cell.classWhen, cell.style and cell.css, all of which may be functions of the cell. Columns: the same four, declared on the column, so they apply to every cell in it; the header takes header.class. Rows: rowClass and rowStyle on the grid.

All of them 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, and a class written once and left alone smears down the grid as the user scrolls.

edit, sort, filter, layout, header

SpecKeys
editenabled (boolean or predicate), editor, props, popup, validate
sortenabled, direction, order, nullsFirst
filterenabled, type, props
layoutwidth, fit, min, max, flex, pin, hidden, resizable, movable, lockVisible, lockPosition. fit: 'content' is the declarative form of columns.autoSize(): the column is sized to what it is showing on the first paint and measured again whenever the rows change, the columns are shown, hidden, reordered or pinned, or the grid is resized - but not as it scrolls, which would make the columns jitter. It measures the mounted rows and the heading, as autoSize() does, so it sizes to visible content rather than to the widest value in the dataset. A declared width outranks it, and so does a width the user drags to, a resize being recorded as a width; flex is resolved first and wins. width is a pixel number or a percentage string ('25%'): a share of the grid's inner width that follows the viewport - after the container changes size the column is re-resolved against the new width, clamped to its min/max, so a '50%' column is half of an 800px grid and half of the same grid at 400px. Percentages summing past 100 overflow and scroll rather than being scaled down. A pin of 'start' or 'end' holds the viewport edge while there is something to scroll; where the columns do not fill the grid, spare width falls beyond the last column rather than in front of it.
headertemplate, render, props, class, tooltip, align. render draws a custom heading and may be a function or a component (a class with a render method); the two forms are interchangeable and each may either append to the passed heading element itself (returning nothing) or return an Element (attached for you) or a string (used as the heading text). class adds a class to the heading cell; template is not read.

Rich cell tooltips

cell.tooltip as a string or a function gives you the browser's own title: one line of plain text, on the browser's schedule, unstyled, and invisible to a keyboard user. The object form declares a tooltip the grid draws instead, so a cell can show a related record, a small chart, a list of validation errors or an edit history. The plain-text form is untouched and still becomes a title, so an existing grid behaves exactly as it did.

render(params) returns one of four things, and the difference between the last two is a security property rather than a matter of taste:

ReturnRendered as
an HTMLElementAttached as it is. Your DOM, your responsibility.
{ title, rows, note }A TooltipSpec, drawn by the grid: a heading, label/value lines, and a closing note. Every field is written as text, so a spec built out of row values needs no escaping.
{ html: '…' }The only wrapper that inserts markup, scrubbed of script by the same rules the cell layer applies to allowUnsafeTemplates output.
a stringAlways text, whatever it contains. A string holding <b>bold</b> shows those characters; it does not embolden.

That last rule is the load-bearing one. The most natural tooltip anyone writes is render: (p) => p.value, and a value comes from row data - data the developer did not write and usually cannot audit. If a bare string were treated as markup, a name field holding <img src=x onerror=…> would execute and nothing in the code would have looked dangerous. Markup therefore has to be asked for in the source, where a reviewer can see it, and no value arriving from data can promote itself.

mount(el, params) and unmount(el) carry live content. Inside mount you call createChart or createKPI from a module bundle your application loaded - the grid core never imports a module - and unmount is called every time the tooltip closes, so nothing keeps running behind a hidden box. One tooltip element is built and re-used for every cell.

Accessibility. Nothing is built until the pointer or the keyboard cursor has rested on the cell for delay (400ms by default), so sweeping across the grid mounts nothing. Focusing a cell shows the same tooltip after the same delay and the cell points at it with aria-describedby; the tooltip can be hovered without closing, and Escape dismisses it (WCAG 2.2 AA, 1.4.13). Escape is only consumed while a tooltip is open, so it still reaches the editor, the menu and the maximised view.

It closes on scroll. Rows and cells are pooled and re-used, so a tooltip left open across a scroll would be anchored to a node that is now showing a different row. Closing is the honest answer and costs nothing: the browser re-hit-tests after a scroll, so a pointer parked over the grid simply gets a fresh tooltip for the row that is actually there. Content is resolved when the tooltip opens rather than when the pointer arrived, so it always names the row that node is showing at the moment it opens.

Grid-level defaults, and a column declaring a spec tooltip

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const grid = createHeadlessGrid({
  tooltip: { delay: 250, maxWidth: 360 }, // the defaults for every tooltip
  columns: [
    { field: 'name' },
    {
      field: 'owner',
      cell: {
        tooltip: {
          // Returned as a spec: the grid writes every field as text.
          render: (p) => ({ title: p.value, rows: [{ label: 'Row', value: p.key }] }),
        },
      },
    },
  ],
  rows: [{ name: 'a', owner: 'Ada' }],
  rowKey: 'name',
});

const defaults = grid.get('tooltip');
const spec = grid.columns.get('owner').cell.tooltip;
const content = spec.render({ value: 'Ada', key: 'a' });
grid.destroy();
return `${defaults.delay}, ${defaults.maxWidth}, ${content.title}, ${content.rows[0].value}`;

Grid methods

Top-level members. Everything else hangs off a namespace.

MemberReturnsDescription
getVersion()stringThe version this grid came from, e.g. '1.70.0'. Also on the module as getVersion(), for when you have no grid to hand.
get(key)unknownRead any configuration key.
set(key, value)voidWrite one key. Every key is live; nothing needs a rebuild.
setAll(values)voidWrite several in one pass. Emits one config:changed for the batch, not one per key.
config()GridConfigThe whole live configuration as a shallow copy. Pairs with setAll for a read-modify-write round trip. Nested objects are shared by reference, so treat it as read-only.
setPinnedRows(rows, opts?)voidPin rows outside the scrolling body. opts.edge is 'top' (the default) or 'bottom'. Pass a new array rather than mutating the previous one: array identity is the change signal. See Pinned rows.
getPinnedRows(opts?)object[]The objects pinned at one edge, as a copy.
on(event, handler)() => voidReturns its own unsubscribe. '*' subscribes to everything; the handler still receives one event object, and reads event.type to tell which arrived.
rendererHost()objectThe 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.
once(event, handler)() => void
off(event, handler)void
emit(event, payload)voidEmit on the grid's bus, for custom components.
attachRenderer(renderer)voidBind a renderer to a headless grid.
destroy()voidRelease listeners, workers and pooled buffers.
elementHTMLElement | nullThe rendered root; null when headless.
readyboolean
destroyedboolean

grid.rows

Data usually arrives after the grid does. Build it with rows: [], then load when your fetch resolves, the sort, filters, grouping and column layout you set up in the meantime all survive, and apply to the new data.

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);          // replaces whatever was there
grid.overlay.hide();
MethodReturnsDescription
load(rows)voidReplaces the data. The view (sort, filters, grouping, column layout) is kept. Same as grid.set('rows', data).
get(index)RowBy display index, after filtering, grouping and flattening.
byKey(key)RowBy row key, whether or not it is on screen.
matchCount()numberData rows passing the filters, across every page. Excludes group headers, footers and totals, the numerator of "1,204 of 100,000".
coverage(){ covered, total, windowed }How much of the data a figure computed from this grid covers - the question the counters above cannot answer, because on a windowed source they all report the rows it is holding and so agree with each other while the data that has been through is larger. covered is the rows a figure would be computed over; total is the rows the source knows about, or null when it cannot know (a stream still open that has evicted nothing has no idea how many rows are coming, and says so rather than repeating covered); windowed is true when a window bounded the computation. covered < total, or total === null, means the figure is approximate - that is the test to write. False windowed means the figure is over all of it: a memory source, or a stream that finished having dropped nothing. Read on demand and cheap (one matchCount() and one progress() on the source, nothing cached), so call it beside every figure you publish rather than once at setup - on a live stream the answer moves.
count()numberDisplay rows: group rows included, collapsed children excluded.
totalCount()numberSource rows before filtering. The denominator of "1,204 of 100,000".
value(key, colId)unknownThe stored value.
text(key, colId)stringThe formatted display text.
values(key)objectEvery column's value for one row.
data()unknown[]The caller's original row objects, in source order.
forEach(fn)voidWalks display rows without materialising them all.
forEachAll(fn)voidWalks every row in the data, before any filter: leaf rows only, in the order they arrived. What you want for a total, an export or a reconciliation, where forEach would give you the view instead. A remote or paged source holds only what it has fetched and says so.
forEachExcept(colId, fn)voidWalks the rows surviving every filter except that column's own, the faceting question, asked of the rows. It is what lets a header histogram keep every bar after one is clicked, and what lets a cross-filtering panel avoid narrowing itself out of existence. Needs a memory source; anything else falls back to the filtered rows and warns.
apply(change)objectTransactional add / update / remove. Needs rowKey.
queue(change)voidBatches a change into the next frame, the high-frequency path.
refresh(opts)voidRe-run computed values and repaint, without re-running sort, filter or grouping. { rows, columns } narrows it to those cells; nothing named means every cell. Either way, the cached results for the named cells are discarded from every cache the grid keeps - the one behind rows.text() and the painted cell, and the one sort and filter read - so a non-stored computation is re-run on the next read, sort or filter. force: true also recomputes a pure (stored) computation for those cells and rewrites it, and repaints cells whose text did not change - the call to make when the answer changed for a reason the grid cannot see, such as an async lookup resolving. A column declared pure: false is never cached, so a plain refresh() is enough to show its new value. Without force, whether a stored (pure) computation is re-run for the named cells depends on the store the grid chose for the row count, and it flips at columnarBelow: below that many rows the named cell is re-run on its next read, at or above it the stored value stands until a force: true. Pass force: true when you want the same answer whatever the row count.
move(key, to){ moved, from, to, reason? }Move a row to another position in the data. Refuses, naming the reason, while a sort, filter or grouping is active. Emits row:moved; persisting the new order is yours.
groupHeadings(index)Row[]The group rows enclosing a display index, outermost first. Empty when the grid is not grouped. Useful for a breadcrumb of your own.
expand(key, deep?)void
collapse(key)void
expandAll() / collapseAll()void

grid.columns

MethodReturnsDescription
all()ResolvedColumn[]Every column, hidden included.
visible()ResolvedColumn[]In render order, including generated group and pivot columns.
get(id)ResolvedColumn
show(ids) / hide(ids)void
move(id, to)voidIndex into the full column order.
pin(id, side)void'start', 'end' or null.
resize(id, px)void
autoSize(ids)voidFit each column to its rendered content.
fit()voidSize the visible resizable columns so that every column the grid draws, together, exactly fills the body viewport's client width at the moment of the call: without the vertical scrollbar when there is one, the full inner width when there is not. Columns it does not size (resizable: false, and the grid's selection checkbox, detail expander, group and tree columns) keep their width and are taken out first; the rest share what is left in proportion to their widths, within each min/max. If that is less than their minimums, each goes to its minimum, never below, and the grid scrolls horizontally, with a warning. One-shot: it sets fixed widths (a flex column included) and does not follow later size changes; call it again after a resize or after late rows bring a scrollbar in.
group(ids)voidSet the row-group columns, in order.
pivot(ids)void
totals(ids)voidWhich columns carry an aggregation.
setTotal(id, fn, opts?)voidChange one column's aggregation. null stops totalling it. A named total the column's type says is meaningless is refused (), the same way it is at configuration. With no opts, fn becomes the shared total and clears any group/grand overrides; pass { scope: 'group' } or { scope: 'grand' } to set the group subtotals and the grand total independently (a scope with no override follows total).
aggregates(id)TotalName[]The aggregate names meaningful for a column, honouring its type's declaration - what the aggregate chooser offers.
distinct(id)unknown[]Distinct values, read from the dictionary rather than by scanning rows.
state()ColumnState[]Serialisable column state.
apply(state)StateApplyReportRestore it. Never throws and never refuses: a saved view written against an older column set applies as much of itself as still makes sense, and the returned { applied, skipped } names what it could not use and why. Columns added since the view was saved appear in their declared state, after the ones it names. See Saved views.

grid.selection

MethodReturnsDescription
keys()string[]Selected row keys.
rows()Row[]
all()Row[]Including rows selected but currently filtered out.
set(keys)voidReplace the selection. In mode: 'single', only the first key of the array is kept; the rest are dropped, they are not an error.
clear()void
ranges()Range[]Cell ranges, for spreadsheet-style selection.
setRange(range)voidReplace every range with one.
addRange(range)voidAdd a range without discarding the others, the API form of ctrl-click. Becomes the anchor extendRange grows.
startRange(rowIndex, colId, opts)voidBegin a range at a cell. opts.additive keeps the existing ranges.
extendRange(rowIndex, colId)voidExtend the newest range, keeping its anchor.
corner(){ row, colId } | nullBottom-right cell of the newest range, where the fill handle sits.
inRange(rowIndex, colId)booleanIs a cell inside any selected range?
cells(){ key, colId }[]Every cell in the selected ranges.
statistics()object | nullEverything summary() reports plus median, quartiles, deviation, distinct and outliers: over the selected cells, so a rectangle spanning three columns is one set of numbers. Null with nothing selected.
summary()objectcount, sum, min, max, avg over the range.

grid.filters

MethodReturnsDescription
get()FilterSetThe whole condition tree.
set(filters)voidReplace it. null clears everything.
quick(text, opts?)voidThe quick filter, applied across every column.
clear()void

grid.sort

MethodReturnsDescription
get()SortEntry[]{ col, dir, nullsFirst? }, in priority order.
set(entries)voidMulti-sort by passing several entries.
clear()void

grid.edit

MethodReturnsDescription
start(key, colId)voidOpen an edit session. The row must be rendered.
stop(cancel?, opts?)objectCommit or discard. Pass { value, key, colId } to write a value.
undo() / redo()voidDepth from edit.undoDepth.
setCells(writes, type?)numberWrite many cells as one undoable step. Returns how many landed.
pasteInto(anchor, text, extent?)numberPaste tab-separated text, using Excel's tiling rules.
previewPaste(anchor, text, extent?)objectCompute what a paste would change without committing: { changes, rejected }. The engine behind edit.pastePreview.
pastePreviewbooleanWhether a bulk paste is previewed before it commits (edit.pastePreview).
settle(id, ok, reason?)booleanReport the outcome of an optimistic write. Only needed with edit.confirm: 'manual'; the id arrives on cell:pending.
pending()OpenWrite[]Writes still awaiting an outcome. Empty unless edit.commit is set.
status(key, colId)'pending' | nullWhether a cell has a write in flight.
addRow(row)string | nullAppend a row optimistically and persist it (over a source declaring mutate.append). Returns the client temp key; on the server key it fires row:confirmed after rekeying selection, expansion, focus and in-flight cell edits. null when append is unavailable.
deleteRow(key)string | nullDelete a row optimistically and persist it (over a source declaring mutate.delete). Tombstones then confirms, or restores on refusal. null when delete is unavailable.
deleteRows(keys?, opts?)string[] | PromiseThe user-gesture delete (what the Delete key and the "Delete row" menu item call), through the cancellable beforeDelete event - on a memory grid as well as a remote one. Off until config.rowDelete. Keys default to the selection; returns the keys removed (empty on a veto or when disabled), or a Promise when a handler deferred.
settleRow(id, ok, reason?, reconcile?)booleanReport the outcome of a structural op. Only needed with edit.confirm: 'manual'; the id arrives on row:pending.
rowStatus(key)'pending' | nullWhether a row has an append/delete in flight.
pendingRows()OpenRowOp[]Structural ops still awaiting an outcome. Empty unless the source can append or delete.

Appending and deleting rows. Over a remote source whose adapter declares mutate.append/mutate.delete, grid.edit.addRow and grid.edit.deleteRow are the structural counterparts of the cell edit path. An appended row shows at once under a client temp key; when the server hands back the real key the row is rekeyed everywhere the grid tracks it - the source row, selection, expansion, focus and any in-flight cell edits all follow - and row:confirmed fires. A delete tombstones the row immediately and either purges it on confirmation or restores it on refusal. The example drives both against a mock adapter, and shows the rekey moving a selection:

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createPushdownSource } = await import('../packages/core/src/source/pushdown.js');

// A mock adapter that persists append and delete. append returns the server key.
let nextId = 1;
const adapter = {
  name: 'mock',
  capabilities: { sort: true, mutate: { append: true, delete: true, returning: 'key' } },
  async execute() { return { rows: [], total: 0 }; },
  async mutate(op) {
    if (op.kind === 'append') return { ok: true, keys: [`srv-${nextId++}`] };
    return { ok: true };   // delete confirmed
  },
};

const grid = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ field: 'id' }, { field: 'name' }],
  source: createPushdownSource({ adapter, edit: true }),
  selection: 'multiple',
  edit: true,
});

// The structural lifecycle events, bridged onto the grid's own bus.
const fired = [];
grid.on('row:pending', (e) => fired.push(`pending:${e.kind}`));
grid.on('row:confirmed', (e) => fired.push(`confirmed:${e.kind}`));
grid.on('row:reverted', (e) => fired.push(`reverted:${e.kind}`));
grid.on('row:conflict', () => fired.push('conflict'));

// Append: shows immediately under a temp key, then rekeys to the server key.
const temp = grid.edit.addRow({ name: 'Ada' });
grid.selection.set([temp]);                  // select the optimistic row
await new Promise((r) => setTimeout(r, 0));   // let mutate resolve; row:confirmed fires
const movedTo = grid.selection.keys()[0];    // selection followed the rekey

// Delete: tombstones then confirms.
grid.edit.deleteRow(movedTo);
await new Promise((r) => setTimeout(r, 0));
const gone = grid.rows.byKey(movedTo) === undefined;

grid.destroy();
// fired: pending:append, confirmed:append, pending:delete, confirmed:delete
return `${movedTo} selected; ${gone ? 'deleted' : 'still-there'}`;

The built-in delete gesture. deleteRow above only fires beforeDelete on a remote source. For the common case - letting a user delete rows of a memory grid with the Delete key or a "Delete row" menu item, confirmed through the same beforeDelete hook - set config.rowDelete: true. It is off by default because deleting data on a keystroke is destructive; every deletion still flows through beforeDelete, so a handler can confirm or veto it with preventDefault(reason). grid.edit.deleteRows is the programmatic entry.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const grid = createHeadlessGrid({
  columns: [{ field: 'id' }],
  rows: [{ id: 'a' }, { id: 'b' }, { id: 'c' }],
  rowKey: 'id',
  selection: 'multiple',
  rowDelete: true,   // opt in to the Delete-key / menu gesture and the API
});

// A confirm hook that vetoes deleting row 'b' but allows the rest.
grid.on('beforeDelete', (e) => { if (e.rows.includes('b')) e.preventDefault('kept'); });

grid.edit.deleteRows(['a']);   // allowed - 'a' is removed
grid.edit.deleteRows(['b']);   // vetoed - 'b' stays, delete:cancelled fires

const count = grid.rows.count();
grid.destroy();
return `${count} rows`;   // 2 rows: 'b' and 'c'

Previewing a bulk paste. A paste can rewrite dozens of cells at once, and one that lands somewhere unexpected looks exactly like one that worked. Set edit.pastePreview: true and a paste into more than one cell opens a confirm/cancel dialog first, listing every cell that changes (old → new) and every cell a commit would reject. It is off by default, so existing paste behaviour is unchanged. previewPaste computes that same diff without any UI:

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const grid = createHeadlessGrid({
  // A per-row rule: the middle row is locked, so a paste over it is refused.
  columns: [{ field: 'a', edit: { enabled: (p) => p.row.data.locked !== true } }],
  rows: [
    { id: '0', a: 'A0', locked: false },
    { id: '1', a: 'A1', locked: true },
    { id: '2', a: 'A2', locked: false },
  ],
  rowKey: 'id',
  // Opt in: off by default, so a plain paste is unaffected.
  edit: { enabled: true, pastePreview: true },
});

// What *would* happen - nothing is committed yet.
const preview = grid.edit.previewPaste({ key: '0', colId: 'a' }, 'X\nY\nZ');

// Confirming is setCells of the changes - the ordinary paste path.
grid.edit.setCells(preview.changes.map((c) => ({ key: c.key, colId: c.colId, value: c.newValue })), 'paste');

const changed = preview.changes.filter((c) => c.changed).length;
return `${changed} change, ${preview.rejected.length} rejected`;   // rows 0 and 2 change; row 1 refused

grid.form

The row edit form, a drawer or dialog holding one control per field. Present whether or not rowForm is configured; without it every method declines rather than throwing, so a caller need not guard. See Editing a row on a form.

MethodReturnsDescription
open(key)booleanOpen a row by key. False if there is no such row, or no form is configured.
close()voidClose without saving. Focus returns to where it was.
save()booleanCommit the fields and close. False if a validator refused, or there is nothing to save.
isOpen()booleanWhether the panel is showing.

grid.pagination

A window over the rows the query already produced, not another query. A page change re-slices; it does not re-filter, re-sort or re-group, so paging a million rows costs nothing beyond the repaint.

MethodReturnsDescription
get(){ page, pageSize, total, pageCount }total is the filtered row count, so it moves when a filter does.
set({ page?, pageSize? })voidMove, resize, or both. pageSize: 0 turns paging off and shows everything. Emits page:changed once the rows have moved.

grid.scroll

MethodReturnsDescription
position(){ top, left }
toRow(row, align?)voidalign: 'start', 'centre', 'end'.
toColumn(id)void
to(at)voidScroll to { top, left }. left is the logical offset: zero at the content's start whichever way the grid reads.
toCell(row, colId, align?)voidScroll a cell into view, both axes in one call. row is a row key or a display index.

grid.export

MethodReturnsDescription
csv(opts)string | BlobFields sanitised against formula injection.
excel(opts)Promise<Blob>Real .xlsx, written without a ZIP dependency. Large exports stream.
clipboard(opts)PromiseTSV, with the grid's own paste parser as its counterpart.
print(opts)voidSwitches virtualisation and pinning off for the printed document.
rows(mode)Row[]'all', 'visible' or 'selected'.

Excel value conversion. A data type may declare toExcelValue and excelKind, because a spreadsheet's number formats are not free-form. A time of day is written as a fraction of a day under hh:mm:ss; a duration as days under [h]:mm:ss, where the brackets are what stop Excel wrapping at 24 hours. Both stay numeric, so they still sort and subtract in the sheet.

IP columns are stored as packed integers so they sort as addresses, and declare excelKind: 'string' so the dotted form is what reaches the file. Radix columns export decimal: OOXML cannot express base 16, and staying numeric was judged worth more than display fidelity.

grid.import

The mirror of grid.export: rows coming in from delimited text - a CSV or TSV file, or the tab-separated block a spreadsheet puts on the clipboard. The pipeline is parse, infer each column's type, map the columns onto the grid's own fields, then preview and confirm. The API is always present; set config.import to add the DOM affordances (a "Import rows from CSV…" cell-menu item, a file drop target, and paste).

MethodReturnsDescription
preview(text, opts?)ImportPreviewParse, infer and map without changing the grid - the columns, the coerced records, a sample and any warnings a confirm dialog needs.
csv(text, opts?)object[]Parse delimited text into coerced record objects, the inverse of export.csv.
apply(input, opts?)ChangeResult | nullAdd (mode: 'append', the default) or replace (mode: 'replace') the grid's rows from text, a preview or a record array. A client-side operation, so it applies to a memory-source grid; on any other source it declines rather than show rows that cannot persist.

Excel. .xlsx is not read: it needs an inflate and XML reader the zero-dependency envelope does not carry. Save the sheet as CSV - every spreadsheet does - and import that.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const grid = createHeadlessGrid({
  columns: [{ field: 'id' }, { field: 'age', type: 'integer' }],
  rows: [{ id: 'seed', age: 1 }],
  rowKey: 'id',
  // Opt in to the DOM affordances; the grid.import API is present either way.
  import: true,
});

// Preview changes nothing - it is what a confirm dialog shows.
const preview = grid.import.preview('id,age\nx,20\ny,30');

// Confirming appends the previewed rows; `age` arrives coerced to a number.
grid.import.apply(preview);

return `${preview.rowCount} previewed, ${grid.rows.count()} rows`;   // 2 previewed, 3 rows

grid.state

MethodReturnsDescription
get()GridStateVersioned and serialisable: columns, columnOrder, filters, quick, sort, group, pivot, expanded, selection, scroll, pagination.
apply(state, opts?)objectRestore a view. Returns a report of anything it could not apply (a column that no longer exists, for instance) rather than failing silently.
baseline()GridState | nullThe state the grid started in, captured once after config.state and any default view, so the baseline is the grid you shipped, not the one before your own configuration ran.
reset()object | nullPut the grid back to that baseline, as one undo entry. Clears anything the baseline does not mention, including the quick filter.
modified()booleanWhether anything has changed since construction. Lets a "restore" control disable itself rather than offering an action that would do nothing.

grid.history

Undo across 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: "sort by Region", not "sort".

MethodReturnsDescription
undo()object | nullThe entry that was undone.
redo()object | null
canUndo() / canRedo()boolean
peek(direction?)object | nullWhat the next undo or redo would do, so a control can name it before it is pressed.
list()object[]The timeline, newest first.
transaction(label, fn)object | nullGroup several changes into one entry. Nested transactions join the outer one.
clear()void

A multi-cell paste is one entry, not one per cell. An AI plan is one entry however many actions it contains, labelled with what it did.

grid.diagnostics

What the grid is doing, as data: render counts and their causes, memory layout, operation and provider timing, event listener counts, effective configuration, and a list of things that look like mistakes.

This is the API; the devtools panel is a consumer of it. Built in that order deliberately: instrumentation shaped by a UI tends to report what is convenient to display rather than what is true, and an API that only exists behind a panel cannot be asserted against in a test.

// the assertion this exists to make possible
const before = grid.diagnostics.renders().dom.cellWrites;
grid.filters.set({ col: 'status', op: 'eq', value: 'active' });
await nextFrame();
const written = grid.diagnostics.renders().dom.cellWrites - before;
expect(written).toBeLessThan(200);
MethodReturnsDescription
snapshot()objectEverything, in one structure.
renders()objectCounts by cause, the last render's phase timings, DOM write counters and viewport state.
store()objectPer-column backing kind and byte footprint, total bytes, rows against physical slots, tombstoned rows.
operations()objectCount, mean and worst per operation kind, with a bounded sample of recent calls.
providers()objectCalls, errors, in-flight count and latency per provider, with failures retained.
events()objectListener count per event type.
config()object{ effective, supplied, defaulted }, which values you chose and which the grid chose.
warnings()object[]Everything flagged, newest first, each with a stable id.
dismiss(id)voidHide a warning for this session. Not permanently.
bundle()objectA support bundle. Contains no row data.
checkOptions(options)booleanTrue when an options object changed identity without its contents changing.
record(kind, detail)voidRecord your own operation, so custom work appears alongside the grid's.
reset()voidZero the counters. Warnings and configuration are left alone.

The support bundle

bundle() returns configuration, query state, timing history, warnings, provider statistics, version and environment. It never contains row data, cell values or column values, and says so in its own contains field. The guarantee is the point: a bundle that had to be read for confidential content before sending is a bundle nobody sends.

Warnings

Each carries a stable id a support conversation can name, a plain description, and the specific values involved. Two sources are merged: checks run against the grid, and everything the grid has reported through its own one-per-cause warnings.

IdWhat it means
options-identity-churnAn options object rebuilt on every parent render. A wrapper comparing by identity will tear the grid down each time.
duplicate-row-keysTwo rows share a key. Presents as "the wrong row updated", never as an error.
query-references-unknown-columnA filter or sort names a column that does not exist. Silently matches nothing.
listener-count-growingProbable subscription leak in the host. Presents as gradual slowdown.
main-thread-eligible-for-workerA large operation ran on the main thread despite the worker threshold.
slow-providerA provider took more than a second to answer.

The devtools panel

An optional module. It imports nothing (the grid is handed to it) so deployments that never load it pay nothing.

import { createGrid } from '@toclocoinc/lattice-grid';
import { createDevtools } from '@toclocoinc/lattice-grid/modules/devtools';

const grid = createGrid(el, config);
createDevtools({ grid });          // Ctrl+Shift+D collapses it

Nine tabs over the API above, a compact vitals strip to leave open while working, and a render heat overlay that tints cells as they are written: blue for a new value, red for a cell rewritten with the value it already held. The second colour is the one worth chasing: it is work the grid did not need to do, and no counter alone will tell you where it is.

The panel observes and never mutates. A configuration editor would create a second path into state that has to be kept correct forever, so there is not one. Nothing leaves the browser: there is no telemetry, and the bundle is produced only when you ask for it.

grid.presence

Who else is on this grid and what they are doing: cursor, selection, active edit, and an optional advisory lock. It prevents the two failure modes of multi-user data work: two people editing the same cell unaware of each other, and one person unable to tell whether anyone else is there at all.

The grid never opens a connection. You supply the transport and the identity; the grid renders what arrives and publishes what changes. A WebSocket, MQTT, a CRDT library or a polling endpoint all satisfy the interface. Without a provider the feature is inert.

presence: {
  provider,                              // subscribe + publish
  me: { id: 'u_17', name: 'Tony' },
  throttleMs: 60,
  lock: true                             // advisory; see below
}
MethodReturnsDescription
enabledbooleanFalse without a provider.
peers()Peer[]Everyone else, most recently active first, each with idle, hidden and cursorFresh.
hiddenCount()numberPeers none of whose positions are in this view.
editorOf(rowId, colId)Peer | nullWho is editing a cell, if the claim is fresh.
lockedBy(rowId, colId)Peer | nullNull unless lock is on. Advisory.
jumpTo(peerId)booleanScroll to a peer's cursor. False when their row is not in this view.
publish()voidPublish now. The grid already does this on cursor, selection and edit changes.
setPublishing(on)voidReceive without appearing, for observer and supervisor roles.
setPaused(paused)voidSuspend publishing. Done for you while the tab is hidden.
connect(provider)voidAttach or detach after construction.
stats()objectPublished, received, throttle drops, provider errors, peer count.

The provider

{
  subscribe(onMessage) { /* call onMessage(peer) or onMessage(peer[]) */ return unsubscribe; },
  publish(state)       { /* send it however you like */ }
}

A message is one peer state or an array of them, so a transport that sends a full roster on connect and deltas afterwards needs no unwrapping. { id, left: true } removes a peer.

Presence carries intent, never values. A peer's committed edit must reach the grid as data, through whatever channel you already use, as a transaction, so it gets the flash-on-change treatment. Presence is throttled, lossy and ephemeral by design, so a value carried on it is a value that can be dropped. That is the kind of bug that surfaces once a month in production and cannot be reproduced.

Positions travel as row keys

Peers sort and filter independently, so a row index addresses a different record on every screen. Presence is positional in data terms: a peer's cursor renders wherever that row currently sits in your view, and is held but not drawn when the row is filtered out, on another page, or evicted from a bounded window. Those peers are counted by hiddenCount() and shown in the roster as “not in view”, so their absence does not read as a disconnection.

Idle and removal

Derived from local receipt time, never the timestamp in the payload. Clocks between clients disagree by seconds routinely, so a peer with a fast clock would look permanently fresh and one with a slow clock permanently idle. Silence past idleMs desaturates them; past removeMs they go. An explicit left signal is used when your transport provides one.

Locking is advisory

Locking reduces collisions. It does not eliminate them. Presence is throttled and can arrive out of order, so two clients can enter an edit at the same moment. The authoritative resolution is the conditional write in edit.commit, which returns a conflict and rolls the optimistic edit back. If you treat locking as a guarantee and skip that write, you will lose data.

With lock: true, starting an edit on a cell a peer holds returns false from edit.start, emits presence:lockRefused, and announces the holder through a live region, a cell that silently refuses to enter edit mode is indistinguishable from a broken grid.

What is drawn

A peer's cursor is a dashed border in their colour; your own focus ring is solid, and the difference is in the kind of line rather than only the hue so the two can never be confused. Their name shows for a moment after their cursor moves and on hover, then fades to the bare border. A selection is a low-opacity tint, with the most recent peer winning a contested cell outright rather than blending. An active edit is solid and tinted, the loudest treatment, because it is the state that matters most.

Nothing is inserted into the grid: every treatment is written onto cells that already exist, so presence cannot shift layout, cover an in-cell chart, or intercept a click. The roster is the exception, because it is a control.

Colours are assigned by hashing the peer id against --lattice-peer-1--lattice-peer-8, so one person is the same colour on every screen and across reloads.

grid.comments

Threaded comments attached to individual cells, for collaborative data review: flagging an anomaly, asking why a figure changed, recording the reason behind a manual correction. A commented cell carries a small triangle in its upper-right corner; clicking the corner opens the thread.

The grid owns presentation and interaction only. Storage, identity and permissions are yours. Comment data lives wherever you put it and is reached through a provider.

A stable rowKey is required. Comments are keyed on row identity plus field, never row index, and they outlive the values they annotate. Configure the grid without a rowKey and comments are disabled: named in the same console warning as the other identity-dependent features: rather than silently filing threads against positions that move on the next sort.

Identity must be stable across sessions and across data reloads, not merely within one session. A key derived from load order is not enough: reload the data in a different order and every comment reattaches to the wrong row.

comments: {
  provider,                        // required; without it the feature is inert
  mode: 'anchored',                // or 'docked' for a side panel
  markdown: false,                 // restricted: emphasis, code, links
  rowLabel: (row) => row.data.name  // so the panel says what is being discussed
}
MethodReturnsDescription
enabledbooleanFalse without a provider or without stable row identity.
unavailable()string | null'no-provider', 'no-row-identity', or null.
at(rowId, colId)object | null{ count, unresolved, updated } for one cell. Counts only: this is read on every repaint.
open(rowId, colId)PromiseOpen a thread and load its bodies.
close(opts?)voidClose and discard the bodies.
add(body, opts?)PromiseAdd to the open thread. opts.parentId replies within it.
edit(commentId, body)Promise
remove(commentId)Promise
resolve() / unresolve()PromiseMark the open thread.
request(rowIds, fields?)voidAsk for index entries. Debounced; the viewport does this for you.
refresh()voidReload the index for known rows, after your application learns of a change elsewhere.
loadAll()Promise<boolean>Load the index for every row, which the comments-only filter needs first.
completebooleanWhether the index covers the whole row set.
hiddenUnresolved()numberUnresolved threads on rows the current filter hides. Zero when the index is partial.
filterToCommented(opts?)booleanRestrict to rows carrying comments. unresolvedOnly narrows further. False when the index is incomplete.
thread / openKey / loading, The open thread, its cell key, and whether it is still loading.

The provider

Every method returns a promise. A rejection surfaces in the panel without disturbing grid state, and an optimistic write is rolled back.

MethodDescription
loadIndex(rowIds, fields)Counts and timestamps for the requested cells. Never bodies. Called for the viewport and on scroll, debounced.
loadThread(cellKey)The ordered comments for one cell.
addComment(cellKey, body, parentId, ctx)ctx.value is the cell's value at the time of writing. Store it.
editComment(id, body)
deleteComment(id)
resolveThread(cellKey) / unresolveThread(cellKey)

The grid performs no authorisation. A comment may carry can: { edit, delete, resolve } and the grid draws affordances accordingly, but that is a convenience for the user and never a security control. Absent flags mean every affordance is shown. Your provider must reject what it must reject.

Author information is rendered exactly as the provider supplies it: author: { name, avatarUrl, initials }. The grid does not know who the user is and does not guess.

Bodies are text. The default path never produces markup. With markdown: true the panel handles emphasis, code and links only, builds elements rather than assigning HTML, and refuses any link scheme other than http, https and mailto.

Comments follow their row through sorting and grouping. When a commented row is filtered out its comments are not lost and not shown; hiddenUnresolved() reports what is outstanding on hidden rows so their absence does not mislead, and the status bar’s comments panel puts that count on screen whenever it is not zero. Comments remain available while streaming, and a thread whose row is evicted by a bounded window closes with an explanation. Comments do not appear in exports and do not serialise into saved views, a view captures display configuration, not data.

Keyboard: Alt+M opens the thread on the focused cell. The panel traps focus while open and returns it to the originating cell on close. Cells carrying comments announce the fact, and the unresolved count, through their accessible description.

grid.facets

A distribution chart in each column heading, which is also a filter control. Clicking a bar filters to that bucket; dragging across bars on an ordered column filters to the range. As filters are applied, the other columns' charts recount, so a dataset can be explored by clicking through headings rather than opening a dialog.

Off by default. The band roughly doubles the header's height.

facets: { enabled: true }                // grid-wide

// per column, layered over the grid's settings
{ field: 'price', type: 'number', facet: { strategy: 'quantile', buckets: 16 } }
{ field: 'notes', facet: false }         // opt one column out
MethodReturnsDescription
get(colId)object | null{ bounds, counts, unfiltered, stale, suppressed }. Schedules the computation if it has not run; redraw on facet:computed rather than awaiting.
suppression(colId)string | nullWhy there is no chart: type, cardinality, rows, streaming, no-provider, disabled. Null when there is one.
config(colId?)objectThe resolved settings, column layered over grid.
select(colId, from, to?, opts?)booleanFilter to a bucket, or to the range from-to. opts.additive adds to a categorical set. Selecting what is already selected clears it.
clear(colId)booleanRemove only this column's filter, leaving every other filter in place.
selected(colId)number[]Which buckets the column's own filter currently covers.
toggle(colId, open?)booleanExpand or collapse the chart. Rides in a saved view.
isExpanded(colId)boolean
refresh(opts?)voidRecount every chart. immediate skips the debounce.
expanded()string[]Every expanded column.

A column is never counted against its own filter. Every other active filter applies; that column's own conditions are pruned out. Without this, clicking a bucket would collapse the chart to that single bar, leaving no way to see what was excluded or to widen the selection.

The filters are ordinary filters. They go through filters.set, so they undo, ride in saved views, and appear in whatever filter UI you already have. A drag emits a between range rather than a set of bucket indices, so it still means something after the data is replaced and the edges move.

OptionDefaultDescription
enabledfalseGrid-wide, or per column.
collapsedtrueStart as a one-line density strip that opens on click.
height28Band height in pixels.
buckets20Numeric and date columns.
strategy'equal'equal, quantile or log. Equal width looks wrong on skewed data.
granularityautohouryear. Chosen from the span when omitted.
order'count'count or alpha, for categorical columns.
cardinalityLimit50Distinct values above which a text column has no readable chart.
aboveLimit'suppress'suppress, or topN for a top list with an aggregated remainder.
rowCeiling2000000Rows above which charts are suppressed.
debounce120Milliseconds a filter change waits before charts recount.
whilePausedtrueWhether a paused stream re-enables charts.
provider, Async bucket counts for a paged or remote source. Without one, charts are suppressed silently.
format, (bucket, count, unfiltered) => string for tooltips and accessible names.

Live streams suppress charts. Buckets that move under the pointer are worse than no chart, the control lies about what clicking it will do. Filters already made stay applied, because they are ordinary filters. Pausing the stream brings the charts back; set whilePaused: false if you would rather it did not.

Server-side sources need a provider. It receives the column, the current filter state with that column's own conditions removed, and the bucketing settings, and returns counts. Results are cached against the filter state, but this is still one query per column per filter change, a grid with eight faceted columns will ask eight questions every time a filter moves, and the backend has to be able to absorb that.

Charts are keyboard operable: focus enters from the header, arrows move between buckets, Enter toggles, Shift with arrows extends a range on ordered columns, Escape clears. Each bucket carries its range and count as an accessible name, and the chart as a whole carries a one-sentence description of the distribution's shape, which is the part bar-by-bar labels cannot convey.

grid.updates

Control over an incoming feed: hold it, let it through, and see what the batching is actually saving you. Pausing does not drop anything: held changes keep merging, so a long pause costs one entry per changed row rather than one per update.

grid.updates.pause();                // hold the feed; it keeps arriving and merging
grid.updates.stats();                // { pending, queued, coalesced, flushes, ... }
grid.updates.flush();                // apply what is waiting, stay paused
grid.updates.resume();               // apply everything and go live again
MethodReturnsDescription
pausedbooleanTrue while updates are held.
pause()booleanHold incoming updates. True when this call paused it.
resume()objectApply everything held and start applying again. Returns the rows added, updated and removed.
flush()objectApply what is waiting without leaving the paused state, a single step.
stats()objectCounters for the feed and the buffer: what arrived, what will be applied, and the difference.
log(opts?)object[]The timestamped changes still held, oldest first. since narrows to a time window.

coalesced is the number a batching strategy is actually bought with: rows that arrived more than once in a window and were written once. A feed where it stays at zero is not being coalesced, whatever the interval says.

The log is bounded two ways, 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.

OptionDefaultDescription
updates.logLimit2000How many changes are kept.
updates.logRows100000How many rows those changes account for between them. A feed delivering large batches reaches this one first.
updates.flush'frame'frame lands on a paint boundary, which is what makes one repaint per batch reliable. microtask at the end of the current task, interval on the coalescing window, manual only when you call flush().
updates.maxQueued20000Queued rows that force an early flush, whatever the strategy: including manual.
updates.budgetMs10Milliseconds one flush may spend applying before deferring the rest to the next frame.

Applying changes

rows.apply(change) applies immediately and returns what happened; rows.queue(change) batches into the next flush and returns a promise. Both take the same shape: add, update, remove, and an optional at insert position.

An update is a patch, not a replacement. Fields absent from the update are untouched, so a delta from a websocket or a save response can be applied as it arrives without reading the row back first. Coalescing merges fields too: {price} and {volume} arriving as separate messages inside one window both survive.

grid.rows.apply({ update: [{ id: 'R1', price: 42 }] });
// every other field on R1 is left alone

Rows that cannot be applied are reported, not thrown. A batch of a thousand containing three bad rows applies the other 997 and lists the three.

ReasonMeaning
unknown-idAn update or remove naming a row that is not in the grid.
duplicate-idAn add whose key already exists. Refused rather than admitted: selection, expansion, comments and the key index all resolve one key to one row.
const result = grid.rows.apply({ update: [ ... ] });
result.rejected;  // [{ operation, id, reason }]

These are batches, not database transactions. There is no isolation and no all-or-nothing guarantee: partial application with per-row rejection is the defined behaviour, which is why the API is not called a transaction.

stats() reports held against heldLimit: what the log is carrying now, against what it will carry. rows is a lifetime total of everything that ever arrived and says nothing about memory; these two do. Raise logRows for a deeper scrubber on a grid you have measured, and lower it on a feed of very wide rows.

grid.timeline

Moves the grid back through recent data changes: what a row held a minute ago, before the number moved. It reads the change log rather than the undo history: history records what you did, and on a live grid the question is what the data did.

Nothing is scrubbable until attach(). What a value used to be is not recoverable after the fact, and reading a row per key on every change is real cost on a busy feed, so recording is off until you ask for it and the window fills from that moment.

grid.timeline.attach();              // start recording; a scrubber appears
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();              // stop recording; the scrubber goes
MethodReturnsDescription
attachedbooleanWhether the scrubber is recording.
livebooleanTrue when the grid is showing the present.
positionnumberHow many steps back the grid is standing. Zero is live.
depthnumberHow many steps back it is possible to go.
attach()voidStart recording what changes replace.
detach()voidStop recording and return to the present.
seek(steps)numberStand a number of steps back, 0 being live. Clamped, not refused, at both ends.
step(by)numberMove relatively; negative goes back in time.
toLive()numberReturn to the present, applying everything stepped over.
at()number | nullThe timestamp being shown.
span()object | null{ from, to }, the range the scrubber can move over.

Cells whose value moved during a seek are marked and stay marked until the next seek, in --lattice-timeline-changed. On a wide row the change you are hunting for is easy to scroll past, and a flash you can miss helps nobody. Marking compares rendered column values rather than raw fields, so a computed column that moved because its inputs moved is marked too. Chart columns redraw as you scrub, like any other cell.

Value changes reverse; row additions and removals do not, a window containing them scrubs over the value changes and leaves the row set alone. While scrubbed back the grid is not live: changes keep being recorded but are not applied, and returning to the head applies everything missed. The delta renderer is the one cell type to keep off a scrubbed grid, because it samples on a wall-clock timer and reads a seek as a real movement.

grid.presentation

Enlarges the grid, drops the chrome and steps through saved views, for a screen share or a room. State only at this level: full screen and the pixels are the DOM layer's, so a headless grid can still be put into presentation state and asked about it.

grid.presentation.start({ scale: 1.5, views: ['q3', 'q4'] });
grid.presentation.step(1);                       // next view
grid.presentation.setSpotlight({ colIds: ['revenue'] });
grid.presentation.nudge(1);                      // a little larger
grid.presentation.stop();                        // or Esc

Esc ends the presentation, not just full screen: leaving one without the other would strand an enlarged, chrome-less grid in the page with no control left to turn it off. An open editor or menu still closes first.

MethodReturnsDescription
activebooleanTrue while a presentation is running.
scalenumberThe current enlargement.
start(options?)booleanBegin presenting. scale, views, chrome keep-list, interval for auto-advance.
stop()booleanStop and put the grid back as it was.
setScale(value)numberSet the enlargement, clamped to 0.5-4.
nudge(steps?)numberMove the enlargement by steps, for the live keyboard adjustment.
optionsobjectThe options the running presentation started with.
viewsstring[]The view ids being stepped through.
indexnumberPosition in the sequence, -1 when there is none.
viewIdstring | nullThe view id currently shown.
step(by?)numberStep forward or back through the sequence.
goTo(index)numberShow a numbered position.
spotlightobject | nullWhat is currently lit.
setSpotlight(target?)booleanLight rows, columns or their intersection and let the rest recede. Call with nothing to clear.
reset()booleanPut the current view back as it was saved, discarding what the presenter has sorted or filtered since.

Enlargement is a CSS scale factor multiplied into the same tokens density uses, so text, rows, padding and controls grow together rather than the grid being zoomed as an image. Font size is damped against it: type that scaled linearly with a 2× row height reads as shouting.

grid.annotate

The drawing layer over the grid: pixels on a transparent canvas, never data. A presenter picks a tool (pen, arrow, rect, highlight) and draws; the layer is inert until one is chosen, so scrolling and selection pass straight through otherwise. Marks are stored in content coordinates, so a circle drawn round a cell stays on that cell as the grid scrolls and resizes rather than hanging over the viewport.

Marks can also be seeded and added without drawing, which is what lets a host ship a pre-drawn callout or restore one from storage. A mark descriptor is { type, points, colour? } - type is freehand, arrow, rect, highlight or text (pen is accepted as an alias for freehand); points are {x, y} in content coordinates (a trail for freehand, the two endpoints for an arrow or rectangle, a single anchor for text). Seeded and added marks are durable: they survive a presentation ending, unlike a live-drawn mark, and they round-trip through getState and a saved view.

A text mark is a label anchored at one content point, carrying its text string and a basic style: colour, an optional fontSize in content pixels (default 14, scaled with a presentation), and an optional background colour drawn behind it. Like every mark it is held in content coordinates, so the label tracks the cell it annotates through scroll and resize.

// Seed a mark at construction - rendered on first paint, the way redaction seeds.
createGrid(el, {
  columns, rows,
  annotate: true,
  state: { annotations: [
    { type: 'arrow', points: [{ x: 40, y: 120 }, { x: 220, y: 80 }], colour: '#e0245e' },
    { type: 'text', text: 'Q3 spike', points: [{ x: 232, y: 72 }], colour: '#1a6bc7', background: '#fffbe6' },
  ] },
});

// Or add one durably at runtime - no synthesised pointer input.
grid.annotate.add({ type: 'rect', points: [{ x: 40, y: 100 }, { x: 260, y: 160 }] });
grid.annotate.add({ type: 'text', text: 'review', points: [{ x: 48, y: 108 }], fontSize: 16 });

// Persist and restore: seeded and added marks come back out of the state.
const marks = grid.getState().annotations;   // [{ type, points, colour }, …]
grid.state.apply({ annotations: marks });     // re-seed a fresh grid

annotate.add adds to the model and paints - it never synthesises pointer events, so a mark is exactly what the descriptor says. undo() removes the most recent mark and clear() removes them all, as before; annotation:changed still fires on every change. A presentation ending clears the presenter's live-drawn marks but keeps the durable ones, which are view state a host means to persist.

grid.redaction

Obscures a column's values on screen while leaving the shape of the data (row count, sort, filters, layout) perfectly readable. Built for presenting and screen sharing. Right-click a column heading for Redact column.

This is not a security control. The values stay in the model, the DOM, the clipboard and every export; anyone with the page can read them from devtools or by turning off one CSS rule. It defeats a camera, which is the whole claim. For a value that must not reach the browser at all, use permissions with writeOnly.

grid.redaction.toggle('salary');     // returns the state it is now in
grid.redaction.add('salary');
grid.redaction.set(['salary', 'bonus']);
grid.redaction.list();               // ['salary', 'bonus']
grid.redaction.clear();              // back to normal when the call ends
MethodReturnsDescription
has(colId)booleanIs this column redacted?
list()string[]Every redacted column id.
toggle(colId)booleanRedact, or stop. Returns the state it is now in.
add(colId)void
remove(colId)void
set(ids)voidReplace the whole set.
clear()voidStop redacting everything.
activebooleanTrue when at least one column is redacted.

The treatment is a CSS token, so a host can swap it: --lattice-redaction-filter defaults to blur(5px) contrast(0.85) and accepts anything the filter property does, including url(#your-svg-filter) for a mosaic.

grid.formatting

Conditional formatting rules the grid holds as runtime state, so an end user can change them. Rules travel in a saved view and undo like any other change. A scope is a column id, or '*' for every column; grid-wide rules are evaluated first, then the column's own, as one ordered list in which the first match wins.

This is distinct from compileRules() feeding cell.style, which compiles at configuration time and is what you want for rules a user should not be able to change. Both work at once: a runtime rule layers over whatever cell.style produced, winning only for the properties it names.

createGrid(el, {
  formatting: {                                   // optional seed
    margin: [{ when: { op: 'lt', value: 0 }, style: { background: '#fbeceb' } }],
  },
});

grid.formatting.add('margin', { when: { op: 'lt', value: 0 }, style: { background: 'red' } });
grid.formatting.add('*', { when: { op: 'blank' }, style: { background: '#f1f3f5' } });
grid.formatting.move('margin', ruleId, 0);        // order is meaning
grid.formatting.update('margin', ruleId, { enabled: false });
grid.formatting.remove('margin', ruleId);
grid.formatting.clear('margin');                  // or clear() for everything
MethodReturnsDescription
list(scope?)Rule[]The rules for one scope, in evaluation order.
all()objectEvery rule keyed by scope, the shape a saved view carries.
scopes()string[]Every scope holding at least one rule.
add(scope, rule, opts?)Rule | nullAppends, or inserts at opts.at. Returns the rule with its generated id.
remove(scope, idOrIndex)booleanBy id or position.
update(scope, idOrIndex, patch)Rule | nullMerges fields. The id is identity and cannot be reassigned.
move(scope, idOrIndex, to)booleanReorder, which can change which rule wins.
set(scope, rules)Rule[]Replace one scope.
replaceAll(rules)voidReplace every scope at once.
clear(scope?)voidOne scope, or all of them.
styleFor(colId, value)object | nullWhat the rules alone would paint, for an export or a preview.

A rule held here must be JSON: style may not be a function, because the rules are serialised into views and undo slices. Config-time cell.style still accepts one. Group rows are not formatted, matching the way decoration is dropped for them.

Rules that describe the data, not a threshold

gt: 100 needs somebody to know that 100 is the interesting number. Often nobody does, the interesting cells are the top decile, or the outliers, and where those fall is a property of the data rather than of the rule. These operators say that directly, and the grid works out the threshold from the column itself, over the filtered rows.

grid.formatting.add('margin', { when: { op: 'outlier' }, style: { background: '#fbeceb' } });
grid.formatting.add('qty',    { when: { op: 'topPercent', value: 10 }, style: { bold: true } });
grid.formatting.add('score',  { scale: { from: 'quantile', colours: ['#f8f9fa', '#1a6bc7'] } });

grid.formatting.distribution('margin');   // { n, min, max, mean, stddev, median, q1, q3, iqr }
grid.formatting.restat();                 // re-derive every threshold from the data as it stands
OperatorvalueMarks
topPercent10 or 0.1The top tenth of the column. Written either way; both mean the same thing.
bottomPercent10 or 0.1The bottom tenth.
topN5The five largest, ties included: three rows sharing second place in a top three all take the colour.
bottomN5The five smallest.
aboveMean / belowMean, Either side of the mean.
aboveMedian / belowMedian, Either side of the median, which is the one to reach for on a skewed column.
zAbove / zBelow2That many standard deviations from the mean. A column with no spread marks nothing rather than everything.
outlier1.5Outside Tukey's fences at that many IQRs, the same definition a box plot draws, so the marked cells are the ones its whiskers exclude.

A colour scale can take its bounds the same way, with from in place of min and max: 'minmax' spans the data, 'quantile' spans low to high (5th to 95th percentile by default), 'stddev' spans deviations either side of the mean. The quantile form is the better default on real data, one mistyped order of magnitude otherwise compresses every real value into the first swatch.

Thresholds are pinned when the rules compile and do not move on their own. That is deliberate: a boundary that re-derived itself as rows were filtered would repaint cells whose values had not changed, and nobody comparing two screenshots could tell which of the two things had moved. grid.formatting.restat() is how you move it, and a "recalculate" control is the natural place to put it.

Data bars and icon sets as rules

A rule can carry a data bar or an icon set instead of a style or a scale, so the same declarative, view-persisted, headless rule list that already paints colour scales also paints proportional bars and per-band glyphs. Both compile to a plain style object - a data bar is a CSS gradient on the background, an icon set a background-image - so they need no extra element, compose with the cell's text, and resolve the same way for a server-side export as for a browser paint. This is the rule-engine sibling of the cell decoration below; reach for a rule when you want the visual to travel in a saved view and to derive its bounds from the column's distribution.

// A data bar spanning the data, and a three-arrow icon set split at the tertiles.
grid.formatting.add('revenue', { dataBar: { from: 'minmax', colour: '#5b9bd5' } });
grid.formatting.add('score',   { iconSet: { set: 'trafficLights' } });

// Bounds and bands can be pinned instead of derived; bars that straddle zero
// grow both ways from a shared axis, in their own colours.
grid.formatting.add('delta', { dataBar: { min: -100, max: 100, colour: '#2e7d32', negativeColour: '#c0392b' } });
grid.formatting.add('rank',  { iconSet: { set: 'arrows', thresholds: [10, 20], reverse: true } });

A data bar takes min/max to pin its scale, or from: 'minmax' | 'quantile' | 'stddev' to derive it from the column; colour and negativeColour fill the two sides of a zero axis, and direction: 'rtl' reverses it. An icon set names a built-in - the keys of ICON_SETS (arrows, trafficLights, ratings) - or supplies its own icons; thresholds place the band edges, or, given none, the column is cut into equal-count bands; reverse flips the order so a high value can read as red.

const { createHeadlessGrid, ICON_SETS } = await import('../packages/core/src/index.js');

const grid = createHeadlessGrid({
  columns: [{ field: 'id' }, { field: 'revenue', type: 'number' }, { field: 'score', type: 'number' }],
  rowKey: 'id',
  rows: [1, 2, 3, 4, 5].map((n) => ({ id: 'r' + n, revenue: n * 20, score: n })),
  formatting: {
    revenue: [{ dataBar: { from: 'minmax', colour: '#5b9bd5' } }],
    score: [{ iconSet: { set: 'trafficLights' } }],
  },
});

// A data bar compiles to a gradient the cell layer paints as a background.
const bar = grid.formatting.styleFor('revenue', 100).backgroundImage.includes('linear-gradient') ? 'bar' : 'none';

// An icon set resolves different bands to different glyphs.
const low = grid.formatting.styleFor('score', 1).backgroundImage;
const high = grid.formatting.styleFor('score', 5).backgroundImage;
const icon = low !== high ? 'icon' : 'flat';

const sets = Object.keys(ICON_SETS).length;   // the three built-in sets
grid.destroy();
return [bar, icon, sets].join('|');

Runtime decorations: data bars and icon sets on demand

Where a colour rule paints the cell's background, a decoration changes the shape the cell renders as, a data bar sized by value, or a threshold icon set. grid.columns.decorate(id, spec) turns one on, changes it, or clears it with null, after the grid is built. It is presentation config rather than query state: unlike a formatting rule it is not on the undo timeline and does not travel in a saved view. Icon sets carry an aria-label per band and keep the value beside the glyph, so the meaning is announced, never only shown.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const grid = createHeadlessGrid({
  columns: [{ field: 'score' }, { field: 'trend' }],
  rows: [{ id: '1', score: 72, trend: 8 }],
  rowKey: 'id',
});

// A data bar, sized 0..100, set at runtime.
grid.columns.decorate('score', { type: 'bar', min: 0, max: 100 });
const bar = grid.columns.get('score').cell.decoration.type;

// A built-in three-arrow icon set on the trend column, with its own bands.
grid.columns.decorate('trend', { type: 'icon', bands: [
  { min: 0, icon: 'chevronUp', label: 'increasing', variant: 'success' },
  {         icon: 'chevronDown', label: 'decreasing', variant: 'danger' },
] });
const set = grid.columns.get('trend').cell.decoration.type === 'icon' ? 'arrows' : 'none';
const band = grid.columns.get('trend').cell.decoration.bands[0].label;

// Clearing a decoration returns the column to plain text.
grid.columns.decorate('score', null);
const cleared = grid.columns.get('score').cell.decoration === undefined ? 'cleared' : 'still-set';

// A runtime colour rule is the durable, view-persisted sibling.
grid.formatting.add('score', { when: { op: 'lt', value: 50 }, style: { background: '#fdecea' } });
const painted = grid.formatting.styleFor('score', 20) ? 'painted' : 'plain';

return [bar, set, band, cleared, painted].join('|');

grid.validation

Declarative column validation. Where edit.validate is an imperative function you write, this is the same job said as data: required, min/max, minLength/maxLength, pattern, oneOf, and a crossField predicate, declared per column in validation. Each rule is checked against a new value before it is written, riding the cancellable beforeEdit before-event: a failing value cancels the commit so no cell is written, marks the cell with the grid's ordinary invalid state (an accessible error, not only a red border), and fires validation:failed. A corrected value clears the mark and, where you are watching, fires validation:cleared. The cancellation carries reason: 'validation:<code>', so a host logging cancellations can tell a validation veto from any other.

Only a user-initiated edit is gated, the same contract beforeEdit itself keeps: a host API write (grid.edit.setCells) and a remote/router-applied delta are the authority and do not self-veto. A grid whose columns declare no rules wires no gate and keeps the byte-for-byte synchronous edit path.

// Declared per column, config-time.
createGrid(el, {
  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.' } } },
  ],
});

grid.validation.check('age', 200);          // { code: 'max', message: 'Must be at most 120.' } - records nothing
grid.validation.errorFor('r1', 'age');       // the recorded error for a cell, or null
grid.validation.errors();                    // every cell that currently holds an error
grid.validation.define('age', { min: 18 }); // set or replace a column's rules at runtime
grid.validation.clear('r1', 'age');          // drop a mark by hand
const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const grid = createHeadlessGrid({
  columns: [{ field: 'id' }, { field: 'age', type: 'number', edit: true, validation: { min: 0, max: 120 } }],
  rowKey: 'id',
  rows: [{ id: 'r1', age: 30 }],
});

let failed = 0;
grid.on('validation:failed', () => { failed++; });
grid.on('validation:cleared', () => {});

// A user edit that breaks the rule is refused: the cell is not written.
grid.edit.start('r1', 'age'); grid.edit.stop(false, { value: 999 });
const blocked = grid.rows.byKey('r1').data.age;              // still 30
const why = grid.validation.errorFor('r1', 'age').code;      // 'max'

// A valid value writes through and clears the mark.
grid.edit.start('r1', 'age'); grid.edit.stop(false, { value: 40 });
const now = grid.rows.byKey('r1').data.age;                  // 40

grid.destroy();
return [blocked, why, now, failed].join('|');

grid.statistics

What the grid knows about its own numbers, and about how they have changed since the page loaded. Every figure is computed over the filtered rows, through the same column handles the totals row uses, so a median here and a median in the footer are the same number, by the same definition (R type 7).

grid.statistics.profile('margin');
// { column, rows, present, missing, distinct, min, max, mean, median,
//   q1, q3, iqr, stddev, outliers, histogram: [{ from, to, count }, …] }

grid.statistics.profile('region');   // a categorical column
// { column, rows, present, missing, distinct, …numeric figures null…,
//   histogram: [], topValues: [{ value, count, share }, …] }

grid.statistics.reduce('margin', 'p95');          // any registered kernel
grid.statistics.correlation('spend', 'revenue');  // Pearson's r, clamped to [-1, 1]
grid.statistics.weightedAverage('price', 'qty');

grid.statistics.covariance('spend', 'revenue');
grid.statistics.regression('spend', 'revenue');   // { slope, intercept, r2, stdError, n }
grid.statistics.spearman('spend', 'revenue');     // rank; one outlier cannot drag it
grid.statistics.kendall('spend', 'revenue');      // tau-b, null past 5,000 rows
grid.statistics.weightedQuantile('price', 'qty'); // the median by default

grid.statistics.series('price', { by: 'date', periodsPerYear: 252 });
// { volatility, annualisedVolatility, growth, maxDrawdown, maxDrawdownFrom,
//   maxDrawdownTo, autocorrelation, upDays, downDays, … }

grid.statistics.capability('mm', { baseline: 20 });
// { cp, cpk, pp, ppk, sigmaWithin, sigmaOverall, outOfSpec, defectRate,
//   limits: { centre, upper, lower, sigma }, violations: [{ index, rule }] }

grid.statistics.shadow('price', 'delta', 'R42');  // one row's shadow value
grid.statistics.rebase('price');                  // "mark all": today's values become the baseline
grid.statistics.tracking();                       // { columns, rows, forgotten }

The statistics tool panel is the end-user half of profile(): a column picker, the twelve figures and a histogram of the column's shape, all following the filters. Add it with toolPanel: { panels: ['columns', 'statistics'] }. It profiles categorical columns too: a text column shows its count, distinct count and Top values (each value with its count and share) instead of the numeric figures and the histogram it has none of.

The column header menu carries a Column statistics item that opens this panel seeded on the column it belongs to. It emits column:profile:open with { colId } rather than reaching into the dock, exactly as the header filter affordance emits column:filter:open; a mounted tool panel turns that into the open, seeded statistics panel.

The regression tool panel is its multi-column sibling: it fits the model you name and shows the coefficient table - each term's estimate ± standard error with its t and p - alongside R² and adjusted R², the variance-inflation factor per predictor, and the Breusch-Pagan heteroscedasticity flag, all over the filtered rows and computed by the one core engine (grid.statistics.regressionModel). Name the model on the panel: toolPanel: { panels: ['columns', { name: 'regression', props: { predictors: ['x1', 'x2'], response: 'y' } }] }. p-values are reported as numbers with a documented method, never a significance verdict.

The same fitted model can live in the data as shadow columns: shadow: { kind: 'fitPredicted', model: { predictors: ['x'], response: 'y' } }, and likewise fitResidual and fitInfluence - plus fitStdResidual, fitLeverage and fitCooksD, which surface the internally studentised residual, the hat-matrix leverage and Cook's distance the engine already computes. They are ordinary numeric/boolean cells - sortable, filterable, groupable, exportable - that read the fit by row key and follow the grid's filters (the model refits over the filtered rows); a row outside the fit reads null. fitInfluence flags Cook's D > 4/n by default (overridable with threshold), keeping "not influential" (false) and "cannot tell" (null) distinct.

It shows the twelve one-pass figures, then Shape (skewness, kurtosis, Jarque-Bera), Robust (trimmed and winsorized means, MAD, robust outliers), Concentration (Gini, HHI, entropy, evenness, top-3 share) and Capability where the column declares a spec. A section whose reductions all return null is left out rather than shown as a column of dashes.

Or put it in your own page. mountPanel takes no dock and does not create one: toolPanel may be off entirely, so a statistics readout can sit beside a chart, in your own sidebar, or in a settings dialog, at whatever size you give it. It repaints on the same events the rail does, so it stays in step with filters, edits and saved views without you subscribing to anything.

import { mountPanel } from '@toclocoinc/lattice-grid';

const stats = mountPanel({ grid, panel: 'statistics', container: sidebar });
stats.refresh();   // for a change the grid does not announce
stats.destroy();   // yours to call: the element belongs to your page

Any built-in panel works: columns, filters, views, quick, formatting, statistics, regression, compare, insights, as does a constructor of your own.

The insights panel

The insights tool panel is the on-screen half of the comparison analytics (): the API-only subsetVsPopulation(), datasetVsDataset(), capability() and compareGroups() rendered without you building any UI. It is opt-in - off unless you name it. Add it with toolPanel: { panels: ['columns', 'insights'] }.

It shows four things, all over the filtered rows: the columns of the current filtered subset ranked by effect size against the whole; the same ranking against a second grid when you pass one as toolPanel: { panels: ['insights'], /* config */ } with insights: { compareWith: otherGrid }; process capability for the chosen column where it declares a spec; and a two-group comparison - pick a column and a column to group by, and the panel shows the named test, its confidence interval and its effect size together.

The stance is enforced on screen. The effect size is never shown without its interval beside it; the test or method that produced a figure is always named, from the API's own method string; and nothing renders a significant flag, a verdict, a badge or a star - the p-value is shown as the plain datum it is, when it is shown at all. The panel adds no statistic of its own: every number it shows is the one grid.statistics returns.

createGrid(el, { toolPanel: { panels: ['columns', 'insights'] } });

// with a second dataset to rank this grid against:
createGrid(el, {
  toolPanel: { panels: ['insights'] },
  insights: { compareWith: otherGrid },
});

The reductions

Forty-one, all available to a totals row, to reduce() and to the profiling panel. Names are the same everywhere and the labels come from the message catalogue, so a grid in Polish reads in Polish.

GroupNames
Basicsum, avg, min, max, count, countValues, first, last, distinct, mode, range
Spreadvariance, varianceP, stddev, stddevP, iqr, mad, sumSquares
Quantilesmedian, p25, p75, p90, p95, p99
Shapeskewness, kurtosis, jarqueBera: above 5.99 the column is not plausibly normal
Meansgeomean, harmean, weightedAvg, trimmedMean, winsorizedMean
OutliersrobustOutliers: by the modified z-score, which an outlier cannot hide inside the way it inflates an ordinary one
Concentrationhhi, entropy, evenness, top3Share, top10Share, gini, the only group that reads a text column, because "how concentrated is this" is a question about categories
Positionalargmin, argmax

Process capability

Declare the customer's tolerance on the column, and the capability figures, a control chart and any rule marking an out-of-tolerance cell all read the same limits.

columns: [{ field: 'mm', type: 'number', spec: { lower: 9.5, upper: 10.8, target: 10 } }]

Cp and Cpk use short-term variation, estimated from the moving range; Pp and Ppk use the overall standard deviation. The gap between them is the point: Cpk well above Ppk means the process drifted. Cp above Cpk means it is precise and aimed wrong, which needs a different fix from being too variable.

baseline fixes the control limits over the first N readings. Without it the limits are computed over everything (including whatever the process did wrong) so a step change pulls the centre line between the two levels and both halves land outside three sigma. Technically true, and useless for finding when it moved.

Seeing it

The statistics have chart types to match, in modules/charts. Each takes its numbers from this namespace rather than recomputing, so a coefficient in a matrix and the same one from the API cannot drift apart.

TypeWhat it shows
correlogramEvery numeric column against every other, on a ramp centred at zero so the sign reads first. method: 'spearman' ranks instead; where the two disagree, the pair is related but not linearly.
qqSample quantiles against normal ones. Jarque-Bera says a column is not normal; this shows how, a heavy tail bends the ends, a skew bows the whole line. The reference runs through the quartiles, as R's qqline does, because a fitted line is dragged by the very tails you are inspecting.
ecdfThe share at or below each value, as a step. No bins, so its shape is not partly a choice, and two overlay cleanly where two histograms fight.
lorenzThe curve a Gini is read off, against the diagonal a perfectly even column would trace.
controlAn individuals chart: control limits from the moving range, the specification, and points breaking a Western Electric rule. The control limits are the process talking and the specification is the customer talking: conflating them is the classic error, so they are drawn differently.
histogramcurve: true overlays a kernel density estimate, which has no bin edges and so separates what is in the data from what is in the binning.
scatterfit: true draws least squares per series with R² beside it.

Values the grid maintains for you

Three of the ideas in this section are not standard grid vocabulary, so it is worth saying what they have in common before the detail. Each of them is a value the grid keeps up to date from data you already have, declared once rather than maintained by hand.

The alternative, in every application that needs one of these, is a parallel structure in the host: a copy of what each row looked like a moment ago, a rank recomputed on every tick, a cumulative total that has to be redone whenever the sort changes, and a dashboard panel running its own query beside the table. That code works for a while and then produces a number nobody can account for, usually because one part of it noticed a filter and another did not.

ConceptWhat it isReach for it when
Shadow column An extra column, declared against a real one, holding something the grid works out about it: how it has changed, or where it sits among the others. You want “what was this an hour ago”, “how many places has it moved”, or “which decile is it in” as a column you can sort and filter on.
Running column A cumulative value: the total, or the share of the total, by the time you reach this row. You want a running balance, a cumulative percentage, or a Pareto curve down the page.
Derived grid A whole second grid whose rows are built from the first: grouped, unnested, filtered, ranked or profiled. You want a top-five panel, a breakdown by region, an exceptions list or a statistics summary beside the table, and it must never disagree with it.

The line between a shadow and a running column is the sort order. A shadow is a function of the column: a row’s own history, or where its value sits among the others. Sort the grid differently and a rank is still the same rank. A running total is the opposite : it answers “how much by the time we reach this row”, and by the time is the order the rows are in, so re-sorting changes every value in the column. That is why they are declared separately rather than as two kinds of one thing.

A derived grid is a different scale of the same idea. A shadow adds a column to the rows you have; a derivation produces different rows altogether: one per sales person rather than one per sale. Because it is a source rather than a special kind of grid, the result sorts, filters, totals, themes and exports like any other, and can itself be the source of another.

All three read the rows the grid is currently showing, so a filter applied to the table moves the ranks, the running totals and every derived panel together. That is the property worth having: not that any one of them is clever, but that they cannot disagree.

Shadow columns

A shadow column is declared against another column and maintained by the grid. It has no field in the data and it is not a pure computed column either, because its value depends on what happened before. It is a real column throughout: sortable, filterable, totalled, grouped, exported, saved into a view, which is what makes "show me every circuit repriced more than twice this session, most-changed first" one gesture rather than a report.

columns: [
  { field: 'price', type: 'number' },
  { id: 'moved',  title: 'Change',  shadow: { of: 'price', kind: 'delta' } },
  { id: 'churn',  title: 'Updates', shadow: { of: 'price', kind: 'updates' } },
  { id: 'run',    title: 'Streak',  shadow: 'streak' },   // shorthand: shadows the column beside it
]
KindValue
updatesHow many times the row's value has changed. Arrival is not a change, so a freshly loaded grid reads zero rather than one.
updatedAtWhen it last changed, as a Date.
sinceUpdateMilliseconds since it last changed.
deltaCurrent value minus the baseline.
deltaPercentThe same as a percentage. A change from nothing has no percentage and reads null rather than infinity.
rateChange per second, from the last two readings.
historyThe recent readings, oldest first. depth sets how many; the default is 20. Counts changes by default - see the time-windowed form below for a real time series.
firstValueThe baseline itself.
streakConsecutive moves in one direction, signed. It resets on a turn, because "seven rises" means something and "seven changes" does not.

A second family answers where the row sits among the others rather than what it did before. They share the same declaration and the same state, the tracker already holds every row's current value and its baseline, which is exactly what a rank and a rank change need.

KindValue
rankCompetition rank, largest first: ties share the better rank and the next value skips, so two firsts are followed by a third.
rankAscThe same ranking read from the other end.
rankChangePlaces climbed since the baseline. Positive means climbed, even though the rank number itself falls: this is the "top movers" column.
percentileThe share of rows at or below this one, 0 to 100.
quartile1 to 4, agreeing with percentile: the 60th percentile is in the third quartile.
zScoreDeviations from the mean. A column with no spread reads null rather than zero.
shareOfTotalThe value over the column's total, as a percentage. A total of zero (a column of offsetting positions) reads null rather than a division by it.

Running totals

A running column answers “how much by the time we reach this row”: a balance that accumulates down the page, or the share of the total accounted for so far. It is the column a Pareto chart is made of, and the one a finance report opens with.

It is declared separately from a shadow column, and the sort order is the reason. Every shadow is a function of the column (of a row's own history, or of where its value sits among the others) so it reads the same however the rows are arranged. A running total does not: sort the grid differently and every value changes, because the question is "how much by the time we reach this row", and by the time is the sort order.

columns: [
  { field: 'amount', type: 'number' },
  { id: 'cum',   title: 'Running',      running: { of: 'amount', kind: 'total' } },
  { id: 'share', title: 'Cumulative %', running: { of: 'amount', kind: 'percent' } },
]

Computed in one pass over the display rows and cached against that ordering, so a hundred thousand rows are walked once per sort rather than once per cell. A running column is not sortable. Sorting on one asks the sort to depend on its own output (the value is defined by the display order) so the column does not offer a sort unless its definition asks for one, and the query layer refuses such a sort with a warning rather than computing it. Sort by the column it runs over instead. Group headings and totals rows are skipped, a running total that counted a subtotal would double everything below it, and a row with no value carries the figure forward unchanged rather than resetting it.

History has two clocks: a count of changes, or a span of time

Plain history (above) is an event series: it records a reading only when the value changes, so a row that sits still never advances it. Bound to a cell.render sparkline ('line', 'area', 'column', 'winloss') that means a static row's sparkline freezes, then jumps when a change finally lands - while a title like "last 60s" keeps claiming a span the column never measured. Add a time window to make it a real time series instead:

{ id: 'spark', title: 'Last 60s', shadow: { of: 'price', kind: 'history', window: { kind: 'time', span: 60_000 }, depth: 20 } }

This is the same window: { kind, span } shape the rolling kinds below already accept - not a second spelling of it - and it changes what history means rather than adding a new kind: depth (20 here) is now how many buckets the span divides into, so this is sixty seconds as twenty three-second buckets. Each bucket is sampled once, at its close, as the row's last known value at that moment - carried forward from the previous bucket when nothing changed in between. A static row therefore draws a flat line that keeps advancing, one sample per bucket, and a change lands in the bucket it actually happened in rather than being appended at the end. The bucket clock runs on its own low-frequency timer (never a render loop), so the series keeps moving even while no data event ever reaches the column. window: { kind: 'count' } and { kind: 'session' } are refused for history - a plain count is already what depth means without a window, and a session has no fixed span to divide into buckets - and a caller who never sets window gets the original count-based behaviour, unchanged.

Positional kinds rank over every tracked row, not over the filtered set: a rank that changed as you filtered would make "the top ten movers" depend on what happened to be on screen, and the column would disagree with itself between two views of the same data. Pass scope: 'filtered' on the shadow spec to rank within what the filters left instead: both answers are legitimate, which is why it is a choice rather than a default.

Shadow state is keyed by row key, never by index: after any sort an index-keyed history would report one row's past against another row's present, and the wrong number would be sortable. Memory is capped at 200,000 tracked rows per column; past that the oldest are dropped and tracking().forgotten says how many, rather than a smaller number being reported as though it were the truth.

Rolling time-series columns

A total answers "how much"; a rolling total answers "how much lately, as the series ran" - the seven-day average that smooths a daily figure, the trailing sum, the change on the period before. These are rolling shadow kinds: real columns, sortable and filterable and exportable like any other, computed in one ordered pass and cached by row key. They compose the windowed-aggregate model the grid already uses for "the average lately" over a live stream, asked instead over a column arranged in a stated order.

The order is explicit and required - an orderBy column, never the screen sort, because a rolling figure defined by the current sort would change on every header click and a column sorted on its own rolling value would define itself. The window is the last span rows (count), the last span of the order axis (time), or the whole series so far (session). The first rows carry a partial window; that figure is still emitted, but a windowCoverage companion stamps how much of the window it actually covers, so a two-day average is never shown as a seven-day one. within chooses per-group (the default, partitioned by the grid's grouping) or across the whole dataset.

A rollingQuantile (a trailing median, a p95, set by q) is exact while the window is small and comes from a KLL sketch past an internal span cap and for a session window - where a windowApproximate companion reports which rows are approximate, so a sketched quantile is never presented as exact. At a million rows the single ordered pass stays well within the suite's budget (≈380ms for the window aggregates, ≈490ms for the exact rolling median, ≈420ms for the session sketch on the reference bench).

columns: [
  { field: 'day',  type: 'date' },
  { field: 'sales', type: 'number' },
  { id: 'ma7',   title: '7-day avg', shadow: { kind: 'rollingAvg', of: 'sales', orderBy: 'day', window: { kind: 'count', span: 7 } } },
  { id: 'cover', title: 'Coverage',  shadow: { kind: 'windowCoverage', of: 'sales', orderBy: 'day', window: { kind: 'count', span: 7 } } },
  { id: 'p50',   title: '30-day median', shadow: { kind: 'rollingQuantile', of: 'sales', orderBy: 'day', window: { kind: 'count', span: 30 }, q: 0.5 } },
  { id: 'ytd',   title: 'Cumulative', shadow: { kind: 'cumulativeToDate', of: 'sales', orderBy: 'day' } },
  { id: 'delta', title: 'vs prev',   shadow: { kind: 'periodOverPeriod', of: 'sales', orderBy: 'day' } },
]

A rolling window is a property of the series, so it is computed over every row before any filter: hiding rows with a filter narrows what you see, never what "the last seven" means. A missing reading is a gap, skipped rather than treated as a zero that would report a plunge and a rebound the series never made.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
// A short series, ordered by t: values 2,4,5,4,5.
const base = { of: 'v', orderBy: 't', within: 'all' };
const win = { kind: 'count', span: 3 };
const grid = createHeadlessGrid({
  columns: [
    { field: 't', type: 'number' },
    { field: 'v', type: 'number' },
    { id: 'sum', shadow: { kind: 'rollingSum', window: win, ...base } },
    { id: 'avg', shadow: { kind: 'rollingAvg', window: win, ...base } },
    { id: 'cov', shadow: { kind: 'windowCoverage', window: win, ...base } },
    { id: 'med', shadow: { kind: 'rollingQuantile', window: win, q: 0.5, ...base } },
    { id: 'cum', shadow: { kind: 'cumulativeToDate', ...base } },
    { id: 'pop', shadow: { kind: 'periodOverPeriod', ...base } },
  ],
  rows: [
    { id: 'r1', t: 1, v: 2 }, { id: 'r2', t: 2, v: 4 }, { id: 'r3', t: 3, v: 5 },
    { id: 'r4', t: 4, v: 4 }, { id: 'r5', t: 5, v: 5 },
  ],
  rowKey: 'id',
});
const round4 = (x) => Math.round(x * 10000) / 10000;
const round2 = (x) => Math.round(x * 100) / 100;
return [
  grid.rows.value('r3', 'sum'),          // 2+4+5 = 11
  round4(grid.rows.value('r3', 'avg')),  // 11/3
  round4(grid.rows.value('r5', 'avg')),  // (5+4+5)/3
  round2(grid.rows.value('r1', 'cov')),  // 1/3 of the window filled
  grid.rows.value('r5', 'cum'),          // running total to the end
  grid.rows.value('r2', 'pop'),          // 4 - 2
  grid.rows.value('r3', 'med'),          // median of 2,4,5 = 4
].join('|');

Seasonal decomposition

Splitting a series into trend + seasonal + residual answers "what's the underlying trend with the weekly pattern removed?". It is classical decomposition - the same algorithm statsmodels.seasonal_decompose uses, verified against it in the reference suite - delivered as four shadow columns over the same ordered pass: tsTrend (a centred moving average), tsSeasonal (the repeating index), tsResidual (what the two leave behind), and tsCoverage.

The period is caller-declared and required - 7 for a weekly cycle in daily data, 12 for a monthly cycle in monthly data; there is no auto-detection in v1. The model is additive by default; decomposition: 'multiplicative' is a declared option that is undefined on a non-positive series (those rows report null, with a warning). The centred window runs off the ends, so the leading and trailing rows have no trend - they are partial edges, reported as null and stamped tsCoverage: 0 rather than emitted as if full.

columns: [
  { field: 'day',  type: 'date' },
  { field: 'sales', type: 'number' },
  { id: 'trend',  title: 'Trend',    shadow: { kind: 'tsTrend',    of: 'sales', orderBy: 'day', period: 7 } },
  { id: 'season', title: 'Weekly',   shadow: { kind: 'tsSeasonal', of: 'sales', orderBy: 'day', period: 7 } },
  { id: 'resid',  title: 'Residual', shadow: { kind: 'tsResidual', of: 'sales', orderBy: 'day', period: 7 } },
  { id: 'cover',  title: 'Coverage', shadow: { kind: 'tsCoverage', of: 'sales', orderBy: 'day', period: 7 } },
]
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
// A period-4 series: trend 10+i plus a season [2,-1,0,-1], so value = trend + season.
const season = [2, -1, 0, -1];
const base = { of: 'v', orderBy: 't', within: 'all', period: 4 };
const grid = createHeadlessGrid({
  columns: [
    { field: 't', type: 'number' },
    { field: 'v', type: 'number' },
    { id: 'trend',  shadow: { kind: 'tsTrend', ...base } },
    { id: 'season', shadow: { kind: 'tsSeasonal', ...base } },
    { id: 'resid',  shadow: { kind: 'tsResidual', ...base } },
    { id: 'cover',  shadow: { kind: 'tsCoverage', ...base } },
  ],
  rows: Array.from({ length: 8 }, (unused, i) => ({ id: String(i), t: i, v: (10 + i) + season[i % 4] })),
  rowKey: 'id',
});
return [
  grid.rows.value('4', 'trend'),   // centred MA recovers the trend: 14
  grid.rows.value('4', 'season'),  // the phase-0 seasonal index: 2
  grid.rows.value('4', 'resid'),   // nothing left over: 0
  grid.rows.value('4', 'cover'),   // interior row: full, 1
  grid.rows.value('0', 'trend') === null ? 'null' : 'x',  // partial edge: null, not invented
  grid.rows.value('0', 'cover'),   // edge stamped partial: 0
].join('|');

Exponential smoothing

Smoothing pulls the signal out of a noisy series. tsSmoothed is the fitted level - not a forecast of the future - from single exponential smoothing (smoothing: 'ses', the default) or Holt's level+trend (smoothing: 'holt'). The recursion matches statsmodels and is verified against it in the reference suite. Holt-Winters (seasonal) smoothing is deferred; seasonality is covered by decomposition above.

The smoothing factor is either caller-set (alpha, and beta for Holt) or fit by minimising the in-sample SSE when omitted - and the chosen value is reported, not hidden, by the tsSmoothingAlpha / tsSmoothingBeta companion columns.

columns: [
  { field: 'day',   type: 'date' },
  { field: 'sales', type: 'number' },
  { id: 'level', title: 'Smoothed', shadow: { kind: 'tsSmoothed',       of: 'sales', orderBy: 'day', smoothing: 'holt' } },
  { id: 'a',     title: 'α',        shadow: { kind: 'tsSmoothingAlpha', of: 'sales', orderBy: 'day', smoothing: 'holt' } },
  { id: 'b',     title: 'β',        shadow: { kind: 'tsSmoothingBeta',  of: 'sales', orderBy: 'day', smoothing: 'holt' } },
]
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
// SES at alpha 0.5 over 4,8,6,10: level runs 4, 6, 6, 8.
const base = { of: 'v', orderBy: 't', within: 'all', smoothing: 'ses', alpha: 0.5 };
const grid = createHeadlessGrid({
  columns: [
    { field: 't', type: 'number' },
    { field: 'v', type: 'number' },
    { id: 'sm', shadow: { kind: 'tsSmoothed', ...base } },
    { id: 'a',  shadow: { kind: 'tsSmoothingAlpha', ...base } },
  ],
  rows: [4, 8, 6, 10].map((v, i) => ({ id: String(i), t: i, v })),
  rowKey: 'id',
});
return [
  grid.rows.value('1', 'sm'),   // 0.5*8 + 0.5*4 = 6
  grid.rows.value('3', 'sm'),   // 0.5*10 + 0.5*6 = 8
  grid.rows.value('0', 'a'),    // the factor used, reported: 0.5
].join('|');

Stationarity (ADF)

Before you compare two series or detrend one, it helps to know whether it is stationary - reverting to a level or trend - or wandering with a unit root. grid.statistics.adf runs the Augmented Dickey-Fuller test and returns a scalar readout, not a per-row column: the statistic, the augmenting lag chosen by AIC, MacKinnon's critical values, an interpolated p-value (stamped approximate), and a plain-language verdict at the 5% level. The constant+trend regression and the AIC lag choice match statsmodels' adfuller, against which the statistic and lag are verified.

The lag search and what it costs. maxlag caps the number of augmenting lags the AIC search considers; left out, it is the Schwert rule ⌈12·(n/100)^0.25⌉ - 34 candidates on 7,000 rows - itself capped so the fixed sample keeps degrees of freedom. The candidates are nested, so the search builds one design matrix at the cap and reads every smaller candidate off it - one pass to accumulate the normal equations, then a small solve and a single residual pass per candidate, rather than a fresh fit each time. The default search over 7,000 rows is a matter of milliseconds. Setting maxlag narrows the search, never the arithmetic: the lag chosen, the statistic and the p-value are whatever the data says, and a cap that still contains the AIC-preferred lag returns exactly the same readout.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
// A random walk (a unit root): it wanders rather than reverting.
const walk = [0.138, -0.725, -1.26, -0.536, -0.267, 0.167, -0.765, -0.146, -0.311, -0.586,
  0.376, -0.41, 0.282, 0.865, -0.024, 0.149, -0.2, -0.654, -1.338, -1.917, -1.832, -1.333,
  -0.492, -1.259, -1.89, -2.145, -2.146, -1.297, -1.26, -0.716, -0.846, -1.206, -2.126,
  -1.724, -0.945, -1.599, -1.316, -0.413, 0.304, 0.732, -0.257, 0.086, -0.572, -0.501,
  -1.153, -1.186, -1.455, -1.607];
const grid = createHeadlessGrid({
  columns: [{ field: 't', type: 'number' }, { field: 'v', type: 'number' }],
  rows: walk.map((v, i) => ({ id: String(i), t: i, v })),
  rowKey: 'id',
});
const adf = grid.statistics.adf({ of: 'v', orderBy: 't' });
return [adf.verdict, adf.usedLag].join('|');   // non-stationary, 0 lags

Autocorrelation (ACF / PACF)

grid.statistics.acf shows how far back a series depends on itself: the autocorrelation (ACF) and partial autocorrelation (PACF) arrays out to a maximum lag, each with the approximate ±1.96/√n white-noise band (stamped approximate) - a lag whose bar clears the band is evidence of real dependence. The estimators are the biased ACF and the Yule-Walker (Levinson-Durbin) PACF, matching statsmodels, verified in the reference suite. Lag 1 is the single source of truth: acf[1] is the same number statistics.series(...).autocorrelation reports, and pacf[1] === acf[1].

The correlogram is the arrays fed to a bar chart over explicit points, with the band as reference lines - reusing the existing chart primitives:

const { acf, bounds } = grid.statistics.acf({ of: 'sales', orderBy: 'day', maxlag: 20 });
createChart({
  grid, container: '#acf', type: 'bar',
  points: acf.map((v, lag) => ({ x: lag, y: v })),
  reference: [{ value: bounds.upper }, { value: bounds.lower }, { value: 0 }],
});
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
// A deterministic AR(1): each reading leans 0.6 on the one before.
let s = 5; const rand = () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 4294967296 - 0.5; };
const y = []; let prev = 0;
for (let i = 0; i < 200; i++) { const v = 0.6 * prev + rand(); y.push(v); prev = v; }
const grid = createHeadlessGrid({
  columns: [{ field: 't', type: 'number' }, { field: 'v', type: 'number' }],
  rows: y.map((v, i) => ({ id: String(i), t: i, v })),
  rowKey: 'id',
});
const res = grid.statistics.acf({ of: 'v', orderBy: 't', maxlag: 6 });
const series = grid.statistics.series('v', { by: 't' });
return [
  res.acf[0],                                    // lag 0 is always 1
  res.pacf[1] === res.acf[1],                    // the first partial equals the first acf
  Math.abs(res.acf[1] - series.autocorrelation) < 1e-9,  // lag 1 is the single source of truth
].join('|');

grid.highlight

One mechanism for two jobs: the flash a changed cell makes, and a marker you paint deliberately. A target is a cell ({key, colId}), a row ({key}, or a bare row key) or a column ({colId}). Cell beats row beats column, so a specific highlight is never hidden by a broad one laid over it.

createGrid(el, {
  highlightOnChange: { colour: '#ffe08a', duration: 1200 },   // or just true
});

grid.highlight({ key: 'r1', colId: 'cap' }, { colour: 'green', duration: 800 });
grid.highlight({ key: 'r3' }, { colour: '#fdeaea', duration: 0 });   // 0 = until cleared
grid.highlight({ colId: 'margin' }, { colour: '#e7f1fd', duration: 0 });
grid.highlight.clear({ key: 'r3' });
grid.highlight.clear();                                             // everything
MethodReturnsDescription
highlight(target, opts?)booleancolour (or color) and duration in milliseconds. duration: 0 stays until cleared.
clear(target?)booleanOne target, or every highlight when called with nothing.
list()object[]Every active highlight and its remaining duration.
colourFor(key, colId)string | nullWhat a given cell is painted, after precedence.

A highlight belongs to the row, not the element. Rows are recycled as you scroll, so highlights are reapplied after every paint, they survive scrolling, sorting, filtering and paging without any of them knowing highlights exist.

grid.views

Named states the user can return to. Views supplied in config.views.saved are defined views: listed apart in the picker, and neither renamable nor deletable, refused by the model as well as hidden in the interface. Views the user saves are their own and carry rename, share, default and delete.

createGrid(el, {
  views: {
    saved: [{ id: 'escalations', name: 'Escalations', description: 'Worst SLA first',
              state: { filters: { col: 'statusId', op: 'eq', value: 4 },
                       sort: [{ col: 'utilisation', dir: 'desc' }] } }],
    allowSave: true,   // false removes the save form entirely
    local: true,       // saved views live in this browser's localStorage, no backend
    // storage: { read, write }, // or bring your own backend; see the note below
  },
});
MethodReturnsDescription
list() / get(id)object[] / object
save(name, opts?)objectCaptures the current state. opts: id, description, shared, isDefault.
apply(id)object | nullOne undo entry. Resets to the baseline first, so a view is a destination rather than a patch, the same view gives the same grid whatever was applied before it.
rename(id, name)object | nullNull for a defined view.
remove(id)booleanFalse for a defined view.
setDefault(id)object | nullnull clears it. A default view is applied on load, without recording an undo entry.
export(id) / import(json)objectA JSON payload. What "sharing" means is yours to decide.
diff(id)object | nullWhat applying a view would change.
reload()voidRe-read from storage, discarding what is in memory.
activeIdstring | null

The grid makes no network calls. storage.write is a synchronous mirror. To persist to a server, listen for view:saved, view:renamed, view:removed and view:default: each carries the one view that moved, so you can send a single record rather than diffing two lists. Because the grid does not track whether your write landed, a failed request leaves the view visible locally: catch it and call views.reload().

views.local is the no-backend option: true stores views under a default localStorage key, shared by every grid on the origin; { key: '…' } picks a key of your own, for more than one grid whose views should stay apart. Given alongside an explicit storage, storage wins and local is ignored, with a console warning, the two are never merged. Built on createLocalViewStorage, exported for direct use (a custom key, or a different Storage-shaped backing such as sessionStorage) without the local shorthand.

grid.diff

Audit mode. Give it a prior snapshot and every row reports whether it was added, removed or changed, and which cells moved.

MethodReturnsDescription
setSnapshot(rows) / clear()voidAlso settable as config.diff.snapshot.
summary()object{ added, removed, changed, unchanged }.
statusOf(key)string'added', 'removed', 'changed' or 'unchanged'.
changedColumns(key)string[]
before(key, colId)unknownThe prior value. Also on the cell as data-before.
enabledboolean
swap()booleanShow the snapshot as the grid's data, and compare it against what was live until now. The snapshot is held as plain objects and never enters the columnar store, so a removed row cannot be sorted or filtered among live ones; swapping is the answer to that, the old rows become real rows with the whole pipeline behind them. Costs one ingest of each set, so it is a deliberate action rather than a toggle. The comparison reverses: what was an addition is now a removal. swapped reports which way round the grid is, and it is worth saying so in your interface.
swappedbooleanTrue while the snapshot is the data.
removedRowsfalse | 'pinned' | 'data'Whether a row in the snapshot but gone from the data is shown, and whether it counts as data. false (the default) leaves it out. 'pinned' shows it beneath the rows, struck through, outside the row set, not counted, not exported, not selectable. 'data' appends it to the set, so it is counted and exported. Neither is sorted or filtered among the live rows, because its values are the snapshot's; neither can be edited, because there is nothing left to write to.
strictNullbooleanOff by default, so null, undefined and an absent field all count as the same absence. Set it to tell them apart, for an audit where a field being cleared and a field never being sent are different events. It compares the data as supplied, not as stored, so it is a statement about your snapshot rather than about the grid.

Built-in renderers and editors

Both are addressable by name. Anything you register through components is addressable the same way, and a name you register wins over a built-in one.

Cell renderers, for cell.render:

NameDraws
areaA filled sparkline over a series.
bulletA value against a target and qualitative bands.
checkboxA boolean, optionally as a switch.
colourA colour swatch with its value.
columnA column sparkline.
deltaMovement since the last value, with direction.
detailExpanderThe master-detail chevron. Generated; not usually named directly.
donutA donut chart from a series.
gaugeA value on an arc against a range.
groupThe group and tree label, with its expander and indent.
iconAn icon chosen from the value.
imageA picture from a URL. Selected automatically for type: 'image'.
lineA line sparkline.
linkAn anchor, with the text and href drawn from the row.
pieA pie chart from a series.
pillA status chip carrying a semantic variant.
progressA progress bar with an optional label.
qrcodeA QR code of the value.
rangeA span between a low and a high value.
ratingA star rating.
skeletonA loading placeholder for a row not yet arrived.
stackedA stacked proportion bar.
twolineA primary value with a secondary line beneath it.
winlossA win/loss sparkline of signed values.

Editors, for edit.editor. Each column type already selects a sensible one, so naming an editor is for overriding that choice:

NameEdits
checkboxA boolean.
codeSource text, in a monospace field.
colourA colour.
dateA calendar date.
datetimeA date and a time together.
durationA length of time.
iconPickerOne icon from a set.
ipaddressAn IPv4 or IPv6 address.
multiSelectSeveral options, as chips.
numberA number, with the column's constraints.
objectPickerA record chosen from a list.
passwordA masked secret.
radixA value in its own base.
ratingA star rating.
segmentedOne of a few options, as a segmented control.
selectOne option from a list.
sliderA number on a track.
textA single line. The default.
textareaSeveral lines.
timeA time of day.
treeSelectA value from a hierarchy.
unitA quantity with a unit.

grid.permissions

Four levels per column, resolved from configuration or a callback. They are the four corners of read × write rather than a ladder:

LevelVisibleReadableEditableFor
hidden, , , Absent from the grid, the tool panel, exports, the clipboard, saved state, the filter model and formula references.
readyesyes, No editor opens; paste, fill and range-clear skip it.
writeOnlyyes, yesA secret: an API key a user may rotate but never read. The cell shows a mask and the editor opens empty, and a formula in another cell cannot reference it.
writeyesyesyesThe default, so the feature is opt-in.
permissions: 'read'                                  // blanket
permissions: { salary: 'read', ssn: 'hidden' }       // map; '*' sets the default
permissions: (column, ctx) => ctx.context.role === 'admin' ? 'write' : 'read'
permissions: { default: 'read', columns: { name: 'write' }, resolve }

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. writeOnly is the exception, and the reason it is worth having: 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 on the server; permittedColumns and permittedExport are pure and dependency-free so the same policy can run there.

grid.ai

The grid composes a prompt describing its own columns and operators, you send it to whichever model you like, and it validates the reply before anything is applied. It makes no network call and has no default model.

MethodReturnsDescription
schema(opts?)objectThe schema: columns, types, permitted operators.
prompt(text, opts?)stringThe message to send, schema included.
plan(reply, opts?)objectParse and validate. Unknown columns and operators are rejected with a reason; valid actions in the same reply are kept. plan.describe() renders it in plain English for confirmation.
apply(plan)objectApplies an approved plan as one undo entry, labelled with what it did.

Actions: setFilters, setSort, groupBy, showColumns, hideColumns, setQuick, clear. Nothing else is executable, so a model cannot be talked into an operation the vocabulary does not contain. docs/AI-SKILL.md is the reference to hand your model.

Ask-your-data (modules/ai)

The opt-in AI module (createAI, UMD LatticeGridAI) turns a question into a validated read-only query spec, runs it in the grid's own engine over the grid.ai skill layer, and - on apply - fans the answer to any router-attached viewers (a chart, a KPI tile) through the Data Router's load(). It uses the same BYO ask() seam as the rest of the module: the grid makes no model call and holds no key. Ask-your-data is read-only; a write/mutation the model asks for is refused and never executed (writes are a separate, human-gated feature).

MethodReturnsDescription
query(question, opts?)Promise<result>Ask the model, validate the reply into a read-only spec. result.describe() shows the resolved query; nothing applies until result.apply() (or autoApply).
applyQuery(result, opts?)objectApply a reviewed result. Re-gated at the seam: an unsafe plan is refused. opts.router fans the answer to other viewers.
askBar(el, opts?)controllerMount the ask/review/apply bar with an "auto-apply safe reads" toggle (off by default). Mounts only when createAI's enable allows 'query'/'ask' (all allowed when omitted); query() itself is never gated.
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: 'region' }, { field: 'amount', type: 'number' }],
  rows: [{ id: 1, region: 'EMEA', amount: 100 }, { id: 2, region: 'AMER', amount: 300 }, { id: 3, region: 'EMEA', amount: 200 }],
});
// Your model returns a schema-constrained SPEC, never rows. WE run it, read-only.
const reads = createAI(grid, { ask: async () => ({ actions: [{ type: 'setFilters', filters: { col: 'region', op: 'eq', value: 'EMEA' } }] }) });
const result = await reads.query('EMEA only');
result.apply();
const rows = grid.rows.data().length;
// A write verb is refused by the read-only gate and never executed.
const writes = createAI(grid, { ask: async () => ({ actions: [{ type: 'setCells', edits: [] }] }) });
const write = await writes.query('change the data');
return `${rows} rows; write ${write.ok ? 'allowed' : 'refused'}`;

AI as a governed actor (modules/ai, writes)

The governed actor lets the model propose edits - a single NL-targeted change (“set the Network Upgrade project to In Progress”) or a bulk cleanup - that a human previews as a before/after diff and approves. The model NEVER writes. On approval the edit applies through the grid's own gate, exactly like a person's edit: a grid cell edit via grid.edit.setCells(writes, 'cell', { origin: 'ai' }) (the beforeEdit veto), a Kanban card move via board.move(…, { origin: 'ai' }) (the beforeMove veto). The AI can never bypass the gate: a host beforeEdit/beforeMove handler that calls preventDefault() (or vetoes async) stops the write, and nothing persists. Writes carry origin: 'ai' on the before-event payload, so a host can allow a person's edit while vetoing the AI's - policing AI writes distinctly. Approved edits are optimistic and revert on a source reject through the shipped write-back (there is no separate beforeCommit event; the edit gate is beforeEdit, and the optimistic/revert half is grid.edit.settle).

NL targeting and any bulk edit bind to the current filtered view (grid.rows.data()), never the whole table implicitly; a target not in the view is surfaced for an explicit opt-in widen, not edited silently. A human label is resolved to the column's stored option value; an unknown label is rejected, not coerced. An ambiguous match (more than one row) surfaces its candidates for the user to pick.

MethodReturnsDescription
propose(instruction, opts?)Promise<proposal>Ask the model for structured edits, validate + resolve them against the current view, and return a reviewable proposal with a before/after diff. Nothing is written. opts.widen opts into the full dataset; opts.board routes a Kanban move.
applyProposal(proposal, opts?)Promise<report>Apply an approved proposal through the gate (origin: 'ai'). A vetoing before-handler stops it; the report gives applied/vetoed.
actorBar(el, opts?)controllerMount the propose → review-diff → approve bar, stating the scope. Mounts only when createAI's enable allows 'actor' (allowed when omitted); propose() itself is never gated.
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createAI } = await import('../packages/modules/ai/index.js');
const status = { options: [{ id: 'todo', label: 'To do' }, { id: 'doing', label: 'In Progress' }] };
const seed = () => createHeadlessGrid({
  rowKey: 'id',
  columns: [{ field: 'id' }, { field: 'name' }, { field: 'status', lookup: status, edit: { enabled: true } }],
  rows: [{ id: 'r1', name: 'Network Upgrade', status: 'todo' }, { id: 'r2', name: 'Payroll', status: 'todo' }],
});
// A MOCK ask() returns a structured PROPOSAL - never a write, never rows.
const ask = async () => ({ structured: { edits: [{ match: 'Network Upgrade', column: 'status', value: 'In Progress' }] } });

// Propose: a before/after diff, nothing written yet.
let grid = seed();
let ai = createAI(grid, { ask });
const proposal = await ai.propose('set the Network Upgrade project to In Progress');
const d = proposal.diff[0];
const before = `${d.oldDisplay} then ${d.newDisplay}`;

// A vetoing beforeEdit handler on the AI write => NOTHING persists.
grid.on('beforeEdit', (e) => { if (e.origin === 'ai') e.preventDefault('reviewed elsewhere'); });
await ai.applyProposal(proposal);
const vetoed = grid.rows.byKey('r1').data.status;

// Approve on a grid with no veto => the edit lands through the gate.
grid = seed();
ai = createAI(grid, { ask });
await ai.applyProposal(await ai.propose('set the Network Upgrade project to In Progress'));
const approved = grid.rows.byKey('r1').data.status;

return `diff ${before}; vetoed kept ${vetoed}; approved wrote ${approved}`;

grid.overlay

MethodReturnsDescription
show(kind, message?)void'loading' or 'empty'.
hide()void

grid.maximise

Fills the browser window with the grid, and puts it back. The rail's last button is this; grid.maximise is the same thing, so an application can bind its own control or keyboard shortcut. Esc restores.

grid.maximise.toggle();     // what the rail button calls
grid.maximise.enter();
grid.maximise.active();     // true while it fills the window
grid.maximise.exit();
MethodReturnsDescription
enter()booleanFill the window. false when the host element is not in the document.
exit()booleanBack to the page. false when it was not maximised.
toggle()booleanWhether the grid is maximised afterwards.
active()booleanWhether it is filling the window now.

The host element is moved to <body> and pinned to the viewport, then moved back between the same two siblings. A position: fixed element is positioned against the nearest ancestor carrying a transform, filter, contain or will-change (any card, animated panel or sticky shell) so styling alone fills the window on one page and lands in a small box on the next. A hidden placeholder holds the vacated space at the size the grid had, so the page behind neither reflows nor loses its scroll position.

Geometry is applied as inline styles and every displaced property is handed back exactly as it was found, because the element being restyled is yours. While maximised the element carries .lat-maximised and <body> carries .lat-maximised-host, as hooks for your own CSS.

grid.licence

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. Nothing ever refuses to render: the failure worth avoiding is a broken production screen, and no licensing state is worth causing one.

// Before creating a grid.
LatticeGrid.setLicence('LG1.…');           // your key; setLicense also works

const grid = LatticeGrid.createGrid(el, config);
grid.licence.state();      // 'licensed' | 'localhost' | 'trial'
MethodReturnsDescription
set(key)objectInstall a key for the process. Returns the provisional verdict; the check is asynchronous and licence:changed fires when it settles.
state()string'licensed', 'localhost' or 'trial'.
info()object{ valid, reason, issuedTo, expires, product }. expires is undefined for a perpetual key (the default) and an ISO date only for one deliberately issued with a term.
watermark()booleanWhether the trial mark is showing.
readyPromiseSettles when the licence check finishes.

Domains

A key names the hosts it covers. *.acme.com matches app.acme.com, a.b.acme.com and acme.com itself, a wildcard that refused the apex would be a puzzle rather than a licence. A bare acme.com matches only itself, and a key naming no domains is valid anywhere. Matching ignores case and a trailing dot.

HostNo keyKey for *.acme.com
localhost, 127.0.0.1, ::1, *.localhosteverything, no markeverything, no mark
app.acme.comeverything, trial watermarkeverything, no mark
acme.comeverything, trial watermarkeverything, no mark
other.example.orgeverything, trial watermarkeverything, trial watermark

.local, .internal and private IP ranges are not exempt. They are ordinary LAN names, and a corporate intranet is a deployment like any other.

Getting a key

Keys are issued from latticegrid.dev. A key is issued per domain 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.

Keys are perpetual by default. A key carries no expiry unless one was deliberately issued (a trial, a time-boxed pilot) so the ordinary key is valid until the domains it names change, not one that quietly lapses on a date nobody is tracking.

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, fresh on every load. A key for the wrong domain, or a key that will not read at all, does the same thing a genuinely expired trial key does: log one console warning and show the watermark.

Install the key before creating a grid. Setting one later still works, licence:changed fires and the watermark is removed, but the first frames of the grid will carry it.

Sources

Where rows come from. memory is the default and needs no configuration.

A memory grid's rows may be declared either way. The top-level rows option is the usual one and what every example here uses; the same array inside the block works identically - createGrid(el, { columns, source: { mode: 'memory', rows } }) opens with exactly those rows, with the same count(), value(), text(), type inference and bound KPI and chart readings as createGrid(el, { columns, rows }), and rows.load() and rows.apply() behave the same afterwards. Either array is copied on ingest, so the one you passed is never written to (see rows under Configuration). Declare them once: a grid given both uses the top-level rows, ignores the block's, and warns once naming both places. Only memory reads a rows key from the block - the fetching modes take theirs from the server.

ModeNeedsDescription
memoryrowsEverything is present. The grid filters, sorts, groups and totals it.
pagedfetchA page at a time from a server that paginates.
remotefetchBlocks fetched as the viewport reaches them, with sort, filter and grouping pushed to the server.
streamconnectRows arriving over time. Promotes to memory once complete.
derivedfromRows built from another grid: grouped, unnested, filtered, ranked or profiled. Read-only, and follows the source.

Load from a URL: a JSON or NDJSON file

createUrlSource(url, opts) points the grid straight at a file. A JSON file (a top-level array, or a nested array picked out with rowsPath or map) is read whole and handed over as rows. An NDJSON / JSONL file - one JSON value per line - is streamed in incrementally: the first rows render while the rest is still arriving, and a large file never sits in memory as one string. It is built on the stream source, so it inherits the frame-coalesced render, the stream:chunk / stream:end progress events and promotion to memory once a small file has fully landed.

Format is resolved in order: an explicit format: 'json' | 'ndjson' wins; else the URL extension (.ndjson/.jsonl vs .json); else the Content-Type (application/x-ndjson vs application/json); else a sniff of the first bytes, or a clear error asking for an explicit format. Pass fetch to supply auth or a proxy (default is the global fetch), headers to merge request headers, batchSize to tune NDJSON chunking (default 500), lenient: true to skip a malformed NDJSON line with a warning rather than failing, and poll (ms) to re-fetch on an interval, replacing the rows each pass. Every failure - a non-2xx status, a network error, a bad body, a malformed line - surfaces as source:error and leaves the grid usable, never an uncaught throw. The fetch is aborted when the grid is destroyed. Zero new dependencies: fetch, response.body.getReader() and TextDecoder.

A JSON file loaded through a mock transport so the example runs headless with no network; in an app, omit fetch and the global one is used. Run on every build.

const { createHeadlessGrid, createUrlSource } = await import('../packages/core/src/index.js');

// A mock transport, so this runs with no network. In your app, drop `fetch`.
const file = JSON.stringify([{ id: 1, city: 'Oslo' }, { id: 2, city: 'Lima' }, { id: 3, city: 'Cairo' }]);
const fetchImpl = async () => new Response(file, { headers: { 'content-type': 'application/json' } });

const grid = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ field: 'id' }, { field: 'city' }],
  source: createUrlSource('https://example.test/cities.json', { fetch: fetchImpl }),
});

await new Promise((r) => setTimeout(r, 50)); // let the file load
const count = grid.rows.count();
grid.destroy();
return count; // 3

For NDJSON, point it at a .ndjson or .jsonl URL - createUrlSource('/events.ndjson') - and the rows stream in as they parse.

Derived sources: a grid built from another grid

Most dashboards put a summary panel beside a table, the top five sales people, the breakdown by region, the exceptions list. Built by hand, that panel runs its own query, and sooner or later somebody filters the table and the panel does not follow. Everyone who has shipped a dashboard has been in the meeting where two numbers on one screen disagree.

A derived grid removes the possibility. It is a second grid whose rows are built from the first (grouped, unnested, filtered, ranked or profiled) so the panel is the table, one derivation later, and one filter moves both. It answers the questions a summary panel exists for: the top five sales people, the most-sold SKUs, a statistical profile of whatever the user has filtered to.

It is a source rather than a new kind of grid, so everything downstream: its own sorting and filters, totals, shadow columns, formatting, export, themes: works on the result and knows nothing about where the rows came from. Charts bind to one as readily as to any grid.

source: {
  mode: 'derived',
  from: salesGrid,
  follow: 'filtered',

  groupBy: 'rep',
  select: { revenue: { of: 'amount', fn: 'sum' }, deals: { fn: 'count' } },
  sort: [{ col: 'revenue', dir: 'desc' }],
  limit: 5,
}

The pipeline runs in one order, and the order is the contract: unnest → where → bucket → group → reduce → sort → limit. where sits before grouping deliberately: filtering afterwards is a different question, which groups, not which rows, and one key cannot mean both.

KeyTypeDescription
fromGrid | UnionSourceOptions[]Required. The grid to read - or several to combine into one row set before the rest of the pipeline runs (a union; see below). A bare Grid in the array is shorthand for { grid }.
follow'filtered' | 'all' | 'selected' | 'grouped'Which of its rows to read. filtered by default. grouped re-aggregates by whatever dimension the user has grouped the source by, so a panel tracks the reader rather than a dimension fixed when the page was built; with the source ungrouped it falls back to groupBy. Ignored (with a warning) when from is a union array - each entry has its own follow instead.
unneststringExpand an array property, one row per element, keeping the parent's fields. Address the element with a dotted path afterwards: lines.sku is the element, region is still the parent. A row whose property is absent or empty contributes nothing.
join{ with, on, type, select, prefix, follow }Match each row against a second grid on a shared key and bring some of its fields across. Runs after unnest and before where, so a condition (and a grouping, and a total) can read a field the join produced.
where(row) => booleanA row predicate, applied before grouping. With no groupBy the rows pass through as themselves, which is how an exceptions list is built.
bucket{ of, by }Round a date column down to the start of its period and group on that. by is day, week, month, quarter or year; weeks start on the ISO Monday.
groupBystring | string[]The dimension, or dimensions, to group by. Omit to pass rows through.
selectRecord<string, {of, fn}>The reduced columns, by output id. fn is any key of the totals-row kernels, so median, p95, stddev and gini are available as readily as sum. count needs no of.
sort{ col, dir }[]Order the derived rows before limiting them. The grid's own user-facing sort is separate and unaffected.
limitnumberKeep at most this many rows.
limitPerstringApply limit within each distinct value of this column rather than overall, the best three SKUs in each region, which a global limit cannot express.
cumulative{ of, upTo }Keep rows until their running share of the total reaches upTo, 0 to 1. The Pareto question. The row that crosses the cutoff is kept, because the set has to reach the share.
profilestring | string[]Replaces the pipeline with a transpose: one row per column, with count, present, missing, distinct, min, max, mean, median, quartiles, deviation and outlier count as its columns.
orient'columns' | 'metrics' DerivedOrientWith profile, emit one row per statistic instead of one per column, the shape a dashboard tile wants.
crossFilterboolean | stringLet this grid filter the grid it derives from. true cross-filters through whatever it groups by; a string names a different source column.
refresh'live' | 'idle' | 'manual' | numberWhen to re-derive. idle by default, coalescing to a frame, because a hundred cell updates in one frame are one derivation. A number debounces by that many milliseconds. live re-derives on every change. manual never re-derives on its own: the host triggers it by calling rows.load() on the derived grid, with no argument, which re-reads from there and then - a frozen panel refreshed on a button press, executed.
// refresh: 'manual' - the summary re-derives only when the host asks.
refreshButton.addEventListener('click', () => summary.rows.load());

The relational statistics, as rows

A single-column statistic already has a route: select reduces a group with any kernel the totals row uses, and that table is a superset of the statistics one, so select: { p95: { of: 'amount', fn: 'p95' } } works, along with median, stddev, gini and the rest. statistics is for what select structurally cannot reach: the figures needing two or more columns, or a second grid. Like profile it is a terminal producer - it replaces the pipeline rather than joining it, and the two cannot be used together. Every row carries n, the rows the figure covered. Full detail, including the measured re-derive cost of each producer, is in the detail reference.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const readings = Array.from({ length: 20 }, (_, i) => ({ id: i, t: i, v: 100 + i * 3, w: 50 - i }));
const plant = createHeadlessGrid({ rowKey: 'id',
  columns: [{ field: 't', type: 'number' }, { field: 'v', type: 'number' }, { field: 'w', type: 'number' }],
  rows: readings });
plant.rows.count();

// One row per column PAIR: { a, b, coefficient, n }. `w` runs exactly against `t`.
const pairs = createHeadlessGrid({ rowKey: '__key',
  columns: [{ field: 'a' }, { field: 'b' }, { field: 'coefficient', type: 'number' }, { field: 'n', type: 'number' }],
  source: { mode: 'derived', from: plant, refresh: 'live',
    statistics: { fn: 'correlation', columns: ['t', 'v', 'w'] } } });
let tw = null;
pairs.rows.forEach((row) => {
  if (pairs.rows.value(row.key, 'a') === 't' && pairs.rows.value(row.key, 'b') === 'w') {
    tw = pairs.rows.value(row.key, 'coefficient');
  }
});

// One row per METRIC of the series summary, not one per point.
const series = createHeadlessGrid({ rowKey: '__key',
  columns: [{ field: 'metric' }, { field: 'value', type: 'number' }],
  source: { mode: 'derived', from: plant, refresh: 'live',
    statistics: { fn: 'series', of: 'v', by: 't' } } });

// One row per compared column, against a second grid. The peer is watched.
// `t` is on this grid only, so it is reported as unmatched rather than dropped.
const peer = createHeadlessGrid({ rowKey: 'id',
  columns: [{ field: 'v', type: 'number' }, { field: 'w', type: 'number' }],
  rows: readings.map((r) => ({ id: r.id, v: r.v * 2, w: r.w })) });
peer.rows.count();
const compared = createHeadlessGrid({ rowKey: '__key',
  columns: [{ field: 'column' }, { field: 'magnitude', type: 'number' }],
  source: { mode: 'derived', from: plant, refresh: 'live',
    statistics: { fn: 'datasetVsDataset', with: peer, columns: ['v', 'w'] } } });

let shared = 0, unmatched = 0;
compared.rows.forEach((row) => {
  if (compared.rows.value(row.key, 'magnitude') === null) unmatched += 1; else shared += 1;
});

return `t/w ${tw}; pairs ${pairs.rows.count()}; metrics ${series.rows.count()}; compared ${shared} + ${unmatched} unmatched`;

Union sources: combining several grids into one

"Worst performers across two datasets" is easy when the two datasets share a key: a join brings the second grid's fields onto the first. It is not expressible at all when they do not: incidents from two regions with no shared identifier, orders from two systems, this quarter and last as one ranked list. from takes an array of sources for exactly this: stack several row sets into one, then rank, group or filter the combined set with the same pipeline a single from already runs.

source: {
  mode: 'derived',
  from: [
    { grid: eastIncidents, label: 'east' },
    { grid: westIncidents, label: 'west' },
  ],
  sort: [{ col: 'severity', dir: 'desc' }],
  limit: 10,
}

Every source is read (each narrowed by its own follow, filtered by default) and concatenated in declaration order, deterministic rather than interleaved, before unnest/join/where/bucket/ groupBy/select/sort/limit/limitPer/ cumulative run once over the result - so "the worst across both" is one derivation, not a hand-merged array.

KeyTypeDescription
gridGridRequired. This source's grid.
labelstringIdentifies this source. Carried onto every row as __source, and used to namespace that row's __key. Defaults to the source's position in the array ('0', '1', …).
follow'filtered' | 'all' | 'selected' | 'grouped'Which of this source's rows to read, independent of every other source's. filtered by default.
map(row) => unknownReshape this source's rows into a common shape before they join the rest - typically a rename or a projection for a field this source calls something else.

__source is required, not optional. Every row carries it - the entry's label, or its declaration index when unlabelled - because without it a combined list cannot be read, filtered or grouped by where it came from, which is most of the point of stacking several sources. It is an ordinary field to where, groupBy and select, exactly like a column the data itself carries.

The union of fields, not the intersection. A field present on only one source is undefined on rows from the others - not fabricated, not coerced. Sources are not type-reconciled: if two disagree on what a field means, map is where you make them agree, before they combine, not something the union guesses at for you.

The key is namespaced. A derived grid's __key is the source row's own key when nothing is grouped, and two sources sharing the same identifiers would otherwise collide. So it is qualified by the source tag when there is no groupBy. Grouped, the key is the group value exactly as it always has been - rows from different sources landing in the same group when their group values agree is the point of grouping a union, not a collision to guard against.

Not a join. There is no dedup and no merge-on-key: two sources reporting the same fact both appear as separate rows, and there is no UNION-vs-UNION-ALL distinction to draw. Reach for join when two sides share a key and you want them matched rather than stacked; use groupBy on the combined set when you want them summed together.

Empty and failing sources. A source with no matching rows contributes nothing; the rest of the union still derives. A source that throws while being read (or mapped) is named in a warnOnce and skipped for that pass - reported, never silently dropped, because a silently missing source would make "worst across both" quietly wrong.

A cycle is refused, not recursed. A source list that includes the grid being derived, directly or through a chain of other derived grids, is refused when the source is built, naming the offending source.

Not supported alongside a union. crossFilter has no single target once there is more than one parent, and profile reduces one grid's own columns, so both are refused with a warning rather than guessed at.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const east = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ field: 'title' }, { field: 'severity', type: 'number' }],
  rows: [{ id: 'e1', title: 'disk-full', severity: 9 }, { id: 'e2', title: 'slow-query', severity: 3 }],
});
// A second, unrelated incident log - its own "rating" field, no shared id with `east` at all.
const west = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ field: 'name' }, { field: 'rating', type: 'number' }],
  rows: [{ id: 'w1', name: 'oom-kill', rating: 10 }, { id: 'w2', name: 'stale-cache', rating: 2 }],
});

const worst = createHeadlessGrid({
  columns: [{ field: 'title' }, { field: 'severity', type: 'number' }, { field: '__source' }],
  source: {
    mode: 'derived',
    from: [
      { grid: east, label: 'east' },
      // `map` brings west's differently-named fields into the common shape.
      { grid: west, label: 'west', map: (row) => ({ title: row.name, severity: row.rating }) },
    ],
    sort: [{ col: 'severity', dir: 'desc' }],
    limit: 2,
  },
});

const titles = [];
worst.rows.forEach((r) => titles.push(worst.rows.value(r.key, 'title')));

// A per-source breakdown reads `__source` like any other field.
const bySource = createHeadlessGrid({
  columns: [{ field: '__source' }],
  source: { mode: 'derived', from: [{ grid: east, label: 'east' }, { grid: west, label: 'west' }] },
});
const sources = [];
bySource.rows.forEach((r) => sources.push(bySource.rows.value(r.key, '__source')));

worst.destroy(); bySource.destroy(); east.destroy(); west.destroy();
return `worst=${titles.join(',')}; sources=${sources.join(',')}`;

Read-only. A derived row is an answer, not a record: there is no write-back for the sum of four hundred rows, so writes are refused with a reason rather than accepted and discarded on the next refresh.

They chain. A derived grid can be the source of another to any depth: a profile of the top five, and a change at the root travels the whole chain. A cycle is refused rather than recursed.

The key. rowKey defaults to the derived key and need not be set. It is the group value, which is what makes a live ranking readable: the row moves rather than the values under it changing.

Cost. The first derivation is linear in the rows read and largely independent of what is reduced: roughly 900 ms per 200,000 rows grouped into forty, whether the selection is one sum or four statistics. After that, a change that names the rows it touched is patched rather than re-derived: only the groups those rows entered or left are reduced again, so a live feed costs time proportional to what changed rather than to the table. Five hundred updates against that same source take under 300 ms in total, not 300 ms each. A joined derivation is maintained the same way from both sides: the lookup is held between derivations rather than rebuilt, a change to the fact table rejoins only the rows that moved, and a change to the lookup rejoins only the rows behind the keys whose match actually changed, about 2 ms per fact update and 1.5 ms per lookup edit against a 200,000-row source joined to 2,000 customers. A change that cannot be reasoned about that way, a new filter, a regrouping, a derivation using unnest or where, or a lookup row arriving for rows an inner join had dropped, falls back to a full derivation, which is correct but costs the full linear pass. Narrow with follow: 'filtered' so the derivation reads what the user is looking at rather than the whole table.

What a change firing promises

Anything maintaining state from rows:changed (a derived grid, a chart, your own cache) needs to know whether a firing names the rows that moved or merely says that something did. One rows.apply announces itself more than once: the source reports how many rows moved, the row model reports which ones, and the grid reports that a change happened. Acting on all three does the work three times over.

FieldMeaning
identified: trueadded, updated and removed are arrays naming exactly the rows that moved. Safe to patch from.
companion: trueA second announcement of a change already reported with identity, or one made before the grid's own view caught up. Ignore it.
neitherA real change whose extent cannot be named: rows replaced wholesale, or a row moved, where what changed is the order. Re-read.

The default is the safe one. A firing that carries neither flag is treated as a change of unknown extent, so a listener re-reads rather than assuming nothing moved. Read the flags rather than the shape of the payload: a firing that reports a row move carries counts, because no row changed value, and only the flags distinguish that case from a duplicate announcement.

Confidence intervals: how much to trust the figure

Every other statistic here describes the data you have. An interval describes how well that data pins down the figure you actually care about, and it is the one thing a descriptive tool can honestly say about the world beyond its rows.

The line this draws. Lattice quantifies uncertainty; it does not adjudicate hypotheses. There are no p-values, no significance tests and no verdicts, and reading two non-overlapping intervals as a significance test is a mistake often enough to be worth not encouraging. An interval says "the mean is 42, and the sample pins that down to between 39 and 45". It does not say 42 differs from 40.

CallReturnsDescription
statistics.interval(colId)ConfidenceIntervalThe interval for a column's mean, using the t distribution rather than the normal: below about thirty readings the normal interval is noticeably too narrow, and at five it understates the width by roughly a sixth.
statistics.keyOf(data)string | nullThe key a row's data resolves to, without needing the row. What a caller holding raw data uses to reach the grid's view of it.
statistics.maintenanceRecord<string, string>Which reductions can be maintained against a change and which must rescan: sum and avg exactly, min and max only away from the extreme, median and the rest never. Ask before putting one in a footer over a million rows on a live feed: the difference is a totals row that costs nothing per tick and one that costs a full pass.
statistics.intervalOf(values)ConfidenceIntervalThe column-free form, for readings that are not a column, the rows behind one bar, a subgroup, a hand-assembled sample. One t-quantile serves the chart's whiskers and the panel's bounds alike.
statistics.interval(colId, { kind: 'proportion' })ProportionIntervalA Wilson score interval for a rate. where decides which rows count as successes; truthiness by default.
statistics.capability(colId).ruleSetstringWhich rule set produced violations. Named in the result because the two number their rules differently: “rule 3” means a trend under Nelson and four-of-five-past-one-sigma under Western Electric.
statistics.capability(colId).intervalCapabilityIntervalAn interval for Cpk, by Bissell's approximation, and intervalPp for Ppk.
slopeInterval(fit)objectAn interval for a regression slope, from the standard error regression already reports.

Every interval carries its level. The result's confidence field says what it was computed at, so a figure copied out of one cannot lose the thing that makes it readable. An interval without its level is not a smaller claim, it is an unreadable one.

A proportion is Wilson, not Wald. The textbook p ± z√(p(1−p)/n) fails exactly where a rate is most interesting: near zero it reaches below zero, and at no observed successes it collapses to the single point zero: claiming perfect certainty from the least informative sample there is. The Wilson interval stays inside 0 to 1 and stays sensible at the extremes, so "none of forty failed" correctly reads as "the failure rate is under 9%" rather than "the failure rate is zero".

Report the capability interval. Its absence is the commonest way a capability study overstates itself. 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. The point estimate alone does not say that; the interval does.

It follows the filters. Like every statistic here, an interval reads the rows the filters left, so it narrows as the user narrows the grid. That is the correct behaviour and worth knowing: it describes the filtered population, not the whole table.

Reading an SPC chart

The figures are only half of it. A capability claim is made in a picture, and these are the two the discipline expects.

TypeShows
controlThe readings in order, with the centre line and control limits the process itself sets, the tolerance the customer set, and every rule break marked and numbered.
capabilityThe readings as a histogram with the tolerance drawn across them, and a fitted normal curve for each of the two spreads: short-term and overall.
movingRangeThe lower half of an I-MR pair: the gap between consecutive readings, against its own limits. Only the upper limit signals, because a range cannot be negative.

The lines are named, on opposite edges. CL, UCL and LCL at the right; LSL, USL and Target at the left. Control limits are what the process does; specification limits are what the customer asked for, and reading one as the other is the classic misreading of a control chart. A capable process puts its control limits just inside its tolerance, so the two families sit close together, which is exactly when they need telling apart, and why they are named at opposite ends rather than left to collide.

Rule breaks carry their number. With Western Electric's four rules a marked point was readable on its own; with Nelson's eight it is not. A spike (rule 1) is a bad part; a six-point trend (rule 3) is tool wear or a drifting sensor. They call for different responses, and a chart that marks both the same way has told you the less useful half of what it knows. Set the rule set with rules: 'nelson' on the chart, as on statistics.capability.

The capability report draws two curves, not one. Cp and Cpk are computed from short-term variation, Pp and Ppk from overall. When a process has drifted the two differ, and the four indices say so only as numbers a reader has to know how to compare. Drawn, the gap is the finding: a narrow solid curve inside a wide dashed one is a capable process that has been allowed to wander, a scheduling problem, not a machine problem.

What this covers, and what it does not. These are individuals charts: one reading per point, with short-term variation estimated from the moving range. That is the right instrument when readings arrive one at a time, a sensor, a test rig, a single-piece flow.

It is not the right instrument for subgrouped data, and the difference is not cosmetic. If you measure five parts an hour, the correct chart is X̄-R: its limits come from within-subgroup variation and are roughly √n tighter, which is what makes a shift in the process centre visible. Run an individuals chart over the same readings and the limits are computed from differences that mix within- and between-subgroup variation; they come out around twice as wide, and a two-sigma shift that X̄-R flags a dozen times over reads as scattered noise. Lattice does not ship X̄-R, X̄-S, or the attribute charts (p, np, c, u), and an individuals chart should not be substituted for them.

Pair the two control charts. An individuals chart asks whether the process has moved; a movingRange chart asks whether it has become less repeatable. A process can fail either without failing the other: it can drift while its point-to-point variation holds steady, and it can hold its average while shaking itself apart. The second is close to invisible on the individuals chart alone, because a wider spread pulls that chart's own limits wider with it: it rescales to accommodate the very thing that has gone wrong. Drawn one above the other, they are the standard I-MR pair.

Bind it to the readings, not to a summary of them. A whisker is computed from the values the chart can see behind each mark. Bound to a grid whose rows are already one per mark, a summary or a derived panel, the chart sees a single value per category and there is no spread to draw: the readings that produced each average are upstream and no longer reachable. Bind the chart to the rows the summary was computed from, or carry a margin yourself and use error: { of: 'margin' }. A chart asked for whiskers it cannot compute says so once rather than drawing nothing in silence.

Uncertainty on a chart. error: true draws a whisker on each mark, computed from the readings behind it. Four bars side by side invite a comparison the numbers alone cannot support, a five per cent gap between two categories of eight readings is noise, and between two of eight hundred it is the finding. The whisker is what tells them apart, and its absence is why bar charts are so often over-read. A mark with a single reading gets none, because one reading has no spread and a zero-height whisker would claim certainty rather than admit ignorance.

Fitted lines. fit: true draws a least-squares line through a scatter with its R² beside it; fit: 'line' draws the line alone. A cloud of points invites a reader to draw the line themselves, and people are consistently poor at it, the eye is pulled by the extremes, which is exactly what least squares is not.

The interval travels with the index. The statistics panel shows the bounds under Cpk and Ppk, and createStat takes an interval function that puts them under the value. A tile is where a figure is read fastest and questioned least, which makes it the place an interval earns its keep rather than the place it is least needed.

Pushdown adapters: one query, many engines

A remote source already receives a structured request: range, sort, filters, quick text, grouping, pivoting and totals. A pushdown adapter turns that request into whatever an engine speaks, so connecting a new back end is a translation layer rather than a new source.

import { createPushdownSource, odataAdapter } from '@toclocoinc/lattice-grid';

const source = createPushdownSource({
  adapter: odataAdapter({ url: 'https://api.example.com/Orders' }),
  compute,
});

createGrid(host, { source, columns: [...] });

An adapter never carries an engine. Each one takes what it needs as a parameter: restAdapter takes a fetch and bundles no HTTP library, dfqlAdapter takes a token, and duckdbAdapter takes a connection you have already made. So a grid can drive a full analytical engine without this package carrying one, and installing Lattice never installs anything else.

An adapter declares what it can answer. No engine speaks the whole query. OData takes a condition tree but only some operators; a single-term API takes one field and one value; a hand-written endpoint may take nothing but a page number. The adapter states its capabilities, the SDK divides the request accordingly, and the grid finishes whatever is left.

CapabilityValuesMeaning
filterfalse | 'term' | 'flat' | 'tree'Nothing, a single field and term, a flat conjunction, or a full condition tree.
operatorsstring[]Which comparisons the engine understands. A condition using anything else stays with the grid.
sortfalse | 'single' | 'multi'How many columns it can order by.
quickbooleanWhether free-text search across columns can be pushed.
rangebooleanWhether it can return a window rather than the whole result.
totalbooleanWhether it can report how many rows match.

Anything left over means the whole result is fetched. Filtering a window of rows in the browser is not a slower way to get the right answer, it is a fast way to get a wrong one: the rows that belong on the first page may be on the ninth, and the count is whatever the engine happened to return. So when the grid has work left to do it asks the engine for the complete result, applies the remainder, and pages from what it holds. It says so once, naming the part that could not be pushed, because the fix is usually a wider adapter rather than a bigger machine. source.lastPlan() reports the division for any request.

A conjunction splits; a disjunction does not. An and group narrows with each condition, so the engine can apply the conditions it understands and the grid narrows what comes back. An or group widens with each branch, so pushing only the supported branches returns fewer rows than the filter allows, and the grid cannot recover rows that were never fetched. A disjunction the engine cannot fully answer therefore stays with the grid whole. The same asymmetry governs faceting.

A sort is pushed whole or not at all. Ordering by the first column and fixing the rest in the browser needs every row anyway, so a partial sort buys nothing and returns rows in an order that is wrong until the grid corrects it.

AdapterForNotes
odataAdapterAny OData v4 endpointWrites $filter, $orderby, $top, $skip and $count. System options keep their $ unencoded, which several servers require.
restAdapterThe API you already haveParameter names are yours to choose. Paging and sorting are assumed; filtering is assumed absent until you declare operators, because an adapter that claims to filter when the endpoint ignores it returns the wrong rows silently.
duckdbAdapterA DuckDB connectionWrites SQL and takes the whole query: filter tree, multi-column sort, paging and grouping - a grouped grid is answered by GROUP BY, one level at a time, with the group counts, the subtotals, the matching count and the grand total all computed in the engine. from is any FROM expression, so read_parquet('s3://bucket/*.parquet') is as valid as a table name. The engine is yours to create and install; this imports nothing, so the bundle is unchanged whether you use it or not.
dfqlAdapterDemandFlow entitiesSpeaks POST /v1/query. Sends the entity, the key attribute and the prefix to match, a field projection and one field-and-term filter, matched as a case-insensitive substring. It cannot sort or page, so the grid does both, and every request carries a countOnly line because limit caps rows scanned rather than matched: a filtered query returns an arbitrary subset, and the count is the only thing that reveals it.
graphqlAdapterAny GraphQL endpointConfigured, not zero-config: GraphQL has no fixed query semantics, so you pass buildQuery to turn the plan into a { query, variables } operation and parseResponse to read data back into rows and total. Defaults cover an offset/limit list with totalCount and a Relay cursor connection (first/after with pageInfo). The default pushes only the window and the total; declare operators or capabilities for filter/sort only alongside a buildQuery that emits them. A cursor connection is forward-only, so a deep window costs round trips proportional to its offset.

What each adapter takes

Every adapter is a function of one options object. The tables below list what each accepts, the type, the default where it is not obvious, and what it means. The defaults are the load-bearing part: an adapter is designed to work when handed almost nothing, so most of what you can set is about telling it what your endpoint cannot do rather than switching features on.

odataAdapter
OptionTypeDefaultMeaning
urlstring - The entity-set endpoint, e.g. https://api.example.com/Orders. Required.
headersRecord<string, string>{}Sent on every request, merged over Accept: application/json. This is where a fixed bearer token or an API key goes. See authenticating.
fetchtypeof fetchthe global fetchYour own fetch, for a token that expires, a proxy, or a non-browser runtime. The adapter bundles no HTTP client. See authenticating.
countbooleantrueWhether to ask for $count=true and read @odata.count. On by default because the grid sizes its scrollbar from the total; set false for a server that does not support it, or to spare a server the count for a grid that never shows one. With it off the adapter reports no total and the grid scrolls open-ended - it does not substitute the page length, which before 1.51 told the grid the entity set was exactly one page long. The count travels inline in the same request, so there is nothing to split out: suppression is the only lever OData offers.
searchbooleanfalseWhether the server implements $search. Off by default, so quick-filter text stays with the grid until you confirm the endpoint honours it; true pushes it as $search.
editbooleanfalseOpt into write-back. Off keeps the source read-only; true advertises mutate: { update: true, delete: true, append: true, returning: 'row' }, so a committed cell edit is persisted with PATCH, a row delete with DELETE /EntitySet(key), and an add-row with POST /EntitySet reading the created entity back for its server key.
keystringthe row keyThe key property every write addresses a row by in its entity-key URL segment, e.g. /Orders(<key>), and that an add-row is rekeyed to from the created entity. Write-back only.
restAdapter

The parameter names are yours, and the defaults are not zero. Paging and sorting are assumed present; filtering is assumed absent until you declare operators, because an adapter that claims to filter when the endpoint ignores it returns the wrong rows silently. The query-string names default to offset, limit, sort, order, filter and q (search); params overrides any of them.

OptionTypeDefaultMeaning
urlstring - The endpoint, e.g. /api/orders. Required.
headersRecord<string, string>{}Sent on every request, merged over Accept: application/json. Where a fixed token or key goes. See authenticating.
fetchtypeof fetchthe global fetchYour own fetch, for an expiring token, a proxy or a non-browser runtime. See authenticating.
paramsPartial<Record<'offset'|'limit'|'sort'|'order'|'filter'|'search', string>>{ offset:'offset', limit:'limit', sort:'sort', order:'order', filter:'filter', search:'q' }Renames the query-string keys to whatever your endpoint already reads. Only the keys you name change; the rest keep the defaults above.
capabilitiesPushdownCapabilities{ range:true, total:true, sort:'multi', filter:false, quick:false }What the endpoint can answer, merged over the defaults. Declaring operators is the usual way to turn filtering on; reach for this to switch off paging or sorting an endpoint cannot do.
operatorsstring[] - (filtering off)The comparisons the endpoint genuinely applies, e.g. ['eq','gt','lt','contains']. Setting it turns filtering on as a tree; a condition using any other operator stays with the grid.
encodeFilter(filters: object) => stringJSON.stringifyHow the pushed condition tree becomes the filter parameter's value. Override it to emit whatever query language your service parses instead of JSON.
rows(body: unknown) => unknown[]body itself if an array, else body.rows then body.dataPulls the row array out of the response body, for an envelope that nests it somewhere else.
total(body: unknown, rows: unknown[]) => numberbody.total then body.count, else the page lengthReads the count of all matching rows, not the page. The grid sizes its scrollbar from it, so a page-sized total makes a large result look like one page.
editbooleanfalseOpt into write-back. Off keeps the source read-only; true advertises mutate: { update: true, delete: true, append: true, returning }, so a committed cell edit is persisted with PATCH, a row delete with DELETE, and an add-row with POST to the collection URL.
returning'row' | 'key' | 'none' ReturningnoneThe reconcile contract for a successful write. none is last-write-wins - the optimistic value stands; row reads the server's authoritative row (via writeRow) back before confirm; key reads only the server-assigned key. An add-row needs row or key so the temp row can be rekeyed.
keyFieldstringidThe property an add-row response carries the server-assigned key in, read back (through writeRow) to rekey the optimistic row. Write-back only.
encodeMutation(op: MutationOp) => { method: string, url: string, headers?: object, body?: unknown }the default verb mapFull control of a mutation's HTTP shape, overriding the default method, URL and body. Supersedes writeUrlFor.
writeUrlFor(op: MutationOp) => string${url}/${key}The endpoint a single mutation targets, when the default per-row URL is not what the service uses. Addresses an existing row; an add-row POSTs to the collection url instead. Ignored when encodeMutation is supplied.
writeRow(body: unknown) => unknownthe entity, or body.row/body.dataPulls the authoritative row out of a write response when returning: 'row', and the created row an add-row reads its key from.

Persisting a cell edit. With edit: true the adapter advertises mutate, so a committed cell edit is sent as an HTTP request. REST has no universal write convention, so the request is yours to shape: writeUrlFor names the per-row endpoint, encodeMutation takes full control of method and body, and writeRow reads the authoritative row back when returning: 'row'.

const { restAdapter } = await import('../packages/core/src/index.js');

// The per-row endpoint a mutation targets.
const writeUrlFor = (op) => `/api/orders/${op.key}`;

let sentMethod;
const adapter = restAdapter({
  url: '/api/orders',
  edit: true,          // opt into write-back (advertises mutate.update / delete)
  returning: 'row',    // reconcile to the server's authoritative row
  writeUrlFor,
  // Full control of the request; supersedes the default envelope.
  encodeMutation: (op) => ({
    method: 'PATCH',
    url: writeUrlFor(op),
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(op.patch),
  }),
  // Pull the authoritative row out of this service's envelope.
  writeRow: (body) => body.record,
  fetch: async (url, init) => {
    sentMethod = init.method;
    return { ok: true, status: 200, json: async () => ({ record: { id: '42', status: 'shipped' } }) };
  },
});

const result = await adapter.mutate({ kind: 'update', key: '42', patch: { status: 'shipped' } });
return result.rows[0].status;   // 'shipped', read back from the server
duckdbAdapter
OptionTypeDefaultMeaning
connectionobject - A live connection exposing query, and ideally prepare. Required. A connection without prepare is used only for unfiltered queries, because interpolating a user's filter into SQL is worse than not filtering.
fromstring - A table, a view, or any FROM expression. Required. read_parquet('s3://bucket/*.parquet') is as valid as a table name.
fieldsstring[]everything (SELECT *)The columns to select. Name them to narrow the projection when the grid shows a subset of a wide table.
countbooleantrueWhether to count the matching set at all. On by default: the total comes from a separate count(*) carrying the same WHERE, dispatched alongside the page query - see how the total is counted. Set false for a grid that never shows a count; then no count statement is issued, capabilities.total is false, and the result carries no total, so the grid scrolls open-ended rather than being told the page length is the whole set. That is a trade: with no total the scrollbar is open-ended and grid.scroll.toRow(n) cannot reach a row past the discovered end. See how the total is counted.
writablebooleanfalseAllow write-back against a plain writable table. Off keeps the source read-only, so a from that is a view or an expression can never be mutated by accident. Enables update, delete and append.
keyFieldstringidThe key column an update and a delete target in their WHERE, and that an add-row is rekeyed by. Write-back is refused unless this names a real column, because an UPDATE/DELETE without a unique key could touch more than one row.
returning'row' | 'none'rowThe reconcile contract for a successful write. row appends RETURNING * and reconciles server truth (computed columns, triggers); none keeps the optimistic value. An add-row always RETURNINGs at least the key column regardless, since it needs that key to rekey the temp row.

How the total is counted, and what it costs. The grid needs the size of the matching set to size its scrollbar. Until 1.51 every page query carried count(*) OVER () AS "__lattice_total", which looks free: the window is evaluated before LIMIT, so one round trip returns both the window and the size of the set it was cut from. Against a local table it is free. Against a remote Parquet it is the most expensive thing the adapter does - a window function has to see every matching row of the projected columns, so whatever row-group pruning or range reads DuckDB and the file host might otherwise manage between them (see whether a Parquet file streams or downloads whole) cannot help, and the whole file crosses the wire to produce one page. Measured in Chrome on duckdb-eh.wasm against a 10,000,000-row Parquet of 162,386,227 bytes - 162.4 MB decimal, 154.9 MiB binary, and every transfer figure on this page is decimal MB so that it can be compared with it directly - on an origin counting bytes actually served, first paint pulled 162.5 MB. Slightly more than the file, because the reads overlap.

The total is now its own statement - SELECT count(*) FROM <from> WHERE …, the same predicate through the same builder with the same typed casts and the same bound values, and no ORDER BY or LIMIT. Read it with adapter.countSqlFor(query), the counting counterpart of adapter.sqlFor(query); it returns null when count is false. The two statements are dispatched in the same tick - both started before either is awaited - so there is no browser round trip between them.

That does not make the count free on the clock, and on DuckDB-Wasm it often is not. One DuckDB-Wasm connection funnels its statements through a single worker, so the engine still runs the two in sequence; what dispatching together buys there is that the second is already queued the instant the first finishes. Measured on the file below: an unfiltered first paint takes 566 ms with the count and 558 ms without, so the count costs about 8 ms - genuinely hidden. A filtered query takes 2122 ms with the count and 573 ms without, so there the count costs about 1550 ms and is not hidden at all. (It is still faster than the 3064 ms the old window function took for the same query.) A server-side DuckDB with a thread pool runs the two at once and the distinction goes away.

Cheap, not free - and on a filtered query, not even cheap. An unfiltered count(*) over Parquet is answered from the file's footer metadata and reads no data at all: measured against the 162.4 MB file above, first paint costs the same 5.71 MB with the count on as with count: false - the count's own share is 0.00 MB. A filtered count still has to evaluate the predicate. It reads the predicate columns rather than the whole projection, and row-group statistics can prune entire groups (a predicate no row group can satisfy is answered from metadata alone - country = 'ZZ' against this file costs 0.12 MB, count and all). But a predicate whose columns are spread across every row group has to read them all: country = 'GB' AND risk_score > 70 costs 45.2 MB with the count and 3.2 MB without, so 41.9 MB of it is the count. Against the 191.2 MB the old window function cost, that is still a 4× saving - and if your grid never shows a count, count: false makes the same query a 59× one.

One case where 1.51 transfers more, not less: a session that eventually reads the whole table anyway. Every individual query above is cheaper than or equal to its 1.50.0 counterpart, but a whole session need not be. 1.50.0 dragged the entire file down on first paint in a handful of large sequential reads, after which everything was cache-warm and every later query cost nothing. 1.51 reads lazily, and lazy range reads over a big Parquet overlap where one eager read did not - so bytes already paid for can be paid for again. Measured over one browser session doing first paint, a selective filter, a full-table ORDER BY, a deep page and two more filters, with the counter reset between each: 1.50.0 transferred 162.5 MB in total and 1.51 transferred 209.8 MB. The user who never sorts the whole table pays 46.7 MB instead of 162.5 MB and sees a first paint in 566 ms instead of 5808 ms; the user who does sort the whole table has to read the whole file either way, and now pays some of it twice. A full ORDER BY over an unindexed column with SELECT * is unchanged at 162.5 MB before and after - this card neither helps nor hurts it.

Turning the count off changes what the grid knows, on purpose. With count: false the adapter reports no total, and the grid does what it already does for any source of unknown length: it scrolls open-ended and discovers the end when a short page arrives. It does not substitute the page length for the total - a page presented as the whole is a wrong number where a right one goes, and every “showing X of Y”, scrollbar and row count would be wrong with nothing said.

So count: false is a trade, not a free win, and here is the part you will notice first: the grid can only scroll as far as it has discovered. The scrollbar is open-ended rather than proportional, a “showing X of Y” readout has no Y, and grid.scroll.toRow(n) cannot jump to a row beyond the discovered end - asking for row 900 of a not-yet-discovered million lands at the furthest row known so far, and reaching the real row 900 means paging to it. Turn the count off for a grid whose users scroll; leave it on for one whose users jump.

Whether a Parquet file streams or downloads whole is DuckDB's and the file host's doing, not the grid's. duckdbAdapter only writes SQL; it never opens a file, so it has no say in whether read_parquet(...) reads the whole thing or only the row groups a query needs. Measured against a 1.5 MB Parquet on GitHub Pages: DuckDB-Wasm 1.32.0's default HTTP path issued 0 Range requests and read 100% of the file, for every query including a single-value chip filter. Running LOAD httpfs; on the connection before the first read_parquet(...) call changed that to 25 Range requests and 30% of the file for the same query. (DuckDB-Wasm 1.29.0 read 119% of the file on the same test - its ranges overlapped - so the exact figures are a version's, not a promise.) A file registered with db.registerFileBuffer(...) is always read whole, whatever version is loaded: a buffer has already been downloaded in full before DuckDB ever sees it. And the file host has to cooperate: 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 - and, cross-origin, its CORS policy must expose Content-Range and Content-Length or the browser cannot read them back. Any of that missing and DuckDB falls back to reading the file whole, silently.

Typed binding for timestamp and date columns. A prepared statement binds a filter value with the value's own type, not the column's: the grid sends an instant as an ISO-8601 string, the client binds it as VARCHAR, and DuckDB refuses "ts" >= ? against a TIMESTAMP column (Binder Error: Cannot compare values of type TIMESTAMP and type VARCHAR). The adapter therefore types the placeholder: a comparison or IN member against a TIMESTAMP, TIMESTAMP WITH TIME ZONE, DATE, TIME or TIMESTAMP_S/_MS/_NS column is written CAST(? AS <that type>), and the value is still bound, never interpolated. The column's type comes from the engine - one DESCRIBE SELECT * FROM <from> on the first query, cached for the adapter's life and exposed as adapter.describe() - so an untyped grid column over a timestamp is covered. When the schema does not name the column (a DESCRIBE that failed, said once), the grid column's declared type on the condition is the fallback: timestamp/datetime cast to TIMESTAMP, date/dateString to DATE, time to TIME. The engine's type wins when both are known. A Date or an epoch-milliseconds number is bound as its ISO instant, because DuckDB has no cast from a number to a timestamp. The same schema fixes blank: = '' is a conversion error on any non-text column, so a typed column's blank test is IS NULL alone. Text and numeric comparisons (VARCHAR, BIGINT, DOUBLE, DECIMAL, HUGEINT) are written exactly as before, with no cast.

Time zones, honestly. The cast is the engine's, so its zone rules apply. Against a naive TIMESTAMP column the wall-clock digits of the bound string are compared; an instant ending in Z - which is what the grid's own date filter sends - therefore matches a column that stores UTC wall time, the usual convention for log and event data. A non-zero offset in the string is engine-version dependent (DuckDB 1.1 converts it to UTC, 1.5 keeps the digits as written), so send Z instants, not local offsets. Against a TIMESTAMP WITH TIME ZONE column an offset or Z is honoured exactly, and a string with no zone is interpreted in the engine's session TimeZone (UTC in DuckDB-Wasm unless the ICU extension is loaded and the setting changed). Against a DATE column an instant is truncated to its UTC day.

duckdbAdapter: grouping runs in the engine, one level at a time

Grouping a hundred million Parquet rows used to mean fetching a hundred million Parquet rows: the adapter declared no group capability, so the push router never sent it a grouped request and the grid grouped whatever it held. duckdbAdapter now declares group: true and answers the grouped view with GROUP BY - one statement per grid level, paged like any other window.

Nothing is configured. Group a grid over a DuckDB source and the grouping is pushed:

const grid = createGrid(el, {
  columns: [
    { id: 'region' },
    { id: 'tier' },
    { id: 'amount', type: 'number', total: 'sum' },
  ],
  groupBy: ['region', 'tier'],
  source: createPushdownSource({
    adapter: duckdbAdapter({ connection, from: "read_parquet('s3://bucket/sales/*.parquet')" }),
  }),
});

The root level is one statement - SELECT "region", count(*), sum("amount") … GROUP BY "region" ORDER BY "region" ASC NULLS LAST LIMIT ? OFFSET ? - so the group rows on screen cost a grouped scan and no leaf crosses the wire. Expanding a group narrows the next level by its parent's key; expanding the deepest one runs the ordinary row query with the same predicate ANDed on, so the leaves arrive paged and sorted exactly as they would without grouping. Every identifier goes through the same validation the row query uses and every value is bound, so the grouped path is no more exposed than the read path.

Subtotals come from the engine, and a statistic it cannot express shows nothing rather than something. Each totalled column contributes one aggregate expression, taken from the same verified pushdown map the statistics panel uses (see pushing statistics down) - sum, avg, min, max, count, the quantiles, and the rest. A column whose total is a host function, a two-column statistic such as weightedAvg (a grouped request carries no weight column), and the one genuine fallback weightedQuantile are not sent, are named in source.lastPlan().aggregates.client with the reason, and warn once. The group row then carries no value for that column. That is deliberate: the leaves of an unexpanded group are not in the browser, so the only alternative to the engine's figure is a figure computed over something that is not the group.

The counts a grouped grid shows are the engine's. Under grouping the display count is group headers plus whatever is expanded, which is how a grid showing three rows under one group once reported “4 of 3”. The root level's fetch therefore also asks for count(*) over the matching set and the grand total over it, in one extra statement, and an unfiltered count(*) once per adapter (on a Parquet file that is a footer read, not a scan). rows.matchCount() and rows.totalCount() read them, and grandTotalRow: 'bottom' draws its row from them.

Group order, and the collation decision. Group rows are ordered by their own key, ascending unless the sort names that column, which is exactly what the grid does to sibling group rows in memory - a sort naming some other column does not reorder groups in either. Absent keys sort last ascending and first descending, written as explicit NULLS LAST/NULLS FIRST rather than left to the connection's default_null_order. No COLLATE is written and the ICU extension is not loaded: the grid compares group keys with < on the JavaScript string (UTF-16 code-unit order) and DuckDB's default VARCHAR ordering is UTF-8 byte order, and the two agree for every character in the Basic Multilingual Plane. They part only for supplementary-plane characters (emoji, CJK extension B and above) compared against U+E000-U+FFFF. An ICU collation would disagree with the grid everywhere instead, so binary ordering is the pin. Practically: 'North' sorts before 'north' in both.

When grouping is not pushed, it says so. Grouping is all or nothing - group rows counted over the wrong set are wrong rows, not slow ones - so the whole level is refused if anything else in the query stayed behind: a filter that did not fully push, a sort the engine could not take, a quick search, a host where predicate, a grouping key that is not a plain column, or fullDataset (which holds the whole set and groups it client-side on purpose). source.lastPlan() then reports grouped: false, 'group' in unpushed and a groupReason sentence, and a one-time warning names it.

One known difference from a memory grid. A column holding both NULL and the empty string produces two groups in the engine and one in a memory grid, because the grid keys its group nodes on a display path where an absent value and an empty string are both ''. The engine's answer is the right one; the two are otherwise identical group for group, count for count and subtotal for subtotal, which the parity suite asserts at every level.

dfqlAdapter
OptionTypeDefaultMeaning
entitystring - The DemandFlow entity to query. Required.
tokenstring - A personal access token, sent as the bearer credential. Required. Never commit one; read it from configuration at runtime.
urlstringhttps://rest.demandflow.comThe API base, for a non-default region or a self-hosted deployment.
comboKey'comboKey' | 'comboKey2' | 'comboKey3'comboKeyThe name of the key attribute to match on. comboKey is the standard hierarchy.
querystringSUBThe prefix matched against the key attribute. SUB alone means every record of the entity in the tenant.
loadstring[]everythingFields to project, which saves bandwidth but not query cost.
limitnumberserver defaultCaps rows scanned, not matched - which is why every request also sends countOnly to reveal the true match count.
headersRecord<string, string>{}Extra headers merged over the bearer token, for a gateway that needs its own.
fetchtypeof fetchthe global fetchYour own fetch, for a proxy or a non-browser runtime.
writeUrlstringthe default write endpointWhere record mutations are POSTed, when the deployment's write endpoint differs from the default. Write-back persists update, delete and add-row.
encodeCreate(row: unknown) => Record<string, unknown>the row's own fieldsMaps a new grid row to the DemandFlow fields an append needs - its required entity/level/comboKey - since the structural append only knows the row's own fields.
graphqlAdapter

GraphQL has no fixed query semantics, so this adapter is configured. A filter, a sort and pagination are whatever the schema defines, so the two hooks are yours to write: buildQuery turns the pushed plan into the { query, variables } body the endpoint is POSTed, and parseResponse reads its data back into { rows, total }. The defaults cover an offset/limit list with a totalCount and a Relay cursor connection; either is replaced whole by passing the hook. The default query pushes only the window and asks for the total, which is why the default capabilities are range and total and nothing more - declare operators or capabilities for filter or sort only alongside a buildQuery that genuinely emits them, or the grid returns the wrong rows silently.

If your schema does not expose totalCount. The adapter reports no total, rather than the number of rows in the page, and the grid scrolls open-ended; the endpoint is named once in a warning so the silence is not mistaken for a working count. Before 1.51 the page length was reported as the total, and that was not only a wrong number on screen - it truncated results. When the grid has residual work to finish it asks for the whole result and the adapter walks offset/limit to get it, stopping when it has as many rows as the total says exist. With the total invented from page one, the walk stopped at page one: a 337-row connection came back as 100 rows, reported as complete, and any client-side filter or sort then ran over that fraction. The walk now stops on a short page or an exhausted cursor, so it returns everything and its count is exact. A schema that does report totalCount was never affected.

OptionTypeDefaultMeaning
urlstring - The GraphQL endpoint, POSTed a { query, variables } body. Required.
headersRecord<string, string>{}Sent on every request, merged over Accept and Content-Type: application/json. Where a fixed bearer token or API key goes. See authenticating.
fetchtypeof fetchthe global fetchYour own fetch, for an expiring token, a proxy or a non-browser runtime. The adapter bundles no HTTP client. See authenticating.
fieldstringitemsThe root query field the default query selects from, e.g. orders. Ignored when you pass buildQuery.
fieldsstring[]['id'] (with a warning)The field names the default query's selection set requests. Name what your grid shows; a default query that selects nothing useful is surfaced rather than left an empty grid.
selectionstring - (uses fields)A raw selection set for nested fields, e.g. 'id name address { city }', overriding fields.
pagination'offset' | 'cursor' GraphqlPaginationoffsetThe default convention: an offset/limit list, or a Relay cursor connection (first/after with pageInfo). A cursor connection is forward-only, so a deep window is paged forward to and costs round trips proportional to its offset.
pageSizenumber1000The page size for the two forward walks: pulling the whole result (when residual work forces it) and walking a cursor connection to a window.
countbooleantrueWhether the default query asks for totalCount. A totalCount on a connection is rarely free on the server - it is usually a second COUNT(*) over the same predicate - so set false for a grid that never shows a count: the field is dropped from the selection set, capabilities.total becomes false, and the grid scrolls open-ended. Unlike duckdbAdapter the count is not split into a second operation, because over HTTP that would cost an extra round trip rather than saving one. Ignored when you pass your own buildQuery.
varsPartial<Record<'offset'|'limit'|'first'|'after', string>>{ offset:'offset', limit:'limit', first:'first', after:'after' }Renames the pagination variables the adapter drives per page, to match the names your schema's arguments use.
capabilitiesPushdownCapabilities{ range:true, total:true, filter:false, sort:false, quick:false }What your buildQuery actually pushes, merged over the defaults. Declaring a capability the hook does not honour returns the wrong rows silently, so the default declares only the window and the total.
operatorsstring[] - (filtering off)The comparisons your buildQuery emits, e.g. ['eq','gt','contains']. Setting it turns filtering on as a tree; pair it with a buildQuery that translates the condition tree, or the filter is declared but not applied.
buildQuery(request: RemoteRequest) => { query, variables }the offset or cursor defaultTurns the pushed plan - the window, and whatever filter/sort/quick you declared pushable - into the GraphQL operation to POST. This is where your schema's argument names live.
parseResponse(data: object) => { rows, total, pageInfo? }the offset or cursor defaultReads the operation's data into the row array and the count of all matching rows. For a cursor connection, return pageInfo (hasNextPage, endCursor) so the adapter can walk forward.
buildMutation(op: object) => { query, variables } - (write-back off)Turns a mutation into a GraphQL operation. A declared follow-up wired by the write-back wave; capabilities.mutate stays false by declaration until then.

Authenticating a remote adapter

Two shapes cover almost every endpoint. A fixed credential - an API key or a long-lived token - goes in headers, which odataAdapter and restAdapter send on every request. A credential that expires - a short-lived bearer token you refresh - goes in a custom fetch, which is the one place that can mint a fresh value per request. dfqlAdapter takes its token directly, and headers for anything a gateway adds on top.

A fixed token in headers. The map is sent on every request, so an Authorization header authenticates the whole grid. Below, a custom fetch stands in for the network only so the example can prove the header arrived:

const { odataAdapter } = await import('../packages/core/src/index.js');

let seen;
const adapter = odataAdapter({
  url: 'https://api.example.com/Orders',
  // A fixed credential authenticates every request.
  headers: { Authorization: 'Bearer static-token-123' },
  // Only here to capture what the adapter sent; in a browser, omit it.
  fetch: async (url, init) => {
    seen = init.headers.Authorization;
    return { ok: true, json: async () => ({ value: [], '@odata.count': 0 }) };
  },
});

await adapter.execute({ range: { start: 0, end: 20 } }, {});
return seen;   // the header reached the request

An expiring token in a custom fetch. A token with a lifetime cannot sit in a fixed map, because the map is read once and the token outlives no request that matters. A custom fetch is called afresh for every request, so it is where you refresh the credential and set the header on the outgoing call:

const { restAdapter } = await import('../packages/core/src/index.js');

// Stands in for a token service that hands out a new value each time.
let issued = 0;
const freshToken = async () => `token-${++issued}`;

let lastAuth;
const adapter = restAdapter({
  url: '/api/orders',
  fetch: async (url, init) => {
    // Refreshed per request, then merged over whatever headers the adapter set.
    const headers = { ...init.headers, Authorization: `Bearer ${await freshToken()}` };
    lastAuth = headers.Authorization;
    return { ok: true, json: async () => ({ rows: [], total: 0 }) };
  },
});

await adapter.execute({ range: { start: 0, end: 20 } }, {});   // token-1
await adapter.execute({ range: { start: 20, end: 40 } }, {});  // token-2
return lastAuth;   // a fresh token on the second request

Wiring it to the API you already have

Most data sits behind a service someone on your team wrote. The adapter below sends four parameters and expects { rows, total } back. Start by declaring only what the endpoint genuinely does, and widen it as you teach the endpoint more.

const source = createPushdownSource({
  compute,
  adapter: restAdapter({
    url: '/api/orders',
    // Only the comparisons the endpoint really applies. Claiming more here
    // returns the wrong rows rather than merely running slowly.
    operators: ['eq', 'gt', 'lt', 'contains'],
    params: { offset: 'skip', limit: 'take' },
  }),
});

The request that reaches your service, and the answer it owes:

ParameterExampleMeaning
skip / take40, 20The window. Return exactly that slice.
sort / orderamount,name / desc,ascColumns in priority order, and a direction for each.
filterJSON condition treeOnly the conditions your declared operators cover. Everything else the grid keeps.
qfree textPresent only when you declare quick: true.
// Express. FastAPI and ASP.NET differ only in how the query string is read.
app.get('/api/orders', async (req, res) => {
  const { skip = 0, take = 100, sort, order, filter } = req.query;

  let q = db('orders');
  if (filter) q = applyConditions(q, JSON.parse(filter));   // your translation
  if (sort) {
    sort.split(',').forEach((col, i) => {
      q = q.orderBy(col, (order || '').split(',')[i] === 'desc' ? 'desc' : 'asc');
    });
  }

  // The count is of everything matching, not of the page. A grid scrollbar is
  // sized from it, so a page-sized total makes the grid look empty below.
  const [{ count }] = await q.clone().clearOrder().count({ count: '*' });
  const rows = await q.offset(Number(skip)).limit(Number(take));

  res.json({ rows, total: Number(count) });
});

The total is the commonest mistake. It is the number of rows matching the filter, not the number returned in this page. The grid sizes its scrollbar from it and requests windows against it, so returning the page length makes a large result look like one page.

A worked example, executed on every build so it cannot go stale (data-run, PRD  C3):

const { capabilitiesOf, splitFilters, resolveMutate } = await import('../packages/core/src/source/pushdown.js');

// An adapter that understands three comparisons and nothing else.
const caps = capabilitiesOf({ filter: 'tree', operators: ['eq', 'gt', 'lt'] });

// Read-only by declaration: an adapter that says nothing about writing
// cannot mutate, and one that opts in resolves to a complete capability.
if (resolveMutate() !== false) throw new Error('a silent adapter must stay read-only');
if (!resolveMutate({ update: true }).update) throw new Error('an opt-in must resolve');

// A conjunction splits: what the engine knows goes to it, the rest stays here.
const { pushed } = splitFilters({
  op: 'and',
  conditions: [
    { col: 'a', op: 'eq', value: 1 },
    { col: 'b', op: 'gt', value: 2 },
    { col: 'c', op: 'lt', value: 3 },
  ],
}, caps);

return pushed.conditions.length;   // all three are supported

Building an adapter from the parts

createPushdownSource is the whole story for most callers. When an engine needs a source of its own, the four pieces it is assembled from are exported separately, so a custom source can plan and finish work the same way rather than reimplementing the split.

ExportSignatureDescription
capabilitiesOf(declared?) => Required<PushdownCapabilities>Resolves what an adapter declared against the defaults, giving a complete set with no absent keys to test for.
resolveMutate(declared?) => false | MutateCapabilityResolves an adapter's mutate declaration against the defaults. Returns false when the adapter cannot mutate, so a source over it stays read-only by declaration and refuses a write loudly rather than dropping it.
splitFilters(filters, caps) => { pushed, residual }Divides a condition tree into the half the engine takes and the half left over. A conjunction splits; a disjunction that is not fully supported stays whole on the client, because pushing part of an or returns fewer rows than the filter allows and the grid cannot recover what was never fetched.
planQuery(request, caps) => PushdownPlanPlans one request: the query to send, the work to finish afterwards, whether the whole result is needed, and which parts stayed behind.
applyResidual(rows, residual, compute) => unknown[]Applies whatever the engine could not, through the grid's own filter and sort kernels rather than a second implementation, so a residual predicate means exactly what the same predicate means anywhere else.
NO_CAPABILITIESReadonly<Required<PushdownCapabilities>>The set an adapter that declares nothing is treated as having: everything off. Such an adapter still works; the grid simply does all the work.

Residual work needs the complete result. applyResidual expects every matching row, not a window. Filtering a window 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. planQuery sets needsAll whenever that applies, and createPushdownSource switches to fetching everything and paging from what it holds.

Whole-dataset statistics: fullDataset

A windowed source computes a total, statistic or group over the loaded window - the rows on screen - not the whole matching set, unless residual work already forced a whole-result fetch. “Median revenue” in the footer becomes the median of ~200 rows, wrong and looking right. fullDataset.enabled makes the whole-result fetch sticky and explicit: the entire matching set is held client-side once per query and every window, total and statistic is served from it, so the figures are computed over everything. It reuses the same whole-result path residual work already takes - needsAll - rather than a parallel mechanism. It is off by default and strictly opt-in. For a restAdapter, which cannot compute, it is the only way to get a correct whole-dataset statistic at all.

Memory-guarded, refused loudly. A matching set past maxRows or maxBytesEstimate is refused - thrown, surfaced as a source:error with no rows shown - never silently truncated. Presenting a fraction as the whole is the exact failure whole-dataset pull exists to prevent, so it is never the failure mode of the fix itself. Narrow the filter or raise the limit.

KeyTypeDefaultDescription
enabledbooleanfalseHold the whole matching set client-side and serve every window, total and statistic from it.
maxRowsnumber1_000_000Refuse (visible source:error) when the matching set is larger.
maxBytesEstimatenumber512 MBRefuse past this estimated heap cost, sampled from a representative row.
// An adapter that can page but reports the whole matching count. Without
// fullDataset a stat would see only the window; with it, the whole set.
const { createPushdownSource } = await import('../packages/core/src/source/pushdown.js');
const all = Array.from({ length: 100 }, (_, i) => ({ id: i, amount: i }));
const adapter = {
  name: 'demo',
  capabilities: { range: true, total: true },
  execute: async (query) => {
    const start = query.range ? query.range.start : 0;
    const end = query.range ? query.range.end : all.length;
    return { rows: all.slice(start, end), total: all.length };
  },
};
const source = createPushdownSource({ adapter, fullDataset: { enabled: true, maxRows: 1000, maxBytesEstimate: 5_000_000 } });
// Ask for a 10-row window; fullDataset holds all 100, so the mean is the true one.
const block = await source.fetch({ range: { start: 0, end: 10 }, filters: null, sort: [], quick: '' });
const held = block.total; // 100: the whole set is held, not the 10-row window
return all.reduce((s, r) => s + r.amount, 0) / held; // 49.5, the true whole-dataset mean

Refusing a partial result: allowPartialResults

When a query has residual work - a filter, sort or quick search the engine could not push - the source asks the adapter for the whole matching set, applies the residual here, and pages from what it holds. If the adapter instead returns a page of that result (it paged when told not to), the client-side filter or sort runs over the wrong rows: the rows that belong on page one may be in the fraction that was never fetched, so a page is presented as the full filtered set. That is a wrong answer, not a slow one.

By default the source refuses such a shortfall - it throws, and the remote source surfaces a source:error with no rows shown, rather than filter a fraction and lie. The fix is to make the adapter follow the engine's own paging before returning, or hold the data in memory. allowPartialResults: true is the knowing escape hatch: a caller who accepts the permissive behaviour - an adapter that genuinely cannot page and a result small enough not to matter, or a diagnostic run - keeps the old warn-once-and-proceed path. It is off by default, because a silent wrong answer is the one thing the design refuses. It does not affect the fullDataset memory guard, nor the no-residual short-return warning.

KeyTypeDefaultDescription
allowPartialResultsbooleanfalseAccept a partial/paged result to a whole-set request that residual work will filter over, keeping the warn-once-and-proceed behaviour instead of refusing. Off by default: the shortfall is thrown.
// An eq-only engine: the `gt` filter is residual and runs in the browser. The
// adapter reports 40 matching but returns only 1 row - a page shown as the whole.
const { createPushdownSource } = await import('../packages/core/src/source/pushdown.js');
const adapter = {
  name: 'shortfall',
  capabilities: { filter: 'tree', operators: ['eq'] },
  execute: async () => ({ rows: [{ id: 1, a: 5 }], total: 40 }),
};
const req = { range: { start: 0, end: 10 }, filters: { col: 'a', op: 'gt', value: 1 }, sort: [], quick: '' };

// Default: refused. Filtering 1 of 40 rows would return the wrong rows.
const strict = createPushdownSource({ adapter });
let refused = false;
try { await strict.fetch(req); } catch (e) { refused = /returned 1 of 40|refused/.test(e.message); }

// Knowing opt-in: warns once and proceeds with the fraction.
const lax = createPushdownSource({ adapter, allowPartialResults: true });
const block = await lax.fetch(req);
return refused ? `refused; opted in: ${block.rows.length} rows` : 'not refused';

Running a host predicate: whereRowLimit

A where predicate is a host function - whether this user may see the row, whether you hold a rate for its currency. No engine can evaluate one, so the only way a pushdown source can honour it is to fetch every matching row and filter here. That is a real answer, and it is also a windowed grid quietly turning into a whole-dataset download - the one thing a pushdown source exists to avoid.

So it is gated rather than done on your behalf. Under whereRowLimit (default 50_000, the same anchor as the grid's workerThreshold) the predicate runs and the counts are whole-dataset counts. At or past it - or when the adapter reports no row total, since the only way to learn the size from such an adapter is to fetch the set - the source refuses: the predicate is not applied, the rows it would exclude stay on screen, and one warning names the adapter, the size, the limit and the way out. Raise the limit when you want the download.

The { condition } twin is the route that works at any size. It is pushed to the engine, which narrows the fetch itself, so no limit applies and nothing is held here. Reach for the limit only when the predicate genuinely cannot be expressed as a condition.

KeyTypeDefaultDescription
whereRowLimitnumber50_000The most rows the source will fetch and hold in order to run a twinless where predicate. At or past this many matching rows the predicate is refused and warned about rather than the whole set downloaded. An adapter reporting no row total counts as over the limit. 0 refuses every predicate.
// Three rows, two of them ana's. The predicate is a host function with no twin,
// so the engine cannot narrow the fetch and the source must hold the set to run it.
const { createPushdownSource } = await import('../packages/core/src/source/pushdown.js');
const all = [{ id: 1, owner: 'ana' }, { id: 2, owner: 'bo' }, { id: 3, owner: 'ana' }];
const adapter = {
  name: 'demo',
  capabilities: { range: true, total: true },
  execute: async (q) => ({ rows: q.range ? all.slice(q.range.start, q.range.end) : all, total: all.length }),
};
const where = { active: true, names: ['mine'], version: 1, passes: (row) => row.owner === 'ana' };
const req = { range: { start: 0, end: 10 }, filters: null, sort: [], quick: '', where };

// Under the limit: the whole matching set is fetched and the predicate runs.
const under = createPushdownSource({ adapter, whereRowLimit: 1000 });
const applied = await under.fetch(req);

// At or past it: refused and warned about, and every row stays on screen.
const over = createPushdownSource({ adapter, whereRowLimit: 2 });
const refused = await over.fetch(req);

return `applied: ${applied.rows.length}, refused: ${refused.rows.length}`;

Pushing statistics down: aggregates

A DuckDB-class engine can compute a median or a standard deviation over the whole matching set far faster than pulling every row to do it here. The aggregates config decides, at grid setup, which statistics are computed by the engine and which by the grid. It is a design-time developer choice - fixed for the life of the grid, never a runtime toggle, never shown to an end user. Absent, every aggregate is computed client-side, so no existing grid changes behaviour.

Each statistic is classified IDENTICAL (the engine's result equals the grid's own kernel, verified against it) or MAY-DIFFER (the engine computes it by a method that can differ from the grid's definition). The classification drives this documentation and build-time provenance, not whether a stat is pushed - that is your choice. Only weightedQuantile is a genuine fallback: the engine cannot express the grid's midpoint convention, so it is always computed client-side.

KeyTypeDefaultDescription
default'engine' | 'client' | 'engine-if-identical' AggregateMode'client'engine pushes everything the engine can express (using its method for MAY-DIFFER stats); engine-if-identical pushes only the verified-identical ones and keeps MAY-DIFFER client-side - the recommended setting for a windowed DuckDB source; client computes everything here.
overridesRecord<stat, 'engine' | 'client'> - Per-stat overrides that win over default. A stat the engine cannot express is always client-side regardless.

No mixed provenance. An engine number and a client number 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 figure computed over a superset beside a client figure over the real set would be wrong-but-plausible. lastPlan().aggregates reports, per statistic, whether the engine or the client computed it and the class it was assigned - build-time inspection, not a per-figure runtime marker.

The classification table below is generated from the single pushdown map (STAT_PUSHDOWN), so it cannot drift from what the adapter actually emits:

StatisticClassDuckDB expressionNote
sumIDENTICALsum(col)Pushes to DuckDB with the same result.
avgIDENTICALavg(col)Pushes to DuckDB with the same result.
minIDENTICALmin(col)Pushes to DuckDB with the same result.
maxIDENTICALmax(col)Pushes to DuckDB with the same result.
countIDENTICALcount(*)Pushes to DuckDB with the same result.
countValuesIDENTICALcount(col)Pushes to DuckDB with the same result.
rangeIDENTICAL(max(col) - min(col))Pushes to DuckDB with the same result.
varianceIDENTICALvar_samp(col)Pushes to DuckDB with the same result.
variancePIDENTICALvar_pop(col)Pushes to DuckDB with the same result.
stddevIDENTICALstddev_samp(col)Pushes to DuckDB with the same result.
stddevPIDENTICALstddev_pop(col)Pushes to DuckDB with the same result.
sumSquaresIDENTICALsum(col * col)Pushes to DuckDB with the same result.
medianIDENTICALmedian(col)Pushes to DuckDB with the same result.
p25IDENTICALquantile_cont(col, 0.25)Pushes to DuckDB with the same result.
p75IDENTICALquantile_cont(col, 0.75)Pushes to DuckDB with the same result.
p90IDENTICALquantile_cont(col, 0.9)Pushes to DuckDB with the same result.
p95IDENTICALquantile_cont(col, 0.95)Pushes to DuckDB with the same result.
p99IDENTICALquantile_cont(col, 0.99)Pushes to DuckDB with the same result.
iqrIDENTICAL(quantile_cont(col, 0.75) - quantile_cont(col, 0.25))Pushes to DuckDB with the same result.
madIDENTICALmad(col)Pushes to DuckDB with the same result.
distinctIDENTICALcount(DISTINCT col)Pushes to DuckDB with the same result.
skewnessIDENTICALskewness(col)Pushes to DuckDB with the same result.
kurtosisIDENTICALkurtosis(col)Pushes to DuckDB with the same result.
geomeanIDENTICALexp(avg(ln(col)))Pushes to DuckDB with the same result.
harmeanIDENTICAL(count(col) / sum(1.0 / col))Pushes to DuckDB with the same result.
entropyIDENTICALentropy(col)Pushes to DuckDB with the same result.
correlationIDENTICALcorr(weight, col)Pushes to DuckDB with the same result.
hhiIDENTICALCASE WHEN count(col)=0 THEN NULL ELSE list_sum(list_transform(map_values(histogram(col)), lambda v: (v::DOUBLE/count(col))*(v::DOUBLE/count(col)))) ENDPushes to DuckDB with the same result.
evennessIDENTICALCASE WHEN count(col)=0 THEN NULL WHEN count(DISTINCT col)<2 THEN 1.0 ELSE entropy(col)/log2(count(DISTINCT col)) ENDPushes to DuckDB with the same result.
top3ShareIDENTICALCASE WHEN count(col)=0 THEN NULL ELSE list_sum(list_slice(list_sort(map_values(histogram(col)),'DESC'),1,3))::DOUBLE/count(col) ENDPushes to DuckDB with the same result.
top10ShareIDENTICALCASE WHEN count(col)=0 THEN NULL ELSE list_sum(list_slice(list_sort(map_values(histogram(col)),'DESC'),1,10))::DOUBLE/count(col) ENDPushes to DuckDB with the same result.
giniIDENTICALCASE WHEN list_min(list(col) FILTER (WHERE isfinite(col)))<0 THEN NULL WHEN len(list(col) FILTER (WHERE isfinite(col)))=0 THEN NULL WHEN list_sum(list(col) FILTER (WHERE isfinite(col)))=0 THEN 0 ELSE 2.0*list_sum(list_transform(list_sort(list(col) FILTER (WHERE isfinite(col))), lambda v, i: i*v))/(len(list(col) FILTER (WHERE isfinite(col)))*list_sum(list(col) FILTER (WHERE isfinite(col))))-(len(list(col) FILTER (WHERE isfinite(col)))+1.0)/len(list(col) FILTER (WHERE isfinite(col))) ENDPushes to DuckDB with the same result.
trimmedMeanIDENTICALCASE WHEN len(list(col) FILTER (WHERE isfinite(col)))=0 THEN NULL ELSE list_avg(list_slice(list_sort(list(col) FILTER (WHERE isfinite(col))), floor(len(list(col) FILTER (WHERE isfinite(col)))*0.1)::BIGINT+1, len(list(col) FILTER (WHERE isfinite(col)))-floor(len(list(col) FILTER (WHERE isfinite(col)))*0.1)::BIGINT)) ENDPushes to DuckDB with the same result.
winsorizedMeanIDENTICALCASE WHEN len(list(col) FILTER (WHERE isfinite(col)))=0 THEN NULL ELSE list_avg(list_transform(list_sort(list(col) FILTER (WHERE isfinite(col))), lambda v: least(list_sort(list(col) FILTER (WHERE isfinite(col)))[len(list(col) FILTER (WHERE isfinite(col)))-floor(len(list(col) FILTER (WHERE isfinite(col)))*0.1)::BIGINT], greatest(list_sort(list(col) FILTER (WHERE isfinite(col)))[floor(len(list(col) FILTER (WHERE isfinite(col)))*0.1)::BIGINT+1], v)))) ENDPushes to DuckDB with the same result.
robustOutliersIDENTICAL(SELECT CASE WHEN len(d.a)=0 THEN NULL WHEN d.mad=0 THEN NULL ELSE len(list_filter(d.a, lambda v: abs(0.6745*(v-d.med)/d.mad)>3.5)) END FROM (SELECT xs AS a, list_median(xs) AS med, list_median(list_transform(xs, lambda w: abs(w-list_median(xs)))) AS mad FROM (SELECT list(col) FILTER (WHERE isfinite(col)) AS xs)) d)Pushes to DuckDB with the same result.
jarqueBeraIDENTICALCASE WHEN len(list(col) FILTER (WHERE isfinite(col)))<8 THEN NULL WHEN list_avg(list_transform(list(col) FILTER (WHERE isfinite(col)), lambda v: power(v-list_avg(list(col) FILTER (WHERE isfinite(col))),2)))=0 THEN NULL ELSE (len(list(col) FILTER (WHERE isfinite(col)))/6.0)*(power(list_avg(list_transform(list(col) FILTER (WHERE isfinite(col)), lambda v: power(v-list_avg(list(col) FILTER (WHERE isfinite(col))),3)))/power(list_avg(list_transform(list(col) FILTER (WHERE isfinite(col)), lambda v: power(v-list_avg(list(col) FILTER (WHERE isfinite(col))),2))),1.5),2)+power(list_avg(list_transform(list(col) FILTER (WHERE isfinite(col)), lambda v: power(v-list_avg(list(col) FILTER (WHERE isfinite(col))),4)))/power(list_avg(list_transform(list(col) FILTER (WHERE isfinite(col)), lambda v: power(v-list_avg(list(col) FILTER (WHERE isfinite(col))),2))),2)-3,2)/4.0) ENDPushes to DuckDB with the same result.
weightedAvgIDENTICALsum(col*weight) FILTER (WHERE isfinite(col) AND isfinite(weight))/nullif(sum(weight) FILTER (WHERE isfinite(col) AND isfinite(weight)),0)Pushes to DuckDB with the same result.
modeMAY-DIFFERmode(col)DuckDB returns a modal value even for an all-distinct column; the grid returns null. Tie-breaking can also differ, and on a text column an empty string counts as a value. Under pushdown you will see a value.
weightedQuantileFALLBACK - No SQL equivalent for the grid's weighted-quantile midpoint convention; always computed client-side (needs a full-dataset pull for a correct figure over a remote source).
// Push the verified-identical stats to the engine; keep the fallback here.
// STAT_PUSHDOWN is the published map every stat's class and SQL comes from.
const { createPushdownSource, STAT_PUSHDOWN } = await import('../packages/core/src/source/index.js');
void STAT_PUSHDOWN;   // the single source of truth for the classification table above
const adapter = {
  name: 'demo',
  capabilities: { filter: 'tree', operators: ['eq'] },
  execute: async () => ({ rows: [], total: 0 }),
  // A real duckdbAdapter runs SQL; here we just echo which stats arrived.
  executeAggregates: async (query, aggs) => Object.fromEntries(aggs.map((a) => [a.id, 1])),
};
const source = createPushdownSource({ adapter, aggregates: {
  default: 'engine-if-identical',   // push only the verified-identical stats
  overrides: { mode: 'client' },     // but always keep mode's exact definition
} });
const split = await source.aggregate(
  { filters: null, sort: [], range: null },
  [{ id: 'a', col: 'revenue', fn: 'median' }, { id: 'b', col: 'size', fn: 'weightedQuantile' }],
);
// median is IDENTICAL so it pushes; weightedQuantile is a fallback so it stays here.
return `engine=${split.engine[0].class} client=${split.client[0].class}`;

Joining two grids

Two grids each holding their own data, and a third showing where they meet. Orders against customers; shipments against carriers; enrolments against students. The third grid derives from one side and names the other as its join partner.

const joined = createGrid(host, {
  source: {
    mode: 'derived',
    from: orders,
    join: {
      with: customers,
      on: { left: 'customerId', right: 'id' },
      select: ['name', 'tier'],
    },
  },
  columns: [{ field: 'ref' }, { field: 'name' }, { field: 'tier' }, { field: 'amount' }],
});
KeyTypeDescription
withGridRequired. The grid holding the other side.
onstring | { left, right }Required. The shared key: one field name when both sides use it, or one each.
type'inner' | 'left' JoinTypeinner by default, keeping only rows that matched, which is usually what “common data” means. left keeps every row and leaves the brought-across fields undefined, the shape you want when the unmatched rows are the finding.
selectstring[]Which of the partner's fields to bring across. All of them by default.
prefixstringRename the brought-across fields, for when both sides have a name worth keeping.
follow'all' | 'filtered' RowScopeWhich of the partner's rows to read. all by default: a lookup table is normally the whole table, and a customer list filtered to Europe would otherwise silently drop every other order from a grid the reader takes to be all orders.

The row count does not change. A key appearing twice on the right keeps the first match rather than emitting a row per pair. SQL would multiply them out; here that would change the row count of a grid the reader thinks of as “the orders” and quietly double every total taken from it.

Both sides are live. The partner is read at derivation time, not captured when the grid was built, and editing it re-derives, a corrected tier in the customer grid moves the order into a different band in the joined one.

Cross-filtering: the path back up

Derivation runs one way. A derived grid reads its source and never writes to it, which is what makes a chain of them safe to reason about. Cross-filtering is the single deliberate path back up: clicking a row in a summary panel filters the grid it summarises.

const byRep = createGrid(panel, {
  source: {
    mode: 'derived', from: main, groupBy: 'rep', refresh: 'live',
    crossFilter: true,
    select: { total: { of: 'amount', fn: 'sum' } },
  },
  columns: [{ field: 'rep' }, { field: 'total' }],
});

byRep.on('row:clicked', (e) => byRep.crossFilter.toggle(e.key));

The event is row:clicked, not row:click. A handler bound to the wrong name subscribes without error and never fires, so this example is executed on every build to keep the name honest: it wires the same handler to a stand-in grid, emits the event, and checks the click reached the cross-filter.

// A stand-in for the grid's event bus and cross-filter, so the wiring above can
// be executed here without a DOM. The names are the product's own.
const listeners = {};
const filtered = [];
const grid = {
  on: (name, fn) => { (listeners[name] = listeners[name] || []).push(fn); },
  emit: (name, e) => { for (const fn of listeners[name] || []) fn(e); },
  crossFilter: { toggle: (key) => { filtered.push(key); } },
};

// The line from the example, verbatim in its event name.
grid.on('row:clicked', (e) => { if (e.key) grid.crossFilter.toggle(e.key); });

// A click on a rep row. The wrong name - 'row:click' - would reach no handler,
// and this block would produce '' instead of the key.
grid.emit('row:clicked', { key: 'EMEA' });
grid.emit('row:click', { key: 'US' });

return filtered.join(',');
MemberReturnsDescription
enabled()booleanWhether this grid can cross-filter a source. False on a grid that is not derived, or whose source has no crossFilter.
column()string | nullThe source column the filter is pushed onto.
get()string[]The keys currently filtering the source.
set(keys)voidFilter the source to these derived rows. null clears.
toggle(key)voidAdd or remove one key: what a click handler wants.
clear()voidTake this grid's filter off its source.

A panel does not filter itself. The grid pushing the filter leaves its own condition out when it reads the source back. Without that, clicking one rep would collapse the panel to that single row and strand the reader with nothing else to click. It is the same rule that keeps a header histogram showing every bar after you click one (facets), applied between grids instead of within one.

Several panels compose. Each leaves out only its own condition, so two panels over different columns narrow each other while both stay whole: pick a rep and the region panel shows that rep's regions, pick a region and the rep panel shows that region's reps.

It needs a memory source. Leaving a panel's own condition out means asking the source for every row that survives the other filters, which a source holding one page cannot answer. Over a remote, paged or stream source the read falls back to the ordinary filtered rows (narrowed by the very column the panel asked to be excluded from) and the panel collapses to the row that was clicked. It warns when it does. Exclude the originating panel on the server instead.

It is an ordinary filter. The condition goes through the source's filters.set, so it undoes, rides in a saved view, and appears in whatever filter UI the grid already has. There is no second filter model beside the real one.

The remote request

Your fetch receives one object and returns { rows, total }.

FieldTypeDescription
range{ start, end }The block wanted, end exclusive. Not from/to.
sort{ col, dir }[]In priority order.
filtersFilterSetThe condition tree, in the wire form described under operators.
quickstringPresent only when the quick filter is set.
groupBy / groupPathstring[] / unknown[]Which columns group, and which node this block belongs to.
pivotBy / pivotModestring[] / boolean
totalsstring[]Columns wanting an aggregate, so the server can compute them.
contextunknownYour own config.context, passed through untouched.
signalAbortSignalAborted when the request is superseded: pass it to fetch.
protocolnumberWire version, so a server can tell what it is talking to.

Blocks are requested as the viewport reaches them and cached. Changing the sort, the filter or the grouping invalidates the cache and re-queries.

Events

One bus. There are no onX configuration properties, and every event is described once, in the generated event table at the end of this page: all 162 of them, with when each fires, the payload interface a handler receives and whether it can be cancelled. That table is generated from the declarations, so it is the release’s own answer rather than a second list to keep in step.

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 debugging
off();                                       // on() returns its own unsubscribe

What a handler receives. Every payload is a GridEvent: the event’s own fields, plus type, grid and origin. origin is 'api', 'user', 'init' or 'ai' - a host persisting state reads it to ignore its own writes and avoid a feedback loop, and it is how a genuine user gesture is told from a module-driven re-entry.

Cancelling an action. Every user-initiated mutation is gated by a before… event carrying a BeforeEvent: the action’s own context plus preventDefault(reason?), defaultPrevented and reason. Calling preventDefault() (or returning false) cancels the action, and a handler may be async - the mutation is held until every before-handler settles, which is what makes a confirm dialog or a server check a genuine gate. On a veto the paired <action>:cancelled fires with the reason. Host/API writes and remote or router-applied deltas (origin !== 'user') do not fire these.

The declared list is complete, and stays complete: tools/check.js compares every emit() in the grid against the declared event names and fails the build on a mismatch. Each module - charts, the tabbed grid, the kanban board, the KPI panel, the dashboard layout, the Gantt, the AI controller and the Data Router - raises its own events, which are listed on that module’s own surface rather than on this bus.

A declared event is reachable directly, executed

Every name in the event table is a first-class event: grid.on(name, ...) binds it without the unknown-name warning, and each maps to a framework handler prop. Shown for annotation:changed, which promoted from a wildcard-only emission to a declared event. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { isKnownEvent } = await import('../packages/core/src/events/index.js');
const { handlerName } = await import('../packages/modules/shared/adapter.js');

const grid = createHeadlessGrid({ columns: [{ field: 'a' }], rows: [] });

// Binding a declared event does not trip the unknown-name warning that an
// undeclared one would - that warning is exactly what removed.
const warnings = [];
const original = console.warn;
console.warn = (...a) => warnings.push(a.join(' '));
const off = grid.on('annotation:changed', () => {});
console.warn = original;
off();
grid.destroy();

return [
  isKnownEvent('annotation:changed'),   // declared at on() time
  warnings.length,                      // 0: no unknown-event warning
  handlerName('annotation:changed'),    // the adapter prop the frameworks expose
].join(' | ');

Cancellable before-events: guarded editing and confirm-before-delete, executed

Every user-initiated mutation has a cancellable before event. A handler cancels the pending action with preventDefault(reason?) and may be async - the mutation is held until it settles, which is what makes a confirm dialog or a server check a genuine gate. On a veto the paired <action>:cancelled fires with the reason. Host/API writes and remote deltas do not fire them. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const grid = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ field: 'id' }, { field: 'name', edit: { enabled: true } }, { field: 'v', type: 'number', edit: { enabled: true } }],
  rows: [{ id: 'a', name: 'Ann', v: 1 }, { id: 'b', name: 'Bo', v: 2 }],
  selection: 'multiple',
});

const log = [];

// Guarded editing: veto a commit on a locked row, and hear the cancellation.
grid.on('beforeEdit', (e) => { if (e.key === 'a') e.preventDefault('locked'); });
grid.on('edit:cancelled', (e) => log.push('edit ' + e.reason));

// Guard the query and layout surfaces.
grid.on('beforeSort', (e) => e.preventDefault('view-locked'));
grid.on('sort:cancelled', () => log.push('sort'));
grid.on('beforeFilter', (e) => e.preventDefault('no-filter'));
grid.on('filter:cancelled', () => log.push('filter'));
grid.on('beforeColumnMove', (e) => e.preventDefault('fixed'));
grid.on('columnMove:cancelled', () => log.push('colmove'));
grid.on('beforeColumnResize', (e) => e.preventDefault('fixed'));
grid.on('columnResize:cancelled', () => log.push('colresize'));
grid.on('beforeColumnHide', (e) => e.preventDefault('mandatory'));
grid.on('columnHide:cancelled', () => log.push('colhide'));
grid.on('beforeGroup', (e) => e.preventDefault('frozen'));
grid.on('group:cancelled', () => log.push('group'));
grid.on('beforeRowMove', (e) => e.preventDefault('ordered'));
grid.on('rowMove:cancelled', () => log.push('rowmove'));
grid.on('beforeRowAdd', (e) => e.preventDefault('quota'));
grid.on('rowAdd:cancelled', () => log.push('rowadd'));
// A row dropped in from another grid: fires on the receiving grid, naming the
// row under the pointer, so "assign this to that" can veto the insert.
grid.on('beforeRowReceive', (e) => { if (e.overKey !== null) e.preventDefault('assigned'); });
grid.on('rowReceive:cancelled', () => log.push('receive'));
grid.on('beforeSelect', () => {});
grid.on('selection:cancelled', () => log.push('sel'));

// Confirm before delete: an async handler holds the delete until it settles.
grid.on('beforeDelete', async (e) => { await Promise.resolve(); e.preventDefault('user cancelled'); });
grid.on('delete:cancelled', (e) => log.push('delete ' + e.reason));

// Past-tense notifications a host can also observe (not gates): print, remote
// export, and the keyboard-shortcuts overlay.
grid.on('print:before', () => log.push('print-before'));
grid.on('print:after', () => log.push('print-after'));
grid.on('export:request', () => {});
grid.on('export:done', () => {});
grid.on('shortcuts:opened', () => {});
grid.on('shortcuts:closed', () => {});

// A vetoed sort (sync) and an edit blocked on the locked row.
grid.sort.set([{ col: 'v', dir: 'desc' }]);
grid.edit.start('a', 'name');
grid.edit.stop(false, { value: 'Nope' });

return grid.sort.get().length + '|' + grid.rows.byKey('a').data.name + '|' + log.join(',');

Conditional formatting

Rules compile into the function cell.style already takes, so a compiled rule set installs exactly like a hand-written style function.

import { compileRules } from '@toclocoinc/lattice-grid';

{ field: 'margin', cell: { style: compileRules([
  { when: { op: 'lt', value: 0 },              style: { background: '#fdecea', colour: '#b91c1c' } },
  { when: { op: 'between', value: 0, value2: 5 }, style: { background: '#fdf3e0' } },
  { scale: { min: 0, max: 100, colours: ['#f8f9fa', '#1a6bc7'] } },
]) } }
KeyDescription
when{ op, value, value2 }, using the same operators as filters: eq, ne, gt, gte, lt, lte, between, outside, contains, notContains, startsWith, endsWith, blank, notBlank, true, false.
styleA style object, or a function of the cell params.
scale{ min, max, colours }, a colour scale. Two or more stops, reached evenly.
stopIfTrueDefault true. false lets a later rule add to this one.
enabledfalse skips the rule without removing it.

Rules are evaluated in order and the first match wins, as in a spreadsheet: "red if overdue, amber if due this week" reads top to bottom and stops. A blank cell satisfies no comparison, so an empty cell is not swept into "less than 100".

A scale's min and max are required rather than derived from the data. A scale that rescaled as rows were filtered would change a cell's colour without its value changing.

Quick filter modes

One box, four ways to match. The mode persists until changed, so a host sets it once and goes on passing text alone.

grid.filters.quick('acme london', { mode: 'words' });
grid.filters.quickState();   // { text: 'acme london', mode: 'words' }
ModeMatchesExample
containsThe text appears somewhere in the row. The default.cir finds CIR-100
wordsEvery term appears, in any order and any column.acme london finds a row with one in each
fuzzyThe characters appear in order, not necessarily together.crc finds CIR-200 Manchester
regexA regular expression, case-insensitive.^CIR-[12]

Matching is against one cached text blob per row, built from the columns the viewer is permitted to see. Hidden and unreadable columns are excluded, so the row count cannot become an oracle for a value behind them.

An unfinished regular expression (foo( on the way to foo(bar)) falls back to a literal search rather than matching nothing, so the grid does not blank on every open bracket. fuzzy does not reorder rows: ranking results would fight the sort the user chose.

Units of your own

Twenty-six unit systems ship: length, mass, pressure, data, bitrate, angle, temperature, flow and the rest. A family of your own takes three things, and all three are yours to set: the symbols, where the symbol sits relative to the number, and how the rungs relate to each other.

import { registerUnitSystem, defineUnit, createUnitType } from '@toclocoinc/lattice-grid';

// `factor` is how many base units one of these is. Exactly one must be 1.
registerUnitSystem('distance', [
  defineUnit('mm', 0.001, ['millimetre', 'millimetres']),
  defineUnit('cm', 0.01,  ['centimetre', 'centimetres']),
  defineUnit('m',  1,     ['metre', 'metres']),
  defineUnit('km', 1000,  ['kilometre', 'kilometres']),
]);

createGrid(el, {
  dataTypes: {
    distance: createUnitType({ system: 'distance', unit: 'm', display: 'auto' }),
  },
  columns: [{ field: 'span', type: 'distance' }],
});

The factor values are the relationship between the rungs: there is no separate ladder to declare, and no ordering to get right, because the ladder is sorted by factor at load. A hand-ordered list of thirty units is one transposition away from an auto display that walks backwards, and that mistake is invisible in review.

OptionDescription
systemA built-in system, or one registered with registerUnitSystem.
unitWhat the column stores. It need not be the system's base: the same ladder with unit: 'km' stores kilometres, and 50mm typed in becomes 0.00005.
display'auto' walks the ladder for the most readable rung, or name a symbol to fix it.
placement'prefix' puts the symbol in front: $1,200, and applies to input as well as display. Defaults to a suffix.
decimalsFixed fraction digits; or minDecimals / maxDecimals, or significantFigures.
localeSeparators and grouping. Follows the grid's locale when unset.

A unit given { auto: false } stays off the display: 'auto' ladder while remaining accepted on input and available as an explicit display. That is how imperial units sit beside metric ones without an auto readout jumping between the two.

The stored value is always a plain number in the column's own unit. Sorting, filtering, grouping, totals and the pivot all read that number and never the text, which is why 250mm sorts below 1.5 cm correctly rather than 1 sorting before 9. registerUnitSystem is global and throws on a duplicate name, so register each system once at startup rather than inside a component that may mount twice.

Compound display: 5 ft 11 in, 1 h 23 m

compound renders one stored number across an ordered subset of the system's units. It is display and parse only: the value stays a single base-unit number, so sort, filter, group and total are the same arithmetic they always were. The order is free - the units are sorted largest to smallest - and the smallest one carries any remainder. Parsing sums the parts, so a paste of 5 ft 11 in round-trips, and a single 71 in or a bare 6 still work.

const { formatUnit, parseUnit } = await import('../packages/core/src/columns/types/unit.js');

// A height column stored in metres, displayed as feet and inches.
const cfg = { system: 'length', unit: 'm', compound: ['ft', 'in'], locale: 'en-GB' };

const shown = formatUnit(1.8034, cfg);       // across the two units
const stored = parseUnit('5 ft 11 in', cfg); // summed back to metres
const stable = formatUnit(parseUnit(shown, cfg), cfg) === shown; // round-trips

return `${shown} | ${stored} | ${stable}`;

The compound units need not include the stored unit, and any unit of the system is accepted on input: 71 in pasted into a feet-and-inches column is still 71 inches. Excel export and display: 'auto' share a rule here - a column of mixed-scale text is not summable in a spreadsheet, so the export uses the raw base number on the configured unit. The compound cell editor (mid-value keystrokes, roll-over between feet and inches, caret behaviour at a boundary) is a separate, later piece; this is the read-and-paste half.

Currency: an amount and a code

Currency is a real type, not a display format. Every other unit multiplies by a factor fixed at load; a currency's “factor” is an exchange rate that moves, so it never joins the unit factory. A value is an amount and a code - { amount: 10, code: 'USD' } is a different value from { amount: 10, code: 'EUR' }, and the code rides on every cell. The grid ships and fetches no rates: the caller supplies a rate source, and a rate that is needed but absent is surfaced loudly, never as zero. A footer refuses to add unlike currencies unless a display currency and rates reconcile every value, the same stance temperature takes for refusing a meaningless sum.

const { createCurrencyType, parseMoney, formatMoney, convertMoney, rateFunction, MISSING_RATE } =
  await import('../packages/core/src/columns/types/currency.js');

// The caller owns the rates; the grid ships none. A missing one is loud, never zero.
const rates = { USD: 1, EUR: 0.92 };
const money = createCurrencyType({
  code: 'USD', display: 'EUR', rates, rateBase: 'USD', decimals: 2,
  nullDisplay: ' - ', missingRate: 'no rate', excel: '€#,##0.00', codes: ['USD', 'EUR'],
});

const rate = rateFunction(rates, 'USD');
const tenInEur = convertMoney(parseMoney('$10', { code: 'USD' }), 'EUR', rate);
const loud = formatMoney({ amount: 5, code: 'XYZ' }, money.currencyConfig).startsWith('no rate');
const marker = MISSING_RATE.length > 0;

return `${tenInEur.toFixed(2)}|${loud}|${marker}`;
OptionDescription
codeThe default currency code for a bare numeric input. A number with its own symbol or code keeps that code.
displayThe currency to render and total in. Omit to keep each cell in its own currency.
ratesThe caller's rate source: a (from, to) => rate | null function, or a table of rates per unit of a common base.
rateBaseThe code a rate table is denominated in. The cross rate is base-independent, so this documents the table's denomination for the reader.
missingRateThe loud marker rendered when a needed rate is absent. Defaults to MISSING_RATE.
decimalsFixed fraction digits; omit for the currency's own convention.
nullDisplayText shown for an empty cell.
excelAn Excel number-format override for export.
codesThe code list the currency editor's picker offers.

The stored value is always the amount and its own code. Sort, filter, group, copy and Excel export all read the underlying amount - converted to the display currency when rates allow, so £5 and $6 order by real value. Five ready-made types ship (currency, usd, eur, gbp, jpy); a mixed-currency column adds display and rates through createCurrencyType.

The statistic block

createStat draws the tile a dashboard opens with: a label, a value, its change against a baseline, and a line saying what the comparison was. Two things make it worth using rather than writing. It reads the grid, so it cannot disagree with the table beneath it, a tile saying £4.2M above a table filtered to £1.8M is worse than no tile, and that is what a hand-built tile does the first time somebody adds a filter. And it formats through the column's own type: a stat over a seconds column reads 42.1 ms, over a money column with an auto ladder £1.2M, with nothing declared.

import { createStat } from '@toclocoinc/lattice-grid';

createStat({
  grid, container: '#mrr',
  title: 'Monthly recurring revenue',
  of: 'mrr', fn: 'sum',
  baseline: lastMonth,
  footer: 'vs. last month',
});
KeyTypeDescription
gridGridThe grid to read.
containerElement | stringRequired. An element, or a selector resolved against the grid's document.
titlestringThe label above the value. Hidden when absent rather than left blank.
ofstringThe column to reduce. Omit for count.
fnTotalNameAny of the totals-row kernels: sum, avg, median, p95, distinct, gini and the rest. sum by default.
showstringReport this column from the row holding the extreme, rather than the extreme itself: { of: 'sales', fn: 'max', show: 'rep' } is the name of the best rep. Needs min or max; no single row holds an average, so any other reduction is refused with a warning.
valueunknown | fnA literal value (numeric or otherwise) or a function of the grid, instead of a reduction.
footerstring | fnText under the value, or a function of it.
baselinenumber | fnWhat the value is compared against. A zero baseline reports the absolute change and no percentage, because “up infinity per cent” is not a reading anyone can act on.
goodWhen'up' | 'down' | 'neither' StatGoodDirectionWhether a rise is good news, which decides the colour. up by default. Revenue up is green and error rate up is red; a tile that paints every rise green is misleading on half a dashboard.
scope'filtered' | 'all' | 'selected' StatFollowScopeWhich rows feed the value. filtered by default; all for a tile that is deliberately a constant, such as the denominator a filtered number is a share of.
livebooleanfalse stops the tile following the grid. refresh() still works, so a caller can drive it.
format(value, grid) => stringOverride the formatting the column's type would apply.
emptystringShown when there is no value. An em dash by default.
decimalsnumberFraction digits for a value whose reduction changed the unit. 2 by default.

The column's formatter is borrowed only where the reduction leaves the unit alone. A Gini coefficient over a money column is a ratio between 0 and 1, and rendering it as $0.34 says it is thirty-four cents; counts, ratios and variances, which are in units squared: fall back to a plain number.

Returns { element, value, refresh, destroy }. A misconfigured tile returns an inert handle rather than throwing, so a dashboard with one bad tile still renders the other eleven.

In-cell charts

Eleven chart renderers for a cell. Each is a single SVG whose path data is the only thing a repaint writes, so they cost the same as any other cell as rows recycle.

NameShowsReads
lineTrend across a series.An array
areaTrend, with the area beneath filled.An array
columnA bar per point, drawn from zero.An array
winlossOne equal mark per point, up or down.An array
pieHow a set of numbers divides.An array
donutThe same, with a hole.An array
bulletOne measure against a target, over bands.A number
stackedHow one row's total divides, across the cell.An array
rangeThe span a set of values covers, middle marked.An array
gaugeOne value as a dial.A number
deltaDirection and movement over a sampling interval.A number
{ id: 'trend',  field: 'readings', cell: 'line' }
{ id: 'spend',  field: 'monthly',  cell: { render: 'column', props: { min: 0, max: 100 } } }
{ 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 } } }

// When the series lives on another property than the cell's value.
{ id: 'trend', field: 'latest', cell: { render: 'line', props: { series: 'readings' } } }
PropApplies toDescription
seriessparklinesProperty name holding the array, when it is not the cell's value.
min / maxallPin the scale so several columns compare like for like.
labelallfalse hides the number beside the chart.
markerline, areafalse hides the dot on the last point.
holedonutInner radius as a fraction, default 0.55.
targetbulletDraws the target marker.
bandsbulletEdges of the qualitative bands, e.g. [60, 85].
intervaldeltaMilliseconds between samples. Default 1000.
modedelta'change' (default) or 'against'.
againstdeltaProperty to compare with in against mode.
showdelta'both', 'arrow' or 'delta'.

Entries that are not numbers are gaps rather than zeroes: a line breaks across them and a bar is omitted. Pin min and max when comparing columns, a sparkline scaled to its own data fills its cell whatever the magnitude.

The chart is aria-hidden and the cell carries a text summary, so a screen reader is told "12 points, 9 to 20, ending 18" rather than each value in turn.

Formulas

A leading = in a numeric cell is a formula. The grid stores what it comes to.

=5 + 5
=quantity * unitPrice
=[Unit Price] * 1.2
=ROUND(quantity * unitPrice, 2)
=IF(quantity > 10, "bulk", "single")
=SUM(readings)              // an array property on the row

References name columns of the same row, not cells, a grid sorts, filters, groups and pages, so A1 would mean a different row from one moment to the next. Matching is on field or title, ignoring case and spacing; bracket a name that contains spaces. A property with no column of its own is reachable too.

GroupFunctions
MathsSUM, AVERAGE/AVG, MIN, MAX, COUNT, PRODUCT, ABS, SQRT, POWER, MOD
RoundingROUND, ROUNDUP, ROUNDDOWN, FLOOR, CEILING
LogicIF, AND, OR, NOT, COALESCE
TextCONCAT, LEN, UPPER, LOWER, TRIM, LEFT, RIGHT
StatisticsMEDIAN, PERCENTILE, QUARTILE1, QUARTILE3, IQR, STDEV, STDEVP, VAR, VARP, COUNTDISTINCT

The statistical functions use R type 7 quantiles, the same definition as the totals row, grid.statistics and the distribution formatting rules, so the four never disagree about what a median is. PERCENTILE reads 90 and 0.9 as the same request. Over an empty set they return a number rather than null, because a formula is arithmetic and has to keep composing.

Operators + - * / ^ with parentheses, comparison for IF, and postfix %. ^ is right-associative and unary minus binds tighter than it, so -2^2 is 4: Excel's answer rather than mathematics'.

// Your own functions, on top of the built-in library.
createGrid(el, {
  formulaFunctions: {
    MARGIN: ([revenue, cost]) => (revenue - cost) / revenue,
  },
});

The result is stored, not the expression. A formula is a way of entering a value: like 1,200, (50) or 12%, and it commits as one undo step with the column's own validation. Persisting a formula and recalculating it when a dependency changes is a separate feature; referencesOf() is exported for anyone building it.

No eval, no new Function. A formula is text a user typed, so evaluating it with the JavaScript engine would let anyone who can edit a cell run code in your page. It is a hand-written parser and the only callable things are the functions above.

Bare arithmetic is not a formula. 2-1 is a plausible product code and 1/2 a plausible date, so both are refused rather than guessed at. Declare a formula with =.

Your own menu items and buttons

The cell menu's function form is handed the cell that was clicked and the built-in items, so adding one entry does not mean reproducing the other thirteen.

createGrid(el, {
  contextMenu: (params, defaults) => [
    ...defaults,
    { separator: true },
    {
      name: `Open ${params.value} in CRM`,
      action: (ctx) => open(`/crm/${ctx.data.accountId}`),
    },
  ],
});

params and the action's argument carry the same cell: { key, colId, value, row, data, column, index, grid }, where data is your original row object. Return the array you want shown: add, remove, reorder or replace. Returning an empty array suppresses the menu; returning nothing at all leaves the defaults alone, so a missing return cannot silently delete the menu.

The same option is accepted on a column definition, so a column's menu is declared where the column is rather than as one more branch inside a single grid-level callback. It takes the same shapes plus a bare array for the common “these items here too” case: boolean | MenuItem[] | (params, defaults) => items.

createGrid(el, {
  columns: [
    { field: 'owner', contextMenu: [{ name: 'Reassign', action: reassign }] },   // appended after the grid's items
    { field: 'amount', contextMenu: (p, defaults) => [...defaults, { name: 'Reprice', action: reprice }] },
    { field: 'ref', contextMenu: () => [{ name: 'Copy reference', action: copyRef }] },   // only this: ignore defaults
    { field: 'nationalId', contextMenu: false },   // no menu on this column, others unaffected
  ],
});

The three levels compose as a chain: built-in defaults, then the grid-level contextMenu, then the column's - each handed the previous result as its defaults, so a column adding one item never restates the built-ins. The array form chains too: contextMenu: [items] on a column is exactly contextMenu: (p, defaults) => [...defaults, ...items], so the built-ins and every grid-level item stay and the column's items follow them, in the order written. To replace a column's menu instead, use the function form and ignore defaults: contextMenu: () => items. (Earlier releases let an array replace the grid-level items; the two forms now agree.) Suppression follows the same order and the more specific level wins: false on a column is a statement about that column alone. The reverse holds too, and is worth knowing before you rely on grid-level contextMenu: false as a safety property: a column that declares its own contextMenu opens one anyway. Grid-level false is a default, not a lock - it is what makes “no menu anywhere except here” expressible. On a right-click inside a multi-column selection the clicked column's menu is the one that opens - not the intersection, which loses items, and not the union, which offers actions wrong for most of the selection. A group row, pivot group row or full-width row belongs to no column, so the chain has one link fewer and the grid-level menu stands. The keyboard routes (Shift+F10 and the Context Menu key) honour the column exactly as the pointer does. See the guide for the full table of combinations.

The empty tail of a row is the row's. When the columns do not fill the grid's width, each row has an empty area to the right of the last column. A right-click there opens the grid's menu for that row, never the browser's, exactly as a right-click on a group row does: there is no column under the pointer, so the column link is missing from the chain and the grid-level menu stands, and cell:contextmenu (and so a builder's params) carries colId: null, column: undefined and value: undefined with the row, key and index filled in. The built-in items that act on a cell - Paste, Clear, Fill down, Edit cell - are not offered there (there is no cell for them to act on); the row and grid items are. A builder that reads params.column should expect it to be absent there. The area below the last row belongs to no row and keeps the browser's menu. On 1.54 and earlier a right-click in the tail fell through to the browser's menu, which looked as though the grid had none; on those versions give one column layout: { flex: 1 } so the cells reach the edge and there is no tail to click.

Expand all and Collapse all are built-in row/grid items: present, in this order, on the row context menu and on every column's header menu (the 3-dot button and a right-click on the heading) whenever the grid is grouped, hidden rather than disabled otherwise. Each drives the public grid.rows.expandAll() / collapseAll(), is matched by its translated name like any other built-in item (catalogue keys menu.expandAll / menu.collapseAll), and passes through the same contextMenu / columnMenu chain above.

columnMenu takes the same form for the header's menu: both the 3-dot button and a right-click on a heading. Its params is { colId, column, grid }. Anything of your own that you put on a column definition is on column.def, so an item can appear on some columns and not others.

createGrid(el, {
  columns: [{ field: 'jan', title: 'Jan', context: { month: 1 } }],
  columnMenu: (params, defaults) => {
    // Your own keys live 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) }];
  },
});

The rail takes host buttons the same way. A string names a built-in and an object is yours, placed where it appears in the list rather than appended after the built-ins.

createGrid(el, {
  toolPanel: {
    side: 'left',
    actions: ['undo', 'redo', {
      name: 'sync',
      title: 'Sync to the server',        // or a function, re-read on every repaint
      icon: 'restore',
      run: ({ grid, keys, cells }) => api.sync(keys),
      enabled: () => grid.history.canUndo(),
    }],
  },
});

An icon naming a sprite the registry does not have draws the blank glyph and logs a [lattice] warning once, naming the icon and how to register it - it does not fail silently as an empty, still-clickable button.

Styling and your page's CSS

Forced colours. In Windows High Contrast Mode the grid translates state that is normally a background tint into borders and system colours: selection takes the system's own selection colours, pinned regions swap their shadow for a rule, and diff states are told apart by border style rather than by hue. A colour swatch and a collaborator's presence colour keep their own colour, because there the colour is the information.

Every selector is namespaced under .lattice, so the grid cannot restyle your page. Since 1.4.0 the reverse holds too: the elements the grid builds are given a floor for the properties a host page commonly sets on a bare tag: margin, padding, border, radius, background, shadow, text transform, letter spacing, and type and colour on form controls. A rule such as section { padding: 5.5rem 0 } no longer reaches inside the grid.

No !important is involved. The reset is specificity (0,1,1); every rule that dresses a grid element is (0,2,0) or higher, and so is any rule of yours aimed at a Lattice class. Deliberate overrides work exactly as before: only bare-tag rules are shut out. The reset covers box model and decoration only, never display, position or any dimension.

Accessibility

Built to WCAG 2.2 level AA. Every operation is reachable without a pointer, including resizing and reordering a column, which have key bindings and column-menu items rather than depending on a drag. The grid reports itself as grid or treegrid following its configuration; rows and cells carry their position in the dataset rather than in the rendered window, so a reader on row 500,000 is told so; and rows in a hierarchy carry their position among their siblings, which a reader cannot count for itself when most of a branch was never rendered.

Focus is real focus rather than aria-activedescendant, and survives row recycling. Tabbing into a grid shows a focus ring around the grid at once; the first arrow key moves focus, and the ring, to a cell, and from then on Tab returns to that cell. Sorting, filtering, selection, grouping, expanding, paging, undo, paste and a refused edit are all announced. In Windows High Contrast Mode state is translated into borders and system colours instead of tints. No information is carried by hue alone.

The full keyboard map, the screen reader support statement and the known limits, including the drag-only pivot zones, are in the guide.

Built-in names

Every registry accepts a custom entry under the same name, which then wins over ours.

Data types

Seven built-in, and seventy-four in the extended catalogue. Inference only ever reaches the built-in names: the candidates are tried in registration order, and every string settles on text and every number on number before an extended type is reached. So a column asks for an extended type by name.

textnumberbooleandatedateStringlookupobject
timedatetimetimestampduration ipv4cidripv6 jsonsecret hexhex8hex16hex32 binarybinary8octaldecibeldecibelAmplitude bytesmegabytesgigabytes bitrategigabits metresmillimetreskilometres gramskilogramstonnes secondsmillisecondshours speedkphmphknots accelerationareahectares volumecubicMetres energykilowattHours powerkilowattsforce pressurebarpsi torquedensity flowlitresPerMinute radiansdegrees voltagecurrentresistance capacitanceinductancecharge conductancefluxDensity luminousFluxilluminancesubstance absorbedDoseequivalentDoseradioactivity frequency luminousIntensitydoseRaterpmangularVelocityppmppbbasisPointsmolaritymassFlowtonnesPerHourviscositykinematicViscositythermalConductivityspecificHeatcelsiusfahrenheitkelvincurrencyusdeurgbpjpy

Editors

texttextareanumberdatecheckboxselectmultiSelect timedatetimedurationipaddresspasswordcode unittemperaturecurrencyradixsliderratingsegmented treeSelectobjectPickericonPickercolour

Cell renderers

groupcheckboxprogresslinkpilliconskeletonratingcolourqrcode

Filters and aggregations

textnumberdatesetmultiadvanced
summinmaxavgcountcountValuesfirstlast

Icons

Inline SVG sprites, overridable by name through registerIcon(name, def).

chevronRight chevronDown chevronUp chevronLeft check dash close plus minus info success warning danger clock lock link external filter pause play chart palette undo redo columns download restore spreadsheet print maximise minimise views search pencil trash share pin sortAsc sortDesc menu drag star heart circleFilled square bolt flag arrow highlight thumbUp eye eyeOff copy present blank

Filter grammar

The condition tree is a published wire protocol, not an internal shape. It serialises into saved state and travels to a remote source unchanged.

grid.filters.set({
  op: 'and',
  conditions: [
    { col: 'region', op: 'eq', value: 'EMEA' },
    { col: 'capacity', op: 'between', value: [100, 500], bounds: '[)' },
    { op: 'not', conditions: [
      { col: 'status', op: 'in', value: ['closed'] },
    ] },
  ],
});
GroupOperators
Equalityeq, ne
Orderinglt, lte, gt, gte
Rangesbetween, notBetween, with bounds of '[]', '[)', '(]' or '()'
Setsin, notIn
Textcontains, notContains, startsWith, endsWith, matches
Blanknessblank, notBlank
Multi-valuecontainsAny, containsAll, containsNone, for cells holding an array of ids
Groupingand, or, not

An operator outside this list is refused, not applied. filters.set() and state.apply() drop a condition whose op is not one of the operators above rather than installing it - it never reaches filters.get() and the grid is left exactly as filtered as it was before. A [lattice] warning names the operator received and the operators valid for that column's type. In a compound filter only the offending leaf is dropped; every other condition still applies.

One filter, not two. A condition set from a header popup, from the tool panel, or through grid.filters.set() all merge into the same tree. Reading grid.filters.get() always gives the whole truth.

Host predicates: where

Some filters cannot be written as a condition, because what they test is not in any column: whether this user may see the row, whether you hold a rate for its currency, whether it is in the set your last API call returned. Register those as named predicates.

grid.filters.where('visibleToMe', row => row.owner === me);
grid.filters.where('rateKnown', row => rates.has(row.ccy), { deps: ['ccy'], pinned: true });
grid.filters.where('visibleToMe', null);   // remove
grid.filters.where();                      // the registered names
grid.filters.reapply('rateKnown');         // re-run one
grid.filters.reapply();                    // re-run all

Registering is activating. There is no "a filter is present" flag to keep in step, because that flag is the thing that goes wrong: it is a second piece of state describing the first, and when the two disagree the grid either filters while reporting that it is not, or reports a filter while every row passes. A predicate is in force from the moment it is registered until it is removed.

Several are in force at once under their own names, ANDed with each other and with the condition tree; removing one leaves the rest alone. The predicate is handed the data row, the same shape DerivedSourceConfig.where receives.

OptionTypeWhat it does
depsstring[]The columns the predicate reads, in the same spirit as value.deps on a computed column. Declared, the verdict is cached per row and re-run only when one of these columns changes on that row. Omitted, the predicate is treated as reading the whole row and runs on every pass - never stale, and never skipped either.
pinnedbooleanSurvive filters.clear(). For row-level permissions and tenant scoping, where a "clear filters" button must never widen what the user can see.
conditionFilterSetA declarative twin, pushed to the source while the function stays as the residual. It must be implied by the predicate: the grid ANDs both, so a twin wider than the function costs only time, while one narrower than it hides rows the function would have kept.

Coming from AG Grid's external filter? The three pieces map onto two. isExternalFilterPresent() disappears - registration is presence. doesExternalFilterPass(node) becomes the named predicate you pass to where. onFilterChanged() becomes either deps, when what changed is a column the grid can watch, or reapply(name?), when it is something the grid cannot see at all - a rate table arriving late, a permission refresh.

A predicate runs where the whole dataset is. Memory, stream and derived sources hold every row, so the function runs across all of them and the counts it produces are whole-dataset counts. The paged and remote sources hold only what they fetched, and what reaches them is the condition tree rather than the function: a predicate on either of those narrows nothing by itself, and the grid warns once when you register it, naming the predicate and the source kind.

A pushdown source is the exception, up to a point. It can fetch the whole matching set and run the function over it, so it does - while that set is under whereRowLimit (default 50,000 rows). At or past the limit, or when the adapter reports no row total, it refuses: the predicate is not applied, the rows it would exclude stay on screen, and a warning names the adapter and the way out. Honouring it past that point would silently turn a windowed grid into a whole-dataset download, which is the thing a pushdown source exists to avoid.

The twin is the route that always works. Give the predicate a condition twin - it is ANDed into the tree the source is sent, so the engine narrows the fetch itself, at any size, and the grid stays silent because that case genuinely works. That is the supported route on a server-delegated or pushdown source.

Only names are state. filters.get() still returns exactly what the user set. state.get() carries where: string[] - the names in force - because a predicate is your code and cannot be serialised into a saved view or restored from one. state.apply() naming a predicate you have not registered reports the skip rather than installing anything, and never removes a predicate a saved view did not name.

A pinned permission filter and a "my items" toggle on one grid, executed on every build:

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const me = 'ana';
const grid = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ field: 'team' }, { field: 'owner' }, { field: 'ccy' }],
  rows: [
    { id: '1', team: 'eu', owner: 'ana', ccy: 'USD' },
    { id: '2', team: 'us', owner: 'ana', ccy: 'USD' },
    { id: '3', team: 'eu', owner: 'bo', ccy: 'ZWL' },
  ],
});

// Row-level permission. Pinned, so "clear filters" cannot widen it, and it
// carries a declarative twin a server can push.
grid.filters.where('teamVisible', (row) => row.team === 'eu', {
  pinned: true,
  condition: { col: 'team', op: 'eq', value: 'eu' },
});
// An ordinary "my items" toggle, re-run only when `owner` changes on a row.
grid.filters.where('myItems', (row) => row.owner === me, { deps: ['owner'] });

const both = grid.rows.count();          // permission AND my items
grid.filters.where('myItems', null);      // toggle off
const afterToggleOff = grid.rows.count();
grid.filters.clear();                    // the pinned one survives
const afterClear = grid.rows.count();
const stillOn = grid.filters.where().join(',');
const inState = grid.state.get().where.join(',');
grid.destroy();

return `${both}|${afterToggleOff}|${afterClear}|${stillOn}|${inState}`;

A configuration, executed

This block runs on every build. If a key here stopped being honoured, or was renamed, the build would fail rather than the documentation quietly going stale.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

// Every one of these is a documented configuration key, set together so the
// example proves they are accepted and honoured rather than merely spelled.
const grid = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ id: 'name', field: 'name' }, { id: 'size', field: 'size', type: 'number' }],
  rows: [{ id: '1', name: 'a', size: 3 }, { id: '2', name: 'b', size: 1 }],
  rowHeight: 32, headerHeight: 40, overscan: 8, autoHeight: false,
  showHeader: true, density: 'compact', theme: 'light',
  locale: 'en-GB', timeZone: 'UTC', title: 'Readings',
  gridLines: 'both', cornerRadius: 4, stripedRows: false, targetSize: 'default',
  sampleSize: 100, quickFilterText: '', maximise: false,
  shortcuts: true, rowReorder: false,
  stickyGroupHeaders: true, groupFooter: false,
  totalFilteredOnly: false, showTotalInHeader: false,
  aggregateChooser: false,
  allowUnsafeTemplates: false, useWorker: false,
  sharedMemory: false, workerThreshold: 100000,
  columnVirtualisationAbove: 40, showColumnFunctions: false,
});

const n = grid.rows.count();
grid.destroy();
return n;

Writing direction and the two alignment vocabularies, executed

direction is a recognised configuration key, and a column's resolved align keeps the spelling it was given: left/right are physical edges, start/end are logical and mirror in a right-to-left grid. A number column with no align of its own still defaults to the logical end.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const grid = createHeadlessGrid({
  direction: 'rtl',
  rowKey: 'id',
  columns: [
    { id: 'l', field: 'l', align: 'left' },   // physical: the left edge in either direction
    { id: 'r', field: 'r', align: 'right' },  // physical: the right edge in either direction
    { id: 's', field: 's', align: 'start' },  // logical: the right edge in this RTL grid
    { id: 'e', field: 'e', align: 'end' },    // logical: the left edge in this RTL grid
    { id: 'n', field: 'n', type: 'number' },  // a number column defaults to the logical end
  ],
  rows: [{ id: '1', l: 'a', r: 'b', s: 'c', e: 'd', n: 1 }],
});
const resolved = ['l', 'r', 's', 'e', 'n'].map((id) => grid.columns.get(id).align);
const out = [grid.config().direction, ...resolved].join('|');
grid.destroy();
return out;

Non-blocking stream ingest, executed

A stream source loaded with ingest.useWorker on. In a browser a chunk that clears ingest.workerThreshold is columnized on a Worker so the main thread is not blocked; here in Node there is no Worker, so it columnizes in-process - the same code, the same result, which is exactly what this asserts. With retainSource:false the grid keeps only the packed columns, so rows.data() returns reconstructed objects rather than the caller's own. This makes stream (and remote) ingest non-blocking; memory and paged sources still read the caller's objects on the main thread.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

// A stream source: the producer pushes chunks of rows as it finds them.
async function* open() {
  yield { rows: [{ id: '1', city: 'Oslo', pop: 700000 }, { id: '2', city: 'Bergen', pop: 280000 }] };
  yield { rows: [{ id: '3', city: 'Tromsø', pop: 77000 }] };
}

const grid = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ id: 'city', field: 'city' }, { id: 'pop', field: 'pop', type: 'number' }],
  source: { mode: 'stream', open },
  // Columnize stream chunks off the main thread when a chunk is large enough,
  // and keep only the packed columns rather than the caller's row objects.
  ingest: { useWorker: true, workerThreshold: 1, retainSource: false },
});

// The stream loads over async frames; wait for it to finish before counting.
await new Promise((resolve) => grid.on('stream:end', resolve));
const n = grid.rows.count();
grid.destroy();
return n;

Source-layer memory reduction, executed

A memory grid loaded with ingest.dropSourceRows on. Once the column store is built, the caller's row objects are released from the source layer and the grid config, so the packed columns are the only resident copy - an order-of-magnitude drop at scale. Reads are served by reconstructing a row from the columns, so the values are unchanged; what is gone is object identity, which is why rows.data() returns a fresh object each call rather than the one you supplied.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const supplied = [{ id: '1', city: 'Oslo', pop: 700000 }, { id: '2', city: 'Bergen', pop: 280000 }];

const grid = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ id: 'city', field: 'city' }, { id: 'pop', field: 'pop', type: 'number' }],
  source: { mode: 'memory' },
  rows: supplied,
  // Release the caller's objects; keep only the packed columns.
  ingest: { dropSourceRows: true },
});

const back = grid.rows.data();
// Same values, reconstructed from the columns - but not the caller's own object.
const valuesMatch = back[0].city === 'Oslo' && back[0].pop === 700000;
const identityDropped = back[0] !== supplied[0];
grid.destroy();
return valuesMatch && identityDropped;

Events, executed

Fourteen events raised by ordinary calls, asserted on every build. An event that stopped firing, or changed name, fails here rather than in a consumer.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const grid = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ id: 'n', field: 'n' }, { id: 's', field: 's', type: 'number' }],
  rows: [{ id: '1', n: 'a', s: 3 }, { id: '2', n: 'b', s: 1 }],
});

// A wildcard handler receives one event object; `type` says which arrived.
const seen = new Set();
grid.on('*', (event) => seen.add(event.type));

grid.sort.set([{ col: 's', dir: 'asc' }]);        // sort:changed
grid.filters.set({ col: 's', op: 'gt', value: 0 }); // filter:changed
grid.columns.hide('n');                            // column:visible
grid.columns.move('n', 1);                         // column:moved
grid.columns.pin('n', 'left');                     // column:pinned
grid.columns.groupColumns(['n', 's'], { title: 'Both' }); // columngroup:changed
grid.set('rowHeight', 30);                         // config:changed
grid.rows.apply({ update: [{ id: '1', s: 9 }] });  // rows:changed, model:changed
grid.state.reset();                                // state:reset, state:changed

const raised = seen.size;
grid.destroy();
return raised;

View persistence, executed

One event carries every state change and says what caused it, so a save layer subscribes once and skips the restore-to-default - which would otherwise write the default straight back over the view the user had just abandoned.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const grid = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ id: 'n', field: 'n' }, { id: 's', field: 's', type: 'number' }],
  rows: [{ id: '1', n: 'a', s: 3 }, { id: '2', n: 'b', s: 1 }],
});

const causes = [];
let writes = 0;
grid.on('state:changed', (event) => {
  causes.push(event.cause);
  // The one cause a save must ignore: persisting a reset writes the default
  // back over the view the user has just abandoned.
  if (event.cause === 'reset') return;
  // A real host debounces, then writes grid.state.get() - which is
  // permission-sanitised, unlike the raw capture.
  writes++;
});

grid.sort.set([{ col: 's', dir: 'asc' }]);   // cause: 'user', sections: ['sort']
grid.state.apply({ version: 2, sort: [] });  // cause: 'apply', with a report
grid.state.reset();                          // cause: 'reset' - deliberately not saved

const result = `${causes.join(',')}:${writes}`;
grid.destroy();
return result;

Every grid namespace and method, executed

Reading a namespace builds it, so this proves each is reachable rather than declared and absent. The plain methods are called, not merely named.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const grid = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ id: 'n', field: 'n' }],
  rows: [{ id: '1', n: 'a' }],
});

// Every namespace the grid exposes. Reading one builds it, so this proves
// each is reachable rather than declared and absent.
const namespaces = [
  'ai', 'columns', 'comments', 'config', 'crossFilter', 'detail',
  'diagnostics', 'diff', 'edit', 'element', 'export', 'facets',
  'filters', 'form', 'formatting', 'highlight', 'history', 'licence',
  'messages', 'overlay', 'pagination', 'permissions', 'presence', 'presentation',
  'ready', 'redaction', 'rows', 'scroll', 'selection', 'sort',
  'state', 'statistics', 'timeline', 'updates', 'views', 'destroyed',
];
const present = namespaces.filter((name) => name in grid).length;

// And the plain methods, each called rather than merely typed.
grid.set('rowHeight', 30);
grid.setAll({ overscan: 6 });
grid.get('rowHeight');
grid.getVersion();
grid.getPinnedRows();
grid.setPinnedRows({ top: [], bottom: [] });
grid.rendererHost();
const off = grid.on('config:changed', () => {});
grid.once('config:changed', () => {});
grid.emit('config:changed', {}, 'api');
grid.off('config:changed', off);
typeof grid.attachRenderer;

grid.destroy();
return present;

Every core export, executed

Named and resolved against the barrel on every build. A rename or a removal fails here rather than in a consumer's build.

const core = await import('../packages/core/src/index.js');

// Every declared export of the core package, named and checked. A symbol
// that was renamed or dropped fails here, not in a consumer's build.
const declared = [
  'AR', 'AR_SA', 'CS_CZ', 'DA_DK',
  'DEFAULT_LOCALE', 'DE_DE', 'EL_GR', 'EN_GB',
  'EN_US', 'ES_ES', 'FI_FI', 'FR_CA',
  'FR_FR', 'HU_HU', 'IT_IT', 'JA_JP',
  'LOCALES', 'MESSAGE_KEYS', 'NB_NO', 'NL_NL',
  'NO_CAPABILITIES', 'PL_PL', 'PT_BR', 'RO_RO',
  'SV_SE', 'UK_UA', 'UNIT_SYSTEMS', 'applyResidual',
  'auditCatalogue', 'compileRules', 'createPushdownSource', 'createRadixType',
  'createUnitType', 'defineUnit', 'dfqlAdapter', 'duckdbAdapter',
  'evaluateFormula', 'formatList', 'formatUnit', 'getVersion',
  'graphqlAdapter', 'ingest', 'ingestSync', 'licenceInfo',
  'licenceState', 'licenseInfo', 'licenseState', 'looksLikeFormula', 'odataAdapter',
  'parseUnit', 'planQuery', 'referencesOf', 'registerModules',
  'registerUnitSystem', 'resolveCatalogue', 'resolveLocale', 'restAdapter',
  'restoreState', 'serialiseState', 'setLicence', 'setLicense',
  'version',
];

return declared.filter((name) => core[name] !== undefined).length;

The dhtmlx translation, executed

Every translated key in one definition, with the result asserted. A key the wrapper stopped honouring drops out of the output and fails the build.

const { translateColumn } = await import('../packages/modules/dhtmlx-compat/columns.js');

// Every dhtmlx column key the wrapper translates, in one definition. A key it
// stopped honouring would drop out of the result and fail this example.
const translated = translateColumn({
  id: 'size',
  header: 'Size',
  type: 'number',
  width: 120, minWidth: 80, maxWidth: 200,
  resizable: true, hidden: false, draggable: true,
  align: 'right',
  tooltip: 'How big', tooltipTemplate: null,
  template: (v) => String(v), htmlEnable: false,
  sortable: true,
  editable: true, editorType: 'datePicker',
  editorConfig: { min: 0 }, options: [],
  summary: 'sum',
});

// And the constructor keys, which translate at the grid rather than the column:
// rowKey, columns, data, autoHeight, rowHeight, headerRowHeight,
// multiselection, dragItem and rowTransfer.
return [
  translated.id,
  translated.layout.width,
  translated.cell.align,
  translated.sort.enabled,
  translated.edit.editor,
  translated.total,
].join('|');

Cell spans, executed

dhtmlx's imperative addSpan maintains a span table, and that table drives Lattice's own per-cell span functions - installed on each column's cell definition. A span declared through the shim is read back through the very functions the renderer calls.

// Aliased on import: the shim's span factory, bound to a local name.
const { createSpans: installSpans } = await import('../packages/modules/dhtmlx-compat/spans.js');

// A minimal stand-in for a grid: it just holds its columns, which is all the
// span shim touches. `createSpans` installs `cell.spanRows`/`cell.spanColumns`
// on each column by re-setting them through its own wrapper.
let columns = [{ id: 'name', cell: {} }];
const grid = {
  get: () => columns,
  set: (_, next) => { columns = next; },
};

const spans = installSpans(grid);
spans.addSpan('R0', 'name', 3, 2); // rowspan 3, colspan 2

// The column's own cell functions now read the maintained table.
const { cell } = columns[0];
const spanned = { row: { key: 'R0' }, colId: 'name' };
const plain = { row: { key: 'R1' }, colId: 'name' };
return [
  cell.spanRows(spanned),
  cell.spanColumns(spanned),
  cell.spanRows(plain),
  cell.spanColumns(plain),
].join('|');

Every module export, executed

Every shipped module’s exports, resolved against its own barrel on every build.

// Every declared export of every shipped module, resolved against its own
// barrel. A module that stopped exporting something fails here.
const modules = [
  [await import('../packages/dom/src/index.js'), [
    'ContextMenu', 'Messages', 'Registry', 'autoInit',
    'createGrid', 'createLocalViewStorage', 'createMessages', 'createStat',
    'deltaOf', 'gridElementsWithin', 'hydrateTable', 'mountPanel',
    'readTable', 'toneOf', 'LatticeGrid',
  ]],
  [await import('../packages/modules/charts/index.js'), [
    'Chart', 'PALETTE', 'SCHEMES', 'TYPES',
    'createChart', 'chartRange', 'canChartRange', 'deriveRangeSpec', 'regressionPlots',
    'registerScheme', 'resolveScheme', 'schemeNames',
    'setDefaultScheme',
  ]],
  [await import('../packages/modules/htmx/index.js'), [
    'HTML_ROW_WARNING_THRESHOLD', 'QUERY_CHANGED_EVENT', 'SCROLL_NEAR_END_EVENT', 'attach',
    'destroyWithin', 'driveInfiniteScroll', 'driveOobUpdates', 'driveServerMode',
    'ingestResponse', 'initWithin', 'queryParams', 'restoreStateWithin',
    'rowsFromFragment', 'rowsFromJson', 'saveStateWithin', 'warnIfLargeHtmlPayload',
  ]],
  [await import('../packages/modules/webcomponent/index.js'), [
    'ATTRIBUTE_CONFIG', 'EVENT_PREFIX', 'GridElementController',
    'TAG_NAME', 'createLatticeGridElement', 'defineLatticeGrid', 'domEventName',
    'observedAttributeNames',
    'createLatticeElements', 'defineLatticeElements', 'ELEMENT_TAGS',
    'createLatticeKPIElement', 'createLatticeChartElement', 'createLatticeKanbanElement',
    'createLatticeGanttElement', 'createLatticeLayoutElement', 'createLatticeTabsElement',
    'createLatticeRouterElement', 'adoptLatticeStyles', 'latticeStyleSheets',
  ]],
  [await import('../packages/modules/devtools/index.js'), [
    'CONSOLE_ACTIVATION', 'createDevtools', 'expose',
  ]],
  [await import('../packages/modules/react/index.js'), [
    'EVENT_NAMES', 'handlerName',
  ]],
  [await import('../packages/modules/vue/index.js'), [
    'createLatticeGrid', 'dashedName',
  ]],
  [await import('../packages/modules/svelte/index.js'), [
    'createLatticeAction',
  ]],
  [await import('../packages/modules/dhtmlx-compat/index.js'), [
    'Grid',
  ]],
  [await import('../packages/modules/data-router/index.js'), [
    'createDataRouter',
  ]],
  [await import('../packages/modules/mock-socket/index.js'), [
    'MockWebSocket', 'rng', 'opsFeed', 'priceFeed',
  ]],
  [await import('../packages/modules/kanban/index.js'), [
    'createKanban',
  ]],
  [await import('../packages/modules/geo-world-110m/index.js'), [
    'pack',
  ]],
];

let present = 0;
for (const [mod, names] of modules) {
  present += names.filter((name) => mod[name] !== undefined).length;
}
return present;

Nested configuration, executed

Thirteen option blocks, each key written where it belongs. Parsed and evaluated on every build, so a key that was renamed or moved shows up here.

// Placeholders for the things a real page supplies. The point of this block is
// the option names: each one below is a documented key, written where it
// belongs, so a key that was renamed or moved stops matching its interface.
const source = {}, other = {}, provider = {}, compute = {}, adapter = {};
const fetch = async () => ({ rows: [], total: 0 });
const open = () => ({ close() {} });

// A derived grid - DerivedSourceConfig
const derivedSourceConfig = { mode: 'derived', from: source, follow: 'filtered', unnest: 'tags',
    join: { with: other, on: 'id' }, where: (r) => r.size > 0,
    bucket: { of: 'taken', by: 'day' }, groupBy: 'team',
    select: { total: { of: 'size', fn: 'sum' } },
    limit: 10, limitPer: 'team', cumulative: { of: 'size', upTo: 0.8 },
    profile: 'size', orient: 'metrics', refresh: 'idle', crossFilter: true };

// Editing - EditConfig
const editConfig = { enabled: true, commit: 'blur', confirm: false, start: 'dblclick',
    enterMovesDown: true, undoDepth: 50, pendingTimeout: 2000 };

// Selection - SelectionConfig
const selectionConfig = { checkbox: true, headerCheckbox: true, checkboxOnly: true, ranges: true, fill: true, fillHandle: true };

// Tree data - TreeConfig
const treeConfig = { parentKey: 'parentId', path: 'path', orphans: 'root',
    loadChildren: async () => [], hasChildren: (r) => !!r.kids };

// Master detail - DetailConfig
const detailConfig = { isMaster: (r) => true, target: '#detail', cacheLimit: 20,
    onCreate: () => {}, placement: 'below' };

// Presence - PresenceConfig
const presenceConfig = { provider, me: { id: 'u1' }, roster: [], palette: ['#0d6b68'],
    idleMs: 30000, removeMs: 60000, throttleMs: 100, lock: true, lockMs: 5000 };

// Comments - CommentConfig
const commentConfig = { provider, debounce: 300, indexLimit: 5000, markdown: false };

// A paged source - PagedSourceConfig
const pagedSourceConfig = { mode: 'paged', pageSize: 100, maxCachedPages: 5, fetch };

// A streaming source - StreamSourceConfig
const streamSourceConfig = { mode: 'stream', open, coalesceMs: 16, maxRows: 1e6, promoteToMemoryBelow: 5e5,
    // A rolling *time* window beside the count one: keep five minutes, aged by the
    // row's own clock. Omit ageBy and rows age from when they arrived instead.
    maxAge: 5 * 60 * 1000, ageBy: 'ts' };

// A pushdown source - PushdownSourceConfig
const pushdownSourceConfig = { adapter, compute, pageSize: 200 };

// CSV export - CsvExportOptions
const csvExportOptions = { fileName: 'rows.csv', delimiter: ',', lineEnding: '\\n',
    headers: true, download: false, quote: 'minimal', processCell: (v) => v };

// Facets - ColumnFacetConfig
const columnFacetConfig = { cardinalityLimit: 200, aboveLimit: 'search', bucketFn: (v) => v,
    buckets: 20, granularity: 'day', strategy: 'even' };

// Numbers and units - NumberFormat
const numberFormat = { decimals: 2, minDecimals: 0, maxDecimals: 4, scale: 1,
    unit: 'metre', system: 'si', space: true, binary: false, format: 'auto',
    display: 'yesNo', label: 'Size', hint: 'in metres' };

// How rows enter the store - IngestConfig
const ingestConfig = { retainSource: false };

return [derivedSourceConfig, editConfig, selectionConfig, treeConfig, detailConfig,
  presenceConfig, commentConfig, pagedSourceConfig, streamSourceConfig,
  pushdownSourceConfig, csvExportOptions, columnFacetConfig, numberFormat, ingestConfig].length;

The remaining option names, executed

Set on a real grid and checked against its own diagnostics: an unrecognised key raises config.unknown, so a renamed or dropped option fails here.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { reportedWarnings } = await import('../packages/core/src/internal/util.js');

// Top-level configuration keys. Each is set on a real grid, and the grid is
// asked whether it recognised them: an unrecognised key raises
// `config.unknown:<key>`, so a renamed or dropped option fails right here.
const documented = [
  'ai', 'alignedGrids', 'anomalySummary', 'columnDefaults', 'columnGroups', 'columnMenu',
  'columnPresets', 'columnTagFilter', 'comments', 'components', 'context',
  'contextMenu', 'dataTypes', 'detail', 'diff', 'edit',
  'environment', 'facets', 'formatting', 'formulaFunctions', 'fullWidth',
  'grandTotalRow', 'groupPanel', 'highlightOnChange', 'historyBar', 'hostFilter', 'ingest',
  'licence',
  'pagination', 'permissions', 'pinnedBottomRows', 'pinnedTopRows', 'pipes',
  'pivot', 'presence', 'responsive', 'rowClass', 'rowForm',
  'rowStyle', 'rowTemplate', 'rowTransfer', 'selection', 'source',
  'state', 'statusBar', 'toolPanel', 'totalFns', 'totalOnlyChangedColumns',
  'tree', 'typeOptions', 'updates', 'variants', 'views',
  'workerUrl',
];

const before = reportedWarnings().length;
const config = { rowKey: 'id', columns: [{ id: 'n', field: 'n' }], rows: [] };
for (const name of documented) config[name] = undefined;

const grid = createHeadlessGrid(config);
const unknown = reportedWarnings().slice(before)
  .filter((w) => w.key.startsWith('config.unknown:'));
grid.destroy();

// These belong to nested option blocks and are exercised in the block above:
//   announce, apply, at, background, byKey, compute
//   config, count, crossFilter, destroy, group, groupSelectsChildren
//   groupSelectsFiltered, height, loaded, order, pageSizes, refresh
//   reload, render, rowLabel, sort

if (unknown.length) throw new Error(`unrecognised: ${unknown.map((w) => w.key).join(', ')}`);
return documented.length;

Every event name, executed

Each documented event is subscribed to and unsubscribed on every build. A consumer wiring a handler to a renamed event gets silence, which is indistinguishable from an event that has not fired yet - so the name is checked rather than left to be discovered.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

// Every documented event name, checked against the bus that would carry it.
// Subscribing to a name the grid does not know is the failure this catches:
// a consumer wiring a handler to a renamed event gets silence, and silence
// is indistinguishable from an event that simply has not fired yet.
const documented = [
  'cell:changed', 'cell:clicked', 'cell:confirmed', 'cell:conflict',
  'cell:contextmenu', 'cell:dblclicked', 'cell:edit:end', 'cell:edit:start',
  'cell:mouseover', 'cell:mouseout', 'cell:mousedown', 'cell:mouseup',
  'cell:pending', 'cell:reverted', 'clipboard:copy', 'column:filter:open', 'column:profile:open', 'column:grouped',
  'column:menu:open', 'column:pivoted', 'column:resized', 'columns:changed',
  'columns:tagged', 'comment:added', 'comment:deleted', 'comment:edited',
  'comment:failed', 'comment:indexLoaded', 'comment:resolved', 'comment:threadClosed',
  'comment:threadOpened', 'comment:unresolved', 'destroy', 'detail:toggled',
  'diff:changed', 'diff:swapped', 'export:progress', 'facet:computed',
  'facet:expanded', 'facet:failed', 'facet:filtered', 'form:closed',
  'form:error', 'form:opened', 'form:saved', 'formatting:changed',
  'group:toggled', 'header:contextmenu', 'highlight:changed', 'find:changed', 'history:applied',
  'history:changed', 'licence:changed', 'page:changed', 'permissions:changed',
  'presence:failed', 'presence:joined', 'presence:left', 'presence:lockRefused',
  'presence:published', 'presence:updated', 'presentation:captured', 'presentation:changed',
  'presentation:ended', 'presentation:scale', 'presentation:spotlight', 'presentation:started',
  'presentation:view', 'range:changed', 'ready', 'redaction:changed',
  'render:done', 'render:first', 'row:clicked', 'row:copied',
  'row:dblclicked', 'row:edit:end', 'row:edit:start', 'row:moved',
  'row:pending', 'row:confirmed', 'row:reverted', 'row:conflict',
  'row:received', 'row:sent', 'rows:deferred', 'rows:paused',
  'rows:queued', 'rows:resumed', 'scroll', 'scroll:end',
  'selection:changed', 'size:changed', 'source:error', 'stream:chunk',
  'stream:end', 'stream:evicted', 'timeline:attached', 'timeline:detached',
  'timeline:seek', 'timeline:seeking', 'toolpanel:focus', 'tree:loadAborted',
  'tree:loadFailed', 'tree:loaded', 'tree:loading', 'view:applied',
  'view:default', 'view:removed', 'view:renamed', 'view:saved',
  'views:changed',
  'rowDrag:started', 'rowDrag:moved', 'rowDrag:left', 'rowDrag:ended',
];

const grid = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ id: 'n', field: 'n' }],
  rows: [{ id: '1', n: 'a' }],
});

// `on` returns its own unsubscribe, so a name it accepts round-trips.
let wired = 0;
for (const name of documented) {
  const off = grid.on(name, () => {});
  if (typeof off === 'function') { off(); wired += 1; }
}

grid.destroy();
return wired;

Module and utility APIs

Everything else the package declares. Each entry is public: it is declared in the type definitions, which is what makes it a promise.

Locale and messages

NameSignatureDescription
DEFAULT_LOCALEstringThe locale used when none is configured and none can be read from the page.
LOCALESRecord<string, object>Every built-in catalogue, by locale name.
resolveLocale(configured?, declared?, fallback?) => stringSettle which locale applies: what you configured, then what the page declares, then the fallback.
createMessages(opts?) => MessagesBuild a message catalogue. A partial set lays over the built-in British English one.
MessagesclassThe catalogue itself. t(key, params) resolves one message; configure() replaces the set at runtime.
formatList(items, locale?, type?) => stringJoin a list the way the locale does, as a conjunction or a disjunction.

Formulas and units

NameSignatureDescription
evaluateFormula(text, params?) => FormulaResultEvaluate one expression. The same closed language the grid uses: no eval, no host access.
looksLikeFormula(text) => booleanWhether a pasted or typed value should be treated as a formula.
parseUnit(text, opts?) => number | nullRead a value with a unit on it back to a number in the base unit. null when it will not parse.
formatUnit(value, opts?) => stringThe inverse: render a base-unit number on the ladder the column asked for.
UNIT_SYSTEMSRecord<string, UnitDescriptor[]>Every registered unit system, by name.

Statistic tiles

NameSignatureDescription
deltaOf(value, baseline) => objectThe change between a value and its baseline, as a tile shows it.
toneOf(direction, goodWhen) => stringWhich way to colour a change, given whether a rise is good news.

Licensing and modules

NameSignatureDescription
licenceInfo() => LicenceInfoWhat the current key says: product, holder, expiry. licenseInfo is the same function under the American spelling.
licenceState() => LicenceInfoWhether the current host is licensed, and why not if it is not. licenseState is its alias.
registerModules(modules, opts?) => voidInstall optional modules once, for every grid on the page.
CONSOLE_ACTIVATIONstringThe console incantation that activates a trial key.

Ingesting rows, and menus

NameSignatureDescription
ingestSync(rows, plan?, opts?) => objectBuild a column store and an inferred schema from raw rows, synchronously. The result records why each column got the type it did.
ContextMenuclassThe menu the grid opens on right-click, reusable for a menu of your own. open(p) places it.

The charts module

NameSignatureDescription
TYPESreadonly ChartType[]Every chart type name createChart accepts.
SCHEMESRecord<string, readonly string[]>The built-in colour schemes, by name.
PALETTEreadonly string[]The default series colours.
registerScheme(name, colours) => voidAdd a colour scheme, or replace one of ours under the same name.
resolveScheme(spec?) => objectSettle which scheme a chart will draw with.
setDefaultScheme(name) => voidChange the scheme every chart uses unless it asks for another.
schemeNames() => string[]Every scheme name available, built-in and registered.

The framework adapters

React, Vue and Svelte build their public surface from the same list, so an event becomes a prop or an emit without either side keeping a second copy.

NameSignatureDescription
EVENT_NAMESreadonly string[]Every event the grid declares. Mirrors the EventName union, and the build fails if the two diverge.
handlerName(event) => stringThe React prop for an event: cell:changed becomes onCellChanged.
dashedName(event) => stringThe Vue and Svelte listener name: cell:edit:start becomes cell-edit-start.

The web component

NameSignatureDescription
defineLatticeGrid(tag?) => voidRegister <lattice-grid>, or your own tag name.
createLatticeGridElement(deps?) => classBuild the element class without registering it, for a custom registry.
GridElementControllerclassThe controller behind the element, if you are wrapping it yourself.
TAG_NAMEstringThe default tag, lattice-grid.
EVENT_PREFIXstringWhat DOM events are prefixed with.
ATTRIBUTE_CONFIGReadonly<Record<string, unknown>>Which attributes map to which configuration keys.
observedAttributeNames() => string[]The attributes the element reacts to.
domEventName(event) => stringThe DOM event name a grid event is dispatched under.
defineLatticeElements(opts?) => Record<string, class>Register one element per viewer from the factories you pass, under prefix (lattice- by default). Safe to call twice. See Web Components.
createLatticeElements(deps?) => Record<string, class>The classes, without registering them.
ELEMENT_TAGSReadonly<Record<string, string>>The tag each viewer takes, before the prefix.
createLatticeKPIElement({ createKPI }) => classThe <lattice-kpi> class on its own.
createLatticeChartElement({ createChart }) => classThe <lattice-chart> class on its own.
createLatticeKanbanElement({ createKanban }) => classThe <lattice-kanban> class on its own.
createLatticeGanttElement({ createGantt }) => classThe <lattice-gantt> class on its own.
createLatticeLayoutElement({ createLayout }) => classThe <lattice-layout> class on its own.
createLatticeTabsElement({ createTabs, createGrid? }) => classThe <lattice-tabs> class on its own.
createLatticeRouterElement({ createDataRouter }) => classThe <lattice-router> class on its own.
adoptLatticeStyles(root, opts?) => () => voidMake a shadow root see Lattice's styles, and keep it seeing them as more are generated. Returns the undo.
latticeStyleSheets(opts?) => CSSStyleSheet[]Those stylesheets as constructable sheets, for a host managing its own adoptedStyleSheets. Shared and live; do not mutate them.

The htmx module

NameSignatureDescription
initWithin(root) => Grid[]Build every grid inside a fragment htmx just swapped in.
destroyWithin(root) => voidTear them down before the fragment goes.
gridElementsWithin(root) => Element[]The grid elements in a fragment, without building them.
rowsFromFragment(fragment) => unknown[]Read rows out of server-rendered markup.
rowsFromJson(text) => unknown[]Read rows out of a JSON payload.
ingestResponse(grid, response) => voidApply an htmx response to a grid, whichever of those two shapes it carries.
saveStateWithin(root) => voidPersist the view state of every grid in a fragment before a swap.
restoreStateWithin(root) => voidPut it back afterwards.
queryParams(grid) => Record<string, string>The grid's sort, filter and page as request parameters.
warnIfLargeHtmlPayload(rows) => voidWarn once when a server-rendered payload is large enough that JSON would serve better.
QUERY_CHANGED_EVENTstringDispatched when the grid's query changes, for htmx to trigger on.
SCROLL_NEAR_END_EVENTstringDispatched as the viewport nears the end, for infinite scroll.
HTML_ROW_WARNING_THRESHOLDnumberThe row count that warning fires at.

The devtools module

NameSignatureDescription
expose(grid, name?) => voidPut a grid on globalThis under a name, so a console session can reach it.

Windowed aggregates

The primitives behind grid.statistics.windowed, for a host that drives a live stream itself: a sliding window over timestamped values that re-reduces on demand and stamps every figure with the window it covered. Reduce over the last N ticks, the last N minutes, or the whole session.

NameSignatureDescription
openWindow(opts, now?) => WindowBuild a window from a spec: { kind: 'count', span } for the last N ticks, { kind: 'time', minutes } for the last N minutes, or { kind: 'session' } for everything since it opened.
WindowclassA sliding window. push(v, t?) adds a value, reduce(fn) returns one named aggregate stamped with the window in over, and aggregate() returns them all at once.
WINDOW_KINDSreadonly ('count' | 'time' | 'session')[]The three window kinds a caller may ask for.
const { openWindow, Window, WINDOW_KINDS } = await import('../packages/core/src/index.js');
// openWindow builds one of the three kinds; here, the last 3 ticks.
const live = openWindow({ kind: 'count', span: 3 });
for (const v of [10, 20, 30, 40]) live.push(v); // 10 is evicted; 20, 30, 40 remain
const avg = live.reduce('avg'); // (20 + 30 + 40) / 3 = 30, stamped with the window it covers
// A Window can be built directly too; a session window never evicts.
const session = new Window('session');
session.push(5);
session.push(15);
return `avg ${avg.value} over ${avg.over.size}; kinds ${WINDOW_KINDS.join('/')}; session ${session.reduce('avg').value}`;

Anomaly detection

Flag the rows that do not belong. Interpretable statistics with a written-down cut, never a black box: a robust per-column outlier score, Tukey's fences, and multivariate distance from the joint centre. These are the pure kernels behind grid.statistics.anomalies(...) and the anomalyScore/anomalyFlag shadow columns; a host can score a plain array the same way the grid scores a column.

NameSignatureDescription
ANOMALY_METHODSreadonly ('modifiedZScore' | 'iqr' | 'mahalanobis')[]The three methods a caller may ask for, named so a result can say which produced a flag.
modifiedZScores(values, opts?) => { median, mad, threshold, scores, flags, flagged }Per-row robust outlier score, 0.6745·(x − median)/MAD, flagged past threshold (default 3.5). Built on the median and MAD, so one wild reading cannot inflate the spread and hide - the masking effect that fools an ordinary z-score. A non-finite reading and a zero-MAD column yield a null score, not an invented one.
iqrFences(values, opts?) => { q1, q3, iqr, lower, upper, k } | nullTukey's fences, [Q1 − k·IQR, Q3 + k·IQR] (default k = 1.5), the same fence the box plot draws, on R type 7 quartiles.
mahalanobis(matrix, opts?) => { center, df, cutoff, singular, used, distances, squared, flags, flagged } | nullDistance of every row from the joint centre in the metric of the data's own covariance, cut at a χ² quantile (default the 0.975 point). Catches a row impossible only in combination - heavy and short - that a per-column scan misses. A row with any missing coordinate is left unplaced; a singular covariance is ridge-regularised and reported as singular rather than throwing.
ROLLING_ANOMALY_METHODSreadonly ('rollingModifiedZScore' | 'rollingIqr')[]The rolling (windowed) methods, named alongside ANOMALY_METHODS so a caller can enumerate every detector. Also accepted by grid.statistics.anomalies({ method, windowLen }).
rollingAnomalies(values, opts?) => { method, windowLen, minPeriods, threshold, k, scores, flags, flagged }Rolling (windowed) detection: judge every reading against a causal trailing window of windowLen ending at it, so a spike is caught against its recent neighbours and a drift never poisons a global baseline. rollingModifiedZScore (median + MAD) or rollingIqr (Tukey fences). With a window as long as the series the last point's score equals the static modifiedZScores one.
anomalyCondition(opts) => (rows) => false | { method, field, flagged }Build a Data Router alert condition from a detector: a (rows) => signal the router's existing router.alert(value, condition, handler) drives, so live anomaly monitoring reuses the router's partitioning, debounce and rising-edge re-arm rather than duplicating an alert engine. Reads one numeric field per row; latest: true signals only when the newest reading is the anomaly.
const { modifiedZScores, iqrFences, mahalanobis, ANOMALY_METHODS } = await import('../packages/core/src/index.js');
// Univariate: the robust modified z-score flags the 500 among steady readings,
// where the mean and standard deviation an ordinary z uses would be dragged up
// by the outlier until it no longer looked like one.
const z = modifiedZScores([20, 21, 19, 20, 21, 19, 20, 500]);
// Tukey's fences say the same, drawn the way a box plot draws them.
const fence = iqrFences([20, 21, 19, 20, 21, 19, 20, 500]);
// Multivariate: height and weight move together; the last person is tall but
// very light, so the pair is impossible even though neither number is extreme.
const hw = [];
for (let i = 0; i < 12; i++) hw.push([160 + i, 60 + i]);
hw.push([182, 45]);
const m = mahalanobis(hw); // the χ² cut flags the off-line row
return `methods ${ANOMALY_METHODS.length}; flagged ${z.flagged}; upper ${fence.upper}; joint ${m.flags[12]}`;

Rolling (windowed) detection and the Data Router bridge: judge each reading against its trailing window for live monitoring, and turn any detector into a router.alert(...) condition without a second alert engine.

const { rollingAnomalies, anomalyCondition, ROLLING_ANOMALY_METHODS } = await import('../packages/core/src/index.js');
// A steady stream that suddenly spikes. The trailing window catches the spike
// against its recent neighbours, where scoring against the whole run could let a
// long earlier drift hide it.
const stream = [10, 11, 9, 10, 11, 9, 10, 11, 9, 10, 40];
const roll = rollingAnomalies(stream, { windowLen: 6, minPeriods: 4 });
// The SAME detector, wired as a Data Router alert condition: (rows) => signal,
// exactly the predicate router.alert(value, condition, handler) already drives - // no second alert engine. `latest` fires only when the newest tick is the one out.
const condition = anomalyCondition({ field: 'temp', method: 'rollingModifiedZScore', orderBy: 't', windowLen: 6, minPeriods: 4, latest: true });
const rows = stream.map((temp, t) => ({ t, temp }));
const signal = condition(rows);
return `methods ${ROLLING_ANOMALY_METHODS.length}; spike ${roll.flags[10]}; alert ${signal ? signal.flagged.length : 0}`;

Forecasting

Project an ordered series forward, and carry a prediction band where a defensible closed form exists. Five methods behind one entry point: movingAverage and ses are flat forecasts (the trailing-window mean, the final smoothed level); holt adds a projected trend, holtWinters a projected trend and an additive seasonal; linear extrapolates an ordinary least-squares fit of the time axis. The exponential-smoothing bands are the innovations state-space forecast variances (Hyndman & Athanasopoulos) at the normal quantile; the linear and moving-average bands are the exact Student-t intervals, and linear also reports the narrower mean-response (confidence) band a trendline draws. These are the pure kernels the chart trendline overlay and the time-series grid forecast from; a host can forecast a plain array the same way.

NameSignatureDescription
FORECAST_METHODSreadonly ('movingAverage' | 'ses' | 'holt' | 'holtWinters' | 'linear')[]The five methods a caller may ask for, named so a result can say which produced it.
forecast(seq, opts?) => ForecastResult | nullForecast an ordered series opts.horizon steps ahead by opts.method (default linear), at opts.confidence (default 0.95). Accepts a plain array of numbers (index is the time axis) or {at, value} rows. A smoothing factor absent from opts (alpha/beta/gamma) is fit by minimising the in-sample one-step SSE; holtWinters needs opts.period (≥ 2) and two whole periods of data. Each point carries mean and, where a band applies, lower/upper; linear adds lowerMean/upperMean. Null when the series is too short for the method.
const { forecast, FORECAST_METHODS } = await import('../packages/core/src/index.js');
// Linear: fit the time axis, project one step, and carry the prediction band.
const lin = forecast([{ at: 1, value: 2 }, { at: 2, value: 4 }, { at: 3, value: 5 }, { at: 4, value: 4 }, { at: 5, value: 5 }], { method: 'linear', horizon: 1 });
const p = lin.points[0];
// Holt-Winters additive: a level, a trend and a two-step season, projected two
// steps ahead - the seasonal swing is carried into the forecast, not smoothed away.
const hw = forecast([10, 20, 30, 40], { method: 'holtWinters', period: 2, alpha: 0.5, beta: 0.5, gamma: 0.5, horizon: 2 });
return `methods ${FORECAST_METHODS.length}; next ${p.mean.toFixed(1)}; r2 ${lin.r2.toFixed(1)}; band ${p.lower.toFixed(2)}..${p.upper.toFixed(2)}; season ${hw.points[1].mean}`;

The same forecast off the standard stats surface: grid.statistics.forecast(colId, opts) reads the column over the filtered rows - ordered by opts.by when the time axis matters, exactly as grid.statistics.series(...) orders - and returns the same ForecastResult, so a host reaches a forecast the way it reaches grid.statistics.anomalies(...) rather than assembling the series itself.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const grid = createHeadlessGrid({
  columns: [{ field: 't', type: 'number' }, { field: 'v', type: 'number' }],
  rows: [[1, 2], [2, 4], [3, 5], [4, 4], [5, 5]].map(([t, v], i) => ({ id: String(i), t, v })),
  rowKey: 'id',
});
// Forecast the 'v' column one step ahead, ordered by 't', with the prediction band.
const f = grid.statistics.forecast('v', { by: 't', method: 'linear', horizon: 1 });
const p = f.points[0];
return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.lower > 0}`;

Type reference

Generated from the type declarations, so it always matches the release. Each surface lists its properties, its methods and the events it raises as three tables; an option or value type lists its members once.

The grid instance

The object `createGrid` returns, and the sub-APIs it exposes. Each surface lists its properties, its methods and the events it raises.

Grid

Properties
PropertyTypeDescription
rowsRowsApiThe data: reading it, changing it, walking it. (read-only)
columnsColumnsApiThe columns: order, width, visibility, grouping and pivoting. (read-only)
selectionSelectionApiWhat is selected, and the range the user has marked. (read-only)
filtersFiltersApiThe filter tree, however it was set. (read-only)
sortSortApiThe sort, in priority order. (read-only)
editEditApiEditing sessions: starting, committing and cancelling them. (read-only)
scrollScrollApiWhere the viewport is, and moving it. (read-only)
exportExportApiCSV, Excel and clipboard. (read-only)
importImportApiBringing rows in from CSV/TSV text, a file, the clipboard or a drop. (read-only)
stateStateApiEverything the user arranged, as a serialisable object. (read-only)
overlayOverlayApiThe loading, empty and error surfaces drawn over the grid. (read-only)
historyHistoryApiUndo and redo over edits and structural changes. (read-only)
viewsViewsApiSaved arrangements the user can switch between. (read-only)
diffDiffApiWhat changed against a baseline, cell by cell. (read-only)
permissionsPermissionsApiWho may see, edit and export what. (read-only)
aiAiApiA machine-readable description of the grid, for a model to read. (read-only)
messagesMessagesApiTranslation: the catalogue and the active locale. (read-only)
licenceLicenceApiLicence state, and setting a key after construction. (read-only)
paginationPaginationApiPages, where the grid is paged rather than scrolled. (read-only)
highlightHighlightApiTransient emphasis on a row, column or cell. (read-only)
findFindApiIn-grid find: locate text without filtering, and step through the matches. (read-only)
redactionRedactionApiValues hidden from view and from export. (read-only)
annotateAnnotationApiDrawing over the grid, where the module is installed. (optional)
presentationPresentationApiFull screen, scaling and chrome suppression. (read-only)
pivotViewPivotViewApiExpand and collapse the pivot presentation's axes; the state a view carries. (read-only)
updatesUpdatesApiThe live feed: pausing it, flushing it, and what it has done. (read-only)
timelineTimelineApiReplaying the changes the grid has seen. (read-only)
crossFilterCrossFilterCross-filtering, a derived grid filtering the grid it derives from. (read-only)
facetsFacetsApiHeader distributions, and the filters clicking one creates. (read-only)
detailDetailApiThe expandable panel beneath a row. (read-only)
commentsCommentsApiThreads attached to rows and cells. (read-only)
presencePresenceApiWho else is looking, and where. (read-only)
diagnosticsDiagnosticsApiWhat the grid is doing, for when it is doing it slowly. (read-only)
statisticsStatisticsApiReductions, profiles, correlations, capability and intervals. (read-only)
formattingFormattingApiFormatting a value as the grid would, outside a cell. (read-only)
validationValidationApiDeclarative column validation: why a write was refused, and clearing marks. (read-only)
maximiseMaximiseApiFull-screen control, where it is enabled. (read-only, optional)
elementHTMLElement | nullThe element you passed to `createGrid`, not the grid's own root. The grid builds its `.lattice` root *inside* that element, so `el.closest('.lattice')` never matches this, and a theme attribute set on it has no effect, the theme is read from the root within. Use `element.querySelector('.lattice')` for the grid's own root. (read-only)
destroyedbooleanWhether `destroy` has run. Every other member is inert afterwards. (read-only)
readybooleanFalse until the first render has been laid out. (read-only)
formRowFormApiThe row form. Declines when `rowForm` is not configured. (read-only)
iconsIconRegistryApiThe grid's icon registry, read-only. The same sprite set `registerIcon` writes to and every cell paints from, reachable from the grid instance so that code outside the grid bundle - an optional module drawing its own glyph, a network chart putting a `router` on a node - draws from the one registry rather than a second, empty copy of it. Register with `registerIcon` or `config.icons`, as before. (read-only)
Methods
MethodSignatureParametersReturnsDescription
capture(opts?: CaptureOptions): Promise<Blob>opts?: CaptureOptionsPromise<Blob>An image of the grid as drawn, where the module is installed. (optional)
config(): GridConfig - GridConfigThe resolved configuration, as one object.
get<K extends keyof GridConfig>(key: K): GridConfig[K]key: KGridConfig[K]One configuration value, as it stands after defaults and validation - not what was passed in. Typed by the key, so `get('rowHeight')` is a number without a cast.
set<K extends keyof GridConfig>(key: K, value: GridConfig[K]): voidkey: K
value: GridConfig[K]
voidWrite one configuration value, doing only the work that key implies. Every key is settable at runtime - there is no "initial options" versus "live options" distinction to learn - and `config:changed` follows, after the grid has rebuilt.
setAll(values: Partial<GridConfig>): voidvalues: Partial<GridConfig>voidApply several configuration changes as one update rather than several.
on(event: EventName, handler: EventHandler): Unsubscribeevent: EventName
handler: EventHandler
UnsubscribeListen. Returns the function that stops listening.
once(event: EventName, handler: EventHandler): Unsubscribeevent: EventName
handler: EventHandler
UnsubscribeListen until it fires once.
off(event: EventName, handler: EventHandler): voidevent: EventName
handler: EventHandler
voidStop listening.
emit(event: string, payload?: Record<string, unknown>): voidevent: string
payload?: Record<string, unknown>
voidRaise an event of your own on the grid's bus.
setPinnedRows(rows: unknown[], opts?: { edge?: 'top' | 'bottom' }): voidrows: unknown[]
opts?: { edge?: 'top' | 'bottom' }
voidPin rows above or below the scrolling body. The rows render through the ordinary column pipeline but are not part of the data: not counted, sorted, filtered, grouped, selectable or exported. Pass a new array rather than mutating the one you passed before: array identity is how the grid knows the pinned rows have changed.
getPinnedRows(opts?: { edge?: 'top' | 'bottom' }): unknown[]opts?: { edge?: 'top' | 'bottom' }unknown[]The objects currently pinned at one edge, as a copy.
getVersion(): string - stringThe library version.
destroy(): void - voidRelease everything: listeners, timers, workers and the DOM the grid made.
Events
EventWhenPayloadCancellable
readyThe grid has finished building and every API on it is ready to call; fires once, on the frame after `createGrid` returns.no payloadno
destroy`grid.destroy()` was called and is about to release everything, so a handler can still read the grid one last time.no payloadno
render:firstThe renderer has written its first frame into the host element.no payloadno
render:doneA render pass has finished writing cells: the row window it drew, what caused the pass, and how long each phase took.RenderDoneEventno
config:changedA configuration key was written at run time through `grid.set(key, value)` or `grid.setAll(values)`, after the grid rebuilt.ConfigChangedEventno
model:changedThe display model was rebuilt - rows reloaded, the tree re-flattened, a page fetched, a query re-run - with `reason` naming which.ModelChangedEventno
source:errorA source could not fetch what was asked of it: a page, a group's children, a tree branch, or the stream itself.SourceErrorEventno
source:totalA source that delivered its rows before counting them has finished counting; the exact total is in the payload.SourceTotalEventno
stream:chunkA streaming source applied a chunk of arriving rows.StreamChunkEventno
stream:endA streaming source reached the end of its feed; `promoted` says whether it handed over to an in-memory source.StreamEndEventno
stream:evictedA rolling-window stream dropped rows off the back of its window to stay inside its limit.StreamEvictedEventno
rowDrag:startedA row drag passed the drag threshold and began, on the grid the row was picked up in.RowDragEventno
rowDrag:movedThe pointer moved during a row drag, coalesced to one event per animation frame.RowDragEventno
rowDrag:leftThe pointer left a grid it had been dragging over; `over` names the grid just left.RowDragEventno
rowDrag:endedThe row drag ended - released anywhere, inside a grid or outside every one; `dropped` says whether it is being acted on.RowDragEventno
cell:changedA cell's value was written: by an edit commit, by a revert, or by an undo/redo step.CellChangedEventno
cell:pendingAn optimistic cell edit was sent to the transport and is awaiting the server's answer.CellPendingEventno
cell:confirmedThe server accepted a pending cell edit; `value` is what it confirmed, which may not be what was sent.CellConfirmedEventno
cell:revertedA pending cell edit was refused and the previous value put back.CellRevertedEventno
cell:conflictThe server accepted a pending cell edit but returned a row that disagrees with what the grid holds.CellConflictEventno
cell:clickedA cell was clicked (primary button, single click).CellPointerEventno
cell:dblclickedA cell was double-clicked.CellPointerEventno
cell:contextmenuA context menu was requested on a cell, by the pointer or by the keyboard's menu key.CellContextMenuEventno
cell:mouseoverThe pointer entered a cell; crossing between two children of one cell is not a re-entry.CellPointerEventno
cell:mouseoutThe pointer left a cell; crossing between two children of one cell is not a departure.CellPointerEventno
cell:mousedownA pointer button was pressed on a cell, before any click is resolved.CellPointerEventno
cell:mouseupA pointer button was released on a cell.CellPointerEventno
cell:edit:startA cell editor opened, by double-click, by Enter, or by typing into the cell.EditStartEventno
cell:edit:endA cell editor closed: committed, cancelled, or refused by validation - `valid` and `cancelled` say which.EditEndEventno
group:toggledA group row was expanded or collapsed - one group, one branch, or all of them at once.GroupToggledEventno
pivot:drillA pivot measure cell was drilled into; the payload names the row and column paths behind it.PivotDrillEventno
columngroup:changedA banded header group was formed, renamed, moved, dissolved, removed or restored from state.ColumnGroupChangedEventno
header:contextmenuA context menu was requested on a column header.HeaderContextMenuEventno
range:changedThe selected cell ranges changed.RangeChangedEventno
clipboard:copyA copy to the clipboard was attempted; `ok` says whether it reached the clipboard.ClipboardCopyEventno
page:changedThe page or the page size changed.PageChangedEventno
size:changedThe host element's box changed size, as reported by the `ResizeObserver` the grid watches it with.no payloadno
toolpanel:focusThe keyboard asked for focus to move to the tool panel (Ctrl+Alt+P).no payloadno
tree:loadingA tree branch was expanded and `tree.loadChildren` was called for it.TreeLoadingEventno
tree:loadedA tree branch's children arrived and were added.TreeLoadedEventno
tree:loadFailedA tree branch's `loadChildren` rejected; the branch is left unloaded so it can be retried.TreeLoadFailedEventno
tree:loadAbortedA tree branch was collapsed before its children arrived, so the fetch was abandoned.TreeLoadAbortedEventno
annotation:changedThe annotation overlay's marks changed: one was drawn, moved or erased, or the tool changed.AnnotationChangedEventno
shortcuts:openedThe keyboard-shortcuts overlay was opened.no payloadno
shortcuts:closedThe keyboard-shortcuts overlay was closed.no payloadno
print:beforePrint mode has been applied and the grid laid out un-virtualised, just before the print dialog.PrintEventno
print:afterThe print dialog has returned and print mode has been undone.PrintEventno
beforeColumnMoveA user column move is about to be applied; call `preventDefault(reason?)` to stop it.BeforeColumnMoveEventyes
beforeColumnResizeA user column resize is about to be applied; call `preventDefault(reason?)` to stop it.BeforeColumnResizeEventyes
beforeColumnHideA user column hide is about to be applied; call `preventDefault(reason?)` to stop it.BeforeColumnHideEventyes
beforeSelectA user selection change is about to be announced; call `preventDefault(reason?)` to snap it back.BeforeSelectEventyes
beforeRowAddA user row append is about to be sent; call `preventDefault(reason?)` to stop it.BeforeRowAddEventyes
beforeDeleteA user row delete is about to be applied; call `preventDefault(reason?)` to stop it.BeforeDeleteEventyes
beforeRowMoveA user row reorder is about to be applied; call `preventDefault(reason?)` to stop it.BeforeRowMoveEventyes
beforeGroupA user group expand or collapse is about to be applied; call `preventDefault(reason?)` to stop it.BeforeGroupEventyes
beforeRowReceiveA row dragged from another grid is about to be inserted here; call `preventDefault(reason?)` to refuse it.BeforeRowReceiveEventyes
columnMove:cancelledA `beforeColumnMove` handler vetoed the move.ColumnMoveCancelledEventno
columnResize:cancelledA `beforeColumnResize` handler vetoed the resize.ColumnResizeCancelledEventno
columnHide:cancelledA `beforeColumnHide` handler vetoed the hide.ColumnHideCancelledEventno
rowAdd:cancelledA `beforeRowAdd` handler vetoed the append.RowAddCancelledEventno
delete:cancelledA `beforeDelete` handler vetoed the delete, or the rows were gone by the time an async handler settled.DeleteCancelledEventno
rowMove:cancelledA `beforeRowMove` handler vetoed the reorder.RowMoveCancelledEventno
group:cancelledA `beforeGroup` handler vetoed the expand or collapse.GroupCancelledEventno
rowReceive:cancelledA `beforeRowReceive` handler refused the drop, or the drop went stale while an async handler was thinking.RowReceiveCancelledEventno
*Every event above, delivered to one handler; the payload is whichever event fired.GridEventno

RowsApi

Properties

No properties.

Methods
MethodSignatureParametersReturnsDescription
load(rows: unknown[]): voidrows: unknown[]voidReplace the data. Sort, filters, grouping and column layout are kept.
apply(change: RowChange): ChangeResultchange: RowChangeChangeResultApply an incremental change - `add`, `update`, `remove` - against the rows already loaded, synchronously, and return what it touched. Rows the grid could not place come back under `rejected` rather than throwing. Needs a `rowKey`: without one, updates and removals cannot be matched to a row, and it says so.
queue(change: RowChange): Promise<ChangeResult>change: RowChangePromise<ChangeResult>The same change, coalesced with everything else arriving inside the batch window (50 ms by default) into one pipeline run and one repaint. The promise resolves with the flushed result. This is the call for a live feed; an over-deep queue flushes early rather than growing.
get(index: number): Row | undefinedindex: numberRow | undefinedThe row at a display index - the index the grid draws at, so group headings, footers and the grand total are counted. `undefined` past the end.
byKey(key: string): Row | undefinedkey: stringRow | undefinedThe row with this key, wherever it sits, or `undefined` when the grid does not hold it.
count(): number - numberHow many rows the grid is displaying, including group headings, footers and the grand total, and including optimistic rows an in-flight write has added.
totalCount(): number | null - number | nullRows in the source before filtering; under pagination, across every page. `null` while a source is still counting - see {@link RowsApi.totalPending}. Reporting the rows fetched so far in that gap would show a page length as a dataset size.
totalPending(): boolean - booleanWhether an exact total is still being counted for the current query. True only between the rows arriving and the total landing, on a source that defers the count because counting reads data. It is what separates "not counted yet" from "never going to be counted": `totalCount()` is `null` for both, and only one of them is going to become a number.
matchCount(): number - numberData rows matching the filters, excluding group, footer and total rows.
coverage(): StatCoverage - StatCoverageHow much of the data a figure computed from this grid covers, so a statistic over a windowed source can say it is approximate.
data(): unknown[] - unknown[]Your own row objects, in source order, with the grid's furniture left out - group and total rows are not in the data and never appear here.
forEach(fn: (row: Row, index: number) => void): voidfn: (row: Row, index: number) => voidvoidVisit every display row in order: filtered, sorted and grouped as drawn, with collapsed rows left out.
forEachAll(fn: (row: Row, index: number) => void): voidfn: (row: Row, index: number) => voidvoidEvery row in the data, before any filter. Leaf rows, in physical order.
forEachExcept(colId: string, fn: (row: Row, index: number) => void): voidcolId: string
fn: (row: Row, index: number) => void
voidVisit the rows surviving every filter except one column's own: the faceting question, asked of the rows.
value(key: string, colId: string): unknownkey: string
colId: string
unknownA cell's resolved value, stored or computed, read through the same path the renderer uses so the API and the cell beside it can never disagree.
text(key: string, colId: string): stringkey: string
colId: string
stringA cell's display text - the value after the column's format and any lookup label, exactly as the cell shows it.
values(key: string): Record<string, unknown>key: stringRecord<string, unknown>Every column's resolved value for one row, keyed by column id. Columns the current permissions hide from reading are left out.
refresh(opts?: { rows?: string[]; columns?: string[]; force?: boolean }): voidopts?: { rows?: string[]; columns?: string[]; force?: boolean }voidRepaint without re-running the sort, filter and group stages, discarding the memoised `compute` results for the named rows and columns (all of them when none are named). `force: true` also recomputes and rewrites pure computed values already materialised into the store - the call to make when an answer the grid cannot see has changed, such as a lookup table that has just arrived.
move(key: string, to: number): { moved: boolean; from: number; to: number; reason?: string }key: string
to: number
{ moved: boolean; from: number; to: number; reason?: string }Move a row to another position in the data. Refuses, with a reason, while a sort, filter or grouping is active.
groupHeadings(index: number): Row[]index: numberRow[]The group headings enclosing a display row, outermost first. Empty when the grid is not grouped.
leavesOf(key: string): Row[]key: stringRow[]The leaf rows beneath a group heading: the members it counts in `leafCount`, as rows, so you can roll up a field the grid was never told to total. Filtered members in display order. Computed per call, so call it when you draw a group row rather than in a loop over every row.
expand(key: string, deep?: boolean): voidkey: string
deep?: boolean
voidOpen a group or tree row. Note that `deep` expands *every* group in the grid, not only this row's descendants.
collapse(key: string): voidkey: stringvoidClose a group or tree row, hiding everything beneath it.
expandAll(): void - voidOpen every group and tree row. An explicit call outranks `groupDefaultExpanded`, so the next rebuild does not re-close them.
collapseAll(): void - voidClose every group and tree row, leaving only the outermost headings on screen.
Events
EventWhenPayloadCancellable
rows:changedRows were added, updated, removed or moved. `identified: true` means the payload names exactly which rows moved.RowsChangedEventno
rows:queuedA change arrived while the feed was being batched and was put on the queue instead of applied.RowsQueuedEventno
rows:deferredA flush ran out of its frame budget and carried the rest of the change into the next one.RowsDeferredEventno
rows:paused`grid.changes.pause()` held the feed: changes keep arriving and stop being applied.RowsFlowEventno
rows:resumed`grid.changes.resume()` released the feed and applied what had been held.RowsFlowEventno
row:receivedA row dragged from another grid was accepted into this one, on the receiving grid.RowReceivedEventno
row:sentA row was dragged out of this grid into another one and removed from here (a move, not a copy).RowTransferEventno
row:copiedA row was dragged out of this grid into another one and kept here as well (a copy).RowTransferEventno
row:movedA row was reordered within this grid, from one display index to another.RowMovedEventno
row:edit:startA whole-row editor opened, the row-edit counterpart of `cell:edit:start`.EditStartEventno
row:edit:endA whole-row editor closed, the row-edit counterpart of `cell:edit:end`.EditEndEventno
row:clickedA row was clicked, alongside the `cell:clicked` for the cell under the pointer.RowPointerEventno
row:dblclickedA row was double-clicked, alongside the `cell:dblclicked` for the cell under the pointer.RowPointerEventno
row:pendingAn optimistic row append or delete was sent to the transport and is awaiting the server's answer.RowPendingEventno
row:confirmedThe server accepted a pending row append or delete; an append is rekeyed from its temporary key first.RowConfirmedEventno
row:revertedA pending row append or delete was refused: the optimistic append is discarded, the tombstoned row restored.RowRevertedEventno
row:conflictThe server accepted a pending row append or delete but returned a row that disagrees with what the grid holds.RowConflictEventno

ColumnsApi

Properties

No properties.

Methods
MethodSignatureParametersReturnsDescription
setTotal( id: string, fn: TotalName | TotalFn | null, opts?: { scope?: 'group' | 'grand' }, ): voidid: string
fn: TotalName | TotalFn | null
opts?: { scope?: 'group' | 'grand' }
voidSet or clear a column's totals-row reduction. With no `scope`, `fn` becomes the column's single `total`, applied to both group subtotals and the grand total, and any independent group/grand overrides are cleared - the same one-property behaviour as before. Pass `scope: 'group'` or `scope: 'grand'` to set just that scope's reduction independently, leaving the other and the base `total` untouched; the scope that has no override falls back to `total`.
aggregates(id: string): TotalName[]id: stringTotalName[]The aggregate names meaningful for a column, honouring its type's `totals.supported` declaration (). What the aggregate chooser offers.
distinct(id: string): unknown[]id: stringunknown[]Every distinct value in a column, from the dictionary where there is one.
get(id: string): ResolvedColumn | undefinedid: stringResolvedColumn | undefinedA resolved column by id, including the generated auto-group column - which `all()` and `state()` deliberately leave out, since it is the grid's and not yours. `undefined` when there is no such column.
all(): ResolvedColumn[] - ResolvedColumn[]Every leaf column in display order, hidden ones included, minus any the current permissions withhold.
visible(): ResolvedColumn[] - ResolvedColumn[]The leaf columns actually on screen, in display order - `all()` without the hidden ones.
state(): ColumnState[] - ColumnState[]The serialisable column state - order, width, pin, visibility, sort, grouping, totals, decoration - ready to store and hand back to `apply()`. The generated auto-group column is excluded.
apply(state: ColumnState[]): voidstate: ColumnState[]voidRestore column state produced by `state()`: order, widths, pins, visibility and the rest, applied in one pass.
tags(): string[] - string[]Every distinct column tag, in the order first declared.
showTagged(tags?: string | string[] | null): string[]tags?: string | string[] | nullstring[]Show only the columns carrying one of these tags. **Columns with no tags are never hidden.** Pass nothing to show every tagged column again. Returns the ids that were hidden.
activeTags(): string[] - string[]The tags currently being shown, empty when all are.
show(ids: string | string[]): voidids: string | string[]voidShow columns by id. Recorded on the undo timeline.
hide(ids: string | string[]): voidids: string | string[]voidHide columns by id. `beforeColumnHide` can cancel it, and a column marked `layout.lockVisible` refuses and warns.
move(id: string, to: number): voidid: string
to: number
voidMove a column to a display position. `beforeColumnMove` can cancel it; a column that is not movable or is position-locked refuses and warns.
groupColumns(ids: string | string[], opts?: { title?: string; at?: number; groupId?: string; id?: string }): string | nullids: string | string[]
opts?: { title?: string; at?: number; groupId?: string; id?: string }
string | nullWrap leaf columns in a banded header, or add them to an existing band. Header banding, not row grouping (see {@link group}); the band is a {@link ColumnGroup} node so a drag-, keyboard- or config-built band is the same tree, and it round-trips through a saved view. Emits `columngroup:changed`. Pass `groupId` to add to the band already carrying that id, or `id` to create a new band with a caller-chosen stable id you can reference later; `groupId` wins if both are given and an `id` already in use warns and no-ops.
ungroupColumn(id: string): voidid: stringvoidTake a leaf out of its band; a band emptied by the move is dissolved.
renameGroup(groupId: string, title: string): voidgroupId: string
title: string
voidRename a banded header.
dissolveGroup(groupId: string): voidgroupId: stringvoidDissolve a band, returning its columns to the enclosing level in place.
moveGroup(groupId: string, to: number): voidgroupId: string
to: number
voidMove a whole band among its siblings, its columns travelling as a block.
pin(id: string, side: Edge | null): voidid: string
side: Edge | null
voidFreeze a column against the start or the end edge, or pass `null` to return it to the scrolling body. Recorded on the undo timeline.
resize(id: string, px: number): voidid: string
px: number
voidSet a column's width in pixels, clamped to its `min` and `max`. A column marked `resizable: false` refuses and warns. An explicit width clears the column's `flex`, so the next layout pass does not undo it.
decorate(id: string, decoration: DecorationName | DecorationSpec | null, opts?: { variant?: VariantSpec }): voidid: string
decoration: DecorationName | DecorationSpec | null
opts?: { variant?: VariantSpec }
voidSet, change or clear a column's decoration at runtime (). Pass `null` to clear it back to plain text. Presentation config: it is not on the undo timeline and is not carried in a saved view - use `grid.formatting` for durable, view-persisted conditional styling.
autoSize(ids?: string | string[]): voidids?: string | string[]voidSize the named columns (or all of them) to the content they are actually showing, heading included. It measures the rows the renderer has mounted rather than the whole dataset, and settles any pending frame first so a call made straight after `rows.load()` sees the rows and not just the header.
fit(): void - voidSize the visible resizable columns so that every column the grid draws, together, exactly fills the width the cells occupy: the body viewport's client width at the moment of the call, which excludes the vertical scrollbar when the grid draws one and is the full inner width when it does not. Columns it does not size keep their width and are taken out of that width first: `resizable: false` columns and the grid's own selection checkbox, detail expander, group and tree columns. The rest share what is left in proportion to their current widths, within each `min`/`max`. If that leaves less than their minimums, each is set to its minimum - the 40px floor when a column's own `min` does not set one of its own, an explicit `min: 0` included - never below, the grid scrolls horizontally, and a `[lattice]` warning says so. Rows given to `createGrid` or `rows.load()` before the call are counted. One-shot: it sets fixed widths once (a `flex` column included) and does not follow later changes; after a resize, rows arriving later bring a vertical scrollbar in, or a column group is expanded or un-grouped, call it again.
group(ids: string | string[]): voidids: string | string[]voidSet the row-grouping columns, outermost first; pass an empty list to ungroup. Columns the current permissions withhold are dropped. Emits `column:grouped` and rebuilds the header, since grouping adds or removes the generated group column.
pivot(ids: string | string[]): voidids: string | string[]voidSet the pivot columns, turning each distinct value into a column heading; pass an empty list to leave pivot mode. Columns the current permissions withhold are dropped. Emits `column:pivoted`.
totals(ids: string | string[]): voidids: string | string[]voidChoose which columns carry a footer total. A column named here that has none is given `sum`; a column not named falls back to whatever its own definition asked for.
Events
EventWhenPayloadCancellable
column:movedA column was moved to a different display position.ColumnMovedEventno
column:resizedA column's width changed, by a header drag or by `grid.columns.resize()`.ColumnResizedEventno
column:visibleColumns were shown or hidden.ColumnVisibleEventno
column:pinnedA column was pinned to a side, or unpinned.ColumnPinnedEventno
column:groupedThe row grouping changed: which columns the rows are grouped by.ColumnGroupedEventno
column:pivotedThe pivot changed: which columns the rows are pivoted by, locally or pushed down to the backend.ColumnPivotedEventno
column:filter:openThe header's filter affordance was activated and the column's filter popup should open.ColumnMenuEventno
column:profile:openThe column menu's profile item was activated and the column's profile should open.ColumnMenuEventno
column:menu:openThe header's menu affordance was activated and the column menu should open.ColumnMenuEventno
columns:changedThe column set changed other than by moving, resizing, hiding or pinning - a type inference pass rewrote it.ColumnsChangedEventno
columns:tagged`grid.columns.showTagged()` chose which columns to show from their tags.ColumnsTaggedEventno

SelectionApi

Properties

No properties.

Methods
MethodSignatureParametersReturnsDescription
clearRange(): void - voidDrop every range, leaving the row and cell selection alone.
summary(): { count: number; numeric: number; sum: number | null; min: number | null; max: number | null; avg: number | null; } - { count: number; numeric: number; sum: number | null; min: number | null; max: number | null; avg: number | null; }The selected cells as a status bar states them: how many carry a value, how many of those are numbers, and the sum, extremes and mean of the numbers. Every figure but the two counts is null when nothing selected is numeric. {@link SelectionApi.statistics} is the fuller answer.
statistics(): object | null - object | nullEverything worth knowing about the selected cells: what `summary()` reports plus median, quartiles, deviation, distinct and outliers. Over the cells rather than a column, so a rectangle spanning three columns is one set of numbers. Null with nothing selected.
rows(): Row[] - Row[]The selected rows, as row objects.
keys(): string[] - string[]The keys of the selected rows.
set(keys: string[]): voidkeys: string[]voidReplace the row selection with exactly these keys and repaint.
all(): void - voidSelect every row currently on display - what the filters leave, detail rows excepted. Does nothing unless the selection mode is `'multiple'`.
clear(): void - voidDrop the row selection, leaving any cell ranges alone.
headerState(): boolean | 'partial' - boolean | 'partial'The select-all checkbox's tri-state over the displayed rows: `true` when every one is selected, `'partial'` when some are, `false` when none are. Group and detail rows are furniture and do not count.
cells(): { key: string; colId: string }[] - { key: string; colId: string }[]Every cell inside the selected ranges, as row key and column id pairs.
ranges(): CellRange[] - CellRange[]The selected cell ranges, in the order they were added.
setRange(range: CellRange): voidrange: CellRangevoidReplace the range selection with this one range.
addRange(range: CellRange): voidrange: CellRangevoidAdd a range without discarding the ones already selected - the API form of ctrl-clicking a second block. The added range becomes the anchor a following `extendRange` grows from.
startRange(rowIndex: number, colId: string, opts?: { additive?: boolean }): voidrowIndex: number
colId: string
opts?: { additive?: boolean }
voidAnchor a new range at a cell, which a drag or Shift+Arrow then extends. `additive: true` keeps the ranges already selected.
extendRange(rowIndex: number, colId: string): voidrowIndex: number
colId: string
voidGrow the live range out to a cell, leaving its anchor where it was.
corner(): { row: number; colId: string } | null - { row: number; colId: string } | nullThe bottom-right corner of the last range - where the fill handle sits - or `null` when nothing is selected.
inRange(rowIndex: number, colId: string): booleanrowIndex: number
colId: string
booleanWhether a cell falls inside any selected range.
Events
EventWhenPayloadCancellable
selection:changedThe row selection changed and was accepted (a `beforeSelect` veto raises `selection:cancelled` instead).SelectionChangedEventno
selection:cancelledA `beforeSelect` handler vetoed the selection change, which has been snapped back.SelectionCancelledEventno

FiltersApi

Properties

No properties.

Methods
MethodSignatureParametersReturnsDescription
quickState(): { text: string; mode: string } - { text: string; mode: string }The quick filter's text and match mode, for restoring a control.
get(): FilterSet - FilterSetThe filter tree in force - the structured conditions, not the quick filter or the `where` predicates.
set(filters: FilterSet): voidfilters: FilterSetvoidReplace the filter tree. `beforeFilter` may cancel it. A condition on a column the user may not read is dropped, since the row count alone would leak the value; a condition naming an operator the grid does not have is dropped too, and if that leaves nothing at all the call is a no-op rather than a clear - a typo must not quietly widen a row set someone had narrowed.
clear(): void - voidDrop the condition tree, the quick filter, and every `where` predicate that was not registered `{ pinned: true }`.
quick(text: string): voidtext: stringvoidSet the quick filter text, which matches against what the cells display. `mode` chooses how - `'contains'` (the default), `'words'`, `'fuzzy'` or `'regex'` - and persists until changed, so a host can set it once and pass text alone afterwards. `beforeFilter` may cancel it, and keystrokes coalesce into one undo entry.
where(): string[] - string[]The names of the `where` predicates in force, in registration order.
where(name: string, predicate: ((row: any) => boolean) | null, opts?: WhereOptions): voidname: string - the name to register under
predicate: ((row: any) => boolean) | null, opts?: WhereOptions - the predicate, or null to remove it
voidRegister, replace or remove a named row predicate composed with the filter set. Registering *is* activating: there is no companion "a predicate is present" flag to keep in sync, which is the failure mode this replaces. Several may be in force at once under their own names, ANDed with each other and with the declarative set, and removing one leaves the rest alone. The predicate is handed the **data row**. grid.filters.where('visibleToMe', row => row.owner === me); grid.filters.where('rateKnown', row => rates.has(row.ccy), { deps: ['ccy'], pinned: true }); grid.filters.where('visibleToMe', null); // remove Only the names reach `filters.get()` and `state.get()`; the functions never do.
reapply(name?: string): booleanname?: stringboolean whether anything was re-runRe-run `where` predicates whose inputs changed where the grid could not see it - a rate table that arrived late, a permission set that refreshed. The out-of-band half of re-evaluation; `deps` is the half the grid observes for itself. Together they replace the manual "filter again" call.
Events
EventWhenPayloadCancellable
filter:changedThe filters changed: a structured condition, the quick filter's text, or a named host predicate.FilterChangedEventno
beforeFilterA user filter - structured or quick - is about to be applied; call `preventDefault(reason?)` to stop it.BeforeFilterEventyes
filter:cancelledA `beforeFilter` handler vetoed the filter.FilterCancelledEventno

SortApi

Properties

No properties.

Methods
MethodSignatureParametersReturnsDescription
get(): SortEntry[] - SortEntry[]The sort entries in force, outermost first. A copy - changing it sorts nothing.
set(entries: SortEntry[]): voidentries: SortEntry[]voidReplace the sort model; an entry naming no known column is dropped with a warning.
clear(): void - voidRemove every sort entry and return the rows to their source order.
Events
EventWhenPayloadCancellable
sort:changedThe sort order changed, through `grid.sort.set()` or a header click.SortChangedEventno
beforeSortA user sort is about to be applied; call `preventDefault(reason?)` to stop it.BeforeSortEventyes
sort:cancelledA `beforeSort` handler vetoed the sort.SortCancelledEventno

EditApi

Properties
PropertyTypeDescription
pastePreviewbooleanWhether a bulk paste is previewed before it commits (`edit.pastePreview`,). (read-only)
Methods
MethodSignatureParametersReturnsDescription
start(key: string, colId: string): booleankey: string
colId: string
booleanOpen an editor on a cell. `false` when the cell is not editable, or is held by another peer under an advisory lock.
stop(cancel?: boolean): voidcancel?: booleanvoidEnd the edit session, committing by default or discarding with `cancel: true`. A commit runs the full write path - parse, validate, apply - and records the change on the undo timeline.
undo(): void - voidUndo the last action. Routed through the grid-wide timeline rather than an edit-only stack: with two stacks the first press after a sort would undo an edit made before it, which is not what the user did last.
redo(): void - voidRedo the last undone action, through the same grid-wide timeline.
setCells( writes: { key: string; colId: string; value: unknown }[], type?: 'cell' | 'fill' | 'paste', opts?: { origin?: 'api' | 'ai' | 'user' }, ): number | Promise<number>writes: { key: string; colId: string; value: unknown }[]
type?: 'cell' | 'fill' | 'paste'
opts?: { origin?: 'api' | 'ai' | 'user' }
number | Promise<number>Write several cells as one undoable step (). `opts.origin` defaults to `'api'` - the ungated seam every existing caller uses (a fill, a paste, a kanban move), unchanged. Pass `{ origin: 'ai' }` (or `'user'`) to route the write through the cancellable `beforeEdit` gate, exactly as an interactive edit is: the AI writes through this so a host `beforeEdit` handler can veto it and nothing persists when it does. With a gated origin and an async (deferring) before-handler, the return is a `Promise<number>`.
bulkSet(value: unknown, opts?: { cells?: { key: string; colId: string }[] }): numbervalue: unknown
opts?: { cells?: { key: string; colId: string }[] }
numberSet one value across a block of cells as a single undoable step (, card 740). Defaults to the selected range; read-only and non-editable cells are skipped and every write runs the normal parse/validate path.
fill(opts?: { direction?: 'down' | 'up' | 'left' | 'right'; series?: boolean; range?: CellRange }): numberopts?: { direction?: 'down' | 'up' | 'left' | 'right'; series?: boolean; range?: CellRange }numberFill a selected range from its leading edge as one undoable step (, card 740). The default copies the anchor across the range (Excel's Ctrl+D and its natural siblings); `series: true` extrapolates a numeric or date series from the first one or two cells of each line, falling back to a copy for types with no series. `direction` defaults to `'down'`.
pasteInto(anchor: { key: string; colId: string }, text: string, extent?: { rows?: number; columns?: number }): numberanchor: { key: string; colId: string }
text: string
extent?: { rows?: number; columns?: number }
numberPaste tab-separated text anchored at a cell, as one undoable step. Excel's shape rules apply: a single value fills the whole target, a smaller block tiles to fill it, and a block taller than the rows available is clipped rather than creating rows. Returns how many cells were written.
previewPaste(anchor: { key: string; colId: string }, text: string, extent?: { rows?: number; columns?: number }): { changes: { key: string; colId: string; oldValue: unknown; newValue: unknown; changed: boolean }[]; rejected: { key: string; colId: string; value: unknown; reason: 'permission' | 'readOnly' | 'validation' | 'locked' | 'missing' }[]; }anchor: { key: string; colId: string }
text: string
extent?: { rows?: number; columns?: number }
{ changes: { key: string; colId: string; oldValue: unknown; newValue: unknown; changed: boolean }[]; rejected: { key: string; colId: string; value: unknown; reason: 'permission' | 'readOnly' | 'validation' | 'locked' | 'missing' }[]; }Compute what a paste would change, without committing (). The engine behind `edit.pastePreview`: `changes` are the accepted writes with their old and new values (and whether each actually differs), `rejected` are the cells a commit would refuse, each with a reason.
settle( id: string, ok: boolean, reason?: string, reconcile?: { value?: unknown; conflict?: { serverRow?: unknown } }, ): booleanid: string
ok: boolean
reason?: string
reconcile?: { value?: unknown; conflict?: { serverRow?: unknown } }
booleanReport the outcome of an in-flight write (;-5.2 reconcile). `reconcile` carries server truth on a successful settle: `value` is a server-authoritative value written back before `cell:confirmed` (`returning: 'row'`); `conflict.serverRow` surfaces a last-write-wins conflict via `cell:conflict`. Omit both to keep the optimistic value.
pending(): OpenWrite[] - OpenWrite[]Every optimistic cell write still awaiting an outcome, oldest first. Always empty when `edit.commit` is not configured.
status(key: string, colId: string): 'pending' | nullkey: string
colId: string
'pending' | nullWhether a cell has a write in flight: `'pending'`, or `null` when it is settled.
addRow(row: object): string | nullrow: object - the new row (it need not carry a key yet)string | null the client temp key the row is tracked under, or null when append is not available on this sourceAppend a row to a remote source optimistically and persist it (), the structural analog of the cell edit path. The row shows immediately under a client temp key, and `adapter.mutate({ kind: 'append', rows: [row] })` is asked to persist it; when the server returns the real key the row is rekeyed everywhere the grid tracks it and `row:confirmed` fires, while a refused append is removed and fires `row:reverted`. Only wired when the source declares `mutate.append`; otherwise it warns once and returns null.
deleteRow(key: string): string | nullkey: string - the row key to removestring | null the id the op is tracked under, or null when delete is not availableDelete a row from a remote source optimistically and persist it (). The row is tombstoned immediately and `adapter.mutate({ kind: 'delete', keys: [key] })` is asked to remove it; on confirmation the row is purged and `row:confirmed` fires, on refusal it is restored and `row:reverted` fires. Only wired when the source declares `mutate.delete`; otherwise it warns once and returns null.
deleteRows(keys?: string | string[], opts?: { origin?: string }): string[] | Promise<string[]>keys?: string | string[]
opts?: { origin?: string }
string[] | Promise<string[]> the removed keys, or a Promise of them on the async pathDelete rows on a user gesture, through the cancellable `beforeDelete` event (, - what the built-in Delete-key and "Delete row" gestures call. Unlike {@link deleteRow}, `beforeDelete` fires on a memory-source grid too, so the row can be confirmed or vetoed there. Off until `config.rowDelete` opts in; the keys default to the row selection. Returns the keys removed (empty on a veto or when disabled), or a Promise of them when a `beforeDelete` handler deferred.
settleRow(id: string, ok: boolean, reason?: string, reconcile?: { key?: string; row?: unknown; conflict?: { serverRow?: unknown } }): booleanid: string - the op id from `row:pending`
ok: boolean - true when the op reached the server
reason?: string
reconcile?: { key?: string; row?: unknown; conflict?: { serverRow?: unknown } }
boolean true when the id named an op still awaiting an outcomeReport the outcome of an optimistic structural write (), the counterpart to {@link settle} for `edit.confirm: 'manual'` over a backend that acknowledges an append/delete on a separate channel. The id arrives on `row:pending`.
rowStatus(key: string): 'pending' | nullkey: string - the row key'pending' | null `'pending'`, or null when the row is settledWhether a row has a structural op in flight ().
pendingRows(): OpenRowOp[] - OpenRowOp[]Every structural op still awaiting an outcome (), oldest first; always empty when the source cannot append or delete.
Events
EventWhenPayloadCancellable
beforeEditA user or AI edit is about to be committed; call `preventDefault(reason?)` to stop it.BeforeEditEventyes
edit:cancelledA `beforeEdit` handler vetoed the commit, or it went stale while an async handler was thinking.EditCancelledEventno

ScrollApi

Properties

No properties.

Methods
MethodSignatureParametersReturnsDescription
toRow(row: string | number, align?: 'start' | 'center' | 'end' | 'auto'): voidrow: string | number
align?: 'start' | 'center' | 'end' | 'auto'
voidA row key, or a display index. A key survives a sort and is usually what a caller holds; resolving one scans the display order, so prefer an index when scrolling a very large grid repeatedly. The row lands fully visible in the part of the body the pinned strips (pinned rows, sticky group headings, a bottom grand total) do not cover: `end` puts it just above the bottom strip, `start` just below the top one.
toColumn(id: string): voidid: stringvoidScroll sideways until a column is in view. Pinned columns need no scrolling and are already there.
toCell(row: string | number, colId: string, align?: 'start' | 'center' | 'end' | 'auto'): voidrow: string | number
colId: string
align?: 'start' | 'center' | 'end' | 'auto'
voidScroll a cell into view, both axes in one call.
position(): { top: number; left: number } - { top: number; left: number }The body's current scroll offsets in pixels. Zeroes before the grid has been rendered.
to(at: { top?: number; left?: number }): voidat: { top?: number; left?: number }void`left` is the logical offset, zero at the content's start in either direction.
Events
EventWhenPayloadCancellable
scrollThe viewport scrolled to a new offset; fires only when the offset actually moved, not on a refresh.ScrollEventno
scroll:endScrolling settled: the last of a scroll gesture's frames has been drawn.ScrollEventno

ExportApi

Properties

No properties.

Methods
MethodSignatureParametersReturnsDescription
rangeText(opts?: object): stringopts?: objectstringThe selected range as tab-separated text, the shape a spreadsheet pastes.
csv(opts?: CsvExportOptions): string | Promise<Blob>opts?: CsvExportOptionsstring | Promise<Blob>Export to CSV. Returns the text, or downloads a blob when the options ask for a file.
excel(opts?: ExcelExportOptions): Promise<Blob>opts?: ExcelExportOptionsPromise<Blob>Export to a real `.xlsx` workbook, with the column formats, widths and any styling the options ask for. Large exports stream.
clipboard(opts?: ClipboardOptions): Promise<void>opts?: ClipboardOptionsPromise<void>Copy to the clipboard as tab-separated text, which is what Excel, Numbers and Sheets paste as cells. With `rows: 'range'` it copies the selected rectangle.
print(opts?: { maxRows?: number; unpin?: boolean; print?: boolean; }): Promise<{ printed: boolean; rows: number; reason?: string }>opts?: { maxRows?: number; unpin?: boolean; print?: boolean; }Promise<{ printed: boolean; rows: number; reason?: string }>Switch the grid into print layout - every row in the document, no virtualisation, no paging, pinned columns released - let the layout settle, call the browser's print dialog, and put the grid back as it was. Refused above `maxRows` (5,000 by default, roughly a hundred printed pages), with a message pointing at the CSV and Excel exports. `unpin` keeps the pinned columns in place; `print: false` lays the grid out and restores it without calling the dialog, which is how a host paginates or photographs it. Resolves with what happened, so a caller can show the reason instead of guessing.
Events
EventWhenPayloadCancellable
export:progressA streaming export wrote another chunk, with rows written, rows expected and bytes so far.ExportProgressEventno
export:requestA remote export request is about to be handed to the host's `export.remote.fetch` hook.ExportRequestEventno
export:doneA remote export came back and the file was handed over (or downloaded).ExportDoneEventno

ImportApi

Bringing rows in - the mirror of {@link ExportApi} (,.

Properties

No properties.

Methods
MethodSignatureParametersReturnsDescription
preview(text: string, opts?: object): ImportPreviewtext: string
opts?: object
ImportPreviewParse delimited text into a preview, changing nothing.
csv(text: string, opts?: object): Record<string, unknown>[]text: string
opts?: object
Record<string, unknown>[]Parse delimited text into coerced records - the inverse of `export.csv`.
previewXlsx(bytes: Uint8Array | ArrayBuffer, opts?: object): Promise<ImportXlsxPreview>bytes: Uint8Array | ArrayBuffer
opts?: object
Promise<ImportXlsxPreview>Parse an `.xlsx` file's bytes into a preview, changing nothing (,. Async: the archive is inflated with `DecompressionStream`.
xlsx(bytes: Uint8Array | ArrayBuffer, opts?: object): Promise<Record<string, unknown>[]>bytes: Uint8Array | ArrayBuffer
opts?: object
Promise<Record<string, unknown>[]>Parse an `.xlsx` file's bytes into coerced records - the inverse of `export.excel`.
apply( input: string | ImportPreview | Record<string, unknown>[], opts?: { mode?: ImportMode }, ): ChangeResult | nullinput: string | ImportPreview | Record<string, unknown>[]
opts?: { mode?: ImportMode }
ChangeResult | nullAdd or replace the grid's rows from text, a preview or records.
Events

No events.

StateApi

Properties

No properties.

Methods
MethodSignatureParametersReturnsDescription
get(): GridState - GridStateCapture the grid's current view - sort, filters, column order, widths, visibility, grouping and the rest - as a plain, JSON-safe object. Columns the reader is not permitted to see are stripped, because the presence of a column is itself information.
apply(state: GridState, opts?: { skip?: (keyof GridState)[] }): StateApplyReportstate: GridState
opts?: { skip?: (keyof GridState)[] }
StateApplyReportRestore a captured state, skipping the sections named in `skip`. Nothing throws: a section that cannot be applied is listed in the report with a reason. The whole restore is one logical change, so it makes one undo entry and one state event.
baseline(): GridState | null - GridState | nullThe grid as configured, without `config.state` - captured once, before that seed is applied, so a view opened through `config.state` is never itself mistaken for the default `reset()` returns to.
reset(): StateApplyReport | null - StateApplyReport | nullPut the grid back the way it started, as one undoable step.
modified(): boolean - booleanWhether anything has changed since construction.
Events
EventWhenPayloadCancellable
state:changedOne logical state change - a gesture, an apply, an undo or a reset - announced once, whatever routed it.StateChangedEventno
state:reset`grid.state.reset()` restored the arrangement the grid was built with.StateResetEventno

OverlayApi

Properties

No properties.

Methods
MethodSignatureParametersReturnsDescription
show(kind: 'loading' | 'empty' | (string & {}), message?: string): voidkind: 'loading' | 'empty' | (string & {})
message?: string
voidCover the grid body with an overlay: `'loading'`, `'empty'`, or a name of your own, with an optional message.
hide(): void - voidTake the overlay away.
Events

No events.

HistoryApi

Properties

No properties.

Methods
MethodSignatureParametersReturnsDescription
undo(): HistoryEntry | null - HistoryEntry | nullUndo the last action, whatever kind it was - an edit, a sort, a filter, a column move - and repaint. Returns the step that was undone, or `null` when there was nothing to undo.
redo(): HistoryEntry | null - HistoryEntry | nullRedo the last undone action. Returns the step that was redone, or `null`.
canUndo(): boolean - booleanWhether there is anything on the timeline to undo.
canRedo(): boolean - booleanWhether anything has been undone that could be redone.
peek(direction?: 'undo' | 'redo'): HistoryEntry | nulldirection?: 'undo' | 'redo'HistoryEntry | nullWhat undo or redo would apply next, for labelling a button.
list(): HistoryEntry[] - HistoryEntry[]The whole timeline, newest first.
transaction(label: string, fn: () => void): HistoryEntry | nulllabel: string
fn: () => void
HistoryEntry | nullGroup everything `fn` does into one undoable step.
clear(): void - voidDrop the timeline. It announces the change like any other move, so an undo button does not keep offering a step that no longer exists.
Events
EventWhenPayloadCancellable
history:changedThe undo/redo stacks moved: what can now be undone or redone.HistoryChangedEventno
history:appliedAn undo or redo step was applied.HistoryAppliedEventno

ViewsApi

Properties
PropertyTypeDescription
activeIdstring | nullThe id of the view currently applied, or `null` when the grid is not on a named view. (read-only)
Methods
MethodSignatureParametersReturnsDescription
list(): SavedView[] - SavedView[]Every saved view with its metadata - name, description, whether it is shared - but not its stored state.
get(id: string): SavedView | undefinedid: stringSavedView | undefinedOne view in full, state included. `undefined` when there is no such view.
save(name: string, opts?: { id?: string; shared?: boolean; description?: string; isDefault?: boolean; }): SavedViewname: string
opts?: { id?: string; shared?: boolean; description?: string; isDefault?: boolean; }
SavedViewSave the grid's current state as a named view and make it the active one. `id` naming an existing view overwrites it; `id` naming none creates a view with that id, which is how a host with server-issued ids seeds the store; with no `id`, a name already taken is overwritten rather than duplicated. (`overwrite` was declared and read nowhere - that decision is the order above - and was removed in.)
apply(id: string): SavedView | nullid: stringSavedView | nullApply a view and make it active. It is a destination, not a patch: the grid returns to its baseline first, so the same view gives the same grid whatever was applied before it. The whole restore is one undo entry. Returns the state report, or `null` when there is no such view.
rename(id: string, name: string): SavedView | nullid: string
name: string
SavedView | nullGive a view a new name. `null` when the name was refused - blank, or already taken.
duplicate(id: string, name?: string): SavedView | nullid: string
name?: string
SavedView | nullCopy a view, optionally under a new name. `null` when there is no such view.
remove(id: string): booleanid: stringbooleanDelete a view. `false` when there was no such view; deleting the active one leaves the grid as it is, with no active view.
setDefault(id: string | null): SavedView | nullid: string | nullSavedView | nullMark the view applied on load; null clears it.
defaultView(): SavedView | null - SavedView | nullThe view marked as the one to apply on load, or `null` when none is.
diff(id: string): Record<string, unknown> | nullid: stringRecord<string, unknown> | nullWhat applying the view would change, without applying it.
export(id?: string): ViewPayload | nullid?: stringViewPayload | nullA shareable payload for one view, or for every view when no id is given - an object, not JSON text, so a host can add to it before sending it. `null` when the id names no view. The default marker never travels: it belongs to this user's store, not to the view.
import(json: ViewPayload | SavedView | SavedView[] | string, opts?: { /** What a name already in the store does. `'rename'` (the default) keeps both. */ onConflict?: ViewConflictPolicy; /** Overrides the shared flag on every incoming view. */ shared?: boolean; }): ViewImportReportjson: ViewPayload | SavedView | SavedView[] | string
opts?: { /** What a name already in the store does. `'rename'` (the default) keeps both. */ onConflict?: ViewConflictPolicy; /** Overrides the shared flag on every incoming view. */ shared?: boolean; }
ViewImportReportTake a shared payload - the object, its JSON text, a bare view or a bare array - and report what was imported, skipped and repaired. It reports rather than throwing: an import that fails silently is worse than one that says so, and one bad entry never rejects the rest of the file.
reload(): void - voidRe-read from storage, after another tab or the server changed it.
Events
EventWhenPayloadCancellable
views:changedThe saved-view list changed, for any reason; the named `view:*` events say which view moved.ViewsChangedEventno
view:appliedA saved view was applied to the grid.ViewAppliedEventno
view:savedA saved view was created, updated or imported.ViewChangedEventno
view:removedA saved view was deleted.ViewChangedEventno
view:renamedA saved view was renamed.ViewChangedEventno
view:defaultA saved view was made the default one.ViewChangedEventno

DiffApi

Properties
PropertyTypeDescription
enabledbooleanWhether a baseline is loaded and the grid is diffing against it. (read-only)
Methods
MethodSignatureParametersReturnsDescription
swap(): boolean - booleanExchange the baseline and the current rows. Returns false with nothing to swap.
setSnapshot(rows: unknown[] | null): voidrows: unknown[] | nullvoidSet the baseline every row is compared against.
clear(): void - voidDrop the baseline and stop diffing.
summary(): { added: number; removed: number; changed: number; unchanged: number } - { added: number; removed: number; changed: number; unchanged: number }How many rows are added, removed, changed and unchanged against the baseline.
statusOf(key: string): 'added' | 'removed' | 'changed' | 'unchanged'key: string'added' | 'removed' | 'changed' | 'unchanged'How one row stands against the baseline: `'added'`, `'removed'`, `'changed'` or `'unchanged'`.
cellStatus(key: string, colId: string): 'changed' | 'unchanged'key: string
colId: string
'changed' | 'unchanged'Whether one cell differs from the baseline.
isChanged(key: string, colId?: string): booleankey: string
colId?: string
booleanWhether a cell differs from the baseline - or, with no column given, whether the row does.
changedColumns(key: string): string[]key: stringstring[]Which of a row's columns differ from the baseline.
before(key: string, colId: string): unknownkey: string
colId: string
unknownThe value a cell held in the baseline.
beforeRow(key: string): unknownkey: stringunknownThe whole row as the baseline holds it.
removedKeys(): string[] - string[]The keys present in the baseline and gone from the current rows.
removedRows(): unknown[] - unknown[]The rows present in the baseline and gone from the current rows, as their original objects.
report(): Record<string, unknown> - Record<string, unknown>A full record of one row's changes - old value and new, per column - for an audit log.
Events
EventWhenPayloadCancellable
diff:changedDiff mode was turned on against a snapshot, or turned off.DiffChangedEventno
diff:swappedThe two sides of a diff were swapped.DiffSwappedEventno

PermissionsApi

Properties

No properties.

Methods
MethodSignatureParametersReturnsDescription
levelOf(column: string | ResolvedColumn): PermissionLevelcolumn: string | ResolvedColumnPermissionLevelThe permission level resolved for a column against the current context: `'hidden'`, `'read'`, `'write'` or `'writeOnly'`.
isHidden(column: string | ResolvedColumn): booleancolumn: string | ResolvedColumnbooleanWhether a column is withheld from this user entirely - it is not in `columns.all()` and its values never reach the row.
isReadable(column: string | ResolvedColumn): booleancolumn: string | ResolvedColumnbooleanWhether this user may see the column's values. Not the same question as `isHidden`: a write-only column is on screen and editable and still fails this.
isEditable(column: string | ResolvedColumn): booleancolumn: string | ResolvedColumnbooleanWhether this user may edit the column.
isSecret(column: string | ResolvedColumn): booleancolumn: string | ResolvedColumnbooleanTrue only at `writeOnly`: writable, never shown or exported.
isExportable(column: string | ResolvedColumn): booleancolumn: string | ResolvedColumnbooleanWhether the column may leave through an export or the clipboard.
levels(): Record<string, PermissionLevel> - Record<string, PermissionLevel>Every column's resolved level, keyed by column id.
setContext(context: unknown): voidcontext: unknownvoidChange the context permissions are evaluated against, and re-evaluate.
invalidate(): void - voidRe-resolve every column against the context as it stands - for a policy whose inputs changed without the context object being replaced.
Events
EventWhenPayloadCancellable
permissions:changedThe per-column permission levels changed.PermissionsChangedEventno

AiApi

Properties

No properties.

Methods
MethodSignatureParametersReturnsDescription
schema(opts?: { maxColumns?: number; maxRows?: number }): Record<string, unknown>opts?: { maxColumns?: number; maxRows?: number }Record<string, unknown>A machine-readable description of the grid, for a model's context.
tool(opts?: { maxColumns?: number; maxRows?: number }): Record<string, unknown>opts?: { maxColumns?: number; maxRows?: number }Record<string, unknown>The same schema as a tool definition.
prompt(opts?: Record<string, unknown>): stringopts?: Record<string, unknown>stringThe prompt describing this grid (its columns, types and operators) for sending to a model. It carries no row values. It does not take the user's question: compose that yourself alongside the text this returns, which is what `ask` receives as `schemaText`.
buildPrompt(text: string, opts?: Record<string, unknown>): stringtext: string
opts?: Record<string, unknown>
stringThe complete prompt for a question: the schema, the rules the plan validator enforces, and the user's text. Use it when you want the shipped prompting; use `prompt()` and compose your own when you do not.
plan(reply: string | Record<string, unknown>, opts?: Record<string, unknown>): Record<string, unknown>reply: string | Record<string, unknown>
opts?: Record<string, unknown>
Record<string, unknown>Parse what the model returned into a plan.
apply(plan: Record<string, unknown>): Record<string, unknown>plan: Record<string, unknown>Record<string, unknown>Run a plan as one undoable step.
Events

No events.

MessagesApi

The resolved message set for a grid: every user-visible string, in the grid's locale.

Properties
PropertyTypeDescription
localestringThe resolved BCP 47 tag. (read-only)
keysReadonlyArray<string>Every key the catalogue defines. (read-only)
Methods
MethodSignatureParametersReturnsDescription
t(key: string, params?: Record<string, unknown>): stringkey: string - a key from `keys`
params?: Record<string, unknown>
stringFormat a message.
list(items: string[], type?: 'conjunction' | 'disjunction'): stringitems: string[]
type?: 'conjunction' | 'disjunction'
stringJoin parts the way this locale joins lists.
number(value: number, opts?: Intl.NumberFormatOptions): stringvalue: number
opts?: Intl.NumberFormatOptions
stringFormat a number for this locale.
Events

No events.

LicenceApi

Properties
PropertyTypeDescription
readyPromise<LicenceInfo>Settles when the licence check finishes. (read-only)
Methods
MethodSignatureParametersReturnsDescription
set(key: string): LicenceInfokey: stringLicenceInfoInstall a licence key for this page. Returns the provisional verdict at once; verification is asynchronous, and `licence:changed` fires again when it settles, so an optimistic watermark can come down.
info(): LicenceInfo - LicenceInfoThe current verdict: who the licence is for, which domains it covers, when it expires, and whether it verified.
state(): 'licensed' | 'localhost' | 'trial' - 'licensed' | 'localhost' | 'trial'What this deployment is running as: `'licensed'`, `'localhost'` (free, never watermarked) or `'trial'`.
watermark(): boolean - booleanWhether the trial watermark should be drawn. The DOM layer reads this; a headless grid can too.
Events
EventWhenPayloadCancellable
licence:changedA licence key was installed through `grid.licence.set(key)`, and again when its asynchronous verification settles.LicenceChangedEventno

PaginationApi

Properties

No properties.

Methods
MethodSignatureParametersReturnsDescription
get(): { page: number; pageSize: number; total: number | null; pageCount: number; counting: boolean; } - { page: number; pageSize: number; total: number | null; pageCount: number; counting: boolean; }The current page, its size, the total rows and how many pages they make. Zeroes with one page on a source that does not page. `total` is `null` and `counting` is `true` while a source is still working out its exact total. A pager reading a total in that gap would offer a last page that is nowhere near the end; with none it degrades to "there is a next page", which is what it already does for a source that cannot count at all.
set(next: { page?: number; pageSize?: number }): voidnext: { page?: number; pageSize?: number }voidMove to a page, change the page size, or both, recording one undo entry. A page size of 0 turns paging off and shows everything.
applyPage(next: { page?: number; pageSize?: number }): voidnext: { page?: number; pageSize?: number }voidThe same move without a separate undo entry - for a control that is already inside a transaction of its own. Does nothing when neither value changes; emits `page:changed` when one does.
Events

No events.

HighlightApi

Properties

No properties.

Methods
MethodSignatureParametersReturnsDescription
clear(target?: { key?: string; colId?: string } | string): booleantarget?: { key?: string; colId?: string } | stringbooleanClear one target, or every highlight when called with nothing.
list(): { scope: string; key: string | null; colId: string | null; colour: string; duration: number }[] - { scope: string; key: string | null; colId: string | null; colour: string; duration: number }[]Every highlight currently in force, with its scope, target, colour and duration.
colourFor(key: string, colId: string): string | nullkey: string
colId: string
string | nullThe colour a cell is painted by the highlights in force, or `null` when it is not highlighted.
Events
EventWhenPayloadCancellable
highlight:changedThe set of host-declared highlights changed.HighlightChangedEventno

FindApi

In-grid find: locate text and step through where it occurs without filtering anything away. Matches are a visual overlay - no row is reordered, removed or edited - and coexist with the quick filter.

Properties

No properties.

Methods
MethodSignatureParametersReturnsDescription
open(text?: string): voidtext?: stringvoidShow the bar with focus in its input, optionally seeding the text.
close(): void - voidHide the bar and clear every match.
clear(): void - voidClear the query and the highlights, leaving the bar as it is.
next(): FindMatch | null - FindMatch | nullThe next match, wrapping from the last to the first, scrolled into view and made the active cell unless an edit is open.
prev(): FindMatch | null - FindMatch | nullThe previous match, wrapping from the first to the last.
goTo(index: number): FindMatch | nullindex: numberFindMatch | nullMake the match at a position in `matches()` current.
matches(): FindMatch[] - FindMatch[]Every match, in display order: pinned-top rows, then the body, then pinned-bottom rows.
count(): FindCount - FindCountHow many matches there are, which one is current, and how much of the data was actually searched. `windowed` is the honest-scope flag: over a source that pages, `loaded` is the rows the scan really read out of the `rows` the source reports, never a first-page count dressed as the whole.
current(): FindMatch | null - FindMatch | nullThe match the grid is standing on, or `null` when there is none.
state(): FindState - FindStateThe query as it stands - the text, the options - together with whether the find bar is open.
stateFor(key: string, colId: string): 'current' | 'match' | nullkey: string
colId: string
'current' | 'match' | nullHow a cell is painted: the current match, another match, or nothing.
Events
EventWhenPayloadCancellable
find:changedThe find bar's query, open state or match count changed.FindChangedEventno

RedactionApi

Redaction obscures a column's values on screen. It is presentational: the values stay in the model, the DOM, the clipboard and every export. Use `permissions` with `writeOnly` for a value that must not be readable.

Properties
PropertyTypeDescription
activebooleanWhether at least one column is being obscured. (read-only)
Methods
MethodSignatureParametersReturnsDescription
has(colId: string): booleancolId: stringbooleanWhether a column's values are being obscured.
list(): string[] - string[]Every redacted column id.
toggle(colId: string): booleancolId: stringbooleanRedact a column, or stop redacting it; returns the state it is now in. Recorded on the undo timeline.
add(colId: string): voidcolId: stringvoidObscure a column's values while leaving its heading, its width and the shape of the data visible. This defeats a camera and a screen recorder, not a reader: the values remain in the DOM, the clipboard and every export. Use column permissions for anything that must not be read.
remove(colId: string): voidcolId: stringvoidStop obscuring a column's values.
set(ids: string[]): voidids: string[]voidReplace the whole redacted set with these column ids.
clear(): void - voidStop obscuring every column.
Events
EventWhenPayloadCancellable
redaction:changedThe set of redacted columns changed.RedactionChangedEventno

AnnotationApi

Properties
PropertyTypeDescription
toolAnnotationTool | nullThe drawing tool in use, or null when the layer is inert - which it is until a tool is chosen, so the grid takes the pointer as usual. (read-only)
countnumberHow many marks the layer is holding, durable and drawn alike. (read-only)
Methods
MethodSignatureParametersReturnsDescription
use(tool: AnnotationTool | null, opts?: { colour?: string }): string | nulltool: AnnotationTool | null
opts?: { colour?: string }
string | nullChoose a tool, or pass null to hand the pointer back to the grid. Asking for the tool already in use turns it off, so one button can toggle; `opts.colour` sets the colour to draw in, and changing only the colour keeps the current tool. Returns the tool now in use.
add(mark: AnnotationMark): numbermark: AnnotationMarknumberAdd a durable mark from a descriptor, without synthesising pointer input. The mark is painted, survives a presentation ending, and round-trips through `getState`. Returns the mark count.
list(): AnnotationMark[] - AnnotationMark[]Every mark on the layer, as descriptors - the shape `getState` persists.
undo(): number - numberRemove the most recent mark and repaint. Returns how many are left; on an empty layer it does nothing and returns 0.
clear(): void - voidRemove every mark, seeded and drawn alike - the explicit "clear all". Ending a presentation drops only the drawn ones.
redraw(): void - voidRepaint every mark at the current scroll offset. Called for you on scroll and after each render; a host needs it only after moving the layer itself.
Events

No events.

PresentationApi

Properties
PropertyTypeDescription
activebooleanWhether a presentation is running. The grid only dims for a spotlight while it is. (read-only)
scalenumberThe enlargement in force, 1 being normal. Clamped to between 0.5 and 4. (read-only)
options{ scale?: number; chrome?: string[]; views?: string[]; from?: number; autoAdvance?: number }The options the running presentation was started with. Empty once it stops. (read-only)
viewsstring[]The saved view ids being stepped through - the slides. Empty when the presentation is just an enlargement. (read-only)
indexnumberWhere in the sequence the presentation is, from 0, or -1 when there is no sequence. (read-only)
viewIdstring | nullThe view currently shown, or null when there is no sequence. (read-only)
spotlight{ keys: string[]; colIds: string[] } | nullWhat is currently lit - rows by key, columns by id, or their intersection - or null when nothing is. Cleared on every step, because a spotlight belongs to the point being made. (read-only)
Methods
MethodSignatureParametersReturnsDescription
start(options?: { scale?: number; chrome?: string[]; /** Saved view ids to step through. Views are the slides. */ views?: string[]; /** Where in that sequence to begin. */ from?: number; /** Milliseconds between automatic advances, for an unattended display. */ autoAdvance?: number; }): booleanoptions?: { scale?: number; chrome?: string[]; /** Saved view ids to step through. Views are the slides. */ views?: string[]; /** Where in that sequence to begin. */ from?: number; /** Milliseconds between automatic advances, for an unattended display. */ autoAdvance?: number; }booleanBegin presenting: enlarge the grid, keep only the named chrome, and step through `views` from `from`. Returns true only when this call started it - calling it again while presenting reconfigures the running presentation and returns false.
stop(): boolean - booleanStop presenting and put the grid back: the scale, the chrome, the sequence and any spotlight. Returns whether one was running.
setScale(value: number): numbervalue: numbernumberSet the enlargement, clamped to 0.5-4, and return the scale now in force.
nudge(steps?: number): numbersteps?: numbernumberMove the enlargement by steps of 0.1 - the live adjustment between a laptop and a projector. Negative shrinks. Returns the scale now in force.
step(by?: number): numberby?: numbernumberMove through the sequence by this many views (1 by default, negative to go back). Clamped at both ends rather than wrapping, so pressing forward on the last slide stays there. Returns the position now shown, or -1 without a sequence.
goTo(index: number): numberindex: numbernumberShow a numbered position in the sequence, clamped into range. Returns the position now shown, or -1 without a sequence. Stepping to a view clears the spotlight.
reset(): boolean - booleanPut the current view back exactly as it was saved, discarding the sorting and filtering done while answering a question. Returns whether a view was restored.
setSpotlight(target?: { keys?: string[]; colIds?: string[] } | null): booleantarget?: { keys?: string[]; colIds?: string[] } | nullbooleanLight rows, columns or their intersection and let the rest recede; call it with nothing to clear. Returns whether anything is lit now. Calling it while no presentation is running warns, because nothing dims outside one.
Events
EventWhenPayloadCancellable
presentation:changedEither the responsive presentation switched between the table and the card layout, or `presentation.start()` was called again while already running.PresentationChangedEventno
presentation:started`grid.presentation.start()` began presenting.PresentationStartedEventno
presentation:ended`grid.presentation.stop()` stopped presenting.no payloadno
presentation:viewThe presentation stepped to a view in its deck, including the first one.PresentationViewEventno
presentation:scaleThe presentation's enlargement changed.PresentationScaleEventno
presentation:spotlightThe presentation's spotlight was armed over some rows and columns, or cleared.PresentationSpotlightEventno
presentation:capturedA screenshot of the grid was captured (`grid.capture()`), with the image's size and type.PresentationCapturedEventno

PivotViewApi

Controls for the pivot presentation (,: expand or collapse an axis node, and read the collapse state a saved view carries. Every method is a no-op on a headless grid, which has no matrix to collapse.

Properties

No properties.

Methods
MethodSignatureParametersReturnsDescription
expand(axis: 'row' | 'column', path: string): voidaxis: 'row' | 'column'
path: string
voidExpand a collapsed node on the row or column axis.
collapse(axis: 'row' | 'column', path: string): voidaxis: 'row' | 'column'
path: string
voidCollapse a node on the row or column axis, hiding its descendants.
toggle(axis: 'row' | 'column', path: string): voidaxis: 'row' | 'column'
path: string
voidToggle a node's collapse on the row or column axis.
state(): { rowsCollapsed: string[]; columnsCollapsed: string[] } - { rowsCollapsed: string[]; columnsCollapsed: string[] }The collapsed row-axis and column-axis paths, as a saved view carries them.
Events

No events.

UpdatesApi

Properties
PropertyTypeDescription
pausedbooleanWhether incoming updates are being held rather than applied. (read-only)
Methods
MethodSignatureParametersReturnsDescription
pause(): boolean - booleanHold incoming updates so the rows stop moving. They keep merging while held, so a long pause costs one entry per changed row rather than one per update. `false` when they were already held.
resume(): ChangeResult - ChangeResultApply everything held and go back to applying as changes arrive. Returns the rows added, updated and removed.
flush(): ChangeResult - ChangeResultApply what is waiting without leaving the paused state - one step forward, which is what a scrubber wants. Returns the rows added, updated and removed.
stats(): { paused: boolean; pending: number; queued: number; coalesced: number; coalescedTotal: number; rows: number; dropped: number; held: number; heldLimit: number; flushes: number; strategy: string; deferrals: number; maxQueued: number; budgetMs: number; span: { from: number; to: number } | null; } - { paused: boolean; pending: number; queued: number; coalesced: number; coalescedTotal: number; rows: number; dropped: number; held: number; heldLimit: number; flushes: number; strategy: string; deferrals: number; maxQueued: number; budgetMs: number; span: { from: number; to: number } | null; }Counters for the feed and the buffer: what arrived, what is still waiting, how much coalescing saved, and what was dropped or held.
log(opts?: { since?: number }): { at: number; change: RowChange; rows: number }[]opts?: { since?: number }{ at: number; change: RowChange; rows: number }[]The timestamped changes still held, oldest first. `since` narrows it to a time window.
Events

No events.

TimelineApi

Moving the grid through recent data changes. Reads the change log rather than the undo history: history records what the *user* did, and the question on a live grid is what the *data* did. Nothing is scrubbable until `attach()`: what a value used to be is not recoverable after the fact.

Properties
PropertyTypeDescription
attachedbooleanWhether the scrubber is recording. Nothing is scrubbable until it is: what a value used to be cannot be recovered after the fact. (read-only)
livebooleanWhether the grid is showing the present rather than standing somewhere in the past. (read-only)
positionnumberHow many steps back from the present the grid is standing; 0 is live. (read-only)
depthnumberHow many steps back it is currently possible to go - the length of the recorded window. (read-only)
Methods
MethodSignatureParametersReturnsDescription
attach(): void - voidStart recording what each change replaces. The scrubbable window fills from this moment on; nothing before it is recoverable.
detach(): void - voidStop recording and return the grid to the present.
seek(steps: number): numbersteps: numbernumberStand a given number of steps back from the present, 0 being live. Returns where it now stands, which may be short of what was asked for.
step(by: number): numberby: numbernumberMove by a relative number of steps, negative going back in time. Returns where it now stands.
toLive(): number - numberReturn to the present, applying everything that was stepped over. Returns 0.
at(): number | null - number | nullThe timestamp of the moment being shown, or `null` when the grid is live.
span(): { from: number; to: number } | null - { from: number; to: number } | nullThe range of time the scrubber can move over, or `null` when nothing is recorded.
Events
EventWhenPayloadCancellable
timeline:attachedThe timeline scrubber began recording what each change replaces.TimelineAttachedEventno
timeline:detachedThe timeline scrubber stopped recording and the grid returned to the present.no payloadno
timeline:seekThe timeline finished moving and the grid now stands at that position.TimelineSeekEventno
timeline:seekingThe timeline is about to move, with where it is coming from and going to.TimelineSeekingEventno

CrossFilter

Properties

No properties.

Methods
MethodSignatureParametersReturnsDescription
enabled(): boolean - booleanWhether this grid can cross-filter a source.
column(): string | null - string | nullThe source column the filter is pushed onto.
get(): string[] - string[]The keys currently filtering the source.
set(keys: string | string[] | null): voidkeys: string | string[] | nullvoidFilter the source to these derived rows.
toggle(key: string): voidkey: stringvoidAdd or remove one key, for click-to-filter.
clear(): void - voidTake this grid's filter off its source.
Events

No events.

FacetsApi

Properties

No properties.

Methods
MethodSignatureParametersReturnsDescription
get(colId: string): FacetState | nullcolId: stringFacetState | nullThe distribution behind a column's header histogram - its buckets, counts and whether they are stale - scheduling the count if it has not run. `null` when the column has no histogram.
suppression(colId: string): string | nullcolId: stringstring | nullWhy a column has no histogram - `'disabled'`, `'type'`, `'rows'`, `'streaming'`, `'cardinality'`, `'no-provider'` - or `null` when it has one.
config(colId?: string): FacetConfigcolId?: stringFacetConfigThe facet settings in force for a column, with the grid's defaults folded in; the grid's own defaults when no column is named.
refresh(opts?: { immediate?: boolean }): voidopts?: { immediate?: boolean }voidRecount every column whose distribution has been asked for. `immediate: true` skips the debounce.
isExpanded(colId: string): booleancolId: stringbooleanWhether a column's chart is drawn full height rather than as a collapsed strip.
toggle(colId: string, open?: boolean): booleancolId: string
open?: boolean
booleanExpand or collapse a column's chart, toggling when no state is given. Returns the state it is now in.
select(colId: string, from: number, to?: number, opts?: { additive?: boolean; gesture?: string }): booleancolId: string
from: number
to?: number
opts?: { additive?: boolean; gesture?: string }
booleanFilter by one bucket, or by a range across several. It sets an ordinary filter, so it undoes, saves and shows in the filter UI like any other. A range is written as a `between` condition rather than a set of buckets, so it stays meaningful when the bucketing changes. `additive` adds to a categorical set instead of replacing it.
clear(colId: string): booleancolId: stringbooleanRemove this column's own facet filter and leave every other filter in place.
selected(colId: string): number[]colId: stringnumber[]Which of a column's buckets its current filter selects, as bucket indices.
expanded(): string[] - string[]Every column whose chart is expanded, for a saved view.
Events
EventWhenPayloadCancellable
facet:computedA column's facet buckets finished computing, with how long it took and whether a worker did it.FacetComputedEventno
facet:filteredA facet histogram was used to filter its column, or that filter was cleared.FacetFilteredEventno
facet:expandedA facet panel section was opened or closed.FacetExpandedEventno
facet:failedA column's facet buckets could not be computed.FacetFailedEventno

DetailApi

Properties

No properties.

Methods
MethodSignatureParametersReturnsDescription
enabled(): boolean - booleanWhether master-detail is configured on this grid.
isMaster(target: string | Row): booleantarget: string | RowbooleanWhether a row - by key or as a row object - can be expanded into a detail region.
isOpen(key: string): booleankey: stringbooleanWhether this master's detail region is open.
open(key: string): voidkey: stringvoidOpen a master's detail region. Does nothing for a key that is not a master.
close(key: string): voidkey: stringvoidClose a master's detail region.
toggle(key: string): booleankey: stringbooleanOpen a master's detail if it is closed and close it if it is open; returns the state it is now in. `false` for a key that is not a master.
closeAll(): void - voidClose every open detail region.
keys(): string[] - string[]The keys of every master whose detail is open, in the order they were opened.
active(): string | null - string | nullThe one open master when the detail is drawn into a host element outside the grid, where only one can be open. `null` when nothing is open.
placement(): 'inline' | 'target' | null - 'inline' | 'target' | nullWhere the detail is drawn: `'inline'` as a row beneath the master, or `'target'` in a host element elsewhere on the page. `null` when master-detail is off.
config(): DetailConfig | null - DetailConfig | nullThe resolved master-detail settings, for a renderer building the region. `null` when master-detail is off.
Events
EventWhenPayloadCancellable
detail:toggledA master-detail region was opened or closed.DetailToggledEventno

CommentsApi

Properties
PropertyTypeDescription
enabledbooleanWhether comments can be used at all: there is a provider, and the grid's row identity is stable enough to file a comment against. (read-only)
openKeystring | nullThe cell whose thread is open, as a composite key, or null when none is. (read-only)
threadComment[] | nullThe open thread's comments, or null when no thread is open. A comment still awaiting the provider is in the list, flagged as pending. (read-only)
loadingbooleanWhether the open thread's bodies are still being fetched. (read-only)
completebooleanWhether the index covers every row rather than only the ones that have been on screen. The comments-only filter needs this first. (read-only)
Methods
MethodSignatureParametersReturnsDescription
unavailable(): string | null - string | null`'no-provider'`, `'no-row-identity'`, or null when available.
at(rowId: string, colId: string): CommentDescriptor | nullrowId: string
colId: string
CommentDescriptor | nullThe counts for one cell, or null when it carries no comments. This is what the marker is drawn from.
request(rowIds: string[], fields?: string[]): voidrowIds: string[]
fields?: string[]
voidAsk the provider for index entries covering these rows - the visible columns unless others are named. Debounced, so scrolling costs one fetch rather than one per frame.
open(rowId: string, colId: string): Promise<Comment[] | null>rowId: string
colId: string
Promise<Comment[] | null>Open a cell's thread and resolve to its comments. The cell's current value is recorded with anything written next, so a later reader can be told the number moved.
close(opts?: { reason?: string }): voidopts?: { reason?: string }voidClose the open thread and drop its bodies. The optional reason is carried on the event.
add(body: string, opts?: { parentId?: string; author?: object }): Promise<Comment | null>body: string
opts?: { parentId?: string; author?: object }
Promise<Comment | null>Add a comment to the open thread, optionally as a reply. It appears at once and is rolled back if the provider rejects it; resolves to the stored comment, or null when there is no open thread or no provider.
edit(commentId: string, body: string): Promise<Comment | null>commentId: string
body: string
Promise<Comment | null>Change a comment's body in the open thread. Resolves to the stored comment, or null when it could not be edited.
remove(commentId: string): Promise<boolean>commentId: stringPromise<boolean>Delete a comment from the open thread. Resolves to whether it went.
resolve(): Promise<boolean> - Promise<boolean>Resolve the open thread, so its cells stop counting as outstanding. Resolves to whether it stuck.
unresolve(): Promise<boolean> - Promise<boolean>Reopen the open thread.
refresh(): void - voidRe-fetch the index for the rows already known, for when the application learns of a change from elsewhere. The grid opens no transport of its own, so nothing tells it otherwise.
loadAll(): Promise<boolean> - Promise<boolean>Fetch the index for every row in the grid, not just the ones seen. Resolves to whether the index is now complete.
hiddenUnresolved(): number - numberHow many unresolved threads sit on rows the current filter is hiding - so a reader who filters and sees no markers is not left thinking there is nothing outstanding. 0 while the index is incomplete, because then it cannot be known.
filterToCommented(opts?: { unresolvedOnly?: boolean }): booleanopts?: { unresolvedOnly?: boolean }booleanNarrow the grid to rows carrying comments, or with `unresolvedOnly` to those carrying unresolved ones. Returns whether the filter could be applied - it needs a complete index.
Events
EventWhenPayloadCancellable
comment:addedA comment was added to a cell, or a reply added to a thread.CommentAddedEventno
comment:editedA comment's text was edited.CommentEventno
comment:deletedA comment was deleted.CommentEventno
comment:failedA comment operation could not reach the backend; `operation` names which one.CommentFailedEventno
comment:resolvedA comment thread was marked resolved.CommentResolvedEventno
comment:unresolvedA resolved comment thread was reopened.CommentResolvedEventno
comment:threadOpenedA cell's comment thread was opened.CommentThreadOpenedEventno
comment:threadClosedA cell's comment thread was closed or dismissed.CommentThreadClosedEventno
comment:indexLoadedThe comment index for the visible rows finished loading, with how many entries it carried.CommentIndexLoadedEventno

PresenceApi

Properties
PropertyTypeDescription
enabledbooleanWhether a presence provider is attached. Without one the whole namespace is inert. (read-only)
meRecord<string, unknown> | nullThis client's own identity as the provider gave it, or `null` when there is none. (read-only)
publishingbooleanWhether this client is sending its own presence, as against only receiving others'. (read-only)
Methods
MethodSignatureParametersReturnsDescription
peers(): Peer[] - Peer[]Every peer, most recently active first, each carrying its cursor, idle state and a `hidden` flag. `hidden` means their row is not in this view - filtered out, on another page, or evicted from a window - not that they have gone.
hiddenCount(): number - numberHow many peers have their cursor on a row this view is not showing.
editorOf(rowId: string, colId: string): Peer | nullrowId: string
colId: string
Peer | nullThe peer editing a cell, when one is and their claim is still fresh. `null` otherwise.
lockedBy(rowId: string, colId: string): Peer | nullrowId: string
colId: string
Peer | nullAdvisory. Reduces collisions; does not eliminate them.
jumpTo(peerId: string): booleanpeerId: stringbooleanScroll this view to a peer's cursor, centring the row. `false` when the peer is unknown or their row is not in this view.
publish(): void - voidSend local presence now, without waiting for the throttle.
setPublishing(on: boolean): voidon: booleanvoidStop or resume sending this client's own presence - an observer role that still receives everyone else's.
setPaused(paused: boolean): voidpaused: booleanvoidSuspend publishing entirely, as the DOM layer does when the tab is hidden.
connect(provider: PresenceProvider | null): voidprovider: PresenceProvider | nullvoidAttach a presence provider after construction, or pass `null` to detach.
stats(): Record<string, number> - Record<string, number>Publish, receive, drop and error counters for the presence channel.
Events
EventWhenPayloadCancellable
presence:publishedThis grid published its own presence - the cell it is on, its selection - to the presence transport.PresencePublishedEventno
presence:joinedA peer appeared in the presence channel for the first time.PresencePeerEventno
presence:updatedA peer already present moved or changed what it is doing.PresencePeerEventno
presence:leftA peer left the presence channel or timed out.PresenceLeftEventno
presence:failedA presence subscribe or publish could not reach the transport.PresenceFailedEventno
presence:lockRefusedAn edit was refused because a peer holds the cell's lock.PresenceLockRefusedEventno

DiagnosticsApi

Properties

No properties.

Methods
MethodSignatureParametersReturnsDescription
snapshot(): Record<string, unknown> - Record<string, unknown>Everything below as one structure - the whole diagnostic picture in a single read.
renders(): Record<string, unknown> - Record<string, unknown>`dom.cellWrites` is the figure a DOM-write assertion reads.
store(): Record<string, unknown> - Record<string, unknown>How the column store is laid out and what it costs: encoding per column, dictionary sizes, bytes held.
operations(): Record<string, unknown> - Record<string, unknown>Timing history per kind of operation - sort, filter, group, ingest - sampled over recent calls.
providers(): Record<string, unknown> - Record<string, unknown>Call counts, errors, latency and recent calls for each provider the host supplied.
events(): Record<string, number> - Record<string, number>How many listeners are attached per event type. A count that grows without bound is almost always a subscription leak in the host application.
config(): { effective: Record<string, unknown>; supplied: string[]; defaulted: string[] } - { effective: Record<string, unknown>; supplied: string[]; defaulted: string[] }The configuration actually in force, and which keys you supplied as against which the grid defaulted.
warnings(): DiagnosticWarning[] - DiagnosticWarning[]Everything the grid has flagged this session, newest first - its own checks and every `[lattice]` warning - each with the stable identifier a support conversation can name.
dismiss(id: string): voidid: stringvoidHide one warning for the rest of this session, by its identifier. Nothing is persisted.
bundle(): Record<string, unknown> - Record<string, unknown>Contains no row data, cell values or column values.
checkOptions(options: unknown): booleanoptions: unknownbooleanAsk whether an options object keeps changing identity without its contents changing - the framework-wrapper mistake that rebuilds the grid on every parent render. True means the identity churned.
record(kind: string, detail: { rows?: number; ms?: number; worker?: boolean }): voidkind: string
detail: { rows?: number; ms?: number; worker?: boolean }
voidRecord an operation your own code performed - its name, the rows it touched, how long it took - so it appears alongside the grid's in `operations()`.
render(cause: string, phases?: Record<string, number>): voidcause: string
phases?: Record<string, number>
voidRecord a repaint and what caused it, with optional per-phase timings. The DOM layer calls this; a custom renderer can too.
recordEvents(on: boolean, limit?: number): voidon: boolean
limit?: number
voidOff by default; recording times every emit.
eventLog(): Array<{ type: string; origin: string; listeners: number; payload: Record<string, string>; at: number; ms: number }> - Array<{ type: string; origin: string; listeners: number; payload: Record<string, string>; at: number; ms: number }>The recorded events, oldest first, once `recordEvents(true)` has been called. Empty otherwise.
clearEventLog(): void - voidForget the recorded events while leaving recording switched on.
mark(): Record<string, unknown> - Record<string, unknown>Keep current store statistics so growth can be measured against them.
since(): Record<string, unknown> | null - Record<string, unknown> | nullWhat has changed since `mark()` - the growth that shows a leak, which no single reading can. `null` when nothing is marked.
reset(): void - voidZero the counters, leaving the warnings and the configuration report alone.
Events

No events.

StatisticsApi

Properties
PropertyTypeDescription
maintenanceReadonly<Record<string, 'maintained' | 'rescan'>>Which reductions can be maintained against a change, and which rescan. (read-only)
approximateReadonly<Record<string, ApproximateEntry>>The approximate tier: kernels a sketch maintains in constant time per tick, keyed by kernel name, each carrying the sketch that backs it and the error bound that sketch is verified to meet. (read-only)
Methods
MethodSignatureParametersReturnsDescription
shadow(colId: string, kind: ShadowKind, rowKey: string, scope?: RowScope, spec?: object): unknowncolId: string
kind: ShadowKind
rowKey: string
scope?: RowScope
spec?: object
unknownOne shadow value for one row, by the column it shadows and the kind. For `kind: 'specStatus'`, `spec` carries the `{lower, upper, warnLower, warnUpper}` limits to judge the row's value against; other kinds ignore it.
fitShadow(kind: 'fitPredicted' | 'fitResidual' | 'fitInfluence' | 'fitStdResidual' | 'fitLeverage' | 'fitCooksD', rowKey: string, spec: RegressionSpec): number | boolean | nullkind: 'fitPredicted' | 'fitResidual' | 'fitInfluence' | 'fitStdResidual' | 'fitLeverage' | 'fitCooksD'
rowKey: string
spec: RegressionSpec
number | boolean | nullOne regression shadow value for a row, by key: the predicted value, residual, or Cook's-distance influence flag from the fitted model, over the filtered rows. Null for a row outside the fit.
running(colId: string, kind: 'total' | 'percent', rowKey: string): number | nullcolId: string
kind: 'total' | 'percent'
rowKey: string
number | nullA running total at one row, down the grid as it is currently ordered.
rebase(colId?: string): voidcolId?: stringvoidMake the current values the new baseline: "mark all".
tracking(): { columns: string[]; rows: number; forgotten: number } - { columns: string[]; rows: number; forgotten: number }What the shadow histories are costing.
reduce(colId: string, fn: string): unknowncolId: string
fn: string
unknownReduce a column by a named kernel over the filtered rows.
profile(colId: string): ColumnProfile | nullcolId: stringColumnProfile | nullEverything worth knowing about one column, in one pass each.
anomalies(opts?: { columns?: string[]; method?: OutlierMethod; threshold?: number; k?: number; p?: number; windowLen?: number; minPeriods?: number }): AnomalyReportopts?: { columns?: string[]; method?: OutlierMethod; threshold?: number; k?: number; p?: number; windowLen?: number; minPeriods?: number }AnomalyReportThe rows that do not belong: anomaly detection over the filtered rows by the robust modified z-score (`modifiedZScore`, the default), Tukey's IQR fences (`iqr`), or multivariate Mahalanobis distance over the chosen columns (`mahalanobis`). Every flagged row carries the score behind it and the reason for it, so a flag is explainable rather than a verdict from nowhere. Non-numeric columns are returned under `skipped`.
subsetVsPopulation(opts?: { columns?: string[] }): SubsetComparisonopts?: { columns?: string[] }SubsetComparisonWhich columns differ most between the filtered subset and the whole population it was drawn from, ranked by effect size - never by a p-value. The measure is stated per column; a numeric and a categorical column are put on one bounded scale so they rank against each other.
datasetVsDataset(other: Grid, opts?: { columns?: string[] }): DatasetComparisonother: Grid
opts?: { columns?: string[] }
DatasetComparisonWhich columns differ most between this grid and another, ranked by effect size - never by a p-value. The generalisation of {@link subsetVsPopulation} from subset-vs-population to dataset-vs-dataset: two independent grids, yoked by passing one in, no shared store. A numeric column reports a pooled standardised mean difference (Cohen's d, symmetric in the two peers where Glass's delta is not); a categorical column the total variation of its category mix; both land on one bounded scale. Both sides are read over their filtered rows. Only shared columns are ranked; a column on one side alone is returned under `unmatched`.
compareGroups(colId: string, opts: TwoSampleSpec): GroupComparison | nullcolId: string
opts: TwoSampleSpec
GroupComparison | nullIs the difference between two groups real? A two-sample test returned as data to interpret - never a verdict. The significance boundary the comparison story (653, 735) stopped short of: those rank by how *much* columns differ and return no p-value; this answers *how sure* for one chosen pair of groups and hands the p-value back as data. There is no `significant` flag, no badge, and no multiple-comparison correction. The rows are split by `opts.by`, the test is chosen by the column's family and named in the result (overridable with `opts.test`): Welch's t or Mann-Whitney U for a numeric column, chi-square for a categorical one. Every result pairs a confidence interval on the difference with the effect size, so it is always "how big and how sure".
correlation(a: string, b: string): number | nulla: string
b: string
number | nullPearson's correlation between two columns.
covariance(a: string, b: string, opts?: { population?: boolean }): number | nulla: string
b: string
opts?: { population?: boolean }
number | nullCovariance, a correlation before the scales are divided out.
regression(a: string, b: string): RegressionFit | nulla: string
b: string
RegressionFit | nullLeast-squares fit of `b` on `a`: in finance, beta and alpha.
regressionModel(spec: RegressionSpec): RegressionModel | nullspec: RegressionSpecRegressionModel | nullFit a multi-predictor linear model over the filtered rows and return the full diagnostic set - coefficients with standard errors, t and p; R² and adjusted R²; per-row fitted values, residuals, leverage and Cook's D; VIF per predictor; a Breusch-Pagan heteroscedasticity flag; and, for a single predictor, a pointwise confidence band. `method` is `ols`, `wls` (needs a `weights` column) or `robust`; `quantile` is reserved and the regularised families refuse. Null on degenerate input.
adf(spec: { of: string; orderBy: string; maxlag?: number }): AdfResult | nullspec: { of: string; orderBy: string; maxlag?: number }AdfResult | nullThe Augmented Dickey-Fuller stationarity test over the `of` series in `orderBy` order, constant+trend form with the lag order chosen by AIC up to an optional cap. Returns the statistic, the lag used, MacKinnon's critical values, an approximate (interpolated) p-value and a plain-language verdict at the 5% level - a scalar readout, not a column.
acf(spec: { of: string; orderBy: string; maxlag?: number }): AcfResult | nullspec: { of: string; orderBy: string; maxlag?: number }AcfResult | nullThe autocorrelation (ACF) and partial autocorrelation (PACF) of the `of` series in `orderBy` order out to `maxlag`, with the approximate ±1.96/√n band. A short-series readout; feed the arrays to a bar chart over explicit points with the band as reference lines. The lag-1 autocorrelation matches `series(...).autocorrelation`.
spearman(a: string, b: string): number | nulla: string
b: string
number | nullSpearman's rank correlation, which one outlier cannot drag.
kendall(a: string, b: string): number | nulla: string
b: string
number | nullKendall's tau-b. Null past 5,000 rows: it is quadratic.
weightedQuantile(colId: string, weightId: string, p?: number): number | nullcolId: string
weightId: string
p?: number
number | nullA quantile of one column weighted by another; the median by default.
capability(colId: string, opts?: { lower?: number; upper?: number; target?: number; by?: string; baseline?: number; /** Which rule set the violations are judged against. Western Electric by default. */ rules?: ControlChartRuleSet; /** The level for the capability interval. 0.95 by default. */ confidence?: number; }): ProcessCapability | nullcolId: string
opts?: { lower?: number; upper?: number; target?: number; by?: string; baseline?: number; /** Which rule set the violations are judged against. Western Electric by default. */ rules?: ControlChartRuleSet; /** The level for the capability interval. 0.95 by default. */ confidence?: number; }
ProcessCapability | nullProcess capability against the column's `spec`, with control limits and the Western Electric rule breaks. `baseline` fixes the limits over the first N readings, which is how a shift is found rather than hidden by the limits it widened.
interval(colId: string, opts?: { kind?: SpcStatistic; confidence?: number; /** Which rows count as successes, for a proportion. Truthiness by default. */ where?: (value: unknown, row: Row) => boolean; }): ConfidenceInterval | ProportionInterval | nullcolId: string
opts?: { kind?: SpcStatistic; confidence?: number; /** Which rows count as successes, for a proportion. Truthiness by default. */ where?: (value: unknown, row: Row) => boolean; }
ConfidenceInterval | ProportionInterval | nullA confidence interval for what a column measures, the range the estimate pins the figure down to, not a verdict about it. Reads the rows the filters left, so an interval narrows as the grid does: it describes the filtered population, not the whole table.
series(colId: string, opts: { by: string; periodsPerYear?: number }): SeriesStats | nullcolId: string
opts: { by: string; periodsPerYear?: number }
SeriesStats | nullHow a column varies along an ordering. `by` is required and never guessed: kernels see rows in the order they arrived, which is not the grid's sort.
forecast(colId: string, opts?: { method?: ForecastMethod; horizon?: number; confidence?: number; windowLen?: number; alpha?: number; beta?: number; gamma?: number; period?: number; /** The column to order by before forecasting - a date or numeric axis. */ by?: string; }): ForecastResult | nullcolId: string
opts?: { method?: ForecastMethod; horizon?: number; confidence?: number; windowLen?: number; alpha?: number; beta?: number; gamma?: number; period?: number; /** The column to order by before forecasting - a date or numeric axis. */ by?: string; }
ForecastResult | nullForecast one column forward: the stats-surface face of the {@link forecast} kernel. The column is read over the filtered rows in arrival order, or ordered by `opts.by` (a date or numeric column, as {@link series} orders) when the time axis matters, then projected `opts.horizon` steps ahead by `opts.method` (default `linear`) with a prediction band where one applies. Every kernel option passes through; returns the same {@link ForecastResult}, or null when the column is unknown or too short.
weightedAverage(colId: string, weightId: string): number | nullcolId: string
weightId: string
number | nullA weighted average of one column by another.
keyOf(data: unknown): string | nulldata: unknownstring | nullThe key a row's data resolves to.
maintenanceTier(fn: string): MaintenanceTierfn: stringMaintenanceTierThe honest tier for one kernel across both the exact and approximate maps: its exact tier and, when one exists, the approximate alternative and bound.
windowed(colId: string, fn: WindowedFn, opts: { kind: WindowKind; /** N ticks for a count window, or N ms for a time window. */ span?: number; /** N minutes for a time window, converted to ms. */ minutes?: number; /** A timestamp column; required for a time or session window. */ by?: string; }): WindowedResult | nullcolId: string
fn: WindowedFn
opts: { kind: WindowKind; /** N ticks for a count window, or N ms for a time window. */ span?: number; /** N minutes for a time window, converted to ms. */ minutes?: number; /** A timestamp column; required for a time or session window. */ by?: string; }
WindowedResult | nullA windowed aggregate - "the average lately" - over one column, stamped with the window it covers (`over`), so a windowed figure is never read without its window. Exact over the values inside the window. `kind: 'count'` takes the last `span` values in arrival order. `kind: 'time'` takes the values within the last `span` ms (or `minutes`) and `kind: 'session'` takes every value; both need a timestamp column, so `by` is required for them and never guessed. Returns null when the column, or the `by` column, is unknown.
Events

No events.

FormattingApi

Properties

No properties.

Methods
MethodSignatureParametersReturnsDescription
list(scope?: FormattingScope): FormattingRule[]scope?: FormattingScopeFormattingRule[]The rules for one scope in evaluation order - a column id, or `'*'` for the rules that apply to every column. Copies, so editing them changes nothing.
all(): Record<FormattingScope, FormattingRule[]> - Record<FormattingScope, FormattingRule[]>Every rule keyed by scope: the shape a saved view carries and undo restores. Plain JSON, which is why a rule whose `style` is a function cannot live here.
scopes(): FormattingScope[] - FormattingScope[]Every scope that currently holds at least one rule.
add(scope: FormattingScope, rule: FormattingRule, opts?: { at?: number }): FormattingRule | nullscope: FormattingScope
rule: FormattingRule
opts?: { at?: number }
FormattingRule | nullAdd a rule to a scope and repaint. Returns the stored rule with its id filled in, or `null` when the rule has no `when`, `scale`, `dataBar` or `iconSet` and so could never match. `at` inserts at a position instead of appending; order is meaning, since the first match wins.
remove(scope: FormattingScope, which: string | number): booleanscope: FormattingScope
which: string | number
booleanRemove a rule by its id or its index. `false` when there is no such rule.
update(scope: FormattingScope, which: string | number, patch: FormattingRule): FormattingRule | nullscope: FormattingScope
which: string | number
patch: FormattingRule
FormattingRule | nullChange a rule's fields in place, leaving its identity and its position alone - an `id` in the patch is ignored. Returns the updated rule, or `null` when there is no such rule.
move(scope: FormattingScope, which: string | number, to: number): booleanscope: FormattingScope
which: string | number
to: number
booleanReorder a rule within its scope. Order is meaning - the first matching rule wins - so a move can change which colour a cell takes. `false` when it did not move.
set(scope: FormattingScope, rules: FormattingRule[]): FormattingRule[]scope: FormattingScope
rules: FormattingRule[]
FormattingRule[]Replace every rule in one scope at once, dropping any that could never match. Returns the stored rules.
replaceAll(rules: Record<FormattingScope, FormattingRule[]>): voidrules: Record<FormattingScope, FormattingRule[]>voidReplace the whole store, scope by scope, as `all()` produced it. What a state restore and undo use.
clear(scope?: FormattingScope): voidscope?: FormattingScopevoidRemove every rule in one scope, or in all of them when no scope is named.
styleFor(colId: string, value: unknown): CellStyle | nullcolId: string
value: unknown
CellStyle | nullThe style one value would take from the runtime rules alone, for painting outside the grid - a server-side export, a preview. `null` when nothing matches.
restat(): void - voidRe-derive the thresholds of distribution rules from the data as it stands.
distribution(colId: string): ColumnDistribution | nullcolId: stringColumnDistribution | nullThe five numbers a distribution rule resolves against for one column.
Events
EventWhenPayloadCancellable
formatting:changedA conditional-formatting rule was added, changed, removed or replaced.FormattingChangedEventno

ValidationApi

The runtime face of declarative column validation.

Properties
PropertyTypeDescription
activebooleanWhether at least one column declares a rule. (read-only)
Methods
MethodSignatureParametersReturnsDescription
check(colId: string, value: unknown, row?: unknown): { code: string; message: string } | nullcolId: string
value: unknown
row?: unknown
{ code: string; message: string } | nullRun a column's rules against a value, returning the first failure or null.
errorFor(key: string, colId: string): ValidationError | nullkey: string
colId: string
ValidationError | nullThe recorded error for one cell, or null when it is valid.
errors(): ValidationError[] - ValidationError[]Every cell that currently holds a validation error.
clear(key?: string, colId?: string): booleankey?: string
colId?: string
booleanClear errors: one cell, a whole row, or all of them.
define(colId: string, spec: ColumnValidation | null): voidcolId: string
spec: ColumnValidation | null
voidSet or replace a column's rules at runtime; null removes them.
Events
EventWhenPayloadCancellable
validation:failedA declared column rule refused an edit; the failures name the column and the message for each.ValidationFailedEventno
validation:clearedRecorded validation errors were cleared - for one cell, one row, or the whole grid.ValidationClearedEventno

MaximiseApi

Properties

No properties.

Methods
MethodSignatureParametersReturnsDescription
enter(): boolean - booleanFill the window with the grid, remembering where it came from and its scroll position. `true` once it is maximised, including when it already was.
exit(): boolean - booleanPut the grid back where it came from, scroll position included. `false` when it was not maximised.
toggle(): boolean - booleanMaximise, or restore when already maximised. Returns whether the grid is maximised afterwards.
active(): boolean - booleanWhether the grid is currently filling the window.
Events

No events.

RowFormApi

Properties

No properties.

Methods
MethodSignatureParametersReturnsDescription
open(key: string): booleankey: stringbooleanOpen the form for a row. False when the form is not configured.
close(): void - voidClose the form without saving, returning focus to wherever it came from.
save(): boolean - booleanCollect the fields, write the ones that map to columns as one undoable step, announce them all on `form:saved`, and close. `false` when a field failed validation - the offending field is scrolled into view and marked - or when there is nothing to save.
isOpen(): boolean - booleanWhether the form panel is showing.
Events
EventWhenPayloadCancellable
form:openedThe row form opened over a row.FormOpenedEventno
form:closedThe row form was closed without saving.FormClosedEventno
form:savedThe row form's values were saved back to the row.FormSavedEventno
form:errorThe row form could not load or save a row; `timedOut` distinguishes a slow backend from a refusal.FormErrorEventno

IconRegistryApi

Read access to the grid's icon sprite set (see {@link Grid.icons}).

Properties

No properties.

Methods
MethodSignatureParametersReturnsDescription
get(name: string): IconGlyph | nullname: stringIconGlyph | nullOne glyph, as a copy, or null when the name is not registered.
names(): string[] - string[]Every registered name, in registration order.
Events

No events.

Configuration

The options object and everything reachable from it, in dependency order.

GridConfig

PropertyTypeDescription
columns(Column | ColumnGroup)[]The columns, in order. A group nests columns under one heading. (optional)
columnGroupsColumnGroup[]Header groups declared separately from the columns they contain. (optional)
rowsunknown[]The data, for a memory grid. Use `source` for anything fetched. (optional)
rowKeystring | string[] | ((row: unknown) => string | string[])What identifies a row. Everything that survives a refresh (selection, expansion, and edits in flight) is keyed on it, so it must be stable and unique. A derived grid defaults to its own derived key. Three shapes: a field name (`'id'`, dot paths allowed); an array of field names, joined into one composite key (`['tenantId', 'circuitId']`); or a function of the row (`row => \`${row.tenantId}#${row.circuitId}\``), itself allowed to return an array to the same effect. (optional)
sourceSourceConfigWhere rows come from: memory, paged, remote, stream or derived. (optional)
ingestIngestConfigHow rows are ingested into the column store. (optional)
columnDefaultsColumnApplied to every column before its own settings. (optional)
columnPresetsRecord<string, Column>Named bundles of column settings, referenced by a column's `preset`. (optional)
dataTypesRecord<string, DataType>Your own data types, alongside the built-in catalogue. (optional)
sampleSizenumberValues sampled per undeclared column when inferring its type. Default 100. (optional)
targetSizeTargetSize 'default' | 'large'Raise every interactive target to a comfortable size for touch, without changing the type. `'large'` asks for it; `'default'` opts out of the coarse-pointer rule that would otherwise apply it. (optional)
componentsRecord<string, RendererCtor | EditorCtor | FilterCtor>Your own renderers, editors and filters, registered by name. (optional)
pipesRecord<string, (value: unknown, ...args: string[]) => string>Named text transforms usable from a format mask or a template. (optional)
totalFnsRecord<string, TotalFn>Your own reductions, alongside the built-in ones. (optional)
variantsRecord<string, VariantDefinition>Named appearance variants a row or cell can be switched into by a rule. (optional)
iconsRecord<string, IconDefinition>Your own SVG glyphs, registered by name before the first paint. The same registry `registerIcon` writes to and every cell, header control, rail button and chart glyph is painted from, so a name given here 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. Read the result back through {@link Grid.icons}. (optional)
treeTreeConfigHierarchical rows: where the parent link or the path lives. (optional)
detailDetailConfigThe expandable panel beneath a row. (optional)
selectionSelectionConfig | 'single' | 'multiple' | 'none'What the user may select, and how selection behaves across groups. The `'none'` shorthand is `{ mode: 'none' }` and behaves identically: no row selection, and no cell ranges or fill handle either. (optional)
editEditConfig | booleanEditing, and how a change is committed and validated. (optional)
paginationPaginationConfig | booleanPage the rows rather than scrolling them. (optional)
localestringThe BCP-47 locale everything the grid formats and collates uses - numbers, dates, text ordering, and the message catalogue. Falls back to the page's own `<html lang>` and then to `en-GB`. (optional)
messagesRecord<string, string | Record<string, string>>A partial message catalogue laid over the one `locale` resolves - every string the grid renders or announces. Supply a bundled catalogue (`FR_FR`, `AR`, …) or your own object; overrides merge over the default rather than replacing it, so translating part of the interface leaves the remainder in English rather than showing raw keys. Every valid key is listed in `MESSAGE_KEYS`; a key that is not is ignored with a warning. (optional)
directionDirection 'ltr' | 'rtl' | 'auto'Writing direction. An explicit `'ltr'` or `'rtl'` always wins. Omit it, or say `'auto'`, to settle it from the mount or the nearest ancestor carrying a `dir="ltr"`/`dir="rtl"` attribute, and only then from `locale`: `ar`, `he`, `fa` and the rest resolve to `rtl`. A page-level `dir="ltr"` therefore wins over an RTL locale - a host that wants RTL inside an LTR page sets `direction: 'rtl'` explicitly. In a right-to-left grid the logical alignments `start`/`end` mirror while the physical `left`/`right` do not (see {@link Align}). (optional)
timeZonestringIANA zone every date column formats in, e.g. 'Europe/London' or 'UTC'. Omit to use each viewer's own zone. A column's own `format.timeZone` wins. (optional)
themeTheme 'light' | 'dark' | 'high-contrast' | 'terminal' | (string & {})The visual theme. (optional)
densityDensity 'compact' | 'standard' | 'comfortable' | 'spacious' | numberRow height and padding as a named step, rather than pixel by pixel. (optional)
gridLinesboolean | 'both' | 'horizontal' | 'vertical' | 'none' | 'rows' | 'columns'Which rules are drawn between cells. `'both'` by default. The two axes are separate decisions: horizontal rules help the eye track along a row, vertical ones stop adjacent values running together. `false` or `'none'` draws neither. Only the rules *between data* are affected, the header's underline, the pinned seams and the totals separator are structure, not grid lines. (optional)
cornerRadiusboolean | number | stringRound the grid's outer corners. Square by default. `true` adopts the theme's own radius; a number is pixels; a string is used as written, so a host can pass its own token or a relative unit. (optional)
stripedRowsbooleanShade alternate data rows (zebra striping). Off by default, and strictly opt-in: an existing grid must look exactly the same on upgrade. When `true`, every other data row takes the theme's `--lattice-surface-alt` background, which every palette already defines, so dark, high-contrast and terminal stripe correctly without extra work. Parity follows the row's *logical* index, not its position in the DOM, so a row keeps its stripe across a scroll even though the rows are recycled. Structural rows - group headings, group footers and the grand total - are never striped, and both selection and hover still win over the stripe. (optional)
verticalAlignVAlign 'top' | 'middle' | 'bottom'Vertical alignment of cell content within a row, as a default for every column. `top`, `middle` or `bottom`; a column's own `verticalAlign` overrides it for that column. The horizontal counterpart is the per-column `align`. Omitted, the grid keeps its historical placement - content centred in a fixed-height row and top-aligned in an `autoHeight` row - so an existing grid is unchanged on upgrade. Setting a value aligns every column uniformly, including `autoHeight` rows, unless a column opts out. (optional)
tooltipTooltipConfigDefaults for the rich cell tooltip. The tooltip itself is declared per column, on `cell.tooltip`; this only carries the settings that are a house style rather than a per-column decision. It switches nothing on: a column with no `cell.tooltip` has no tooltip whatever is set here. (optional)
scrollbarsScrollbarMode | { x?: ScrollbarMode; y?: ScrollbarMode }How the scroll viewport's scrollbars are drawn,. `'auto'` (the default) is the platform's native behaviour, where overlay scrollbars fade when idle. `'always'` keeps that native bar shown whether or not the pointer is over the grid. `'custom'` makes the grid draw its own bar on each axis instead - always visible, the same in every browser, and sized by `--lattice-scrollbar-size` / `--lattice-scrollbar-thumb-min` rather than by the platform. Scrolling itself is unchanged in every mode. The object form controls each axis on its own - `{ y: 'always' }` pins the vertical bar while the horizontal one stays native. Note that `'custom'` on one axis hides the native bar on both, because no browser offers per-axis control of that; the grid warns once if the two axes disagree. Omitted, the grid is unchanged on upgrade. (optional)
columnTagFilterboolean | { multiple?: boolean; label?: string }Show a bar above the column headings for filtering columns by tag. Off by default, and it draws nothing unless some column carries a `tags` entry. `multiple: true` lets more than one tag be chosen at once. Only tagged columns are ever hidden, so an untagged account or total column stays visible whatever is selected. (optional)
anomalySummaryboolean | { column?: string; label?: string }Show a small chip in the grid chrome that reads how many rows an anomaly shadow column has flagged, and filters the grid to exactly those when it is clicked. Off by default, and it draws nothing unless a column declares a `shadow: { kind: 'anomalyFlag' }`. The count and the filter both read that one shadow column, so the number on the chip is the number of rows the click reveals. `column` names the base column to summarise when more than one anomaly-flag shadow is present; `label` overrides the chip's wording. (optional)
typeOptionsRecord<string, { weight?: string; significantFigures?: number; [option: string]: unknown; }>Per-column options a data type reads. `ratio` and `percentRate` use `{ weight }` to name the column their average is weighted by. A unit type reads `{ significantFigures }` to render to a fixed precision rather than a fixed number of decimals. (optional)
rowTemplatestring | { template: string; /** A class of your own on every card, alongside the grid's. */ className?: string; /** The layer's role. `list` by default; `listbox` for a selectable set. */ role?: string; /** Each card's role. `listitem` by default. */ itemRole?: string; /** A fixed number of cards on a line. Does not reflow. */ cardsPerRow?: number; /** A ceiling on card width; the number on a line follows the container. */ maxCardWidth?: number; /** Space between tiles, in pixels. 8 by default. */ gap?: number; }Draw each row as a card built from a declarative template instead of as a row of cells. The template compiles once into static and dynamic segments, so scrolling a thousand rows through a hundred pooled cards allocates nothing. A card is still a row - selection, the context menu, focus and drag reorder all work unchanged - but it has no columns, so the layer declares itself a list rather than a grid. (optional)
galleryboolean | { /** The tile layout. Generated from the columns when omitted. */ template?: string; /** How wide a tile aims to be; the count across follows the container. 240 by default. */ tileWidth?: number; /** How tall a tile is. 180 by default. */ tileHeight?: number; /** A fixed number of tiles on a line, instead of `tileWidth`. Does not reflow. */ cardsPerRow?: number; /** Space between tiles, in pixels. 8 by default. */ gap?: number; /** A class of your own on every tile, alongside the grid's. */ className?: string; /** The layer's role. `list` by default; `listbox` for a selectable set. */ role?: string; /** Each tile's role. `listitem` by default. */ itemRole?: string; }Present rows as a gallery of tiles (). The tiled card layout with a size-driven column count: tiles as wide as `tileWidth` allows, as many across as the container holds, laid out by the same 2-D virtualisation the grid already runs. `true` draws a tile per row generated from the columns; an object sizes them or supplies a template. Presentation only - sort, filter, group and the data pipeline are unchanged. (optional)
recordCardboolean | { /** The card layout. Generated from the columns as label/value pairs when omitted. */ template?: string; /** How tall a card is. 200 by default; a form needs room per field. */ cardHeight?: number; /** A class of your own on every card, alongside the grid's. */ className?: string; /** The layer's role. `list` by default. */ role?: string; /** Each card's role. `listitem` by default. */ itemRole?: string; }Present each row as a record card - a form of label/value pairs (). For a screen where reading one record matters more than comparing many. `true` draws a card per row generated from the columns, each column a labelled line in display order, showing the same text the table shows. An object supplies a template or sizes the card. A card list underneath, so it inherits the virtualisation and every interaction a card carries. Presentation only - sort, filter, group and the data pipeline are unchanged. (optional)
boardboolean | { /** The card layout. Generated from the columns as a tile when omitted. */ template?: string; /** How wide a lane is, in pixels. 280 by default. */ laneWidth?: number; /** How tall a card is, in pixels. 120 by default. */ cardHeight?: number; /** Space between lanes, in pixels. 16 by default. */ laneGap?: number; /** Space around a card within its lane, in pixels. 8 by default. */ gap?: number; /** A class of your own on every card, alongside the grid's. */ className?: string; /** The board's role. `list` by default; `listbox` for a selectable set. */ role?: string; /** Each card's role. `listitem` by default. */ itemRole?: string; }Present rows as a board - a kanban of grouped lanes of cards (). The top-level group becomes a lane and every leaf under it becomes a card stacked in that lane: a pipeline by stage, a task list by status, a backlog by owner. `true` draws a card per row generated from the columns; an object sizes the lanes and cards or supplies a template. Group the grid to give the board its lanes; an ungrouped board is a single lane of every card. A card is drawn through the same code the other card presentations use, so a board card is still a row: it clicks, selects and drags through the grid's own handlers, masks protected columns, and shows the same text the table shows. Both axes are virtualised - the lanes across and the cards down each - so a board of many long lanes renders only what is on screen. Presentation only: sort, filter, group and the data pipeline are unchanged. (optional)
pivotViewboolean | { /** How wide one value column is, in pixels. 120 by default. */ cellWidth?: number; /** How tall one body row is, in pixels. 32 by default. */ cellHeight?: number; /** How wide the row-label gutter is, in pixels. 200 by default. */ headerWidth?: number; /** Degrade to cards at or below this container width. 640 by default. */ maxWidth?: number; /** The card layout the narrow fallback uses. The record card when omitted. */ fallbackTemplate?: string | object; /** A class of your own on the pivot root, alongside the grid's. */ className?: string; /** The pivot's role. `grid` by default - a pivot is a grid of cells. */ role?: string; }Present the grid as a pivot - a cross-tab drawn as a matrix (,. The row dimensions (the grid's `group`) go down the left gutter, the column dimensions (the grid's `pivot`) go across the top, and each totalled column fills a cell with its reduction. `true` draws the matrix with the default geometry; an object sizes the cells and gutter or names the breakpoint below which it degrades to cards. **The numbers are the grid's own.** Every cell - body, subtotal, grand total - 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. Both axes expand and collapse, both are virtualised, and a cell click emits `pivot:drill` with the keys of the contributing rows. **Narrow-screen fallback.** A matrix cannot be read on a phone, so at or below `maxWidth` (the container width, not the viewport) the pivot degrades to a card list - the record card by default - exactly as the table does under `responsive`. Presentation only: sort, filter, group, pivot and the data pipeline are unchanged. (optional)
responsive{ /** Collapse at or below this container width. 640 by default. */ maxWidth?: number; /** The card layout, as `rowTemplate` takes it. */ template: string | object; /** How tall a collapsed card is. 64 by default, a table row is too short. */ rowHeight?: number; }Present rows as cards when the grid's container is too narrow to be a table honestly, a phone, or a narrow panel on a wide screen. Measured on the container, not the viewport, so a grid in a sidebar collapses and a grid filling a small tablet does not. Sorting, filtering and export continue to work; the tool panel is where they live when there are no column headings to click. Emits `presentation:changed`. (optional)
rowFormboolean | { mode?: RowFormMode; load?: (p: { row: Row; data: unknown; key: string; grid: Grid }) => unknown | Promise<unknown>; fields?: (string | { field: string; label?: string; /** Which editor to build, by registry name or constructor. Defaults to the column's, then the type's. */ editor?: string | (new () => object); type?: TypeName; props?: object; lookup?: LookupSpec; })[]; title?: string | ((p: { row: Row; data: unknown }) => string); width?: string; trigger?: false; /** How long to wait for `load`, in milliseconds. 2000 by default; `false` waits indefinitely. */ timeout?: number | false; /** * An element of your own to build the form in, instead of over the grid. * An element, a CSS selector or a function returning either; a selector is * resolved when the form opens, not when the grid is configured. A form in * your own container fills it, is a region rather than a modal dialog, and * does not trap Tab. */ container?: HTMLElement | string | (() => HTMLElement | string | null); }Open a row on a form - a drawer or a centred dialog with one control per field, a Save and a Cancel - on a double-click or through `grid.form.open()`. By default the fields are the grid's own columns, edited with the same editors the cells use; supply `load` to show a fuller record than the grid displays, in which case you must also say which fields and in what order. The panel opens immediately with a loading state and offers a retry on failure or on a load that never answers, rather than closing. Save applies the changed fields that map to columns and announces the rest - persisting the record is yours. (optional)
showColumnFunctionsbooleanDraw the sort, filter and menu controls in the column headings. `true` by default. `false` leaves each heading as its label alone, which is what a dense grid wants: three affordances take roughly fifty pixels, and on an eighty-pixel column that leaves the heading nothing and the label disappears entirely. Only the furniture goes. Sorting, filtering and the column menu are still reachable through the API, the keyboard and the tool panel. (optional)
headerControlsHeaderControlsVisibility 'hover' | 'always' | 'hidden' | 'none'When the per-column header controls - the sort arrow, the filter funnel and the menu button - are shown, as a default for every column. - `'hover'` (the default) reveals them when the heading is hovered or a keyboard user focuses into it, which is the historical behaviour: a wide header does not read as a row of identical icons. - `'always'` keeps them visible unconditionally, for a grid where the controls are the point and the discoverability of hover is not wanted. - `'hidden'` draws none of them, for a clean read-only heading; they leave the tab order with the elements that carried them. An active filter and a live sort are still reflected by the heading's state attributes, but no control furniture is built. A column that is actually sorted still shows a read-only sort arrow and multi-sort order number. - `'none'` is `'hidden'` with that last exception removed: the heading shows only its title, whatever the grid's state - no controls, no hover affordance, and no sort/filter/group badge even when the column is sorted, filtered or grouped programmatically or via a saved view. The column's own `aria-sort` still reports the truth; only the visual badge is gone. For a dashboard heading that must never change its own appearance, however the grid around it is driven. A column's own `headerControls` overrides this default for that column. Distinct from `showColumnFunctions: false`, which also drops the furniture but keeps sorting, filtering and the menu reachable from the keyboard; `'hidden'` and `'none'` are the read-only choices that remove them outright. (optional)
rowHeightnumber | ((row: Row) => number)Row height in pixels, or a function of the row. A function makes the grid measure rather than assume, which costs a pass over what is on screen: worth it for wrapped text, wasteful for a uniform grid. (optional)
titlestringA caption for the grid, drawn above the column headings. Inside the grid rather than an element the host places above it: a title outside does not scroll with the grid, is not in the region a screen reader announces, and is left behind by image capture and print. (optional)
showHeaderbooleanDraw the column headings at all. `true` by default. `false` removes the row, and removes it from the accessibility tree rather than only from view, a heading a screen reader still announces is invisible, not hidden. What a small dashboard tile wants when its `title` already says what the panel is. Distinct from `showColumnFunctions`, which keeps the headings and drops only the sort, filter and menu controls inside them. (optional)
headerHeightnumberHeader height in pixels. Omitted, the header takes its height from the density-scaled `--lattice-header-height` token, so `density` sizes the header as it sizes the rows. A number names one explicitly and outranks the token. (optional)
overscannumberHow many rows to render beyond the viewport. More costs memory and smooths fast scrolling; fewer is lighter and can show a gap. (optional)
autoHeightboolean | 'visible'Size rows to their content rather than to the density token. Only rows that are actually rendered are ever measured, in both settings: the grid does not lay out rows you cannot see. The difference is what happens on a large grid: `true` gives up above ten thousand rows and falls back to fixed heights, because a cumulative offset array being patched as you scroll a million rows is not worth the result. `'visible'` keeps measuring at any size, accepting that the scrollbar shifts as rows are measured on the way past. The name is historical and reads as though it were about which rows are measured; it is about whether the ceiling applies. (optional)
stateGridStateSort, filters, grouping, widths and the rest, restored at construction. Takes precedence over a saved view flagged `isDefault`: when both are present, this wins outright and the default view is never applied - the active view id stays `null`. (optional)
licencestringYour licence key. Without one the grid renders in full and watermarks off localhost. (optional)
maximisebooleanOffer a full-screen control. (optional)
formulaFunctionsRecord<string, (args: unknown[]) => unknown>Extra functions a formula may call, on top of the built-in library. (optional)
allowUnsafeTemplatesbooleanPermit raw HTML from a template without sanitising it. Off, and worth leaving off: a template usually interpolates data, and data is where injected markup arrives from. (optional)
updates{ logLimit?: number; logRows?: number; /** * When a queued batch applies. `frame` (default) lands on a paint * boundary, which is what makes one repaint per batch reliable; * `microtask` at the end of the current task; `interval` on the coalescing * window; `manual` only when you call `grid.updates.flush()`. */ flush?: UpdatesFlushMode; /** Queued rows that force an early flush regardless of strategy. */ maxQueued?: number; /** Milliseconds one flush may spend before deferring the rest. */ budgetMs?: number; }Caps on the change log behind `grid.updates` and `grid.timeline`. Two caps, because an entry is not a fixed size: `logLimit` bounds how many changes are kept (default 2000) and `logRows` bounds the rows they account for between them (default 100,000). A feed delivering large batches reaches the second long before the first, and without it the log is unbounded in bytes while looking bounded in entries. (optional)
commentsCommentConfigThreaded comments on individual cells. Requires a stable `rowKey`: comments outlive the values they annotate, and index identity would reattach every thread on the next sort. (optional)
presencePresenceConfigCollaborative presence. A display feature over a transport the grid does not own; without a provider it is inert. (optional)
facetsFacetConfig | booleanColumn header histograms and the filters clicking them creates. Off by default: the band roughly doubles header height, which is a cost no grid should pay without asking. Per-column settings layer over these. (optional)
hostFilter{ active(): boolean; passes(row: Row): boolean }A filter your application owns, applied alongside the grid's own and invisible to its filter UI. (optional)
contextunknownAnything of yours, passed untouched to renderers, editors and sources. (optional)
workerThresholdnumberRow count above which eligible work is computed in a Worker: column distributions, and a portable sort (a built-in collation with no custom comparator). Below it, everything runs on the main thread. (optional)
useWorkerbooleanCompute eligible work off the main thread: column distributions, and a portable sort above {@link GridConfig.workerThreshold} (a re-sort recomputes off-thread while the grid keeps showing the prior order, then swaps to the new one when it lands). Filtering and grouping still run on the main thread. (optional)
workerUrlstringWhere to load the worker kernel from, when hosting it yourself. (optional)
sharedMemorybooleanUse a shared buffer for the worker, where the page's headers allow it. (optional)
groupFooterbooleanA totals line at the foot of each group as well as the grid. (optional)
groupDefaultExpandedboolean | number | ((group: GroupInfo) => boolean)Which groups start expanded, before anyone has opened or closed one. `true` (the default) opens every group, `false` closes every group, a number opens the first N levels (`0` closes everything, a negative opens every level), and a predicate answers per group - the current sprint's section open while the rest start closed. Only ever consulted for a group nobody has touched: once the user or your code expands or collapses one, that decision stands. (optional)
grandTotalRowboolean | 'bottom'Where the grand total goes. `true` adds it as the last display row, counted by `rows.count()` like any other. `'bottom'` pins it beneath the viewport instead, so it stays in view while the rows scroll and is *not* part of `rows.count()`. Omitted or `false` means no grand total row. (optional)
pinnedTopRowsunknown[]Rows pinned above the scrolling body. The objects are rendered through the ordinary column pipeline but are not part of the data: not counted by `rows.count()`, not sorted, filtered, grouped, selectable or exported. Use it for a totals line or a units row that must stay against the header. (optional)
pinnedBottomRowsunknown[]Rows pinned below the scrolling body. As `pinnedTopRows`, at the other edge. (optional)
fullWidth{ when: (row: Row) => boolean; /** * Return a string for text, or a node for content. Return nothing and * write into `params.element` yourself. An HTML string is deliberately not * accepted: see `allowUnsafeTemplates` for that decision elsewhere. */ render: (params: FullWidthParams) => string | Node | void; }Rows drawn as a single band across every column instead of being divided into them, a section banner, a note, a "load more" affordance. `when` picks the rows; `render` fills them. A full-width row is still one of your data rows: counted by `rows.count()`, sorted, filtered and exported like any other. Only its presentation changes. For a row that should *not* be part of the data, use `pinnedTopRows`. (optional)
totalFilteredOnlybooleanTotal what the filters left rather than the whole set. (optional)
totalOnlyChangedColumnsbooleanOn a change, recompute only the totals whose column moved. (optional)
showTotalInHeaderbooleanPut the total in the header rather than a footer row. (optional)
aggregateChooserbooleanLet the user pick a column's reduction from the column menu. On, the totalling entry becomes an "Aggregate" submenu offering the aggregates the column's type says are meaningful (); off, the menu keeps its plain "Total this column" toggle. Off by default, so an existing grid is unchanged. (optional)
columnVirtualisationAbovenumberRender only the visible columns once there are more than this many. (optional)
statusBarboolean | { panels?: string[] }The bar beneath the grid, and which panels it carries. (optional)
contextMenuboolean | ((p: CellMenuParams, defaults: MenuItem[]) => MenuItem[] | void)The cell right-click menu. A function supplies custom items; `false` suppresses it entirely, which is what a read-only grid wants, the default menu offers Paste, Clear and Fill down. (optional)
importboolean | ImportSettingsBringing rows in from a file, the clipboard or a drop (, the mirror of export). `true` adds a "Import rows from CSV…" item to the cell menu, makes the grid a drop target for `.csv`/`.tsv` files, and reads a pasted spreadsheet block, each opening a preview the user confirms. An object tunes the affordances. Off by default; the `grid.import` API is always present. Import is a client-side data operation, so it applies to a memory grid. (optional)
rowDeletebooleanEnable the built-in row-delete gesture (, - the Delete/Backspace key on selected rows and a "Delete row" cell-menu item - and the `grid.edit.deleteRows` API. Off by default, because deleting data on a keystroke is destructive and opt-in. Every deletion flows through the cancellable `beforeDelete` event, so a handler can confirm or veto it, on a memory-source grid as well as a remote one. (optional)
columnMenuboolean | ((p: ColumnMenuParams, defaults: MenuItem[]) => MenuItem[] | void)The header's 3-dot menu, and the right-click menu on a column heading. `false` suppresses both. A function supplies custom items, receiving the grid's own so it can add to them rather than reproduce them. Default true. (optional)
rangeChart| boolean | ((grid: Grid, range: CellRange) => void) | { onChart?: (grid: Grid, range: CellRange) => void }Chart a selected cell range - the spreadsheet "chart this selection" gesture. Off by default, so a grid opts in. The DOM layer draws no charts itself - the charts module is optional and loaded by the host - so this is where the host wires the two together: a function, or an object carrying `onChart`, is called with the grid and the selected range when the reader chooses "Chart selection" from the cell menu. The handler typically calls `chartRange` from `lattice-grid/modules/charts`. `true` offers the item and emits nothing extra; supply a handler to have it actually draw. (optional)
shortcutsbooleanThe `?` keyboard shortcut overlay. `false` suppresses it, for a host that wants `?` for itself. Default true. (optional)
findboolean | FindConfigThe in-grid find bar: Ctrl+F / Cmd+F with focus in the grid opens it; typing highlights every matching cell in place without filtering a row away; Enter and Shift+Enter step through the matches. `false` removes the bar and its shortcut; the `grid.find` API still works. Default true. (optional)
rowReorderboolean | { column?: string }Let a user reorder rows by dragging a handle, or with Alt+Shift+Up/Down. `true` puts the handle in the first visible column; `{ column }` names a different one. The move reorders your data and emits `row:moved`; persisting it is yours, and `rows.data()` afterwards is the new order. Refused, with a reason announced, while a sort, filter or grouping is active, the position a row is dropped at has no single meaning in the underlying order then. (optional)
rowTransferboolean | { send?: boolean; receive?: boolean; mode?: RowTransferMode; group?: string; }Let rows be dragged out of this grid, into it, or both. Off by default: rows leaving a grid is a data change a host has to want, and a mis-drag that silently removed one has no gesture a user would think to undo. `send` and `receive` are both on when the option is present, so one-way is expressed by turning off the direction you do not want, a source grid is `{ receive: false }` and a target is `{ send: false }`. `mode: 'copy'` 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. The source needs `rowReorder` as well, since that is what draws the handle a drag starts from. (optional)
alignedGridsunknown[]Other grids to stay column-aligned with. Column widths, order, visibility and pinning are shared, and horizontal scrolling moves them together. Sort, filters, selection, grouping and the rows themselves stay independent: sharing those would make one grid with extra steps rather than two aligned ones. Declared on the grid created last, since it is the only one that can name the others; the link is peer-based once made. (optional)
stickyGroupHeadersboolean | number | { depth?: number }Keep the enclosing group headings pinned above the viewport while scrolling inside a group. Off by default - a deliberate product default; sticky group headers are opt-in. `true` turns it on, stacking at most two; a number, or `{ depth }`, sets how many may stack: each costs a row of viewport, so a deep grouping would otherwise spend the screen describing itself. `false` is off, the same as leaving it unset. (optional)
highlightOnChangeboolean | string | { colour?: string; color?: string; /** Milliseconds. `0` leaves the highlight until it is cleared. */ duration?: number; enabled?: boolean; }Flash a cell when its value changes. `true` takes the defaults; an object names a colour, a duration in milliseconds, or both. (optional)
formattingRecord<string, FormattingRule[]>Conditional formatting rules the grid holds as runtime state, keyed by column id or `'*'` for every column (spec 8.12). Seeds `grid.formatting`, which an end user can then change; the rules travel in saved views and undo like any other change. Config-time `cell.style` is unaffected. (optional)
rowClassstring | string[] | ((p: RowStyleParams) => string | string[])A class, or classes, for every row. Re-evaluated on each repaint. (optional)
rowStyleCellStyle | ((p: RowStyleParams) => CellStyle)Inline styles for every row. Camel-case or hyphenated property names. (optional)
toolPanelboolean | { /** Built-in names: `columns`, `filters`, `views`, `quick`, `formatting`. */ panels?: ToolPanelName[]; openPanel?: string; /** Which edge to dock against. `left` is the icon rail; default `right`. */ side?: ToolPanelSide; /** Icon-only tabs. Defaults to true for `side: 'left'`, false otherwise. */ icons?: boolean; /** * Rail action buttons: `undo`, `redo`, `export`, `restore`. Defaults to all * four on the left rail and none on the right; `false` drops them. */ /** * Which action buttons the rail offers, in order. `false` drops them. * * The built-in names are `undo`, `redo`, `pause`, `restore`, `maximise`, * `export`, `excel`, `clipboard` and `print`, plus `'-'` for a divider. * A {@link RailAction} object places one of your own among them. */ actions?: false | (RailActionName | '-' | RailAction)[]; /** File name for the export action, without the extension. */ exportName?: string; /** * Put the native annotation tools - pen, arrow, rectangle, highlighter - on * the rail. Each is a real toggle button that shows pressed while it is the * tool in use and turns off when pressed again. Three states, not two: * `true` opts in and keeps the tools on the rail always, presentation or * not; `false` opts OUT and the tools are never added, not even for a * presentation; omitted keeps the default, where the tools are off until a * presentation starts, appear for its duration, and leave when it ends. */ annotate?: boolean; }Dock the side panels against the grid: columns, filters, views, quick search and formatting. The columns panel is where row grouping, values and pivot are assembled by drag, which is why it carries three drop zones as well as the visibility list - a column opts out of each with `allowGroup`, `allowPivot` and `allowTotal`. (optional)
groupPanelboolean | { /** Placeholder shown while nothing is grouped. */ hint?: string; }A drag-and-drop group-by strip above the column header - the pattern AG Grid calls the row-group panel. Drag a column heading into it to group by that column; the active groups show as removable, reorderable chips, and reordering the chips changes the nesting order. It is keyboard-operable (arrows navigate, Shift+arrow reorders, Delete ungroups, and an add control groups any column), and every change is announced through the live region, which is why it also addresses the drag-only complaint. Off by default and non-breaking, matching `toolPanel`. It drives the same grouping model as `grid.columns.group()`; it reimplements nothing. (optional)
kpisArray<Omit<StatConfig, 'grid' | 'container'>>A built-in KPI/stat strip: a labelled band of {@link createStat} tiles the grid places for you, above the column header. Each entry is a stat spec - the same fields {@link StatConfig} takes, minus `grid` and `container`, which the grid supplies - so a strip tile and a hand-placed one are the same object. The tiles follow the grid's filters, recomputing on every change exactly as a stand-alone stat does. Off by default and non-breaking, matching `groupPanel`: no `kpis` means no band and no cost. It reuses `createStat` and reimplements no compute. (optional)
quickFilterTextstringThe quick filter's initial text. (optional)
permissionsPermissionPolicyPer-column read/write/hidden policy. A usability control, not a security boundary: hidden data is still resident in the store. Enforce the same policy server-side with `permittedColumns` / `permittedExport`. (optional)
diff{ snapshot?: unknown[] | Map<string, unknown>; strictNull?: boolean; addedColumns?: DiffAddedColumns; /** * Whether a row present in the snapshot but gone from the data is shown, * and whether it counts as data when it is. * * `false` (the default) leaves it out entirely. `'pinned'` shows it * beneath the rows, struck through: visible history that is not part of the * row set, so it is excluded from `rows.count()`, from exports and from * selection. `'data'` appends it to the row set instead, so it *is* * counted and exported. * * Neither is sorted or filtered among the live rows: a removed row's values * are the snapshot's, and ordering yesterday's numbers among today's would * present two data sets as one. Neither can be edited: there is nothing * left to write to. */ removedRows?: false | 'pinned' | 'data'; }Prior state for diff and audit mode. (optional)
views{ storage?: { read(): unknown[]; write(views: unknown[]): void }; saved?: unknown[] }Saved views: a storage adapter and any pre-loaded views. (optional)
historyBarboolean | { element?: HTMLElement; timeline?: boolean }The undo toolbar. `element` mounts it into the host's own chrome. (optional)
ai{ ask: (p: { /** The full text to send: the schema description and the question together. */ prompt: string; /** The grid's schema as data: columns, types and operators. No row values. */ schema: unknown; /** The same schema rendered as text, which is what `prompt` embeds. */ schemaText: string; /** What the user typed. */ message: string; context?: unknown; }) => Promise<unknown>; schemaOptions?: object; context?: unknown; element?: HTMLElement; placeholder?: string; }The AI skill layer. The grid makes no network call of its own: `ask` is the host's, and owns the model, the key and the privacy decision. (optional)
pivot{ enabled?: boolean; /** * Add a column group totalling every value column across all pivot values, * the grand total beside the pivoted ones. `'before'` places it at the near * edge, `'after'` at the far edge. Omitted or `false` adds none. */ groupTotals?: PivotGroupTotalsPlacement | false; /** Heading for that group. Defaults to `Total`. */ totalsLabel?: string; maxColumns?: number; separator?: string; }Pivot-mode settings: whether the grid starts pivoted, whether to add a group of grand-total columns beside the pivoted ones and what to call it, the separator joining a group's values in a generated column, and `maxColumns` - the ceiling on generated columns, 500 by default. Past it no pivot columns are produced at all and a clear error is reported, rather than a high-cardinality pivot locking the browser. (optional)
MethodSignatureParametersReturnsDescription
environment() => Record<string, unknown> - => Record<string, unknown>Host environment for a support bundle. Supplied by the DOM layer; core cannot read `navigator` or `window` itself. (optional)
groupRenderer(params: GroupRowParams) => string | Node | voidparams: GroupRowParams=> string | Node | voidDraw the group row yourself. The grid's own group row is an expander, a label and a count. A host that needs more - a section header with a points rollup, a done/total count and a progress bar - supplies this instead, and owns the whole row: it is drawn as one band across every column, and no ordinary cells are mounted for it. Return an HTML string, or a node, or write into `params.element` and return nothing. Unlike `fullWidth.render`, a string here **is** inserted as markup, on the same footing as the board's `cardRenderer`: this is your own template for a row the grid synthesised, not a value out of your data. The chevron is yours to draw and yours to wire: give any element in your markup `data-lat-group-toggle` and a click on it expands or collapses the group, or call `params.toggle()` from a node you built yourself. (optional)

Column

PropertyTypeDescription
tagsstring | string[]Free-form labels for grouping columns together. A bare string is accepted for a single tag. Used by the column tag bar to show and hide sets of columns: tag sixty monthly columns with their year, and a user can switch to one year. (optional)
idstringThe column's own identity. Defaults to `field`; needed explicitly when two columns read the same field, as a value and its running total do. (optional)
fieldstringThe property to read from each row. Dotted paths reach into nested data. (optional)
titlestringThe heading. Defaults to a readable form of `field`. (optional)
typeTypeName | falseThe data type, which decides parsing, formatting, sorting, the default editor and the default filter together. `false` turns inference off and treats the values as opaque. (optional)
presetstring | string[]Named column presets to merge in first, so a house style is declared once. (optional)
formatFormatSpec | stringHow a value is rendered as text. A string is a shorthand mask. (optional)
lookupLookupSpecDisplay a stored code as a label, and edit it as a list. (optional)
valueColumnValueSpecA computed value, with the columns it depends on, in place of a stored one. (optional)
cellColumnCellSpec | stringThe renderer, and what it is given. A string names a registered renderer. (optional)
editColumnEditSpec | boolean | stringWhether and how the cell can be edited. A string names an editor. (optional)
validationColumnValidationDeclarative edit-validation rules. Each is checked against a value before it is written, through the `beforeEdit` before-event: a failing value cancels the commit and marks the cell. Distinct from and complementary to `edit.validate`, which is an imperative function. (optional)
sortColumnSortSpec | booleanWhether the column sorts, and by what comparison. `false` refuses it. (optional)
filterColumnFilterSpec | boolean | FilterNameWhether the column filters, and with which filter. A string names one. (optional)
quickFilterbooleanWhether the column takes part in the quick filter (the single search box across every column). Defaults to `true`, independently of `filter`: `filter.enabled` turns off the column's own funnel/menu control and has never governed quick search, which reads across columns rather than filtering one. Set this `false` to drop a column from quick search while leaving its funnel alone, or leave both alone for the common case. **Behaviour change:** before this, `filter: { enabled: false }` also removed the column from quick search as a side effect. A host that relied on that coupling to keep a column out of quick search must now set `quickFilter: false` explicitly; `filter.enabled` no longer touches quick search at all. (optional)
group{ enabled?: boolean; index?: number; explode?: boolean; granularity?: ColumnGroupGranularity; weekStart?: number; } | booleanRow grouping by this column. `index` fixes its place among several; `explode` gives a multi-value cell one group per value rather than one group for the combination. `granularity` and `weekStart` apply to a `timestamp` column: it buckets by civil `day` (the default), `week` or `month` in the display zone, or `instant` for one group per exact moment. `weekStart` is the first weekday, 1=Monday (default) to 7=Sunday. (optional)
pivot{ enabled?: boolean; index?: number } | booleanUse this column as a pivot dimension, and where it sits among several. (optional)
totalTotalName | TotalFnThe reduction shown in the totals row and in group footers. (optional)
groupTotalTotalName | TotalFnThe reduction for group subtotals - group footers, tree-node rollups and pivot cells - where it should differ from the grand total. Overrides `total` for those scopes only; when omitted the column's `total` applies to both. Lets a column average within each group while the grand total sums, for example. (optional)
grandTotalTotalName | TotalFnThe reduction for the pinned grand-total row, where it should differ from the group subtotals. Overrides `total` for the grand total only; when omitted the column's `total` applies. (optional)
shadowShadowKind | { of?: string; kind: ShadowKind; /** * For `kind: 'history'`, how many past readings to keep (20 by default). With * a time `window` this is instead how many buckets the span * divides into - `window: {kind: 'time', span: 60_000}, depth: 20` is sixty * one-second buckets. Ignored by every other kind. */ depth?: number; /** * For a positional kind, what to rank against. `'all'` (the default) uses * every tracked row, so a rank does not move when the grid is filtered; * `'filtered'` ranks within what the filters left. */ scope?: RowScope; /** * For `kind: 'anomalyFlag'`, the modified-z score a row must clear to be * flagged an anomaly. Default 3.5 (Iglewicz & Hoaglin). Ignored by * `anomalyScore`, which reports the raw score, and by the other kinds. */ threshold?: number; /** * For `kind: 'specStatus'`, the hard specification the row is judged * against. `lower`/`upper` are the pass limits (a value beyond either * fails); the optional `warnLower`/`warnUpper` are inner thresholds that * mark a still-in-spec reading `'WARN'`. Centred-target ± tolerance is a * deliberate follow-up and is not read here. */ lower?: number; upper?: number; warnLower?: number; warnUpper?: number; /** * For a rolling time-series kind (`rollingSum`/`rollingAvg`/`rollingMin`/ * `rollingMax`/`windowCoverage`/`cumulativeToDate`/`periodOverPeriod`, *, the column whose order defines the series - dates, * sequence numbers, timestamps. **Required**: the screen sort is never used, * because a rolling figure would then change on every header click, so a * rolling column with no `orderBy` reports null and warns. */ orderBy?: string; /** * For the rolling window kinds, the window to aggregate over: the last `span` * rows (`count`), the last `span` ms - or `minutes` - of the `orderBy` axis * (`time`), or everything so far (`session`). The first rows of a series * carry a partial window, stamped by a `windowCoverage` companion rather than * dressed as full. * * For `kind: 'history'`, only `{kind: 'time', span}` (or * `minutes`) applies, and it changes what `history` means rather than what it * aggregates: the `depth` buckets that span divides into are read once each, * carrying the row's last known value forward into any bucket in which it did * not change, so a static row still draws a flat, advancing line instead of * freezing - the plain count-based history (no `window`) is a count of * *changes* and stays exactly as it was. `count` and `session` are refused * here: a plain count is already what `depth` means, and a session has no * fixed span to divide into buckets. */ window?: { kind: WindowKind; span?: number; minutes?: number; }; /** * For a rolling kind, whether the series is computed per group (`'group'`, * the default - partitioned by the grid's active grouping) or across the * whole dataset (`'all'`). */ within?: ShadowWithin; /** * For `kind: 'rollingQuantile'` (and its `windowApproximate` companion), the * quantile in `[0, 1]`, defaulting to the median (`0.5`). Exact while the * window is small; past an internal span cap, and for a session window, the * value comes from a sketch and is stamped by a `windowApproximate` column. */ q?: number; /** * For a seasonal-decomposition kind (`tsTrend`/`tsSeasonal`/`tsResidual`/ * `tsCoverage`,, the season length - **required**, since * there is no auto-detection in v1: 7 for a weekly cycle in daily data, 12 * for a monthly cycle in monthly data. An integer of at least 2. */ period?: number; /** * For a decomposition kind, the classical model: additive by default, or * `multiplicative` (which is undefined on a non-positive series, so those * rows report null and the caller is warned). */ decomposition?: ShadowDecomposition; /** * For an exponential-smoothing kind (`tsSmoothed`/`tsSmoothingAlpha`/ * `tsSmoothingBeta`,, the model: single exponential * smoothing (`ses`, the default) or Holt's level+trend (`holt`). */ smoothing?: SmoothingMethod; /** * For a smoothing kind, the level factor in `[0, 1]`. Omit to fit it by * minimising in-sample SSE; the chosen value is reported by a * `tsSmoothingAlpha` column. */ alpha?: number; /** * For `smoothing: 'holt'`, the trend factor in `[0, 1]`. Omit to fit it; * reported by a `tsSmoothingBeta` column. */ beta?: number; /** * For a `fit*` kind, the regression model the shadow reads * - predictors, response, method and confidence. Its predictors/response may * also be given directly on this object. */ model?: RegressionSpec; predictors?: string[]; response?: string; method?: RegressionMethod; }A value the grid maintains about this column's own history, rather than a field in the data. `{of: 'price', kind: 'delta'}`, or the bare kind to shadow the column it sits beside. (optional)
runningRunningTotalMode | { of?: string; kind?: RunningTotalMode }A running total down the grid **as it is currently ordered**. The one derived value that depends on the display order: sort differently and every value changes. That is why it is not a shadow kind: every shadow reads the same however the rows are arranged. (optional)
spec{ lower?: number; upper?: number; target?: number }The customer's tolerance, for process capability and control charts. Declared here rather than passed to each call so the capability figures, a control chart and any rule marking an out-of-tolerance cell cannot disagree about what the tolerance is. (optional)
layoutColumnLayoutSpec | numberWidth, pinning and flex. A bare number is the width in pixels. (optional)
headerColumnHeaderSpec | stringThe header cell: its text, tooltip, menu and any header chart. (optional)
contextMenuboolean | MenuItem[] | ((p: CellMenuParams, defaults: MenuItem[]) => MenuItem[] | void)The cell right-click menu for this column alone, in the same shapes the grid-level `contextMenu` takes plus a bare array for the common "just these items here" case. Declared where the column is declared rather than as another branch inside one grid-level callback: the menu logic for a column belongs beside the column it belongs to. It does not replace the grid-level menu - the three levels compose as a chain, built-in defaults then grid-level then this one, each handed the previous result as its `defaults`, so a column adding one item does not have to restate Paste, Clear and Fill down. `false` suppresses the menu on this column and leaves every other column alone: what a sensitive or read-only column wants. The more specific level wins, so a column may also declare a menu on a grid whose `contextMenu` is `false`. (optional)
headerControlsHeaderControlsVisibility 'hover' | 'always' | 'hidden' | 'none'When this column's header controls - its sort arrow, filter funnel and menu button - are shown, overriding the grid-level `headerControls` default for this column alone. `'hover'` reveals them on hover or focus, `'always'` keeps them visible, `'hidden'` draws none of them and leaves them out of the tab order, but still shows a read-only sort badge when the column actually is sorted. `'none'` goes further: the heading shows its title and nothing else whatever the grid's state - no controls, and not even `'hidden'`'s read-only sort badge; the column's `aria-sort` still reports the truth, only the visual is gone. Omitted, the column follows the grid default, which is itself `'hover'`. (optional)
verticalAlignVAlign 'top' | 'middle' | 'bottom'Vertical alignment of this column's cell content within the row. Overrides the grid-level `verticalAlign` for this column alone; `top`, `middle` or `bottom`. Also accepted as `cell.verticalAlign`, the way `align` is. Omitted, the column follows the grid default. (optional)
showWhenShowWhen 'open' | 'closed' | 'always'When this leaf column is shown, the same union `ColumnGroup` declares. A leaf reads its own `showWhen` exactly as a group reads its own - `open`/`closed` tie the leaf to an ancestor group's collapsed state, `always` (the default) shows it regardless - so tying a leaf's visibility to a group's open/closed state does not require wrapping it in a `ColumnGroup` of its own just to hold this setting; a wrapper is for grouping columns, not for this. (optional)
exportColumnExportSpecHow the column leaves the grid, where that differs from how it is shown. (optional)
allowGroupbooleanWhether the user may group by this column from the interface. (optional)
allowPivotbooleanWhether the user may pivot on it. (optional)
allowTotalbooleanWhether the user may put a total on it. (optional)
nullablebooleanWhether an empty value is a legitimate value rather than a gap. (optional)

ColumnGroup

PropertyTypeDescription
idstringA stable identity for the band, which `renameGroup`, `dissolveGroup` and `moveGroup` take and which a saved view keys the band's open state by. The grid generates one when you do not. (optional)
titlestringThe heading drawn across the band. Also the fallback identity when the band has no `id`.
columns(Column | ColumnGroup)[]The band's children, in order: leaf columns, further bands, or a mix - the header nests as deeply as the tree does.
collapsiblebooleanGive the band a control that opens and closes it. Off by default; a band that is not collapsible passes its ancestor's open state straight down. (optional)
openByDefaultbooleanWhether a collapsible band starts open. Open unless set to `false`; once the user has toggled it, their choice stands. (optional)
showWhenShowWhen 'open' | 'closed' | 'always'When this node is shown relative to the enclosing collapsible band: `'open'`, `'closed'`, or `'always'` (the default). This is how a band shows a detailed set of columns when open and a single summary column when closed. A leaf column may declare it too. (optional)
marryChildrenbooleanKeep this band's columns together: a move that would take one of them out of the band's run, or drop a column from outside into it, is refused with a warning. Off by default, in which case a drag, a keyboard move or the column menu may separate them. (optional)
header{ render?: string | RendererCtor; props?: Record<string, unknown>; class?: string | string[] }Presentation for the band's own heading cell: a custom renderer, the props handed to it, and classes to add. (optional)
facetColumnFacetConfig | booleanThis column's histogram. `true` turns it on with the grid's settings. (optional)

MemorySourceConfig

PropertyTypeDescription
mode'memory'Selects the in-memory source: the grid holds every row and answers sort, filter, group, pivot and totals itself.
columnarBelownumberThe row count below which the grid skips columnar storage and keeps plain row objects. 5,000 by default - columnarising a small grid costs more than it saves. (optional)
rowsunknown[]The rows a memory source opens with; equivalent to top-level `rows`, which wins if both are given. (optional)

PagedSourceConfig

PropertyTypeDescription
mode'paged'Selects the paged source: block-based lazy loading over a flat list, with sorting and filtering delegated to the server.
pageSizenumberHow many rows are fetched per block. 100 by default. (optional)
maxCachedPagesnumberHow many blocks are kept before the least recently used ones outside the viewport are evicted. 32 by default. (optional)
MethodSignatureParametersReturnsDescription
fetch(req: { range: { start: number; end: number }; sort: SortEntry[]; filters: FilterSet; quick?: string; context: unknown; signal: AbortSignal; }): Promise<{ rows: unknown[]; total?: number }>req: { range: { start: number; end: number }; sort: SortEntry[]; filters: FilterSet; quick?: string; context: unknown; signal: AbortSignal; }Promise<{ rows: unknown[]; total?: number }>Answer one block: the rows for the requested range, and the total when known. Changing the sort or the filters invalidates every block, since the server may return an entirely different window for the same range.

RemoteSourceConfig

PropertyTypeDescription
mode'remote'Selects the remote source: the grid holds a window of rows and asks the server to sort, filter, group, pivot and total.
pageSizenumberHow many rows are fetched per block. 100 by default. (optional)
maxCachedPagesnumberHow many blocks are kept before the least recently used ones outside the viewport are evicted. 32 by default. (optional)
MethodSignatureParametersReturnsDescription
fetch(req: RemoteRequest): Promise<RemoteResult>req: RemoteRequestPromise<RemoteResult>Answer one block. The request is a published protocol - version, range, group path, sort, filters, pivot, totals and the abort signal - so a server implementation targets the documented shape rather than guessing.

StreamSourceConfig

PropertyTypeDescription
mode'stream'Selects the streaming source: rows arrive over time and the grid keeps rendering as they land.
maxRowsnumberThe most rows to keep. A stream has no end, so an unbounded grid dies overnight; this makes it a sliding window and the oldest rows are dropped. Omit for no limit. Set on the source, not passed to `open`, it bounds what the grid retains rather than what the producer sends. (optional)
maxAgenumberThe longest a row is kept, in milliseconds - a rolling *time* window, sitting beside `maxRows` as a second, independent bound. Rows older than the span are evicted through the same path, the same `evicted` counters and the same `stream:evicted` event as the count bound, so an existing readout keeps working. Set both and whichever bites first applies. Eviction continues on a low-frequency timer while the feed is idle, so "the last five minutes" keeps shrinking through a silent period rather than freezing - which is the thing `maxRows` cannot do. Retention is a *bound, not a guillotine*: rows live a little past the span before a block is dropped. Two things add to it. First the eviction slack, ten per cent of the span, exactly as `maxRows` overshoots its count, so the row permutation is rebuilt once per block rather than once per 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. So the real ceiling is roughly `span * 1.1 + tick`, and because the tick has a floor it is proportionally larger the shorter the window: negligible at a five-minute window (about 10%), around 1.25x at ten seconds, and as much as ~1.35x at three. That is the deliberate trade for an idle grid that costs no CPU. Omit for no age limit. (optional)
ageBystring | ((row: unknown) => unknown)Which clock `maxAge` reads: a column id (or dotted path), or a function of the row returning a `Date`, epoch milliseconds, or an ISO string. Given, the window follows the **data's own** clock, so it means what the producer means - and inherits the producer's clock skew. Omitted, `maxAge` falls back to **arrival time**: when the row reached this source. Arrival time needs no timestamp column and cannot be skewed, but it is not event time - a row delayed in transit counts as young. A row whose time value cannot be read is never aged out. (optional)
promoteToMemoryBelownumberWhen the feed ends with fewer rows than this, the grid adopts them as an ordinary in-memory source, so later sorts and filters are local. Defaults to 250,000. A store-backed stream is never promoted. (optional)
coalesceMsnumberHow long, in milliseconds, the grid may spend applying arriving chunks per frame. Defaults to 8 - roughly one frame's budget. Raise it to take more rows per frame at the cost of responsiveness. (optional)
MethodSignatureParametersReturnsDescription
open(req: { sort: SortEntry[]; filters: FilterSet; quick?: string; context: unknown; signal: AbortSignal; }): AsyncIterable<Chunk>req: { sort: SortEntry[]; filters: FilterSet; quick?: string; context: unknown; signal: AbortSignal; }AsyncIterable<Chunk>Opens the feed and returns an async iterable of chunks. It is called with the grid's current sort, filters, quick-filter text and context, and an `AbortSignal` that fires when the query changes or the grid is destroyed - stop producing when it does. Backpressure is the iterator protocol's: the grid awaits the next chunk.

DerivedSourceConfig

A grid whose rows are derived from another grid: aggregated, unnested, filtered, ranked or profiled. Read-only: write to the source instead.

PropertyTypeDescription
mode'derived'Selects the derived source: this grid's rows are computed from another grid's, and re-derived when that one changes.
fromGrid | UnionSourceOptions[]The grid to read, or several to combine into one row set before the rest of the pipeline runs. A bare `Grid` is shorthand for a `UnionSourceOptions` with no `label`/`follow`/`map` override, so an existing `from: <grid>` keeps meaning exactly what it always has. Given an array, every source is read (each narrowed by its own `follow`, defaulting to `'filtered'` as a lone `from` does today), concatenated in **declaration order** - deterministic, not interleaved - and only then does `unnest`/`join`/`where`/`bucket`/`groupBy`/`select`/`sort`/`limit`/ `limitPer`/`cumulative` run, over the combined set, so "the worst performers across both" is one derivation rather than a hand-merge. The output carries the **union of the sources' fields**: a field present on only one source is `undefined` on rows from the others. Sources are **not** type-reconciled - if two disagree on what a field means or holds, that is not resolved for you; give each source a `map` to project it into a common shape first. Every row also carries `__source` (the entry's `label`, or its declaration index when unlabelled), which is required - not optional - because without it a combined list cannot be read, filtered or grouped by where it came from; it is an ordinary field to `where`, `groupBy` and `select`. And because the derived key (`__key`) would otherwise collide across sources sharing the same identifiers, it is namespaced by the same source tag when nothing is grouped (a grouped union's `__key` is the group value, exactly as today, and rows from different sources correctly land in the *same* group when their group values agree - that merging is the point of grouping a union, not a collision to guard against). This is **not** a join: there is no dedup or merge-on-key, and it draws no UNION/UNION ALL distinction - overlapping rows from two sources simply both appear. Reach for `join` when two sides share a key and you want them matched rather than stacked. An empty source contributes nothing and the rest still combine; a source that fails to read is named in a `warnOnce` and skipped for that pass rather than silently dropped, because a silently missing source would make "worst across both" quietly wrong. A source list that includes the grid being derived, directly or through a chain, is refused when the source is built (naming the offender) rather than recursed into. `crossFilter` has no single target once there is more than one parent, so it is not supported alongside a union `from` (ignored, with a `warnOnce`, rather than guessing which parent to push onto).
followDerivedFollow 'filtered' | 'all' | 'selected' | 'grouped'Which of its rows to read. `filtered` by default. Ignored - with a `warnOnce` - when `from` is a union array: each entry there carries its own `follow` instead. (optional)
unneststringAn array property to expand, one row per element, before anything else. (optional)
joinDerivedJoinMatch each row against a second grid on a shared key, and bring some of its fields across. Runs after `unnest` and before `where`, so a condition: and a grouping, and a total: can read a field the join produced. (optional)
bucket{ of: string; by: 'day' | 'week' | 'month' | 'quarter' | 'year' }Round a date column down to a period, and group on that. (optional)
groupBystring | string[]The dimension, or dimensions, to group by. Omit to pass rows through. (optional)
selectRecord<string, DerivedSelect>The reduced columns, by output id. (optional)
sort{ col: string; dir?: 'asc' | 'desc' }[]How to order the derived rows before limiting them. (optional)
limitnumberKeep at most this many rows. (optional)
limitPerstringApply `limit` within each distinct value of this column, not overall. (optional)
cumulative{ of: string; upTo: number }Keep rows until their running share of the total reaches `upTo`, 0 to 1. (optional)
profilestring | string[]One row per column, with the statistics as columns. Replaces the pipeline. (optional)
orientDerivedOrient 'columns' | 'metrics'With `profile`, emit one row per statistic instead of one per column. (optional)
statisticsDerivedStatisticsProject a **relational** statistic into rows: the figures that need two or more columns, or a second grid, and so cannot be reached through `select`. Every *single-column* statistic already has a route and this is not it - the derived `select` reduces a group by any kernel the totals row uses, and that table is a superset of the statistics one, so `select: { p95: { of: 'amount', fn: 'p95' } }` (or `gini`, `stddev`, `median`, `trimmedMean`, …) works today. Reach for `statistics` only when the answer is a correlation, a series summary or a comparison against another dataset. **A terminal producer, like `profile`, not a pipeline stage.** A correlation is one row per column *pair*, a series summary one row per *metric*, a comparison one row per compared *column* - none of which is one row per group, so there is no position in `unnest → where → bucket → groupBy → select → sort → limit` for it to occupy. It replaces the pipeline, and those keys are ignored with a warning naming them rather than silently discarded. Sort, filter or limit the derived grid itself instead, 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. **Not supported alongside a union `from`** - a relational statistic reduces one grid's own columns and a union has no single set of them; also refused by name. **Cost.** Like every terminal producer this never patches incrementally: a change on the parent re-derives the whole thing. `correlation` additionally scans the rows once *per pair*, so N columns cost N·(N−1)/2 passes. Use `refresh` (`'idle'` is the default; `'manual'` or a debounce in ms for an expensive analysis over a live feed; under `'manual'` the host re-derives by calling `rows.load()` on the derived grid) - see `docs/api-detail.html` for the measured figures. Every row carries `n`, the rows the figure covered, because a derived statistic travels into an export or a chart without its grid and "r = 0.98 over eleven rows" is a different claim from the same number over eleven thousand. It does NOT carry a windowed/approximate flag: whether a source held fewer rows than matched its filters is decided from the source's own counters, which a derived source cannot reach, so that signal stays where it already works - the `stat.windowed:*` console warning the parent grid emits. (optional)
refreshDerivedRefresh | numberWhen to re-derive. `idle` by default: coalesced to a frame. A number debounces by that many milliseconds; `live` re-derives on every change. `manual` never re-derives on its own: the host triggers it by calling `rows.load()`, with no argument, on the derived grid - from a Refresh button, say. Each call re-reads `from` there and then and replaces the rows; a derived grid takes its rows from `from`, so anything passed to `load` is not used. Executed example: `docs/api-detail.html#derived-manual-refresh`. (optional)
crossFilterboolean | string | { col?: string }Let this grid filter the grid it derives from. `true` cross-filters through whatever it groups by; a string names a different source column. (optional)
MethodSignatureParametersReturnsDescription
where(row: unknown) => booleanrow: unknown=> booleanA row predicate, applied before grouping. (optional)

IngestConfig

How rows are ingested into the column store.

PropertyTypeDescription
retainSourcebooleanRetain the caller's row objects by reference so identity round-trips. Default `true`, the historical behaviour: `rows.data()` returns the exact objects you supplied, `row === sourceObject` holds, and a custom renderer reading `row.sourceObject` works. Set `false` to keep only the packed columns and reconstruct a plain row object from them on demand. This drops roughly half the resident footprint, but changes three behaviours: `rows.data()` returns freshly reconstructed objects (new object each call, so `row === sourceObject` no longer holds), a custom renderer that reaches for `row.sourceObject` gets a reconstruction rather than the original, and equality against a row becomes value-based. The stored values are unchanged, so `get()`, `byKey()`, `value()` and `values()` are unaffected. (optional)
dropSourceRowsbooleanRelease the caller's row objects from the *source layer* once the column store has been built, so the columns become the sole resident copy of the data. Default `false`, which keeps today's behaviour. `retainSource:false` stops the {@link https://en.wikipedia.org/wiki/Column-oriented_DBMS column store} from holding the caller's objects, but the memory source and the grid config still retain the supplied array by reference - so the objects stay alive and the resident footprint does not actually fall. This flag closes that gap: it clears `MemorySource`'s retained array and drops the array from the grid config, leaving nothing on the heap but the packed columns. That is where the large reduction comes from (roughly an order of magnitude at a million rows), not from `retainSource` on its own. Implies `retainSource:false`: dropping the caller's objects while the store still expects to read through them would leave the source with no data at all, so setting this on forces the store to reconstruct rows from columns. Every read is therefore served from the columns - `at()`, `byKey()`, `get()`, `value()`, `values()`, filtering, sorting, grouping, totals and export are all unaffected in their values. What changes is the same three identity behaviours `retainSource:false` documents: `rows.data()` returns freshly reconstructed objects (so `row === sourceObject` no longer holds), a custom renderer reaching for `row.sourceObject` gets a reconstruction, and equality against a row becomes value-based. One consumer cannot be served from the columns: an *impure computed column* (a shadow, or a rank/positional column) is deliberately never materialised into the store, so its handle is built by reading the source objects. Under `dropSourceRows` those objects are gone, so such a column reduces over nothing and warns once rather than returning a silently wrong figure. Do not enable `dropSourceRows` on a grid that sorts, filters, groups or totals on a shadow or a positional column. (optional)
useWorkerbooleanColumnize `stream`-source ingest on a Worker so a large load does not block the main thread. Default `false`. When on, an arriving chunk that clears {@link IngestConfig.workerThreshold} is packed into typed column buffers on the Worker; the main thread merges the finished buffers into the store and renders, without running the per-field extraction pass that otherwise dominates ingest. This makes **stream** ingest non-blocking (remote sources already are). Memory and paged sources cannot be made non-blocking this way - the main thread must read the caller's own row objects - and are unaffected. The effect composes with `retainSource: false`: with it off the source keeps no caller-object array on the main thread at all, so the load is both non-blocking and lighter on memory. A column that reads through a closure - a `date` column's storage conversion, or a computed column - cannot cross the Worker boundary, so a grid with any such column columnizes on the main thread and says so once. Falls back silently to the main thread wherever a Worker cannot be created. (optional)
workerThresholdnumberRow count in a single stream chunk at or above which columnization is offloaded to the Worker when {@link IngestConfig.useWorker} is on. Default `10000`. A smaller first chunk is packed on the main thread, where the cost is trivial and the postMessage round trip would only add latency to time-to-first-row. (optional)

DataType

PropertyTypeDescription
baseDataTypeBase 'text' | 'number' | 'boolean' | 'date' | 'dateString' | 'object'The storage family the type belongs to, which decides how a value is held, compared and exported. Inherited through `extends`, and `'text'` when neither says.
extendsTypeName 'text' | 'number' | 'boolean' | 'date' | 'dateString' | 'object' | 'lookup' | 'image'The type this one inherits from. `defaults` are merged rather than replaced, so a derived type can override one default and keep the rest. A circular chain fails at construction. (optional)
compareComparatorOrder two values of this type. Used for sorting unless the column supplies `value.compare` or is lookup-backed. (optional)
defaults{ filter?: FilterName; editor?: EditorName; total?: TotalName; align?: Align; /** The cell renderer this type's values are drawn with by default. */ render?: RendererName; }What a column of this type gets for free - its filter kind, editor, footer total, alignment and cell renderer - unless the column, a preset or `columnDefaults` says otherwise. `type: false` on a column turns this layer off. (optional)
storageDataTypeStorage 'float64' | 'int32' | 'bitset' | 'dictionary' | 'object'How the column store holds these values in bulk: a typed array, a bitset, a dictionary of codes, or plain objects. `'object'` when unset. (optional)
totals{ /** * The aggregates that mean something. A column of this type configured * with any other fails at construction rather than rendering a confident * wrong number. */ supported?: TotalName[]; /** * The type's own reduction for an aggregate, replacing the built-in * arithmetic. Receives the values index-aligned with their rows, and a * context carrying `column` and `valueAt(colId, i)` for reading another * column of the same row. */ implement?: Record<string, TotalFn>; }Which aggregates are meaningful for this type, and how. Omit it and every aggregate is allowed, which is what every type that shipped before this does. (optional)
excelstringThe Excel number format an export writes for this type, when the column's own formatter supplies none. `'General'` when neither does. (optional)
MethodSignatureParametersReturnsDescription
matches(value: unknown) => booleanvalue: unknown=> booleanRecognise a value as belonging to this type, for type inference from a sample. A type is inferred only when every sampled value matches. (optional)
format(p: FormatParams) => stringp: FormatParams=> stringTurn a value of this type into display text. Last in the display chain, behind the column's `value.format` and its `format` spec. (optional)
parse(p: ParseParams) => unknownp: ParseParams=> unknownRead the formatted form back into a value - what a paste and a text editor go through. It is a *text* parser: the grid does not put an already-typed editor value through it. (optional)
toClipboard(v: unknown) => stringv: unknown=> stringTurn a value of this type into the text a copy puts on the clipboard. First in that chain, ahead of the grid-wide hook and the plain display text. (optional)
fromClipboard(s: string) => unknowns: string=> unknownRead pasted text back into a value of this type. First in that chain, ahead of the grid-wide hook and taking the text unchanged. (optional)

Renderer

MethodSignatureParametersReturnsDescription
init(p: CellParams): voidp: CellParamsvoidPrepare the renderer for a cell, before `element()` is asked for. Called once per mounted cell.
element(): HTMLElement - HTMLElementThe DOM the grid should put in the cell. Called once, straight after `init`.
refresh(p: CellParams): booleanp: CellParamsbooleanUpdate the existing DOM for a new value and return `true`. Returning anything else - or not implementing it - makes the grid tear the cell down and build it again, which is the single biggest cost on an update-heavy screen. (optional)
attached(): void - voidCalled once the element is in the document, for anything that needs real layout - a measurement, a chart draw, focus. (optional)
destroy(): void - voidRelease whatever the renderer holds - timers, observers, listeners - as the cell is recycled. A throw here is reported once per column and does not stop the recycle. (optional)

Editor

PropertyTypeDescription
popupbooleanMount the editor in the grid's overlay layer, positioned over the cell, so it escapes the cell's clipping. Otherwise it is mounted inside the cell. (optional)
MethodSignatureParametersReturnsDescription
init(p: EditorParams): voidp: EditorParamsvoidPrepare the editor for one edit session, before `element()` is asked for. It receives the cell, the current value, the column and the merged `edit.props`.
element(): HTMLElement - HTMLElementThe DOM the grid mounts for the session - inside the cell, or in the overlay layer when `popup` is set.
value(): unknown - unknownThe value to commit, in the column's stored form. Read when the session ends without a cancel.
attached(): void - voidCalled once the element is in the document. Where a popup is shown and focus is placed, rather than in `init`, so nothing is mounted for an editor that refuses to open. (optional)
cancelBeforeStart(): boolean - booleanReturn `true` to refuse to open on this cell, so keyboard navigation moves on instead of opening an editor the user cannot use. The built-in editors refuse a read-only cell this way. (optional)
cancelOnClose(): boolean - booleanReturn `true` when the session ends to discard the edit instead of committing it - the counterpart of `cancelBeforeStart`, asked once, on the commit path every route ends at (Enter, Tab, and clicking away). The built-in editors answer `true` after their own `cancel()`; an editor may also discard at any moment by calling `params.stop(true)`. (optional)
destroy(): void - voidRelease whatever the editor holds as the session ends, however it ended. (optional)

Filter

MethodSignatureParametersReturnsDescription
init(p: FilterParams): voidp: FilterParamsvoidSet the filter up for a column, once, before its element is asked for. The params carry the column, the grid, a value getter and the filter's own `props`.
active(): boolean - booleanWhether the filter is actually excluding anything. A set filter with everything ticked is not filtering, and says so.
passes(p: { row: Row; data: unknown }): booleanp: { row: Row; data: unknown }booleanWhether a row survives. Evaluated through the same condition the server would be sent, so local and remote agree on what the filter means.
get(): unknown - unknownSerialise the filter to a condition node for the filter set - what travels to a server and into a saved view. `null` when the filter is not active.
set(state: unknown): voidstate: unknownvoidRestore the filter from a condition node, so a saved view puts the control back where it was. A null node resets it.
element(): HTMLElement - HTMLElementThe filter's own UI, which the grid mounts in the column's filter popup.
onRowsChanged(): void - voidCalled when the rows changed, for a filter whose control is built from the data - a set filter's value list. A list that came from a lookup or the host is left alone. (optional)

Row

PropertyTypeDescription
keystringWhat identifies the row. Selection, expansion and edits are all keyed on it.
dataunknown | nullThe object you supplied. Null on a group heading, which is a product of the grouping rather than a record.
levelnumberDepth in a tree or a grouping. Zero at the top.
parentRow | nullThe row above it in a tree or grouping, or null at the top.
childrenRow[]Every child, before filtering. (optional)
filteredChildrenRow[]The children the filters left. (optional)
sortedChildrenRow[]The children in display order. (optional)
groupbooleanWhether this is a group heading rather than a record. A heading carries no data and must be skipped when totalling.
expandedbooleanWhether its children are showing.
leafCountnumberHow many records sit beneath it, at any depth.
totalsRecord<string, unknown>The group's own reductions, by column id. (optional)
detailbooleanWhether this row is the expanded detail panel of the one above. (optional)
masterbooleanWhether this row has a detail panel. (optional)
heightnumberThe row's height in pixels, as measured or configured.
indexnumber | nullPosition in the display order, or null when off screen.
selectedboolean | 'partial'Selection state. `partial` is a group some but not all of whose children are selected.
physicalnumber | nullPhysical index into the ColumnStore. Null for synthetic rows. (optional)
groupColumnstringGroup rows only: the column id this level groups on, and the group value. (optional)
groupValueunknownThe value this group heading stands for. (optional)
groupPathstring[]Stable path of group keys from root to this row. (optional)
hasChildrenbooleanWhether children exist, which a lazily loaded tree knows before it has them. (optional)
pinnedRowPin 'top' | 'bottom'Which sticky strip this row is pinned in, when it is one the host pinned through `setPinnedRows`. Absent on every row that is part of the data. (optional)

VariantDefinition

PropertyTypeDescription
light{ fill: string; text: string; border: string }The three colours the token uses in a light theme: the `fill` behind it, the `text` on it, and the `border` round it. The border doubles as the strong marker colour a dot or a bar takes.
dark{ fill: string; text: string; border: string }The same three colours for a dark theme. Applied both when the grid is explicitly dark and when the reader's own system preference is.

TreeConfig

PropertyTypeDescription
parentKeystring | ((row: unknown) => unknown)Name each row's parent by key, which is the shape a join or a document store produces. Every node is then a real row; a parent that is not in the data makes an orphan, and a cycle is broken rather than followed forever. (optional)
orphans'root' | stringWhere a row whose parent is missing goes. `'root'` (the default) leaves it at the top level; any other string is the title of a synthesised bucket that gathers them, so they are visibly gathered rather than silently promoted. Rows caught in a parent cycle go the same way, and are reported. (optional)
labelstring | ((data: unknown, row: Row) => unknown)Where the generated tree column takes its text from: a field or a function. (optional)
titlestringThe tree column's heading. Defaults to the label column's own title. (optional)
MethodSignatureParametersReturnsDescription
path(row: unknown) => string[]row: unknown=> string[]Read a row's own ancestry - `['EMEA', 'UK', 'Colchester']`. Levels with no row of their own are synthesised, so a row can sit several levels deep without its parents existing in the data. (optional)
hasChildren(row: unknown) => booleanrow: unknown=> booleanSay that a row has children the grid has not seen yet, so it draws an expander for a branch that is still to be fetched. Without it, only rows with children already in the data can be opened. (optional)
loadChildren(row: Row, signal: AbortSignal) => Promise<unknown[]>row: Row
signal: AbortSignal
=> Promise<unknown[]>Fetch a branch's children when it is first opened, with an abort signal for a branch closed before they arrive. Fetched once: a branch reopened shows what it already has. The rows are added to the grid like any others, so they sort, filter and export normally. A failure is announced and the branch is left unmarked, so the user can retry by opening it again. (optional)

DetailConfig

PropertyTypeDescription
enabledbooleanTurn master-detail on. `detail: true` is the shorthand; `enabled: false` turns it off without removing the rest of the block. (optional)
renderstring | RendererCtorDraw the detail region yourself - a renderer, or the name of a registered one. One of `render` or `rows` is required, or the feature stays off and says so. (optional)
configGridConfigThe grid configuration for the nested grid the detail region builds from `rows`. Columns, formatting, everything a grid takes. (optional)
heightnumber | 'auto' | ((row: Row) => number)How tall the detail region is, in pixels: a number, a function of the master row, or `'auto'` to measure it once it has content. 240 px by default. (optional)
cacheLimitnumberHow many nested grids stay alive after their master is closed, so collapsing and reopening does not refetch. 10 by default; beyond it the least recently opened are destroyed, because one grid per row is a memory leak with a friendly name. (optional)
targetstring | HTMLElementRender the detail into this element instead of into a row beneath its master. A selector or an element. Exactly one detail is open at a time in this placement. (optional)
pathstringThe property of the master's record the detail rows live on, so an edit in the detail is reported as a path on the master: `ports.1.vlan`. Inferred by identity when `rows(row)` returns an array already on the record, which is the usual shape; set this when it does not. (optional)
MethodSignatureParametersReturnsDescription
rows(row: Row) => unknown[] | Promise<unknown[]>row: Row=> unknown[] | Promise<unknown[]>Produce the detail rows for a master, synchronously or as a promise. The grid puts them in a nested grid built from `config`. (optional)
isMaster(data: unknown, row: Row) => booleandata: unknown
row: Row
=> booleanDecide which rows can be expanded. Every data row can when this is absent; group rows and detail rows never can. (optional)
onCreate(grid: Grid, masterRow: Row) => voidgrid: Grid
masterRow: Row
=> voidHanded the nested grid as it is created, for whatever the forwarded events do not cover. (optional)

SelectionConfig

PropertyTypeDescription
modeSelectionMode 'none' | 'single' | 'multiple'`'none'` also turns off `ranges` and `fillHandle` unless either is set explicitly alongside it. (optional)
checkboxbooleanAdd a column of checkboxes down the start of the grid. The grid generates it: it is not one of your columns, so it is never exported, never in the tool panel, and it disappears when the option is turned off. (optional)
headerCheckboxbooleanPut a select-all checkbox in that column's heading, showing the tri-state over the displayed rows. (optional)
checkboxOnlybooleanOnly the `checkbox` column may change row selection - a click anywhere else in the row, and Space with focus anywhere but the checkbox, leave selection untouched. Range and cell selection are unaffected either way. For a host whose row click is bound to its own action (opening a record): without this, that click also selects the row, so a later bulk action can reach rows nobody chose. Off by default. `mode: 'none'` already refuses every selection path regardless of this flag. (optional)
groupSelectsChildrenbooleanSelecting a group row selects every row beneath it, and a group shows as partially selected when only some of its children are. Off by default. (optional)
groupSelectsFilteredbooleanExtend that cascade to children the filters have excluded. Off by default, so selecting a group selects what the user can see. (optional)
rangesbooleanAllow rectangular cell-range selection by drag and by Shift+Arrow. On unless `mode: 'none'` turns it off, which an explicit `true` overrides. (optional)
fillHandlebooleanShow the drag handle at a range's corner that fills from it. On unless `mode: 'none'` turns it off; it needs ranges to be usable at all. (optional)
MethodSignatureParametersReturnsDescription
fill(p: { source: unknown[]; target: { row: Row; column: ResolvedColumn }[]; direction: string }) => unknown[]p: { source: unknown[]; target: { row: Row; column: ResolvedColumn }[]; direction: string }=> unknown[]Produce the values a fill writes, given the source cells, the target cells and the direction. Without it the grid copies the anchor and extrapolates numeric and date series. (optional)

EditConfig

PropertyTypeDescription
enabledbooleanTurn editing on for the grid. `edit: true` is the shorthand; over a remote source with nothing to persist the write, it warns loudly rather than letting cells change on screen and never save. (optional)
modeEditMode 'cell' | 'row'`'cell'` (the default) edits one cell at a time - moving to another commits the first. `'row'` keeps one session open across the row, so the whole row commits as one step. (optional)
startEditStartGesture 'single' | 'double' | 'key'Which mouse gesture opens an editor: `'double'` click, the default, or `'single'`. `'key'` binds no mouse gesture at all, for a grid that is read with the mouse and written with the keyboard. The keyboard path (Enter, F2, typing over a cell) is live under all three. (optional)
enterMovesDownbooleanWhether Enter commits and moves to the cell below, as a spreadsheet does. On by default; Shift+Enter moves up. (optional)
undoDepthnumberHow many steps the undo timeline keeps. 10 by default. (optional)
confirmEditConfirmMode 'auto' | 'manual'How an optimistic write settles: `'auto'` (the default) on what `commit` returns, or `'manual'` when the acknowledgement arrives on another channel and you will call `edit.settle` yourself. `'manual'` without a `commit` hook warns and turns tracking off; an unrecognised value warns and is treated as `'auto'`. (optional)
pendingTimeoutnumberHow long, in milliseconds, a write may stay unsettled before the grid warns that it is stuck. 15,000 by default. (optional)
pastePreviewbooleanShow a preview of what a bulk paste will change before it commits (), with confirm/cancel. Off by default: a paste commits straight away, exactly as it always has. When on, a paste into more than one cell first opens a dialog listing every cell that changes (old → new) and every cell that would be rejected (permission, data-type, read-only); confirm commits precisely that set through the ordinary edit path, cancel commits nothing. (optional)
MethodSignatureParametersReturnsDescription
commit(write: PendingWrite) => unknownwrite: PendingWrite=> unknownSend a committed edit to your backend. Supplying it turns on optimistic write-back: the cell changes at once, `cell:pending` fires, and the promise's outcome confirms or reverts it. (optional)

PaginationConfig

PropertyTypeDescription
enabledbooleanShow the grid a page at a time rather than as one scrolling list. (optional)
pageSizenumberHow many rows a page holds. 0 turns paging off and shows everything. (optional)
pageSizesnumber[]The sizes the page-size control offers the user. (optional)

TooltipConfig

Grid-level defaults for the rich cell tooltip, set once for every column rather than repeated on each. Defaults only: it switches nothing on. A tooltip exists because a column declares `cell.tooltip`, and a grid whose columns declare none has no tooltips whatever is set here.

PropertyTypeDescription
delaynumberHow long the pointer or the keyboard cursor must rest on a cell before the tooltip is built, in milliseconds. 400 by default. The delay is why a pointer sweeping across the grid mounts nothing: a tooltip that built a chart on every cell it crossed would be unusable, and `0` asks for exactly that. (optional)
maxWidthnumber | stringHow wide the tooltip may grow. A number is pixels; a string is used as written. (optional)

LookupSpec

PropertyTypeDescription
optionsOption[] | (() => Option[] | Promise<Option[]>)The dictionary: a list of options, or a function returning one, synchronously or as a promise. Loaded once per column, not per cell, and cached against a version so a refresh invalidates every dependent cell in one pass. Options may nest through `children` for the tree editor; every other consumer sees the flattened list. (optional)
valueKeystringWhich property of an option holds the stored value. `'id'` by default. A bare string or number is accepted as an option and becomes its own value and label. (optional)
labelKeystringWhich property of an option holds the displayed text. `'label'` by default; an option with no label falls back to its value. (optional)
groupKeystringWhich property of an option names the option group it belongs to, for an editor that shows headings. `'group'` when unset. (optional)
multiplebooleanThe cell holds a list of values rather than one. The display joins their labels with `separator`, and grouping keys on the whole combination. (optional)
allowCustombooleanAccept a value that is not in the option list, without complaint. Off by default, in which case an unknown value still renders - never blanked - but is reported once per column, since a blank cell hides a data problem. (optional)
unknownLabelstring | ((v: unknown) => string)What to show for a value the option list does not contain: fixed text, or a function of the value. The raw value itself when unset. (optional)
sortByLookupSortBy 'label' | 'value' | 'optionOrder' | 'count'How the editor and the set filter order the options: `'label'` (the default, collated for the locale), `'value'`, `'optionOrder'` to keep them exactly as declared, or `'count'` to put the commonest first. (optional)
separatorstringWhat joins the labels of a multi-value cell, in the display and in an export. `', '` by default. (optional)
MethodSignatureParametersReturnsDescription
search(query: string, signal: AbortSignal) => Promise<Option[]>query: string
signal: AbortSignal
=> Promise<Option[]>Ask the server for matching options as the user types, with an abort signal for the request this one supersedes. Results are shown in the editor but never merged into the cached dictionary. Without it the editor filters the loaded options by label, case-insensitively. (optional)

GridState

PropertyTypeDescription
versionnumberThe state format this snapshot was written in. A snapshot from a newer build is applied field by field with the unknown ones reported and skipped, rather than refused.
columnsColumnState[]Each leaf column's width, visibility, pin, sort, grouping and total, in display order. (optional)
columnOrderstring[]The column ids in display order - the same order `columns` is in, held separately so a restore can reorder without reading every entry. (optional)
columnGroupsColumnGroupState[]The banded-header tree, when the grid has one. (optional)
filtersFilterSetThe structured filter tree that was in force, or `null` when nothing was filtered. (optional)
wherestring[]The `where` predicates that were in force, as names only. A predicate is host code: it cannot be serialised into a view or restored from one. `apply` reconciles these against what the host has registered and reports every name it cannot honour rather than restoring a view that silently shows more rows than the one that was saved. Absent when none is registered. (optional)
quickstringThe quick-filter text. Absent when there was none. (optional)
quickModeQuickFilterMode 'contains' | 'words' | 'fuzzy' | 'regex'How the quick filter matched. Saved with the text and only when it is not the default, because a view restored as `contains` when it was saved as `words` or `regex` shows a different set of rows than the one it captured. (optional)
sortSortEntry[]The sort entries that were in force, outermost first. (optional)
groupstring[]The ids of the columns the rows were grouped by, outermost first. (optional)
pivot{ enabled: boolean; columns: string[] }Whether the grid was in pivot mode, and the columns it pivoted on. (optional)
pivotView{ rowsCollapsed: string[]; columnsCollapsed: string[] }The pivot presentation's collapse state (,: which row-axis and column-axis nodes are collapsed. Absent when the matrix is fully expanded, and tolerated as "expand all" when applied. (optional)
formattingRecord<string, FormattingRule[]>Every conditional-formatting rule, keyed by scope. Always present when the grid has a formatting model - even empty - so that clearing every rule is an action redo can reproduce. (optional)
annotationsAnnotationMark[]Durable annotation marks: seeded from here on first paint, and written back by `getState` so a host can persist and restore them. In content coordinates, so they track scroll and resize. (optional)
redactionstring[]The ids of the redacted columns. Always present when the grid has a redaction model - even empty - so that "stop redacting" is an action redo can reproduce. (optional)
facetsstring[]The ids of the columns whose histogram is open. Always present when the grid has a facet model, for the same reason `redaction` is. (optional)
expandedstring[]The keys of the group and tree rows that were open. (optional)
selectionstring[]The keys of the selected rows. (optional)
scroll{ top: number; left: number }Where the body was scrolled to. Absent on a headless grid, which has no scroll position worth saving. (optional)
pagination{ page: number; pageSize: number }The page being shown and its size. Absent when the grid does not page. (optional)

CommentConfig

PropertyTypeDescription
providerCommentProviderWithout one the feature is inert and no error is raised. (optional)
debouncenumberMilliseconds a viewport change waits before the index is fetched. (optional)
indexLimitnumberCell descriptors held before the oldest are dropped. (optional)
modeCommentDisplayMode 'anchored' | 'docked'`'anchored'` floats beside the cell; `'docked'` uses a side panel. (optional)
markdownbooleanRestricted markdown in bodies: emphasis, code and links only. (optional)
MethodSignatureParametersReturnsDescription
rowLabel(row: Row) => stringrow: Row=> stringLabel for the row, so the panel says what is being commented on. (optional)

PresenceConfig

PropertyTypeDescription
providerPresenceProviderWithout one the feature is inert and raises nothing. (optional)
me{ id: string; name?: string; colour?: string; avatarUrl?: string; initials?: string }The local identity, echoed in everything published. (optional)
throttleMsnumberMilliseconds between published updates. Throttled, not debounced. (optional)
idleMsnumberSilence after which a peer is shown idle. (optional)
removeMsnumberSilence after which a peer is dropped. (optional)
lockMsnumberSilence after which a peer's edit claim is disregarded. (optional)
lockbooleanRefuse local editing of a cell a peer is editing. Advisory only: the authoritative resolution is the conditional write in `edit.commit`. (optional)
palettestring[]Override the peer colour palette. (optional)
rosterboolean | { side?: 'start' | 'end' }Suppress the roster, or place it. (optional)
announcebooleanSuppress join and leave announcements to assistive technology. (optional)

FacetConfig

Grid-level histogram settings.

PropertyTypeDescription
enabledbooleanOff unless asked for: header space is tight and this doubles its height. (optional)
collapsedbooleanStart as a one-line density strip that opens on hover or click. (optional)
heightnumberBand height in pixels. (optional)
rowCeilingnumberRows above which histograms are suppressed. (optional)
debouncenumberMilliseconds a filter change waits before charts recount. (optional)
whilePausedbooleanWhether a paused stream re-enables histograms. Defaults to true. (optional)
MethodSignatureParametersReturnsDescription
provider(request: { colId: string; column: unknown; filters: unknown; quick: string; bounds: FacetBounds | null; buckets: number; strategy: string; granularity?: string; }) => Promise<{ bounds?: FacetBounds; buckets?: FacetBucket[]; counts: ArrayLike<number>; unfiltered?: ArrayLike<number>; kind?: string }>request: { colId: string; column: unknown; filters: unknown; quick: string; bounds: FacetBounds | null; buckets: number; strategy: string; granularity?: string; }=> Promise<{ bounds?: FacetBounds; buckets?: FacetBucket[]; counts: ArrayLike<number>; unfiltered?: ArrayLike<number>; kind?: string }>Bucket counts for a source the client cannot compute over. (optional)

GroupRowParams

What `groupRenderer` is handed.

PropertyTypeDescription
rowRowThe group row itself.
keystringThe group's key, as `rows.expand`/`rows.collapse` take it.
columnstringThe id of the column this level groups on. (optional)
valueunknownThe value this group stands for.
levelnumberDepth of the group. Zero is the outermost level.
expandedbooleanWhether the group is currently open.
leafCountnumberHow many records sit beneath it, at any depth.
totalsRecord<string, unknown>The group's own reductions, by column id - whatever `total` asked for. (optional)
gridGridThe grid instance, for anything the parameters above do not carry.
elementHTMLElementThe element to fill. Write into it directly, or return content instead.
MethodSignatureParametersReturnsDescription
leaves(): Row[] - Row[]The rows beneath this group, computed when you call it. A function rather than an array because a group is unbounded and this runs per paint: a host that only needs the count should read `leafCount` and never call this.
toggle(): void - voidExpand the group if it is closed, collapse it if it is open.

GroupInfo

Which group `groupDefaultExpanded` is being asked about.

PropertyTypeDescription
keystringThe group's key, the same string `Row.key` carries and `rows.expand` takes.
columnstringThe id of the column this level groups on. (optional)
valueunknownThe value this group stands for. (optional)
levelnumberDepth of the group. Zero is the outermost level. (optional)
pathstring[]The group path from the root down to this group. (optional)

FullWidthParams

What `fullWidth.render` is handed.

PropertyTypeDescription
rowRowThe grid's row wrapper for this row.
dataunknownYour original row object.
indexnumberDisplay index of the row.
gridGridThe grid instance.
elementHTMLElementThe element to fill. Write into it directly, or return content instead.

CellMenuParams

What a cell-menu builder and a host item's `action` are handed.

PropertyTypeDescription
keystringThe key of the row the menu opened on.
colIdstring | nullThe column under the pointer, or `null` when the row belongs to no column: a right-click in the empty tail of a row beyond the last column, or on a group row, pivot group row or full-width row. The grid-level menu stands in that case.
valueunknownThe cell's value; `undefined` when there is no column.
rowRowThe row wrapper.
dataunknownYour original row object.
columnResolvedColumn | undefinedThe resolved column; `undefined` when `colId` is `null`.
indexnumberThe row's display index.
gridGridThe grid instance, so an item's action can do whatever it needs to.

MenuItem

PropertyTypeDescription
namestringThe item's label. Omit it on a separator. (optional)
iconstringAn icon shown in the slot before the label. Three forms, told apart without a second option so existing definitions keep working: a registered sprite name (`'download'`), a single character or emoji (`'↑'`), or author-trusted element markup (`'<i class="fa-light fa-download"></i>'`), which is rendered as an element rather than shown as text. Markup is inserted into the icon slot only - never the label - at the same trust as `action`. (optional)
shortcutstringKeyboard hint shown right-aligned in the item. Display only - the grid does not bind the key for you. (optional)
disabledbooleanShow the item greyed out and unusable, rather than hiding it, so the menu keeps its shape. (optional)
separatorbooleanDraw a divider instead of an item. Everything else on the entry is ignored. (optional)
childrenMenuItem[]Nested items, turning this entry into a submenu. (optional)
MethodSignatureParametersReturnsDescription
action() => void - => voidWhat choosing the item does. An item with children opens the submenu instead. (optional)

ImportSettings

PropertyTypeDescription
filebooleanAdd the cell-menu item and open a file picker for CSV/TSV. Default true. (optional)
dropbooleanMake the grid a drop target for `.csv`/`.tsv` files. Default true. (optional)
pastebooleanRead a pasted spreadsheet block into a preview. Default true. (optional)
modeImportMode 'append' | 'replace'How a confirmed import lands: append (default) or replace the dataset. (optional)

ColumnMenuParams

What a column menu's item builder and its actions are handed.

PropertyTypeDescription
colIdstringThe id of the column whose menu opened.
columnResolvedColumnThe resolved column, including any properties you defined on it.
gridGridThe grid instance, so an item's action can reach the rest of it.

CellRange

PropertyTypeDescription
startRownumberThe display index the range starts at. It is an index, not a key: a range is a rectangle on screen, so sorting or filtering changes what it covers.
endRownumberThe display index the range ends at, inclusive.
columnsstring[]The ids of the columns the range spans, in display order.

FindConfig

The in-grid find bar's settings. `find: true` or an omitted key mounts the bar with these defaults; `find: false` removes the bar and its shortcut while `grid.find` keeps working programmatically.

PropertyTypeDescription
shortcutbooleanBind Ctrl+F (Cmd+F on a Mac) while focus is in the grid. The browser's own find is untouched while focus is anywhere else on the page. Default true. (optional)
debouncenumberMilliseconds of typing quiet before the bar searches. Default 120. (optional)

FormattingRule

One rule. A condition and the styling it produces, a colour scale, a data bar or an icon set. A rule held as runtime state must be JSON, so `style` may not be a function there (config-time `cell.style` still accepts one) and a data bar / icon set / scale is the JSON way to say the same visual intent.

PropertyTypeDescription
idstringThe rule's identity, which `remove`, `update` and `move` accept in place of an index. The grid fills one in (`rule-1`, `rule-2`) when you do not. (optional)
whenFormattingConditionThe condition the cell must meet for the rule's `style` to apply. A rule with none of `when`, `scale`, `dataBar` or `iconSet` is refused, since it could never match. (optional)
styleCellStyle | ((p: CellParams) => CellStyle | null)The style to apply when `when` holds - the same shape `cell.style` takes. A function is allowed but cannot be saved: only plain objects survive a saved view. (optional)
scaleFormattingScaleColour the cell by where its value sits between two bounds. Two colours give a gradient, three or more give a piecewise scale so a mid colour is actually reached at the middle. Values outside the bounds clamp to the end colours. (optional)
dataBarDataBarSpecAn in-cell proportional bar. (optional)
iconSetIconSetSpecA per-band glyph beside the value. (optional)
stopIfTruebooleanWhether a match ends the evaluation. True by default, so an ordered list reads top to bottom like a sentence; set it `false` to let a later rule add to this one - bold from one, colour from another. (optional)
enabledbooleanTurn the rule off without deleting it. On by default. The grid also disables a distribution rule it could not resolve, rather than colouring by a guess. (optional)
labelstringA human name for the rule, for your own panel to show. The grid stores it and hands it back but never acts on it. (optional)

RowStyleParams

PropertyTypeDescription
rowRowThe grid's row wrapper, carrying whether this is a group heading, a footer, a grand total or a detail row.
keystringThe key of the row being styled.
indexnumberWhere the row sits on screen, counting the grid's own rows.
dataunknownYour own row object. `null` on a row the grid produced itself, such as a group heading.
gridGridThe grid instance.
contextunknownWhatever `config.context` holds.

RailAction

PropertyTypeDescription
namestringThe action's identity, used to place it in the rail's order and, when `icon` is absent, tried as the icon name - so an action named after a built-in glyph needs no separate icon.
titlestring | (() => string)The hover and accessible label. A function form is re-read on every repaint, so a toggle can change what it says with its state.
iconIconName | (() => IconName)A glyph name from the icon registry (see {@link IconName}) - a built-in name, or one registered with `registerIcon`/`registerIcons`, `config.icons` or `grid.icons`. A function form is re-read on every repaint, the same as `title`, so a toggle can swap its glyph with its state. When omitted, the rail tries `name` as the icon name instead (so an action named after a built-in, e.g. `'undo'`, needs no separate `icon`); an unrecognised name - from either `icon` or the `name` fallback - draws a blank glyph, and only an explicitly-given unrecognised `icon` warns once in the console. (optional)
MethodSignatureParametersReturnsDescription
run(params: RailActionParams): voidparams: RailActionParamsvoidWhat pressing the button does. It receives the grid and the selection as it stood when the button was pressed.
enabled(): boolean - booleanWhether the button is usable. Re-read on every repaint, so a button greys out as the selection changes. Always enabled when absent. (optional)
active(): boolean - booleanMarks the action as a toggle and reports whether it is currently on. When present the rail renders `aria-pressed` and a pressed style, re-read on every repaint; a one-shot action omits it and is unchanged. This is the hook the native annotation tools use, and it is available to a host button that is itself a toggle. (optional)

StatConfig

PropertyTypeDescription
gridGridThe grid the tile reads from, follows and resolves its container selector against. A tile with a literal `value` needs none. (optional)
containerHTMLElement | stringAn element, or a CSS selector resolved against the grid's document.
titlestringThe label above the value. Omitted, the label element is hidden rather than left empty. (optional)
iconstringAn optional leading icon beside the title and value, using the same value contract as a menu item: a registered sprite name, a single character or emoji, or author-trusted element markup (`'<i class="fa-light fa-bolt"> </i>'`, an `<img>`). It lays out to the side without disturbing the change indicator, threshold bands or confidence interval; omit it for the plain tile layout. (optional)
valueunknown | StatValueSpec | ((grid: Grid) => unknown)A literal value, a spec to reduce, or a function of the grid. (optional)
footerstring | ((value: unknown, grid: Grid) => string)Text under the value, or a function of it. (optional)
baselinenumber | ((grid: Grid) => number)What the value is compared against, for the change indicator. (optional)
goodWhenStatGoodDirection 'up' | 'down' | 'neither'Whether a rise is good news. `up` by default. (optional)
bands{ good?: number; warn?: number; direction?: 'up' | 'down' } | ((value: unknown, grid: Grid) => 'good' | 'warn' | 'bad' | null)Thresholds the value itself is judged against, setting `data-tone` on the tile. Separate from `goodWhen`, which judges the *change*: a Cpk of 0.9 is bad news whether it rose or fell to get there. (optional)
scopeStatFollowScope 'filtered' | 'all' | 'selected'Which rows feed the value. `filtered` by default. (optional)
liveboolean`false` stops the tile following the grid; `refresh()` still works. (optional)
emptystringShown when there is no value. `, ` by default. (optional)
decimalsnumberFraction digits for a value whose reduction changed the unit. 2 by default. (optional)
classstringExtra class names for the tile's root. (optional)
MethodSignatureParametersReturnsDescription
interval(value: unknown, grid: Grid) => { lower: number; upper: number; confidence?: number } | nullvalue: unknown
grid: Grid
=> { lower: number; upper: number; confidence?: number } | nullAn interval to show under the value: how much to trust it. Return whichever of the grid's intervals belongs to this tile. (optional)
format(value: unknown, grid: Grid) => stringvalue: unknown
grid: Grid
=> stringOverride the formatting the column's type would apply. (optional)

ResolvedColumn

A column after presets, type defaults and grid defaults are folded in.

PropertyTypeDescription
idstringThis column's identity, which every API that names a column uses. The definition's `id`, or its `field` when no `id` was given, or a generated `col…` name when it has neither.
fieldstring | nullThe path into a row's data this column reads and writes, dotted for a nested value. `null` on a column whose value is computed rather than read.
titlestringThe heading text. Defaults to the field (or the id) made human: `unitPrice` becomes `Unit Price`, and a dotted path uses only its last segment.
typeTypeName 'text' | 'number' | 'boolean' | 'date' | 'dateString' | 'object' | 'lookup' | 'image'The data type actually in force. Not always the name that was asked for: an unknown `type` falls back to `text` for every behaviour, and reads back here as `text`.
dataTypeDataTypeThe resolved data type itself - the object supplying the parser, comparator, editor, Excel format and defaults this column behaves by.
nullablebooleanWhether an empty value is allowed in this column. True unless the definition said `nullable: false`.
alignAlign 'start' | 'center' | 'end' | 'left' | 'right' | 'centre'The horizontal alignment in force, after the column's own `align` or `cell.align`, the grid's column defaults and the data type's default have been weighed in that order. `'start'` when nothing sets one.
verticalAlignVAlign 'top' | 'middle' | 'bottom'The resolved vertical alignment, or `undefined` when neither the column nor the grid set one - in which case the cell keeps the grid's historical vertical placement (centred, or top for `autoHeight`). (optional)
valueRequired<Pick<ColumnValueSpec, 'pure'>> & ColumnValueSpecThe value hooks with defaults filled in. `pure` is always present and is `true` unless the column said otherwise - the grid forces it `false` on a shadow or running-total column, whose answer does not depend on the row alone.
cellColumnCellSpecThe resolved cell spec - renderer or template, decoration, variant, classes, tooltip. `cell.align` is always filled in, from the column's own `cell.align` or from `align`.
editColumnEditSpecThe resolved editing spec. `enabled` is `false` unless the column turned editing on, so a column is read-only until it says otherwise.
sortColumnSortSpecThe resolved sort spec. Sorting is enabled, with no direction, order 0 and nulls last, unless the column or a preset says otherwise.
filterColumnFilterSpecThe resolved filter spec. Filtering is enabled by default, with the filter kind the data type asks for - `'text'` when it names none, and disabled entirely when the type declares `filter: 'none'`.
quickFilterbooleanWhether this column takes part in the quick filter.
group{ enabled: boolean; index: number; explode: boolean; granularity?: ColumnGroupGranularity; weekStart?: number }The resolved grouping spec: whether this column is a grouping key, its place in the group order (`-1` when it is not one), whether a multi-value cell explodes into one group per value, and the date granularity to group by.
pivot{ enabled: boolean; index: number }The resolved pivot spec: whether this column is a pivot key and its place in the pivot order (`-1` when it is not one).
totalTotalName | TotalFn | nullThe footer aggregate: a built-in name, or the function a custom or type-specific total resolved to, or `null` for none. A total the data type declares meaningless for the column (the mean of a bearing, the sum of decibels) is refused at configuration time rather than shown as a wrong number.
groupTotalTotalName | TotalFn | nullThe group-subtotal override, or null when group subtotals follow `total`.
grandTotalTotalName | TotalFn | nullThe grand-total override, or null when the grand total follows `total`.
layoutColumnLayoutSpecThe resolved layout: width, min and max, flex, pin, hidden, and the resize, move and lock flags. Width defaults to 150 px and min to 40 px; `fit: 'content'` drops the default width so the measurement can supply one.
headerColumnHeaderSpecThe resolved header spec. `header.align` is always filled in and follows the cell alignment, so a right-aligned number column gets a right-aligned heading.
contextMenuboolean | MenuItem[] | ((p: CellMenuParams, defaults: MenuItem[]) => MenuItem[] | void) | nullThis column's own cell-menu declaration, or null when it makes none and the grid-level menu stands alone. Carried onto the resolved column so a column preset or `columnDefaults` can supply one.
exportColumnExportSpecThe resolved export spec: by default a lookup column exports its label, and the column appears in both the CSV and the Excel export.
lookupLookupSpec | nullThe compiled lookup for a dictionary column - value-to-label mapping, its comparator and group keys, and its loading state for an asynchronous dictionary - or `null` when the column has none.
allowGroupbooleanWhether the user may group by this column from the menu or the tool panel. True unless the definition said `allowGroup: false`.
allowPivotbooleanWhether the user may pivot on this column from the menu or the tool panel. True unless the definition said `allowPivot: false`.
allowTotalbooleanWhether the user may put a footer total on this column from the menu. True unless the definition said `allowTotal: false`.
defColumnThe column definition exactly as it was given, before presets, `columnDefaults`, data-type defaults and the grid's own defaults were folded in.
MethodSignatureParametersReturnsDescription
formatValue(value: unknown, row?: Row, data?: unknown): stringvalue: unknown
row?: Row
data?: unknown
stringCompiled display-text producer.
getValue(data: unknown, row?: Row): unknowndata: unknown
row?: Row
unknownResolve the value for a row, through the computed-value graph.

NumberFormat

PropertyTypeDescription
type'number'Marks this as the number format, so the grid compiles it with the number formatter. (optional)
styleNumberFormatStyle 'decimal' | 'currency' | 'percent'`'decimal'` (the default), `'currency'` or `'percent'`. `'percent'` multiplies by 100 and appends the locale's percent sign, so store 0.12 for 12%. (optional)
currencystringThe ISO currency code for `style: 'currency'` - `'GBP'`, `'EUR'`. Defaults to `'USD'`. (optional)
currencyDisplayCurrencyDisplay 'symbol' | 'code' | 'name' | 'narrowSymbol'How the currency is shown: `'symbol'` (£1.00), `'narrowSymbol'`, `'code'` (GBP 1.00) or `'name'` (1.00 British pounds). The locale's own default when unset. (optional)
decimalsnumberShorthand for a fixed number of decimal places: it sets the minimum and the maximum to the same figure, so 2 always shows two. (optional)
minDecimalsnumberThe fewest decimal places to show, padding with zeros. Ignored when `decimals` is given. (optional)
maxDecimalsnumberThe most decimal places to show, rounding beyond it. Raised to `minDecimals` if it would fall below. (optional)
thousandsSeparatorboolean | string`false` turns grouping off entirely; a string replaces the locale's group separator with your own. Grouping is on by default. (optional)
decimalSeparatorstringReplaces the locale's decimal separator with your own. The locale's is used when unset. (optional)
notationNumberFormatNotation 'standard' | 'compact' | 'scientific'`'standard'` (the default), `'compact'` - 1,234,567 as `1.2M` - or `'scientific'`. (optional)
compactDisplayCompactDisplay 'short' | 'long'Which compact form `notation: 'compact'` uses - `'short'` (the default) gives `1.2M`, `'long'` gives `1.2 million`. Ignored under any other notation. (optional)
negativeNumberFormatNegative 'minus' | 'parentheses' | 'suffix'How a negative number reads: `'minus'`, the default, gives `-1,234`; `'parentheses'` gives `(1,234)`, the accounting form; `'suffix'` gives `1,234-`. (optional)
negativeClassstringA class name put on the cell when the value is negative, so the stylesheet can colour it. Nothing is added when unset. (optional)
signedbooleanShow a leading `+` on a positive value (`+5`, `+£5.00`, `+12%`). A negative value keeps whatever `negative` says regardless of this flag, and zero shows no sign either way. Off by default. (optional)
prefixstringFree text placed before the number - and after the currency symbol when there is one, so `£~1,234` rather than `~£1,234`. (optional)
suffixstringFree text placed after the number, after any percent sign or currency code. (optional)
zeroDisplaystringThe text shown instead of a formatted zero - `' - '`, `'free'`. Zero is formatted normally when unset. Tested after `scale` is applied. (optional)
nullDisplaystringThe text shown for null, undefined, an empty string, and anything that is not a number. Empty by default. (optional)
localestringThe locale for number, date and text formatting. The page's by default. (optional)
messagesRecord<string, string | Record<string, string>>A partial message catalogue laid over the built-in British English one. Every valid key is listed in `MESSAGE_KEYS`; a key that is not is ignored with a warning. Import a bundled locale (`FR_FR`, `AR`, …) or supply your own object. Merged rather than replacing, so an incomplete translation leaves the remainder in English rather than showing raw keys. (optional)
scalenumberA multiplier applied before formatting, for showing stored units in another magnitude - `0.001` to read a column of pounds as thousands. 1 by default. It changes only the display: sort, filter and totals still use the stored number. (optional)

DateFormat

PropertyTypeDescription
type'date'Marks this as the date format, so the grid compiles it with the date formatter.
patternstringA token pattern - `'dd/MM/yyyy HH:mm'`, `'dd MMM yyyy'`. Numeric fields are assembled by hand so the output is stable across browsers; only month and weekday names come from the locale. An unsupported letter is rendered as text and warns; quote it to silence that. (optional)
dateStyleDateStyle 'short' | 'medium' | 'long' | 'full'A locale-chosen date form - `'short'`, `'medium'`, `'long'`, `'full'` - used when no `pattern` is given. Medium is the default when neither is set. (optional)
timeStyleTimeStyle 'short' | 'medium' | 'long'A locale-chosen time form - `'short'`, `'medium'`, `'long'` - shown alongside `dateStyle`. No time is shown when unset. (optional)
timeZonestringThe IANA zone the instant is rendered in - `'Europe/London'`, `'UTC'`. The browser's own zone when unset. It changes only the display; the stored instant is untouched. (optional)
relativeboolean | { threshold?: number }Render as `yesterday`, `in 3 hours` while the date is within the threshold, falling back to the absolute form beyond it. The threshold is 7 days unless `{ threshold: n }` names another number of days. (optional)
nullDisplaystringThe text shown for null, undefined and anything that will not read as a date. Empty by default. (optional)
localestringThe BCP-47 locale this column formats in, overriding the grid's. The page's locale by default. (optional)

BooleanFormat

PropertyTypeDescription
type'boolean'Marks this as the boolean format, so the grid compiles it with the boolean formatter.
displayBooleanDisplay 'checkbox' | 'switch' | 'text' | 'icon'Which text stands in for each state: `'text'` (the default) uses the labels below; `'checkbox'` and `'switch'` both render the glyph pair ☑ / ☐ as text; `'icon'` uses `trueIcon` and `falseIcon`. An interactive tick box is the `checkbox` cell renderer, which is a separate setting. (optional)
trueLabelstringThe text for true. `'Yes'` by default. (optional)
falseLabelstringThe text for false. `'No'` by default. (optional)
nullLabelstringThe text for a value that is neither - null, undefined or empty, which the grid keeps distinct from false. Empty by default. (optional)
trueIconstringThe text used for true under `display: 'icon'` - a character or emoji, placed in the cell as written. Falls back to `trueLabel`. (optional)
falseIconstringThe text used for false under `display: 'icon'`. Falls back to `falseLabel`. (optional)

TextFormat

PropertyTypeDescription
type'text'Marks this as the text format, so the grid compiles it with the text formatter.
transformTextTransform 'none' | 'upper' | 'lower' | 'title'Change the case for display: `'upper'`, `'lower'`, `'title'`, or `'none'` (the default). Casing is locale-aware, which matters for Turkish `i`. (optional)
truncatenumber | { chars: number; ellipsis?: string }Cut the text to a number of characters and append an ellipsis. Give a number for the character count, or `{ chars, ellipsis }` to choose the trailing mark; `…` by default. (optional)
nullDisplaystringThe text shown for null and undefined. Empty by default. (optional)
emptyDisplaystringThe text shown for an empty string, which the grid keeps distinct from null. Empty by default. (optional)

ColumnValueSpec

PropertyTypeDescription
depsstring[] | '*'Which columns `compute` reads, so an edit to one of them invalidates just this column. `'*'` means the whole row. Leaving it out is treated as `'*'` and warns, because the grid then has to recompute on every change. (optional)
purebooleanWhether `compute` is a function of its dependencies alone. True by default, which lets the value be materialised and read back cheaply. Set it `false` for a value that depends on anything else - history, sort position - or the first answer is frozen for good. (optional)
compareComparatorOrder two of this column's values. Wins over the lookup's comparator and over the data type's, for sorting and for the diff view. (optional)
MethodSignatureParametersReturnsDescription
compute(deps: DepValues, ctx: ValueContext) => unknowndeps: DepValues
ctx: ValueContext
=> unknownProduce this column's value from its declared dependencies rather than from a field. Called with the dependency values and a context carrying the row, the grid and your own context. (optional)
format(p: FormatParams) => stringp: FormatParams=> stringTurn this column's value into the text a cell shows. First in the display chain: it wins over the column's `format` and over the data type's own formatter. (optional)
apply(p: ApplyParams) => booleanp: ApplyParams=> booleanWrite an edited value back into the row's data yourself, instead of the grid writing through `field`. Return `false` to decline the write; a throw is reported once and the edit discarded. (optional)
parse(p: ParseParams) => unknownp: ParseParams=> unknownTurn an editor's output into the stored value. Always honoured - the data type's own text parser is used only when the editor emitted a string and you supplied no `parse`. (optional)
key(p: KeyParams) => stringp: KeyParams=> stringThe identity a value groups and set-filters by, when the stringified value is the wrong answer (an object, a pair of coordinates). Wins over a lookup's own group key. (optional)
quickFilterText(p: ValueParams) => stringp: ValueParams=> stringThe text the quick filter searches for this cell. By default it matches the display text, which is what the reader can see; supply this to search something else. (optional)

ColumnCellSpec

PropertyTypeDescription
decorationDecorationName | DecorationSpecDraw the formatted text as something richer - a pill, a proportional bar, an icon - by name or as a full spec. Ignored, with a warning, on a column that also sets `template` or `render`, since those own the cell's content. (optional)
variantVariantSpecWhich semantic token a cell's decoration takes its colour from: `neutral`, `info`, `success`, `warning`, `danger`, `accent`, or `none` to suppress it. Give a fixed token, a value-to-token map, ordered `when` clauses, or a function of the row - it always resolves to a token, never to a colour. (optional)
templatestringA declarative cell template compiled once per column: `{{ value }}`, `{{ text }}`, `{{ index }}`, `{{ data.field }}` and anything in `cell.props`. Only a safe subset of tags survives; a binding that resolves to nothing renders empty and warns. (optional)
renderstring | RenderFn | RendererCtorA custom cell renderer: a function, a component class with a `render` method, or the name of a registered renderer. It owns the cell's content, so a decoration set alongside it is ignored. (optional)
propsRecord<string, unknown>Values passed on to the renderer as `params.props`, and reachable from a template as `{{ name }}`. (optional)
classstring | string[] | ((p: CellParams) => string | string[])Class names put on every cell of the column, or a function asked per cell. Composed with the decoration and `classWhen` classes rather than replacing them. (optional)
classWhenRecord<string, string | ((p: CellParams) => boolean)>Conditional classes: each class name is mapped to a condition - a comparison written as text (`'>= 100'`, `"= 'Open'"`) or a predicate on the cell - and every class whose condition holds is added. (optional)
styleCellStyle | ((p: CellParams) => CellStyle)Inline style properties for every cell of the column, as an object or a function of the cell. Merged with any runtime formatting rule in one write, the rule winning on the properties it names. (optional)
tooltipstring | ((p: CellParams) => string) | ColumnTooltipSpecA tooltip for this column's cells. A string or a function is the plain-text case and becomes the browser's own `title`. An object is a {@link ColumnTooltipSpec}: a tooltip the grid draws, which can carry structure, markup or live content and which a keyboard user can reach. (optional)
alignAlign 'start' | 'center' | 'end' | 'left' | 'right' | 'centre'Horizontal alignment of this column's cell content. Accepted at the top level of the column too; falls back to the data type's default and finally to `'start'`. (optional)
verticalAlignVAlign 'top' | 'middle' | 'bottom'Vertical alignment of this column's cell content, overriding the grid-level `verticalAlign` for this column alone. Accepted at the top level of the column too, as `align` is. (optional)
wrapbooleanLet a cell's text wrap onto further lines instead of being clipped to one. Off by default. Pair it with `autoHeight` on the grid for rows that grow to fit. (optional)
flashboolean | string | { colour?: string; color?: string; /** Milliseconds. `0` leaves the highlight until it is cleared. */ duration?: number; enabled?: boolean; }Flash this column's cells when their value changes - the per-column half of the grid-level `highlightOnChange`, for a grid where one column is the one worth watching. `true` takes the default colour and duration; an object takes the same `{ colour, duration }` the grid-level key accepts. Grid-level `highlightOnChange` covers every column and wins where both are set. (optional)
MethodSignatureParametersReturnsDescription
css(p: CellParams) => CellStylep: CellParams=> CellStyleAn escape hatch that computes style properties for every cell. It leaves the class fast path and warns once per column; unsuitable for large datasets, where a decoration and a variant do the same job from the stylesheet. (optional)
spanColumns(p: SpanParams) => numberp: SpanParams=> numberMake this column's cell span several columns, as a function of the cell. Return 1 or less for no span; the span is floored and clamped to the columns remaining to its right, and a spanned cell is drawn in its own layer so row recycling cannot clip it. (optional)
spanRows(p: SpanParams) => numberp: SpanParams=> numberMake this column's cell span several rows, as a function of the cell. Return 1 or less for no span. The renderer looks back 50 rows above the viewport for a span's origin: a span taller than that is reported once and drawn from the first row in range, and one whose origin sits further above the viewport than that is not found, so its cells draw individually. (optional)

ColumnEditSpec

PropertyTypeDescription
enabledboolean | ((p: CellParams) => boolean)Whether this column's cells can be edited. `false` by default; a function is asked per cell, and a throw is reported once and the cell treated as read-only. (optional)
editorstring | EditorCtorWhich editor opens on this column: the name of a registered editor, or a class of your own. The data type's default editor is used when this is absent. Naming one without enabling editing leaves the column read-only, and says so. (optional)
propsRecord<string, unknown>Values handed to the editor as `params.props` - the option list for a select, the step for a number. (optional)
popupbooleanOpen the editor in a popup over the cell rather than inside it. Defaults to whatever the editor class declares. (optional)
MethodSignatureParametersReturnsDescription
validate(p: ValidateParams) => true | stringp: ValidateParams=> true | stringCheck an edited value before it is written. Return `true` to accept, or a message to reject and show. A throw is reported once and the edit rejected. (optional)

ColumnValidation

Declarative edit-validation rules for a column. Rules are checked in a fixed order - `required` first, then the value-shape rules, then the functions - and the first failure wins. A blank but optional value passes everything after `required`: an empty cell is empty, not "below the minimum". A failure vetoes the commit through `beforeEdit` and marks the cell; the cancellation carries `reason: 'validation:<code>'`.

PropertyTypeDescription
requiredboolean | stringThe value may not be blank. A string is used as the message. (optional)
minnumberMinimum, for a number or a date. (optional)
maxnumberMaximum, for a number or a date. (optional)
minLengthnumberMinimum text length. (optional)
maxLengthnumberMaximum text length. (optional)
patternstring | RegExpA pattern the whole value must match. A string is a RegExp source. (optional)
oneOfunknown[]The value must be one of these. (optional)
messagestringA default message for any rule without its own. (optional)
messagesRecord<string, string>Per-rule messages, keyed by rule name (`required`, `min`, `pattern`, …). (optional)
MethodSignatureParametersReturnsDescription
crossField(value: unknown, row: unknown, ctx: { key: string; colId: string; changes: unknown[] }) => true | string | voidvalue: unknown
row: unknown
ctx: { key: string; colId: string; changes: unknown[] }
=> true | string | voidA cross-field rule: return `true` to pass, or a message string to fail. The row is passed so a rule can compare against its siblings. (optional)
validate(value: unknown, row: unknown, ctx: { key: string; colId: string; changes: unknown[] }) => true | string | voidvalue: unknown
row: unknown
ctx: { key: string; colId: string; changes: unknown[] }
=> true | string | voidA free-form check, the same contract as `crossField`. (optional)

ColumnSortSpec

PropertyTypeDescription
enabledbooleanWhether this column can be sorted. True by default, and forced off on a running-total column, whose value is defined by the display order. (optional)
directionSortDirection | nullThe sort direction this column starts in - `'asc'`, `'desc'`, or `null` for unsorted, which is the default. (optional)
ordernumberThis column's place in a multi-column sort, lowest first. 0 by default. (optional)
nullsFirstbooleanPut empty values before the rest instead of after them. Off by default, so nulls sort last whichever direction is in force. (optional)

ColumnFilterSpec

PropertyTypeDescription
enabledbooleanWhether this column offers a filter. True by default. (optional)
typeFilterName | FilterCtorWhich filter the column uses: a built-in name, or a filter class of your own. Defaults to the kind the data type asks for, and to `'text'` when it names none. (optional)
propsRecord<string, unknown>Values handed to the filter as `params.props` - the option list for a set filter, the step for a number range. (optional)

RegressionSpec

The specification of a multi-predictor model.

PropertyTypeDescription
predictorsstring[]The predictor column ids.
responsestringThe response column id.
methodRegressionMethod 'ols' | 'wls' | 'robust' | 'quantile'`ols` (default), `wls` or `robust`. `quantile` is reserved (coming next). (optional)
weightsstringA weights column id, required for `wls`. (optional)
confidencenumberThe confidence level for the band; 0.95 by default. (optional)

ColumnLayoutSpec

PropertyTypeDescription
widthnumber | stringA pixel width, or a percentage of the grid's inner width as a string, `'25%'`. A percentage is a share of the *whole* grid. `flex` divides only the space left over after fixed columns, so the two are not interchangeable: `flex: 25` on four columns is a quarter of the remainder, which is a quarter of the grid only when nothing else is fixed. (optional)
fit'content'`'content'` sizes the column to what it is actually showing, the way `columns.autoSize()` does, and keeps doing it: on the first paint, and again whenever the rows change, the columns are shown, hidden, reordered or pinned, or the grid is resized. It is the declarative form of the imperative call, so a host no longer has to re-issue `autoSize()` after every data change. Sized to the *visible* content, not to the widest value in the dataset: the measurement reads the rows the renderer has mounted, because measuring a million rows is not a plan. It measures the heading too, so a column whose title is longer than its values widens to show the title. **Anything the caller states outranks it.** A declared `width` wins, and so does a width the user drags to - a resize is recorded as a `width`, so from that moment the column is that wide and the fit no longer touches it. `min` and `max` clamp the fitted width as they clamp any other. `flex` is resolved before this and wins, the two being contradictory instructions: `flex` fits the column to the *grid*, this fits it to the *content*. Not re-measured on scroll, deliberately: different rows mount as the grid scrolls, and re-fitting against them would make the columns jitter under the reader. (optional)
minnumberThe narrowest this column may be, in pixels; 40 by default. It clamps a drag, a flex share and a content fit alike. (optional)
maxnumberThe widest this column may be, in pixels. No maximum by default. (optional)
flexnumberA share of the space left over once the fixed-width columns are laid out; 0 (no share) by default. Resizing the column by hand sets it back to 0, so the drag is not immediately undone. (optional)
pinEdge | nullFreeze the column against the start or the end edge, so it stays put while the rest scroll sideways. `null`, the default, leaves it in the scrolling body. Start and end rather than left and right, so a right-to-left grid needs no change. (optional)
hiddenbooleanKeep the column out of the grid without removing it. Off by default; `columns.show()` and `columns.hide()` move it. (optional)
resizablebooleanWhether the user may drag this column's width. True by default; a resize of a column that says `false` is ignored and warns once. (optional)
movablebooleanWhether the user may drag this column to another position. True by default; a move of a column that says `false` is ignored and warns once. (optional)
lockVisiblebooleanRefuse to hide this column: a hide is ignored and warns once, and the AI layer is told the column cannot be hidden. Off by default. (optional)
lockPositionbooleanHold this column where it is: it cannot be dragged, it cannot be moved by the keyboard, and it cannot be pulled into a header band. Off by default. This locks a column; it does not place one. `'start'` and `'end'` were declared and never implemented - every reader tested truthiness, so either one pinned the column wherever it already sat - and the union was narrowed to the boolean the grid actually honours. Put a column at an edge by ordering `columns`, or pin it with `layout.pinned`. (optional)

ColumnHeaderSpec

PropertyTypeDescription
templatestringNot read by the header renderer; use `render` to draw a custom heading. (optional)
renderstring | RendererCtorA custom heading renderer: a function, or a component (a class with a `render` method). A string names a registered renderer. Either form draws the same two ways and they are interchangeable - it may append to the passed label element itself and return nothing, or return an `Element` (attached for you) or a `string` (used as the heading text). (optional)
propsRecord<string, unknown>Props passed to `render` as `params.props`. (optional)
classstring | string[]A class, or classes, added to the heading cell. A string may hold several space-separated tokens (`'a b'`), each applied individually. (optional)
tooltipstringDeclared, but not drawn: the header renderer never reads it, so a heading's only hover text is the drag hint. Put the text in the heading itself with `header.render`, or on the cells with `cell.tooltip`. (optional)
alignAlign 'start' | 'center' | 'end' | 'left' | 'right' | 'centre'Horizontal alignment of the heading text. Follows the cell alignment when it is not set, so a right-aligned number column gets a right-aligned heading. (optional)

ColumnExportSpec

PropertyTypeDescription
lookupColumnExportLookup 'label' | 'value' | 'columns'How a lookup column leaves in an export: `'label'` (the default) writes what the reader sees, `'value'` writes the stored code, `'columns'` writes both in a pair of columns. (optional)
csvbooleanInclude this column in a CSV export. True by default. (optional)
excelbooleanInclude this column in an Excel export. True by default. (optional)

ColumnFacetConfig

PropertyTypeDescription
enabledbooleanWhether this column draws a histogram under its heading. It layers over the grid's `facets` settings rather than replacing them, and `facet: true` on the column is the shorthand for turning it on without restating anything else. (optional)
bucketsnumberHow many buckets to cut the values into. 20 by default. On a date column it is a target rather than an exact count, since the granularity has to land on real calendar units. (optional)
strategyFacetBucketStrategy 'equal' | 'quantile' | 'log'How numeric buckets are placed: `'equal'` width (the default), `'quantile'` so each holds roughly the same number of rows, or `'log'`. A log scale over values reaching zero or below falls back to equal width rather than drawing nothing. (optional)
granularityFacetDateGranularity 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'The calendar unit a date column buckets by - hour through year. Chosen automatically from the span and the wanted bucket count when unset. (optional)
orderFacetBarOrder 'count' | 'alpha'How a categorical column's bars are ordered: `'count'`, commonest first (the default), or `'alpha'`. Fixed against the unfiltered column so bars do not reorder themselves under the pointer. (optional)
cardinalityLimitnumberHow many distinct values a categorical column may have before `aboveLimit` applies. 50 by default - beyond that a bar chart stops telling anyone anything. (optional)
aboveLimitFacetOverflowMode 'suppress' | 'topN'What to do with a categorical column past the cardinality limit: `'suppress'` (the default) draws no chart, `'topN'` draws the commonest values and gathers the rest into one `Other` bar. (optional)
MethodSignatureParametersReturnsDescription
bucketFn(handle: unknown, indices: Uint32Array | null, count: number) => FacetBoundshandle: unknown
indices: Uint32Array | null
count: number
=> FacetBoundsReplace the built-in bucketing entirely. (optional)
format(bucket: FacetBucket, count: number, unfiltered: number) => stringbucket: FacetBucket
count: number
unfiltered: number
=> stringLabel a bucket for its tooltip and accessible name. (optional)

SortEntry

PropertyTypeDescription
colstringThe id of the column to sort by. An entry naming a column the grid does not have is dropped with a warning rather than stored.
dirSortDirection 'asc' | 'desc'`'asc'` or `'desc'`.
nullsFirstbooleanPut empty values before the rest instead of after them. Off by default, so nulls sort last in either direction. (optional)

FilterGroup

PropertyTypeDescription
opFilterOp 'and' | 'or' | 'not'How the children combine: `'and'`, `'or'`, or `'not'` to negate them.
conditionsFilterSet[]The children - conditions, or further groups, so a filter set is a tree of any depth.

Condition

PropertyTypeDescription
colstringThe id of the column this condition reads.
typeTypeName 'text' | 'number' | 'boolean' | 'date' | 'dateString' | 'object' | 'lookup' | 'image'The column's type, which decides how both sides are normalised before comparison - a `date` or `dateString` condition brings the value and the operand to the same calendar day first, so a `Date`, an epoch number and an ISO string all line up. (optional)
opOperatorThe comparison to make: `eq`, `contains`, `between`, `in`, `blank` and the rest, each with an exact negation (`ne`, `notContains`, `notBetween`, `notIn`, `notBlank`).
valueunknownThe operand: a single value, a pair for `between`, or a list for `in`. Left out by the operators that need none. (optional)
boundsIntervalBounds '[]' | '[)' | '(]' | '()'Which ends of a `between` range are inclusive, written in interval notation. `'[]'` - both ends - by default; `'[)'` is the half-open form a date range usually wants. (optional)
caseSensitivebooleanCompare text exactly as written. Off by default, so text matching, set membership and regular expressions all ignore case. (optional)
metaRecord<string, unknown>Extra information carried alongside the condition for whoever executes it - the relative date token a range was built from, so a saved `last 7 days` filter is re-derived rather than frozen to the day it was written. (optional)

RemoteRequest

PropertyTypeDescription
protocol1The wire protocol version, always 1. A server implementation targets the documented version rather than reverse-engineering what this release happens to send.
range{ start: number; end: number }The half-open row range being asked for at this level, `start` inclusive and `end` exclusive.
groupPathstring[]The group ancestry of the level being fetched, outermost first, as display strings. It is a stable identity for expansion state - use `groupValues` to narrow a query.
groupValuesunknown[]The same ancestry as `groupPath`, but as the values the server returned rather than their display strings. Always present, empty at the root, so a source can tell "no ancestors" from "a host that does not send this". `groupPath` is stringified because it is a stable *identity* for expansion state, and that is what it must stay: a numeric key `3` is `'3'` there and an absent key is `''`, indistinguishable from a group whose key really is the empty string. Useless for narrowing a query, then - which is what a grouping engine needs it for - so the typed values travel beside it.
groupByColumnRef[]The columns the grid is grouping by, outermost first. Empty when the rows are flat.
totalsColumnRef[]The columns that want a subtotal on each group row. The grid does not recompute what a remote source returns.
totalFnsRecord<string, string>The named statistic each totalled column reduces with - `{ amount: 'sum' }`. `totals` has always said *which* columns want a subtotal and never *what*, because the client reads the reduction off the column model and a server had no way to. Only string reductions appear: a column totalling with a host function has no name to send, and naming one that merely resembles it would put a plausible wrong number on every group row. `groupTotal` wins over `total`, the same precedence the client applies for the group scope.
pivotByColumnRef[]The columns the grid is pivoting on.
pivotModebooleanWhether the grid is in pivot mode - true exactly when `pivotBy` is not empty.
filtersFilterSetThe filter tree, wired for the wire: relative date tokens resolved to absolute ranges, so the server is never asked to interpret `last 7 days`.
quickstringThe quick-filter text, present only when there is some. It is not column-scoped, which is why it sits beside the tree rather than in it. (optional)
sortSortEntry[]The sort entries in force, outermost first.
contextunknownWhatever the grid's `context` holds - a tenant id, an auth token, a locale - passed through untouched for the fetch to use.
signalAbortSignalAborts when the grid no longer needs this block: the user scrolled past it, changed the query, or destroyed the grid. Pass it to `fetch` and the superseded request is cancelled rather than paid for.
whereWhereRuntimeThe `where` predicates in force, as a runtime the source can evaluate but not mutate. Present **only when at least one predicate is registered**, so a grid that does not use `where` sends the request it always sent, field for field. A host `fetch` may ignore it, and every existing one does: it is a host function, so there is nothing to serialise and no engine can evaluate it - `passes` is dropped by `JSON.stringify` the way `signal` already is. It is carried for the one reader that can act on it, `createPushdownSource`, which runs it as the residual over the matching set when that set is under `whereRowLimit`. The `{ condition }` twin remains the route that narrows the fetch itself, at any size. (optional)

RemoteResult

PropertyTypeDescription
rowsunknown[]The rows for the requested range, at the requested group level. Group rows carry their own subtotals; the grid does not recompute them.
countnumberHow many rows exist at this level in total, so the scrollbar is the right size. Omit it while the total is unknown and the grid keeps discovering. (optional)
pendingTotalPromise<number | null>The exact count, still being worked out. A source whose count has to read data delivers the rows as soon as the page settles and resolves this when the count finishes; the grid keeps discovering until it does, then adopts the number and fires `source:total`. Ignored when `count` is present. Resolving with anything other than the exact count - an estimate, a page length - puts a wrong number in the place a right one goes. (optional)
pivotFieldsstring[]The value-column names this level's pivot produced, so the grid can build the headings it has never seen before. (optional)

Chunk

PropertyTypeDescription
rowsunknown[]The rows in this chunk. They are appended to whatever has already arrived, merged into the current sort rather than re-sorting the whole set.
progress{ loaded: number; estimated?: number }How far the stream has come: `loaded` is how many rows have arrived, `estimated` the total when the producer knows it. It drives the progress readout instead of skeletons. (optional)
donebooleanThe last chunk. It closes the stream, and a final count under `promoteToMemoryBelow` promotes the source to an in-memory one so later sorts and filters are local. (optional)

UnionSourceOptions

PropertyTypeDescription
gridGridThe grid this source reads.
labelstringIdentifies this source: it is what `__source` carries on every row this source contributes, and what namespaces that row's `__key` so two sources sharing the same identifiers do not collide. Defaults to the source's position in the `from` array (`'0'`, `'1'`, …), as a string. (optional)
followFollowScope 'filtered' | 'all' | 'selected' | 'grouped'Which of this source's rows to read. `filtered` by default, exactly as a lone `from` follows its grid today - set independently per source, so filtering one narrows only its own contribution. (optional)
MethodSignatureParametersReturnsDescription
map(row: unknown) => unknownrow: unknown=> unknownReshape this source's rows into the common shape before they join the rest - typically a rename or a projection, for a field this source calls something else. Not a type coercion: if a field means something different on two sources, `map` is where you make them agree, because the union itself does not guess. (optional)

DerivedJoin

PropertyTypeDescription
withGridThe grid holding the other side.
onstring | { left?: string; right?: string }The shared key: one field name when both sides use it, or one each.
typeJoinType 'inner' | 'left'`inner` keeps only rows that matched; `left` keeps them all. (optional)
selectstring[]Which of the partner's fields to bring across. All of them by default. (optional)
prefixstringRename the brought-across fields, when both sides have one worth keeping. (optional)
followRowScope 'all' | 'filtered'Which of the partner's rows to read. `all` by default. (optional)

DerivedSelect

One reduced column of a derived grid.

PropertyTypeDescription
ofstringThe column to reduce, as a field name or a dotted path. Omit for `count`. (optional)
fnTotalName 'sum' | 'min' | 'max' | 'avg' | 'count' | 'first' | 'last' | 'countValues' | (string & {})A key of `TOTAL_FNS`: `sum`, `avg`, `median`, `p95`, `distinct` and the rest. (optional)

DerivedCorrelation

PropertyTypeDescription
fn'correlation'Selects the pairwise-correlation producer. It replaces the pipeline: one row per column pair rather than one per group.
columnsstring[]The columns to correlate pairwise. At least two, or the source is refused.
orientCorrelationOrient 'pairs' | 'matrix'`pairs` (default) for one row per pair; `matrix` for the square. (optional)

DerivedSeries

A `grid.statistics.series` summary, as one row per metric: `{ metric, value, n }`. One row per *metric*, not per point: `series` returns a `SeriesStats` summary object - `n`, `first`, `last`, `change`, `changePercent`, `volatility`, `annualisedVolatility`, `growth`, `maxDrawdown`, `maxDrawdownFrom`, `maxDrawdownTo`, `autocorrelation`, `upDays`, `downDays` - and not a value per row. The shape is deliberately the one `profile`'s `orient: 'metrics'` already emits rather than a third convention for the same idea.

PropertyTypeDescription
fn'series'Selects the series-summary producer - volatility, growth, drawdown, autocorrelation - one row per metric. It replaces the pipeline.
ofstringThe column to summarise.
bystringThe column that orders it. Required and never guessed.
periodsPerYearnumberAnnualise volatility and growth against this many periods per year. (optional)

DerivedDatasetComparison

How this grid differs from another, ranked by effect size, as rows: `{ column, measure, magnitude, distance, direction, nA, nB, reliable, unmatched }`, largest difference first. The two-grid shape: one grid is the data, a second *is* the analysis of it. 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. It is still reported, as a row with a null `magnitude` and `unmatched` set to `'A'` or `'B'`, so a reader sees that it was skipped and why rather than finding it absent.

PropertyTypeDescription
fn'datasetVsDataset'Selects the dataset-comparison producer: one row per compared column, against a second grid. It replaces the pipeline.
withGridThe second grid to compare this one against.
columnsstring[]Restrict the comparison to these columns. All shared columns by default. (optional)

CellParams

PropertyTypeDescription
textstringThe display text: the value after the column's format and any lookup label - exactly what the cell shows.
indexnumberThe row's display index, counting the grid's own rows: group headings, footers and totals included.
propsRecord<string, unknown>Whatever the column's `cell.props` holds, passed through so one renderer can be configured per column. (optional)
MethodSignatureParametersReturnsDescription
t(key: string, vars?: Record<string, unknown>) => stringkey: string
vars?: Record<string, unknown>
=> stringFormat a message from the grid's catalogue, for a renderer that wants its own accessible names and labels localised rather than hard-coded (, WCAG 4.1.2). The built-in renderers use this; a custom renderer may too. Optional: absent when a renderer is exercised without a grid to ask. (optional)

EditorParams

PropertyTypeDescription
keystringThe name of the key that opened the editor, when a keystroke did. Absent when it was opened by a click or by the API. (optional)
charPressstringThe printable character that opened the editor, so typing straight into a cell seeds the first character instead of losing it. (optional)
MethodSignatureParametersReturnsDescription
stop(cancel?: boolean): voidcancel?: booleanvoidEnd the session from inside the editor: with no argument it commits, and `stop(true)` discards. This is how an editor cancels; the grid does not poll for it.

FilterParams

PropertyTypeDescription
columnColumnThe resolved column this filter belongs to.
colIdstringThe column's id, which the conditions the filter produces are keyed by.
gridGridThe grid instance, for a filter that needs to read the rows or another column.
contextunknownWhatever `config.context` holds - the tenant, the user, whatever the filter's own logic needs.
propsRecord<string, unknown>Whatever the column's `filter.props` holds: the option list for a set filter, the step for a number range. (optional)
MethodSignatureParametersReturnsDescription
changed(): void - voidTell the grid the filter's state moved. It reads `get()` back and applies the result; nothing happens until this is called.

PendingWrite

PropertyTypeDescription
idstringThe write's identity, which `cell:pending` carries and `edit.settle` takes.
keystringThe key of the row being written to.
colIdstringThe column being written to.
valueunknownThe new value to persist.
beforeunknownThe value the cell held before the edit - what a rejected write is rolled back to, and what a conditional write can check the server against.
rowRowThe row the cell belongs to, so the commit hook can send whatever else it needs to identify the record. (optional)

Option

PropertyTypeDescription
idunknownThe stored value - what the cell holds, sorts by and exports when `export.lookup` asks for the value. A bare string or number as the whole option becomes both its id and its label.
labelstringWhat the reader sees. Falls back to the id stringified when the option carries none.
disabledbooleanShow the option in the editor but refuse to let it be chosen - for a value that exists historically and should not be used again. (optional)
variantVariantName 'neutral' | 'info' | 'success' | 'warning' | 'danger' | 'accent' | 'none' | (string & {})A semantic token for this option, so a pill or a dot takes its colour from the value rather than from a rule. (optional)
iconIconNameA glyph name from the icon registry (see {@link IconName}), shown before the label. (optional)
groupstringThe heading this option sits under in an editor that groups its list. Read from `groupKey` when the column names one. (optional)

ColumnState

PropertyTypeDescription
idstringWhich column this entry restores. An entry naming a column the grid no longer has is reported and skipped, not thrown.
widthnumberThe column's width in pixels at the time the state was taken. (optional)
flexnumberThe column's flex weight, present only when it has one - a fixed-width column leaves it out. (optional)
hiddenbooleanWhether the column was hidden. (optional)
pinEdge | nullWhich edge the column was frozen against, or `null` when it was in the scrolling body. (optional)
sortSortDirection | nullThe column's sort direction, or `null` when it was not sorted. (optional)
sortIndexnumber | nullThe column's place in a multi-column sort, or `null` when it was not sorted. (optional)
groupIndexnumber | nullThe column's place in the grouping order, or `null` when it was not a grouping key. (optional)
pivotIndexnumber | nullThe column's place in the pivot order, or `null` when it was not a pivot key. (optional)
totalTotalName | nullThe named footer aggregate the column carried, or `null`. A total given as a function has no name to save and is not recorded. (optional)
groupTotalTotalName | nullThe group-subtotal override, when one differs from `total`. (optional)
grandTotalTotalName | nullThe grand-total override, when one differs from `total`. (optional)
decorationDecorationName | DecorationSpec | nullThe column's runtime decoration, present only when the column carries one, so a `columns.decorate()` survives a saved view and participates in undo/redo. Absent means "not recorded"; an explicit `null` on an undo patch clears a decoration back to plain text. (optional)
variantVariantSpec | nullThe variant set alongside the decoration, when one is present. (optional)

ColumnGroupState

A persisted banded-header node (,: a band with a `columns` list whose members are leaf ids or nested bands. This is what round-trips a drag-created group through a saved view.

PropertyTypeDescription
idstringThe band's identity, matching the `id` on the live band, so a restore reattaches to the right one.
titlestringThe band's heading at the time the state was taken.
collapsiblebooleanWhether the band carried an open/close control.
openByDefaultbooleanWhether the band opened by default.
columnsArray<string | ColumnGroupState>The band's children in order - a leaf column's id, or a nested band's own state.

AnnotationMark

PropertyTypeDescription
typeAnnotationKind 'freehand' | 'arrow' | 'rect' | 'highlight' | 'text'What the mark is: a freehand trail, an arrow, a rectangle, a highlighter stroke, or a text label. `pen` is accepted on input as another name for `freehand`, and `list()` reports `freehand`.
points{ x: number; y: number }[]Content coordinates. A `text` mark carries a single anchor point; `arrow` and `rect` carry their two corners, and `freehand` a trail.
colourstringThe mark's colour, any CSS colour. Omitted, it takes the layer's current colour. (optional)
textstringThe label of a `text` mark. Required for `text`, ignored for other types. (optional)
fontSizenumberA `text` mark's font size in content pixels (before presentation scale). Defaults to 14. (optional)
backgroundstringAn optional backing colour drawn behind a `text` mark's label. (optional)
regionAnnotationRegion 'start' | 'centre' | 'end'Which columns the mark belongs to: a pinned region holds still while the grid scrolls sideways, the centre moves with it. Set from where a stroke began; omitted (the centre) for every mark that is not over a pinned column, so a mark saved before this existed reads unchanged. (optional)

CommentProvider

Storage for comments. Every method returns a promise; a rejection surfaces in the panel without disturbing grid state.

MethodSignatureParametersReturnsDescription
loadIndex(rowIds: string[], fields: string[]): Promise<CommentIndexEntry[]>rowIds: string[]
fields: string[]
Promise<CommentIndexEntry[]>Return the counts for these rows and columns - counts and timestamps only, never bodies, because this is consulted on every repaint. Called as the viewport moves, debounced.
loadThread(cellKey: string): Promise<Comment[]>cellKey: stringPromise<Comment[]>Return the bodies for one cell, in display order. Called when a thread is opened and dropped when it closes, so a grid never holds every thread.
addComment(cellKey: string, body: string, parentId: string | null, context?: { value?: unknown }): Promise<Comment>cellKey: string
body: string
parentId: string | null
context?: { value?: unknown }
Promise<Comment>Store a new comment on a cell, optionally as a reply, with the cell's value at the time as context. Return the stored comment; the grid shows it optimistically and rolls it back if this rejects.
editComment(commentId: string, body: string): Promise<Comment>commentId: string
body: string
Promise<Comment>Store a new body for an existing comment and return it.
deleteComment(commentId: string): Promise<void>commentId: stringPromise<void>Remove a comment. The grid shows it gone at once and puts it back if this rejects.
resolveThread(cellKey: string): Promise<void>cellKey: stringPromise<void>Mark a cell's thread resolved.
unresolveThread(cellKey: string): Promise<void>cellKey: stringPromise<void>Reopen a cell's thread.

PresenceProvider

Transport for presence. The grid never opens a connection: it subscribes to what the provider delivers and hands it what changed locally.

MethodSignatureParametersReturnsDescription
subscribe(onMessage: (message: Peer | Peer[]) => void): (() => void) | voidonMessage: (message: Peer | Peer[]) => void(() => void) | voidReturns an unsubscribe function, if it has one.
publish(state: Record<string, unknown>): voidstate: Record<string, unknown>voidSend this client's cursor, ranges and open editor to the other clients. The grid throttles the calls and drops them while publishing is paused, so a transport does no rate limiting of its own.

FacetBounds

PropertyTypeDescription
kindFacetBoundsKind 'numeric' | 'date' | 'category' | 'boolean' | 'none'Which family of buckets these are: `'numeric'`, `'date'`, `'category'`, `'boolean'`, or `'none'` when the column has no histogram at all.
bucketsFacetBucket[]Where the bars are - a range per bar for an ordered column, a value per bar for a categorical one. A column with absent values carries a final `Empty` bucket, and a capped categorical one carries an `Other` bucket.
suppressedFacetSuppressedReason 'type' | 'cardinality' | 'rows' | 'streaming' | 'no-provider' | 'disabled'Set when no histogram was drawn, naming why. (optional)
cardinalitynumberDistinct values, on categorical columns. (optional)
granularityFacetDateGranularity 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'The time unit chosen, on date columns. (optional)
strategyFacetBucketStrategy 'equal' | 'quantile' | 'log'The numeric strategy actually applied, which may differ from the request. (optional)
minnumberThe smallest value the buckets span, on an ordered column. (optional)
maxnumberThe largest value the buckets span. The last bucket's top edge is inclusive so the maximum has somewhere to land; every other bucket is half-open. (optional)

FacetBucket

One bucket of a column's distribution.

PropertyTypeDescription
fromnumberLower edge, for ordered columns. Half-open `[from, to)` except the last. (optional)
tonumberUpper edge, for ordered columns. Inclusive on the last bucket only. (optional)
valueunknownThe value, for categorical and boolean columns. (optional)
nullbooleanTrue on the terminal bucket holding nulls, NaN and empty values. (optional)
remainderbooleanTrue on the aggregated tail bucket under `aboveLimit: 'topN'`. (optional)
labelstringA ready-made label, where one is more useful than the raw value. (optional)

FormattingCondition

PropertyTypeDescription
opOperator | DistributionOpA filter operator compared against `value`, or a distribution operator whose threshold comes from the column itself: `{op: 'topPercent', value: 10}`, `{op: 'outlier'}`. Distribution thresholds are pinned when the rules compile; `grid.formatting.restat()` moves them.
valueunknownWhat the operator compares against - the number, text, date or list the rule tests for. Left out by operators that need no operand, such as `blank`. (optional)
value2unknownThe upper operand of a two-sided operator such as `between`. (optional)

FormattingScale

PropertyTypeDescription
fromScaleFrom 'minmax' | 'quantile' | 'stddev'Where the bounds come from when `min` and `max` are not given. `'minmax'` spans the data, `'quantile'` spans `low` to `high` (5th to 95th percentile by default), `'stddev'` spans `deviations` either side of the mean. (optional)
minnumberThe value that takes the first colour. Required unless `from` derives it. (optional)
maxnumberThe value that takes the last colour. Required unless `from` derives it. (optional)
midnumberThe value the middle colour is reached at. Needs an odd number of `colours` (a middle stop to pin) and a value inside `min`..`max`; each half of the scale is then spaced evenly within itself, so only the pivot moves. Without it the middle stop sits at the midpoint of the range. (optional)
lownumberThe lower percentile for `from: 'quantile'`, as a number from 0 to 100. 5 by default. (optional)
highnumberThe upper percentile for `from: 'quantile'`, as a number from 0 to 100. 95 by default. (optional)
deviationsnumberHow many standard deviations either side of the mean `from: 'stddev'` spans. 2 by default. (optional)
coloursstring[]The colour stops, low to high; at least two are needed or the scale falls back to a pale-to-blue pair. Three or more give a piecewise scale rather than an averaged blend. (optional)

DataBarSpec

An in-cell proportional bar. Drawn as a CSS gradient on the cell background - no extra element, and it composes with the cell's text. The bar's length is the value's position between `min` and `max`. Give both to pin the scale (0 to 100 for a percentage); otherwise `from` derives them from the column - `'minmax'` (the default) spans the data, `'quantile'` the 5th-95th percentile, `'stddev'` a number of deviations either side of the mean. When the range straddles zero, bars grow from a shared axis: positive right, negative left, each in its own colour.

PropertyTypeDescription
minnumberThe value at which the bar is empty. Derived from the data when omitted. (optional)
maxnumberThe value at which the bar is full. Derived from the data when omitted. (optional)
fromScaleFrom 'minmax' | 'quantile' | 'stddev'Where the bounds come from when `min` and `max` are not given: `'minmax'` (the default) spans the data, `'quantile'` spans `low` to `high`, `'stddev'` spans `deviations` either side of the mean. Derived once when the rules compile and then held, so a bar does not move without its value changing; `formatting.restat()` re-derives it. (optional)
lownumberThe lower percentile for `from: 'quantile'`, 0 to 100. 5 by default. (optional)
highnumberThe upper percentile for `from: 'quantile'`, 0 to 100. 95 by default. (optional)
deviationsnumberHow many standard deviations either side of the mean `from: 'stddev'` spans. 2 by default. (optional)
colourstringThe fill for non-negative values. (optional)
colorstringAmerican spelling of `colour`. (optional)
negativeColourstringThe fill for negative values. (optional)
negativeColorstringAmerican spelling of `negativeColour`. (optional)
directionExtract<Direction, 'ltr' | 'rtl'>Which way the bar grows. `'ltr'` (the default) or `'rtl'`. (optional)

IconSetSpec

PropertyTypeDescription
setIconSetKind | stringA built-in glyph set: `'arrows'`, `'trafficLights'` or `'ratings'`. `'arrows'` when nothing else is given, and ignored when you supply your own `icons`. (optional)
iconsstring[]Your own glyphs, low value first: SVG documents, data URIs or `url(...)`. (optional)
countnumberHow many bands, where the set's size is not fixed (e.g. `'ratings'`). (optional)
thresholdsnumber[]Band edges, ascending; one fewer than the number of icons. (optional)
reversebooleanReverse the glyph order, so the highest band takes the first icon. (optional)
sizenumberGlyph height in pixels. Default 16. (optional)

RailActionParams

What a host rail action's `run` is handed.

PropertyTypeDescription
gridGridThe grid instance the rail belongs to.
keysstring[]The keys of the selected rows at the moment the action ran.
cells{ key: string; colId: string }[]The cells inside the selected ranges at the moment the action ran, as row key and column id pairs.

StatValueSpec

How a statistic block finds the number it reports.

PropertyTypeDescription
ofstringThe column to reduce, as a field name or a dotted path. Omit for `count`. (optional)
fnTotalName 'sum' | 'min' | 'max' | 'avg' | 'count' | 'first' | 'last' | 'countValues' | (string & {})A key of `TOTAL_FNS`: `sum`, `avg`, `median`, `p95`, `gini` and the rest. (optional)
showstringReport this column from the row holding the extreme, rather than the extreme itself: `{ of: 'sales', fn: 'max', show: 'rep' }` is the *name* of the best rep. Needs `min` or `max`, no single row holds an average. (optional)

ValueParams

PropertyTypeDescription
valueunknownThe cell's resolved value, after any `compute` and before formatting.
dataunknownYour own row object, exactly as you supplied it.
rowRowThe grid's wrapper round that object, carrying the key, the display index and whether the row is a group, a footer or a total.
columnColumnThe resolved column, including anything you declared on it.
colIdstringThe column's id, for the common case where that is all the callback needs.
gridGridThe grid instance, so a callback can read the rest of the grid - another cell, the selection, the filters.
contextunknownWhatever `config.context` holds: the application state a callback needs and the grid knows nothing about.

DecorationSpec

PropertyTypeDescription
typeDecorationName 'plain' | 'fill' | 'pill' | 'dot' | 'bar' | 'heat' | 'icon'Which shape the cell draws as: `plain`, `fill`, `pill`, `dot`, `bar`, `heat` or `icon`. Colour, padding and radius follow from the shape, the size token and the theme, so every pill in an application matches without being configured.
sizeDecorationSize 'sm' | 'md' | 'lg'The size token - `'sm'`, `'md'` or `'lg'`. Unset follows the grid's density. (optional)
shapeDecorationShape 'pill' | 'rounded' | 'square'The container's outline shape: `'pill'`, `'rounded'` or `'square'`. Unset follows the decoration's own default. (optional)
outlinebooleanPill only: draw it as a coloured border round transparent fill rather than a solid tint. Off by default. (optional)
edgebooleanFill only: draw a leading colour bar at the cell's edge instead of tinting the whole cell. Off by default. (optional)
positionEdge 'start' | 'end'Dot and icon only: which side of the value the mark sits on. `'start'` by default. (optional)
nameIconName | Record<string, IconName>`icon` decoration only: either a single glyph name (see {@link IconName}) used for every value, or a value -> glyph name map for exact-value icons. Omit both `name` and `bands` to use `iconSet`/its default instead. (optional)
iconSetIconSetName 'trafficLights' | 'arrows' | 'trafficArrows' | 'ratings' | (string & {})icon only: a built-in threshold icon set, expanded to `bands`. (optional)
bandsIconBand[]icon only: value bands mapped to glyphs, first match by descending `min`. (optional)
minnumberBar and heat only: the value at which the bar is empty or the heat is coldest. 0 by default. A function of the cell is accepted, for a scale that follows the data. (optional)
maxnumberBar and heat only: the value at which the bar is full or the heat is hottest. 100 by default - or 1 on a percent-formatted column, whose values are stored as fractions, since otherwise every bar would be a sliver disagreeing with the `87%` printed beside it. (optional)
originnumberBar only: the value the bar grows out from, so a bar for a signed column can run left for negatives and right for positives. Unset grows from `min`. (optional)
showValuebooleanBar only: whether the number is printed as well as drawn. `true` by default; `'inside'` puts it within the bar and `false` leaves the bar alone. (optional)
trackbooleanBar only: paint the unfilled remainder as a track, so the full scale is visible. On by default. (optional)
rampstringHeat only: the colour token used below the midpoint, which is what makes a diverging scale - one colour for the low arm, the decoration's own for the high. (optional)
midpointnumberHeat only: the value the scale diverges about. Unset makes a single-ended ramp from `min` to `max`; set, the intensity is the distance from this point, normalised by the longer arm. (optional)

ColumnTooltipSpec

A rich, keyboard-accessible tooltip for a column's cells - the object form of `cell.tooltip`, drawn by the grid rather than handed to the browser as a native `title`. Shown after a delay (`tooltip.delay`, 400ms by default) on hover *and* on keyboard focus; the cell points at it with `aria-describedby`; it can be hovered without closing and Escape dismisses it (WCAG 2.2 AA, 1.4.13). It closes on scroll, because rows are pooled and a bubble left open would be anchored to a node that is now showing a different row.

MethodSignatureParametersReturnsDescription
render(params: TooltipParams) => HTMLElement | TooltipSpec | { html: string } | string | null | undefinedparams: TooltipParams=> HTMLElement | TooltipSpec | { html: string } | string | null | undefinedProduce the content. Four shapes, and the difference between the last two is a security property rather than a style choice: - an **element** - your own DOM, attached as it is; - a **{@link TooltipSpec}** - `{ title, rows, note }`, rendered as text; - **`{ html }`** - the only wrapper that inserts markup, scrubbed of script the same way `allowUnsafeTemplates` output is; - a **string** - *always* text, never markup. The last rule is what makes `render: (p) => p.value` safe: a value comes from row data, and data must not be able to promote itself to HTML. (optional)
mount(el: HTMLElement, params: TooltipParams) => voidel: HTMLElement
params: TooltipParams
=> voidPut live content in the tooltip - a sparkline, a KPI tile - by calling into a module bundle your application loaded. The grid core never imports a module, so anything live is mounted here by you. (optional)
unmount(el: HTMLElement) => voidel: HTMLElement=> voidTear down whatever `mount` built. Called every time the tooltip closes, so nothing keeps running behind a hidden box. (optional)

WhereRuntime

The `where` predicates in force, as a source sees them. A snapshot rather than the model, so a source can evaluate the predicates but cannot register or remove one through it.

PropertyTypeDescription
activebooleanWhether any predicate is registered at all.
namesstring[]The registered names, in registration order - for diagnostics.
versionnumberBumped on every registration or removal, so a cache key can track it.
MethodSignatureParametersReturnsDescription
passes(row: unknown, key?: string): booleanrow: unknown
key?: string
booleanDoes this row survive every registered predicate?

CommentIndexEntry

What `loadIndex` returns per commented cell.

PropertyTypeDescription
cellKeystringThe cell this entry describes, as the grid's own composite key. Give this, or `rowId` and `field`, and the grid builds it. (optional)
rowIdstringThe row key, when the entry is identified by row and column rather than by `cellKey`. (optional)
fieldstringThe column id, beside `rowId`. (optional)

Comment

One comment in a thread, as the provider returns it.

PropertyTypeDescription
idstringThe comment's identity, as the provider assigns it. A comment awaiting the provider carries a temporary id and is replaced when the write returns.
bodystringThe text of the comment.
author{ name?: string; avatarUrl?: string; initials?: string }Rendered as supplied. The grid does not know who the user is. (optional)
atnumberWhen it was written, as epoch milliseconds. (optional)
editedbooleanWhether the body has been changed since it was posted, so a reader can be told. (optional)
resolvedbooleanWhether the thread this comment belongs to has been resolved. (optional)
parentIdstring | nullThe comment this one replies to, or null at the top of the thread. (optional)
valueunknownThe cell's value when this was written, so a later reader is told it moved. (optional)
can{ edit?: boolean; delete?: boolean; resolve?: boolean }What the current user may do. Absent means the grid shows every affordance and relies on the provider to refuse. Hiding a button is a convenience, never a security control. (optional)

Peer

One peer, as the grid holds them.

PropertyTypeDescription
idstringThe peer's stable identity, as the provider supplies it. Everything else keys off it - the colour, the cursor, the lock.
namestringThe display name. Falls back to the id when the provider sends none.
colourstringAssigned deterministically from the id when the provider supplies none.
avatarUrlstring | nullA picture for the peer, or `null` when the provider sends none. (optional)
initialsstring | nullInitials to draw when there is no avatar, or `null`. (optional)
cursor{ rowId: string; colId: string } | nullRow key and column, never an index.
rangesArray<{ rowIds: string[]; columns: string[] }>The cell ranges the peer has selected, as row keys and column ids - never indices, since peers sort and filter independently.
editing{ rowId: string; colId: string } | nullThe cell the peer has an editor open on, or `null`. This is what `editorOf` and the advisory lock read.
atnumberLocal receipt time, not the sender's clock.
sentAtnumber | nullThe sender's own timestamp, for inspection only. Nothing decides on it. (optional)
idlebooleanWhether nothing has been heard from this peer for the idle window (30 seconds by default). Derived when the peers are read, so a peer goes idle without any timer having to fire. (optional)
silentMsnumberHow long it has been, in milliseconds, since anything was heard from this peer. (optional)
hiddenbooleanTrue when the peer's cursor is on a row this view is not showing. (optional)

IconBand

One band of a threshold icon set. A value clears a band when it is at least `min`; the highest band it clears wins. Omit `min` on the last band to make it the catch-all. `label` is what assistive technology announces for the glyph, so a screen-reader user hears the band's meaning, not only the value.

PropertyTypeDescription
minnumberThe lowest value in this band. Bands are tested from the highest `min` down, so the first one a value reaches wins; leave it out on the fallback band. (optional)
iconIconNameA glyph name from the icon registry (see {@link IconName}).
labelstringAccessible text for the glyph, so the band means something to a screen reader and in a tooltip. (optional)
variantVariantName 'neutral' | 'info' | 'success' | 'warning' | 'danger' | 'accent' | 'none' | (string & {})The semantic token this band's glyph is coloured with. (optional)

TooltipParams

What a tooltip's `render` and `mount` are given: the same identification `cell:clicked` carries, plus the cell element itself and the grid. Resolved from the DOM at the moment the tooltip opens rather than when the pointer arrived, so a pooled row re-used in between names the row it is showing now.

PropertyTypeDescription
rowRowThe row under the pointer or the keyboard cursor.
keystringThat row's key.
indexnumberIts display index.
colIdstringThe column the cell belongs to.
columnColumnThe resolved column.
valueunknownThe cell's value.
textstringThe cell's formatted text.
cellHTMLElementThe cell element the tooltip is anchored to.
gridGridThe grid.

TooltipSpec

Structured tooltip content the grid renders for you: a heading, a list of label/value lines, and a closing note. Every field is written as **text**, never as markup, so a spec built out of row values needs no escaping and cannot become HTML by accident. Return `{ html }` from `render` when markup is genuinely wanted.

PropertyTypeDescription
titleunknownA heading for the tooltip. (optional)
rowsTooltipRow[]Label/value lines, in order. (optional)
noteunknownA closing note under the lines, drawn quieter than them. (optional)

TooltipRow

One label/value line in a {@link TooltipSpec}. Both halves are written as text by the grid, whatever they contain.

PropertyTypeDescription
labelunknownThe line's label, drawn on the leading edge. (optional)
valueunknownThe line's value, drawn on the trailing edge. (optional)

Events

Every event the grid declares, and the payload interfaces handlers receive.

Every event

Generated from the EventName union and the EventPayloads map. A payload of unknown is one the declarations do not name yet; cancellable is read off the payload type, and - means the payload is undeclared, not that the event cannot be cancelled.

EventRaised byWhenPayloadCancellable
readyLifecycleThe grid has finished building and every API on it is ready to call; fires once, on the frame after `createGrid` returns.no payloadno
destroyLifecycle`grid.destroy()` was called and is about to release everything, so a handler can still read the grid one last time.no payloadno
render:firstLifecycleThe renderer has written its first frame into the host element.no payloadno
render:doneLifecycleA render pass has finished writing cells: the row window it drew, what caused the pass, and how long each phase took.RenderDoneEventno
config:changedLifecycleA configuration key was written at run time through `grid.set(key, value)` or `grid.setAll(values)`, after the grid rebuilt.ConfigChangedEventno
licence:changedLifecycleA licence key was installed through `grid.licence.set(key)`, and again when its asynchronous verification settles.LicenceChangedEventno
model:changedDataThe display model was rebuilt - rows reloaded, the tree re-flattened, a page fetched, a query re-run - with `reason` naming which.ModelChangedEventno
rows:changedDataRows were added, updated, removed or moved. `identified: true` means the payload names exactly which rows moved.RowsChangedEventno
rows:queuedDataA change arrived while the feed was being batched and was put on the queue instead of applied.RowsQueuedEventno
rows:deferredDataA flush ran out of its frame budget and carried the rest of the change into the next one.RowsDeferredEventno
rows:pausedData`grid.changes.pause()` held the feed: changes keep arriving and stop being applied.RowsFlowEventno
rows:resumedData`grid.changes.resume()` released the feed and applied what had been held.RowsFlowEventno
row:receivedDataA row dragged from another grid was accepted into this one, on the receiving grid.RowReceivedEventno
row:sentDataA row was dragged out of this grid into another one and removed from here (a move, not a copy).RowTransferEventno
row:copiedDataA row was dragged out of this grid into another one and kept here as well (a copy).RowTransferEventno
row:movedDataA row was reordered within this grid, from one display index to another.RowMovedEventno
source:errorDataA source could not fetch what was asked of it: a page, a group's children, a tree branch, or the stream itself.SourceErrorEventno
source:totalDataA source that delivered its rows before counting them has finished counting; the exact total is in the payload.SourceTotalEventno
stream:chunkDataA streaming source applied a chunk of arriving rows.StreamChunkEventno
stream:endDataA streaming source reached the end of its feed; `promoted` says whether it handed over to an in-memory source.StreamEndEventno
stream:evictedDataA rolling-window stream dropped rows off the back of its window to stay inside its limit.StreamEvictedEventno
rowDrag:startedThe row-drag gesture as it happensA row drag passed the drag threshold and began, on the grid the row was picked up in.RowDragEventno
rowDrag:movedThe row-drag gesture as it happensThe pointer moved during a row drag, coalesced to one event per animation frame.RowDragEventno
rowDrag:leftThe row-drag gesture as it happensThe pointer left a grid it had been dragging over; `over` names the grid just left.RowDragEventno
rowDrag:endedThe row-drag gesture as it happensThe row drag ended - released anywhere, inside a grid or outside every one; `dropped` says whether it is being acted on.RowDragEventno
cell:changedCells and editingA cell's value was written: by an edit commit, by a revert, or by an undo/redo step.CellChangedEventno
cell:pendingCells and editingAn optimistic cell edit was sent to the transport and is awaiting the server's answer.CellPendingEventno
cell:confirmedCells and editingThe server accepted a pending cell edit; `value` is what it confirmed, which may not be what was sent.CellConfirmedEventno
cell:revertedCells and editingA pending cell edit was refused and the previous value put back.CellRevertedEventno
cell:conflictCells and editingThe server accepted a pending cell edit but returned a row that disagrees with what the grid holds.CellConflictEventno
cell:clickedCells and editingA cell was clicked (primary button, single click).CellPointerEventno
cell:dblclickedCells and editingA cell was double-clicked.CellPointerEventno
cell:contextmenuCells and editingA context menu was requested on a cell, by the pointer or by the keyboard's menu key.CellContextMenuEventno
cell:mouseoverThe pointer entering and leaving a cellThe pointer entered a cell; crossing between two children of one cell is not a re-entry.CellPointerEventno
cell:mouseoutThe pointer entering and leaving a cellThe pointer left a cell; crossing between two children of one cell is not a departure.CellPointerEventno
cell:mousedownCells and editingA pointer button was pressed on a cell, before any click is resolved.CellPointerEventno
cell:mouseupCells and editingA pointer button was released on a cell.CellPointerEventno
cell:edit:startCells and editingA cell editor opened, by double-click, by Enter, or by typing into the cell.EditStartEventno
cell:edit:endCells and editingA cell editor closed: committed, cancelled, or refused by validation - `valid` and `cancelled` say which.EditEndEventno
row:edit:startCells and editingA whole-row editor opened, the row-edit counterpart of `cell:edit:start`.EditStartEventno
row:edit:endCells and editingA whole-row editor closed, the row-edit counterpart of `cell:edit:end`.EditEndEventno
row:clickedCells and editingA row was clicked, alongside the `cell:clicked` for the cell under the pointer.RowPointerEventno
row:dblclickedCells and editingA row was double-clicked, alongside the `cell:dblclicked` for the cell under the pointer.RowPointerEventno
row:pendingCells and editingAn optimistic row append or delete was sent to the transport and is awaiting the server's answer.RowPendingEventno
row:confirmedCells and editingThe server accepted a pending row append or delete; an append is rekeyed from its temporary key first.RowConfirmedEventno
row:revertedCells and editingA pending row append or delete was refused: the optimistic append is discarded, the tombstoned row restored.RowRevertedEventno
row:conflictCells and editingThe server accepted a pending row append or delete but returned a row that disagrees with what the grid holds.RowConflictEventno
form:openedCells and editingThe row form opened over a row.FormOpenedEventno
form:closedCells and editingThe row form was closed without saving.FormClosedEventno
form:savedCells and editingThe row form's values were saved back to the row.FormSavedEventno
form:errorCells and editingThe row form could not load or save a row; `timedOut` distinguishes a slow backend from a refusal.FormErrorEventno
sort:changedQueryThe sort order changed, through `grid.sort.set()` or a header click.SortChangedEventno
filter:changedQueryThe filters changed: a structured condition, the quick filter's text, or a named host predicate.FilterChangedEventno
group:toggledQueryA group row was expanded or collapsed - one group, one branch, or all of them at once.GroupToggledEventno
facet:computedQueryA column's facet buckets finished computing, with how long it took and whether a worker did it.FacetComputedEventno
facet:filteredQueryA facet histogram was used to filter its column, or that filter was cleared.FacetFilteredEventno
facet:expandedQueryA facet panel section was opened or closed.FacetExpandedEventno
facet:failedQueryA column's facet buckets could not be computed.FacetFailedEventno
column:movedColumnsA column was moved to a different display position.ColumnMovedEventno
column:resizedColumnsA column's width changed, by a header drag or by `grid.columns.resize()`.ColumnResizedEventno
column:visibleColumnsColumns were shown or hidden.ColumnVisibleEventno
column:pinnedColumnsA column was pinned to a side, or unpinned.ColumnPinnedEventno
column:groupedColumnsThe row grouping changed: which columns the rows are grouped by.ColumnGroupedEventno
column:pivotedColumnsThe pivot changed: which columns the rows are pivoted by, locally or pushed down to the backend.ColumnPivotedEventno
column:filter:openColumnsThe header's filter affordance was activated and the column's filter popup should open.ColumnMenuEventno
column:profile:openColumnsThe column menu's profile item was activated and the column's profile should open.ColumnMenuEventno
column:menu:openColumnsThe header's menu affordance was activated and the column menu should open.ColumnMenuEventno
pivot:drillColumnsA pivot measure cell was drilled into; the payload names the row and column paths behind it.PivotDrillEventno
columns:changedColumnsThe column set changed other than by moving, resizing, hiding or pinning - a type inference pass rewrote it.ColumnsChangedEventno
columns:taggedColumns`grid.columns.showTagged()` chose which columns to show from their tags.ColumnsTaggedEventno
columngroup:changedColumnsA banded header group was formed, renamed, moved, dissolved, removed or restored from state.ColumnGroupChangedEventno
header:contextmenuColumnsA context menu was requested on a column header.HeaderContextMenuEventno
selection:changedSelection and viewThe row selection changed and was accepted (a `beforeSelect` veto raises `selection:cancelled` instead).SelectionChangedEventno
range:changedSelection and viewThe selected cell ranges changed.RangeChangedEventno
clipboard:copySelection and viewA copy to the clipboard was attempted; `ok` says whether it reached the clipboard.ClipboardCopyEventno
page:changedSelection and viewThe page or the page size changed.PageChangedEventno
scrollSelection and viewThe viewport scrolled to a new offset; fires only when the offset actually moved, not on a refresh.ScrollEventno
scroll:endSelection and viewScrolling settled: the last of a scroll gesture's frames has been drawn.ScrollEventno
size:changedSelection and viewThe host element's box changed size, as reported by the `ResizeObserver` the grid watches it with.no payloadno
detail:toggledSelection and viewA master-detail region was opened or closed.DetailToggledEventno
toolpanel:focusSelection and viewThe keyboard asked for focus to move to the tool panel (Ctrl+Alt+P).no payloadno
highlight:changedSelection and viewThe set of host-declared highlights changed.HighlightChangedEventno
find:changedSelection and viewThe find bar's query, open state or match count changed.FindChangedEventno
tree:loadingTree dataA tree branch was expanded and `tree.loadChildren` was called for it.TreeLoadingEventno
tree:loadedTree dataA tree branch's children arrived and were added.TreeLoadedEventno
tree:loadFailedTree dataA tree branch's `loadChildren` rejected; the branch is left unloaded so it can be retried.TreeLoadFailedEventno
tree:loadAbortedTree dataA tree branch was collapsed before its children arrived, so the fetch was abandoned.TreeLoadAbortedEventno
state:changedState, history and viewsOne logical state change - a gesture, an apply, an undo or a reset - announced once, whatever routed it.StateChangedEventno
state:resetState, history and views`grid.state.reset()` restored the arrangement the grid was built with.StateResetEventno
history:changedState, history and viewsThe undo/redo stacks moved: what can now be undone or redone.HistoryChangedEventno
history:appliedState, history and viewsAn undo or redo step was applied.HistoryAppliedEventno
views:changedState, history and viewsThe saved-view list changed, for any reason; the named `view:*` events say which view moved.ViewsChangedEventno
view:appliedState, history and viewsA saved view was applied to the grid.ViewAppliedEventno
view:savedState, history and viewsA saved view was created, updated or imported.ViewChangedEventno
view:removedState, history and viewsA saved view was deleted.ViewChangedEventno
view:renamedState, history and viewsA saved view was renamed.ViewChangedEventno
view:defaultState, history and viewsA saved view was made the default one.ViewChangedEventno
validation:failedState, history and viewsA declared column rule refused an edit; the failures name the column and the message for each.ValidationFailedEventno
validation:clearedState, history and viewsRecorded validation errors were cleared - for one cell, one row, or the whole grid.ValidationClearedEventno
formatting:changedFormatting and presentationA conditional-formatting rule was added, changed, removed or replaced.FormattingChangedEventno
redaction:changedFormatting and presentationThe set of redacted columns changed.RedactionChangedEventno
permissions:changedFormatting and presentationThe per-column permission levels changed.PermissionsChangedEventno
presentation:changedFormatting and presentationEither the responsive presentation switched between the table and the card layout, or `presentation.start()` was called again while already running.PresentationChangedEventno
presentation:startedFormatting and presentation`grid.presentation.start()` began presenting.PresentationStartedEventno
presentation:endedFormatting and presentation`grid.presentation.stop()` stopped presenting.no payloadno
presentation:viewFormatting and presentationThe presentation stepped to a view in its deck, including the first one.PresentationViewEventno
presentation:scaleFormatting and presentationThe presentation's enlargement changed.PresentationScaleEventno
presentation:spotlightFormatting and presentationThe presentation's spotlight was armed over some rows and columns, or cleared.PresentationSpotlightEventno
presentation:capturedFormatting and presentationA screenshot of the grid was captured (`grid.capture()`), with the image's size and type.PresentationCapturedEventno
comment:addedCollaborationA comment was added to a cell, or a reply added to a thread.CommentAddedEventno
comment:editedCollaborationA comment's text was edited.CommentEventno
comment:deletedCollaborationA comment was deleted.CommentEventno
comment:failedCollaborationA comment operation could not reach the backend; `operation` names which one.CommentFailedEventno
comment:resolvedCollaborationA comment thread was marked resolved.CommentResolvedEventno
comment:unresolvedCollaborationA resolved comment thread was reopened.CommentResolvedEventno
comment:threadOpenedCollaborationA cell's comment thread was opened.CommentThreadOpenedEventno
comment:threadClosedCollaborationA cell's comment thread was closed or dismissed.CommentThreadClosedEventno
comment:indexLoadedCollaborationThe comment index for the visible rows finished loading, with how many entries it carried.CommentIndexLoadedEventno
presence:publishedCollaborationThis grid published its own presence - the cell it is on, its selection - to the presence transport.PresencePublishedEventno
presence:joinedCollaborationA peer appeared in the presence channel for the first time.PresencePeerEventno
presence:updatedCollaborationA peer already present moved or changed what it is doing.PresencePeerEventno
presence:leftCollaborationA peer left the presence channel or timed out.PresenceLeftEventno
presence:failedCollaborationA presence subscribe or publish could not reach the transport.PresenceFailedEventno
presence:lockRefusedCollaborationAn edit was refused because a peer holds the cell's lock.PresenceLockRefusedEventno
diff:changedComparison and timeDiff mode was turned on against a snapshot, or turned off.DiffChangedEventno
diff:swappedComparison and timeThe two sides of a diff were swapped.DiffSwappedEventno
timeline:attachedComparison and timeThe timeline scrubber began recording what each change replaces.TimelineAttachedEventno
timeline:detachedComparison and timeThe timeline scrubber stopped recording and the grid returned to the present.no payloadno
timeline:seekComparison and timeThe timeline finished moving and the grid now stands at that position.TimelineSeekEventno
timeline:seekingComparison and timeThe timeline is about to move, with where it is coming from and going to.TimelineSeekingEventno
annotation:changedAnnotationsThe annotation overlay's marks changed: one was drawn, moved or erased, or the tool changed.AnnotationChangedEventno
export:progressExportA streaming export wrote another chunk, with rows written, rows expected and bytes so far.ExportProgressEventno
export:requestExportA remote export request is about to be handed to the host's `export.remote.fetch` hook.ExportRequestEventno
export:doneExportA remote export came back and the file was handed over (or downloaded).ExportDoneEventno
shortcuts:openedKeyboard help overlay (past-tense notifications)The keyboard-shortcuts overlay was opened.no payloadno
shortcuts:closedKeyboard help overlay (past-tense notifications)The keyboard-shortcuts overlay was closed.no payloadno
print:beforePrint (past-tense notifications,Print mode has been applied and the grid laid out un-virtualised, just before the print dialog.PrintEventno
print:afterPrint (past-tense notifications,The print dialog has returned and print mode has been undone.PrintEventno
beforeEditCancellable before-eventsA user or AI edit is about to be committed; call `preventDefault(reason?)` to stop it.BeforeEditEventyes
beforeSortCancellable before-eventsA user sort is about to be applied; call `preventDefault(reason?)` to stop it.BeforeSortEventyes
beforeFilterCancellable before-eventsA user filter - structured or quick - is about to be applied; call `preventDefault(reason?)` to stop it.BeforeFilterEventyes
beforeColumnMoveCancellable before-eventsA user column move is about to be applied; call `preventDefault(reason?)` to stop it.BeforeColumnMoveEventyes
beforeColumnResizeCancellable before-eventsA user column resize is about to be applied; call `preventDefault(reason?)` to stop it.BeforeColumnResizeEventyes
beforeColumnHideCancellable before-eventsA user column hide is about to be applied; call `preventDefault(reason?)` to stop it.BeforeColumnHideEventyes
beforeSelectCancellable before-eventsA user selection change is about to be announced; call `preventDefault(reason?)` to snap it back.BeforeSelectEventyes
beforeRowAddCancellable before-eventsA user row append is about to be sent; call `preventDefault(reason?)` to stop it.BeforeRowAddEventyes
beforeDeleteCancellable before-eventsA user row delete is about to be applied; call `preventDefault(reason?)` to stop it.BeforeDeleteEventyes
beforeRowMoveCancellable before-eventsA user row reorder is about to be applied; call `preventDefault(reason?)` to stop it.BeforeRowMoveEventyes
beforeGroupCancellable before-eventsA user group expand or collapse is about to be applied; call `preventDefault(reason?)` to stop it.BeforeGroupEventyes
beforeRowReceiveRow transfer between gridsA row dragged from another grid is about to be inserted here; call `preventDefault(reason?)` to refuse it.BeforeRowReceiveEventyes
edit:cancelledTheir cancellation notifications (past-tense, non-cancellable).A `beforeEdit` handler vetoed the commit, or it went stale while an async handler was thinking.EditCancelledEventno
sort:cancelledTheir cancellation notifications (past-tense, non-cancellable).A `beforeSort` handler vetoed the sort.SortCancelledEventno
filter:cancelledTheir cancellation notifications (past-tense, non-cancellable).A `beforeFilter` handler vetoed the filter.FilterCancelledEventno
columnMove:cancelledTheir cancellation notifications (past-tense, non-cancellable).A `beforeColumnMove` handler vetoed the move.ColumnMoveCancelledEventno
columnResize:cancelledTheir cancellation notifications (past-tense, non-cancellable).A `beforeColumnResize` handler vetoed the resize.ColumnResizeCancelledEventno
columnHide:cancelledTheir cancellation notifications (past-tense, non-cancellable).A `beforeColumnHide` handler vetoed the hide.ColumnHideCancelledEventno
selection:cancelledTheir cancellation notifications (past-tense, non-cancellable).A `beforeSelect` handler vetoed the selection change, which has been snapped back.SelectionCancelledEventno
rowAdd:cancelledTheir cancellation notifications (past-tense, non-cancellable).A `beforeRowAdd` handler vetoed the append.RowAddCancelledEventno
delete:cancelledTheir cancellation notifications (past-tense, non-cancellable).A `beforeDelete` handler vetoed the delete, or the rows were gone by the time an async handler settled.DeleteCancelledEventno
rowMove:cancelledTheir cancellation notifications (past-tense, non-cancellable).A `beforeRowMove` handler vetoed the reorder.RowMoveCancelledEventno
group:cancelledTheir cancellation notifications (past-tense, non-cancellable).A `beforeGroup` handler vetoed the expand or collapse.GroupCancelledEventno
rowReceive:cancelledTheir cancellation notifications (past-tense, non-cancellable).A `beforeRowReceive` handler refused the drop, or the drop went stale while an async handler was thinking.RowReceiveCancelledEventno
*Every event at once, for logging and debugging.Every event above, delivered to one handler; the payload is whichever event fired.GridEventno

AnnotationChangedEvent

`annotation:changed`: the annotation overlay's marks or tool changed.

PropertyTypeDescription
toolAnnotationTool | nullThe tool now in use, or null when none is.
countnumberHow many marks the layer now holds.

BeforeColumnHideEvent

`beforeColumnHide`: one or more columns are about to be hidden.

PropertyTypeDescription
columnsstring[]The columns about to be hidden.

BeforeColumnMoveEvent

`beforeColumnMove`: a column is about to be moved.

PropertyTypeDescription
columnstringThe column being moved.
tonumberThe display index it would take.

BeforeColumnResizeEvent

`beforeColumnResize`: a column is about to be resized.

PropertyTypeDescription
columnstringThe column being resized.
widthnumberThe width it would take, in pixels.

BeforeDeleteEvent

`beforeDelete`: one or more rows are about to be deleted.

PropertyTypeDescription
keystringThe first key about to be deleted.
keysstring[]Every key about to be deleted, on the multi-row gesture. (optional)
rowsstring[]Every key about to be deleted.

BeforeEditEvent

`beforeEdit`: a cell or row edit is about to be committed. Raised only for a person's or the AI's write - origin `'user'` or `'ai'`. An `'api'` write (`grid.edit.setCells` with no origin, and the paste, fill and clear that funnel through it) is not gated and raises nothing, so nothing that worked before the gate existed changed shape.

PropertyTypeDescription
rowRowThe row about to be committed.
keystringThat row's key.
modeEditMode 'cell' | 'row'Whether it is a cell edit or a row edit.
changes{ colId: string; oldValue: unknown; newValue: unknown }[]The writes that are about to be made.

BeforeEvent

A cancellable *before*-event, delivered to `on('beforeX')` handlers before a user-initiated mutation is applied. A handler cancels the pending action by calling `preventDefault(reason?)`; the mutation is then abandoned and a past-tense `<action>:cancelled` event carries the reason. A handler may be `async` (or return a Promise): the grid awaits every before-handler before deciding, so a confirm dialog or a server check can gate the write. Any one handler preventing cancels the action. The action-specific fields (the edited cells, the target index, the affected rows) are spread alongside these, so a handler decides without reaching into grid internals. `origin` distinguishes a genuine user gesture from a host/module-driven or remote write, which is how a module whose move re-enters core is deduplicated by the host.

PropertyTypeDescription
defaultPreventedbooleanTrue once any handler has called `preventDefault` or returned false.
reasonstring | nullThe reason given to `preventDefault`, or null; `'stale'` when re-validation failed.
MethodSignatureParametersReturnsDescription
preventDefault(reason?: string): voidreason?: stringvoidCancel the pending action; the optional reason is surfaced on the cancellation event.

BeforeFilterEvent

PropertyTypeDescription
filtersFilterSetThe structured filter about to be applied, on a `kind: 'structured'` firing. (optional)
quickstringThe quick-filter text about to be applied, on a `kind: 'quick'` firing. (optional)
kindFilterKind 'structured' | 'quick'Which filter this is.

BeforeGroupEvent

`beforeGroup`: a group row is about to be expanded or collapsed.

PropertyTypeDescription
keystringThe key of the group row.
expandedbooleanTrue when it is being opened, false when it is being closed.

BeforeRowAddEvent

`beforeRowAdd`: a record is about to be appended through the pending-row path.

PropertyTypeDescription
rowRecord<string, unknown>The record about to be appended.

BeforeRowMoveEvent

`beforeRowMove`: a row is about to be reordered within this grid.

PropertyTypeDescription
keystringThe key of the row being moved.
fromnumberThe display index it is at.
tonumberThe display index it would take.

BeforeRowReceiveEvent

The `beforeRowReceive` event: a row dragged from another grid is about to be inserted into this one. Fires on the **receiving** grid, before the insert, with the row under the pointer named - so a drop that means "assign this to that" can be recorded by the host and the insert stopped with `preventDefault(reason)`. A veto leaves the source grid untouched: the row stays where it was, and neither `row:sent` nor `row:copied` fires there. The source removes its row only after the target has admitted it, and a veto is a refusal to admit. The paired `rowReceive:cancelled` carries the same context plus the reason. Like every {@link BeforeEvent}, the handler may be `async`; the insert is held until it settles, and is cancelled as `'stale'` if the source row is gone by then, or if the row under the pointer is gone or has moved to a different index - `at` names a slot as "before `overKey`", and once that is no longer where `overKey`'s row sits, `at` is a stale index into a list that changed while the handler was thinking, not the slot the drop meant. `overKey: null` (the drop landed on no row) has no row to drift against and is never stale on that account.

PropertyTypeDescription
dataRecord<string, unknown>The row about to be inserted: a shallow copy of the source row's data, and the very object that is inserted if no handler vetoes, so a change made to it here lands with the row.
atnumberThe display index the row would be inserted at: the index of the row under the pointer, or `rows.count()` when the drop landed on no row. When `overKey` names a row, this is guaranteed to still be that row's index at the moment the insert actually runs - an async handler that leaves the named row at a different index causes the drop to be cancelled as `'stale'` rather than inserted at this index regardless.
overKeystring | nullThe key of the row under the pointer when the drop happened - the row the user meant. Null when the drop landed past the last row, on empty space, on the header, or on a pinned row: there is no row to name, and a nearest guess would be wrong in a way that looks right.
sourceGridThe grid the row is being dragged from.

BeforeSelectEvent

`beforeSelect`: the user changed the selection, which has not been announced yet.

PropertyTypeDescription
keysstring[]The keys the user has just selected.
previousstring[]The keys the selection would snap back to on a veto.

BeforeSortEvent

`beforeSort`: the user asked for a sort, which has not been applied yet.

PropertyTypeDescription
sortSortEntry[]The sort that is about to be applied.

CellChangedEvent

`cell:changed`: a cell's value was written. Fired by an edit commit, by a revert, and by each cell an undo or redo step moves - `revert` and `undo` say which, and both are absent on a plain edit.

PropertyTypeDescription
rowRowThe row the cell belongs to.
keystringThat row's key.
colIdstringThe column id that was written.
valueunknownThe value the cell now holds.
oldValueunknownThe value it held before.
revertbooleanTrue when the write put back a value the server refused. (optional)
reasonstring | nullWhy it was reverted, or null. (optional)
undobooleanTrue when the write came from an undo step rather than a redo. (optional)

CellConfirmedEvent

`cell:confirmed`: the server accepted a pending cell edit.

PropertyTypeDescription
rowRowThe row the cell belongs to.
keystringThat row's key.
colIdstringThe column id that was written.
valueunknownWhat the server confirmed, which need not be what was sent.
idstringThe id the op was tracked under.
supersededbooleanTrue when a newer write on the same cell had already replaced this one.

CellConflictEvent

`cell:conflict`: the server confirmed, but returned a row that disagrees.

PropertyTypeDescription
rowRowThe row the cell belongs to.
keystringThat row's key.
colIdstringThe column id that was written.
valueunknownWhat the server confirmed for the cell.
serverRowRecord<string, unknown>The row the server sent back, which the grid applied over its own.
idstringThe id the op was tracked under.

CellContextMenuEvent

`cell:contextmenu`: a context menu was requested on a cell. Raised twice over, by two routes with different payloads: the keyboard's menu key goes through the grid's action table and carries `rowIndex` and `colId`; the pointer goes through the renderer and carries the full cell with the pointer position. A handler that wants the position must read it defensively (F-1688-E).

PropertyTypeDescription
rowRowThe row the menu was requested on.
keystringThat row's key; absent on the keyboard route. (optional)
indexnumberIts display index; absent on the keyboard route. (optional)
rowIndexnumberIts display index, on the keyboard route. (optional)
colIdstringThe column id the menu was requested on.
columnColumnThe resolved column; absent on the keyboard route. (optional)
valueunknownThe cell's value; absent on the keyboard route. (optional)
xnumberThe pointer's viewport x, on the pointer route. (optional)
ynumberThe pointer's viewport y, on the pointer route. (optional)
eventunknownThe DOM event behind this one, on the pointer route. (optional)

CellPendingEvent

`cell:pending`: an optimistic cell edit was sent and is awaiting an answer.

PropertyTypeDescription
rowRowThe row the cell belongs to.
keystringThat row's key.
colIdstringThe column id that was written.
valueunknownThe value that was sent.
beforeunknownThe value it is holding in reserve to put back if the write is refused.
idstringThe id the op is tracked under; `grid.edit.settle(id, …)` answers it.

CellPointerEvent

The pointer events a cell raises: `cell:clicked`, `cell:dblclicked`, `cell:mouseover`, `cell:mouseout`, `cell:mousedown` and `cell:mouseup`. All six carry the cell, its value and the DOM event behind them. `target` - the cell element - is carried by the hover and press pairs, which exist precisely so a host does not have to find that node itself: rows and cells are pooled and re-used as the grid scrolls, so a listener a host bound to a cell node would fire for whichever row occupies it next.

PropertyTypeDescription
rowRowThe row under the pointer.
keystringThat row's key.
indexnumberIts display index.
colIdstringThe column id under the pointer.
columnColumnThe resolved column.
valueunknownThe cell's value, before formatting.
textstringThe cell's text, as it is drawn.
eventunknownThe DOM event behind this one, for modifier keys and `preventDefault`.
targetunknownThe cell element, on the hover and press pairs; absent on click and double-click. (optional)

CellRevertedEvent

`cell:reverted`: a pending cell edit was refused and rolled back.

PropertyTypeDescription
rowRowThe row the cell belongs to.
keystringThat row's key.
colIdstringThe column id that was written.
rejectedunknownThe value the server refused.
restoredunknownThe value put back, or `undefined` when a newer write owns the cell.
reasonstring | nullWhy it was refused, or null when the transport gave no reason.
idstringThe id the op was tracked under.
supersededbooleanTrue when a newer write on the same cell had already replaced this one.
appliedbooleanTrue when the rollback was actually applied; false when it was superseded.

ClipboardCopyEvent

`clipboard:copy`: a copy to the clipboard was attempted.

PropertyTypeDescription
textstringThe text that was put on the clipboard; empty when the copy was refused.
okbooleanWhether it reached the clipboard.
rowsstringWhat was copied: `'range'`, or whichever row scope the options asked for.
reasonstringWhy a refused copy was refused - `'discontiguous'` for a non-rectangular range. (optional)

ColumnGroupChangedEvent

`columngroup:changed`: a banded header group was formed, renamed, moved, dissolved, removed or restored.

PropertyTypeDescription
actionColumnGroupAction 'formed' | 'removed' | 'renamed' | 'dissolved' | 'moved' | 'applied'What happened to it.
groupIdstringThe band the action was on, where it has an id. (optional)
idstringThe leaf column removed from a band, on `'removed'`. (optional)
idsstring[]The leaves a band was formed over, on `'formed'`. (optional)
tonumberThe display position a band moved to, on `'moved'`. (optional)
titlestringThe band's new title, on `'renamed'`. (optional)
dissolvedbooleanTrue when removing the last leaf dissolved the band with it. (optional)

ColumnGroupedEvent

`column:grouped`: the row grouping changed.

PropertyTypeDescription
columnsstring[]The column ids the rows are grouped by, outermost first; empty when grouping was cleared.

ColumnHideCancelledEvent

`columnHide:cancelled`: a `beforeColumnHide` handler vetoed the hide.

PropertyTypeDescription
columnsstring[]The columns that were not hidden.
reasonstringThe reason given to `preventDefault`, or `'prevented'`.

ColumnMenuEvent

`column:filter:open`, `column:profile:open` and `column:menu:open`: the header asked for a popup to be opened over a column. The grid raises these rather than opening anything itself, so a host can put its own control where the built-in one would go.

PropertyTypeDescription
colIdstringThe column the popup belongs to.
elementunknownThe header element to anchor it to, where the caller had one. (optional)

ColumnMoveCancelledEvent

`columnMove:cancelled`: a `beforeColumnMove` handler vetoed the move.

PropertyTypeDescription
columnstringThe column that was not moved.
tonumberThe display index it would have taken.
reasonstringThe reason given to `preventDefault`, or `'prevented'`.

ColumnMovedEvent

`column:moved`: a column was moved to a different display position. Two routes, two spellings of the same thing: the column model names it `id`, and the header drag names it `colId` (F-1688-C). Read whichever is present.

PropertyTypeDescription
idstringThe column that moved, on the model route. (optional)
colIdstringThe column that moved, on the header-drag route. (optional)
tonumberThe display index it moved to.

ColumnPinnedEvent

`column:pinned`: a column was pinned to a side, or unpinned.

PropertyTypeDescription
idstringThe column that was pinned.
sideEdge | nullWhich side it is pinned to now, or null when it was unpinned. The sides are the writing-direction ones {@link ColumnApi#pin} takes - `'start'` and `'end'` - not left and right, so a right-to-left grid reports the same value for the same gesture.

ColumnPivotedEvent

`column:pivoted`: the pivot changed, locally or pushed down to the backend.

PropertyTypeDescription
columnsstring[]The column ids the rows are pivoted by, on a local pivot. (optional)
pivotFieldsstring[]The fields the backend was asked to pivot by, on a pushed-down pivot. (optional)
remotebooleanTrue when the backend did the pivot. (optional)

ColumnResizeCancelledEvent

`columnResize:cancelled`: a `beforeColumnResize` handler vetoed the resize.

PropertyTypeDescription
columnstringThe column that was not resized.
widthnumberThe width it would have taken, in pixels.
reasonstringThe reason given to `preventDefault`, or `'prevented'`.

ColumnResizedEvent

`column:resized`: a column's width changed. Named `id` by the model and `colId` by the header drag (F-1688-C).

PropertyTypeDescription
idstringThe column that was resized, on the model route. (optional)
colIdstringThe column that was resized, on the header-drag route. (optional)
widthnumberIts new width, in pixels.

ColumnVisibleEvent

`column:visible`: columns were shown or hidden.

PropertyTypeDescription
idsstring[]The columns whose visibility actually changed.
hiddenbooleanTrue when they were hidden, false when they were shown.

ColumnsChangedEvent

`columns:changed`: the column set was rewritten other than by moving, resizing, hiding or pinning.

PropertyTypeDescription
reasonstringWhy it was rewritten; `'inferred'` when a type-inference pass did it.
typesRecord<string, string>The type inferred for each column, by column id.

ColumnsTaggedEvent

`columns:tagged`: `grid.columns.showTagged()` chose the visible set from the columns' tags.

PropertyTypeDescription
tagsstring[]The tags that were asked for.
hiddenstring[]The columns hidden because they carry none of them.

CommentAddedEvent

`comment:added`: a comment was added to a cell, or a reply added to a thread.

PropertyTypeDescription
cellKeystringThe cell the comment is on, as the provider keys it.
commentIdstringThe stored comment's id, where the provider returned one. (optional)
parentIdstring | nullThe comment this one replies to, or null when it starts a thread.

CommentEvent

`comment:edited` and `comment:deleted`: one comment changed.

PropertyTypeDescription
cellKeystringThe cell the comment is on.
commentIdstringThe comment that changed.

CommentFailedEvent

`comment:failed`: a comment operation could not reach the backend.

PropertyTypeDescription
operationCommentOperation 'loadIndex' | 'loadThread' | 'addComment' | 'editComment' | 'deleteComment' | 'resolveThread' | 'unresolveThread'Which provider call failed.
cellKeystringThe cell it was for, where the call named one. (optional)
commentIdstringThe comment it was for, where the call named one. (optional)
errorunknownWhat the provider threw or rejected with.

CommentIndexLoadedEvent

`comment:indexLoaded`: the comment index for the visible rows finished loading.

PropertyTypeDescription
rowsnumberHow many rows the index was asked for.
entriesnumberHow many entries came back.
msnumberHow long it took, in milliseconds.

CommentResolvedEvent

`comment:resolved` and `comment:unresolved`: a thread was marked resolved or reopened.

PropertyTypeDescription
cellKeystringThe cell whose thread changed.

CommentThreadClosedEvent

`comment:threadClosed`: a cell's comment thread was closed.

PropertyTypeDescription
cellKeystringThe cell whose thread was closed.
reasonstringWhy it closed; `'dismissed'` when the caller gave no reason.

CommentThreadOpenedEvent

`comment:threadOpened`: a cell's comment thread was opened.

PropertyTypeDescription
cellKeystringThe cell whose thread was opened.
rowIdstringThe row it sits on.
fieldstringThe column it sits on.
valueunknownThe cell's value, so a thread header can quote what is being discussed.

ConfigChangedEvent

`config:changed`: a configuration key was written at run time. `grid.set(key, value)` fills the single form; `grid.setAll(values)` fills the batch form and fires once for the whole batch rather than once per key, so a host restoring a saved arrangement hears one event describing a settled grid instead of a dozen describing half-applied ones.

PropertyTypeDescription
keystringThe key that was written, on a `grid.set` change. (optional)
valueunknownIts new value, on a `grid.set` change. (optional)
oldValueunknownWhat it held before, on a `grid.set` change. (optional)
keysstring[]The keys that were written, on a `grid.setAll` change. (optional)
valuesRecord<string, unknown>Their new values, by key, on a `grid.setAll` change. (optional)
oldValuesRecord<string, unknown>What each of them held before, by key, on a `grid.setAll` change. (optional)

DeleteCancelledEvent

`delete:cancelled`: a `beforeDelete` handler vetoed the delete, or the rows were gone by the time it settled.

PropertyTypeDescription
keystringThe first key that was not deleted.
keysstring[]Every key that was not deleted, on the multi-row gesture. (optional)
rowsstring[]Every key that was not deleted.
reasonstringThe reason given to `preventDefault`, `'prevented'` when none was, or `'stale'`.

DetailToggledEvent

`detail:toggled`: a master-detail region was opened or closed.

PropertyTypeDescription
keysstring[]The keys of every row with an open detail region.
activestring | nullThe key of the region that is mounted, or null when none is.

DiffChangedEvent

`diff:changed`: diff mode was turned on against a snapshot, or turned off.

PropertyTypeDescription
enabledbooleanWhether the grid is now diffing.

DiffSwappedEvent

`diff:swapped`: the two sides of a diff were swapped.

PropertyTypeDescription
swappedbooleanTrue when the grid is now showing the snapshot as the "after" side.
rowsnumberHow many rows are on the side now being shown.
snapshotnumberHow many rows are on the side it came from.

EditCancelledEvent

`edit:cancelled`: a `beforeEdit` handler vetoed the commit, or it went stale.

PropertyTypeDescription
rowRowThe row whose commit was abandoned.
keystringThat row's key.
modeEditMode 'cell' | 'row'Whether it was a cell edit or a row edit.
changes{ colId: string; oldValue: unknown; newValue: unknown }[]The writes that would have been made.
reasonstringThe reason given to `preventDefault`, `'prevented'` when none was, or `'stale'`.

EditEndEvent

`cell:edit:end` and `row:edit:end`: an editor closed. `valid: false` means a column rule refused the commit and the editor stayed the user's problem; `cancelled: true` means nothing was written, either because the user pressed Escape or because a `beforeEdit` handler vetoed.

PropertyTypeDescription
rowRowThe row that was being edited.
keystringThat row's key.
colIdstring | nullThe column the caret was in; null on a row editor with none.
validbooleanTrue when the commit passed validation, false when a rule refused it.
cancelledbooleanTrue when nothing was written - Escape, or a vetoed commit. (optional)
errorsValidationError[]The validation failures, when `valid` is false. (optional)
writes{ key: string; colId: string; before: unknown; after: unknown }[]The cells that were written; empty on a cancel. (optional)

EditStartEvent

`cell:edit:start` and `row:edit:start`: an editor opened.

PropertyTypeDescription
rowRowThe row being edited.
keystringThat row's key.
colIdstring | nullThe column the caret is in; null on a row editor with no focused column.
columnColumnThe resolved column the caret is in.
keyNamestringThe key that opened the editor, when a keypress did. (optional)
charPressstringThe character typed into the cell to open it, when typing did. (optional)

EventPayloads

What a handler receives, per event. `on()` is declared as `on(event: EventName, handler: EventHandler)`, so the declarations named every event and typed none of their payloads. The published reference could list the names and nothing else, which is half an event reference: a reader still has to run the grid to find out what arrives. This map is the other half. It is populated from the payload interfaces that already exist and from the comments in {@link EventName} that name them - an event with no entry here publishes `unknown` in the reference and is counted by the undescribed-member ratchet in `tools/check.js`, so the gaps are visible and shrink rather than being papered over with a generic type. Every payload extends {@link GridEvent}; an entry says which specialisation. An entry of `void` means the event carries nothing of its own: the bus still hands the handler the {@link GridEvent} envelope - `type`, `origin` and `grid` - and the reference prints "no payload" rather than a type.

PropertyTypeDescription
readyvoidNothing: the grid being ready is the whole message.
destroyvoidNothing: the grid is still readable from the handler, and that is the point.
render:firstvoidNothing: the first frame's window is reported by `render:done`, which follows it.
render:doneRenderDoneEventThe window that was drawn and the milliseconds each phase took.
config:changedConfigChangedEventThe key (or keys) that were written, with their old values.
licence:changedLicenceChangedEventThe verdict and what this deployment is now treated as.
model:changedModelChangedEventWhy the model was rebuilt, and whatever that reason has to say.
rows:changedRowsChangedEventWhich rows moved - records when `identified`, counts on a companion firing.
rows:queuedRowsQueuedEventHow much is waiting on the batch queue.
rows:deferredRowsDeferredEventHow much a flush carried into the next frame, and the budget it ran out of.
rows:pausedRowsFlowEventThe whole feed counter set, as `grid.changes.stats()` returns it.
rows:resumedRowsFlowEventThe whole feed counter set, as `grid.changes.stats()` returns it.
row:receivedRowReceivedEventThe row that arrived, where it landed, and anything the insert refused.
row:sentRowTransferEventThe row that left and whether it was moved or copied.
row:copiedRowTransferEventThe row that was copied out and left here as well.
row:movedRowMovedEventThe row that was reordered, and the indices it moved between.
source:errorSourceErrorEventWhat the source threw, and what it was fetching.
source:totalSourceTotalEventThe exact total a deferred count settled on, and the level it counts.
stream:chunkStreamChunkEventHow much of the stream has arrived and how much is expected.
stream:endStreamEndEventThe final row count and whether the stream promoted to memory.
stream:evictedStreamEvictedEventHow many rows the window dropped, and how many are still live.
rowDrag:startedRowDragEventThe row-drag gesture; all four carry the same payload.
rowDrag:movedRowDragEventThe row being dragged, the grid under the pointer, and where it would land.
rowDrag:leftRowDragEventThe grid the pointer has just left, with no candidate index to report.
rowDrag:endedRowDragEventWhere the drag ended and whether the release is being acted on.
cell:changedCellChangedEventThe cell that was written, with its old and new values.
cell:pendingCellPendingEventThe cell that was sent, the value held in reserve, and the op id.
cell:confirmedCellConfirmedEventWhat the server confirmed, which need not be what was sent.
cell:revertedCellRevertedEventThe value that was refused, the value put back, and why.
cell:conflictCellConflictEventThe row the server sent back, which disagrees with what the grid holds.
cell:clickedCellPointerEventThe cell that was clicked, with its value and the DOM event.
cell:dblclickedCellPointerEventThe cell that was double-clicked, with its value and the DOM event.
cell:contextmenuCellContextMenuEventThe cell the menu was requested on; the two routes fill different fields (F-1688-E).
cell:mouseoverCellPointerEventThe cell entered, plus its element as `target`.
cell:mouseoutCellPointerEventThe cell left, plus its element as `target`.
cell:mousedownCellPointerEventThe cell pressed, plus its element as `target`.
cell:mouseupCellPointerEventThe cell released over, plus its element as `target`.
cell:edit:startEditStartEventThe cell being edited, and the keypress that opened the editor.
cell:edit:endEditEndEventWhether the commit was valid, whether it was cancelled, and what was written.
row:edit:startEditStartEventThe row being edited, and the keypress that opened the editor.
row:edit:endEditEndEventWhether the commit was valid, whether it was cancelled, and what was written.
row:clickedRowPointerEventThe row that was clicked and the DOM event.
row:dblclickedRowPointerEventThe row that was double-clicked and the DOM event.
row:pendingRowPendingEventWhich structural write was sent, under which id and key.
row:confirmedRowConfirmedEventThe confirmed write, already rekeyed when it was an append.
row:revertedRowRevertedEventThe refused write, why, and whether the rollback was applied.
row:conflictRowConflictEventThe row the server sent back, which disagrees with what the grid holds.
form:openedFormOpenedEventThe row the form is editing.
form:closedFormClosedEventThe row the form was editing.
form:savedFormSavedEventEvery value the form held, which of them changed, and which mapped to no column.
form:errorFormErrorEventWhat went wrong, and whether it was a timeout rather than a refusal.
sort:changedSortChangedEventThe sort now in force, in precedence order.
filter:changedFilterChangedEventWhichever of the four filter routes changed, and to what.
group:toggledGroupToggledEventWhich group moved, whether it is now open, and whether it was a deep or an all-groups toggle.
facet:computedFacetComputedEventHow many buckets, how long it took, and whether a worker did it.
facet:filteredFacetFilteredEventThe condition the facet installed, or null when it was cleared.
facet:expandedFacetExpandedEventThe facet section that opened or closed.
facet:failedFacetFailedEventThe column the facets were for, and what went wrong.
column:movedColumnMovedEventThe column that moved and where to; spelled `id` or `colId` by route (F-1688-C).
column:resizedColumnResizedEventThe column that was resized and its new width.
column:visibleColumnVisibleEventThe columns whose visibility changed, and which way.
column:pinnedColumnPinnedEventThe column and the side it is pinned to now, or null.
column:groupedColumnGroupedEventThe columns the rows are grouped by now.
column:pivotedColumnPivotedEventThe columns the rows are pivoted by now, locally or on the backend.
column:filter:openColumnMenuEventThe column whose filter popup should open, and the element to anchor it to.
column:profile:openColumnMenuEventThe column whose profile should open.
column:menu:openColumnMenuEventThe column whose menu should open, and the element to anchor it to.
pivot:drillPivotDrillEventThe source rows behind the measure, and the paths that identify the cell.
columns:changedColumnsChangedEventWhy the column set was rewritten, and what was inferred.
columns:taggedColumnsTaggedEventThe tags that were asked for and the columns hidden for carrying none.
columngroup:changedColumnGroupChangedEventWhat happened to the band, and to which one.
header:contextmenuHeaderContextMenuEventThe header the menu was requested on, and where the pointer was.
selection:changedSelectionChangedEventThe keys and rows now selected.
range:changedRangeChangedEventEvery cell range now selected.
clipboard:copyClipboardCopyEventThe text, whether it reached the clipboard, and why not when it did not.
page:changedPageChangedEventThe page, the page size, and how many pages the data makes.
scrollScrollEventThe viewport's new offset.
scroll:endScrollEventThe viewport's offset once the gesture settled.
size:changedvoidNothing: the new size is read off the element, which the handler already has.
detail:toggledDetailToggledEventWhich detail regions are open, and which one is mounted.
toolpanel:focusvoidNothing: it is a request to move focus, not a report about state.
highlight:changedHighlightChangedEventEvery highlight now in force.
find:changedFindChangedEventThe query, whether the bar is open, and the match count.
tree:loadingTreeLoadingEventThe branch whose children are being fetched.
tree:loadedTreeLoadedEventThe branch and how many children arrived.
tree:loadFailedTreeLoadFailedEventThe branch and what the loader rejected with.
tree:loadAbortedTreeLoadAbortedEventThe branch whose fetch was abandoned.
state:changedStateChangedEventOne event per logical state change.
state:resetStateResetEventThe baseline that was restored.
history:changedHistoryChangedEventWhat can now be undone and redone.
history:appliedHistoryAppliedEventWhich way the stack moved, and the entry that was applied.
views:changedViewsChangedEventEvery view after the change, and which one moved.
view:appliedViewAppliedEventThe view that was applied, and the id now active.
view:savedViewChangedEventThe one view that was created, updated or imported.
view:removedViewChangedEventThe one view that was deleted.
view:renamedViewChangedEventThe one view that was renamed.
view:defaultViewChangedEventThe one view that was made the default.
validation:failedValidationFailedEventEvery cell a column rule refused, with its code and message.
validation:clearedValidationClearedEventThe row and column that were cleared, or null for all of them.
formatting:changedFormattingChangedEventWhat changed, in which scope, and every rule now in force.
redaction:changedRedactionChangedEventEvery column id now redacted.
permissions:changedPermissionsChangedEventThe permission level now in force for each column that has one.
presentation:changedPresentationChangedEventEither the responsive layout's new presentation, or the deck's settings (F-1688-A).
presentation:startedPresentationStartedEventThe scale, options and deck the presentation started with.
presentation:endedvoidNothing: the presentation is over and there is no state left to report.
presentation:viewPresentationViewEventThe view now showing and its position in the deck.
presentation:scalePresentationScaleEventThe enlargement now in force.
presentation:spotlightPresentationSpotlightEventWhat is lit, or null when the spotlight was cleared.
presentation:capturedPresentationCapturedEventThe captured image's size, type and file name.
comment:addedCommentAddedEventThe cell, the stored comment, and the thread it replies to.
comment:editedCommentEventThe comment whose text changed.
comment:deletedCommentEventThe comment that was deleted.
comment:failedCommentFailedEventWhich provider call failed, on what, and with what.
comment:resolvedCommentResolvedEventThe cell whose thread was marked resolved.
comment:unresolvedCommentResolvedEventThe cell whose thread was reopened.
comment:threadOpenedCommentThreadOpenedEventThe cell whose thread was opened, and the value being discussed.
comment:threadClosedCommentThreadClosedEventThe cell whose thread was closed, and why.
comment:indexLoadedCommentIndexLoadedEventHow many rows were indexed, how many entries came back, and how long it took.
presence:publishedPresencePublishedEventThis grid's own presence, as it was published.
presence:joinedPresencePeerEventThe peer that appeared.
presence:updatedPresencePeerEventThe peer that moved or changed what it is doing.
presence:leftPresenceLeftEventThe peer that left, and why.
presence:failedPresenceFailedEventWhich presence call failed, and with what.
presence:lockRefusedPresenceLockRefusedEventThe locked cell and the peer holding it.
diff:changedDiffChangedEventWhether the grid is now diffing.
diff:swappedDiffSwappedEventWhich way round the diff now is, and how many rows are on each side.
timeline:attachedTimelineAttachedEventHow far back the recorded window now reaches.
timeline:detachedvoidNothing: the grid is back in the present and nothing is recorded.
timeline:seekTimelineSeekEventWhere the grid now stands, and whether that is live.
timeline:seekingTimelineSeekingEventWhere the move is coming from and going to.
annotation:changedAnnotationChangedEventThe tool in use and how many marks the layer holds.
export:progressExportProgressEventRows written, rows expected, bytes so far.
export:requestExportRequestEventThe request about to go to the host's export hook.
export:doneExportDoneEventThe request that produced the file that came back.
shortcuts:openedvoidNothing: the overlay is open and there is nothing else to say about it.
shortcuts:closedvoidNothing: the overlay is closed and focus has gone back where it was.
print:beforePrintEventHow many rows the print covers.
print:afterPrintEventHow many rows the print covered.
beforeEditBeforeEditEventThe row, the mode and the writes about to be committed, with `preventDefault` to stop them.
beforeSortBeforeSortEventThe sort about to be applied, with `preventDefault` to stop it.
beforeFilterBeforeFilterEventThe filter about to be applied, with `preventDefault` to stop it.
beforeColumnMoveBeforeColumnMoveEventThe column move about to be applied, with `preventDefault` to stop it.
beforeColumnResizeBeforeColumnResizeEventThe column resize about to be applied, with `preventDefault` to stop it.
beforeColumnHideBeforeColumnHideEventThe column hide about to be applied, with `preventDefault` to stop it.
beforeSelectBeforeSelectEventThe selection about to be announced, with `preventDefault` to snap it back.
beforeRowAddBeforeRowAddEventThe row append about to be sent, with `preventDefault` to stop it.
beforeDeleteBeforeDeleteEventThe row delete about to be applied, with `preventDefault` to stop it.
beforeRowMoveBeforeRowMoveEventThe row reorder about to be applied, with `preventDefault` to stop it.
beforeGroupBeforeGroupEventThe group toggle about to be applied, with `preventDefault` to stop it.
beforeRowReceiveBeforeRowReceiveEventA row dropped in from another grid, on the receiving grid.
edit:cancelledEditCancelledEventThe commit that was abandoned, and why.
sort:cancelledSortCancelledEventThe sort that was not applied, and why.
filter:cancelledFilterCancelledEventThe filter that was not applied, and why.
columnMove:cancelledColumnMoveCancelledEventThe column move that was not applied, and why.
columnResize:cancelledColumnResizeCancelledEventThe column resize that was not applied, and why.
columnHide:cancelledColumnHideCancelledEventThe column hide that was not applied, and why.
selection:cancelledSelectionCancelledEventThe selection that was snapped back, and why.
rowAdd:cancelledRowAddCancelledEventThe append that was not sent, and why.
delete:cancelledDeleteCancelledEventThe delete that was not applied, and why.
rowMove:cancelledRowMoveCancelledEventThe reorder that was not applied, and why.
group:cancelledGroupCancelledEventThe group toggle that was not applied, and why.
rowReceive:cancelledRowReceiveCancelledEventThat veto's notification, with the reason.
*GridEventWhichever past-tense event fired; the wildcard is never given a before-event.

ExportDoneEvent

`export:done`: a remote export came back and the file was handed over.

PropertyTypeDescription
requestRecord<string, unknown>The request that produced it.
remotebooleanTrue - this firing is the remote path's; a local export does not raise it.

ExportProgressEvent

`export:progress`: a streaming export wrote another chunk.

PropertyTypeDescription
writtennumberRows written so far.
totalnumberRows expected in all.
bytesnumberBytes written so far.

ExportRequestEvent

`export:request`: a remote export request is about to go to the host's `export.remote.fetch` hook.

PropertyTypeDescription
requestRecord<string, unknown>The request, as the hook will receive it: the query, the columns and the format.

FacetComputedEvent

`facet:computed`: a column's facet buckets finished computing.

PropertyTypeDescription
colIdstringThe column the facets are for.
bucketsnumberHow many buckets the distribution was cut into.
msnumberHow long it took, in milliseconds.
workerbooleanTrue when a worker computed it rather than the main thread.

FacetExpandedEvent

`facet:expanded`: a facet panel section was opened or closed.

PropertyTypeDescription
colIdstringThe column whose section moved.
expandedbooleanTrue when it is now open.

FacetFailedEvent

`facet:failed`: a column's facet buckets could not be computed.

PropertyTypeDescription
colIdstringThe column the facets were for.
errorunknownWhat went wrong.

FacetFilteredEvent

`facet:filtered`: a facet histogram was used to filter its column, or cleared.

PropertyTypeDescription
colIdstringThe column that was filtered.
filterFilterSetThe condition that was installed, or null when the filter was cleared.
gesturestringThe gesture behind it: `'click'`, `'drag'`, `'clear'`, or whatever the caller named.
buckets[number, number]The inclusive bucket range that was selected. (optional)

FilterCancelledEvent

`filter:cancelled`: a `beforeFilter` handler vetoed the filter.

PropertyTypeDescription
filtersFilterSetThe structured filter that was not applied, on a `kind: 'structured'` veto. (optional)
quickstringThe quick-filter text that was not applied, on a `kind: 'quick'` veto. (optional)
kindFilterKind 'structured' | 'quick'Which filter was refused.
reasonstringThe reason given to `preventDefault`, or `'prevented'`.

FilterChangedEvent

`filter:changed`: the filters changed. Four routes reach it - a structured condition, the quick filter, a named host predicate, and the comments filter - and each fills its own fields, so every one of them is optional.

PropertyTypeDescription
filtersFilterSetThe structured filter now in force, or null when it was cleared. (optional)
quickstringThe quick-filter text now in force. (optional)
quickModestringHow the quick filter matches. (optional)
wherestring[]The named host predicates now in force. (optional)
causestring`'where'` when a host predicate was registered, replaced, removed or re-run. (optional)
commentsstring`'unresolved'` or `'any'` when the change was the comments filter. (optional)

FindChangedEvent

`find:changed`: the find bar's query, open state or match count changed. The count here is the model's narrower one - `current`, `total` and `complete`. `grid.find.count()` adds the windowed-scope fields on top; this event does not carry them (F-1688-D).

PropertyTypeDescription
textstringWhat is being searched for.
caseSensitivebooleanWhether the search distinguishes case.
wholeCellbooleanWhether the whole cell must match rather than contain.
columnsstring[] | nullThe columns being searched, or null for every visible column.
openbooleanWhether the find bar is showing.
count{ current: number; total: number; complete: boolean }Which match is current, how many there are, and whether the scan finished.

FormClosedEvent

`form:closed`: the row form was closed without saving.

PropertyTypeDescription
keystringThe key of the row the form was editing.

FormErrorEvent

`form:error`: the row form could not load or save a row.

PropertyTypeDescription
keystringThe key of the row the form was working on.
errorunknownWhat went wrong.
timedOutbooleanTrue when the load timed out rather than being refused.

FormOpenedEvent

`form:opened`: the row form opened over a row.

PropertyTypeDescription
keystringThe key of the row the form is editing.
rowRowThat row.

FormSavedEvent

`form:saved`: the row form's values were written back to the row.

PropertyTypeDescription
keystringThe key of the row that was saved.
valuesRecord<string, unknown>Every value the form held, by field name.
changedRecord<string, unknown>Only the values that differ from what the row held.
unmappedstring[]Fields the form held that no column maps, so nothing was written for them.

FormattingChangedEvent

`formatting:changed`: a conditional-formatting rule was added, changed, removed or replaced.

PropertyTypeDescription
reasonstringWhat happened to it.
scopeFormattingScopeThe scope that changed: a column id, or the grid scope.
rulesRecord<FormattingScope, FormattingRule[]>Every rule now in force, by scope.

GridEvent

PropertyTypeDescription
typestringWhich event this is - `rows:changed`, `sort:changed`, `cell:edit:end` and the rest.
originEventOrigin 'api' | 'user' | 'init' | 'ai'Who caused the action. `'ai'` tags a write an AI proposed and a human approved, applied through `grid.edit.setCells(writes, type, { origin: 'ai' })`; it fires the same cancellable `beforeEdit` gate a `'user'` edit does, so a host can policy-gate AI writes distinctly.
gridGridThe grid that emitted it, so one handler can serve several grids.
[key: string]unknownThe event's own fields, spread alongside the three above: which cell, which column, which rows. What arrives depends on the event - {@link EventPayloads} names the specialisation each one carries.

GroupCancelledEvent

`group:cancelled`: a `beforeGroup` handler vetoed the expand or collapse.

PropertyTypeDescription
keystringThe key of the group row that did not move.
expandedbooleanWhether it was being opened (true) or closed (false).
reasonstringThe reason given to `preventDefault`, or `'prevented'`.

GroupToggledEvent

`group:toggled`: a group row was expanded or collapsed. One group carries `key` or `row`; "expand all" / "collapse all" carries `all: true` and no target; a deep expand carries `deep: true`.

PropertyTypeDescription
keystringThe key of the group row that was toggled. (optional)
rowRowThat group row, where the caller had it. (optional)
expandedbooleanTrue when it is now open.
deepbooleanTrue when the whole branch beneath it was opened. (optional)
allbooleanTrue when every group was toggled at once. (optional)

HeaderContextMenuEvent

`header:contextmenu`: a context menu was requested on a column header.

PropertyTypeDescription
colIdstringThe column the menu was requested on.
columnColumnThe resolved column.
elementunknownThe header element, to anchor a menu to.
xnumberThe pointer's viewport x.
ynumberThe pointer's viewport y.
eventunknownThe DOM event behind this one.

HighlightChangedEvent

`highlight:changed`: the set of host-declared highlights changed.

PropertyTypeDescription
highlights{ scope: string; key: string | null; colId: string | null; colour: string; duration: number }[]Every highlight in force, with its scope, target, colour and duration.

HistoryAppliedEvent

`history:applied`: an undo or redo step was applied.

PropertyTypeDescription
directionHistoryDirection 'undo' | 'redo'Which way the stack moved.
stepHistoryEntry | nullThe entry that was applied, or null when there was nothing to apply.

HistoryChangedEvent

`history:changed`: the undo and redo stacks moved.

PropertyTypeDescription
canUndobooleanWhether there is anything to undo.
canRedobooleanWhether there is anything to redo.
undoHistoryEntry | nullThe entry an undo would apply, or null.
redoHistoryEntry | nullThe entry a redo would apply, or null.

LicenceChangedEvent

`licence:changed`: a key was installed, and again when its check settles.

PropertyTypeDescription
infoLicenceInfoThe verdict as it stands - provisional on the first firing, settled on the second.
stateLicenceState 'licensed' | 'localhost' | 'trial'What this deployment is now treated as.

ModelChangedEvent

`model:changed`: the display model was rebuilt. The one event every viewer of the grid follows. `reason` is what actually happened, and the rest of the payload is whatever that reason has to say - a page's block and range, a tree branch's row, a stream's anchor.

PropertyTypeDescription
reasonstringWhy it was rebuilt: `'rows'`, `'tree'`, `'expanded'`, `'children'`, `'children:loading'`, `'reload'`, `'page'`, `'expand'`, `'collapse'`, `'query'`, `'stream'`, or one of the pipeline's settle reasons.
countnumberHow many display rows there now are, or how many children arrived. (optional)
rowRowThe branch row a `'children'` or `'children:loading'` rebuild is about. (optional)
keystringThe row key an `'expand'` or `'collapse'` is about. (optional)
blockstring | numberThe block id a `'page'` rebuild filled. (optional)
fromnumberThe first display index a `'page'` rebuild filled. (optional)
tonumberOne past the last display index a `'page'` rebuild filled. (optional)
groupPathunknown[]The group path a remote source's `'page'` rebuild filled under. (optional)
shiftAboveViewportnumberHow far a stream's arrivals pushed the rows above the viewport down. (optional)
anchorunknownThe row the stream is holding the viewport against. (optional)

PageChangedEvent

`page:changed`: the page or the page size changed.

PropertyTypeDescription
pagenumberThe page now showing, zero-based.
pageSizenumberRows per page; 0 means paging is off.
totalnumber | nullHow many rows the current query produces, or null while the source is still counting.
pageCountnumberHow many pages that makes.
countingbooleanWhether a deferred exact total is still being counted. (optional)

PermissionsChangedEvent

`permissions:changed`: the per-column permission levels changed.

PropertyTypeDescription
levelsRecord<string, PermissionLevel>The level now in force for each column that has one.

PivotDrillEvent

`pivot:drill`: a pivot measure cell was drilled into.

PropertyTypeDescription
keysstring[]The keys of the source rows behind the measure.
rowPathstring | nullThe row path of the cell, as the header wrote it.
colPathstring | nullThe column path of the cell.
measurestring | nullWhich measure the cell shows.
eventunknownThe DOM event behind the drill.

PresenceFailedEvent

`presence:failed`: a presence subscribe or publish could not reach the transport.

PropertyTypeDescription
operationPresenceOperation 'subscribe' | 'publish'Which call failed.
errorunknownWhat the transport threw or rejected with.

PresenceLeftEvent

`presence:left`: a peer left the presence channel or timed out.

PropertyTypeDescription
peerPeerThe peer that left.
reasonstringWhy it left - the transport's reason, or the grid's own timeout.

PresenceLockRefusedEvent

`presence:lockRefused`: an edit was refused because a peer holds the cell's lock.

PropertyTypeDescription
keystringThe row key of the locked cell.
colIdstringIts column.
peerPeerThe peer holding the lock.

PresencePeerEvent

`presence:joined` and `presence:updated`: a peer appeared, or one already present moved.

PropertyTypeDescription
peerPeerThe peer, as the grid now holds it.

PresencePublishedEvent

`presence:published`: this grid published its own presence to the transport.

PropertyTypeDescription
stateRecord<string, unknown>What was published: this peer's cursor, selection and identity.

PresentationCapturedEvent

`presentation:captured`: a screenshot of the grid was taken.

PropertyTypeDescription
widthnumberThe image's width in pixels.
heightnumberIts height in pixels.
bytesnumberIts size in bytes.
mimeTypestringIts MIME type.
fileNamestring | nullThe file name it was downloaded under, or null when it was not downloaded.

PresentationChangedEvent

PropertyTypeDescription
presentationViewPresentation 'cards' | 'table'`'cards'` or `'table'`, on the responsive-layout firing. (optional)
scalenumberThe enlargement now in force, on the presentation-model firing. (optional)
optionsRecord<string, unknown>The options the presentation is running with. (optional)
viewsstring[]The view ids in the deck. (optional)
indexnumberWhich of them is showing, or -1 when the deck is empty. (optional)

PresentationScaleEvent

`presentation:scale`: the presentation's enlargement changed.

PropertyTypeDescription
scalenumberThe enlargement now in force, already clamped to the allowed range.

PresentationSpotlightEvent

`presentation:spotlight`: the spotlight was armed over some rows and columns, or cleared.

PropertyTypeDescription
spotlight{ keys: string[]; colIds: string[] } | nullWhat is lit, or null when the spotlight was cleared.

PresentationStartedEvent

`presentation:started`: `grid.presentation.start()` began presenting.

PropertyTypeDescription
scalenumberThe enlargement it started at.
optionsRecord<string, unknown>The options it was started with.
viewsstring[]The view ids in the deck; empty when it is presenting the grid as it stands.
indexnumberWhich view is showing, or -1 when there is no deck.

PresentationViewEvent

`presentation:view`: the presentation stepped to a view, including the first.

PropertyTypeDescription
viewIdstring | nullThe view now showing, or null when the deck is empty.
indexnumberIts position in the deck.
countnumberHow many views the deck holds.

PrintEvent

`print:before` and `print:after`: print mode was applied, and undone.

PropertyTypeDescription
rowsnumberHow many rows the print covers.

RangeChangedEvent

`range:changed`: the selected cell ranges changed.

PropertyTypeDescription
rangesCellRange[]Every range now selected.

RedactionChangedEvent

`redaction:changed`: the set of redacted columns changed.

PropertyTypeDescription
columnsstring[]Every column id now redacted.

RenderDoneEvent

`render:done`: one render pass has finished writing cells. The pass is over and the cells are stable, which is why anything that decorates them from outside - a highlight painter, a diff painter - hangs off this rather than guessing at a frame delay.

PropertyTypeDescription
firstnumberThe first display index the pass drew, including the overscan either side.
lastnumberThe last display index the pass drew, inclusive; `-1` when there were no rows.
causestringWhat asked for the pass - `'scroll'` unless something else invalidated first.
phases{ layoutMs: number; hintMs: number; writeMs: number; totalMs: number }Milliseconds per phase. `layoutMs` is deciding what to draw, `hintMs` is telling the source about it, `writeMs` is the DOM itself. The wait for paint is the browser's and is not measurable here.

RowAddCancelledEvent

`rowAdd:cancelled`: a `beforeRowAdd` handler vetoed the append.

PropertyTypeDescription
rowRecord<string, unknown>The record that was not appended.
reasonstringThe reason given to `preventDefault`, or `'prevented'`.

RowConfirmedEvent

`row:confirmed`: the server accepted a pending row append or delete.

PropertyTypeDescription
idstringThe id the op was tracked under.
kindRowChangeKind 'append' | 'delete'Which structural write it was.
keystringThe row key, already rekeyed from the temporary one on an append.
tempKeystringThe temporary key an append was rekeyed from. (optional)
rowRowThe row as it now stands; undefined for a confirmed delete. (optional)
supersededbooleanTrue when a newer op on the same key had already replaced this one.

RowConflictEvent

`row:conflict`: the server confirmed a structural write but sent back a row that disagrees.

PropertyTypeDescription
idstringThe id the op was tracked under.
kindRowChangeKind 'append' | 'delete'Which structural write it was.
keystringThe row key.
serverRowRecord<string, unknown>The row the server sent back.
rowRowThe row as the grid holds it; undefined for a delete. (optional)

RowDragEvent

The row-drag lifecycle events: `rowDrag:started`, `rowDrag:moved`, `rowDrag:left` and `rowDrag:ended`, which report a row drag *as it happens* rather than once it has settled. Before them a host got the handle the grid draws and then one settled event, with nothing in between to highlight a candidate target, drive a custom drop indicator, or react when the pointer left the grid. **All four fire on the grid the drag started in**, whether the row is being reordered within that grid or dragged into another one. A drag is one gesture with one owner, and the source grid is the only grid present for the whole of it - the pointer may cross several others, or none. `over` names whichever grid the event is about, so a single subscription can drive decoration on any of them. **Notifications, not gates.** None of these 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. **What is safe to do in a handler.** Read, measure and draw: highlight a candidate row, move an indicator, update a side panel. Do not mutate rows, columns, sort, filters or grouping from one of these. The drag resolves where it would land against the display order, so changing that order 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 the row that is still in the grid without announcing it. Work that changes the grid belongs in `beforeRowReceive`, which is asked before the insert, or in the settled events afterwards. **`rowDrag:moved` is coalesced to one event per animation frame**, carrying the latest pointer position of that frame, so a handler runs at the display's rate rather than the pointer's several hundred events a second. The other three fire on the transition itself. The sequence for any gesture is `rowDrag:started`, then `rowDrag:moved` and `rowDrag:left` as the pointer travels, then exactly one `rowDrag:ended` - including when the pointer is released outside every grid. No `rowDrag:moved` is delivered after `rowDrag:ended`. 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`.

PropertyTypeDescription
keystringThe key of the row being dragged.
dataRecord<string, unknown> | nullThe dragged row's data as it stands in the source grid - that row's own object, not a copy. Null if the row has left the source during the drag.
overGrid | nullThe grid the event is about: the grid under the pointer for `rowDrag:started`, `rowDrag:moved` and `rowDrag:ended`, and the grid just left for `rowDrag:left`. Null when the pointer is over no grid at all.
atnumber | nullWhere the row would land in `over`: the display index it would take. Null when there is no candidate to report - the pointer is over no grid, over a grid that will refuse the row, or over a header; and on `rowDrag:left`, which is about a grid the pointer has already gone from.
overKeystring | nullThe key of the row under the pointer in `over`, or null where there is no row to name: past the last row, on empty space, on a header, on a pinned row, on a grid that will refuse the drop, or on `rowDrag:left`.
droppedboolean`rowDrag:ended` only: whether the release is being acted on - a transfer the target accepts, or a same-grid reorder that is a real move and is not refused by a sort, filter or grouping. False when the row was released over no grid, over a grid that refuses it, or back where it started. What became of an acted-on drop is reported by `row:moved`, `row:sent`, `row:received` and `rowReceive:cancelled`. (optional)

RowMoveCancelledEvent

`rowMove:cancelled`: a `beforeRowMove` handler vetoed the reorder.

PropertyTypeDescription
keystringThe key of the row that did not move.
fromnumberThe display index it is still at.
tonumberThe display index it would have taken.
reasonstringThe reason given to `preventDefault`, `'prevented'`, or `'unchanged'` when the move was a no-op.

RowMovedEvent

`row:moved`: a row was reordered within this grid.

PropertyTypeDescription
keystringThe key of the row that moved.
fromnumberThe display index it came from.
tonumberThe display index it went to.
dataRecord<string, unknown>That row's data.

RowPendingEvent

`row:pending`: an optimistic row append or delete was sent to the transport.

PropertyTypeDescription
idstringThe id the op is tracked under; `grid.edit.settleRow(id, …)` answers it.
kindRowChangeKind 'append' | 'delete'Which structural write it is.
keystringThe row key - a temporary one for an append until the server rekeys it.
tempbooleanTrue while the key is the grid's own temporary one.
rowRowThe row as it stands in the grid, or undefined when there is none. (optional)

RowPointerEvent

`row:clicked` and `row:dblclicked`: a row was clicked or double-clicked.

PropertyTypeDescription
rowRowThe row under the pointer.
keystringThat row's key.
indexnumberIts display index.
eventunknownThe DOM event behind this one.

RowReceiveCancelledEvent

The `rowReceive:cancelled` event: a `beforeRowReceive` was vetoed, or went stale during an async handler. Nothing was inserted and the source grid is untouched.

PropertyTypeDescription
dataRecord<string, unknown>The row that was not inserted, as the handler saw it.
atnumberThe display index it would have taken.
overKeystring | nullThe key of the row under the pointer, or null.
sourceGridThe grid the row would have come from; it still holds the row.
reasonstringThe reason given to `preventDefault`, `'prevented'` when none was given, or `'stale'` when the row under the pointer or the source row was gone by the time an async handler settled.

RowReceivedEvent

`row:received`: a row dragged from another grid was inserted here.

PropertyTypeDescription
dataRecord<string, unknown>The row's data, as it was inserted.
atnumberThe display index it took.
overKeystring | nullThe key of the row it was dropped on, or null when it landed on no row.
rejectedRejectedRow[]Rows the insert could not apply; empty on a clean insert.

RowRevertedEvent

`row:reverted`: a pending row append or delete was refused and rolled back.

PropertyTypeDescription
idstringThe id the op was tracked under.
kindRowChangeKind 'append' | 'delete'Which structural write it was.
keystringThe row key.
tempKeystringThe temporary key the append had been given. (optional)
reasonstring | nullWhy it was refused, or null when the transport gave no reason.
supersededbooleanTrue when a newer op on the same key had already replaced this one.
appliedbooleanTrue when the rollback was applied; false when it was superseded.
rowRowThe row as it stands after the rollback, where there is one. (optional)

RowTransferEvent

`row:sent` and `row:copied`: a row left this grid for another one. `row:sent` means it was removed from here, `row:copied` means it was kept.

PropertyTypeDescription
keystringThe key of the row that was transferred.
dataRecord<string, unknown>That row's data, as the target received it.
modeRowTransferMode 'move' | 'copy'Which gesture it was.

RowsChangedEvent

`rows:changed`: rows were added, updated, removed or moved. **Read `identified` first.** When it is `true` the three arrays name exactly the rows that moved and a derived viewer can patch rather than rescan. Every other firing omits it, and a consumer that does not see it re-reads in full () - the default is deliberately the safe one. The `companion: true` firings carry **counts** in `added`/`updated`/ `removed`, not rows: they come from the source's own companion channel, which has the numbers and not the records. Anything reading `.length` has to check `identified` rather than assume an array (F-1688-B).

PropertyTypeDescription
identifiedbooleanTrue when `added`, `updated` and `removed` name exactly the rows that moved. (optional)
addedRow[] | numberThe rows added - records when `identified`, a count on a companion firing. (optional)
updatedRow[] | numberThe rows updated - records when `identified`, a count on a companion firing. (optional)
removedstring[] | numberThe keys removed - keys when `identified`, a count on a companion firing. (optional)
rejectedRejectedRow[]Rows the host could not apply; the rest of the batch still applied. (optional)
planunknownHow the change was planned and applied, for diagnostics. (optional)
companionbooleanTrue on the firings that echo a change the source has already applied. (optional)
changeRowChangeThe change as it was handed in, on a companion firing that carries one. (optional)
reasonstring`'import'` on a CSV/Excel import; `'edit'`-side reasons name the write. (optional)
editbooleanTrue when the change came from an edit commit rather than a data feed. (optional)
columnsstring[]The column ids an edit wrote to. (optional)
movednumber`1` when the change was a single row reorder. (optional)
keystringThe key of the row that moved. (optional)
fromnumberThe display index it moved from. (optional)
tonumberThe display index it moved to. (optional)

RowsDeferredEvent

`rows:deferred`: a flush ran out of frame budget and carried work over.

PropertyTypeDescription
deferrednumberHow many rows were carried into the next frame.
appliednumberHow many rows this flush did apply.
budgetMsnumberThe per-flush budget, in milliseconds, that ran out.

RowsFlowEvent

`rows:paused` and `rows:resumed`: the whole counter set the feed keeps, which is what a host watching a live feed wants at the moment it stops or starts. Identical to what `grid.changes.stats()` returns.

PropertyTypeDescription
pausedbooleanWhether the feed is held.
pendingnumberChanges waiting to be applied.
queuednumberRows those changes carry.
coalescednumberRows coalescing saved on the current queue.
coalescedTotalnumberRows coalescing has saved over the grid's life.
rowsnumberRows that have arrived over the grid's life.
droppednumberRows dropped because the buffer was full.
heldnumberRows the change log is holding right now.
heldLimitnumberThe most it will hold before trimming.
flushesnumberHow many flushes have run.
strategystringThe batching strategy in force.
deferralsnumberFlushes that ran out of budget and carried work over; a rising number means the feed outpaces the grid.
maxQueuednumberThe largest queue seen.
budgetMsnumberThe per-flush budget, in milliseconds.
span{ from: number; to: number } | nullThe time span the held log covers, or null when it holds nothing.

RowsQueuedEvent

`rows:queued`: a change arrived while the feed was batching and was queued.

PropertyTypeDescription
pendingnumberHow many changes are waiting to be applied.
queuednumberHow many rows those changes carry.
coalescednumberHow many rows coalescing has saved on this queue.
pausedbooleanWhether the feed is currently paused.

ScrollEvent

`scroll` and `scroll:end`: the viewport's offset. `scroll` fires only when the offset actually moved, so a refresh is never mistaken for a scroll; `scroll:end` fires once the gesture has settled.

PropertyTypeDescription
topnumberThe vertical offset, in content space rather than spacer space, so it survives a row-count change.
leftnumberThe logical horizontal offset: zero at the content's start in either writing direction.

SelectionCancelledEvent

`selection:cancelled`: a `beforeSelect` handler vetoed the change, which has been snapped back.

PropertyTypeDescription
keysstring[]The keys the user had selected, which are no longer selected.
previousstring[]The keys the selection was snapped back to.
reasonstringThe reason given to `preventDefault`, or `'prevented'`.

SelectionChangedEvent

`selection:changed`: the row selection changed and was accepted.

PropertyTypeDescription
keysstring[]The keys of every selected row.
rowsRow[]Those rows.

SortCancelledEvent

`sort:cancelled`: a `beforeSort` handler vetoed the sort.

PropertyTypeDescription
sortSortEntry[]The sort that was not applied.
reasonstringThe reason given to `preventDefault`, or `'prevented'`.

SortChangedEvent

`sort:changed`: the sort order changed.

PropertyTypeDescription
sortSortEntry[]The sort now in force, in precedence order; empty when nothing is sorted.

SourceErrorEvent

`source:error`: a source could not fetch what was asked of it. Which of the optional fields is present says what was being fetched.

PropertyTypeDescription
errorunknownWhat the source threw or rejected with.
rowRowThe branch row whose children could not be loaded. (optional)
reasonstring`'loadChildren'` on a tree fetch; absent on a page or stream failure. (optional)
blockstring | numberThe block id that failed, on a paged or remote source. (optional)
range{ start: number; end: number }The display range that block covers. (optional)
groupPathunknown[]The group path the failed block sits under, on a remote grouped source. (optional)

SourceTotalEvent

`source:total`: a deferred exact total landed. A pushdown source whose count has to read data - a filtered query against a remote Parquet file, where counting costs a second and a page costs a tenth of one - delivers the rows as soon as the page settles and counts afterwards. This is the count arriving: the number is exact, it fires once per query, and until it does `grid.rows.totalCount()` is `null` and `grid.rows.totalPending()` is `true`. A query whose total arrives with its rows never fires it.

PropertyTypeDescription
totalnumberThe exact number of rows the query matches. Never an estimate.
groupPathunknown[]The group path the total counts, empty at the root of an ungrouped grid. (optional)

StateChangedEvent

The `state:changed` event. Fires once per logical state change, whether it began as a user gesture or as a programmatic call, so view persistence is built on this one event rather than on the ten individual ones - `reset()` raises those too, which made a debounced save write the reset arrangement back. **Exactly one event per change.** A change that internally routes through `state.apply()` - applying a saved view, an undo, a reset - announces itself once, carrying the outermost cause rather than the inner mechanism's. A host predicate registered, replaced or removed through `filters.where(name, fn)`, and a `filters.reapply()` that re-runs one, go through the same tracked door as `sort` and `filters`: each fires this event once, `cause: 'user'`, with `'where'` in `sections`.

PropertyTypeDescription
causeStateChangeCause 'user' | 'apply' | 'reset'Why the state changed. `'reset'` is the one a save should ignore.
sectionsStateSection[]Which sections moved, sorted and de-duplicated. For `'apply'` and `'reset'` these are the sections the report applied; for `'user'`, the sections the change touches.
stateGridState | nullThe state that was applied - present for `'apply'` and `'reset'`, null for `'user'`. A full capture on every gesture would put an unsanitised copy of the state, hidden column ids and widths included, on the bus for every listener; a host calls `grid.state.get()` when it decides to write, which is permission-sanitised.
reportStateApplyReport | nullWhat an apply could not restore; null for `'user'`.

StateResetEvent

`state:reset`: `grid.state.reset()` restored the arrangement the grid was built with.

PropertyTypeDescription
stateGridStateThe baseline that was restored.

StreamChunkEvent

`stream:chunk`: a streaming source applied a chunk of arriving rows.

PropertyTypeDescription
loadednumberBytes or rows read so far, as the transport reports them.
estimatednumberWhat the transport expects in total, or 0 when it does not say.
countnumberHow many rows the source now holds.
rendersnumberHow many times the grid has been asked to repaint for this stream.

StreamEndEvent

`stream:end`: a streaming source reached the end of its feed.

PropertyTypeDescription
loadednumberHow many rows arrived in all.
promotedbooleanTrue when the stream handed over to an in-memory source at the end.
thresholdnumberThe row count above which it would have promoted.

StreamEvictedEvent

`stream:evicted`: a rolling-window stream dropped rows off the back.

PropertyTypeDescription
evictednumberHow many rows this eviction dropped.
totalnumberHow many rows have been evicted over the stream's life.
livenumberHow many rows are still live in the window.

TimelineAttachedEvent

`timeline:attached`: the scrubber began recording what each change replaces.

PropertyTypeDescription
depthnumberHow many steps back it is currently possible to go.

TimelineSeekEvent

`timeline:seek`: the timeline finished moving.

PropertyTypeDescription
positionnumberHow many steps back from the present the grid now stands; 0 is live.
depthnumberHow many steps back it is possible to go.
livebooleanWhether it is standing in the present.
atnumber | nullThe timestamp of the recorded state it is standing at, or null when live.

TimelineSeekingEvent

`timeline:seeking`: the timeline is about to move.

PropertyTypeDescription
fromnumberHow many steps back it is coming from.
tonumberHow many steps back it is going to.

TreeLoadAbortedEvent

`tree:loadAborted`: a branch was collapsed before its children arrived, so the fetch was abandoned.

PropertyTypeDescription
keystringThe branch's row key.

TreeLoadFailedEvent

`tree:loadFailed`: a branch's `loadChildren` rejected; the branch stays unloaded so it can be retried.

PropertyTypeDescription
keystringThe branch's row key.
errorunknownWhat the loader rejected with.

TreeLoadedEvent

`tree:loaded`: a branch's children arrived and were added.

PropertyTypeDescription
keystringThe branch's row key.
countnumberHow many children arrived.

TreeLoadingEvent

`tree:loading`: a branch was expanded and `tree.loadChildren` was called for it.

PropertyTypeDescription
keystringThe branch's row key.
rowRowThat branch row.

ValidationClearedEvent

`validation:cleared`: recorded validation errors were cleared.

PropertyTypeDescription
keystring | nullThe row that was cleared, or null when every row was.
colIdstring | nullThe column that was cleared, or null when every column was.

ValidationFailedEvent

`validation:failed`: a declared column rule refused an edit.

PropertyTypeDescription
keystringThe key of the row whose commit was refused.
failuresValidationError[]One entry per failing cell, with its column, code and message.
countnumberHow many cells failed.

ViewAppliedEvent

`view:applied`: a saved view was applied to the grid.

PropertyTypeDescription
viewSavedViewThe view that was applied.
viewsSavedView[]Every view, unchanged by the apply.
activeIdstringThe id of the view now active.

ViewChangedEvent

`view:saved`, `view:removed`, `view:renamed` and `view:default`: the one view that moved, so a host can POST that record instead of diffing two full lists to work out what the user just did.

PropertyTypeDescription
viewSavedView | nullThe view that moved.
viewsSavedView[]Every view after the change.
reasonstringThe underlying reason: `'save'`, `'update'`, `'import'`, `'remove'`, `'rename'` or `'default'`.

ViewsChangedEvent

`views:changed`: the saved-view list changed, for any reason. Paired with a named `view:*` event that carries the one view that moved: this one is what a picker or a `localStorage` mirror wants, the named one is what a host persisting to a server wants.

PropertyTypeDescription
viewsSavedView[]Every view after the change.
reasonstringWhat happened: `'save'`, `'update'`, `'import'`, `'remove'`, `'rename'`, `'default'`, `'seed'`, `'replace'` or `'apply'`.
viewSavedView | nullThe view that moved, or null when the change was not about one view.
activeIdstringThe view now applied, on the `'apply'` firing. (optional)