import { faker } from '@faker-js/faker'
import {
aggregationFns,
createExpandedRowModel,
createFacetedMinMaxValues,
createFacetedRowModel,
createFacetedUniqueValues,
createFilteredRowModel,
createGroupedRowModel,
createPaginatedRowModel,
createSortedRowModel,
createTableHook,
filterFns,
sortFns,
stockFeatures,
} from '@tanstack/solid-table'
import { useTanStackTableDevtools } from '@tanstack/solid-table-devtools'
import { compareItems, rankItem } from '@tanstack/match-sorter-utils'
import {
For,
createEffect,
createMemo,
createSignal,
onCleanup,
} from 'solid-js'
import { makeData } from './makeData'
import type { JSX } from 'solid-js'
import type { RankingInfo } from '@tanstack/match-sorter-utils'
import type { Person } from './makeData'
import type {
Cell,
CellData,
Column,
FilterFn,
Header,
Row,
RowData,
SortFn,
TableFeatures,
} from '@tanstack/solid-table'
declare module '@tanstack/solid-table' {
interface ColumnMeta<
TFeatures extends TableFeatures,
TData extends RowData,
TValue extends CellData = CellData,
> {
filterVariant?: 'text' | 'range' | 'select'
}
interface FilterFns {
fuzzy: FilterFn<typeof stockFeatures, Person>
}
interface FilterMeta {
itemRank?: RankingInfo
}
}
const { createAppTable, createAppColumnHelper } = createTableHook({
features: stockFeatures,
rowModels: {
expandedRowModel: createExpandedRowModel(),
filteredRowModel: createFilteredRowModel({
...filterFns,
fuzzy: (row, columnId, value, addMeta) => {
const itemRank = rankItem(row.getValue(columnId), value)
addMeta?.({ itemRank })
return itemRank.passed
},
}),
facetedRowModel: createFacetedRowModel(),
facetedMinMaxValues: createFacetedMinMaxValues(),
facetedUniqueValues: createFacetedUniqueValues(),
groupedRowModel: createGroupedRowModel(aggregationFns),
paginatedRowModel: createPaginatedRowModel(),
sortedRowModel: createSortedRowModel(sortFns),
},
})
const fuzzySort: SortFn<typeof stockFeatures, Person> = (
rowA,
rowB,
columnId,
) => {
let dir = 0
if (rowA.columnFiltersMeta[columnId]) {
dir = compareItems(
rowA.columnFiltersMeta[columnId].itemRank!,
rowB.columnFiltersMeta[columnId].itemRank!,
)
}
return dir === 0 ? sortFns.alphanumeric(rowA, rowB, columnId) : dir
}
const sortStatusFn: SortFn<typeof stockFeatures, Person> = (rowA, rowB) => {
const statusOrder = ['single', 'complicated', 'relationship']
return (
statusOrder.indexOf(rowA.original.status) -
statusOrder.indexOf(rowB.original.status)
)
}
const getCommonPinningStyles = (
column: Column<typeof stockFeatures, Person>,
): JSX.CSSProperties => {
const isPinned = column.getIsPinned()
const isLastLeftPinnedColumn =
isPinned === 'left' && column.getIsLastColumn('left')
const isFirstRightPinnedColumn =
isPinned === 'right' && column.getIsFirstColumn('right')
return {
'box-shadow': isLastLeftPinnedColumn
? '-4px 0 4px -4px gray inset'
: isFirstRightPinnedColumn
? '4px 0 4px -4px gray inset'
: undefined,
left: isPinned === 'left' ? `${column.getStart('left')}px` : undefined,
right: isPinned === 'right' ? `${column.getAfter('right')}px` : undefined,
opacity: isPinned ? 0.97 : 1,
position: isPinned ? 'sticky' : 'relative',
'z-index': isPinned ? 1 : 0,
}
}
function IndeterminateCheckbox(
props: {
indeterminate?: boolean
} & JSX.InputHTMLAttributes<HTMLInputElement>,
) {
let ref!: HTMLInputElement
createEffect(() => {
if (typeof props.indeterminate === 'boolean') {
ref.indeterminate = !props.checked && props.indeterminate
}
})
return <input type="checkbox" ref={ref} {...props} />
}
function DebouncedInput(
props: {
value: string | number
onChange: (value: string | number) => void
debounce?: number
} & Omit<JSX.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'>,
) {
const [value, setValue] = createSignal(props.value)
createEffect(() => {
setValue(props.value)
})
createEffect(() => {
const currentValue = value()
const timeout = setTimeout(
() => props.onChange(currentValue),
props.debounce ?? 300,
)
onCleanup(() => clearTimeout(timeout))
})
return (
<input
{...props}
value={value()}
onInput={(e) => setValue(e.currentTarget.value)}
/>
)
}
function Filter(props: { column: Column<typeof stockFeatures, Person> }) {
const filterVariant = () => props.column.columnDef.meta?.filterVariant
const sortedUniqueValues = createMemo(() =>
filterVariant() === 'range'
? []
: Array.from(props.column.getFacetedUniqueValues().keys())
.sort()
.slice(0, 5000),
)
return (
<>
{filterVariant() === 'range' ? (
<div class="filter-row">
<DebouncedInput
type="number"
min={Number(props.column.getFacetedMinMaxValues()?.[0] ?? '')}
max={Number(props.column.getFacetedMinMaxValues()?.[1] ?? '')}
value={
(
props.column.getFilterValue() as [number, number] | undefined
)?.[0] ?? ''
}
onChange={(value) =>
props.column.setFilterValue(
(old: [number, number] | undefined) => [value, old?.[1]],
)
}
placeholder={`Min${
props.column.getFacetedMinMaxValues()?.[0] !== undefined
? ` (${props.column.getFacetedMinMaxValues()?.[0]})`
: ''
}`}
class="filter-input"
/>
<DebouncedInput
type="number"
min={Number(props.column.getFacetedMinMaxValues()?.[0] ?? '')}
max={Number(props.column.getFacetedMinMaxValues()?.[1] ?? '')}
value={
(
props.column.getFilterValue() as [number, number] | undefined
)?.[1] ?? ''
}
onChange={(value) =>
props.column.setFilterValue(
(old: [number, number] | undefined) => [old?.[0], value],
)
}
placeholder={`Max${
props.column.getFacetedMinMaxValues()?.[1] !== undefined
? ` (${props.column.getFacetedMinMaxValues()?.[1]})`
: ''
}`}
class="filter-input"
/>
</div>
) : filterVariant() === 'select' ? (
<select
onChange={(e) => props.column.setFilterValue(e.currentTarget.value)}
value={(props.column.getFilterValue() ?? '').toString()}
class="filter-select"
>
<option value="">All</option>
<For each={sortedUniqueValues()}>
{(value) => <option value={String(value)}>{String(value)}</option>}
</For>
</select>
) : (
<>
<datalist id={props.column.id + 'list'}>
<For each={sortedUniqueValues()}>
{(value) => <option value={String(value)} />}
</For>
</datalist>
<DebouncedInput
type="text"
value={(props.column.getFilterValue() ?? '') as string}
onChange={(value) => props.column.setFilterValue(value)}
placeholder={`Search (${props.column.getFacetedUniqueValues().size})`}
class="filter-select"
list={props.column.id + 'list'}
/>
</>
)}
</>
)
}
type AppTable = ReturnType<typeof createAppTable<Person>>
function TableHeader(props: {
header: Header<typeof stockFeatures, Person, unknown>
table: AppTable
}) {
const column = () => props.header.column
const style = () => ({
...getCommonPinningStyles(column()),
'white-space': 'nowrap',
width: `calc(var(--header-${props.header.id}-size) * 1px)`,
})
return (
<th style={style()} colSpan={props.header.colSpan}>
{!props.header.isPlaceholder ? (
<>
<div class="header-row">
<div style={{ flex: 1, 'min-width': 0 }}>
<div class="header-controls">
{column().getCanPin() ? (
<span class="pin-actions">
{column().getIsPinned() !== 'left' ? (
<button
class="pin-button"
onClick={() => column().pin('left')}
>
{'<'}
</button>
) : null}
{column().getIsPinned() ? (
<button
class="pin-button"
onClick={() => column().pin(false)}
>
x
</button>
) : null}
{column().getIsPinned() !== 'right' ? (
<button
class="pin-button"
onClick={() => column().pin('right')}
>
{'>'}
</button>
) : null}
</span>
) : null}
{column().getCanGroup() ? (
<button
class="pin-button"
onClick={column().getToggleGroupingHandler()}
>
{column().getIsGrouped()
? `Stop (${column().getGroupedIndex()})`
: 'Group'}
</button>
) : null}
</div>
{column().getCanSort() ? (
<span
class="sortable-header"
onClick={column().getToggleSortingHandler()}
>
<props.table.FlexRender header={props.header} />
{{
asc: ' â–²',
desc: ' â–¼',
}[column().getIsSorted() as string] ?? null}
</span>
) : (
<props.table.FlexRender header={props.header} />
)}
{column().getCanFilter() ? (
<div>
<Filter column={column()} />
</div>
) : null}
</div>
</div>
{column().getCanResize() ? (
<div
onDblClick={() => column().resetSize()}
onMouseDown={props.header.getResizeHandler()}
onTouchStart={props.header.getResizeHandler()}
class={`resizer ${column().getIsResizing() ? 'isResizing' : ''}`}
/>
) : null}
</>
) : null}
</th>
)
}
function TableCell(props: {
cell: Cell<typeof stockFeatures, Person, unknown>
table: AppTable
}) {
const className = () => {
const groupingActive = props.table.atoms.grouping.get().length > 0
const hasAggregation = !!props.cell.column.columnDef.aggregationFn
return !groupingActive
? undefined
: props.cell.getIsGrouped()
? 'cell-grouped'
: hasAggregation && props.cell.getIsAggregated()
? 'cell-aggregated'
: props.cell.getIsPlaceholder()
? 'cell-placeholder'
: undefined
}
return (
<td
style={{
...getCommonPinningStyles(props.cell.column),
width: `calc(var(--col-${props.cell.column.id}-size) * 1px)`,
}}
class={className()}
>
{props.cell.getIsGrouped() ? (
<button
onClick={props.cell.row.getToggleExpandedHandler()}
style={{
cursor: props.cell.row.getCanExpand() ? 'pointer' : 'normal',
}}
>
{props.cell.row.getIsExpanded() ? 'v' : '>'}{' '}
<props.table.FlexRender cell={props.cell} /> (
{props.cell.row.subRows.length.toLocaleString()})
</button>
) : (
<props.table.FlexRender cell={props.cell} />
)}
</td>
)
}
function PinnedRow(props: {
row: Row<typeof stockFeatures, Person>
table: AppTable
}) {
const bottomRows = () => props.table.getBottomRows()
return (
<tr
class="pinned-row"
style={{
position: 'sticky',
top:
props.row.getIsPinned() === 'top'
? `${props.row.getPinnedIndex() * 32 + 48}px`
: undefined,
bottom:
props.row.getIsPinned() === 'bottom'
? `${(bottomRows().length - 1 - props.row.getPinnedIndex()) * 32}px`
: undefined,
'z-index': 1,
}}
>
<For each={props.row.getVisibleCells()}>
{(cell) => <TableCell cell={cell} table={props.table} />}
</For>
</tr>
)
}
const columnHelper = createAppColumnHelper<Person>()
const columns = columnHelper.columns([
columnHelper.display({
id: 'select',
size: 80,
minSize: 80,
maxSize: 80,
enableSorting: false,
enableGrouping: false,
enableHiding: false,
enableResizing: false,
header: ({ table }) => (
<IndeterminateCheckbox
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()}
onChange={table.getToggleAllPageRowsSelectedHandler()}
title="Select all on this page"
/>
),
cell: ({ row }) => (
<div class="column-toggle-row">
<IndeterminateCheckbox
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()}
/>{' '}
<button
class="pin-button"
onClick={() => row.pin(row.getIsPinned() === 'top' ? false : 'top')}
>
{row.getIsPinned() === 'top' ? 'Pinned' : 'Pin'}
</button>
</div>
),
}),
columnHelper.accessor('firstName', {
id: 'firstName',
size: 200,
header: 'First Name',
filterFn: 'fuzzy',
sortFn: fuzzySort,
meta: { filterVariant: 'text' },
getGroupingValue: (row) => `${row.firstName} ${row.lastName}`,
cell: ({ row, getValue }) => (
<div style={{ 'padding-left': `${row.depth * 1.5}rem` }}>
{row.getCanExpand() ? (
<button
onClick={row.getToggleExpandedHandler()}
style={{ cursor: 'pointer', 'margin-right': '0.25rem' }}
>
{row.getIsExpanded() ? 'v' : '>'}
</button>
) : (
<span style={{ 'margin-right': '0.25rem' }}>-</span>
)}
{String(getValue())}
</div>
),
}),
columnHelper.accessor((row) => row.lastName, {
id: 'lastName',
size: 180,
header: 'Last Name',
meta: { filterVariant: 'text' },
}),
columnHelper.accessor('age', {
id: 'age',
size: 200,
header: 'Age',
meta: { filterVariant: 'range' },
aggregationFn: 'median',
aggregatedCell: ({ getValue }) =>
Math.round(getValue<number>() * 100) / 100,
}),
columnHelper.accessor('visits', {
id: 'visits',
size: 200,
header: 'Visits',
meta: { filterVariant: 'range' },
aggregationFn: 'sum',
aggregatedCell: ({ getValue }) => getValue<number>().toLocaleString(),
}),
columnHelper.accessor('status', {
id: 'status',
size: 200,
header: 'Status',
sortFn: sortStatusFn,
meta: { filterVariant: 'select' },
}),
columnHelper.accessor('progress', {
id: 'progress',
size: 200,
header: 'Profile Progress',
meta: { filterVariant: 'range' },
aggregationFn: 'mean',
cell: ({ getValue }) => `${Math.round(getValue<number>() * 100) / 100}%`,
aggregatedCell: ({ getValue }) =>
`${Math.round(getValue<number>() * 100) / 100}%`,
}),
])
function App() {
const [data, setData] = createSignal(makeData(1_000))
const [, setRenderTick] = createSignal({})
const table = createAppTable(
{
key: 'kitchen-sink', // needed for devtools
columns,
get data() {
return data()
},
getSubRows: (row) => row.subRows,
globalFilterFn: 'fuzzy',
columnResizeMode: 'onChange',
defaultColumn: { minSize: 200, maxSize: 800 },
initialState: {
columnOrder: columns.map((c) => c.id!),
columnPinning: { left: ['select'], right: [] },
pagination: { pageIndex: 0, pageSize: 20 },
},
keepPinnedRows: true,
debugTable: true,
},
(state) => state,
)
useTanStackTableDevtools(table)
const columnSizeVars = createMemo(() => {
void table.atoms.columnResizing.get()
void table.atoms.columnSizing.get()
const colSizes: Record<string, number> = {}
for (const header of table.getFlatHeaders()) {
colSizes[`--header-${header.id}-size`] = header.getSize()
colSizes[`--col-${header.column.id}-size`] = header.column.getSize()
}
return colSizes
})
const refreshData = () => setData(makeData(1_000))
const nestedData = () => setData(makeData(100, 5, 3))
const stress10k = () => setData(makeData(10_000))
const stress100k = () => setData(makeData(100_000))
const shuffleColumns = () => {
table.setColumnOrder(
faker.helpers.shuffle(table.getAllLeafColumns().map((d) => d.id)),
)
}
return (
<div class="demo-root">
<h1>Kitchen Sink - All Features</h1>
<div class="toolbar">
<div class="toolbar-row">
<DebouncedInput
value={(table.atoms.globalFilter.get() ?? '') as string}
onChange={(value) => table.setGlobalFilter(String(value))}
class="global-filter-input"
placeholder="Fuzzy search all columns..."
/>
</div>
<div class="toolbar-row">
<button onClick={refreshData} class="demo-button demo-button-sm">
Flat 1k
</button>
<button onClick={nestedData} class="demo-button demo-button-sm">
Nested 100x5x3
</button>
<button onClick={stress10k} class="demo-button demo-button-sm">
Stress 10k (flat)
</button>
<button onClick={stress100k} class="demo-button demo-button-sm">
Stress 100k (flat)
</button>
<button
onClick={() => table.reset()}
class="demo-button demo-button-sm"
>
Reset Table
</button>
<button onClick={shuffleColumns} class="demo-button demo-button-sm">
Shuffle Columns
</button>
<button
onClick={() => setRenderTick({})}
class="demo-button demo-button-sm"
>
Force Rerender
</button>
<span class="nowrap">
{table.getSelectedRowModel().flatRows.length.toLocaleString()} of{' '}
{table.getCoreRowModel().flatRows.length.toLocaleString()} selected
</span>
</div>
<details class="column-toggle-panel">
<summary class="column-toggle-panel-header">
Column visibility
</summary>
<div class="column-toggle-row">
<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()}
disabled={!column.getCanHide()}
onChange={column.getToggleVisibilityHandler()}
/>{' '}
{column.id}
</label>
</div>
)}
</For>
</details>
</div>
<div class="table-container">
<table
style={{ ...columnSizeVars(), width: `${table.getTotalSize()}px` }}
>
<thead>
<For each={table.getHeaderGroups()}>
{(headerGroup) => (
<tr>
<For each={headerGroup.headers}>
{(header) => <TableHeader header={header} table={table} />}
</For>
</tr>
)}
</For>
</thead>
<tbody>
<For each={table.getTopRows()}>
{(row) => <PinnedRow row={row} table={table} />}
</For>
<For each={table.getCenterRows()}>
{(row) => (
<tr>
<For each={row.getVisibleCells()}>
{(cell) => <TableCell cell={cell} table={table} />}
</For>
</tr>
)}
</For>
<For each={table.getBottomRows()}>
{(row) => <PinnedRow row={row} table={table} />}
</For>
</tbody>
</table>
</div>
<div class="spacer-sm" />
<div class="controls">
<button
class="demo-button demo-button-sm"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
{'<<'}
</button>
<button
class="demo-button demo-button-sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
{'<'}
</button>
<button
class="demo-button demo-button-sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
{'>'}
</button>
<button
class="demo-button demo-button-sm"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
{'>>'}
</button>
<span class="inline-controls">
<div>Page</div>
<strong>
{(table.atoms.pagination.get().pageIndex + 1).toLocaleString()} of{' '}
{table.getPageCount().toLocaleString()}
</strong>
</span>
<span class="inline-controls">
| Go to page:
<input
type="number"
min="1"
max={table.getPageCount()}
value={table.atoms.pagination.get().pageIndex + 1}
onInput={(e) => {
const page = e.currentTarget.value
? Number(e.currentTarget.value) - 1
: 0
table.setPageIndex(page)
}}
class="page-size-input"
/>
</span>
<select
value={table.atoms.pagination.get().pageSize}
onChange={(e) => table.setPageSize(Number(e.currentTarget.value))}
>
<For each={[10, 20, 30, 50, 100]}>
{(pageSize) => <option value={pageSize}>Show {pageSize}</option>}
</For>
</select>
</div>
<div class="spacer-sm" />
<div class="nowrap">
{table.getRowModel().rows.length.toLocaleString()} rows on this page (
{table.getFilteredRowModel().rows.length.toLocaleString()} filtered of{' '}
{table.getCoreRowModel().rows.length.toLocaleString()} total)
</div>
<div class="spacer-md" />
<details>
<summary>Table state (live)</summary>
<pre class="state-dump">
{JSON.stringify(table.store.get(), null, 2)}
</pre>
</details>
</div>
)
}
export default App
import { faker } from '@faker-js/faker'
import {
aggregationFns,
createExpandedRowModel,
createFacetedMinMaxValues,
createFacetedRowModel,
createFacetedUniqueValues,
createFilteredRowModel,
createGroupedRowModel,
createPaginatedRowModel,
createSortedRowModel,
createTableHook,
filterFns,
sortFns,
stockFeatures,
} from '@tanstack/solid-table'
import { useTanStackTableDevtools } from '@tanstack/solid-table-devtools'
import { compareItems, rankItem } from '@tanstack/match-sorter-utils'
import {
For,
createEffect,
createMemo,
createSignal,
onCleanup,
} from 'solid-js'
import { makeData } from './makeData'
import type { JSX } from 'solid-js'
import type { RankingInfo } from '@tanstack/match-sorter-utils'
import type { Person } from './makeData'
import type {
Cell,
CellData,
Column,
FilterFn,
Header,
Row,
RowData,
SortFn,
TableFeatures,
} from '@tanstack/solid-table'
declare module '@tanstack/solid-table' {
interface ColumnMeta<
TFeatures extends TableFeatures,
TData extends RowData,
TValue extends CellData = CellData,
> {
filterVariant?: 'text' | 'range' | 'select'
}
interface FilterFns {
fuzzy: FilterFn<typeof stockFeatures, Person>
}
interface FilterMeta {
itemRank?: RankingInfo
}
}
const { createAppTable, createAppColumnHelper } = createTableHook({
features: stockFeatures,
rowModels: {
expandedRowModel: createExpandedRowModel(),
filteredRowModel: createFilteredRowModel({
...filterFns,
fuzzy: (row, columnId, value, addMeta) => {
const itemRank = rankItem(row.getValue(columnId), value)
addMeta?.({ itemRank })
return itemRank.passed
},
}),
facetedRowModel: createFacetedRowModel(),
facetedMinMaxValues: createFacetedMinMaxValues(),
facetedUniqueValues: createFacetedUniqueValues(),
groupedRowModel: createGroupedRowModel(aggregationFns),
paginatedRowModel: createPaginatedRowModel(),
sortedRowModel: createSortedRowModel(sortFns),
},
})
const fuzzySort: SortFn<typeof stockFeatures, Person> = (
rowA,
rowB,
columnId,
) => {
let dir = 0
if (rowA.columnFiltersMeta[columnId]) {
dir = compareItems(
rowA.columnFiltersMeta[columnId].itemRank!,
rowB.columnFiltersMeta[columnId].itemRank!,
)
}
return dir === 0 ? sortFns.alphanumeric(rowA, rowB, columnId) : dir
}
const sortStatusFn: SortFn<typeof stockFeatures, Person> = (rowA, rowB) => {
const statusOrder = ['single', 'complicated', 'relationship']
return (
statusOrder.indexOf(rowA.original.status) -
statusOrder.indexOf(rowB.original.status)
)
}
const getCommonPinningStyles = (
column: Column<typeof stockFeatures, Person>,
): JSX.CSSProperties => {
const isPinned = column.getIsPinned()
const isLastLeftPinnedColumn =
isPinned === 'left' && column.getIsLastColumn('left')
const isFirstRightPinnedColumn =
isPinned === 'right' && column.getIsFirstColumn('right')
return {
'box-shadow': isLastLeftPinnedColumn
? '-4px 0 4px -4px gray inset'
: isFirstRightPinnedColumn
? '4px 0 4px -4px gray inset'
: undefined,
left: isPinned === 'left' ? `${column.getStart('left')}px` : undefined,
right: isPinned === 'right' ? `${column.getAfter('right')}px` : undefined,
opacity: isPinned ? 0.97 : 1,
position: isPinned ? 'sticky' : 'relative',
'z-index': isPinned ? 1 : 0,
}
}
function IndeterminateCheckbox(
props: {
indeterminate?: boolean
} & JSX.InputHTMLAttributes<HTMLInputElement>,
) {
let ref!: HTMLInputElement
createEffect(() => {
if (typeof props.indeterminate === 'boolean') {
ref.indeterminate = !props.checked && props.indeterminate
}
})
return <input type="checkbox" ref={ref} {...props} />
}
function DebouncedInput(
props: {
value: string | number
onChange: (value: string | number) => void
debounce?: number
} & Omit<JSX.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'>,
) {
const [value, setValue] = createSignal(props.value)
createEffect(() => {
setValue(props.value)
})
createEffect(() => {
const currentValue = value()
const timeout = setTimeout(
() => props.onChange(currentValue),
props.debounce ?? 300,
)
onCleanup(() => clearTimeout(timeout))
})
return (
<input
{...props}
value={value()}
onInput={(e) => setValue(e.currentTarget.value)}
/>
)
}
function Filter(props: { column: Column<typeof stockFeatures, Person> }) {
const filterVariant = () => props.column.columnDef.meta?.filterVariant
const sortedUniqueValues = createMemo(() =>
filterVariant() === 'range'
? []
: Array.from(props.column.getFacetedUniqueValues().keys())
.sort()
.slice(0, 5000),
)
return (
<>
{filterVariant() === 'range' ? (
<div class="filter-row">
<DebouncedInput
type="number"
min={Number(props.column.getFacetedMinMaxValues()?.[0] ?? '')}
max={Number(props.column.getFacetedMinMaxValues()?.[1] ?? '')}
value={
(
props.column.getFilterValue() as [number, number] | undefined
)?.[0] ?? ''
}
onChange={(value) =>
props.column.setFilterValue(
(old: [number, number] | undefined) => [value, old?.[1]],
)
}
placeholder={`Min${
props.column.getFacetedMinMaxValues()?.[0] !== undefined
? ` (${props.column.getFacetedMinMaxValues()?.[0]})`
: ''
}`}
class="filter-input"
/>
<DebouncedInput
type="number"
min={Number(props.column.getFacetedMinMaxValues()?.[0] ?? '')}
max={Number(props.column.getFacetedMinMaxValues()?.[1] ?? '')}
value={
(
props.column.getFilterValue() as [number, number] | undefined
)?.[1] ?? ''
}
onChange={(value) =>
props.column.setFilterValue(
(old: [number, number] | undefined) => [old?.[0], value],
)
}
placeholder={`Max${
props.column.getFacetedMinMaxValues()?.[1] !== undefined
? ` (${props.column.getFacetedMinMaxValues()?.[1]})`
: ''
}`}
class="filter-input"
/>
</div>
) : filterVariant() === 'select' ? (
<select
onChange={(e) => props.column.setFilterValue(e.currentTarget.value)}
value={(props.column.getFilterValue() ?? '').toString()}
class="filter-select"
>
<option value="">All</option>
<For each={sortedUniqueValues()}>
{(value) => <option value={String(value)}>{String(value)}</option>}
</For>
</select>
) : (
<>
<datalist id={props.column.id + 'list'}>
<For each={sortedUniqueValues()}>
{(value) => <option value={String(value)} />}
</For>
</datalist>
<DebouncedInput
type="text"
value={(props.column.getFilterValue() ?? '') as string}
onChange={(value) => props.column.setFilterValue(value)}
placeholder={`Search (${props.column.getFacetedUniqueValues().size})`}
class="filter-select"
list={props.column.id + 'list'}
/>
</>
)}
</>
)
}
type AppTable = ReturnType<typeof createAppTable<Person>>
function TableHeader(props: {
header: Header<typeof stockFeatures, Person, unknown>
table: AppTable
}) {
const column = () => props.header.column
const style = () => ({
...getCommonPinningStyles(column()),
'white-space': 'nowrap',
width: `calc(var(--header-${props.header.id}-size) * 1px)`,
})
return (
<th style={style()} colSpan={props.header.colSpan}>
{!props.header.isPlaceholder ? (
<>
<div class="header-row">
<div style={{ flex: 1, 'min-width': 0 }}>
<div class="header-controls">
{column().getCanPin() ? (
<span class="pin-actions">
{column().getIsPinned() !== 'left' ? (
<button
class="pin-button"
onClick={() => column().pin('left')}
>
{'<'}
</button>
) : null}
{column().getIsPinned() ? (
<button
class="pin-button"
onClick={() => column().pin(false)}
>
x
</button>
) : null}
{column().getIsPinned() !== 'right' ? (
<button
class="pin-button"
onClick={() => column().pin('right')}
>
{'>'}
</button>
) : null}
</span>
) : null}
{column().getCanGroup() ? (
<button
class="pin-button"
onClick={column().getToggleGroupingHandler()}
>
{column().getIsGrouped()
? `Stop (${column().getGroupedIndex()})`
: 'Group'}
</button>
) : null}
</div>
{column().getCanSort() ? (
<span
class="sortable-header"
onClick={column().getToggleSortingHandler()}
>
<props.table.FlexRender header={props.header} />
{{
asc: ' â–²',
desc: ' â–¼',
}[column().getIsSorted() as string] ?? null}
</span>
) : (
<props.table.FlexRender header={props.header} />
)}
{column().getCanFilter() ? (
<div>
<Filter column={column()} />
</div>
) : null}
</div>
</div>
{column().getCanResize() ? (
<div
onDblClick={() => column().resetSize()}
onMouseDown={props.header.getResizeHandler()}
onTouchStart={props.header.getResizeHandler()}
class={`resizer ${column().getIsResizing() ? 'isResizing' : ''}`}
/>
) : null}
</>
) : null}
</th>
)
}
function TableCell(props: {
cell: Cell<typeof stockFeatures, Person, unknown>
table: AppTable
}) {
const className = () => {
const groupingActive = props.table.atoms.grouping.get().length > 0
const hasAggregation = !!props.cell.column.columnDef.aggregationFn
return !groupingActive
? undefined
: props.cell.getIsGrouped()
? 'cell-grouped'
: hasAggregation && props.cell.getIsAggregated()
? 'cell-aggregated'
: props.cell.getIsPlaceholder()
? 'cell-placeholder'
: undefined
}
return (
<td
style={{
...getCommonPinningStyles(props.cell.column),
width: `calc(var(--col-${props.cell.column.id}-size) * 1px)`,
}}
class={className()}
>
{props.cell.getIsGrouped() ? (
<button
onClick={props.cell.row.getToggleExpandedHandler()}
style={{
cursor: props.cell.row.getCanExpand() ? 'pointer' : 'normal',
}}
>
{props.cell.row.getIsExpanded() ? 'v' : '>'}{' '}
<props.table.FlexRender cell={props.cell} /> (
{props.cell.row.subRows.length.toLocaleString()})
</button>
) : (
<props.table.FlexRender cell={props.cell} />
)}
</td>
)
}
function PinnedRow(props: {
row: Row<typeof stockFeatures, Person>
table: AppTable
}) {
const bottomRows = () => props.table.getBottomRows()
return (
<tr
class="pinned-row"
style={{
position: 'sticky',
top:
props.row.getIsPinned() === 'top'
? `${props.row.getPinnedIndex() * 32 + 48}px`
: undefined,
bottom:
props.row.getIsPinned() === 'bottom'
? `${(bottomRows().length - 1 - props.row.getPinnedIndex()) * 32}px`
: undefined,
'z-index': 1,
}}
>
<For each={props.row.getVisibleCells()}>
{(cell) => <TableCell cell={cell} table={props.table} />}
</For>
</tr>
)
}
const columnHelper = createAppColumnHelper<Person>()
const columns = columnHelper.columns([
columnHelper.display({
id: 'select',
size: 80,
minSize: 80,
maxSize: 80,
enableSorting: false,
enableGrouping: false,
enableHiding: false,
enableResizing: false,
header: ({ table }) => (
<IndeterminateCheckbox
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()}
onChange={table.getToggleAllPageRowsSelectedHandler()}
title="Select all on this page"
/>
),
cell: ({ row }) => (
<div class="column-toggle-row">
<IndeterminateCheckbox
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()}
/>{' '}
<button
class="pin-button"
onClick={() => row.pin(row.getIsPinned() === 'top' ? false : 'top')}
>
{row.getIsPinned() === 'top' ? 'Pinned' : 'Pin'}
</button>
</div>
),
}),
columnHelper.accessor('firstName', {
id: 'firstName',
size: 200,
header: 'First Name',
filterFn: 'fuzzy',
sortFn: fuzzySort,
meta: { filterVariant: 'text' },
getGroupingValue: (row) => `${row.firstName} ${row.lastName}`,
cell: ({ row, getValue }) => (
<div style={{ 'padding-left': `${row.depth * 1.5}rem` }}>
{row.getCanExpand() ? (
<button
onClick={row.getToggleExpandedHandler()}
style={{ cursor: 'pointer', 'margin-right': '0.25rem' }}
>
{row.getIsExpanded() ? 'v' : '>'}
</button>
) : (
<span style={{ 'margin-right': '0.25rem' }}>-</span>
)}
{String(getValue())}
</div>
),
}),
columnHelper.accessor((row) => row.lastName, {
id: 'lastName',
size: 180,
header: 'Last Name',
meta: { filterVariant: 'text' },
}),
columnHelper.accessor('age', {
id: 'age',
size: 200,
header: 'Age',
meta: { filterVariant: 'range' },
aggregationFn: 'median',
aggregatedCell: ({ getValue }) =>
Math.round(getValue<number>() * 100) / 100,
}),
columnHelper.accessor('visits', {
id: 'visits',
size: 200,
header: 'Visits',
meta: { filterVariant: 'range' },
aggregationFn: 'sum',
aggregatedCell: ({ getValue }) => getValue<number>().toLocaleString(),
}),
columnHelper.accessor('status', {
id: 'status',
size: 200,
header: 'Status',
sortFn: sortStatusFn,
meta: { filterVariant: 'select' },
}),
columnHelper.accessor('progress', {
id: 'progress',
size: 200,
header: 'Profile Progress',
meta: { filterVariant: 'range' },
aggregationFn: 'mean',
cell: ({ getValue }) => `${Math.round(getValue<number>() * 100) / 100}%`,
aggregatedCell: ({ getValue }) =>
`${Math.round(getValue<number>() * 100) / 100}%`,
}),
])
function App() {
const [data, setData] = createSignal(makeData(1_000))
const [, setRenderTick] = createSignal({})
const table = createAppTable(
{
key: 'kitchen-sink', // needed for devtools
columns,
get data() {
return data()
},
getSubRows: (row) => row.subRows,
globalFilterFn: 'fuzzy',
columnResizeMode: 'onChange',
defaultColumn: { minSize: 200, maxSize: 800 },
initialState: {
columnOrder: columns.map((c) => c.id!),
columnPinning: { left: ['select'], right: [] },
pagination: { pageIndex: 0, pageSize: 20 },
},
keepPinnedRows: true,
debugTable: true,
},
(state) => state,
)
useTanStackTableDevtools(table)
const columnSizeVars = createMemo(() => {
void table.atoms.columnResizing.get()
void table.atoms.columnSizing.get()
const colSizes: Record<string, number> = {}
for (const header of table.getFlatHeaders()) {
colSizes[`--header-${header.id}-size`] = header.getSize()
colSizes[`--col-${header.column.id}-size`] = header.column.getSize()
}
return colSizes
})
const refreshData = () => setData(makeData(1_000))
const nestedData = () => setData(makeData(100, 5, 3))
const stress10k = () => setData(makeData(10_000))
const stress100k = () => setData(makeData(100_000))
const shuffleColumns = () => {
table.setColumnOrder(
faker.helpers.shuffle(table.getAllLeafColumns().map((d) => d.id)),
)
}
return (
<div class="demo-root">
<h1>Kitchen Sink - All Features</h1>
<div class="toolbar">
<div class="toolbar-row">
<DebouncedInput
value={(table.atoms.globalFilter.get() ?? '') as string}
onChange={(value) => table.setGlobalFilter(String(value))}
class="global-filter-input"
placeholder="Fuzzy search all columns..."
/>
</div>
<div class="toolbar-row">
<button onClick={refreshData} class="demo-button demo-button-sm">
Flat 1k
</button>
<button onClick={nestedData} class="demo-button demo-button-sm">
Nested 100x5x3
</button>
<button onClick={stress10k} class="demo-button demo-button-sm">
Stress 10k (flat)
</button>
<button onClick={stress100k} class="demo-button demo-button-sm">
Stress 100k (flat)
</button>
<button
onClick={() => table.reset()}
class="demo-button demo-button-sm"
>
Reset Table
</button>
<button onClick={shuffleColumns} class="demo-button demo-button-sm">
Shuffle Columns
</button>
<button
onClick={() => setRenderTick({})}
class="demo-button demo-button-sm"
>
Force Rerender
</button>
<span class="nowrap">
{table.getSelectedRowModel().flatRows.length.toLocaleString()} of{' '}
{table.getCoreRowModel().flatRows.length.toLocaleString()} selected
</span>
</div>
<details class="column-toggle-panel">
<summary class="column-toggle-panel-header">
Column visibility
</summary>
<div class="column-toggle-row">
<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()}
disabled={!column.getCanHide()}
onChange={column.getToggleVisibilityHandler()}
/>{' '}
{column.id}
</label>
</div>
)}
</For>
</details>
</div>
<div class="table-container">
<table
style={{ ...columnSizeVars(), width: `${table.getTotalSize()}px` }}
>
<thead>
<For each={table.getHeaderGroups()}>
{(headerGroup) => (
<tr>
<For each={headerGroup.headers}>
{(header) => <TableHeader header={header} table={table} />}
</For>
</tr>
)}
</For>
</thead>
<tbody>
<For each={table.getTopRows()}>
{(row) => <PinnedRow row={row} table={table} />}
</For>
<For each={table.getCenterRows()}>
{(row) => (
<tr>
<For each={row.getVisibleCells()}>
{(cell) => <TableCell cell={cell} table={table} />}
</For>
</tr>
)}
</For>
<For each={table.getBottomRows()}>
{(row) => <PinnedRow row={row} table={table} />}
</For>
</tbody>
</table>
</div>
<div class="spacer-sm" />
<div class="controls">
<button
class="demo-button demo-button-sm"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
{'<<'}
</button>
<button
class="demo-button demo-button-sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
{'<'}
</button>
<button
class="demo-button demo-button-sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
{'>'}
</button>
<button
class="demo-button demo-button-sm"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
{'>>'}
</button>
<span class="inline-controls">
<div>Page</div>
<strong>
{(table.atoms.pagination.get().pageIndex + 1).toLocaleString()} of{' '}
{table.getPageCount().toLocaleString()}
</strong>
</span>
<span class="inline-controls">
| Go to page:
<input
type="number"
min="1"
max={table.getPageCount()}
value={table.atoms.pagination.get().pageIndex + 1}
onInput={(e) => {
const page = e.currentTarget.value
? Number(e.currentTarget.value) - 1
: 0
table.setPageIndex(page)
}}
class="page-size-input"
/>
</span>
<select
value={table.atoms.pagination.get().pageSize}
onChange={(e) => table.setPageSize(Number(e.currentTarget.value))}
>
<For each={[10, 20, 30, 50, 100]}>
{(pageSize) => <option value={pageSize}>Show {pageSize}</option>}
</For>
</select>
</div>
<div class="spacer-sm" />
<div class="nowrap">
{table.getRowModel().rows.length.toLocaleString()} rows on this page (
{table.getFilteredRowModel().rows.length.toLocaleString()} filtered of{' '}
{table.getCoreRowModel().rows.length.toLocaleString()} total)
</div>
<div class="spacer-md" />
<details>
<summary>Table state (live)</summary>
<pre class="state-dump">
{JSON.stringify(table.store.get(), null, 2)}
</pre>
</details>
</div>
)
}
export default App