developer guide
Licensing and recipes: JavaScript Data Grid Guide
How the licence key works, driving the grid from a language model through the intent layer, worked recipes for the shapes that come up most, and the rest of the surface in one place.
Developer guide › Licensing and recipes
Licensing
There is one Lattice Grid and every copy is feature-identical. No community edition, no pro tier, no feature held back behind a key. A licence removes the trial watermark; that is the whole of what it does.
Free to develop against, licensed to deploy. A grid on localhost, or any loopback host: needs no key at all. On any other domain an unlicensed grid still renders everything and carries a small trial watermark linking to latticegrid.dev.
| Where it runs | No key | Valid key |
|---|---|---|
| localhost, *.localhost, 127.0.0.0/8, ::1 | everything, no mark | everything, no mark |
| any other domain | everything, trial watermark | everything, no mark |
.local, .internal and private IP ranges are not exempt. They are ordinary LAN names, and a corporate intranet is a deployment like any other.
Installing a key
LatticeGrid.setLicence('LG1.…'); // your key; setLicense also works
grid.licence.state(); // 'licensed' | 'localhost' | 'trial'
Call it once, before creating a grid. Setting a key later still works, the watermark comes
off and licence:changed fires, but the first frames of the grid will carry it.
Keys come from latticegrid.dev and are issued per domain rather than per developer or per seat: name the domains the grid will run on and
one key covers every developer, every build and every user on them. A key names the domains
it covers as you would expect: *.acme.com matches app.acme.com,
a.b.acme.com and acme.com itself.
Checking a key needs no network. There is no licence server, no call home, and nothing that can fail at three in the morning, a key carries its own answer and the grid reads it locally, so a grid on an air-gapped network behaves exactly like one on the open internet.
Nothing ever refuses to render, and nothing is ever withheld. An expired key, a wrong domain, a key that will not read: all of them log one warning and show the watermark. Every feature keeps working. The failure to avoid is a customer's production screen going blank because a licence lapsed over a weekend, and a grid that quietly drops a feature is the same failure wearing a disguise.
Driving the grid with a model
The grid describes its own columns and operators, you send that to whichever model you like, and it validates the reply before anything is applied. It makes no network call and has no default provider.
The loop
ai: {
async ask({ message }) {
const res = await yourModelClient.complete({
model: 'your-model-of-choice', messages: [{ role: 'user', content: message }],
});
return res.text;
},
}
That mounts a prompt bar. The user types "EMEA circuits over 500 gigs, biggest first"; the grid composes a message including its schema; your callback returns the model's reply; the grid validates it and shows what it would do in plain English, "Filter Region is EMEA and Capacity is more than 500, sort Capacity descending", with Apply and Discard.
Nothing is executed on trust. The vocabulary is seven actions,
setFilters, setSort, groupBy,
showColumns, hideColumns, setQuick,
clear, and a reply naming a column that does not exist is rejected with a
reason while the valid actions in the same reply are kept. A model cannot be talked into an
operation the vocabulary does not contain, because there is nothing else to call.
Applying is one undo entry, labelled with what it did. docs/AI-SKILL.md is the
reference to hand your model.
Recipes
Put the view in the URL
grid.on('state:changed', () => {
const encoded = btoa(JSON.stringify(grid.state.get()));
history.replaceState(null, '', `?view=${encoded}`);
});
const saved = new URLSearchParams(location.search).get('view');
if (saved) grid.state.apply(JSON.parse(atob(saved)));
Save edits as they happen
grid.on('cell:changed', async (e) => {
if (e.undo) return; // a rollback, not a new change
grid.highlight({ key: e.key, colId: e.colId }, { colour: '#fff3cd', duration: 0 });
try {
await api.patch(`/rows/${e.key}`, { [e.colId]: e.value });
grid.highlight({ key: e.key, colId: e.colId }, { colour: '#d4edda', duration: 900 });
} catch {
grid.highlight({ key: e.key, colId: e.colId }, { colour: '#f8d7da', duration: 0 });
grid.history.undo();
}
});
A read-only grid
createGrid(el, {
columns, rows, rowKey: 'id',
edit: false,
contextMenu: false, // the default menu offers Paste, Clear and Fill down
columnMenu: false, // optional: the header's 3-dot menu
selection: { ranges: false },
});
A dashboard grid, no chrome
createGrid(el, {
columns, rows, rowKey: 'id',
edit: false, contextMenu: false, selection: 'none',
rowHeight: 24, density: 'compact',
grandTotalRow: 'bottom',
});
House-wide defaults, without patching createGrid
createGrid is exported through a getter with no setter, so
LatticeGrid.createGrid = myWrapper does not replace it - silently in a plain
script, with a TypeError in a module (see the reference for the exact descriptor). Own the seam
yourself instead: one module that every call site imports from.
// lattice.js
import { createGrid as baseCreateGrid } from '@toclocoinc/lattice-grid';
export function createGrid(element, config) {
return baseCreateGrid(element, { theme: 'house', locale: 'en-GB', ...config });
}
There is no shipped defaults() call that does this for you today (a separate card,, is considering one) - a wrapping module you own and every call site imports is
the supported pattern until then.
Options and calls you may not have met
The rest of the surface, in one place. Each of these is documented in full in the reference; this is here so that reading the guide end to end leaves nothing you have never heard of, which is the difference between a guide and a tour of the parts we found most interesting.
Configuration
| Name | What it does |
|---|---|
| autoHeight | autoHeight. |
| bom | Write the UTF-8 byte-order mark at the head of a CSV export, so Excel detects the encoding instead of guessing at it. Off by default. (optional) |
| bucketFn | Replace the built-in bucketing entirely. (optional) |
| coalesceMs | (optional) |
| columnGroups | Header grouping declared separately from the columns. |
| columnPresets | Named bundles applied with preset: 'money'. |
| columnVirtualisationAbove | Column count above which columns virtualise too. |
| delimiter | (optional) |
| enterMovesDown | (optional) |
| fillHandle | (optional) |
| granularity | hour … year. Chosen from the span when omitted. |
| groupFooter | A closing total row per group. |
| groupSelectsChildren | (optional) |
| groupSelectsFiltered | (optional) |
| headerHeight | Per header row. |
| historyBar | A standalone undo/redo toolbar with a timeline. |
| hostFilter | An application-level predicate composed with the grid's own filters. |
| idleMs | Silence after which a peer is shown idle. (optional) |
| indexLimit | Cell descriptors held before the oldest are dropped. (optional) |
| lineEnding | (optional) |
| lockMs | Silence after which a peer's edit claim is disregarded. (optional) |
| maxCachedPages | (optional) |
| maxDecimals | (optional) |
| me | (read-only) |
| minDecimals | (optional) |
| overscan | Rows rendered beyond the viewport. |
| pageSizes | (optional) |
| pinnedBottomRows | As pinnedTopRows, held below the body instead. Sits under the grand total when both are shown. |
| pipes | Template pipes for cell.template. |
| processCell | (optional) |
| promoteToMemoryBelow | (optional) |
| quickFilterText | Initial quick-filter term. Equivalent to grid.filters.quick(text). |
| find | The in-grid find bar (Ctrl+F): false removes it, { shortcut, debounce } tunes it. See Find. |
| quote | (optional) |
| removeMs | Silence after which a peer is dropped. (optional) |
| showHeader | Draw the column headings at all. false removes the row, and removes it from the accessibility tree rather than only from view. Distinct from showColumnFunctions, which keeps the headings and drops only their sort, filter and menu controls. |
| throttleMs | Milliseconds between published updates. Throttled, not debounced. (optional) |
| totalFns | Custom aggregations, addressable from column.total. |
| undoDepth | (optional) |
Factories and registration
| Name | What it does |
|---|---|
| createChart | Draw one of the thirty-seven chart types from a grid’s own data. It follows the grid’s filters, and a mark can filter the grid back. |
| chartRange | Chart a selected cell range - the spreadsheet gesture. Derives the chart from the range’s shape (a leading text column is the categories, the numeric columns the measures), respects hidden and unreadable columns, and returns the live chart or null when there is nothing to measure. |
| canChartRange | Whether chartRange would draw something for the grid’s current selection - the question a menu asks before offering the item. |
| deriveRangeSpec | Decide what a chart of a range should be without drawing it: the type, the category column, the measures, and a spec ready for createChart. |
| regressionPlots | Turn a fitted regression model into diagnostic chart specs ready for createChart: the fit line with its confidence band, residuals-vs-fitted, a QQ plot of the residuals, and a multicollinearity correlogram with the model’s VIF. The plots that need a per-row or per-coefficient quantity the grid has no column for (scale-location, residuals-vs-leverage, the coefficient forest) are returned as a null spec carrying the reason rather than dropped. |
| createDataRouter | Split one arriving stream or dataset across many grids by what each record is - a property or a predicate - driving each grid through the public keyed rows.apply path so a snapshot is a diff, a delta is applied in place, a moved partition moves the row rather than duplicating it, and an unmatched record is counted, sunk and never dropped. The router never opens a connection itself - it takes rows, not a URL or a socket - so the host owns the connection (a WebSocket, SSE, CDC, long-poll, anything) and this module owns everything once a message has arrived; see a live WebSocket feed for the worked, runnable example. By default routing is first-match-wins (overlap: false); to fan one partition value to several viewers at once (a grid and a KPI panel and a chart off one feed) create the router with overlap: true - with the default, a second viewer on the same value receives nothing and the router emits a one-time dev warning naming the clash. v2 adds cross-grid selection filtering: link(source, target, relation) makes a selection in one grid filter what another receives - by a key map or a predicate function, multi-select as an IN set, debounced - re-pushed through the same keyed-diff path so the target stays dumb. v5 adds wedge-conversion primitives: subscribe(value, handler) routes a slice to any non-grid view (KPI tile, detail pane, map, form) as the same keyed diff a grid gets; alert(value, condition, handler) evaluates a condition over a slice and emits (edge-triggered, debounced) rather than rendering; and configure(spec) (or createDataRouter({ config })) takes the whole routing graph as one declarative data spec that desugars to the imperative API and composes with it. v3 also adds per-route reshaping - transform/filter/sort and rollup ({ groupBy, aggregate }) summaries - a relationship graph (relate(edges): multi-hop, several-into-one AND, and mutual edges) that scales v2's pairwise link, and stream hygiene: a seq/version orders and de-duplicates a feed (stale/duplicate deltas dropped, counted in dropped), push with a batch/coalesce buffers a high-frequency feed (flushStream for a deterministic point), and lastSeq/checkpoint/seenThrough let the host resume precisely after its own connection drops and reconnects. v4 adds time-travel: buffer({ window, max }) records the ordered stream into a bounded ring over a moving base, so scrubTo reconstructs a past point, replay (with pause/resume) walks a range, and live returns to the head - every state pushed by the same keyed diff, traveling/buffered reporting the state. v6 adds cross-tab sync: broadcast({ channel }) mirrors the ordered, de-duplicated deltas to other tabs/windows over a BroadcastChannel with no echo loop, a popped-out grid joining the same feed with no second socket and resyncing mid-stream via the reconnect path. v7 adds query-slice routing: query(adapter, request) sources the router from a DFQL/DuckDB (or any pushdown) adapter, partitioning one result across the routes; a route-level where is pushed down where the adapter's capabilities allow and the residual finished client-side, with lastQueryPlan reporting the split. v8 adds write-back: a writable route captures the grid's committed edits off its public edit surface and routes them to onWrite(change, ctx) (per-route or router-global), reverting on reject, re-entering an accepted write as a delta, and surfacing a last-write-wins onConflict; a derived route cannot be writable. v9 adds fan-in: addSource(feed, { map, key }) returns a per-feed handle (load/apply/push/remove) whose rows are normalized and namespaced so many feeds merge into one keyed store without id collisions, removeSource dropping exactly a feed's rows and sources listing them. v10 adds observability: metrics() is a cheap snapshot of per-route/per-source counts and throughput plus the global unrouted/dropped/buffered/lag figures, on('metrics') drives a periodic emit (off unless a listener is registered), and mountDevtools(el) renders a live panel from the module's own DOM file. v16 adds a SPREAD join: addSource('company', { join: { from: 'companyAttr', foreignKey: 'companyId', spread: { name: 'attribute', value: 'value', prefix: 'cf_' } } }) turns each EAV attribute row into its own field on the parent (gaining cf_industry, cf_tier as attributes arrive), reported per route through fieldsOf(ref) and the fields:changed event so a host can add or drop grid columns as custom attributes appear and disappear. v18 adds parent-delete integrity: onParentDelete: 'cascade' | 'orphan' | 'keep' on a collect join or an unnest, and standalone relateRows({ child, parent, foreignKey, onParentDelete, writeBack }), decide what deleting a parent does to its children - cascade deletes them (recursively, with cycle protection), orphan keeps them marked __orphan: true and warns once, keep leaves them bare; cascaded deletes write back only when writeBack: true. Detaches its grids on destroy; the host owns them. |
| createKanban | A board (kanban) view of rows as cards, grouped into columns by a configurable property (a status, a stage, a state field), with per-column card count and an optional points sum, a WIP over-limit flag, configured columns shown even when empty, granular readonly (whole board / per column / per card), field-mapped card templates that reuse the grid’s own column formatters when a grid is bound, and the core pointer events (card:click, card:dblclick, card:contextmenu). It is a dataset viewer like any other: it consumes data through the same keyed-diff rows.apply({ add, update, remove }) contract a grid exposes, so a Data Router can attach(value, board) and drive a kanban beside a grid and a chart off one feed. Accessibility is built in from the start - a labelled group of labelled column lists, cards in a roving-tabindex focus ring with arrow-key navigation, and a polite live region. Every structural property is named in config (columnProperty, pointsProperty, orderProperty, swimlaneProperty, sprintProperty, epicProperty) so it maps any customer schema without code change. Pass null as the element for a headless board that computes the same model without a DOM. Cards drag between columns (writing the column property) and within a column into a position (writing the order property with fractional ranking), and the same move is keyboard-accessible - Space to grab, arrows to choose a target, Space/Enter to drop, Escape to cancel - announced on the live region; multi-select drags every selected card. A move calls onBeforeMove(card, from, to, index) first (return false to veto) and then persists through the grid’s shipped write-back path - grid-bound via grid.edit.setCells (the same public edit-commit path inline editing uses, so the grid’s pipeline owns optimistic apply / confirm / revert), standalone with a revert when onCardMove rejects - emitting card:move. A configurable per-card contextMenu replaces the card:contextmenu event when present. With swimlanes: true it renders a 2D lane×column grid grouped by swimlaneProperty (per-lane count/points, columns aligned across lanes, a cross-lane drag writing the swimlane property); columns and lanes collapse (state surviving a keyed-diff update), columns reorder by header drag, and setQuickFilter/setFilter/facets drive search and faceting. setSprint/showBacklog/sprints give a sprint view, switcher and backlog; setEpic/epicRollup/rollup give an epic view and rollups (count, points, progress toward the done columns). A card can pop out a nested grid of its children (an epic's stories, a story's tasks, recursively) via a childrenProperty and/or loadChildren(card): the child is a full composed createGrid (or, with asBoard, a nested board) opened in a drawer/modal/inline container - reuse by composition, no grid-core coupling - emitting card:expand/card:drill. Live updates arrive through the same keyed-diff contract a grid uses, so a Data Router drives the board directly (attach(board, predicate)); on a standalone board (not grid-bound, which ignores rows.apply and routes to the bound grid instead) rows.apply({ update }) merges each patch into the row already stored under its key, matching the grid's own rows.apply({ update }) - a field the patch omits is preserved, and a patch that does change the grouping property still moves the card - while add sets the row outright; a live rows.apply re-renders preserving scroll, focus, selection, collapse and any open pop-out. virtualize renders only a scroll window of a tall column; getState/setState (and config.state) save and restore collapse, order, filter and sprint/epic selection; setLoading/setError give loading and error states. Columns fill the board by default: columnWidth: 'fill' shares the width equally (never below minColumnWidth, default 240px), a number fixes every column at that many pixels, and 'auto' keeps the natural token width, while a host-set --lattice-board-column-width still overrides. The move is fully keyboard-driven - Space to grab, arrows for column/position, Alt+Up/Down across swimlanes, Space/Enter to drop, Escape to cancel - announced on a live region. A field opted in with card: { title: { field, edit: true } } edits inline (double-click or editCard): grid-bound through the grid's own field editor via its public edit path, standalone through a host editor factory or a default input with an onCardEdit revert; a per-column add-card (config.addCard/onAddCard, or grid.edit.addRow) creates a card and opens it in edit. The module imports nothing from the grid's DOM package. |
| createCalendar | Create a calendar viewer (module calendar) of rows as cards on a month grid, a week day-grid or a week-time/day time grid, keyed by a date field. Point events by default (startProperty); an endProperty turns them into multi-day spans, drawn as one continuous bar across the days they cover and clipped at the range edges. The time grid adds a vertical hour axis: a row with a start time is a block positioned at its start and sized by its end (defaultDuration when none), overlapping blocks split the column side by side, all-day rows stack in a top all-day row, businessHours shades the working span, and a now line marks the current time in the current day column. slotDuration, slotMinTime/slotMaxTime, scrollTime and weekNumbers tune the axis, and times read through the timeZone rule. The built-in header (prev/next/today, the SEP 28 - OCT 4, 2026 / SEPTEMBER 2026 title via Intl.DateTimeFormat(locale), and the week/month toggle) renders unless header: false; cells grow with their cards (no inner scroll). A field-mapped card template plus a host renderCard(row, el) whose element stays mounted across updates (the async image-swap contract), colorProperty/colorMap edge colouring, eventOrder, dayMaxEvents +N more overflow, and granular readonly. It is a dataset viewer like any other: it consumes data through the same keyed-diff rows.apply({ add, update, remove }) contract (plus rows.get(key)) a grid exposes, so a Data Router can attach(value, calendar) and drive a calendar beside a grid and a chart off one feed. Events: card:click/card:dblclick/card:contextmenu { row, key, clientX, clientY, nativeEvent }, card:mount { row, key, el }, and range:change { start, end, view } on every prev/next/today/goTo/setView (not on a data update). Navigation: prev()/next()/today()/goTo(date)/setView(view)/getRange(). Accessibility is built in - role="grid" with columnheader/gridcell, a roving-tabindex focus ring, arrows by day/week (by slot in the time grid), PageUp/PageDown by period, a live region, an announced time slot on each timed block - and getState()/setState() round-trip the arrangement ({ version: 2, view, date, firstDay, slotDuration, scrollTime }). Pass null as the element for a headless calendar that computes the same model without a DOM. UMD global LatticeGridCalendar. |
| createCalendar | Create a calendar viewer (module calendar) of rows as cards on a month grid or a week day-grid, keyed by a date field. Point events by default (startProperty); an endProperty turns them into multi-day spans clipped at the range edges and marked continuing. The built-in header (prev/next/today, the SEP 28 - OCT 4, 2026 / SEPTEMBER 2026 title via Intl.DateTimeFormat(locale), and the week/month toggle) renders unless header: false; cells grow with their cards (no inner scroll). A field-mapped card template plus a host renderCard(row, el) whose element stays mounted across updates (the async image-swap contract), colorProperty/colorMap edge colouring, eventOrder, dayMaxEvents +N more overflow, and granular readonly. It is a dataset viewer like any other: it consumes data through the same keyed-diff rows.apply({ add, update, remove }) contract (plus rows.get(key)) a grid exposes, so a Data Router can attach(value, calendar) and drive a calendar beside a grid and a chart off one feed. Events: card:click/card:dblclick/card:contextmenu { row, key, clientX, clientY, nativeEvent }, card:mount { row, key, el }, and range:change { start, end, view } on every prev/next/today/goTo/setView (not on a data update). Navigation: prev()/next()/today()/goTo(date)/setView(view)/getRange(), and rebind(grid) points a grid-bound calendar at a replacement grid after an htmx swap (a no-op on a standalone calendar). Accessibility is built in - role="grid" with columnheader/gridcell, a roving-tabindex focus ring, arrows by day/week, PageUp/PageDown by period, a live region - and getState()/setState() round-trip the arrangement ({ version: 1, view, date, firstDay }). Pass null as the element for a headless calendar that computes the same model without a DOM. UMD global LatticeGridCalendar. |
| createKPI | A KPI / stat-tile view (module kpi) of a dataset as a panel of aggregate tiles - each tile a sum, avg, min, max, count, countDistinct or a custom reducer over the routed rows, with an optional filter predicate, number format (number/currency/percent/compact), a baseline for a delta, semantic threshold bands (thresholds with two cut points and a direction, or an explicit bands list, giving a good/warn/critical status kept separate from any accent), and an optional sparkline series. It is a dataset viewer like any other: it consumes data through the same keyed-diff rows.apply({ add, update, remove }) contract a grid exposes, so a Data Router can attach(value, kpi) and drive a KPI panel beside a grid, a kanban and a chart off one feed. Updates are incremental - a delta adjusts each tile's running accumulator by only the rows it carries (an add contributes, a remove reverses, an update reverses-then-contributes), the two bounded exceptions being a min/max whose current extreme is removed (a rescan of that tile's own value multiset) and a custom reducer (recomputed over the filtered store, an arbitrary function having no inverse). Each tile is a labelled <figure>, focusable and keyboard-activatable, its value announced, the sparkline respecting prefers-reduced-motion; a tile:click event (also from the keyboard) lets a host drill down or filter a routed grid. Pass null as the element for a headless panel that computes the same tile model without a DOM. Not a dashboard layout engine (that is the parked dashboard generator) and no charting beyond the minimal sparkline (that is the charts module). |
| createAI | Create an AI narrative / insights controller (module ai) over a live grid. It produces a short, plain-language narrative of the grid's computed figures - a per-KPI / per-chart / per-column explain, or an insights panel over the current filtered view. The grid makes no AI call of its own: createAI imports no provider SDK, holds no key, and makes no network request; it calls one host callback, ask({ system, messages, prompt, tools?, schema?, signal }) - your model, your key, your privacy decision - the same philosophy as the data adapters. Two grounding paths feed one guard: where the provider offers tool-use the model is given a curated read-only tool set (getSchema, getProfile, getStatistics, getForecast, runQuery) and the grid's own engine computes what it asks for; otherwise the module builds a facts packet from grid.statistics/the profile/forecasts/view counts and passes it in the prompt. Every figure in the narrative is reconciled against the values the engine produced this render - an ungrounded number is stripped before display (the number-reconciliation guard), so a hallucinated figure never reaches the user. The prompt is constrained to narrate-only; the layer is read-only and never mutates data. redact (a column id, a list, or a predicate) and maxRows bound what the module hands ask(), and the module sends nothing itself; an ask() error surfaces a friendly message and the grid stays fully usable, AI being additive rather than load-bearing. Complementary to grid.ai (the intent/plan skill layer): pass no ask and it adopts the grid's configured ai.ask, running the facts-packet path over it. Pass a headless grid for a DOM-free narrative; facts(target) returns the exact grounded packet without calling ask(). UMD global LatticeGridAI. |
| createTabs | Create a tabbed grid (module tabs): a role="tablist" strip above a stack of role="tabpanel" regions, each hosting its own, independently-configured createGrid instance - "configure each tab as per a normal grid" rather than one grid whose state is swapped (ColumnModel#applyState only repositions/hides/resizes existing columns by id; it carries no field, type or row data, so a state-swap only works when every tab shares one schema). createGrid is injected (createTabs(el, { createGrid, tabs })), the same pattern the React/Vue/Svelte adapters use, so the module imports no engine code and adds nothing to a page that does not load it. A tab that names from: '<tabId>' gets a source: { mode: 'derived', from: <the parent tab’s live grid>, where, group, join, … } wired for it automatically - reusing the shipped derived-source mechanism rather than a new config-inheritance one - and activating a derived tab materialises its whole ancestor chain first; a cyclic from graph is refused (naming the exact cycle) when createTabs is called, not at first click. A tab’s grid mounts on first activation and then stays alive, hidden, so its scroll/selection/filters/sort/grouping/expansion - and an open cell/row editor, left exactly as it was, uncommitted and undiscarded - survive a switch natively; destroy() tears every mounted tab down. The strip is a real tablist with aria-selected, a roving tabindex, and manual-activation keyboard handling (arrows/Home/End move focus, Enter/Space or a click activates). Events: tab:changed, a cancellable beforeTabChange paired with tabChange:cancelled. UMD global LatticeGridTabs. |
| createLayout | Create a reconfigurable dashboard layout (module layout): a cell grid inside an element, and a set of windows on it that a user can move, resize and close by drag or by keyboard - the surface a customer would otherwise reach for GridStack to get. It is payload-agnostic: a window body is a div with an id that the module creates, sizes and never reads, so it imports no engine code at all (not even createGrid) and the whole module is about 17,500 bytes gzipped on the built bundle, of which roughly 2KB is the shared module runtime. columns/rows divide the element; overflowX and overflowY are independent axes, each 'static' (tracks divide the container with minmax(0, 1fr)) or 'scroll' (tracks take a fixed columnWidth/rowHeight and the canvas extends past the viewport, so a column keeps the size it asked for - measured: shrinking a 600px host to 300px leaves a 200px column at 200px). Spacing takes a real CSS length: a number of pixels, or '200px', '25%', '1fr', '2rem'; anything else is refused by name and replaced by the default, because the value reaches an inline style. Windows are placed by 1-based xPos/yPos/xSize/ySize, or auto-placed in the first free cell; chrome defaults on, and closable/movable/resizable all default off, so a fixed dashboard is fixed without opting out. corners: 'rounded' (the default, today's look) is themed via --lattice-layout-radius, a token scoped to this module rather than the shared --lattice-radius every other module reads; corners: 'square' zeroes the radius on the window, its title bar, the minimised strip and the maximised frame - all the same frame element in a different state, so one rule reaches every one of them - with no other visual change, and a window's own corners overrides the layout's. compact: 'vertical' pushes displaced windows down then pulls them up (window:moved carries both to and landed); 'none' keeps every window where it is put. closable/movable/resizable each also take a layout-level default of the same name, which a window's own boolean overrides, and setInteractive(true|false|{movable, resizable, closable}) changes that default at runtime - the “Edit layout” button - without destroying the layout or any payload in it, with getInteractive() reading it back. Locking always wins and unlocking never overrides an opt-out: setInteractive(false) pins a window that declared movable: true, while setInteractive(true) leaves a window that declared movable: false pinned. The config key and the method are deliberately different: movable: false in the config states the default for windows that declare nothing and takes nothing away from one that opted in, whereas setInteractive(false) is an active lock. getInteractive() is three-valued - undefined for unset, true, or false for a lock - and a key carrying undefined is treated as absent, so setInteractive(getInteractive()) is a no-op in every state. It moves both halves of the enforcement, the rendered handles and the pointer and keyboard gesture checks, and fires no event because a mode is not an arrangement - getLayout() neither carries it nor restores it. A locked layout is not a read-only dashboard: what is inside a window is configured with that payload's own settings. maximise(id), minimise(id) and restore(id) are the display modes, with maximised() and minimised() reading them back: maximise fills the layout host rather than the browser window (no position: fixed, whose containing block is the nearest ancestor with a transform or a contain; no reparenting; nothing that can disturb the page around the dashboard), hides the other windows, runs no compaction at all and keeps the payload container as the very same DOM node - and Escape restores it from anywhere inside the layout. minimise draws a window as a single row and hides its payload while its chrome stays to carry the way back, so the windows below pull up on screen; in the arrangement nothing moves at all, because the collapse is a projection of the dashboard rather than a change to it, so restoring gives back exactly the arrangement that was there in any order and with any number of other windows still collapsed. A chrome: false window is refused by name. Both controls are opt-in per window (maximisable, minimisable) with a layout-level default of the same name, and setInteractive() deliberately does not touch either: a mode is not an arrangement, so neither appears in getLayout(), which reports the underlying placement in both states. Keyboard parity with the drag: a focusable handle per window running the kanban board's grab/move/drop/cancel model, with a polite live region announcing grabbed, every tentative position, dropped, cancelled and reverted. It owns exactly one ResizeObserver for the whole layout, over two targets, and tells payloads their new content box through window:resized - it never calls into a payload, because it cannot know what one is. Events: window:moved, window:resized, window:closed, layout:changed, the cancellable beforeWindowMove/beforeWindowResize/beforeWindowClose and their *:cancelled pairs; drag progress is not emitted per frame. getLayout()/setLayout() round-trip the arrangement as plain JSON, and getState()/setState() are the versioned pair. Closing a window does not destroy its payload - the container is handed back on window:closed and the host owns that lifecycle. UMD global LatticeGridLayout. |
| createDevtools | Mount the devtools panel against a grid, including its accessibility checks. |
| createGantt | Create a project-planning Gantt controller (module gantt) over a task list and a dependency list. A CPM engine (computeSchedule) computes each task's early/late start and finish, its slack and the zero-float critical path, honouring the four link types (LINK_TYPES: FS/SS/FF/SF) with lag, and recomputes on every edit - emitting schedule or, on a dependency cycle or bad input, error (a code from SCHEDULE_ERROR). Milestones are zero-duration points; summary (WBS) tasks are derived from their children (earliest start, latest finish, weighted progress) rather than scheduled; findViolations flags a task placed earlier than its predecessors allow, and toISODate maps an engine day-number back to a calendar date. |
| createResourceView | Create a standalone resource-load band (module gantt,: the workload band on its own, with resources and bookings and no task Gantt, no plan and no CPM - a resource-availability view by department, portfolio or scenario. It reuses the split view's workload band rather than forking it (createGantt with no tasks, then mountSplit in a resource-only mode that hides the task table and the timeline bars and fills the view with the band), so day/week/month/quarter buckets with the scale header and zoom, hours/percent/cost units with utilisation thresholds, expand-to-bookings, the workload:click bucket event, its own grid-backed column definitions and width round-trip, and SVG export all come across unchanged. Bookings are { resource, start, end, units | hours, label, group } (the workload.external shape); setBookings applies a changed set live, and the time-bucket columns never carry a grid control. UMD global via the gantt module. |
| createLatticeGridElement | Build the element class without registering it, for a custom registry. |
| createMessages | Build a message catalogue. A partial set lays over the built-in British English one. |
| defineLatticeGrid | Register <lattice-grid>, or your own tag name. |
| defineLatticeElements | Register one custom element per Lattice viewer - <lattice-kpi>, <lattice-chart>, <lattice-kanban>, <lattice-gantt>, <lattice-layout>, <lattice-tabs> and <lattice-router> - from the module factories your page has imported, under a configurable tag prefix. Nothing is imported by the web component module itself, so a page that draws no charts never loads the charts bundle; the grid element is already registered on import. Structures (columns, rows, tiles, tasks, windows, a geometry pack) are properties and scalars work as attributes too, a property assigned before the element upgrades is honoured, a change is diffed into the live instance rather than rebuilding it, and a viewer declared before its grid mounts when the grid arrives. Safe to call twice: an already-registered tag is handed back. |
| createLatticeElements | The same element classes without registering them, for a page that owns its own tag names or its own registry. |
| createLatticeKPIElement | The <lattice-kpi> class on its own, given the KPI module's factory. Grid-bound through grid-name by default; set rows for a standalone panel. |
| createLatticeChartElement | The <lattice-chart> class on its own. A chart is always drawn from a grid, so nothing is built until one is published and a different grid builds a new chart; every other attribute is the chart spec and a change goes into chart.update(). |
| createLatticeKanbanElement | The <lattice-kanban> class on its own, given the board's factory. |
| createLatticeCalendarElement | The <lattice-calendar> class on its own, given the calendar's factory. Grid-bound through grid-name by default; set rows for a standalone calendar. |
| createLatticeGanttElement | The <lattice-gantt> class on its own; tasks and dependencies are live properties. |
| createLatticeLayoutElement | The <lattice-layout> class on its own; windows is a property. |
| createLatticeTabsElement | The <lattice-tabs> class on its own. A tab's content is a <template data-tab="‹id›"> child cloned into the panel the module creates for it, so a tab can hold a real <lattice-grid>; a tab with no template is built by the module from its configuration, and the two kinds mix on one strip. |
| createLatticeRouterElement | The <lattice-router> class on its own. It is a grouping element: it 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. |
| adoptLatticeStyles | Make a host's shadow root see Lattice's styles, and keep it seeing them. The theme, each module's injected stylesheet and the grid's growing generated one are mirrored into the root as constructable stylesheets and kept level with their sources, so a class compiled later still arrives; a sheet that cannot be read as text (cross-origin, or carrying @import) is cloned into the root as a link instead. Every Lattice element calls it for its own root on connect, so this is for a host that mounts Lattice inside a shadow root by other means. Returns the undo. |
| defineUnit | Add one unit to a system, or override one of ours under the same name. |
| registerChartType | Register an extension chart type so createChart({ type }) can draw it. Extension types ship as their own opt-in modules (e.g. modules/chart-ridgeline), so the base charts bundle does not grow for a type a caller never imports - you pay only for the charts you use. registeredChartTypes() lists what is registered. |
| createFrameClock | Make the clock the animated chart types run on: a time, a play state and a speed. Pass the same clock as clock to a barRace and a motion chart (modules/chart-barrace, modules/chart-motion) and they play, pause and scrub together; clock.subscribe lets a host move its own filter with it. |
| registerModules | Install optional modules once, for every grid on the page. |
| registerScheme | Add a colour scheme, or replace one of ours under the same name. |
| restoreStateWithin | Put it back afterwards. |
Alarms. createAlarms (modules/alarms) is the opt-in
module that turns data which is already graded into alarm:raised and
alarm:cleared events: attach a KPI panel, a grid's columns or a router route, set
a holdMs hold-down so a flapping value cannot page anyone, and read
active() and pending() for the current state. See
the alarms module.
Grid methods
| Name | What it does |
|---|---|
| attachRenderer | Bind a renderer to a headless grid. |
| emit | Emit on the grid's bus, for custom components. |
| getPinnedRows | The objects pinned at one edge, as a copy. |
| rendererHost | The host object a renderer reads: columns, rows, callbacks. Deliberately a plain bag rather than the grid itself, so a renderer cannot reach into core internals. You need this only when writing a renderer of your own. |
| setAll | Write several in one pass. Emits one config:changed for the batch, not one per key. |
Module configuration
The optional module bundles take their own configuration object rather than extending
GridConfig. Each is documented in full in the
reference; these are the keys most easily missed, listed here so that
reading the guide end to end leaves nothing you have never heard of. A callback named
on… is a second route to an event the module already raises: passing the
callback in config and binding the event both work, and both fire.
| Name | What it does |
|---|---|
| onBeforeTabChange | Tabs (TabsConfig). Cancellable gate before the active tab changes: return false, or call preventDefault() on the event, to veto the switch - an unsaved edit, say. A veto fires onTabChangeCancelled instead of the change. May return a promise, in which case the switch waits on it. |
| onTabChange | Tabs (TabsConfig). Fires after the active tab has changed. The config route to the tab:changed event. |
| onTabChangeCancelled | Tabs (TabsConfig). Fires when onBeforeTabChange vetoed a switch, carrying the resolved reason. The config route to the tabChange:cancelled event. |
| onBeforeWindowClose | Layout (LayoutConfig). Cancellable gate before a window closes: return false to veto, which fires onWindowCloseCancelled instead. May return a promise. |
| onBeforeWindowMove | Layout (LayoutConfig). Cancellable gate before a window moves: return false to veto, which fires onWindowMoveCancelled instead. May return a promise. |
| onBeforeWindowResize | Layout (LayoutConfig). Cancellable gate before a window resizes: return false to veto, which fires onWindowResizeCancelled instead. May return a promise. |
| onLayoutChanged | Layout (LayoutConfig). Fires after a layout change with the full layout snapshot - the shape you persist and restore. The config route to the layout:changed event. |
| onWindowCloseCancelled | Layout (LayoutConfig). Fires when onBeforeWindowClose vetoed a close. The config route to the windowClose:cancelled event. |
| onWindowClosed | Layout (LayoutConfig). Fires after a window has closed. The config route to the window:closed event. |
| onWindowMoveCancelled | Layout (LayoutConfig). Fires when onBeforeWindowMove vetoed a move. The config route to the windowMove:cancelled event. |
| onWindowMoved | Layout (LayoutConfig). Fires after a window has moved. The config route to the window:moved event. |
| onWindowResizeCancelled | Layout (LayoutConfig). Fires when onBeforeWindowResize vetoed a resize. The config route to the windowResize:cancelled event. |
| onWindowResized | Layout (LayoutConfig). Fires after a window has resized. The config route to the window:resized event. |
| nullText | KPI (KPIConfig). The placeholder rendered in place of a null tile value. Defaults to an em dash. |
| onChange | KPI (KPIConfig). Fires after every update with the resolved model: its tiles, and its nodes when the strip is a tree. The config route to the change event. |
| onNodeToggle | KPI (KPIConfig). Fires when a branch of a KPI tree opens or closes, with the node key and its new expanded state. The config route to the node:toggle event. |
| onTileClick | KPI (KPIConfig). Tile click handler, for drilling into what a tile summarises. The config route to the tile:click event. |
| onTileContextMenu | KPI (KPIConfig). Tile right-click handler. The config route to the tile:contextmenu event. |
| onTileDblClick | KPI (KPIConfig). Tile double-click handler. The config route to the tile:dblclick event. |
| ariaLabel | Kanban (KanbanConfig). The board's accessible name. Defaults to Board. |
| columnOrder | Kanban (KanbanConfig). An explicit column order, by column id. |
| doneColumns | Kanban (KanbanConfig). Column ids that count as done when a rollup computes progress. A column definition's done: true says the same thing. |
| emptyText | Kanban (KanbanConfig). Host-localised placeholder shown in a column holding no cards. Empty by default, which shows no placeholder at all. |
| enforceWip | Kanban (KanbanConfig). Enforce wipLimit as a hard gate: a move that would take a column over its limit is refused rather than merely flagged. Default false. |
| laneOrder | Kanban (KanbanConfig). An explicit swimlane order, by lane id. Also written by a lane-header drag. |
| onCardClick | Kanban (KanbanConfig). Card click handler. The config route to the card:click event. |
| onCardContextMenu | Kanban (KanbanConfig). Card right-click handler. The config route to the card:contextmenu event. |
| onCardDblClick | Kanban (KanbanConfig). Card double-click handler. The config route to the card:dblclick event. |
| quickFilter | Kanban (KanbanConfig). Quick-filter text, matched case-insensitively across a card's fields. |
| showPoints | Kanban (KanbanConfig). Show the points sum in each column header. Needs pointsProperty to say which row property carries the points. |
| rows | Calendar (CalendarConfig). The source rows, one card per row. One of rows/grid. |
| grid | Calendar (CalendarConfig). A Lattice grid to bind to instead of rows; the calendar follows its filtered/sorted rows live. |
| rowKey | Calendar (CalendarConfig). The row identity: a property path or a function returning a string or number. Default 'id'. |
| startProperty | Calendar (CalendarConfig). The field naming a point event's start. Default 'start'. |
| endProperty | Calendar (CalendarConfig). The optional field naming a span's end; omitted means point events. |
| allDayProperty | Calendar (CalendarConfig). An optional boolean field marking an all-day event, stacked in the time-grid all-day row. |
| view | Calendar (CalendarConfig). The view to show: 'week', 'month', 'week-time' or 'day'. Default 'month'. |
| date | Calendar (CalendarConfig). The anchor date the visible range centres on. Default today. |
| firstDay | Calendar (CalendarConfig). Week-start weekday, 0=Sun…6=Sat. Default 1 (Monday). |
| locale | Calendar (CalendarConfig). BCP-47 locale for weekday/month names, the title and the card time. |
| timeZone | Calendar (CalendarConfig). IANA time-zone name, honoured on the hour axis and the now line. |
| pinchZoom | Calendar (CalendarConfig) and Scheduler (SchedulerConfig). Whether a two-finger pinch over the timeline steps the zoom one level (the calendar's slotDuration, the scheduler's zoom). Default true. |
| slotDuration | Calendar (CalendarConfig). The time-grid slot step in minutes, or an 'H:MM(:SS)' duration. Default 30. |
| slotMinTime | Calendar (CalendarConfig). The hour axis's first time, a clock or minutes. Default '00:00'. |
| slotMaxTime | Calendar (CalendarConfig). The hour axis's last time, a clock or minutes. Default '24:00'. |
| scrollTime | Calendar (CalendarConfig). The hour axis's initial scroll, a clock or minutes. Default slotMinTime. |
| defaultDuration | Calendar (CalendarConfig). A timed block's length when it has no end. Default 60 minutes. |
| businessHours | Calendar (CalendarConfig). The working span the time grid shades: true, or { startTime, endTime, daysOfWeek }. Default off. |
| weekNumbers | Calendar (CalendarConfig). Show the ISO week number in the time-grid header. Default false. |
| header | Calendar (CalendarConfig). The header toolbar sections (navigation/title/views), or false to render none. |
| card | Calendar (CalendarConfig). The default card template's field mapping (title/subtitle/…). |
| renderCard | Calendar (CalendarConfig). A host card renderer (row, el); el stays mounted for its async updates. |
| colorProperty | Calendar (CalendarConfig). The property whose value picks the card's edge colour. |
| colorMap | Calendar (CalendarConfig). An optional value→colour map applied to colorProperty's value. |
| eventOrder | Calendar (CalendarConfig). Card stacking order within a day: a property path, or a comparator. |
| dayMaxEvents | Calendar (CalendarConfig). The +N more overflow threshold per day. Default false (off). |
| readonly | Calendar (CalendarConfig). Granular readonly (whole calendar, per day, per card). |
| ariaLabel | Calendar (CalendarConfig). The calendar's accessible name. Defaults to Calendar. |
| labels | Calendar (CalendarConfig). Host-localised labels: today, week, month, more, event, eventSingular, calendar. |
| messages | Calendar (CalendarConfig). A message catalogue with t(key, params); a bound grid's is borrowed. With neither, the calendar renders its built-in English. |
| onCardClick | Calendar (CalendarConfig). Card click handler. The config route to the card:click event. |
| onCardDblClick | Calendar (CalendarConfig). Card double-click handler. The config route to the card:dblclick event. |
| onCardContextMenu | Calendar (CalendarConfig). Card right-click handler. The config route to the card:contextmenu event. |
| onRangeChange | Calendar (CalendarConfig). Range-changed handler. The config route to the range:change event. |
| createdProperty | Kanban SLA (KanbanSlaConfig). The row property holding the wall-clock time the card was created. |
| enteredProperty | Kanban SLA (KanbanSlaConfig). The row property holding the wall-clock time the card entered its current column. |
| ignoreDone | Kanban SLA (KanbanSlaConfig). Whether cards in a done column are exempt from ageing. Default true. |
| onBreach | Kanban SLA (KanbanSlaConfig). Called on a rising crossing into breach level, as (level, rows) - the same shape the router's alert handler takes. |
| onWarn | Kanban SLA (KanbanSlaConfig). Called on a rising crossing into warn level, as (level, rows) - the same shape the router's alert handler takes. |
| showAge | Kanban SLA (KanbanSlaConfig). Show the age chip on every aged card ('always'), or only once a card reaches warn or breach ('threshold', the default). |
| useTransitionLog | Kanban SLA (KanbanSlaConfig). Whether the flow transition log drives the ageing basis when one is present. Default true. |
| autoApply | AI (AIConfig). Ask-your-data: apply a safe, read-only query result without a confirm step. Off by default - the resolved query is shown and waits for Apply. |
| onError | AI (AIConfig). Called when ask() errors, with the error and the target it was asked of. The grid stays usable. |
| onNarrative | AI (AIConfig). Called when a narrative has been produced. |
| onProposal | AI (AIConfig). Called with each governed-actor proposal, before any approval. |
| onQuery | AI (AIConfig). Called with each ask-your-data result. |
| schemaOptions | AI (AIConfig). Budgets passed to the schema builder that describes your data to the model for ask-your-data. |
| holdMs | Alarms (AlarmsConfig, AlarmsAttachOptions, AlarmsColumnOptions). The hold-down before a changed level is believed. A new level must persist this long before its transition is emitted, and a crossing back inside the window discards the pending transition rather than delaying it - so a value flapping across a cut point emits nothing and a sustained breach emits once. Symmetric: a clear is held exactly as a raise is. Set on the set, overridden per attach, overridden again per column. Default 0, which emits on every settled crossing. |
| clock | Alarms (AlarmsConfig). The clock the hold timer runs on - now, setTimeout, clearTimeout. Injected so a test can drive a thousand crossings through a holdMs window without sleeping for it; the real clock otherwise. |
| sourceId | Alarms (AlarmsAttachOptions). What this source is called in every alarm's id. Defaults to kpi#1, grid#2, … in attach order, so naming it yourself is what keeps the ids stable across a reordering. |
| columns | Alarms (AlarmsAttachOptions). Which columns of an attached grid are graded, keyed by column id, each taking the KPI tile's own thresholds or bands. An undeclared column raises nothing. |
| when | Alarms (AlarmsColumnOptions). An extra condition a cell must satisfy before it is graded at all, in the shape a conditional-formatting rule takes and evaluated by the same code - so, like a formatting rule, it tests that cell’s own value. A cell that fails it raises nothing and clears whatever it had: the way to keep an implausible reading from a broken agent off the on-call rota. |
| value | Alarms (AlarmsAttachOptions), routers only. What the route's slice measures, as (rows) => number. Defaults to the number of rows in the slice. |
| label | Alarms (AlarmsAttachOptions), routers only. The route's name, which becomes the alarm's key. Defaults to the partition value. |
Module instance methods
Each optional module's factory returns an object with its own methods, distinct from
Grid's. The frequently reached-for ones are shown in their own examples through
this guide; these are the rest, so that nothing on the returned object is undocumented here.
Each is a method on the value createKanban/createGantt/…
returns, not a config key.
| Name | What it does |
|---|---|
| setColumns | Kanban. Replace the board's configured column set. Keeps card placement and interaction state (collapsed columns, column order, quick filter, selection) for every column id that survives. |
| collapseColumn | Kanban. Collapse, expand or toggle a column (emits column:collapse). |
| collapseLane | Kanban. Collapse, expand or toggle a swimlane (emits swimlane:collapse). |
| reorderColumns | Kanban. Reorder the columns to the given id order (emits column:reorder). |
| reorderLanes | Kanban. Reorder the swimlanes to the given id order (emits swimlane:reorder). |
| moveColumn | Kanban. Move one column before another (or to the end); emits column:reorder. |
| moveLane | Kanban. Move one swimlane before another (or to the end); emits swimlane:reorder. |
| clearSelection | Kanban. Clear the selection. |
| isSelected | Kanban. Whether a card is selected. |
| closeDetail | Kanban. Close any open card pop-out. |
| isFieldEditable | Kanban. Whether a mapped card field is opted into inline edit and writable. |
| epics | Kanban. The distinct epic values. |
| sprintDefs | Kanban. The sprint dataset as { id, title } descriptors - the configured list plus any data-only sprint. |
| prev | Calendar. Move to the previous week or month, firing range:change. |
| next | Calendar. Move to the next week or month, firing range:change. |
| today | Calendar. Move back to today, firing range:change. |
| goTo | Calendar. Jump to an anchor date, firing range:change. |
| setView | Calendar. Switch the view ('week' | 'month' | 'week-time' | 'day'), firing range:change. |
| getRange | Calendar. The visible range { start, end, view }, both local midnights, end inclusive. |
| refresh | Calendar. Recompute and re-render. A grid-bound calendar re-reads its grid; a routed calendar re-reads nothing. |
| getState | Calendar. Serialise the arrangement state: { version: 2, view, date, firstDay, slotDuration, scrollTime }. |
| setState | Calendar. Restore a state snapshot from getState(), then re-render. |
| destroy | Calendar. Empty the element, remove only what the module added, and drop handlers and caches. |
| on | Calendar. Register an event handler; returns a function that removes it. |
| off | Calendar. Remove a handler registered with on. |
| readonly | Calendar. Whether a scope (the whole calendar, a day, or a card) is readonly. |
| setTasks | Gantt. Replace the whole task list (copied in) and recompute, returning the new schedule. Ignored with a warning after destroy(). |
| setDependencies | Gantt. Replace the link list and recompute. Adding a link is gated on beforeDependencyCreate, so this returns undefined on a veto, or a promise when a handler defers; a pure removal or reorder applies straight away. |
| deleteDependency | Gantt. Remove one dependency link, by its from/to/type identity - what a selected link's Delete key and the split view's link editor both call. Gated on beforeDependencyDelete, so this returns undefined on a veto or when no link matches, or a promise when a handler defers. |
| captureBaseline | Gantt. Capture a baseline (planned) snapshot of the current schedule as host data. Store it and feed it back as baselineStart/baselineEnd task fields to get variance and ghost bars. |
| undo | Gantt. Undo the most recent plan edit, restoring the exact prior plan - tasks, dependencies, assignments and work contours - and re-running scheduling to the identical result; a coalesced drag and a whole level() run each undo as one step. Returns false when there is nothing to undo. History is session only. |
| redo | Gantt. Redo the most recently undone edit; the redo branch is discarded the moment a new edit is made. Returns false when there is nothing to redo. |
| canUndo | Gantt. Whether there is an edit to undo - drive a toolbar button off it, together with the history event. |
| canRedo | Gantt. Whether there is an edit to redo. |
| clearHistory | Gantt. Forget the whole session undo/redo timeline and emit history. History is also cleared by setTasks/setState and lost on reload, and is not part of getState/setState. |
| gantt.select | Gantt. Select one or more tasks by id - the task selection is the table's row selection, extended to the timeline bars. Adds to the current selection (the inverse of gantt.deselect), the last id becoming the range anchor, and fires one selectionChange with cause: 'api'. The mounted view paints the selected rows and bars with a theme token; the selection survives redraws, zoom and filters. |
| gantt.deselect | Gantt. Deselect one or more tasks by id, leaving the rest - call gantt.deselect(gantt.selected()) to clear. Fires one selectionChange with cause: 'api' when the selection changes. |
| gantt.selected | Gantt. The selected task ids in selection order, including tasks a collapse or filter has hidden - they stay selected. |
| gantt.selectedHidden | Gantt. The selected task ids that are not currently visible: still selected, but collapsed under a summary row or filtered out. A click, a Ctrl/Shift click, an arrow key or the API all raise selectionChange; expanding or collapsing a summary row raises the vetoable beforeTaskExpand/beforeTaskCollapse then taskExpand/taskCollapse, and the workload band's resource rows raise resourceExpand/resourceCollapse. |
| gantt.copyTasks | Gantt. Copy tasks with their subtrees to the Gantt clipboard: every field with assignments, plus the links between two copied tasks; a link to an outside task is dropped on paste. Returns the tab-separated text form (name, start, end, duration, percent), also written to the system clipboard. Ctrl/Cmd+C does the same on the selection. |
| gantt.cutTasks | Gantt. Copy tasks and their subtrees, then remove them and their links as one undo step; refused when beforeTaskDelete is vetoed. Ctrl/Cmd+X does the same on the selection. |
| gantt.addTask | Gantt. Add a task { above | below | child | successorOf | predecessorOf: id } another, as one undo step. The vetoable beforeTaskAdd carries the task about to be inserted; a veto raises taskAdd:cancelled. A successor or predecessor is linked finish-to-start. Insert adds below the focused row, Shift+Insert as its child. |
| gantt.convertToMilestone | Gantt. Turn a task into a zero-duration milestone, as one undo step. The vetoable beforeMilestoneConvert gates it; a veto raises milestoneConvert:cancelled. A summary is refused. |
| gantt.convertToTask | Gantt. Turn a milestone back into an ordinary task of one day (or duration), as one undo step, gated on beforeMilestoneConvert. |
| gantt.deleteTask | Gantt. Delete a task and its links, as one undo step, gated on the vetoable beforeTaskDelete; the Delete key and the default menu's "Delete task" call it. A read-only task refuses an interface-driven delete. |
| gantt.pasteTasks | Gantt. Paste the clipboard as new tasks with new ids, { below: id } or { childOf: id }, as one undo step. The vetoable beforeTaskPaste carries the tasks about to be inserted; a veto raises taskPaste:cancelled. text pastes tab-separated rows instead. Ctrl/Cmd+V pastes below the focused row, Ctrl/Cmd+Shift+V as its children. |
| earnedValue | Gantt. PV/EV/AC and the derived SV/CV/SPI/CPI per task, rolled up to summaries and the project. |
| sCurve | Gantt. The same earned-value arithmetic over time: one cumulative { date, pv, ev, ac } point per bucket from the project start to the finish, where the final PV equals the total budget. |
| mountSCurve | Gantt. Mount the S-curve chart (three series, a status-date marker, a legend with SPI/CPI and a hidden table of points); the returned handle's refresh() redraws it and destroy() detaches it. |
| cost | Gantt. Resource-rate cost per task - a resource's hourly rate (dated changes honoured day by day), a 'material' resource's cost per unit, or a 'cost' resource's flat amount per assignment, plus each task's own fixed cost - rolled up to summaries and the project; read as the EV/BAC fallback by earnedValue() when a task states no cost. |
| resources | Gantt. Compute the resource load and over-allocations on demand, optionally overriding the capacities for this call. |
| mountSplit | Gantt. Mount the resource-load split view beneath the plan - as a table (workload, or as bars with a capacity line (histogram, - in hours or, per bucket, as a percent of the resource's own available hours (unit,, switchable live off the band's own <button aria-pressed> or view.setWorkloadUnit(). |
| previewAssignments | Gantt. The resource load as it would be if one task carried a given roster, from the same calculation as resources(); the split view's assignment picker uses it to warn of an over-allocation before it commits. Nothing is changed or emitted. |
| previewEdit | Gantt. Dry-runs a start or duration edit and returns the schedule it would produce, without changing the model, emitting or writing back; both views call it on every drag frame so dependency arrows follow the bar and the successors the scheduler will push are previewed. null when it cannot be scheduled. |
| mountSplit | Gantt. Mount the resource-load split view beneath the plan; display-only, it reports hours rather than accepting them - as a table (workload) or as bars with a capacity line (histogram,. |
| toCSV | Gantt and charts. On a Gantt, export the scheduled tasks as CSV ({ dates: true } writes ISO dates). On a chart, the numbers it is drawing as CSV - one column per series, one row per category. |
| toMSPDI | Gantt. Export the current plan as Microsoft Project (MSPDI) XML: tasks, dependencies, constraints, baseline, resources and assignments, plus the working-time calendar. |
| toSVG | Gantt. Serialise the mounted view - the plain chart, or the split view's table, timeline and whichever bands are mounted - to a standalone SVG string, drawn with a fixed, theme-independent palette. |
| toPNG | Gantt. The mounted view as a PNG: toSVG rasterised through a canvas, resolving to null where there is no canvas to rasterise with. |
Gantt. Print the mounted split view - paged to a chosen paper and orientation, fit to the page width or at a fixed scale, the table header and timeline header repeating on every page and no row split across one - through the browser's own print path (paper, orientation, fitToWidth/scale, title, header/footer); always in a print-safe light palette, whatever theme is mounted. | |
| moveRow | Gantt. Move or re-parent one row - a name-column drag handle, a drop onto another row, Alt+Up/Down or Tab/Shift+Tab all funnel through this, gated on the cancellable beforeRowMove and writing the live re-parent path as one undo step. |
| taskByWbs | Gantt. The raw task carrying a given WBS outline code - '1', '1.1', '1.2', '2', … - or null when none does. Reads the latest schedule's own derived codes, so the lookup always reflects the plan's current order rather than a code a since-moved task used to carry. |
| visibleNodes | KPI. The nodes on screen for a tree strip: the roots, plus the children of every open branch. |
| activate | Tabs. Switch the active tab, gated by beforeTabChange. Returns (or resolves to) whether the switch happened. |
| isMounted | Tabs. Whether a tab's grid has been created yet. |
| ascend | Charts. Go up one level, on a drillable hierarchy. |
| fitToSelection | Charts. Turn a globe (projection: 'orthographic') to the middle of the grid's selected rows; true when it turned, false on a flat map or an empty selection. |
| toSVG | Charts. The chart as standalone SVG markup, empty string before the first draw. |
| toPNG | Charts. Rasterise the chart through the browser; resolves to null where there is no canvas or Image. |
| applyQuery | AI. Apply a reviewed ask-your-data query result - the confirm path - re-gated at the same seam the initial query was. |
| attachExplain | AI. Build an "Explain" button bound to a target (a KPI tile, a stat, a chart). |
| riskSummary | AI. A risk narrative over the module sources you name (gantt, board/sla, or precomputed outputs), reconciled the same way explain() is. |
| flushBackpressure | Data Router. Refresh every backpressured route to the latest state now; a no-op with nothing pending. |
| flushPersist | Data Router. Flush any pending durable write now; resolves once it has settled. |
| removeEventListener | Mock socket (MockWebSocket). Remove a handler registered with addEventListener. |
Gantt split view: filtering the task table, executed
With the task table mounted as a grid, a filter hides the tasks that do not match and the
timeline follows. A match keeps its ancestors, marked lat-gantt-split__row--ancestor;
the plan itself is never touched, so the undo stack stays empty. gantt.scrollToTask(id) on a task the
filter hides returns false, leaves the filter alone and fires task:filtered with { id }.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createGrid } = await import('../packages/dom/src/index.js');
const { createGantt } = await import('../packages/modules/gantt/index.js');
const { root } = createTestDom();
const gantt = createGantt({ tasks: [
{ id: 'a', name: 'Design', duration: 5, assignee: 'Ann' },
{ id: 'b', name: 'Build', duration: 8, assignee: 'Bob' },
{ id: 'c', name: 'Ship', duration: 1, assignee: 'Bob' },
] });
const view = gantt.mountSplit(root, { createGrid });
const filtered = [];
gantt.on('task:filtered', (e) => filtered.push(e.id));
view.grids.tasks.filters.set({ col: 'assignee', op: 'eq', value: 'Bob' });
const rows = view.rowGeometry().length; // Build and Ship
const scrolled = gantt.scrollToTask('a'); // Design is filtered out: false, filter untouched
return [rows, scrolled, filtered.join(','), gantt.canUndo() ? 1 : 0].join(' | ');
Gantt split view: reordering and re-parenting rows, executed
A name-column drag handle, a drop onto another row, Alt+Up/Down (reorder among
siblings) and Tab/Shift+Tab (indent/outdent) all funnel through gantt.moveRow(id, { parent, index }): cancellable through beforeRowMove, and once allowed, one rowMove and
one undo step, by the live re-parent path.
const { createGantt } = await import('../packages/modules/gantt/index.js');
const gantt = createGantt({ tasks: [{ id: 'a', duration: 2 }, { id: 'b', duration: 2 }, { id: 'c', duration: 2 }] });
gantt.moveRow('c', { parent: null, index: 0 }); // one rowMove, one undo step
const first = gantt.schedule.order[0];
gantt.undo();
const restored = gantt.schedule.order.join(',');
gantt.on('beforeRowMove', (e) => e.preventDefault('locked'));
gantt.moveRow('b', { parent: 'a' }); // vetoed: the model stays put
const kept = gantt.schedule.tasks.get('b').parent === 'a';
gantt.destroy();
return `${first} first | undone back to ${restored} | veto kept b under a: ${kept}`;
Gantt: WBS outline codes, executed
Every scheduled task carries a derived wbs string from its position in
the tree, renumbering on every reorder, indent/outdent, add or delete with nothing written onto the task itself. gantt.taskByWbs(code) resolves a task by its current code, reading the latest
schedule rather than a code a since-moved task used to carry.
const { createGantt } = await import('../packages/modules/gantt/index.js');
const gantt = createGantt({ tasks: [
{ id: 'p', name: 'Phase 1' },
{ id: 'a', name: 'A', parent: 'p', duration: 2 },
{ id: 'b', name: 'B', parent: 'p', duration: 2 },
] });
const before = gantt.taskByWbs('1.2').id;
gantt.moveRow('b', { parent: 'p', index: 0 }); // b renumbers to 1.1, a to 1.2
const after = gantt.taskByWbs('1.1').id;
const stale = gantt.taskByWbs('1.2').id;
const missing = gantt.taskByWbs('9.9');
gantt.destroy();
return `before ${before} | after ${after} | stale now ${stale} | missing ${missing}`;
Gantt split view: toSVG, toPNG and print, executed
The split view serialised to SVG, rasterised to PNG (null here - this
sandbox has no canvas - a real browser returns a Blob), and printed: a paginated,
A4-landscape HTML document opened through the browser's own print path, the table
header and timeline header repeating on every page it spans.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createGantt } = await import('../packages/modules/gantt/index.js');
const { root } = createTestDom();
const gantt = createGantt({
title: 'Launch plan',
tasks: [{ id: 'a', name: 'Design', duration: 5 }, { id: 'b', name: 'Build', duration: 8 }],
});
gantt.mountSplit(root, { rowHeight: 24 });
const svg = gantt.toSVG();
const png = await gantt.toPNG(); // null: no canvas in this sandbox
let printed = null;
const view = root.ownerDocument.defaultView || (root.ownerDocument.defaultView = {});
view.open = () => ({
document: { open() {}, close() {}, write(h) { printed = h; }, readyState: 'complete' },
focus() {}, print() {}, addEventListener() {},
});
const opened = gantt.print({ paper: 'A4', orientation: 'landscape' });
const pages = (printed.match(/lat-gantt-print__page"/g) || []).length;
return 'svg ' + (svg.startsWith('<svg') ? 'ok' : 'bad')
+ ' | png ' + (png === null ? 'null (no canvas here)' : 'blob')
+ ' | printed ' + opened + ' pages ' + pages;
Calendar, bound to a grid and driven by a router, executed
One calendar bound to a live grid (following its rows), and a second fed by a Data Router
through the same keyed-diff rows.apply path - the two data-source shapes. Run on every build.
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createCalendar } = await import('../packages/modules/calendar/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');
const grid = createHeadlessGrid({
rowKey: 'id',
rows: [
{ id: 1, kind: 'post', start: '2026-09-29 10:00', title: 'P1' },
{ id: 2, kind: 'post', start: '2026-09-30 11:00', title: 'P2' },
{ id: 3, kind: 'note', start: '2026-10-01 09:00', title: 'N1' },
],
columns: [{ field: 'start' }, { field: 'title' }, { field: 'kind' }],
});
const bound = createCalendar(null, { grid, startProperty: 'start', view: 'week', date: '2026-09-30', firstDay: 1 });
const boundCount = bound.rows.count; // 3
const router = createDataRouter({ key: 'kind', rowKey: 'id' });
const target = createCalendar(null, { rows: [], rowKey: 'id', startProperty: 'start', view: 'week', date: '2026-09-30' });
router.attach(target, 'post');
router.load([
{ id: 'a', kind: 'post', start: '2026-09-28 10:00', title: 'A' },
{ id: 'b', kind: 'post', start: '2026-09-29 10:00', title: 'B' },
]);
const routed = target.rows.count; // 2
target.destroy(); router.destroy(); grid.destroy(); bound.destroy();
return boundCount + '|' + routed;
The resource scheduler module
The opt-in scheduler: resources as rows in a real grid on the left, events as bars on a shared time axis on the right, reusing the Gantt's time scale and calendars.
import { createScheduler } from '@toclocoinc/lattice-grid/modules/scheduler';
createScheduler(element, config) mounts a read-only schedule. The
resources array seeds the rows (one per resource, keyed by
resourceKey, drawn through the columns you name, defaulting to name,
role and capacity), and the events array seeds the bars (one per event, keyed by
eventKey, addressed to a resource through fields, which names the
resourceId, startDate and endDate properties). The left
table is a real Lattice grid - pass your own createGrid so the module never
bundles its own core - and the right side lays bars along a shared time axis at the chosen
zoom: hour, day, week, month or
quarter, pinned by date and weekStartDay.
Calendars shade the axis, per resource. When nonWorking is on,
each resource's calendars entry shades that row's non-working days, exactly as the
Gantt shades them, and the todayLine marks today. A row's height is
rowHeight; its text renders in the locale and
timeZone you name.
Overlaps stack, not collide. Events that share a resource and a span split
into sub-lanes, so two events in one row sit side by side. A bar's colour comes from
colorProperty and colorMap.
The resource pane resizes. The left table's width is the
resourceWidth option (300px default), resized live by a draggable,
keyboard-operable divider between it and the time axis, or programmatically with
setResourceWidth(px); the width is carried by
getState()/setState() so a restored view keeps it. When the resources
are grouped, the group column honours its own column width instead of a fixed one, so a
person's name stays fully visible - or, in a column too narrow for it, ellipsises with the
full name as its native title, never a mid-word clip.
Find and read the plan. A groupBy - a field name or a
per-resource function - groups the resources into collapsible rows, driven by the left
grid's own grouping so the two panes can never disagree, and a search query narrows
rows and bars at once (a row stays when its own text or one of its events' text
matches, so a match never orphans its bar). A resourceFilter hides rows and an
eventFilter hides bars, each a predicate the row must pass. The Gantt's tooltip
card opens on an event's hover or keyboard focus with its start, end, duration and resource;
every bar, row and the axis is named for screen readers, and the keyboard alone reaches every
event - arrows between events and rows, Enter to activate, Home/End, and +/− to
zoom.
Bounded at any size. Rows and time are virtualised - overscan sets how many rows render past the viewport - so a schedule of five
hundred resources and twenty thousand events keeps the DOM to the visible window. The
ariaLabel and labels options name the widget and its corner heading
for assistive technology.
Headless and teardown. Pass null for the element and the model
still computes: rows.apply and events.apply take the same keyed-diff
shape a grid does, setZoom/getZoom change the level (emitting
zoom:change), setEvents(events) replaces the whole feed in one call,
and destroy() releases the grid and every listener.
The workload band. workload: true (or
{ hoursPerDay, defaultCapacity, decimals }) draws a utilisation histogram across
the bottom of each row: one cell per zoom bucket, its fill height the booked share of that
resource's own capacity and calendar, computed with the Gantt's computeWorkload.
A bucket booked past capacity takes the theme's danger tokens and a warning glyph - never colour alone - and the schedule's live region names each over-allocated resource with its
periods, so a screen reader hears it too. The band re-derives on every setEvents,
events.apply, setZoom or rows.apply, and a headless
scheduler exposes the same figures as scheduler.workload.
Events are editable. When editable is on (the default), a bar
can be dragged to move it in time or dropped on another row to reassign it, either edge dragged
to resize, empty time in a row dragged to create an event, and Delete (or the right-click menu)
removes it. Each edit snaps to the zoom step, writes through events.apply like a
grid edit, is one step of undo()/redo() (Ctrl/Cmd+Z / Ctrl/Cmd+Shift+Z),
and raises one cancellable beforeEventChange then one event:change
carrying the event id and old/new values. A row or event marked readOnly: true, or
named by readOnlyWhen / resourceReadOnlyWhen, refuses the gesture with
a catalogued reason.
The shell module
The opt-in shell: cells nested as rows and columns to any depth, sized by pure CSS flexbox in its own bundle that grows grid core by nothing.
import { createShell } from '@toclocoinc/lattice-grid/modules/shell';
createShell(element, config) mounts a layout container that is complete the moment the
factory returns: every cell element is already in the document and already sized, so a widget
mounted into getCell(id).el on the very next line measures a non-zero box in the same
synchronous task. The config names the top-level axis and separators - rows stacks the
cells top-to-bottom (used when cols is absent, which lays them side-by-side),
type draws the separators (line, wide, space or
none), padding pads the whole shell, and css is an object of
camelCase properties or a string of declarations written straight onto the element.
One element per cell. A cell is the flex item of its parent and, when it declares
nested rows or cols, the flex container of its children - getCell(id).el is that single element, a leaf cell a widget fills and a nested cell already
laying its own children out. A fixed width/height (a pixel number or any CSS
length string) is the cell's flex-basis and never grows; the gravity of the cells
without one shares the space left over after the fixed sizes are subtracted.
minWidth/maxWidth/minHeight/maxHeight clamp a cell as
the CSS properties do, padding pads it (a widget mounts inside the padding),
align is its align-self, and css is written onto the element, never
parsed as HTML.
Separators are the shell's type, not markup. line draws a
thin border between cells, wide a thick one, space an even gap, and
none nothing - each a --lattice-* theme token with a literal fallback, so a
shell inside a themed grid inherits the theme and one on a bare page still draws. Nothing in the module is
interactive, so there is nothing to keep keyboard-operable beyond the widgets a host mounts.
Synchronous by construction. A window resize reflows by CSS alone - there is no
listener, timer, observer or requestAnimationFrame in the module. forEach(fn)
walks every cell in tree order, getParent() on a handle is the parent cell (or
null at the root), and destroy() empties the element, removes only what the
module added, restores the host's own inline style and drops every cell record - the host still owns
the element.
Live structure. The tree is not frozen at creation: addCell(parentId, config,
index?) builds a new cell through the same one synchronous pass - its element is in the document
and sized when the call returns - and removeCell(id) drops a cell and its whole subtree,
releasing every piece of content inside it (detached, never destroyed). A cell can also hide()/
show() to give its space to its neighbours and take it back, and progressShow()/
progressHide() overlay and clear a loading indicator.
The form module
The opt-in form: items laid out in nested rows and columns, each control in its own bundle that grows grid core by nothing.
import { createForm } from '@toclocoinc/lattice-grid/modules/form';
createForm(element, config) mounts a form that is complete the moment the factory
returns: every item is already in the document and already sized, so a field reached on the very
next line measures a real box in the same synchronous task. The config names the top-level axis - rows stacks the blocks vertically, cols lays them side by side - and nests them to any depth with container and fieldset blocks,
beside spacer and separator. A cols container shares its
width equally among its children, and stacks them into one column once its own width drops below
colsBreakpoint (default 480px; 0 or false turns
stacking off so the row never stacks). A fullWidth button spans the form, and inside a
cols row it fills its own column while the columns keep sharing the row. An item names a type - an input whose subtype is text, email,
password or url, a number, textarea,
checkbox or button - a label at a
labelPosition of top, left (with a
labelWidth when left) or hidden, a helpMessage line under the
control, and an initial value. labelPosition and labelWidth
set at the form, container or fieldset level are the defaults its items inherit, an item’s
own value winning. A hidden label stays the field’s accessible name but takes no
space and draws no asterisk; a field with no label text falls back to the placeholder as its
accessible name, and a required field carries aria-required with the
requiredHint marker (true - the default - appends
" *" to the placeholder, a string resolves through the message catalogue, and
false shows nothing).
A number field formats without changing the value. A
number item edits the raw number and shows the grid's own NumberFormat
while blurred: format takes the same spec a grid number column does - currency,
percent, fixed decimals, units - and locale resolves both the display and the
typed-input separators, so getValue() stays a number while the field reads
£20,000.00 and a comma-decimal locale parses its own convention. Focused, the field
shows the plain number, and ArrowUp/ArrowDown still step by step within
min/max. spinner: true draws two step buttons beside the
field; otherwise the native spinner never appears. A number item is also
unit-aware: system and unit take the grid unit column's configuration,
so the field stores a plain number in the base unit, shows the quantity with its unit, and
accepts any unit of the same system from the keyboard - 145 psi into a bar
field commits about 9.997, and an unknown unit is refused while the previous value
is kept. scale makes a temperature field, whose conversion is affine
(212 °F into a Celsius field stores 100), and
unitPicker: true draws a compact selector inside the field that changes only the
display unit, never the committed value.
Files and rich text. A file item is an uploader field - drop,
paste or pick - with accept (extensions or MIME patterns), maxSize and
maxFiles rules enforced at admission, a multiple toggle, dropText
for the drop zone and a hint line, a custom drop-area icon (a glyph name from
the file module’s sprite set, an SVG markup string, an element, or false for none) and
a browseText word for the browse link, and a host upload(file, { onProgress, signal })
callback that does the actual upload; the value is the accepted file entries, never a rejected one. A
richtext item is a sanitised HTML editor: a toolbar preset or item array,
minHeight/maxHeight bounds, linkTarget, colors,
headings, allowedTags, maxImageSize, onImage and
sanitizePaste, with the whitelist sanitiser as the security boundary - scripts, event
handlers and javascript: URLs never survive pasting or setValue.
A button acts rather than holds a value. A button item renders a
native <button> - or an <a> when href is set - with an optional icon (a name from the grid’s icon set, an image URL or
data: URI, or an element) placed by iconPosition (start default /
end), a variant (primary, secondary default,
tonal, outline, text or danger, all
theme-token colours in light and dark), fullWidth to span the form, a
size (sm/md/lg) and a
textTransform (none default / uppercase). Its
buttonType is button, submit or reset: a
submit runs form.validate() first and only then fires a
submit event on the form’s element with the values in
event.detail.values when valid, and a reset calls
form.clear(). loading: true (or item.setLoading(bool)) swaps
the icon for a spinner, keeps the width, sets aria-busy and makes the button ignore
clicks. An icon with no label is icon-only - it requires ariaLabel and renders
square - and a plain button fires the ordinary click event with a
null value. A { type: 'buttons', align, items } row lays a group of
buttons out along the width, align of start (default), end,
center, stretch or space-between, and each child takes the
same options as a button item.
Because a button acts, the view draws no field label, required asterisk, help line or hint line
for it - its label is the button’s own text, printed once - and in a
labelPosition: 'left' form it lines up with the fields’ input column (unless
fullWidth spans the row). Every variant defines the same state matrix in light and
dark, each state’s colours read from an overridable
--lattice-button-<variant>-<state> token: rest, hover (one step toward the
foreground), active (a deeper step), focus-visible (a 2px accent ring with a 2px offset),
disabled (muted text/border/fill with text at or above 3:1 against its fill,
cursor: not-allowed, aria-disabled, and no hover/pressed change),
loading (the spinner) and toggled (a distinct pressed look). toggle: true makes the
button two-state - it flips aria-pressed and fires change with the
new boolean, starting pressed when value is true - and
focusableWhenDisabled: true keeps a disabled button focusable and in the tab order,
reading aria-disabled instead of being natively disabled (clicks are still ignored).
Two more controls cover the feedback and workflow cases. A rating item is a row of
icons the user clicks or arrows through, storing a whole or half number: max sets the
icon count, allowHalf permits half steps, allowClear lets a second click
return it to zero, icon (or icons filled/empty) picks the glyph,
labels names each icon for its title and announced value, showValue draws
the number beside the icons, and size (sm/md/lg)
scales them. A stage item is a pipeline of chevron steps for a workflow:
stages names the steps, current sets the initial selection (an index or a
label), completed marks the last done step, clickable toggles whether
clicking selects, and showIcons draws the done check glyphs. Both are ordinary fields:
they commit through change, are gated by beforeChange, and round-trip
through getValue()/setValue() like every other value-holding item.
A toggle item is an on/off switch - a native checkbox drawn as a pill track with a
sliding handle and exposed as role="switch" - holding a boolean the same way a
checkbox does: Space toggles it, it commits through change, is gated by
beforeChange, and reads back through getValue()/setValue().
onLabel and offLabel draw short text (ON/OFF, a tick/cross glyph) that swaps
with the state, inside the track by default or beside it per props.labelPosition
(inside, before or after - the item’s top-level
labelPosition already means the item label’s side). size
(small/default/large) scales the switch from the density tokens
and variant (round/square/slim) picks the track
shape; the ON label reads --lattice-switch-label and the OFF label the foreground, keeping
AA 4.5:1 against the track in light and dark, and the transition honours
prefers-reduced-motion. A toggle that names none of these still renders the bare switch.
A chips item is a row of pill buttons for picking one or several options:
options names the choices ({ value, label, icon?, disabled? }), and
multiple switches between single-select (a radio group - one chip selected, the
arrow keys move and select, a roving tabindex) and multi-select (a group of toggle buttons with
aria-pressed and a check mark on each selected chip). allowEmpty lets a
single-select group be left with nothing selected, wrap lays the pills onto several
lines (or, when false, one scrolling row), and max caps the number of selections in
multi mode; a required group fails with nothing selected. getValue() returns the
selected value in single mode, or the selected values in option order in multi mode, and the
control commits through change, honours beforeChange and the item API,
and follows density and the light/dark theme tokens.
A togglegroup item is a row of two-state buttons - one per option - whose
pressed and unpressed faces differ. options names each button as
{ id, text?, offText?, icon?, offIcon?, selected?, disabled?, hidden?, full? }: the
pressed state shows text/icon and the unpressed one
offText/offIcon (each unpressed value falling back to its pressed twin),
with id as the map key (falling back to value, then the option’s
position) and hidden options dropped from the group and its value. Single mode (the
default) keeps exactly one button pressed - pressing another releases the rest, pressing the
pressed one keeps it - while multiselect: true toggles each button
independently. The value is a { [id]: boolean } map; getValue() and
setValue() round-trip it, clear() empties it to {}, and a
press commits through change whose payload also carries the option’s
id and new pressed state (one change per press, gated by
beforeChange). Left/Right arrows move focus between the enabled buttons and
Space/Enter toggle the focused one; gap sets the pixel space between buttons,
full stretches the group to the item’s width and an option’s
full stretches that one button. The pressed fill reads the toggle’s
--lattice-switch-track-on/--lattice-switch-label tokens, so the four
presets and dark mode apply as they do to a toggle.
A color item is a swatch field: clicking the swatch or the trailing icon opens a
popover under the field (flipped above when there is no room) over a preset palette - rows of hues by shade, defaulting to ten hues in five shades - plus a grey row
(grayShades: false drops it) and a row of customColors, extended by
every colour picked in a saturation/lightness picker reached through a "Custom colour" button
(pickerOnly: true removes the palette view, leaving only the picker;
mode: 'picker' opens on it instead). The text part reads and writes the value as hex - editable: false makes it read-only, leaving only the popover to set the value - and clearable: true adds a control that sets null. The value is a
lowercase #rrggbb string or null; setValue normalises
#rgb, #rrggbb and rgb(r, g, b), and anything else is
refused with a warning, the value unchanged.
A daterange item is a single field holding a start and end date - typed into
the editable field (joined by a separator) or picked from a two-month popup calendar, where the
first click sets the start, the second the end, a click before the start restarts, and hovering
previews the span. A left-hand presets column (false hides it) offers
{ label, range } shortcuts - a literal [start, end] pair or a
function of today - defaulting to today, yesterday, last 7/30 days and this/last month.
min/max bound the calendar, disabledDates (a list of ISO
values or a predicate) fences individual days, minSpan/maxSpan cap the
range length in days, and weekNumbers adds an ISO week column while
firstDay (a locale default or an explicit weekday) shifts the grid.
applyButton (default true) shows Apply/Cancel so nothing commits until
Apply - Cancel restores and applyButton: false commits on the second click - and granularity: 'month' turns the picker into a year-month range
committing YYYY-MM values. The value is { start, end } of ISO strings
or null; clearable: true adds a control that sets null.
An avatar item is a profile picture field: with a value it shows value.src at
size (small, medium - the default, 64px - large,
or a pixel number), rounded with circle: true; with none it falls back to preview (an
image URL), then icon, then the placeholder text. Clicking the picture, pressing
Enter/Space on it, or dropping a file onto it opens the file chooser (accept defaults to
['image/*']); a file over maxSize is refused with a message surfaced through
validate() rather than admitted. An admitted file previews immediately as a data URL and commits
{ src, file, name, size, status: 'pending' }; an upload config - { target, fieldName, params, headers, autosend, updateFromResponse }, with fieldName
defaulting to 'file' and autosend/updateFromResponse both defaulting to
true - posts the file as multipart form data the moment it is admitted (unless
autosend: false), moving status through 'uploading' to
'done'/'failed' and, with updateFromResponse, reading src/
id back from the JSON response. removeIcon (default true) shows a clear
control once there is a value, setting it to null; readOnly shows the picture with no
chooser or remove control.
A time item is the datepicker’s clock under its own control type: a field with
a clock toggle that opens a popup of hour and minute boxes - under the field, flipped above
when there is no room - plus an AM/PM segment when timeFormat is 12
(default 24). The value is read from a Date, an HH:MM
string, a number of minutes since midnight, or { hour, minute, AM? }, and written as
a string - HH:MM, or hh:MM AM in 12-hour - or the object
form when valueFormat is 'object'. editable: true makes the
field text typable, committing HH:MM or hh:MM AM on blur or Enter and
refusing an invalid clock with a validation message; controls: true adds Close and
Save so nothing commits until Save and Close discards (default: the popup commits as the clock
changes); and icon swaps the clock toggle glyph.
Help tooltips, read-only locks and field addons attach to any item. An
item’s tooltip draws a “?” trigger after its label - keyboard
reachable and linked to the control by aria-describedby - that shows the text on
hover, focus, Enter and tap. A read-only input or textarea shows a muted
lock inside its right edge, the value still selectable and copyable, unless
lockIcon: false hides it. An addons array attaches round icon buttons to
the right of any field - { icon, ariaLabel, onClick(value, item), href, tooltip } - sized to the control’s height and disabled along with the field; an
avatar: true addon shows a round avatar (an image URL, initials, or a function of the
value) beside a user select, following the selected option as the value changes.
A panel groups items on their own surface. A block whose config is
{ panel: { title, subtitle, actions, collapsible }, items } renders a bordered
section with a heading-semantics title, a muted subtitle, a divider and
header actions - round icon buttons of
{ icon, ariaLabel, onClick } - plus a collapse/expand chevron when
collapsible is true. Panels compose in rows and columns like any other block, so two
side-by-side panels lay out one status column and one classification column.
Values are named, not indexed. getValue() snapshots every named
value-holding item into one object, and setValue({ name: value }) writes a matching
set back silently - an edit made through a control instead raises a cancellable
beforeChange then a change, with input on each keystroke.
getItem(name) returns a handle whose own getValue()/
setValue() read and write one item, and whose show(),
hide(), isVisible(), enable(), disable(),
focus() and blur() act on that item without touching its siblings.
Density is a theme token, not a size. Each control height is
--lattice-row-height scaled by a --lattice-density-scale of
0.85, 1 or 1.5, so setDensity('compact')
re-themes every control in place without a re-render, and setDensity('standard') or
setDensity('comfortable') takes it back up. clear() resets every
user-editable item to its empty value, forEach(fn) walks the items in document
order, and destroy() empties the element and drops every listener and handler the
module added - the host still owns the element.
Spacing is a theme token too. The vertical gap between items is
--lattice-form-item-gap and the gap between a label and its field is
--lattice-form-label-gap, both defaulting from the density scale so an unset
spacing changes nothing. config.spacing and config.labelGap
set them as inline properties - a px number or a CSS length - which always beat a
stylesheet, and setSpacing(value) re-themes the gap live with one property write and
no re-render (null clears the override). A rows container or a
fieldset that sets its own spacing overrides it for its own items only,
while a horizontal cols container keeps its own gap.
A form can be bound, not just declared. bind: { grid, rowKey }
binds a grid row: with no layout the items are generated from the grid's own columns, so a
boolean column is a checkbox and a number column a number, and the
form follows the grid's density. A committed edit marks its item dirty - isDirty(name?) reports it for one item or the whole form - save()
writes the dirty items once through the grid's edit path and revert() restores the
bound values. An external edit to the bound row updates a clean item and leaves a dirty one
alone, flagging it with isConflicted(). bind: { router, route, key }
follows a data-router record by the same update-unless-dirty rule, filling in eagerly from
router.get(route, key) whatever the order of ingest and creation, then from the
route's keyed diffs; save() writes the dirty items back through
router.apply and calls bind.onSave(changes, record) when given - it may be async, and a rejection reverts the router record while the items stay dirty so the
user can retry - and a plain object of values is initialised from and written back by
save(). The binding is duck-typed against the public grid API, so the module still
imports no grid and no core source.
Any control type is first-class. registerControl(type, definition)
adds a control the same way a built-in is added: the definition's create(el, item, api)
builds its DOM, getValue(el)/setValue(el, value) round-trip the value,
and an optional validate(value, item, el) refuses a bad commit. The new type gets the
same wrapper, label, item API, events and density as a built-in, and a later registration for the
same type replaces the earlier one.
Rules declare what a change handler used to script. visibleWhen,
readOnlyWhen and requiredWhen are re-evaluated on every committed change:
a constant, a function of the whole value snapshot, or a condition on one named item's value with
equals, in, empty or fn. A false
visibleWhen hides the item, a true readOnlyWhen makes it read-only, and a
true requiredWhen marks it required - the commit path refuses an empty value.
A steps array makes a wizard. Each { id, label, rows }
step is one body, only the active one visible, under a step indicator whose
stepOrientation is horizontal (default) or vertical.
form.next() validates the current step before advancing, form.back() goes
back without validating, and form.goToStep(index) moves to a step validating the ones
passed over on the way forward - the indicator marks each step pending, active, complete or in
error and never disagrees with the form. Set validateSteps: false to navigate
unchecked and run form.validate() yourself.
Validation is opt-in and per item. Mark an item required, or
give it a validation rule - a built-in name (email,
number, integer, url), a RegExp, or a function
(value, item) => boolean | string | void. A failure shows the item's
errorMessage (or the rule's own message) beside aria-invalid; a pass
shows successMessage, and preMessage is the quiet hint under the
control. validationMode decides when a re-check happens - change
after a committed edit, blur on blur, or submit only when asked.
form.validate() checks every rule-bearing item and returns true or
false; item.validate() checks one, and a validated event
carries the failures.
Several forms can act as one. createFormGroup(forms?, options?)
joins forms mounted in different places - one per cell of a shell layout, say - into one
logical form that never renders anything. getValue() merges every member’s values
in add order; setValue(values) routes each key to the member that owns that field, the
first owner winning when two forms declare the same name and unknown keys ignored;
getItem(name) reaches the owning member’s item handle; validate()
validates every member and focuses the first invalid field; and
setReadOnly(bool)/setDisabled(bool)/clear()/
reset()/isDirty() apply across the members. A member’s
change or validated event re-emits on the group with
{ form, name, value } or { form, valid }; a destroyed member leaves the
group by itself; and group.destroy() detaches the group’s listeners without
destroying the forms. A group takes the same bind once - every member fills with
its own fields off the record and follows it, and group.save() writes one router
update with all the members’ dirty fields, calling onSave once.
options.transform = { get, set } joins the merged values on read
and splits them back on write.
The combo control
A searchable combobox with dynamic server values, dependent lists, multi-select and multi-line options.
import { createCombo } from '@toclocoinc/lattice-grid/modules/form';
createCombo(element, config) mounts a combobox that is the same control the form's
type: 'combo' item uses, and is complete the moment the factory returns. It takes a
load recipe - an async load({ search, page, signal }) that pages a
search server-side and answers { items, total, hasMore } - or a static
options list when there is no server. The value is a single option, or an array of
options with multiple, in selection order. Chips wrap and cap at a
maxHeight, with selectAll over the loaded set and a
resolveLabels callback that supplies labels for a preselected value the first page
does not carry. Options are a bare value or
{ value, label, description, icon, avatar, color, disabled }: an icon is
a glyph name from the grid's icon set drawn as the real SVG (or a DOM node) and an
avatar is an image URL or initials, each drawn round on the left of the list rows,
the multi-select chips and the single-select shown value; a disabled option shows but
refuses a click. chipPlacement: 'above' | 'below' moves the chips out of the control
into their own wrapping row beside the field, so the input stays a single clean search line.
newOptions turns the box into free entry, optionTemplate
replaces the row renderer and selectedTemplate the chips and the shown value - either
may return a DOM node or an HTML string, which is sanitised before insertion - and
debounce and pageSize tune the search and the pages.
Dependent lists clear and reload. A form item with
dependsOn: ['otherItem'] reloads page zero and clears its value whenever that parent
item commits a change, so a city list follows its country.
One handle, five verbs. The handle is
{ el, root, input, getValue, setValue, reload, clear, open, close, focus, blur,
setDisabled, setReadOnly, destroy }. getValue()/setValue() read
and write the committed value silently, reload() drops the page cache,
clear() empties the selection, open()/close() drive the
popup, focus()/blur() move keyboard focus, and
setDisabled()/setReadOnly() gate editing before
destroy() tears it down.
The speed dial control
A floating action button that fans its actions out as a linear strip or a radial arc.
import { createSpeedDial } from '@toclocoinc/lattice-grid/modules/form';
createSpeedDial(element, config) mounts a floating action button (FAB) that is the same
control the form's type: 'speeddial' item uses. It anchors to a corner or edge of its
container - position is top-left, top-center,
top-right, middle-left, middle-right, bottom-left,
bottom-center or bottom-right - and fixed anchors it to the
viewport instead. Its items are { id, icon, text, disabled, render }: an
icon is a glyph name drawn as the real SVG (or a URL or element), text is the
label - an item may be icon-only, text-only or both - disabled shows but refuses a
click, and render(item, index) replaces the item's own content. The FAB opens them as a linear
strip whose direction is up, down, left,
right or auto (default, into the container's room side), or as a radial fan with
mode: 'radial' whose radial startAngle/endAngle/offset
default from position. modal paints a backdrop that swallows page clicks,
openOnHover opens on pointer enter and closes on leave, the FAB swaps
openIcon/closeIcon between its closed and open states, and one of seven
variants colours it. Dark mode and RTL mirroring are built in.
One handle, five verbs. The handle is
{ el, fab, menu, open, close, toggle, setItems, destroy }. open()/close()
drive the menu and emit open/close, toggle() flips it,
setItems(list) replaces the actions, and destroy() tears it down. A click on an
item fires itemClick once and closes; Escape closes and returns focus to the FAB.
Accessible by construction. The FAB is a role="button" with
aria-expanded and aria-controls; the menu is a role="menu" of
role="menuitem"s, and the arrow keys move across items (skipping disabled ones)
while Enter or Space activates.
The geometry module
import '@toclocoinc/lattice-grid/modules/geometry';
Importing modules/geometry registers the geometry column type; without
it, type: 'geometry' warns type:geometry, naming the import, and the
column is plain text. type: 'geometry' holds a point, a line or a shape as a value. It reads the three
forms geometry arrives in - WKB (a Uint8Array or ArrayBuffer, or
the same bytes as hex or base64 text), WKT (POINT (…), LINESTRING,
POLYGON, the MULTI* kinds and GEOMETRYCOLLECTION) and
GeoJSON (an object or its JSON text; a Feature gives its geometry) - and
stores one GeoJSON geometry per cell, so everything downstream sees one shape.
Five cities and two areas, filtered, sorted, shown and exported, executed
const { geometryModule } = await import('../packages/modules/geometry/index.js'); // registers the type
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
// Five cities and two areas, in all three of the forms geometry arrives in.
const grid = createHeadlessGrid({
rowKey: 'id',
locale: 'en-US',
columns: [{ field: 'id' }, { field: 'site', type: 'geometry' }],
rows: [
{ id: 'London', site: { type: 'Point', coordinates: [-0.1278, 51.5074] } },
{ id: 'Paris', site: 'POINT (2.3522 48.8566)' },
{ id: 'Birmingham', site: 'POINT (-1.8904 52.4862)' },
{ id: 'Edinburgh', site: 'POINT (-3.1883 55.9533)' },
{ id: 'Madrid', site: '0101000000fe65f7e461a10dc0857cd0b359354440' }, // WKB, as hex
{ id: 'South East', site: 'POLYGON ((-1.5 50.7, 1.5 50.7, 1.5 52, -1.5 52, -1.5 50.7))' },
{ id: 'Iberia', site: { type: 'Feature', geometry: { type: 'Polygon',
coordinates: [[[-9.5, 36], [3.3, 36], [3.3, 43.8], [-9.5, 43.8], [-9.5, 36]]] } } },
],
});
const ids = () => Array.from({ length: grid.rows.count() }, (_, i) => grid.rows.get(i).key).join(', ');
grid.filters.set({ col: 'site', op: 'withinDistance',
value: { lon: -0.1278, lat: 51.5074, metres: 200000 } });
const near = ids(); // by great-circle distance from each centroid
grid.filters.set({ col: 'site', op: 'withinBbox', value: [-10, 35, 4, 50] });
grid.sort.set([{ col: 'site', dir: 'asc' }]); // centroid latitude, then longitude
const south = ids();
const text = grid.rows.text('South East', 'site'); // what the cell shows
const csv = grid.export.csv({ lineEnding: '\n' }).split('\n')[2]; // CSV writes WKT, whatever the input was
if (geometryModule.name !== 'geometry') throw new Error('not registered');
return `near London: ${near} | in the box, south first: ${south} | ${text} | ${csv}`;
Coordinates are longitude, then latitude, in degrees - the GeoJSON and
WKT x y order. A Z or M ordinate is dropped on ingest: every measure here is on
the surface. POINT EMPTY, and a Feature whose geometry is
null, is no geometry and leaves the cell blank without a warning.
Rejected input never becomes state. A value that is none of the three forms - free text, WKB that runs out of bytes, a ring with two corners, a
FeatureCollection (many rows, not one value) - stores nothing, and
geometry:refused:<column> warns once with the reason. An edit or paste that
does not read is refused and the cell keeps its value.
What the cell shows. A summary by kind: Point 51.5074°N 0.1278°W,
LineString, 12 points, 3.4 km, Polygon, 14 vertices, 2.31 km²,
MultiPolygon, 3 parts, 40 vertices, 12.5 km², numbers in the grid's locale.
format: { pattern: 'wkt' } shows the WKT and 'geojson' the GeoJSON.
The quick filter searches what the cell shows. CSV, Excel and the clipboard always write WKT,
which a GIS tool reads back and a paste turns into the same geometry.
Sorting and grouping. A geometry has no natural order, so the column sorts
by its centroid's latitude, then longitude - south to north, then west to east - with blanks last. Grouping buckets by kind: every Polygon together.
For maps and charts. A viewer reads a cell's measures through
column.dataType.geometry(value): kind, bbox
([minLon, minLat, maxLon, maxLat]), centroid ([lon, lat]),
length in metres and area in square metres. Lengths and areas are on
a sphere of the mean Earth radius, within about 0.5% of the WGS84 ellipsoid; the centroid is
planar in degrees, which is what a database's ST_Centroid gives on a geometry
column.
Filtering. A geometry column answers withinBbox,
withinPolygon, intersects and withinDistance (plus
blank and notBlank), evaluated in memory, so they work over any
source that does not push them down; the column menu and the tool panel offer them with a box
that says what to type. Anything else on the column is refused by name, as is a box that
crosses the antimeridian - split it into two withinBbox conditions joined by
or. The reference gives each operator's
exact rule.
The list module
import '@toclocoinc/lattice-grid/modules/list';
Importing modules/list registers the list column type; without it,
type: 'list' warns type:list, naming the import, and the column is
plain text. A list column holds many values in one cell, each usable on its own: the column's
typeOptions name the item type in of (any scalar type,
'text' by default) and, in joiner, the text copy, CSV and Excel put
between items (', ' by default).
{ field: 'sizes', type: 'list', typeOptions: { of: 'number', joiner: '; ' } }
Each item is read, shown and compared as its item type: the cell draws chips with a
+N for the ones that do not fit, the editor takes one item per Enter, and every
operator of the item type filters with "any item matches" (eq 'gold' keeps a row
whose list holds gold; ne 'gold' one with no gold in it). hasAny,
hasAll, hasNone and listCount ask about the whole list.
The reference gives every rule, sorting, grouping and the DuckDB
pushdown.
Kanban config keys, executed
The table above names each Kanban config key on its own line; these six examples run the board (headless, or over the test DOM where a key's effect is visual) and show what each key actually does, grouped by what they configure together.
Board structure: grouping, order and a done set, executed
const { createKanban } = await import('../packages/modules/kanban/index.js');
const rows = [
{ id: 1, status: 'todo', epic: 'E1', points: 3, order: 20 },
{ id: 2, status: 'todo', epic: 'E1', points: 2, order: 10 },
{ id: 3, status: 'done', epic: 'E1', points: 5, order: 5 },
];
const b = createKanban(null, {
rows, rowKey: 'id',
columnProperty: 'status',
columns: [{ id: 'todo' }, { id: 'done' }],
columnOrder: ['done', 'todo'],
pointsProperty: 'points', showPoints: true,
orderProperty: 'order',
doneColumns: ['done'],
});
const ids = b.columns().map((c) => c.id);
const todoOrder = b.column('todo').cards.map((c) => c.row.id);
const donePoints = b.rollup('epic')[0].donePoints;
return (`${ids.join(',')} | ${todoOrder.join(',')} | points ${b.points('todo')} | done ${donePoints}`);
columnOrder pins done before todo though it was configured
second; orderProperty sorts card 2 (order 10) before card 1 (order 20) inside
todo; pointsProperty is what makes b.points('todo') a real
sum rather than 0 (showPoints only decides whether that sum is drawn into the DOM
column header - see the accessible-name example below for that half); and
doneColumns: ['done'] - set directly, with no column def carrying
done: true - is what lets rollup('epic') count card 3's points as done.
Sprint, epic and swimlane selection, executed
const { createKanban } = await import('../packages/modules/kanban/index.js');
const rows = [
{ id: 1, status: 'todo', sprint: 'S1', epic: 'E1', assignee: 'Ann' },
{ id: 2, status: 'todo', sprint: 'S2', epic: 'E1', assignee: 'Bob' },
{ id: 3, status: 'todo', sprint: 'S1', epic: 'E2', assignee: 'Cy' },
];
const b = createKanban(null, {
rows, rowKey: 'id',
columnProperty: 'status',
columns: [{ id: 'todo' }],
sprintProperty: 'sprint', sprint: 'S1',
epicProperty: 'epic', epic: 'E1',
swimlaneProperty: 'assignee', swimlanes: true,
lanes: [{ id: 'Ann', title: 'Ann T' }, { id: 'Zed', title: 'Empty lane' }],
laneOrder: ['Zed', 'Ann'],
sprints: [{ id: 'S1', title: 'Sprint 1' }, { id: 'S2', title: 'Sprint 2' }],
});
const visible = [...b.model.cardsByKey.keys()];
const laneIds = b.model.lanes.map((l) => l.id);
const emptyLane = b.model.lanes.find((l) => l.id === 'Zed');
return (`${visible.join(',')} | lanes ${laneIds.join(',')} | empty ${emptyLane ? emptyLane.count : 'MISSING'} | sprints ${b.sprints().join(',')}`);
The board is constructed already narrowed to sprint S1 and epic E1 - no setSprint/
setEpic call - so only card 1 is visible. lanes configures an
Ann lane and a Zed lane nobody's assignee matches, kept
(count 0) because it is configured, exactly as an empty column is; laneOrder pins
it first. sprints lists both configured sprints even though only S1 has a visible
card.
Card mapping, quick filter and readonly, executed
const { createKanban } = await import('../packages/modules/kanban/index.js');
const rows = [
{ id: 1, status: 'todo', title: 'Login form' },
{ id: 2, status: 'todo', title: 'OAuth flow' },
];
const b = createKanban(null, {
rows, rowKey: 'id',
columnProperty: 'status',
columns: [{ id: 'todo' }],
card: { title: 'title' },
quickFilter: 'oauth',
readonly: { columns: { todo: true } },
});
const visibleTitles = [...b.model.cardsByKey.values()].map((c) => c.fields.title);
const isReadonly = b.readonly({ column: 'todo' });
return (`${visibleTitles.join(',')} | readonly ${isReadonly}`);
card: { title: 'title' } is what makes c.fields.title readable at all;
the initial quickFilter narrows the board to the one matching card before anything
renders; and the per-column readonly map reports todo as locked.
Accessible name, localised labels and selection, executed
const { createTestDom, TestEvent } = await import('../packages/dom/src/renderer/testdom.js');
const { createKanban } = await import('../packages/modules/kanban/index.js');
const dom = createTestDom({});
const el = dom.document.createElement('div');
dom.root.appendChild(el);
const b = createKanban(el, {
rows: [{ id: 1, status: 'todo', title: 'A', points: 5 }],
rowKey: 'id', columnProperty: 'status',
columns: [{ id: 'todo' }, { id: 'blocked' }],
card: { title: 'title' },
ariaLabel: 'Sprint board',
emptyText: 'No cards',
selectable: false,
labels: { grabbed: 'Grabbed' },
pointsProperty: 'points', showPoints: true,
});
const cardEl = el.querySelector('.lat-kanban__card');
cardEl.dispatchEvent(new TestEvent('click', {}));
const selectedAfterClick = b.selection().length;
cardEl.setAttribute('tabindex', '0');
cardEl.dispatchEvent(new TestEvent('keydown', { key: ' ' }));
const liveText = b.live.textContent;
const blockedEmpty = [...el.querySelectorAll('.lat-kanban__column')].find(c => c.dataset.column === 'blocked').querySelector('.lat-kanban__empty').textContent;
const ariaLabel = el.getAttribute('aria-label');
const todoContent = el.querySelector('.lat-kanban__headcontent');
const hasPoints = /lat-kanban__points/.test(todoContent.innerHTML);
return (`${ariaLabel} | selectable-click ${selectedAfterClick} | grab '${liveText}' | empty '${blockedEmpty}' | points ${hasPoints}`);
ariaLabel replaces the default "Board" name; selectable: false means a
plain click leaves the selection empty (the keyboard grab is a separate mechanism, unaffected);
labels.grabbed supplies the word the live region announces; emptyText
is the placeholder shown in the empty blocked column; and showPoints
(with pointsProperty) is what draws the points span into the column header - its
real job, distinct from pointsProperty alone computing the sum (see the board
structure example above, where showPoints is absent and b.points()
still works).
Add-card, edit and move lifecycle, plus WIP enforcement, executed
const { createKanban } = await import('../packages/modules/kanban/index.js');
const seen = [];
const b = createKanban(null, {
rows: [{ id: 'a', status: 'todo' }, { id: 'b', status: 'doing' }],
rowKey: 'id', columnProperty: 'status',
columns: [{ id: 'todo' }, { id: 'doing', wipLimit: 1 }, { id: 'blocked' }, { id: 'review' }],
card: { title: { field: 'title', edit: true } },
addCard: true,
onAddCard: (columnId) => ({ id: 'new', status: columnId, title: 'Created by onAddCard' }),
onCardEdit: () => true,
onBeforeMove: (card, from, to) => to !== 'blocked',
onCardMove: (e) => e.keys[0] !== 'a' || e.to !== 'review',
onCardClick: (e) => seen.push(`click:${e.card.key}`),
onCardDblClick: (e) => seen.push(`dbl:${e.card.key}`),
onCardContextMenu: (e) => seen.push(`ctx:${e.card.key}`),
enforceWip: true,
});
const newKey = b.addCard('todo');
const createdTitle = b.card(newKey).row.title;
await b.applyEdit('a', 'title', 'Renamed');
const editedTitle = b.card('a').row.title;
const wipRefused = await b.move('a', 'doing');
const vetoRefused = await b.move('a', 'blocked');
const revertedMove = await b.move('a', 'review');
b.fire('card:click', { card: b.card('a') });
b.fire('card:dblclick', { card: b.card('a') });
b.fire('card:contextmenu', { card: b.card('a') });
return ([
`created ${createdTitle}`,
`edited ${editedTitle}`,
`wip-moved ${wipRefused.moved.length}`,
`veto-moved ${vetoRefused.moved.length}`,
`reverted ${revertedMove.reverted}`,
`events ${seen.join(',')}`,
].join(' | '));
addCard opts the column into add-card at all; onAddCard supplies the
new row rather than an auto-generated one; onCardEdit confirms the inline edit.
enforceWip refuses the move into doing (already at its limit of 1) before
onBeforeMove is ever asked; a separate move into blocked is vetoed by
onBeforeMove instead; and a move into review - allowed by both - is
still reverted because onCardMove rejects it. onCardClick/
onCardDblClick/onCardContextMenu each fire from the matching
card:* event.
Workflow rules: transitions and required fields, executed
const { createKanban } = await import('../packages/modules/kanban/index.js');
const b = createKanban(null, {
rows: [
{ id: 1, status: 'todo', title: 'A' },
{ id: 2, status: 'doing', title: 'B', deployed: '2026-01-01' },
],
rowKey: 'id', columnProperty: 'status',
columns: [{ id: 'todo' }, { id: 'doing' }, { id: 'done' }],
card: { title: 'title' },
transitions: { todo: ['doing'], doing: ['done'] },
require: { done: [{ field: 'deployed', label: 'Deployed' }] },
});
let transition = 'no';
try { await b.move(1, 'done'); } catch (err) { transition = /kanban\.transition/.test(err.message) ? 'transition' : err.message; }
let required = 'no';
await b.move(1, 'doing');
try { await b.move(1, 'done'); } catch (err) { required = /kanban\.require/.test(err.message) ? 'require' : err.message; }
const moved = (await b.move(2, 'done')).moved.length;
return ([transition, required, moved].join(' | '));
transitions maps a source column to the columns a card may enter - todo
lists only doing, so a straight todo → done move is refused with a
kanban.transition error, while a source column absent from the map may move anywhere.
require maps a destination column to the fields a card must carry to enter it, so the
doing → done move of card 1 - allowed by the transition, but lacking
deployed - is refused with kanban.require, while card 2, which already
carries it, moves through. A drag over a disallowed column is marked not droppable and the
keyboard move skips it; a drop into a column with missing required fields opens a small form and
the move commits only when they are filled. Both maps may also be set per lane in
config.lanes.
Card aging / SLA: basis, thresholds, callbacks and the tick, executed
const { createKanban } = await import('../packages/modules/kanban/index.js');
const E = 1_700_000_000_000;
const DAY = 24 * 60 * 60 * 1000;
// basis comparison: same row data, two boards differing only in `basis`.
const rowData = { id: 'x', status: 'doing', enteredAt: E - 1 * DAY, createdAt: E - 10 * DAY };
function makeBoard(basis) {
return createKanban(null, {
rows: [rowData],
rowKey: 'id', columnProperty: 'status',
columns: [{ id: 'doing' }],
flow: false,
sla: {
basis, enteredProperty: 'enteredAt', createdProperty: 'createdAt',
useTransitionLog: false, warn: { days: 2 }, breach: { days: 5 },
now: () => E,
},
});
}
const byColumn = makeBoard('column').sla.stateFor('x').level; // uses enteredAt (1d) -> ok
const byBoard = makeBoard('board').sla.stateFor('x').level; // uses createdAt (10d) -> breach
// ignoreDone
const doneRow = { id: 'd', status: 'done', enteredAt: E - 30 * DAY };
const withIgnore = createKanban(null, {
rows: [doneRow], rowKey: 'id', columnProperty: 'status',
columns: [{ id: 'done', done: true }], flow: false,
sla: { enteredProperty: 'enteredAt', useTransitionLog: false, warn: { days: 2 }, breach: { days: 5 }, now: () => E },
});
const ignoredByDefault = withIgnore.sla.stateFor('d').level;
const notIgnored = createKanban(null, {
rows: [doneRow], rowKey: 'id', columnProperty: 'status',
columns: [{ id: 'done', done: true }], flow: false,
sla: { enteredProperty: 'enteredAt', useTransitionLog: false, ignoreDone: false, warn: { days: 2 }, breach: { days: 5 }, now: () => E },
}).sla.stateFor('d').level;
// onWarn/onBreach + showAge
const hits = [];
const warnBoard = createKanban(null, {
rows: [
{ id: 'w', status: 'doing', enteredAt: E - 3 * DAY },
{ id: 'br', status: 'doing', enteredAt: E - 6 * DAY },
],
rowKey: 'id', columnProperty: 'status', columns: [{ id: 'doing' }], flow: false,
sla: {
enteredProperty: 'enteredAt', useTransitionLog: false,
warn: { days: 2 }, breach: { days: 5 }, now: () => E, showAge: 'always',
onWarn: (level, rows) => hits.push(`warn:${level}:${rows[0].id}`),
onBreach: (level, rows) => hits.push(`breach:${level}:${rows[0].id}`),
},
});
const showAgeState = warnBoard.sla.stateFor('w').ageText;
// tick: a real re-check interval that fires without a manual evaluate()/move.
const clock = { t: E };
const ticks = [];
const tickBoard = createKanban(null, {
rows: [{ id: 't', status: 'doing', enteredAt: E }],
rowKey: 'id', columnProperty: 'status', columns: [{ id: 'doing' }], flow: false,
sla: {
enteredProperty: 'enteredAt', useTransitionLog: false,
breach: { days: 1 }, tick: 15, now: () => clock.t,
},
});
tickBoard.on('card:sla', (e) => ticks.push(e.level));
clock.t = E + 2 * DAY; // now past breach, but nobody called evaluate()
await new Promise((resolve) => setTimeout(resolve, 60));
tickBoard.sla.destroy();
return ([
`basis-column ${byColumn}`,
`basis-board ${byBoard}`,
`ignore-default ${ignoredByDefault}`,
`ignore-false ${notIgnored}`,
`hits ${hits.join(',')}`,
`age ${showAgeState}`,
`tick-fired ${ticks.length > 0}`,
].join(' | '));
The same card (an enteredAt of 1 day and a createdAt of 10 days) is
ok under basis: 'column' (which prefers enteredProperty) and
breach under basis: 'board' (which prefers createdProperty) - the same warn/breach thresholds, only the basis changed.
ignoreDone defaults a done card to no ageing at all (null) and
ignoreDone: false ages it anyway. onWarn and onBreach each
fire, shaped (level, rows), for the card that reaches that level; showAge:
'always' gives an age chip on a card that is not yet warned. tick re-evaluates
on its own timer - the card crosses into breach with no move or manual
evaluate() call - and useTransitionLog: false throughout is what keeps
every reading on the row timestamps rather than the flow log.
Custom card body, virtualization and a child pop-out, executed
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createKanban } = await import('../packages/modules/kanban/index.js');
const dom = createTestDom({});
const el = dom.document.createElement('div');
dom.root.appendChild(el);
const many = [];
for (let i = 0; i < 200; i++) many.push({ id: i, status: 'todo', title: `C${i}`, parent: null });
many.push({ id: 'e1', status: 'todo', title: 'Epic', parent: null });
many.push({ id: 's1', status: 'todo', title: 'Story', parent: 'e1' });
const b = createKanban(el, {
rows: many, rowKey: 'id', columnProperty: 'status', columns: [{ id: 'todo' }],
card: { title: 'title' },
cardRenderer: (card) => `<div class="custom">${card.fields.title}!</div>`,
virtualize: { rowHeight: 100, viewport: 500, threshold: 50, overscan: 2 },
children: { property: 'parent' },
});
const list = el.querySelector('.lat-kanban__cards');
const rendered = list.querySelectorAll('.lat-kanban__card').length;
const isVirtual = list.dataset.virtual;
const first = list.querySelector('.lat-kanban__card');
const customRendered = /class="custom"/.test(first.innerHTML);
const canExpandEpic = b.canExpand(b.card('e1'));
const canExpandLeaf = b.canExpand(b.card('s1'));
return ([
`virtual ${isVirtual}`,
`rendered ${rendered > 0 && rendered < 202}`,
`custom ${customRendered}`,
`expand-epic ${canExpandEpic}`,
`expand-leaf ${canExpandLeaf}`,
].join(' | '));
202 cards in one column, but virtualize renders only a scroll window of them;
cardRenderer owns the whole card body, replacing the default template; and
children: { property: 'parent' } is what makes the epic card expandable
(canExpand true) while a plain story is not.
Kanban board methods, executed
The methods table above names each board method on its own line; these five examples run a headless (or test-DOM) board and show what each method actually does, grouped by the workflow a host uses them for.
Column and lane interaction: collapse, reorder and single-move, executed
const { createKanban } = await import('../packages/modules/kanban/index.js');
const rows = [
{ id: 1, status: 'todo', assignee: 'Ann' },
{ id: 2, status: 'doing', assignee: 'Bob' },
{ id: 3, status: 'done', assignee: 'Cy' },
];
const b = createKanban(null, {
rows, rowKey: 'id', columnProperty: 'status',
columns: [{ id: 'todo' }, { id: 'doing' }, { id: 'done' }],
swimlaneProperty: 'assignee', swimlanes: true,
});
b.collapseColumn('doing');
const doingCollapsed = b.column('doing').collapsed;
b.collapseLane('Bob');
const bobCollapsed = b.model.lanes.find((l) => l.id === 'Bob').collapsed;
b.reorderColumns(['done', 'doing', 'todo']);
const afterReorder = b.columns().map((c) => c.id).join(',');
b.moveColumn('todo', 'done'); // sit todo just before done
const afterMoveColumn = b.columns().map((c) => c.id).join(',');
b.reorderLanes(['Cy', 'Bob', 'Ann']);
const afterLaneReorder = b.model.lanes.map((l) => l.id).join(',');
b.moveLane('Ann', 'Cy'); // sit Ann just before Cy
const afterMoveLane = b.model.lanes.map((l) => l.id).join(',');
return (`collapsed ${doingCollapsed},${bobCollapsed} | reorder ${afterReorder} | moveColumn ${afterMoveColumn} | laneReorder ${afterLaneReorder} | moveLane ${afterMoveLane}`);
collapseColumn/collapseLane toggle without an id argument and force a
state with one; reorderColumns/reorderLanes take the whole desired
order, while moveColumn/moveLane move a single id to sit before
another (or the end, with null) - the primitive a header drag calls underneath a
full reorder.
Selection and search: select, filter and facets, executed
const { createKanban } = await import('../packages/modules/kanban/index.js');
const rows = [
{ id: 'a', status: 'todo', points: 3, title: 'Login form' },
{ id: 'b', status: 'todo', points: 5, title: 'OAuth flow' },
{ id: 'c', status: 'doing', points: 2, title: 'Reset flow' },
];
const b = createKanban(null, {
rows, rowKey: 'id', columnProperty: 'status',
columns: [{ id: 'todo' }, { id: 'doing' }], card: { title: 'title' },
});
b.select(['a', 'b'], 'set');
const aSelected = b.isSelected('a');
b.clearSelection();
const afterClear = b.selection().length;
b.setFilter((row) => row.points >= 3);
const afterFilter = [...b.model.cardsByKey.keys()].sort().join(',');
b.setQuickFilter('oauth');
const afterQuick = [...b.model.cardsByKey.keys()].join(',');
b.setFilter(null); b.setQuickFilter('');
const facetCounts = b.facets('status').map((f) => `${f.value}:${f.count}`).join(',');
return (`selected ${aSelected} | cleared ${afterClear} | filter ${afterFilter} | quick ${afterQuick} | facets ${facetCounts}`);
select replaces the set (mode 'set'); isSelected reads it
back; clearSelection empties it. setFilter narrows to the points-3+
cards, and setQuickFilter narrows that further by text - the two compose, which is
why only card b survives both. facets reads every row (not just the
shown ones) for a distinct-value/count breakdown, over status here.
Sprint, epic and backlog navigation, executed
const { createKanban } = await import('../packages/modules/kanban/index.js');
const rows = [
{ id: 1, status: 'todo', sprint: 'S1', epic: 'E1', points: 3 },
{ id: 2, status: 'doing', sprint: 'S1', epic: 'E1', points: 5 },
{ id: 3, status: 'done', sprint: 'S2', epic: 'E2', points: 8 },
{ id: 4, status: 'todo', sprint: null, epic: 'E1', points: 2 }, // backlog: no sprint
];
const b = createKanban(null, {
rows, rowKey: 'id', columnProperty: 'status',
columns: [{ id: 'todo' }, { id: 'doing' }, { id: 'done', done: true }],
sprintProperty: 'sprint', epicProperty: 'epic', pointsProperty: 'points',
sprints: [{ id: 'S1', title: 'Sprint 1' }, { id: 'S2', title: 'Sprint 2' }],
});
b.setSprint('S1');
const inSprint = [...b.model.cardsByKey.keys()].sort().join(',');
b.showBacklog();
const inBacklog = [...b.model.cardsByKey.keys()].join(',');
b.setSprint(undefined);
b.setEpic('E1');
const inEpic = [...b.model.cardsByKey.keys()].sort().join(',');
const epicList = b.epics().join(',');
const rollup = b.epicRollup().find((r) => r.value === 'E1');
const defs = b.sprintDefs().map((d) => d.title).join(',');
return (`sprint ${inSprint} | backlog ${inBacklog} | epic ${inEpic} | epics ${epicList} | rollup ${rollup.count},${rollup.points} | defs ${defs}`);
setSprint narrows to one sprint; showBacklog (sugar for
setSprint(board.BACKLOG)) shows only the card with no sprint; setEpic
narrows by epic instead. epics lists the distinct epic values (facet sugar);
epicRollup gives per-epic count/points; sprintDefs gives the
configured sprint dataset as display titles, in order.
Board lifecycle: columns, rows, status and events, executed
const { createKanban } = await import('../packages/modules/kanban/index.js');
const rows = [{ id: 1, status: 'todo' }, { id: 2, status: 'doing' }];
const b = createKanban(null, {
rows, rowKey: 'id', columnProperty: 'status', columns: [{ id: 'todo' }, { id: 'doing' }],
});
const countTodo = b.count('todo');
const cardsCount = b.cards().length;
b.setColumns([{ id: 'todo' }, { id: 'doing' }, { id: 'done' }]);
const columnsAfter = b.columns().map((c) => c.id).join(',');
const arr = [{ id: 3, status: 'done' }];
b.setRows(arr); // back to a configured array (rather than a routed rows.apply feed)
arr.push({ id: 4, status: 'todo' }); // mutate the array in place
b.refresh(); // re-reads config.rows, so the pushed row shows up
const afterRefresh = b.cards().map((c) => c.key).sort().join(',');
b.setLoading(true);
const loadingState = b.status.loading;
b.setError('Oops');
const errorState = b.status.error;
b.collapseColumn('done');
const snap = b.getState();
const b2 = createKanban(null, {
rows: [{ id: 3, status: 'done' }], rowKey: 'id', columnProperty: 'status',
columns: [{ id: 'todo' }, { id: 'doing' }, { id: 'done' }],
});
b2.setState(snap);
const restoredCollapsed = b2.column('done').collapsed;
let handlerCalls = 0;
const h = () => { handlerCalls += 1; };
b.on('card:add', h);
b.off('card:add', h); // unsubscribed before the next card:add
b.addCard('todo');
b.destroy();
const afterDestroy = b.model.columns.length;
return (`count ${countTodo} | cards ${cardsCount} | setColumns ${columnsAfter} | refresh ${afterRefresh} | loading ${loadingState} | error ${errorState} | state ${restoredCollapsed} | off ${handlerCalls} | destroy ${afterDestroy}`);
setColumns replaces the column set in place; setRows points a
standalone board back at a plain array, and refresh re-reads it (the pushed row
appears with no further call); setLoading/setError set the host-visible
status; getState/setState round-trip the collapsed set onto a second
board; off - called before the triggering addCard - is why the handler
never runs; and destroy empties the model.
Blocked cards and dependencies, executed
const { createKanban } = await import('../packages/modules/kanban/index.js');
const board = createKanban(null, {
rows: [
{ id: 'a', status: 'todo', title: 'A' },
{ id: 'b', status: 'todo', title: 'B', blockedBy: 'a' },
{ id: 'c', status: 'doing', title: 'C', blocked: 'Waiting on design' },
],
rowKey: 'id', columnProperty: 'status',
columns: [{ id: 'todo' }, { id: 'doing' }, { id: 'done', done: true }],
blockedProperty: 'blocked', blockedByProperty: 'blockedBy', blockedFilter: true, card: { title: 'title' },
});
board.setBlockedFilter(false);
const c = board.card('c');
const badge = c.blocked + ' ' + c.blockedReason;
const b = board.card('b');
const deps = b.blockedBy.join(',') + ' ' + b.blocked;
board.setBlockedFilter(true);
const filter = board.model.cardsByKey.size;
board.setBlockedFilter(false);
await board.setBlocked('c', false);
const cleared = board.card('c').explicitBlocked;
await board.toggleBlocked('c', 'oops');
const toggled = board.card('c').blocked;
await board.move('a', 'done');
const ready = board.card('b').ready;
board.destroy();
return (`c ${badge} | b ${deps} | filter ${filter} | cleared ${cleared} | toggled ${toggled} | ready ${ready}`);
blockedProperty names the field that blocks a card (a boolean or a reason
string); blockedByProperty names the keys of the cards blocking it. The
blocked filter (setBlockedFilter) shows only blocked cards, and
setBlocked/toggleBlocked write the property and raise
card:edit; a blocker moving into a done column flips the dependant to
ready.
Card pop-out and inline edit, executed
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createKanban } = await import('../packages/modules/kanban/index.js');
const dom = createTestDom({});
const el = dom.document.createElement('div');
dom.root.appendChild(el);
const rows = [
{ id: 'e1', status: 'todo', title: 'Epic', parent: null },
{ id: 's1', status: 'todo', title: 'Story', parent: 'e1' },
];
const b = createKanban(el, {
rows, rowKey: 'id', columnProperty: 'status', columns: [{ id: 'todo' }],
card: { title: { field: 'title', edit: true } },
children: { property: 'parent', render: () => {} },
});
const editable = b.isFieldEditable('title');
const detail = await b.expand('e1');
const opened = !!detail;
b.closeDetail();
const handle = b.editCard('e1', 'title');
const editing = !!handle;
return (`editable ${editable} | opened ${opened} | editing ${editing}`);
isFieldEditable is true only for a card field mapped with edit: true;
expand opens the epic's child pop-out (here via a custom children.render,
so no grid factory is needed) and closeDetail closes it; editCard then
starts inline edit on the same field, independent of the pop-out.
Server-side board (pushdown), executed
Bind a board to the same source object a grid takes - createPushdownSource({ adapter, pageSize, aggregates: { default: 'engine' }, edit: true }) - and the column count and points sum read an engine
GROUP BY rather than the loaded page, cards load one pageSize at a time
with board.loadMore(columnId) fetching the next page as a column scrolls, the board
search and the pushable named filters run as engine predicates, and a move writes through the
source's update path and re-queries the affected columns. board.reload() re-reads every
column's first page and the engine totals. Flow analytics need a transition log the source supplies:
pass transitionLog to enable them; without one the board reports the flow features
unavailable. The example runs the whole path headless.
const { createKanban } = await import('../packages/modules/kanban/index.js');
const { createPushdownSource } = await import('../packages/core/src/index.js');
const rows = [
{ id: 1, status: 'todo', points: 1, title: 'A' },
{ id: 2, status: 'todo', points: 2, title: 'B' },
{ id: 3, status: 'todo', points: 3, title: 'C' },
{ id: 4, status: 'todo', points: 4, title: 'D' },
{ id: 5, status: 'todo', points: 5, title: 'E' },
{ id: 6, status: 'done', points: 6, title: 'F' },
{ id: 7, status: 'done', points: 7, title: 'G' },
];
const adapter = {
capabilities: {
filter: true, operators: ['eq', 'and'], quick: true, range: true, total: true, group: true,
mutate: { update: true, returning: 'row' },
},
async execute(query) {
const tree = query.filters;
const eq = tree && Array.isArray(tree.conditions) ? tree.conditions.find((c) => c.op === 'eq') : tree;
const col = eq && eq.value;
const matched = rows.filter((r) => col === undefined || r.status === col);
const start = query.range ? query.range.start : 0;
const end = query.range ? Math.min(query.range.end, matched.length) : matched.length;
return { rows: matched.slice(start, end), total: matched.length };
},
async executeGroupedAggregates(query, groupBy, aggs) {
const by = new Map();
for (const r of rows) {
const keys = groupBy.map((g) => r[g]);
const k = JSON.stringify(keys);
if (!by.has(k)) by.set(k, { keys, rows: [] });
by.get(k).rows.push(r);
}
return [...by.values()].map(({ keys, rows: bucket }) => ({
keys,
level: groupBy.length,
values: Object.fromEntries(aggs.map((a) => [a.id,
a.fn === 'count' ? bucket.length : bucket.reduce((t, r) => t + r[a.col], 0)])),
}));
},
async mutate(op) {
const r = rows.find((x) => String(x.id) === op.key);
if (r) Object.assign(r, op.patch);
return { keys: [op.key], rows: [r] };
},
};
const source = createPushdownSource({ adapter, pageSize: 2, aggregates: { default: 'engine' }, edit: true });
const board = createKanban(null, {
source,
rowKey: 'id',
columnProperty: 'status',
columns: [{ id: 'todo', title: 'To do', wipLimit: 2 }, { id: 'done', title: 'Done' }],
pointsProperty: 'points',
card: { title: 'title' },
pageSize: 2,
transitionLog: [],
});
await new Promise((r) => setTimeout(r, 20));
const count = board.count('todo');
const cards = board.column('todo').cards.length;
const over = board.column('todo').over;
const more = await board.loadMore('todo');
const cardsAfter = board.column('todo').cards.length;
const reloaded = await board.reload();
const isBoard = reloaded === board;
const flow = board.flowAvailable;
board.destroy();
return [count, cards, over, more, cardsAfter, isBoard, flow].join(' | ');
Layout config keys, executed
The reference table names each Layout config key on its own line; these five examples run a real dashboard over the test DOM and show what each key actually does, grouped by what they configure together.
Geometry: overflow axes, track size, gap and padding, executed
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createLayout } = await import('../packages/modules/layout/index.js');
const { document, root } = createTestDom({ width: 600, height: 400 });
const host = document.createElement('div');
root.appendChild(host);
const layout = createLayout(host, {
columns: 6, rows: 4, overflowX: 'scroll', overflowY: 'static', columnWidth: '200px',
gap: '20px', padding: '15px', compact: 'none',
windows: [{ id: 'a', xPos: 1, yPos: 1, xSize: 2, ySize: 1 }],
});
const canvas = host.querySelector('.lat-layout__canvas');
const viewport = host.querySelector('.lat-layout__viewport');
return (`${viewport.style.overflowX} ${viewport.style.overflowY} | ${canvas.style.gridTemplateColumns} | gap ${canvas.style.gap} | padding ${layout.payload('a').style.padding}`);
overflowX: 'scroll' is the axis that scrolls; overflowY stays
'static' independently; columnWidth is the fixed track a scrolling
axis repeats; and gap/padding reach the DOM as the real CSS lengths
configured, not their 8px/5px built-in fallbacks. compact: 'none' is what a
single-window layout cannot show on its own - see the before-events example below for
compact's effect on a move.
Layout-level interactivity defaults, executed
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createLayout } = await import('../packages/modules/layout/index.js');
const { document, root } = createTestDom({ width: 600, height: 400 });
const host = document.createElement('div');
root.appendChild(host);
const layout = createLayout(host, {
columns: 3, rows: 1,
closable: true, movable: true, resizable: true, maximisable: true, minimisable: true,
windows: [
{ id: 'bare', xPos: 1, yPos: 1, xSize: 1, ySize: 1 },
{ id: 'opted-out', xPos: 2, yPos: 1, xSize: 1, ySize: 1, movable: false },
],
});
const frame = (id) => host.querySelector(`[data-window-id="${id}"]`);
const bareHasClose = !!frame('bare').querySelector('.lat-layout__close');
const bareHasGrip = !!frame('bare').querySelector('.lat-layout__grip');
const optedOutHasGrip = !!frame('opted-out').querySelector('.lat-layout__grip');
const canMaximise = layout.maximise('bare');
const canMinimise = layout.minimise('bare');
return (`close ${bareHasClose} | grip ${bareHasGrip} | opted-out-grip ${optedOutHasGrip} | maximise ${canMaximise} | minimise ${canMinimise}`);
Every layout-level default (closable, movable, resizable,
maximisable, minimisable) applies to bare, which declares
none of its own; opted-out's own movable: false beats the layout-level
true, which is why it renders no grip. maximisable/minimisable
are what let maximise()/minimise() succeed on a window that never
declared either itself.
A layout-level corners default, and a window's own override, executed
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createLayout } = await import('../packages/modules/layout/index.js');
const { document, root } = createTestDom({ width: 400, height: 200 });
const host = document.createElement('div');
root.appendChild(host);
const layout = createLayout(host, {
columns: 2, rows: 1, corners: 'square',
windows: [
{ id: 'a', xPos: 1, yPos: 1, xSize: 1, ySize: 1 },
{ id: 'b', xPos: 2, yPos: 1, xSize: 1, ySize: 1, corners: 'rounded' },
],
});
const frame = (id) => host.querySelector(`[data-window-id="${id}"]`);
const a = frame('a').getAttribute('data-corners');
const b = frame('b').getAttribute('data-corners');
return (`a ${a} | b ${b}`);
The layout's corners: 'square' reaches a, which declares none of its
own, and zeroes its radius (and its title bar's, and - were it minimised or maximised - that
state's too, since all three are this same frame element). b's own
corners: 'rounded' overrides the layout default back to today's look, which is
why it carries no data-corners attribute at all - the default needs none, it is
themed through --lattice-layout-radius instead.
The window list and a saved arrangement applied at mount, executed
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createLayout } = await import('../packages/modules/layout/index.js');
const { document, root } = createTestDom({ width: 600, height: 400 });
const host = document.createElement('div');
root.appendChild(host);
const layout = createLayout(host, {
columns: 4, rows: 4, compact: 'none',
windows: [
{ id: 'a', xPos: 1, yPos: 1, xSize: 1, ySize: 1 },
{ id: 'b', xPos: 2, yPos: 1, xSize: 1, ySize: 1 },
],
layout: { columns: 4, rows: 4, windows: [{ id: 'a', xPos: 3, yPos: 2, xSize: 1, ySize: 1 }] },
});
const ids = layout.windows();
const snap = layout.getLayout();
const aPlaced = snap.windows.find((w) => w.id === 'a');
const bPlaced = snap.windows.find((w) => w.id === 'b');
return (`${ids.join(',')} | a@${aPlaced.xPos},${aPlaced.yPos} | b@${bPlaced.xPos},${bPlaced.yPos}`);
windows declares two windows, a at column 1 and b at
column 2; the mount-time layout arrangement then moves a to (3, 2),
overriding its own declared placement, while b - not named in layout - stays exactly where windows put it.
Move and resize before-events, independently gated and paired with their cancellations, executed
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createLayout } = await import('../packages/modules/layout/index.js');
const { document, root } = createTestDom({ width: 600, height: 400 });
const host = document.createElement('div');
root.appendChild(host);
const moved = [];
const resized = [];
const cancelledMove = [];
const cancelledResize = [];
const layout = createLayout(host, {
columns: 4, rows: 4, compact: 'none',
windows: [{ id: 'a', xPos: 1, yPos: 1, xSize: 1, ySize: 1 }],
onWindowMoved: (e) => moved.push(e),
onWindowResized: (e) => resized.push([e.id, e.xSize, e.ySize]),
onBeforeWindowMove: (e) => (e.to.xPos === 4 ? e.preventDefault('pinned') : true),
onWindowMoveCancelled: (e) => cancelledMove.push(e.reason),
onBeforeWindowResize: (e) => (e.to.xSize === 3 ? e.preventDefault('too-wide') : true),
onWindowResizeCancelled: (e) => cancelledResize.push(e.reason),
});
const okMove = await layout.move('a', { xPos: 2 });
const vetoedMove = await layout.move('a', { xPos: 4 });
const okResize = await layout.move('a', { xSize: 2 });
const vetoedResize = await layout.move('a', { xSize: 3 });
return (`moved ${okMove}/${vetoedMove} to xPos ${layout.getLayout().windows[0].xPos} | resized ${okResize}/${vetoedResize} to xSize ${layout.getLayout().windows[0].xSize} | cancelled-move ${cancelledMove.join(',')} | cancelled-resize ${cancelledResize.join(',')} | moved-events ${moved.length} | resized-events ${resized.length}`);
move(id, to) resolves to a resize or a move from what changed. The first move and
the first resize both succeed and fire onWindowMoved/onWindowResized
once each; the second of each is vetoed by its own before-hook - onBeforeWindowMove
never sees the resize and onBeforeWindowResize never sees the move - firing
onWindowMoveCancelled/onWindowResizeCancelled with the veto's reason
and leaving the window exactly where it was.
Close before-event and layout:changed, executed
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createLayout } = await import('../packages/modules/layout/index.js');
const { document, root } = createTestDom({ width: 600, height: 400 });
const host = document.createElement('div');
root.appendChild(host);
const closed = [];
const cancelledClose = [];
const changed = [];
const layout = createLayout(host, {
columns: 4, rows: 4, compact: 'none',
windows: [
{ id: 'a', xPos: 1, yPos: 1, xSize: 1, ySize: 1, closable: true },
{ id: 'locked', xPos: 2, yPos: 1, xSize: 1, ySize: 1, closable: true },
],
onWindowClosed: (e) => closed.push(e.id),
onBeforeWindowClose: (e) => (e.id === 'locked' ? e.preventDefault('unsaved') : true),
onWindowCloseCancelled: (e) => cancelledClose.push(e.reason),
onLayoutChanged: (e) => changed.push(e.cause),
});
const okClose = await layout.close('a');
const vetoedClose = await layout.close('locked');
return (`closed ${okClose}/${vetoedClose} | ids ${closed.join(',')} | cancelled ${cancelledClose.join(',')} | changed-causes ${changed.join(',')} | remaining ${layout.windows().join(',')}`);
Closing a succeeds, firing onWindowClosed and onLayoutChanged
(cause 'close'); closing locked is vetoed by onBeforeWindowClose,
firing onWindowCloseCancelled with the reason instead, and it survives in
windows().
AI config keys, executed
The reference table names each AI config key on its own line; these three examples run a
real controller (a MOCK ask() throughout - never a real provider) and show
what each key actually does.
The insights panel: element, tools, enable, maxColumns, reconcile, onNarrative, executed
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createAI } = await import('../packages/modules/ai/index.js');
const dom = createTestDom({});
const el = dom.document.createElement('div');
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'id' }, { field: 'a' }, { field: 'b' }],
rows: [{ id: 1, a: 1, b: 2 }, { id: 2, a: 3, b: 4 }],
});
const narrated = [];
const ai = createAI(grid, {
ask: async () => ({ text: 'There are 2 rows. Confidence 900%.' }),
element: el,
tools: false,
maxColumns: 1,
reconcile: 'flag',
enable: ['narrative'],
onNarrative: (r) => narrated.push(r.text),
});
ai.insights();
const result = await ai.explain({ kind: 'view' });
const cappedFacts = ai.facts({ kind: 'view' }).facts.length;
const uncapped = createAI(grid, { ask: async () => ({ text: '' }) }).facts({ kind: 'view' }).facts.length;
return (`${result.text} | narrated ${narrated.length} | flagged ${result.flagged.join(',')} | panel ${!!el.querySelector('.lat-ai__btn')} | facts ${cappedFacts}/${uncapped}`);
element + tools: false (packet mode, no function-calling) are what
make ai.insights() mount a real panel button; enable: ['narrative']
is what lets that mounting succeed at all - leaving it out of enable would
return with no panel. enable gates only the three DOM-mounting convenience
methods (insights(), askBar(), actorBar()): the
ai.explain() call right below still runs and returns a result even though
'insights'/'query'/'ask'/'actor' are absent
from this enable list - the programmatic API is never gated, so a host that
wants no AI surface at all simply never calls these methods. reconcile: 'flag'
keeps the ungrounded "900%" in the text
(the default strips it instead) while still reporting it in flagged;
onNarrative fires with the same result explain() returns;
maxColumns: 1 is why the capped run gathers 12 facts against 34 uncapped.
Ask-your-data: autoApply, schemaOptions, redact, router, onQuery, onError, executed
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');
const { createAI } = await import('../packages/modules/ai/index.js');
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'id' }, { field: 'region' }, { field: 'ssn' }],
rows: [
{ id: 1, region: 'EMEA', ssn: 'a' },
{ id: 2, region: 'AMER', ssn: 'b' },
{ id: 3, region: 'EMEA', ssn: 'c' },
],
});
const chart = createHeadlessGrid({ rowKey: 'id', columns: [{ field: 'id' }, { field: 'region' }] });
const router = createDataRouter({ rowKey: 'id', overlap: true });
router.attach(chart, () => true);
const errors = [];
const queried = [];
let capturedSchema = null;
const ask = async (req) => {
capturedSchema = req.schema;
return { actions: [{ type: 'setFilters', filters: { col: 'region', op: 'eq', value: 'EMEA' } }] };
};
const ai = createAI(grid, {
ask, router, autoApply: true, schemaOptions: { maxColumns: 2 }, redact: ['ssn'],
onQuery: (r) => queried.push(r.ok), onError: (e) => errors.push(e),
});
await ai.query('EMEA only');
const chartIds = [];
for (let i = 0; i < chart.rows.count(); i++) chartIds.push(chart.rows.get(i).key);
return (`queried ${queried.join(',')} | schema-cols ${capturedSchema.columns.map((c) => c.id).join(',')} | chart ${chartIds.sort().join(',')} | errors ${errors.length}`);
schemaOptions: { maxColumns: 2 } is why the model is shown only
id,region - ssn is also stripped by redact, so it
never reaches the schema either way; autoApply runs the safe read the moment it
resolves, with no confirm step; router, configured once here rather than passed
to every call, is what fans the answer to the chart with no opts.router anywhere
in this example; onQuery fires with the result, and onError is wired
but silent because nothing failed.
The governed actor: onProposal, executed
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createAI } = await import('../packages/modules/ai/index.js');
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'id' }, { field: 'name' }, { field: 'score', type: 'number', edit: { enabled: true } }],
rows: [{ id: 1, name: 'Alpha', score: 10 }],
});
const proposals = [];
const ask = async () => ({ structured: { edits: [{ match: 'Alpha', column: 'score', value: 50 }] } });
const ai = createAI(grid, { ask, onProposal: (r) => proposals.push(r.ok) });
const result = await ai.propose('set Alpha score to 50');
const report = await ai.applyProposal(result);
return (`proposals ${proposals.join(',')} | diff ${result.diff.map((d) => `old ${d.oldValue} new ${d.newValue}`).join(',')} | applied ${report.applied} | score-now ${grid.rows.byKey('1').data.score}`);
onProposal fires with the built diff before any approval; applyProposal
is the separate human-approval step that actually writes, through the grid's own gated edit
path.
KPI config keys, executed
Four examples covering the twelve KPI config keys not already demonstrated elsewhere in this guide.
The tile hierarchy: tiles, an explicit separator, expanded, onNodeToggle, executed
const { createKPI } = await import('../packages/modules/kpi/index.js');
const HOSTS = [{ id: 1, cpu: 91, mem: 40, lat: 12 }, { id: 2, cpu: 30, mem: 55, lat: 40 }];
const toggled = [];
const kpi = createKPI(null, {
rows: HOSTS, rowKey: 'id',
tree: { separator: '/', expanded: true },
tiles: [
{ id: 'compute/cpu', label: 'cpu', aggregation: 'max', field: 'cpu' },
{ id: 'compute/memory', label: 'memory', aggregation: 'avg', field: 'mem' },
{ id: 'network/latency', label: 'latency', aggregation: 'max', field: 'lat' },
],
onNodeToggle: (e) => toggled.push([e.key, e.expanded]),
});
const topLevel = kpi.nodes().map((n) => n.label).sort();
const key = kpi.nodes()[0].key;
kpi.toggle(key);
return (`${topLevel.join(',')} | tiles ${kpi.model.tiles.length} | toggled ${toggled.map((t) => t.join(':')).join(',')}`);
tiles declares the three measured leaves; separator: '/' is what
derives compute/network from ids that only contain /,
not the default .; expanded: true opens every branch at construction,
so the very first toggle() call closes one rather than opening it;
onNodeToggle reports exactly that.
Grid-bound events: fields, onTileClick, onTileDblClick, onTileContextMenu, onChange, executed
const { createTestDom, TestEvent } = await import('../packages/dom/src/renderer/testdom.js');
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createKPI } = await import('../packages/modules/kpi/index.js');
const dom = createTestDom({});
const el = dom.document.createElement('div');
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ id: 'id', field: 'id' }, { id: 'priority', field: 'priority' }],
rows: [{ id: 1, priority: 'P1' }, { id: 2, priority: 'P2' }],
});
const clicks = [];
const dbl = [];
const ctx = [];
const changes = [];
const kpi = createKPI(el, {
grid,
fields: ['priority'],
tiles: [{ id: 'p1', label: 'P1 jobs', aggregation: 'count', filter: (r) => r.priority === 'P1' }],
onTileClick: ({ id }) => clicks.push(id),
onTileDblClick: ({ id }) => dbl.push(id),
onTileContextMenu: ({ id }) => ctx.push(id),
onChange: (e) => changes.push(e.model.tiles[0].value),
});
const fig = el.querySelector('.lat-kpi__tile');
fig.dispatchEvent(new TestEvent('click', {}));
fig.dispatchEvent(new TestEvent('dblclick', {}));
fig.dispatchEvent(new TestEvent('contextmenu', {}));
return (`p1-jobs ${kpi.value('p1')} | click ${clicks.join(',')} | dbl ${dbl.join(',')} | ctx ${ctx.join(',')} | changes ${changes.length}`);
fields: ['priority'] is what lets the tile's filter read
priority at all on a grid-bound panel - a field a tile does not itself declare is
otherwise not projected; the three pointer events each route to their matching config
callback, and onChange fires once, at construction.
A host catalogue and an unmeasured tile: messages, nullText, executed
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createKPI } = await import('../packages/modules/kpi/index.js');
const dom = createTestDom({});
const el = dom.document.createElement('div');
const HOSTS = [{ id: 1, cpu: 91, mem: 40 }];
const kpi = createKPI(el, {
rows: HOSTS, rowKey: 'id',
tree: { expanded: true },
nullText: 'n/a',
messages: {
t: (key, params) => {
if (key === 'kpi.status.critical') return 'kritisch';
if (key === 'kpi.node.worst') return `schlimmster Status ${params.status}`;
return key;
},
},
tiles: [
{ id: 'compute.cpu', label: 'cpu', aggregation: 'max', field: 'cpu', thresholds: { warn: 70, critical: 90, direction: 'lowerIsBetter' } },
// A tile whose field names no column any row carries: measures nothing.
{ id: 'compute.disk', label: 'disk', aggregation: 'max', field: 'disk' },
],
});
const compute = kpi.nodes()[0];
const diskTile = kpi.tile('compute.disk');
return (`${el.querySelectorAll('.lat-kpi__node')[0].getAttribute('aria-label')} | disk-formatted ${diskTile.formatted} | disk-status ${diskTile.status}`);
messages translates the node's accessible name (falling back to English for a key
the host catalogue omits); nullText is what the unmeasured disk tile
renders instead of a fabricated zero. The heading counts that tile among the ones it could
not grade - 1 unknown, beside the worst status it did grade - because a leaf with no reading is one of the two things a parent has to report, and a
heading that named only the severity would be telling a reader the other leaf agreed.
What the movement line prints against a baseline: delta, executed
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createKPI } = await import('../packages/modules/kpi/index.js');
const dom = createTestDom({});
const el = dom.document.createElement('div');
// A rate series (4.10 vs a 4.30 baseline a year earlier): the percentage of a
// rate is meaningless, so a host names 'absolute' to keep the line without it.
const kpi = createKPI(el, {
rows: [{ id: 1, rate: 4.10 }], rowKey: 'id',
tiles: [
{ id: 'both', label: 'Default', aggregation: 'max', field: 'rate',
baseline: 4.30, format: { decimals: 1, locale: 'en-US' } },
{ id: 'absolute', label: 'Difference alone', aggregation: 'max', field: 'rate',
baseline: 4.30, format: { decimals: 1, locale: 'en-US' }, delta: 'absolute' },
{ id: 'relative', label: 'Percentage alone', aggregation: 'max', field: 'rate',
baseline: 4.30, format: { decimals: 1, locale: 'en-US' }, delta: 'relative' },
],
});
const lines = ['both', 'absolute', 'relative'].map((id) => {
const fig = [...el.querySelectorAll('.lat-kpi__tile')].find((f) => f.getAttribute('aria-label').startsWith(
id === 'both' ? 'Default' : (id === 'absolute' ? 'Difference' : 'Percentage')));
return `${id}:${fig.querySelector('.lat-kpi__delta').textContent}`;
});
return lines.join(' | ');
delta: 'absolute' | 'relative' | 'both' (default 'both', unchanged)
chooses what a tile's movement line prints against its baseline - the
difference alone, the percentage alone, or both together, exactly as before this option
existed. The arrow and its colour follow the sign of the difference in every mode.
A stat tile in full: kind, aggregation, field, baseline, thresholds, bands, sparkline, executed
const { createKPI } = await import('../packages/modules/kpi/index.js');
const READINGS = [
{ id: 1, host: 'a', hour: 1, cpu: 41, up: true },
{ id: 2, host: 'a', hour: 2, cpu: 58, up: true },
{ id: 3, host: 'a', hour: 3, cpu: 77, up: true },
{ id: 4, host: 'b', hour: 1, cpu: 12, up: false },
];
const kpi = createKPI(null, {
rows: READINGS, rowKey: 'id',
tiles: [
{ kind: 'stat', id: 'cpu', label: 'CPU', aggregation: 'max', field: 'cpu',
filter: (row) => row.up, baseline: 50, delta: 'absolute',
thresholds: { warn: 60, critical: 85, direction: 'lowerIsBetter' },
sparkline: { x: 'hour', y: 'cpu' } },
{ kind: 'stat', id: 'spread', label: 'Spread', aggregation: 'avg', field: 'cpu',
bands: [{ max: 40, status: 'good' }, { min: 40, max: 60, status: 'warn' },
{ min: 60, status: 'critical' }] },
],
});
const [cpu, spread] = kpi.model.tiles;
return (`cpu ${cpu.value} ${cpu.status} | delta ${cpu.delta}`
+ ` | spark ${cpu.sparkline.join(',')} | spread ${spread.value} ${spread.status}`);
kind: 'stat' is the default and is written here only to sit beside the clock
tile below. aggregation and field are the measurement; the
filter is what makes the CPU tile read 77 rather than the whole panel's rows;
baseline: 50 is what the movement line is measured from.
thresholds grades against two cut points - here lowerIsBetter,
so 77 is a warning - while bands is the explicit alternative, a list of
half-open [min, max) ranges, first match winning. sparkline plots
the tile's own rows in x order.
A feed that went quiet: maxAge, ageBy, executed
const { createKPI } = await import('../packages/modules/kpi/index.js');
const hosts = (at) => Array.from({ length: 200 }, (_, i) => (
{ id: 'h' + i, sev: 3, observedAt: at }));
const panel = (at) => createKPI(null, {
rows: hosts(at), rowKey: 'id',
maxAge: 60000, ageBy: 'observedAt',
tiles: [{ id: 'sev1', label: 'Sev-1', aggregation: 'count', filter: (r) => r.sev === 1,
thresholds: { warn: 1, critical: 5, direction: 'lowerIsBetter' } }],
});
const quiet = panel(Date.now() - 6 * 60 * 60 * 1000);
const live = panel(Date.now());
const read = (kpi) => {
const tile = kpi.tile('sev1');
return `${tile.status} ${tile.value === null ? 'no-number' : tile.value}${tile.stale ? ' stale' : ''}`;
};
const out = `quiet ${read(quiet)} | live ${read(live)}`;
quiet.destroy();
live.destroy();
return out;
Both panels hold the same 200 rows and the same tile, and that tile's filter matches
none of them - a count of zero either way. They differ in one thing: when the rows
arrived. maxAge (milliseconds) is how long the panel keeps grading what it
holds after its feed last spoke, and ageBy names the clock that is measured
on - the same two options, with the same two meanings, a source's rolling window
takes. Inside the window the zero is graded, and “no Sev-1 incidents out of 200
healthy hosts” reads good, which is the news it is. Past it the panel
stops grading: every stat tile reports unknown with no number and
stale: true, and the tile captions itself “No recent data”, so a
feed that died six hours ago cannot read as a healthy zero under lowerIsBetter
cut points or as an alarm under higherIsBetter ones.
Omit ageBy and maxAge measures arrival instead - when rows last reached the panel, through rows.apply,
setRows or the bound grid announcing that its own rows changed - which
is what a feed with no timestamp column has to be judged on. A filter, a sort, an
expansion and a refresh() are not arrivals, so a live feed whose rows are all
filtered out of the grid beneath the panel reports the empty panel's unknown
(“No data”, stale: false) rather than a silence, and an empty
panel never goes stale. Declare no maxAge and the panel judges no freshness
at all: nobody has said what recent means, so everything it holds is graded however old it
is. A panel with a window checks itself on a low-frequency timer, because a silence is
the absence of events and nothing else would notice one.
A clock tile: kind, seconds, date, executed
const { createKPI } = await import('../packages/modules/kpi/index.js');
const kpi = createKPI(null, {
rows: [], rowKey: 'id', locale: 'en-GB',
tiles: [
{ kind: 'clock', id: 'utc', label: 'UTC', timeZone: 'UTC' },
{ kind: 'clock', id: 'desk', label: 'Desk', timeZone: 'UTC', seconds: false, date: false },
],
});
const shape = (tile) => `${tile.clock.time.split(':').length === 3 ? 'hh:mm:ss' : 'hh:mm'}`
+ `${tile.clock.date === null ? ' no date' : ' with date'}`;
const [full, plain] = kpi.model.tiles;
return `full ${shape(full)} | plain ${shape(plain)}`;
A clock tile measures nothing: kind: 'clock' takes a timeZone and a
locale and prints the device clock, the date on one line and the time on the
next. seconds: false drops the seconds from the time line and
date: false drops the date line entirely, which is what a wall of city clocks
along the top of a dashboard usually wants.
Data Router route options, executed
A per-route filter and transform, executed
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }, { id: 'amt', field: 'amt', type: 'number' }, { id: 'label', field: 'label' }],
});
const router = createDataRouter({ key: 'type', rowKey: 'id' });
router.attach(grid, 'order', {
filter: (row) => row.amt >= 20,
transform: (row) => ({ id: row.id, type: row.type, amt: row.amt, label: `#${row.id}:${row.amt}` }),
});
router.load([
{ id: 'o1', type: 'order', amt: 10 },
{ id: 'o2', type: 'order', amt: 30 },
{ id: 'o3', type: 'order', amt: 20 },
]);
const ids = [];
for (let i = 0; i < grid.rows.count(); i++) ids.push(grid.rows.get(i).key);
return (`${ids.sort().join(',')} | label ${grid.rows.value('o2', 'label')}`);
filter gives the grid only the rows at or above 20 - the route decides what the
grid ever sees, before any grid-level filter runs; transform derives the
label field the columns declare, reshaping each row on the way in.