๐ŸŸข 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
CandidaStoltenberg1840complicated25
AnnettaKiehn2529complicated95
AriannaGleason25329relationship44
RebekahBorer2574single28
ReySatterfield0548relationship39
<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
TyrellErdman-Dickinson27484complicated64
DarrionAdams4729single95
LorineHuel2750relationship27
FelipaJakubowski-Hickle6826complicated80
BenStroman10864complicated96
DelphineQuigley34104single56
CaterinaCummerata9920single88
OrlandoNolan16721relationship28
JakaylaBernier16442single76
KiraMills20673relationship67
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
RobertoFadel3554single51
StephanieBahringer2919single70
AndreanneBogisich36664complicated17
DarrelWolf23618relationship79
DavinPagac23718relationship95
<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
GrantMills10512relationship24
VeronicaWintheiser29260relationship18
DesireeBogan2192single73
DurwardFritsch35306complicated87
PierceKilback37349single52
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
BennettSkiles39176single68
JaronRohan2812relationship80
AngelaRohan27948single36
ClotildeJacobi27922complicated42
AdahStehr24421single95
CloydWintheiser24707single11
RyleyBernier-Schimmel22131relationship33
RetaKoelpin25753complicated33
DonatoHartmann7629relationship26
DaisyBotsford22712relationship21
<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
SteveKovacek5416complicated39
WymanPfeffer13340complicated2
CortneyBuckridge38451relationship49
DeliaMarquardt-Stamm1347relationship45
EliasLueilwitz7227relationship86
<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
ZoilaBruen35805complicated1
LadariusDooley-Welch24735single4
RayWard15606complicated10
BartonGutmann3748single35
MaximilliaBayer2842relationship87
GeovanyStoltenberg39668complicated94
MaximillianCremin9805complicated87
LinniePollich30596single6
TysonRuecker20626relationship39
GarnettMacGyver8882single30
<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
JovanSchultz24749complicated17
GersonShields10600single80
ElianeKessler27188complicated0
TaureanSchmitt2723single57
TheodoreOlson37744single84
<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
WaylonHeller2390complicated5
LorenzoWyman14899complicated15
IsomNienow39515complicated73
AgustinaQuitzon13445relationship84
KentonBashirian31168complicated55
<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
complicatedLuraReichel1580584
relationshipJeradDaniel1821249
relationshipNedMacejkovic786345
relationshipZellaKulas1573311
complicatedIlaWhite3811151
<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
MillieEmmerich15346relationship18
AgnesConroy13993single99
NormaSchmeler11645single7
AgustinHarber25261single31
KatarinaWaelchi1763complicated50
<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
DeionHermann19938relationship72
JillianFrami21993single48
BaileeMraz25668relationship17
HoseaBogisich18352single83
AdellTrantow24942relationship41
<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>

Customization

You can customize the table by using the following props.

PropTypeDefaultDescription
columns.meta.metaObject{}Column meta data.
unaObject{}Unique name attribute.
For more information on customization, read the available props and presets.
First NameLast NameAgeVisitsStatusProfile Progress
DexterBayer27487relationship96
JacintoRoob29584complicated75
DorianMraz37297complicated94
AylinMitchell12799single37
RodolfoHammes14833complicated89
<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',
    meta: {
      una: {
        tableHead: 'text-left',
        tableCell: 'text-left',
      },
    },
  },
  {
    header: 'Last Name',
    accessorKey: 'lastName',
    meta: {
      una: {
        tableHead: 'text-left',
        tableCell: 'text-left',
      },
    },
  },
  {
    header: 'Age',
    accessorKey: 'age',
    meta: {
      una: {
        tableHead: 'text-center',
        tableCell: 'text-center',
      },
    },
  },
  {
    header: 'Visits',
    accessorKey: 'visits',
  },
  {
    header: 'Status',
    accessorKey: 'status',
  },
  {
    header: 'Profile Progress',
    accessorKey: 'progress',
  },
]
</script>

<template>
  <NTable
    :columns
    :data
    :una="{
      tableHead: 'text-right',
      tableCell: 'text-right',
    }"
  />
</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
MR
Magali Rohan
Joan24@yahoo.com
MagaliRohansingle
10%
TH
Tina Hoppe
Eliza.Berge30@yahoo.com
TinaHopperelationship
60%
CS
Clara Shields
Ima_Corwin29@gmail.com
ClaraShieldssingle
18%
MG
Maiya Glover
Libbie8@gmail.com
MaiyaGloverrelationship
61%
RL
Rosalia Langosh
Aniya15@hotmail.com
RosaliaLangoshcomplicated
3%
CM
Ciara Mills
Osborne58@hotmail.com
CiaraMillsrelationship
66%
JL
Justus Lang-Brown
Beau_Fahey87@yahoo.com
JustusLang-Brownrelationship
5%
GO
Gilberto Oberbrunner
Mariano95@yahoo.com
GilbertoOberbrunnerrelationship
57%
GS
Geovany Schimmel
Meda81@gmail.com
GeovanySchimmelrelationship
5%
AB
Alexa Borer
Khalid.Bernier99@hotmail.com
AlexaBorersingle
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' | 'tableRow' | 'tableCell'>
}

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

  _tableCell?: NTableCellProps
  _tableRow?: NTableRowProps

  una?: Pick<NTableUnaProps, 'tableLoading' | 'tableRow' | 'tableCell'>
}

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, ...header.column.columnDef.meta }"
          >
            <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, ...header.column.columnDef.meta }"
            >
              <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, ...cell.column.columnDef.meta }"
                >
                  <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, ...header.column.columnDef.meta }"
              >
                <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.una?.tableRoot,
        props.class,
      )"
    >
      <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?.una?.tableHeader,
      props.class,
    )"
    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.una?.tableHead,
      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?.una?.tableBody,
      props.class,
    )"
    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.una?.tableFooter,
      props.class,
    )"
  >
    <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',
        props?.una?.tableCell,
        props.class,
        { 'table-cell-pinned': dataPinned },
        dataPinned === 'left' ? 'table-cell-pinned-left' : 'table-cell-pinned-right',
      )
    "
    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.una?.tableRow,
      props.class,
    )"
    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',
      props.una?.tableRow,
    )"
    v-bind="delegatedProps._tableRow"
  >
    <TableCell
      :class="
        cn(
          'table-empty-cell',
          props.una?.tableCell,
        )
      "
      :colspan="props.colspan"
      v-bind="delegatedProps._tableCell"
    >
      <div
        :class="cn(
          'table-empty',
          props.una?.tableEmpty,
          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',
      props.una?.tableRow,
    )"
    data-loading="true"
    v-bind="delegatedProps._tableRow"
  >
    <td
      :class="
        cn(
          'table-loading-cell',
          props.una?.tableCell,
        )
      "
      :colspan="0"
      v-bind="delegatedProps._tableCell"
    >
      <div
        v-if="enabled"
        :class="cn(
          'table-loading',
          props.una?.tableLoading,
        )"
      >
        <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?.una?.tableCaption,
      props.class,
    )"
    v-bind="$attrs"
  >
    <slot />
  </caption>
</template>