Lattice Grid Buy a licence

api reference

Menu, sidebar and toolbar: JavaScript Data Grid API

createContextMenu and openContextMenu, createSidebar, and createToolbar bound to the actions of a view.

API reference › Menu, sidebar and toolbar

The context menu module

modules/contextmenu is the one menu every Lattice surface opens: the grid's own cell and column-header menu renders through it, and the tree and list view open it from their itemContextMenu event, so the grid, the tree and the list all share one keyboard-accessible menu and one theme. createContextMenu is the factory - declare the menu's items up front (createContextMenu({ items, ctx, onClose, host })) or pass a host element first (createContextMenu(el, { ctx })), then menu.show({ x, y }) opens it (or anchor/placement on an element) and menu.hide() closes it. An item is an object - name, icon, shortcut, disabled, checked, children (a nested submenu), swatches (a colour-swatch row whose action(ctx, colour) receives the picked colour), action(ctx) - or the string 'separator' for a divider, or a function (ctx) => item | string | null | false so a host decides the shape at open time from the cell or item that was right-clicked; resolveMenuItems is that same resolution as a pure helper. openContextMenu(trigger, config) is the trigger helper: hand it a contextmenu event (whose default it claims, so the browser's own menu does not open over it), a { clientX, clientY } point, or an element to anchor below, and it opens the menu inside the viewport now. Every menu is a real role="menu" of menuitem/menuitemcheckbox rows with aria-checked/aria-disabled, arrows, Enter, Escape, Home/End and type-ahead, and focus returns to the trigger on close. The class names are the grid's own lat-menu__*, so a menu opened beside a themed grid picks up that grid's theme with no second stylesheet.

import { createContextMenu, openContextMenu, resolveMenuItems } from '@toclocoinc/lattice-grid/modules/contextmenu';

const menu = createContextMenu({ items: [
  { name: 'Rename', action: (ctx) => rename(ctx.item) },
  'separator',
  { name: 'Share', children: [{ name: 'Copy link', action: () => {} }] },
] });
menu.show({ x: 40, y: 40 });   // at a point - or { anchor: el, placement: 'below' }
menu.destroy();

openContextMenu(event, { items, ctx: { row }, returnFocus: event.target });

Executed: the item model, the factory, and the trigger helper.

const { createTestDom, TestEvent } = await import('../packages/dom/src/renderer/testdom.js');
const { createContextMenu, openContextMenu, resolveMenuItems } = await import('../packages/modules/contextmenu/index.js');

const { document, root } = createTestDom();

// resolveMenuItems: functions run against the context, a bare string is a
// named entry, 'separator' is a divider, children nest, and a divider left
// trailing by a filtered-out entry is dropped.
const resolved = resolveMenuItems([
  (ctx) => ({ name: `Rename ${ctx.row}` }),
  'separator',
  'Plain',
  { name: 'Share', children: [{ name: 'Copy link', action() {} }] },
  { name: 'Mute', disabled: true, checked: true, icon: 'close', shortcut: 'Ctrl+M' },
  'separator',
  () => null,
], { row: 'A1' });
const shape = resolved.map((i) => i.separator ? '---' : (i.subMenu ? 'Share/Copy link' : i.name)).join(',');
const mute = `${resolved[4].disabled},${resolved[4].checked}`;

// createContextMenu({ items, ... }) - the factory shape - opens at a point.
const menu = createContextMenu({ document, host: root, items: [
  { name: 'Cut', icon: 'pencil', shortcut: 'Ctrl+X' },
  { name: 'Copy', checked: true, icon: 'copy' },
  'separator',
  { name: 'Paste', disabled: true, icon: 'download' },
] });
const el = menu.show({ x: 30, y: 20 });
const labels = [...el.querySelectorAll('.lat-menu__item__label')].map((n) => n.textContent).join(',');
const checked = el.querySelector('[role="menuitemcheckbox"]').getAttribute('aria-checked');
const disabled = el.querySelector('[aria-disabled="true"]').querySelector('.lat-menu__item__label').textContent;
const role = el.getAttribute('role');
menu.hide();

// openContextMenu claims a right-click so the browser's own menu does not open
// over it, then opens at the pointer.
const ev = new TestEvent('contextmenu', { clientX: 40, clientY: 30 });
const opened = openContextMenu(ev, { host: root, items: [{ name: 'Inspect' }] });
const pointerLabel = opened.root.querySelector('.lat-menu__item__label').textContent;
const claimed = ev.defaultPrevented;
opened.hide();

// And it anchors below an element when handed one.
const anchor = document.createElement('button');
document.body.appendChild(anchor);
const anchored = openContextMenu(anchor, { host: root, items: [{ name: 'Anchor' }] });
const anchoredOk = !!anchored.root;
anchored.destroy();

return [shape, mute, labels, checked, disabled, role, pointerLabel, claimed, anchoredOk].join(' | ');

modules/sidebar is an opt-in navigation rail - a tree of groups that collapse, nested items, badges and an active item, with a filter box and an icons-only rail that expands on demand - drawn by createSidebar(el, config), which returns a Sidebar instance. A leaf is a destination: clicking it (or pressing Enter on it) calls the host's onNavigate({ id, node, originalEvent }) and raises navigate, then selects it. The host routes through that callback and tells the rail what is now current with setActive(id) (or the active config key), which paints the row aria-current="page". The rail itself is a navigation landmark, so a screen reader can jump to it.

import { createSidebar } from '@toclocoinc/lattice-grid/modules/sidebar';

const sidebar = createSidebar(document.querySelector('#nav'), {
  items: [
    { id: 'home', text: 'Home', icon: 'user' },
    { id: 'reports', text: 'Reports', nodes: [
      { id: 'sales', text: 'Sales', count: 3 },
      { id: 'ops', text: 'Ops' },
    ] },
  ],
  active: 'home',
  onNavigate: ({ id }) => router.go(id),
  search: true,
  railToggle: true,
});

The node tree. items (or its alias nodes) holds the tree: a branch is any node with a nodes array, a group header is a node with group: true, a badge is count (with countColor choosing the role), and type: 'separator' draws a divider. A branch expands and collapses on click, by expand/collapse/toggle, or with the arrow keys; select(id) reveals a node by opening its ancestors.

Filtering is a class toggle, not a re-render. filter(term) shows every node whose text matches and the ancestors needed to reach it, opening those ancestors, and returns the match count; an empty term restores the tree. The optional search: true draws the filter box and runs filter on each keystroke. Every node keeps its element, so filtering never rebuilds the tree.

The icons-only rail. flat: true (or goFlat() to toggle) collapses the rail to icons; a hovered or focused icon shows its label through the grid's one shared tooltip, and the optional railToggle: true draws the button that flips the rail, an aria-expanded toggle.

MemberDescription
createSidebar(el, config)Create the rail; config takes items/nodes, active, onNavigate, search, railToggle, flat, ariaLabel and messages.
setActive(id) / select(id) / unselect()Mark the page the host is showing (aria-current="page") and select it; select a node, revealing its ancestors; clear the selection.
expand(id) / collapse(id) / toggle(id) / expandAll() / collapseAll() / expandedIds()Open, close or flip a branch, open or close all, and read the ids of every open branch.
filter(term, restore?)Filter by text, case-insensitive; returns the match count. An empty term restores the tree and reopens restore.
goFlat(on?)Narrow to icons only, or back; omit on to toggle. Returns whether it is now flat.
show(id) / hide(id) / get(id) / nodeElement(id) / parentsOf(id)Show or hide a node; read the live node, its row element, or its ancestors' ids.
add / insert / remove / setNodes / data.*Change the tree; data is the flat, id-keyed store (parse, add, update, remove, getItem, serialize, …).
on(name, fn)Events: click, navigate, select, expand, collapse, flat, mouseEnter, mouseLeave, and the cancellable beforeExpand/beforeCollapse.
destroy()Remove the DOM and listeners. The host still owns the element.
FALLBACK_ENThe frozen English strings the sidebar uses when no locale catalogue supplies a message, keyed by message key (sidebar.nav, sidebar.search, …).

A two-branch rail with an active item and a host onNavigate, filtered to one leaf (opening its ancestor), narrowed to icons only and back, then re-pointed at a different active node - run headless with the suite's DOM test double, since this module needs a real element to mount into.

const { createTestDom, flushFrames } = await import('../packages/dom/src/renderer/testdom.js');
const { createSidebar, Sidebar, FALLBACK_EN } = await import('../packages/modules/sidebar/index.js');

const { document, root } = createTestDom({ width: 240, height: 400 });
globalThis.document = document;
const navigated = [];
const sidebar = createSidebar(root, {
  items: [
    { id: 'home', text: 'Home', icon: 'user', count: 3 },
    { id: 'reports', text: 'Reports', nodes: [
      { id: 'sales', text: 'Sales' },
      { id: 'ops', text: 'Ops' },
    ] },
  ],
  active: 'home',
  onNavigate: ({ id }) => navigated.push(id),
});
flushFrames();
const isSidebar = sidebar instanceof Sidebar;
const activeCurrent = sidebar.nodeElement('home').getAttribute('aria-current');
const landmark = sidebar.el.getAttribute('role');
const matched = sidebar.filter('sales');
const salesVisible = !sidebar.get('sales').hidden;
const homeHidden = sidebar.get('home').hidden;
const reportsOpen = sidebar.get('reports').expanded;
const flat = sidebar.goFlat(true);
sidebar.filter('');
sidebar.setActive('ops');
const opsCurrent = sidebar.nodeElement('ops').getAttribute('aria-current');
sidebar.destroy();
return [isSidebar, activeCurrent, landmark, matched, salesVisible, homeHidden, reportsOpen, flat, opsCurrent, FALLBACK_EN['sidebar.nav']].join('|');

The toolbar module

modules/toolbar is an opt-in data-driven toolbar - a row of buttons, two-state toggles, dropdowns, separators, spacers, inputs, titles and custom HTML - drawn by createToolbar(el, config), which returns a Toolbar instance with a data store (parse, add, update, remove, getItem, serialize, …) so the bar can be described once and changed item by item. When the row is too narrow for its items, the trailing ones move into a “more” button that opens the context-menu module. An item bound to a view names the view's action with action; the toolbar drives it on click and keeps the item's disabled (and, for a toggle, its aria-pressed) following the view's own state.

import { createToolbar } from '@toclocoinc/lattice-grid/modules/toolbar';

const tb = createToolbar(document.querySelector('#bar'), {
  bind: grid,
  items: [
    { id: 'undo', value: 'Undo', icon: 'undo', action: 'undo' },
    { id: 'redo', value: 'Redo', icon: 'redo', action: 'redo' },
    { type: 'separator' },
    { id: 'export', value: 'Export', icon: 'download', action: 'export' },
    { type: 'spacer' },
    { id: 'search', type: 'input', placeholder: 'Search', icon: 'search' },
  ],
});
tb.on('click', ({ id, item }) => { /* payload handlers */ });
tb.data.update('undo', { disabled: true });

The item model. items is the spec: id, type (button, selectButton, title, separator, spacer, input, customHTML), value (the label), icon from the grid's registry, tooltip, hidden, disabled, count/countColor (a badge), twoState/group (toggles, active state, aria-pressed), items (a dropdown), placeholder and width (an input), and overflow: 'never' to keep an item out of the overflow menu. Any other property is carried through and returned from getItem and serialize.

Binding to a view. bind names a grid, tree, list view or form; an item's action (export, undo, redo, addRow, removeRow, expandAll, collapseAll, submit, refresh) is resolved duck-typed - undo runs view.history.undo() on a grid and view.undo() on a form - and the item's disabled follows the view's canUndo/canRedo (or the method's presence) on refresh(). The binding owns those two properties on action items; the host can also call toolbar.bind(view) and toolbar.refresh() directly.

