Lattice Grid Buy a licence

api reference

Tabs API Reference

createTabs: a tab strip where each tab holds a grid, a board, a KPI strip or a Gantt, and the tablist keyboard model.

API reference › The tabbed grid

All 26 pages Everything on one page → Developer guide →

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 about 12KB gzipped - the module and nothing else. A module bundle carries its own code plus a small shared runtime, not the engine: the framework adapters are 3-11KB, the KPI module about 50KB, while a module that inlines the whole engine (the web component, htmx) ships at roughly 760KB.

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

const tabs = createTabs(document.querySelector('#tabs'), {
  createGrid,                     // injected -- see below
  createHeadlessGrid,             // optional: lets an unvisited tab still carry a count
  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 },
      icon: '⚠️', badge: true,                          // "Breached 14" -- and it follows Open's filter
      badgeTone: (count) => (count > 0 ? 'bad' : 'good') },
  ],
  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.

A tab can say what is waiting on it. badge: true puts that tab's own live row count on it - and for a derived tab the count follows, so filtering the parent restates the child's badge with it. A number or a string is a static badge; a function is handed the live count and returns what to show (null hides it). The tone is declared, never inferred from a threshold this module would have had to invent: badgeTone takes good, warn, bad or unknown - the same vocabulary a statistic tile grades to, so one data-tone rule in a theme dresses both - or a function of the count. icon adds a leading glyph: a single character or emoji, or an element you built. Never a markup string; nothing in this module parses HTML, and anything it cannot use is warned about once and ignored rather than coerced. Badges are off by default, and a tab that asks for none renders exactly the DOM it did before.

A tab nobody has clicked can still carry a count. Tabs mount lazily, so the tab most worth badging - the one you want to glance at - is precisely the one with no grid behind it. Inject the optional createHeadlessGrid, the same way createGrid is already injected and for the same reason, and an unactivated tab's count is computed with no element and no renderer, correct from first paint and still following its parent's filter. Leave it out and nothing breaks: that tab simply shows no badge until it is first activated, and the module says so once, naming the option. When the strip is narrow it is the label that gives way - it ellipsises while the count and the icon stay whole - and the strip still wraps rather than growing a scroll affordance.

A tab body does not have to be a grid. Give a tab a view - the factory that mounts its body, called as (el, config) => instance - and the tab hosts a kanban board, a KPI strip or a Gantt instead. createKanban and createKPI already have that signature, so they are passed straight in; the Gantt takes a single options object and mounts itself when given an element, so it is adapted in a line: view: (el, config) => createGantt({ ...config, element: el }). The factory is injected, never imported, for the same reason createGrid is. Such a tab's config is that viewer's own config.

{ id: 'board', label: 'Board', from: 'open',       // derives from the Open tab
  where: (r) => r.owner === me, follow: 'filtered', refresh: 'live',
  view: createKanban, badge: true,
  config: { rowKey: 'id', columnProperty: 'stage' } }

And it derives exactly as a grid tab does. That is the point rather than a bonus: from plus the same narrowing (where, group, join, follow, …) applies to a board or a KPI strip just as it does to a grid, so filtering the parent tab restates the board beside it. The mechanism is the derived source you already have: for a non-grid body the module materialises a headless grid carrying that same derived source and pipes its rows into the viewer through rows.apply({ add, update, remove }) - the keyed diff the grid, the board, the KPI panel and the Gantt all already accept from a Data Router. Nothing new is invented, the viewer stays unaware it is in a tab, and the module still imports no engine code. Deriving into a viewer therefore needs createHeadlessGrid injected alongside createGrid; without it the body mounts with its own rows and follows nothing, and the module says so once. A view tab with no from simply holds its own rows.

