Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
<script setup lang="ts">
import { h, resolveComponent } from 'vue'
import type { TableColumn } from '@nuxt/ui'
import type { RowPinningState } from '@tanstack/table-core'

const UButton = resolveComponent('UButton')
const UBadge = resolveComponent('UBadge')

type Payment = {
id: string
date: string
status: 'paid' | 'failed' | 'refunded'
email: string
amount: number
}

const data = ref<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: TableColumn<Payment>[] = [{
id: 'pin',
cell: ({ row }) => h(UButton, {
'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(UBadge, { 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>
<UTable
v-model:row-pinning="rowPinning"
:data="data"
:columns="columns"
:get-row-id="(row: Payment) => row.id"
class="flex-1 h-96"
/>
Comment thread
benjamincanac marked this conversation as resolved.
</template>
40 changes: 32 additions & 8 deletions docs/content/docs/2.components/table.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ class: '!p-0'
::

::tip
You can use the `expanded` prop to control the expandable state of the rows (can be binded with `v-model`).
You can use the `expanded` prop to control the expandable state of the rows (can be bound with `v-model`).
::

::note
Expand Down Expand Up @@ -314,6 +314,30 @@ class: '!p-0'
---
::

### With row pinning :badge{label="Soon" class="align-text-top"}
Comment thread
benjamincanac marked this conversation as resolved.

You can add a column that renders a [Button](/docs/components/button) component inside the `cell` to toggle the pinning state of a row using the TanStack Table [Row Pinning APIs](https://tanstack.com/table/latest/docs/api/features/row-pinning). Pinned rows will stay at the top or bottom of the table regardless of sorting or filtering.

::component-example
---
prettier: true
collapse: true
name: 'table-row-pinning-example'
overflowHidden: true
highlights:
- 91
- 107
- 160
- 165
- 168
class: '!p-0'
---
::

::tip
You can use the `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](/docs/components/checkbox) component inside the `header` and `cell` to select rows using the TanStack Table [Row Selection APIs](https://tanstack.com/table/latest/docs/api/features/row-selection).
Expand All @@ -331,7 +355,7 @@ class: '!p-0'
::

::tip
You can use the `row-selection` prop to control the selection state of the rows (can be binded with `v-model`).
You can use the `row-selection` prop to control the selection state of the rows (can be bound with `v-model`).
::

### With row select event
Expand Down Expand Up @@ -452,7 +476,7 @@ class: '!p-0'
::

::tip
You can use the `sorting` prop to control the sorting state of the columns (can be binded with `v-model`).
You can use the `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.
Expand Down Expand Up @@ -495,7 +519,7 @@ class: '!p-0 overflow-clip'
::

::tip
You can use the `column-pinning` prop to control the pinning state of the columns (can be binded with `v-model`).
You can use the `column-pinning` prop to control the pinning state of the columns (can be bound with `v-model`).
::

### With column visibility
Expand All @@ -515,7 +539,7 @@ class: '!p-0'
::

::tip
You can use the `column-visibility` prop to control the visibility state of the columns (can be binded with `v-model`).
You can use the `column-visibility` prop to control the visibility state of the columns (can be bound with `v-model`).
::

### With column filters
Expand All @@ -535,7 +559,7 @@ class: '!p-0'
::

::tip
You can use the `column-filters` prop to control the filters state of the columns (can be binded with `v-model`).
You can use the `column-filters` prop to control the filters state of the columns (can be bound with `v-model`).
::

### With global filter
Expand All @@ -554,7 +578,7 @@ highlights:
::

::tip
You can use the `global-filter` prop to control the global filter state (can be binded with `v-model`).
You can use the `global-filter` prop to control the global filter state (can be bound with `v-model`).
::

### With pagination
Expand All @@ -576,7 +600,7 @@ highlights:
::

::tip
You can use the `pagination` prop to control the pagination state (can be binded with `v-model`).
You can use the `pagination` prop to control the pagination state (can be bound with `v-model`).
::

### With fetched data
Expand Down
24 changes: 20 additions & 4 deletions playgrounds/nuxt/app/pages/components/table.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { h, resolveComponent } from 'vue'
import { upperFirst } from 'scule'
import type { TableColumn, TableRow } from '@nuxt/ui'
import type { Column } from '@tanstack/vue-table'
import type { Column, RowPinningState } from '@tanstack/vue-table'
import { getPaginationRowModel } from '@tanstack/vue-table'
import { useClipboard, refDebounced } from '@vueuse/core'

Expand Down Expand Up @@ -80,7 +80,21 @@ function getRowItems(row: TableRow<Payment>) {
}]
}

const rowPinning = ref<RowPinningState>({ top: [], bottom: [] })

const columns: TableColumn<Payment>[] = [{
id: 'pin',
cell: ({ row }) => h(UButton, {
'icon': row.getIsPinned() ? 'i-lucide-pin-off' : 'i-lucide-pin',
'color': row.getIsPinned() ? 'primary' : 'neutral',
'variant': 'ghost',
'aria-label': row.getIsPinned() ? 'Unpin row' : 'Pin row to top',
'onClick': () => row.pin(row.getIsPinned() ? false : 'top')
}),
enableSorting: false,
enableHiding: false,
size: 64
}, {
id: 'select',
header: ({ table }) => h(UCheckbox, {
'modelValue': table.getIsSomePageRowsSelected() ? 'indeterminate' : table.getIsAllPageRowsSelected(),
Expand Down Expand Up @@ -115,7 +129,8 @@ const columns: TableColumn<Payment>[] = [{
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
hour12: false,
timeZone: 'UTC'
})
}
}, {
Expand Down Expand Up @@ -235,7 +250,7 @@ function getPinnedHeader(column: Column<Payment>, label: string, position: 'left

const loading = ref(true)
const columnPinning = ref({
left: ['select'],
left: ['pin', 'select'],
right: ['actions']
})

Expand Down Expand Up @@ -335,11 +350,12 @@ onMounted(() => {
</UDropdownMenu>
</Navbar>

<div class="flex flex-col gap-4 w-full h-full">
<div class="flex flex-col flex-1 gap-4 w-full max-h-[calc(100vh-7rem)]">
<UContextMenu :items="contextmenuItems">
<UTable
ref="table"
:key="String(virtualize)"
v-model:row-pinning="rowPinning"
:data="data"
:columns="columns"
:column-pinning="columnPinning"
Expand Down
18 changes: 13 additions & 5 deletions src/runtime/components/Table.vue
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export interface TableProps<T extends TableData = TableData> extends TableOption
meta?: TableMeta<T>
/**
* Enable virtualization for large datasets.
* Note: when enabled, the divider between rows and sticky properties are not supported.
* Note: when enabled, the divider between rows, sticky and row pinning properties are not supported.
* @see https://tanstack.com/virtual/latest/docs/api/virtualizer#options
* @defaultValue false
*/
Expand Down Expand Up @@ -416,6 +416,9 @@ const tableApi = useVueTable({
})

const rows = computed(() => tableApi.getRowModel().rows)
const topRows = computed(() => props.virtualize ? [] : tableApi.getTopRows())
const bottomRows = computed(() => props.virtualize ? [] : tableApi.getBottomRows())
const centerRows = computed(() => topRows.value.length || bottomRows.value.length ? tableApi.getCenterRows() : rows.value)

const virtualizerProps = toRef(() => defu(typeof props.virtualize === 'boolean' ? {} : props.virtualize, {
estimateSize: 65,
Expand All @@ -425,7 +428,7 @@ const virtualizerProps = toRef(() => defu(typeof props.virtualize === 'boolean'
const virtualizer = !!props.virtualize && useVirtualizer({
...virtualizerProps.value,
get count() {
return rows.value.length
return centerRows.value.length
},
getScrollElement: () => rootRef.value?.$el,
estimateSize: (index: number) => {
Expand Down Expand Up @@ -528,6 +531,7 @@ defineExpose({
:data-selected="row.getIsSelected()"
:data-selectable="!!props.onSelect || !!props.onHover || !!props.onContextmenu"
:data-expanded="row.getIsExpanded()"
:data-pinned="row.getIsPinned() || undefined"
:role="props.onSelect ? 'button' : undefined"
:tabindex="props.onSelect ? 0 : undefined"
data-slot="tr"
Expand Down Expand Up @@ -618,10 +622,12 @@ defineExpose({
<slot name="body-top" />

<template v-if="rows.length">
<ReuseRowTemplate v-for="row in topRows" :key="row.id" :row="row" />

<template v-if="virtualizer">
<template v-for="(virtualRow, index) in virtualizer.getVirtualItems()" :key="rows[virtualRow.index]?.id">
<template v-for="(virtualRow, index) in virtualizer.getVirtualItems()" :key="centerRows[virtualRow.index]?.id">
<ReuseRowTemplate
:row="rows[virtualRow.index]!"
:row="centerRows[virtualRow.index]!"
:style="{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start - index * virtualRow.size}px)`
Expand All @@ -631,8 +637,10 @@ defineExpose({
</template>

<template v-else>
<ReuseRowTemplate v-for="row in rows" :key="row.id" :row="row" />
<ReuseRowTemplate v-for="row in centerRows" :key="row.id" :row="row" />
</template>

<ReuseRowTemplate v-for="row in bottomRows" :key="row.id" :row="row" />
</template>

<tr v-else-if="loading && !!slots['loading']">
Expand Down
2 changes: 2 additions & 0 deletions test/components/Table.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,8 @@ describe('Table', () => {
['with meta prop', { props: { ...props, meta: { class: { tr: 'custom-row-class' }, style: { tr: { backgroundColor: 'lightgray' } } } } }],
['with meta field on columns', { props: { ...props, columns: columns.map(c => ({ ...c, meta: { class: { th: 'custom-heading-class', td: 'custom-cell-class' }, style: { th: { backgroundColor: 'black' }, td: { backgroundColor: 'lightgray' } } } })) } }],
['with virtualize', { props: { ...props, virtualize: true } }],
['with row pinning', { props: { ...props, rowPinning: { top: ['2'], bottom: ['3'] } } }],
['with row pinning and virtualization', { props: { ...props, virtualize: true, rowPinning: { top: ['2'], bottom: ['3'] } } }],
['with as', { props: { ...props, as: 'section' } }],
['with class', { props: { ...props, class: 'absolute' } }],
['with ui', { props: { ...props, ui: { base: 'table-auto' } } }],
Expand Down
Loading
Loading