๐ŸŸข Table


Basic

use NTable component to create a powerful table and datagrids built using Tanstack. Read more about the Tanstack Table documentation.

PropTypeDefaultDescription
columnsArray[]Table columns.
dataArray[]Table data.
First NameLast NameAgeVisitsStatusProfile Progress
BennyErnser38361complicated14
JoannieLubowitz-Emmerich40298single24
BettyDooley20720single30
GwenCarroll2420complicated69
CandiceGottlieb4851complicated57
<script setup lang="ts">
import type { ColumnDef } from '@tanstack/vue-table'
import type { Person } from './makeData'
import makeData from './makeData'

const data = ref(makeData(5))

const columns: ColumnDef<Person>[] = [
  {
    header: 'First Name',
    accessorKey: 'firstName',
  },
  {
    header: 'Last Name',
    accessorKey: 'lastName',
  },
  {
    header: 'Age',
    accessorKey: 'age',
  },
  {
    header: 'Visits',
    accessorKey: 'visits',
  },
  {
    header: 'Status',
    accessorKey: 'status',
  },
  {
    header: 'Profile Progress',
    accessorKey: 'progress',
  },
]
</script>

<template>
  <NTable
    :columns
    :data
  />
</template>
import { faker } from '@faker-js/faker'

export interface Person {
  id: string
  username: string
  email: string
  firstName?: string
  lastName?: string
  avatar?: string
  age: number
  visits: number
  progress: number
  status: 'relationship' | 'complicated' | 'single'
  subRows?: Person[]
}

function range(len: number) {
  const arr: number[] = []
  for (let i = 0; i < len; i++)
    arr.push(i)

  return arr
}

function newPerson(): Person {
  return {
    id: faker.database.mongodbObjectId(),
    username: faker.internet.userName(),
    email: faker.internet.email(),
    firstName: faker.person.firstName(),
    lastName: faker.person.lastName(),
    avatar: faker.image.avatarGitHub(),
    age: faker.number.int(40),
    visits: faker.number.int(1000),
    progress: faker.number.int(100),
    status: faker.helpers.shuffle<Person['status']>([
      'relationship',
      'complicated',
      'single',
    ])[0]!,
  }
}

export function makeData(...lens: number[]) {
  const makeDataLevel = (depth = 0): Person[] => {
    const len = lens[depth]!
    return range(len).map((): Person => {
      return {
        ...newPerson(),
        subRows: lens[depth + 1] ? makeDataLevel(depth + 1) : undefined,
      }
    })
  }

  return makeDataLevel()
}

export default makeData

Row Selection

Row selection allows you to select rows in the table. This is useful when you want to select rows in the table. Read more about row selection in the Tanstack Row Selection documentation.

PropTypeDefaultDescription
modelValueArray[]Selected rows.
enableRowSelectionBooleanfalseEnable row selection.
enableMultiRowSelectionBooleanfalseEnable multiple row selection.
rowIdStringidRow id to uniquely identify each row.
enableSubRowSelectionBooleanfalseEnable sub row selection.
@selectEventEmitted when a row is selected.
@select-allEventEmitted when all rows are selected.
First NameLast NameAgeVisitsStatusProfile Progress
RhiannonWaters2722complicated30
ChadrickArmstrong21683complicated63
DuncanReichert0999complicated23
ColleenHammes35869relationship27
ReynaBlanda16632relationship32
ImogeneRunolfsson20864relationship82
HalleGusikowski17256complicated20
WeldonBins7439relationship84
AmariKuvalis20480single85
EleonoreDietrich21167relationship63
of row(s) selected.
<script setup lang="ts">
import type { ColumnDef, Table } from '@tanstack/vue-table'
import type { Person } from './makeData'
import makeData from './makeData'

const data = ref(makeData(10))

const columns: ColumnDef<Person>[] = [
  {
    header: 'First Name',
    accessorKey: 'firstName',
  },
  {
    header: 'Last Name',
    accessorKey: 'lastName',
  },
  {
    header: 'Age',
    accessorKey: 'age',
  },
  {
    header: 'Visits',
    accessorKey: 'visits',
  },
  {
    header: 'Status',
    accessorKey: 'status',
  },
  {
    header: 'Profile Progress',
    accessorKey: 'progress',
  },
]

const select = ref()
const table = ref<Table<Person>>()
</script>

<template>
  <div class="flex flex-col space-y-4">
    <NTable
      ref="table"
      v-model="select"
      :columns
      :data
      enable-row-selection
    />

    <div
      class="flex items-center justify-between px-2"
    >
      <div
        class="flex-1 text-sm text-muted"
      >
        {{ table?.getFilteredSelectedRowModel().rows.length }} of
        {{ table?.getFilteredRowModel().rows.length }} row(s) selected.
      </div>
    </div>
  </div>
</template>
import { faker } from '@faker-js/faker'

export interface Person {
  id: string
  username: string
  email: string
  firstName?: string
  lastName?: string
  avatar?: string
  age: number
  visits: number
  progress: number
  status: 'relationship' | 'complicated' | 'single'
  subRows?: Person[]
}

function range(len: number) {
  const arr: number[] = []
  for (let i = 0; i < len; i++)
    arr.push(i)

  return arr
}

function newPerson(): Person {
  return {
    id: faker.database.mongodbObjectId(),
    username: faker.internet.userName(),
    email: faker.internet.email(),
    firstName: faker.person.firstName(),
    lastName: faker.person.lastName(),
    avatar: faker.image.avatarGitHub(),
    age: faker.number.int(40),
    visits: faker.number.int(1000),
    progress: faker.number.int(100),
    status: faker.helpers.shuffle<Person['status']>([
      'relationship',
      'complicated',
      'single',
    ])[0]!,
  }
}

export function makeData(...lens: number[]) {
  const makeDataLevel = (depth = 0): Person[] => {
    const len = lens[depth]!
    return range(len).map((): Person => {
      return {
        ...newPerson(),
        subRows: lens[depth + 1] ? makeDataLevel(depth + 1) : undefined,
      }
    })
  }

  return makeDataLevel()
}

export default makeData

Loading

Loading allows you to show a loading progress indicator in the table. This is useful when you want to show a loading progress indicator in the table.

PropTypeDefaultDescription
loadingBooleanfalseLoading state.
First NameLast NameAgeVisitsStatusProfile Progress
HettieRenner26538relationship25
LincolnStokes15135single42
AllisonProsacco14687relationship99
EldridgeBoyle5186complicated14
TremayneAbbott33861single28
<script setup lang="ts">
import type { ColumnDef } from '@tanstack/vue-table'
import type { Person } from './makeData'
import makeData from './makeData'

const data = ref(makeData(5))

const columns: ColumnDef<Person>[] = [
  {
    header: 'First Name',
    accessorKey: 'firstName',
  },
  {
    header: 'Last Name',
    accessorKey: 'lastName',
  },
  {
    header: 'Age',
    accessorKey: 'age',
  },
  {
    header: 'Visits',
    accessorKey: 'visits',
  },
  {
    header: 'Status',
    accessorKey: 'status',
  },
  {
    header: 'Profile Progress',
    accessorKey: 'progress',
  },
]

const loading = ref(true)
</script>

<template>
  <div class="flex flex-col space-y-2">
    <NCheckbox
      v-model:checked="loading"
      label="Loading"
    />

    <NTable
      :loading
      :columns
      :data
    />
  </div>
</template>

Pagination

Pagination allows you to paginate rows in the table. This is useful when you want to paginate rows in the table. Read more about pagination in the Tanstack Pagination documentation.

PropTypeDefaultDescription
v-model:paginationObject{pageIndex: 0, pageSize: 10}Pagination default configuration.
manualPaginationBooleanfalseEnable manual pagination.
First NameLast NameAgeVisitsStatusProfile Progress
LeilaniMcLaughlin4218complicated84
EnriqueSipes16676complicated50
TryciaConn29660relationship32
JesseGusikowski18538relationship56
LilianLynch6715single99
Page 1 of
<script setup lang="ts">
import type { ColumnDef, Table } from '@tanstack/vue-table'
import type { Person } from './makeData'
import makeData from './makeData'

const data = ref(makeData(100))

const columns: ColumnDef<Person>[] = [
  {
    header: 'First Name',
    accessorKey: 'firstName',
  },
  {
    header: 'Last Name',
    accessorKey: 'lastName',
  },
  {
    header: 'Age',
    accessorKey: 'age',
  },
  {
    header: 'Visits',
    accessorKey: 'visits',
  },
  {
    header: 'Status',
    accessorKey: 'status',
  },
  {
    header: 'Profile Progress',
    accessorKey: 'progress',
  },
]

const pagination = ref({
  pageSize: 5,
  pageIndex: 0,
})

const table = ref<Table<Person>>()
</script>

