Lattice Grid Buy a licence

api reference

Filtering and sorting: JavaScript Data Grid API

grid.filters and the condition tree, grid.sort, facets, quick filter modes, and the filter grammar.

API reference › Filtering and sorting

grid.filters

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

Dates and times in a condition. A condition on a date column compares calendar days. A condition on a datetime column compares the full wall clock - date and time, to the millisecond (since 1.86.2,; before, it compared the day alone, so a lower bound of 13:03 on the 4th admitted the 4th’s midnight). A zone-less value such as '2024-01-04T13:03' is read as written; an instant - a Date, epoch milliseconds or an ISO string with Z or an offset - is read as the wall clock it shows in the grid’s timeZone, else the viewer’s: the zone the column stored its own values in. The column filter menu on a datetime column takes a date and a time (filter.props.time: false keeps it to days) and writes type: 'datetime'; a condition saved with type: 'date' keeps comparing days.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const count = (g) => { let n = 0; g.rows.forEach(() => { n++; }); return n; };

// Hourly readings, kept as the wall clock they were taken at.
const grid = createHeadlessGrid({
  rowKey: 'id', timeZone: 'America/New_York',
  columns: [{ field: 'at', type: 'datetime' }],
  rows: Array.from({ length: 24 }, (v, h) => ({ id: h, at: `2024-01-04T${String(h).padStart(2, '0')}:00` })),
});
grid.filters.set({ col: 'at', op: 'gte', value: '2024-01-04T13:03' });
const wall = count(grid);
grid.filters.set({ col: 'at', op: 'gte', value: new Date('2024-01-04T12:00:00Z') });
const instant = count(grid);

// A date column still compares whole days.
const days = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ field: 'on', type: 'date' }],
  rows: [3, 4, 5, 6].map((d) => ({ id: d, on: `2024-01-0${d}` })),
});
days.filters.set({ col: 'on', op: 'gte', value: '2024-01-04T13:03' });
return `from 13:03: ${wall} rows; from 12:00Z in New York (07:00): ${instant} rows; `
  + `a date column, from the 4th at 13:03: ${count(days)} days`;

grid.sort

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

grid.facets

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

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

facets: true                                // grid-wide (same as { enabled: true })

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

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

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

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

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

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

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

Quick filter modes

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

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

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

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

Filter grammar

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

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

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

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

Host predicates: where

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

A configuration, executed

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

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

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

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

Writing direction and the two alignment vocabularies, executed

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

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

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

Non-blocking stream ingest, executed

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

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

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

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

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

Source-layer memory reduction, executed

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

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

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

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

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

Events, executed

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

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

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

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

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

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

View persistence, executed

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

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

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

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

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

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

Every grid namespace and method, executed

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

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

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

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

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

grid.destroy();
return present;

Every core export, executed

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

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

// Every declared export of the core package, named and checked. A symbol
// that was renamed or dropped fails here, not in a consumer's build. Locale
// catalogues are no longer named here: each ships as its own entry point and
// registers itself on import.
const declared = [
  'DEFAULT_LOCALE', 'EN_GB', 'MESSAGE_KEYS',
  'NO_CAPABILITIES', 'UNIT_SYSTEMS', 'applyResidual',
  'auditCatalogue', 'compileRules', 'createPushdownSource', 'createRadixType',
  'createUnitType', 'defineUnit', 'dfqlAdapter', 'duckdbAdapter',
  'evaluateFormula', 'formatList', 'formatUnit', 'getVersion',
  'graphqlAdapter', 'ingest', 'ingestSync', 'licenceInfo',
  'licenceState', 'licenseInfo', 'licenseState', 'looksLikeFormula',
  'odataAdapter', 'parseUnit', 'planQuery', 'referencesOf',
  'registerLocale', 'registerModules', 'registerUnitSystem', 'resolveLocale',
  'restAdapter', 'restoreState', 'serialiseState', 'setLicence',
  'setLicense', 'version',
];

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

The compatibility translation, executed

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

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

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

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

Cell spans, executed

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

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

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

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

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

Every module export, executed

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

