Table
Usage
The Table component is built on top of TanStack Table v8 and is powered by the useVueTable composable to provide a flexible and fully type-safe API.
It renders your data as rows and columns and supports sorting, filtering, pagination, row selection, expansion, grouping, pinning and virtualization, so you can build everything from a simple data table to a fully featured data grid.
| # | Date | Status | Amount | |||
|---|---|---|---|---|---|---|
| #4600 | Mar 11, 15:30 | paid | james.anderson@example.com | €594.00 | ||
| #4599 | Mar 11, 10:10 | failed | mia.white@example.com | €276.00 | ||
| #4598 | Mar 11, 08:50 | refunded | william.brown@example.com | €315.00 | ||
| #4597 | Mar 10, 19:45 | paid | emma.davis@example.com | €529.00 | ||
| #4596 | Mar 10, 15:55 | paid | ethan.harris@example.com | €639.00 | ||
| #4595 | Mar 10, 13:40 | refunded | ava.thomas@example.com | €428.00 | ||
| #4594 | Mar 10, 09:15 | paid | michael.wilson@example.com | €683.00 | ||
| #4593 | Mar 9, 20:25 | failed | olivia.taylor@example.com | €947.00 | ||
| #4592 | Mar 9, 18:45 | paid | benjamin.jackson@example.com | €851.00 | ||
| #4591 | Mar 9, 16:05 | paid | sophia.miller@example.com | €762.00 | ||
| #4590 | Mar 9, 14:20 | paid | noah.clark@example.com | €573.00 | ||
| #4589 | Mar 9, 11:35 | failed | isabella.lee@example.com | €389.00 | ||
| #4588 | Mar 8, 22:50 | refunded | liam.walker@example.com | €701.00 | ||
| #4587 | Mar 8, 20:15 | paid | charlotte.hall@example.com | €856.00 | ||
| #4586 | Mar 8, 17:40 | paid | mason.young@example.com | €492.00 | ||
| #4585 | Mar 8, 14:55 | failed | amelia.king@example.com | €637.00 | ||
| #4584 | Mar 8, 12:30 | paid | elijah.wright@example.com | €784.00 | ||
| #4583 | Mar 8, 09:45 | refunded | harper.scott@example.com | €345.00 | ||
| #4582 | Mar 7, 23:10 | paid | evelyn.green@example.com | €918.00 | ||
| #4581 | Mar 7, 20:25 | paid | logan.baker@example.com | €567.00 |
Table component. Check out the source code on GitHub.Data
Use the data prop as an array of objects, the columns will be generated based on the keys of the objects.
| Id | Date | Status | Amount | |
|---|---|---|---|---|
| 4600 | 2024-03-11T15:30:00 | paid | james.anderson@example.com | 594 |
| 4599 | 2024-03-11T10:10:00 | failed | mia.white@example.com | 276 |
| 4598 | 2024-03-11T08:50:00 | refunded | william.brown@example.com | 315 |
| 4597 | 2024-03-10T19:45:00 | paid | emma.davis@example.com | 529 |
| 4596 | 2024-03-10T15:55:00 | paid | ethan.harris@example.com | 639 |
<script setup lang="ts">
const data = ref([
{
id: '4600',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 529
},
{
id: '4596',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
}
])
</script>
<template>
<PTable :data="data" class="flex-1" />
</template><script setup lang="ts">
import { ref } from 'vue'
const data = ref([
{
id: '4600',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 529
},
{
id: '4596',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
}
])
</script>
<template>
<PTable :data="data" class="flex-1" />
</template>Columns
Use the columns prop as an array of ColumnDef objects with properties like:
accessorKey: The key of the row object to use when extracting the value for the column.header: The header to display for the column. If a string is passed, it can be used as a default for the column ID. If a function is passed, it will be passed a props object for the header and should return the rendered header value (the exact type depends on the adapter being used).footer: The footer to display for the column. Works exactly like header, but is displayed under the table.cell: The cell to display each row for the column. If a function is passed, it will be passed a props object for the cell and should return the rendered cell value (the exact type depends on the adapter being used).meta: Extra properties for the column.class:td: The classes to apply to thetdelement.th: The classes to apply to thethelement.
style:td: The style to apply to thetdelement.th: The style to apply to thethelement.
colspan:td: The colspan attribute to apply to thetdelement.
rowspan:td: The rowspan attribute to apply to thetdelement.
To render components or other HTML elements, you need to use the Vue h function inside the header and cell props. This is different from other components that use slots but allows for more flexibility.
| # | Date | Status | Amount | |
|---|---|---|---|---|
| #4600 | Mar 11, 15:30 | paid | james.anderson@example.com | €594.00 |
| #4599 | Mar 11, 10:10 | failed | mia.white@example.com | €276.00 |
| #4598 | Mar 11, 08:50 | refunded | william.brown@example.com | €315.00 |
| #4597 | Mar 10, 19:45 | paid | emma.davis@example.com | €529.00 |
| #4596 | Mar 10, 15:55 | paid | ethan.harris@example.com | €639.00 |
<script setup lang="ts">
import type { TableColumn } from 'pohon-ui'
import { h, resolveComponent } from 'vue'
const PBadge = resolveComponent('PBadge')
interface Payment {
id: string
date: string
status: 'paid' | 'failed' | 'refunded'
email: string
amount: number
}
const data = ref<Array<Payment>>([
{
id: '4600',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 529
},
{
id: '4596',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
}
])
const columns: Array<TableColumn<Payment>> = [
{
accessorKey: 'id',
header: '#',
cell: ({ row }) => `#${row.getValue('id')}`
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const color = {
paid: 'success' as const,
failed: 'error' as const,
refunded: 'neutral' as const
}[row.getValue('status') as string]
return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
row.getValue('status')
)
}
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
}
}
]
</script>
<template>
<PTable :data="data" :columns="columns" class="flex-1" />
</template><script setup lang="ts">
import type { TableColumn } from 'pohon-ui'
import { h, resolveComponent, ref } from 'vue'
const PBadge = resolveComponent('PBadge')
interface Payment {
id: string
date: string
status: 'paid' | 'failed' | 'refunded'
email: string
amount: number
}
const data = ref<Array<Payment>>([
{
id: '4600',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 529
},
{
id: '4596',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
}
])
const columns: Array<TableColumn<Payment>> = [
{
accessorKey: 'id',
header: '#',
cell: ({ row }) => `#${row.getValue('id')}`
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const color = {
paid: 'success' as const,
failed: 'error' as const,
refunded: 'neutral' as const
}[row.getValue('status') as string]
return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
row.getValue('status')
)
}
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
}
}
]
</script>
<template>
<PTable :data="data" :columns="columns" class="flex-1" />
</template>h, you can either use the resolveComponent function or import from #components.Meta
Use the meta prop as an object (TableMeta) to pass properties like:
class:tr: The classes to apply to thetrelement.
style:tr: The style to apply to thetrelement.
| ID | Date | Status | Amount | |
|---|---|---|---|---|
| 4600 | Mar 11, 15:30 | paid | james.anderson@example.com | $594.00 |
| 4599 | Mar 11, 10:10 | failed | mia.white@example.com | $276.00 |
| 4598 | Mar 11, 08:50 | refunded | william.brown@example.com | $315.00 |
| 4597 | Mar 10, 19:45 | paid | emma.davis@example.com | $529.00 |
| 4596 | Mar 10, 15:55 | paid | ethan.harris@example.com | $639.00 |
<script setup lang="ts">
import type { Row, TableMeta } from '@tanstack/vue-table'
import type { TableColumn } from 'pohon-ui'
interface Payment {
id: string
date: string
status: 'paid' | 'failed' | 'refunded'
email: string
amount: number
}
const data = ref<Array<Payment>>([
{
id: '4600',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 529
},
{
id: '4596',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
}
])
const columns: Array<TableColumn<Payment>> = [
{
accessorKey: 'id',
header: 'ID',
meta: {
class: {
th: 'text-center font-semibold',
td: 'text-center font-mono'
}
}
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
},
{
accessorKey: 'status',
header: 'Status',
meta: {
class: {
th: 'text-center',
td: 'text-center'
}
},
cell: ({ row }) => {
const status = row.getValue('status') as string
const colorMap = {
paid: 'color-success',
failed: 'color-error',
refunded: 'color-warning'
}
return h(
'span',
{ class: `font-semibold capitalize ${colorMap[status as keyof typeof colorMap]}` },
status
)
}
},
{
accessorKey: 'email',
header: 'Email',
meta: {
class: {
th: 'text-left',
td: 'text-left'
}
}
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right font-bold color-primary',
td: 'text-right font-mono'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
const formatted = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(amount)
return h('span', { class: 'font-semibold color-success' }, formatted)
}
}
]
const meta: TableMeta<Payment> = {
class: {
tr: (row: Row<Payment>) => {
if (row.original.status === 'failed') {
return 'bg-error/10'
}
if (row.original.status === 'refunded') {
return 'bg-warning/10'
}
return ''
}
}
}
</script>
<template>
<PTable :data="data" :columns="columns" :meta="meta" class="flex-1" />
</template><script setup lang="ts">
import { ref, h } from 'vue'
import type { Row, TableMeta } from '@tanstack/vue-table'
import type { TableColumn } from 'pohon-ui'
interface Payment {
id: string
date: string
status: 'paid' | 'failed' | 'refunded'
email: string
amount: number
}
const data = ref<Array<Payment>>([
{
id: '4600',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 529
},
{
id: '4596',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
}
])
const columns: Array<TableColumn<Payment>> = [
{
accessorKey: 'id',
header: 'ID',
meta: {
class: {
th: 'text-center font-semibold',
td: 'text-center font-mono'
}
}
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
},
{
accessorKey: 'status',
header: 'Status',
meta: {
class: {
th: 'text-center',
td: 'text-center'
}
},
cell: ({ row }) => {
const status = row.getValue('status') as string
const colorMap = {
paid: 'color-success',
failed: 'color-error',
refunded: 'color-warning'
}
return h(
'span',
{ class: `font-semibold capitalize ${colorMap[status as keyof typeof colorMap]}` },
status
)
}
},
{
accessorKey: 'email',
header: 'Email',
meta: {
class: {
th: 'text-left',
td: 'text-left'
}
}
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right font-bold color-primary',
td: 'text-right font-mono'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
const formatted = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(amount)
return h('span', { class: 'font-semibold color-success' }, formatted)
}
}
]
const meta: TableMeta<Payment> = {
class: {
tr: (row: Row<Payment>) => {
if (row.original.status === 'failed') {
return 'bg-error/10'
}
if (row.original.status === 'refunded') {
return 'bg-warning/10'
}
return ''
}
}
}
</script>
<template>
<PTable :data="data" :columns="columns" :meta="meta" class="flex-1" />
</template>Loading
Use the loading prop to display a loading state, the loading-color prop to change its color and the loading-animation prop to change its animation.
| Id | Date | Status | Amount | |
|---|---|---|---|---|
| 4600 | 2024-03-11T15:30:00 | paid | james.anderson@example.com | 594 |
| 4599 | 2024-03-11T10:10:00 | failed | mia.white@example.com | 276 |
| 4598 | 2024-03-11T08:50:00 | refunded | william.brown@example.com | 315 |
| 4597 | 2024-03-10T19:45:00 | paid | emma.davis@example.com | 529 |
| 4596 | 2024-03-10T15:55:00 | paid | ethan.harris@example.com | 639 |
<script setup lang="ts">
const data = ref([
{
id: '4600',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 529
},
{
id: '4596',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
}
])
</script>
<template>
<PTable
loading
loading-color="primary"
loading-animation="carousel"
:data="data"
class="flex-1"
/>
</template><script setup lang="ts">
import { ref } from 'vue'
const data = ref([
{
id: '4600',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 529
},
{
id: '4596',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
}
])
</script>
<template>
<PTable
loading
loading-color="primary"
loading-animation="carousel"
:data="data"
class="flex-1"
/>
</template>Sticky
Use the sticky prop to make the header or footer sticky.
| Id | Date | Status | Amount | |
|---|---|---|---|---|
| 4600 | 2024-03-11T15:30:00 | paid | james.anderson@example.com | 594 |
| 4599 | 2024-03-11T10:10:00 | failed | mia.white@example.com | 276 |
| 4598 | 2024-03-11T08:50:00 | refunded | william.brown@example.com | 315 |
| 4597 | 2024-03-10T19:45:00 | paid | emma.davis@example.com | 529 |
| 4596 | 2024-03-10T15:55:00 | paid | ethan.harris@example.com | 639 |
| 4595 | 2024-03-10T15:55:00 | paid | ethan.harris@example.com | 639 |
| 4594 | 2024-03-10T15:55:00 | paid | ethan.harris@example.com | 639 |
<script setup lang="ts">
const data = ref([
{
id: '4600',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 529
},
{
id: '4596',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
},
{
id: '4595',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
},
{
id: '4594',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
}
])
</script>
<template>
<PTable sticky :data="data" class="flex-1 max-h-[312px]" />
</template><script setup lang="ts">
import { ref } from 'vue'
const data = ref([
{
id: '4600',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 529
},
{
id: '4596',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
},
{
id: '4595',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
},
{
id: '4594',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
}
])
</script>
<template>
<PTable sticky :data="data" class="flex-1 max-h-[312px]" />
</template>Examples
With row actions
You can add a new column that renders a DropdownMenu component inside the cell to render row actions.
| # | Date | Status | Amount | ||
|---|---|---|---|---|---|
| #4600 | Mar 11, 15:30 | paid | james.anderson@example.com | €594.00 | |
| #4599 | Mar 11, 10:10 | failed | mia.white@example.com | €276.00 | |
| #4598 | Mar 11, 08:50 | refunded | william.brown@example.com | €315.00 | |
| #4597 | Mar 10, 19:45 | paid | emma.davis@example.com | €529.00 | |
| #4596 | Mar 10, 15:55 | paid | ethan.harris@example.com | €639.00 |
<script setup lang="ts">
import type { Row } from '@tanstack/vue-table'
import type { TableColumn } from 'pohon-ui'
import { useClipboard } from '@vueuse/core'
import { h, resolveComponent } from 'vue'
const PButton = resolveComponent('PButton')
const PBadge = resolveComponent('PBadge')
const PDropdownMenu = resolveComponent('PDropdownMenu')
const toast = useToast()
const { copy } = useClipboard()
interface Payment {
id: string
date: string
status: 'paid' | 'failed' | 'refunded'
email: string
amount: number
}
const data = ref<Array<Payment>>([
{
id: '4600',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 529
},
{
id: '4596',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
}
])
const columns: Array<TableColumn<Payment>> = [
{
accessorKey: 'id',
header: '#',
cell: ({ row }) => `#${row.getValue('id')}`
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const color = {
paid: 'success' as const,
failed: 'error' as const,
refunded: 'neutral' as const
}[row.getValue('status') as string]
return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
row.getValue('status')
)
}
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
}
},
{
id: 'actions',
meta: {
class: {
td: 'text-right'
}
},
cell: ({ row }) => {
return h(
PDropdownMenu,
{
content: {
align: 'end'
},
items: getRowItems(row),
'aria-label': 'Actions dropdown'
},
() =>
h(PButton, {
icon: 'i-lucide-ellipsis-vertical',
color: 'neutral',
variant: 'ghost',
'aria-label': 'Actions dropdown'
})
)
}
}
]
function getRowItems(row: Row<Payment>) {
return [
{
type: 'label',
label: 'Actions'
},
{
label: 'Copy payment ID',
onSelect() {
copy(row.original.id)
toast.add({
title: 'Payment ID copied to clipboard!',
color: 'success',
icon: 'i-lucide-circle-check'
})
}
},
{
type: 'separator'
},
{
label: 'View customer'
},
{
label: 'View payment details'
}
]
}
</script>
<template>
<PTable :data="data" :columns="columns" class="flex-1" />
</template><script setup lang="ts">
import type { Row } from '@tanstack/vue-table'
import type { TableColumn } from 'pohon-ui'
import { useClipboard } from '@vueuse/core'
import { h, resolveComponent, ref } from 'vue'
const PButton = resolveComponent('PButton')
const PBadge = resolveComponent('PBadge')
const PDropdownMenu = resolveComponent('PDropdownMenu')
const toast = useToast()
const { copy } = useClipboard()
interface Payment {
id: string
date: string
status: 'paid' | 'failed' | 'refunded'
email: string
amount: number
}
const data = ref<Array<Payment>>([
{
id: '4600',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 529
},
{
id: '4596',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
}
])
const columns: Array<TableColumn<Payment>> = [
{
accessorKey: 'id',
header: '#',
cell: ({ row }) => `#${row.getValue('id')}`
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const color = {
paid: 'success' as const,
failed: 'error' as const,
refunded: 'neutral' as const
}[row.getValue('status') as string]
return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
row.getValue('status')
)
}
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
}
},
{
id: 'actions',
meta: {
class: {
td: 'text-right'
}
},
cell: ({ row }) => {
return h(
PDropdownMenu,
{
content: {
align: 'end'
},
items: getRowItems(row),
'aria-label': 'Actions dropdown'
},
() =>
h(PButton, {
icon: 'i-lucide-ellipsis-vertical',
color: 'neutral',
variant: 'ghost',
'aria-label': 'Actions dropdown'
})
)
}
}
]
function getRowItems(row: Row<Payment>) {
return [
{
type: 'label',
label: 'Actions'
},
{
label: 'Copy payment ID',
onSelect() {
copy(row.original.id)
toast.add({
title: 'Payment ID copied to clipboard!',
color: 'success',
icon: 'i-lucide-circle-check'
})
}
},
{
type: 'separator'
},
{
label: 'View customer'
},
{
label: 'View payment details'
}
]
}
</script>
<template>
<PTable :data="data" :columns="columns" class="flex-1" />
</template>With expandable rows
You can add a new column that renders a Button component inside the cell to toggle the expandable state of a row using the TanStack Table Expanding APIs.
#expanded slot to render the expanded content which will receive the row as a parameter.| # | Date | Status | Amount | ||
|---|---|---|---|---|---|
| #4600 | Mar 11, 15:30 | paid | james.anderson@example.com | €594.00 | |
| #4599 | Mar 11, 10:10 | failed | mia.white@example.com | €276.00 | |
{
"id": "4599",
"date": "2024-03-11T10:10:00",
"status": "failed",
"email": "mia.white@example.com",
"amount": 276
} | |||||
| #4598 | Mar 11, 08:50 | refunded | william.brown@example.com | €315.00 | |
| #4597 | Mar 10, 19:45 | paid | emma.davis@example.com | €529.00 | |
| #4596 | Mar 10, 15:55 | paid | ethan.harris@example.com | €639.00 | |
<script setup lang="ts">
import type { TableColumn } from 'pohon-ui'
import { h, resolveComponent } from 'vue'
const PButton = resolveComponent('PButton')
const PBadge = resolveComponent('PBadge')
interface Payment {
id: string
date: string
status: 'paid' | 'failed' | 'refunded'
email: string
amount: number
}
const data = ref<Array<Payment>>([
{
id: '4600',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 529
},
{
id: '4596',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
}
])
const columns: Array<TableColumn<Payment>> = [
{
id: 'expand',
cell: ({ row }) =>
h(PButton, {
color: 'neutral',
variant: 'ghost',
icon: 'i-lucide-chevron-down',
square: true,
'aria-label': 'Expand',
ui: {
leadingIcon: [
'transition-transform',
row.getIsExpanded() ? 'duration-200 rotate-180' : ''
]
},
onClick: () => row.toggleExpanded()
})
},
{
accessorKey: 'id',
header: '#',
cell: ({ row }) => `#${row.getValue('id')}`
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const color = {
paid: 'success' as const,
failed: 'error' as const,
refunded: 'neutral' as const
}[row.getValue('status') as string]
return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
row.getValue('status')
)
}
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
}
}
]
const expanded = ref({ 1: true })
</script>
<template>
<PTable
v-model:expanded="expanded"
:data="data"
:columns="columns"
:ui="{ tr: 'data-[expanded=true]:bg-background-elevated/50' }"
class="flex-1"
>
<template #expanded="{ row }">
<pre>{{ row.original }}</pre>
</template>
</PTable>
</template><script setup lang="ts">
import type { TableColumn } from 'pohon-ui'
import { h, resolveComponent, ref } from 'vue'
const PButton = resolveComponent('PButton')
const PBadge = resolveComponent('PBadge')
interface Payment {
id: string
date: string
status: 'paid' | 'failed' | 'refunded'
email: string
amount: number
}
const data = ref<Array<Payment>>([
{
id: '4600',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 529
},
{
id: '4596',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
}
])
const columns: Array<TableColumn<Payment>> = [
{
id: 'expand',
cell: ({ row }) =>
h(PButton, {
color: 'neutral',
variant: 'ghost',
icon: 'i-lucide-chevron-down',
square: true,
'aria-label': 'Expand',
ui: {
leadingIcon: [
'transition-transform',
row.getIsExpanded() ? 'duration-200 rotate-180' : ''
]
},
onClick: () => row.toggleExpanded()
})
},
{
accessorKey: 'id',
header: '#',
cell: ({ row }) => `#${row.getValue('id')}`
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const color = {
paid: 'success' as const,
failed: 'error' as const,
refunded: 'neutral' as const
}[row.getValue('status') as string]
return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
row.getValue('status')
)
}
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
}
}
]
const expanded = ref({ 1: true })
</script>
<template>
<PTable
v-model:expanded="expanded"
:data="data"
:columns="columns"
:ui="{ tr: 'data-[expanded=true]:bg-background-elevated/50' }"
class="flex-1"
>
<template #expanded="{ row }">
<pre>{{ row.original }}</pre>
</template>
</PTable>
</template>expanded prop to control the expandable state of the rows (can be bound with v-model).DropdownMenu component inside the actions column.With grouped rows
You can group rows based on a given column value and show/hide sub rows via some button added to the cell using the TanStack Table Grouping APIs.
Important parts
- Add
groupingprop with an array of column ids you want to group by. - Add
grouping-optionsprop. It must includegetGroupedRowModel, you can import it from@tanstack/vue-tableor implement your own. - Expand rows via
row.toggleExpanded()method on any cell of the row. Keep in mind, it also toggles#expandedslot. - Use
aggregateFnon column definition to define how to aggregate the rows. agregatedCellrenderer on column definition only works if there is nocellrenderer.
| Item | # | Date | Amount | |
|---|---|---|---|---|
Account 1 | 3 records | Mar 11, 15:30 | 3 customers | €1,548.00 |
Account 2 | 2 records | Mar 11, 10:10 | 2 customers | €805.00 |
<script setup lang="ts">
import type { GroupingOptions } from '@tanstack/vue-table'
import type { TableColumn } from 'pohon-ui'
import { getGroupedRowModel } from '@tanstack/vue-table'
import { resolveComponent } from 'vue'
const PBadge = resolveComponent('PBadge')
interface Account {
id: string
name: string
}
type PaymentStatus = 'paid' | 'failed' | 'refunded'
interface Payment {
id: string
date: string
status: PaymentStatus
email: string
amount: number
account: Account
}
function getColorByStatus(status: PaymentStatus) {
return {
paid: 'success',
failed: 'error',
refunded: 'neutral'
}[status]
}
const data = ref<Array<Payment>>([
{
id: '4600',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594,
account: {
id: '1',
name: 'Account 1'
}
},
{
id: '4599',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276,
account: {
id: '2',
name: 'Account 2'
}
},
{
id: '4598',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315,
account: {
id: '1',
name: 'Account 1'
}
},
{
id: '4597',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 529,
account: {
id: '2',
name: 'Account 2'
}
},
{
id: '4596',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639,
account: {
id: '1',
name: 'Account 1'
}
}
])
const columns: Array<TableColumn<Payment>> = [
{
id: 'title',
header: 'Item'
},
{
id: 'account_id',
accessorKey: 'account.id'
},
{
accessorKey: 'id',
header: '#',
cell: ({ row }) =>
row.getIsGrouped() ? `${row.getValue('id')} records` : `#${row.getValue('id')}`,
aggregationFn: 'count'
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
},
aggregationFn: 'max'
},
{
accessorKey: 'status',
header: 'Status'
},
{
accessorKey: 'email',
header: 'Email',
meta: {
class: {
td: 'w-full'
}
},
cell: ({ row }) =>
row.getIsGrouped() ? `${row.getValue('email')} customers` : row.getValue('email'),
aggregationFn: 'uniqueCount'
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
},
aggregationFn: 'sum'
}
]
const grouping_options = ref<GroupingOptions>({
groupedColumnMode: 'remove',
getGroupedRowModel: getGroupedRowModel()
})
</script>
<template>
<PTable
:data="data"
:columns="columns"
:grouping="['account_id', 'status']"
:grouping-options="grouping_options"
:ui="{
root: 'min-w-full',
td: 'empty:p-0' // helps with the colspaned row added for expand slot
}"
>
<template #title-cell="{ row }">
<div v-if="row.getIsGrouped()" class="flex items-center">
<span class="inline-block" :style="{ width: `calc(${row.depth} * 1rem)` }" />
<PButton
variant="outline"
color="neutral"
class="mr-2"
size="xs"
:icon="row.getIsExpanded() ? 'i-lucide-minus' : 'i-lucide-plus'"
@click="row.toggleExpanded()"
/>
<strong v-if="row.groupingColumnId === 'account_id'">{{
row.original.account.name
}}</strong>
<PBadge
v-else-if="row.groupingColumnId === 'status'"
:color="getColorByStatus(row.original.status)"
class="capitalize"
variant="subtle"
>
{{ row.original.status }}
</PBadge>
</div>
</template>
</PTable>
</template><script setup lang="ts">
import type { GroupingOptions } from '@tanstack/vue-table'
import type { TableColumn } from 'pohon-ui'
import { getGroupedRowModel } from '@tanstack/vue-table'
import { resolveComponent, ref } from 'vue'
const PBadge = resolveComponent('PBadge')
interface Account {
id: string
name: string
}
type PaymentStatus = 'paid' | 'failed' | 'refunded'
interface Payment {
id: string
date: string
status: PaymentStatus
email: string
amount: number
account: Account
}
function getColorByStatus(status: PaymentStatus) {
return {
paid: 'success',
failed: 'error',
refunded: 'neutral'
}[status]
}
const data = ref<Array<Payment>>([
{
id: '4600',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594,
account: {
id: '1',
name: 'Account 1'
}
},
{
id: '4599',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276,
account: {
id: '2',
name: 'Account 2'
}
},
{
id: '4598',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315,
account: {
id: '1',
name: 'Account 1'
}
},
{
id: '4597',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 529,
account: {
id: '2',
name: 'Account 2'
}
},
{
id: '4596',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639,
account: {
id: '1',
name: 'Account 1'
}
}
])
const columns: Array<TableColumn<Payment>> = [
{
id: 'title',
header: 'Item'
},
{
id: 'account_id',
accessorKey: 'account.id'
},
{
accessorKey: 'id',
header: '#',
cell: ({ row }) =>
row.getIsGrouped() ? `${row.getValue('id')} records` : `#${row.getValue('id')}`,
aggregationFn: 'count'
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
},
aggregationFn: 'max'
},
{
accessorKey: 'status',
header: 'Status'
},
{
accessorKey: 'email',
header: 'Email',
meta: {
class: {
td: 'w-full'
}
},
cell: ({ row }) =>
row.getIsGrouped() ? `${row.getValue('email')} customers` : row.getValue('email'),
aggregationFn: 'uniqueCount'
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
},
aggregationFn: 'sum'
}
]
const grouping_options = ref<GroupingOptions>({
groupedColumnMode: 'remove',
getGroupedRowModel: getGroupedRowModel()
})
</script>
<template>
<PTable
:data="data"
:columns="columns"
:grouping="['account_id', 'status']"
:grouping-options="grouping_options"
:ui="{
root: 'min-w-full',
td: 'empty:p-0' // helps with the colspaned row added for expand slot
}"
>
<template #title-cell="{ row }">
<div v-if="row.getIsGrouped()" class="flex items-center">
<span class="inline-block" :style="{ width: `calc(${row.depth} * 1rem)` }" />
<PButton
variant="outline"
color="neutral"
class="mr-2"
size="xs"
:icon="row.getIsExpanded() ? 'i-lucide-minus' : 'i-lucide-plus'"
@click="row.toggleExpanded()"
/>
<strong v-if="row.groupingColumnId === 'account_id'">{{
row.original.account.name
}}</strong>
<PBadge
v-else-if="row.groupingColumnId === 'status'"
:color="getColorByStatus(row.original.status)"
class="capitalize"
variant="subtle"
>
{{ row.original.status }}
</PBadge>
</div>
</template>
</PTable>
</template>With row pinning
You can add a column that renders a Button component inside the cell to toggle the pinning state of a row using the TanStack Table Row Pinning APIs. Pinned rows will stay at the top or bottom of the table regardless of sorting or filtering.
| Date | Status | Amount | ||
|---|---|---|---|---|
| Mar 11, 02:10 | failed | mia.white@example.com | €276.00 | |
| Mar 10, 11:45 | paid | emma.davis@example.com | €529.00 | |
| Mar 11, 07:30 | paid | james.anderson@example.com | €594.00 | |
| Mar 11, 00:50 | refunded | william.brown@example.com | €315.00 | |
| Mar 10, 07:55 | paid | ethan.harris@example.com | €639.00 | |
| Mar 10, 05:40 | refunded | ava.thomas@example.com | €428.00 | |
| Mar 10, 01:15 | paid | michael.wilson@example.com | €683.00 | |
| Mar 9, 12:25 | failed | olivia.taylor@example.com | €947.00 | |
| Mar 9, 10:45 | paid | benjamin.jackson@example.com | €851.00 | |
| Mar 9, 08:05 | paid | sophia.miller@example.com | €762.00 |
<script setup lang="ts">
import type { RowPinningState } from '@tanstack/table-core'
import type { TableColumn } from 'pohon-ui'
import { h, resolveComponent } from 'vue'
const PButton = resolveComponent('PButton')
const PBadge = resolveComponent('PBadge')
interface Payment {
id: string
date: string
status: 'paid' | 'failed' | 'refunded'
email: string
amount: number
}
const data = ref<Array<Payment>>([
{
id: '4600',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 529
},
{
id: '4596',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
},
{
id: '4595',
date: '2024-03-10T13:40:00',
status: 'refunded',
email: 'ava.thomas@example.com',
amount: 428
},
{
id: '4594',
date: '2024-03-10T09:15:00',
status: 'paid',
email: 'michael.wilson@example.com',
amount: 683
},
{
id: '4593',
date: '2024-03-09T20:25:00',
status: 'failed',
email: 'olivia.taylor@example.com',
amount: 947
},
{
id: '4592',
date: '2024-03-09T18:45:00',
status: 'paid',
email: 'benjamin.jackson@example.com',
amount: 851
},
{
id: '4591',
date: '2024-03-09T16:05:00',
status: 'paid',
email: 'sophia.miller@example.com',
amount: 762
}
])
const columns: Array<TableColumn<Payment>> = [
{
id: 'pin',
cell: ({ row }) =>
h(PButton, {
icon: 'i-lucide-star',
color: row.getIsPinned() ? 'primary' : 'neutral',
variant: 'ghost',
'aria-label': row.getIsPinned() ? 'Unpin row' : 'Pin row to top',
onClick: () => {
if (row.getIsPinned()) {
row.pin(false)
} else {
row.pin('top')
}
}
})
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: 'UTC'
})
}
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const color = {
paid: 'success' as const,
failed: 'error' as const,
refunded: 'neutral' as const
}[row.getValue('status') as string]
return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
row.getValue('status')
)
}
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
}
}
]
const rowPinning = ref<RowPinningState>({ top: ['4599', '4597'], bottom: [] })
</script>
<template>
<PTable
v-model:row-pinning="rowPinning"
:data="data"
:columns="columns"
:get-row-id="(row: Payment) => row.id"
class="flex-1 h-96"
/>
</template><script setup lang="ts">
import type { RowPinningState } from '@tanstack/table-core'
import type { TableColumn } from 'pohon-ui'
import { h, resolveComponent, ref } from 'vue'
const PButton = resolveComponent('PButton')
const PBadge = resolveComponent('PBadge')
interface Payment {
id: string
date: string
status: 'paid' | 'failed' | 'refunded'
email: string
amount: number
}
const data = ref<Array<Payment>>([
{
id: '4600',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 529
},
{
id: '4596',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
},
{
id: '4595',
date: '2024-03-10T13:40:00',
status: 'refunded',
email: 'ava.thomas@example.com',
amount: 428
},
{
id: '4594',
date: '2024-03-10T09:15:00',
status: 'paid',
email: 'michael.wilson@example.com',
amount: 683
},
{
id: '4593',
date: '2024-03-09T20:25:00',
status: 'failed',
email: 'olivia.taylor@example.com',
amount: 947
},
{
id: '4592',
date: '2024-03-09T18:45:00',
status: 'paid',
email: 'benjamin.jackson@example.com',
amount: 851
},
{
id: '4591',
date: '2024-03-09T16:05:00',
status: 'paid',
email: 'sophia.miller@example.com',
amount: 762
}
])
const columns: Array<TableColumn<Payment>> = [
{
id: 'pin',
cell: ({ row }) =>
h(PButton, {
icon: 'i-lucide-star',
color: row.getIsPinned() ? 'primary' : 'neutral',
variant: 'ghost',
'aria-label': row.getIsPinned() ? 'Unpin row' : 'Pin row to top',
onClick: () => {
if (row.getIsPinned()) {
row.pin(false)
} else {
row.pin('top')
}
}
})
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: 'UTC'
})
}
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const color = {
paid: 'success' as const,
failed: 'error' as const,
refunded: 'neutral' as const
}[row.getValue('status') as string]
return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
row.getValue('status')
)
}
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
}
}
]
const rowPinning = ref<RowPinningState>({ top: ['4599', '4597'], bottom: [] })
</script>
<template>
<PTable
v-model:row-pinning="rowPinning"
:data="data"
:columns="columns"
:get-row-id="(row: Payment) => row.id"
class="flex-1 h-96"
/>
</template>row-pinning prop to control the pinning state of the rows (can be bound with v-model).With row selection
You can add a new column that renders a Checkbox component inside the header and cell to select rows using the TanStack Table Row Selection APIs.
| Date | Status | Amount | ||
|---|---|---|---|---|
| Mar 11, 15:30 | paid | james.anderson@example.com | €594.00 | |
| Mar 11, 10:10 | failed | mia.white@example.com | €276.00 | |
| Mar 11, 08:50 | refunded | william.brown@example.com | €315.00 | |
| Mar 10, 19:45 | paid | emma.davis@example.com | €529.00 | |
| Mar 10, 15:55 | paid | ethan.harris@example.com | €639.00 |
<script setup lang="ts">
import type { TableColumn } from 'pohon-ui'
import { h, resolveComponent } from 'vue'
const PCheckbox = resolveComponent('PCheckbox')
const PBadge = resolveComponent('PBadge')
interface Payment {
id: string
date: string
status: 'paid' | 'failed' | 'refunded'
email: string
amount: number
}
const data = ref<Array<Payment>>([
{
id: '4600',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 529
},
{
id: '4596',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
}
])
const columns: Array<TableColumn<Payment>> = [
{
id: 'select',
header: ({ table }) =>
h(PCheckbox, {
modelValue: table.getIsSomePageRowsSelected()
? 'indeterminate'
: table.getIsAllPageRowsSelected(),
'onUpdate:modelValue': (value: boolean | 'indeterminate') =>
table.toggleAllPageRowsSelected(!!value),
'aria-label': 'Select all'
}),
cell: ({ row }) =>
h(PCheckbox, {
modelValue: row.getIsSelected(),
'onUpdate:modelValue': (value: boolean | 'indeterminate') => row.toggleSelected(!!value),
'aria-label': 'Select row'
})
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const color = {
paid: 'success' as const,
failed: 'error' as const,
refunded: 'neutral' as const
}[row.getValue('status') as string]
return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
row.getValue('status')
)
}
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
}
}
]
const table = useTemplateRef('table')
const rowSelection = ref({ 1: true })
</script>
<template>
<div class="flex-1 w-full">
<PTable ref="table" v-model:row-selection="rowSelection" :data="data" :columns="columns" />
<div class="text-sm color-text-muted px-4 py-3.5 border-t border-border-accented">
{{ table?.tableApi?.getFilteredSelectedRowModel().rows.length || 0 }} of
{{ table?.tableApi?.getFilteredRowModel().rows.length || 0 }} row(s) selected.
</div>
</div>
</template><script setup lang="ts">
import type { TableColumn } from 'pohon-ui'
import { h, resolveComponent, ref, useTemplateRef } from 'vue'
const PCheckbox = resolveComponent('PCheckbox')
const PBadge = resolveComponent('PBadge')
interface Payment {
id: string
date: string
status: 'paid' | 'failed' | 'refunded'
email: string
amount: number
}
const data = ref<Array<Payment>>([
{
id: '4600',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 529
},
{
id: '4596',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
}
])
const columns: Array<TableColumn<Payment>> = [
{
id: 'select',
header: ({ table }) =>
h(PCheckbox, {
modelValue: table.getIsSomePageRowsSelected()
? 'indeterminate'
: table.getIsAllPageRowsSelected(),
'onUpdate:modelValue': (value: boolean | 'indeterminate') =>
table.toggleAllPageRowsSelected(!!value),
'aria-label': 'Select all'
}),
cell: ({ row }) =>
h(PCheckbox, {
modelValue: row.getIsSelected(),
'onUpdate:modelValue': (value: boolean | 'indeterminate') => row.toggleSelected(!!value),
'aria-label': 'Select row'
})
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const color = {
paid: 'success' as const,
failed: 'error' as const,
refunded: 'neutral' as const
}[row.getValue('status') as string]
return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
row.getValue('status')
)
}
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
}
}
]
const table = useTemplateRef('table')
const rowSelection = ref({ 1: true })
</script>
<template>
<div class="flex-1 w-full">
<PTable ref="table" v-model:row-selection="rowSelection" :data="data" :columns="columns" />
<div class="text-sm color-text-muted px-4 py-3.5 border-t border-border-accented">
{{ table?.tableApi?.getFilteredSelectedRowModel().rows.length || 0 }} of
{{ table?.tableApi?.getFilteredRowModel().rows.length || 0 }} row(s) selected.
</div>
</div>
</template>row-selection prop to control the selection state of the rows (can be bound with v-model).With row select event
You can add a @select listener to make rows clickable with or without a checkbox column.
Event and TableRow instance as the first and second arguments respectively.| Date | Status | Amount | ||
|---|---|---|---|---|
| Mar 11, 15:30 | paid | james.anderson@example.com | €594.00 | |
| Mar 11, 10:10 | failed | mia.white@example.com | €276.00 | |
| Mar 11, 08:50 | refunded | william.brown@example.com | €315.00 | |
| Mar 10, 19:45 | paid | emma.davis@example.com | €529.00 | |
| Mar 10, 15:55 | paid | ethan.harris@example.com | €639.00 |
<script setup lang="ts">
import type { TableColumn, TableRow } from 'pohon-ui'
import { h, resolveComponent } from 'vue'
const PBadge = resolveComponent('PBadge')
const PCheckbox = resolveComponent('PCheckbox')
interface Payment {
id: string
date: string
status: 'paid' | 'failed' | 'refunded'
email: string
amount: number
}
const data = ref<Array<Payment>>([
{
id: '4600',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 529
},
{
id: '4596',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
}
])
const columns: Array<TableColumn<Payment>> = [
{
id: 'select',
header: ({ table }) =>
h(PCheckbox, {
modelValue: table.getIsSomePageRowsSelected()
? 'indeterminate'
: table.getIsAllPageRowsSelected(),
'onUpdate:modelValue': (value: boolean | 'indeterminate') =>
table.toggleAllPageRowsSelected(!!value),
'aria-label': 'Select all'
}),
cell: ({ row }) =>
h(PCheckbox, {
modelValue: row.getIsSelected(),
'onUpdate:modelValue': (value: boolean | 'indeterminate') => row.toggleSelected(!!value),
'aria-label': 'Select row'
})
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const color = {
paid: 'success' as const,
failed: 'error' as const,
refunded: 'neutral' as const
}[row.getValue('status') as string]
return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
row.getValue('status')
)
}
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
}
}
]
const table = useTemplateRef('table')
const rowSelection = ref<Record<string, boolean>>({})
function onSelect(e: Event, row: TableRow<Payment>) {
/* If you decide to also select the column you can do this */
row.toggleSelected(!row.getIsSelected())
}
</script>
<template>
<div class="flex flex-1 gap-1 w-full">
<div class="flex-1">
<PTable
ref="table"
v-model:row-selection="rowSelection"
:data="data"
:columns="columns"
@select="onSelect"
/>
<div class="text-sm color-text-muted px-4 py-3.5 border-t border-border-accented">
{{ table?.tableApi?.getFilteredSelectedRowModel().rows.length || 0 }} of
{{ table?.tableApi?.getFilteredRowModel().rows.length || 0 }} row(s) selected.
</div>
</div>
</div>
</template><script setup lang="ts">
import type { TableColumn, TableRow } from 'pohon-ui'
import { h, resolveComponent, ref, useTemplateRef } from 'vue'
const PBadge = resolveComponent('PBadge')
const PCheckbox = resolveComponent('PCheckbox')
interface Payment {
id: string
date: string
status: 'paid' | 'failed' | 'refunded'
email: string
amount: number
}
const data = ref<Array<Payment>>([
{
id: '4600',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 529
},
{
id: '4596',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
}
])
const columns: Array<TableColumn<Payment>> = [
{
id: 'select',
header: ({ table }) =>
h(PCheckbox, {
modelValue: table.getIsSomePageRowsSelected()
? 'indeterminate'
: table.getIsAllPageRowsSelected(),
'onUpdate:modelValue': (value: boolean | 'indeterminate') =>
table.toggleAllPageRowsSelected(!!value),
'aria-label': 'Select all'
}),
cell: ({ row }) =>
h(PCheckbox, {
modelValue: row.getIsSelected(),
'onUpdate:modelValue': (value: boolean | 'indeterminate') => row.toggleSelected(!!value),
'aria-label': 'Select row'
})
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const color = {
paid: 'success' as const,
failed: 'error' as const,
refunded: 'neutral' as const
}[row.getValue('status') as string]
return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
row.getValue('status')
)
}
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
}
}
]
const table = useTemplateRef('table')
const rowSelection = ref<Record<string, boolean>>({})
function onSelect(e: Event, row: TableRow<Payment>) {
/* If you decide to also select the column you can do this */
row.toggleSelected(!row.getIsSelected())
}
</script>
<template>
<div class="flex flex-1 gap-1 w-full">
<div class="flex-1">
<PTable
ref="table"
v-model:row-selection="rowSelection"
:data="data"
:columns="columns"
@select="onSelect"
/>
<div class="text-sm color-text-muted px-4 py-3.5 border-t border-border-accented">
{{ table?.tableApi?.getFilteredSelectedRowModel().rows.length || 0 }} of
{{ table?.tableApi?.getFilteredRowModel().rows.length || 0 }} row(s) selected.
</div>
</div>
</div>
</template>With row context menu event
You can add a @contextmenu listener to make rows right clickable and wrap the Table in a ContextMenu component to display row actions for example.
Event and TableRow instance as the first and second arguments respectively.| # | Date | Status | Amount | ||
|---|---|---|---|---|---|
| #4600 | Mar 11, 15:30 | paid | james.anderson@example.com | €594.00 | |
| #4599 | Mar 11, 10:10 | failed | mia.white@example.com | €276.00 | |
| #4598 | Mar 11, 08:50 | refunded | william.brown@example.com | €315.00 | |
| #4597 | Mar 10, 19:45 | paid | emma.davis@example.com | €529.00 | |
| #4596 | Mar 10, 15:55 | paid | ethan.harris@example.com | €639.00 |
<script setup lang="ts">
import type { ContextMenuItem, TableColumn, TableRow } from 'pohon-ui'
import { useClipboard } from '@vueuse/core'
import { h, resolveComponent } from 'vue'
const PBadge = resolveComponent('PBadge')
const PCheckbox = resolveComponent('PCheckbox')
const toast = useToast()
const { copy } = useClipboard()
interface Payment {
id: string
date: string
status: 'paid' | 'failed' | 'refunded'
email: string
amount: number
}
const data = ref<Array<Payment>>([
{
id: '4600',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 529
},
{
id: '4596',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
}
])
const columns: Array<TableColumn<Payment>> = [
{
id: 'select',
header: ({ table }) =>
h(PCheckbox, {
modelValue: table.getIsSomePageRowsSelected()
? 'indeterminate'
: table.getIsAllPageRowsSelected(),
'onUpdate:modelValue': (value: boolean | 'indeterminate') =>
table.toggleAllPageRowsSelected(!!value),
'aria-label': 'Select all'
}),
cell: ({ row }) =>
h(PCheckbox, {
modelValue: row.getIsSelected(),
'onUpdate:modelValue': (value: boolean | 'indeterminate') => row.toggleSelected(!!value),
'aria-label': 'Select row'
})
},
{
accessorKey: 'id',
header: '#',
cell: ({ row }) => `#${row.getValue('id')}`
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const color = {
paid: 'success' as const,
failed: 'error' as const,
refunded: 'neutral' as const
}[row.getValue('status') as string]
return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
row.getValue('status')
)
}
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
}
}
]
const items = ref<Array<ContextMenuItem>>([])
function getRowItems(row: TableRow<Payment>) {
return [
{
type: 'label' as const,
label: 'Actions'
},
{
label: 'Copy payment ID',
onSelect() {
copy(row.original.id)
toast.add({
title: 'Payment ID copied to clipboard!',
color: 'success',
icon: 'i-lucide-circle-check'
})
}
},
{
label: row.getIsExpanded() ? 'Collapse' : 'Expand',
onSelect() {
row.toggleExpanded()
}
},
{
type: 'separator' as const
},
{
label: 'View customer'
},
{
label: 'View payment details'
}
]
}
function onContextmenu(_e: Event, row: TableRow<Payment>) {
items.value = getRowItems(row)
}
</script>
<template>
<PContextMenu :items="items">
<PTable :data="data" :columns="columns" class="flex-1" @contextmenu="onContextmenu">
<template #expanded="{ row }">
<pre>{{ row.original }}</pre>
</template>
</PTable>
</PContextMenu>
</template><script setup lang="ts">
import type { ContextMenuItem, TableColumn, TableRow } from 'pohon-ui'
import { useClipboard } from '@vueuse/core'
import { h, resolveComponent, ref } from 'vue'
const PBadge = resolveComponent('PBadge')
const PCheckbox = resolveComponent('PCheckbox')
const toast = useToast()
const { copy } = useClipboard()
interface Payment {
id: string
date: string
status: 'paid' | 'failed' | 'refunded'
email: string
amount: number
}
const data = ref<Array<Payment>>([
{
id: '4600',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 529
},
{
id: '4596',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
}
])
const columns: Array<TableColumn<Payment>> = [
{
id: 'select',
header: ({ table }) =>
h(PCheckbox, {
modelValue: table.getIsSomePageRowsSelected()
? 'indeterminate'
: table.getIsAllPageRowsSelected(),
'onUpdate:modelValue': (value: boolean | 'indeterminate') =>
table.toggleAllPageRowsSelected(!!value),
'aria-label': 'Select all'
}),
cell: ({ row }) =>
h(PCheckbox, {
modelValue: row.getIsSelected(),
'onUpdate:modelValue': (value: boolean | 'indeterminate') => row.toggleSelected(!!value),
'aria-label': 'Select row'
})
},
{
accessorKey: 'id',
header: '#',
cell: ({ row }) => `#${row.getValue('id')}`
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const color = {
paid: 'success' as const,
failed: 'error' as const,
refunded: 'neutral' as const
}[row.getValue('status') as string]
return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
row.getValue('status')
)
}
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
}
}
]
const items = ref<Array<ContextMenuItem>>([])
function getRowItems(row: TableRow<Payment>) {
return [
{
type: 'label' as const,
label: 'Actions'
},
{
label: 'Copy payment ID',
onSelect() {
copy(row.original.id)
toast.add({
title: 'Payment ID copied to clipboard!',
color: 'success',
icon: 'i-lucide-circle-check'
})
}
},
{
label: row.getIsExpanded() ? 'Collapse' : 'Expand',
onSelect() {
row.toggleExpanded()
}
},
{
type: 'separator' as const
},
{
label: 'View customer'
},
{
label: 'View payment details'
}
]
}
function onContextmenu(_e: Event, row: TableRow<Payment>) {
items.value = getRowItems(row)
}
</script>
<template>
<PContextMenu :items="items">
<PTable :data="data" :columns="columns" class="flex-1" @contextmenu="onContextmenu">
<template #expanded="{ row }">
<pre>{{ row.original }}</pre>
</template>
</PTable>
</PContextMenu>
</template>With row hover event
You can add a @hover listener to make rows hoverable and use a Popover or a Tooltip component to display row details for example.
Event and TableRow instance as the first and second arguments respectively.| # | Date | Status | Amount | ||
|---|---|---|---|---|---|
| #4600 | Mar 11, 15:30 | paid | james.anderson@example.com | €594.00 | |
| #4599 | Mar 11, 10:10 | failed | mia.white@example.com | €276.00 | |
| #4598 | Mar 11, 08:50 | refunded | william.brown@example.com | €315.00 | |
| #4597 | Mar 10, 19:45 | paid | emma.davis@example.com | €529.00 | |
| #4596 | Mar 10, 15:55 | paid | ethan.harris@example.com | €639.00 |
<script setup lang="ts">
import type { TableColumn, TableRow } from 'pohon-ui'
import { h, resolveComponent } from 'vue'
const PBadge = resolveComponent('PBadge')
const PCheckbox = resolveComponent('PCheckbox')
interface Payment {
id: string
date: string
status: 'paid' | 'failed' | 'refunded'
email: string
amount: number
}
const data = ref<Array<Payment>>([
{
id: '4600',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 529
},
{
id: '4596',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
}
])
const columns: Array<TableColumn<Payment>> = [
{
id: 'select',
header: ({ table }) =>
h(PCheckbox, {
modelValue: table.getIsSomePageRowsSelected()
? 'indeterminate'
: table.getIsAllPageRowsSelected(),
'onUpdate:modelValue': (value: boolean | 'indeterminate') =>
table.toggleAllPageRowsSelected(!!value),
'aria-label': 'Select all'
}),
cell: ({ row }) =>
h(PCheckbox, {
modelValue: row.getIsSelected(),
'onUpdate:modelValue': (value: boolean | 'indeterminate') => row.toggleSelected(!!value),
'aria-label': 'Select row'
})
},
{
accessorKey: 'id',
header: '#',
cell: ({ row }) => `#${row.getValue('id')}`
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const color = {
paid: 'success' as const,
failed: 'error' as const,
refunded: 'neutral' as const
}[row.getValue('status') as string]
return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
row.getValue('status')
)
}
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
}
}
]
const anchor = ref({ x: 0, y: 0 })
const reference = computed(() => ({
getBoundingClientRect: () =>
({
width: 0,
height: 0,
left: anchor.value.x,
right: anchor.value.x,
top: anchor.value.y,
bottom: anchor.value.y,
...anchor.value
}) as DOMRect
}))
const open = ref(false)
const openDebounced = refDebounced(open, 10)
const selectedRow = ref<TableRow<Payment> | null>(null)
function onHover(_e: Event, row: TableRow<Payment> | null) {
selectedRow.value = row
open.value = !!row
}
</script>
<template>
<div class="flex flex-1 gap-1 w-full">
<PTable
:data="data"
:columns="columns"
class="flex-1"
@pointermove="
(ev: PointerEvent) => {
anchor.x = ev.clientX
anchor.y = ev.clientY
}
"
@hover="onHover"
/>
<PPopover
:content="{ side: 'top', sideOffset: 16, updatePositionStrategy: 'always' }"
:open="openDebounced"
:reference="reference"
>
<template #content>
<div class="p-4">
{{ selectedRow?.original?.id }}
</div>
</template>
</PPopover>
</div>
</template><script setup lang="ts">
import { refDebounced } from '@vueuse/core'
import type { TableColumn, TableRow } from 'pohon-ui'
import { h, resolveComponent, ref, computed } from 'vue'
const PBadge = resolveComponent('PBadge')
const PCheckbox = resolveComponent('PCheckbox')
interface Payment {
id: string
date: string
status: 'paid' | 'failed' | 'refunded'
email: string
amount: number
}
const data = ref<Array<Payment>>([
{
id: '4600',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 529
},
{
id: '4596',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
}
])
const columns: Array<TableColumn<Payment>> = [
{
id: 'select',
header: ({ table }) =>
h(PCheckbox, {
modelValue: table.getIsSomePageRowsSelected()
? 'indeterminate'
: table.getIsAllPageRowsSelected(),
'onUpdate:modelValue': (value: boolean | 'indeterminate') =>
table.toggleAllPageRowsSelected(!!value),
'aria-label': 'Select all'
}),
cell: ({ row }) =>
h(PCheckbox, {
modelValue: row.getIsSelected(),
'onUpdate:modelValue': (value: boolean | 'indeterminate') => row.toggleSelected(!!value),
'aria-label': 'Select row'
})
},
{
accessorKey: 'id',
header: '#',
cell: ({ row }) => `#${row.getValue('id')}`
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const color = {
paid: 'success' as const,
failed: 'error' as const,
refunded: 'neutral' as const
}[row.getValue('status') as string]
return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
row.getValue('status')
)
}
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
}
}
]
const anchor = ref({ x: 0, y: 0 })
const reference = computed(() => ({
getBoundingClientRect: () =>
({
width: 0,
height: 0,
left: anchor.value.x,
right: anchor.value.x,
top: anchor.value.y,
bottom: anchor.value.y,
...anchor.value
}) as DOMRect
}))
const open = ref(false)
const openDebounced = refDebounced(open, 10)
const selectedRow = ref<TableRow<Payment> | null>(null)
function onHover(_e: Event, row: TableRow<Payment> | null) {
selectedRow.value = row
open.value = !!row
}
</script>
<template>
<div class="flex flex-1 gap-1 w-full">
<PTable
:data="data"
:columns="columns"
class="flex-1"
@pointermove="
(ev: PointerEvent) => {
anchor.x = ev.clientX
anchor.y = ev.clientY
}
"
@hover="onHover"
/>
<PPopover
:content="{ side: 'top', sideOffset: 16, updatePositionStrategy: 'always' }"
:open="openDebounced"
:reference="reference"
>
<template #content>
<div class="p-4">
{{ selectedRow?.original?.id }}
</div>
</template>
</PPopover>
</div>
</template>refDebounced to prevent the Popover from opening and closing too quickly when moving the cursor from one row to another.With column footer
You can add a footer property to the column definition to render a footer for the column.
| # | Date | Status | Amount | |
|---|---|---|---|---|
| #4600 | Mar 11, 15:30 | paid | james.anderson@example.com | €594.00 |
| #4599 | Mar 11, 10:10 | failed | mia.white@example.com | €276.00 |
| #4598 | Mar 11, 08:50 | refunded | william.brown@example.com | €315.00 |
| #4597 | Mar 10, 19:45 | paid | emma.davis@example.com | €529.00 |
| #4596 | Mar 10, 15:55 | paid | ethan.harris@example.com | €639.00 |
| Total: €2,353.00 |
<script setup lang="ts">
import type { TableColumn, TableRow } from 'pohon-ui'
import { h, resolveComponent } from 'vue'
const PBadge = resolveComponent('PBadge')
interface Payment {
id: string
date: string
status: 'paid' | 'failed' | 'refunded'
email: string
amount: number
}
const data = ref<Array<Payment>>([
{
id: '4600',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 529
},
{
id: '4596',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
}
])
const columns: Array<TableColumn<Payment>> = [
{
accessorKey: 'id',
header: '#',
cell: ({ row }) => `#${row.getValue('id')}`
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const color = {
paid: 'success' as const,
failed: 'error' as const,
refunded: 'neutral' as const
}[row.getValue('status') as string]
return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
row.getValue('status')
)
}
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
footer: ({ column }) => {
const total = column
.getFacetedRowModel()
.rows.reduce(
(acc: number, row: TableRow<Payment>) => acc + Number.parseFloat(row.getValue('amount')),
0
)
const formatted = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(total)
return `Total: ${formatted}`
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
}
}
]
</script>
<template>
<PTable :data="data" :columns="columns" class="flex-1" />
</template><script setup lang="ts">
import type { TableColumn, TableRow } from 'pohon-ui'
import { h, resolveComponent, ref } from 'vue'
const PBadge = resolveComponent('PBadge')
interface Payment {
id: string
date: string
status: 'paid' | 'failed' | 'refunded'
email: string
amount: number
}
const data = ref<Array<Payment>>([
{
id: '4600',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 529
},
{
id: '4596',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
}
])
const columns: Array<TableColumn<Payment>> = [
{
accessorKey: 'id',
header: '#',
cell: ({ row }) => `#${row.getValue('id')}`
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const color = {
paid: 'success' as const,
failed: 'error' as const,
refunded: 'neutral' as const
}[row.getValue('status') as string]
return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
row.getValue('status')
)
}
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
footer: ({ column }) => {
const total = column
.getFacetedRowModel()
.rows.reduce(
(acc: number, row: TableRow<Payment>) => acc + Number.parseFloat(row.getValue('amount')),
0
)
const formatted = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(total)
return `Total: ${formatted}`
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
}
}
]
</script>
<template>
<PTable :data="data" :columns="columns" class="flex-1" />
</template>With column span
You can use the colspan and rowspan properties in the column meta to merge cells. These properties accept a static value or a function that receives the cell and returns the span value.
rowspan, cells that are "absorbed" by a previous row's span need to be visually hidden. Use the class meta with a function that returns 'hidden' for those cells.| Category | Name | Price | Stock |
|---|---|---|---|
| Electronics | Laptop | $999.00 | 45 |
| Phone | $699.00 | 120 | |
| Tablet | $499.00 | 78 | |
| Clothing | T-Shirt | $29.00 | 200 |
| Jeans | $59.00 | 150 |
<script setup lang="ts">
import type { Cell } from '@tanstack/vue-table'
import type { TableColumn } from 'pohon-ui'
interface Product {
category: string
name: string
price: number
stock: number
}
const data = ref<Array<Product>>([
{ category: 'Electronics', name: 'Laptop', price: 999, stock: 45 },
{ category: 'Electronics', name: 'Phone', price: 699, stock: 120 },
{ category: 'Electronics', name: 'Tablet', price: 499, stock: 78 },
{ category: 'Clothing', name: 'T-Shirt', price: 29, stock: 200 },
{ category: 'Clothing', name: 'Jeans', price: 59, stock: 150 }
])
function getCategoryRowSpan(cell: Cell<Product, unknown>) {
const category = cell.row.original.category
const rows = cell.getContext().table.getRowModel().rows
const rowIndex = rows.findIndex((r: (typeof rows)[number]) => r.id === cell.row.id)
if (rowIndex > 0 && rows[rowIndex - 1]!.original.category === category) {
return '1'
}
let span = 1
for (let i = rowIndex + 1; i < rows.length; i++) {
if (rows[i]!.original.category === category) {
span++
} else {
break
}
}
return `${span}`
}
function getCategoryClass(cell: Cell<Product, unknown>) {
const category = cell.row.original.category
const rows = cell.getContext().table.getRowModel().rows
const rowIndex = rows.findIndex((r: (typeof rows)[number]) => r.id === cell.row.id)
if (rowIndex > 0 && rows[rowIndex - 1]!.original.category === category) {
return 'hidden'
}
return 'font-medium align-middle border-r border-border'
}
const columns: Array<TableColumn<Product>> = [
{
accessorKey: 'category',
header: 'Category',
meta: {
rowspan: {
td: getCategoryRowSpan
},
class: {
td: getCategoryClass
}
}
},
{
accessorKey: 'name',
header: 'Name'
},
{
accessorKey: 'price',
header: 'Price',
meta: {
class: {
th: 'text-right',
td: 'text-right'
}
},
cell: ({ row }) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(row.getValue('price'))
}
},
{
accessorKey: 'stock',
header: 'Stock',
meta: {
class: {
th: 'text-right',
td: 'text-right'
}
}
}
]
</script>
<template>
<PTable :data="data" :columns="columns" class="flex-1" />
</template><script setup lang="ts">
import { ref } from 'vue'
import type { Cell } from '@tanstack/vue-table'
import type { TableColumn } from 'pohon-ui'
interface Product {
category: string
name: string
price: number
stock: number
}
const data = ref<Array<Product>>([
{ category: 'Electronics', name: 'Laptop', price: 999, stock: 45 },
{ category: 'Electronics', name: 'Phone', price: 699, stock: 120 },
{ category: 'Electronics', name: 'Tablet', price: 499, stock: 78 },
{ category: 'Clothing', name: 'T-Shirt', price: 29, stock: 200 },
{ category: 'Clothing', name: 'Jeans', price: 59, stock: 150 }
])
function getCategoryRowSpan(cell: Cell<Product, unknown>) {
const category = cell.row.original.category
const rows = cell.getContext().table.getRowModel().rows
const rowIndex = rows.findIndex((r: (typeof rows)[number]) => r.id === cell.row.id)
if (rowIndex > 0 && rows[rowIndex - 1]!.original.category === category) {
return '1'
}
let span = 1
for (let i = rowIndex + 1; i < rows.length; i++) {
if (rows[i]!.original.category === category) {
span++
} else {
break
}
}
return `${span}`
}
function getCategoryClass(cell: Cell<Product, unknown>) {
const category = cell.row.original.category
const rows = cell.getContext().table.getRowModel().rows
const rowIndex = rows.findIndex((r: (typeof rows)[number]) => r.id === cell.row.id)
if (rowIndex > 0 && rows[rowIndex - 1]!.original.category === category) {
return 'hidden'
}
return 'font-medium align-middle border-r border-border'
}
const columns: Array<TableColumn<Product>> = [
{
accessorKey: 'category',
header: 'Category',
meta: {
rowspan: {
td: getCategoryRowSpan
},
class: {
td: getCategoryClass
}
}
},
{
accessorKey: 'name',
header: 'Name'
},
{
accessorKey: 'price',
header: 'Price',
meta: {
class: {
th: 'text-right',
td: 'text-right'
}
},
cell: ({ row }) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(row.getValue('price'))
}
},
{
accessorKey: 'stock',
header: 'Stock',
meta: {
class: {
th: 'text-right',
td: 'text-right'
}
}
}
]
</script>
<template>
<PTable :data="data" :columns="columns" class="flex-1" />
</template>With column sorting
You can update a column header to render a Button component inside the header to toggle the sorting state using the TanStack Table Sorting APIs.
Set enableSorting: true on those columns as well. This puts aria-sort on the <th> so screen readers can read the current sort state of the column: none, ascending or descending. The Button stays the control that changes it.
| # | Date | Status | Amount | |
|---|---|---|---|---|
| #4597 | Mar 10, 19:45 | paid | emma.davis@example.com | €529.00 |
| #4596 | Mar 10, 15:55 | paid | ethan.harris@example.com | €639.00 |
| #4600 | Mar 11, 15:30 | paid | james.anderson@example.com | €594.00 |
| #4599 | Mar 11, 10:10 | failed | mia.white@example.com | €276.00 |
| #4598 | Mar 11, 08:50 | refunded | william.brown@example.com | €315.00 |
<script setup lang="ts">
import type { TableColumn } from 'pohon-ui'
import { h, resolveComponent } from 'vue'
const PBadge = resolveComponent('PBadge')
const PButton = resolveComponent('PButton')
interface Payment {
id: string
date: string
status: 'paid' | 'failed' | 'refunded'
email: string
amount: number
}
const data = ref<Array<Payment>>([
{
id: '4600',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 529
},
{
id: '4596',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
}
])
const columns: Array<TableColumn<Payment>> = [
{
accessorKey: 'id',
header: '#',
cell: ({ row }) => `#${row.getValue('id')}`
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const color = {
paid: 'success' as const,
failed: 'error' as const,
refunded: 'neutral' as const
}[row.getValue('status') as string]
return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
row.getValue('status')
)
}
},
{
accessorKey: 'email',
enableSorting: true,
header: ({ column }) => {
const isSorted = column.getIsSorted()
return h(PButton, {
color: 'neutral',
variant: 'ghost',
label: 'Email',
icon: isSorted
? isSorted === 'asc'
? 'i-lucide-arrow-up-narrow-wide'
: 'i-lucide-arrow-down-wide-narrow'
: 'i-lucide-arrow-up-down',
class: '-mx-2.5',
onClick: () => column.toggleSorting(column.getIsSorted() === 'asc')
})
}
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
}
}
]
const sorting = ref([
{
id: 'email',
desc: false
}
])
</script>
<template>
<PTable v-model:sorting="sorting" :data="data" :columns="columns" class="flex-1" />
</template><script setup lang="ts">
import type { TableColumn } from 'pohon-ui'
import { h, resolveComponent, ref } from 'vue'
const PBadge = resolveComponent('PBadge')
const PButton = resolveComponent('PButton')
interface Payment {
id: string
date: string
status: 'paid' | 'failed' | 'refunded'
email: string
amount: number
}
const data = ref<Array<Payment>>([
{
id: '4600',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 529
},
{
id: '4596',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
}
])
const columns: Array<TableColumn<Payment>> = [
{
accessorKey: 'id',
header: '#',
cell: ({ row }) => `#${row.getValue('id')}`
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const color = {
paid: 'success' as const,
failed: 'error' as const,
refunded: 'neutral' as const
}[row.getValue('status') as string]
return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
row.getValue('status')
)
}
},
{
accessorKey: 'email',
enableSorting: true,
header: ({ column }) => {
const isSorted = column.getIsSorted()
return h(PButton, {
color: 'neutral',
variant: 'ghost',
label: 'Email',
icon: isSorted
? isSorted === 'asc'
? 'i-lucide-arrow-up-narrow-wide'
: 'i-lucide-arrow-down-wide-narrow'
: 'i-lucide-arrow-up-down',
class: '-mx-2.5',
onClick: () => column.toggleSorting(column.getIsSorted() === 'asc')
})
}
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
}
}
]
const sorting = ref([
{
id: 'email',
desc: false
}
])
</script>
<template>
<PTable v-model:sorting="sorting" :data="data" :columns="columns" class="flex-1" />
</template>sorting prop to control the sorting state of the columns (can be bound with v-model).You can also create a reusable component to make any column header sortable.
| #4596 | Mar 10, 15:55 | paid | ethan.harris@example.com | €639.00 |
| #4597 | Mar 10, 19:45 | paid | emma.davis@example.com | €529.00 |
| #4598 | Mar 11, 08:50 | refunded | william.brown@example.com | €315.00 |
| #4599 | Mar 11, 10:10 | failed | mia.white@example.com | €276.00 |
| #4600 | Mar 11, 15:30 | paid | james.anderson@example.com | €594.00 |
<script setup lang="ts">
import type { Column } from '@tanstack/vue-table'
import type { TableColumn } from 'pohon-ui'
import { h, resolveComponent } from 'vue'
const PBadge = resolveComponent('PBadge')
const PButton = resolveComponent('PButton')
const PDropdownMenu = resolveComponent('PDropdownMenu')
interface Payment {
id: string
date: string
status: 'paid' | 'failed' | 'refunded'
email: string
amount: number
}
const data = ref<Array<Payment>>([
{
id: '4600',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 529
},
{
id: '4596',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
}
])
const columns: Array<TableColumn<Payment>> = [
{
accessorKey: 'id',
enableSorting: true,
header: ({ column }) => getHeader(column, 'ID'),
cell: ({ row }) => `#${row.getValue('id')}`
},
{
accessorKey: 'date',
enableSorting: true,
header: ({ column }) => getHeader(column, 'Date'),
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
},
{
accessorKey: 'status',
enableSorting: true,
header: ({ column }) => getHeader(column, 'Status'),
cell: ({ row }) => {
const color = {
paid: 'success' as const,
failed: 'error' as const,
refunded: 'neutral' as const
}[row.getValue('status') as string]
return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
row.getValue('status')
)
}
},
{
accessorKey: 'email',
enableSorting: true,
header: ({ column }) => getHeader(column, 'Email')
},
{
accessorKey: 'amount',
enableSorting: true,
header: ({ column }) => getHeader(column, 'Amount'),
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
}
}
]
function getHeader(column: Column<Payment>, label: string) {
const isSorted = column.getIsSorted()
return h(
PDropdownMenu,
{
content: {
align: 'start'
},
'aria-label': 'Actions dropdown',
items: [
{
label: 'Asc',
type: 'checkbox',
icon: 'i-lucide-arrow-up-narrow-wide',
checked: isSorted === 'asc',
onSelect: () => {
if (isSorted === 'asc') {
column.clearSorting()
} else {
column.toggleSorting(false)
}
}
},
{
label: 'Desc',
icon: 'i-lucide-arrow-down-wide-narrow',
type: 'checkbox',
checked: isSorted === 'desc',
onSelect: () => {
if (isSorted === 'desc') {
column.clearSorting()
} else {
column.toggleSorting(true)
}
}
}
]
},
() =>
h(PButton, {
color: 'neutral',
variant: 'ghost',
label,
icon: isSorted
? isSorted === 'asc'
? 'i-lucide-arrow-up-narrow-wide'
: 'i-lucide-arrow-down-wide-narrow'
: 'i-lucide-arrow-up-down',
class: '-mx-2.5 data-[state=open]:bg-background-elevated',
'aria-label': `Sort by ${isSorted === 'asc' ? 'descending' : 'ascending'}`
})
)
}
const sorting = ref([
{
id: 'id',
desc: false
}
])
</script>
<template>
<PTable v-model:sorting="sorting" :data="data" :columns="columns" class="flex-1" />
</template><script setup lang="ts">
import type { Column } from '@tanstack/vue-table'
import type { TableColumn } from 'pohon-ui'
import { h, resolveComponent, ref } from 'vue'
const PBadge = resolveComponent('PBadge')
const PButton = resolveComponent('PButton')
const PDropdownMenu = resolveComponent('PDropdownMenu')
interface Payment {
id: string
date: string
status: 'paid' | 'failed' | 'refunded'
email: string
amount: number
}
const data = ref<Array<Payment>>([
{
id: '4600',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 529
},
{
id: '4596',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
}
])
const columns: Array<TableColumn<Payment>> = [
{
accessorKey: 'id',
enableSorting: true,
header: ({ column }) => getHeader(column, 'ID'),
cell: ({ row }) => `#${row.getValue('id')}`
},
{
accessorKey: 'date',
enableSorting: true,
header: ({ column }) => getHeader(column, 'Date'),
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
},
{
accessorKey: 'status',
enableSorting: true,
header: ({ column }) => getHeader(column, 'Status'),
cell: ({ row }) => {
const color = {
paid: 'success' as const,
failed: 'error' as const,
refunded: 'neutral' as const
}[row.getValue('status') as string]
return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
row.getValue('status')
)
}
},
{
accessorKey: 'email',
enableSorting: true,
header: ({ column }) => getHeader(column, 'Email')
},
{
accessorKey: 'amount',
enableSorting: true,
header: ({ column }) => getHeader(column, 'Amount'),
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
}
}
]
function getHeader(column: Column<Payment>, label: string) {
const isSorted = column.getIsSorted()
return h(
PDropdownMenu,
{
content: {
align: 'start'
},
'aria-label': 'Actions dropdown',
items: [
{
label: 'Asc',
type: 'checkbox',
icon: 'i-lucide-arrow-up-narrow-wide',
checked: isSorted === 'asc',
onSelect: () => {
if (isSorted === 'asc') {
column.clearSorting()
} else {
column.toggleSorting(false)
}
}
},
{
label: 'Desc',
icon: 'i-lucide-arrow-down-wide-narrow',
type: 'checkbox',
checked: isSorted === 'desc',
onSelect: () => {
if (isSorted === 'desc') {
column.clearSorting()
} else {
column.toggleSorting(true)
}
}
}
]
},
() =>
h(PButton, {
color: 'neutral',
variant: 'ghost',
label,
icon: isSorted
? isSorted === 'asc'
? 'i-lucide-arrow-up-narrow-wide'
: 'i-lucide-arrow-down-wide-narrow'
: 'i-lucide-arrow-up-down',
class: '-mx-2.5 data-[state=open]:bg-background-elevated',
'aria-label': `Sort by ${isSorted === 'asc' ? 'descending' : 'ascending'}`
})
)
}
const sorting = ref([
{
id: 'id',
desc: false
}
])
</script>
<template>
<PTable v-model:sorting="sorting" :data="data" :columns="columns" class="flex-1" />
</template>With column pinning
You can update a column header to render a Button component inside the header to toggle the pinning state using the TanStack Table Column Pinning APIs.
size values for your columns to ensure proper column width handling, especially with multiple pinned columns.| #4600000000000000000000000000000000000000 | 2024-03-11T15:30:00 | paid | james.anderson@example.com | €594,000.00 |
| #4599000000000000000000000000000000000000 | 2024-03-11T10:10:00 | failed | mia.white@example.com | €276,000.00 |
| #4598000000000000000000000000000000000000 | 2024-03-11T08:50:00 | refunded | william.brown@example.com | €315,000.00 |
| #4597000000000000000000000000000000000000 | 2024-03-10T19:45:00 | paid | emma.davis@example.com | €5,290,000.00 |
| #4596000000000000000000000000000000000000 | 2024-03-10T15:55:00 | paid | ethan.harris@example.com | €639,000.00 |
<script setup lang="ts">
import type { Column } from '@tanstack/vue-table'
import type { TableColumn } from 'pohon-ui'
import { h, resolveComponent } from 'vue'
const PBadge = resolveComponent('PBadge')
const PButton = resolveComponent('PButton')
interface Payment {
id: string
date: string
status: 'paid' | 'failed' | 'refunded'
email: string
amount: number
}
const data = ref<Array<Payment>>([
{
id: '4600000000000000000000000000000000000000',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594000
},
{
id: '4599000000000000000000000000000000000000',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276000
},
{
id: '4598000000000000000000000000000000000000',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315000
},
{
id: '4597000000000000000000000000000000000000',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 5290000
},
{
id: '4596000000000000000000000000000000000000',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639000
}
])
const columns: Array<TableColumn<Payment>> = [
{
accessorKey: 'id',
header: ({ column }) => getHeader(column, 'ID', 'left'),
cell: ({ row }) => `#${row.getValue('id')}`,
size: 381
},
{
accessorKey: 'date',
header: ({ column }) => getHeader(column, 'Date', 'left'),
size: 172
},
{
accessorKey: 'status',
header: ({ column }) => getHeader(column, 'Status', 'left'),
cell: ({ row }) => {
const color = {
paid: 'success' as const,
failed: 'error' as const,
refunded: 'neutral' as const
}[row.getValue('status') as string]
return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
row.getValue('status')
)
},
size: 103
},
{
accessorKey: 'email',
header: ({ column }) => getHeader(column, 'Email', 'left'),
size: 232
},
{
accessorKey: 'amount',
header: ({ column }) => getHeader(column, 'Amount', 'right'),
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
},
size: 130
}
]
function getHeader(column: Column<Payment>, label: string, position: 'left' | 'right') {
const isPinned = column.getIsPinned()
return h(PButton, {
color: 'neutral',
variant: 'ghost',
label,
icon: isPinned ? 'i-lucide-pin-off' : 'i-lucide-pin',
class: '-mx-2.5',
onClick() {
column.pin(isPinned === position ? false : position)
}
})
}
const columnPinning = ref({
left: ['id'],
right: ['amount']
})
</script>
<template>
<PTable v-model:column-pinning="columnPinning" :data="data" :columns="columns" class="flex-1" />
</template><script setup lang="ts">
import type { Column } from '@tanstack/vue-table'
import type { TableColumn } from 'pohon-ui'
import { h, resolveComponent, ref } from 'vue'
const PBadge = resolveComponent('PBadge')
const PButton = resolveComponent('PButton')
interface Payment {
id: string
date: string
status: 'paid' | 'failed' | 'refunded'
email: string
amount: number
}
const data = ref<Array<Payment>>([
{
id: '4600000000000000000000000000000000000000',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594000
},
{
id: '4599000000000000000000000000000000000000',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276000
},
{
id: '4598000000000000000000000000000000000000',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315000
},
{
id: '4597000000000000000000000000000000000000',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 5290000
},
{
id: '4596000000000000000000000000000000000000',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639000
}
])
const columns: Array<TableColumn<Payment>> = [
{
accessorKey: 'id',
header: ({ column }) => getHeader(column, 'ID', 'left'),
cell: ({ row }) => `#${row.getValue('id')}`,
size: 381
},
{
accessorKey: 'date',
header: ({ column }) => getHeader(column, 'Date', 'left'),
size: 172
},
{
accessorKey: 'status',
header: ({ column }) => getHeader(column, 'Status', 'left'),
cell: ({ row }) => {
const color = {
paid: 'success' as const,
failed: 'error' as const,
refunded: 'neutral' as const
}[row.getValue('status') as string]
return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
row.getValue('status')
)
},
size: 103
},
{
accessorKey: 'email',
header: ({ column }) => getHeader(column, 'Email', 'left'),
size: 232
},
{
accessorKey: 'amount',
header: ({ column }) => getHeader(column, 'Amount', 'right'),
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
},
size: 130
}
]
function getHeader(column: Column<Payment>, label: string, position: 'left' | 'right') {
const isPinned = column.getIsPinned()
return h(PButton, {
color: 'neutral',
variant: 'ghost',
label,
icon: isPinned ? 'i-lucide-pin-off' : 'i-lucide-pin',
class: '-mx-2.5',
onClick() {
column.pin(isPinned === position ? false : position)
}
})
}
const columnPinning = ref({
left: ['id'],
right: ['amount']
})
</script>
<template>
<PTable v-model:column-pinning="columnPinning" :data="data" :columns="columns" class="flex-1" />
</template>column-pinning prop to control the pinning state of the columns (can be bound with v-model).With column visibility
You can use a DropdownMenu component to toggle the visibility of the columns using the TanStack Table Column Visibility APIs.
| Date | Status | Amount | |
|---|---|---|---|
| Mar 11, 15:30 | paid | james.anderson@example.com | €594.00 |
| Mar 11, 10:10 | failed | mia.white@example.com | €276.00 |
| Mar 11, 08:50 | refunded | william.brown@example.com | €315.00 |
| Mar 10, 19:45 | paid | emma.davis@example.com | €529.00 |
| Mar 10, 15:55 | paid | ethan.harris@example.com | €639.00 |
<script setup lang="ts">
import type { TableColumn } from 'pohon-ui'
import { capitalize } from '@vinicunca/perkakas'
import { h, resolveComponent } from 'vue'
const PBadge = resolveComponent('PBadge')
interface Payment {
id: string
date: string
status: 'paid' | 'failed' | 'refunded'
email: string
amount: number
}
const data = ref<Array<Payment>>([
{
id: '4600',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 529
},
{
id: '4596',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
}
])
const columns: Array<TableColumn<Payment>> = [
{
accessorKey: 'id',
header: '#',
cell: ({ row }) => `#${row.getValue('id')}`
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const color = {
paid: 'success' as const,
failed: 'error' as const,
refunded: 'neutral' as const
}[row.getValue('status') as string]
return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
row.getValue('status')
)
}
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
}
}
]
const table = useTemplateRef('table')
const columnVisibility = ref({
id: false
})
</script>
<template>
<div class="flex flex-1 flex-col w-full">
<div class="px-4 py-3.5 border-b border-border-accented flex justify-end">
<PDropdownMenu
:items="
table?.tableApi
?.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => ({
label: capitalize(column.id),
type: 'checkbox' as const,
checked: column.getIsVisible(),
onUpdateChecked(checked: boolean) {
table?.tableApi?.getColumn(column.id)?.toggleVisibility(!!checked)
},
onSelect(e: Event) {
e.preventDefault()
}
}))
"
:content="{ align: 'end' }"
>
<PButton
label="Columns"
color="neutral"
variant="outline"
trailing-icon="i-lucide-chevron-down"
/>
</PDropdownMenu>
</div>
<PTable
ref="table"
v-model:column-visibility="columnVisibility"
:data="data"
:columns="columns"
/>
</div>
</template><script setup lang="ts">
import type { TableColumn } from 'pohon-ui'
import { capitalize } from '@vinicunca/perkakas'
import { h, resolveComponent, ref, useTemplateRef } from 'vue'
const PBadge = resolveComponent('PBadge')
interface Payment {
id: string
date: string
status: 'paid' | 'failed' | 'refunded'
email: string
amount: number
}
const data = ref<Array<Payment>>([
{
id: '4600',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 529
},
{
id: '4596',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
}
])
const columns: Array<TableColumn<Payment>> = [
{
accessorKey: 'id',
header: '#',
cell: ({ row }) => `#${row.getValue('id')}`
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const color = {
paid: 'success' as const,
failed: 'error' as const,
refunded: 'neutral' as const
}[row.getValue('status') as string]
return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
row.getValue('status')
)
}
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
}
}
]
const table = useTemplateRef('table')
const columnVisibility = ref({
id: false
})
</script>
<template>
<div class="flex flex-1 flex-col w-full">
<div class="px-4 py-3.5 border-b border-border-accented flex justify-end">
<PDropdownMenu
:items="
table?.tableApi
?.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => ({
label: capitalize(column.id),
type: 'checkbox' as const,
checked: column.getIsVisible(),
onUpdateChecked(checked: boolean) {
table?.tableApi?.getColumn(column.id)?.toggleVisibility(!!checked)
},
onSelect(e: Event) {
e.preventDefault()
}
}))
"
:content="{ align: 'end' }"
>
<PButton
label="Columns"
color="neutral"
variant="outline"
trailing-icon="i-lucide-chevron-down"
/>
</PDropdownMenu>
</div>
<PTable
ref="table"
v-model:column-visibility="columnVisibility"
:data="data"
:columns="columns"
/>
</div>
</template>column-visibility prop to control the visibility state of the columns (can be bound with v-model).With column filters
You can use an Input component to filter per column the rows using the TanStack Table Column Filtering APIs.
| # | Date | Status | Amount | |
|---|---|---|---|---|
| #4600 | Mar 11, 15:30 | paid | james.anderson@example.com | €594.00 |
<script setup lang="ts">
import type { TableColumn } from 'pohon-ui'
import { h, resolveComponent } from 'vue'
const PBadge = resolveComponent('PBadge')
interface Payment {
id: string
date: string
status: 'paid' | 'failed' | 'refunded'
email: string
amount: number
}
const data = ref<Array<Payment>>([
{
id: '4600',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 529
},
{
id: '4596',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
}
])
const columns: Array<TableColumn<Payment>> = [
{
accessorKey: 'id',
header: '#',
cell: ({ row }) => `#${row.getValue('id')}`
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const color = {
paid: 'success' as const,
failed: 'error' as const,
refunded: 'neutral' as const
}[row.getValue('status') as string]
return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
row.getValue('status')
)
}
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
}
}
]
const table = useTemplateRef('table')
const columnFilters = ref([
{
id: 'email',
value: 'james'
}
])
</script>
<template>
<div class="flex flex-1 flex-col w-full">
<div class="px-4 py-3.5 border-b border-border-accented flex">
<PInput
:model-value="table?.tableApi?.getColumn('email')?.getFilterValue() as string"
class="max-w-sm"
placeholder="Filter emails..."
@update:model-value="table?.tableApi?.getColumn('email')?.setFilterValue($event)"
/>
</div>
<PTable ref="table" v-model:column-filters="columnFilters" :data="data" :columns="columns" />
</div>
</template><script setup lang="ts">
import type { TableColumn } from 'pohon-ui'
import { h, resolveComponent, ref, useTemplateRef } from 'vue'
const PBadge = resolveComponent('PBadge')
interface Payment {
id: string
date: string
status: 'paid' | 'failed' | 'refunded'
email: string
amount: number
}
const data = ref<Array<Payment>>([
{
id: '4600',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 529
},
{
id: '4596',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
}
])
const columns: Array<TableColumn<Payment>> = [
{
accessorKey: 'id',
header: '#',
cell: ({ row }) => `#${row.getValue('id')}`
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const color = {
paid: 'success' as const,
failed: 'error' as const,
refunded: 'neutral' as const
}[row.getValue('status') as string]
return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
row.getValue('status')
)
}
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
}
}
]
const table = useTemplateRef('table')
const columnFilters = ref([
{
id: 'email',
value: 'james'
}
])
</script>
<template>
<div class="flex flex-1 flex-col w-full">
<div class="px-4 py-3.5 border-b border-border-accented flex">
<PInput
:model-value="table?.tableApi?.getColumn('email')?.getFilterValue() as string"
class="max-w-sm"
placeholder="Filter emails..."
@update:model-value="table?.tableApi?.getColumn('email')?.setFilterValue($event)"
/>
</div>
<PTable ref="table" v-model:column-filters="columnFilters" :data="data" :columns="columns" />
</div>
</template>column-filters prop to control the filters state of the columns (can be bound with v-model).With global filter
You can use an Input component to filter the rows using the TanStack Table Global Filtering APIs.
| # | Date | Status | Amount | |
|---|---|---|---|---|
| #4599 | Mar 11, 10:10 | failed | mia.white@example.com | €276.00 |
| #4598 | Mar 11, 08:50 | refunded | william.brown@example.com | €315.00 |
| #4597 | Mar 10, 19:45 | paid | emma.davis@example.com | €529.00 |
| #4596 | Mar 10, 15:55 | paid | ethan.harris@example.com | €639.00 |
<script setup lang="ts">
import type { TableColumn } from 'pohon-ui'
import { h, resolveComponent } from 'vue'
const PBadge = resolveComponent('PBadge')
interface Payment {
id: string
date: string
status: 'paid' | 'failed' | 'refunded'
email: string
amount: number
}
const data = ref<Array<Payment>>([
{
id: '4600',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 529
},
{
id: '4596',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
}
])
const columns: Array<TableColumn<Payment>> = [
{
accessorKey: 'id',
header: '#',
cell: ({ row }) => `#${row.getValue('id')}`
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const color = {
paid: 'success' as const,
failed: 'error' as const,
refunded: 'neutral' as const
}[row.getValue('status') as string]
return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
row.getValue('status')
)
}
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
}
}
]
const globalFilter = ref('45')
</script>
<template>
<div class="flex flex-1 flex-col w-full">
<div class="px-4 py-3.5 border-b border-border-accented flex">
<PInput v-model="globalFilter" class="max-w-sm" placeholder="Filter..." />
</div>
<PTable ref="table" v-model:global-filter="globalFilter" :data="data" :columns="columns" />
</div>
</template><script setup lang="ts">
import type { TableColumn } from 'pohon-ui'
import { h, resolveComponent, ref } from 'vue'
const PBadge = resolveComponent('PBadge')
interface Payment {
id: string
date: string
status: 'paid' | 'failed' | 'refunded'
email: string
amount: number
}
const data = ref<Array<Payment>>([
{
id: '4600',
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
status: 'failed',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
status: 'refunded',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
status: 'paid',
email: 'emma.davis@example.com',
amount: 529
},
{
id: '4596',
date: '2024-03-10T15:55:00',
status: 'paid',
email: 'ethan.harris@example.com',
amount: 639
}
])
const columns: Array<TableColumn<Payment>> = [
{
accessorKey: 'id',
header: '#',
cell: ({ row }) => `#${row.getValue('id')}`
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const color = {
paid: 'success' as const,
failed: 'error' as const,
refunded: 'neutral' as const
}[row.getValue('status') as string]
return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
row.getValue('status')
)
}
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
}
}
]
const globalFilter = ref('45')
</script>
<template>
<div class="flex flex-1 flex-col w-full">
<div class="px-4 py-3.5 border-b border-border-accented flex">
<PInput v-model="globalFilter" class="max-w-sm" placeholder="Filter..." />
</div>
<PTable ref="table" v-model:global-filter="globalFilter" :data="data" :columns="columns" />
</div>
</template>global-filter prop to control the global filter state (can be bound with v-model).With pagination
You can use a Pagination component to control the pagination state using the Pagination APIs.
There are different pagination approaches as explained in Pagination Guide. In this example, we use client-side pagination so we need to manually pass getPaginationRowModel() function.
| # | Date | Amount | |
|---|---|---|---|
| #4600 | Mar 11, 15:30 | james.anderson@example.com | €594.00 |
| #4599 | Mar 11, 10:10 | mia.white@example.com | €276.00 |
| #4598 | Mar 11, 08:50 | william.brown@example.com | €315.00 |
| #4597 | Mar 10, 19:45 | emma.davis@example.com | €529.00 |
| #4596 | Mar 10, 15:55 | ethan.harris@example.com | €639.00 |
<script setup lang="ts">
import type { TableColumn } from 'pohon-ui'
import { getPaginationRowModel } from '@tanstack/vue-table'
const table = useTemplateRef('table')
interface Payment {
id: string
date: string
email: string
amount: number
}
const data = ref<Array<Payment>>([
{
id: '4600',
date: '2024-03-11T15:30:00',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
email: 'emma.davis@example.com',
amount: 529
},
{
id: '4596',
date: '2024-03-10T15:55:00',
email: 'ethan.harris@example.com',
amount: 639
},
{
id: '4595',
date: '2024-03-10T13:20:00',
email: 'sophia.miller@example.com',
amount: 428
},
{
id: '4594',
date: '2024-03-10T11:05:00',
email: 'noah.wilson@example.com',
amount: 673
},
{
id: '4593',
date: '2024-03-09T22:15:00',
email: 'olivia.jones@example.com',
amount: 382
},
{
id: '4592',
date: '2024-03-09T20:30:00',
email: 'liam.taylor@example.com',
amount: 547
},
{
id: '4591',
date: '2024-03-09T18:45:00',
email: 'ava.thomas@example.com',
amount: 291
},
{
id: '4590',
date: '2024-03-09T16:20:00',
email: 'lucas.martin@example.com',
amount: 624
},
{
id: '4589',
date: '2024-03-09T14:10:00',
email: 'isabella.clark@example.com',
amount: 438
},
{
id: '4588',
date: '2024-03-09T12:05:00',
email: 'mason.rodriguez@example.com',
amount: 583
},
{
id: '4587',
date: '2024-03-09T10:30:00',
email: 'sophia.lee@example.com',
amount: 347
},
{
id: '4586',
date: '2024-03-09T08:15:00',
email: 'ethan.walker@example.com',
amount: 692
},
{
id: '4585',
date: '2024-03-08T23:40:00',
email: 'amelia.hall@example.com',
amount: 419
},
{
id: '4584',
date: '2024-03-08T21:25:00',
email: 'oliver.young@example.com',
amount: 563
},
{
id: '4583',
date: '2024-03-08T19:50:00',
email: 'aria.king@example.com',
amount: 328
},
{
id: '4582',
date: '2024-03-08T17:35:00',
email: 'henry.wright@example.com',
amount: 647
},
{
id: '4581',
date: '2024-03-08T15:20:00',
email: 'luna.lopez@example.com',
amount: 482
}
])
const columns: Array<TableColumn<Payment>> = [
{
accessorKey: 'id',
header: '#',
cell: ({ row }) => `#${row.getValue('id')}`
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
}
}
]
const pagination = ref({
pageIndex: 0,
pageSize: 5
})
const globalFilter = ref('')
</script>
<template>
<div class="pb-4 w-full space-y-4">
<div class="px-4 py-3.5 border-b border-border-accented flex">
<PInput v-model="globalFilter" class="max-w-sm" placeholder="Filter..." />
</div>
<PTable
ref="table"
v-model:pagination="pagination"
v-model:global-filter="globalFilter"
:data="data"
:columns="columns"
:pagination-options="{
getPaginationRowModel: getPaginationRowModel()
}"
class="flex-1"
/>
<div class="px-4 pt-4 border-t border-border flex justify-end">
<PPagination
:page="(table?.tableApi?.getState().pagination.pageIndex || 0) + 1"
:items-per-page="table?.tableApi?.getState().pagination.pageSize"
:total="table?.tableApi?.getFilteredRowModel().rows.length"
@update:page="(p) => table?.tableApi?.setPageIndex(p - 1)"
/>
</div>
</div>
</template><script setup lang="ts">
import { ref, useTemplateRef } from 'vue'
import type { TableColumn } from 'pohon-ui'
import { getPaginationRowModel } from '@tanstack/vue-table'
const table = useTemplateRef('table')
interface Payment {
id: string
date: string
email: string
amount: number
}
const data = ref<Array<Payment>>([
{
id: '4600',
date: '2024-03-11T15:30:00',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
email: 'emma.davis@example.com',
amount: 529
},
{
id: '4596',
date: '2024-03-10T15:55:00',
email: 'ethan.harris@example.com',
amount: 639
},
{
id: '4595',
date: '2024-03-10T13:20:00',
email: 'sophia.miller@example.com',
amount: 428
},
{
id: '4594',
date: '2024-03-10T11:05:00',
email: 'noah.wilson@example.com',
amount: 673
},
{
id: '4593',
date: '2024-03-09T22:15:00',
email: 'olivia.jones@example.com',
amount: 382
},
{
id: '4592',
date: '2024-03-09T20:30:00',
email: 'liam.taylor@example.com',
amount: 547
},
{
id: '4591',
date: '2024-03-09T18:45:00',
email: 'ava.thomas@example.com',
amount: 291
},
{
id: '4590',
date: '2024-03-09T16:20:00',
email: 'lucas.martin@example.com',
amount: 624
},
{
id: '4589',
date: '2024-03-09T14:10:00',
email: 'isabella.clark@example.com',
amount: 438
},
{
id: '4588',
date: '2024-03-09T12:05:00',
email: 'mason.rodriguez@example.com',
amount: 583
},
{
id: '4587',
date: '2024-03-09T10:30:00',
email: 'sophia.lee@example.com',
amount: 347
},
{
id: '4586',
date: '2024-03-09T08:15:00',
email: 'ethan.walker@example.com',
amount: 692
},
{
id: '4585',
date: '2024-03-08T23:40:00',
email: 'amelia.hall@example.com',
amount: 419
},
{
id: '4584',
date: '2024-03-08T21:25:00',
email: 'oliver.young@example.com',
amount: 563
},
{
id: '4583',
date: '2024-03-08T19:50:00',
email: 'aria.king@example.com',
amount: 328
},
{
id: '4582',
date: '2024-03-08T17:35:00',
email: 'henry.wright@example.com',
amount: 647
},
{
id: '4581',
date: '2024-03-08T15:20:00',
email: 'luna.lopez@example.com',
amount: 482
}
])
const columns: Array<TableColumn<Payment>> = [
{
accessorKey: 'id',
header: '#',
cell: ({ row }) => `#${row.getValue('id')}`
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
}
}
]
const pagination = ref({
pageIndex: 0,
pageSize: 5
})
const globalFilter = ref('')
</script>
<template>
<div class="pb-4 w-full space-y-4">
<div class="px-4 py-3.5 border-b border-border-accented flex">
<PInput v-model="globalFilter" class="max-w-sm" placeholder="Filter..." />
</div>
<PTable
ref="table"
v-model:pagination="pagination"
v-model:global-filter="globalFilter"
:data="data"
:columns="columns"
:pagination-options="{
getPaginationRowModel: getPaginationRowModel()
}"
class="flex-1"
/>
<div class="px-4 pt-4 border-t border-border flex justify-end">
<PPagination
:page="(table?.tableApi?.getState().pagination.pageIndex || 0) + 1"
:items-per-page="table?.tableApi?.getState().pagination.pageSize"
:total="table?.tableApi?.getFilteredRowModel().rows.length"
@update:page="(p) => table?.tableApi?.setPageIndex(p - 1)"
/>
</div>
</div>
</template>pagination prop to control the pagination state (can be bound with v-model).With fetched data
You can fetch data from an API and use them in the Table.
| ID | Name | Company | |
|---|---|---|---|
| No data | |||
<script setup lang="ts">
import type { AvatarProps, TableColumn } from 'pohon-ui'
const PAvatar = resolveComponent('PAvatar')
interface User {
id: number
name: string
username: string
email: string
avatar: AvatarProps
company: { name: string }
}
const { data, status } = useLazyFetch<Array<User>>('https://jsonplaceholder.typicode.com/users', {
key: 'table-users',
transform: (data) => {
return (
data?.map((user) => ({
...user,
avatar: { src: `https://i.pravatar.cc/120?img=${user.id}`, alt: `${user.name} avatar` }
})) || []
)
},
server: false
})
const columns: Array<TableColumn<User>> = [
{
accessorKey: 'id',
header: 'ID'
},
{
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => {
return h('div', { class: 'flex items-center gap-3' }, [
h(PAvatar, {
...row.original.avatar,
loading: 'lazy',
size: 'lg'
}),
h('div', undefined, [
h('p', { class: 'font-medium color-text-highlighted' }, row.original.name),
h('p', { class: '' }, `@${row.original.username}`)
])
])
}
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'company',
header: 'Company',
cell: ({ row }) => row.original.company.name
}
]
</script>
<template>
<PTable
:data="data"
:columns="columns"
:loading="status === 'pending' || status === 'idle'"
class="flex-1 h-80"
/>
</template><script setup lang="ts">
import { h, resolveComponent } from 'vue'
import type { AvatarProps, TableColumn } from 'pohon-ui'
const PAvatar = resolveComponent('PAvatar')
interface User {
id: number
name: string
username: string
email: string
avatar: AvatarProps
company: { name: string }
}
const { data, status } = useLazyFetch<Array<User>>('https://jsonplaceholder.typicode.com/users', {
key: 'table-users',
transform: (data) => {
return (
data?.map((user) => ({
...user,
avatar: { src: `https://i.pravatar.cc/120?img=${user.id}`, alt: `${user.name} avatar` }
})) || []
)
},
server: false
})
const columns: Array<TableColumn<User>> = [
{
accessorKey: 'id',
header: 'ID'
},
{
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => {
return h('div', { class: 'flex items-center gap-3' }, [
h(PAvatar, {
...row.original.avatar,
loading: 'lazy',
size: 'lg'
}),
h('div', undefined, [
h('p', { class: 'font-medium color-text-highlighted' }, row.original.name),
h('p', { class: '' }, `@${row.original.username}`)
])
])
}
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'company',
header: 'Company',
cell: ({ row }) => row.original.company.name
}
]
</script>
<template>
<PTable
:data="data"
:columns="columns"
:loading="status === 'pending' || status === 'idle'"
class="flex-1 h-80"
/>
</template>useLazyFetch with server: false to fetch data on the client without blocking the initial render. The loading state checks for both pending and idle status to display a loading indicator before and during the fetch.With infinite scroll
If you use server-side pagination, you can use the useInfiniteScroll composable to load more data as the user scrolls.
| ID | Avatar | First name | Username | |
|---|---|---|---|---|
| No data | ||||
<script setup lang="ts">
import type { TableColumn } from 'pohon-ui'
import { useInfiniteScroll } from '@vueuse/core'
const PAvatar = resolveComponent('PAvatar')
interface User {
id: number
firstName: string
username: string
email: string
image: string
}
interface UserResponse {
users: Array<User>
total: number
skip: number
limit: number
}
const skip = ref(0)
const { data, status } = useLazyFetch(
'https://dummyjson.com/users?limit=10&select=firstName,username,email,image',
{
key: 'table-users-infinite-scroll',
query: { skip },
transform: (data?: UserResponse) => {
return data?.users
},
server: false
}
)
const columns: Array<TableColumn<User>> = [
{
accessorKey: 'id',
header: 'ID'
},
{
accessorKey: 'image',
header: 'Avatar',
cell: ({ row }) => h(PAvatar, { src: row.original.image, loading: 'lazy' })
},
{
accessorKey: 'firstName',
header: 'First name'
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'username',
header: 'Username'
}
]
const users = ref<Array<User>>([])
watch(data, () => {
users.value = [...users.value, ...(data.value || [])]
})
const table = useTemplateRef('table')
onMounted(() => {
useInfiniteScroll(
table.value?.$el,
() => {
skip.value += 10
},
{
distance: 200,
canLoadMore: () => {
return status.value !== 'pending'
}
}
)
})
</script>
<template>
<PTable
ref="table"
:data="users"
:columns="columns"
:loading="status === 'pending' || status === 'idle'"
sticky
class="flex-1 h-80"
/>
</template><script setup lang="ts">
import { ref, watch, onMounted, h, resolveComponent, useTemplateRef } from 'vue'
import type { TableColumn } from 'pohon-ui'
import { useInfiniteScroll } from '@vueuse/core'
const PAvatar = resolveComponent('PAvatar')
interface User {
id: number
firstName: string
username: string
email: string
image: string
}
interface UserResponse {
users: Array<User>
total: number
skip: number
limit: number
}
const skip = ref(0)
const { data, status } = useLazyFetch(
'https://dummyjson.com/users?limit=10&select=firstName,username,email,image',
{
key: 'table-users-infinite-scroll',
query: { skip },
transform: (data?: UserResponse) => {
return data?.users
},
server: false
}
)
const columns: Array<TableColumn<User>> = [
{
accessorKey: 'id',
header: 'ID'
},
{
accessorKey: 'image',
header: 'Avatar',
cell: ({ row }) => h(PAvatar, { src: row.original.image, loading: 'lazy' })
},
{
accessorKey: 'firstName',
header: 'First name'
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'username',
header: 'Username'
}
]
const users = ref<Array<User>>([])
watch(data, () => {
users.value = [...users.value, ...(data.value || [])]
})
const table = useTemplateRef('table')
onMounted(() => {
useInfiniteScroll(
table.value?.$el,
() => {
skip.value += 10
},
{
distance: 200,
canLoadMore: () => {
return status.value !== 'pending'
}
}
)
})
</script>
<template>
<PTable
ref="table"
:data="users"
:columns="columns"
:loading="status === 'pending' || status === 'idle'"
sticky
class="flex-1 h-80"
/>
</template>useLazyFetch with server: false to fetch data on the client without blocking the initial render. The loading state checks for both pending and idle status to display a loading indicator before and during the fetch. Additional pages are loaded as the user scrolls.With drag and drop
You can use the useSortable composable from @vueuse/integrations to enable drag and drop functionality on the Table. This integration wraps Sortable.js to provide a seamless drag and drop experience.
:ui prop to target it with useSortable (e.g. :ui="{ tbody: 'my-table-tbody' }").| # | Date | Amount | |
|---|---|---|---|
| #4600 | Mar 11, 15:30 | james.anderson@example.com | €594.00 |
| #4599 | Mar 11, 10:10 | mia.white@example.com | €276.00 |
| #4598 | Mar 11, 08:50 | william.brown@example.com | €315.00 |
| #4597 | Mar 10, 19:45 | emma.davis@example.com | €529.00 |
<script setup lang="ts">
import type { TableColumn } from 'pohon-ui'
import { useSortable } from '@vueuse/integrations/useSortable'
interface Payment {
id: string
date: string
email: string
amount: number
}
const data = ref<Array<Payment>>([
{
id: '4600',
date: '2024-03-11T15:30:00',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
email: 'emma.davis@example.com',
amount: 529
}
])
const columns: Array<TableColumn<Payment>> = [
{
accessorKey: 'id',
header: '#',
cell: ({ row }) => `#${row.getValue('id')}`
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
}
}
]
useSortable('.my-table-tbody', data, {
animation: 150
})
</script>
<template>
<PTable
ref="table"
:data="data"
:columns="columns"
:ui="{
tbody: 'my-table-tbody'
}"
class="flex-1"
/>
</template><script setup lang="ts">
import { ref } from 'vue'
import type { TableColumn } from 'pohon-ui'
import { useSortable } from '@vueuse/integrations/useSortable'
interface Payment {
id: string
date: string
email: string
amount: number
}
const data = ref<Array<Payment>>([
{
id: '4600',
date: '2024-03-11T15:30:00',
email: 'james.anderson@example.com',
amount: 594
},
{
id: '4599',
date: '2024-03-11T10:10:00',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
email: 'emma.davis@example.com',
amount: 529
}
])
const columns: Array<TableColumn<Payment>> = [
{
accessorKey: 'id',
header: '#',
cell: ({ row }) => `#${row.getValue('id')}`
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
}
}
]
useSortable('.my-table-tbody', data, {
animation: 150
})
</script>
<template>
<PTable
ref="table"
:data="data"
:columns="columns"
:ui="{
tbody: 'my-table-tbody'
}"
class="flex-1"
/>
</template>With virtualization
Use the virtualize prop to enable virtualization for large datasets as a boolean or an object with options like { estimateSize: 65, overscan: 12 }. You can also pass other TanStack Virtual options to customize the virtualization behavior. The sticky prop works in combination with virtualize to keep the header or footer visible while scrolling through large datasets.
| # | Date | Status | Amount | |
|---|---|---|---|---|
<script setup lang="ts">
import type { TableColumn } from 'pohon-ui'
import { h, resolveComponent } from 'vue'
const PBadge = resolveComponent('PBadge')
interface Payment {
id: string
date: string
status: 'paid' | 'failed' | 'refunded'
email: string
amount: number
}
const data = ref<Array<Payment>>(
Array(1000)
.fill(0)
.map((_, i) => ({
id: `4600-${i}`,
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594
}))
)
const columns: Array<TableColumn<Payment>> = [
{
accessorKey: 'id',
header: '#',
cell: ({ row }) => `#${row.getValue('id')}`
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const color = {
paid: 'success' as const,
failed: 'error' as const,
refunded: 'neutral' as const
}[row.getValue('status') as string]
return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
row.getValue('status')
)
}
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
}
}
]
</script>
<template>
<PTable sticky virtualize :data="data" :columns="columns" class="flex-1 h-80" />
</template><script setup lang="ts">
import type { TableColumn } from 'pohon-ui'
import { h, resolveComponent, ref } from 'vue'
const PBadge = resolveComponent('PBadge')
interface Payment {
id: string
date: string
status: 'paid' | 'failed' | 'refunded'
email: string
amount: number
}
const data = ref<Array<Payment>>(
Array(1000)
.fill(0)
.map((_, i) => ({
id: `4600-${i}`,
date: '2024-03-11T15:30:00',
status: 'paid',
email: 'james.anderson@example.com',
amount: 594
}))
)
const columns: Array<TableColumn<Payment>> = [
{
accessorKey: 'id',
header: '#',
cell: ({ row }) => `#${row.getValue('id')}`
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const color = {
paid: 'success' as const,
failed: 'error' as const,
refunded: 'neutral' as const
}[row.getValue('status') as string]
return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
row.getValue('status')
)
}
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
}
}
]
</script>
<template>
<PTable sticky virtualize :data="data" :columns="columns" class="flex-1 h-80" />
</template>class="h-[400px]").estimateSize only needs to be a close guess. Rows can have variable heights, and you can pass a custom measureElement to control how each one is measured (the height of its expanded row is added to it).With external scroll element
Pass a getScrollElement function in the virtualize prop to virtualize against an ancestor scroll container instead of the table's own root. Set scrollMargin to the table's offset from the scroll element's start (e.g. the height of the content above it), so a header and the table body share a single scrollbar.
Payments
The title stays put while the wide table scrolls both axes under one scrollbar.
| # | Date | Customer | Method | Status | Amount | |
|---|---|---|---|---|---|---|
<script setup lang="ts">
import type { TableColumn } from 'pohon-ui'
import { h, resolveComponent } from 'vue'
const PBadge = resolveComponent('PBadge')
interface Payment {
id: string
date: string
customer: string
email: string
method: string
status: 'paid' | 'failed' | 'refunded'
amount: number
}
const names = [
'James Anderson',
'Mary Johnson',
'Robert Williams',
'Patricia Brown',
'Michael Davis'
]
const methods = ['Visa', 'Mastercard', 'PayPal', 'Amex']
const statuses = ['paid', 'failed', 'refunded'] as const
const data = ref<Array<Payment>>(
Array.from({ length: 1000 }, (_, i) => {
const customer = names[i % names.length]!
return {
id: `4600-${i}`,
date: '2024-03-11T15:30:00',
customer,
email: `${customer.toLowerCase().replace(' ', '.')}@example.com`,
method: methods[i % methods.length]!,
status: statuses[i % statuses.length]!,
amount: 100 + ((i * 37) % 900)
}
})
)
// The container owns the scroll; the table virtualizes against it so the header and rows share one scrollbar.
const container = useTemplateRef('container')
const title = useTemplateRef('title')
const getScrollElement = () => container.value
// `scrollMargin` is the table's offset within the scroll element (border-box height of the title above it).
const { height: titleHeight } = useElementSize(title, undefined, { box: 'border-box' })
const columns: Array<TableColumn<Payment>> = [
{
accessorKey: 'id',
header: '#',
cell: ({ row }) => `#${row.getValue('id')}`
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) =>
new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
},
{
accessorKey: 'customer',
header: 'Customer'
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'method',
header: 'Method'
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const color = {
paid: 'success' as const,
failed: 'error' as const,
refunded: 'neutral' as const
}[row.getValue('status') as string]
return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
row.getValue('status')
)
}
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) =>
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(Number.parseFloat(row.getValue('amount')))
}
]
</script>
<template>
<div ref="container" class="h-96 w-full overflow-auto">
<div
ref="title"
class="p-6 bg-background-elevated/50 flex gap-4 items-end left-0 justify-between sticky z-10"
>
<div>
<h2 class="text-2xl color-text-highlighted font-bold">Payments</h2>
<p class="color-text-muted">
The title stays put while the wide table scrolls both axes under one scrollbar.
</p>
</div>
<PBadge color="neutral" variant="subtle" :label="`${data.length} rows`" />
</div>
<PTable
sticky
:data="data"
:columns="columns"
:virtualize="{ scrollMargin: titleHeight, getScrollElement }"
:ui="{ base: 'min-w-[1200px]' }"
/>
</div>
</template><script setup lang="ts">
import { useElementSize } from '@vueuse/core'
import type { TableColumn } from 'pohon-ui'
import { h, resolveComponent, ref, useTemplateRef } from 'vue'
const PBadge = resolveComponent('PBadge')
interface Payment {
id: string
date: string
customer: string
email: string
method: string
status: 'paid' | 'failed' | 'refunded'
amount: number
}
const names = [
'James Anderson',
'Mary Johnson',
'Robert Williams',
'Patricia Brown',
'Michael Davis'
]
const methods = ['Visa', 'Mastercard', 'PayPal', 'Amex']
const statuses = ['paid', 'failed', 'refunded'] as const
const data = ref<Array<Payment>>(
Array.from({ length: 1000 }, (_, i) => {
const customer = names[i % names.length]!
return {
id: `4600-${i}`,
date: '2024-03-11T15:30:00',
customer,
email: `${customer.toLowerCase().replace(' ', '.')}@example.com`,
method: methods[i % methods.length]!,
status: statuses[i % statuses.length]!,
amount: 100 + ((i * 37) % 900)
}
})
)
// The container owns the scroll; the table virtualizes against it so the header and rows share one scrollbar.
const container = useTemplateRef('container')
const title = useTemplateRef('title')
const getScrollElement = () => container.value
// `scrollMargin` is the table's offset within the scroll element (border-box height of the title above it).
const { height: titleHeight } = useElementSize(title, undefined, { box: 'border-box' })
const columns: Array<TableColumn<Payment>> = [
{
accessorKey: 'id',
header: '#',
cell: ({ row }) => `#${row.getValue('id')}`
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) =>
new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
},
{
accessorKey: 'customer',
header: 'Customer'
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'method',
header: 'Method'
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const color = {
paid: 'success' as const,
failed: 'error' as const,
refunded: 'neutral' as const
}[row.getValue('status') as string]
return h(PBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
row.getValue('status')
)
}
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) =>
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(Number.parseFloat(row.getValue('amount')))
}
]
</script>
<template>
<div ref="container" class="h-96 w-full overflow-auto">
<div
ref="title"
class="p-6 bg-background-elevated/50 flex gap-4 items-end left-0 justify-between sticky z-10"
>
<div>
<h2 class="text-2xl color-text-highlighted font-bold">Payments</h2>
<p class="color-text-muted">
The title stays put while the wide table scrolls both axes under one scrollbar.
</p>
</div>
<PBadge color="neutral" variant="subtle" :label="`${data.length} rows`" />
</div>
<PTable
sticky
:data="data"
:columns="columns"
:virtualize="{ scrollMargin: titleHeight, getScrollElement }"
:ui="{ base: 'min-w-[1200px]' }"
/>
</div>
</template>overflow is visible and the external container owns scrolling on both axes, so give it overflow-auto (not just overflow-y-auto) to keep wide tables horizontally scrollable. A sticky header then anchors to that container.With tree data
You can use the get-sub-rows prop to display hierarchical (tree) data in the table.
For example, if your data objects have a children array, set :get-sub-rows="row => row.children" to enable expandable rows.
| # | Date | Amount | ||
|---|---|---|---|---|
4600 | Mar 11, 15:30 | james.anderson@example.com | €594.00 | |
4599 | Mar 11, 10:10 | mia.white@example.com | €276.00 | |
4598 | Mar 11, 08:50 | william.brown@example.com | €315.00 | |
4597 | Mar 10, 19:45 | emma.davis@example.com | €529.00 | |
4589 | Mar 9, 11:35 | isabella.lee@example.com | €389.00 | |
<script setup lang="ts">
import type { TableColumn } from 'pohon-ui'
import { h, resolveComponent } from 'vue'
const PCheckbox = resolveComponent('PCheckbox')
const PButton = resolveComponent('PButton')
interface Payment {
id: string
date: string
email: string
amount: number
children?: Array<Payment>
}
const data = ref<Array<Payment>>([
{
id: '4600',
date: '2024-03-11T15:30:00',
email: 'james.anderson@example.com',
amount: 594,
children: [
{
id: '4599',
date: '2024-03-11T10:10:00',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
email: 'emma.davis@example.com',
amount: 529,
children: [
{
id: '4592',
date: '2024-03-09T18:45:00',
email: 'benjamin.jackson@example.com',
amount: 851
},
{
id: '4591',
date: '2024-03-09T16:05:00',
email: 'sophia.miller@example.com',
amount: 762
},
{
id: '4590',
date: '2024-03-09T14:20:00',
email: 'noah.clark@example.com',
amount: 573,
children: [
{
id: '4596',
date: '2024-03-10T15:55:00',
email: 'ethan.harris@example.com',
amount: 639
},
{
id: '4595',
date: '2024-03-10T13:40:00',
email: 'ava.thomas@example.com',
amount: 428
}
]
}
]
}
]
},
{
id: '4589',
date: '2024-03-09T11:35:00',
email: 'isabella.lee@example.com',
amount: 389
}
])
const columns: Array<TableColumn<Payment>> = [
{
id: 'select',
header: ({ table }) =>
h(PCheckbox, {
modelValue: table.getIsSomePageRowsSelected()
? 'indeterminate'
: table.getIsAllPageRowsSelected(),
'onUpdate:modelValue': (value: boolean | 'indeterminate') =>
table.toggleAllPageRowsSelected(!!value),
'aria-label': 'Select all'
}),
cell: ({ row }) =>
h(PCheckbox, {
modelValue: row.getIsSelected() ? true : row.getIsSomeSelected() ? 'indeterminate' : false,
'onUpdate:modelValue': (value: boolean | 'indeterminate') => row.toggleSelected(!!value),
'aria-label': 'Select row'
})
},
{
accessorKey: 'id',
header: '#',
cell: ({ row }) => {
return h(
'div',
{
style: {
paddingInlineStart: `${row.depth}rem`
},
class: 'flex items-center gap-2'
},
[
h(PButton, {
color: 'neutral',
variant: 'outline',
size: 'xs',
icon: row.getIsExpanded() ? 'i-lucide-minus' : 'i-lucide-plus',
class: !row.getCanExpand() && 'invisible',
ui: {
base: 'p-0 rounded-sm',
leadingIcon: 'size-4'
},
onClick: row.getToggleExpandedHandler()
}),
row.getValue('id') as string
]
)
}
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
}
}
]
const expanded = ref({ 0: true })
</script>
<template>
<PTable
v-model:expanded="expanded"
:data="data"
:columns="columns"
:get-sub-rows="(row) => row.children"
class="flex-1"
:ui="{
base: 'border-separate border-spacing-0',
tbody: '[&>tr]:last:[&>td]:border-b-0',
tr: 'group',
td: 'empty:p-0 group-has-[td:not(:empty)]:border-b border-border'
}"
/>
</template><script setup lang="ts">
import type { TableColumn } from 'pohon-ui'
import { h, resolveComponent, ref } from 'vue'
const PCheckbox = resolveComponent('PCheckbox')
const PButton = resolveComponent('PButton')
interface Payment {
id: string
date: string
email: string
amount: number
children?: Array<Payment>
}
const data = ref<Array<Payment>>([
{
id: '4600',
date: '2024-03-11T15:30:00',
email: 'james.anderson@example.com',
amount: 594,
children: [
{
id: '4599',
date: '2024-03-11T10:10:00',
email: 'mia.white@example.com',
amount: 276
},
{
id: '4598',
date: '2024-03-11T08:50:00',
email: 'william.brown@example.com',
amount: 315
},
{
id: '4597',
date: '2024-03-10T19:45:00',
email: 'emma.davis@example.com',
amount: 529,
children: [
{
id: '4592',
date: '2024-03-09T18:45:00',
email: 'benjamin.jackson@example.com',
amount: 851
},
{
id: '4591',
date: '2024-03-09T16:05:00',
email: 'sophia.miller@example.com',
amount: 762
},
{
id: '4590',
date: '2024-03-09T14:20:00',
email: 'noah.clark@example.com',
amount: 573,
children: [
{
id: '4596',
date: '2024-03-10T15:55:00',
email: 'ethan.harris@example.com',
amount: 639
},
{
id: '4595',
date: '2024-03-10T13:40:00',
email: 'ava.thomas@example.com',
amount: 428
}
]
}
]
}
]
},
{
id: '4589',
date: '2024-03-09T11:35:00',
email: 'isabella.lee@example.com',
amount: 389
}
])
const columns: Array<TableColumn<Payment>> = [
{
id: 'select',
header: ({ table }) =>
h(PCheckbox, {
modelValue: table.getIsSomePageRowsSelected()
? 'indeterminate'
: table.getIsAllPageRowsSelected(),
'onUpdate:modelValue': (value: boolean | 'indeterminate') =>
table.toggleAllPageRowsSelected(!!value),
'aria-label': 'Select all'
}),
cell: ({ row }) =>
h(PCheckbox, {
modelValue: row.getIsSelected() ? true : row.getIsSomeSelected() ? 'indeterminate' : false,
'onUpdate:modelValue': (value: boolean | 'indeterminate') => row.toggleSelected(!!value),
'aria-label': 'Select row'
})
},
{
accessorKey: 'id',
header: '#',
cell: ({ row }) => {
return h(
'div',
{
style: {
paddingInlineStart: `${row.depth}rem`
},
class: 'flex items-center gap-2'
},
[
h(PButton, {
color: 'neutral',
variant: 'outline',
size: 'xs',
icon: row.getIsExpanded() ? 'i-lucide-minus' : 'i-lucide-plus',
class: !row.getCanExpand() && 'invisible',
ui: {
base: 'p-0 rounded-sm',
leadingIcon: 'size-4'
},
onClick: row.getToggleExpandedHandler()
}),
row.getValue('id') as string
]
)
}
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
}
}
]
const expanded = ref({ 0: true })
</script>
<template>
<PTable
v-model:expanded="expanded"
:data="data"
:columns="columns"
:get-sub-rows="(row) => row.children"
class="flex-1"
:ui="{
base: 'border-separate border-spacing-0',
tbody: '[&>tr]:last:[&>td]:border-b-0',
tr: 'group',
td: 'empty:p-0 group-has-[td:not(:empty)]:border-b border-border'
}"
/>
</template>With slots
You can use slots to customize the header and data cells of the table.
Use the #<column>-header slot to customize the header of a column. You will have access to the column, header and table properties in the slot scope.
Use the #<column>-cell slot to customize the cell of a column. You will have access to the cell, column, getValue, renderValue, row, and table properties in the slot scope.
| ID | Name | Role | ||
|---|---|---|---|---|
| 1 | Lindsay Walton Front-end Developer | lindsay.walton@example.com | Member | |
| 2 | Courtney Henry Designer | courtney.henry@example.com | Admin | |
| 3 | Tom Cook Director of Product | tom.cook@example.com | Member | |
| 4 | Whitney Francis Copywriter | whitney.francis@example.com | Admin | |
| 5 | Leonard Krasner Senior Designer | leonard.krasner@example.com | Owner | |
| 6 | Floyd Miles Principal Designer | floyd.miles@example.com | Member |
<script setup lang="ts">
import type { DropdownMenuItem, TableColumn } from 'pohon-ui'
import { useClipboard } from '@vueuse/core'
interface User {
id: number
name: string
position: string
email: string
role: string
}
const toast = useToast()
const { copy } = useClipboard()
const data = ref<Array<User>>([
{
id: 1,
name: 'Lindsay Walton',
position: 'Front-end Developer',
email: 'lindsay.walton@example.com',
role: 'Member'
},
{
id: 2,
name: 'Courtney Henry',
position: 'Designer',
email: 'courtney.henry@example.com',
role: 'Admin'
},
{
id: 3,
name: 'Tom Cook',
position: 'Director of Product',
email: 'tom.cook@example.com',
role: 'Member'
},
{
id: 4,
name: 'Whitney Francis',
position: 'Copywriter',
email: 'whitney.francis@example.com',
role: 'Admin'
},
{
id: 5,
name: 'Leonard Krasner',
position: 'Senior Designer',
email: 'leonard.krasner@example.com',
role: 'Owner'
},
{
id: 6,
name: 'Floyd Miles',
position: 'Principal Designer',
email: 'floyd.miles@example.com',
role: 'Member'
}
])
const columns: Array<TableColumn<User>> = [
{
accessorKey: 'id',
header: 'ID'
},
{
accessorKey: 'name',
header: 'Name'
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'role',
header: 'Role'
},
{
id: 'action'
}
]
function getDropdownActions(user: User): Array<Array<DropdownMenuItem>> {
return [
[
{
label: 'Copy user Id',
icon: 'i-lucide-copy',
onSelect: () => {
copy(user.id.toString())
toast.add({
title: 'User ID copied to clipboard!',
color: 'success',
icon: 'i-lucide-circle-check'
})
}
}
],
[
{
label: 'Edit',
icon: 'i-lucide-edit'
},
{
label: 'Delete',
icon: 'i-lucide-trash',
color: 'error'
}
]
]
}
</script>
<template>
<PTable :data="data" :columns="columns" class="flex-1">
<template #name-cell="{ row }">
<div class="flex gap-3 items-center">
<PAvatar
:src="`https://i.pravatar.cc/120?img=${row.original.id}`"
size="lg"
loading="lazy"
:alt="`${row.original.name} avatar`"
/>
<div>
<p class="color-text-highlighted font-medium">
{{ row.original.name }}
</p>
<p>
{{ row.original.position }}
</p>
</div>
</div>
</template>
<template #action-cell="{ row }">
<PDropdownMenu :items="getDropdownActions(row.original)">
<PButton
icon="i-lucide-ellipsis-vertical"
color="neutral"
variant="ghost"
aria-label="Actions"
/>
</PDropdownMenu>
</template>
</PTable>
</template><script setup lang="ts">
import { ref } from 'vue'
import type { DropdownMenuItem, TableColumn } from 'pohon-ui'
import { useClipboard } from '@vueuse/core'
interface User {
id: number
name: string
position: string
email: string
role: string
}
const toast = useToast()
const { copy } = useClipboard()
const data = ref<Array<User>>([
{
id: 1,
name: 'Lindsay Walton',
position: 'Front-end Developer',
email: 'lindsay.walton@example.com',
role: 'Member'
},
{
id: 2,
name: 'Courtney Henry',
position: 'Designer',
email: 'courtney.henry@example.com',
role: 'Admin'
},
{
id: 3,
name: 'Tom Cook',
position: 'Director of Product',
email: 'tom.cook@example.com',
role: 'Member'
},
{
id: 4,
name: 'Whitney Francis',
position: 'Copywriter',
email: 'whitney.francis@example.com',
role: 'Admin'
},
{
id: 5,
name: 'Leonard Krasner',
position: 'Senior Designer',
email: 'leonard.krasner@example.com',
role: 'Owner'
},
{
id: 6,
name: 'Floyd Miles',
position: 'Principal Designer',
email: 'floyd.miles@example.com',
role: 'Member'
}
])
const columns: Array<TableColumn<User>> = [
{
accessorKey: 'id',
header: 'ID'
},
{
accessorKey: 'name',
header: 'Name'
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'role',
header: 'Role'
},
{
id: 'action'
}
]
function getDropdownActions(user: User): Array<Array<DropdownMenuItem>> {
return [
[
{
label: 'Copy user Id',
icon: 'i-lucide-copy',
onSelect: () => {
copy(user.id.toString())
toast.add({
title: 'User ID copied to clipboard!',
color: 'success',
icon: 'i-lucide-circle-check'
})
}
}
],
[
{
label: 'Edit',
icon: 'i-lucide-edit'
},
{
label: 'Delete',
icon: 'i-lucide-trash',
color: 'error'
}
]
]
}
</script>
<template>
<PTable :data="data" :columns="columns" class="flex-1">
<template #name-cell="{ row }">
<div class="flex gap-3 items-center">
<PAvatar
:src="`https://i.pravatar.cc/120?img=${row.original.id}`"
size="lg"
loading="lazy"
:alt="`${row.original.name} avatar`"
/>
<div>
<p class="color-text-highlighted font-medium">
{{ row.original.name }}
</p>
<p>
{{ row.original.position }}
</p>
</div>
</div>
</template>
<template #action-cell="{ row }">
<PDropdownMenu :items="getDropdownActions(row.original)">
<PButton
icon="i-lucide-ellipsis-vertical"
color="neutral"
variant="ghost"
aria-label="Actions"
/>
</PDropdownMenu>
</template>
</PTable>
</template>API
Props
| Prop | Default | Type |
|---|
Slots
| Slot | Type |
|---|
Expose
You can access the typed component instance using useTemplateRef.
<script setup lang="ts">
const table = useTemplateRef('table');
</script>
<template>
<PTable ref="table" />
</template>
This will give you access to the following:
| Name | Type |
|---|---|
tableRef | Ref<HTMLTableElement | null> |
tableApi | Table |
Theme
export default defineAppConfig({
ui: {
table: {
slots: {
root: 'relative overflow-auto outline-primary/25 focus-visible:outline-3',
base: 'min-w-full overflow-clip',
caption: 'sr-only',
thead: 'relative',
tbody: 'isolate divide-border divide-y [&>tr[data-selectable=true]]:outline-primary/25 [&>tr[data-selectable=true]:focus-visible]:outline-3 [&>tr[data-selectable=true]:hover]:bg-background-elevated/50 [&>tr[data-selectable=true]]:-outline-offset-3',
tfoot: 'relative [&>[data-slot=separator]]:z-2',
tr: 'data-[selected=true]:bg-background-elevated/50',
th: 'px-4 py-3.5 text-sm color-text-highlighted text-start font-600 [&:has([role=checkbox])]:pe-0',
td: 'p-4 text-sm color-text-muted whitespace-nowrap [&:has([role=checkbox])]:pe-0',
separator: 'bg-border-accented h-px w-full start-0 absolute z-1',
empty: 'py-6 text-center text-sm color-text-muted',
loading: 'py-6 text-center'
},
variants: {
pinned: {
true: {
th: 'sticky bg-background/75 z-1',
td: 'sticky bg-background/75 z-1'
}
},
sticky: {
true: {
thead: 'sticky top-0 inset-x-0 bg-background/75 backdrop-blur-sm z-1',
tfoot: 'sticky bottom-0 inset-x-0 bg-background/75 backdrop-blur-sm z-1'
},
header: {
thead: 'sticky top-0 inset-x-0 bg-background/75 backdrop-blur-sm z-1'
},
footer: {
tfoot: 'sticky bottom-0 inset-x-0 bg-background/75 backdrop-blur-sm z-1'
}
},
loading: {
true: {
thead: 'after:h-px after:content-empty after:absolute after:z-1 motion-reduce:after:inset-x-0 motion-reduce:after:animate-pulse'
}
},
externalScroll: {
true: {
root: 'overflow-visible'
}
},
loadingAnimation: {
carousel: '',
'carousel-inverse': '',
swing: '',
elastic: ''
},
loadingColor: {
primary: '',
secondary: '',
success: '',
info: '',
warning: '',
error: '',
neutral: ''
}
},
compoundVariants: [
{
loading: true,
loadingColor: 'primary',
class: {
thead: 'after:bg-primary'
}
},
{
loading: true,
loadingColor: 'neutral',
class: {
thead: 'after:bg-background-inverted'
}
},
{
loading: true,
loadingAnimation: 'carousel',
class: {
thead: 'motion-safe:after:animate-carousel motion-safe:rtl:after:animate-carousel-rtl'
}
},
{
loading: true,
loadingAnimation: 'carousel-inverse',
class: {
thead: 'motion-safe:after:animate-carousel-inverse motion-safe:rtl:after:animate-carousel-inverse-rtl'
}
},
{
loading: true,
loadingAnimation: 'swing',
class: {
thead: 'motion-safe:after:animate-swing'
}
},
{
loading: true,
loadingAnimation: 'elastic',
class: {
thead: 'motion-safe:after:animate-elastic'
}
}
],
defaultVariants: {
loadingColor: 'primary',
loadingAnimation: 'carousel'
}
}
}
})import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import ui from 'pohon-ui/vite'
export default defineConfig({
plugins: [
vue(),
ui({
ui: {
table: {
slots: {
root: 'relative overflow-auto outline-primary/25 focus-visible:outline-3',
base: 'min-w-full overflow-clip',
caption: 'sr-only',
thead: 'relative',
tbody: 'isolate divide-border divide-y [&>tr[data-selectable=true]]:outline-primary/25 [&>tr[data-selectable=true]:focus-visible]:outline-3 [&>tr[data-selectable=true]:hover]:bg-background-elevated/50 [&>tr[data-selectable=true]]:-outline-offset-3',
tfoot: 'relative [&>[data-slot=separator]]:z-2',
tr: 'data-[selected=true]:bg-background-elevated/50',
th: 'px-4 py-3.5 text-sm color-text-highlighted text-start font-600 [&:has([role=checkbox])]:pe-0',
td: 'p-4 text-sm color-text-muted whitespace-nowrap [&:has([role=checkbox])]:pe-0',
separator: 'bg-border-accented h-px w-full start-0 absolute z-1',
empty: 'py-6 text-center text-sm color-text-muted',
loading: 'py-6 text-center'
},
variants: {
pinned: {
true: {
th: 'sticky bg-background/75 z-1',
td: 'sticky bg-background/75 z-1'
}
},
sticky: {
true: {
thead: 'sticky top-0 inset-x-0 bg-background/75 backdrop-blur-sm z-1',
tfoot: 'sticky bottom-0 inset-x-0 bg-background/75 backdrop-blur-sm z-1'
},
header: {
thead: 'sticky top-0 inset-x-0 bg-background/75 backdrop-blur-sm z-1'
},
footer: {
tfoot: 'sticky bottom-0 inset-x-0 bg-background/75 backdrop-blur-sm z-1'
}
},
loading: {
true: {
thead: 'after:h-px after:content-empty after:absolute after:z-1 motion-reduce:after:inset-x-0 motion-reduce:after:animate-pulse'
}
},
externalScroll: {
true: {
root: 'overflow-visible'
}
},
loadingAnimation: {
carousel: '',
'carousel-inverse': '',
swing: '',
elastic: ''
},
loadingColor: {
primary: '',
secondary: '',
success: '',
info: '',
warning: '',
error: '',
neutral: ''
}
},
compoundVariants: [
{
loading: true,
loadingColor: 'primary',
class: {
thead: 'after:bg-primary'
}
},
{
loading: true,
loadingColor: 'neutral',
class: {
thead: 'after:bg-background-inverted'
}
},
{
loading: true,
loadingAnimation: 'carousel',
class: {
thead: 'motion-safe:after:animate-carousel motion-safe:rtl:after:animate-carousel-rtl'
}
},
{
loading: true,
loadingAnimation: 'carousel-inverse',
class: {
thead: 'motion-safe:after:animate-carousel-inverse motion-safe:rtl:after:animate-carousel-inverse-rtl'
}
},
{
loading: true,
loadingAnimation: 'swing',
class: {
thead: 'motion-safe:after:animate-swing'
}
},
{
loading: true,
loadingAnimation: 'elastic',
class: {
thead: 'motion-safe:after:animate-elastic'
}
}
],
defaultVariants: {
loadingColor: 'primary',
loadingAnimation: 'carousel'
}
}
}
})
]
})