A body you have never opened is complete the moment you open it. Tabs mount lazily, so a board first shown after its parent has been filtered twice has a lot to catch up on - and because the headless grid holds the rows, there is no feed to join late: the body is seeded with the current set at mount and agrees exactly with a sibling that was mounted earlier. A count badge works on all three the same way, so a Gantt tab can read "Plan 14" as readily as a grid tab.

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.createHeadlessGrid is optional, and is what gives a never-activated tab a live count and lets a non-grid body derive. config.tabs is a non-empty array of tab descriptors, each an id, a label, a config, optionally an icon and a badge/badgeTone, 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.
tab.viewMount something other than a grid in this tab: the factory that builds it, called as (el, config) => instance - createKanban, createKPI, or a one-line Gantt adapter. The tab's config is then that viewer's own config, and the tab derives from from exactly as a grid tab does (which needs createHeadlessGrid).
tabs() / tab(id) / isMounted(id)The configured tab ids, in order; the live instance mounted in a tab - the grid, or the viewer for a view tab (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

Opening on a chosen tab, and vetoing a switch, executed

active picks the tab the strip opens on rather than the first; the three config callbacks are sugar for the same events on() exposes, so onBeforeTabChange can veto a switch with preventDefault(reason) and onTabChangeCancelled is told why. onTabChange fires only for a switch that actually happened - note it does not fire for the initial tab.

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 }];
const columns = [{ field: 'id' }];
const log = [];

const tabs = createTabs(root, {
  createGrid,
  active: 'review',                             // open here, not on the first tab
  tabs: [
    { id: 'draft', label: 'Draft', config: { rowKey: 'id', rows, columns } },
    { id: 'review', label: 'Review', config: { rowKey: 'id', rows, columns } },
    { id: 'locked', label: 'Locked', config: { rowKey: 'id', rows, columns } },
  ],
  onTabChange: (e) => log.push(`changed:${e.id}`),
  onBeforeTabChange: (e) => { if (e.id === 'locked') e.preventDefault('sealed'); },
  onTabChangeCancelled: (e) => log.push(`cancelled:${e.id}(${e.reason})`),
});

const opened = tabs.activeId;                   // 'review' -- config.active won
tabs.activate('draft');                         // allowed, so onTabChange fires
tabs.activate('locked');                        // vetoed, so onTabChangeCancelled fires
const ended = tabs.activeId;                    // still 'draft'
tabs.destroy();
return `${opened} | ${log.join('; ')} | ${ended}`;

A tab nobody has clicked, carrying a live count, executed

A badge needs rows, and rows normally need a mounted grid - so a tab that has never been activated would have nothing to count. Injecting createHeadlessGrid alongside createGrid gives that tab a real, derived count with no DOM and no mount. Without it the tab below shows no badge at all until its first activation, and the module says so once.

const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { root } = createTestDom();
const { createGrid } = await import('../packages/dom/src/index.js');
const { createHeadlessGrid } = await import('../packages/core/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', type: 'number' }];

const tabs = createTabs(root, {
  createGrid,
  createHeadlessGrid,                           // what gives an unmounted tab a count
  tabs: [
    { id: 'all', label: 'All', badge: true, config: { rowKey: 'id', rows, columns } },
    { id: 'breached', label: 'Breached', from: 'all', where: (r) => r.daysOverdue > 0,
      follow: 'filtered', refresh: 'live', badge: true, config: { columns } },
  ],
});

// 'breached' has never been activated, so it has no grid of its own.
const button = root.querySelectorAll('[role=tab]')
  .find((b) => b.getAttribute('data-tab-id') === 'breached');
const badge = button.children
  .find((c) => String(c.className || '').includes('lat-tabs__badge'));
const state = tabs.isMounted('breached') ? 'mounted' : 'unmounted';
const text = String(badge.textContent).trim().replace(/\s+/g, ' ');
tabs.destroy();
return `${state} | ${text}`;                     // unmounted | 2 rows

Type reference

Generated from the type declarations, so it always matches the release. Each surface lists its properties, its methods and the events it raises as three tables; an option or value type lists its members once.

The tabs module

TabDescriptor

