feat(Table): add support for context menu

Resolves #4259
This commit is contained in:
Benjamin Canac
2025-07-01 13:15:00 +02:00
parent b96a1ccbab
commit f62c5ec20c
5 changed files with 259 additions and 54 deletions

View File

@@ -0,0 +1,159 @@
<script setup lang="ts">
import { h, resolveComponent } from 'vue'
import type { ContextMenuItem, TableColumn, TableRow } from '@nuxt/ui'
import { useClipboard } from '@vueuse/core'
const UBadge = resolveComponent('UBadge')
const UCheckbox = resolveComponent('UCheckbox')
const toast = useToast()
const { copy } = useClipboard()
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
}])
const columns: TableColumn<Payment>[] = [{
id: 'select',
header: ({ table }) => h(UCheckbox, {
'modelValue': table.getIsSomePageRowsSelected() ? 'indeterminate' : table.getIsAllPageRowsSelected(),
'onUpdate:modelValue': (value: boolean | 'indeterminate') => table.toggleAllPageRowsSelected(!!value),
'aria-label': 'Select all'
}),
cell: ({ row }) => h(UCheckbox, {
'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(UBadge, { class: 'capitalize', variant: 'subtle', color }, () => row.getValue('status'))
}
}, {
accessorKey: 'email',
header: 'Email'
}, {
accessorKey: 'amount',
header: () => h('div', { class: 'text-right' }, 'Amount'),
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
const formatted = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
return h('div', { class: 'text-right font-medium' }, formatted)
}
}]
const items = ref<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>
<UContextMenu :items="items">
<UTable
:data="data"
:columns="columns"
class="flex-1"
@contextmenu="onContextmenu"
>
<template #expanded="{ row }">
<pre>{{ row.original }}</pre>
</template>
</UTable>
</UContextMenu>
</template>

View File

