Lattice Grid Buy a licence

developer guide

Data Grid Card View, Lists and Feeds

The same rows can be drawn as cards in a responsive gallery, as a list, or as a feed, with rowTemplate or a card renderer deciding what each one shows. Sorting, filtering and grouping carry on working, so a card wall is the grid in another shape rather than a second component.

Developer guideColumns and cell rendering › Data Grid Card View, Lists and Feeds

Cards, lists and feeds

rowTemplate draws each row with a layout of your own instead of dividing it into columns. A card list, a feed, a search-result list, a message list: any presentation where a record is a small piece of layout rather than a line of cells.

Two shorthands sit on top of it for the shapes that recur. recordCard presents each row as a record card - a form of label/value pairs, one line per column in display order, showing the same text the table shows - for a screen where reading one record matters more than comparing many. gallery presents the rows as a grid of tiles laid out by the same 2-D virtualisation the grid already runs. Both take true to generate the layout from the columns, or an object to size it or supply a template, and both are presentation only: sort, filter, group and the data pipeline are unchanged.

board is the third shape: a kanban of grouped lanes of cards. Group the grid and the top-level group becomes a lane, with every leaf under it a card stacked in that lane - a pipeline by stage, a task list by status, a backlog by owner. Both axes are virtualised, the lanes across and the cards down each, so a board of many long lanes draws only what is on screen. A board card is still a row: it clicks, selects and drags through the grid's own handlers, and masks protected columns exactly as every other card does. Like the others it is presentation only.

pivotView is the fourth shape: the grid drawn as a pivot - a cross-tab matrix. The grid's group dimensions run down the left gutter, its pivot dimensions run across the top, and each totalled column fills a cell with its reduction, with a subtotal down every row, across every column, and the grand total in the corner. Those numbers are the grid's own: every cell is the same aggregate kernel the totals row uses, run over the rows that feed the cell, so a pivot subtotal equals the grid's group total for that set by construction rather than being re-derived - an average subtotal is the average of the rows, never an average of cell averages. Both axes expand and collapse and both are virtualised through the shared row-template layer, so a wide, deep matrix draws only the cells on screen. Clicking a body cell emits pivot:drill with the keys of the contributing rows, the pivot's answer to “what is behind this number”. The collapse state rides in a saved view, and because a matrix cannot be read on a phone, at or below maxWidth the pivot degrades to a card list, exactly as the table does under responsive. Like the others it is presentation only.

The shorthands, and the config a grid reports back

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const grid = createHeadlessGrid({
  columns: [{ field: 'name' }, { field: 'owner' }],
  rows: [
    { id: '1', name: 'Alpha', owner: 'Ada' },
    { id: '2', name: 'Beta', owner: 'Ben' },
  ],
  rowKey: 'id',
  // Each row as a labelled record card…
  recordCard: true,
  // …or a gallery of size-driven tiles…
  gallery: { tileWidth: 240 },
  // …or a kanban of grouped lanes. Presentation only.
  board: { laneWidth: 300 },
});

const c = grid.config();
return `recordCard ${c.recordCard === true}, gallery ${c.gallery.tileWidth}px, board ${c.board.laneWidth}px`;

A pivot: group down, pivot across, and the drill event a cell click fires

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const grid = createHeadlessGrid({
  columns: [{ field: 'region' }, { field: 'product' }, { field: 'amount', type: 'number', total: 'sum' }],
  rows: [
    { id: '1', region: 'EMEA', product: 'Widget', amount: 10 },
    { id: '2', region: 'APAC', product: 'Gadget', amount: 5 },
  ],
  rowKey: 'id',
  // Draw the grid as a pivot: the group down, the pivot across, a measure per cell.
  pivotView: true,
});
grid.columns.group(['region']);
grid.columns.pivot(['product']);

// A body-cell click drills to its rows through this event.
let drills = 0;
grid.on('pivot:drill', () => { drills += 1; });

// The collapse state a saved view carries.
const state = grid.pivotView.state();

return `pivotView ${grid.config().pivotView === true}, collapsed ${state.rowsCollapsed.length}, drills ${drills}`;

A card list

createGrid(element, {
  columns,                       // still declared: they are the data model
  rows,
  rowKey: 'id',
  rowHeight: 64,
  rowTemplate: '<p class="title">{{data.name}}</p>'
             + '<p class="sub">{{data.owner}} · {{data.stage}}</p>',
});

The template compiles; it does not call back. There is deliberately no "here is a container, build what you like for this row" hook. That shape is easy to offer and would be used to allocate DOM per row, and at that moment the virtualisation stops paying for itself: quietly, and in a way nobody can attribute to a change. A row template is the same declarative string a cell template is: parsed once, built into real DOM the first time an element is used, and afterwards updated by writing text into the few nodes the bindings own. Scrolling ten thousand records through a hundred pooled cards allocates nothing.

