sveltelean Table
Versionv0.2.0 GitHub

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.

Range and clipboard
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

RangeClipboard.svelte
<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>
Click a cell, then Shift+click (or hold Shift with the arrow keys) to extend the rectangle. Copy range writes tab-separated text to the clipboard and into the textarea; edit the text and Paste at range start turns it into validated edits applied with applyEdit(). Member is not editable and is skipped on paste.
  • 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

KeyWhenResult
Taboutside the bodyEnters the body on the last cell reached, or the first cell.
ArrowLeft/ArrowRight/ArrowUp/ArrowDownon a cellMoves to the neighbouring cell.
Shift+Arrowon a cellMoves and extends the range from its start to the new cell.

CSV export

Export visible data with correct quoting and spreadsheet-formula protection.

CSV export
=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"

CsvExport.svelte
<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>
The first row's name starts with an equals sign; in the output it is prefixed with an apostrophe so a spreadsheet shows it as text instead of evaluating it. Revenue is exported through its format function. Download CSV calls downloadText(), which creates an object URL, clicks a link and revokes the URL.
options
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

NameOfTypeDefaultDescription
cellRangeEvents and compositionCellRange | nullundefinedThe highlighted rectangular range; with onCellRangeChange cells become focusable.
onCellRangeChangeEvents and composition(range: CellRange | null) => voidundefinedReports a click, Shift+click or Shift+arrow range.
CellRangeEvents and positions{ start: CellPosition; end: CellPosition }–An inclusive rectangle anchored by two positions.
CellPositionEvents and positions{ row: RowId; column: string }–A cell by row id and column id.
editableEditingboolean | ((row: T) => boolean)falseCell editing for the column or per row; needs onEdit on the table.
formatData and content(value: unknown, row: T) => string | nullString(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/clipboardEntry pointscopyRange · pasteRange · rangeBounds–A rectangular range to tab-separated text and back to validated EditEvents.
@svelte-lean/table/exportEntry pointstoCsv · parseDelimited · downloadText–CSV with quoting and formula protection, a parser, and a browser download.
@svelte-lean/table/editingEntry pointsapplyEdit · setValue · parseEdit · createHistory · editEvent–Immutable edit application and a caller-owned undo history.