Skip to content

Commit f62c5ec

Browse files
committed
feat(Table): add support for context menu
Resolves #4259
1 parent b96a1cc commit f62c5ec

File tree

5 files changed

+259
-54
lines changed

5 files changed

+259
-54
lines changed
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
<script setup lang="ts">
2+
import { h, resolveComponent } from 'vue'
3+
import type { ContextMenuItem, TableColumn, TableRow } from '@nuxt/ui'
4+
import { useClipboard } from '@vueuse/core'
5+
6+
const UBadge = resolveComponent('UBadge')
7+
const UCheckbox = resolveComponent('UCheckbox')
8+
9+
const toast = useToast()
10+
const { copy } = useClipboard()
11+
12+
type Payment = {
13+
id: string
14+
date: string
15+
status: 'paid' | 'failed' | 'refunded'
16+
email: string
17+
amount: number
18+
}
19+
20+
const data = ref<Payment[]>([{
21+
id: '4600',
22+
date: '2024-03-11T15:30:00',
23+
status: 'paid',
24+
email: 'james.anderson@example.com',
25+
amount: 594
26+
}, {
27+
id: '4599',
28+
date: '2024-03-11T10:10:00',
29+
status: 'failed',
30+
email: 'mia.white@example.com',
31+
amount: 276
32+
}, {
33+
id: '4598',
34+
date: '2024-03-11T08:50:00',
35+
status: 'refunded',
36+
email: 'william.brown@example.com',
37+
amount: 315
38+
}, {
39+
id: '4597',
40+
date: '2024-03-10T19:45:00',
41+
status: 'paid',
42+
email: 'emma.davis@example.com',
43+
amount: 529
44+
}, {
45+
id: '4596',
46+
date: '2024-03-10T15:55:00',
47+
status: 'paid',
48+
email: 'ethan.harris@example.com',
49+
amount: 639
50+
}])
51+
52+
const columns: TableColumn<Payment>[] = [{
53+
id: 'select',
54+
header: ({ table }) => h(UCheckbox, {
55+
'modelValue': table.getIsSomePageRowsSelected() ? 'indeterminate' : table.getIsAllPageRowsSelected(),
56+
'onUpdate:modelValue': (value: boolean | 'indeterminate') => table.toggleAllPageRowsSelected(!!value),
57+
'aria-label': 'Select all'
58+
}),
59+
cell: ({ row }) => h(UCheckbox, {
60+
'modelValue': row.getIsSelected(),
61+
'onUpdate:modelValue': (value: boolean | 'indeterminate') => row.toggleSelected(!!value),
62+
'aria-label': 'Select row'
63+
})
64+
}, {
65+
accessorKey: 'id',
66+
header: '#',
67+
cell: ({ row }) => `#${row.getValue('id')}`
68+
}, {
69+
accessorKey: 'date',
70+
header: 'Date',
71+
cell: ({ row }) => {
72+
return new Date(row.getValue('date')).toLocaleString('en-US', {
73+
day: 'numeric',
74+
month: 'short',
75+
hour: '2-digit',
76+
minute: '2-digit',
77+
hour12: false
78+
})
79+
}
80+
}, {
81+
accessorKey: 'status',
82+
header: 'Status',
83+
cell: ({ row }) => {
84+
const color = ({
85+
paid: 'success' as const,
86+
failed: 'error' as const,
87+
refunded: 'neutral' as const
88+
})[row.getValue('status') as string]
89+
90+
return h(UBadge, { class: 'capitalize', variant: 'subtle', color }, () => row.getValue('status'))
91+
}
92+
}, {
93+
accessorKey: 'email',
94+
header: 'Email'
95+
}, {
96+
accessorKey: 'amount',
97+
header: () => h('div', { class: 'text-right' }, 'Amount'),
98+
cell: ({ row }) => {
99+
const amount = Number.parseFloat(row.getValue('amount'))
100+
101+
const formatted = new Intl.NumberFormat('en-US', {
102+
style: 'currency',
103+
currency: 'EUR'
104+
}).format(amount)
105+
106+
return h('div', { class: 'text-right font-medium' }, formatted)
107+
}
108+
}]
109+
110+
const items = ref<ContextMenuItem[]>([])
111+
112+
function getRowItems(row: TableRow<Payment>) {
113+
return [{
114+
type: 'label' as const,
115+
label: 'Actions'
116+
}, {
117+
label: 'Copy payment ID',
118+
onSelect() {
119+
copy(row.original.id)
120+
121+
toast.add({
122+
title: 'Payment ID copied to clipboard!',
123+
color: 'success',
124+
icon: 'i-lucide-circle-check'
125+
})
126+
}
127+
}, {
128+
label: row.getIsExpanded() ? 'Collapse' : 'Expand',
129+
onSelect() {
130+
row.toggleExpanded()
131+
}
132+
}, {
133+
type: 'separator' as const
134+
}, {
135+
label: 'View customer'
136+
}, {
137+
label: 'View payment details'
138+
}]
139+
}
140+
141+
function onContextmenu(_e: Event, row: TableRow<Payment>) {
142+
items.value = getRowItems(row)
143+
}
144+
</script>
145+
146+
<template>
147+
<UContextMenu :items="items">
148+
<UTable
149+
:data="data"
150+
:columns="columns"
151+
class="flex-1"
152+
@contextmenu="onContextmenu"
153+
>
154+
<template #expanded="{ row }">
155+
<pre>{{ row.original }}</pre>
156+
</template>
157+
</UTable>
158+
</UContextMenu>
159+
</template>

