Lattice Grid Buy a licence

api reference

Adapters API Reference

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.

API reference › Framework adapters and custom elements

All 29 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:changed → onCellChanged 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.
createLatticeCalendar({ React, createCalendar })The calendar. rows, view and date are live props; grid-bound through context by default. The reschedule events arrive as onBeforeMove (cancel with preventDefault(reason?)) and onCardMove; call move(key, to) on the ref's instance.
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.
createLatticeDesigner({ React, createDesigner })The designer. mode, state, sources and guardrails are live; relationships, factories and llm are mount-time - change the component's key to apply one. state is controlled when the host passes it: a new state (by identity) is pushed in with setState, and designer changes arrive through onStateChange as a copy, never as a write to the prop. The ref is the live Designer handle.
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.
createDesignerController({ createDesigner, element, props })The framework-free designer lifecycle - mount once, push changed props into the live instance, destroy - shared by every adapter, with the controlled-state identity guard.
DESIGNER_EVENTSreadonly string[]Every event the designer emits: state, mode, page, select.
designerHandlerName(event) => stringA designer event as its callback prop: state becomes onStateChange, the rest onMode, onPage, onSelect.
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.createLatticeCalendar({ React, createCalendar: stub }),
  A.createLatticeGantt({ React, createGantt: stub }),
  A.createLatticeScheduler({ React, createScheduler: stub }),
  A.createLatticeLayout({ React, createLayout: stub }),
  A.createLatticeTabs({ React, ReactDOM, createTabs: stub }),
  A.createLatticeViewer({ React, viewer: 'kpi', mount: stub }),
  A.createLatticeShell({ React, createShell: stub }),
  A.createLatticeForm({ React, createForm: 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}`;

The designer is not a viewer. It has no grid, so instead of createLatticeViewer each framework wraps it directly - createLatticeDesigner({ React, createDesigner }) in React, the same in Vue, and bindDesigner under Designer.svelte in Svelte. One framework-free createDesignerController owns the lifecycle every adapter drives: mount once, push changed props into the live instance, destroy. Its state prop is controlled when the host passes it - a new state by identity is applied, and a designer change comes back out as a copy through onStateChange, so echoing it never loops.

const A = await import('../packages/modules/react/index.js');
const S = await import('../packages/modules/svelte/index.js');
const D = await import('../packages/modules/shared/designer.js');

// A stand-in for React; the adapter never imports it.
const React = { createElement: () => ({}), forwardRef: (f) => f, useRef: (v) => ({ current: v }), useEffect: () => {}, useImperativeHandle: () => {} };
const calls = [];
const designer = () => ({
  on: () => () => {}, setState: () => calls.push('state'), setMode: () => calls.push('mode'),
  setSources: () => calls.push('sources'), setGuardrails: () => calls.push('guardrails'),
  destroy() { calls.push('destroy'); },
});

// The one framework-free controller: mount, apply a new state, destroy.
const c = D.createDesignerController({ createDesigner: designer, element: {}, props: { state: { pages: [] } } });
c.update({ state: { pages: [{ id: 'a' }] } });
c.destroy();

// React's (and Vue's) component, and Svelte's binding helper, both build over
// the injected factory rather than importing the designer module.
const LatticeDesigner = A.createLatticeDesigner({ React, createDesigner: designer });
const binding = S.bindDesigner({ createDesigner: designer, element: {} });
binding.sync({ state: { pages: [] } });
binding.destroy();

return `${D.DESIGNER_EVENTS.join(',')}, ${D.designerHandlerName('state')}, ${LatticeDesigner.displayName}, ${calls.join(' ')}`;

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.

React: <LatticeDataGrid>, a DataGrid page by one import

createLatticeDataGrid (1.80) builds a component that takes the props of the <DataGrid> - the free-tier ones in the table below - so a page written against a data-grid package keeps its column definitions, its controlled models and its callbacks, and changes the import. Every accepted prop maps to documented grid configuration, a grid API call or a subscription to a grid event. Every other prop is refused once, by name, with the catalogued warning react:mui-prop-unsupported:<prop>, and ignored - never half-applied. It is React-only by design: it exists for pages leaving a data-grid package, which Vue, Svelte and Angular hosts are not, so the rule that every framework gets every viewer does not extend to it. sx and the .MuiDataGrid-* class names are not emulated; theme the grid through its --lattice-* tokens. The grid's stylesheet still needs loading.

import React from 'react';
import * as ReactDOM from 'react-dom/client';
import { createGrid } from '@toclocoinc/lattice-grid';
import { createLatticeDataGrid } from '@toclocoinc/lattice-grid/modules/react';

// Built once, at module scope. ReactDOM is needed only by a renderCell that returns an element.
export const DataGrid = createLatticeDataGrid({ React, ReactDOM, createGrid });

// …and in the page, instead of `import { DataGrid } from '@mui/x-data-grid'`:
<DataGrid rows={rows} columns={columns} checkboxSelection
  sortModel={sortModel} onSortModelChange={setSortModel}
  paginationModel={paginationModel} onPaginationModelChange={setPaginationModel} />
PropWhat it becomes
rowsthe rows of a memory source (copied on ingest)
columnscolumns, each definition translated (table below)
getRowIdrowKey as a function, read at call time; id by default
loadinggrid.overlay.show('loading') / hide()
densitydensity (the three MUI values are grid values too)
checkboxSelectionselection: { mode: 'multiple', checkbox: true, headerCheckbox: true }; without it, mode: 'single'
disableRowSelectionOnClickselection.checkboxOnly
editModeedit.mode ('cell' or 'row')
sortModel / onSortModelChangegrid.sort.set / sort:changed
filterModel / onFilterModelChangegrid.filters.set and filters.quick(…, { mode: 'words' }) / filter:changed; every free-tier operator translates
rowSelectionModel / onRowSelectionModelChangegrid.selection.set / selection:changed, as { type: 'include', ids } by MUI id
columnVisibilityModel / onColumnVisibilityModelChangelayout.hidden, columns.hide/show / column:visible
paginationModel / onPaginationModelChangepagination.pageSize and pagination.applyPage / page:changed - memory source only
pageSizeOptionspagination.pageSizes; [25, 50, 100] by default, as in MUI - memory source only
onRowClickrow:clicked, as (params, event, details)
onCellClick / onCellDoubleClickcell:clicked / cell:dblclicked
onCellEditStop / onCellModesModelChangecell:edit:end / cell:edit:start and cell:edit:end
onRowEditStop / onRowModesModelChangerow:edit:end / row:edit:start and row:edit:end, in editMode="row"
onColumnResizecolumn:resized
onColumnOrderChangecolumn:moved
onDataSourceErrorsource:error
apiRefapiRef.current is the six methods below while the grid exists, null after
slots / showToolbarslots.toolbar rendered above the grid when showToolbar is true; any other slot is refused
sourceNot an MUI prop. A Lattice source, for rows that are not in memory. Over one, the three paging props are refused by name
className / stylethe host element
Column keyWhat it becomes
fieldthe column id and the row property
headerNamethe heading
widthlayout.width; 100 by default, as in MUI
flexlayout.flex, instead of a width
typestring, number, date, dateTime, boolean to the grid's types; any other is refused
valueGettervalue.compute, called (value, row, column, apiRef)
valueFormattervalue.format, called the same way
renderCella cell renderer: text as text, a React element through its own root (needs ReactDOM)
editableedit
sortablesort: false
filterablefilter: false
apiRef methodWhat it does
getSelectedRows()a Map of MUI id to row, from grid.selection.rows()
setSortModel(model)grid.sort.set; onSortModelChange fires
setFilterModel(model)the filter translation; onFilterModelChange fires once
scrollToIndexes({ rowIndex, colIndex })grid.scroll.toCell/toRow/toColumn; the checkbox column is index 0, as in MUI
exportDataAsCsv(options)grid.export.csv({ download: true }): the selected rows if any, else the filtered rows; fileName, delimiter, includeHeaders, utf8WithBom, fields, allColumns, escapeFormulas mapped
updateRows(updates)merge by id, add a new id, _action: 'delete' removes; _action: 'replace' is refused

Refused by name, with a reason of its own: paginationMode, rowCount, paginationMeta and onPaginationMetaChange (MUI's server paging hands the paging contract to the host; the grid pages a non-memory source inside the source); dataSource (use a Lattice source); the Pro/Premium props rowGroupingModel, onRowGroupingModelChange, groupingColDef, rowGroupingColumnMode, disableRowGrouping, treeData, getTreeDataPath, onRowExpansionChange, aggregationModel, onAggregationModelChange and aggregationFunctions (not verified against the Pro types, so not guessed at); sx and classes; and initialState (pass the controlled models instead). Any other prop, column key, slot, column type, filter operator or CSV option is refused with the same warning family, by its own name. A filter model with one untranslatable operator is not applied at all.

Client-side paging over a memory source only. MUI pages rows the browser already holds, which is what the grid's own paging does over a memory source - so there the three paging props map one for one, and MUI's defaults (100 a page, [25, 50, 100]) apply. Over a pushdown, paged or remote source the page belongs to the source: paginationModel, onPaginationModelChange and pageSizeOptions are each refused by name and the rows are left exactly as the source delivers them.

Controlled models follow the adapter's identity rule. sortModel, filterModel, rowSelectionModel, columnVisibilityModel and paginationModel are applied when their identity changes, and their callbacks fire for the reader's and apiRef's changes, never for the prop's own - MUI's rule. One difference, stated: the grid is not pinned to a controlled model between renders, so a host that ignores onSortModelChange sees the reader's sort until it next passes a sortModel, where MUI would snap it back.

const { createTestDom, flushFrames } = await import('../packages/dom/src/renderer/testdom.js');
const { createGrid, createPushdownSource } = await import('../packages/dom/src/index.js');
const { createLatticeDataGrid, createDataGridController } = await import('../packages/modules/react/index.js');

// A data-grid page's rows and column definitions, unchanged.
const rows = [
  { code: 'a', name: 'Ada', age: 36, first: 'Ada', last: 'Lovelace', ok: true },
  { code: 'b', name: 'Bob', age: 20, first: 'Bob', last: 'Moog', ok: false },
  { code: 'c', name: 'Cy', age: 50, first: 'Cy', last: 'Young', ok: true },
];
const columns = [
  { field: 'code', headerName: 'Code', width: 80, sortable: false, filterable: false },
  { field: 'name', headerName: 'Name', flex: 1, editable: true },
  { field: 'age', type: 'number', valueFormatter: (value) => `${value} yrs` },
  { field: 'full', valueGetter: (value, row) => `${row.first} ${row.last}`,
    renderCell: (params) => String(params.value).toUpperCase() },
  { field: 'ok', type: 'boolean' },
];

// Every callback records its own name, so the result says which fired.
const heard = [];
const hear = (name) => () => heard.push(name);
const apiRef = { current: null };

// The framework-free half, driven here with no React at all.
const grid = createDataGridController({
  createGrid,
  element: createTestDom().root,
  props: {
    rows, columns, getRowId: (row) => row.code, loading: false, density: 'compact',
    checkboxSelection: true, disableRowSelectionOnClick: true, editMode: 'cell',
    sortModel: [{ field: 'age', sort: 'desc' }], onSortModelChange: hear('sort'),
    filterModel: { items: [] }, onFilterModelChange: hear('filter'),
    rowSelectionModel: { type: 'include', ids: new Set(['b']) }, onRowSelectionModelChange: hear('selection'),
    columnVisibilityModel: { ok: false }, onColumnVisibilityModelChange: hear('visibility'),
    paginationModel: { page: 0, pageSize: 2 }, onPaginationModelChange: hear('page'), pageSizeOptions: [2, 5],
    onRowClick: hear('row'), onCellClick: hear('cell'), onCellDoubleClick: hear('dblclick'),
    onCellEditStop: hear('editStop'), onColumnResize: hear('resize'), onColumnOrderChange: hear('move'),
    onCellModesModelChange: hear('cellModes'), apiRef,
  },
}).grid;
flushFrames();
const shown = `rows ${grid.rows.count()} of ${grid.rows.matchCount()}, first ${grid.rows.get(0).key}, `
  + `${grid.rows.text('a', 'age')}, ${grid.rows.text('a', 'full')}`;

// The props' own values called nothing back; the reader and the apiRef do.
grid.columns.show('ok');
apiRef.current.setSortModel([{ field: 'name', sort: 'asc' }]);
apiRef.current.setFilterModel({ items: [{ field: 'age', operator: '>', value: 25 }] });
grid.selection.set(['a', 'c']);
grid.pagination.set({ page: 0, pageSize: 5 });
flushFrames();
grid.edit.start('a', 'name');
grid.edit.stop();
grid.columns.resize('name', 150);
grid.columns.move('age', 0);
const selected = [...apiRef.current.getSelectedRows().keys()].join('+');
apiRef.current.updateRows([{ code: 'c', name: 'Cyrus' }]);
const scrolled = apiRef.current.scrollToIndexes({ rowIndex: 1 });
// In a browser this downloads people.csv; Node has no download, so the grid says so.
apiRef.current.exportDataAsCsv({ fileName: 'people', fields: ['code', 'name'] });

// Row editing, with its two callbacks.
const rowHeard = [];
const rowGrid = createDataGridController({
  createGrid, element: createTestDom().root,
  props: { rows, columns, getRowId: (row) => row.code, editMode: 'row',
    onRowEditStop: () => rowHeard.push('rowEditStop'), onRowModesModelChange: () => rowHeard.push('rowModes') },
}).grid;
flushFrames();
rowGrid.edit.start('a', 'name');
rowGrid.edit.stop();

// A pushdown source: the page model is refused by name, the error still reaches the host.
let failed = '';
const remote = createDataGridController({
  createGrid, element: createTestDom().root,
  props: {
    columns, paginationModel: { page: 1, pageSize: 2 },
    source: createPushdownSource({ adapter: { name: 'down', capabilities: {}, execute: async () => { throw new Error('offline'); } } }),
    onDataSourceError: (error) => { failed = error.message; },
  },
}).grid;
await new Promise((r) => setTimeout(r, 20));

// And the React component: the toolbar slot above the grid, on a host element.
const React = {
  createElement: (type, props, ...children) => ({ type, props, children }),
  useRef: (current) => ({ current }), useEffect() {}, useImperativeHandle() {}, forwardRef: (f) => f,
};
const DataGrid = createLatticeDataGrid({ React, createGrid });
const tree = DataGrid({ rows, columns, slots: { toolbar: 'MyToolbar' }, showToolbar: true,
  className: 'people', style: { height: 300 } }, null);

return `${shown} | heard ${heard.join(',')} | selected ${selected}, ${grid.rows.byKey('c').data.name}, `
  + `scrolled ${scrolled} | row edit ${rowHeard.join(',')} | pushdown pagination `
  + `${remote.config().pagination === undefined ? 'refused' : 'applied'}, error ${failed} | `
  + `${tree.props.className} toolbar ${tree.children[0].children[0].type}`;

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.
createLatticeCalendar({ vue, createCalendar })The calendar. rows, view and date are live props; grid-bound through the registry by default. The reschedule events arrive as @beforeMove (cancel with preventDefault(reason?)) and @card-move; call move(key, to) on the exposed instance.
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.
createLatticeDesigner({ vue, createDesigner })The designer. mode, state, sources and guardrails are live; relationships, factories and llm are mount-time - change the component's key to apply one. state is controlled when bound: a new state (by identity) is pushed in, and designer changes are emitted as @state, @mode, @page and @select, never as a write to the prop. The template ref is the live Designer handle.
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.createLatticeCalendar({ vue, createCalendar: stub }),
  A.createLatticeGantt({ vue, createGantt: stub }),
  A.createLatticeScheduler({ vue, createScheduler: stub }),
  A.createLatticeLayout({ vue, createLayout: stub }),
  A.createLatticeTabs({ vue, createTabs: stub }),
  A.createLatticeForm({ vue, createForm: 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.
Designer.svelte…/svelte/Designer.svelteThe designer. mode, state, sources and guardrails are live; relationships, factories and llm are mount-time. state is controlled when passed. bind:this plus instance() hands you the live Designer.

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.
bindDesigner({ createDesigner, element })Owns one designer. sync(props) builds it once, then pushes changed props into the live instance; destroy() tears it down. Built over createDesignerController, the same lifecycle the other adapters drive.
createDesignerController
DESIGNER_EVENTS
designerHandlerName
shared with React and VueThe framework-free designer lifecycle, the four designer events, and the event-to-prop name mapping - re-exported here so a Svelte host can build its own component on the same footing.
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.
LatticeCalendarComponent<lattice-calendar>The calendar. [rows], [view] and [date] are live inputs; grid-bound through the registry by default. The reschedule events arrive as (beforeMove) (cancel with preventDefault(reason?)) and (card-move); call move(key, to) on the component.
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.
LatticeDesignerComponent<lattice-designer>The designer. [mode], [state], [sources] and [guardrails] are live inputs; [relationships], [factories] and [llm] are mount-time. [state] is controlled when bound. The events are (stateChange), (modeChange), (pageChange) and (selectChange); (ready) hands you the live Designer.
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.
filter-rowfilterRowBoolean attribute. Draws the inline filter row under the headings.
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-calendar>createCalendarCalendar. rows, card and header are properties; view, date and start-property are scalars. The reschedule events arrive as lattice-beforeMove (cancel on event.detail) and lattice-card-move; call move(key, to) on the element.
<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-property → columnProperty). 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:move → lattice-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'));

Grid compatibility wrapper

A Grid class shaped like a common grid widget API (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 object model.

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 the source API'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 the source API'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 the source API's genuine RangeSelection module.

Which compatibility 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.
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: the source API 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 source-API 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. The source API 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: the source API 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. A <th data-edit> makes that column editable, data-type/data-format set its type/format, and a <tr data-lattice-row="…"> names the row key an out-of-band update later targets. <th data-width> (pixels or a percentage), data-min-width, data-max-width and data-flex size a hydrated column - the same width/minWidth/maxWidth/flex keys are accepted for hydrated columns in data-lattice-config - and with none given the columns flex to fill the host, wider for longer text.
serialiseState(grid, opts?) / restoreState(grid, encoded, opts?)A compact, URL-safe encoding of everything grid.state covers (sort, filters, column order and widths, selection) diffed against the grid's own defaults first, so an untouched grid encodes to a handful of characters. Scroll position is included or restored only with { scroll: true }; the htmx module's history mechanism passes it at both ends, so a browser back/forward still lands where the user scrolled to.
<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, calendars from [data-lattice-calendar], Gantts from [data-lattice-gantt], maps from [data-lattice-map], 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.
autoInitGantt(root) / registerGantt(factory)Builds a Gantt over every [data-lattice-gantt] element's server-rendered <table> - one <tr> per task, one <th data-field> per field. The factory is not inlined into the htmx bundle: register your own Gantt module with registerGantt(createGantt), or load it as a script tag exposing globalThis.LatticeGridGantt.createGantt, so a page with htmx and no Gantt pays nothing. Config comes from the same sibling data-lattice-config script/attribute the grid reads, with a nested split object for the split view.
driveGanttOobUpdates(opts?)Applies an out-of-band [data-lattice-row="<task id>"] fragment to that task through gantt.rows.apply, so htmx never writes raw HTML into the rendered plan - one reschedule, zoom/scroll/selection/collapse untouched.
GANTT_CHANGE_EVENTEvery committed change (move, resize, progress, cell edit, link, add, delete, row move) re-raises a bubbling lattice:gantt-change on the host with detail { kind, id, task, changes } - start/end already converted to ISO dates - so hx-trigger="lattice:gantt-change" can POST it. See the hx-vals recipe.
GANTT_COMMIT_EVENTOne lattice:gantt-commit per user gesture or API transaction - a single bubbling event on the host carrying the whole edit in detail { cause, primary: { id, kind }, changes: [{ id, kind, task, changes }] } (primary the task the gesture acted on) - so hx-trigger="lattice:gantt-commit" posts a drag that pushed successors once instead of a burst of lattice:gantt-change events. See the hx-vals recipe.
autoInitDesigner(root) / registerDesigner(factory)Builds a Designer over every [data-lattice-designer] element from its config - the mode, saved state and guardrails - with live data read from the grid named by its data-lattice-bind attribute or a grid: "<id>" config string (renamed by source: "<id>"). The factory is not inlined into the htmx bundle: register your own Designer module with registerDesigner(createDesigner), or load it as a script tag exposing globalThis.LatticeGridDesigner.createDesigner, so a page with htmx and no Designer pays nothing. Config comes from the same sibling data-lattice-config script/attribute the grid reads.
DESIGNER_CHANGE_EVENTEvery author change (add, remove, move, resize, page switch, undo, redo, panel chrome) re-raises a bubbling lattice:designer-change on the host with detail { state, cause } - the state includes the selected page - so hx-trigger="lattice:designer-change" can POST it. See the hx-vals recipe.
GRID_CHANGE_EVENTEvery committed cell edit on a declarative [data-lattice-grid] re-raises a bubbling lattice:grid-change on the host with detail { key, field, oldValue, newValue } - the row key, the edited column's field, and the old/new values - so hx-trigger="lattice:grid-change" can POST it. Nothing fires for a programmatic rows.apply/rows.load (those announce rows:changed, not cell:changed). See the hx-vals recipe.
CALENDAR_CHANGE_EVENTEvery move or resize on a declarative [data-lattice-calendar] re-raises a bubbling lattice:calendar-change on the host with detail { id, start, end } - the event id and the new start/end, already ISO strings - so hx-trigger="lattice:calendar-change" can POST it. See the hx-vals recipe.
autoInitMap(root) / registerMapFactories({ mapview, chart, leaflet, deck })Builds a map over every uninitialised [data-lattice-map] element, bound to a grid by data-lattice-bind/config.grid. The config's type selects the viewer: "mapview" (the default) builds the 3D Map View, "leaflet"/"deck" the matching adapter, anything else a chart type (markermap, geomap, …). No map engine is inlined into the htmx bundle: register your own factories with registerMapFactories, or load the modules as script tags exposing globalThis.LatticeGridMapView.createMapView/LatticeGrid.createChart/LatticeGridLeaflet.bindLeaflet/LatticeGridDeck.bindDeck, so a page with htmx and no map pays nothing.
destroyMapWithin(root)Releases every map in a fragment before htmx detaches it - tile layers, observers and listeners - before the grid cleanup pass, so a map's own grid subscriptions are dropped by the viewer itself.
saveMapStateWithin / restoreMapStateWithinCarry a map's view state (centre, zoom, selected layer) across htmx:beforeHistorySave/htmx:historyRestore in a data-lattice-map-state attribute, re-applied through the published wrapper's setState.
MAP_ATTRThe marker attribute autoInitMap scans for: data-lattice-map.
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 - a Gantt's zoom, visible range, scroll, collapsed rows and column widths, a Designer's full state including the selected page, and a map's centre, zoom and selected layer.
autoInitCalendar(root) / registerCalendar(factory)Builds a calendar over every [data-lattice-calendar] element, bound to a grid by data-lattice-bind/config.grid, or hydrated straight from a server-rendered <table> inside the element - bind wins, then table, then JSON rows. The calendar engine is inlined into the htmx bundle, so a page that declares a calendar needs no other module; registerCalendar(createCalendar) optionally overrides the factory. Config comes from the same sibling data-lattice-config script/attribute the grid reads.
destroyCalendarWithin / saveCalendarStateWithin / restoreCalendarStateWithinTear a calendar down before htmx detaches its fragment, and carry its view state (view, date, first day, time-axis scroll) across htmx:beforeHistorySave/htmx:historyRestore - the original table is swapped back in for the snapshot, then restoreSwappedCalendars() puts the rendered view back a microtask later.
driveCalendarOobUpdates(opts?)Applies an out-of-band [data-lattice-row="<event id>"] fragment to that event through calendar.rows.apply, so htmx never writes raw HTML into the rendered view - one reschedule, the view, date and scroll untouched. Wired by attach().
autoInitKanban(root) / registerKanban(factory)Builds a Kanban board over every [data-lattice-kanban] element, bound to a grid by data-lattice-bind/config.grid, or hydrated straight from a server-rendered <table> inside the element - bind wins, then table, then JSON rows. The factory is not inlined into the htmx bundle: register your own Kanban module with registerKanban(createKanban), or load it as a script tag exposing globalThis.LatticeGridKanban.createKanban, so a page with htmx and no board pays nothing.
destroyKanbanWithin / saveKanbanStateWithin / restoreKanbanStateWithinTear a board down before htmx detaches its fragment, and carry its view state (collapsed columns and lanes, column order, quick filter, sprint/epic selection, selection) across htmx:beforeHistorySave/htmx:historyRestore - the original table is swapped back in for the snapshot, then restoreSwappedKanbans() puts the rendered board back a microtask later.
driveKanbanOobUpdates(opts?)Applies an out-of-band [data-lattice-row="<card id>"] fragment to that card through board.rows.apply, so htmx never writes raw HTML into the rendered board - one move, collapsed columns and the sprint filter untouched. Wired by attach().
KANBAN_CHANGE_EVENTEvery card move on a declarative [data-lattice-kanban] re-raises a bubbling lattice:kanban-change on the host - one event per moved card, with detail { id, from, to, index, lane } (the card key, the column it left, the column it landed in, the requested position and the swimlane) - so hx-trigger="lattice:kanban-change" can POST a drag. See the hx-vals recipe.
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 - and a Gantt's zoom, visible range, scroll, collapsed rows and column widths.

Declaring a Gantt, and posting its changes

A data-lattice-gantt element wraps the server-rendered task table - no JS needed to make the markup read as a table, and one htmx:load turns it into the split view. Because the Gantt engine is not inlined, the page loads the Gantt module itself, which publishes globalThis.LatticeGridGantt.createGantt that the adapter resolves. The lattice:gantt-change event re-raised for every edit carries the task id and the field-level changes (dates already ISO), so an hx-trigger posts exactly what changed.

<!-- The table names the task table's columns; the rows are the tasks. -->
<div data-lattice-gantt hx-trigger="lattice:gantt-change from:body"
     hx-post="/tasks/{id}" hx-vals='js:{ id: event.detail.id, kind: event.detail.kind, changes: JSON.stringify(event.detail.changes) }'>
  <table>
    <thead><tr><th data-field="name">Task</th><th data-field="start">Start</th><th data-field="end">End</th></tr></thead>
    <tbody>
      <tr><td>Design</td><td>2026-09-28</td><td>2026-10-02</td></tr>
      <tr><td>Build</td><td>2026-10-03</td><td>2026-10-09</td></tr>
    </tbody>
  </table>
</div>
<!-- htmx, then the Gantt module (publishes the factory the adapter resolves). -->
<script src="/dist/modules/gantt.esm.min.js"></script>
<script type="module">import "/dist/modules/htmx.esm.min.js";</script>

Posting a whole Gantt gesture at once

Dragging one bar reschedules its successors and summaries, so lattice:gantt-change fires once per task touched - a burst htmx cannot post as one synchronous request (each queued request loses its event, so js: hx-vals come out empty). For that, the adapter also re-raises the controller's one-per-action batch as a single bubbling lattice:gantt-commit: detail { cause, primary: { id, kind }, changes: [{ id, kind, task, changes }] }, where primary is the task the user dragged and changes lists every task the gesture moved. Point hx-trigger at it and post the whole edit once.

<!-- The table names the task table's columns; the rows are the tasks. -->
<div data-lattice-gantt hx-trigger="lattice:gantt-commit from:body"
     hx-post="/tasks/commit"
     hx-vals='js:{ cause: event.detail.cause, id: event.detail.primary.id, changes: JSON.stringify(event.detail.changes) }'>
  <table>
    <thead><tr><th data-field="name">Task</th><th data-field="start">Start</th><th data-field="end">End</th></tr></thead>
    <tbody>
      <tr><td>Design</td><td>2026-09-28</td><td>2026-10-02</td></tr>
      <tr><td>Build</td><td>2026-10-03</td><td>2026-10-09</td></tr>
    </tbody>
  </table>
</div>
<!-- The plan's dependencies (auto-scheduled, so a drag pushes successors). -->
<script type="application/json" data-lattice-config>{
  "projectStart": "2026-09-28",
  "autoSchedule": true,
  "dependencies": [{ "from": "a", "to": "b", "type": "FS" }],
  "split": { "zoom": "day" }
}</script>
<!-- htmx, then the Gantt module (publishes the factory the adapter resolves). -->
<script src="/dist/modules/gantt.esm.min.js"></script>
    

Declaring a Designer, and posting its changes

A data-lattice-designer element builds a dashboard from its config - the mode, saved state and guardrails - with live data bound from a grid by data-lattice-bind (or grid/source in the config). Because the Designer engine is not inlined, the page loads the Designer module itself, which publishes globalThis.LatticeGridDesigner.createDesigner that the adapter resolves. The lattice:designer-change event re-raised for every author edit carries the whole state (including the selected page), so an hx-trigger posts exactly what changed.

<!-- The grid the designer reads; swapped in and out by htmx like any other. -->
<div id="sales" data-lattice-grid></div>
<script type="application/json" data-lattice-config>{"columns":[{"field":"region"},{"field":"sales","type":"number"}],"rows":[{"id":"a","region":"north","sales":10},{"id":"b","region":"south","sales":20}],"rowKey":"id"}</script>

<!-- The designer: mode, guardrails and saved state in the config; data bound to the grid. -->
<div data-lattice-designer data-lattice-bind="sales"
     hx-trigger="lattice:designer-change from:body"
     hx-post="/designers/main" hx-vals='js:{ state: JSON.stringify(event.detail.state), cause: event.detail.cause }'></div>
<script type="application/json" data-lattice-config>{
  "mode": "edit",
  "guardrails": { "maxPanels": 6 },
  "state": {
    "pages": [{ "id": "p1", "title": "Sales", "spec": { "layout": { "columns": 2 }, "panels": [
      { "id": "table", "kind": "grid", "title": "Orders", "source": "sales" }
    ] } }],
    "selectedPageId": "p1",
    "panels": {}
  }
}</script>
<!-- htmx, then the Designer module (publishes the factory the adapter resolves). -->
<script src="/dist/modules/designer.esm.min.js"></script>
<script type="module">import "/dist/modules/htmx.esm.min.js";</script>

Posting a declarative grid's edits

A data-lattice-grid element is the grid's own declarative host: autoInit builds it, and GRID_CHANGE_EVENT re-raises each committed cell edit as a bubbling lattice:grid-change on that host. detail carries the row key, the edited column's field and the oldValue/newValue, so an hx-trigger posts exactly what changed. A programmatic rows.apply/rows.load posts nothing - it announces rows:changed on the grid's own bus, not cell:changed.

<!-- The grid, built declaratively; hx-trigger posts each committed edit. -->
<div data-lattice-grid hx-trigger="lattice:grid-change from:body"
     hx-post="/rows/{key}" hx-vals='js:{ key: event.detail.key, field: event.detail.field, oldValue: event.detail.oldValue, newValue: event.detail.newValue }'>
  <script type="application/json" data-lattice-config>{
    "columns": [{ "field": "name" }, { "field": "status" }],
    "rows": [{ "id": 1, "name": "Design", "status": "open" }],
    "rowKey": "id"
  }</script>
</div>
<script type="module">import "/dist/modules/htmx.esm.min.js";</script>

A server table hydrating into an editable, postable grid

A server-rendered <table data-lattice-grid> hydrates straight into a grid with no config script: the <th> cells name the columns (data-field, data-type, data-format honoured as above), and <th data-edit> makes a column editable. A <tr data-lattice-row="…"> names the row key the out-of-band handler reads, so a later hx-swap-oob fragment can patch that row. Committed edits re-raise the same bubbling lattice:grid-change a config-built grid raises.

Columns are sized from the table too: <th data-width> fixes a width in pixels (or a percentage string), data-min-width/data-max-width bound it, and data-flex gives it a flex share of the leftover - the same width/minWidth/maxWidth/flex keys work on hydrated columns in the sibling data-lattice-config. With none given, hydrated columns fill the host width instead of the fixed 150px default: each column flexes in proportion to its longest text, with a sensible minimum, so a long heading is wider than a short one. Text that still does not fit ends in an ellipsis with its full value in the heading's title/accessible name, exactly as a declared grid.

<!-- The table is the whole grid: editable columns, row keys, edits posted back. -->
<table id="postable" data-lattice-grid hx-trigger="lattice:grid-change from:body"
       hx-post="/rows" hx-vals='js:{ key: event.detail.key, field: event.detail.field, newValue: event.detail.newValue }'>
  <thead><tr>
    <th data-field="id" data-width="90">ID</th>
    <th data-field="title" data-flex="3" data-edit>Title</th>
    <th data-field="status" data-edit>Status</th>
  </tr></thead>
  <tbody>
    <tr data-lattice-row="A1"><td>A1</td><td>Fix login</td><td>Todo</td></tr>
    <tr data-lattice-row="A2"><td>A2</td><td>Write docs</td><td>Todo</td></tr>
  </tbody>
</table>
<!-- The anchor next to the table is a sibling of the host after hydration; an
     hx-swap-oob fragment for it patches that row in place. -->
<i id="postable-A2" data-lattice-row="A2"></i>
<script type="module">import "/dist/modules/htmx.esm.min.js";</script>

Posting a declarative calendar's moves

A data-lattice-calendar element, bound to a grid by data-lattice-bind or a grid string, re-raises each move or resize as a bubbling lattice:calendar-change on the host - detail { id, start, end } with the event id and the new start/end already ISO strings - so an hx-trigger posts the reschedule.

<!-- The grid the calendar follows, then the calendar itself. -->
<div id="cal-source" data-lattice-grid></div>
<script type="application/json" data-lattice-config>{"columns":[{"field":"start"}],"rows":[{"id":"a","start":"2026-09-28 10:00"}],"rowKey":"id"}</script>
<div data-lattice-calendar data-lattice-bind="cal-source" hx-trigger="lattice:calendar-change from:body"
     hx-post="/events/{id}" hx-vals='js:{ id: event.detail.id, start: event.detail.start, end: event.detail.end }'></div>
<script type="module">import "/dist/modules/htmx.esm.min.js";</script>

Declaring a calendar view of a data grid

A server-rendered <table id="…" data-lattice-grid> hydrates into a grid whose host keeps the table's id, so a data-lattice-calendar beside it can bind by that id and draw one card per row. The calendar engine is inlined into the htmx bundle, so importing that one module builds the calendar - registerCalendar(createCalendar) is an optional override for an adapter that wants to swap in its own factory.

<!-- The server-rendered table: its id is the grid the calendar binds to. -->
<table id="cal-grid" data-lattice-grid>
  <thead><tr><th data-field="title">Title</th><th data-field="start">Start</th></tr></thead>
  <tbody>
    <tr><td>Design</td><td>2026-09-28 10:00</td></tr>
    <tr><td>Build</td><td>2026-09-29 11:00</td></tr>
    <tr><td>Ship</td><td>2026-09-30 12:00</td></tr>
  </tbody>
</table>

<!-- The calendar, bound to that grid by id: one card per row, on its start day. -->
<div data-lattice-calendar data-lattice-bind="cal-grid" style="width:720px;height:400px"></div>
<script type="application/json" data-lattice-config>{"startProperty":"start","view":"month","date":"2026-09-30"}</script>

<!-- htmx builds the calendar: its engine is inlined into the htmx module. -->
<script type="module">import "/dist/modules/htmx.esm.min.js";</script>

No grid needed: a calendar hydrates straight from the server-rendered <table> inside it. The <th data-field> cells name the fields, each <tr> is one event, and data-type/data-format are honoured exactly as the grid's own table hydration honours them. data-lattice-row on a <tr> names the event id the calendar keys by (else the config's rowKey field, else data-id, else the row index); an out-of-band <tr data-lattice-row="…"> later patches that one event in place. Keep an empty out-of-band target beside the table - an element carrying the event's id and data-lattice-row, like the <i> below - and the calendar preserves it across renders, so htmx always has somewhere to land the swapped row. The anchor needs no styling: the calendar keeps it out of layout itself, whatever markup the page uses.

<!-- The table is the data: the calendar replaces it, no grid in between. -->
<div data-lattice-calendar style="width:720px;height:400px">
  <table>
    <thead><tr><th data-field="title">Title</th><th data-field="start" data-type="datetime">Start</th></tr></thead>
    <tbody>
      <tr data-lattice-row="d"><td>Design</td><td>2026-09-28 10:00</td></tr>
      <tr data-lattice-row="b"><td>Build</td><td>2026-09-29 11:00</td></tr>
      <tr data-lattice-row="s"><td>Ship</td><td>2026-09-30 12:00</td></tr>
    </tbody>
  </table>
  <!-- The out-of-band target htmx swaps into for a rescheduled event. -->
  <i id="shift-d" data-lattice-row="d"></i>
</div>
<script type="application/json" data-lattice-config>{"startProperty":"start","view":"week"}</script>

<!-- htmx builds the calendar: its engine is inlined into the htmx module. -->
<script type="module">import "/dist/modules/htmx.esm.min.js";</script>

Declaring a Kanban board

A data-lattice-kanban element carries the board's config in the same sibling data-lattice-config script (or attribute) the grid reads, and names the grid it follows by data-lattice-bind or a grid: "<id>" string - or hydrates straight from a server-rendered table inside it (below). Because the board engine is not inlined, the page loads the Kanban module itself, which publishes globalThis.LatticeGridKanban.createKanban that the adapter resolves. A board left on the page across a swap is re-pointed at the replacement grid through its own rebind(grid), and its view state (collapsed columns and lanes, the sprint filter, the search) rides browser history.

<!-- The grid the board follows, named by data-lattice-bind below. -->
<div id="work" data-lattice-grid><script type="application/json" data-lattice-config>{…}</script></div>

<!-- The board: config carries rows/columns/card; the binding names the grid. -->
<div data-lattice-kanban data-lattice-bind="work">
  <script type="application/json" data-lattice-config>{
    "columnProperty": "status",
    "columns": [{ "id": "todo", "title": "To do" }, { "id": "done", "title": "Done" }],
    "card": { "title": "title", "subtitle": "assignee" }
  }</script>
</div>
<!-- htmx, then the Kanban module (publishes the factory the adapter resolves). -->
<script src="/dist/modules/kanban.esm.min.js"></script>
<script type="module">import "/dist/modules/htmx.esm.min.js";</script>

No grid needed either: a board hydrates straight from a server-rendered <table> inside it - the <th data-field> cells name the fields, each <tr> is one card, and the board replaces the table. data-lattice-row on a <tr> names the card id the board keys by (else the config's rowKey field, else data-id, else the row index); an out-of-band <tr data-lattice-row="…"> later patches that one card in place. Keep an empty out-of-band target beside the table - an element carrying the card's id and data-lattice-row - and the board preserves it across renders, so htmx always has somewhere to land the swapped row. The anchor needs no styling: the board keeps it out of layout itself, whatever markup the page uses.

<!-- The table is the data: the board replaces it, no grid in between. -->
<div data-lattice-kanban style="width:900px;height:420px">
  <table>
    <thead><tr><th data-field="title">Title</th><th data-field="status">Status</th></tr></thead>
    <tbody>
      <tr data-lattice-row="a"><td>Alpha</td><td>todo</td></tr>
      <tr data-lattice-row="b"><td>Beta</td><td>doing</td></tr>
      <tr data-lattice-row="c"><td>Gamma</td><td>done</td></tr>
    </tbody>
  </table>
</div>
<script type="application/json" data-lattice-config>{"columnProperty":"status","columns":[{"id":"todo","title":"To do"},{"id":"doing","title":"Doing"},{"id":"done","title":"Done"}],"card":{"title":"title"}}</script>

<!-- htmx, then the Kanban module (publishes the factory the adapter resolves). -->
<script src="/dist/modules/kanban.esm.min.js"></script>
<script type="module">import "/dist/modules/htmx.esm.min.js";</script>

Posting a card move

A card drag re-raises a bubbling lattice:kanban-change on the host - one event per moved card, with detail { id, from, to, index, lane } - so an hx-trigger posts exactly the move the user made.

<!-- The board: a drag posts the card key, its old column and its new column. -->
<div data-lattice-kanban hx-trigger="lattice:kanban-change from:body"
     hx-post="/cards/{id}" hx-vals='js:{ id: event.detail.id, from: event.detail.from, to: event.detail.to }'>
  <table>
    <thead><tr><th data-field="title">Title</th><th data-field="status">Status</th></tr></thead>
    <tbody>
      <tr data-lattice-row="a"><td>Alpha</td><td>todo</td></tr>
      <tr data-lattice-row="b"><td>Beta</td><td>doing</td></tr>
    </tbody>
  </table>
</div>
<script type="application/json" data-lattice-config>{"columnProperty":"status","columns":[{"id":"todo","title":"To do"},{"id":"doing","title":"Doing"},{"id":"done","title":"Done"}],"card":{"title":"title"}}</script>

<!-- htmx, then the Kanban module (publishes the factory the adapter resolves). -->
<script src="/dist/modules/kanban.esm.min.js"></script>
<script type="module">import "/dist/modules/htmx.esm.min.js";</script>

Declaring a map, bound to its grid

A data-lattice-map element binds to a grid by id and draws one marker per row - no JS needed to make the markup read as a map, and one htmx:load builds it. The config's type selects the viewer; a chart type like markermap needs no CDN or WebGL, the "mapview" kind carries a view (centre and zoom) and a layer through htmx's history cache. Because the engines are not inlined, the page loads the one it uses itself, and registerMapFactories hands it to the adapter - the same seam registerGantt uses.

<!-- The grid the map follows, named by id. -->
<div id="g" data-lattice-grid></div>
<script type="application/json" data-lattice-config>{"columns":[{"field":"site"},{"field":"lng","type":"number"},{"field":"lat","type":"number"}],"rows":[{"id":"ldn","site":"London","lng":-0.13,"lat":51.5}],"rowKey":"id"}</script>

<!-- The map, bound to that grid; `type` names a chart type or the map view. -->
<div data-lattice-map data-lattice-bind="g" style="width:520px;height:360px"></div>
<script type="application/json" data-lattice-config>{"type":"markermap","lon":"lng","lat":"lat","label":"site"}</script>

<!-- htmx, then the chart module (register its factory for the adapter). -->
<script type="importmap">{ "imports": { "@toclocoinc/lattice-grid/modules/charts": "/dist/modules/charts.esm.js" } }</script>
<script src="/test/fixtures/htmx/htmx.min.js"></script>
<script type="module">
import { registerMapFactories } from "/dist/modules/htmx.esm.js";
import { createChart } from "/dist/modules/charts.esm.js";
import "/dist/modules/chart-markermap.esm.js";
registerMapFactories({ chart: createChart });
</script>

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.

LatticeFormProps

The form component's callback props: one per form event.

Properties

No properties.

Methods
MethodSignatureParametersReturnsDescription
onBeforeChange(event: FormBeforeChangeEvent) => unknownevent: FormBeforeChangeEvent=> unknownRaised before a value commits; return `false` or a promise for `false` to veto. (optional)
onChange(event: FormValueEvent) => voidevent: FormValueEvent=> voidA value committed. (optional)
onInput(event: FormValueEvent) => voidevent: FormValueEvent=> voidA live, uncommitted value. (optional)
onClick(event: FormClickEvent) => voidevent: FormClickEvent=> voidA button item was pressed. (optional)
onFocus(event: FormFocusEvent) => voidevent: FormFocusEvent=> voidAn item's control gained focus. (optional)
onBlur(event: FormFocusEvent) => voidevent: FormFocusEvent=> voidAn item's control lost focus. (optional)
onValidated(event: FormValidatedEvent) => voidevent: FormValidatedEvent=> void`validate()` finished, with every failure. (optional)
Events

No events.

LatticeDataGridColDef

One column definition, as `<LatticeDataGrid>` takes it. These eleven keys are mapped onto a grid column; any other key is refused once, by name, as `columns.<key>`.

PropertyTypeDescription
fieldstringThe row property the column shows; also the column's id. Required.
headerNamestringThe heading text. Defaults to the field. (optional)
widthnumberThe width in pixels; 100 by default, as in MUI. Ignored when `flex` is set. (optional)
flexnumberA share of the spare width, instead of a fixed `width`. (optional)
type'string' | 'number' | 'date' | 'dateTime' | 'boolean'The value type: `'string'`, `'number'`, `'date'`, `'dateTime'` or `'boolean'`, each mapped to the grid's own type. Any other type is refused by name and the type is inferred from the data. (optional)
editablebooleanLet the reader edit this column's cells. (optional)
sortableboolean`false` stops the column being sorted. (optional)
filterableboolean`false` stops the column being filtered. (optional)
MethodSignatureParametersReturnsDescription
valueGetter(value: any, row: any, column: LatticeDataGridColDef, apiRef: { current: LatticeDataGridApi | null }) => unknownvalue: any
row: any
column: LatticeDataGridColDef
apiRef: { current: LatticeDataGridApi | null }
=> unknownDerive the cell value, MUI's four-argument form: the row's value for `field`, the row, this definition, and the `apiRef`. (optional)
valueFormatter(value: any, row: any, column: LatticeDataGridColDef, apiRef: { current: LatticeDataGridApi | null }) => unknownvalue: any
row: any
column: LatticeDataGridColDef
apiRef: { current: LatticeDataGridApi | null }
=> unknownTurn the cell value into the text the cell shows, with the same four arguments. (optional)
renderCell(params: LatticeDataGridCellParams) => unknownparams: LatticeDataGridCellParams=> unknownRender the cell: a string or number is shown as text, a React element is mounted into the cell through its own root (which needs `ReactDOM` passed to `createLatticeDataGrid`). (optional)

LatticeDataGridCellParams

What `onCellClick`, `onCellDoubleClick`, `onCellEditStop` and `renderCell` receive, MUI's shape.

PropertyTypeDescription
idunknownThe row's MUI id: `getRowId(row)`, or `row.id`.
fieldstringThe column's field.
valueunknownThe cell's value.
formattedValueunknownThe cell's display text.
rowanyThe row object.
colDefLatticeDataGridColDef | undefinedThe column's definition, or undefined for a column the grid generated.
cellMode'view'Always `'view'`: the callbacks fire outside an edit.
hasFocusbooleanAlways false; focus is not reported.
tabIndexnumberAlways -1.
apiLatticeDataGridApiThe `apiRef` methods, on `renderCell` only. (optional)

LatticeDataGridRowParams

What `onRowClick` and `onRowEditStop` receive, MUI's shape.

PropertyTypeDescription
idunknownThe row's MUI id.
rowanyThe row object.
columnsLatticeDataGridColDef[]Every column definition.
fieldstringThe field the edit stopped on, on `onRowEditStop` only. (optional)

LatticeDataGridCallbackDetails

The second argument MUI's `on*Change` callbacks receive.

PropertyTypeDescription
apiLatticeDataGridApiThe `apiRef` methods.

LatticeDataGridSortItem

One sort item, MUI's shape.

PropertyTypeDescription
fieldstringThe column field.
sort'asc' | 'desc' | null | undefinedThe direction; an item with no direction is dropped.

LatticeDataGridFilterItem

One filter item, MUI's shape.

PropertyTypeDescription
idnumber | stringAn id for the item; kept for the host, not read. (optional)
fieldstringThe column field.
operatorstringThe MUI operator. Every free-tier operator translates; an operator that does not makes the whole model refused, never partly applied.
valueunknownThe operand. An item with no operand is skipped, except `isEmpty`/`isNotEmpty`. (optional)

LatticeDataGridFilterModel

The filter model, MUI's shape.

PropertyTypeDescription
itemsLatticeDataGridFilterItem[]The conditions.
logicOperator'and' | 'or'How the items combine; `'and'` by default. (optional)
quickFilterValuesunknown[]Quick-filter words: every word must match somewhere in the row. (optional)
quickFilterLogicOperator'and' | 'or'`'and'` only: `'or'` has no quick-filter equivalent and refuses the model. (optional)

LatticeDataGridRowSelectionModel

The row-selection model, MUI's shape. `'exclude'` is refused by name.

PropertyTypeDescription
type'include' | 'exclude'`'include'`: exactly these rows are selected.
idsSet<unknown>The selected rows' MUI ids.

LatticeDataGridPaginationModel

The page model, MUI's shape; the page is zero-based.

PropertyTypeDescription
pagenumberThe zero-based page.
pageSizenumberRows per page.

LatticeDataGridApi

The methods the `apiRef` exposes: the commonly used six, with MUI's signatures. `apiRef.current` is set when the grid is built and cleared when it is destroyed.

Properties

No properties.

Methods
MethodSignatureParametersReturnsDescription
getSelectedRows(): Map<unknown, any> - Map<unknown, any>The selected rows, keyed by MUI row id.
setSortModel(model: LatticeDataGridSortItem[]): voidmodel: LatticeDataGridSortItem[]voidReplace the sort model. `onSortModelChange` fires, as in MUI.
setFilterModel(model: LatticeDataGridFilterModel): voidmodel: LatticeDataGridFilterModelvoidReplace the filter model. `onFilterModelChange` fires once, as in MUI.
scrollToIndexes(params: { rowIndex?: number; colIndex?: number }): booleanparams: { rowIndex?: number; colIndex?: number }booleanScroll a row, a column, or a cell into view. The column index counts the checkbox column as 0 when there is one, as MUI's does. Returns false when neither index names anything.
exportDataAsCsv(options?: Record<string, unknown>): voidoptions?: Record<string, unknown>voidDownload the rows as CSV: the selected rows when there are any, otherwise every row the filters pass. `fileName`, `delimiter`, `includeHeaders`, `utf8WithBom`, `fields`, `allColumns` and `escapeFormulas` are mapped; any other option is refused by name.
updateRows(updates: Array<Record<string, unknown>>): voidupdates: Array<Record<string, unknown>>voidMerge a partial row into the row with its id, add a row with a new id, or delete one with `_action: 'delete'`. `_action: 'replace'` is refused by name.
Events

No events.

LatticeDataGridProps

What `<LatticeDataGrid>` takes: the DataGrid props it maps, and nothing else. Every other prop is refused once, by name, as `react:mui-prop-unsupported:<prop>`, and ignored.

Properties
PropertyTypeDescription
rowsreadonly any[]The rows, for a memory source. Each needs an `id`, unless `getRowId` says otherwise. (optional)
columnsreadonly LatticeDataGridColDef[]The column definitions. Hoist or memoise them: a new array rebuilds the columns.
loadingbooleanShow the loading overlay while true. (optional)
density'compact' | 'standard' | 'comfortable'Row density: `'compact'`, `'standard'` or `'comfortable'`. (optional)
checkboxSelectionbooleanA checkbox column, with a select-all box in its heading, and multiple selection. (optional)
disableRowSelectionOnClickbooleanA click on a row does not select it; only the checkbox does. (optional)
editMode'cell' | 'row'`'cell'` or `'row'` editing; `'row'` is what makes `onRowEditStop` fire. (optional)
sortModelLatticeDataGridSortItem[]The sort, applied when its identity changes. (optional)
filterModelLatticeDataGridFilterModelThe filter, applied when its identity changes. (optional)
rowSelectionModelLatticeDataGridRowSelectionModel | readonly unknown[]The selected rows, applied when its identity changes. An array of ids is taken too. (optional)
columnVisibilityModelRecord<string, boolean>`{ [field]: false }` hides a column; applied when its identity changes. (optional)
paginationModelLatticeDataGridPaginationModelThe page and page size, client-side over a memory source only. Over any other source it is refused by name and the rows are left untouched. (optional)
pageSizeOptionsReadonlyArray<number | { value: number; label: string }>The page sizes the pager offers; `[25, 50, 100]` by default. Memory source only. (optional)
apiRef{ current: LatticeDataGridApi | null }An object whose `current` is set to the {@link LatticeDataGridApi} while the grid exists. (optional)
slots{ toolbar?: unknown }Component slots; only `toolbar` is mapped, and it renders when `showToolbar` is true. (optional)
showToolbarbooleanRender `slots.toolbar` above the grid. (optional)
sourceSourceConfig | Record<string, unknown>Not an MUI prop: a Lattice `source` for rows that are not in memory. Over one, the three paging props are refused by name. (optional)
classNamestringApplied to the host element. (optional)
styleRecord<string, unknown>Applied to the host element, over its flex-column default. (optional)
Methods
MethodSignatureParametersReturnsDescription
getRowId(row: any) => string | numberrow: any=> string | numberRead a row's id, when it is not `row.id`. Read at call time, so an inline arrow is fine. (optional)
onSortModelChange(model: LatticeDataGridSortItem[], details: LatticeDataGridCallbackDetails) => voidmodel: LatticeDataGridSortItem[]
details: LatticeDataGridCallbackDetails
=> voidTold when the reader or `apiRef` changes the sort; never for the prop's own change. (optional)
onFilterModelChange(model: LatticeDataGridFilterModel, details: LatticeDataGridCallbackDetails) => voidmodel: LatticeDataGridFilterModel
details: LatticeDataGridCallbackDetails
=> voidTold when the reader or `apiRef` changes the filter. (optional)
onRowSelectionModelChange(model: LatticeDataGridRowSelectionModel, details: LatticeDataGridCallbackDetails) => voidmodel: LatticeDataGridRowSelectionModel
details: LatticeDataGridCallbackDetails
=> voidTold when the selection changes, always with an `'include'` model. (optional)
onColumnVisibilityModelChange(model: Record<string, boolean>, details: LatticeDataGridCallbackDetails) => voidmodel: Record<string, boolean>
details: LatticeDataGridCallbackDetails
=> voidTold when a column is shown or hidden, with every column's visibility. (optional)
onPaginationModelChange(model: LatticeDataGridPaginationModel, details: LatticeDataGridCallbackDetails) => voidmodel: LatticeDataGridPaginationModel
details: LatticeDataGridCallbackDetails
=> voidTold when the page or page size changes; memory source only. (optional)
onRowClick(params: LatticeDataGridRowParams, event: unknown, details: LatticeDataGridCallbackDetails) => voidparams: LatticeDataGridRowParams
event: unknown
details: LatticeDataGridCallbackDetails
=> voidTold when a row is clicked. (optional)
onCellClick(params: LatticeDataGridCellParams, event: unknown, details: LatticeDataGridCallbackDetails) => voidparams: LatticeDataGridCellParams
event: unknown
details: LatticeDataGridCallbackDetails
=> voidTold when a cell is clicked. (optional)
onCellDoubleClick(params: LatticeDataGridCellParams, event: unknown, details: LatticeDataGridCallbackDetails) => voidparams: LatticeDataGridCellParams
event: unknown
details: LatticeDataGridCallbackDetails
=> voidTold when a cell is double-clicked. (optional)
onCellEditStop(params: LatticeDataGridCellParams, event: { defaultMuiPrevented: boolean }, details: LatticeDataGridCallbackDetails) => voidparams: LatticeDataGridCellParams
event: { defaultMuiPrevented: boolean }
details: LatticeDataGridCallbackDetails
=> voidTold when a cell edit ends. The edit has ended; `defaultMuiPrevented` is not read. (optional)
onRowEditStop(params: LatticeDataGridRowParams, event: { defaultMuiPrevented: boolean }, details: LatticeDataGridCallbackDetails) => voidparams: LatticeDataGridRowParams
event: { defaultMuiPrevented: boolean }
details: LatticeDataGridCallbackDetails
=> voidTold when a row edit ends, in `editMode="row"`. (optional)
onColumnResize(params: { colDef: LatticeDataGridColDef | undefined; width: number; element: null }, event: unknown, details: LatticeDataGridCallbackDetails) => voidparams: { colDef: LatticeDataGridColDef | undefined; width: number; element: null }
event: unknown
details: LatticeDataGridCallbackDetails
=> voidTold when a column is resized. (optional)
onColumnOrderChange(params: { column: LatticeDataGridColDef | undefined; targetIndex: number; oldIndex: number }, event: unknown, details: LatticeDataGridCallbackDetails) => voidparams: { column: LatticeDataGridColDef | undefined; targetIndex: number; oldIndex: number }
event: unknown
details: LatticeDataGridCallbackDetails
=> voidTold when a column is moved. (optional)
onCellModesModelChange(model: Record<string, Record<string, { mode: 'edit' }>>, details: LatticeDataGridCallbackDetails) => voidmodel: Record<string, Record<string, { mode: 'edit' }>>
details: LatticeDataGridCallbackDetails
=> voidTold when a cell enters or leaves edit mode: `{ [id]: { [field]: { mode: 'edit' } } }`. (optional)
onRowModesModelChange(model: Record<string, { mode: 'edit' }>, details: LatticeDataGridCallbackDetails) => voidmodel: Record<string, { mode: 'edit' }>
details: LatticeDataGridCallbackDetails
=> voidTold when a row enters or leaves edit mode: `{ [id]: { mode: 'edit' } }`. (optional)
onDataSourceError(error: unknown) => voiderror: unknown=> voidTold when the grid's `source` reports an error. (optional)
Events

No events.

LatticeDesignerHandle

The live designer a `<LatticeDesigner>` ref exposes; `null` before mount.

PropertyTypeDescription
instanceDesigner | nullThe live designer the component built, or null before mount and after destroy. (read-only)
designerDesigner | nullThe same designer, under its own name. (read-only)

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

LatticeCalendarElement

`<lattice-calendar>`. Grid-bound like the board; give it `rows` to stand alone.

PropertyTypeDescription
rowsRow[]Rows for a standalone calendar. Set them and the element stops waiting for a grid.
cardunknownThe default card template's field mapping - title, subtitle and the rest.
headerunknownThe header toolbar sections, or `false` to render none.
colorMapunknownAn optional value→colour map for the `color-property` field.
labelsunknownHost-localised labels.
renderCardunknownA host card renderer `(row, el)`.
rowKeystring | undefinedWhich field identifies a card.
view'week' | 'month' | undefinedThe view to show: `week` or `month`. The `view` attribute.
dateunknownThe anchor date the visible range centres on.
startPropertystring | undefinedThe field naming a point event's start.
endPropertystring | undefinedThe optional field naming a span's end.
allDayPropertystring | undefinedAn optional boolean field marking an all-day event.
firstDaynumber | undefinedWeek-start weekday, 0=Sun…6=Sat.
localestring | undefinedBCP-47 locale.
timeZonestring | undefinedIANA time-zone name.
colorPropertystring | undefinedThe property whose value picks the card's edge colour.
eventOrderstring | undefinedCard stacking order within a day.
dayMaxEventsnumber | undefinedThe `+N more` overflow threshold per day.
readonlyunknownGranular readonly (whole calendar, per day, per card).
ariaLabelstring | undefinedThe calendar's accessible name.
MethodSignatureParametersReturnsDescription
move(key: unknown, to: { start?: Date | null; end?: Date | null }): Promise<boolean>key: unknown
to: { start?: Date | null; end?: Date | null }
Promise<boolean>Reschedule a card: move it to a new start (or resize its end), gated by `beforeMove` and announced by `card:move` (`lattice-beforeMove` / `lattice-card-move`). Resolves whether the reschedule stuck.

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.

StateApplyReport

PropertyTypeDescription
appliedstring[]The state keys that were restored, in application order.
skipped{ key: string; reason: string }[]The keys that were not restored, each with a reason in words a host can show - `not an array`, `unknown column`, `skipped by caller`, and so on. A partial restore is reported, never thrown.