<template>
  <div class="flex flex-col space-y-4">
    <!-- table -->
    <NTable
      ref="table"
      v-model:pagination="pagination"
      :columns
      :data
    />

    <!-- pagination -->
    <div
      class="flex items-center justify-between px-2"
    >
      <div
        class="flex items-center justify-center text-sm font-medium"
      >
        Page {{ (table?.getState().pagination.pageIndex ?? 0) + 1 }} of
        {{ table?.getPageCount().toLocaleString() }}
      </div>

      <NPagination
        :page="(table?.getState().pagination.pageIndex ?? 0) + 1"
        :total="table?.getFilteredRowModel().rows.length"
        show-edges
        :items-per-page="table?.getState().pagination.pageSize"
        @update:page="table?.setPageIndex($event - 1)"
      />
    </div>
  </div>
</template>
import { faker } from '@faker-js/faker'

export interface Person {
  id: string
  username: string
  email: string
  firstName?: string
  lastName?: string
  avatar?: string
  age: number
  visits: number
  progress: number
  status: 'relationship' | 'complicated' | 'single'
  subRows?: Person[]
}

function range(len: number) {
  const arr: number[] = []
  for (let i = 0; i < len; i++)
    arr.push(i)

  return arr
}

function newPerson(): Person {
  return {
    id: faker.database.mongodbObjectId(),
    username: faker.internet.userName(),
    email: faker.internet.email(),
    firstName: faker.person.firstName(),
    lastName: faker.person.lastName(),
    avatar: faker.image.avatarGitHub(),
    age: faker.number.int(40),
    visits: faker.number.int(1000),
    progress: faker.number.int(100),
    status: faker.helpers.shuffle<Person['status']>([
      'relationship',
      'complicated',
      'single',
    ])[0]!,
  }
}

export function makeData(...lens: number[]) {
  const makeDataLevel = (depth = 0): Person[] => {
    const len = lens[depth]!
    return range(len).map((): Person => {
      return {
        ...newPerson(),
        subRows: lens[depth + 1] ? makeDataLevel(depth + 1) : undefined,
      }
    })
  }

  return makeDataLevel()
}

export default makeData

Sorting

Sorting allows you to sort columns in ascending or descending order. This is useful when you want to sort columns in the table. Read more about sorting in the Tanstack Sorting documentation.

Status
MableWalter16550complicated15
VedaWilkinson25877single19
CarlieRunte28174relationship55
WillisKautzer9202relationship57
TreverBashirian-Skiles37963relationship25
WhitneyDare20851complicated61
LolaHahn21867relationship54
WernerAdams37327single87
ValentinKautzer4040relationship88
AlvertaHowe23626single22
<script setup lang="ts">
import type { ColumnDef } from '@tanstack/vue-table'
import type { Person } from './makeData'
import makeData from './makeData'

const data = ref(makeData(10))

const columns: ColumnDef<Person>[] = [
  {
    header: 'First Name',
    accessorKey: 'firstName',
  },
  {
    header: 'Last Name',
    accessorKey: 'lastName',
  },
  {
    header: 'Age',
    accessorKey: 'age',
  },
  {
    header: 'Visits',
    accessorKey: 'visits',
  },
  {
    header: 'Status',
    accessorKey: 'status',
    enableSorting: false,
  },
  {
    header: 'Profile Progress',
    accessorKey: 'progress',
  },
]
</script>

<template>
  <NTable
    :columns
    :data
    enable-sorting
    enable-multi-sort
  />
</template>
import { faker } from '@faker-js/faker'

export interface Person {
  id: string
  username: string
  email: string
  firstName?: string
  lastName?: string
  avatar?: string
  age: number
  visits: number
  progress: number
  status: 'relationship' | 'complicated' | 'single'
  subRows?: Person[]
}

function range(len: number) {
  const arr: number[] = []
  for (let i = 0; i < len; i++)
    arr.push(i)

  return arr
}

function newPerson(): Person {
  return {
    id: faker.database.mongodbObjectId(),
    username: faker.internet.userName(),
    email: faker.internet.email(),
    firstName: faker.person.firstName(),
    lastName: faker.person.lastName(),
    avatar: faker.image.avatarGitHub(),
    age: faker.number.int(40),
    visits: faker.number.int(1000),
    progress: faker.number.int(100),
    status: faker.helpers.shuffle<Person['status']>([
      'relationship',
      'complicated',
      'single',
    ])[0]!,
  }
}

export function makeData(...lens: number[]) {
  const makeDataLevel = (depth = 0): Person[] => {
    const len = lens[depth]!
    return range(len).map((): Person => {
      return {
        ...newPerson(),
        subRows: lens[depth + 1] ? makeDataLevel(depth + 1) : undefined,
      }
    })
  }

  return makeDataLevel()
}

export default makeData

Visibility

Visibility allows you to show or hide columns in the table. This is useful when you want to show or hide columns in the table. Read more about visibility in the Tanstack Visibility documentation.

First NameLast NameAgeVisitsStatusProfile Progress
DannieSchmeler22455single54
ChelseyCruickshank-Aufderhar37981relationship61
FilibertoDaugherty2756relationship10
DannyRogahn0510relationship24
SolonAbernathy19754complicated13
<script setup lang="ts">
import type { ColumnDef, Table } from '@tanstack/vue-table'
import type { Person } from './makeData'
import makeData from './makeData'

const data = ref(makeData(5))

const columns: ColumnDef<Person>[] = [
  {
    header: 'First Name',
    accessorKey: 'firstName',
  },
  {
    header: 'Last Name',
    accessorKey: 'lastName',
  },
  {
    header: 'Age',
    accessorKey: 'age',
  },
  {
    header: 'Visits',
    accessorKey: 'visits',
  },
  {
    header: 'Status',
    accessorKey: 'status',
  },
  {
    header: 'Profile Progress',
    accessorKey: 'progress',
  },
]

const table = ref<Table<Person>>()

const columnVisibility = ref({})
</script>

<template>
  <div>
    <div class="flex flex-wrap gap-4">
      <NCheckbox
        v-for="tableColumn in table?.getAllLeafColumns()"
        :key="tableColumn.id"
        :checked="tableColumn.getIsVisible()"
        :label="tableColumn.id"
        @update:checked="tableColumn.toggleVisibility()"
      />
    </div>

    <NSeparator />

    <NTable
      ref="table"
      v-model:column-visibility="columnVisibility"
      :columns
      :data
    />
  </div>
</template>
import { faker } from '@faker-js/faker'

export interface Person {
  id: string
  username: string
  email: string
  firstName?: string
  lastName?: string
  avatar?: string
  age: number
  visits: number
  progress: number
  status: 'relationship' | 'complicated' | 'single'
  subRows?: Person[]
}

function range(len: number) {
  const arr: number[] = []
  for (let i = 0; i < len; i++)
    arr.push(i)

  return arr
}

function newPerson(): Person {
  return {
    id: faker.database.mongodbObjectId(),
    username: faker.internet.userName(),
    email: faker.internet.email(),
    firstName: faker.person.firstName(),
    lastName: faker.person.lastName(),
    avatar: faker.image.avatarGitHub(),
    age: faker.number.int(40),
    visits: faker.number.int(1000),
    progress: faker.number.int(100),
    status: faker.helpers.shuffle<Person['status']>([
      'relationship',
      'complicated',
      'single',
    ])[0]!,
  }
}

export function makeData(...lens: number[]) {
  const makeDataLevel = (depth = 0): Person[] => {
    const len = lens[depth]!
    return range(len).map((): Person => {
      return {
        ...newPerson(),
        subRows: lens[depth + 1] ? makeDataLevel(depth + 1) : undefined,
      }
    })
  }

  return makeDataLevel()
}

export default makeData

Global Filtering

Global filtering allows you to filter rows based on the value entered in the filter input. This is useful when you want to filter rows in the table. Read more about global filtering in the Tanstack Global Filtering documentation.

First NameLast NameAgeVisitsStatusProfile Progress
CaleighArmstrong29262single12
GuiseppeHaley9576relationship6
TedTillman29971single80
HillardSpinka-Bergstrom20873complicated67
CandiceHermann30941single84
AldaPollich35728relationship13
RoryConroy2285complicated69
JoanieKing7367single42
DagmarDaugherty2433complicated57
AleenLittle33531complicated6
<script setup lang="ts">
import type { ColumnDef } from '@tanstack/vue-table'
import type { Person } from './makeData'
import makeData from './makeData'

const data = ref(makeData(10))

const columns: ColumnDef<Person>[] = [
  {
    header: 'First Name',
    accessorKey: 'firstName',
  },
  {
    header: 'Last Name',
    accessorKey: 'lastName',
  },
  {
    header: 'Age',
    accessorKey: 'age',
  },
  {
    header: 'Visits',
    accessorKey: 'visits',
  },
  {
    header: 'Status',
    accessorKey: 'status',
  },
  {
    header: 'Profile Progress',
    accessorKey: 'progress',
  },
]

