Docs
Cloudflare
Railway
CodeRabbit
OpenRouter
SerpAPI
Netlify
Clerk
AG Grid
WorkOS
Electric
Sentry
Prisma
Unkey
Cloudflare
Railway
CodeRabbit
OpenRouter
SerpAPI
Netlify
Clerk
AG Grid
WorkOS
Electric
Sentry
Prisma
Unkey
Table API Reference
Column API Reference
Row API Reference
Cell API Reference
Header API Reference
Features API Reference
Static Functions API Reference

Vue Example: Kitchen Sink

<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')"
                        >
                          &lt;
                        </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')"
                        >
                          &gt;
                        </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()"
      >
        &lt;&lt;
      </button>
      <button
        class="demo-button demo-button-sm"
        @click="table.previousPage()"
        :disabled="!table.getCanPreviousPage()"
      >
        &lt;
      </button>
      <button
        class="demo-button demo-button-sm"
        @click="table.nextPage()"
        :disabled="!table.getCanNextPage()"
      >
        &gt;
      </button>
      <button
        class="demo-button demo-button-sm"
        @click="table.setPageIndex(table.getPageCount() - 1)"
        :disabled="!table.getCanNextPage()"
      >
        &gt;&gt;
      </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')"
                        >
                          &lt;
                        </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')"
                        >
                          &gt;
                        </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()"
      >
        &lt;&lt;
      </button>
      <button
        class="demo-button demo-button-sm"
        @click="table.previousPage()"
        :disabled="!table.getCanPreviousPage()"
      >
        &lt;
      </button>
      <button
        class="demo-button demo-button-sm"
        @click="table.nextPage()"
        :disabled="!table.getCanNextPage()"
      >
        &gt;
      </button>
      <button
        class="demo-button demo-button-sm"
        @click="table.setPageIndex(table.getPageCount() - 1)"
        :disabled="!table.getCanNextPage()"
      >
        &gt;&gt;
      </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>