Lattice Grid Buy a licence

api reference

Framework adapters and custom elements

A component for every view in React, Vue, Svelte and Angular, a custom element per view, declarative init, the dhtmlx wrapper and htmx.

API reference › Framework adapters and custom elements

All 13 pages Everything on one page → Developer guide →

Framework adapters

The web component carries the grid inside it, so use it or createGrid in a page, not both: two copies keep separate registries, and a renderer registered through one is invisible to the other. One optional bundle per framework. The framework and createGrid are passed in rather than imported, so the adapters add no dependency and carry no second copy of the grid.

// React
import React from 'react';
import { createGrid } from './dist/lattice-grid.esm.js';
import { createLatticeGrid } from './dist/modules/react.esm.js';

const LatticeGrid = createLatticeGrid({ React, createGrid });
<LatticeGrid columns={columns} rows={rows} rowKey="id" onCellChanged={fn} />

// Vue 3
import * as vue from 'vue';
import { createLatticeGrid } from './dist/modules/vue.esm.js';

const LatticeGrid = createLatticeGrid({ vue, createGrid });
<LatticeGrid :columns="columns" :rows="rows" row-key="id" @cell-changed="fn" />

// Svelte, an action, so no framework runtime is needed
import { createLatticeAction } from './dist/modules/svelte.esm.js';