const search = ref('')
</script>

<template>
  <div class="flex flex-col space-y-4">
    <div class="flex flex-wrap items-center justify-between gap-4">
      <NInput
        v-model="search"
        placeholder="Search"
        :una="{
          inputWrapper: 'w-full md:w-80',
        }"
      />

      <NButton
        label="Add new"
        leading="i-radix-icons-plus"
        class="w-full md:w-auto"
      />
    </div>

    <NTable
      :columns
      :global-filter="search"
      :data
    />
  </div>
</template>
import { faker } from '@faker-js/faker'

export interface Person {
  id: string
  username: string
  email: string
  firstName?: string
  lastName?: string
  avatar?: string
  age: number
  visits: number
  progress: number
  status: 'relationship' | 'complicated' | 'single'
  subRows?: Person[]
}

function range(len: number) {
  const arr: number[] = []
  for (let i = 0; i < len; i++)
    arr.push(i)

  return arr
}

function newPerson(): Person {
  return {
    id: faker.database.mongodbObjectId(),
    username: faker.internet.userName(),
    email: faker.internet.email(),
    firstName: faker.person.firstName(),
    lastName: faker.person.lastName(),
    avatar: faker.image.avatarGitHub(),
    age: faker.number.int(40),
    visits: faker.number.int(1000),
    progress: faker.number.int(100),
    status: faker.helpers.shuffle<Person['status']>([
      'relationship',
      'complicated',
      'single',
    ])[0]!,
  }
}

export function makeData(...lens: number[]) {
  const makeDataLevel = (depth = 0): Person[] => {
    const len = lens[depth]!
    return range(len).map((): Person => {
      return {
        ...newPerson(),
        subRows: lens[depth + 1] ? makeDataLevel(depth + 1) : undefined,
      }
    })
  }

  return makeDataLevel()
}

export default makeData

Column Filtering

Filtering allows you to filter columns based on the value entered in the filter input. This is useful when you want to filter columns in the table. Read more about filtering in the Tanstack Column Filtering documentation.

First NameLast NameAgeVisitsStatusProfile Progress
ClemensCorkery32237single72
JamaalMacejkovic-Smith18248single82
KarenHamill1244single45
MayKlocko16409single52
NoreneLang2834complicated9
<script setup lang="ts">
import type { ColumnDef } from '@tanstack/vue-table'
import type { Person } from './makeData'
import makeData from './makeData'

const data = ref(makeData(5))

const columns: ColumnDef<Person>[] = [
  {
    header: 'First Name',
    accessorKey: 'firstName',
  },
  {
    header: 'Last Name',
    accessorKey: 'lastName',
  },
  {
    header: 'Age',
    accessorKey: 'age',
  },
  {
    header: 'Visits',
    accessorKey: 'visits',
    enableColumnFilter: false,
  },
  {
    header: 'Status',
    accessorKey: 'status',
  },
  {
    header: 'Profile Progress',
    accessorKey: 'progress',
  },
]
</script>

<template>
  <NTable
    :columns
    enable-column-filters
    :data
  />
</template>
import { faker } from '@faker-js/faker'

export interface Person {
  id: string
  username: string
  email: string
  firstName?: string
  lastName?: string
  avatar?: string
  age: number
  visits: number
  progress: number
  status: 'relationship' | 'complicated' | 'single'
  subRows?: Person[]
}

function range(len: number) {
  const arr: number[] = []
  for (let i = 0; i < len; i++)
    arr.push(i)

  return arr
}

function newPerson(): Person {
  return {
    id: faker.database.mongodbObjectId(),
    username: faker.internet.userName(),
    email: faker.internet.email(),
    firstName: faker.person.firstName(),
    lastName: faker.person.lastName(),
    avatar: faker.image.avatarGitHub(),
    age: faker.number.int(40),
    visits: faker.number.int(1000),
    progress: faker.number.int(100),
    status: faker.helpers.shuffle<Person['status']>([
      'relationship',
      'complicated',
      'single',
    ])[0]!,
  }
}

export function makeData(...lens: number[]) {
  const makeDataLevel = (depth = 0): Person[] => {
    const len = lens[depth]!
    return range(len).map((): Person => {
      return {
        ...newPerson(),
        subRows: lens[depth + 1] ? makeDataLevel(depth + 1) : undefined,
      }
    })
  }

  return makeDataLevel()
}

export default makeData

Column Ordering

Column ordering allows you to reorder columns by dragging and dropping them. This is useful when you want to change the order of columns in the table. Read more about column ordering in the Tanstack Column Ordering documentation.

First NameLast NameAgeVisitsStatusProfile Progress
BrionnaLind31414single51
LillyHessel23828relationship3
AudreyVonRueden35118relationship6
MichaelBraun24815complicated12
KianaSmitham27444complicated17
<script setup lang="ts">
import type { ColumnDef, Table } from '@tanstack/vue-table'
import type { Person } from './makeData'
import { faker } from '@faker-js/faker'
import makeData from './makeData'

const data = ref(makeData(5))

const columns: ColumnDef<Person>[] = [
  {
    header: 'First Name',
    accessorKey: 'firstName',
  },
  {
    header: 'Last Name',
    accessorKey: 'lastName',
  },
  {
    header: () => 'Age',
    accessorKey: 'age',
  },
  {
    header: 'Visits',
    accessorKey: 'visits',
  },
  {
    header: 'Status',
    accessorKey: 'status',
  },
  {
    header: 'Profile Progress',
    accessorKey: 'progress',
  },
]

const table = ref<Table<Person>>()

function randomizeColumns() {
  table.value?.setColumnOrder(faker.helpers.shuffle(table.value?.getAllLeafColumns().map(d => d.id)))
}
</script>

<template>
  <NButton
    label="Randomize columns"
    leading="i-radix-icons-shuffle"
    class="mb-4"
    @click="randomizeColumns"
  />

  <!-- table -->
  <NTable
    ref="table"
    :columns
    :data
  />
</template>
import { faker } from '@faker-js/faker'

export interface Person {
  id: string
  username: string
  email: string
  firstName?: string
  lastName?: string
  avatar?: string
  age: number
  visits: number
  progress: number
  status: 'relationship' | 'complicated' | 'single'
  subRows?: Person[]
}

function range(len: number) {
  const arr: number[] = []
  for (let i = 0; i < len; i++)
    arr.push(i)

  return arr
}

function newPerson(): Person {
  return {
    id: faker.database.mongodbObjectId(),
    username: faker.internet.userName(),
    email: faker.internet.email(),
    firstName: faker.person.firstName(),
    lastName: faker.person.lastName(),
    avatar: faker.image.avatarGitHub(),
    age: faker.number.int(40),
    visits: faker.number.int(1000),
    progress: faker.number.int(100),
    status: faker.helpers.shuffle<Person['status']>([
      'relationship',
      'complicated',
      'single',
    ])[0]!,
  }
}

export function makeData(...lens: number[]) {
  const makeDataLevel = (depth = 0): Person[] => {
    const len = lens[depth]!
    return range(len).map((): Person => {
      return {
        ...newPerson(),
        subRows: lens[depth + 1] ? makeDataLevel(depth + 1) : undefined,
      }
    })
  }

  return makeDataLevel()
}

export default makeData

Column Pinning

Column pinning allows you to pin columns to the left or right of the table. This is useful when you have a large number of columns and you want to keep some columns in view while scrolling. Read more about column pinning in the Tanstack Column Pinning documentation.

StatusFirst NameLast NameAgeVisitsProfile Progress
singleHipolitoSteuber1191375
singleJazlynMurphy2912419
relationshipMavisBoyer3194767
complicatedBrigitteAltenwerth1793823
relationshipShayleeSchmitt421231
<script setup lang="ts">
import type { ColumnDef } from '@tanstack/vue-table'
import type { Person } from './makeData'
import makeData from './makeData'

const data = ref(makeData(5))

const columns: ColumnDef<Person>[] = [
  {
    header: 'First Name',
    accessorKey: 'firstName',
  },
  {
    header: 'Last Name',
    accessorKey: 'lastName',
  },
  {
    header: () => 'Age',
    accessorKey: 'age',
  },
  {
    header: 'Visits',
    accessorKey: 'visits',
  },
  {
    header: 'Status',
    accessorKey: 'status',
  },
  {
    header: 'Profile Progress',
    accessorKey: 'progress',
  },
]
</script>

<template>
  <NTable
    :columns
    :data
    :column-pinning="{
      left: ['status'],
      right: ['priority'],
    }"
  />
