<script lang="ts">
import { faker } from '@faker-js/faker'
import { untrack } from 'svelte'
import { createHotkeysAttachment } from '@tanstack/svelte-hotkeys'
import {
FlexRender,
cellSelectionFeature,
columnOrderingFeature,
columnPinningFeature,
columnVisibilityFeature,
createColumnHelper,
createSortedRowModel,
createTable,
rowSortingFeature,
sortFn_alphanumeric,
sortFn_datetime,
sortFn_text,
tableFeatures,
} from '@tanstack/svelte-table'
import { makeData } from './makeData'
import type { Cell } from '@tanstack/svelte-table'
import type { Person } from './makeData'
import './index.css'
const features = tableFeatures({
cellSelectionFeature,
columnOrderingFeature,
columnPinningFeature,
columnVisibilityFeature,
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
sortFns: {
alphanumeric: sortFn_alphanumeric,
datetime: sortFn_datetime,
text: sortFn_text,
},
})
const columnHelper = createColumnHelper<typeof features, Person>()
function escapeTsvValue(value: unknown) {
const text = value == null ? '' : String(value)
const safeText =
typeof value === 'string' && /^[\t\r ]*[=+@-]/.test(value)
? `'${text}`
: text
// spreadsheets expect a field to be quoted once it contains a delimiter, a
// newline, or a quote, with inner quotes doubled
return /["\t\n\r]/.test(safeText)
? `"${safeText.replace(/"/g, '""')}"`
: safeText
}
function toTsv(ranges: Array<Array<Array<unknown>>>) {
return ranges
.map((grid) =>
grid.map((row) => row.map(escapeTsvValue).join('\t')).join('\n'),
)
.join('\n\n') // blank line between disjoint rectangles
}
function getCellClassName(cell: Cell<typeof features, Person>) {
// Most cells in a large grid are unselected, so bail before asking for
// edges. getSelectionEdges() would otherwise resolve the cell's position a
// second time just to discover it is not selected.
if (!cell.getIsSelected()) {
return cell.getIsFocused()
? 'cell-selectable cell-focused'
: 'cell-selectable'
}
const edges = cell.getSelectionEdges()
return [
'cell-selectable',
'cell-selected',
cell.getIsFocused() && 'cell-focused',
edges.top && 'cell-edge-top',
edges.right && 'cell-edge-right',
edges.bottom && 'cell-edge-bottom',
edges.left && 'cell-edge-left',
]
.filter(Boolean)
.join(' ')
}
const columns = columnHelper.columns([
columnHelper.accessor('firstName', {
header: 'First Name',
cell: (info) => info.getValue(),
footer: (props) => props.column.id,
}),
columnHelper.accessor((row) => row.lastName, {
id: 'lastName',
header: () => 'Last Name',
cell: (info) => info.getValue(),
footer: (props) => props.column.id,
}),
columnHelper.accessor('age', {
header: () => 'Age',
footer: (props) => props.column.id,
}),
columnHelper.accessor('visits', {
header: () => 'Visits',
footer: (props) => props.column.id,
}),
columnHelper.accessor('status', {
header: 'Status',
footer: (props) => props.column.id,
}),
columnHelper.accessor('progress', {
header: 'Profile Progress',
// enableCellSelection: false, // this column opts out of cell selection
footer: (props) => props.column.id,
}),
columnHelper.accessor('email', {
header: 'Email',
footer: (props) => props.column.id,
}),
columnHelper.accessor('phone', {
header: 'Phone',
footer: (props) => props.column.id,
}),
columnHelper.accessor('city', {
header: 'City',
footer: (props) => props.column.id,
}),
columnHelper.accessor('country', {
header: 'Country',
footer: (props) => props.column.id,
}),
columnHelper.accessor('department', {
header: 'Department',
footer: (props) => props.column.id,
}),
columnHelper.accessor('salary', {
header: 'Salary',
cell: (info) => info.getValue().toLocaleString(),
footer: (props) => props.column.id,
}),
])
let data = $state(makeData(20))
const refreshData = () => (data = makeData(20))
const stressTest = () => (data = makeData(1_000))
const table = createTable(
{
key: 'cell-selection',
features,
get data() {
return data
},
columns,
getRowId: (row: Person) => row.id,
enableCellSelection: true, // enable cell selection for all cells
// initialState: { cellSelection: [] }, // select cells on first render
// state: { cellSelection }, // classic controlled state; pair with onCellSelectionChange
// onCellSelectionChange: setCellSelection,
// enableCellRangeSelection: false, // disable Shift-click and drag ranges; default true
// enableMultiCellRangeSelection: false, // allow only one rectangle at a time; default true
// enableCellSelectionDrag: false, // disable drag-to-select; default true
// isCellRangeSelectionEvent: event => Boolean(event.metaKey), // use Meta instead of Shift
debugTable: true,
},
(state) => ({
sorting: state.sorting,
columnVisibility: state.columnVisibility,
columnOrder: state.columnOrder,
columnPinning: state.columnPinning,
}),
)
const randomizeColumns = () =>
table.setColumnOrder(
faker.helpers.shuffle(table.getAllLeafColumns().map((d) => d.id)),
)
const copySelection = () =>
void navigator.clipboard.writeText(toTsv(table.getSelectedCellRangesData()))
// optionally, reset the cellSelection state not only when data changes, but
// also when column order, pinning, visibility, or sorting changes
// customize this to your needs
let isFirstColumnLayout = true
$effect(() => {
// read the atoms directly so this tracks only the layout slices. Reading
// table.state here would re-fire on every selection change, because the
// selector rebuilds that object each time any selected slice changes.
void table.atoms.columnOrder.get()
void table.atoms.columnPinning.get()
void table.atoms.columnVisibility.get()
void table.atoms.sorting.get()
if (isFirstColumnLayout) {
isFirstColumnLayout = false
return
}
untrack(() => table.resetCellSelection(true))
})
// keyboard navigation is TanStack Hotkeys driving the table's imperative
// APIs; table-core ships no keydown handling of its own
const gridKeys = createHotkeysAttachment([
{ hotkey: 'ArrowUp', callback: () => table.moveCellSelection('up') },
{ hotkey: 'ArrowDown', callback: () => table.moveCellSelection('down') },
{ hotkey: 'ArrowLeft', callback: () => table.moveCellSelection('left') },
{ hotkey: 'ArrowRight', callback: () => table.moveCellSelection('right') },
{
hotkey: 'Shift+ArrowUp',
callback: () => table.extendCellSelection('up'),
},
{
hotkey: 'Shift+ArrowDown',
callback: () => table.extendCellSelection('down'),
},
{
hotkey: 'Shift+ArrowLeft',
callback: () => table.extendCellSelection('left'),
},
{
hotkey: 'Shift+ArrowRight',
callback: () => table.extendCellSelection('right'),
},
{ hotkey: 'Mod+A', callback: () => table.selectAllCells() },
{ hotkey: 'Escape', callback: () => table.resetCellSelection(true) },
{ hotkey: 'Mod+C', callback: () => copySelection() },
])
</script>
<div class="demo-root">
<div>
<button class="demo-button demo-button-spaced" onclick={() => refreshData()}
>Regenerate Data</button
>
<button class="demo-button demo-button-spaced" onclick={() => stressTest()}
>Stress Test (1K rows)</button
>
</div>
<div class="spacer-sm"></div>
<p>
Click and drag to select a range of cells. Hold Shift while clicking to
extend the selection, or Ctrl/Cmd to add a second rectangle. Arrow keys move
the selection, Shift+Arrow extends it, Mod+A selects all, Mod+C copies, and
Escape clears. Uncomment `enableCellSelection: false` on a column def to opt
that column out of selection.
</p>
<p class="demo-note">
Hiding, reordering, and pinning columns all keep a live selection anchored
to the same cell ids. Ranges are indexed in render order, so a pinned column
moves the rectangle with it rather than splitting it.
</p>
<div class="column-toggle-panel">
<div class="column-toggle-panel-header">
<label>
<input
type="checkbox"
checked={table.getIsAllColumnsVisible()}
onchange={table.getToggleAllColumnsVisibilityHandler()}
/>
Toggle All
</label>
</div>
{#each table.getAllLeafColumns() as column (column.id)}
<div class="column-toggle-row">
<label>
<input
type="checkbox"
checked={column.getIsVisible()}
onchange={column.getToggleVisibilityHandler()}
/>
{column.id}
</label>
</div>
{/each}
</div>
<div class="spacer-sm"></div>
<div class="button-row">
<button
class="demo-button demo-button-spaced"
onclick={() => randomizeColumns()}>Shuffle Columns</button
>
<button
class="demo-button demo-button-spaced"
onclick={() =>
table.setColumnOrder(
[...table.getAllLeafColumns().map((column) => column.id)].reverse(),
)}>Reverse Column Order</button
>
<button
class="demo-button demo-button-spaced"
onclick={() => table.resetColumnOrder()}>Reset Column Order</button
>
<button
class="demo-button demo-button-spaced"
onclick={() => table.resetColumnPinning()}>Reset Pinning</button
>
<button
class="demo-button demo-button-spaced"
onclick={() => table.resetColumnVisibility()}>Reset Visibility</button
>
</div>
<div class="spacer-sm"></div>
<!--
Svelte's runes track this read, and the compiler updates only the affected
text nodes. There is no equivalent of React's per-row Subscribe to reach for.
-->
<div>
{table.getSelectedCellCount().toLocaleString()} cells selected across {table
.getCellSelectionRowIds()
.length.toLocaleString()} rows and {table.getCellSelectionColumnIds()
.length} columns
</div>
<div class="spacer-sm"></div>
<div>
<table
role="grid"
aria-label="Cell selection grid"
tabindex="0"
{@attach gridKeys}
>
<thead>
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
<tr>
{#each headerGroup.headers as header (header.id)}
<th colspan={header.colSpan}>
{#if !header.isPlaceholder}
<button
type="button"
disabled={!header.column.getCanSort()}
class={header.column.getCanSort()
? 'header-button sortable-header'
: 'header-button'}
onclick={header.column.getToggleSortingHandler()}
>
<FlexRender {header} />
{{ asc: ' 🔼', desc: ' 🔽' }[
header.column.getIsSorted() as string
] ?? ''}
</button>
{#if header.column.getCanPin()}
<div class="pin-actions">
{#if header.column.getIsPinned() !== 'start'}
<button
class="pin-button"
onclick={() => header.column.pin('start')}
>{'<='}</button
>
{/if}
{#if header.column.getIsPinned()}
<button
class="pin-button"
onclick={() => header.column.pin(false)}>X</button
>
{/if}
{#if header.column.getIsPinned() !== 'end'}
<button
class="pin-button"
onclick={() => header.column.pin('end')}
>{'=>'}</button
>
{/if}
</div>
{/if}
{/if}
</th>
{/each}
</tr>
{/each}
</thead>
<tbody>
{#each table.getRowModel().rows as row (row.id)}
<tr>
{#each row.getVisibleCells() as cell (cell.id)}
{#if cell.getCanSelect()}
<td
class={getCellClassName(cell)}
tabindex={cell.getTabIndex()}
onmousedown={cell.getSelectionStartHandler()}
onmouseenter={cell.getSelectionExtendHandler()}
>
<FlexRender {cell} />
</td>
{:else}
<td>
<FlexRender {cell} />
</td>
{/if}
{/each}
</tr>
{/each}
</tbody>
<tfoot>
<tr>
<td colspan={20}>
Rows ({table.getRowModel().rows.length.toLocaleString()})
</td>
</tr>
</tfoot>
</table>
</div>
<div class="spacer-sm"></div>
<div>
<button
class="demo-button demo-button-spaced"
onclick={() => copySelection()}>Copy Selection</button
>
<button
class="demo-button demo-button-spaced"
onclick={() => table.selectAllCells()}>Select All Cells</button
>
<button
class="demo-button demo-button-spaced"
onclick={() => table.resetCellSelection(true)}>Clear Selection</button
>
</div>
<hr />
<br />
<div>
<button
class="demo-button demo-button-spaced"
onclick={() =>
console.info(
'table.getSelectedCellRangesData()',
table.getSelectedCellRangesData(),
)}>Log table.getSelectedCellRangesData()</button
>
</div>
<div>
<label for="state-dump">State:</label>
<pre id="state-dump">{data.length < 1_001
? JSON.stringify(table.state, null, 2)
: ''}</pre>
</div>
<div>
<label for="paste-target">Paste Test:</label>
<textarea
id="paste-target"
class="text-input"
rows={8}
placeholder="Copy a selection, then paste here to check the tab-separated output..."
></textarea>
</div>
</div>