Breaking change. The widgets NTable renders for you — the sort button, the
column filter, the selection checkbox and the expand toggle — are now
sub-components of their own, and the two columns it injects are named select
and expand rather than selection and expanded. Slot names and column ids
change with them, and columns.meta no longer reaches the DOM. See
Migrating below.
Examples
Basic
| Prop | Default | Type | Description |
|---|---|---|---|
columns | [] | array | Table columns. |
data | [] | array | Table data. |
| First Name | Last Name | Age | Visits | Status | Profile Progress |
|---|---|---|---|---|---|
| Jorge | Collier | 1 | 30 | relationship | 67 |
| Lynda | Johns | 24 | 18 | single | 90 |
| Vicky | Ebert | 17 | 467 | relationship | 20 |
| Eleanor | Huels | 16 | 241 | single | 90 |
| Jesus | Barrows | 35 | 191 | complicated | 48 |
<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.
| Prop | Default | Type | Description |
|---|---|---|---|
rowSelection | - | object | Selected row state, can be binded with v-model. |
enableRowSelection | false | boolean | Enable row selection. |
enableMultiRowSelection | true | boolean | Enable multiple row selection. |
rowId | id | string | Row id to uniquely identify each row. |
enableSubRowSelection | false | boolean | Enable sub row selection. |
@select | - | event, row | Emitted when a row is selected. |
@select-all | - | event, rows | Emitted when all rows are selected. |
@row | - | event, row | Emitted when a row is clicked. |
When using the @row event, you should stop propagation if you have interactive elements like buttons or links inside the row to prevent triggering the row click event.
The checkboxes are NTableSelectionHeader and NTableSelectionCell, both
NCheckbox underneath. Configure them through _tableSelectionHeader and
_tableSelectionCell, or replace them with the select-header and
select-cell slots — see Widgets.
| First Name | Last Name | Age | Visits | Status | Profile Progress | |
|---|---|---|---|---|---|---|
| Jeremiah | Emmerich-Yundt | 3 | 214 | single | 72 | |
| Keshaun | Feil | 40 | 232 | complicated | 36 | |
| Fernando | Ernser | 35 | 768 | relationship | 58 | |
| Terri | Ruecker | 3 | 70 | relationship | 65 | |
| Josefina | Hilll-Hilll | 27 | 186 | single | 58 | |
| Arnold | O'Connell | 27 | 794 | complicated | 95 | |
| Irma | Spinka | 25 | 87 | complicated | 20 | |
| Scott | Corwin | 38 | 76 | complicated | 54 | |
| Cathy | DuBuque | 6 | 748 | single | 13 | |
| Carolyn | Lesch | 29 | 479 | single | 15 | |
<script setup lang="ts">
import type { ColumnDef, RowSelectionState, 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<RowSelectionState>()
const table = useTemplateRef<Table<Person>>('table')
</script>
<template>
<div class="flex flex-col space-y-4">
<NTable
ref="table"
v-model:row-selection="select"
:columns
:data
enable-row-selection
/>
<div
class="flex items-center justify-between px-2"
>
<div
class="flex-1 text-sm text-muted-foreground"
>
{{ 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
Empty
Empty allows you to show a message when the table is empty. This is useful when you want to show a message when the table is empty.
| Prop | Default | Type | Description |
|---|---|---|---|
empty-text | No results. | string | Empty text. |
empty-icon | i-tabler-database-x | string | Empty icon. |
| First Name | Last Name | Age | Visits | Status | Profile Progress |
|---|---|---|---|---|---|
No data. | |||||
<script setup lang="ts">
import type { ColumnDef } from '@tanstack/vue-table'
import type { Person } from './makeData'
const data = ref<Person[]>([])
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>
<div class="flex flex-col space-y-2">
<NTable
:columns
:data
empty-text="No data."
empty-icon="i-lucide-package-x"
/>
</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.
| Prop | Default | Type | Description |
|---|---|---|---|
loading | false | boolean | Loading state. |
| First Name | Last Name | Age | Visits | Status | Profile Progress |
|---|---|---|---|---|---|
| Lenna | Reilly-Green | 0 | 675 | complicated | 67 |
| Chelsea | Stokes | 9 | 983 | single | 82 |
| Henrietta | Macejkovic | 2 | 991 | complicated | 64 |
| Whitney | Hintz | 17 | 36 | complicated | 12 |
| Rolando | Mayert-Larson | 35 | 486 | relationship | 79 |
| Rafael | Gutmann | 33 | 854 | relationship | 63 |
| Monica | Feil | 32 | 437 | relationship | 86 |
| Alanna | Tillman | 19 | 690 | complicated | 99 |
| Arlene | Lehner | 9 | 816 | relationship | 46 |
| Rudolph | Greenfelder | 15 | 160 | complicated | 58 |
<script setup lang="ts">
import type { ColumnDef } from '@tanstack/vue-table'
import type { Person } from './makeData'
import makeData from './makeData'
const data = ref(makeData(50))
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="loading"
label="Loading"
/>
<NTable
:loading
: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
Pagination
NTable owns the pagination state and, with show-pagination, renders
shadcn's pagination bar below the table: the selection count (or row range) on
the left, then rows per page, Page X of Y and the navigation on the right.
Page numbers are off by default, as in shadcn; turn them on with
_tablePagination: { showListItem: true }. Below the lg breakpoint the bar
keeps only Page X of Y and the previous/next buttons.
| Prop | Default | Type | Description |
|---|---|---|---|
pagination | {pageIndex: 0, pageSize: 10} | {pageIndex: Number, pageSize: Number} | Pagination state, can be binded with v-model. |
manualPagination | false | boolean | Enable manual pagination, ideal for server-side pagination. |
rowCount | - | number | The full row count, for manual pagination. |
showPagination | false | boolean | Render the built-in pagination bar. |
_tablePagination | {} | object | Props for the bar, NTablePagination. |
| First Name | Last Name | Age | Visits | Status | Profile Progress |
|---|---|---|---|---|---|
| Hermina | Reichert | 37 | 27 | relationship | 29 |
| Myrtle | Dibbert | 34 | 240 | relationship | 22 |
| Ozella | McClure | 5 | 240 | relationship | 54 |
| Webster | Kulas | 27 | 550 | complicated | 55 |
| Oscar | Ferry | 20 | 948 | complicated | 12 |
<script setup lang="ts">
import type { ColumnDef } from '@tanstack/vue-table'
import type { Person } from './makeData'
import makeData from './makeData'
const data = ref(makeData(50))
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,
})
</script>
<template>
<NTable
v-model:pagination="pagination"
:columns
:data
show-pagination
/>
</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
The bar is NTablePagination. It takes every NPagination prop except the
state ones — page, itemsPerPage and total come from the table — plus:
| Prop | Default | Type | Description |
|---|---|---|---|
showInfo | true | boolean | Render the status text on the left. |
showRowsPerPage | true | boolean | Render the rows-per-page select. |
table | - | Table | The table instance, when used outside NTable; read from context otherwise. |
Pass them through _tablePagination for prop-only changes. For anything more —
the status text, say, whose default is English — take over the pagination
slot and render NTablePagination yourself. It reads the table from context, so
it needs no props, and its status slot exposes selected, filtered,
total, first and last. Providing the slot renders the bar on its own;
show-pagination is only needed for the default one.
| First Name | Last Name | Age | Visits | Status | Profile Progress | |
|---|---|---|---|---|---|---|
| Jackie | Dach | 29 | 931 | complicated | 78 | |
| Raleigh | Beatty | 14 | 835 | relationship | 96 | |
| Jessie | Feil | 34 | 524 | relationship | 50 | |
| Jerrell | Fritsch | 1 | 178 | relationship | 63 | |
| Fannie | Barrows | 25 | 980 | single | 77 | |
<script setup lang="ts">
import type { ColumnDef } from '@tanstack/vue-table'
import type { Person } from './makeData'
import makeData from './makeData'
const data = ref(makeData(50))
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,
})
</script>
<template>
<NTable
v-model:pagination="pagination"
:columns
:data
enable-row-selection
>
<!-- the bar reads the table from context, so it needs no props -->
<template #pagination>
<NTablePagination
:_pagination-rows-per-page="{ pageSizes: [5, 10, 25], label: 'Per page' }"
>
<template #status="{ selected, filtered }">
{{ selected }} of {{ filtered }} selected
</template>
</NTablePagination>
</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
With manualPagination, pass rowCount so the bar knows the full size — the
table only ever holds the current page; see Server-side. When
row selection is on, the status shows the selection count instead of the row
range. To compose the bar outside the root, NTablePagination also takes the
table instance directly:
<NTable ref="table" :columns :data />
<NTablePagination v-if="table" :table />
Sorting
Sorting allows you to sort columns in ascending or descending order. This is useful when you want to sort columns in the table.
| Prop | Default | Type | Description |
|---|---|---|---|
sorting | - | array | Sorting state, can be binded with v-model. |
enableMultiSort | - | boolean | Enable multi-column sorting |
enableSorting | - | boolean | Enable all column sorting |
column.enableSorting | - | boolean | Enable specific column sorting |
enableSortingRemoval | true | boolean | Enables the ability to remove sorting for the table. |
Sortable headers render NTableSortButton, a NButton whose trailing icon
follows the column's sort state. Configure it through _tableSortButton (any
NButton prop), re-point the icons with the table-sort-asc-icon,
table-sort-desc-icon and table-sort-none-icon preset aliases or the matching
una keys, and replace its content with the {column}-header slot. Sortable
headers also carry aria-sort.
| Status | |||||
|---|---|---|---|---|---|
| Kendall | Smitham | 8 | 957 | relationship | 56 |
| Pasquale | Batz | 3 | 69 | single | 50 |
| Leah | Gorczany | 0 | 836 | single | 7 |
| Brody | Feest | 34 | 65 | single | 37 |
| Sheridan | Adams-Ritchie | 6 | 421 | single | 0 |
| Ruth | Botsford | 2 | 379 | relationship | 58 |
| Alexzander | Veum | 16 | 352 | relationship | 95 |
| Cornelius | Muller | 34 | 787 | relationship | 5 |
| Erica | Gleason | 9 | 186 | single | 9 |
| Tanya | Dickinson | 31 | 803 | complicated | 28 |
<script setup lang="ts">
import type { ColumnDef } from '@tanstack/vue-table'
import type { Person } from './makeData'
import makeData from './makeData'
const data = ref(makeData(50))
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.
| Prop | Default | Type | Description |
|---|---|---|---|
columnVisibility | - | object | Column visibility state, can be binded with v-model. |
| First Name | Last Name | Age | Visits | Status | Profile Progress |
|---|---|---|---|---|---|
| Shelley | Muller | 21 | 80 | relationship | 33 |
| Mercedes | Nitzsche | 33 | 139 | single | 69 |
| Giovanny | Hartmann | 36 | 760 | complicated | 28 |
| Daphnee | Heidenreich | 11 | 946 | relationship | 32 |
| Donnie | Jacobs | 13 | 618 | complicated | 98 |
<script setup lang="ts">
import type { ColumnDef, Table, VisibilityState } from '@tanstack/vue-table'
import type { Person } from './makeData'
import makeData from './makeData'
const data = ref(makeData(5))
const columns = [
{
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',
},
] satisfies ColumnDef<Person>[]
const table = useTemplateRef<Table<Person>>('table')
const columnVisibility = ref<VisibilityState>({})
</script>
<template>
<div>
<div class="flex flex-wrap gap-4">
<NCheckbox
v-for="tableColumn in table?.getAllLeafColumns()"
:key="tableColumn.id"
:model-value="tableColumn.getIsVisible()"
:label="tableColumn.id"
@update:model-value="tableColumn.toggleVisibility()"
/>
</div>
<NSeparator />
<NTable
ref="table"
: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.
| Prop | Default | Type | Description |
|---|---|---|---|
globalFilter | - | string | Global filter state, can be binded with v-model. |
manualFiltering | - | boolean | Enable manual filtering. Ideal for server-side filtering. |
| First Name | Last Name | Age | Visits | Status | Profile Progress |
|---|---|---|---|---|---|
| Krystal | Robel-Quigley | 0 | 893 | single | 78 |
| Michelle | Bayer | 13 | 24 | single | 61 |
| Glenda | D'Amore | 39 | 859 | relationship | 31 |
| Paula | Wuckert | 33 | 156 | relationship | 50 |
| Terri | Koch | 15 | 733 | relationship | 23 |
| Jaime | Hansen | 2 | 2 | relationship | 47 |
| Dana | Mueller | 8 | 442 | relationship | 31 |
| Camilla | Runte | 21 | 919 | complicated | 17 |
| Ted | Daniel | 2 | 34 | single | 66 |
| Kristopher | Stehr | 25 | 660 | complicated | 65 |
<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"
disabled
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
Column 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.
| Prop | Default | Type | Description |
|---|---|---|---|
columnFilters | - | array | Column filter state, can be binded with v-model. |
enableColumnFilters | - | boolean | Enable all column filtering |
column.enableColumnFilter | - | boolean | Enable specific column filtering |
Each filter is NTableColumnFilter, a NInput bound to the column's filter
value with the header text as its placeholder. Configure it through
_tableColumnFilter (any NInput prop), or replace it per column with the
{column}-filter slot.
| First Name | Last Name | Age | Visits | Status | Profile Progress |
|---|---|---|---|---|---|
| Perry | Wilderman | 4 | 647 | complicated | 35 |
| Tommie | Doyle | 40 | 858 | single | 65 |
| Christina | Walker | 17 | 608 | complicated | 5 |
| Russell | West | 29 | 488 | relationship | 59 |
| Karen | Sporer | 10 | 386 | relationship | 39 |
<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.
| Prop | Default | Type | Description |
|---|---|---|---|
columnOrder | - | array | Column order state, can be binded with v-model. |
| First Name | Last Name | Age | Visits | Status | Profile Progress |
|---|---|---|---|---|---|
| Jadon | Kunze | 18 | 188 | relationship | 59 |
| Johnathan | Olson | 19 | 717 | single | 58 |
| Ronnie | Grady | 35 | 338 | single | 15 |
| Rafael | Parker | 37 | 25 | relationship | 27 |
| Otis | Koss | 2 | 665 | single | 12 |
<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 = useTemplateRef<Table<Person>>('table')
function randomizeColumns() {
table.value?.setColumnOrder(faker.helpers.shuffle(table.value?.getAllLeafColumns().map(d => d.id)))
}
</script>
<template>
<div class="flex justify-end">
<NButton
label="Randomize columns"
class="mb-4"
@click="randomizeColumns"
/>
</div>
<!-- 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.
| Prop | Default | Type | Description |
|---|---|---|---|
columnPinning | - | { left: array, right: array } | Column pinning state, can be binded with v-model. |
| Status | First Name | Last Name | Age | Visits | Profile Progress |
|---|---|---|---|---|---|
| relationship | Carmelo | Ward | 15 | 799 | 41 |
| single | Lela | Collins | 22 | 780 | 53 |
| complicated | Abdiel | Hammes | 26 | 195 | 34 |
| relationship | Irma | Ortiz | 24 | 131 | 24 |
| complicated | Rebecca | Douglas | 25 | 36 | 0 |
<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
Expanding
Expanding allows you to expand rows to show additional information. This is useful when you want to show additional information about a row.
| Prop | Default | Type | Description |
|---|---|---|---|
expanded | - | object | Expanded state, can be binded with v-model. |
@expand | - | event, row | Emitted when a row's expand toggle is clicked. |
The toggle is NTableExpandButton, an icon-only NButton that turns its
chevron while the row is open. Configure it through _tableExpandButton,
re-point the icon with the table-expand-icon alias, or replace it with the
expand-cell slot. expanded renders the row's content; expand-cell renders
the toggle.
| First Name | Last Name | Age | Visits | Status | Profile Progress | |
|---|---|---|---|---|---|---|
| Ryley | Ondricka | 39 | 238 | complicated | 30 | |
| Lynda | Rutherford | 13 | 40 | relationship | 56 | |
| Jeanne | Powlowski | 25 | 547 | single | 100 | |
| Eugene | Auer | 33 | 961 | complicated | 61 | |
| Mandy | Beer | 34 | 347 | complicated | 77 | |
<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-foreground">
Object:
</p>
<p class="text-foreground">
{{ 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.
| Prop | Default | Type | Description |
|---|---|---|---|
grouping | - | array | Grouping state, can be binded with v-model. |
manualGrouping | - | boolean | Enable manual grouping. |
| Info | Name | Info | |||
|---|---|---|---|---|---|
| Status | Progress | First Name | Last Name | Age | Visits |
| single | 0 | Kay | Heathcote | 20 | 724 |
| relationship | 94 | Marge | Runolfsdottir | 8 | 34 |
| relationship | 26 | Olga | Bayer | 39 | 62 |
| complicated | 38 | Shawna | Buckridge | 20 | 994 |
| relationship | 33 | Byron | Keeling | 27 | 968 |
<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: 'Progress',
accessorKey: 'progress',
},
],
},
]
const grouping = ref([
'status',
'progress',
'firstName',
'lastName',
'age',
'visits',
])
</script>
<template>
<NTable
v-model:grouping="grouping"
manual-grouping
: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
Allows you to fetch data from the server. Set manualPagination so the table
stops slicing the rows itself, and pass rowCount from the response so the
pagination bar knows the full size — the table only ever holds the current
page.
| Name | Url |
|---|---|
| bulbasaur | https://pokeapi.co/api/v2/pokemon/1/ |
| ivysaur | https://pokeapi.co/api/v2/pokemon/2/ |
| venusaur | https://pokeapi.co/api/v2/pokemon/3/ |
| charmander | https://pokeapi.co/api/v2/pokemon/4/ |
| charmeleon | https://pokeapi.co/api/v2/pokemon/5/ |
| charizard | https://pokeapi.co/api/v2/pokemon/6/ |
| squirtle | https://pokeapi.co/api/v2/pokemon/7/ |
| wartortle | https://pokeapi.co/api/v2/pokemon/8/ |
| blastoise | https://pokeapi.co/api/v2/pokemon/9/ |
| caterpie | https://pokeapi.co/api/v2/pokemon/10/ |
<script setup lang="ts">
import type { ColumnDef } 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: 10,
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(() => resource.value?.results ?? [])
const columns: ColumnDef<Pokemon>[] = [
{
header: 'Name',
accessorKey: 'name',
},
{
header: 'Url',
accessorKey: 'url',
},
]
</script>
<template>
<div class="flex flex-col space-y-4">
<div class="flex justify-end">
<NButton
:loading="status === 'pending'"
@click="refresh()"
>
Reload
</NButton>
</div>
<!-- the table only ever holds one page: `row-count` gives the bar the full size -->
<NTable
v-model:pagination="pagination"
manual-pagination
:row-count="resource?.count"
:columns
:data
:loading="status === 'pending'"
show-pagination
/>
</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
Customization
Configure the table using the una prop and utility classes.
| Prop | Default | Type | Description |
|---|---|---|---|
columns.meta.una | {} | object | Column Una meta data. |
una | {} | object | Global Una attribute. |
_tableSortButton | {} | object | Props for the sort button in sortable headers. |
_tableColumnFilter | {} | object | Props for the per-column filter input. |
_tableSelectionHeader | {} | object | Props for the select-all checkbox. |
_tableSelectionCell | {} | object | Props for the per-row selection checkbox. |
_tableExpandButton | {} | object | Props for the row expand toggle. |
_tablePagination | {} | object | Props for the built-in pagination bar. |
Set them for the whole table with the _table* props, or per column through
columns.meta, which accepts the same keys:
const columns = [
{
header: 'Age',
accessorKey: 'age',
meta: {
una: { tableHead: 'text-center' },
_tableColumnFilter: { placeholder: 'Filter age…' },
},
},
]
Only una and the _table* keys are read from columns.meta; anything else
you store there stays out of the DOM.
| First Name | Last Name | Age | Visits | Status | Profile Progress |
|---|---|---|---|---|---|
| Ulises | Torp | 12 | 633 | relationship | 63 |
| Angelo | Pagac | 10 | 639 | single | 87 |
| Jeannie | Hansen | 13 | 885 | complicated | 82 |
| Irvin | Volkman | 7 | 541 | single | 11 |
| Stanton | Lynch | 20 | 213 | complicated | 24 |
<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 bg-primary-700 text-white',
tableCell: 'text-left bg-primary-700 text-white',
},
},
},
{
header: 'Last Name',
accessorKey: 'lastName',
meta: {
una: {
tableHead: 'text-left bg-primary-700 text-white',
tableCell: 'text-left bg-primary-700 text-white',
},
},
},
{
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
:column-pinning="{
left: ['firstName', 'lastName'],
}"
:una="{
tableHead: 'text-right',
tableCell: 'text-right',
}"
/>
</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
Widgets
Every widget NTable renders on your behalf is a component in its own right,
so it takes the full prop surface of what it wraps, has una keys of its own,
and can be swapped out through a slot:
| Component | Wraps | Props | Slot | Icon aliases |
|---|---|---|---|---|
NTableSortButton | NButton | _tableSortButton | {column}-header | table-sort-asc-icon, table-sort-desc-icon, table-sort-none-icon |
NTableColumnFilter | NInput | _tableColumnFilter | {column}-filter | - |
NTableSelectionHeader | NCheckbox | _tableSelectionHeader | select-header | - |
NTableSelectionCell | NCheckbox | _tableSelectionCell | select-cell | - |
NTableExpandButton | NButton | _tableExpandButton | expand-cell | table-expand-icon |
NTablePagination | NPagination | _tablePagination | pagination | - |
Their una keys — tableSortButton, tableSortIconBase, tableSortAscIcon,
tableSelection, tableExpandButton, tablePagination and the rest — are
listed under Props; the *Icon keys take an icon name, the others
take classes. The icon aliases are preset shortcuts, so they can also be
re-pointed for the whole app from your UnoCSS config. One thing to know: the
sort button's font-normal is marked important in the table-sort-button
shortcut, so override its weight with font-medium!.
| Status | |||||
|---|---|---|---|---|---|
| Darrin | Douglas-Bode | 6 | relationship | ||
| Sandrine | Lakin | 23 | relationship | ||
| Albertha | Kilback | 38 | relationship | ||
| Guillermo | Kuhn | 23 | single | ||
| Maeve | Koss | 0 | relationship | ||
<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 expanded = ref<Record<string, boolean>>({})
const columns: ColumnDef<Person>[] = [
{
header: 'First Name',
accessorKey: 'firstName',
// per column, `meta` takes the same keys as the `_table*` props
meta: {
_tableColumnFilter: {
placeholder: 'Search first names…',
leading: 'i-lucide-search',
},
},
},
{
header: 'Last Name',
accessorKey: 'lastName',
},
{
header: 'Age',
accessorKey: 'age',
meta: {
_tableSortButton: { btn: 'soft-primary' },
},
},
{
header: 'Status',
accessorKey: 'status',
enableSorting: false,
enableColumnFilter: false,
},
]
</script>
<template>
<NTable
v-model:expanded="expanded"
:columns
:data
enable-sorting
enable-column-filters
enable-row-selection
:_table-sort-button="{ btn: 'ghost-primary' }"
:_table-column-filter="{ size: 'sm' }"
:_table-selection-header="{ checkbox: 'lime' }"
:_table-selection-cell="{ checkbox: 'lime' }"
:_table-expand-button="{ btn: 'outline-gray', size: 'sm' }"
:una="{
tableSortAscIcon: 'i-lucide-arrow-up',
tableSortDescIcon: 'i-lucide-arrow-down',
tableSortNoneIcon: 'i-lucide-chevrons-up-down',
}"
>
<template #expanded="{ row }">
<p class="p-4 text-sm text-muted-foreground">
{{ row.original.email }}
</p>
</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
Slots
| Name | Props | Description |
|---|---|---|
{column}-filter | column | Column filter slot. |
{column}-header | column | Column header slot. |
{column}-cell | cell | Column cell slot. |
{column}-footer | column | Column footer slot. |
header | table | Header slot. |
body | table | Body slot. |
row | row | Row slot. |
footer | table | Footer slot. |
select-header | column | Select-all checkbox slot, when enableRowSelection is set. |
select-cell | cell | Per-row selection checkbox slot. |
expand-cell | cell | Row expand toggle slot, when an expanded slot is provided. |
expanded | row | Expanded row content slot. |
empty | - | Empty slot. |
loading | - | Loading slot. |
pagination | table, pagination | Replaces the built-in pagination bar. |
select-header, select-cell and expand-cell are keyed off the reserved
column ids select and expand. Do not give one of your own columns either
id — NTable warns in dev if you do, because TanStack keys columns by id and
would silently drop one of the two.
Note that expand-cell (the toggle button) and expanded (the expanded row's
content) are different slots.
| Account | |||||
|---|---|---|---|---|---|
Marty Lakin Edwina_Kuhn@hotmail.com | Marty | Lakin | relationship | 81% | |
Shirley McCullough Bernadette72@yahoo.com | Shirley | McCullough | single | 98% | |
Brian Hermann Roger84@yahoo.com | Brian | Hermann | complicated | 97% | |
Nellie Reilly Melyna_Abbott@gmail.com | Nellie | Reilly | complicated | 17% | |
Ciara Yost Joey54@yahoo.com | Ciara | Yost | single | 22% | |
Inez Cassin Jeremy.Brown@gmail.com | Inez | Cassin | relationship | 35% | |
Vera Rogahn Jennie_Adams@hotmail.com | Vera | Rogahn | single | 27% | |
Sophia Kohler Salvador.Collins94@hotmail.com | Sophia | Kohler | relationship | 97% | |
Mauricio Conroy Rachel_Haley@gmail.com | Mauricio | Conroy | complicated | 4% | |
Ned Mertz Emmie_Bednar16@hotmail.com | Ned | Mertz | relationship | 7% | |
<script setup lang="ts">
import type { ColumnDef, RowSelectionState } from '@tanstack/vue-table'
import type { Person } from './makeData'
import { NAvatar } from '#components'
import { faker } from '@faker-js/faker'
import makeData from './makeData'
const data = ref(makeData(50))
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-foreground',
}, 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<RowSelectionState>()
</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="outline-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
v-model:row-selection="select"
:columns
:data
:global-filter="search"
enable-row-selection enable-column-filters enable-sorting
row-id="username"
show-pagination
>
<!-- 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-foreground">{{ cell.row.original.progress }}%</span>
</div>
</template>
<!-- end cell -->
</NTable>
</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
Migrating
The widgets NTable injected into its columns were built inline, with literal
props and no way to adjust them. They are now sub-components, which moved a
handful of names and defaults:
- <template #selection-header>…</template>
- <template #selection-cell>…</template>
- <template #expanded-cell>…</template>
+ <template #select-header>…</template>
+ <template #select-cell>…</template>
+ <template #expand-cell>…</template>
- :column-pinning="{ left: ['selection', 'firstName'] }"
+ :column-pinning="{ left: ['select', 'firstName'] }"
| Before | After |
|---|---|
Injected columns with the ids selection and expanded | select and expand, as in shadcn. Rename them wherever a column id appears — columnOrder, columnPinning, columnVisibility. |
#selection-header, #selection-cell, #expanded-cell | #select-header, #select-cell, #expand-cell. #expanded, the row content, is unchanged. |
A column of your own with the id select or expand | Rename it. TanStack keys columns by id and silently drops one of the two; NTable now warns in dev. |
Any key on columns.meta landed on <th> and <td> as an attribute | Only una and the _table* keys are read. Set attributes through meta._tableHead and meta._tableCell. |
| Sort button, filter input, checkboxes and expand toggle hardcoded | NTableSortButton, NTableColumnFilter, NTableSelectionHeader, NTableSelectionCell, NTableExpandButton — configured through the _table* props and una keys. |
| Sort and expand icons hardcoded | The table-sort-asc-icon, table-sort-desc-icon, table-sort-none-icon and table-expand-icon preset aliases, or the matching una keys. |
| The selection checkboxes and the expand toggle had no accessible name | aria-label on all three, aria-sort on sortable headers, data-expanded on the toggle. |
table-default-variant, table-loading-icon, table-loading-icon-name | Removed — none of them rendered anything. |
NTableLoading ignored its colspan | Honoured; NTable passes its leaf-column count. |
NTable rendered a single root element | It renders a fragment — the root plus, with show-pagination, the bar. Attributes still land on <table>; if you relied on $el, wrap it yourself. |
The pagination bar — showPagination, _tablePagination, the pagination
slot — is additive; a table without them renders as before.
Presets
type TablePrefix = 'table'
export const staticTable: Record<`${TablePrefix}-${string}` | TablePrefix, string> = {
// icons
'table-sort-asc-icon': 'i-lucide-arrow-up-wide-narrow',
'table-sort-desc-icon': 'i-lucide-arrow-down-narrow-wide',
'table-sort-none-icon': 'i-lucide-arrow-up-down',
'table-expand-icon': 'i-radix-icons-chevron-down',
// table-root
'table-root': 'relative w-full overflow-x-auto overflow-y-hidden border border-border rounded-md',
'table': 'w-full caption-bottom text-sm',
'table-body': '[&_tr:last-child]:border-0',
'table-caption': 'mt-4 text-sm text-muted-foreground',
// table-head
'table-head': 'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-0.5',
'table-head-pinned': 'sticky bg-background',
'table-head-pinned-left': 'left-0',
'table-head-pinned-right': 'right-0',
// table-header
'table-header': '[&_tr]:border-b [&_tr]:border-border',
// table-row
'table-row': 'border-b border-border transition-colors hover:bg-muted/50 data-[filter=true]:hover:bg-background data-[state=selected]:bg-muted',
// table-cell
'table-cell': 'p-4 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-0.5',
'table-cell-pinned': 'sticky bg-background',
'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-foreground bg-background',
'table-empty': 'flex items-center flex-col justify-center py-10 gap-4',
'table-empty-text': 'text-center text-wrap',
'table-empty-icon-name': 'i-tabler-database-x size-2xl',
// table-loading
'table-loading-row': 'data-[loading=true]:border-0 absolute inset-x-0 -mt-1.5px',
'table-loading-cell': '',
'table-loading': 'absolute inset-x-0 overflow-hidden p-0',
// table-sort-button
// `-ml-1em` cancels the button's own padding so sortable headers align with
// plain ones. `font-normal!` because `.btn`'s font-medium is emitted after
// this shortcut in the same layer and would otherwise win.
'table-sort-button': 'font-normal! -ml-1em',
// sized by width/height, not font-size: `btn-trailing` sets font-size on the
// same element and is emitted later, so `text-sm` here was inert
'table-sort-icon-base': 'square-0.875rem',
// table-column-filter
'table-column-filter': 'w-auto',
// table-selection
'table-selection': '',
'table-selection-header': '',
'table-selection-cell': '',
// table-expand-button
'table-expand-button': '',
'table-expand-icon-base': 'transform transition-transform duration-200',
// table-pagination
// shadcn's DataTablePagination, part for part: status on the left, then rows
// per page, "Page X of Y" and the navigation on the right; a sibling below
// `table-root`, hence the `mt-4`
'table-pagination': 'mt-4 flex items-center justify-between px-4',
'table-pagination-status': 'hidden flex-1 text-sm text-muted-foreground lg:flex',
'table-pagination-controls': 'flex w-full items-center gap-8 lg:w-fit',
'table-pagination-page-size': 'hidden items-center gap-2 lg:flex',
'table-pagination-page': 'flex w-fit items-center justify-center text-sm',
'table-pagination-nav': 'ml-auto flex items-center lg:ml-0',
// table-footer
'table-footer': 'border-t border-border bg-muted font-medium [&>tr]:last:border-b-0',
}
export const dynamicTable: [RegExp, (params: RegExpExecArray) => string][] = [
]
export const table = [
...dynamicTable,
staticTable,
]
Props
import type {
Column,
ColumnDef,
CoreOptions,
FilterFn,
FilterFnOption,
GroupColumnDef,
Row,
RowData,
Table,
} from '@tanstack/vue-table'
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { NButtonProps } from './button'
import type { NCheckboxProps } from './checkbox'
import type { NInputProps } from './input'
import type { NPaginationProps } from './pagination'
import type { NProgressProps } from './progress'
import type { NScrollAreaProps, NScrollAreaUnaProps } from './scroll-area'
export interface NTableProps<TData, TValue> extends Omit<CoreOptions<TData>, 'data' | 'columns' | 'getCoreRowModel' | 'state' | 'onStateChange' | 'renderFallbackValue'>, Pick<NTableEmptyProps, 'emptyText' | 'emptyIcon'> {
class?: HTMLAttributes['class']
/**
* @see https://tanstack.com/table/latest/docs/api/core/table#state
*/
state?: CoreOptions<TData>['state']
/**
* @see https://tanstack.com/table/latest/docs/api/core/table#onstatechange
*/
onStateChange?: CoreOptions<TData>['onStateChange']
/**
* @see https://tanstack.com/table/latest/docs/api/core/table#renderfallbackvalue
*/
renderFallbackValue?: CoreOptions<TData>['renderFallbackValue']
/**
* @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/column-filtering#manualfiltering
*/
manualFiltering?: boolean
/**
* The filter function to use for global filtering.
* Can be a built-in filter function name or a custom filter function.
*
* @see https://tanstack.com/table/latest/docs/api/features/global-filtering#globalfilterfn
*/
globalFilterFn?: FilterFnOption<TData>
/**
* Custom filter functions that can be referenced by columns or globalFilterFn by string key.
*
* @see https://tanstack.com/table/latest/docs/api/features/column-filtering#filterfns
*/
filterFns?: Record<string, FilterFn<TData>>
/**
* @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 | ((row?: TData) => NTableRowProps)
_tableCell?: NTableCellProps
_tableEmpty?: NTableEmptyProps
_tableLoading?: NTableLoadingProps
_tableSortButton?: Omit<NTableSortButtonProps<TData, TValue>, 'column'>
_tableColumnFilter?: Omit<NTableColumnFilterProps<TData, TValue>, 'column'>
_tableSelectionHeader?: Omit<NTableSelectionHeaderProps<TData>, 'table'>
_tableSelectionCell?: Omit<NTableSelectionCellProps<TData>, 'row'>
_tableExpandButton?: Omit<NTableExpandButtonProps<TData>, 'row'>
_tablePagination?: Omit<NTablePaginationProps, 'table'>
_scrollArea?: NScrollAreaProps
/**
* Whether the table is loading.
*/
loading?: boolean
/**
* Render the built-in pagination bar below the root, as a sibling rather
* than inside it. Configure it through `_tablePagination`, or replace it
* with the `pagination` slot.
*
* @default false
*/
showPagination?: boolean
/**
* `UnaUI` preset configuration
*
* @see https://github.com/una-ui/una-ui/blob/main/packages/preset/src/_shortcuts/table.ts
*/
una?: NTableUnaProps & NScrollAreaUnaProps
}
export interface NTableBodyProps extends PrimitiveProps {
[key: string]: any
class?: HTMLAttributes['class']
una?: Pick<NTableUnaProps, 'tableBody'>
}
export interface NTableHeadProps extends PrimitiveProps {
[key: string]: any
class?: HTMLAttributes['class']
dataPinned?: 'left' | 'right' | false
una?: Pick<NTableUnaProps, 'tableHead'>
}
export interface NTableHeaderProps extends PrimitiveProps {
[key: string]: any
class?: HTMLAttributes['class']
una?: Pick<NTableUnaProps, 'tableHeader'>
}
export interface NTableFooterProps extends PrimitiveProps {
[key: string]: any
class?: HTMLAttributes['class']
una?: Pick<NTableUnaProps, 'tableFooter'>
}
export interface NTableRowProps extends PrimitiveProps {
[key: string]: any
class?: HTMLAttributes['class']
una?: Pick<NTableUnaProps, 'tableRow'>
}
export interface NTableCellProps extends PrimitiveProps {
[key: string]: any
class?: HTMLAttributes['class']
dataPinned?: 'left' | 'right' | false
una?: Pick<NTableUnaProps, 'tableCell'>
}
export interface NTableEmptyProps {
[key: string]: any
class?: HTMLAttributes['class']
colspan?: number
/**
* The text to display when the table is empty.
*/
emptyText?: string
/**
* The icon to display when the table is empty.
*/
emptyIcon?: string
_tableCell?: NTableCellProps
_tableRow?: NTableRowProps
una?: Pick<NTableUnaProps, 'tableEmpty' | 'tableRow' | 'tableCell' | 'tableEmptyText' | 'tableEmptyIcon'>
}
export interface NTableLoadingProps {
[key: string]: any
size?: HTMLAttributes['class']
enabled?: boolean
class?: HTMLAttributes['class']
colspan?: number
_tableCell?: NTableCellProps
_tableRow?: NTableRowProps
_tableProgress?: NProgressProps
una?: Pick<NTableUnaProps, 'tableLoading' | 'tableLoadingCell' | 'tableLoadingRow'>
}
export interface NTableCaptionProps extends PrimitiveProps {
[key: string]: any
class?: HTMLAttributes['class']
una?: Pick<NTableUnaProps, 'tableCaption'>
}
export interface NTableSortButtonProps<TData = any, TValue = any> extends Omit<NButtonProps, 'una'> {
/** The TanStack column this button sorts. Supplied by `NTable`. */
column: Column<TData, TValue>
una?: Pick<NTableUnaProps, 'tableSortButton' | 'tableSortIconBase' | 'tableSortAscIcon' | 'tableSortDescIcon' | 'tableSortNoneIcon'> & NButtonProps['una']
}
export interface NTableColumnFilterProps<TData = any, TValue = any> extends Omit<NInputProps, 'una'> {
class?: HTMLAttributes['class']
/** The TanStack column this input filters. Supplied by `NTable`. */
column: Column<TData, TValue>
una?: Pick<NTableUnaProps, 'tableColumnFilter'> & NInputProps['una']
}
export interface NTableSelectionHeaderProps<TData = any> extends Omit<NCheckboxProps, 'una'> {
/** The TanStack table instance. Supplied by `NTable`. */
table: Table<TData>
una?: Pick<NTableUnaProps, 'tableSelection' | 'tableSelectionHeader'> & NCheckboxProps['una']
}
export interface NTableSelectionCellProps<TData = any> extends Omit<NCheckboxProps, 'una'> {
/** The TanStack row this checkbox selects. Supplied by `NTable`. */
row: Row<TData>
una?: Pick<NTableUnaProps, 'tableSelection' | 'tableSelectionCell'> & NCheckboxProps['una']
}
export interface NTableExpandButtonProps<TData = any> extends Omit<NButtonProps, 'una'> {
/** The TanStack row this button expands. Supplied by `NTable`. */
row: Row<TData>
una?: Pick<NTableUnaProps, 'tableExpandButton' | 'tableExpandIconBase' | 'tableExpandIcon'> & NButtonProps['una']
}
export interface NTablePaginationProps extends Omit<NPaginationProps, 'page' | 'defaultPage' | 'itemsPerPage' | 'total' | 'una' | 'showInfo' | 'showRowsPerPage'> {
/**
* The TanStack table instance. Supplied through context when rendered by
* `NTable`; pass it directly to use the bar on its own, outside the root.
*/
table?: Table<any>
/**
* Render the status text on the left: the selection count when the table
* has row selection enabled, otherwise the visible row range.
*
* Unlike `NPagination`'s flag of the same name, this never toggles
* `NPaginationInfo` — the bar always renders "Page X of Y" among its
* controls.
*
* @default true
*/
showInfo?: boolean
/**
* Render the rows-per-page select among the controls.
*
* @default true
*/
showRowsPerPage?: boolean
una?: Pick<NTableUnaProps, 'tablePagination' | 'tablePaginationStatus' | 'tablePaginationControls' | 'tablePaginationPageSize' | 'tablePaginationPage' | 'tablePaginationNav'> & NPaginationProps['una']
}
export interface NTableUnaProps {
table?: HTMLAttributes['class']
tableRoot?: HTMLAttributes['class']
tableBody?: HTMLAttributes['class']
tableHead?: HTMLAttributes['class']
tableHeader?: HTMLAttributes['class']
tableFooter?: HTMLAttributes['class']
tableRow?: HTMLAttributes['class']
tableCell?: HTMLAttributes['class']
tableCaption?: HTMLAttributes['class']
tableLoading?: HTMLAttributes['class']
tableLoadingRow?: HTMLAttributes['class']
tableLoadingCell?: HTMLAttributes['class']
tableEmpty?: HTMLAttributes['class']
tableEmptyText?: HTMLAttributes['class']
tableEmptyIcon?: HTMLAttributes['class']
// injected widgets
tableSortButton?: HTMLAttributes['class']
tableSortIconBase?: HTMLAttributes['class']
tableSortAscIcon?: HTMLAttributes['class']
tableSortDescIcon?: HTMLAttributes['class']
tableSortNoneIcon?: HTMLAttributes['class']
tableColumnFilter?: HTMLAttributes['class']
tableSelection?: HTMLAttributes['class']
tableSelectionHeader?: HTMLAttributes['class']
tableSelectionCell?: HTMLAttributes['class']
tableExpandButton?: HTMLAttributes['class']
tableExpandIconBase?: HTMLAttributes['class']
tableExpandIcon?: HTMLAttributes['class']
tablePagination?: HTMLAttributes['class']
tablePaginationStatus?: HTMLAttributes['class']
tablePaginationControls?: HTMLAttributes['class']
tablePaginationPageSize?: HTMLAttributes['class']
tablePaginationPage?: HTMLAttributes['class']
tablePaginationNav?: HTMLAttributes['class']
}
/**
* Per-column configuration, read from `columnDef.meta`.
*
* `NTable` passes only these keys through to its parts — anything else on
* `meta` stays out of the DOM.
*/
declare module '@tanstack/vue-table' {
interface ColumnMeta<TData extends RowData, TValue> {
una?: NTableUnaProps
_tableHead?: NTableHeadProps
_tableCell?: NTableCellProps
_tableSortButton?: Omit<NTableSortButtonProps<TData, TValue>, 'column'>
_tableColumnFilter?: Omit<NTableColumnFilterProps<TData, TValue>, 'column'>
_tableSelectionHeader?: Omit<NTableSelectionHeaderProps<TData>, 'table'>
_tableSelectionCell?: Omit<NTableSelectionCellProps<TData>, 'row'>
_tableExpandButton?: Omit<NTableExpandButtonProps<TData>, 'row'>
}
}
Components
<script setup lang="ts" generic="TData, TValue">
import type {
Column,
ColumnDef,
ColumnFiltersState,
ColumnOrderState,
ColumnPinningState,
ExpandedState,
GroupingState,
Header,
PaginationState,
RowSelectionState,
SortingState,
VisibilityState,
} from '@tanstack/vue-table'
import type { NTableProps } from '../../../types'
import {
FlexRender,
getCoreRowModel,
getExpandedRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useVueTable,
} from '@tanstack/vue-table'
import { computed } from 'vue'
import { cn, valueUpdater } from '../../../utils'
import ScrollArea from '../../scroll-area/ScrollArea.vue'
import TableBody from './TableBody.vue'
import TableCell from './TableCell.vue'
import TableColumnFilter from './TableColumnFilter.vue'
import TableEmpty from './TableEmpty.vue'
import TableExpandButton from './TableExpandButton.vue'
import TableFooter from './TableFooter.vue'
import TableHead from './TableHead.vue'
import TableHeader from './TableHeader.vue'
import TableLoading from './TableLoading.vue'
import TablePagination from './TablePagination.vue'
import TableRow from './TableRow.vue'
import TableSelectionCell from './TableSelectionCell.vue'
import TableSelectionHeader from './TableSelectionHeader.vue'
import TableSortButton from './TableSortButton.vue'
import { provideTableContext } from './useTable'
// the root is a fragment once the pagination bar renders beside `table-root`,
// so attrs cannot be inherited — they keep going to `<table>` through the
// explicit `v-bind="$attrs"` there, as they always have
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps <NTableProps<TData, TValue>>(), {
enableMultiRowSelection: true,
enableSortingRemoval: true,
showPagination: false,
})
const emit = defineEmits<{
select: [row: TData]
selectAll: [rows: TData[]]
expand: [row: TData]
row: [event: Event, row: TData]
}>()
const slots = defineSlots()
/**
* Column ids `NTable` injects for its own widgets. A user column resolving to
* either would be silently dropped — TanStack keys columns by id in a plain
* map, so the later definition wins without warning.
*/
const SELECT_COLUMN_ID = 'select'
const EXPAND_COLUMN_ID = 'expand'
const rowSelection = defineModel<RowSelectionState>('rowSelection')
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(() => {
if (import.meta.dev) {
const reservedIds = [SELECT_COLUMN_ID, EXPAND_COLUMN_ID]
for (const column of props.columns ?? []) {
const id = (column as any).id ?? (column as any).accessorKey
if (reservedIds.includes(id)) {
console.warn(`[NTable]: The column id '${id}' is reserved for the built-in ${id === SELECT_COLUMN_ID ? 'row selection' : 'row expansion'} column. TanStack keys columns by id, so one of the two will be silently dropped. Please choose a different id.`)
}
}
}
let data = props.columns as ColumnDef<TData, TValue>[]
// add selection column
data = props.enableRowSelection
? [
{
id: SELECT_COLUMN_ID,
enableSorting: false,
enableHiding: false,
enableColumnFilter: false,
},
...data,
]
: data
// add expanded column
data = slots.expanded
? [
{
id: EXPAND_COLUMN_ID,
enableSorting: false,
enableHiding: false,
enableColumnFilter: false,
},
...data,
]
: data
return data
})
const table = 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 },
},
// getters, like `data` and `columns` above: the Vue adapter reads options
// through a lazy proxy, so a plain `x: props.x` is captured once at setup
// and never sees the prop change — a `rowCount` arriving after a fetch, or
// a `pageCount` recomputed for a new page size, left the table on a stale
// count
get enableMultiRowSelection() {
return props.enableMultiRowSelection
},
get enableSubRowSelection() {
return props.enableSubRowSelection
},
get autoResetAll() {
return props.autoResetAll
},
get enableRowSelection() {
return props.enableRowSelection
},
get enableColumnFilters() {
return props.enableColumnFilters
},
get manualPagination() {
return props.manualPagination
},
get manualSorting() {
return props.manualSorting
},
get manualFiltering() {
return props.manualFiltering
},
get globalFilterFn() {
return props.globalFilterFn
},
get filterFns() {
return props.filterFns
},
get pageCount() {
return props.pageCount
},
get rowCount() {
return props.rowCount
},
get autoResetPageIndex() {
return props.autoResetPageIndex
},
get enableSorting() {
return props.enableSorting
},
get enableSortingRemoval() {
return props.enableSortingRemoval
},
get enableMultiSort() {
return props.enableMultiSort
},
get enableMultiRemove() {
return props.enableMultiRemove
},
get maxMultiSortColCount() {
return props.maxMultiSortColCount
},
get sortingFns() {
return props.sortingFns
},
get isMultiSortEvent() {
return props.isMultiSortEvent
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getRowId: (row: any) => props.rowId ? row[props.rowId] : row.id,
getSubRows: (row: any) => row.subRows,
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),
})
// the seam the built-in pagination bar — and anything composed into
// `#pagination` — reads the instance through, without a template ref
provideTableContext({
table,
pagination: computed(() => pagination.value),
setPageIndex: index => table.setPageIndex(index),
setPageSize: size => table.setPageSize(size),
})
function getHeaderColumnFiltersCount(headers: Header<unknown, unknown>[]): number {
let count = 0
headers.forEach((header) => {
if (header.column.columnDef.enableColumnFilter)
count++
})
return count
}
function getRowAttrs(data?: TData) {
if (typeof props._tableRow === 'function') {
return props._tableRow(data)
}
return props._tableRow
}
function isReserved(column: Column<TData, any>) {
return column.id === SELECT_COLUMN_ID || column.id === EXPAND_COLUMN_ID
}
function isSortable(column: Column<TData, any>) {
return Boolean(
column.columnDef.enableSorting
|| (column.columnDef.enableSorting !== false && props.enableSorting),
)
}
function getAriaSort(column: Column<TData, any>) {
if (!isSortable(column))
return undefined
const sorted = column.getIsSorted()
return sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : 'none'
}
/**
* `columnDef.meta` is an arbitrary user bag, so only the keys `NTable`
* understands are read from it — the rest never reaches the DOM.
*/
function columnUna(column: Column<TData, any>) {
return { ...props.una, ...column.columnDef.meta?.una }
}
defineExpose({
...table,
})
</script>
<template>
<div
:class="cn('table-root', props.una?.tableRoot)"
>
<ScrollArea
orientation="horizontal"
v-bind="props._scrollArea"
:una
>
<table
v-bind="$attrs"
:class="cn(
'table',
props.una?.table,
props.class,
)"
>
<!-- header -->
<TableHeader
:una
v-bind="props._tableHeader"
>
<slot name="header" :table="table">
<TableRow
v-for="headerGroup in table.getHeaderGroups()"
:key="headerGroup.id"
:una
v-bind="getRowAttrs()"
>
<!-- headers -->
<TableHead
v-for="header in headerGroup.headers"
:key="header.id"
:colspan="header.colSpan"
:data-pinned="header.column.getIsPinned()"
:aria-sort="getAriaSort(header.column)"
:una="columnUna(header.column)"
v-bind="{ ...props._tableHead, ...header.column.columnDef.meta?._tableHead }"
>
<TableSortButton
v-if="isSortable(header.column)"
:column="header.column"
:una="columnUna(header.column)"
v-bind="{ ...props._tableSortButton, ...header.column.columnDef.meta?._tableSortButton }"
>
<slot
:name="`${header.id}-header`"
:column="header.column"
>
<FlexRender
v-if="!header.isPlaceholder"
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
</slot>
</TableSortButton>
<slot
v-else
:name="`${header.id}-header`"
:column="header.column"
>
<TableSelectionHeader
v-if="!header.isPlaceholder && header.column.id === SELECT_COLUMN_ID && enableMultiRowSelection"
:table
:una="columnUna(header.column)"
v-bind="{ ...props._tableSelectionHeader, ...header.column.columnDef.meta?._tableSelectionHeader }"
@change="emit('selectAll', $event)"
/>
<FlexRender
v-else-if="!header.isPlaceholder"
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
</slot>
</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
v-bind="getRowAttrs()"
>
<TableHead
v-for="header in headerGroup.headers"
:key="header.id"
:colspan="header.colSpan"
class="font-normal"
:data-pinned="header.column.getIsPinned()"
:una="columnUna(header.column)"
v-bind="{ ...props._tableHead, ...header.column.columnDef.meta?._tableHead }"
>
<slot
v-if="!isReserved(header.column) && ((header.column.columnDef.enableColumnFilter !== false && enableColumnFilters) || header.column.columnDef.enableColumnFilter)"
:name="`${header.id}-filter`"
:column="header.column"
>
<TableColumnFilter
:column="header.column"
:una="columnUna(header.column)"
v-bind="{ ...props._tableColumnFilter, ...header.column.columnDef.meta?._tableColumnFilter }"
/>
</slot>
</TableHead>
</TableRow>
</template>
</slot>
<TableLoading
:enabled="props.loading"
:colspan="table.getAllLeafColumns().length"
:una
v-bind="props._tableLoading"
>
<slot name="loading" />
</TableLoading>
</TableHeader>
<!-- body -->
<TableBody
: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
v-bind="getRowAttrs(row.original)"
@click="emit('row', $event, row.original)"
>
<slot
name="row"
:row="row"
>
<!-- rows -->
<TableCell
v-for="cell in row.getVisibleCells()"
:key="cell.id"
:data-pinned="cell.column.getIsPinned()"
:una="columnUna(cell.column)"
v-bind="{ ...props._tableCell, ...cell.column.columnDef.meta?._tableCell }"
>
<slot
:name="`${cell.column.id}-cell`"
:cell="cell"
>
<TableSelectionCell
v-if="cell.column.id === SELECT_COLUMN_ID"
:row
:una="columnUna(cell.column)"
v-bind="{ ...props._tableSelectionCell, ...cell.column.columnDef.meta?._tableSelectionCell }"
@change="emit('select', $event)"
/>
<TableExpandButton
v-else-if="cell.column.id === EXPAND_COLUMN_ID"
:row
:una="columnUna(cell.column)"
v-bind="{ ...props._tableExpandButton, ...cell.column.columnDef.meta?._tableExpandButton }"
@change="emit('expand', $event)"
/>
<FlexRender
v-else
:render="cell.column.columnDef.cell"
:props="cell.getContext()"
/>
</slot>
</TableCell>
</slot>
</TableRow>
<!-- expanded -->
<TableRow
v-if="row.getIsExpanded() && $slots.expanded"
:una
v-bind="getRowAttrs(row.original)"
>
<TableCell
:colspan="row.getAllCells().length"
:una
v-bind="props._tableCell"
>
<slot name="expanded" :row="row" />
</TableCell>
</TableRow>
</template>
</template>
<TableEmpty
v-else
:colspan="table.getAllLeafColumns().length"
:una
:empty-text="props.emptyText"
:empty-icon="props.emptyIcon"
v-bind="props._tableEmpty"
>
<slot name="empty" />
</TableEmpty>
</slot>
</TableBody>
<!-- footer -->
<TableFooter
v-if="table.getFooterGroups().length > 0"
: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
v-bind="getRowAttrs()"
>
<template
v-for="header in footerGroup.headers"
:key="header.id"
>
<TableHead
v-if="header.column.columnDef.footer"
:colspan="header.colSpan"
:una="columnUna(header.column)"
v-bind="{ ...props._tableHead, ...header.column.columnDef.meta?._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>
</table>
</ScrollArea>
</div>
<!-- pagination bar: a sibling below the root, where shadcn's
DataTablePagination sits relative to the bordered table -->
<slot
v-if="showPagination || $slots.pagination"
name="pagination"
:table="table"
:pagination="pagination"
>
<TablePagination
:una
v-bind="props._tablePagination"
/>
</slot>
</template>
<script setup lang="ts">
import type { NTableHeaderProps } from '../../../types'
import { reactiveOmit } from '@vueuse/core'
import { Primitive } from 'reka-ui'
import { cn } from '../../../utils'
const props = withDefaults(defineProps<NTableHeaderProps>(), {
as: 'thead',
})
const rootProps = reactiveOmit(props, ['una', 'class'])
</script>
<template>
<Primitive
:class="cn(
'table-header',
props?.una?.tableHeader,
props.class,
)"
v-bind="{ ...rootProps, ...$attrs }"
>
<slot />
</Primitive>
</template>
<script setup lang="ts">
import type { NTableHeadProps } from '../../../types'
import { reactiveOmit } from '@vueuse/core'
import { Primitive } from 'reka-ui'
import { cn } from '../../../utils'
const props = withDefaults(defineProps<NTableHeadProps>(), {
as: 'th',
})
const rootProps = reactiveOmit(props, ['una', 'class'])
</script>
<template>
<Primitive
: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="{ ...rootProps, ...$attrs }"
>
<slot />
</Primitive>
</template>
<script setup lang="ts">
import type { NTableBodyProps } from '../../../types'
import { reactiveOmit } from '@vueuse/core'
import { Primitive } from 'reka-ui'
import { cn } from '../../../utils'
const props = withDefaults(defineProps<NTableBodyProps>(), {
as: 'tbody',
})
const rootProps = reactiveOmit(props, ['una', 'class'])
</script>
<template>
<Primitive
:class="cn(
'table-body',
props?.una?.tableBody,
props.class,
)"
v-bind="{ ...rootProps, ...$attrs }"
>
<slot />
</Primitive>
</template>
<script setup lang="ts">
import type { NTableFooterProps } from '../../../types'
import { reactiveOmit } from '@vueuse/core'
import { Primitive } from 'reka-ui'
import { cn } from '../../../utils'
const props = withDefaults(defineProps<NTableFooterProps>(), {
as: 'tfoot',
})
const rootProps = reactiveOmit(props, ['una', 'class'])
</script>
<template>
<Primitive
:class="cn(
'table-footer',
props.una?.tableFooter,
props.class,
)"
v-bind="{ ...rootProps, ...$attrs }"
>
<slot />
</Primitive>
</template>
<script setup lang="ts">
import type { NTableCellProps } from '../../../types'
import { reactiveOmit } from '@vueuse/core'
import { Primitive } from 'reka-ui'
import { cn } from '../../../utils'
const props = withDefaults(defineProps<NTableCellProps>(), {
as: 'td',
})
const rootProps = reactiveOmit(props, ['una', 'class'])
</script>
<template>
<Primitive
: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="{ ...rootProps, ...$attrs }"
>
<slot />
</Primitive>
</template>
<script setup lang="ts">
import type { NTableRowProps } from '../../../types'
import { reactiveOmit } from '@vueuse/core'
import { Primitive } from 'reka-ui'
import { cn } from '../../../utils'
const props = withDefaults(defineProps<NTableRowProps>(), {
as: 'tr',
})
const rootProps = reactiveOmit(props, ['una', 'class'])
</script>
<template>
<Primitive
role="row"
:class="cn(
'table-row',
props.una?.tableRow,
props.class,
)"
v-bind="{ ...rootProps, ...$attrs }"
>
<slot />
</Primitive>
</template>
<style>
/* the builtin unocss utility is overwritten, so it has to be defined here */
.table-row {
display: table-row;
}
</style>
<script setup lang="ts">
import type { NTableEmptyProps } from '../../../types'
import { reactiveOmit } from '@vueuse/core'
import { cn, omitProps } from '../../../utils'
import TableCell from './TableCell.vue'
import TableRow from './TableRow.vue'
const props = withDefaults(defineProps<NTableEmptyProps>(), {
colspan: 1,
emptyText: 'No results.',
emptyIcon: 'table-empty-icon-name',
})
const delegatedProps = reactiveOmit(props, ['class'])
</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>
<NIcon
:name="props.emptyIcon"
:class="cn(
'table-empty-icon',
props.una?.tableEmptyIcon,
)"
/>
<span
:class="cn(
'table-empty-text',
props.una?.tableEmptyText,
)"
>
{{ props.emptyText }}
</span>
</slot>
</div>
</TableCell>
</TableRow>
</template>
<script setup lang="ts">
import type { NTableLoadingProps } from '../../../types'
import { reactiveOmit } from '@vueuse/core'
import { cn } from '../../../utils'
import Progress from '../../elements/Progress.vue'
import TableRow from './TableRow.vue'
const props = withDefaults(defineProps<NTableLoadingProps>(), {
size: '2.5px',
colspan: 1,
})
const delegatedProps = reactiveOmit(props, ['class'])
</script>
<template>
<TableRow
:class="cn(
'table-loading-row',
props.una?.tableLoadingRow,
)"
data-loading="true"
v-bind="delegatedProps._tableRow"
>
<td
:class="
cn(
'table-loading-cell',
props.una?.tableLoadingCell,
)
"
:colspan="props.colspan"
v-bind="delegatedProps._tableCell"
>
<div
v-if="enabled"
:class="cn(
'table-loading',
props.una?.tableLoading,
)"
>
<slot>
<Progress
:size
v-bind="props._tableProgress"
:class="cn(
props._tableProgress?.class,
)"
/>
</slot>
</div>
</td>
</TableRow>
</template>
<script setup lang="ts">
import type { NTableCaptionProps } from '../../../types'
import { reactiveOmit } from '@vueuse/core'
import { Primitive } from 'reka-ui'
import { cn } from '../../../utils'
const props = withDefaults(defineProps<NTableCaptionProps>(), {
as: 'caption',
})
const rootProps = reactiveOmit(props, ['una', 'class'])
</script>
<template>
<Primitive
:class="cn(
'table-caption',
props?.una?.tableCaption,
props.class,
)"
v-bind="{ ...rootProps, ...$attrs } "
>
<slot />
</Primitive>
</template>
<script setup lang="ts">
import type { NTableSortButtonProps } from '../../../types'
import { reactiveOmit } from '@vueuse/core'
import { useForwardProps } from 'reka-ui'
import { computed } from 'vue'
import { cn } from '../../../utils'
import Button from '../../elements/Button.vue'
const props = withDefaults(defineProps<NTableSortButtonProps>(), {
btn: 'ghost-gray',
size: 'sm',
})
const delegatedProps = reactiveOmit(props, ['class', 'column', 'una', 'trailing'])
// forward only what was actually passed: props typed as booleans are cast
// to `false` when absent, and spreading those would override the wrapped
// component's own defaults
const forwardedProps = useForwardProps(delegatedProps)
const sorted = computed(() => props.column.getIsSorted())
const sortIcon = computed(() => {
if (sorted.value === 'asc')
return props.una?.tableSortAscIcon ?? 'table-sort-asc-icon'
if (sorted.value === 'desc')
return props.una?.tableSortDescIcon ?? 'table-sort-desc-icon'
return props.una?.tableSortNoneIcon ?? 'table-sort-none-icon'
})
</script>
<template>
<Button
v-bind="forwardedProps"
:trailing="props.trailing ?? sortIcon"
:class="cn(
'table-sort-button',
props.una?.tableSortButton,
props.class,
)"
:una="{
...props.una,
btnTrailing: cn(
'table-sort-icon-base',
props.una?.tableSortIconBase,
props.una?.btnTrailing,
),
}"
@click="props.column.getToggleSortingHandler()?.($event)"
>
<slot />
</Button>
</template>
<script setup lang="ts">
import type { NTableColumnFilterProps } from '../../../types'
import { reactiveOmit } from '@vueuse/core'
import { useForwardProps } from 'reka-ui'
import { computed } from 'vue'
import { cn } from '../../../utils'
import Input from '../../forms/Input.vue'
const props = defineProps<NTableColumnFilterProps>()
const delegatedProps = reactiveOmit(props, ['class', 'column', 'una'])
// forward only what was actually passed: props typed as booleans are cast
// to `false` when absent, and spreading those would override the wrapped
// component's own defaults
const forwardedProps = useForwardProps(delegatedProps)
// `columnDef.header` may be a render function, which is not a usable placeholder
const placeholder = computed(() => {
const header = props.column.columnDef.header
return typeof header === 'string' ? header : undefined
})
</script>
<template>
<Input
:placeholder
v-bind="forwardedProps"
:model-value="props.column.getFilterValue() as string"
:class="cn(
'table-column-filter',
props.una?.tableColumnFilter,
props.class,
)"
:una="props.una"
@update:model-value="props.column.setFilterValue($event)"
/>
</template>
<script setup lang="ts">
import type { NTableSelectionHeaderProps } from '../../../types'
import { reactiveOmit } from '@vueuse/core'
import { useForwardProps } from 'reka-ui'
import { computed } from 'vue'
import { cn } from '../../../utils'
import Checkbox from '../../forms/Checkbox.vue'
const props = defineProps<NTableSelectionHeaderProps>()
const emit = defineEmits<{
change: [rows: any[]]
}>()
const delegatedProps = reactiveOmit(props, ['class', 'table', 'una'])
// forward only what was actually passed: props typed as booleans are cast
// to `false` when absent, and spreading those would override the wrapped
// component's own defaults
const forwardedProps = useForwardProps(delegatedProps)
const modelValue = computed(() =>
props.table.getIsAllPageRowsSelected()
|| (props.table.getIsSomePageRowsSelected() && 'indeterminate'),
)
function onUpdate(value: boolean | 'indeterminate' | null) {
props.table.toggleAllPageRowsSelected(!!value)
emit('change', props.table.getRowModel().rows.map(row => row.original))
}
</script>
<template>
<Checkbox
aria-label="Select all rows"
v-bind="forwardedProps"
:model-value="modelValue"
:class="cn(
'table-selection',
props.una?.tableSelection,
'table-selection-header',
props.una?.tableSelectionHeader,
props.class,
)"
:una="props.una"
@update:model-value="onUpdate"
@click.stop
/>
</template>
<script setup lang="ts">
import type { NTableSelectionCellProps } from '../../../types'
import { reactiveOmit } from '@vueuse/core'
import { useForwardProps } from 'reka-ui'
import { cn } from '../../../utils'
import Checkbox from '../../forms/Checkbox.vue'
const props = defineProps<NTableSelectionCellProps>()
const emit = defineEmits<{
change: [row: any]
}>()
const delegatedProps = reactiveOmit(props, ['class', 'row', 'una'])
// forward only what was actually passed: props typed as booleans are cast
// to `false` when absent, and spreading those would override the wrapped
// component's own defaults
const forwardedProps = useForwardProps(delegatedProps)
function onUpdate(value: boolean | 'indeterminate' | null) {
props.row.toggleSelected(!!value)
emit('change', props.row.original)
}
</script>
<template>
<Checkbox
aria-label="Select row"
v-bind="forwardedProps"
:model-value="props.row.getIsSelected() ?? false"
:class="cn(
'table-selection',
props.una?.tableSelection,
'table-selection-cell',
props.una?.tableSelectionCell,
props.class,
)"
:una="props.una"
@update:model-value="onUpdate"
@click.stop
/>
</template>
<script setup lang="ts">
import type { NTableExpandButtonProps } from '../../../types'
import { reactiveOmit } from '@vueuse/core'
import { useForwardProps } from 'reka-ui'
import { computed } from 'vue'
import { cn } from '../../../utils'
import Button from '../../elements/Button.vue'
const props = withDefaults(defineProps<NTableExpandButtonProps>(), {
btn: 'ghost-gray',
size: 'xs',
icon: true,
square: true,
})
const emit = defineEmits<{
change: [row: any]
}>()
const delegatedProps = reactiveOmit(props, ['class', 'row', 'una', 'label', 'ariaLabel'])
// forward only what was actually passed: props typed as booleans are cast
// to `false` when absent, and spreading those would override the wrapped
// component's own defaults
const forwardedProps = useForwardProps(delegatedProps)
const isExpanded = computed(() => props.row.getIsExpanded())
function onClick() {
props.row.toggleExpanded()
emit('change', props.row.original)
}
</script>
<template>
<Button
v-bind="forwardedProps"
:aria-label="props.ariaLabel ?? (isExpanded ? 'Collapse row' : 'Expand row')"
:label="props.label ?? props.una?.tableExpandIcon ?? 'table-expand-icon'"
:data-expanded="isExpanded"
:class="cn(
'table-expand-button',
props.una?.tableExpandButton,
props.class,
)"
:una="{
...props.una,
btnIconLabel: cn(
'table-expand-icon-base',
isExpanded ? '-rotate-180' : 'rotate-0',
props.una?.tableExpandIconBase,
props.una?.btnIconLabel,
),
}"
@click="onClick"
/>
</template>
<script setup lang="ts">
import type { NTablePaginationProps } from '../../../types'
import { reactiveOmit } from '@vueuse/core'
import { useForwardProps } from 'reka-ui'
import { computed } from 'vue'
import { cn } from '../../../utils'
import Pagination from '../../elements/pagination/Pagination.vue'
import PaginationInfo from '../../elements/pagination/PaginationInfo.vue'
import PaginationRowsPerPage from '../../elements/pagination/PaginationRowsPerPage.vue'
import { useTable } from './useTable'
// shadcn's DataTablePagination, part for part: the selection count on the
// left, then rows per page, "Page X of Y" and first/prev/next/last on the
// right — and no page numbers, which is what keeps it on one row
const props = withDefaults(defineProps<NTablePaginationProps>(), {
showInfo: true,
showRowsPerPage: true,
showListItem: false,
// shadcn's `size-8` navigation buttons
square: '8',
})
const context = useTable(null)
// context when rendered by `NTable`, the `table` prop when composed on its own —
// the same pattern as `NPaginationInfo`, and shadcn's `<DataTablePagination :table />`
const table = computed(() => props.table ?? context?.table)
// `showInfo`, `showRowsPerPage` and their prop bags are consumed here: the
// inner `NPagination` renders the navigation only, never its own regions
const delegatedProps = reactiveOmit(props, ['class', 'una', 'table', 'showInfo', 'showRowsPerPage', '_paginationInfo', '_paginationRowsPerPage'])
// forward only what was actually passed: props typed as booleans are cast to
// `false` when absent, and spreading those would override the wrapped
// component's own defaults
const forwardedProps = useForwardProps(delegatedProps)
const pagination = computed(() => context?.pagination.value ?? table.value?.getState().pagination)
const page = computed(() => (pagination.value?.pageIndex ?? 0) + 1)
const itemsPerPage = computed(() => pagination.value?.pageSize ?? 10)
// a server-side table reports its size through `rowCount`; one that only gives
// `pageCount` can still get the page count right, at the cost of "of N"
// rounding up to a multiple of the page size
const total = computed(() => {
const t = table.value
if (!t)
return 0
const { rowCount, pageCount: count } = t.options
if (rowCount != null)
return rowCount
if (count != null && count >= 0)
return count * itemsPerPage.value
return t.getFilteredRowModel().rows.length
})
const pageCount = computed(() => {
const n = table.value?.getPageCount() ?? 0
return n > 0 ? n : Math.max(1, Math.ceil(total.value / itemsPerPage.value))
})
const selectable = computed(() => Boolean(table.value?.options.enableRowSelection))
const selectedCount = computed(() => table.value?.getFilteredSelectedRowModel().rows.length ?? 0)
const filteredCount = computed(() => table.value?.getFilteredRowModel().rows.length ?? 0)
const first = computed(() => total.value ? (page.value - 1) * itemsPerPage.value + 1 : 0)
const last = computed(() => Math.min(page.value * itemsPerPage.value, total.value))
// shadcn's left-hand text; the row range when there is nothing to select
const status = computed(() => selectable.value
? `${selectedCount.value} of ${filteredCount.value} row(s) selected.`
: `Showing ${first.value}–${last.value} of ${total.value}`)
function onPage(n: number) {
if (context)
context.setPageIndex(n - 1)
else
table.value?.setPageIndex(n - 1)
}
function onItemsPerPage(n: number) {
if (context)
context.setPageSize(n)
else
table.value?.setPageSize(n)
}
</script>
<template>
<div
:class="cn(
'table-pagination',
props.una?.tablePagination,
props.class,
)"
>
<div
v-if="showInfo"
:class="cn('table-pagination-status', props.una?.tablePaginationStatus)"
>
<slot
name="status"
:selected="selectedCount"
:filtered="filteredCount"
:total
:first
:last
>
{{ status }}
</slot>
</div>
<div :class="cn('table-pagination-controls', props.una?.tablePaginationControls)">
<div
v-if="showRowsPerPage"
:class="cn('table-pagination-page-size', props.una?.tablePaginationPageSize)"
>
<PaginationRowsPerPage
label="Rows per page"
:disabled="props.disabled"
:items-per-page="itemsPerPage"
:una
v-bind="{ _selectTrigger: { class: 'h-8' }, ...props._paginationRowsPerPage }"
@update:items-per-page="onItemsPerPage"
/>
</div>
<!-- literal weight/colour rather than in the shortcut: `pagination-info`
sets a muted colour in the same layer, and literals win regardless -->
<PaginationInfo
:page
:page-count="pageCount"
:una
:class="cn('table-pagination-page font-medium text-foreground', props.una?.tablePaginationPage)"
v-bind="props._paginationInfo"
/>
<!-- `hidden lg:inline-flex` stays literal for the same reason: it has to
beat `.btn`'s own display, which lives in the shortcuts layer -->
<Pagination
v-bind="forwardedProps"
:page
:items-per-page="itemsPerPage"
:total
:una
:class="cn('table-pagination-nav', props.una?.tablePaginationNav)"
:_pagination-list="{ class: 'gap-2', ...props._paginationList }"
:_pagination-first="{ class: 'hidden lg:inline-flex', ...props._paginationFirst }"
:_pagination-last="{ class: 'hidden lg:inline-flex', ...props._paginationLast }"
@update:page="onPage"
>
<template v-for="(_, name) in $slots" #[name]="slotData">
<slot :name="name" v-bind="slotData" />
</template>
</Pagination>
</div>
</div>
</template>
On This Page