</template>
import { faker } from '@faker-js/faker'

export interface Person {
  id: string
  username: string
  email: string
  firstName?: string
  lastName?: string
  avatar?: string
  age: number
  visits: number
  progress: number
  status: 'relationship' | 'complicated' | 'single'
  subRows?: Person[]
}

function range(len: number) {
  const arr: number[] = []
  for (let i = 0; i < len; i++)
    arr.push(i)

  return arr
}

function newPerson(): Person {
  return {
    id: faker.database.mongodbObjectId(),
    username: faker.internet.userName(),
    email: faker.internet.email(),
    firstName: faker.person.firstName(),
    lastName: faker.person.lastName(),
    avatar: faker.image.avatarGitHub(),
    age: faker.number.int(40),
    visits: faker.number.int(1000),
    progress: faker.number.int(100),
    status: faker.helpers.shuffle<Person['status']>([
      'relationship',
      'complicated',
      'single',
    ])[0]!,
  }
}

export function makeData(...lens: number[]) {
  const makeDataLevel = (depth = 0): Person[] => {
    const len = lens[depth]!
    return range(len).map((): Person => {
      return {
        ...newPerson(),
        subRows: lens[depth + 1] ? makeDataLevel(depth + 1) : undefined,
      }
    })
  }

  return makeDataLevel()
}

export default makeData

Expandanding

Expanding allows you to expand rows to show additional information. This is useful when you want to show additional information about a row. Read more about expanding in the Tanstack Expanding documentation.

First NameLast NameAgeVisitsStatusProfile Progress
IbrahimJacobson27901relationship11
TrevorWelch26182relationship63
SaulSchamberger30110single54
KoleWatsica18854single2
ArnoldStamm29508complicated69
<script setup lang="ts">
import type { ColumnDef } from '@tanstack/vue-table'
import type { Person } from './makeData'
import makeData from './makeData'

const data = ref(makeData(5))

const columns: ColumnDef<Person>[] = [
  {
    header: 'First Name',
    accessorKey: 'firstName',
  },
  {
    header: 'Last Name',
    accessorKey: 'lastName',
  },
  {
    header: 'Age',
    accessorKey: 'age',
  },
  {
    header: 'Visits',
    accessorKey: 'visits',
  },
  {
    header: 'Status',
    accessorKey: 'status',
  },
  {
    header: 'Profile Progress',
    accessorKey: 'progress',
  },
]

const expanded = ref<Record<string, boolean>>({})
</script>

<template>
  <NTable
    v-model:expanded="expanded"
    :columns
    :data
  >
    <template #expanded="{ row }">
      <div class="p-4">
        <p class="text-sm text-muted">
          Object:
        </p>
        <p class="text-base">
          {{ row }}
        </p>
      </div>
    </template>
  </NTable>
</template>
import { faker } from '@faker-js/faker'

export interface Person {
  id: string
  username: string
  email: string
  firstName?: string
  lastName?: string
  avatar?: string
  age: number
  visits: number
  progress: number
  status: 'relationship' | 'complicated' | 'single'
  subRows?: Person[]
}

function range(len: number) {
  const arr: number[] = []
  for (let i = 0; i < len; i++)
    arr.push(i)

  return arr
}

function newPerson(): Person {
  return {
    id: faker.database.mongodbObjectId(),
    username: faker.internet.userName(),
    email: faker.internet.email(),
    firstName: faker.person.firstName(),
    lastName: faker.person.lastName(),
    avatar: faker.image.avatarGitHub(),
    age: faker.number.int(40),
    visits: faker.number.int(1000),
    progress: faker.number.int(100),
    status: faker.helpers.shuffle<Person['status']>([
      'relationship',
      'complicated',
      'single',
    ])[0]!,
  }
}

export function makeData(...lens: number[]) {
  const makeDataLevel = (depth = 0): Person[] => {
    const len = lens[depth]!
    return range(len).map((): Person => {
      return {
        ...newPerson(),
        subRows: lens[depth + 1] ? makeDataLevel(depth + 1) : undefined,
      }
    })
  }

  return makeDataLevel()
}

export default makeData

Grouping

Grouping allows you to group rows based on a column value. This is useful when you want to group rows in the table. Read more about grouping in the Tanstack Grouping documentation.

NameInfo
First NameLast NameAgeVisitsStatusProfile Progress
JessyD'Amore2270complicated52
KasandraKunde7777single0
BirdieBartell26271complicated76
DorisGoodwin13711relationship60
DerickGrimes741complicated5
<script setup lang="ts">
import type { ColumnDef } from '@tanstack/vue-table'
import type { Person } from './makeData'
import { makeData } from './makeData'

const data = ref(makeData(5))

const columns: ColumnDef<Person>[] = [
  {
    header: 'Name',
    enableSorting: false,
    columns: [
      {
        header: 'First Name',
        accessorKey: 'firstName',
      },
      {
        header: 'Last Name',
        accessorKey: 'lastName',
      },
    ],
  },
  {
    header: 'Info',
    columns: [
      {
        header: () => 'Age',
        accessorKey: 'age',
      },
      {
        header: 'Visits',
        accessorKey: 'visits',
      },
      {
        header: 'Status',
        accessorKey: 'status',
      },
      {
        header: 'Profile Progress',
        accessorKey: 'progress',
      },
    ],
  },
]

const sorting = ref()
</script>

<template>
  <NTable
    v-model:sorting="sorting"
    :columns
    :data
  />
</template>
import { faker } from '@faker-js/faker'

export interface Person {
  id: string
  username: string
  email: string
  firstName?: string
  lastName?: string
  avatar?: string
  age: number
  visits: number
  progress: number
  status: 'relationship' | 'complicated' | 'single'
  subRows?: Person[]
}

function range(len: number) {
  const arr: number[] = []
  for (let i = 0; i < len; i++)
    arr.push(i)

  return arr
}

function newPerson(): Person {
  return {
    id: faker.database.mongodbObjectId(),
    username: faker.internet.userName(),
    email: faker.internet.email(),
    firstName: faker.person.firstName(),
    lastName: faker.person.lastName(),
    avatar: faker.image.avatarGitHub(),
    age: faker.number.int(40),
    visits: faker.number.int(1000),
    progress: faker.number.int(100),
    status: faker.helpers.shuffle<Person['status']>([
      'relationship',
      'complicated',
      'single',
    ])[0]!,
  }
}

export function makeData(...lens: number[]) {
  const makeDataLevel = (depth = 0): Person[] => {
    const len = lens[depth]!
    return range(len).map((): Person => {
      return {
        ...newPerson(),
        subRows: lens[depth + 1] ? makeDataLevel(depth + 1) : undefined,
      }
    })
  }

  return makeDataLevel()
}

export default makeData

Server-side

Server-side allows you to fetch data from the server. This is useful when you want to fetch data from the server. Read more about server-side in the Tanstack Server-side documentation.

NameUrl
bulbasaurhttps://pokeapi.co/api/v2/pokemon/1/
ivysaurhttps://pokeapi.co/api/v2/pokemon/2/
venusaurhttps://pokeapi.co/api/v2/pokemon/3/
charmanderhttps://pokeapi.co/api/v2/pokemon/4/
charmeleonhttps://pokeapi.co/api/v2/pokemon/5/
Page 1 of 261
<script setup lang="ts">
import type { ColumnDef, Table } from '@tanstack/vue-table'

interface Pokemon {
  name: string
  url: string
}

interface ResourceMeta {
  count: number
  next: string | null
  previous: string | null
  results: Pokemon[]
}

const pagination = ref({
  pageSize: 5,
  pageIndex: 0,
})

const endpoint = computed (() => {
  const { pageSize, pageIndex } = pagination.value

  return `https://pokeapi.co/api/v2/pokemon?limit=${pageSize}&offset=${pageSize * pageIndex}`
})

const { data: resource, refresh, status } = await useLazyFetch<ResourceMeta>(endpoint)

const data = computed(() => {
  return resource.value?.results ?? []
})

const columns = ref<ColumnDef<Pokemon>[]>([
  {
    header: 'Name',
    accessorKey: 'name',
  },
  {
    header: 'Url',
    accessorKey: 'url',
  },
])

const pageCount = computed(() => {
  const { pageSize } = pagination.value

  return Math.ceil((resource.value?.count || 0) / pageSize)
})

const table = ref<Table<Pokemon>>()
</script>