const lattice = createLatticeAction({ createGrid });
<div use:lattice={{ columns, rows, rowKey: 'id' }} on:cell-changed={fn}></div>
Entry pointFactoryNeeds
modules/reactcreateLatticeGrid({ React, createGrid })Returns a component. Forwards a ref exposing .grid. Since 1.63 the same entry point also builds a component for every other viewer and the data router.
@toclocoinc/lattice-grid/angular<lattice-grid [config]="…">A compiled entry point of this package, not a bundle: standalone components compiled ahead of time, one per viewer, plus the data router as a service. No second install. See Angular. modules/angular, which needs the JIT compiler, is deprecated.
modules/vuecreateLatticeGrid({ vue, createGrid })Returns a Vue 3 component definition. Since 1.66 the same entry point also builds a component for every other viewer and the data router.
modules/sveltecreateLatticeAction({ createGrid })Returns a use: action. Since 1.66 the package also ships a Svelte 5 component per viewer as .svelte source at @toclocoinc/lattice-grid/svelte/*.svelte, and this entry point carries the helpers they are built from.
PropTypeDoes
any config keyas documented belowApplied through grid.setAll() when the reference changes. Never rebuilds the grid.
sortSortEntry[]grid.sort.set()
filtersFilterSetgrid.filters.set()
quickFilterstring | { text, mode }grid.filters.quick()
selectedKeysstring[]grid.selection.set()
on<Event>(e: GridEvent) => voidOne per event. cell:changedonCellChanged in React; @cell-changed in Vue; on:cell-changed in Svelte.
className, style, idstring | objectReact only. Applied to the host element, not the grid.

React: every viewer, not only the grid

Until 1.63 the React adapter wrapped createGrid and nothing else. Every other shipped viewer - the KPI panel, a chart, the board, the Gantt, the layout, the tab strip - and the data router had no React surface, so a React application wrote its own useEffect per viewer. It does not have to any more: there is one component per viewer, and each keeps the contract the grid component already kept.

import React from 'react';
import ReactDOM from 'react-dom';
import { createGrid } from '@toclocoinc/lattice-grid';
import { createKPI } from '@toclocoinc/lattice-grid/modules/kpi';
import { createChart } from '@toclocoinc/lattice-grid/modules/charts';
import { createDataRouter } from '@toclocoinc/lattice-grid/modules/data-router';
import { createLatticeReact } from '@toclocoinc/lattice-grid/modules/react';

// Built once, at module scope. Building components inside a component hands React a
// new type on every render, and a new type is a different element: the subtree would
// unmount and remount, destroying and rebuilding the grid on every keystroke.
const L = createLatticeReact({
  React, ReactDOM, createGrid, createKPI, createChart, createDataRouter,
});

function Dashboard({ rows }) {
  const router = L.useLatticeRouter(ROUTER_CONFIG);
  return (
    <L.LatticeRouterProvider router={router}>
      <L.LatticeGridProvider>
        <L.LatticeGrid name="quakes" route="all" {...GRID_CONFIG} rows={rows} />
        <L.LatticeKPI gridName="quakes" tiles={TILES} columns={5} />
        <L.LatticeChart gridName="quakes" type="bar" x="region" y="count" />
      </L.LatticeGridProvider>
    </L.LatticeRouterProvider>
  );
}
ExportSignatureWhat it is
createLatticeGrid({ React, createGrid })The grid component. Extended in 1.63 with rowUpdates, predicates, onGridReady, onGridDestroy, name and route.
createLatticeKPI({ React, createKPI })The KPI panel. Grid-bound through context by default; pass rows instead for a panel with no grid.
createLatticeChart({ React, createChart })A chart. Requires a grid, so nothing is mounted until one exists; a changed spec key goes to chart.update() and the chart redraws rather than being rebuilt.
createLatticeKanban({ React, createKanban })The board. rows, quickFilter, sprint, epic, loading and error are live props.
createLatticeGantt({ React, createGantt })The Gantt. tasks and dependencies are live props.
createLatticeLayout({ React, createLayout })The layout. Windows are driven through the ref; its events arrive as onLayoutChanged, onWindowMoved and the rest.
createLatticeTabs({ React, ReactDOM, createTabs, createGrid? })The tab strip, with React-rendered tab content: a tab's content is a React element (or a function returning one) rendered into the strip's own panel through createPortal, so a tab's grid is a real <LatticeGrid> with props, a ref and the surrounding context. A tab with no content is left to the module, so the two kinds mix on one strip.
createLatticeGridContext({ React })Returns { LatticeGridProvider, useLatticeGrid }. A grid-bound viewer needs the grid instance, which appears after the first render; a ref cannot help because writing to a ref re-renders nobody. Several grids may publish under one provider - name on the grid, gridName on the viewer.
createLatticeRouter({ React, createDataRouter })Returns { useLatticeRouter, LatticeRouterProvider, useRouter }. The hook creates the router in an effect and destroys it in that effect's cleanup, so it returns null on the first render; the config is read once, because rebuilding would drop every attached grid and every row held.
createLatticeViewer({ React, viewer, mount, … })The generic behind all of the above, and the escape hatch for a viewer with no named factory yet.
createLatticeReact({ React, ReactDOM?, …factories })Every binding from one call: pass the factories the application uses and it builds those components, leaving the rest undefined. Nothing is imported, so an application that never uses the board never loads the board.
createViewerController({ viewer, mount, element, props })The framework-free viewer lifecycle - mount once, push changed props into the live instance, destroy - shared by every adapter.
VIEWER_EVENTSRecord<string, readonly string[]>Every event each non-grid viewer emits, keyed by viewer name.
VIEWER_APPLYRecord<string, Record<string, Function>>Which props each viewer can take live, and the instance call each becomes. Anything not listed is mount-time configuration.
viewerHandlerName(event) => stringA viewer event as its React prop: card:move becomes onCardMove.
DEFAULT_GRID_NAMEstringThe name a grid publishes itself under when you do not choose one: default.
const A = await import('../packages/modules/react/index.js');

// A stand-in for React and react-dom. The adapter never imports either, so a
// factory only needs the handful of names it destructures - which is exactly
// why this example runs in Node with no framework installed.
const React = { createElement: () => ({}), createContext: () => ({}), forwardRef: (f) => f };
const ReactDOM = { createPortal: () => ({}) };
const stub = () => ({ on: () => () => {}, destroy() {} });

// One call builds whichever components the factories you pass support.
const L = A.createLatticeReact({
  React, ReactDOM, createGrid: stub, createKPI: stub, createChart: stub, createDataRouter: stub,
});

// …or one factory at a time.
const components = [
  A.createLatticeGrid({ React, createGrid: stub }),
  A.createLatticeKPI({ React, createKPI: stub }),
  A.createLatticeChart({ React, createChart: stub }),
  A.createLatticeKanban({ React, createKanban: stub }),
  A.createLatticeGantt({ React, createGantt: stub }),
  A.createLatticeLayout({ React, createLayout: stub }),
  A.createLatticeTabs({ React, ReactDOM, createTabs: stub }),
  A.createLatticeViewer({ React, viewer: 'kpi', mount: stub }),
].filter((c) => typeof c === 'function');

// The provider/hook pair and the router hook are built the same way.
const { LatticeGridProvider, useLatticeGrid } = A.createLatticeGridContext({ React });
const { useLatticeRouter } = A.createLatticeRouter({ React, createDataRouter: stub });

// And the lifecycle underneath, driven with no framework at all: mount once,
// push a changed live prop into the instance that already exists, destroy.
const seen = [];
const controller = A.createViewerController({
  viewer: 'kpi',
  element: {},
  props: { rows: [{ id: 1 }] },
  mount: () => ({ setRows: (r) => seen.push(r.length), on: () => () => {}, destroy() {} }),
});
controller.update({ rows: [{ id: 1 }, { id: 2 }] });
controller.destroy();

return `${components.length} components, ${A.viewerHandlerName('card:move')}, `
  + `${A.DEFAULT_GRID_NAME}, rows ${seen.join('/')}, `
  + `events ${A.VIEWER_EVENTS.kpi.length}/${Object.keys(A.VIEWER_APPLY).length}`;

Live props versus mount-time props. The grid takes any changed configuration key through one call (grid.setAll); no other viewer does. So each viewer declares which props it can take while it is running - the table above, and VIEWER_APPLY at runtime - and everything else is mount-time configuration. A mount-time prop that changes is not silently ignored and not silently remounted (that would throw away scroll position, selection and expansion): it is named once in a warning that tells you to give the component a key that changes when the rebuild is wanted, or to drive the instance through the ref.

Two props rebuild a viewer rather than update it: the grid it is bound to, and anything it cannot exist without. A chart holding a destroyed grid is not stale, it is invalid, so a new grid tears the old chart down and builds a new one against it - in that order, never the reverse.

A live feed: rowUpdates and predicates. rowUpdates is a keyed diff handed straight to grid.rows.apply(), applied when the object's identity changes - a feed produces a new change object per batch, so identity is the right trigger and re-applying the same object would re-land rows the grid already has. predicates is { name: fn } mapped to grid.filters.where(name, fn), diffed by name, with a name that has gone removed. Those compose with whatever filter the reader set in the tool panel; the filters prop cannot, because it maps to filters.set and replaces the whole condition tree.

StrictMode creates one instance. React 18's StrictMode deliberately mounts, unmounts and mounts again in development. Every component here builds its instance in an effect with an empty dependency list and destroys it in that effect's cleanup, so the first is destroyed before the second is built and exactly one survives. There is no module-level "already mounted" flag, because that would defeat a genuine remount.

Props are diffed with Object.is, and that is a promise about identity. An inline columns={[{ field: 'a' }]} is a new array on every render, so it counts as changed every render and reconfigures the grid every render. Hoist it to module scope or wrap it in useMemo. This is not a defect to work around: a deep compare of a million-row array on every render would cost more than the reload it prevents.

Sharing one rows array between grids is safe. Since 1.63 the grid copies the array it is handed on ingest, so two grids given the same rows={EMPTY} default no longer contaminate each other. The row objects are still shared, as they always were - mutate one and both grids see it.

React 18 and 19, no SSR. Nothing in the adapter uses an API added in 19 or removed in 19 (forwardRef is still supported there). Every component owns a real DOM element, so there is no server rendering and no React Server Component support: render them on the client.

One build warning is gone. The version resolver carried a Node-only fallback whose import('node:' + 'module') Vite could not analyse statically, so every Vite build of every application printed "The above dynamic import cannot be analyzed by Vite" about a line that could never run. The build now deletes that branch from every emitted artefact.

Vue 3: every viewer, not only the grid

Until 1.66 the Vue adapter wrapped createGrid and nothing else. Every other shipped viewer - the KPI panel, a chart, the board, the Gantt, the layout, the tab strip - and the data router had no Vue surface, so a Vue application wrote its own onMounted/onBeforeUnmount pair per viewer. It does not have to any more: there is one component per viewer, and each keeps the contract the grid component already kept.

Every component is built with defineComponent and a render function, so the adapter needs no template compiler in your build and runs on vue.runtime.* - the compiler-free build a Vite application ships. The floor is Vue 3.3.

// lattice.js - built once, at module scope.
import * as vue from 'vue';
import { createGrid } from '@toclocoinc/lattice-grid';
import { createKPI } from '@toclocoinc/lattice-grid/modules/kpi';
import { createChart } from '@toclocoinc/lattice-grid/modules/charts';
import { createDataRouter } from '@toclocoinc/lattice-grid/modules/data-router';
import { createLatticeVue } from '@toclocoinc/lattice-grid/modules/vue';

export const L = createLatticeVue({ vue, createGrid, createKPI, createChart, createDataRouter });
// Dashboard.vue
<script setup>
import { L } from './lattice.js';

L.provideLatticeGrids();
L.provideLatticeRouter(ROUTER_CONFIG);

const rows = shallowRef([]);
</script>

<template>
  <component :is="L.LatticeGrid" name="quakes" route="all" v-bind="GRID_CONFIG" :rows="rows" />
  <component :is="L.LatticeKPI" grid-name="quakes" :tiles="TILES" :columns="5" />
  <component :is="L.LatticeChart" grid-name="quakes" type="bar" x="region" y="count" />
</template>
ExportSignatureWhat it is
createLatticeGrid({ vue, createGrid })The grid component. Extended in 1.66 with rowUpdates, predicates, name, route and the grid-ready / grid-destroyed emits. Unchanged for anyone already on it.
createLatticeKPI({ vue, createKPI })The KPI panel. Grid-bound through the provided registry by default; bind :rows instead for a panel with no grid.
createLatticeChart({ vue, createChart })A chart. Requires a grid, so nothing is mounted until one exists; a changed spec key goes to chart.update() and the chart redraws rather than being rebuilt.
createLatticeKanban({ vue, createKanban })The board. rows, quickFilter, sprint, epic, loading and error are live props.
createLatticeGantt({ vue, createGantt })The Gantt. tasks and dependencies are live props.
createLatticeLayout({ vue, createLayout })The layout. Windows are driven through the exposed instance; its events arrive as @layout-changed, @window-moved and the rest.
createLatticeTabs({ vue, createTabs, createGrid? })The tab strip, with Vue-rendered tab content: a tab's content is a named slot - <template #overview> for a tab with id: 'overview', or <template #tab-overview> - rendered into the strip's own panel through a <Teleport>, so a tab's grid is a real LatticeGrid with props, a ref and the surrounding provides. A tab with no slot is left to the module, so the two kinds mix on one strip.
createLatticeGridContext({ vue })Returns { LatticeGridProvider, provideLatticeGrids, useLatticeGrid }. A grid-bound viewer needs the grid instance, which appears after the first render; a template ref cannot help, because assigning to one re-renders nobody. Call provideLatticeGrids() in a parent's setup, or wrap the subtree in <LatticeGridProvider>. Several grids may publish under one registry - name on the grid, grid-name on the viewer.
createLatticeRouter({ vue, createDataRouter })Returns { provideLatticeRouter, useLatticeRouter, LatticeRouterProvider }. provideLatticeRouter(config) is called in a component's setup: the router is built by the first grid under it that names a route, and destroyed when that component's scope is. The config is read once, because rebuilding would drop every attached grid and every row held.
createLatticeViewer({ vue, viewer, mount, … })The generic behind all of the above, and the escape hatch for a viewer with no named factory yet.
createLatticeVue({ vue, …factories })Every binding from one call: pass the factories the application uses and it builds those components, leaving the rest undefined. Nothing is imported, so an application that never uses the board never loads the board.
createViewerController({ viewer, mount, element, props })The framework-free viewer lifecycle - mount once, push changed props into the live instance, destroy - shared by every adapter.
VIEWER_EVENTSRecord<string, readonly string[]>Every event each non-grid viewer emits, keyed by viewer name.
VIEWER_APPLYRecord<string, Record<string, Function>>Which props each viewer can take live, and the instance call each becomes. Anything not listed is mount-time configuration.
viewerHandlerName(event) => stringA viewer event as the handler key the shared controller dispatches on: card:move becomes onCardMove. A Vue template binds the dashed form instead - @card-move.
DEFAULT_GRID_NAMEstringThe name a grid publishes itself under when you do not choose one: default.
const A = await import('../packages/modules/vue/index.js');

// A stand-in for Vue. The adapter never imports it, so a factory only needs
// the handful of names it destructures - which is exactly why this example
// runs in Node with no framework installed.
const vue = {
  defineComponent: (d) => d, h: () => ({}), ref: (v) => ({ value: v }),
  shallowRef: (v) => ({ value: v }), computed: (f) => ({ get value() { return f(); } }),
  watch: () => () => {}, onMounted: () => {}, onBeforeUnmount: () => {},
  onScopeDispose: () => {}, provide: () => {}, inject: () => null,
  Teleport: Symbol('Teleport'), Fragment: Symbol('Fragment'),
};
const stub = () => ({ on: () => () => {}, setRows: () => {}, destroy() {} });

// One call builds whichever components the factories you pass support.
const L = A.createLatticeVue({
  vue, createGrid: stub, createKPI: stub, createChart: stub, createDataRouter: stub,
});

// …or one factory at a time. Each returns a Vue component definition.
const components = [
  A.createLatticeGrid({ vue, createGrid: stub }),
  A.createLatticeKPI({ vue, createKPI: stub }),
  A.createLatticeChart({ vue, createChart: stub }),
  A.createLatticeKanban({ vue, createKanban: stub }),
  A.createLatticeGantt({ vue, createGantt: stub }),
  A.createLatticeLayout({ vue, createLayout: stub }),
  A.createLatticeTabs({ vue, createTabs: stub }),
].filter((c) => typeof c.setup === 'function');

// The generic behind them, the registry pair and the router composables.
A.createLatticeViewer({ vue, viewer: 'kpi', mount: stub });
const { LatticeGridProvider, useLatticeGrid } = A.createLatticeGridContext({ vue });
const { provideLatticeRouter } = A.createLatticeRouter({ vue, createDataRouter: stub });

// Events are bound under dashed names, because a colon is directive syntax.
const gridEvent = A.dashedName(A.EVENT_NAMES.find((n) => n === 'cell:changed'));
const viewerEvent = A.dashedName(A.VIEWER_EVENTS.kanban.find((n) => n === 'card:move'));
A.viewerHandlerName('card:move');

// And the lifecycle underneath, driven with no framework at all: mount once,
// push a changed live prop into the instance that already exists, destroy.
const seen = [];
const controller = A.createViewerController({
  viewer: 'kpi',
  element: {},
  props: { rows: [{ id: 1 }] },
  mount: () => ({ setRows: (r) => seen.push(r.length), on: () => () => {}, destroy() {} }),
});
controller.update({ rows: [{ id: 1 }, { id: 2 }] });
controller.destroy();
void [L.LatticeGrid, LatticeGridProvider, useLatticeGrid, provideLatticeRouter, A.VIEWER_APPLY];

return `${components.length} components, ${gridEvent}, ${viewerEvent}, `
  + `${A.DEFAULT_GRID_NAME}, rows ${seen.join('/')}`;

The grid paints outside Vue's reactivity, and that is deliberate. The adapter creates the grid in onMounted and pushes changed props into it; the grid then renders on its own schedule, not Vue's. So nextTick() tells you Vue has patched the DOM, not that the grid has painted. Measure, screenshot or scroll in a grid event - @render-done, @rows-changed, @size-changed - rather than after nextTick or in onMounted.

Hold big row arrays in a shallowRef, never a ref. ref([...100k rows]) makes every row object a reactive proxy: Vue walks the array on assignment and every property read on every row goes through a proxy trap from then on. The grid neither needs nor wants that - it re-reads the array itself when the reference changes. Use shallowRef(rows) and assign a new array when the data changes (rows.value = next), or markRaw the objects. The same goes for the grid instance you keep off a template ref: a ref would proxy the whole Grid, and the proxy is not === the object createGrid returned.

Props are diffed with Object.is, and that is a promise about identity. An inline :columns="[{ field: 'a' }]" is a new array on every render, so it counts as changed every render and reconfigures the grid every render. Hoist it to module scope or put it behind a computed. This is not a defect to work around: a deep compare of a million-row array on every render would cost more than the reload it prevents. For the same reason nothing here uses a { deep: true } watcher - one over a config carrying rows would walk every row object on every tick.

Live props versus mount-time props. The grid takes any changed configuration key through one call (grid.setAll); no other viewer does. So each viewer declares which props it can take while it is running (VIEWER_APPLY), and everything else is mount-time configuration. A mount-time prop that changes is not silently ignored and not silently remounted (that would throw away scroll position, selection and expansion): it is named once in a warning that tells you to give the component a :key that changes when the rebuild is wanted, or to drive the instance through the ref. The tab strip's whole tabs array is mount-time for the same reason - the module has no way to repaint an existing tab's label or badge.

Events are dashed; the lifecycle pair is grid-ready and grid-destroyed. cell:changed is emitted as @cell-changed, because a colon in a Vue binding is directive syntax. Every grid event is declared in emits, so none of them falls through onto the host element. The two lifecycle emits are named grid-* because ready and destroy are grid event names already re-emitted here, and the React and Angular adapters name their pair the same.

Reaching the instance, and class/style/id. A template ref on the grid exposes grid() (gridRef.value.grid()); every other component exposes instance(). Both are null before mount. class, style and id are applied to the component's own host element rather than passed on as configuration; every other binding is the viewer's configuration, with a known kebab-cased key mapped to its camelCase form (:row-key reaches rowKey), as the web component does.

Cleanup, and no SSR. Every component destroys its instance, stops its watchers and unsubscribes its listeners in onBeforeUnmount, so a v-if toggled twice leaves exactly one instance alive and an unmounted page leaves nothing on a timer or an event bus. The router is destroyed with the component whose setup provided it. Every component owns a real DOM element and builds nothing until onMounted, so a server render emits the empty host and nothing else: there is no SSR or Nuxt server-side support, and Vue 2 is not supported.

Svelte 5: components you compile, not a bundle you load

Until 1.66 the Svelte adapter was a single action, use:lattice, over a grid. An action cannot do what the other adapters do - it takes a node and a value, so it has no children, no snippets and no context of its own, and a tab strip whose panels hold Svelte-rendered content is simply out of its reach. So 1.66 adds components, one per viewer, and they ship the way the Svelte ecosystem ships components: as .svelte source, compiled by your own toolchain. svelte is an optional peer dependency; the package still installs nothing. Svelte 5 only - the components are written in runes and snippets, neither of which exists in Svelte 4. The action is unchanged and still supported.

<!-- Dashboard.svelte -->
<script>
  import Provider from '@toclocoinc/lattice-grid/svelte/GridProvider.svelte';
  import Grid from '@toclocoinc/lattice-grid/svelte/Grid.svelte';
  import KPI from '@toclocoinc/lattice-grid/svelte/KPI.svelte';
  import Chart from '@toclocoinc/lattice-grid/svelte/Chart.svelte';
  import Tabs from '@toclocoinc/lattice-grid/svelte/Tabs.svelte';

  let rows = $state.raw([]);
  let board;
</script>

<Provider>
  <Grid bind:this={board} name="quakes" class="grid" {...GRID_CONFIG} {rows}
        onCellChanged={(e) => save(e)} />
  <KPI gridName="quakes" tiles={TILES} columns={5} />
  <Chart gridName="quakes" type="bar" x="region" y="count" />

  <Tabs tabs={TABS} active={open} onTabChanged={(e) => (open = e.id)}>
    {#snippet all()}<Grid name="all" {...GRID_CONFIG} {rows} />{/snippet}
  </Tabs>
</Provider>
ComponentImportWhat it is
Grid.svelte@toclocoinc/lattice-grid/svelte/Grid.svelteOne grid for the component's lifetime. Every configuration key is a prop, diffed with Object.is and pushed through setAll; rowUpdates is a keyed diff straight to rows.apply(); predicates are named filters registered through filters.where, so they compose with the reader's own. bind:this plus grid() hands you the live Grid.
GridProvider.svelte…/svelte/GridProvider.sveltePuts a grid registry in context. A grid publishes itself under its name; a viewer finds it by gridName. Also available as createGridRegistry() for a host that would rather call setContext itself.
KPI, Chart, Kanban, Gantt, Layout…/svelte/KPI.svelte, …One viewer each, built once and updated in place. A grid-bound viewer waits for its grid rather than mounting empty, and a different grid rebuilds it. bind:this plus instance() hands you the live viewer.
Tabs.svelte…/svelte/Tabs.svelteThe tab strip, with Svelte-rendered panels: a tab's content is a {#snippet} named after the tab's id, so a tab's grid is a real component with props, context and a reference. A tab with no snippet is left to the module, so configuration-driven tabs still work and the two kinds mix.
Router.svelte…/svelte/Router.svelteA Data Router in context, built by the first grid that names a route and destroyed with the component.

The components are deliberately thin - a template, the $props() plumbing, a bind:this getter and the context wiring - because they ship as source you read. Every rule they follow lives in modules/svelte, which ships minified, and each is exported so you can build a component of your own on exactly the same footing:

ExportSignatureWhat it does
bindGrid({ createGrid, element, props, registry, router })Owns one grid: builds it once, diffs props into it through setAll, registers predicates before it publishes, applies each new rowUpdates object through rows.apply(), attaches it to the router, and on destroy detaches and withdraws before destroying.
bindViewer({ viewer, factory, element, registry })Owns one KPI panel, chart, board, plan or layout. sync(props) builds it once everything it needs has arrived, pushes changed props into the live instance, and rebuilds it only when it is bound to a different grid.
bindTabs({ createTabs, element, createGrid, onPanels })Owns one tab strip and claims a panel for each tab the host rendered content for. place(id, holder) moves that tab's display: contents holder into the panel - Svelte has no portal, and moving one container is what keeps the subtree a genuine child of the component that declared it.
createGridRegistry()The registry GridProvider.svelte puts in context: publish, get, names and a subscribe a component turns into its own reactivity.
createRouterHandle({ createDataRouter, config })A Data Router built on the first grid that asks to be routed and destroyed with the component that provided it. config may be a function, read at build time.
adaptProps(kind, props)Splits a component's props into the instance's configuration and the adapter's own, dropping class/style/id, resolving row-key-style aliases, and normalising a callback prop written either way.
pickCallback(props, name)A callback prop under either spelling: onGridReady or ongridready.
viewerMount(viewer, factory)The call each module's factory actually takes - three want (element, config), the chart wants a container in its options and the Gantt an element.
snippetTabIds(tabs, props)Which tabs the host declared a snippet for, in declaration order.
VIEWER_BINDINGRecord<string, {…}>How each viewer is wired: its label, the config keys without which nothing is built, and the key its bound grid is passed under.
GRID_REGISTRY_KEY
ROUTER_KEY
symbolThe context keys the registry and the router handle are published under. Registered symbols (Symbol.for), so two components imported from different files meet the same registry.
const S = await import('../packages/modules/svelte/index.js');

// A component hands its props straight to the helpers. They keep the
// adapter's own props out of the instance and accept a callback under
// either Svelte 5 spelling.
const split = S.adaptProps('grid', {
  rowKey: 'id', 'class': 'grid', name: 'main', oncellchanged: () => {},
});
const ready = S.pickCallback({ ongridready: () => 'called' }, 'onGridReady');

// The registry a <GridProvider> puts in context, and the grid binding a
// <Grid> drives. Both are plain functions: no Svelte here at all.
const registry = S.createGridRegistry();
const applied = [];
const fakeGrid = () => ({
  setAll: (c) => applied.push(c.theme), destroy() {},
  on: () => () => {}, rows: { apply: () => {} },
  filters: { where: () => {} },
});
const bound = S.bindGrid({
  createGrid: fakeGrid, element: {}, registry, props: { name: 'main', theme: 'light' },
});
bound.update({ name: 'main', theme: 'dark' });

// A viewer waits for its grid, then is built once. VIEWER_BINDING says
// which key its grid arrives under and what it cannot be built without.
const built = [];
const stub = () => ({ on: () => () => {}, setRows: () => {}, update: () => {}, destroy() {} });
for (const viewer of Object.keys(S.VIEWER_BINDING)) {
  const panel = S.bindViewer({
    viewer, element: {}, registry,
    factory: (...args) => { built.push(viewer); return stub(...args); },
  });
  panel.sync({ gridName: 'main' });
  panel.destroy();
}

// A tab strip claims a panel for each tab the host wrote a snippet for.
const tabs = [{ id: 'one' }, { id: 'two' }];
const props = { one: () => {} };
const strip = S.bindTabs({
  element: {},
  createTabs: (el, config) => ({ config, on: () => () => {}, destroy() {} }),
});
strip.sync({ tabs, contentIds: S.snippetTabIds(tabs, props) });
const claimed = strip.instance.config.tabs.filter((t) => t.view).map((t) => t.id);

// One router, built by the first grid that asks and destroyed with the
// component that provided it.
let routers = 0;
const handle = S.createRouterHandle({
  createDataRouter: () => { routers += 1; return { attach: () => {}, detach: () => {}, destroy: () => {} }; },
});
handle.attach({}, 'all'); handle.attach({}, 'all'); handle.destroy();

// And the call each module's factory actually takes.
S.viewerMount('chart', (opts) => opts)({}, { type: 'bar' });
void [S.GRID_REGISTRY_KEY, S.ROUTER_KEY, ready()];

return `config ${Object.keys(split.config).join(',')}; own ${split.own.name}; `
  + `registry ${registry.names().join(',')}; grid ${applied.join(',')}; `
  + `tabs ${claimed.join(',')}; viewers ${built.join(',')}; router ${routers}`;

Events are callback props. Svelte 5 removed component events, so cell:changed arrives as onCellChanged - the same name React uses. The all-lowercase oncellchanged is accepted too, for anyone who prefers Svelte's DOM attribute style. The grid's own dashed names (cell-changed) still apply to the use:lattice action, which dispatches CustomEvents on the node.

Reactivity, and rows. The grid paints outside Svelte's scheduler: it owns its element and its own render loop, and nothing inside it is reactive state. Hold large row arrays in $state.raw (or in an ordinary let), never in deep $state - a proxy over a million rows costs far more than the reload it would save, and the components diff by identity, so a new array is what signals a change. An inline literal prop (columns={[…]}) is a new array on every render and reconfigures the viewer every time; hoist it.

Cleanup, and no SSR. Every component builds its instance in onMount and destroys it in onDestroy, unsubscribing its listeners, withdrawing its grid from the registry and detaching from the router before the grid goes. Nothing is built during server rendering - SvelteKit SSR emits the empty host element and nothing else - so these components are browser-only by construction.

Angular: a compiled entry point, @toclocoinc/lattice-grid/angular

Angular's components are not objects a library can assemble at run time in a production build. modules/angular did assemble them that way, which needs Angular's JIT compiler in the page - present on a development server, absent from every AOT build. So Angular gets an entry point of its own: TypeScript components compiled by @angular/compiler-cli into a partial-Ivy library, which your build's Angular Linker turns into definitions exactly as it does for any other Angular library you install. No compiler in your bundle, and one standalone component per viewer.

It ships inside the grid package, at the subpath @toclocoinc/lattice-grid/angular: one install, one version number, one licence. @angular/core and @angular/common are optional peer dependencies of the package, so a project that is not an Angular one installs nothing extra and sees no warning.

npm install @toclocoinc/lattice-grid
import { bootstrapApplication } from '@angular/platform-browser';
import { createGrid } from '@toclocoinc/lattice-grid';
import { createKPI } from '@toclocoinc/lattice-grid/modules/kpi';
import { createChart } from '@toclocoinc/lattice-grid/modules/charts';
import {
  LatticeGridComponent, LatticeKpiComponent, LatticeChartComponent, provideLattice,
} from '@toclocoinc/lattice-grid/angular';

@Component({
  selector: 'app-dashboard',
  imports: [LatticeGridComponent, LatticeKpiComponent, LatticeChartComponent],
  template: `
    <lattice-grid #grid name="quakes" [config]="config" [quickFilter]="search()"
                  (cell-changed)="save($event)" />
    <lattice-kpi  gridName="quakes" [config]="{ tiles }" />
    <lattice-chart gridName="quakes" [config]="{ type: 'bar', x: 'region', y: 'count' }" />
  `,
})
export class Dashboard {
  // The live grid, the same object createGrid returns.
  grid = viewChild<LatticeGridComponent>('grid');
}

// Each factory is injected, not imported by the library: an application that
// shows a grid downloads the grid, and never the board or the eighteen charts.
bootstrapApplication(Dashboard, {
  providers: [provideLattice({ createGrid, createKPI, createChart })],
});
ExportElementWhat it is
LatticeGridComponent<lattice-grid>The grid. [config] is every configuration key; [sort], [filters], [quickFilter] and [selectedKeys] are applied through the matching API; [rowUpdates] and [predicates] drive a live feed. Every grid event is an output under its kebab-case name, and (grid-ready) hands you the instance. Give the element a height.
LatticeGridDirective[latticeGrid]The same component on an element your template already owns: <div [latticeGrid]="config" class="tall"></div>. Same inputs, outputs and grid reference.
LatticeKpiComponent<lattice-kpi>The KPI panel. Grid-bound by default - [gridName] picks which published grid - or give it [rows] for a panel with no grid.
LatticeChartComponent<lattice-chart>A chart. Requires a grid, so nothing is built until one exists; a changed spec key goes to chart.update() and the chart redraws rather than being rebuilt. (click), (hover) and (leave) on this element are the chart's events, carrying the datum under the pointer.
LatticeKanbanComponent<lattice-kanban>The board. [rows], [quickFilter], [sprint], [epic], [loading] and [error] are live inputs.
LatticeGanttComponent<lattice-gantt>The plan. [tasks] and [dependencies] are live inputs. It takes an explicit [grid] but never adopts a published one.
LatticeLayoutComponent<lattice-layout>The dashboard layout. Windows are driven through the instance; its events arrive as (layout-changed), (window-moved) and the rest.
LatticeTabsComponent
LatticeTabDirective
<lattice-tabs>
ng-template[latticeTab]
The tab strip, with Angular-rendered tab content: a tab's content is an <ng-template latticeTab="id"> in your own template, rendered into the strip's panel through this component's ViewContainerRef - so a tab's grid is a real <lattice-grid> with inputs, a reference and your injectors above it. A tab with no template is left to the module, so configuration-driven grid tabs still work and the two kinds mix on one strip.
LatticeGridRegistryinject(LatticeGridRegistry)Where <lattice-grid name="…"> publishes itself and grid-bound viewers find it, as a signal per name - a panel declared before its grid exists mounts itself the moment the grid arrives. providedIn: 'root'; put it in a component's own providers to scope a registry to that subtree.
provideLatticeprovideLattice({ …factories })The factories the components build through. Give it the ones your application uses; a component whose factory is missing names the import and the provider call that fixes it.
provideLatticeRouter
LatticeRouter
providers: [provideLatticeRouter(cfg)]The data router as a service. Put it in a component's providers and it is created when the first <lattice-grid route="…"> under it attaches, and destroyed with that component - its configuration read once, because rebuilding would drop every attached grid and every row it holds. Each grid detaches before it is destroyed, so the router never holds a dead grid.

Change detection: zone or zoneless, unconfigured. The grid is created inside NgZone.runOutsideAngular, because it installs its own scroll, wheel and pointer listeners and running change detection on every scroll frame of a million-row grid is the difference between smooth and unusable. An event that reaches an output you have bound re-enters the zone, so (cell-changed)="count = count + 1" repaints exactly as you expect; an output nobody bound costs nothing. Under zoneless change detection that machinery is Angular's own no-op and a signal you set in a handler repaints the view. The one thing to know when reading the grid's DOM from Angular: the grid paints on its own schedule, not Angular's, so measure a cell in a grid event, not in ngAfterViewInit.

OnPush is safe everywhere. None of these components asks its parent to re-render: each owns one element, builds one instance in afterNextRender and pushes changed inputs into it from ngOnChanges. A host on OnPush that never re-renders still gets a fully live grid, because the grid is not rendered by Angular.

Inputs are diffed by identity, and never rebuild the instance. A changed input reaches the viewer that is already on screen - scroll position, selection, expansion and any open editor intact. Two things rebuild rather than update: the grid a viewer is bound to, and an input it cannot exist without. An input a viewer has no live setter for is named once in a warning rather than silently dropped. And because the comparison is Object.is, an inline [config]="{ rows: rows }" is a new object on every pass: hold it in a field or a signal.

Cleanup is the component's. ngOnDestroy detaches from the router, withdraws the grid from the registry and destroys the instance - in that order. An @if that closes and opens again leaves exactly one instance alive, and destroying the application leaves no instance, interval or listener behind. That is asserted with counters in a real browser, against the linked package, in test/angular-package-browser.test.js.

Angular 17 and up, browser only. The library is compiled partially, so your own Angular version compiles it: its declarations need a linker no newer than 14, and its peer range is >=17. Nothing is created on the server - isPlatformBrowser guards every build and afterNextRender does not run there - so a server-rendered page emits the empty host element and the grid is built on hydration. Angular Universal is not otherwise supported.

modules/angular is deprecated. The old bundle still works where it always worked - a page with @angular/compiler loaded - and it now says so once, and fails with a [lattice] message naming this package when it finds a real Angular with no JIT compiler, instead of leaving you with Angular's own. In 1.65 it also gained the fix that <div [latticeGrid]="config"> binds the configuration through the directive's selector, as its documentation always said it did. It will be removed in a later release; move to @toclocoinc/lattice-grid/angular, which covers every viewer rather than the grid alone and needs no second install.

Published as @toclocoinc/lattice-grid: npm install @toclocoinc/lattice-grid, then import { createLatticeGrid } from '@toclocoinc/lattice-grid/modules/react' (swap the module name for Vue or Svelte) resolves like any other package. Type declarations resolve automatically through the package's own types field, no @types package to install. Importing the built file by path, or the script tag, both still work for a project with no npm install step at all.

FileNeeded?What it is
lattice-grid.min.jsyesThe whole product as a UMD build: core, renderer, editors, exports. Defines window.LatticeGrid, and also works with AMD or CommonJS loaders.
lattice-grid.min.cssyesThe single stylesheet. Without it the grid is in the DOM and unreadable, no column widths, no scrolling, no theme.
lattice-grid.esm.min.jsalternativeThe same thing as an ES module, if you are importing rather than script-tagging.
lattice-grid.d.tsoptionalType declarations, for editor tooling.
the unminified buildsoptionallattice-grid.js, .esm.js, .css: readable source for debugging. Ship the minified ones.
SignatureReturnsNotes
createGrid(element, config?) Grid Resolves the document from element.ownerDocument, so a grid inside an iframe uses that frame's document. Throws with a clear message if there is no DOM. The exported name itself cannot be reassigned to wrap it - see the note below.
createHeadlessGrid(config?) Grid Core only. Everything below except grid.element and the DOM-only config keys works unchanged. What that does and doesn't reach is spelled out below.
defaults(config?) object House-wide defaults, merged beneath the config of every grid built afterwards, through either factory. The per-grid value always wins. defaults() reads the current set; defaults(null) clears it. See the note below.

createGrid cannot be monkey-patched. Wherever it is exported - window.LatticeGrid.createGrid from the UMD build, or the named import from the ESM build - it is defined with Object.defineProperty(..., { get, enumerable: true }) and no setter, and configurable defaults to false because the descriptor never sets it. Assigning to it in an ordinary (non-strict) script is not an error: the assignment is simply discarded and LatticeGrid.createGrid still returns the original function. In a module or any script under 'use strict' - which every ES module is - the same assignment throws TypeError: Cannot set property createGrid of [object Object] which has only a getter. Either way, a house-wide patch applied this way has no effect, and in the sloppy-mode case nothing tells you it didn't. There is no supported way to replace the function in place. The supported pattern is a factory your own code owns:

// your-lattice.js - the one place that knows your house defaults
import { createGrid as baseCreateGrid } from '@toclocoinc/lattice-grid';

export function createGrid(element, config) {
  return baseCreateGrid(element, { theme: 'house', density: 'compact', ...config });
}

// everywhere else
import { createGrid } from './your-lattice.js';

The wrapping function above is still a good seam when the wrapper does more than supply options. When all it does is supply options, use defaults() instead: it applies to every grid built afterwards through either factory, including the ones built for you inside a framework adapter or a module, which a wrapper in your own code never reaches.

  • The per-grid config always wins. Defaults sit beneath what the factory is passed: a key the grid names keeps the grid's value, a key it omits takes the house one. A key passed as undefined means "say nothing" - as it does everywhere else in the config surface - and so takes the house value rather than blanking it.
  • Plain objects deep-merge; arrays and everything else replace. A house views: { storage } and a grid's views: { local: true } both survive. A grid's columns array replaces the house one rather than extending it. Which keys behave as option bags follows from the value at the key, not from a fixed list.
  • Calling it again replaces the set, it does not accumulate, so the result never depends on the order your modules load. Extend explicitly with defaults({ ...defaults(), density: 'compact' }).
  • Never retroactive. The merge happens as a grid is built, so a grid that already exists is never revisited. Nothing reached from the defaults is shared between two grids: nested objects and arrays are copied per grid.
const { createHeadlessGrid, defaults } = await import('../packages/core/src/index.js');

// One place says what every grid in this application starts from.
defaults({ rowKey: 'id', selection: 'multiple' });

// This grid says nothing about rowKey, so the house value applies.
const a = createHeadlessGrid({
  columns: [{ field: 'id' }, { field: 'name' }],
  rows: [{ id: 'a1', name: 'Ada' }],
});
const house = a.rows.byKey('a1').data.name;

// This one names its own rowKey. The grid's own config always wins.
const b = createHeadlessGrid({
  rowKey: 'sku',
  columns: [{ field: 'sku' }],
  rows: [{ sku: 's9', id: 'ignored' }],
});
const own = b.rows.byKey('s9') !== undefined && b.rows.byKey('ignored') === undefined;

a.destroy();
b.destroy();
defaults(null);                // clear: grids built after this are unaffected
return `${house}; own-key ${own}; cleared ${JSON.stringify(defaults())}`;

What createHeadlessGrid covers, and what it cannot. It builds the same core the DOM build attaches a renderer to, so everything that is not the renderer itself is exercised exactly as it runs in a browser:

  • Covered: data (rows, columns), state (grid.state, saved views), sort, filter, group, total and pivot, formulas and computed columns, editing and optimistic write-back, export, and every event the grid emits.
  • Not covered: the DOM renderer, layout and measurement (column widths, row heights, scrolling), focus, and anything whose behaviour depends on a real box being painted on screen - grid.element is null and there is nothing to measure.

See How it works in the guide for the two specifics that have cost real debugging time: a grid mounted where it has no rendered box, and what the in-repo test DOM stub does and does not stand in for.

<lattice-grid> web component

A self-contained module bundle that registers a custom element on import. One script, one tag, no build step, for Rails, Django, Laravel or any page without a bundler. Load this or lattice-grid.esm.js, not both: the module carries the grid with it.

<link rel="stylesheet" href="dist/lattice-grid.min.css">
<script type="module" src="dist/modules/webcomponent.esm.min.js"></script>

<lattice-grid row-key="id"
  columns='[{"field":"id"},{"field":"city"}]'
  rows='[{"id":"A","city":"Leeds"}]'></lattice-grid>
AttributeConfig keyNotes
themetheme
densitydensity
localelocale
row-keyrowKey
row-heightrowHeightNumber.
header-heightheaderHeightNumber.
auto-heightautoHeightBoolean attribute.
selectionselectionnone, single or multiple.
rowsrowsJSON. Prefer the property.
columnscolumnsJSON. Prefer the property.
PropertyTypeNotes
rowsunknown[]Row data. A structure, so a property rather than an attribute.
columnsColumn[]Column definitions.
configGridConfigMerges any configuration without an attribute of its own.
gridGrid | nullRead-only. The underlying grid, for the full imperative API.

Events are re-dispatched as CustomEvents named lattice- plus the grid name with colons hyphenated: cell:changed becomes lattice-cell-changed. The payload is on event.detail. The prefix avoids colliding with platform events, the grid emits one called scroll.

Web Components: every viewer, not only the grid

Since 1.66 the same bundle registers one element per viewer, so a page without a bundler gets the KPI panel, a chart, the board, the Gantt, the dashboard layout, the tab strip and the Data Router as tags. The grid is already registered on import; defineLatticeElements registers the rest from the module factories the page has imported, because nothing is imported by this module - a page that draws no charts never loads the charts bundle.

// Every tag this package can register, under the default prefix.
const { ELEMENT_TAGS } = await import('../packages/modules/webcomponent/elements.js');
return Object.keys(ELEMENT_TAGS).length;
<script type="module">
  import { createKPI } from './dist/modules/kpi.esm.js';
  import { createChart } from './dist/modules/charts.esm.js';
  import { defineLatticeElements } from './dist/modules/webcomponent.esm.js';
  defineLatticeElements({ createKPI, createChart });
</script>

<lattice-grid name="quakes" row-key="id"></lattice-grid>
<lattice-kpi   grid-name="quakes"></lattice-kpi>
<lattice-chart grid-name="quakes" type="bar" x="place" y="mag"></lattice-chart>
TagViewerNotes
<lattice-grid>createGridRegistered on import. Gains name, route, route-options, the predicates and rowUpdates properties, and grid-ready / grid-destroyed.
<lattice-kpi>createKPIGrid-bound by default; set rows for a standalone panel. tiles is a property. Like every grid-bound viewer it waits for its grid rather than mounting empty, so a panel written above its grid is built once, when the grid arrives.
<lattice-chart>createChartAlways drawn from a grid, so it waits for one. Every other attribute is the chart spec and a change goes into chart.update().
<lattice-kanban>createKanbanBoard. rows, columns, card, facets are properties.
<lattice-gantt>createGantttasks and dependencies are properties and both are live.
<lattice-layout>createLayoutwindows is a property.
<lattice-tabs>createTabsA tab's content is a <template data-tab="‹id›"> child, cloned into the panel the module creates. With no tabs property the descriptors are read from the templates. A tab with no template is left to the module; the two kinds mix.
<lattice-router>createDataRouterA grouping element. Owns one Data Router, built by the first <lattice-grid route="…"> beneath it and destroyed on disconnect, and the name registry its descendants resolve grid-name through.

Attributes for scalars, properties for structures. An attribute is a string, so columns, rows, tiles, tasks, windows and a geometry pack are properties. Scalars work as either: an empty attribute is true, true/false are booleans, a finite number is a number, a value starting [ or { is JSON, and anything else is the string - so use the property when the type matters. On the viewer elements a hyphenated attribute reaches the camelCase key (column-propertycolumnProperty). Every configuration key has a real property, including one no table here names: assign it and the element adopts it. <lattice-grid> keeps its own attribute table above - each of those is a property too (el.theme, el.rowKey) - and takes everything outside it through config.

Upgrade order. A property assigned before the element upgrades is honoured. Set your configuration before the element connects - document.createElement, assign, then append - so the viewer is built once with everything it needs rather than built empty and reconfigured; in markup, use the JSON attribute forms. Cleanup is on disconnect, deferred by a microtask so moving an element in the DOM keeps its instance rather than rebuilding it. SSR: every element owns a real DOM node, so nothing is built until it is connected in a browser; a server render emits the empty tag and no more.

Events. Instance events are CustomEvents named lattice- plus the event name with colons hyphenated (card:movelattice-card-move); the prefix is load-bearing, because the grid emits scroll and a chart emits click. The lifecycle events are this adapter's own and are unprefixed: grid-ready / grid-destroyed on the grid and ready / destroyed on every viewer, each carrying the instance in detail. The instance is also on el.grid / el.instance.

Inside a shadow root

The elements are light DOM, deliberately. A host that puts them inside a shadow root of its own is a different matter: the theme, each module's injected stylesheet and the grid's generated one all live in document.head and none of them crosses a shadow boundary, so a grid in there used to render structurally perfect and completely unstyled. Every element now calls adoptLatticeStyles on its own root when that root is a shadow root, mirroring each sheet as a constructable CSSStyleSheet and keeping it level as the grid compiles new classes and as a module injects its own. A host that manages its own adoptedStyleSheets can take the sheets directly with latticeStyleSheets(); lattice-styles="off" on an element opts out. A stylesheet that cannot be read as text - a cross-origin <link>, or one carrying @import - is cloned into the root as a <link> instead.

const root = host.attachShadow({ mode: 'open' });
adoptLatticeStyles(root);
root.append(document.createElement('lattice-grid'));

dhtmlx Grid compatibility wrapper

A Grid class shaped like dhtmlx's own dhx.Grid (Suite 5+), backed by a real Lattice grid. Covers column definitions, .data, .selection, .history, .export.csv/.xlsx, and a name-mapped subset of .events: see the guide for exactly what is and is not covered. Not the classic pre-Suite-5 dhtmlXGridObject.

import { Grid } from './dist/modules/dhtmlx-compat.esm.min.js';

const grid = new Grid(container, {
  columns: [{ id: 'name', header: [{ text: 'Name' }], sortable: true }],
  data: rows,
});
grid.data.serialize(); // every row's data, in source order
NamespaceCovers
.dataadd, update, remove, removeAll, parse, load, find, findAll, exists, getItem, getId, getIndex, getLength, forEach, serialize, sort, filter, resetFilter. Index means current display order throughout.
.selectionsetCell(rowId, colId, ctrlUp?, shiftUp?), getCell, getCells, isSelectedCell, removeCell. row/column carry .id; a row's own fields sit alongside it, matching dhtmlx's own IRow.
.historyundo, redo, canUndo, canRedo, clear, getHistory.
.exportcsv, xlsx. pdf/png throw, no raster export to translate to.
.eventscellClick/cellDblClick/cellRightClick/afterEditStart/afterEditEnd/afterSort call your handler with dhtmlx's own positional arguments. afterRowDrop fires (data, event) from a same-grid reorder settling or a row landing from another grid. Every other mapped name passes Lattice's own event object. Every before*/can*/cancel* event, and row/column drag negotiation (a handler refusing or steering a drop mid-gesture), is unmapped: logged once per name if subscribed to.
.rangeSelectionBest-effort only: its range shape is this wrapper's own design, not dhtmlx's genuine RangeSelection module.

