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 oneisCollapsed(collapseAll(), 'a group nothing has loaded yet'); // trueOne 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)}/>Install
Section titled “Install”pnpm dlx shadcn@latest add https://sg-widgets.vercel.app/r/react/grouped-list.jsonpnpm dlx shadcn-svelte@latest add https://sg-widgets.vercel.app/r/svelte/grouped-list.jsonReact
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.
| prop | type | default | meaning |
|---|---|---|---|
groupBy | CollectionColumn | — | 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. |
thumbnail | string | false | false | Field holding the thumbnail URL. |
labelField | string | null | null | Field shown as the row's label. Defaults to the type's display name. |
subLabelField | string | CollectionColumn | null | null | The 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. |
secondaryField | string | CollectionColumn | null | null | The 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. |
showCode | boolean | false | Show the row's code beside the label when the two differ. |
details | CollectionColumn[] | [] | Extra values drawn under the label. The source must already read their paths. |
statuses | Record<string, StatusRecord> | null | null | Status rows by code. |
context | SgContext | — | 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. |
selectable | boolean | false | Draws a checkbox on each row. |
collapsed | string[] | CollapseState | expandAll() | Which groups are shut, two-way. A bare key list is the open mode with those keys shut. |
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 and headers above which the list draws only the lines on screen. |
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 thumbnail. A compact row takes the step below. |
class / className | string | — | Merged after the widget's own classes. |
source | EntitySource | required | The 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. |
sort | SortSpec[] | — | The source's sort, two-way, so a SortPicker drops into header. |
filters | FilterNode | WireGroup | null | — | The source's filter, two-way, so a FilterBar drops into header. |
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 reached by Tab. |
loadingLabelfrom CollectionControl | string | 'Loading…' | Names the skeletons a read stands behind, for a screen reader. |
Events
Section titled “Events”| event | payload | when |
|---|---|---|
onSelect | EntityRow | A row was activated, when selectable is off. |
onCollapsedChange | CollapseState | A group was opened or shut. |
onSortChange | SortSpec[] | The source's sort changed. |
onFiltersChange | FilterNode | WireGroup | null | The source's filter changed. |
onSelectionChange | EntityRef[] | The selection changed. |
| slot | receives | draws |
|---|---|---|
leading | EntityRow | Fixed-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. |
footer | — | Region below the footer. |
Keyboard
Section titled “Keyboard”| key | does |
|---|---|
| Tab | Moves through group headers, checkboxes, rows and the footer. |
| Enter, Space on a header | Collapses or expands the group. |
| Enter, Space on a row | Selects it, or emits the select event. |
| ArrowDown, ArrowUp on a row | Moves the cursor one row. |
| 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. |
A disabled row is skipped by Tab: its checkbox and its own control take no keys and no click.
API behaviour
Section titled “API behaviour”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).