Selection
Single or multiple row selection with a checkbox column, keyboard ranges, clipboard copy and CSV export. Selection addresses the rows the user can see, so it indexes the filtered set rather than the raw data.
Basic Usage
Click the checkboxes, Shift-click for a range, or focus a row and press Space. The header checkbox reports all, some or none, so a partial selection reads as a dash rather than a guess.
The whole checkbox cell is the click target, not just the 18 pixels of the checkbox, since this is the one column a user aims at casually. The checkbox keeps the role and the name, so what a screen reader hears is unchanged.
<!-- The shorthand: defaults are multiple selection with a checkbox column
pinned to the left edge -->
<DataGrid data={people} {columns} {getRowId} selection toolbar pageSize={8} />
<script lang="ts">
import { createDataGrid, selection } from '@sv5ui/datagrid';
// The same thing, with the feature registered by hand
const grid = createDataGrid<Person>({
data: people,
columns,
getRowId,
features: [selection({ mode: 'multiple', checkbox: true })]
});
</script>Single Selection
mode: 'single' keeps at most one row, so selecting another releases the first.
// One row at a time. Selecting another replaces the first.
selection({ mode: 'single' })
// No checkbox column: rows are still selectable through the keyboard,
// clicks and the API, but nothing is drawn for it.
selection({ checkbox: false })Rows That Cannot Be Selected
Suspended members below refuse selection: their checkbox is disabled, select-all skips them, and a Shift range passes over them rather than stopping at them.
// Rows that refuse selection are skipped by select-all and by range
// selection, and their checkbox renders disabled.
selection({
isRowSelectable: (person) => person.status !== 'suspended'
})Clipboard
Copy writes tab-separated values, which is what a spreadsheet expects from a paste. The synthetic checkbox column is never part of the output.
Tick a row or two, press the button, then paste into the box to see exactly what was written. Ctrl or Cmd + C on a focused cell does the same thing without the button.
// Tab-separated, which is what a spreadsheet expects from a paste
await getSelection(grid)?.copySelection();
await getSelection(grid)?.copySelection({ headers: true });
// The same text without touching the clipboard, for a custom flow
const tsv = getSelection(grid)?.copyText({ headers: true });
// Ctrl+C on a focused grid does the same thing, and emits rowsCopied
grid.events.on('rowsCopied', ({ count }) => toast(`Copied ${count} rows`));CSV Export
The toolbar export menu offers the selection or every row the filter left. The same thing is available as a call, with more control over the file than a menu can offer.
Each button writes a real file. Narrow the grid with the quick filter first to see what allRows means: it is every row the filter left, not every row in the data.
getSelection(grid)?.exportCsv({
filename: 'members.csv',
headers: true,
// Every filtered row rather than the selection. Also what happens
// automatically when nothing is selected.
allRows: false,
// Excel follows the machine's list separator, so much of Europe needs ';'
delimiter: ',',
// Ids in this order. Hidden columns are fair game, because they are
// resolved against every column rather than the visible ones.
columns: ['name', 'email', 'team', 'salary', 'country'],
// Without it a value is written raw, so a spreadsheet keeps its type.
// With it, the file reads the way the grid does.
formatValue: ({ value, column }) =>
column.id === 'salary' ? `$${value}` : String(value ?? '')
});Every row the filter left is every row the grid is holding, which under rowModel: 'server' is one page. The toolbar says so, naming the item Loaded rows there rather than promising the rest, and onExportAll is how it becomes the whole set again: the server row model page covers it. A selection is unaffected by the row model, since the ids are held across pages.
What a File Holds
Values are written raw by default, because a spreadsheet wants a number it can sum rather
than $127,691.00. Raw is not the same as untouched, though: a date has to be readable as a date at the other
end.
A date column writes the calendar day the cell drew, and a datetime column the local date and time, rather than the UTC instant toISOString would write, which is the previous day wherever the clock is ahead of Greenwich. A column holding
an epoch number comes out as a date rather than as a number. The same goes for a copy to the clipboard.
formatted is the other direction: it writes what the grid is showing, formatting and all, for a file
meant to be read rather than summed. formatValue wins over both, being the caller saying something specific.
A feature can stand in front of the value before any of that happens. A cellValue gate is asked for the 'export' and 'clipboard' purposes as well as for the cell, so a column masked on screen leaves masked in the file and
on the clipboard rather than only looking masked.
// Raw by default: a spreadsheet gets a number it can sum. This is the
// "Export three columns" button above, and its first line.
selection.exportCsv({
filename: 'members-short.csv',
allRows: true,
columns: ['name', 'salary', 'joinedAt']
});
// Name,Salary,Joined <- the column headers, not the ids
// Hoang Kowalski,127691,2024-03-06
// A date column writes the day the cell drew, and a datetime column the
// local date and time (2024-03-06T09:30:00). toISOString would write the
// UTC instant, which is the previous day wherever the clock is ahead of
// Greenwich, and an epoch column would leave as a number.
// Formatted: what the grid is showing, for a file meant to be read.
selection.exportCsv({
filename: 'members-short.csv',
allRows: true,
columns: ['name', 'salary', 'joinedAt'],
formatted: true
});
// Name,Salary,Joined
// Hoang Kowalski,"$127,691.00","Mar 6, 2024"
// The clipboard takes the same option.
selection.copySelection({ headers: true, formatted: true });
// formatValue wins over both, per cell.
selection.exportCsv({
formatValue: ({ value, column }) =>
column.id === 'salary' ? `${Number(value) / 1000}k` : String(value ?? '')
});Export Building Blocks
The pieces the export path is made of are exported too, for a download flow of your own or a format the grid does not produce.
rowsToMatrix, pickColumns, withHeaderRow, toCsv and toTsv. The formula guard is inside toCsv rather than beside it, so a path built from these is as safe as the built-in one.
import { pickColumns, rowsToMatrix, toCsv, toTsv, withHeaderRow } from '@sv5ui/datagrid';
// The export path in pieces, for a download flow of your own or a format
// the grid does not produce.
const matrix = rowsToMatrix(nodes, pickColumns(grid, ['name', 'salary']));
const csv = toCsv(withHeaderRow(matrix, ['Name', 'Salary']), ';');
const tsv = toTsv(matrix);
// A cell starting with = + - or @ is a formula to a spreadsheet, and a
// hostile one is a real attack. toCsv neutralizes them itself, so an export
// path built from these pieces is guarded the same way the built-in one is.
// exportCsv and copySelection take formatted: write what the grid shows
// rather than the value behind it. Off by default, because a spreadsheet
// wants a number it can sum and a date it can sort.
selection.exportCsv({ filename: 'people.csv', formatted: true });
selection.copySelection({ headers: true, formatted: true });Driving the Selection
getSelection(grid) holds the state; the same methods are merged into grid.api under slightly longer names.
The badges below read count and allState directly, so they update with every click without a subscription of their own, and every button
is a call rather than a gesture.
import { getSelection } from '@sv5ui/datagrid';
const selection = getSelection(grid);
selection?.selectedIds; // ReadonlySet<string>, reactive
selection?.count; // how many rows are selected
selection?.allState; // 'none' | 'some' | 'all'
selection?.isSelected('42');
selection?.getSelectedRows(); // TRow[], in pipeline order
selection?.select('42');
selection?.deselect('42');
selection?.toggle('42');
selection?.selectRangeTo('58'); // from the anchor row
selection?.selectAll();
selection?.toggleAll();
selection?.clear();
// The same through grid.api
grid.api.selectRow?.('42');
grid.api.clearSelection?.();
grid.api.getSelectedRows?.();
grid.events.on('selectionChanged', ({ selectedIds }) => console.log(selectedIds));selection() Options
| Option | Default |
|---|---|
mode | 'multiple' |
checkbox | true |
isRowSelectable | () => true |
Selection State
| Member | Description |
|---|---|
selectedIds | The selected row ids |
count | How many rows are selected |
allState | What the header checkbox shows, including the indeterminate middle |
isSelected(id) | Membership test |
getSelectedRows() | The selected rows themselves, in pipeline order |
select / deselect / toggle | One row |
selectRangeTo(id) | Extends the selection from the anchor row, as Shift-click does |
selectAll / toggleAll / clear | selectAll adds the selectable rows in view, toggleAll takes them back out, clear empties the selection |
copySelection(opts) | Writes the selection to the clipboard as TSV |
copyText(opts) | The same text, without touching the clipboard |
exportCsv(opts) | Builds a CSV and downloads it |
ExportCsvOptions
| Option | Default |
|---|---|
filename | 'export.csv' |
headers | true |
allRows | false |
delimiter | ',' |
columns | every visible column |
formatValue | - |
formatted | false |
Keyboard and Mouse
| Input | Action |
|---|---|
Space | Toggles the focused row |
Shift + Space | Extends the selection from the anchor row |
Ctrl or Cmd + A | Selects every selectable row |
Ctrl or Cmd + C | Copies the selection as TSV |
Space on the header checkbox | Toggles select-all. The checkbox itself is out of the tab order, so the cell carries it |
Shift + Click | Selects the range between the anchor row and this one |
Ctrl or Cmd + Click | Toggles one row without clearing the rest |