Overflow and keyboard. overflow: 'menu' (the default) moves the trailing items into a “more” button that opens the context-menu module; 'scroll' scrolls and 'none' clips. The bar is a roving role="toolbar": one tab stop, arrows move between items, Home/End jump the ends, and a toggle carries aria-pressed. The overflow button's name reads the shipped catalogue (toolbar.more).

MemberDescription
createToolbar(el, config)Create the bar; config takes items, bind, overflow, moreIcon/moreLabel, tooltips, ariaLabel and messages.
bind(view) / bound / refresh()Bind the bar to a view whose actions the action items drive; read the bound view; re-read every bound item's disabled/active from the view.
data.*The item store: parse, add, update, remove, removeAll, getItem, exists, getIndex, getId, getItems, getParent, getLength, forEach, map, find, serialize.
show(id) / hide(id) / enable(id) / disable(id) / isDisabled(id)Show, hide, enable or disable items (omitting id applies to the root items); read an item's disabled state.
select(id) / unselect(id?) / isSelected(id) / getState() / setState()Toggle items and groups; read or write the stateful items' state in one object.
setFocus(id) / itemElement(id)Focus an item's control; read an item's wrapper element.
on(name, fn)Events: click, input, inputChange, inputFocus, inputBlur, change, keydown, openMenu.
destroy()Remove the DOM and listeners. The host still owns the element.
FALLBACK_ENThe frozen English strings the toolbar uses when no locale catalogue supplies a message, keyed by message key (toolbar.more).

