Lattice Grid Buy a licence

api reference

The board (kanban) view

createKanban, status columns with points and work-in-progress limits, drag or keyboard move with write-back, swimlanes, sprints, epics and a pop-out child grid.

API reference › The board (kanban) view

The board (kanban) view

modules/kanban is an opt-in view of grid rows as cards, grouped into columns by a configurable property - a status, a stage, a state GUID, whatever the host's schema calls it. It is a separate bundle in the same shape as charts and the data router: it adds no weight to a page that does not load it, changes nothing in grid core, and pulls in no dependency. A board is just another dataset viewer: it consumes data through the same keyed-diff contract a grid exposes, board.rows.apply({ add, update, remove }), so a Data Router can attach(value, board) and drive a kanban beside a grid and a chart off one feed. (Distinct from the grid-core board presentation mode, config.board in, which lays a single grid's rows out as lanes; this is a standalone view module.)

import { createKanban } from '@toclocoinc/lattice-grid/modules/kanban';

const board = createKanban(document.querySelector('#board'), {
  rows,                        // or { grid } to bind to a live grid; reuses its column formatters
  rowKey: 'id',
  columnProperty: 'status',    // the group-by property (configurable)
  columns: [                   // shown even when empty; this order is honoured
    { id: 'todo', title: 'To do' },
    { id: 'doing', title: 'In progress', wipLimit: 3 },
    { id: 'done', title: 'Done', color: '#2e7d32' },
  ],
  pointsProperty: 'points',    // configurable; drives the header points sum
  showPoints: true,
  card: { title: 'title', subtitle: 'assignee', labels: 'tags' },
  readonly: { columns: { done: true } },     // granular: whole board / per column / per card
  onCardClick: ({ card }) => open(card.row),
});

Every structural property is named in config - columnProperty, pointsProperty, orderProperty, swimlaneProperty, sprintProperty, epicProperty - so the same board maps DemandFlow (a status field, points, sprint, epic, a swimlane) and any customer schema without code change. Configured columns show even when empty; a data value outside them gets its own column rather than being dropped. Each column header carries its card count and, with showPoints, its points sum, and is flagged when the count exceeds a column's wipLimit. Accessibility is built in: the board is a labelled group, each column a labelled list, each card a list item in a roving-tabindex focus ring with arrow-key navigation, and a polite live region is present for the move announcements a later cycle adds.