PropertyTypeDescription
idstringA stable, unique id. Required.
labelstringThe tab button's text. Defaults to `id`. (optional)
configobjectThe config for this tab's body: the grid config passed to `createGrid` (merged with the derived `source`, when `from` is set), or - with `view` - that viewer's own config. (optional)
fromstringThe parent tab id to derive from. When set, `config.source` is built for you and any of your own is replaced (with a warning). (optional)
groupunknownGroup-by forwarded to the derived source. (optional)
groupByunknownAn alias for `group`, forwarded verbatim to the derived source. (optional)
bucketunknownTime-bucketing forwarded to the derived source. (optional)
joinunknownJoin spec forwarded to the derived source. (optional)
unnestunknownArray-field unnesting forwarded to the derived source. (optional)
refreshDerivedRefresh | number`'live' | 'idle' | 'manual' | number` forwarded to the derived source. (optional)
crossFilterunknownCross-filter wiring forwarded to the derived source. (optional)
followFollowScope 'filtered' | 'all' | 'selected' | 'grouped'Which slice of the parent's rows to derive from: `'filtered' | 'all' | 'selected' | 'grouped'`. (optional)
limitnumberRow limit forwarded to the derived source. (optional)
sortunknownSort forwarded to the derived source. (optional)
profileunknownStatistical-profile derivation, forwarded to the derived source. (optional)
ariaLabelstringThis tab's panel's own `aria-label`, when the label alone is not enough context. (optional)
iconstring | HTMLElementA leading icon: a single character or emoji, or an element you built. Never a markup string - nothing here parses HTML. Decorative, so it is hidden from assistive technology. (optional)
badgetrue | number | string | ((count: number | null, tab: { id: string; label: string; from: string | null }) => unknown)A count badge. `true` shows this tab's own live row count and follows it; a number or string is static; a function is given the live count and returns what to show (`null` hides it). Off when absent. (optional)
badgeToneBadgeTone | ((count: number | null, tab: { id: string; label: string; from: string | null }) => BadgeTone | null)The badge's tone, declared by the host rather than derived from a threshold: `'good' | 'warn' | 'bad' | 'unknown'`, or a function of the live count returning one. (optional)
MethodSignatureParametersReturnsDescription
view(el: HTMLElement, config: object) => unknownel: HTMLElement
config: object
=> unknownMount something other than a grid in this tab: the factory that builds it, called as `(el, config) => instance`. `createKanban` and `createKPI` have that signature already; a Gantt is adapted in a line (`(el, config) => createGantt({ ...config, element: el })`). The factory is injected rather than imported, exactly as `createGrid` is. A `view` tab derives from `from` exactly as a grid tab does: a headless grid carries the derived source and its rows are piped into the viewer through `rows.apply`, so deriving into one needs `createHeadlessGrid` injected too. (optional)
where(row: unknown) => booleanrow: unknown=> booleanRow predicate forwarded to the derived source. (optional)

TabChangeEvent

`beforeTabChange`: a switch is about to be applied, and may be refused. Delivered through `packages/modules/shared/emitter.js`'s `emitBefore`, which builds the event from the module's action context plus `type`, the resolved `origin` and the veto surface - so every member below is always present on this event. A handler refuses the switch by calling `preventDefault(reason?)` or by returning a Promise that resolves `false`; the active tab then does not change and `tabChange:cancelled` fires carrying the reason.

PropertyTypeDescription
typestringWhich event this is: `beforeTabChange`.
idstringThe tab being switched to.
previousIdstring | nullThe tab being left, or null when no tab was active.
originViewerOrigin 'api' | 'user'Who asked for the switch: `user` for a click or a keypress, `api` for `activate()`. The opening tab is activated silently, so it raises no `beforeTabChange` at all.
reasonstring | nullThe reason given to `preventDefault`, or null while nothing has refused the switch.
defaultPreventedbooleanTrue once a handler has refused the switch.
MethodSignatureParametersReturnsDescription
preventDefault(reason?: string): voidreason?: stringvoidRefuse the switch; the optional reason is carried on `tabChange:cancelled`.

TabChangedEvent

`tab:changed`: the switch happened - the target panel is revealed, its grid materialised on first activation, the badges refreshed and `aria-selected` moved. A notification, so it carries no `preventDefault`.

PropertyTypeDescription
typestringWhich event this is: `tab:changed`.
idstringThe tab now active.
previousIdstring | nullThe tab that was left, or null for the mount-time activation - which happens while the strip is being built, before a handler can subscribe.

TabChangeCancelledEvent

`tabChange:cancelled`: a `beforeTabChange` handler refused the switch, so the active tab did not change. A notification, so it carries no `preventDefault`.

PropertyTypeDescription
typestringWhich event this is: `tabChange:cancelled`.
idstringThe tab that was not switched to.
previousIdstring | nullThe tab that is still active, or null when none was.
originViewerOrigin 'api' | 'user'Who asked for the switch that was refused.
reasonstringThe reason given to `preventDefault`, or `'prevented'` when none was.

TabsEventPayloads

What a handler receives, per tabs event.