The bar, the binding, the toggle and the overflow button, executed

A toolbar bound to a duck-typed view, run headless with the suite's DOM test double: the undo action runs the view's history and disables once the timeline is empty, a two-state button reflects aria-pressed, and the overflow button is named from the catalogue.

const { createTestDom, flushFrames, TestEvent } = await import('../packages/dom/src/renderer/testdom.js');
const { createToolbar, Toolbar, FALLBACK_EN } = await import('../packages/modules/toolbar/index.js');

const { document, root } = createTestDom({ width: 640, height: 48 });
globalThis.document = document;
const undoLog = [];
const view = {
  history: { canUndo: () => undoLog.length < 1, undo: () => undoLog.push('undo') },
  expandAll: () => {}, collapseAll: () => {},
};
const tb = createToolbar(root, {
  bind: view,
  items: [
    { id: 'undo', value: 'Undo', icon: 'undo', action: 'undo' },
    { id: 'bold', value: 'Bold', twoState: true },
  ],
});
flushFrames();
const isToolbar = tb instanceof Toolbar;
const role = tb.el.getAttribute('role');
const undoEnabledAtStart = tb.isDisabled('undo') === false;
const click = (id) => tb.itemElement(id).querySelector('button').dispatchEvent(new TestEvent('click', { bubbles: true }));
click('undo');
const undoRan = undoLog.length === 1;
const undoNowDisabled = tb.isDisabled('undo') === true;
click('bold');
const pressed = tb.itemElement('bold').querySelector('button').getAttribute('aria-pressed');
const more = tb.el.querySelector('.lat-toolbar__more').getAttribute('aria-label');
tb.destroy();
return [isToolbar, role, undoEnabledAtStart, undoRan, undoNowDisabled, pressed, more, FALLBACK_EN['toolbar.more']].join('|');