<script setup lang="ts">
import { useTanStackTableDevtools } from '@tanstack/vue-table-devtools'
import { computed, ref } from 'vue'
import { faker } from '@faker-js/faker'
import {
FlexRender,
aggregationFns,
createColumnHelper,
createExpandedRowModel,
createFacetedMinMaxValues,
createFacetedRowModel,
createFacetedUniqueValues,
createFilteredRowModel,
createGroupedRowModel,
createPaginatedRowModel,
createSortedRowModel,
filterFns,
sortFns,
stockFeatures,
useTable,
} from '@tanstack/vue-table'
import { compareItems, rankItem } from '@tanstack/match-sorter-utils'
import { makeData } from './makeData'
import type { CSSProperties } from 'vue'
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/vue-table'
declare module '@tanstack/vue-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 fuzzyFilter: FilterFn<typeof stockFeatures, Person> = (
row,
columnId,
value,
addMeta,
) => {
const itemRank = rankItem(row.getValue(columnId), value)
addMeta?.({ itemRank })
return itemRank.passed
}
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 columnHelper = createColumnHelper<typeof stockFeatures, Person>()
const columns = ref(
columnHelper.columns([
columnHelper.display({
id: 'select',
size: 80,
minSize: 80,
maxSize: 80,
enableSorting: false,
enableGrouping: false,
enableHiding: false,
enableResizing: false,
header: ({ table }) =>
table.getIsAllPageRowsSelected()
? 'all'
: table.getIsSomePageRowsSelected()
? 'some'
: 'none',
cell: ({ row }) => (row.getIsPinned() === 'top' ? 'Pinned' : 'Pin'),
}),
columnHelper.accessor('firstName', {
id: 'firstName',
size: 200,
header: 'First Name',
filterFn: 'fuzzy',
sortFn: fuzzySort,
meta: { filterVariant: 'text' },
getGroupingValue: (row) => `${row.firstName} ${row.lastName}`,
}),
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}%`,
}),
]),
)
const data = ref(makeData(1_000))
const table = useTable({
key: 'kitchen-sink', // needed for devtools
features: stockFeatures,
rowModels: {
expandedRowModel: createExpandedRowModel(),
filteredRowModel: createFilteredRowModel({
...filterFns,
fuzzy: fuzzyFilter,
}),
facetedRowModel: createFacetedRowModel(),
facetedMinMaxValues: createFacetedMinMaxValues(),
facetedUniqueValues: createFacetedUniqueValues(),
groupedRowModel: createGroupedRowModel(aggregationFns),
paginatedRowModel: createPaginatedRowModel(),
sortedRowModel: createSortedRowModel(sortFns),
},
data,
get columns() {
return columns.value
},
getSubRows: (row: Person) => row.subRows,
globalFilterFn: 'fuzzy',
columnResizeMode: 'onChange',
defaultColumn: { minSize: 200, maxSize: 800 },
initialState: {
columnOrder: columns.value.map((c) => c.id!),
columnPinning: { left: ['select'], right: [] },
pagination: { pageIndex: 0, pageSize: 20 },
},
keepPinnedRows: true,
debugTable: true,
})
useTanStackTableDevtools(table)
const columnSizeVars = computed(() => {
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 debounceTimers = new Map<string, ReturnType<typeof setTimeout>>()
function debounceSet(key: string, setValue: () => void) {
clearTimeout(debounceTimers.get(key))
debounceTimers.set(
key,
setTimeout(() => {
setValue()
debounceTimers.delete(key)
}, 300),
)
}
function getCommonPinningStyles(
column: Column<typeof stockFeatures, Person>,
): CSSProperties {
const isPinned = column.getIsPinned()
const isLastLeftPinnedColumn =
isPinned === 'left' && column.getIsLastColumn('left')
const isFirstRightPinnedColumn =
isPinned === 'right' && column.getIsFirstColumn('right')
return {
boxShadow: 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',
zIndex: isPinned ? 1 : 0,
}
}
function headerStyle(
header: Header<typeof stockFeatures, Person, unknown>,
): CSSProperties {
return {
...getCommonPinningStyles(header.column),
whiteSpace: 'nowrap',
width: `calc(var(--header-${header.id}-size) * 1px)`,
}
}
function cellStyle(cell: Cell<typeof stockFeatures, Person, unknown>) {
return {
...getCommonPinningStyles(cell.column),
width: `calc(var(--col-${cell.column.id}-size) * 1px)`,
}
}
function cellClass(cell: Cell<typeof stockFeatures, Person, unknown>) {
const groupingActive = table.atoms.grouping.get().length > 0
const hasAggregation = !!cell.column.columnDef.aggregationFn
return !groupingActive
? undefined
: cell.getIsGrouped()
? 'cell-grouped'
: hasAggregation && cell.getIsAggregated()
? 'cell-aggregated'
: cell.getIsPlaceholder()
? 'cell-placeholder'
: undefined
}
function rowStyle(row: Row<typeof stockFeatures, Person>): CSSProperties {
const bottomRows = table.getBottomRows()
return {
position: 'sticky',
top:
row.getIsPinned() === 'top'
? `${row.getPinnedIndex() * 32 + 48}px`
: undefined,
bottom:
row.getIsPinned() === 'bottom'
? `${(bottomRows.length - 1 - row.getPinnedIndex()) * 32}px`
: undefined,
zIndex: 1,
}
}
function sortedUniqueValues(column: Column<typeof stockFeatures, Person>) {
if (column.columnDef.meta?.filterVariant === 'range') return []
return Array.from(column.getFacetedUniqueValues().keys())
.sort()
.slice(0, 5000)
}
function updateRangeFilter(
column: Column<typeof stockFeatures, Person>,
index: 0 | 1,
value: string,
) {
column.setFilterValue((old: [number, number] | undefined) => {
return index === 0 ? [value, old?.[1]] : [old?.[0], value]
})
}
function refreshData() {
data.value = makeData(1_000)
}
function nestedData() {
data.value = makeData(100, 5, 3)
}
function stress10k() {
data.value = makeData(10_000)
}
function stress100k() {
data.value = makeData(100_000)
}
function shuffleColumns() {
table.setColumnOrder(
faker.helpers.shuffle(table.getAllLeafColumns().map((d) => d.id)),
)
}
</script>
<template>
<div class="demo-root">
<h1>Kitchen Sink - All Features</h1>
<div class="toolbar">
<div class="toolbar-row">
<input
class="global-filter-input"
placeholder="Fuzzy search all columns..."
:value="table.atoms.globalFilter.get() ?? ''"
@input="
(event) =>
debounceSet('global', () =>
table.setGlobalFilter((event.target as HTMLInputElement).value),
)
"
/>
</div>
<div class="toolbar-row">
<button @click="refreshData" class="demo-button demo-button-sm">
Flat 1k
</button>
<button @click="nestedData" class="demo-button demo-button-sm">
Nested 100x5x3
</button>
<button @click="stress10k" class="demo-button demo-button-sm">
Stress 10k (flat)
</button>
<button @click="stress100k" class="demo-button demo-button-sm">
Stress 100k (flat)
</button>
<button @click="table.reset()" class="demo-button demo-button-sm">
Reset Table
</button>
<button @click="shuffleColumns" class="demo-button demo-button-sm">
Shuffle Columns
</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()"
@change="table.getToggleAllColumnsVisibilityHandler()?.($event)"
/>
Toggle All
</label>
</div>
<div
v-for="column in table.getAllLeafColumns()"
:key="column.id"
class="column-toggle-row"
>
<label>
<input
type="checkbox"
:checked="column.getIsVisible()"
:disabled="!column.getCanHide()"
@change="column.getToggleVisibilityHandler()?.($event)"
/>
{{ column.id }}
</label>
</div>
</details>
</div>
<div class="table-container">
<table :style="{ ...columnSizeVars, width: table.getTotalSize() + 'px' }">
<thead>
<tr
v-for="headerGroup in table.getHeaderGroups()"
:key="headerGroup.id"
>
<th
v-for="header in headerGroup.headers"
:key="header.id"
:colspan="header.colSpan"
:style="headerStyle(header)"
>
<template v-if="!header.isPlaceholder">
<div class="header-row">
<div style="flex: 1; min-width: 0">
<div class="header-controls">
<span
v-if="header.column.getCanPin()"
class="pin-actions"
>
<button
v-if="header.column.getIsPinned() !== 'left'"
class="pin-button"
@click="header.column.pin('left')"
>
<
</button>
<button
v-if="header.column.getIsPinned()"
class="pin-button"
@click="header.column.pin(false)"
>
x
</button>
<button
v-if="header.column.getIsPinned() !== 'right'"
class="pin-button"
@click="header.column.pin('right')"
>
>
</button>
</span>
<button
v-if="header.column.getCanGroup()"
class="pin-button"
@click="header.column.getToggleGroupingHandler()?.()"
>
{{
header.column.getIsGrouped()
? `Stop (${header.column.getGroupedIndex()})`
: 'Group'
}}
</button>
</div>
<template v-if="header.column.id === 'select'">
<input
type="checkbox"
:checked="table.getIsAllPageRowsSelected()"
:indeterminate="table.getIsSomePageRowsSelected()"
@change="
table.getToggleAllPageRowsSelectedHandler()?.($event)
"
/>
</template>
<span
v-else-if="header.column.getCanSort()"
class="sortable-header"
@click="header.column.getToggleSortingHandler()?.($event)"
>
<FlexRender :header="header" />
{{
header.column.getIsSorted() === 'asc'
? ' â–²'
: header.column.getIsSorted() === 'desc'
? ' â–¼'
: ''
}}
</span>
<FlexRender v-else :header="header" />
<div v-if="header.column.getCanFilter()">
<div
v-if="
header.column.columnDef.meta?.filterVariant ===
'range'
"
class="filter-row"
>
<input
type="number"
class="filter-input"
:min="
header.column.getFacetedMinMaxValues()?.[0] ?? ''
"
:max="
header.column.getFacetedMinMaxValues()?.[1] ?? ''
"
:value="
(
header.column.getFilterValue() as
| [number, number]
| undefined
)?.[0] ?? ''
"
:placeholder="`Min${header.column.getFacetedMinMaxValues()?.[0] !== undefined ? ` (${header.column.getFacetedMinMaxValues()?.[0]})` : ''}`"
@input="
(event) =>
debounceSet(header.column.id + '-min', () =>
updateRangeFilter(
header.column,
0,
(event.target as HTMLInputElement).value,
),
)
"
/>
<input
type="number"
class="filter-input"
:min="
header.column.getFacetedMinMaxValues()?.[0] ?? ''
"
:max="
header.column.getFacetedMinMaxValues()?.[1] ?? ''
"
:value="
(
header.column.getFilterValue() as
| [number, number]
| undefined
)?.[1] ?? ''
"
:placeholder="`Max${header.column.getFacetedMinMaxValues()?.[1] !== undefined ? ` (${header.column.getFacetedMinMaxValues()?.[1]})` : ''}`"
@input="
(event) =>
debounceSet(header.column.id + '-max', () =>
updateRangeFilter(
header.column,
1,
(event.target as HTMLInputElement).value,
),
)
"
/>
</div>
<select
v-else-if="
header.column.columnDef.meta?.filterVariant ===
'select'
"
class="filter-select"
:value="
(header.column.getFilterValue() ?? '').toString()
"
@change="
header.column.setFilterValue(
($event.target as HTMLSelectElement).value,
)
"
>
<option value="">All</option>
<option
v-for="value in sortedUniqueValues(header.column)"
:key="String(value)"
:value="String(value)"
>
{{ String(value) }}
</option>
</select>
<template v-else>
<datalist :id="header.column.id + 'list'">
<option
v-for="value in sortedUniqueValues(header.column)"
:key="String(value)"
:value="String(value)"
/>
</datalist>
<input
type="text"
class="filter-select"
:list="header.column.id + 'list'"
:value="
(header.column.getFilterValue() ?? '') as string
"
:placeholder="`Search (${header.column.getFacetedUniqueValues().size})`"
@input="
(event) =>
debounceSet(header.column.id, () =>
header.column.setFilterValue(
(event.target as HTMLInputElement).value,
),
)
"
/>
</template>
</div>
</div>
</div>
<div
v-if="header.column.getCanResize()"
:class="`resizer ${header.column.getIsResizing() ? 'isResizing' : ''}`"
@dblclick="header.column.resetSize()"
@mousedown="header.getResizeHandler()($event)"
@touchstart="header.getResizeHandler()($event)"
/>
</template>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in table.getTopRows()"
:key="row.id"
class="pinned-row"
:style="rowStyle(row)"
>
<td
v-for="cell in row.getVisibleCells()"
:key="cell.id"
:style="cellStyle(cell)"
:class="cellClass(cell)"
>
<FlexRender :cell="cell" />
</td>
</tr>
<tr v-for="row in table.getCenterRows()" :key="row.id">
<td
v-for="cell in row.getVisibleCells()"
:key="cell.id"
:style="cellStyle(cell)"
:class="cellClass(cell)"
>
<template v-if="cell.column.id === 'select'">
<div class="column-toggle-row">
<input
type="checkbox"
:checked="cell.row.getIsSelected()"
:disabled="!cell.row.getCanSelect()"
:indeterminate="cell.row.getIsSomeSelected()"
@change="cell.row.getToggleSelectedHandler()?.($event)"
/>
<button
class="pin-button"
@click="
cell.row.pin(
cell.row.getIsPinned() === 'top' ? false : 'top',
)
"
>
{{ cell.row.getIsPinned() === 'top' ? 'Pinned' : 'Pin' }}
</button>
</div>
</template>
<template v-else-if="cell.column.id === 'firstName'">
<div :style="{ paddingLeft: `${cell.row.depth * 1.5}rem` }">
<button
v-if="cell.row.getCanExpand()"
@click="cell.row.getToggleExpandedHandler()?.()"
style="cursor: pointer; margin-right: 0.25rem"
>
{{ cell.row.getIsExpanded() ? 'v' : '>' }}
</button>
<span v-else style="margin-right: 0.25rem">-</span>
<FlexRender :cell="cell" />
</div>
</template>
<template v-else-if="cell.getIsGrouped()">
<button
@click="cell.row.getToggleExpandedHandler()?.()"
:style="{
cursor: cell.row.getCanExpand() ? 'pointer' : 'normal',
}"
>
{{ cell.row.getIsExpanded() ? 'v' : '>' }}
<FlexRender :cell="cell" />
({{ cell.row.subRows.length.toLocaleString() }})
</button>
</template>
<FlexRender v-else :cell="cell" />
</td>
</tr>
<tr
v-for="row in table.getBottomRows()"
:key="row.id"
class="pinned-row"
:style="rowStyle(row)"
>
<td
v-for="cell in row.getVisibleCells()"
:key="cell.id"
:style="cellStyle(cell)"
:class="cellClass(cell)"
>
<FlexRender :cell="cell" />
</td>
</tr>
</tbody>
</table>
</div>
<div class="spacer-sm" />
<div class="controls">
<button
class="demo-button demo-button-sm"
@click="table.setPageIndex(0)"
:disabled="!table.getCanPreviousPage()"
>
<<
</button>
<button
class="demo-button demo-button-sm"
@click="table.previousPage()"
:disabled="!table.getCanPreviousPage()"
>
<
</button>
<button
class="demo-button demo-button-sm"
@click="table.nextPage()"
:disabled="!table.getCanNextPage()"
>
>
</button>
<button
class="demo-button demo-button-sm"
@click="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"
@input="
table.setPageIndex(
($event.target as HTMLInputElement).value
? Number(($event.target as HTMLInputElement).value) - 1
: 0,
)
"
class="page-size-input"
/>
</span>
<select
:value="table.atoms.pagination.get().pageSize"
@change="
table.setPageSize(Number(($event.target as HTMLSelectElement).value))
"
>
<option
v-for="pageSize in [10, 20, 30, 50, 100]"
:key="pageSize"
:value="pageSize"
>
Show {{ pageSize }}
</option>
</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>
</template>
<script setup lang="ts">
import { useTanStackTableDevtools } from '@tanstack/vue-table-devtools'
import { computed, ref } from 'vue'
import { faker } from '@faker-js/faker'
import {
FlexRender,
aggregationFns,
createColumnHelper,
createExpandedRowModel,
createFacetedMinMaxValues,
createFacetedRowModel,
createFacetedUniqueValues,
createFilteredRowModel,
createGroupedRowModel,
createPaginatedRowModel,
createSortedRowModel,
filterFns,
sortFns,
stockFeatures,
useTable,
} from '@tanstack/vue-table'
import { compareItems, rankItem } from '@tanstack/match-sorter-utils'
import { makeData } from './makeData'
import type { CSSProperties } from 'vue'
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/vue-table'
declare module '@tanstack/vue-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 fuzzyFilter: FilterFn<typeof stockFeatures, Person> = (
row,
columnId,
value,
addMeta,
) => {
const itemRank = rankItem(row.getValue(columnId), value)
addMeta?.({ itemRank })
return itemRank.passed
}
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 columnHelper = createColumnHelper<typeof stockFeatures, Person>()
const columns = ref(
columnHelper.columns([
columnHelper.display({
id: 'select',
size: 80,
minSize: 80,
maxSize: 80,
enableSorting: false,
enableGrouping: false,
enableHiding: false,
enableResizing: false,
header: ({ table }) =>
table.getIsAllPageRowsSelected()
? 'all'
: table.getIsSomePageRowsSelected()
? 'some'
: 'none',
cell: ({ row }) => (row.getIsPinned() === 'top' ? 'Pinned' : 'Pin'),
}),
columnHelper.accessor('firstName', {
id: 'firstName',
size: 200,
header: 'First Name',
filterFn: 'fuzzy',
sortFn: fuzzySort,
meta: { filterVariant: 'text' },
getGroupingValue: (row) => `${row.firstName} ${row.lastName}`,
}),
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}%`,
}),
]),
)
const data = ref(makeData(1_000))
const table = useTable({
key: 'kitchen-sink', // needed for devtools
features: stockFeatures,
rowModels: {
expandedRowModel: createExpandedRowModel(),
filteredRowModel: createFilteredRowModel({
...filterFns,
fuzzy: fuzzyFilter,
}),
facetedRowModel: createFacetedRowModel(),
facetedMinMaxValues: createFacetedMinMaxValues(),
facetedUniqueValues: createFacetedUniqueValues(),
groupedRowModel: createGroupedRowModel(aggregationFns),
paginatedRowModel: createPaginatedRowModel(),
sortedRowModel: createSortedRowModel(sortFns),
},
data,
get columns() {
return columns.value
},
getSubRows: (row: Person) => row.subRows,
globalFilterFn: 'fuzzy',
columnResizeMode: 'onChange',
defaultColumn: { minSize: 200, maxSize: 800 },
initialState: {
columnOrder: columns.value.map((c) => c.id!),
columnPinning: { left: ['select'], right: [] },
pagination: { pageIndex: 0, pageSize: 20 },
},
keepPinnedRows: true,
debugTable: true,
})
useTanStackTableDevtools(table)
const columnSizeVars = computed(() => {
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 debounceTimers = new Map<string, ReturnType<typeof setTimeout>>()
function debounceSet(key: string, setValue: () => void) {
clearTimeout(debounceTimers.get(key))
debounceTimers.set(
key,
setTimeout(() => {
setValue()
debounceTimers.delete(key)
}, 300),
)
}
function getCommonPinningStyles(
column: Column<typeof stockFeatures, Person>,
): CSSProperties {
const isPinned = column.getIsPinned()
const isLastLeftPinnedColumn =
isPinned === 'left' && column.getIsLastColumn('left')
const isFirstRightPinnedColumn =
isPinned === 'right' && column.getIsFirstColumn('right')
return {
boxShadow: 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',
zIndex: isPinned ? 1 : 0,
}
}
function headerStyle(
header: Header<typeof stockFeatures, Person, unknown>,
): CSSProperties {
return {
...getCommonPinningStyles(header.column),
whiteSpace: 'nowrap',
width: `calc(var(--header-${header.id}-size) * 1px)`,
}
}
function cellStyle(cell: Cell<typeof stockFeatures, Person, unknown>) {
return {
...getCommonPinningStyles(cell.column),
width: `calc(var(--col-${cell.column.id}-size) * 1px)`,
}
}
function cellClass(cell: Cell<typeof stockFeatures, Person, unknown>) {
const groupingActive = table.atoms.grouping.get().length > 0
const hasAggregation = !!cell.column.columnDef.aggregationFn
return !groupingActive
? undefined
: cell.getIsGrouped()
? 'cell-grouped'
: hasAggregation && cell.getIsAggregated()
? 'cell-aggregated'
: cell.getIsPlaceholder()
? 'cell-placeholder'
: undefined
}
function rowStyle(row: Row<typeof stockFeatures, Person>): CSSProperties {
const bottomRows = table.getBottomRows()
return {
position: 'sticky',
top:
row.getIsPinned() === 'top'
? `${row.getPinnedIndex() * 32 + 48}px`
: undefined,
bottom:
row.getIsPinned() === 'bottom'
? `${(bottomRows.length - 1 - row.getPinnedIndex()) * 32}px`
: undefined,
zIndex: 1,
}
}
function sortedUniqueValues(column: Column<typeof stockFeatures, Person>) {
if (column.columnDef.meta?.filterVariant === 'range') return []
return Array.from(column.getFacetedUniqueValues().keys())
.sort()
.slice(0, 5000)
}
function updateRangeFilter(
column: Column<typeof stockFeatures, Person>,
index: 0 | 1,
value: string,
) {
column.setFilterValue((old: [number, number] | undefined) => {
return index === 0 ? [value, old?.[1]] : [old?.[0], value]
})
}
function refreshData() {
data.value = makeData(1_000)
}
function nestedData() {
data.value = makeData(100, 5, 3)
}
function stress10k() {
data.value = makeData(10_000)
}
function stress100k() {
data.value = makeData(100_000)
}
function shuffleColumns() {
table.setColumnOrder(
faker.helpers.shuffle(table.getAllLeafColumns().map((d) => d.id)),
)
}
</script>
<template>
<div class="demo-root">
<h1>Kitchen Sink - All Features</h1>
<div class="toolbar">
<div class="toolbar-row">
<input
class="global-filter-input"
placeholder="Fuzzy search all columns..."
:value="table.atoms.globalFilter.get() ?? ''"
@input="
(event) =>
debounceSet('global', () =>
table.setGlobalFilter((event.target as HTMLInputElement).value),
)
"
/>
</div>
<div class="toolbar-row">
<button @click="refreshData" class="demo-button demo-button-sm">
Flat 1k
</button>
<button @click="nestedData" class="demo-button demo-button-sm">
Nested 100x5x3
</button>
<button @click="stress10k" class="demo-button demo-button-sm">
Stress 10k (flat)
</button>
<button @click="stress100k" class="demo-button demo-button-sm">
Stress 100k (flat)
</button>
<button @click="table.reset()" class="demo-button demo-button-sm">
Reset Table
</button>
<button @click="shuffleColumns" class="demo-button demo-button-sm">
Shuffle Columns
</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()"
@change="table.getToggleAllColumnsVisibilityHandler()?.($event)"
/>
Toggle All
</label>
</div>
<div
v-for="column in table.getAllLeafColumns()"
:key="column.id"
class="column-toggle-row"
>
<label>
<input
type="checkbox"
:checked="column.getIsVisible()"
:disabled="!column.getCanHide()"
@change="column.getToggleVisibilityHandler()?.($event)"
/>
{{ column.id }}
</label>
</div>
</details>
</div>
<div class="table-container">
<table :style="{ ...columnSizeVars, width: table.getTotalSize() + 'px' }">
<thead>
<tr
v-for="headerGroup in table.getHeaderGroups()"
:key="headerGroup.id"
>
<th
v-for="header in headerGroup.headers"
:key="header.id"
:colspan="header.colSpan"
:style="headerStyle(header)"
>
<template v-if="!header.isPlaceholder">
<div class="header-row">
<div style="flex: 1; min-width: 0">
<div class="header-controls">
<span
v-if="header.column.getCanPin()"
class="pin-actions"
>
<button
v-if="header.column.getIsPinned() !== 'left'"
class="pin-button"
@click="header.column.pin('left')"
>
<
</button>
<button
v-if="header.column.getIsPinned()"
class="pin-button"
@click="header.column.pin(false)"
>
x
</button>
<button
v-if="header.column.getIsPinned() !== 'right'"
class="pin-button"
@click="header.column.pin('right')"
>
>
</button>
</span>
<button
v-if="header.column.getCanGroup()"
class="pin-button"
@click="header.column.getToggleGroupingHandler()?.()"
>
{{
header.column.getIsGrouped()
? `Stop (${header.column.getGroupedIndex()})`
: 'Group'
}}
</button>
</div>
<template v-if="header.column.id === 'select'">
<input
type="checkbox"
:checked="table.getIsAllPageRowsSelected()"
:indeterminate="table.getIsSomePageRowsSelected()"
@change="
table.getToggleAllPageRowsSelectedHandler()?.($event)
"
/>
</template>
<span
v-else-if="header.column.getCanSort()"
class="sortable-header"
@click="header.column.getToggleSortingHandler()?.($event)"
>
<FlexRender :header="header" />
{{
header.column.getIsSorted() === 'asc'
? ' â–²'
: header.column.getIsSorted() === 'desc'
? ' â–¼'
: ''
}}
</span>
<FlexRender v-else :header="header" />
<div v-if="header.column.getCanFilter()">
<div
v-if="
header.column.columnDef.meta?.filterVariant ===
'range'
"
class="filter-row"
>
<input
type="number"
class="filter-input"
:min="
header.column.getFacetedMinMaxValues()?.[0] ?? ''
"
:max="
header.column.getFacetedMinMaxValues()?.[1] ?? ''
"
:value="
(
header.column.getFilterValue() as
| [number, number]
| undefined
)?.[0] ?? ''
"
:placeholder="`Min${header.column.getFacetedMinMaxValues()?.[0] !== undefined ? ` (${header.column.getFacetedMinMaxValues()?.[0]})` : ''}`"
@input="
(event) =>
debounceSet(header.column.id + '-min', () =>
updateRangeFilter(
header.column,
0,
(event.target as HTMLInputElement).value,
),
)
"
/>
<input
type="number"
class="filter-input"
:min="
header.column.getFacetedMinMaxValues()?.[0] ?? ''
"
:max="
header.column.getFacetedMinMaxValues()?.[1] ?? ''
"
:value="
(
header.column.getFilterValue() as
| [number, number]
| undefined
)?.[1] ?? ''
"
:placeholder="`Max${header.column.getFacetedMinMaxValues()?.[1] !== undefined ? ` (${header.column.getFacetedMinMaxValues()?.[1]})` : ''}`"
@input="
(event) =>
debounceSet(header.column.id + '-max', () =>
updateRangeFilter(
header.column,
1,
(event.target as HTMLInputElement).value,
),
)
"
/>
</div>
<select
v-else-if="
header.column.columnDef.meta?.filterVariant ===
'select'
"
class="filter-select"
:value="
(header.column.getFilterValue() ?? '').toString()
"
@change="
header.column.setFilterValue(
($event.target as HTMLSelectElement).value,
)
"
>
<option value="">All</option>
<option
v-for="value in sortedUniqueValues(header.column)"
:key="String(value)"
:value="String(value)"
>
{{ String(value) }}
</option>
</select>
<template v-else>
<datalist :id="header.column.id + 'list'">
<option
v-for="value in sortedUniqueValues(header.column)"
:key="String(value)"
:value="String(value)"
/>
</datalist>
<input
type="text"
class="filter-select"
:list="header.column.id + 'list'"
:value="
(header.column.getFilterValue() ?? '') as string
"
:placeholder="`Search (${header.column.getFacetedUniqueValues().size})`"
@input="
(event) =>
debounceSet(header.column.id, () =>
header.column.setFilterValue(
(event.target as HTMLInputElement).value,
),
)
"
/>
</template>
</div>
</div>
</div>
<div
v-if="header.column.getCanResize()"
:class="`resizer ${header.column.getIsResizing() ? 'isResizing' : ''}`"
@dblclick="header.column.resetSize()"
@mousedown="header.getResizeHandler()($event)"
@touchstart="header.getResizeHandler()($event)"
/>
</template>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in table.getTopRows()"
:key="row.id"
class="pinned-row"
:style="rowStyle(row)"
>
<td
v-for="cell in row.getVisibleCells()"
:key="cell.id"
:style="cellStyle(cell)"
:class="cellClass(cell)"
>
<FlexRender :cell="cell" />
</td>
</tr>
<tr v-for="row in table.getCenterRows()" :key="row.id">
<td
v-for="cell in row.getVisibleCells()"
:key="cell.id"
:style="cellStyle(cell)"
:class="cellClass(cell)"
>
<template v-if="cell.column.id === 'select'">
<div class="column-toggle-row">
<input
type="checkbox"
:checked="cell.row.getIsSelected()"
:disabled="!cell.row.getCanSelect()"
:indeterminate="cell.row.getIsSomeSelected()"
@change="cell.row.getToggleSelectedHandler()?.($event)"
/>
<button
class="pin-button"
@click="
cell.row.pin(
cell.row.getIsPinned() === 'top' ? false : 'top',
)
"
>
{{ cell.row.getIsPinned() === 'top' ? 'Pinned' : 'Pin' }}
</button>
</div>
</template>
<template v-else-if="cell.column.id === 'firstName'">
<div :style="{ paddingLeft: `${cell.row.depth * 1.5}rem` }">
<button
v-if="cell.row.getCanExpand()"
@click="cell.row.getToggleExpandedHandler()?.()"
style="cursor: pointer; margin-right: 0.25rem"
>
{{ cell.row.getIsExpanded() ? 'v' : '>' }}
</button>
<span v-else style="margin-right: 0.25rem">-</span>
<FlexRender :cell="cell" />
</div>
</template>
<template v-else-if="cell.getIsGrouped()">
<button
@click="cell.row.getToggleExpandedHandler()?.()"
:style="{
cursor: cell.row.getCanExpand() ? 'pointer' : 'normal',
}"
>
{{ cell.row.getIsExpanded() ? 'v' : '>' }}
<FlexRender :cell="cell" />
({{ cell.row.subRows.length.toLocaleString() }})
</button>
</template>
<FlexRender v-else :cell="cell" />
</td>
</tr>
<tr
v-for="row in table.getBottomRows()"
:key="row.id"
class="pinned-row"
:style="rowStyle(row)"
>
<td
v-for="cell in row.getVisibleCells()"
:key="cell.id"
:style="cellStyle(cell)"
:class="cellClass(cell)"
>
<FlexRender :cell="cell" />
</td>
</tr>
</tbody>
</table>
</div>
<div class="spacer-sm" />
<div class="controls">
<button
class="demo-button demo-button-sm"
@click="table.setPageIndex(0)"
:disabled="!table.getCanPreviousPage()"
>
<<
</button>
<button
class="demo-button demo-button-sm"
@click="table.previousPage()"
:disabled="!table.getCanPreviousPage()"
>
<
</button>
<button
class="demo-button demo-button-sm"
@click="table.nextPage()"
:disabled="!table.getCanNextPage()"
>
>
</button>
<button
class="demo-button demo-button-sm"
@click="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"
@input="
table.setPageIndex(
($event.target as HTMLInputElement).value
? Number(($event.target as HTMLInputElement).value) - 1
: 0,
)
"
class="page-size-input"
/>
</span>
<select
:value="table.atoms.pagination.get().pageSize"
@change="
table.setPageSize(Number(($event.target as HTMLSelectElement).value))
"
>
<option
v-for="pageSize in [10, 20, 30, 50, 100]"
:key="pageSize"
:value="pageSize"
>
Show {{ pageSize }}
</option>
</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>
</template>