// Every declared export of every shipped module, resolved against its own
// barrel. A module that stopped exporting something fails here.
const modules = [
  [await import('../packages/dom/src/index.js'), [
    'ContextMenu', 'Messages', 'Registry', 'autoInit',
    'createGrid', 'createLocalViewStorage', 'createMessages', 'createStat',
    'deltaOf', 'gridElementsWithin', 'hydrateTable', 'mountPanel',
    'readTable', 'toneOf', 'LatticeGrid',
  ]],
  [await import('../packages/modules/charts/index.js'), [
    'Chart', 'PALETTE', 'SCHEMES', 'TYPES',
    'createChart', 'chartRange', 'canChartRange', 'deriveRangeSpec', 'regressionPlots',
    'registerScheme', 'resolveScheme', 'schemeNames',
    'setDefaultScheme',
  ]],
  [await import('../packages/modules/htmx/index.js'), [
    'HTML_ROW_WARNING_THRESHOLD', 'QUERY_CHANGED_EVENT', 'SCROLL_NEAR_END_EVENT', 'attach',
    'destroyWithin', 'driveInfiniteScroll', 'driveOobUpdates', 'driveServerMode',
     'ingestResponse', 'initWithin', 'queryParams', 'rebindViewersWithin', 'restoreStateWithin',
     'rowsFromFragment', 'rowsFromJson', 'saveStateWithin', 'warnIfLargeHtmlPayload',
     'autoInitCalendar', 'CALENDAR_ATTR', 'registerCalendar',
     'destroyCalendarWithin', 'saveCalendarStateWithin', 'restoreCalendarStateWithin',
     'restoreSwappedCalendars', 'driveCalendarOobUpdates',
     'CALENDAR_CHANGE_EVENT', 'GRID_CHANGE_EVENT',
     'autoInitGantt', 'GANTT_ATTR', 'GANTT_CHANGE_EVENT', 'GANTT_COMMIT_EVENT', 'registerGantt',
     'destroyGanttWithin', 'saveGanttStateWithin', 'restoreGanttStateWithin',
     'restoreSwappedGantts', 'driveGanttOobUpdates',
     'autoInitDesigner', 'DESIGNER_ATTR', 'DESIGNER_CHANGE_EVENT', 'registerDesigner',
     'destroyDesignerWithin', 'saveDesignerStateWithin', 'restoreDesignerStateWithin',
     'restoreSwappedDesigners',

     'autoInitKanban', 'KANBAN_ATTR', 'KANBAN_CHANGE_EVENT', 'registerKanban',
     'destroyKanbanWithin', 'saveKanbanStateWithin', 'restoreKanbanStateWithin',
     'restoreSwappedKanbans', 'driveKanbanOobUpdates',
     'autoInitMap', 'MAP_ATTR', 'registerMapFactories',
     'destroyMapWithin', 'saveMapStateWithin', 'restoreMapStateWithin',
     'autoInitScheduler', 'SCHEDULER_ATTR', 'SCHEDULER_CHANGE_EVENT', 'registerScheduler',
     'destroySchedulerWithin', 'saveSchedulerStateWithin', 'restoreSchedulerStateWithin',
     'restoreSwappedSchedulers', 'driveSchedulerOobUpdates',
  ]],
  [await import('../packages/modules/webcomponent/index.js'), [
    'ATTRIBUTE_CONFIG', 'EVENT_PREFIX', 'GridElementController',
    'TAG_NAME', 'createLatticeGridElement', 'defineLatticeGrid', 'domEventName',
    'observedAttributeNames',
    'createLatticeElements', 'defineLatticeElements', 'ELEMENT_TAGS',
    'createLatticeKPIElement', 'createLatticeChartElement', 'createLatticeKanbanElement',
    'createLatticeCalendarElement',
    'createLatticeGanttElement', 'createLatticeLayoutElement', 'createLatticeTabsElement',
    'createLatticeRouterElement', 'adoptLatticeStyles', 'latticeStyleSheets',
  ]],
  [await import('../packages/modules/devtools/index.js'), [
    'CONSOLE_ACTIVATION', 'createDevtools', 'expose',
  ]],
  [await import('../packages/modules/react/index.js'), [
    'EVENT_NAMES', 'handlerName',
  ]],
  [await import('../packages/modules/vue/index.js'), [
    'createLatticeGrid', 'dashedName',
  ]],
  [await import('../packages/modules/svelte/index.js'), [
    'createLatticeAction',
  ]],
  [await import('../packages/modules/dhtmlx-compat/index.js'), [
    'Grid',
  ]],
  [await import('../packages/modules/data-router/index.js'), [
    'createDataRouter',
  ]],
  [await import('../packages/modules/mock-socket/index.js'), [
    'MockWebSocket', 'rng', 'opsFeed', 'priceFeed',
  ]],
  [await import('../packages/modules/kanban/index.js'), [
    'createKanban',
  ]],
  [await import('../packages/modules/geo-world-110m/index.js'), [
    'pack',
  ]],
];

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

Nested configuration, executed

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The remaining option names, executed

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

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

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

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

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

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

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

Every event name, executed

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

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

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

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

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

grid.destroy();
return wired;