Skip to content

Entity Grid

Renders rows from an entity source as EntityCard tiles: the thumbnail fills the top of each tile, the status sits on it, and one metadata line runs under the name.

paging says how the set is walked, and the source follows it: scroll loads the next page when the scroller reaches the last loaded tile, more puts a load-more row under the tiles, and pages draws the pagination footer. The grid defaults to scroll. A wall of pictures is browsed rather than read, and the tile a reader wants is found by looking, not by page number.

more and scroll append: the tiles already loaded stay and the new page lands under them. A page that fails leaves its tiles and puts one error line under them with a retry.

Installing this pulls EntityCard, and StatusBadge and Thumbnail with it.

Terminal window
pnpm dlx shadcn@latest add https://sg-widgets.vercel.app/r/react/entity-grid.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://sg-widgets.vercel.app/r/svelte/entity-grid.json
Entity Grid: Versions at three tile sizes, a selectable grid, one with no picture, one with every third card disabled, and one drawing a card of the caller's own

React

Loading the React demo…

Svelte

Loading the Svelte demo…

const context = createSgContext({ client });
const source = createEntitySource({
client: context.client,
entityType: 'Version',
fields: ['code', 'image', 'sg_status_list', 'user'],
pageSize: 12,
});
const [artist] = await resolveColumns(context.schema, 'Version', ['user']);

The source reads the fields the tiles draw. The status badge needs the type’s status field in that list; the metadata line needs whatever subLabelField and secondaryField name.

proptypedefaultmeaning
contextSgContextrequiredThe widget context. Every tile reads its schema, its statuses and its links through it.
thumbnailstring | false'image'Field holding the thumbnail URL. false leaves every tile on the placeholder.
labelFieldstring | nullnullField shown as the tile's name. Defaults to the type's display name.
subLabelFieldstring | CollectionColumn | nullnullThe left of the metadata line. A resolved column renders it by type.
subLabel(row) => string—The caller's own sub-label. Wins over subLabelField.
secondaryFieldstring | CollectionColumn | nullnullThe right of the metadata line.
secondary(row) => string—The caller's own text on the right. Wins over secondaryField.
showCodebooleanfalseShow the row's code beside the name when the two differ.
statusesRecord<string, StatusRecord> | nullnullStatus rows by code. Read through the context when not given.
size'sm' | 'md' | 'lg''md'Tile size and column minimum: 160, 224 or 288 pixels.
selectablebooleanfalseDraws a checkbox on each tile.
pageSizesnumber[][25, 50, 100]Rows per page offered in the footer. pages only.
maxHeightstring'32rem'Height of the scrolling body.
virtualizeAfternumber100Rows above which the grid draws only the tiles on screen.
emptyLabelstring'No rows'Shown when the read returned nothing.
errorLabelstring—Shown in place of what the failed read said.
density'compact' | 'default''default'Compact halves the gap between tiles.
class / classNamestring—Merged after the widget's own classes.
sourceEntitySourcerequiredThe rows and the paging behind them.
paging'pages' | 'more' | 'scroll''scroll'How the set is walked: a page number, a load-more row, or the scroller.
sortSortSpec[]—The source's sort, two-way, so a SortPicker drops into header.
filtersFilterNode | WireGroup | null—The source's filter, two-way, so a FilterBar drops into header.
selectionfrom CollectionControlEntityRef[][]The selected rows, two-way. Names loaded rows only.
getRowIdfrom CollectionControl(row) => string—How a row is keyed, in the DOM and in the selection. Default Type:id.
isRowDisabled(row) => boolean—True for a row the arrows skip and the selection refuses.
loadingLabelfrom CollectionControlstring'Loading…'Names the skeletons a read stands behind, for a screen reader.

Nothing is on the metadata line by default, and the row’s id is never on it.

eventpayloadwhen
onSelectEntityRowA tile was activated, by click or by Enter.
onSortChangeSortSpec[]The source's sort changed.
onFiltersChangeFilterNode | WireGroup | nullThe source's filter changed.
onSelectionChangeEntityRef[]The selection changed.
slotreceivesdraws
card{ row, id, index, selected, disabled, active }Draws one grid cell. The grid keeps the cell's box, its tab stop and its keys.
header—Region above the grid.

The grid is one tab stop: Tab moves into the tile the cursor is on, and the arrows walk from there.

keydoes
TabMoves into the grid, then out of it.
Left, RightMoves the cursor one tile.
Up, DownMoves the cursor one row of tiles.
Home, EndMoves to the first or the last tile.
Right, Down past the last tileIn more and scroll, asks for the next page. The cursor stays where it is until the tiles arrive, then lands on the first of them.
SpaceSelects the tile, when the grid is selectable.
EnterEmits the select event.
Enter in the page numberGoes to that page.
Tabfrom CollectionControlReaches the collection. It is one tab stop, and the cursor holds the focus inside it.
↑ ↓from CollectionControlMoves the cursor over the rows it can land on, never a disabled one, and brings it into view.
↓ on the last loaded rowfrom CollectionControlIn more and scroll, asks for the next page. The cursor waits where it is and lands on the first of the new rows.

A disabled row takes no keys and no click: the arrows step over it and the cursor never lands on it.

The tile follows the thumbnail view of the Flow PT web app and the asset grid of Frame.io: the picture first and at one aspect, the state on the picture rather than under it, one line of name and one of metadata, and the controls only while the pointer or the focus is on the tile.

The value of an image field is a presigned URL re-signed on every read, and it is the only state marker there is: null means the row never had a thumbnail, and a URL under the transient status path means one is still transcoding. All three states render (field_types/image). A Version tile carries a play overlay.

Paging stops on a short page: links.next is emitted on every page forever, including empty ones, and no total is in a read (006_pagination). The “of N” in the footer comes from one _summarize call counting id; a site that does not answer that key leaves the range without a total (020_summarize).