‎docs/app/components/content/examples/table/TableRowSelectionEventExample.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ function onSelect(row: TableRow<Payment>, e?: Event) {
112112
</script>
113113

114114
<template>
115-
<div class=" flex w-full flex-1 gap-1">
115+
<div class="flex w-full flex-1 gap-1">
116116
<div class="flex-1">
117117
<UTable
118118
ref="table"

‎docs/content/3.components/table.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,22 @@ class: '!p-0'
324324
---
325325
::
326326

327+
### With context menu :badge{label="Soon" class="align-text-top"}
328+
329+
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.
330+
331+
::component-example
332+
---
333+
prettier: true
334+
collapse: true
335+
name: 'table-context-menu-example'
336+
highlights:
337+
- 130
338+
- 170
339+
class: '!p-0'
340+
---
341+
::
342+
327343
### With column sorting
328344

329345
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).

‎playground/app/pages/components/table.vue

Lines changed: 65 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,35 @@ const data = ref<Payment[]>([{
147147
148148
const currentID = ref(4601)
149149
150+
function getRowItems(row: TableRow<Payment>) {
151+
return [{
152+
type: 'label' as const,
153+
label: 'Actions'
154+
}, {
155+
label: 'Copy payment ID',
156+
onSelect() {
157+
copy(row.original.id)
158+
159+
toast.add({
160+
title: 'Payment ID copied to clipboard!',
161+
color: 'success',
162+
icon: 'i-lucide-circle-check'
163+
})
164+
}
165+
}, {
166+
label: row.getIsExpanded() ? 'Collapse' : 'Expand',
167+
onSelect() {
168+
row.toggleExpanded()
169+
}
170+
}, {
171+
type: 'separator' as const
172+
}, {
173+
label: 'View customer'
174+
}, {
175+
label: 'View payment details'
176+
}]
177+
}
178+
150179
const columns: TableColumn<Payment>[] = [{
151180
id: 'select',
152181
header: ({ table }) => h(UCheckbox, {
@@ -227,38 +256,11 @@ const columns: TableColumn<Payment>[] = [{
227256
id: 'actions',
228257
enableHiding: false,
229258
cell: ({ row }) => {
230-
const items = [{
231-
type: 'label',
232-
label: 'Actions'
233-
}, {
234-
label: 'Copy payment ID',
235-
onSelect() {
236-
copy(row.original.id)
237-
238-
toast.add({
239-
title: 'Payment ID copied to clipboard!',
240-
color: 'success',
241-
icon: 'i-lucide-circle-check'
242-
})
243-
}
244-
}, {
245-
label: row.getIsExpanded() ? 'Collapse' : 'Expand',
246-
onSelect() {
247-
row.toggleExpanded()
248-
}
249-
}, {
250-
type: 'separator'
251-
}, {
252-
label: 'View customer'
253-
}, {
254-
label: 'View payment details'
255-
}]
256-
257259
return h('div', { class: 'text-right' }, h(UDropdownMenu, {
258260
'content': {
259261
align: 'end'
260262
},
261-
items,
263+
'items': getRowItems(row),
262264
'aria-label': 'Actions dropdown'
263265
}, () => h(UButton, {
264266
'icon': 'i-lucide-ellipsis-vertical',
@@ -296,8 +298,17 @@ function randomize() {
296298
data.value = data.value.sort(() => Math.random() - 0.5)
297299
}
298300
301+
const rowSelection = ref<Record<string, boolean>>({})
302+
299303
function onSelect(row: TableRow<Payment>) {
300-
console.log(row)
304+
row.toggleSelected(!row.getIsSelected())
305+
}
306+
307+
const contextmenuRow = ref<TableRow<Payment> | null>(null)
308+
const contextmenuItems = computed(() => contextmenuRow.value ? getRowItems(contextmenuRow.value) : [])
309+
310+
function onContextmenu(e: Event, row: TableRow<Payment>) {
311+
contextmenuRow.value = row
301312
}
302313
303314
onMounted(() => {
@@ -344,27 +355,31 @@ onMounted(() => {
344355
</UDropdownMenu>
345356
</div>
346357

347-
<UTable
348-
ref="table"
349-
:data="data"
350-
:columns="columns"
351-
:column-pinning="columnPinning"
352-
:loading="loading"
353-
:pagination="pagination"
354-
:pagination-options="{
355-
getPaginationRowModel: getPaginationRowModel()
356-
}"
357-
:ui="{
358-
tr: 'divide-x divide-default'
359-
}"
360-
sticky
361-
class="border border-accented rounded-sm"
362-
@select="onSelect"
363-
>
364-
<template #expanded="{ row }">
365-
<pre>{{ row.original }}</pre>
366-
</template>
367-
</UTable>
358+
<UContextMenu :items="contextmenuItems">
359+
<UTable
360+
ref="table"
361+
:data="data"
362+
:columns="columns"
363+
:column-pinning="columnPinning"
364+
:row-selection="rowSelection"
365+
:loading="loading"
366+
:pagination="pagination"
367+
:pagination-options="{
368+
getPaginationRowModel: getPaginationRowModel()
369+
}"
370+
:ui="{
371+
tr: 'divide-x divide-default'
372+
}"
373+
sticky
374+
class="border border-accented rounded-sm"
375+
@select="onSelect"
376+
@contextmenu="onContextmenu"
377+
>
378+
<template #expanded="{ row }">
379+
<pre>{{ row.original }}</pre>
380+
</template>
381+
</UTable>
382+
</UContextMenu>
368383

369384
<div class="flex items-center justify-between gap-3">
370385
<div class="text-sm text-muted">

‎src/runtime/components/Table.vue

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ export interface TableProps<T extends TableData = TableData> extends TableOption
165165
*/
166166
facetedOptions?: FacetedOptions<T>
167167
onSelect?: (row: TableRow<T>, e?: Event) => void
168+
onContextmenu?: ((e: Event, row: TableRow<T>) => void) | Array<((e: Event, row: TableRow<T>) => void)>
168169
class?: any
169170
ui?: Table['slots']
170171
}
@@ -313,7 +314,7 @@ function valueUpdater<T extends Updater<any>>(updaterOrValue: T, ref: Ref) {
313314
ref.value = typeof updaterOrValue === 'function' ? updaterOrValue(ref.value) : updaterOrValue
314315
}
315316
316-
function handleRowSelect(row: TableRow<T>, e: Event) {
317+
function onRowSelect(e: Event, row: TableRow<T>) {
317318
if (!props.onSelect) {
318319
return
319320
}
@@ -326,9 +327,22 @@ function handleRowSelect(row: TableRow<T>, e: Event) {
326327
e.preventDefault()
327328
e.stopPropagation()
328329
330+
// FIXME: `e` should be the first argument for consistency
329331
props.onSelect(row, e)
330332
}
331333
334+
function onRowContextmenu(e: Event, row: TableRow<T>) {
335+
if (!props.onContextmenu) {
336+
return
337+
}
338+
339+
if (Array.isArray(props.onContextmenu)) {
340+
props.onContextmenu.forEach(fn => fn(e, row))
341+
} else {
342+
props.onContextmenu(e, row)
343+
}
344+
}
345+
332346
watch(
333347
() => props.data, () => {
334348
data.value = props.data ? [...props.data] : []
@@ -382,7 +396,7 @@ defineExpose({
382396
<template v-for="row in tableApi.getRowModel().rows" :key="row.id">
383397
<tr
384398
:data-selected="row.getIsSelected()"
385-
:data-selectable="!!props.onSelect"
399+
:data-selectable="!!props.onSelect || !!props.onContextmenu"
386400
:data-expanded="row.getIsExpanded()"
387401
:role="props.onSelect ? 'button' : undefined"
388402
:tabindex="props.onSelect ? 0 : undefined"
@@ -392,7 +406,8 @@ defineExpose({
392406
typeof tableApi.options.meta?.class?.tr === 'function' ? tableApi.options.meta.class.tr(row) : tableApi.options.meta?.class?.tr
393407
]
394408
})"
395-
@click="handleRowSelect(row, $event)"
409+
@click="onRowSelect($event, row)"
410+
@contextmenu="onRowContextmenu($event, row)"
396411
>
397412
<td
398413
v-for="cell in row.getVisibleCells()"

0 commit comments

Comments
 (0)