Skip to content

Grouped List

Renders rows from an entity source as one line each, under collapsible headers of a shared value.

paging says how the set is walked, and the source follows it: more puts a load-more row under the rows, scroll loads the next page when the scroller reaches the last loaded row, and pages draws the pagination footer. The list defaults to more. A group’s count is the rows loaded under it, so a boundary a reader can see is worth more here than one that passes under them while they read.

A page whose first rows carry the value the last header carries grows that group rather than opening a second one, and a group shut before the page arrived is still shut after it. A page that fails leaves its rows and puts one error line under them with a retry.

collapsed is a mode with exceptions rather than a list of keys, so Collapse all covers the groups the next page brings as well as the ones already loaded. A bare key list still works and reads as the open mode with those keys shut.

import { collapseAll, expandAll, isCollapsed, toggleCollapsed } from 'sg-widgets-core';
collapseAll(); // { all: true, except: [] }
toggleCollapsed(collapseAll(), 'group:2'); // every group shut but that one
isCollapsed(collapseAll(), 'a group nothing has loaded yet'); // true

One of groupBy and groupKey is required; a list given neither throws. groupKey groups on a value derived from the row instead of read from a column: the record a note is about, which is a multi-entity field no site sorts on, or a value that comes from one field on one type and another on another. The list then leaves the source’s sort as the caller set it, so the caller orders the rows so each run comes out whole. Two rows share a run when the JSON text of their keys is the same, so a key answers a stable shape: the same keys in the same order, or a string. groupLabel draws the header’s text for a derived key, since there is no column to render the value by; with groupBy it is not read.

<GroupedList
source={source}
groupKey={(row) => recordOf(row)}
groupLabel={(record) => displayNameOf(record)}
/>
Terminal window
pnpm dlx shadcn@latest add https://sg-widgets.vercel.app/r/react/grouped-list.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://sg-widgets.vercel.app/r/svelte/grouped-list.json
Grouped List: Tasks by pipeline step, then Versions under the record each is of, from a derived key

React

Loading the React demo…

Svelte

Loading the Svelte demo…

const context = createSgContext({ client });
const source = createEntitySource({
client: context.client,
entityType: 'Task',
fields: ['content', 'sg_status_list', 'step.Step.code', 'sg_description', 'due_date'],
mode: 'pages',
pageSize: 25,
});
const [group, sub, secondary] = await resolveColumns(context.schema, 'Task', [
'step.Step.code',
'sg_description',
'due_date',
]);

groupBy takes a resolved column. subLabelField and secondaryField take one too, or a bare path, which the list resolves against the source’s type through the context: either way the value draws by its data type, so a status reads the name the site gives its code rather than the code. Without a context a bare path draws as the text it is stored as.

proptypedefaultmeaning
groupByCollectionColumn—Column the rows are grouped on. The source is sorted on it. Not read with groupKey. One of the two is required.
groupKey(row) => unknown—The value a row groups under, derived from the row and compared by its JSON text. The source's sort is left as the caller set it.
groupLabel(value) => string—The header's text for a derived key; not read with groupBy. Without it the key reads as its own display name.
thumbnailstring | falsefalseField holding the thumbnail URL.
labelFieldstring | nullnullField shown as the row's label. Defaults to the type's display name.
subLabelFieldstring | CollectionColumn | nullnullThe muted line under the label, drawn by its data type: a status reads its display name.
subLabel(row) => string—The caller's own sub-label. Wins over subLabelField.
secondaryFieldstring | CollectionColumn | nullnullThe right-aligned value at the end of the row, drawn by its data type: a status reads its display name.
secondary(row) => string—The caller's own right-aligned text. Wins over secondaryField.
showCodebooleanfalseShow the row's code beside the label when the two differ.
detailsCollectionColumn[][]Extra values drawn under the label. The source must already read their paths.
statusesRecord<string, StatusRecord> | nullnullStatus rows by code.
contextSgContext—The widget context. Values render with its preferences, and an entity value links to the row's page when it carries a site.
density'compact' | 'default''default'Compact halves the vertical row padding and draws a row chip and badge a step smaller.
selectablebooleanfalseDraws a checkbox on each row.
collapsedstring[] | CollapseStateexpandAll()Which groups are shut, two-way. A bare key list is the open mode with those keys shut.
pageSizesnumber[][25, 50, 100]Rows per page offered in the footer. pages only.
maxHeightstring'28rem'Height of the scrolling body.
virtualizeAfternumber100Rows and headers above which the list draws only the lines on screen.
emptyLabelstring'No rows'Shown when the read returned nothing.
errorLabelstring—Shown in place of what the failed read said.
size'sm' | 'md' | 'lg''md'Row text and thumbnail. A compact row takes the step below.
class / classNamestring—Merged after the widget's own classes.
sourceEntitySourcerequiredThe rows and the order behind them.
paging'pages' | 'more' | 'scroll''more'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 that cannot be selected or reached by Tab.
loadingLabelfrom CollectionControlstring'Loading…'Names the skeletons a read stands behind, for a screen reader.
eventpayloadwhen
onSelectEntityRowA row was activated, when selectable is off.
onCollapsedChangeCollapseStateA group was opened or shut.
onSortChangeSortSpec[]The source's sort changed.
onFiltersChangeFilterNode | WireGroup | nullThe source's filter changed.
onSelectionChangeEntityRef[]The selection changed.
slotreceivesdraws
leadingEntityRowFixed-size slot at the start of the row, when thumbnail is not the one wanted: an avatar, a status glyph.
row{ row, id, index, selected, disabled }Draws a row's contents. The list keeps the row's box.
groupHeader{ value, column, count, collapsed, id }Draws a group header's contents, after its chevron. column is null for a derived key.
header—Region above the list.
keydoes
TabMoves through group headers, checkboxes, rows and the footer.
Enter, Space on a headerCollapses or expands the group.
Enter, Space on a rowSelects it, or emits the select event.
ArrowDown, ArrowUp on a rowMoves the cursor one row.
ArrowDown on the last loaded rowIn 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 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 is skipped by Tab: its checkbox and its own control take no keys and no click.

Grouping a paged read is only honest over an order the server produced, so the widget sets the group path as the source’s first sort key and walks the contiguous runs. A group’s count is therefore the rows loaded so far. A derived key has no path to sort on, so the sort stays the caller’s and a value that returns after an interruption opens a second group under the same name.

Sorts fail silently where filters fail loudly: a field that cannot be sorted returns 200 with the rows in default order, so a group path is verified against the schema first (026_result_order).

No total is in a read, 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 forever (006_pagination). The “of N” in the range comes from one _summarize call counting id (020_summarize).