<template>
  <div class="flex flex-col space-y-4">
    <!-- header -->
    <div class="flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
      <div class="flex items-center gap-x-2 sm:ml-auto">
        <NButton
          :loading="status === 'pending'"
          @click="refresh()"
        >
          Reload
        </NButton>
      </div>
    </div>

    <!-- table -->
    <NTable
      ref="table"
      v-model:pagination="pagination"
      manual-pagination
      :columns
      :loading="status === 'pending'"
      :page-count
      :data
    />

    <!-- footer -->
    <div
      class="flex items-center justify-end px-2"
    >
      <div class="flex items-center justify-between space-x-6 lg:space-x-8">
        <div
          class="hidden items-center justify-center text-sm font-medium sm:flex space-x-2"
        >
          <span class="text-nowrap">
            Rows per page
          </span>

          <NSelect
            :items="[5, 10, 20, 30, 40, 50]"
            :_select-trigger="{
              class: 'w-15',
            }"
            :model-value="table?.getState().pagination.pageSize"
            @update:model-value="table?.setPageSize($event as unknown as number)"
          />
        </div>

        <div
          class="flex items-center justify-center text-sm font-medium"
        >
          Page {{ (table?.getState().pagination.pageIndex ?? 0) + 1 }} of  {{ pageCount }}
        </div>

        <NPagination
          :page="(table?.getState().pagination.pageIndex ?? 0) + 1"
          :total="pageCount * pagination.pageSize"
          :show-list-item="false"
          :items-per-page="table?.getState().pagination.pageSize"
          @update:page="table?.setPageIndex($event - 1)"
        />
      </div>
    </div>
  </div>
</template>

Slots

You can use the following slots to customize the table.

NameDescriptionProps
{column}-filterColumn filter slot.column
{column}-headerColumn header slot.column
{column}-cellColumn cell slot.cell
{column}-footerColumn footer slot.column
headerHeader slot.table
bodyBody slot.table
rawRow slot.row
footerFooter slot.table
expandedExpanded slot.row
emptyEmpty slot.
loadingLoading slot.
Account
KD
Kevon Durgan
Dante72@hotmail.com
KevonDurganrelationship
50%
MF
Mazie Farrell
Luella92@gmail.com
MazieFarrellsingle
96%
PB
Paolo Barrows
Coby_Ratke@hotmail.com
PaoloBarrowsrelationship
48%
KK
Kennith Kunde
Katarina19@yahoo.com
KennithKunderelationship
42%
ER
Easter Reilly
Sylvia.Bode@gmail.com
EasterReillyrelationship
23%
CC
Courtney Collins
Dillon.Steuber@gmail.com
CourtneyCollinssingle
77%
AJ
Avery Johnson
Sebastian.Lowe12@gmail.com
AveryJohnsoncomplicated
47%
AR
Aubree Ratke
Wilma.Yundt87@gmail.com
AubreeRatkesingle
100%
DN
Declan Nikolaus
Hoyt98@hotmail.com
DeclanNikolauscomplicated
62%
JR
Julius Romaguera
Nathanael_Mraz@gmail.com
JuliusRomaguerarelationship
39%
Page 1 of
<script setup lang="ts">
import type { ColumnDef, Table } from '@tanstack/vue-table'
import { NAvatar } from '#components'
import { faker } from '@faker-js/faker'

import makeData, { type Person } from './makeData'

const data = ref(makeData(10))

const columns: ColumnDef<Person>[] = [
  {
    header: 'Account',
    accessorKey: 'account',
    accessorFn: (row) => {
      return {
        fullname: `${row.firstName} ${row.lastName}`,
        avatar: faker.image.avatar(),
        email: row.email,
      }
    },
    // you can customize the cell renderer like this as an alternative to slot ๐Ÿ˜‰
    cell: (info: any) => {
      const fullname = info.getValue().fullname

      return h('div', {
        class: 'flex items-center',
      }, [
        h(NAvatar, {
          src: info.getValue().avatar,
          alt: fullname,
        }),
        [
          h('div', {
            class: 'ml-2',
          }, [
            h('div', {
              class: 'text-sm font-semibold leading-none',
            }, fullname),
            h('span', {
              class: 'text-sm text-muted',
            }, info.getValue().email),
          ]),
        ],
      ])
    },
    enableSorting: false,
    enableColumnFilter: false,
  },
  {
    header: 'First Name',
    accessorKey: 'firstName',
  },
  {
    header: 'Last Name',
    accessorKey: 'lastName',
  },
  {
    header: 'Status',
    accessorKey: 'status',
  },
  {
    header: 'Progress',
    accessorKey: 'progress',
  },
]

const search = ref('')
const select = ref()

const table = ref<Table<Person>>()
</script>

<template>
  <div class="flex flex-col space-y-4">
    <!-- header -->
    <div class="flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
      <NInput
        v-model="search"
        leading="i-radix-icons-magnifying-glass"
        placeholder="Search"
        :una="{
          inputWrapper: 'w-full md:w-80',
        }"
      />

      <div class="flex items-center gap-x-2 sm:ml-auto">
        <NButton
          label="Rerender"
          btn="solid-gray"
          leading="i-radix-icons-update"
          class="w-full sm:w-auto sm:shrink-0 active:translate-y-0.5"
          @click="data = makeData(20_000)"
        />

        <NButton
          label="Add 1000"
          btn="solid-primary"
          leading="i-radix-icons-plus"
          class="w-full sm:w-auto sm:shrink-0 active:translate-y-0.5"
          @click="data = [...makeData(1_000), ...data]"
        />
      </div>
    </div>

    <!-- table -->
    <NTable
      ref="table"
      v-model="select"
      :columns
      :data
      :global-filter="search"
      enable-column-filters enable-row-selection enable-sorting
      row-id="username"
    >
      <!-- filters -->
      <template #status-filter="{ column }">
        <NSelect
          :items="['Relationship', 'Complicated', 'Single']"
          placeholder="All"
          :model-value="column.getFilterValue()"
          @update:model-value="column?.setFilterValue($event)"
        />
      </template>

      <template #progress-filter="{ column }">
        <div class="flex items-center space-x-2">
          <NInput
            type="number"
            placeholder="min"
            :model-value="column.getFilterValue()?.[0] ?? ''"
            @update:model-value="column?.setFilterValue((old: [number, number]) => [
              $event,
              old?.[1],
            ])"
          />

          <NInput
            type="number"
            placeholder="max"
            :model-value="column.getFilterValue()?.[1] ?? ''"
            @update:model-value="column?.setFilterValue((old: [number, number]) => [
              old?.[0],
              $event,
            ])"
          />
        </div>
      </template>
      <!-- end filter -->

      <!-- cells -->
      <template #status-cell="{ cell }">
        <NBadge
          :una="{
            badgeDefaultVariant: cell.row.original.status === 'relationship'
              ? 'badge-soft-success' : cell.row.original.status === 'single'
                ? 'badge-soft-info' : 'badge-soft-warning' }"
          class="capitalize"
          :label="cell.row.original.status"
        />
      </template>

      <template #progress-cell="{ cell }">
        <div class="flex items-center">
          <NProgress
            :model-value="cell.row.original.progress"
            :una="{
              progressRoot: cell.row.original.progress >= 85
                ? 'progress-success' : cell.row.original.progress >= 70
                  ? 'progress-info' : cell.row.original.progress >= 55
                    ? 'progress-warning' : 'progress-error' }"
          />
          <span class="ml-2 text-sm text-muted">{{ cell.row.original.progress }}%</span>
        </div>
      </template>
      <!-- end cell -->
    </NTable>

    <!-- footer -->
    <div
      class="flex items-center justify-between px-2"
    >
      <div
        class="hidden text-sm text-muted sm:block"
      >
        {{ table?.getFilteredSelectedRowModel().rows.length.toLocaleString() }} of
        {{ table?.getFilteredRowModel().rows.length.toLocaleString() }} row(s) selected.
      </div>
      <div class="flex items-center space-x-6 lg:space-x-8">
        <div
          class="hidden items-center justify-center text-sm font-medium sm:flex space-x-2"
        >
          <span class="text-nowrap">
            Rows per page
          </span>

          <NSelect
            :items="[10, 20, 30, 40, 50]"
            :_select-trigger="{
              class: 'w-15',
            }"
            :model-value="table?.getState().pagination.pageSize"
            @update:model-value="table?.setPageSize($event as unknown as number)"
          />
        </div>

        <div
          class="flex items-center justify-center text-sm font-medium"
        >
          Page {{ (table?.getState().pagination.pageIndex ?? 0) + 1 }} of
          {{ table?.getPageCount().toLocaleString() }}
        </div>

        <NPagination
          :page="(table?.getState().pagination.pageIndex ?? 0) + 1"
          :total="table?.getFilteredRowModel().rows.length"
          :show-list-item="false"
          :items-per-page="table?.getState().pagination.pageSize"
          @update:page="table?.setPageIndex($event - 1)"
        />
      </div>
    </div>
  </div>
</template>

Props

import type {
  ColumnDef,
  GroupColumnDef,
} from '@tanstack/vue-table'
import type { HTMLAttributes } from 'vue'