Which dhtmlx keys are translated

A key outside these lists is not silently dropped: the wrapper names it through warnOnce at construction, so a migration is told what did not come across rather than discovering it later.

Constructor keyBecomes
rowKeyThe grid's rowKey. Defaults to id, as dhtmlx does.
columnsTranslated column by column; see the next table.
dataThe initial rows.
autoHeightautoHeight.
rowHeightrowHeight.
headerRowHeightheaderHeight.
multiselectionselection: 'multiple'.
dragItemrowReorder when set to row. It does not also enable rowTransfer: dhtmlx lets any two grids on a page exchange rows by default, and enabling that implicitly is the accident Lattice's opt-in design exists to prevent.
rowTransferPassed through. There is no dhtmlx property to translate it from, so a pair of grids that exchange rows names it explicitly.
Column keyBecomes
idThe column id, and its field.
headerThe title. A multi-row header collapses to its first row, and says so: Lattice titles are a single string.
typeThe data type, by name.
width, minWidth, maxWidthlayout.width, layout.min, layout.max.
resizablelayout.resizable.
hiddenlayout.hidden.
draggablelayout.movable.
aligncell.align.
tooltip, tooltipTemplatecell.tooltip.
templatecell.render. dhtmlx returns HTML and Lattice mutates an element, so the return is treated as markup only when htmlEnable is set.
htmlEnableWhether template output is trusted as markup.
sortablesort.enabled.
editableedit.enabled.
editorTypeedit.editor, by name. An unrecognised one warns and falls back to the text editor.
editorConfigedit.props.
optionsedit.props.options, for a select-shaped editor.
summaryThe column's total.
Config keyBecomes
dragItem: 'row'rowReorder: true: same-grid drag-to-reorder.
rowTransferPassed straight through, unlike every other key: dhtmlx allows any two dragItem: 'row' grids on a page to exchange rows by default; Lattice's rowTransfer is deliberately opt-in per pair, so there is nothing to derive it from.

