Entity Table
Renders rows from an entity source as a table whose columns come from the schema, with server-side sorting and paging, a toolbar, a pagination footer, row selection, column sizing, resizing, ordering, pinning and grouping, and inline edit.
Install
Section titled “Install”pnpm dlx shadcn@latest add https://sg-widgets.vercel.app/r/react/entity-table.jsonpnpm dlx shadcn-svelte@latest add https://sg-widgets.vercel.app/r/svelte/entity-table.jsonReact
Loading the React demo…
Svelte
Loading the Svelte demo…
Build the source and the columns once, then hand both to the table.
const context = createSgContext({ client });const source = createEntitySource({ client: context.client, entityType: 'Version', fields: ['code', 'entity', 'sg_status_list', 'image', 'description', 'user'], pageSize: 25,});const columns = await resolveColumns(context.schema, 'Version', ['code', 'entity', 'sg_status_list']);Paging
Section titled “Paging”paging says how the set is walked, and the source follows it, so a caller sets one prop rather
than two.
| value | the table draws |
|---|---|
pages |
The footer’s pager: rows per page, a page number, and n to m of N once the set is counted. |
more |
A load-more row under the last row. The footer counts what is loaded. |
scroll |
The next page arrives when the scroller reaches the last loaded row. A skeleton row sits at the bottom while it does, and the footer counts what is loaded. |
The table defaults to pages. A table is read as a spreadsheet, where a row’s place in the set is
part of what it means, and a caller who walks to page 12 wants to come back to it.
more and scroll append: the rows already loaded stay and the new page lands under them, so a
group that spans a page boundary stays one group and its count grows. A page that fails leaves its
rows and puts one error line under them with a retry.
React
Loading the React demo…
Svelte
Loading the Svelte demo…
| prop | type | default | meaning |
|---|---|---|---|
columns | CollectionColumn[] | required | Columns in display order, from resolveColumns. Two-way: hiding one writes the shorter list back. |
statuses | Record<string, StatusRecord> | null | null | Status rows by code, for status cells. |
context | SgContext | — | The widget context. Cells render with its preferences, a cell editor reads through it, and an entity cell links to the row's page when it carries a site. |
projectId | number | — | The project the columns were resolved with. Scopes a status, list or entity cell editor. |
precision | number | — | Decimals a float cell keeps. |
symbol | string | '$' | Shown before the value in a currency cell. |
density | 'compact' | 'default' | 'default' | Compact halves the vertical cell padding and draws a cell chip and badge a step smaller. |
selectable | boolean | false | Draws a checkbox column; a press on a row selects it, with Shift for a range. |
onSelectAllMatching | () => void | Promise<void> | — | Offers every row the filter matches once every loaded row is selected and the set holds more. The host reads the set and writes the selection. |
groupBy | string | null | null | Collapses rows under headers of a shared value at this path. |
collapsed | string[] | CollapseState | expandAll() | Which group headers are shut, two-way. A bare id list is the open mode with those ids shut. |
editable | boolean | false | Lets an editable cell open an editor. |
editorFor | (dataType) => ComponentType<CellEditorProps> | null / (dataType) => Component<CellEditorProps> | null | — | The editor a cell opens, by data type. A type it does not answer for opens FieldEditor. |
editorPlacement | 'inline' | 'popover' | per data type | Where every cell editor opens. A column's own editorPlacement wins over it. |
showCode | boolean | false | Show the programmatic field path beside the header's display name. |
columnMenu | boolean | false | A menu on every header: sort, hide and pin left. |
columnPicker | boolean | false | A Columns button in the toolbar: the user adds, removes and reorders the type's fields, kept in this browser. Needs context. |
columnsKey | string | one per entity type | The localStorage key the column choice is kept under. |
pageSizes | number[] | [25, 50, 100] | Rows per page offered in the footer. pages only. |
maxHeight | string | '28rem' | Height of the scrolling body. |
virtualizeAfter | number | 100 | Rows above which the body is virtualised. |
emptyLabel | string | 'No rows' | Shown when the read returned nothing. |
errorLabel | string | — | Shown in place of what the failed read said. |
size | 'sm' | 'md' | 'lg' | 'md' | Row text and header height: text-xs/h-9, text-sm/h-10, text-base/h-11. |
class / className | string | — | Merged after the widget's own classes. |
sourcefrom CollectionControl | EntitySource | required | The rows, the filter, the sort and the page behind them. |
paging | 'pages' | 'more' | 'scroll' | 'pages' | How the set is walked: a page number, a load-more row, or the scroller. |
sortfrom CollectionControl | SortSpec[] | — | The source's sort, two-way. |
filtersfrom CollectionControl | FilterNode | WireGroup | null | — | The source's filter, two-way. |
selectionfrom CollectionControl | EntityRef[] | [] | 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 that cannot be selected or edited. |
loadingLabelfrom CollectionControl | string | 'Loading…' | Names the skeletons a read stands behind, for a screen reader. |
Events
Section titled “Events”| event | payload | when |
|---|---|---|
onColumnsChange | CollectionColumn[] | A column was hidden from its header menu, or chosen in the column picker. |
onCollapsedChange | CollapseState | A group header was opened or shut. |
onSortChange | SortSpec[] | The source's sort changed, from a header or from the prop. |
onFiltersChange | FilterNode | WireGroup | null | The source's filter changed. |
onSelectionChange | EntityRef[] | The selection changed. |
| slot | receives | draws |
|---|---|---|
toolbarStart | — | Left region of the toolbar above the table. |
toolbarEnd | — | Right region of the toolbar above the table. |
row | { row, id, index, selected, disabled, columns } | Draws the cells of one row. The table keeps the row's box. |
cell | { row, id, column, value, disabled } | Draws one cell's contents. Ignored where row is given. |
groupHeader | { value, column, count, expanded, id } | Draws a group header's contents, after its chevron. |
sort and filters mirror the source, so a SortPicker, a FilterBar and a ColumnPicker drop into
the toolbar and none of them reaches into source.
<EntityTable source={source} columns={columns} onColumnsChange={setColumns} sort={toSortSpecs(sortKeys)} filters={filter} selection={selected} onSelectionChange={setSelected} toolbarStart={<FilterBar entityType="Version" context={context} facets={['sg_status_list']} value={filter} onValueChange={setFilter} />} toolbarEnd={<SortPicker entityType="Version" context={context} value={sortKeys} onValueChange={setSortKeys} />}/>With columnPicker, a Columns button at the end of the toolbar opens a ColumnPicker over the
entity type’s fields. The user adds, removes and reorders columns; the columns first passed are the
defaults, and Reset columns puts them back. The choice is kept in localStorage under one key per
entity type, or under columnsKey (a project and a type, say), and a later visit opens on it. A
path the type no longer has is skipped. A column the source does not read yet is added to it with
addFields, which reads the loaded rows again. Hiding a column from its header menu is kept the
same way. In React the table writes through onColumnsChange, so a host holding columns in state
passes both.
<EntityTable source={source} columns={columns} onColumnsChange={setColumns} context={context} columnPicker columnsKey={`shots:${projectId}`}/>A ColumnPicker can also sit in the toolbar on its own, bound to the same array the table holds: the two drive each other, and nothing is kept.
<EntityTable source={source} columns={columns} onColumnsChange={setColumns} toolbarStart={ <ColumnPicker context={context} entityType="Version" value={columns.map((column) => column.path)} onValueChange={pickColumns} /> }/>An editor is handed value, dataType, field, commit and cancel, and owns its own keys.
Without editorFor, an editable cell opens the type’s own control from the field-editor item: a
status cell opens StatusPicker, an entity cell EntityPicker and a multi-entity cell
EntityMultiPicker, each reading through context. The cell editor takes projectId, precision,
symbol and the context’s site preferences.
The editor opens in a popover anchored to the cell, carrying the field’s name, the control and
Cancel and Save, so a cell’s width never squeezes it. A checkbox is one press and stays in the cell.
editorPlacement on the table forces one or the other everywhere, and editorPlacement on a column
spec forces it for that column.
Selection
Section titled “Selection”With selectable, a press anywhere on a row toggles it, as a press on its checkbox does, and
anchors there. Cmd or Ctrl with a press toggles one row as well. Shift with a press takes every row
from the anchor to the one pressed, and selects or clears the range as the anchor went; a second
Shift+press moves the far end rather than adding a second range. A press on a link, a button or an
open editor inside a cell is left to it.
The header box adds or drops the loaded rows and keeps picks made on other pages; it reads
indeterminate when some loaded rows are picked. onSelectAllMatching offers the rest of the set:
once every loaded row is picked and the set holds more, a line above the table offers every
matching row, and the host reads their ids and writes the selection. The table never reads past its
own pages.
async function selectAllMatching() { const refs = []; for (let number = 1; ; number += 1) { const page = await client.search('Version', { filters: source.filters, fields: ['id'], page: { size: 500, number } }); refs.push(...page.data.map((row) => ({ type: row.type, id: row.id }))); if (!page.hasMore) break; } setSelected(refs);}The selection model is core’s toggleRow, extendRange, extendByStep and setRefs, for a host
holding a list of its own.
Column menu
Section titled “Column menu”With columnMenu, every header carries a menu: sort ascending, sort descending, clear sort, hide
column, and pin left. It is off by default: a header sorts on a press, and the column picker in the
toolbar is where columns are shown and hidden.
The sort entries are inert on a column the schema says cannot be sorted. Hiding writes the shorter
column list back through columns. Pinning sticks the column to the start of the scrolling body.
Keyboard
Section titled “Keyboard”| key | does |
|---|---|
| Tab | Moves through toolbar, header buttons, column menus, checkboxes, editable cells and the footer. |
| Enter, Space on a header | Sorts by that column, ascending, then descending, then unsorted. |
| Enter, Space on a column menu | Opens it; arrows walk it, Escape closes it. |
| Enter on an editable cell | Opens the editor. |
| Enter in an editor | Commits the value. In a textarea it adds a line, and Cmd or Ctrl with Enter commits. |
| Escape in an editor | Cancels and restores the value. |
| a press outside an editor | Commits the value. |
| Enter, Escape, Save, Cancel in a popover editor | The same: Enter and Save commit, Escape and Cancel restore. |
| Space on a checkbox or a cell | Toggles the row, or every loaded row from the header. |
| Shift+ArrowDown, Shift+ArrowUp in the body | Moves the cursor and carries the range from the anchor with it, selecting or clearing as the anchor went. |
| Cmd+A, Ctrl+A in the table | Selects every loaded row, keeping picks from other pages. |
| ArrowDown, ArrowUp in the body | Moves the cursor down or up the same column, over the rows it can land on. |
| ArrowDown on the last loaded row | In more and scroll, asks for the next page. The cursor stays where it is until the rows arrive, then lands on the first of them. |
| Enter in the page number | Goes to that page. |
| Tabfrom CollectionControl | Reaches the collection. It is one tab stop, and the cursor holds the focus inside it. |
| ↑ ↓from CollectionControl | Moves the cursor over the rows it can land on, never a disabled one, and brings it into view. |
| ↓ on the last loaded rowfrom CollectionControl | In more and scroll, asks for the next page. The cursor waits where it is and lands on the first of the new rows. |
An editable cell says so: it washes to bg-accent/50 under the pointer and under the keyboard
cursor, and its tooltip reads “Double-click or press Enter to edit”. A double-click opens the editor
too. A press inside a popup the editor opened — a calendar, a status list, a picker’s results —
leaves the cell open. A header drags onto another to reorder, and its right edge drags to resize.
A disabled row takes no keys, no click and no checkbox, and none of its cells opens an editor.
API behaviour
Section titled “API behaviour”Sorting is the server’s. A header click sets the source’s sort and reads the page again, because a sort applied to one loaded page orders the page and not the set. Sorts also fail silently where filters fail loudly: a field that cannot be sorted, and a name that does not exist, both return 200 with the rows in default order, so a sortable path is verified against the schema first (026_result_order).
The cursor in the body is the focused checkbox or editable cell: a row with neither is not a place a cursor can be.
Paging is the server’s too. No total is in a read: a paged GET answers data and links alone, and
five spellings of a count option are accepted at 200 and change nothing, so pages mode walks the
set with an explicit page number and stops on a short page rather than on a missing links.next,
which is emitted on every page forever (006_pagination). The “of N” in the range comes from one
_summarize call counting id, taken once per filter; a site that does not answer that key leaves
the range reading “n to m” (020_summarize).
Grouping collapses the loaded rows under headers, and the group path leads the source’s sort so a group is not split across pages. A count is the rows loaded under that header, and a page that opens on the value the last header carries grows that group rather than opening a second one.
collapsed is a mode with exceptions rather than a list of ids, so Collapse all covers the headers
the next page brings as well as the ones already loaded. A bare id list still works and reads as the
open mode with those ids shut. collapseAll, expandAll, isCollapsed and toggleCollapsed in
core are the readings over it, and a host holding the same state for a list of its own imports them.
An edit writes one field with PUT /entity/<type>/<id>, which changes only the keys it is given and
leaves the rest alone (put_entity_type_id). The write answers the whole record but resolves no dotted
path, so the row is read again afterwards (024_read_after_write). A dotted path is never editable: a
write names one field of one row.
Reference
Section titled “Reference”Column sizing, resizing, ordering and grouping are TanStack Table’s (@tanstack/table-core 9.2.4).
The markup, the classes and the anatomy of the toolbar, the pagination footer and the column menu
follow ReUI’s Base UI data grid (ReUI 2.5.2).