api reference
JavaScript Data Grid API Reference
How to construct a grid, the configuration and column specs, and the grid methods. Each namespace has its own page below, and the type reference organises every surface into its own properties, methods and events tables; the full reference is one archive away.
Version 1.103.2 Zero dependencies 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.latticegrid.dev/lattice-grid@1.103.2/lattice-grid.min.css">
<script src="https://cdn.latticegrid.dev/lattice-grid@1.103.2/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.latticegrid.dev/lattice-grid@1.103.2/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);
Our CDN serves every file of the package at
cdn.latticegrid.dev/lattice-grid@1.103.2/<file>. The version in the address is the exact release, so a page in production never picks up a different build. 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
| Property | Type | Default | Description |
|---|---|---|---|
| columns | (Column | ColumnGroup)[] | , | Column definitions. Groups may nest. |
| columnGroups | ColumnGroup[] | , | Header grouping declared separately from the columns. |
| rows | unknown[] | , | 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. |
| rowKey | string | (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. |
| source | SourceConfig | memory | Where rows come from: memory, paged, remote or stream. See Sources. |
| ingest | IngestConfig | , | { 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. |
| tree | TreeConfig | , | { path } or { parentKey }, plus label, orphans. Rows form a hierarchy. See Tree data. |
| detail | DetailConfig | , | { 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. |
| context | unknown | , | Arbitrary value passed to every callback, so formatters and renderers need no closures over app state. |
Defaults and registries
| Property | Type | Description |
|---|---|---|
| columnDefaults | Column | Merged under every column before its own definition. |
| columnPresets | Record<string, Column> | Named bundles applied with preset: 'money'. |
| dataTypes | Record<string, DataType> | Custom types. Registered ahead of the built-ins, so a name here overrides one of ours. |
| sampleSize | number | Values read per undeclared column when inferring its type. Default 100. |
| targetSize | 'default' | 'large' TargetSize | Raises every interactive target to a comfortable size for touch, leaving the type alone. Applied automatically on a coarse pointer; 'default' opts out of that. |
| components | Record<string, Ctor> | Renderers, editors and filters addressable by name. |
| pipes | Record<string, fn> | Template pipes for cell.template. |
| totalFns | Record<string, TotalFn> | Custom aggregations, addressable from column.total. |
| variants | Record<string, VariantDefinition> | Semantic colour tokens for decorations. |
Behaviour
| Property | Type | Default | Description |
|---|---|---|---|
| selection | SelectionConfig | '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. |
| edit | EditConfig | 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. |
| pagination | PaginationConfig | boolean | , | Local or remote paging. |
| quickFilterText | string | , | 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. |
| pivot | object | , | { 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. |
| grandTotalRow | boolean | 'bottom' | false | true 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. |
| pinnedTopRows | object[] | , | 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. |
| pinnedBottomRows | object[] | , | 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. |
| groupDefaultExpanded | boolean | 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. |
| groupFooter | boolean | false | A closing total row per group. |
| totalFilteredOnly | boolean | true | Totals reduce the filtered set. false totals the whole dataset, group totals included. See Grouping, totals and pivot. |
| totalOnlyChangedColumns | boolean | false | Reduce 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. |
| showTotalInHeader | boolean | true | Under grouping or pivot, a totalled column's heading names its reduction on a line above the column name. See Grouping, totals and pivot. |
| aggregateChooser | boolean | false | Let 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. |
| allowUnsafeTemplates | boolean | false | Off 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. |
| licence | string | , | Signed licence key. Removes the trial watermark; unlocks nothing, because nothing is locked. |
Presentation
| Property | Type | Default | Description |
|---|---|---|---|
| messages | object | en-GB | Replaces 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. The core ships British English only; every other catalogue is its own entry point. import '@toclocoinc/lattice-grid/locales/fr-FR' registers French, after which locale: 'fr-FR' resolves to it - and an untranslated region falls back to its base language, so es-MX finds Spanish once es-ES is imported. import '@toclocoinc/lattice-grid/locales/all' registers every language at once. Setting a locale whose entry point was not imported keeps English and logs one warning naming the import to add. Every key is listed in MESSAGE_KEYS; auditCatalogue() reports what a catalogue of your own is missing. |
| locale | string | runtime | BCP-47. Drives every formatter and one shared Intl.Collator. |
| direction | 'ltr' | 'rtl' | 'auto' Direction | auto | Writing 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' | 'auto' | 'high-contrast' | 'terminal' Theme | , | Stamped as data-theme on the grid's root. Unset follows the nearest data-theme on an ancestor, and is light when there is none; 'auto' follows the viewer's prefers-color-scheme. See Theming and the full theme token contract. |
| 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. |
| rowHeight | number | (row) => number | 28 | A function enables variable-height rows. |
| headerHeight | number | 32 | Per header row. |
| title | string | , | 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. |
| showHeader | boolean | true | Draw the column headings at all. false removes the row, and removes it from the accessibility tree rather than only from view. Distinct from showColumnFunctions, which keeps the headings and drops only their sort, filter and menu controls. |
| overscan | number | 4 | Rows rendered beyond the viewport. |
| autoHeight | boolean | '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. |
| columnVirtualisationAbove | number | 30 | Column count above which columns virtualise too. |
| state | GridState | , | Restore a saved view at construction. |
Performance
| Property | Type | Default | Description |
|---|---|---|---|
| useWorker | boolean | true | Compute column distributions off the main thread. Sorting, filtering and grouping run on the main thread. |
| workerThreshold | number | 50000 | Row count above which a distribution is sent to the Worker. |
| workerUrl | string | , | External worker file, for a CSP that forbids blob:. Settled when the Worker is built; changing it rebuilds one. |
| sharedMemory | boolean | false | Pass 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
| Property | Type | Description | |
|---|---|---|---|
| statusBar | boolean | { panels } | Composable panels along the bottom. Default set: rowCount, selectedCount, aggregation, comments, updates, progress. Each is silent when it has nothing to report. | |
| maximise | boolean | { zIndex, container } | true | false removes the rail button and grid.maximise, for an application with its own full-screen mode. An object tunes how it fills: zIndex is the CSS layer the maximised grid sits at, defaulting to the --lattice-maximise-z token (1000, far below the ceiling) so a host's toasts, dialogs and popups open above it - lower it to tuck the grid under a specific layer. container is an element to fill instead of the browser viewport, for a host that wants "maximise" to mean its own modal or panel; that element should be position: relative. |
| toolPanel | boolean | object | Side dock. panels: columns, filters, views, quick, formatting, statistics, regression - each a bare name, an object giving it props/title/icon, or a constructor of your own. The regression panel falls back to the grid's own regression option when its entry carries no props. 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. | |
| groupPanel | boolean | object | A 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. | |
| kpis | StatConfig[] | 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. | |
| timeZone | string | An IANA zone every date column formats and parses in, so a grid shows one zone whatever the viewer's machine says. The zone is the grid's alone: a column carries no timeZone of its own (declaring one warns), so every datetime column stores, displays, sorts and filters in it, else in the viewer's. | |
| formulaFunctions | object | Your 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. | |
| formatting | object | Conditional 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. | |
| facets | boolean | object | Header histograms that double as a filter. collapsed, height, and per-column strategy and buckets. | |
| updates | object | How a live feed behaves: batching, the queue that holds while paused, and the highlight a changed cell flashes. | |
| comments | object | Threaded cell comments: storage, the current author, and whether the indicator shows on an unread thread. | |
| presence | object | Live cursors, selections and edit locks. Carries intent and never values; see grid.presence. | |
| environment | function | Extra fields for the diagnostics bundle: build number, tenant, region. Called when a bundle is taken, never on the render path. | |
| contextMenu | boolean | (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. | |
| columnMenu | boolean | (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. | |
| rangeChart | fn | { onChart } | boolean | Off 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. | |
| shortcuts | boolean | true | The ? keyboard shortcut overlay. false suppresses it, for a host that wants ? for itself. See Keyboard. |
| find | boolean | FindConfig | true | The 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. |
| rowReorder | boolean | { 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. |
| rowTransfer | boolean | { 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. |
| alignedGrids | Grid[] | , | 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. |
| stickyGroupHeaders | boolean | number | { depth } | false | Keep 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. |
| gridLines | boolean | '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. |
| cornerRadius | boolean | number | string | , | Round the grid's outer corners. true adopts the theme's radius, a number is pixels, a string is used as written. |
| stripedRows | boolean | false | Shade 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-row-stripe, 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. |
| tooltip | TooltipConfig | , | { 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. |
| columnTagFilter | boolean | { 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. |
| rowTemplate | string | { 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. |
| gallery | boolean | { 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. |
| recordCard | boolean | { 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. |
| board | boolean | { 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. |
| rowForm | boolean | { 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. |
| showColumnFunctions | boolean | true | false 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. |
| significantFigures | number | , | 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. |
| typeOptions | object | , | Per-column options a data type reads. ratio and percentRate use { weight } to name the column their average is weighted by. See Aggregate safety. |
| highlightOnChange | boolean | string | object | Flash a cell when its value changes. { colour, duration }; duration: 0 stays until cleared. | |
| rowClass | string | string[] | (p) => … | A class, or classes, for every row. Re-evaluated on each repaint. | |
| rowStyle | CellStyle | (p) => CellStyle | Inline styles for every row. Camel-case or hyphenated property names. | |
| views | object | saved, allowSave, storage. See grid.views. | |
| permissions | string | object | fn | Per-column access. See grid.permissions. | |
| diff | object | { snapshot } turns on audit mode. | |
| historyBar | boolean | object | A standalone undo/redo toolbar with a timeline. | |
| ai | object | { ask } mounts the prompt bar. Your ask receives { prompt, schema, schemaText, message, context } and returns the model's reply. | |
| dataTypes | object | Custom types by name. createRadixType and createUnitType are exported for building them. | |
| editBar | boolean | A spreadsheet-style input above the header. When on, it hosts the column's real editor and inline editing is suppressed. | |
| pagination | boolean | object | Renders 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. Filters differ too: a date
column compares on the day, and a datetime column compares the full wall clock, date
and time (since 1.86.2,. 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.
| Property | Type | Description | |
|---|---|---|---|
| id | string | Defaults to field. Required when there is no field. | |
| field | string | Dotted paths supported: 'site.address.postcode'. | |
| title | string | Header text. Defaults to a humanised field. | |
| type | TypeName | false | A 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. | |
| preset | string | string[] | Named bundles from columnPresets. | |
| tags | string | string[] | , | Labels grouping columns together, used by the column tag bar. A bare string is accepted for one tag. |
| format | FormatSpec | string | Shorthand strings like 'percent:1', 'date:dd MMM yyyy', or an Excel-style number pattern like '#,##0.00' - see Number format patterns. | |
| lookup | LookupSpec | Id-to-label mapping. Nested children are flattened, so a tree-shaped list resolves labels everywhere. | |
| value | ColumnValueSpec | Computed values and the value lifecycle. | |
| cell | ColumnCellSpec | string | A bare string is a renderer name. | |
| edit | ColumnEditSpec | boolean | string | A bare string is an editor name. | |
| sort | ColumnSortSpec | boolean | ||
| filter | ColumnFilterSpec | boolean | FilterName | ||
| group | object | boolean | { enabled, index, explode }. | |
| pivot | object | boolean | { enabled, index }. | |
| total | TotalName | TotalFn | One 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. | |
| groupTotal | TotalName | TotalFn | The 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. | |
| grandTotal | TotalName | TotalFn | The reduction for the pinned grand-total row, where it should differ from the subtotals. Overrides total for the grand total only; omitted, total applies. | |
| layout | ColumnLayoutSpec | number | A bare number is the width. | |
| header | ColumnHeaderSpec | string | ||
| export | ColumnExportSpec | { lookup: 'label' | 'value' | 'columns', csv, excel }. | |
| allowGroup / allowPivot / allowTotal | boolean | Whether the tool panel offers the column for that zone. | |
| nullable | boolean | Affects storage choice and null ordering. | |
| rowSpan / colSpan | (p) => number | Merge this column's cell down over the rows beneath it, or across the columns to its right. 1 or less is no merge. See Merged cells. |
Number format patterns
A compact Excel-style template, for when spelling out the full NumberFormat object is more than a one-line column needs. A bare string number format is the same thing: format: '#,##0.00' and format: { pattern: '#,##0.00' } compile identically.
The pattern is the shorthand the options object spells out, and it compiles to those options - so it shares the one compiled, Intl-backed formatter with the object form, and the Excel export carries the pattern through as the cell's number format. 0 is a required digit and # an optional one; , turns thousands grouping on; . and the digits after it set the minimum and maximum decimals; % scales by 100; a second ; section is the negative form and a third is the zero form. Literal text - currency symbols, and anything quoted with " - sits before or after the number. The locale supplies the grouping and decimal characters; options written beside pattern win over what it implies.
| Pattern | Example output |
|---|---|
#,##0.00 | 1,234.50 |
#,##0 | 1,235 |
0.0% | 25.6% |
£#,##0.00 | £1,234.50 |
#,##0.00;(#,##0.00) | (12.00) |
#,##0.## | 1,234.5 |
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
// The same pattern, read back from a real column - and the locale decides the
// grouping and decimal characters, so de-DE points with "." and groups with ",".
const grid = createHeadlessGrid({
rowKey: 'id', locale: 'en-GB',
columns: [
{ field: 'plain', id: 'plain', type: 'number', format: '#,##0.00' },
{ field: 'whole', id: 'whole', type: 'number', format: '#,##0' },
{ field: 'pct', id: 'pct', type: 'number', format: '0.0%' },
{ field: 'pounds', id: 'pounds', type: 'number', format: '£#,##0.00' },
{ field: 'acct', id: 'acct', type: 'number', format: '#,##0.00;(#,##0.00)' },
{ field: 'loose', id: 'loose', type: 'number', format: '#,##0.##' },
{ field: 'german', id: 'german', type: 'number', format: { pattern: '#,##0.00', locale: 'de-DE' } },
],
rows: [{ id: 1, plain: 1234.5, whole: 1234.5, pct: 0.256, pounds: 1234.5, acct: -12, loose: 1234.5, german: 1234.5 }],
});
const key = grid.rows.get(0).key;
const out = ['plain', 'whole', 'pct', 'pounds', 'acct', 'loose', 'german']
.map((c) => grid.rows.text(key, c));
grid.destroy();
return out.join(' | ');
Column sub-specs
value
| Key | Type | Description |
|---|---|---|
| compute | (deps, ctx) => unknown | Derived 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. |
| deps | string[] | '*' | 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. |
| pure | boolean | Default 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) => string | Overrides the type's formatter. |
| parse | (p) => unknown | Editor output to value. Always called, whatever the editor emitted. |
| apply | (p) => boolean | Writes the value back into the row object. |
| key | (p) => string | Group key override. |
| compare | Comparator | Overrides the type's comparator. |
| quickFilterText | (p) => string | What the quick filter matches against. |
cell
| Key | Type | Description |
|---|---|---|
| render | RendererName | RenderFn | Ctor | Renderer name or component. The built-in names are listed under built-in renderers. |
| props | object | Passed to the renderer. |
| decoration | DecorationName | spec | pill, bar, fill, dot, edge. |
| variant | VariantSpec | Maps a value to a semantic token: { map }, or { when: [...], default }. |
| template | string | Escaped unless allowUnsafeTemplates is set. |
| class | string | string[] | (p) => … | Classes for this column's cells. |
| classWhen | { [class]: (p) => boolean } | A class per predicate, re-evaluated as values change. |
| style / css | CellStyle | (p) => CellStyle | Inline styles, static or computed. |
| tooltip | string | (p) => string | ColumnTooltipSpec | A 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) => number | Spanned 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
| Spec | Keys |
|---|---|
| edit | enabled (boolean or predicate), editor, props, popup, validate |
| sort | enabled, direction, order, nullsFirst |
| filter | enabled, type, props |
| layout | width, 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. |
| header | template, 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. |
Every block below runs in the test suite, against this build, and states what it produced. Between them they exercise the column sub-specs above end to end.
Everything a column’s layout accepts, and the three locks as refusals
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
// Everything ColumnLayoutSpec accepts, on three columns.
const grid = createHeadlessGrid({
rowKey: 'id',
rows: [{ id: 1, ref: 'A-1', notes: 'long text', qty: 3 }],
columns: [
// An identifier: fixed width, pinned to the start edge, and locked against
// every user gesture - resize, move, hide, and being pulled into a band.
{ field: 'ref', layout: { width: 130, min: 44, max: 300, pin: 'start',
resizable: false, movable: false, lockVisible: true, lockPosition: true } },
// A column that follows the grid's width, and one sized to its content.
{ field: 'notes', layout: { flex: 1, min: 200 } },
{ field: 'qty', layout: { fit: 'content', hidden: true } },
],
});
const ref = grid.columns.get('ref').layout;
const notes = grid.columns.get('notes').layout;
const qty = grid.columns.get('qty').layout;
// The locks are refusals, not decoration: each gesture is ignored and warns.
grid.columns.hide('ref');
grid.columns.resize('ref', 300);
grid.columns.move('ref', 2);
const stillThere = grid.columns.visible().map((c) => c.id).join(',');
const order = grid.columns.all().map((c) => c.id).join(',');
grid.destroy();
return `ref ${ref.width}/${ref.min}/${ref.max} ${ref.pin}; notes flex ${notes.flex} min ${notes.min};`
+ ` qty ${qty.fit} hidden ${qty.hidden}; after locks ${stillThere} | ${order}`;
A lookup column read by key, ordered, joined, and asked about an unknown value
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
// The dictionary is read by key, so an option list you already have needs no
// reshaping: `code` is the stored value, `name` the label, `team` the heading an
// editor groups under.
const dictionary = [
{ code: 'ops', name: 'Operations', team: 'Field' },
{ code: 'eng', name: 'Engineering', team: 'Field' },
{ code: 'fin', name: 'Finance', team: 'Back office' },
];
const base = { valueKey: 'code', labelKey: 'name', groupKey: 'team', options: dictionary };
const grid = createHeadlessGrid({
rowKey: 'id',
rows: [{ id: 1, owner: ['ops', 'fin'], next: 'ops', raw: 'nope', open: 'anything' }],
columns: [
// A cell that holds several values: the labels are joined by `separator`.
{ field: 'owner', type: 'lookup', lookup: { ...base, multiple: true, separator: ' + ' } },
// Workflow order rather than alphabetical order, for the editor and the set filter.
{ field: 'next', type: 'lookup', lookup: { ...base, sortBy: 'optionOrder' } },
// A value the dictionary does not hold still renders, never blanked.
{ field: 'raw', type: 'lookup', lookup: { ...base, unknownLabel: (v) => `unknown (${v})` } },
// ...and is reported once per column, unless the column says it is open.
{ field: 'open', type: 'lookup', lookup: { ...base, allowCustom: true } },
],
});
// The text the reader sees, straight off the row.
const shown = (id) => grid.rows.text('1', id);
const order = (id) => grid.columns.get(id).lookup.sorted().map((o) => o.id).join(',');
// `sortBy` decides what the editor lists, and it is not the declaration order by
// default: 'label' collates, 'optionOrder' keeps the sequence as declared.
const byLabel = order('owner');
const byDeclared = order('next');
// `groupKey` names the heading an editor groups the options under.
const headings = grid.columns.get('owner').lookup.sorted().map((o) => o.group).join('|');
// Both are read before the snapshot below, because the unknown value is
// reported when the cell is formatted.
const multi = shown('owner');
const unknown = shown('raw');
const custom = shown('open');
const reported = grid.diagnostics.snapshot().warnings
.filter((w) => w.id.startsWith('lookup:unknown:')).map((w) => w.id).join(',');
// Asked for matching options as the user types; the signal belongs to the
// request this keystroke supersedes.
const search = async (query, signal) => (signal.aborted ? [] : [{ code: 'new', name: query }]);
const extra = await search('Newco', { aborted: false });
// The unknown value is reported against the column that did not allow it, and
// not against the one that did.
grid.destroy();
return `${multi}; ${unknown}; ${custom}; ${byLabel} / ${byDeclared}; search ${extra[0].name}; reported ${reported}; groups ${headings}`;
Every declarative validation rule, refusing a commit
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
// Declarative edit rules. Each is checked before the value is written, so a
// failing commit is refused rather than corrected afterwards.
const grid = createHeadlessGrid({
rowKey: 'id',
rows: [{ id: 'r1', name: 'Alice', age: 30, ref: 'ABC', state: 'open', from: 5, to: 9, span: 1 }],
columns: [
{ field: 'id' },
{ field: 'name', edit: true, validation: { required: true, minLength: 2, maxLength: 20 } },
{ field: 'age', type: 'number', edit: true, validation: { min: 0, max: 120 } },
{ field: 'ref', edit: true, validation: { pattern: '^[A-Z]{3}$', messages: { pattern: 'Three capitals.' } } },
{ field: 'state', edit: true, validation: { oneOf: ['open', 'closed'], message: 'Not a state.' } },
// A cross-field rule sees the whole row, so it can compare siblings.
{ field: 'to', type: 'number', edit: true, validation: {
crossField: (value, row) => (Number(value) >= Number(row.from) ? true : 'Ends before it starts.'),
} },
// A free-form check, the same contract as `crossField`.
{ field: 'span', type: 'number', edit: true, validation: {
validate: (value) => (Number(value) < 100 ? true : 'Too far out.'),
} },
],
});
// Commit a value the way a user does, through the editor.
const commit = (colId, value) => { grid.edit.start('r1', colId); grid.edit.stop(false, { value }); };
const refused = [];
for (const [colId, value] of [['name', ''], ['age', 999], ['ref', 'abc'], ['state', 'paused'], ['to', 1], ['span', 500]]) {
commit(colId, value);
const error = grid.validation.errorFor('r1', colId);
if (error) refused.push(`${colId}:${error.code}`);
}
// The cells still hold what they held: a failing value is not written.
const kept = `${grid.rows.byKey('r1').data.age}/${grid.rows.byKey('r1').data.ref}`;
// A valid value writes through and clears the mark.
commit('age', 40);
const after = `${grid.rows.byKey('r1').data.age} ${grid.validation.errorFor('r1', 'age')}`;
const wording = grid.validation.errorFor('r1', 'ref');
grid.destroy();
return `refused ${refused.join(',')}; kept ${kept}; then ${after}; message ${wording && wording.message}`;
Rolling, positional and anomaly shadows over a stated order
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
// A shadow is a value the grid maintains about a column, not a field in the
// data. The rolling kinds read an explicit `orderBy` - never the screen sort, or
// a trailing average would change on every header click.
const grid = createHeadlessGrid({
rowKey: 'id',
rows: [
{ id: 'r1', t: 1, v: 2, g: 'a' }, { id: 'r2', t: 2, v: 4, g: 'a' },
{ id: 'r3', t: 3, v: 5, g: 'a' }, { id: 'r4', t: 4, v: 4, g: 'b' },
{ id: 'r5', t: 5, v: 99, g: 'b' },
],
columns: [
{ field: 't', type: 'number' },
{ field: 'v', type: 'number' },
{ field: 'g' },
// The last three rows of the series, across the whole dataset rather than
// per group.
{ id: 'sum3', shadow: { kind: 'rollingSum', of: 'v', orderBy: 't', within: 'all',
window: { kind: 'count', span: 3 } } },
// How full that window is: the first rows carry a partial one, and are
// stamped as such rather than dressed as full.
{ id: 'cov', shadow: { kind: 'windowCoverage', of: 'v', orderBy: 't', within: 'all',
window: { kind: 'count', span: 3 } } },
// The median of the window; `q` moves it anywhere in [0, 1].
{ id: 'p90', shadow: { kind: 'rollingQuantile', of: 'v', orderBy: 't', within: 'all', q: 0.9,
window: { kind: 'session' } } },
// Everything so far, and the change on the row before.
{ id: 'cum', shadow: { kind: 'cumulativeToDate', of: 'v', orderBy: 't', within: 'all' } },
{ id: 'pop', shadow: { kind: 'periodOverPeriod', of: 'v', orderBy: 't', within: 'group' } },
// A rank is positional: `scope` decides whether the filters move it.
{ id: 'rankAll', shadow: { kind: 'rank', of: 'v', scope: 'all' } },
{ id: 'rankLeft', shadow: { kind: 'rank', of: 'v', scope: 'filtered' } },
// An outlier by modified z-score; `threshold` is the score a row must clear.
{ id: 'odd', shadow: { kind: 'anomalyFlag', of: 'v', threshold: 3.5 } },
// A running total is the one derived value that depends on the display
// order, which is why it is not a shadow kind.
{ field: 'v', id: 'sofar', running: 'total' },
],
});
const at = (col, key) => grid.rows.value(key, col);
const series = ['r1', 'r2', 'r3', 'r4', 'r5'].map((k) => at('sum3', k)).join(',');
const coverage = `${at('cov', 'r1').toFixed(2)}/${at('cov', 'r5')}`;
const quantile = at('p90', 'r5');
const cumulative = at('cum', 'r5');
const perGroup = at('pop', 'r5');
const running = at('sofar', 'r5');
const flagged = ['r1', 'r5'].map((k) => at('odd', k)).join(',');
// With `scope: 'filtered'` the rank is taken over what the filters left; with
// `'all'` it does not move.
grid.filters.set({ col: 'g', op: 'eq', value: 'b' });
const ranks = `${at('rankAll', 'r4')}/${at('rankLeft', 'r4')}`;
grid.destroy();
return `sum ${series}; cov ${coverage}; q ${quantile}; cum ${cumulative}; pop ${perGroup};`
+ ` running ${running}; anomaly ${flagged}; rank ${ranks}`;
The options a decomposition, a smoothing model, a regression and a tolerance need
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
// A seasonal series: level 10 plus a four-step cycle, so the decomposition has
// something to find. `period` is required - there is no auto-detection.
const values = [12, 8, 11, 9, 12, 8, 11, 9, 12, 8, 11, 9];
const series = { of: 'v', orderBy: 't', within: 'all' };
const grid = createHeadlessGrid({
rowKey: 'id',
rows: values.map((v, i) => ({ id: String(i), t: i, v, x: i, y: 2 * i + 1 })),
columns: [
{ field: 't', type: 'number' },
{ field: 'v', type: 'number' },
{ field: 'x', type: 'number' },
{ field: 'y', type: 'number' },
// The classical decomposition. `decomposition: 'multiplicative'` is the
// other model, and is undefined on a non-positive series.
{ id: 'trend', shadow: { kind: 'tsTrend', ...series, period: 4 } },
{ id: 'seasonal', shadow: { kind: 'tsSeasonal', ...series, period: 4, decomposition: 'additive' } },
// Single smoothing at a fixed level factor, and Holt's level+trend, whose
// `beta` is the trend factor.
{ id: 'ses', shadow: { kind: 'tsSmoothed', ...series, smoothing: 'ses', alpha: 0.5 } },
{ id: 'holt', shadow: { kind: 'tsSmoothed', ...series, smoothing: 'holt', alpha: 0.5, beta: 0.1 } },
// A regression: the predictors and the response may be given on the shadow
// itself, or as a whole `model`. `method` picks the fit.
{ id: 'fit', shadow: { kind: 'fitPredicted', predictors: ['x'], response: 'y', method: 'ols' } },
{ id: 'fit2', shadow: { kind: 'fitPredicted', model: { predictors: ['x'], response: 'y', method: 'robust' } } },
// The customer's tolerance, judged per row: outside lower/upper fails, and
// inside the inner warnLower/warnUpper band is a still-in-spec warning.
{ id: 'judged', shadow: { kind: 'specStatus', of: 'v', lower: 8, upper: 12, warnLower: 9, warnUpper: 11 } },
// Declared on the column, so the capability figures, a control chart and a
// rule marking an out-of-tolerance cell cannot disagree about the tolerance.
{ field: 'v', id: 'graded', spec: { lower: 8, upper: 12, target: 10 } },
],
});
const at = (col, key) => grid.rows.value(key, col);
const decomposed = `${at('trend', '4')}/${at('seasonal', '4')}`;
const smoothed = `${at('ses', '11').toFixed(2)}/${at('holt', '11').toFixed(2)}`;
const fitted = `${at('fit', '5')}/${at('fit2', '5')}`;
const judged = ['0', '2', '1'].map((k) => at('judged', k)).join(',');
const tolerance = grid.columns.get('graded').spec;
grid.destroy();
return `trend/seasonal ${decomposed}; ses/holt ${smoothed}; fit ${fitted}; spec ${judged};`
+ ` tolerance ${tolerance.lower}-${tolerance.upper}`;
Grouping and pivot order, three reductions, and what the interface is allowed to offer
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const grid = createHeadlessGrid({
rowKey: 'id',
rows: [
{ id: 1, region: 'EMEA', team: 'Ops', tags: ['red', 'blue'], at: '2026-03-02T09:00:00Z', qty: 4, price: 10 },
{ id: 2, region: 'EMEA', team: 'Eng', tags: ['red'], at: '2026-03-03T09:00:00Z', qty: 6, price: 20 },
{ id: 3, region: 'APAC', team: 'Ops', tags: ['blue'], at: '2026-03-09T09:00:00Z', qty: 2, price: 30 },
],
columns: [
// Two grouping columns, in a stated order rather than the column order.
{ field: 'team', group: { enabled: true, index: 1 } },
{ field: 'region', group: { enabled: true, index: 0 } },
// A multi-value cell: one group per value rather than one for the combination.
{ field: 'tags', group: { explode: true } },
// A timestamp buckets by civil week, starting on Sunday.
{ field: 'at', type: 'timestamp', group: { granularity: 'week', weekStart: 7 } },
// Two pivot dimensions, also ordered by a stated index.
{ field: 'region', id: 'pivotRegion', pivot: { enabled: true, index: 1 } },
{ field: 'team', id: 'pivotTeam', pivot: { enabled: true, index: 0 } },
// A column that averages within each group while the grand total sums, and
// one nobody may group, pivot or total from the interface.
{ field: 'qty', type: 'number', total: 'sum', groupTotal: 'avg', grandTotal: 'sum' },
{ field: 'price', type: 'number', allowGroup: false, allowPivot: false, allowTotal: false, nullable: true },
],
});
// The grouping order is the declared `index`, not the column order.
const grouped = grid.columns.state().filter((c) => c.groupIndex !== null)
.sort((a, b) => a.groupIndex - b.groupIndex).map((c) => c.id).join('+');
// `explode` gives the row one group per value.
const exploded = grid.columns.get('tags').group.explode;
const buckets = grid.columns.get('at').group;
const reductions = ['total', 'groupTotal', 'grandTotal'].map((k) => grid.columns.get('qty')[k]).join('/');
// The pivot order is the declared `index` too.
const pivoted = grid.columns.state().filter((c) => c.pivotIndex !== null)
.sort((a, b) => a.pivotIndex - b.pivotIndex).map((c) => c.id).join('+');
// The three permissions are what the column menu, the tool panel and the AI
// layer's schema read to decide what to offer; they are carried on the resolved
// column, and they do not veto your own calls.
const price = grid.columns.get('price');
const offered = `${price.allowGroup}/${price.allowPivot}/${price.allowTotal}`;
const blanks = price.nullable;
grid.destroy();
return `groups ${grouped}; pivots ${pivoted}; explode ${exploded}; ${buckets.granularity}/${buckets.weekStart};`
+ ` reductions ${reductions}; offered ${offered}; nullable ${blanks}`;
A preset, a type turned off, column tags, and a computed value’s own hooks
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
let computes = 0;
const grid = createHeadlessGrid({
rowKey: 'id',
locale: 'en-US',
// A house style declared once, merged in before anything the column says.
columnPresets: { money: { type: 'number', format: { decimals: 2, prefix: '$' } } },
rows: [
{ id: 1, code: 'b', paid: 1000.5, qty: 2, note: 'x' },
{ id: 2, code: 'a', paid: 20, qty: 3, note: null },
],
columns: [
// `type` decides parsing, formatting, sorting, the default editor and the
// default filter together; `false` turns inference off.
{ field: 'qty', type: 'number' },
{ field: 'note', type: false },
// The preset is merged first, so the column may still override it.
{ field: 'paid', preset: 'money' },
// Free-form labels, so the column tag bar can show and hide sets of columns.
{ field: 'code', tags: ['identity', 'audited'] },
// A computed value: `deps` are the columns it reads, `pure` says the result
// depends on nothing else, so it is computed once per dependency change.
{ id: 'net', value: {
deps: ['paid', 'qty'],
compute: (deps) => { computes++; return deps.paid * deps.qty; },
pure: true,
} },
// A comparator of the column's own, and the parse an edit goes through.
{ id: 'byLength', field: 'code', value: {
compare: (a, b) => String(a).localeCompare(String(b)),
parse: (p) => String(p.value).trim().toLowerCase(),
} },
],
});
const shown = grid.rows.text('1', 'paid');
const opaque = grid.columns.get('note').type;
const tags = grid.columns.tags().join(',');
const net = `${grid.rows.value('1', 'net')}/${grid.rows.value('2', 'net')}`;
// A pure compute is memoised: reading the same cell again does not re-run it.
const before = computes;
grid.rows.value('1', 'net');
const memoised = computes === before;
const compare = grid.columns.get('byLength').compare('a', 'b') < 0;
const parsed = grid.columns.get('byLength').def.value.parse({ value: ' AB ' });
grid.destroy();
return `preset ${shown}; type ${opaque}; tags ${tags}; net ${net}; memoised ${memoised};`
+ ` compare ${compare}; parse ${parsed}`;
Null ordering, an unexported column, the filter and editor a column asks for, and a history depth
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const grid = createHeadlessGrid({
rowKey: 'id',
rows: [
{ id: 1, ref: 'A', closed: null, secret: 'hide me', note: 'first', price: 100 },
{ id: 2, ref: 'B', closed: '2026-03-02', secret: 'hide me too', note: 'second', price: 100 },
],
columns: [
{ field: 'ref' },
// Empty values before the rest, in both directions: the sort never moves them.
{ field: 'closed', type: 'date', sort: { direction: 'asc', nullsFirst: true } },
// Shown, but never exported.
{ field: 'secret', export: { csv: false, excel: false } },
// Which filter the column offers, and what that filter is handed.
{ field: 'note', filter: { type: 'set', props: { limit: 50 } } },
// The editor by name, in a popup, with props of its own. `edit.validate` is
// the imperative check, distinct from the declarative `validation` rules.
{ field: 'price', type: 'number', edit: {
enabled: true, editor: 'textarea', popup: true, props: { rows: 3 },
validate: (value) => (Number(value) > 0 ? true : 'Must be positive.'),
} },
// `history` keeps the last `depth` readings of the column it shadows.
{ id: 'trail', shadow: { of: 'price', kind: 'history', depth: 3 } },
],
});
// The excluded column is not in the file, and the nulls sorted to the head.
const header = grid.export.csv({ lineEnding: '|' }).split('|')[0];
const order = [0, 1].map((i) => grid.rows.get(i).data.ref).join(',');
const filter = grid.columns.get('note').filter;
const editor = grid.columns.get('price').edit;
// The imperative check is a veto: the cell keeps the value it had.
grid.edit.start('1', 'price');
grid.edit.stop(false, { value: -5 });
const kept = grid.rows.byKey('1').data.price;
// Four readings, a depth of three: the oldest falls off the end.
for (const value of [110, 120, 130, 140]) grid.rows.apply({ update: [{ id: 1, price: value }] });
const trail = grid.rows.value('1', 'trail').join('+');
grid.destroy();
return `csv ${header}; nulls ${order}; filter ${filter.type}/${filter.props.limit};`
+ ` editor ${editor.editor}/${editor.popup}/${editor.props.rows}; kept ${kept}; trail ${trail}`;
A band whose columns are married: both moves are refused
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const grid = createHeadlessGrid({
rowKey: 'id',
rows: [{ id: 1, ref: 'A', jul: 10, aug: 10, sep: 10, other: 1 }],
columns: [
{ field: 'ref' },
// A band whose columns are kept together: a move that would take one of them
// out of the band's run, or drop an outsider into it, is refused.
{ id: 'q3', title: 'Q3', marryChildren: true, columns: [
{ field: 'jul' }, { field: 'aug' }, { field: 'sep' },
] },
{ field: 'other' },
],
});
const order = () => grid.columns.all().map((c) => c.id).join(',');
const before = order();
// Moving a married child out of its band, and an outsider into it, are both
// refused: the column order does not change.
grid.columns.move('aug', 4);
const afterChild = order();
grid.columns.move('other', 2);
const afterOutsider = order();
grid.destroy();
return `${before}; child ${afterChild}; outsider ${afterOutsider}`;
The rest of a column, drawn and executed
The blocks above run headless. The ones below also mount a grid in the in-repo test DOM, so what they report is what the renderer drew: the classes, styles, headings and tooltips a column asks for, read back off the cells. Every block runs in the test suite against this build.
A cell drawn every way a column can ask: renderer and props, classes, inline styles, a decoration and variant, a template, alignment, wrapping, a title tooltip and both spans
const { createTestDom, flushFrames } = await import('../packages/dom/src/renderer/testdom.js');
const { createGrid } = await import('../packages/dom/src/index.js');
const { root } = createTestDom({ width: 1200, height: 300 });
const grid = createGrid(root, {
rowKey: 'id',
locale: 'en-US',
rows: [
{ id: 1, account: 'Acme', amount: 1200, status: 'late', note: 'Call before noon', region: 'North' },
{ id: 2, account: 'Bolt', amount: -40, status: 'ok', note: 'Paid', region: 'North' },
],
columns: [
// A renderer of your own, handed the column's `props`, and a plain-text
// tooltip, which becomes the browser's own `title`.
{ field: 'account', title: 'Customer', cell: {
render: (p) => `${p.props.prefix}${p.value}`,
props: { prefix: '#' },
tooltip: (p) => `Account ${p.value}`,
} },
// Classes, static and per value, and an inline style computed per cell.
{ field: 'amount', type: 'number', format: { decimals: 1 }, cell: {
class: 'money',
classWhen: { 'is-negative': (p) => p.value < 0 },
css: (p) => (p.value < 0 ? { background: 'mistyrose' } : {}),
} },
// A decoration whose colour comes from a variant mapped from the value.
{ field: 'status', cell: { decoration: 'pill', variant: { map: { late: 'danger', ok: 'success' } } } },
// A template (escaped), aligned, top-aligned and wrapping.
{ field: 'note', cell: { template: '<b>{{value}}</b>', align: 'center', verticalAlign: 'top', wrap: true } },
// A fixed inline style, and a region cell that covers the row below it.
{ field: 'region', cell: {
style: { color: 'navy' },
spanRows: (p) => (p.index === 0 ? 2 : 1),
} },
// A cell that covers the column to its right when the amount is negative.
{ field: 'id', title: 'Ref', cell: { spanColumns: (p) => (p.data.amount < 0 ? 2 : 1) } },
{ field: 'spare' },
],
});
flushFrames();
const cells = [...root.querySelectorAll('.lat-cell')];
const cell = (key, col) => cells.find((c) => c.getAttribute('data-col') === col
&& c.closest('[data-key]')?.getAttribute('data-key') === String(key));
const has = (el, name) => String(el?.className || '').split(/\s+/).includes(name);
const account = cell(1, 'account');
const drawn = `${account.textContent} '${account.getAttribute('title')}'`;
const money = cell(2, 'amount');
const classes = `${has(money, 'money')}/${has(money, 'is-negative')}/${has(cell(1, 'amount'), 'is-negative')}`;
const shaded = money.style.getPropertyValue('background');
const pill = has(cell(1, 'status'), 'lat-dec--pill') && String(cell(1, 'status').className).includes('danger');
const note = cell(1, 'note');
const bold = note.childNodes.some((n) => n.tagName === 'B');
const laid = ['lat-cell--center', 'lat-cell--valign-top', 'lat-cell--wrap'].every((c) => has(note, c));
const navy = cell(1, 'region').style.getPropertyValue('color');
const tall = cell(1, 'region').style.height;
const wide = `${cell(1, 'id').style.width}/${cell(2, 'id').style.width}`;
const heading = [...root.querySelectorAll('.lat-header-cell')]
.find((h) => h.getAttribute('data-col') === 'account').textContent.trim();
grid.destroy();
return `${heading}: ${drawn}; classes ${classes}; css ${shaded}; pill ${pill}; template ${bold};`
+ ` laid ${laid}; style ${navy}; rows ${tall}; cols ${wide}; amount ${money.textContent}`;
A rich tooltip: content from render, live content from mount, and the unmount that tears it down
const { createTestDom, flushFrames, TestEvent } = await import('../packages/dom/src/renderer/testdom.js');
const { createGrid } = await import('../packages/dom/src/index.js');
const { root, document } = createTestDom({ width: 800, height: 300 });
const log = [];
const grid = createGrid(root, {
rowKey: 'id',
// No delay, so focusing a cell opens its tooltip at once.
tooltip: { delay: 0 },
rows: [{ id: 'R1', account: 'Acme', qty: 3 }],
columns: [
// `render` returns the content: here a { title, rows } spec the grid lays out.
{ field: 'account', cell: { tooltip: {
render: (p) => ({ title: `Account ${p.value}`, rows: [{ label: 'Qty', value: p.row.data.qty }] }),
} } },
// `mount` fills the element with live content; `unmount` is its teardown,
// called exactly once when the tooltip closes.
{ field: 'qty', cell: { tooltip: {
mount: (el, p) => { log.push(`mount ${p.key}`); el.textContent = `live ${p.value}`; },
unmount: (el) => { log.push(`unmount ${el.textContent}`); },
} } },
],
});
flushFrames();
const cell = (col) => [...root.querySelectorAll('.lat-cell')].find((c) => c.getAttribute('data-col') === col);
const tip = () => String(document.querySelector('[role="tooltip"]')?.textContent || '').trim();
// A keyboard user reaches the tooltip exactly as a pointer does: by focus.
cell('account').dispatchEvent(new TestEvent('focusin', { bubbles: true }));
const rendered = tip();
cell('qty').dispatchEvent(new TestEvent('focusin', { bubbles: true }));
const live = tip();
// Escape closes it without moving the pointer, and the host's teardown runs.
document.dispatchEvent(new TestEvent('keydown', { bubbles: true, key: 'Escape' }));
grid.destroy();
return `render ${rendered} | mount ${live} | ${log.join(', ')}`;
Headings and bands: a leaf and a band heading drawn by their own renderers, and a band that opens and closes with the columns that follow it
const { createTestDom, flushFrames, TestEvent } = await import('../packages/dom/src/renderer/testdom.js');
const { createGrid } = await import('../packages/dom/src/index.js');
const { root } = createTestDom({ width: 1200, height: 300 });
const grid = createGrid(root, {
rowKey: 'id',
rows: [{ id: 1, ref: 'A-1', jan: 1, feb: 2, mar: 3, q1: 6, cat: 4, dog: 5, note: 'hi', code: 'R' }],
columns: [
// A leaf heading drawn by a renderer of your own, handed `header.props`,
// with a class on the heading cell.
{ id: 'ref', field: 'ref', header: {
render: (el, p) => `${p.props.mark} ${p.title}`, props: { mark: '' }, class: 'is-key',
} },
// A band that opens and closes, starting closed. Its months show only while
// it is open and its total only while it is closed. The band heading takes
// the same `render`, `props` and `class` a leaf heading does.
{ id: 'q1', title: 'Q1', collapsible: true, openByDefault: false,
header: { render: (el, p) => `${p.title} (${p.props.unit})`, props: { unit: 'kg' }, class: 'quarter' },
columns: [
{ field: 'jan', showWhen: 'open' }, { field: 'feb', showWhen: 'open' },
{ field: 'mar', showWhen: 'open' },
{ field: 'q1', id: 'q1total', title: 'Q1 total', showWhen: 'closed' },
] },
// A plain band: its title over its columns, always shown.
{ id: 'pets', title: 'Pets', columns: [{ field: 'cat' }, { field: 'dog' }] },
// A heading that wraps: the newline in the title is honoured and the cell
// is stamped `data-wrap` so the theme folds it instead of clipping.
{ field: 'note', title: 'Long note\nwrapped', header: { wrap: true } },
// A heading that turns: for a narrow RACI-style column the title reads
// bottom-to-top, and the cell is stamped `data-vertical`.
{ field: 'code', title: 'RACI code', header: { orientation: 'vertical' }, layout: { width: 20 } },
],
});
flushFrames();
const leaves = () => [...new Set([...root.querySelectorAll('.lat-header-cell')]
.map((c) => c.getAttribute('data-col')))].join(',');
const bands = [...root.querySelectorAll('.lat-header-group-cell')]
.map((c) => `${String(c.textContent).trim()}${c.className.includes('quarter') ? '.quarter' : ''}`).join(' + ');
const ref = [...root.querySelectorAll('.lat-header-cell')].find((c) => c.getAttribute('data-col') === 'ref');
const heading = `${String(ref.textContent).trim()}${ref.className.includes('is-key') ? '.is-key' : ''}`;
const note = [...root.querySelectorAll('.lat-header-cell')].find((c) => c.getAttribute('data-col') === 'note');
const code = [...root.querySelectorAll('.lat-header-cell')].find((c) => c.getAttribute('data-col') === 'code');
const closed = leaves();
// Open the band with its own control.
root.querySelector('.lat-header-group-toggle').dispatchEvent(new TestEvent('click', { bubbles: true }));
flushFrames();
const opened = leaves();
const wrap = note && note.getAttribute('data-wrap') === '' ? 'on' : 'off';
const nl = note && String(note.textContent).includes('\n') ? 'nl' : 'no-nl';
const vertical = code && code.getAttribute('data-vertical') === '' ? 'on' : 'off';
grid.destroy();
return `${heading} | ${bands} | closed ${closed} | open ${opened} | wrap ${wrap} ${nl} | vertical ${vertical}`;
A column that flashes its own changed cells: the default, a colour of its own, a duration, and switched off
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const grid = createHeadlessGrid({
rowKey: 'id',
edit: { enabled: true },
rows: [{ id: 1, bid: 10, ask: 11, last: 10.5, volume: 100 }],
columns: [
// `true`: the default flash colour and duration.
{ field: 'bid', edit: { enabled: true }, cell: { flash: true } },
// A colour of its own (either spelling) and a duration in milliseconds;
// `0` leaves the highlight until it is cleared.
{ field: 'ask', edit: { enabled: true }, cell: { flash: { color: 'gold', duration: 0 } } },
{ field: 'last', edit: { enabled: true }, cell: { flash: { colour: 'tomato', duration: 5000 } } },
// Declared but switched off, so a host can toggle it without deleting it.
{ field: 'volume', edit: { enabled: true }, cell: { flash: { colour: 'tomato', enabled: false } } },
],
});
grid.edit.setCells(['bid', 'ask', 'last', 'volume'].map((colId) => ({ key: '1', colId, value: 12 })));
const colours = ['bid', 'ask', 'last', 'volume']
.map((id) => `${id} ${grid.highlight.colourFor('1', id) ?? 'none'}`).join(', ');
const held = grid.highlight.list().find((h) => h.colId === 'ask').duration;
const timed = grid.highlight.list().find((h) => h.colId === 'last').duration > 4000;
grid.destroy();
return `${colours}; ask held ${held}; last timed ${timed}`;
The starting sort, the controls a heading offers, and a column left out of quick search
const { createTestDom, flushFrames } = await import('../packages/dom/src/renderer/testdom.js');
const { createGrid } = await import('../packages/dom/src/index.js');
const { root } = createTestDom({ width: 1000, height: 300 });
const grid = createGrid(root, {
rowKey: 'id',
headerControls: 'always',
rows: [
{ id: 1, team: 'b', qty: 2, code: 'X-9', secret: 'kryptonite' },
{ id: 2, team: 'a', qty: 2, code: 'Y-1', secret: 'plain' },
{ id: 3, team: 'a', qty: 1, code: 'Z-5', secret: 'plain' },
],
columns: [
// The starting sort: qty descending first (`order: 0`), then team ascending.
{ field: 'team', sort: { direction: 'asc', order: 1 } },
{ field: 'qty', type: 'number', sort: { direction: 'desc', order: 0 } },
// No sort control and no filter control in this heading.
{ field: 'code', sort: { enabled: false }, filter: { enabled: false } },
// Left out of quick search, whatever it holds.
{ field: 'secret', quickFilter: false },
],
});
flushFrames();
const order = () => grid.rows.data().map((r) => r.id).join(',');
const sorted = order();
const heading = (id) => [...root.querySelectorAll('.lat-header-cell')].find((c) => c.getAttribute('data-col') === id);
const controls = (id) => ['.lat-header-sort', '.lat-header-filter']
.map((sel) => (heading(id).querySelector(sel) ? 'y' : 'n')).join('');
const affordances = `team ${controls('team')}, code ${controls('code')}`;
grid.filters.quick('kryptonite');
const hidden = order() || 'none';
grid.filters.quick('X-9');
const found = order();
grid.destroy();
return `sorted ${sorted}; controls ${affordances}; search secret ${hidden}, code ${found}`;
A rolling window in minutes, the value hooks, a data type’s own options, a target, and a column kept out of a workbook
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
await import('../packages/modules/list/index.js');
const t0 = Date.UTC(2026, 0, 1, 9, 0);
const grid = createHeadlessGrid({
rowKey: 'id',
edit: { enabled: true },
rows: [0, 1, 2, 5, 6].map((minute, i) => ({
id: `r${i}`, at: t0 + minute * 60_000, v: i + 1, size: i + 1, note: `batch ${i}`, tag: `lot-${i}`,
sizes: [i, i + 1], secret: `s${i}`,
})),
columns: [
{ field: 'at', type: 'number' },
{ field: 'v', type: 'number' },
// The same rolling sum over the last three minutes of `at`, and over the
// last three rows: `kind` picks the statistic, `window.minutes` the span.
{ id: 'last3min', shadow: { kind: 'rollingSum', of: 'v', orderBy: 'at', within: 'all',
window: { kind: 'time', minutes: 3 } } },
{ id: 'last3rows', shadow: { kind: 'rollingSum', of: 'v', orderBy: 'at', within: 'all',
window: { kind: 'count', span: 3 } } },
// What the reader sees, what a commit writes, and what quick search reads.
{ field: 'size', type: 'number', edit: true, value: {
format: (p) => `${p.value} kg`,
apply: (p) => { p.data.size = Number(p.value) * 10; return true; },
quickFilterText: (p) => String(p.data.tag),
} },
// Options the data type reads: a list of numbers, joined with ' / '.
{ field: 'sizes', type: 'list', typeOptions: { of: 'number', joiner: ' / ' } },
// A tolerance with a target, declared once on the column.
{ field: 'v', id: 'graded', spec: { lower: 1, upper: 5, target: 3 } },
// Both shown; only `note` is written to a workbook.
{ field: 'note' },
{ field: 'secret', export: { excel: false } },
],
});
const byTime = grid.rows.data().map((r) => grid.rows.value(r.id, 'last3min')).join(',');
const byCount = grid.rows.data().map((r) => grid.rows.value(r.id, 'last3rows')).join(',');
const shown = grid.rows.text('r1', 'size');
grid.edit.start('r1', 'size');
grid.edit.stop(false, { value: 3 });
const written = grid.rows.byKey('r1').data.size;
grid.filters.quick('lot-4');
const searched = grid.rows.data().map((r) => r.id).join(',');
grid.filters.quick('');
const items = grid.columns.get('sizes').itemType.name;
const target = grid.columns.get('graded').spec.target;
// A stored (`level: 0`) workbook keeps its text readable in the bytes.
const workbook = new TextDecoder().decode(await (await grid.export.excel({ level: 0 })).arrayBuffer());
const excel = `${workbook.includes('batch 0')}/${workbook.includes('s0')}`;
grid.destroy();
return `3min ${byTime}; 3rows ${byCount}; shown ${shown}; written ${written}; searched ${searched};`
+ ` items ${items}; target ${target}; note/secret in xlsx ${excel}`;
Grid methods
Top-level members. Everything else hangs off a namespace.
| Member | Returns | Description |
|---|---|---|
| getVersion() | string | The version this grid came from, e.g. '1.103.2'. Also on the module as getVersion(), for when you have no grid to hand. |
| get(key) | unknown | Read any configuration key. |
| set(key, value) | void | Write one key. Every key is live; nothing needs a rebuild. |
| setAll(values) | void | Write several in one pass. Emits one config:changed for the batch, not one per key. |
| config() | GridConfig | The 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?) | void | Pin 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) | () => void | Returns its own unsubscribe. '*' subscribes to everything; the handler still receives one event object, and reads event.type to tell which arrived. |
| rendererHost() | object | The host object a renderer reads: columns, rows, callbacks. Deliberately a plain bag rather than the grid itself, so a renderer cannot reach into core internals. You need this only when writing a renderer of your own. |
| once(event, handler) | () => void | |
| off(event, handler) | void | |
| emit(event, payload) | void | Emit on the grid's bus, for custom components. |
| attachRenderer(renderer) | void | Bind a renderer to a headless grid. |
| destroy() | void | Release listeners, workers and pooled buffers. |
| element | HTMLElement | null | The rendered root; null when headless. |
| ready | boolean | |
| destroyed | boolean |
The reference, by module
One page per package module, narrative and its generated reference tables together, exactly as the package itself ships them: the grid for every core config property, column spec and grid method, and the eleven optional modules beside it.
-
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.
-
The charts module
createChart, the chart types and their options, the axes, annotations and tooltips, and the geographic packs.
-
The data router
createDataRouter: one stream or one dataset split across many viewers, keyed-diff snapshots, join and rollup routes, and backpressure.
-
The KPI (stat-tile) view
createKPI: stat tiles over a dataset, the aggregations, baselines and delta, threshold bands, sparklines and the tile tree.
-
The alarms module
createAlarms: levelled alarm events from KPI tiles, grid cells and router routes, raise and clear, with a hold timer that swallows a flapping feed.
-
The board (kanban) view
createKanban: columns from any status property, drag or keyboard moves that write back, swimlanes, sprints, epics and the child grid.
-
The Gantt module
createGantt: the scheduling engine and the critical path, the four dependency links, milestones and summaries, and editable bars.
-
The calendar viewer
createCalendar: a month grid and a week day-grid of rows as cards, keyed by a date field, with navigation, overflow and the viewer contract.
-
The tabbed grid
createTabs: a tab strip where each tab holds a grid, a board, a KPI strip or a Gantt, and the tablist keyboard model.
-
The dashboard layout
createLayout: windows a reader moves, resizes and closes, the arrangement saved as plain JSON, locking, and the cancellable gestures.
-
The AI narrative and insights layer
The narrative module: summaries, insights and risk text over what the grid already holds. The grid.ai intent layer is documented on the platform API page.
-
The mock socket
A scripted feed with no backend behind it, for building and demonstrating a live screen.
-
Framework adapters and custom elements
A component for every view in React, Vue, Svelte and Angular, a custom element per view, declarative init, the grid-API compatibility wrapper and htmx.
-
The geometry module
type: 'geometry': WKB, WKT and GeoJSON read into one GeoJSON geometry per cell, a readable summary, WKT export, and four spatial filter operators.
-
Types and the glossary
Every type alias the declarations export, with its definition, and the value and result types the surfaces hand back. Every type name on every page links here.
-
The list module
type: 'list': many values in one cell, each usable on its own - chips, a token editor, "any item matches" filters and four list operators.
-
The deck.gl adapter
bindDeck: a deck.gl layer as one more viewer of a grid, and deck's view as the grid's viewport filter. deck.gl stays the page's; nothing of it is imported.
-
The Leaflet binding
bindLeaflet: Leaflet layers as one more viewer of a grid, the map's view as the grid's viewport filter, and a click as its selection. Leaflet stays the page's; nothing of it is imported.
-
Dashboard composition
createDashboard: a dashboard described as a JSON spec, a layout, grid, chart, KPI, map and html panels, a source per panel, links between them, built into the wired page, handed back by spec(), and proposed by the AI.
-
The Designer
createDesigner: a dashboard designer whose state is the dashboard spec extended with pages, in edit and view mode, with JSON state in and out.
-
Design-system presets
Four design-system stylesheets (Material 3, Bootstrap 5, Ant Design 5, Fluent 2), themeFromMui for a live MUI theme, and the theme token contract every preset restyles against.
-
The map view
createMapView: a 3D MapLibre and deck.gl map of a grid’s rows, as points, columns, polygons and flowing routes, kept in step with the grid’s own filter, search and selection.
-
The resource scheduler
createScheduler: resources as rows and events as bars on a shared time axis, with editing, a workload band, grouping and search, and builders from a Gantt plan, rows or a calendar.
-
The application shell
createShell: an application layout of nested rows and columns of panels, with splitters, collapse to a header strip, tabs, and content that keeps its state through hide, collapse and tab switches.
-
The form module
createForm: controls laid out in nested rows and columns with percentage widths, number formats and units, date, date-time and time pickers, a file drop area, disabled and read-only states, rules, and binding to a grid or the data router.
Grid topics, by namespace
The grid's own surface again, as a narrower guided walkthrough per namespace rather than the one module page above.
-
Rows and data sources
grid.rows and the transaction API, grid.columns, the source modes, and the live-update stats.
-
Filtering and sorting
grid.filters and the condition tree, grid.sort, facets, quick filter modes, and the filter grammar.
-
Editing and selection
grid.edit and validation, grid.selection and ranges, grid.history, column permissions, and your own menu items.
-
Renderers and formatting
The built-in renderers and editors, rich cell tooltips, grid.formatting and rules, in-cell charts, change highlighting, merged cells, and styling with CSS custom properties.
-
Statistics and units
grid.statistics and shadow columns, process capability, the statistic tile block, and unit and currency types of your own.
-
Export and saved views
grid.export to Excel and CSV, grid.import from a CSV or a pasted block, grid.state, grid.views, pagination and scroll.
-
Presence and comments
grid.presence, grid.comments, grid.redaction, and grid.diff.
-
Presentation and timeline
grid.presentation, grid.annotate, grid.timeline, grid.maximise, and grid.overlay.
-
Events
One bus: grid.on, grid.once, grid.off, and the wildcard subscription, with the full event table generated from the declarations.
-
The model layer
grid.ai and the intent layer, grid.diagnostics, formulas, grid.licence, and the module and utility exports.
-
Accessibility
The keyboard map, the ARIA the grid writes, and the conformance notes.
Type declarations
Every property, method and event on this page ships with a matching TypeScript declaration, so an editor gets full IntelliSense with no install and no configuration. Read or download the current release directly at /reference/latest/lattice-grid.d.ts.