Table Workflows
Clipboard & Export
A range is two cell positions in a prop; the text formats are pure functions in two entry points. Copy produces tab-separated text a spreadsheet understands; paste returns validated edits; CSV export quotes and protects formulas.
- Fixture
- Benchmark
On this page
Range selection is controlled: cellRange is the highlighted rectangle and onCellRangeChange reports a click, a Shift+click or a Shift+arrow extension. With
the callback set, cells become focusable in the grid pattern. The renderer only highlights;
reading the cells is copyRange() from @svelte-lean/table/clipboard (895 B gzip), which quotes values
containing tabs, line breaks or quotes.
pasteRange() goes the other way: it parses tab-separated text, walks the target
rectangle, skips columns that are not editable, parses and validates each value with the column
rules, and returns EditEvents without touching the rows. An invalid cell throws
with the column name, so nothing is half-applied. CSV export lives in @svelte-lean/table/export (741 B gzip) and reuses the column format functions, so the file shows what the user saw.
Range selection and clipboard
Select a rectangular range and translate it to or from tab-delimited spreadsheet data.
Amara Okafor | Engineering | Europe | $4,501 |
Jonas Lindqvist | Design | Americas | $12,420 |
Mei Tanaka | Operations | Asia | $20,339 |
Rafael Duarte | Growth | Europe | $28,258 |
Selin Aydın | Engineering | Americas | $36,177 |
Noah Brenner | Design | Asia | $44,096 |
<script lang="ts">
import { DataTable, type CellRange } from '@svelte-lean/table';
import { applyEdit } from '@svelte-lean/table/editing';
import { copyRange, pasteRange } from '@svelte-lean/table/clipboard';
import { memberColumns, pick } from '$lib/demo/columns';
import { people } from '$lib/demo/data';
let rows = $state(people(6));
const columns = pick(memberColumns(), ['name', 'team', 'region', 'revenue']).map((column) => ({
...column,
editable: column.id !== 'name'
}));
let range = $state<CellRange | null>({ start: { row: 1, column: 'team' }, end: { row: 3, column: 'revenue' } });
let tsv = $state('');
let notice = $state('');
async function copy() {
if (!range) return;
tsv = copyRange(range, rows, columns, (row) => row.id);
try {
await navigator.clipboard.writeText(tsv);
notice = 'Copied as tab-separated text.';
} catch {
notice = 'Clipboard unavailable; the text is shown below.';
}
}
// pasteRange returns validated EditEvents; the application applies them.
function paste() {
if (!range) return;
const start = {
row: rows.findIndex((row) => row.id === range!.start.row),
column: columns.findIndex((column) => column.id === range!.start.column)
};
const changes = pasteRange(tsv, start, rows, columns, (row) => row.id);
rows = changes.reduce((next, change) => applyEdit(next, change, columns, (row) => row.id), rows);
notice = `${changes.length} cells updated.`;
}
</script>
<button type="button" data-slean="button" data-variant="outline" onclick={copy}>Copy range</button>
<button type="button" data-slean="button" data-variant="outline" onclick={paste} disabled={!tsv}>Paste at range start</button>
<button type="button" data-slean="button" data-variant="outline" onclick={() => (range = null)}>Clear range</button>
<!-- Click a cell, then Shift+click (or Shift+arrow) another cell to extend the rectangle. -->
<DataTable data={rows} {columns} getRowId={(row) => row.id} cellRange={range} onCellRangeChange={(next) => (range = next)} pagination={false} label="Members: range and clipboard" />
<textarea aria-label="Tab-separated text" bind:value={tsv}></textarea>
<p role="status">{notice}</p>- Positions are row ids and column ids, so a range survives sorting and re-rendering.
- The clipboard write needs a secure context and permission; the textarea shows the text either way.
- Revenue is a number editor: pasting text into it throws and nothing is applied.
Keyboard
| Key | When | Result |
|---|---|---|
| Tab | outside the body | Enters the body on the last cell reached, or the first cell. |
| ArrowLeft/ArrowRight/ArrowUp/ArrowDown | on a cell | Moves to the neighbouring cell. |
| Shift+Arrow | on a cell | Moves and extends the range from its start to the new cell. |
CSV export
Export visible data with correct quoting and spreadsheet-formula protection.
=HYPERLINK("https://example.com") | Engineering | Europe | $4,501 |
Jonas Lindqvist | Design | Americas | $12,420 |
Mei Tanaka | Operations | Asia | $20,339 |
Rafael Duarte | Growth | Europe | $28,258 |
Selin Aydın | Engineering | Americas | $36,177 |
Member,Team,Region,Revenue
"'=HYPERLINK(""https://example.com"")",Engineering,Europe,"$4,501"
Jonas Lindqvist,Design,Americas,"$12,420"
Mei Tanaka,Operations,Asia,"$20,339"
Rafael Duarte,Growth,Europe,"$28,258"
Selin Aydın,Engineering,Americas,"$36,177" <script lang="ts">
import { DataTable } from '@svelte-lean/table';
import { downloadText, toCsv } from '@svelte-lean/table/export';
import { memberColumns, pick } from '$lib/demo/columns';
import { people, type Person } from '$lib/demo/data';
// One value starts with "=": the export prefixes it so a spreadsheet does not run it as a formula.
const data: Person[] = people(5).map((row, index) =>
index === 0 ? { ...row, name: '=HYPERLINK("https://example.com")' } : row
);
const columns = pick(memberColumns(), ['name', 'team', 'region', 'revenue']);
const csv = toCsv(data, columns); // format() applies, header row included, BOM for spreadsheets
</script>
<button type="button" data-slean="button" data-variant="outline" onclick={() => downloadText(csv, 'members.csv')}>
Download CSV
</button>
<DataTable {data} {columns} getRowId={(row) => row.id} pagination={false} label="Members: CSV export" />
<pre>{csv}</pre>toCsv(rows, columns, {
delimiter: ';', // ',' (default), ';' or '\t'
headers: true, // the header row from column.header
protectFormulas: true, // prefixes values starting with = + - @ tab or CR with an apostrophe
bom: true // a UTF-8 byte-order mark so spreadsheets read the encoding
});parseDelimited()reads the format back, including quoted line breaks and doubled quotes.- Export what the user sees: pass the visible columns and the filtered rows from the row model.
API
| Name | Of | Type | Default | Description |
|---|---|---|---|---|
cellRange | Events and composition | CellRange | null | undefined | The highlighted rectangular range; with onCellRangeChange cells become focusable. |
onCellRangeChange | Events and composition | (range: CellRange | null) => void | undefined | Reports a click, Shift+click or Shift+arrow range. |
CellRange | Events and positions | { start: CellPosition; end: CellPosition } | – | An inclusive rectangle anchored by two positions. |
CellPosition | Events and positions | { row: RowId; column: string } | – | A cell by row id and column id. |
editable | Editing | boolean | ((row: T) => boolean) | false | Cell editing for the column or per row; needs onEdit on the table. |
format | Data and content | (value: unknown, row: T) => string | null | String(value) | Formats the plain text of a cell and of a CSV export without a snippet; null or an empty string renders the placeholder. |
@svelte-lean/table/clipboard | Entry points | copyRange · pasteRange · rangeBounds | – | A rectangular range to tab-separated text and back to validated EditEvents. |
@svelte-lean/table/export | Entry points | toCsv · parseDelimited · downloadText | – | CSV with quoting and formula protection, a parser, and a browser download. |
@svelte-lean/table/editing | Entry points | applyEdit · setValue · parseEdit · createHistory · editEvent | – | Immutable edit application and a caller-owned undo history. |