Declarative init, hydration & state

Core-level primitives, usable with or without any framework adapter or the htmx module below. An element built by createGrid is discoverable from itself: element.__lattice holds the live instance, cleared on destroy().

ExportDoes
autoInit(root)Builds a grid on every [data-lattice-grid] element under root not already built: idempotent, safe to call again after new content arrives. Config comes from a sibling <script type="application/json" data-lattice-config>; without one, a <table> element is hydrated instead.
hydrateTable(table, config?)Reads a <table>'s header row for column definitions and body rows for data (type-inferred per cell), then replaces the table with the grid. config (columns, rows, anything else) always wins over what was inferred.
serialiseState(grid) / restoreState(grid, encoded)A compact, URL-safe encoding of everything grid.state covers (sort, filters, column order and widths, scroll position, selection) diffed against the grid's own defaults first, so an untouched grid encodes to a handful of characters.
<meta name="lattice-license" content="…">Read automatically when no licence is passed to createGrid, no imperative call required.

htmx integration

One file. modules/htmx re-exports createGrid, autoInit, hydrateTable, readTable, serialiseState and restoreState alongside its own exports below, so a page using htmx integration never also loads the base bundle: that would mean two independent copies of the whole engine on one page. Registers on import: builds grids from [data-lattice-grid] elements or server-rendered <table>s, tears them down before htmx detaches a swapped-out subtree, and rebuilds them in newly-loaded content. driveServerMode/driveInfiniteScroll drive sort, filter and infinite scroll over plain htmx requests; driveOobUpdates applies out-of-band row updates in place. See the guide for the full request-lifecycle wiring and why infinite scroll uses two triggers.

