Bulk-action bar

Tick some rows and a bar floats over the grid holding the count, the actions that apply to the whole selection, and a way to clear it - the pattern an issue tracker uses for bulk edit.

Open the live example: Bulk-action bar (Selection & Clipboard)

Every example below runs against this setup. The bar renderer ships in @svgrid/enterprise, so enableSelectionBar() is what lights it up - the selectionBar prop and its types are part of the free grid. Without the renderer the grid shows a note in the bar's place saying it is an Enterprise feature and pointing at licensing.

Like the rest of Enterprise this comes with the 30-day evaluation period: with the package installed the bar works whether or not a license key is set, and without one the grid displays the watermark and the console message about the Enterprise package. Set a key with setLicenseKey() before shipping.

<script lang="ts">
  import { SvGrid, type GridColumns, type SelectionBarAction } from '@svgrid/grid'
  import { enableSelectionBar } from '@svgrid/enterprise'

  enableSelectionBar()

  type Task = {
    id: number
    key: string
    title: string
    assignee: string
    status: 'Open' | 'In progress' | 'Done'
  }

  const tasks: Task[] = [
    { id: 1, key: 'PLAT-412', title: 'Virtualize the audit log',  assignee: 'A. Osei',      status: 'Open' },
    { id: 2, key: 'PLAT-413', title: 'Retry webhook delivery',    assignee: 'R. Vance',     status: 'In progress' },
    { id: 3, key: 'PLAT-414', title: 'Cache the tenant lookup',   assignee: 'M. Iqbal',     status: 'Done' },
    { id: 4, key: 'PLAT-415', title: 'Fix timezone drift',        assignee: 'J. Lindqvist', status: 'Open' },
    { id: 5, key: 'PLAT-416', title: 'Batch the settings writes', assignee: 'D. Okonkwo',   status: 'Done' },
  ]

  const columns: GridColumns<Task> = [
    { field: 'key',      header: 'Key',      width: 110 },
    { field: 'title',    header: 'Summary',  width: 240 },
    { field: 'assignee', header: 'Assignee', width: 150 },
    { field: 'status',   header: 'Status',   width: 130 },
  ]
</script>

The smallest version

selectionBar on its own gives the count and a clear button. Nothing else is required - showRowSelection puts the checkboxes there, and the bar reacts to whatever ends up selected:

<SvGrid data={tasks} {columns} getRowId={(t) => String(t.id)} showRowSelection selectionBar />

Adding actions

An array is the shorthand for { actions }. Each action gets the whole selection as { rows, ids }, in display order - so it reads the same order the user is looking at, even after a sort:

<script lang="ts">
  let log = $state<string[]>([])

  const actions: SelectionBarAction<Task>[] = [
    {
      key: 'done',
      label: 'Mark done',
      icon: 'M20 6 9 17l-5-5',
      action: ({ ids }) => (log = [`Marked ${ids.length} done`, ...log]),
    },
    {
      key: 'delete',
      label: 'Delete',
      icon: 'M3 6h18M8 6V4h8v2m-1 0v14H9V6',
      danger: true,
      action: ({ ids }) => (log = [`Deleted ${ids.join(', ')}`, ...log]),
    },
  ]
</script>

<SvGrid data={tasks} {columns} getRowId={(t) => String(t.id)} showRowSelection selectionBar={actions} />