PropertyTypeDescription
beforeTabChangeTabChangeEventThe switch about to happen, with `preventDefault` to refuse it.
tab:changedTabChangedEventThe tab now active, and the one it replaced.
tabChange:cancelledTabChangeCancelledEventThe switch that was refused, and why.

TabsConfig

Tabbed-grid configuration. The tab strip inherits as below, and so does every grid it mounts unless that tab's own `config.theme` says otherwise. Theme: no `theme` of its own - it inherits. Its colours are the grid's `--lattice-*` tokens, so `data-theme="dark"` on any ancestor (a layout's `theme: 'dark'`, or `<html>`) darkens it with everything else, and the nearest `data-theme` wins. Load the grid stylesheet, which carries the tokens.

PropertyTypeDescription
tabsTabDescriptor[]The tabs, in display order. Required, at least one.
activestringThe initially active tab id. Defaults to the first tab. (optional)
ariaLabelstringThe tablist landmark's accessible name. (optional)
messages{ t(key: string, params?: Record<string, unknown>): string }An explicit message-catalogue override; otherwise a mounted tab's own `grid.messages` is used. (optional)
MethodSignatureParametersReturnsDescription
createGrid(el: HTMLElement, config: object) => unknownel: HTMLElement
config: object
=> unknownThe grid factory to mount each tab with, e.g. `import { createGrid } from '@toclocoinc/lattice-grid'`. Required.
createHeadlessGrid(config: object) => unknownconfig: object=> unknownThe headless grid factory, injected the same way and for the same reason. Optional, and only needed for badges: with it, a tab that has never been activated still carries a live count, computed with no DOM. Without it, such a tab shows no badge until its first activation. (optional)
onTabChange(event: TabChangedEvent) => voidevent: TabChangedEvent=> voidCalled after the active tab has changed, alongside the `tab:changed` event. (optional)
onBeforeTabChange(event: TabChangeEvent) => boolean | void | Promise<boolean>event: TabChangeEvent=> boolean | void | Promise<boolean>Called before the switch. Return `false`, call `preventDefault(reason)`, or throw, to stay where you are; return a promise and the switch waits for it. Alongside the `beforeTabChange` event. (optional)
onTabChangeCancelled(event: TabChangeCancelledEvent) => voidevent: TabChangeCancelledEvent=> voidCalled when a switch was refused, with the reason on the payload. Alongside `tabChange:cancelled`. (optional)

Tabs

A tabbed grid: a `role="tablist"` strip above a stack of `role="tabpanel"` regions, each hosting its own, independently-configured grid instance. A tab's grid mounts on first activation and is kept alive, hidden, until `destroy()`.

Properties
PropertyTypeDescription
elHTMLElementThe element the strip was mounted on. (read-only)
activeIdstringThe currently active tab id. (read-only)
Methods
MethodSignatureParametersReturnsDescription
tabs(): string[] - string[]The configured tab ids, in order.
tab(id: string): unknown | nullid: stringunknown | nullThe live grid instance for a tab, or `null` before it has been materialised.
isMounted(id: string): booleanid: stringbooleanWhether a tab's grid has been created yet.
activate(id: string, opts?: { origin?: 'api' | 'user' }): boolean | Promise<boolean>id: string
opts?: { origin?: 'api' | 'user' }
boolean | Promise<boolean>Switch the active tab, gated by `beforeTabChange`.
on(name: TabsEventName, fn: (event: TabsEventPayloads[TabsEventName]) => void): () => voidname: TabsEventName
fn: (event: TabsEventPayloads[TabsEventName]) => void
() => voidRegister an event handler; returns a function that removes it. Any other name is warned about once, because it names a binding that could never fire. What each event carries is {@link TabsEventPayloads}; the handler is declared with the widest of them, so narrow on the name inside it.
off(name: TabsEventName, fn: (event: TabsEventPayloads[TabsEventName]) => void): voidname: TabsEventName
fn: (event: TabsEventPayloads[TabsEventName]) => void
voidRemove a handler registered with `on`.
destroy(): void - voidTear the whole strip down; destroys every mounted tab's grid.
Events
EventWhenPayloadCancellable
beforeTabChangeA switch is about to be applied; cancellable.TabChangeEventyes
tab:changedThe active tab changed: its panel is showing and its grid is mounted.TabChangedEventno
tabChange:cancelledA `beforeTabChange` handler refused the switch; the active tab did not change.TabChangeCancelledEventno