import { autoInit, driveServerMode, driveInfiniteScroll } from './dist/modules/htmx.esm.min.js';

autoInit(document); // builds every [data-lattice-grid] under it
ExportDoes
createGrid, autoInit, hydrateTable, readTable, serialiseState, restoreStateThe same functions documented above, re-exported: this is the one import a page using htmx integration needs for both grid construction and htmx wiring.
driveServerMode(grid, trigger, opts?)A sort or filter change fires a request on trigger carrying offset/limit/sort/filters; the response replaces the grid's rows. opts.columns for the HTML-fragment ingest path.
driveInfiniteScroll(grid, sentinel, opts?)Appends rows as the grid's own visible window nears the end of what's loaded. sentinel's own hx-trigger names revealed, lattice:scroll-near-end, the first fires the initial chunk, the second every chunk after. opts.threshold (default 20) sets how many rows from the end counts as near.
driveOobUpdates(grid, opts?)Applies an out-of-band swap landing on [data-lattice-row="<key>"] to that row, in place: scroll, selection and filter state untouched.
Browser historyserialiseState/restoreState above, wired automatically to htmx:beforeHistorySave/htmx:historyRestore once this module is imported: browser back restores the prior sort, filter and scroll position.

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.

Framework adapters

The component wrappers each framework package exports.

