Lattice Grid Buy a licence

api reference

The dashboard layout

createLayout and the windows a reader arranges for themselves, the arrangement saved and restored, locking and unlocking while the screen stays up, and the events every gesture goes through.

API reference › The dashboard layout

The dashboard layout

modules/layout is an opt-in reconfigurable dashboard surface: a cell grid inside an element, and a set of windows placed on it that a user can move, resize and close - by drag or by keyboard. It is the thing a customer would otherwise reach for GridStack or react-grid-layout to get, which means a second dependency, a second sizing model, and a seam where the viewers in it do not resize properly.

It is payload-agnostic, and that is the whole design. A window body is a div with an id. The module creates it, sizes it, and never reads or writes its contents - it does not import createGrid, does not know what a payload is, and never calls into one. What it does instead is emit window:resized with the measured content box, which is the contract. That rule is what keeps its own code to 11,202 bytes gzipped (measured: a 75,502-byte bundle over a 62,206-byte fixed floor, of which 2,094 bytes are the two shared module helpers) and what makes it usable for a payload we have not written yet.

import { createLayout } from '@toclocoinc/lattice-grid/modules/layout';

const layout = createLayout(document.querySelector('#dash'), {
  columns: 12, rows: 8, gap: 8,
  overflowX: 'static', overflowY: 'scroll', rowHeight: '160px',
  windows: [
    { id: 'pipeline', title: 'Pipeline', xPos: 1, yPos: 1, xSize: 6, ySize: 4,
      movable: true, resizable: true, closable: true },
    { id: 'trend', title: 'Trend', xPos: 7, yPos: 1, xSize: 6, ySize: 4,
      movable: true, resizable: true },
  ],
  onWindowResized: ({ id, width, height }) => redraw(id, width, height),
});

// The module made the container; you fill it and you own what is inside it.
createGrid(layout.payload('pipeline'), { rowKey: 'id', rows, columns });

Two independent overflow axes, not one setting. overflowX and overflowY are each 'static' or 'scroll', because a dashboard that scrolls both ways is ordinary and a single enum cannot express it. The difference is what happens to a track's size. A static axis divides the mounted element with minmax(0, 1fr) - never a bare 1fr, whose implicit auto minimum lets one stubborn payload drag a track past the container. A scrolling axis repeats a fixed track (columnWidth / rowHeight) and the canvas extends past the viewport, which then scrolls. That is the owner's "maintaining their sizing", and it is the difference between this and flex-wrap: measured in a real browser, ten 200px columns in a 600px host paint at 200px each over a 2000px canvas, and shrinking the host to 300px leaves the column at 200px and scrolls further.

Spacing takes a real CSS length. gap, padding, columnWidth and rowHeight each accept a number (pixels), or a string: '200px', '25%', '1fr', '2rem', '10vh'. Percentages that sum past 100 are allowed to overflow and scroll rather than being silently scaled down, which is the honest outcome. Anything outside that vocabulary - including calc() and var() - is refused by name with one warning and replaced by the default, because the value is written into an inline style.

Rearrangement. compact: 'vertical' (the default) pushes displaced windows down and then pulls everything up into whatever space that left, so a window dropped into empty space falls to the top of its column - window:moved carries both to (where it was asked to go) and landed (where it actually ended up). compact: 'none' keeps every window exactly where it is put. There is no horizontal compactor: pushing sideways has no single obviously-correct direction, and getting it wrong silently rearranges a dashboard a user carefully built.

Keyboard, to the same standard as the drag. Every movable and resizable window carries a focusable handle running the full grab / move / drop / cancel model the kanban board established: Space or Enter grabs, the arrow keys move a tentative placement, Enter drops it through the same beforeWindowMove gate the pointer drag uses, and Escape cancels. A polite live region announces every step - grabbed, each tentative position with its column and row, dropped, cancelled, and reverted when a handler vetoes the drop - and focus returns to the handle afterwards. A window with chrome: false still gets a handle, because a movable window a keyboard user cannot move is not movable.