MemberDescription
createKanban(el, config)Create a board. Pass a DOM element to render into, or null for a headless board that computes the same column/card model without a DOM.
columns() / column(id)The columns with their cards and aggregates, or one column by id (id, title, count, points, wipLimit, over, cards).
count(id) / points(id)A column's card count, and its points sum from pointsProperty.
cards() / card(key)Every card model, or one by its key.
rows.apply({ add, update, remove })The keyed-diff consumer contract a grid shares, so the board is a drop-in Data Router target. Also rows.forEach and rows.count.
move(keys, toColumn, toIndex?)Move one or more cards to a column (and, with orderProperty, to a position within it) - the entry point behind drag-and-drop and keyboard move. Runs onBeforeMove first, then writes through the grid's shipped write-back path.
select / selection / isSelected / clearSelectionCard selection: select(keys, 'set'|'add'|'toggle'|'remove'), the selected keys, a membership test, and a clear. Click selects; Ctrl/Cmd toggles; Shift extends within a column.
collapseColumn(id) / collapseLane(id)Collapse, expand or toggle a column or swimlane; emits column:collapse / swimlane:collapse. State survives a keyed-diff update.
reorderColumns(order) / moveColumn(id, before)Reorder the columns (also done by dragging a column header); emits column:reorder.
setQuickFilter(text) / setFilter(fn) / facets(property)Quick text search across card fields, a predicate filter, and distinct-value counts for a facet control.
setSprint(id) / showBacklog() / sprints()Sprint view and switcher: show one sprint, the backlog (board.BACKLOG - cards with no sprint), or all; and the distinct sprint values. Emits sprint:changed.
setEpic(id) / epics() / epicRollup() / rollup(property)Epic view and rollup: filter to an epic, list epics, and roll rows up by epic (or any property) into count, points, and progress toward the done columns.
expand(key) / closeDetail() / canExpand(card)Pop a card's children out as a nested child grid (or board) in a drawer/modal/inline container; emits card:expand and card:drill.
editCard(key, field) / applyEdit(key, field, value)Inline-edit a card field opted in with card: { title: { field, edit: true } }: grid-bound it commits through the grid's own field editor path (grid.edit.setCells); standalone it uses a host editor factory or a default input, reverting when onCardEdit rejects. Double-click a card to edit; emits card:edit.
addCard(columnId, seed?)Add a card to a column (with config.addCard's per-column affordance) and open it in inline edit; a host onAddCard(columnId) supplies the row, or one is generated (grid-bound via grid.edit.addRow). Emits card:add.
getState() / setState(snapshot)Serialise and restore the board state - collapsed columns/lanes, column order, quick filter, sprint/epic selection and selection. Also accepted as config.state at construction.
setLoading(bool) / setError(message)A loading state and a host-supplied error banner; empty columns already render their placeholder.
setRows(rows) / refresh()Replace the source rows, or recompute and re-render.
on(name, fn) / off(name, fn)Events: card:click, card:dblclick, card:contextmenu, card:move, card:reverted, selection:changed, drag:start, drag:end (plus the vocabulary the later cycles emit).
readonly(scope)Whether a scope is readonly - the whole board, a { column } or a { card }. A readonly card is not draggable; a move into a readonly column is refused.
destroy()Empty the element and drop the model. The host still owns any bound grid.

Drag-and-drop and keyboard move (write-back). Cards drag between columns (writing columnProperty) and within a column into a position (writing orderProperty with fractional ranking, so only the moved cards' order is written). The same move is available from the keyboard: focus a card, press Space to grab it, use the arrows to choose a target column and position (announced on a live region), Space/Enter to drop, Escape to cancel. Multi-select drags every selected card. A move calls onBeforeMove(card, from, to, index) first - return false (or a promise of it) to veto - then persists: grid-bound, through grid.edit.setCells (the same public edit-commit path inline editing and the write-back adapter use, so the grid's own pipeline owns the optimistic apply, the confirm and the revert); standalone, optimistically with a revert when onCardMove returns false/rejects. A configurable per-card contextMenu (an array or fn(card, selected)) replaces the card:contextmenu event when present.

Swimlanes, collapse, reorder and search. Set swimlanes: true to render a 2D lane×column grid grouped by swimlaneProperty: one band per lane with its own count/points, columns aligned across every lane, the board scrolling vertically through lanes and horizontally through columns inside its own box. A drag across lanes writes the swimlane property too. Columns and lanes collapse (their state survives a keyed-diff update); columns reorder by dragging their header (reorderColumns/moveColumn). setQuickFilter(text) searches across card fields, setFilter(fn) applies a predicate, and facets(property) returns distinct-value counts to build a facet control. Naming the swimlaneProperty is separate from turning on the lane view, so a board can carry it for a cross-lane move without switching layout.

Sprint, epic and card pop-out. setSprint(id) shows one sprint, showBacklog() the cards with no sprint, and sprints() feeds a switcher; setEpic(id) narrows to an epic and epicRollup() (or rollup(property)) returns per-epic count, points and progress toward the done columns. A card can pop out a nested grid of its children - an epic's stories, a story's tasks, recursively. The child relationship is a childrenProperty (parent-id within the dataset) and/or a loadChildren(card) (per-card dataset or async fetch), and the child is a full composed createGrid (sort/filter/edit/write-back) - supplied as children.factory - opened in a drawer (default), modal or inline. With children.asBoard the child is itself a board, so it can pop its own children. This reuses the grid by composition and adds no grid-core coupling. expand(key) and the per-card drill affordance emit card:expand; a deeper open emits card:drill.

Live updates. Because the board consumes data through the same keyed-diff contract a grid does, a Data Router drives it directly - router.attach(board, predicate) - and one feed fans out to a grid, a kanban, a chart and a KPI tile at once. A live rows.apply({ add, update, remove }) is applied as a keyed diff (an unchanged card keeps its model) and re-rendered preserving scroll, focus, selection, collapsed columns/lanes and any open pop-out, so a card can appear, move or update under the user without losing their place.

Scale, state and accessibility. virtualize renders only a scroll window of a tall column (with true-height spacers so the scrollbar stays honest), for boards of thousands of cards. getState()/setState() (and config.state) save and restore the collapsed columns and lanes, the column order, the quick filter and the sprint/epic selection, so a reopened board comes back as it was; setLoading/setError add loading and error states. Accessibility runs throughout: the board is a labelled group of labelled column lists, cards are a roving-tabindex focus ring (arrows to move focus, Enter to activate), the move is fully keyboard-driven (Space grab, arrows for column/position, Alt+↑/↓ across swimlanes, Space/Enter drop, Escape cancel) with live-region announcements, and every affordance carries a name.

Inline edit and add-card. A field is opted into inline edit with the object mapping form - card: { title: { field: 'title', edit: true } }. Double-clicking a card (or editCard(key, field)) edits it in place: grid-bound, through the grid's own field editor for that column via its public edit path, so the column's parse, validate and optimistic/confirm/revert all run; standalone, through a host editor factory (or a default input), reverting when onCardEdit rejects. A per-column add-card affordance (config.addCard) creates a card carrying the column's group value - from a host onAddCard(columnId), or generated, or appended through grid.edit.addRow when bound - and opens it straight in inline edit on its title, so the user just types. The module imports nothing from the grid's DOM package: grid-bound edits ride the grid's public edit API, standalone edits use the host's editor, so a board-only page never pulls the grid in.

A board, grouped and aggregated, executed

A DemandFlow-shaped set - statuses as columns, points, an empty configured column, a WIP limit, and one status outside the configured set - grouped headless, then a keyed diff applied through the same rows.apply contract the Data Router drives. Run on every build.

const { createKanban } = await import('../packages/modules/kanban/index.js');

const board = createKanban(null, {
  rows: [
    { id: 'a', status: 'todo', points: 3, title: 'Login form' },
    { id: 'b', status: 'doing', points: 5, title: 'OAuth' },
    { id: 'c', status: 'doing', points: 2, title: 'Reset flow' },
    { id: 'd', status: 'done', points: 8, title: 'Audit log' },
    { id: 'z', status: 'archived', points: 1, title: 'Old ticket' }, // outside the configured columns
  ],
  rowKey: 'id',
  columnProperty: 'status',
  columns: [
    { id: 'todo', title: 'To do' },
    { id: 'doing', title: 'In progress', wipLimit: 1 },
    { id: 'done', title: 'Done' },
    { id: 'blocked', title: 'Blocked' },   // configured but empty; still shown
  ],
  pointsProperty: 'points',
  card: { title: 'title' },
});

const todo = board.count('todo') + '/' + board.points('todo');       // 1/3
const doing = board.count('doing') + '/' + board.points('doing');    // 2/7
const over = board.column('doing').over ? 'over' : 'ok';             // over the WIP limit of 1
const extra = board.column('archived') ? 'archived' : 'dropped';     // an out-of-set value is kept

// The keyed-diff consumer contract a Data Router drives: move b to done, drop c.
board.rows.apply({ update: [{ id: 'b', status: 'done', points: 5, title: 'OAuth' }], remove: ['c'] });
const done = board.count('done') + '/' + board.points('done');       // 2/13

board.destroy();
return [todo, doing, over, extra, done].join(' | ');

Move, reorder and veto, executed

The move path drag-and-drop and keyboard move share: a reorder writes the order property with fractional ranking, a cross-column move writes the column property, and onBeforeMove can veto. Run headless on every build.

const { createKanban } = await import('../packages/modules/kanban/index.js');

const board = createKanban(null, {
  rows: [
    { id: 'a', status: 'todo', ord: 1, title: 'A' },
    { id: 'b', status: 'todo', ord: 2, title: 'B' },
    { id: 'c', status: 'todo', ord: 3, title: 'C' },
  ],
  rowKey: 'id',
  columnProperty: 'status',
  columns: [{ id: 'todo' }, { id: 'doing' }, { id: 'done' }],
  orderProperty: 'ord',
  // Veto any move into 'doing'; allow the rest.
  onBeforeMove: (card, from, to) => to !== 'doing',
});

await board.move('c', 'todo', 0);          // reorder C to the top of todo
const order = board.column('todo').cards.map((x) => x.key).join(',');  // c,a,b

await board.move('a', 'done');             // cross-column move writes the column
const moved = board.card('a').columnId;    // done

await board.move('b', 'doing');            // vetoed by onBeforeMove
const vetoed = board.card('b').columnId;   // still todo

return [order, moved, vetoed].join(' | ');

Swimlanes, collapse and search, executed

Lanes with per-lane aggregates, a collapsed column, a reorder, and a quick-filter - all headless, run on every build.

const { createKanban } = await import('../packages/modules/kanban/index.js');

const board = createKanban(null, {
  rows: [
    { id: 1, status: 'todo', assignee: 'Ann', points: 3, title: 'Login' },
    { id: 2, status: 'doing', assignee: 'Ann', points: 5, title: 'OAuth' },
    { id: 3, status: 'doing', assignee: 'Bob', points: 2, title: 'Reset' },
    { id: 4, status: 'done', assignee: 'Bob', points: 8, title: 'Audit' },
  ],
  rowKey: 'id',
  columnProperty: 'status',
  columns: [{ id: 'todo' }, { id: 'doing' }, { id: 'done' }],
  swimlaneProperty: 'assignee',
  swimlanes: true,
  pointsProperty: 'points',
  card: { title: 'title' },
});

const ann = board.model.lanes.find((l) => l.id === 'Ann');
const lane = 'Ann ' + ann.count + '/' + ann.points;   // Ann 2/8

board.reorderColumns(['done', 'todo', 'doing']);
const order = board.columns().map((c) => c.id).join(',');  // done,todo,doing

board.setQuickFilter('reset');
const shown = board.model.cardsByKey.size;             // 1 (only "Reset" matches)

return [lane, order, shown].join(' | ');

Sprint, backlog and epic rollup, executed

A sprint view, the backlog, and an epic rollup with progress toward the done column - headless, run on every build.

const { createKanban } = await import('../packages/modules/kanban/index.js');

const board = createKanban(null, {
  rows: [
    { id: 's1', status: 'todo', epic: 'E', sprint: 'S1', points: 3, title: 'Story 1' },
    { id: 's2', status: 'done', epic: 'E', sprint: 'S1', points: 5, title: 'Story 2' },
    { id: 's3', status: 'todo', epic: 'E', sprint: null, points: 2, title: 'Story 3' },
  ],
  rowKey: 'id',
  columnProperty: 'status',
  columns: [{ id: 'todo' }, { id: 'done', done: true }],
  pointsProperty: 'points',
  sprintProperty: 'sprint',
  epicProperty: 'epic',
});

board.setSprint('S1');
const inSprint = board.model.cardsByKey.size;   // 2 (s1, s2 are in S1; s3 has no sprint)
board.showBacklog();
const backlog = board.model.cardsByKey.size;    // 1 (s3)
board.setSprint(undefined);

const e = board.epicRollup()[0];
const rollup = e.points + ' ' + e.progress;      // 10 0.5

return [inSprint, backlog, rollup].join(' | ');

Live, driven by a Data Router, executed

One feed, routed to a board through the same keyed-diff contract a grid uses: a snapshot hydrates it and a delta moves a card. Run headless on every build.

const { createKanban } = await import('../packages/modules/kanban/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const board = createKanban(null, {
  rows: [], rowKey: 'id', columnProperty: 'status',
  columns: [{ id: 'todo' }, { id: 'done' }], card: { title: 'title' },
});

const router = createDataRouter({ key: 'kind', rowKey: 'id' });
router.attach(board, 'task');                // the board is a drop-in router target
router.load([
  { id: 't1', kind: 'task', status: 'todo', title: 'T1' },
  { id: 't2', kind: 'task', status: 'done', title: 'T2' },
]);
const hydrated = board.count('todo') + ',' + board.count('done');   // 1,1

router.apply([{ op: 'upsert', row: { id: 't1', kind: 'task', status: 'done', title: 'T1' } }]);
const afterDelta = board.count('todo') + ',' + board.count('done'); // 0,2

router.destroy();
return [hydrated, afterDelta].join(' | ');