<ul>
  {#each log as line, i (i)}<li>{line}</li>{/each}
</ul>

icon is the d of a single 24x24 SVG path, so an action array stays plain data you can lift into its own module next to the columns.

Built-in actions

Strings in the array are built-ins, so the common buttons need no wiring:

Key Does
'selectAll' Selects every row in the view, then disables itself
'editFields' Opens the bulk-edit drawer - see below
'deleteRows' Deletes the selected rows after an inline "Delete N rows?" confirm on the bar, then fires onRowsDelete with the rows, their ids and the data left. Not offered over a server row model
'separator' Draws a divider. Not counted against maxVisible

Mix them with your own actions in one array:

<script lang="ts">
  const bar: Array<SelectionBarAction<Task> | 'selectAll' | 'editFields' | 'deleteRows' | 'separator'> = [
    'selectAll',
    'editFields',
    'deleteRows',
    'separator',
    { key: 'watch', label: 'Watch', action: () => {} },
  ]
</script>

<SvGrid data={tasks} {columns} getRowId={(t) => String(t.id)} showRowSelection selectionBar={bar} />

Bulk edit

'editFields' opens a drawer - the same SvDrawer + SvForm the grid uses for single-row editing - holding one control per editable column. Change as many fields as you like and they apply to every selected row, as a single undo run.

The rule that makes that safe: only fields you actually change are written.

The field Opens showing Leave it alone and
Every selected row agrees That shared value Nothing is written
They disagree Blank, labelled "Multiple values" Each row keeps its own value

So editing Status across twelve issues does not flatten their summaries to whichever one happened to be first.

The field list is the grid's own editable, field-backed columns. A column with editable: false is not offered, because writing it in bulk would be a footgun, and editorOptions carry over - a status column with a fixed set becomes a picker in the drawer rather than a free-text box.

Over field types the controls follow the field: a select or a user field becomes a picker (several values for a multiple select), a rating its stars, long text a text area, a date the date picker; a number is typed as the field writes it ($1,200, 42 for 42%) and read by the field's own parser. Attachments, links and computed fields are not offered.

What it does per cell:

The engine is exported if you want the same write from your own UI:

import { applyBulkEdit, bulkEditableFields, bulkEditInitialValues } from '@svgrid/enterprise'

const fields = bulkEditableFields(ctrl)                    // what can be targeted
const { values, mixed } = bulkEditInitialValues(ctrl, fields) // what to show
const { changed, skipped, fields: n } = applyBulkEdit(ctrl, { status: 'Done', points: 8 })

On a server-side row model

Under <SvGrid rowModel={ctl}> with the Enterprise row model's selection turned on, the bar reads the model instead of the loaded rows: the count chip says what the rule selects - a million under select-all, even with twenty rows on screen - and Edit fields sends the patch and the rule to the datasource's updateWhere as one request, rather than writing the loaded cells. See Server selection.

Where it floats

position pins the bar to the 'bottom' (default) or 'top' edge of the grid.

The bar floats - it never resizes the grid, so a layout holding one does not jump when a checkbox is ticked. To stop it hiding anything, the scroll area gains bottom padding the height of the bar while a selection is live: the last row can always be scrolled clear, and the bar overlays that padding rather than a row.

<SvGrid
  data={tasks}
  {columns}
  getRowId={(t) => String(t.id)}
  showRowSelection
  selectionBar={{ position: 'top', actions: [{ key: 'x', label: 'Export', action: () => {} }] }}
/>

Buttons that come and go

hidden and disabled are re-checked on every render against the live selection, so buttons appear and grey out as the set changes rather than freezing at first paint. Select one row and Merge is not there; select two and it appears:

<script lang="ts">
  const conditional: SelectionBarAction<Task>[] = [
    {
      key: 'merge',
      label: 'Merge',
      // Merging one task into itself is meaningless.
      hidden: ({ ids }) => ids.length < 2,
      action: () => {},
    },
    {
      key: 'close',
      label: 'Close',
      // Nothing to do when everything picked is already closed.
      disabled: ({ rows }) => rows.every((t) => t.status === 'Done'),
      action: () => {},
    },
  ]
</script>

<SvGrid data={tasks} {columns} getRowId={(t) => String(t.id)} showRowSelection selectionBar={conditional} />

More actions than fit

Past maxVisible (6 by default) the tail folds into a ยทยทยท menu, which opens away from the edge the bar is pinned to - upward from a bottom bar, downward from a top one - so it never falls off the grid. A bar wide enough to scroll is a bar whose last button nobody finds:

<script lang="ts">
  const many: SelectionBarAction<Task>[] = [
    'Mark done', 'Assign', 'Watch', 'Export', 'Move', 'Archive',
  ].map((label) => ({ key: label, label, action: () => {} }))
</script>

<SvGrid
  data={tasks}
  {columns}
  getRowId={(t) => String(t.id)}
  showRowSelection
  selectionBar={{ actions: many, maxVisible: 3 }}
/>

Keyboard and screen readers

The bar is a role="toolbar", which is a promise about how it behaves, so it keeps that promise:

Key Does
Tab One stop for the whole bar, not one per button
Left / Right Move between buttons, wrapping at each end
Esc Closes the overflow menu if it is open, otherwise clears the selection

These are handled on the bar, so they apply while focus is inside it. Esc pressed in the grid body still does whatever the grid does with it - the bar does not install a global key handler that would fight cell editing or an open menu.

The count carries aria-live="polite" so it is read as the selection changes. The bar itself is not live - that would re-read every button on each tick.

Options

Option Default Does
actions [] The buttons, left to right
position 'bottom' Which edge the bar floats against
maxVisible 6 How many stay on the bar before the rest collapse. Separators do not count
hideClear false Drop the trailing clear button

selectionBar={true} is shorthand for all defaults with no actions, and selectionBar={[...]} for { actions: [...] }.

Demo 23-bulk-actions builds the same idea by hand - a toolbar of your own markup driven by onRowSelectionChange. Reach for selectionBar when you want it working out of the box; hand-roll it when you want full control of the layout.

Learn SvGrid 5: selection and bulk actions (1:26, silent). Watch with narration on YouTube
Transcript

Selecting rows is easy. Knowing which rows are selected after a sort, a filter or a refresh is the part that catches people out, and it comes down to one prop.

A small backlog of tasks and the columns to show them.

Three things on the component. showRowSelection adds the checkbox column. selectionBar floats the action bar. And getRowId is the one that matters: it tells the grid what identifies a row, so a selection survives sorting, filtering and new data arriving. Without it the grid falls back to position, and position changes.

Tick a couple of rows and the bar appears with a live count and the actions you defined. It stays out of the way until there is something to act on.

Now sort the table while those rows are ticked. The same three stay selected, because the grid is tracking ids rather than row numbers.

Over server data the same idea scales: select-all becomes a rule, every row except these, rather than a list of ids the browser would have to hold. Lesson six groups the rows and adds totals.

The file is on the page below. See you in lesson six.

See also

Related articles