An “Edit layout” button, without rebuilding the dashboard. closable, movable and resizable also take a layout-level default, so unlocking a twelve-window dashboard is one setting rather than twenty-four, and setInteractive(true|false|{movable, resizable, closable}) changes that default at runtime - unlock, let the user rearrange, lock again and save getLayout(). Nothing is destroyed and nothing is rebuilt, so every grid, chart and board mounted in a window survives the toggle untouched. The asymmetry is deliberate: you can always take a capability away; you can never grant one where the developer said no. setInteractive(false) locks every window, including one whose own spec says movable: true, so a dashboard hard-locks in a single call without auditing twelve window specs; setInteractive(true) unlocks only the windows that never opted out, so a masthead declared movable: false stays pinned. Both halves of the enforcement move together - the handles a window renders and the checks the pointer and keyboard paths make, because removing a handle stops a mouse while only the gesture check stops a keyboard user already standing on one. config.movable: false and setInteractive(false) are deliberately not the same thing: the config states the default for windows that declare nothing - and false is already that default, so it takes nothing away from a window that declared movable: true - while setInteractive(false) is an active lock that pins every window whatever its own spec says. getInteractive() reports all three states rather than two: undefined where no layout-level default is in force, true, or false for a lock. Reporting “unset” as false would read correctly and round-trip wrongly, so setInteractive(getInteractive()) is a no-op in every state, and a key carrying undefined means “leave this capability alone”. Interactivity is a mode, not part of the arrangement: getLayout() does not carry it, setLayout() does not read it, and no event fires. A locked layout is not a read-only dashboard: the module creates the payload container and never reads or writes its contents, so a grid inside a window is made read-only with the grid's own settings - a dashboard that must not be edited is two decisions, not one.

Which payloads re-lay-out on window:resized, honestly. The grid and the chart each own a ResizeObserver and respond correctly; the Gantt does too since 1.52.0; kanban and KPI do no JS work at all on a resize and need none, because they reflow by CSS construction (a kanban column keeps its 280px and the board starts scrolling). Every one of those is measured in test/layout-browser.test.js rather than asserted. One exception, named rather than softened: a grid column declared as a percentage (layout: { width: '50%' }) is resolved against the viewport width once and never re-resolved, so halving the window leaves the column wider than the viewport it sits in (800px host → 783px viewport, 391px column; 400px host → 383px viewport, still a 391px column). That is not caused by this module - it reproduces on a plain grid in a plain resized div - and until it is fixed, size grid columns inside a resizable window in pixels or with flex, not with percentages.

Idle cost, measured. A twelve-window dashboard is indistinguishable from a page with no layout module on it at all. Over 8 seconds of real Chrome (bench/layout-idle.mjs), twelve windows with empty payloads, twelve independent live grids in them, and a single lone grid with no layout module all sit in the same few-millisecond band - under a tenth of one percent of a core. They are not separated here because they cannot be: seven runs across two machines land between 3.1ms and 9.0ms and the ordering between them inverts run to run, so a stated delta would be reporting the noise floor. The module adds no timer, no frame loop and no polling, and owns exactly one ResizeObserver for the whole layout rather than one per window. The one figure that is a result rather than noise is not this module's: twelve grids derived from one shared parent filtered at 20Hz cost 3,155-3,409ms over the same 8 seconds - several hundred times the quiet band, and stable across every run - because the engine's repaint listener for a derived source's rows:changed calls the renderer directly and bypasses grid.updates.pause(). The bench reports the size of that gap rather than leaving it inferred.

Closing a window does not destroy its payload. window:closed hands the payload container back; whatever you mounted inside it is yours to destroy. Stated plainly because a leaked grid per closed window is the obvious failure, and this module has no way to know that a div contains something with a destroy().

Not in v1: horizontal compaction; per-frame drag events; nested layouts; window maximise/minimise; tabbed windows (that is modules/tabs); and responsive breakpoints - a twelve-window dashboard on a phone is unsolved, and this does not pretend otherwise. Server-side persistence is the host's, with getLayout().