LatticeGridHandle

The live instance a `<LatticeGrid>` ref exposes; `null` before mount.

PropertyTypeDescription
gridGrid | nullThe live grid the component built, or null before the mount effect has run and after it has been destroyed. (read-only)

LatticeViewerHandle

The live instance a viewer component's ref exposes; `null` before mount.

PropertyTypeDescription
instanceInstance | nullThe live viewer the component built - the board, panel, chart, plan, layout or strip - or null before mount. (read-only)

LatticeViewerCommonProps

What every viewer component takes beyond its own configuration: the grid it binds to, which published grid to take when that is left off, the lifecycle callbacks, and the host-element props.

Properties
PropertyTypeDescription
gridGrid | nullThe grid this viewer is built against; taken from context when absent. (optional)
gridNamestringWhich published grid to take from context; `'default'` when absent. (optional)
classNamestringApplied to the host element rather than to the viewer. (optional)
styleRecord<string, unknown>Applied to the host element rather than to the viewer. (optional)
idstringApplied to the host element rather than to the viewer. (optional)
Methods
MethodSignatureParametersReturnsDescription
onReady(instance: Instance) => voidinstance: Instance=> voidTold when the viewer exists. (optional)
onDestroy() => void - => voidTold just before it is destroyed. (optional)
Events

