Row actions and selection
Row action menus and quick bars, row selection, bulk actions, and selecting every result that matches the current query.
Row actions
RowActions renders a row's actions either way:
{
key: 'actions', header: '', sticky: 'right', width: '7rem', exportable: false,
render: (item, i) => (
<RowActions
item={item}
index={i}
variant='inline' // icon buttons in the cell; 'menu' (default) collapses them behind •••
maxInline={3} // past this the overflow folds into a trailing •••
actions={[
{ label: 'Ver', icon: <Eye size={16} />, onClick: open },
{ label: 'Descargar', icon: <FileDown size={16} />, onClick: download },
{ label: 'Cancelar', icon: <X size={16} />, danger: true, onClick: cancel,
disabled: item => item.canceled && 'Ya está cancelada' },
]}
/>
),
}'inline' is one click instead of two, for the actions an operator uses on
every row. Each button is icon-only with its label as the accessible name and
the tooltip, so it costs a fixed width no matter how long the label is — an
action without an icon falls back to rendering the label, which widens the
column and usually means it belongs in the menu. disabled returns the reason,
which becomes the tooltip.
loading covers the action that takes a server round trip — preparing a
download, sending a mail. It swaps the icon for a spinner and blocks a second
click, which is what separates "nothing happened" from "working on it":
{ label: 'Descargar', icon: <FileDown size={16} />, onClick: download,
loading: item => downloading.has(item.id) }Quick actions. Mark an action quick (it needs an icon) and, on
hover-capable devices, hovering the ••• slides it out to the left as an icon
button on the row's own line — one click for the action an operator reaches for
on every row. It is a shortcut, not the only path: the action still appears in
the ••• menu, so on touch — where hover does not exist — nothing is lost, it
just lives one tap deeper.
The bar is forgiving by design: it stays interactive through a short grace
period after the pointer leaves, so aiming across the gap between the •••
and a button never loses the target; it hides the moment one of its actions
runs, so an action that opens a dialog does not leave the bar floating; and
revealing one row's bar dismisses the previous row's instantly, so sweeping
down the column leaves no trail.
By default the bar reveals when the pointer reaches the ••• cluster. Set
rowActionsQuickReveal: 'row' in the config (or quickReveal='row' on a
hand-rolled RowActions) to reveal it from anywhere on the row — one less
aiming step:
defineListConfig({ rowActions, rowActionsQuickReveal: 'row' /* … */ })Both the row ••• menu and the toolbar overflow follow the WAI-ARIA menu
keyboard pattern: ArrowUp/ArrowDown move through the items (wrapping), Home/End
jump to the extremes, Escape closes.
Grouped menu. Give actions a group and the ••• menu clusters them under
that title, separated by dividers — the Stripe-style sectioned menu. Ungrouped
actions render first, untitled; groups follow in first-appearance order. The
title is user-facing text, so pass it already localized:
rowActions: [
{
label: 'Download PDF',
icon: <FileDown size={16} />,
quick: true,
group: 'Actions',
onClick: download,
},
{
label: 'Edit invoice',
icon: <Pencil size={16} />,
quick: true,
group: 'Actions',
onClick: edit,
},
{ label: 'Copy invoice ID', group: 'Actions', onClick: copyId },
{ label: 'View customer', group: 'Connections', onClick: viewCustomer },
]The same group field exists on toolbar actions: on small screens, where they
fold into the toolbar's ••• overflow, the menu renders the same titled
sections.
Pagination variants. 'sticky' floats in the content flow and needs no
viewport offset — which is why it is now the default. 'fixed' pins to the
viewport and, in an app with a fixed sidebar, needs paginationOffsetLeft so
the bar clears it:
<ListView
config={config}
paginationVariant='fixed'
paginationOffsetLeft='var(--app-sidebar-w)'
/>'inline' renders the bar statically at the end of its container. Inside a
flex-column card it settles at the bottom even when the list is short or
empty — the case that previously required position: static !important.
Pinned columns. Give a column sticky: 'left' | 'right' plus a width and
it stays visible while the table scrolls sideways. Use 'right' for an actions
column so a row can be acted on without scrolling to the end:
{ key: 'actions', header: '', sticky: 'right', width: '64px', exportable: false, render: rowActions }Several columns may pin to the same edge; their offsets stack in column order.
A pinned column without a width is left unpinned rather than placed at a
wrong offset.
Row selection & bulk actions
Enable checkboxes and a selection bar with selection. Selection is key-based, survives pagination, and clears when the dataset changes (search/filters/sort/refresh) so a stale selection can't leak:
import { Star, Trash2 } from 'lucide-react'
defineListConfig<Product>({
getItemKey: p => p.id, // required for stable selection
selection: {
actions: [
{
label: 'Feature',
icon: <Star size={16} />,
onClick: rows => featureMany(rows),
},
{
label: 'Delete',
icon: <Trash2 size={16} />,
variant: 'danger',
// Each action gets the selected rows + helpers: { selectedKeys, clear }.
onClick: async (rows, { selectedKeys, clear }) => {
await deleteMany(selectedKeys) // ids, from getItemKey
clear() // drop the selection after a successful bulk action
},
},
],
onSelectionChange: rows => setSelected(rows),
},
})What the selection gives you. Selection is keyed by getItemKey, so each entry has an id (the key) and the full row object:
- A bulk action's
onClick(selected, { selectedKeys, clear })receivesselected(theT[]rows — even ones from other pages) andselectedKeys(their ids fromgetItemKey). Use the ids for aDELETE … WHERE id IN (…)andclear()to reset afterwards. onSelectionChange(selected, details)fires whenever the set changes:selectedis the sameT[], anddetailscarriesmode,keys,excludedKeysand the resolvedcount— the all-matching mode a bare array cannot express.- For full control, call the exported
useRowSelectionhook directly (selectedKeys,selectedItems,isSelected,toggle,toggleMany,clear).
Other notes:
- The table gains a separated checkbox column with a header select-all-this-page (indeterminate when only some are selected).
- Rows are tracked by key, so selecting across pages keeps the full row objects for your bulk handler — no React Query required.
clearOnDataChange: falsekeeps the selection across filter/sort changes (the default clears it).- When
exportis enabled, the selection bar also shows Export selected (disable withshowExport: false). - In cards view,
ctx.selection(isSelected/toggle) lets a custom card render its own checkbox.
Locking or gating the checkboxes. disabled: true keeps the column but refuses every toggle, so it reads as a read-only indicator — use it when picking rows is meaningless in the current mode, since a column that disappears and comes back moves the table under the reader, and hiding it loses the state it was showing. The bulk bar, "export selected" and the selection shortcuts go with it. selectableRow(item, key) gates one row at a time; the page-header checkbox then covers only the selectable rows.
selection: {
disabled: mode === 'review', // read-only indicator
selectableRow: row => row.status !== 'locked',
}Both gates hold on every write path — the row checkbox, the page header, the card checkbox, the keyboard shortcuts and the published controller. The one thing selectableRow cannot reach is select all N matching: that selection is virtual and resolved server-side from the query, so rejected rows are still included. Pair the two only when the gate is advisory, or set allowSelectAllMatching: false.
Driving the selection from your own UI. controllerRef publishes the live selection API — mode, selectedKeys, excludedKeys, selectedItems, selectedCount, query, pageEntries, plus toggle / setSelected / toggleMany / selectAllMatching / clear — so a button outside the list can read and drive the checked set. Pass a plain ref object; listkit nulls it on unmount.
const controllerRef = useRef<SelectionController<Invoice> | null>(null)
selection: {
controllerRef
}
// anywhere else:
controllerRef.current?.toggleMany(controllerRef.current.pageEntries, true)preselectLoadedRows: true inverts the default: every newly loaded row arrives checked, so the scope the user filtered to is the selection and unchecking is the exception. A key the user unchecks stays unchecked — only never-seen keys auto-select — and the seen set resets with the dataset. Pair it with an adapter whose key folds in every external scope, so a scope change never preselects the previous scope's rows into the new one.
Selecting every matching result
Selecting the whole page offers to escalate to all N matching results — the Gmail/Stripe pattern. It is a virtual selection: listkit does not load the other pages, it records the current search/filters plus whatever you untick afterwards.
selection: {
allowSelectAllMatching: false, // opt out; default true
}What a bulk action receives depends on the mode, and the difference matters:
helpers.mode | selected / selectedKeys | Resolve against |
|---|---|---|
'explicit' | every selected row | the keys |
'all-matching' | only the rows this client loaded | helpers.query minus helpers.excludedKeys |
An 'all-matching' action must run against the query, not the row array — the rows on other pages were never fetched, so keying off selectedKeys would touch the current page and silently spare every other one:
onClick: async (rows, { selectedKeys, mode, query, excludedKeys, clear }) => {
if (mode === 'all-matching') await archiveByQuery(query, excludedKeys)
else await archiveMany(selectedKeys)
clear()
}The server side resolves that query with the same builders the list uses — buildMongoFilter / buildSqlFilter — so the rows an action touches are exactly the rows the user saw. Export works the same way: the dialog's all scope hands the resolver the query and the exclusions.
Posting a selection to the server. For a bulk mutation, send a SelectionDescriptor rather than a flat id list — the mutation counterpart of the export request. toSelectionDescriptor builds one from any selection snapshot (the details argument, a controller, or a bulk action's helpers), selectionDescriptorToBody serializes it for a POST, parseSelectionDescriptor (from /query) validates it server-side, and resolveSelectionFilter (from /mongoose) turns it into the filter the write runs on:
// client
const body = selectionDescriptorToBody(
toSelectionDescriptor(controllerRef.current!)
)
await fetch('/api/invoices/archive', {
method: 'POST',
body: JSON.stringify(body),
})
// server
const descriptor = parseSelectionDescriptor(req.body)
if (!descriptor) return res.status(400).end()
const filter = await resolveSelectionFilter({
descriptor,
fields: maps.main,
baseFilter: { tenant: req.tenant }, // auth scope — always
})
if (filter) await Invoice.updateMany(filter, { $set: { archived: true } })Both halves fail closed. parseSelectionDescriptor returns null for anything structurally wrong — including a body with no nested query, which under an 'all' scope would otherwise resolve to every row the base filter allows — and resolveSelectionFilter returns null for an empty selection, so the caller no-ops instead of handing updateMany a match-everything filter.
Exporting that selection needs a way to reach the rows. With in-memory data or an export resolve, the dialog's Selected scope covers all 12,000; without one, it is disabled with an explanation rather than hidden, and the one-click "Export selected" is withheld — a file holding the loaded page while the bar reads "12,000 selected" is worse than no file.