MemberDescription
createLayout(el, config)Create a dashboard layout. columns/rows (default 12/6) divide the element; overflowX/overflowY are each 'static' or 'scroll'; columnWidth/rowHeight are the fixed track sizes a scrolling axis uses; gap (8px), padding (5px) and compact ('vertical') complete it. A second mount on the same element is refused by name.
config.windows[]Each window: id (required, unique), xPos/yPos/xSize/ySize in 1-based cells (auto-placed in the first free cell when omitted), title, chrome (default true), and closable/movable/resizable (all default false, so a dashboard the developer wants fixed is fixed without opting out of anything; each also takes a layout-level default of the same name, which a window's own boolean overrides). padding and payloadId (default `${id}-body`) override per window.
payload(id) / window(id) / windows()The payload container for a window - the div carrying its payloadId, which you fill; a copy of a window's current descriptor; every window id in mount order.
add(spec) / move(id, to) / close(id)Add a window after mount (returns its payload container); move or resize one through the same before-events the drag uses; close one through beforeWindowClose. move and close return true/false synchronously with no handler registered, or a Promise<boolean> when a handler deferred.
getLayout() / setLayout(snapshot)The full current arrangement as plain JSON ({columns, rows, windows: [{id, xPos, yPos, xSize, ySize}]}), and its restore. setLayout never throws on garbage, and an entry naming a window that does not exist yet is retained and applied when that window is added.
getState() / setState(snapshot)The versioned persistence pair, following core's and the Gantt's shape: no arguments in, one plain JSON-safe object out, and setState survives whatever is handed to it.
setInteractive(value) / getInteractive()Lock or unlock the whole dashboard at runtime, without destroying it. A boolean sets movable, resizable and closable together; an object sets only the keys it carries, and a key carrying undefined is treated as absent; getInteractive() returns the layout-level values as a copy, three-valued (undefined for unset, true, or false for a lock) so that setInteractive(getInteractive()) is a no-op in every state. The config keys of the same name state the default; only this method takes a capability away. Locking always wins and unlocking never overrides an opt-out: setInteractive(false) pins a window that declared movable: true, and setInteractive(true) leaves a window that declared movable: false pinned. No event fires and getLayout() is unchanged - a mode is not an arrangement.
refresh()Re-measure every window and emit window:resized for those that changed. Called automatically; exposed for a host that changed something the module cannot observe, such as revealing an ancestor.
on(name, fn) / off(name, fn)Events: window:moved, window:resized, window:closed, layout:changed; the cancellable beforeWindowMove, beforeWindowResize and beforeWindowClose (call preventDefault(reason?) or return false), each paired with windowMove:cancelled, windowResize:cancelled and windowClose:cancelled. '*' subscribes to every past-tense event and is deliberately never delivered a before-event. Config sugar for all ten. Drag progress is not emitted per frame.
destroy()Stop observing, drop every listener including any left by a gesture in flight, and remove the DOM the module built. Whatever you mounted in a payload is yours to destroy.

Placement, compaction and a saved arrangement, executed

createLayout needs a real host element, the same way createGrid does, so this executed example reaches for the same in-tree DOM test double the suite runs the renderer against headlessly (packages/dom/src/renderer/testdom.js). demo/layout.html is the browser version, with a real grid, chart and KPI rail in three windows that you can drag, resize with the keyboard, close, save and restore.

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

const layout = createLayout(root, {
  columns: 4, rows: 4,
  windows: [
    { id: 'a', title: 'A', xPos: 1, yPos: 1, xSize: 2, ySize: 1 },
    { id: 'b', title: 'B', xSize: 2, ySize: 1 },   // no coordinates: auto-placed
  ],
});
const shape = () => layout.getLayout().windows.map((w) => `${w.id}@${w.xPos},${w.yPos}`).join(' ');

const placed = shape();                        // B landed in the first free cell: 3,1
const saved = JSON.parse(JSON.stringify(layout.getLayout()));

layout.move('a', { xPos: 3, yPos: 1 });        // drop A on top of B
const pushed = shape();                        // B is pushed down to 3,2

layout.setLayout(saved);                       // the saved arrangement round-trips
const restored = shape();

const payload = layout.payload('a').id;        // the container you fill: 'a-body'
layout.destroy();
return [placed, pushed, restored, payload].join('|');