No events.

LatticeTabSpec

One tab of a `<LatticeTabs>`; `content` makes it React's rather than the module's.

PropertyTypeDescription
idstringThe tab's identity, used to select it, to name its slot and to derive another tab from it. Required, non-empty and unique; a duplicate or missing id is refused.
labelstringThe text on the tab button. Defaults to the id. (optional)
contentunknown | (() => unknown)A React element, or a function returning one, rendered through a portal. (optional)
[key: string]unknownAny other prop the host wants to carry on the tab. Passed through untouched, so a React host can key its own state off the same object the grid holds.

LatticeVueGridExposed

What a `<LatticeGrid>` template ref exposes; `grid()` is null before mount.

MethodSignatureParametersReturnsDescription
grid(): Grid | null - Grid | nullThe live grid the component built, or null before mount.

LatticeVueViewerExposed

What a viewer's template ref exposes; `instance()` is null before mount.

MethodSignatureParametersReturnsDescription
instance(): Instance | null - Instance | nullThe live viewer the component built, or null before mount.

LatticeVueTabSpec

One tab of a `<LatticeTabs>`; a slot named for its `id` makes it Vue's.

PropertyTypeDescription
idstringThe tab's identity, which also names the slot whose content Vue renders into the panel. Required, non-empty and unique.
labelstringThe text on the tab button. Defaults to the id. (optional)
[key: string]unknownAny other prop the host wants to carry on the tab. Passed through untouched, so a Vue host can key its own state off the same object the grid holds.