@@ -112,7 +112,7 @@ function onSelect(row: TableRow<Payment>, e?: Event) {
</script> </script>
<template> <template>
<div class=" flex w-full flex-1 gap-1"> <div class="flex w-full flex-1 gap-1">
<div class="flex-1"> <div class="flex-1">
<UTable <UTable
ref="table" ref="table"

View File

@@ -324,6 +324,22 @@ class: '!p-0'
--- ---
:: ::
### With context menu :badge{label="Soon" class="align-text-top"}
You can wrap the `UTable` component in a [ContextMenu](/components/context-menu) component to make rows right clickable. You also need to add a `@contextmenu` listener to the `UTable` component to determine wich row is being right clicked. The handler function receives the `Event` and `TableRow` instance as the first and second arguments respectively.
::component-example
---
prettier: true
collapse: true
name: 'table-context-menu-example'
highlights:
- 130
- 170
class: '!p-0'
---
::
### With column sorting ### With column sorting
You can update a column `header` to render a [Button](/components/button) component inside the `header` to toggle the sorting state using the TanStack Table [Sorting APIs](https://tanstack.com/table/latest/docs/api/features/sorting). You can update a column `header` to render a [Button](/components/button) component inside the `header` to toggle the sorting state using the TanStack Table [Sorting APIs](https://tanstack.com/table/latest/docs/api/features/sorting).

View File

@@ -147,6 +147,35 @@ const data = ref<Payment[]>([{
const currentID = ref(4601) const currentID = ref(4601)
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'
}]
}
const columns: TableColumn<Payment>[] = [{ const columns: TableColumn<Payment>[] = [{
id: 'select', id: 'select',
header: ({ table }) => h(UCheckbox, { header: ({ table }) => h(UCheckbox, {
@@ -227,38 +256,11 @@ const columns: TableColumn<Payment>[] = [{
id: 'actions', id: 'actions',
enableHiding: false, enableHiding: false,
cell: ({ row }) => { cell: ({ row }) => {
const items = [{
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'
})
}
}, {
label: row.getIsExpanded() ? 'Collapse' : 'Expand',
onSelect() {
row.toggleExpanded()
}
}, {
type: 'separator'
}, {
label: 'View customer'
}, {
label: 'View payment details'
}]
return h('div', { class: 'text-right' }, h(UDropdownMenu, { return h('div', { class: 'text-right' }, h(UDropdownMenu, {
'content': { 'content': {
align: 'end' align: 'end'
}, },
items, 'items': getRowItems(row),
'aria-label': 'Actions dropdown' 'aria-label': 'Actions dropdown'
}, () => h(UButton, { }, () => h(UButton, {
'icon': 'i-lucide-ellipsis-vertical', 'icon': 'i-lucide-ellipsis-vertical',
@@ -296,8 +298,17 @@ function randomize() {
data.value = data.value.sort(() => Math.random() - 0.5) data.value = data.value.sort(() => Math.random() - 0.5)
} }
const rowSelection = ref<Record<string, boolean>>({})
function onSelect(row: TableRow<Payment>) { function onSelect(row: TableRow<Payment>) {
console.log(row) row.toggleSelected(!row.getIsSelected())
}
const contextmenuRow = ref<TableRow<Payment> | null>(null)
const contextmenuItems = computed(() => contextmenuRow.value ? getRowItems(contextmenuRow.value) : [])
function onContextmenu(e: Event, row: TableRow<Payment>) {
contextmenuRow.value = row
} }
onMounted(() => { onMounted(() => {
@@ -344,27 +355,31 @@ onMounted(() => {
</UDropdownMenu> </UDropdownMenu>
</div> </div>
<UTable <UContextMenu :items="contextmenuItems">
ref="table" <UTable
:data="data" ref="table"
:columns="columns" :data="data"
:column-pinning="columnPinning" :columns="columns"
:loading="loading" :column-pinning="columnPinning"
:pagination="pagination" :row-selection="rowSelection"
:pagination-options="{ :loading="loading"
getPaginationRowModel: getPaginationRowModel() :pagination="pagination"
}" :pagination-options="{
:ui="{ getPaginationRowModel: getPaginationRowModel()
tr: 'divide-x divide-default' }"
}" :ui="{
sticky tr: 'divide-x divide-default'
class="border border-accented rounded-sm" }"
@select="onSelect" sticky
> class="border border-accented rounded-sm"
<template #expanded="{ row }"> @select="onSelect"
<pre>{{ row.original }}</pre> @contextmenu="onContextmenu"
</template> >
</UTable> <template #expanded="{ row }">
<pre>{{ row.original }}</pre>
</template>
</UTable>
</UContextMenu>
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<div class="text-sm text-muted"> <div class="text-sm text-muted">

View File

@@ -165,6 +165,7 @@ export interface TableProps<T extends TableData = TableData> extends TableOption
*/ */
facetedOptions?: FacetedOptions<T> facetedOptions?: FacetedOptions<T>
onSelect?: (row: TableRow<T>, e?: Event) => void onSelect?: (row: TableRow<T>, e?: Event) => void
onContextmenu?: ((e: Event, row: TableRow<T>) => void) | Array<((e: Event, row: TableRow<T>) => void)>
class?: any class?: any
ui?: Table['slots'] ui?: Table['slots']
} }
@@ -313,7 +314,7 @@ function valueUpdater<T extends Updater<any>>(updaterOrValue: T, ref: Ref) {
ref.value = typeof updaterOrValue === 'function' ? updaterOrValue(ref.value) : updaterOrValue ref.value = typeof updaterOrValue === 'function' ? updaterOrValue(ref.value) : updaterOrValue
} }
function handleRowSelect(row: TableRow<T>, e: Event) { function onRowSelect(e: Event, row: TableRow<T>) {
if (!props.onSelect) { if (!props.onSelect) {
return return
} }
@@ -326,9 +327,22 @@ function handleRowSelect(row: TableRow<T>, e: Event) {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
// FIXME: `e` should be the first argument for consistency
props.onSelect(row, e) props.onSelect(row, e)
} }
function onRowContextmenu(e: Event, row: TableRow<T>) {
if (!props.onContextmenu) {
return
}
if (Array.isArray(props.onContextmenu)) {
props.onContextmenu.forEach(fn => fn(e, row))
} else {
props.onContextmenu(e, row)
}
}
watch( watch(
() => props.data, () => { () => props.data, () => {
data.value = props.data ? [...props.data] : [] data.value = props.data ? [...props.data] : []
@@ -382,7 +396,7 @@ defineExpose({
<template v-for="row in tableApi.getRowModel().rows" :key="row.id"> <template v-for="row in tableApi.getRowModel().rows" :key="row.id">
<tr <tr
:data-selected="row.getIsSelected()" :data-selected="row.getIsSelected()"
:data-selectable="!!props.onSelect" :data-selectable="!!props.onSelect || !!props.onContextmenu"
:data-expanded="row.getIsExpanded()" :data-expanded="row.getIsExpanded()"
:role="props.onSelect ? 'button' : undefined" :role="props.onSelect ? 'button' : undefined"
:tabindex="props.onSelect ? 0 : undefined" :tabindex="props.onSelect ? 0 : undefined"
@@ -392,7 +406,8 @@ defineExpose({
typeof tableApi.options.meta?.class?.tr === 'function' ? tableApi.options.meta.class.tr(row) : tableApi.options.meta?.class?.tr typeof tableApi.options.meta?.class?.tr === 'function' ? tableApi.options.meta.class.tr(row) : tableApi.options.meta?.class?.tr
] ]
})" })"
@click="handleRowSelect(row, $event)" @click="onRowSelect($event, row)"
@contextmenu="onRowContextmenu($event, row)"
> >
<td <td
v-for="cell in row.getVisibleCells()" v-for="cell in row.getVisibleCells()"