Lattice Grid Buy a licence

api reference

The tabbed grid

createTabs, a tab strip of independently-configured grids, a tab that derives from and narrows another, and the tablist keyboard model and events.

API reference › The tabbed grid

The tabbed grid

modules/tabs is an opt-in top-of-grid tab strip where each tab is its own full, independently-configured grid instance - "configure each tab as per a normal grid" rather than one grid whose state is swapped. That is a deliberate rejection of the cheaper alternative: grid.state.get()/.apply() only repositions, hides, resizes and sorts existing columns by id (no field, type, editor or row data), so a state-swap only works when every tab shares one column schema and one source - strictly less than the ask. A tab may instead declare from: '<tabId>' plus a narrowing (where, group, join, …), and the module wires a source: { mode: 'derived', from: <the parent tab's live grid>, … } for it - the shipped derived-source mechanism, not a new config-inheritance one. createGrid is injected (the same pattern the React/Vue/Svelte adapters use), so the module imports no engine code regardless of how it is loaded - its own minified ESM build (tabs.esm.min.js) is ~65KB gzipped, the same size class as the KPI and framework-adapter modules (~61-66KB), rather than the ~700KB a module that inlines the whole engine (the web component, htmx) ships.

import { createGrid } from '@toclocoinc/lattice-grid';
import { createTabs } from '@toclocoinc/lattice-grid/modules/tabs';

const tabs = createTabs(document.querySelector('#tabs'), {
  createGrid,                     // injected -- see below
  tabs: [
    { id: 'all', label: 'All', config: { rowKey: 'id', rows, columns } },
    { id: 'open', label: 'Open', from: 'all', where: (r) => r.stage === 'Open',
      follow: 'filtered', refresh: 'live', config: { columns } },
    { id: 'breached', label: 'Breached', from: 'open',       // derives from Open, not All -- a chain
      where: (r) => r.daysOverdue > 0, config: { columns } },
  ],
  onBeforeTabChange: ({ id }) => !hasUnsavedEdit(),  // veto a switch
});

Lifecycle. A tab's grid mounts on first activation, not up front, and then stays alive - hidden, never destroyed - until the whole strip is. Per-tab scroll, selection, filters, sort, grouping, expansion - and an open cell/row editor - therefore survive a switch away and back natively, by simply not touching that grid instance, rather than through a lossy serialise/restore round-trip: leave a tab mid-edit, switch away, switch back, and the editor is exactly as it was left, uncommitted and undiscarded. Activating a derived tab materialises its whole ancestor chain first (mounted, hidden), and a cyclic from graph is refused - naming the exact cycle - when createTabs is called, not at first click.

A hidden tab costs nothing this module can spend. An inactive panel carries the hidden attribute (display:none); the module runs no timer, observer or repaint of its own against it. Measured with a real browser (bench/tabs-idle.mjs): several mounted-but-hidden, untouched tabs cost the same idle CPU as none at all. The one honestly-reported exception is not this module's: a derived tab's row model still re-derives on every change to its (possibly hidden) parent - by design, so reactivating it is instant rather than a stale flash - and the engine's own repaint listener for a derived source's rows:changed calls the renderer directly, bypassing grid.updates.pause() (which only holds the streaming-ingestion path). A hidden derived tab therefore still runs a read/compute/write pass on every parent change, even though the write phase paints a zero-size viewport; the bench measures and reports the size of that gap rather than leaving it inferred.

Accessibility. A real role="tablist"/"tab"/"tabpanel" with aria-selected and a roving tabindex, imitating the grid's own column-header keyboard model rather than the tool panel's tablist (which has the roles but no arrow-key handling). This is manual activation: / and Home/End move the roving tab stop without switching the panel or mounting a grid; Enter/Space, or a click, activates. The newly active tab's label is announced through a polite live region.

MemberDescription
createTabs(el, config)Create a tabbed grid. config.createGrid is required (injected, not imported); config.tabs is a non-empty array of tab descriptors, each an id, a label, a grid config, and optionally from plus the derivation narrowing (where, group, groupBy, bucket, join, unnest, refresh, crossFilter, follow, limit, sort, profile) forwarded onto the derived source built for it.
tabs() / tab(id) / isMounted(id)The configured tab ids, in order; a tab's live grid instance (or null before its first activation); whether a tab has been materialised yet.
activate(id, opts)Switch the active tab, gated by beforeTabChange. Returns true/false synchronously with no handler registered, or a Promise<boolean> when a handler deferred.
on(name, fn) / off(name, fn)Events: tab:changed, the cancellable beforeTabChange (call preventDefault(reason?) or return false to veto), and its paired tabChange:cancelled. Config sugar: onTabChange, onBeforeTabChange, onTabChangeCancelled.
destroy()Tear the whole strip down; destroys every mounted tab's grid (each isolated, so one throwing does not strand the rest).

All / Open / Breached, a two-deep derivation chain, executed

createTabs deliberately has no headless mode - it requires a real host element, the same way createGrid itself does - so this executed example reaches for the same in-tree DOM test double the suite itself runs the renderer against headlessly (packages/dom/src/renderer/testdom.js), rather than a real browser. demo/tabs.html is the browser version of the same chain, with buttons that edit All directly and let Open and Breached follow.

const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { root } = createTestDom();
const { createGrid } = await import('../packages/dom/src/index.js');
const { createTabs } = await import('../packages/modules/tabs/index.js');

const rows = [
  { id: 1, stage: 'Open', daysOverdue: 0 },
  { id: 2, stage: 'Open', daysOverdue: 5 },
  { id: 3, stage: 'Won', daysOverdue: 0 },
  { id: 4, stage: 'Open', daysOverdue: 2 },
];
const columns = [{ field: 'id' }, { field: 'stage' }, { field: 'daysOverdue' }];

const tabs = createTabs(root, {
  createGrid,
  tabs: [
    { id: 'all', label: 'All', config: { rowKey: 'id', rows, columns } },
    { id: 'open', label: 'Open', from: 'all', where: (r) => r.stage === 'Open', config: { columns } },
    // derives from Open, not All -- a two-deep chain
    { id: 'breached', label: 'Breached', from: 'open', where: (r) => r.daysOverdue > 0, config: { columns } },
  ],
});

tabs.activate('breached');                    // materialises 'open' too, automatically
const counts = ['all', 'open', 'breached'].map((id) => tabs.tab(id).rows.count());
tabs.destroy();
return counts.join(',');                       // 4,3,2