export interface NTableProps<TData, TValue> extends NTableRootProps {
  /**
   * @see https://tanstack.com/table/latest/docs/guide/data
   */
  data: TData[]
  /**
   * @see https://tanstack.com/table/latest/docs/api/core/column
   */
  columns: ColumnDef<TData, TValue>[] | GroupColumnDef<TData, TValue>[]
  /**
   * @see https://tanstack.com/table/latest/docs/api/core/table#getrowid
   */
  rowId?: string
  /**
   * @see https://tanstack.com/table/latest/docs/api/core/table#autoresetall
   */
  autoResetAll?: boolean
  /**
   * @see https://tanstack.com/table/latest/docs/api/features/row-selection#enablerowselection
   */
  enableRowSelection?: boolean
  /**
   * @see https://tanstack.com/table/latest/docs/api/features/row-selection#enablemultirowselection
   */
  enableMultiRowSelection?: boolean
  /**
   * @see https://tanstack.com/table/latest/docs/api/features/row-selection#enablesubrowselection
   */
  enableSubRowSelection?: boolean
  /**
   * @see https://tanstack.com/table/latest/docs/api/features/column-filtering#enablecolumnfilters
   */
  enableColumnFilters?: boolean
  /**
   * @see https://tanstack.com/table/latest/docs/api/features/sorting#enablesorting
   */
  enableSorting?: boolean
  /**
   * @see https://tanstack.com/table/latest/docs/api/features/sorting#enablemultisort
   */
  enableMultiSort?: boolean
  /**
   * @see https://tanstack.com/table/latest/docs/api/features/sorting#enablemultiremove
   */
  enableMultiRemove?: boolean
  /**
   * @see https://tanstack.com/table/latest/docs/api/features/sorting#enablesortingremoval
   */
  enableSortingRemoval?: boolean
  /**
   * @see https://tanstack.com/table/latest/docs/api/features/sorting#manualsorting
   */
  manualSorting?: boolean
  /**
   * @see https://tanstack.com/table/latest/docs/api/features/sorting#maxmultisortcolcount
   */
  maxMultiSortColCount?: number
  /**
   * @see https://tanstack.com/table/latest/docs/api/features/pagination#manualpagination
   */
  manualPagination?: boolean
  /**
   * @see https://tanstack.com/table/latest/docs/api/features/pagination#pagecount
   */
  pageCount?: number
  /**
   * @see https://tanstack.com/table/latest/docs/api/features/pagination#rowcount
   */
  rowCount?: number
  /**
   * @see https://tanstack.com/table/latest/docs/api/features/pagination#autoresetpageindex
   */
  autoResetPageIndex?: boolean
  /**
   * @see https://tanstack.com/table/latest/docs/api/features/sorting#sortingfns
   */
  sortingFns?: Record<string, (a: any, b: any) => number>
  /**
   * @see https://tanstack.com/table/latest/docs/api/features/sorting#sortdescfirst-1
   */
  sortDescFirst?: boolean
  /**
   * @see https://tanstack.com/table/latest/docs/api/features/sorting#ismultisortevent
   */
  isMultiSortEvent?: (e: unknown) => boolean

  // sub-components props
  _tableHead?: NTableHeadProps
  _tableHeader?: NTableHeaderProps
  _tableFooter?: NTableFooterProps
  _tableBody?: NTableBodyProps
  _tableCaption?: NTableCaptionProps
  _tableRow?: NTableRowProps
  _tableCell?: NTableCellProps
  _tableEmpty?: NTableEmptyProps
  _tableLoading?: NTableLoadingProps

  loading?: boolean

  /**
   * `UnaUI` preset configuration
   *
   * @see https://github.com/una-ui/una-ui/blob/main/packages/preset/src/_shortcuts/table.ts
   */
  una?: NTableUnaProps
}

export interface NTableRootProps {
  class?: HTMLAttributes['class']

  una?: Pick<NTableUnaProps, 'tableRoot' | 'tableRootWrapper'>
}

export interface NTableBodyProps {
  class?: HTMLAttributes['class']

  una?: Pick<NTableUnaProps, 'tableBody'>
}

export interface NTableHeadProps {
  class?: HTMLAttributes['class']

  dataPinned?: 'left' | 'right' | false

  una?: Pick<NTableUnaProps, 'tableHead'>
}

export interface NTableHeaderProps {
  class?: HTMLAttributes['class']

  una?: Pick<NTableUnaProps, 'tableHeader'>
}

export interface NTableFooterProps {
  class?: HTMLAttributes['class']

  una?: Pick<NTableUnaProps, 'tableFooter'>
}

export interface NTableRowProps {
  class?: HTMLAttributes['class']

  una?: Pick<NTableUnaProps, 'tableRow'>
}

export interface NTableCellProps {
  class?: HTMLAttributes['class']

  dataPinned?: 'left' | 'right' | false

  una?: Pick<NTableUnaProps, 'tableCell'>
}

export interface NTableEmptyProps {
  class?: HTMLAttributes['class']
  colspan?: number

  _tableCell?: NTableCellProps
  _tableRow?: NTableRowProps

  una?: Pick<NTableUnaProps, 'tableEmpty'>
}

export interface NTableLoadingProps {
  enabled?: boolean
  class?: HTMLAttributes['class']
  colspan?: number

  _tableCell?: NTableCellProps
  _tableRow?: NTableRowProps

  una?: Pick<NTableUnaProps, 'tableLoading'>
}

export interface NTableCaptionProps {
  class?: HTMLAttributes['class']

  una?: Pick<NTableUnaProps, 'tableCaption'>
}

interface NTableUnaProps {
  tableRoot?: HTMLAttributes['class']
  tableRootWrapper?: HTMLAttributes['class']
  tableBody?: HTMLAttributes['class']
  tableHead?: HTMLAttributes['class']
  tableHeader?: HTMLAttributes['class']
  tableFooter?: HTMLAttributes['class']
  tableRow?: HTMLAttributes['class']
  tableCell?: HTMLAttributes['class']
  tableCaption?: HTMLAttributes['class']
  tableEmpty?: HTMLAttributes['class']
  tableLoading?: HTMLAttributes['class']
}

Presets

type TablePrefix = 'table'

export const staticTable: Record<`${TablePrefix}-${string}` | TablePrefix, string> = {
  // config
  'table-default-variant': 'table-solid-gray',
  'table': '',

  // table-root
  'table-root': 'w-full caption-bottom text-sm',
  'table-root-wrapper': 'relative w-full overflow-x-auto overflow-y-hidden border border-base rounded-md',
  'table-body': '[&_tr:last-child]:border-0 border-base',
  'table-caption': 'mt-4 text-sm text-muted',

  // table-head
  'table-head': 'h-12 px-4 text-left align-middle font-medium text-muted [&:has([role=checkbox])]:pr-0',
  'table-head-pinned': 'sticky bg-base',
  'table-head-pinned-left': 'left-0',
  'table-head-pinned-right': 'right-0',

  // table-header
  'table-header': '[&_tr]:border-b border-base',

  // table-row
  'table-row': 'border-b border-base hover:bg-muted data-[filter=true]:hover:bg-base data-[state=selected]:bg-muted',

  // table-cell
  'table-cell': 'p-4 align-middle [&:has([role=checkbox])]:pr-0',
  'table-cell-pinned': 'sticky bg-base',
  'table-cell-pinned-left': 'left-0',
  'table-cell-pinned-right': 'right-0',

  // table-empty
  'table-empty-row': '',
  'table-empty-cell': 'p-4 whitespace-nowrap align-middle text-sm text-muted hover:bg-base',
  'table-empty': 'flex items-center justify-center py-10',

  // table-loading
  'table-loading-icon': 'i-lucide-refresh-ccw animate-spin text-lg',
  'table-loading-row': 'data-[loading=true]:border-0',
  'table-loading-cell': '',
  'table-loading': 'absolute inset-x-0 overflow-hidden p-0',

  // table-footer
  'table-footer': 'border-t border-base bg-muted font-medium [&>tr]:last:border-b-0',
}

export const dynamicTable: [RegExp, (params: RegExpExecArray) => string][] = [
]

export const table = [
  ...dynamicTable,
  staticTable,
]

Component

<script setup lang="ts" generic="TData, TValue">
import type {
  ColumnFiltersState,
  ColumnOrderState,
  ColumnPinningState,
  ExpandedState,
  GroupingState,
  Header,
  PaginationState,
  SortingState,
  VisibilityState,
} from '@tanstack/vue-table'
import type { Ref } from 'vue'
import type { NTableProps } from '../../../types'

import {
  FlexRender,
  getCoreRowModel,
  getExpandedRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  getSortedRowModel,
  useVueTable,
} from '@tanstack/vue-table'
import { computed, h } from 'vue'

