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 guide › Columns 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.
| Binding | Reads |
|---|---|
| {{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.