Active Foundation

Search documentation

Search all pages, headings and text, in the current package or topic or in all documentation.

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

The package's own guide

This guide is the package's docs/guides/selection.md, 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/active-grid works out of the box with the getSelectionColumn 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 SelectionModel API consumers use to pass the selection to their actions.

Two modes#

ModeWhen activeState shape
InclusiveDefault. The user ticks rows.{ rowIds: ["id-1", "id-3"] } — the IDs of the selected rows
ExclusiveThe user accepts the "Select all N rows" offer{ excludedRowIds: ["id-7"], totalCount: 5000, query } — 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 — SelectionModel#

TypeScript
export type SelectionModel =  | {mode: 'inclusive'; rowIds: string[]}  | {      mode: 'exclusive';      excludedRowIds: string[];      totalCount: number;      query: SelectionQuery;    };

Consumers receive it through onSelectionModelChange:

TSX
<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#

Text
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 excludedRowIds. 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 selectionActions draws its own bulk bar above the body:

Text
[ 10 selected ] [ Actions v ]                    [ Select all 32 rows ]
TSX
<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 barWhere it lives instead
a "Clear selection" itemthe header checkbox, which already does it
an "Apply" buttona menu item IS the instruction
a ReactNode slotGridSelectionAction, declarative
a disabled item for a missing rightabsence — leave it out of the array

Without selectionActions 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 SelectionQuery:

typescript
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 SelectionQuery into a validated mutation scope today: the consumer must translate the SelectionQuery itself, against its own closed schema. That resolver is open work, tracked as R-33 in ../ROADMAP.md. Until a consumer has written that translation, the honest declaration is supportsDatasetWideSelection: false (below).

Three rules for the server side, and they are not negotiable:

  1. Never trust totalCount. 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.

  2. 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 where never crosses the wire.

  3. Apply the exclusions server-side (id: {notIn: excludedRowIds}) inside that same query, never by filtering a client-side list.

The shape of such an action, where translateSelectionScope stands for the consumer's own translation (it is not a function this package or any shared package provides):

TypeScript
'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 supportsDatasetWideSelection: false 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 where at all.

Imperative selection (advanced)#

Keep the latest model from onSelectionModelChange in host state or a ref when a separate action needs to read it. GridImperativeHandle exposes resetSelection(), 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-checked is false (none), mixed (part of the page) or true (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 ../ROADMAP.md). During an exclusive selection the header checkbox is checked, and a click clears the whole selection. With enableMultiRowSelection={false} there is no header checkbox.

  • Selection action bar (with selectionActions, 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.

  • Banner row (without selectionActions, the original behaviour): the offer "Select all 5000 rows", or "All 5000 rows are selected." with a clear link.

  • Body checkbox per row: visually the same in both modes, but the click differs — in inclusive mode it adds to rowIds, in exclusive mode it moves the row to excludedRowIds.

Both bars are styled by the package stylesheet (styles/chrome.css) from the grid's role tokens: --grid-separator for the bottom edge of the action bar, --grid-primary 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 onSelectionModelChange. The component has no controlled selection props named rowSelectionState or onRowSelectionStateChange; do not confuse the public kernel command grid.setRowSelection() with component props.

Cross-refs#

  • api/grid-props.md — selectionActions, GridSelectionAction, SelectionQuery, onSelectionModelChange, enableMultiRowSelection

  • guides/server-mode.md — server-mode basics

  • ADR-026 in DECISIONS.md — the exclusive model, its SelectionQuery, and why an action declares which selection shape it can execute

This page as Markdown