Selection
Row selection, select-all in server mode without loading every row, and the selection model.
@workspace/active-grid 11.0.0Status unknowncontent differs from the recorded release 11.0.0baseline ce081dc3
This guide is the package's docs/, included unchanged
from the package as it is in Foundation. Links to the package's internal
design documents keep their text.
Row selection in @workspace/ works out of the box with the
get helper. For server-mode grids with large datasets the
grid adds a second selection mechanism: "select all 5000 rows" without
materialising all of them in client state. This guide documents both modes and
the Selection API consumers use to pass the selection to their actions.
Two modes#
| Mode | When active | State shape |
|---|---|---|
| Inclusive | Default. The user ticks rows. | { row — the IDs of the selected rows |
| Exclusive | The user accepts the "Select all N rows" offer | { excluded — every row EXCEPT these IDs, plus the query that gives "all" meaning |
Inclusive is always available. Exclusive exists only in server mode with multi-row selection, and only once the user has taken the dataset-wide offer. The banner makes that offer when the whole page is selected and the dataset holds more rows; the selection action bar makes it when rows of the page are selected, the dataset holds more, and at least one action supports it.
API — Selection Model#
export type SelectionModel = | {mode: 'inclusive'; rowIds: string[]} | { mode: 'exclusive'; excludedRowIds: string[]; totalCount: number; query: SelectionQuery; };Consumers receive it through on:
<ActiveGrid mode="server" fetchFn={fetchFn} columns={columns} onSelectionModelChange={(model) => { if (model.mode === 'inclusive') { console.log('Selected IDs:', model.rowIds); } else { console.log('Excluded IDs:', model.excludedRowIds); console.log('Total in dataset:', model.totalCount); } }}/>Server-mode flow#
User ticks the header checkbox: the current page is selected ↓The grid offers "Select all 5000 rows" (banner or selection action bar) ↓User takes the offer → the grid switches to mode: "exclusive" ↓The header checkbox shows checked; the banner reads "All 5000 rows areselected." and the action bar "All 5000 selected" ↓User deselects a row → that ID goes to excludedRowIds ↓onSelectionModelChange fires on every change of the model ↓The consumer action uses the model (see "Executing an exclusive selection")The exclusive selection survives page navigation: when the user moves to page
2 and deselects a row there, that ID goes to excluded. The total count
stays that of the whole dataset, not of the visible page. Deselecting every row
of the dataset returns the grid to an empty inclusive selection.
The selection action bar#
A grid that declares selection draws its own bulk bar above the body:
[ 10 selected ] [ Actions v ] [ Select all 32 rows ]<ActiveGrid mode="server" enableRowSelection selectionActions={[ { id: 'refresh', label: t('records.bulk.refresh'), icon: RefreshCw, supportsDatasetWideSelection: false, onAction: handleRefresh, }, { id: 'delete', label: t('records.actions.bulkDelete'), icon: Trash2, tone: 'danger', separatorBefore: true, supportsDatasetWideSelection: false, onAction: () => setShowConfirm(true), }, ]}/>The shape never varies with the number of actions. One action lives in the same menu as five. A bar that shows a bare button at one action and a menu at two teaches the reader nothing: the control moves, its label changes, and the same task is reached two different ways on two screens.
What the grid owns: the bar, the count, the fixed Actions menu, keyboard behaviour, the default and destructive presentations, the busy state (an async handler holds the whole menu until it settles, so one action cannot be started twice and two cannot overlap), and suppressing the footer's selection counter so the same number is never stated twice.
What the consumer owns: which actions exist, their labels, their icons, what they do, and confirmation copy. The grid holds no business meaning.
What is deliberately absent:
| Not in the bar | Where it lives instead |
|---|---|
| a "Clear selection" item | the header checkbox, which already does it |
| an "Apply" button | a menu item IS the instruction |
a React slot | Grid, declarative |
| a disabled item for a missing right | absence — leave it out of the array |
Without selection the grid keeps the original select-all banner, so
existing consumers are unchanged.
Executing an exclusive selection#
An exclusive model says "everything except these ids", and everything is only
meaningful together with the query that produced it. That query travels with
the model as Selection:
if (selection.mode === 'exclusive') { selection.query.filters; // the grid's own column filters selection.query.search; // the grid's own search term selection.query.externalKey; // your `serverQueryKey`, verbatim}There is no mutation resolver for this yet. The grid carries the query and
never interprets it, and no shared server-side resolver turns a
Selection into a validated mutation scope today: the consumer must
translate the Selection itself, against its own closed schema. That
resolver is open work, tracked as R-33 in ../.
Until a consumer has written that translation, the honest declaration is
supports (below).
Three rules for the server side, and they are not negotiable:
Never trust
total. It is a display number that arrived from the client. The count of what was actually done comes from the query that did the work.Count Re-derive and re-authorize the query server-side. The client sends the scope it was looking at; the server resolves the caller's visibility again and validates the scope against a closed schema. A raw
wherenever crosses the wire.Apply the exclusions server-side (
id: {not) inside that same query, never by filtering a client-side list.In: excluded Row Ids}
The shape of such an action, where translate stands for the
consumer's own translation (it is not a function this package or any shared
package provides):
'use server';
export async function bulkArchiveByScopeAction( scope: unknown, excludedIds: string[], csrfToken: string,) { const auth = await performAuthCheckWithCsrf(PERMISSIONS.X.ARCHIVE, csrfToken); if (!auth.success) return auth;
// Consumer-written: re-validates the scope against a closed schema and // resolves the caller's own visibility again, on the server. const where = await translateSelectionScope( auth.data, ScopeSchema.parse(scope), );
const result = await db.invoice.updateMany({ where: {AND: [where, {id: {notIn: excludedIds}}]}, data: {archived: true}, }); return {success: true, data: {archived: result.count}};}When the action cannot do this, say so in the declaration. Set
supports and the grid stops offering dataset-wide
select-all altogether (when no action supports it) and hides the action from
the menu while such a selection is active. That is the whole point: the UI
never communicates a selection the action will not honour.
Reasons an action legitimately cannot:
its server side takes an explicit id list;
the work is per-row and unbounded (an object-store delete, an image re-render) with no background carrier to run it on;
the mutation cannot express the query as a
whereat all.
Imperative selection (advanced)#
Keep the latest model from on in host state or a ref when
a separate action needs to read it. Grid exposes
reset, not a selection-model getter.
UI affordances#
The grid shows:
Header checkbox: it selects and deselects the rows of the CURRENT PAGE, and is therefore also the way to clear a selection — which is why the action bar carries no clear button of its own. For assistive technology it has three states:
aria-ischecked false(none),mixed(part of the page) ortrue(the whole page). Visually it shows two: a partly selected page looks the same as a fully selected one, and a click on it clears the page. The partial mark and the click behaviour are open (TD-45 in../). During an exclusive selection the header checkbox is checked, and a click clears the whole selection. WithROADMAP. md enablethere is no header checkbox.Multi Row Selection={false} Selection action bar (with
selection, above the body): the count, the fixed Actions menu, and on the right the dataset-wide offer when an action can execute it. See above.Actions Banner row (without
selection, the original behaviour): the offer "Select all 5000 rows", or "All 5000 rows are selected." with a clear link.Actions Body checkbox per row: visually the same in both modes, but the click differs — in inclusive mode it adds to
row, in exclusive mode it moves the row toIds excluded.Row Ids
Both bars are styled by the package stylesheet (styles/) from the
grid's role tokens: --grid- for the bottom edge of the action bar,
--grid- at 10% for the banner row. A host changes them through those
tokens, not through its own classes.
Persistence across reloads#
Selection state is not persisted in localStorage or the URL. When the user reloads the page the selection is gone. The reason: a selection of 5000 rows on a filtered dataset depends on its context, and persisting it would risk stale state after server-side mutations.
For selections that must persist (for example a wishlist or a multi-step
wizard): keep the chosen IDs in the host through on. The
component has no controlled selection props named row or
on; do not confuse the public kernel command
grid. with component props.
Cross-refs#
api/—grid- props. md selection,Actions Grid,Selection Action Selection,Query on,Selection Model Change enableMulti Row Selection guides/— server-mode basicsserver- mode. md ADR-026 in
DECISIONS.— the exclusive model, itsmd Selection, and why an action declares which selection shape it can executeQuery