LatticeVueGridRegistry

Where grids publish themselves so the viewers around them can find one.

Properties
PropertyTypeDescription
grids{ value: Readonly<Record<string, Grid>> }A shallow ref of name → grid; replaced, never mutated, on each change.
Methods
MethodSignatureParametersReturnsDescription
publish(name: string, grid: Grid | null): voidname: string
grid: Grid | null
voidPublish a grid under a name, or withdraw it with `null`.
Events

No events.

LatticeVueRouterHandle

The Data Router handle a `provideLatticeRouter` puts in scope.

Properties
PropertyTypeDescription
routerunknownThe live router, built the first time anything asks for it. (read-only)
Methods
MethodSignatureParametersReturnsDescription
attach(grid: Grid, route: unknown, opts?: Record<string, unknown>): voidgrid: Grid
route: unknown
opts?: Record<string, unknown>
voidAttach a grid to a route.
detach(grid: Grid): voidgrid: GridvoidDetach a grid, before it is destroyed.
destroy(): void - voidDestroy the router; called for you when the providing scope is disposed.
Events

No events.

LatticeGridElement

`<lattice-grid>`. Structures are properties, scalars are the attributes in the table above, and `config` merges anything without an attribute of its own. Instance events arrive as `CustomEvent`s named `lattice-` plus the event name with colons hyphenated; `grid-ready` and `grid-destroyed` are this adapter's own and carry {@link LatticeGridEventDetail}.

Properties
PropertyTypeDescription
themestring | undefinedEvery attribute in the table above is also a property of the same name.
densitystring | undefinedThe row height and padding as a named step, rather than pixel by pixel. The `density` attribute.
localestring | undefinedThe locale the grid formats and sorts in. The `locale` attribute.
rowKeystring | undefinedWhich field identifies a row. As an attribute it can only be a field name; the composite and function forms are properties.
rowHeightnumber | undefinedRow height in pixels. The `row-height` attribute, parsed as a number.
headerHeightnumber | undefinedHeader height in pixels. The `header-height` attribute, parsed as a number; unset, the header follows the density token.
autoHeightboolean | undefinedLet the grid grow to its rows rather than capping its height. The `auto-height` attribute is a bare boolean - present is true.
selectionstring | undefinedWhat the reader may select: `single`, `multiple` or `none`. The `selection` attribute; the full selection config goes through `config`.
gridGrid | nullThe live grid, or null while the element is disconnected. (read-only)
instanceGrid | nullThe live grid, under the name every viewer element uses. (read-only)
rowsRow[]The row data.
columnsColumn[]The column definitions.
configGridConfigAny configuration without an attribute of its own; merged, not replaced.
predicatesRecord<string, ((row: Row) => boolean) | null> | undefinedNamed row predicates, registered through `grid.filters.where` - they compose with whatever filter the reader has set, unlike `filters`.
rowUpdates{ add?: Row[]; update?: Row[]; remove?: unknown[] } | undefinedA keyed diff applied straight to `grid.rows.apply()`.
Methods

No methods.

Events

No events.

LatticeViewerElement

What every viewer element carries.

Properties
PropertyTypeDescription
instanceInstance | nullThe live viewer, or null before it is built. Every configuration key below is also a property, and an attribute of the same name is the same key. A key none of these interfaces declares works as a property too - the element adopts it - but is written through `config` where the declarations are the contract. (read-only)
gridunknownThe grid this viewer is built against, when it is given one directly.
gridNamestring | undefinedWhich published grid to bind to; the `grid-name` attribute in markup.
configRecord<string, unknown>Any configuration without a property of its own; merged, not replaced.
Methods

No methods.

Events

No events.

LatticeKPIElement

`<lattice-kpi>`. Grid-bound by default, and it **waits** for its grid rather than mounting empty; give it `rows` for a standalone panel.

PropertyTypeDescription
tilesunknown[]The panel's tiles. A structure, so it is a property rather than an attribute.
rowsRow[]Rows for a standalone panel. Set them and the element stops waiting for a grid.
columnsnumber | undefinedHow many tile columns to aim for.
rowKeystring | undefinedWhich field identifies a row, for the panel's own keyed store.
localestring | undefinedThe default locale a clock tile formats in.

LatticeChartElement

`<lattice-chart>`. Always drawn from a grid, so it waits for one.

PropertyTypeDescription
shapesunknownA geomap's geometry: a loaded pack, `{ pack: id }`, GeoJSON, or a map of code to path data. A structure, so it is a property.
seriesunknownThe column that splits the measure into one series per distinct value.
dataunknownRows to draw instead of the grid's own filtered rows - the chart spec's `rows`, under the name the element gives it. An explicit `config: { rows }` wins.
typestring | undefinedWhich chart to draw. The `type` attribute.
xstring | undefinedThe category column.
ystring | string[] | undefinedThe measure column, or several for a multi-measure chart.
valuestring | undefinedThe measure a markermap writes beside each dot and colours it by.
labelstring | undefinedThe row-label column, for the types that name their rows.
lonstring | undefinedThe longitude column, in degrees east, for the maps that place a row by where it is.
latstring | undefinedThe latitude column, in degrees north.
labelsboolean | undefinedPrint the value beside each mark. The `labels` attribute is a bare boolean.
stackedboolean | undefinedStack the series rather than drawing them side by side - the spec's `stack`.
horizontalboolean | undefinedDraw a `type="bar"` chart along y instead of x, by selecting the `horizontalBar` type. Ignored on any other chart type, whose orientation is the type's own.
schemestring | undefinedThe colour scheme by name.

LatticeKanbanElement

`<lattice-kanban>`. Grid-bound like the KPI panel; give it `rows` to stand alone.

PropertyTypeDescription
rowsRow[]Rows for a standalone board. Set them and the element stops waiting for a grid.
columnsunknown[]The board's column definitions.
cardunknownThe card template's field mapping - title, subtitle, labels, assignee and the rest.
rowKeystring | undefinedWhich field identifies a card.
columnPropertystring | undefinedThe row property that puts a card in a column.
titlePropertystring | undefinedThe row property a card's headline comes from - the shorthand for `card: { title }`, which wins where both are given.
swimlanesboolean | undefinedDraw the two-dimensional swimlane layout. The `swimlanes` attribute is a bare boolean; name the lane property through `swimlane-property`.

LatticeGanttElement

`<lattice-gantt>`.

PropertyTypeDescription
tasksunknown[]The plan's tasks. A structure, so it is a property.
dependenciesunknown[]The links between tasks.
resourcesunknownThe resource capacities, for over-allocation and levelling.
calendarunknownThe working-time calendar: the `weekends` preset, or explicit workdays and holidays.
columnsunknownThe table panel's columns, for the split view.
rowKeystring | undefinedWhich field identifies a task.
scalestring | undefinedThe timeline's zoom - the shorthand for `config: { render: { zoom } }`, which wins where both are given.
autoScheduleboolean | undefinedCascade an edit down the dependency chain rather than only recomputing. A bare boolean attribute.

LatticeLayoutElement

`<lattice-layout>`.

PropertyTypeDescription
windowsunknown[]The windows to place, each with its content and its cell. A structure, so it is a property.
columnsnumber | undefinedHow many columns the grid of windows has.
rowsnumber | undefinedHow many rows the grid of windows has.
compactstring | undefinedWhich way windows collapse into the space a closed one left.

LatticeTabsElement

`<lattice-tabs>`. A tab's content is a `<template data-tab="‹id›">` child, cloned into the panel the module creates for it; a tab with no template is left to the module.

PropertyTypeDescription
tabsunknown[]The tab descriptors. A tab's content comes from a `<template data-tab="‹id›">` child when there is one.

LatticeRouterElement

`<lattice-router>`. Owns a Data Router for the life of the element, built by the first `<lattice-grid route="…">` beneath it.

Properties
PropertyTypeDescription
routerunknownThe live router, or null until something routes. (read-only)
instanceunknownThe live router, under the name every viewer element uses. (read-only)
configRecord<string, unknown>The router configuration; read once, when the router is built.
Methods

No methods.

Events

No events.