Everything underneath is unchanged. Sorting, filtering, grouping, selection, permissions, redaction, saved views, undo, export and the remote source all apply exactly as they do to a table: only the drawing changes. That is the reason to build a card view on a grid rather than beside one.

A card is still a row. It carries the same row identity a table row does, so row:clicked and row:dblclicked fire with the same payload, clicking selects, the context menu opens, and rowReorder works, with the card itself as the drag handle, since there is no cell to put a grip in. None of that is a second implementation; it is the same code that serves a table.

It is announced as a list, not a grid. A card has no columns, so the grid role, which promises columns, gridcell children and a two-dimensional keyboard model: would misdescribe it completely. The layer is a list, each card a listitem carrying its position and the size of the whole set, and the column header is not drawn. role and itemRole override both, for a presentation that is really a listbox.

Collapsing to cards on a narrow screen

A table on a phone is a compromise however it is styled. responsive presents rows as cards when the grid is too narrow to be a table honestly, and returns to a table above the threshold.

One grid, two presentations, chosen by width

createGrid(element, {
  columns, rows, rowKey: 'id',
  toolPanel: true,
  responsive: {
    maxWidth: 640,                // the default
    rowHeight: 64,
    template: '<p class="title">{{cell.name}}</p><p>{{cell.owner}}</p>',
  },
});

Measured on the container, not the viewport. A media query is the obvious way and the wrong one: a grid inside a 400px panel on a large monitor is narrow, and a grid filling a small tablet is not. The grid already watches its own element for size changes, so the same observer answers this.

The state a user built survives the switch. Rotating a phone must not lose the sort, the filters, the selection or the scroll position, and it does not, it is one grid throughout, and only the drawing changes. An open cell editor is closed, since the cell it belonged to stops existing.

Sorting and filtering need a home when there are no column headings to click, and the tool panel is it: set toolPanel: true and its rail stays available in card presentation with the columns and filter panels behind it. Export is unaffected: the columns are still the data model, so a CSV or an Excel file from a collapsed grid holds every column, including ones the card does not show.

A collapsed card takes rowHeight from the responsive block rather than the grid's, because a table row is too short to hold one; 64px by default. The change is announced, and the role tree follows the presentation. presentation:changed fires with 'cards' or 'table'.

Showing what the grid shows

{{cell.column}} is the text the table puts in that cell, the column's own formatter, data type, number and date settings and lookup label, all of it. {{data.field}} is the raw value underneath.

BindingReads
{{cell.value}}£1,250.50, the cell's rendered text
{{data.value}}1250.5, the stored number
{{cell.stage}}Held, a lookup's label
{{data.stage}}2, the lookup's id

Both are wanted, which is why both exist: a card showing a value to a person wants cell, and a template comparing or calculating wants data. A lookup is the case that decides it, a card showing 2 where the table shows Held is not a formatting preference but a plain bug.

A protected column cannot be read raw. Binding {{data.password}} on a secret or redacted column would print the value the column exists to hide, while the table beside it shows dots. Such a binding reads the masked text instead and says once that it did, a card must not become the hole a redaction closes.

Several cards on a line

By default a card takes a line to itself, which is what a feed or a search-result list wants. A gallery wants tiles, and there are two ways to ask for them because they answer different questions.

A fixed count, and a size that flows

rowTemplate: { template: CARD, cardsPerRow: 3 }        // always three across
rowTemplate: { template: CARD, maxCardWidth: 260 }    // as many as fit

cardsPerRow is a count, for a layout that must not reflow. maxCardWidth is a ceiling: the grid fits as many whole cards as it can without exceeding it, and they share the remaining space rather than leaving a ragged margin, so 900px at a 200px ceiling is four cards of 225px, and the count changes with the container. gap sets the space between them. Where both are given, cardsPerRow wins, being an instruction rather than a preference.

The scroll height counts lines, not records. Four records on a line means the content is a quarter as tall as the row model alone would make it, and a scrollbar sized per record would be four times too long, the last several screens empty. The tiled layout works out its own window from the scroll position for the same reason: the window it would otherwise be handed counts one record per line and would leave the bottom of the screen bare. Pooling is unaffected; scrolling a tiled gallery reuses its elements exactly as a list does.

Tiles are a fixed height, taken from rowHeight, rowHeight: 'auto' measures a rendered row and cannot describe a line holding several of different heights. Variable-height tiles flowing into the shortest column is a masonry layout, which is a different thing and is not offered.

Row heights work as they do everywhere else, including rowHeight: 'auto', which measures the rendered card: content-driven card heights need no extra configuration. The columns are still declared and still hold the data: they are what sorting, filtering and export operate on, and what the bindings read.