import { For, Show, createEffect, createSignal, on } from 'solid-js'
import { faker } from '@faker-js/faker'
import {
FlexRender,
cellSelectionFeature,
columnOrderingFeature,
columnPinningFeature,
columnVisibilityFeature,
createColumnHelper,
createSortedRowModel,
createTable,
rowSortingFeature,
sortFn_alphanumeric,
sortFn_datetime,
sortFn_text,
tableFeatures,
} from '@tanstack/solid-table'
import { useTanStackTableDevtools } from '@tanstack/solid-table-devtools'
import { createHotkeys } from '@tanstack/solid-hotkeys'
import { makeData } from './makeData'
import type { Cell } from '@tanstack/solid-table'
import type { Person } from './makeData'
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
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,
}),
])
function App() {
const [data, setData] = createSignal(makeData(20))
const refreshData = () => setData(makeData(20))
const stressTest = () => setData(makeData(1_000))
const table = createTable({
key: 'cell-selection', // needed for devtools
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,
})
useTanStackTableDevtools(table)
const randomizeColumns = () => {
table.setColumnOrder(
faker.helpers.shuffle(table.getAllLeafColumns().map((d) => d.id)),
)
}
// 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. `defer: true` skips the first run so an
// initialState selection survives mount.
createEffect(
on(
() => [
table.atoms.columnOrder.get(),
table.atoms.columnPinning.get(),
table.atoms.columnVisibility.get(),
table.atoms.sorting.get(),
],
() => table.resetCellSelection(true),
{ defer: true },
),
)
let gridRef!: HTMLDivElement
// keyboard navigation is TanStack Hotkeys driving the table's imperative
// APIs; table-core ships no keydown handling of its own
createHotkeys(
[
{ 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: () => {
void navigator.clipboard.writeText(
toTsv(table.getSelectedCellRangesData()),
)
},
},
],
() => ({ target: gridRef }),
)
return (
<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" />
<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>
<For each={table.getAllLeafColumns()}>
{(column) => (
<div class="column-toggle-row">
<label>
<input
type="checkbox"
checked={column.getIsVisible()}
onChange={column.getToggleVisibilityHandler()}
/>{' '}
{column.id}
</label>
</div>
)}
</For>
</div>
<div class="spacer-sm" />
<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>
{table.getSelectedCellCount().toLocaleString()} cells selected across{' '}
{table.getCellSelectionRowIds().length.toLocaleString()} rows and{' '}
{table.getCellSelectionColumnIds().length} columns
</div>
<div class="spacer-sm" />
<div ref={gridRef} tabindex={0}>
<table>
<thead>
<For each={table.getHeaderGroups()}>
{(headerGroup) => (
<tr>
<For each={headerGroup.headers}>
{(header) => (
<th colSpan={header.colSpan}>
<Show when={!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={header} />
{{ asc: ' 🔼', desc: ' 🔽' }[
header.column.getIsSorted() as string
] ?? null}
</button>
<Show when={header.column.getCanPin()}>
<div class="pin-actions">
<Show
when={header.column.getIsPinned() !== 'start'}
>
<button
class="pin-button"
onClick={() => header.column.pin('start')}
>
{'<='}
</button>
</Show>
<Show when={header.column.getIsPinned()}>
<button
class="pin-button"
onClick={() => header.column.pin(false)}
>
X
</button>
</Show>
<Show
when={header.column.getIsPinned() !== 'end'}
>
<button
class="pin-button"
onClick={() => header.column.pin('end')}
>
{'=>'}
</button>
</Show>
</div>
</Show>
</Show>
</th>
)}
</For>
</tr>
)}
</For>
</thead>
<tbody>
<For each={table.getRowModel().rows}>
{(row) => (
<tr>
<For each={row.getVisibleCells()}>
{(cell) => (
<Show
when={cell.getCanSelect()}
fallback={
<td>
<FlexRender cell={cell} />
</td>
}
>
<td
class={getCellClassName(cell)}
tabindex={cell.getTabIndex()}
onMouseDown={cell.getSelectionStartHandler()}
onMouseEnter={cell.getSelectionExtendHandler()}
>
<FlexRender cell={cell} />
</td>
</Show>
)}
</For>
</tr>
)}
</For>
</tbody>
<tfoot>
<tr>
<td colSpan={20}>
Rows ({table.getRowModel().rows.length.toLocaleString()})
</td>
</tr>
</tfoot>
</table>
</div>
<div class="spacer-sm" />
<div>
<button
class="demo-button demo-button-spaced"
onClick={() =>
void navigator.clipboard.writeText(
toTsv(table.getSelectedCellRangesData()),
)
}
>
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>State:</label>
<pre>
{data().length < 1_001
? JSON.stringify(table.store.get(), 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..."
/>
</div>
</div>
)
}
export default App