import { cn, pickProps, valueUpdater } from '../../../utils'

import Button from '../../elements/Button.vue'
import Checkbox from '../../forms/Checkbox.vue'
import Input from '../../forms/Input.vue'
import TableBody from './TableBody.vue'
import TableCell from './TableCell.vue'
import TableEmpty from './TableEmpty.vue'
import TableFooter from './TableFooter.vue'
import TableHead from './TableHead.vue'
import TableHeader from './TableHeader.vue'
import TableLoading from './TableLoading.vue'
import TableRoot from './TableRoot.vue'
import TableRow from './TableRow.vue'

const props = withDefaults(defineProps <NTableProps<TData, TValue>>(), {
  enableMultiRowSelection: true,
})

const emit = defineEmits(['select', 'selectAll', 'expand'])

const slots = defineSlots()

const rowSelection = defineModel<Record<string, boolean>>('modelValue')
const sorting = defineModel<SortingState>('sorting')
const columnVisibility = defineModel<VisibilityState>('columnVisibility')
const columnFilters = defineModel<ColumnFiltersState>('columnFilters')
const globalFilter = defineModel<string>('globalFilter')
const columnOrder = defineModel<ColumnOrderState>('columnOrder')
const columnPinning = defineModel<ColumnPinningState>('columnPinning')
const expanded = defineModel<ExpandedState>('expanded')
const grouping = defineModel<GroupingState>('grouping')
const pagination = defineModel<PaginationState>('pagination', {
  default: () => ({
    pageIndex: 0,
    pageSize: 10,
  }),
})

const columnsWithMisc = computed(() => {
  let data = []

  // add selection column
  data = props.enableRowSelection
    ? [
        {
          accessorKey: 'selection',
          header: props.enableMultiRowSelection
            ? ({ table }: any) => h(Checkbox, {
                'checked': table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && 'indeterminate'),
                'onUpdate:checked': (value: boolean) => {
                  table.toggleAllPageRowsSelected(!!value)
                  emit('selectAll', table.getRowModel().rows)
                },
                'areaLabel': 'Select all rows',
              })
            : '',
          cell: ({ row }: any) => h(Checkbox, {
            'checked': row.getIsSelected() ?? false,
            'onUpdate:checked': (value: boolean) => {
              row.toggleSelected(!!value)
              emit('select', row)
            },
            'areaLabel': 'Select row',
          }),
          enableSorting: false,
          enableHiding: false,
        },
        ...props.columns,
      ]
    : props.columns

  // add expanded column
  data = slots.expanded
    ? [
        {
          accessorKey: 'expanded',
          header: '',
          cell: ({ row }: any) => h(Button, {
            size: 'xs',
            icon: true,
            label: 'i-radix-icons-chevron-down',
            onClick: () => {
              row.toggleExpanded()
              emit('expand', row)
            },
            una: {
              btnDefaultVariant: 'btn-ghost-gray btn-square',
              btnIconLabel: cn(
                'transform transition-transform duration-200',
                row.getIsExpanded() ? '-rotate-180' : 'rotate-0',
              ),
            },
          }),
          enableSorting: false,
          enableHiding: false,
        },
        ...data,
      ]
    : data

  return data
})

const table = computed(() => {
  return useVueTable({
    get data() {
      return props.data ?? []
    },
    get columns() {
      return columnsWithMisc.value ?? []
    },
    state: {
      get sorting() { return sorting.value },
      get columnFilters() { return columnFilters.value },
      get globalFilter() { return globalFilter.value },
      get rowSelection() { return rowSelection.value },
      get columnVisibility() { return columnVisibility.value },
      get pagination() { return pagination.value },
      get columnOrder() { return columnOrder.value },
      get columnPinning() { return columnPinning.value },
      get expanded() { return expanded.value },
      get grouping() { return grouping.value },
    },

    enableMultiRowSelection: props.enableMultiRowSelection,
    enableSubRowSelection: props.enableSubRowSelection,
    autoResetAll: props.autoResetAll,
    enableRowSelection: props.enableRowSelection,
    enableColumnFilters: props.enableColumnFilters,
    manualPagination: props.manualPagination,
    manualSorting: props.manualSorting,
    pageCount: props.pageCount,
    rowCount: props.rowCount,
    autoResetPageIndex: props.autoResetPageIndex,
    enableSorting: props.enableSorting,
    enableSortingRemoval: props.enableSortingRemoval,
    enableMultiSort: props.enableMultiSort,
    enableMultiRemove: props.enableMultiRemove,
    maxMultiSortColCount: props.maxMultiSortColCount,
    sortingFns: props.sortingFns,
    isMultiSortEvent: props.isMultiSortEvent,

    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    getRowId: (row: any) => props.rowId ? row[props.rowId] : row.id,
    getExpandedRowModel: getExpandedRowModel(),

    onSortingChange: updaterOrValue => valueUpdater(updaterOrValue, sorting),
    onRowSelectionChange: updaterOrValue => valueUpdater(updaterOrValue, rowSelection),
    onColumnVisibilityChange: updaterOrValue => valueUpdater(updaterOrValue, columnVisibility),
    onColumnFiltersChange: updaterOrValue => valueUpdater(updaterOrValue, columnFilters),
    onGlobalFilterChange: updaterOrValue => valueUpdater(updaterOrValue, globalFilter),
    onPaginationChange: updaterOrValue => valueUpdater(updaterOrValue, pagination),
    onColumnOrderChange: updaterOrValue => valueUpdater(updaterOrValue, columnOrder),
    onColumnPinningChange: updaterOrValue => valueUpdater(updaterOrValue, columnPinning),
    onExpandedChange: updaterOrValue => valueUpdater(updaterOrValue, expanded),
    onGroupingChange: updaterOrValue => valueUpdater(updaterOrValue, grouping),
  })
}) as Ref<ReturnType<typeof useVueTable>>

function getHeaderColumnFiltersCount(headers: Header<unknown, unknown>[]): number {
  let count = 0
  headers.forEach((header) => {
    if (header.column.columnDef.enableColumnFilter)
      count++
  })

  return count
}

defineExpose({
  ...table.value,
})
</script>

<template>
  <TableRoot
    v-bind="pickProps(props, ['class', 'una'])"
  >
    <!-- header -->
    <TableHeader
      :una="una"
      v-bind="props._tableHeader"
    >
      <slot name="header" :table="table">
        <TableRow
          v-for="headerGroup in table.getHeaderGroups()"
          :key="headerGroup.id"
          :una="una"
          v-bind="props._tableRow"
        >
          <!-- headers -->
          <TableHead
            v-for="header in headerGroup.headers"
            :key="header.id"
            :colspan="header.colSpan"
            :data-pinned="header.column.getIsPinned()"
            :una="una"
            v-bind="props._tableHead"
          >
            <Button
              v-if="header.column.columnDef.enableSorting || (header.column.columnDef.enableSorting !== false && enableSorting)"
              btn="ghost-gray"
              size="sm"
              class="font-normal -ml-0.85em"
              :una="{
                btnTrailing: 'text-sm',
              }"
              :trailing="header.column.getIsSorted() === 'asc'
                ? 'i-lucide-arrow-up-wide-narrow' : header.column.getIsSorted() === 'desc'
                  ? 'i-lucide-arrow-down-narrow-wide' : 'i-lucide-arrow-up-down'"
              @click="header.column.toggleSorting(
                header.column.getIsSorted() === 'asc' ? undefined : header.column.getIsSorted() !== 'desc',
                enableMultiSort,
              )"
            >
              <slot
                :name="`${header.id}-header`"
                :column="header.column"
              >
                <FlexRender
                  v-if="!header.isPlaceholder"
                  :render="header.column.columnDef.header"
                  :props="header.getContext()"
                />
              </slot>
            </Button>
            <component
              :is="header.id === 'selection' ? 'div' : 'span'"
              v-else
              class="text-sm text-muted"
            >
              <slot
                :name="`${header.id}-header`"
                :column="header.column"
              >
                <FlexRender
                  v-if="!header.isPlaceholder"
                  :render="header.column.columnDef.header"
                  :props="header.getContext()"
                />
              </slot>
            </component>
          </TableHead>
        </TableRow>

        <!-- column filters -->
        <template
          v-for="headerGroup in table.getHeaderGroups()"
          :key="headerGroup.id"
        >
          <TableRow
            v-if="getHeaderColumnFiltersCount(headerGroup.headers) > 0 || enableColumnFilters"
            data-filter="true"
            :una="una"
            v-bind="props._tableRow"
          >
            <TableHead
              v-for="header in headerGroup.headers"
              :key="header.id"
              :una="una"
              :colspan="header.colSpan"
              :data-pinned="header.column.getIsPinned()"
              v-bind="props._tableHead"
            >
              <slot
                v-if="header.id !== 'selection' && ((header.column.columnDef.enableColumnFilter !== false && enableColumnFilters) || header.column.columnDef.enableColumnFilter)"
                :name="`${header.id}-filter`"
                :column="header.column"
              >
                <Input
                  class="w-auto"
                  :model-value="header.column.getFilterValue() as string"
                  :placeholder="header.column.columnDef.header"
                  @update:model-value="header.column.setFilterValue($event)"
                />
              </slot>
            </TableHead>
          </TableRow>
        </template>
      </slot>

      <TableLoading
        :enabled="props.loading"
        :una="una"
        v-bind="props._tableLoading"
      >
        <slot name="loading" />
      </TableLoading>
    </TableHeader>

    <!-- body -->
    <TableBody
      :una="una"
      v-bind="props._tableBody"
    >
      <slot name="body" :table="table">
        <template v-if="table.getRowModel().rows?.length">
          <template
            v-for="row in table.getRowModel().rows"
            :key="row.id"
          >
            <TableRow
              :data-state="row.getIsSelected() && 'selected'"
              :una="una"
              v-bind="props._tableRow"
            >
              <slot
                name="row"
                :row="row"
              >
                <!-- rows -->
                <TableCell
                  v-for="cell in row.getVisibleCells()"
                  :key="cell.id"
                  :data-pinned="cell.column.getIsPinned()"
                  :una="una"
                  v-bind="props._tableCell"
                >
                  <slot
                    :name="`${cell.column.id}-cell`"
                    :cell="cell"
                  >
                    <FlexRender
                      :render="cell.column.columnDef.cell"
                      :props="cell.getContext()"
                    />
                  </slot>
                </TableCell>
              </slot>
            </TableRow>

            <!-- expanded -->
            <TableRow
              v-if="row.getIsExpanded() && $slots.expanded"
              :una="una"
              v-bind="props._tableRow"
            >
              <TableCell
                :colspan="row.getAllCells().length"
                :una="una"
                v-bind="props._tableCell"
              >
                <slot name="expanded" :row="row" />
              </TableCell>
            </TableRow>
          </template>
        </template>

        <TableEmpty
          v-else
          :colspan="table.getAllLeafColumns().length"
          :una="una"
          v-bind="props._tableEmpty"
        >
          <slot name="empty" />
        </TableEmpty>
      </slot>
    </TableBody>

    <!-- footer -->
    <TableFooter
      v-if="table.getFooterGroups().length > 0"
      :una="una"
      v-bind="props._tableFooter"
    >
      <slot name="footer" :table="table">
        <template
          v-for="footerGroup in table.getFooterGroups()"
          :key="footerGroup.id"
        >
          <TableRow
            v-if="footerGroup.headers.length > 0"
            :una="una"
            v-bind="props._tableRow"
          >
            <template
              v-for="header in footerGroup.headers"
              :key="header.id"
            >
              <TableHead
                v-if="header.column.columnDef.footer"
                :colspan="header.colSpan"
                :una="una"
                v-bind="props._tableHead"
              >
                <slot :name="`${header.id}-footer`" :column="header.column">
                  <FlexRender
                    v-if="!header.isPlaceholder"
                    :render="header.column.columnDef.footer"
                    :props="header.getContext()"
                  />
                </slot>
              </TableHead>
            </template>
          </TableRow>
        </template>
      </slot>
    </TableFooter>
  </TableRoot>
</template>.
<script setup lang="ts">
import type { NTableRootProps } from '../../../types'
import { cn } from '../../../utils'

const props = defineProps<NTableRootProps>()
</script>

<template>
  <div
    :class="cn('table-root-wrapper', props.una?.tableRootWrapper)"
  >
    <table
      :class="cn(
        'table-root',
        props.class,
        props.una?.tableRoot,
      )"
    >
      <slot />
    </table>
  </div>
</template>
<script setup lang="ts">
import type { NTableHeaderProps } from '../../../types'
import { cn } from '../../../utils'

const props = defineProps<NTableHeaderProps>()
</script>

<template>
  <thead
    :class="cn(
      'table-header',
      props.class,
      props?.una?.tableHeader,
    )"
    v-bind="$attrs"
  >
    <slot />
  </thead>
</template>
<script setup lang="ts">
import type { NTableHeadProps } from '../../../types'
import { cn } from '../../../utils'

const props = defineProps<NTableHeadProps>()
</script>

<template>
  <th
    :class="cn(
      'table-head',
      props.class,
      { 'table-head-pinned': props.dataPinned },
      props.dataPinned === 'left' ? 'table-head-pinned-left' : 'table-head-pinned-right',
    )"
    v-bind="$attrs"
  >
    <slot />
  </th>
</template>
<script setup lang="ts">
import type { NTableBodyProps } from '../../../types'
import { cn } from '../../../utils'

const props = defineProps<NTableBodyProps>()
</script>

<template>
  <tbody
    :class="cn(
      'table-body',
      props.class,
      props?.una?.tableBody,
    )"
    v-bind="$attrs"
  >
    <slot />
  </tbody>
</template>
<script setup lang="ts">
import type { NTableFooterProps } from '../../../types'
import { cn } from '../../../utils'

const props = defineProps<NTableFooterProps>()
</script>

<template>
  <tfoot
    :class="cn(
      'table-footer',
      props.class,
      props.una?.tableFooter,
    )"
  >
    <slot />
  </tfoot>
</template>
<script setup lang="ts">
import type { NTableCellProps } from '../../../types'
import { cn } from '../../../utils'

const props = defineProps<NTableCellProps>()
</script>

<template>
  <td
    :class="
      cn(
        'table-cell',
        { 'table-cell-pinned': dataPinned },
        dataPinned === 'left' ? 'table-cell-pinned-left' : 'table-cell-pinned-right',
        props.class,
        props?.una?.tableCell,
      )
    "
    v-bind="$attrs"
  >
    <slot />
  </td>
</template>
<script setup lang="ts">
import type { NTableRowProps } from '../../../types'
import { cn } from '../../../utils'

const props = defineProps<NTableRowProps>()
</script>

<template>
  <tr
    :class="cn(
      'table-row',
      props.class,
      props.una?.tableRow,
    )"
    v-bind="$attrs"
  >
    <slot />
  </tr>
</template>
<script setup lang="ts">
import type { NTableEmptyProps } from '../../../types'
import { computed } from 'vue'
import { cn, omitProps } from '../../../utils'
import TableCell from './TableCell.vue'
import TableRow from './TableRow.vue'

const props = withDefaults(defineProps<NTableEmptyProps>(), {
  colspan: 1,
})

const delegatedProps = computed(() => {
  const { class: _, ...delegated } = props

  return delegated
})
</script>

<template>
  <TableRow
    :class="cn(
      'table-empty-row',
    )"
    v-bind="delegatedProps._tableRow"
  >
    <TableCell
      :class="
        cn(
          'table-empty-cell',
        )
      "
      :colspan="props.colspan"
      v-bind="delegatedProps._tableCell"
    >
      <div
        :class="cn(
          'table-empty',
          props.class,
        )"
        v-bind="omitProps(delegatedProps, ['_tableRow', '_tableCell', 'colspan'])"
      >
        <slot>
          <div class="grid place-items-center gap-4">
            <NIcon
              name="i-tabler-database-x"
              size="2xl"
            />

            <span>
              No results.
            </span>
          </div>
        </slot>
      </div>
    </TableCell>
  </TableRow>
</template>
<script setup lang="ts">
import type { NTableLoadingProps } from '../../../types'
import { computed } from 'vue'
import { cn } from '../../../utils'
import Progress from '../../elements/Progress.vue'
import TableRow from './TableRow.vue'

const props = withDefaults(defineProps<NTableLoadingProps>(), {
})

const delegatedProps = computed(() => {
  const { class: _, ...delegated } = props

  return delegated
})
</script>

<template>
  <TableRow
    :class="cn(
      'table-loading-row',
    )"
    data-loading="true"
    v-bind="delegatedProps._tableRow"
  >
    <td
      :class="
        cn(
          'table-loading-cell',
        )
      "
      :colspan="0"
      v-bind="delegatedProps._tableCell"
    >
      <div
        v-if="enabled"
        class="table-loading"
      >
        <slot>
          <Progress
            size="3px"
          />
        </slot>
      </div>
    </td>
  </TableRow>
</template>
<script setup lang="ts">
import type { NTableCaptionProps } from '../../../types'
import { cn } from '../../../utils'

const props = defineProps<NTableCaptionProps>()
</script>

<template>
  <caption
    :class="cn(
      'table-caption',
      props.class,
      props?.una?.tableCaption,
    )"
    v-bind="$attrs"
  >
    <slot />
  </caption>
</template>