Skip to content

Commit f903ec3

Browse files
committed
feat(Table): add row hover event
Resolves #2435
1 parent b00e07f commit f903ec3

File tree

6 files changed

+248
-11
lines changed

6 files changed

+248
-11
lines changed
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
<script setup lang="ts">
2+
import { h, resolveComponent } from 'vue'
3+
import type { TableColumn, TableRow } from '@nuxt/ui'
4+
5+
const UBadge = resolveComponent('UBadge')
6+
const UCheckbox = resolveComponent('UCheckbox')
7+
8+
type Payment = {
9+
id: string
10+
date: string
11+
status: 'paid' | 'failed' | 'refunded'
12+
email: string
13+
amount: number
14+
}
15+
16+
const data = ref<Payment[]>([{
17+
id: '4600',
18+
date: '2024-03-11T15:30:00',
19+
status: 'paid',
20+
21+
amount: 594
22+
}, {
23+
id: '4599',
24+
date: '2024-03-11T10:10:00',
25+
status: 'failed',
26+
27+
amount: 276
28+
}, {
29+
id: '4598',
30+
date: '2024-03-11T08:50:00',
31+
status: 'refunded',
32+
33+
amount: 315
34+
}, {
35+
id: '4597',
36+
date: '2024-03-10T19:45:00',
37+
status: 'paid',
38+
39+
amount: 529
40+
}, {
41+
id: '4596',
42+
date: '2024-03-10T15:55:00',
43+
status: 'paid',
44+
45+
amount: 639
46+
}])
47+
48+
const columns: TableColumn<Payment>[] = [{
49+
id: 'select',
50+
header: ({ table }) => h(UCheckbox, {
51+
'modelValue': table.getIsSomePageRowsSelected() ? 'indeterminate' : table.getIsAllPageRowsSelected(),
52+
'onUpdate:modelValue': (value: boolean | 'indeterminate') => table.toggleAllPageRowsSelected(!!value),
53+
'aria-label': 'Select all'
54+
}),
55+
cell: ({ row }) => h(UCheckbox, {
56+
'modelValue': row.getIsSelected(),
57+
'onUpdate:modelValue': (value: boolean | 'indeterminate') => row.toggleSelected(!!value),
58+
'aria-label': 'Select row'
59+
})
60+
}, {
61+
accessorKey: 'id',
62+
header: '#',
63+
cell: ({ row }) => `#${row.getValue('id')}`
64+
}, {
65+
accessorKey: 'date',
66+
header: 'Date',
67+
cell: ({ row }) => {
68+
return new Date(row.getValue('date')).toLocaleString('en-US', {
69+
day: 'numeric',
70+
month: 'short',
71+
hour: '2-digit',
72+
minute: '2-digit',
73+
hour12: false
74+
})
75+
}
76+
}, {
77+
accessorKey: 'status',
78+
header: 'Status',
79+
cell: ({ row }) => {
80+
const color = ({
81+
paid: 'success' as const,
82+
failed: 'error' as const,
83+
refunded: 'neutral' as const
84+
})[row.getValue('status') as string]
85+
86+
return h(UBadge, { class: 'capitalize', variant: 'subtle', color }, () => row.getValue('status'))
87+
}
88+
}, {
89+
accessorKey: 'email',
90+
header: 'Email'
91+
}, {
92+
accessorKey: 'amount',
93+
header: () => h('div', { class: 'text-right' }, 'Amount'),
94+
cell: ({ row }) => {
95+
const amount = Number.parseFloat(row.getValue('amount'))
96+
97+
const formatted = new Intl.NumberFormat('en-US', {
98+
style: 'currency',
99+
currency: 'EUR'
100+
}).format(amount)
101+
102+
return h('div', { class: 'text-right font-medium' }, formatted)
103+
}
104+
}]
105+
106+
const anchor = ref({ x: 0, y: 0 })
107+
108+
const reference = computed(() => ({
109+
getBoundingClientRect: () =>
110+
({
111+
width: 0,
112+
height: 0,
113+
left: anchor.value.x,
114+
right: anchor.value.x,
115+
top: anchor.value.y,
116+
bottom: anchor.value.y,
117+
...anchor.value
118+
} as DOMRect)
119+
}))
120+
121+
const open = ref(false)
122+
const openDebounced = refDebounced(open, 10)
123+
const selectedRow = ref<TableRow<Payment> | null>(null)
124+
125+
function onHover(_e: Event, row: TableRow<Payment> | null) {
126+
selectedRow.value = row
127+
128+
open.value = !!row
129+
}
130+
</script>
131+
132+
<template>
133+
<div class="flex w-full flex-1 gap-1">
134+
<UTable
135+
:data="data"
136+
:columns="columns"
137+
class="flex-1"
138+
@pointermove="(ev: PointerEvent) => {
139+
anchor.x = ev.clientX
140+
anchor.y = ev.clientY
141+
}"
142+
@hover="onHover"
143+
/>
144+
145+
<UPopover
146+
:content="{ side: 'top', sideOffset: 16, updatePositionStrategy: 'always' }"
147+
:open="openDebounced"
148+
:reference="reference"
149+
>
150+
<template #content>
151+
<div class="p-4">
152+
{{ selectedRow?.original?.id }}
153+
</div>
154+
</template>
155+
</UPopover>
156+
</div>
157+
</template>

docs/content/3.components/table.md

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -266,8 +266,8 @@ You can group rows based on a given column value and show/hide sub rows via some
266266

267267
#### Important parts:
268268

269-
* Add prop `grouping` to `UTable` component with an array of column ids you want to group by.
270-
* Add prop `grouping-options` to `UTable`. It must include `getGroupedRowModel`, you can import it from `@tanstack/vue-table` or implement your own.
269+
* Add `grouping` prop with an array of column ids you want to group by.
270+
* Add `grouping-options` prop. It must include `getGroupedRowModel`, you can import it from `@tanstack/vue-table` or implement your own.
271271
* Expand rows via `row.toggleExpanded()` method on any cell of the row. Keep in mind, it also toggles `#expanded` slot.
272272
* Use `aggregateFn` on column definition to define how to aggregate the rows.
273273
* `agregatedCell` renderer on column definition only works if there is no `cell` renderer.
@@ -304,42 +304,74 @@ class: '!p-0'
304304
You can use the `row-selection` prop to control the selection state of the rows (can be binded with `v-model`).
305305
::
306306

307-
### With `@select` event
307+
### With row select event
308308

309-
You can add a `@select` listener to make rows clickable. The handler function receives the `TableRow` instance as the first argument and an optional `Event` as the second argument.
309+
You can add a `@select` listener to make rows clickable with or without a checkbox column.
310310

311311
::note
312-
You can use this to navigate to a page, open a modal or even to select the row manually.
312+
The handler function receives the `TableRow` instance as the first argument and an optional `Event` as the second argument.
313313
::
314314

315315
::component-example
316316
---
317317
prettier: true
318318
collapse: true
319-
name: 'table-row-selection-event-example'
319+
name: 'table-row-select-event-example'
320320
highlights:
321321
- 123
322322
- 130
323323
class: '!p-0'
324324
---
325325
::
326326

327-
### With context menu :badge{label="Soon" class="align-text-top"}
327+
::tip
328+
You can use this to navigate to a page, open a modal or even to select the row manually.
329+
::
328330

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.
331+
### With row context menu event :badge{label="Soon" class="align-text-top"}
332+
333+
You can add a `@contextmenu` listener to make rows right clickable and wrap the Table in a [ContextMenu](/components/context-menu) component to display row actions for example.
334+
335+
::note
336+
The handler function receives the `Event` and `TableRow` instance as the first and second arguments respectively.
337+
::
330338

331339
::component-example
332340
---
333341
prettier: true
334342
collapse: true
335-
name: 'table-context-menu-example'
343+
name: 'table-row-context-menu-event-example'
336344
highlights:
337345
- 130
338346
- 170
339347
class: '!p-0'
340348
---
341349
::
342350

351+
### With row hover event :badge{label="Soon" class="align-text-top"}
352+
353+
You can add a `@hover` listener to make rows hoverable and use a [Popover](/components/popover) or a [Tooltip](/components/tooltip) component to display row details for example.
354+
355+
::note
356+
The handler function receives the `Event` and `TableRow` instance as the first and second arguments respectively.
357+
::
358+
359+
::component-example
360+
---
361+
prettier: true
362+
collapse: true
363+
name: 'table-row-hover-event-example'
364+
highlights:
365+
- 126
366+
- 149
367+
class: '!p-0'
368+
---
369+
::
370+
371+
::note
372+
This example is similar as the Popover [with following cursor example](/components/popover#with-following-cursor) and uses a [`refDebounced`](https://vueuse.org/shared/refDebounced/#refdebounced) to prevent the Popover from opening and closing too quickly when moving the cursor from one row to another.
373+
::
374+
343375
### With column sorting
344376

345377
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: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { h, resolveComponent } from 'vue'
33
import { upperFirst } from 'scule'
44
import type { TableColumn, TableRow } from '@nuxt/ui'
55
import { getPaginationRowModel } from '@tanstack/vue-table'
6-
import { useClipboard } from '@vueuse/core'
6+
import { useClipboard, refDebounced } from '@vueuse/core'
77
88
const UButton = resolveComponent('UButton')
99
const UCheckbox = resolveComponent('UCheckbox')
@@ -311,6 +311,30 @@ function onContextmenu(e: Event, row: TableRow<Payment>) {
311311
contextmenuRow.value = row
312312
}
313313
314+
const popoverOpen = ref(false)
315+
const popoverOpenDebounced = refDebounced(popoverOpen, 1)
316+
const popoverAnchor = ref({ x: 0, y: 0 })
317+
const popoverRow = ref<TableRow<Payment> | null>(null)
318+
319+
const reference = computed(() => ({
320+
getBoundingClientRect: () =>
321+
({
322+
width: 0,
323+
height: 0,
324+
left: popoverAnchor.value.x,
325+
right: popoverAnchor.value.x,
326+
top: popoverAnchor.value.y,
327+
bottom: popoverAnchor.value.y,
328+
...popoverAnchor.value
329+
} as DOMRect)
330+
}))
331+
332+
function onHover(_e: Event, row: TableRow<Payment> | null) {
333+
popoverRow.value = row
334+
335+
popoverOpen.value = !!row
336+
}
337+
314338
onMounted(() => {
315339
setTimeout(() => {
316340
loading.value = false
@@ -374,13 +398,26 @@ onMounted(() => {
374398
class="border border-accented rounded-sm"
375399
@select="onSelect"
376400
@contextmenu="onContextmenu"
401+
@pointermove="(ev: PointerEvent) => {
402+
popoverAnchor.x = ev.clientX
403+
popoverAnchor.y = ev.clientY
404+
}"
405+
@hover="onHover"
377406
>
378407
<template #expanded="{ row }">
379408
<pre>{{ row.original }}</pre>
380409
</template>
381410
</UTable>
382411
</UContextMenu>
383412

413+
<UPopover :content="{ side: 'top', sideOffset: 16, updatePositionStrategy: 'always' }" :open="popoverOpenDebounced" :reference="reference">
414+
<template #content>
415+
<div class="p-4">
416+
{{ popoverRow?.original?.id }}
417+
</div>
418+
</template>
419+
</UPopover>
420+
384421
<div class="flex items-center justify-between gap-3">
385422
<div class="text-sm text-muted">
386423
{{ table?.tableApi?.getFilteredSelectedRowModel().rows.length || 0 }} of

src/runtime/components/Table.vue

Lines changed: 12 additions & 1 deletion
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+
onHover?: (e: Event, row: TableRow<T> | null) => void
168169
onContextmenu?: ((e: Event, row: TableRow<T>) => void) | Array<((e: Event, row: TableRow<T>) => void)>
169170
class?: any
170171
ui?: Table['slots']
@@ -331,6 +332,14 @@ function onRowSelect(e: Event, row: TableRow<T>) {
331332
props.onSelect(row, e)
332333
}
333334
335+
function onRowHover(e: Event, row: TableRow<T> | null) {
336+
if (!props.onHover) {
337+
return
338+
}
339+
340+
props.onHover(e, row)
341+
}
342+
334343
function onRowContextmenu(e: Event, row: TableRow<T>) {
335344
if (!props.onContextmenu) {
336345
return
@@ -396,7 +405,7 @@ defineExpose({
396405
<template v-for="row in tableApi.getRowModel().rows" :key="row.id">
397406
<tr
398407
:data-selected="row.getIsSelected()"
399-
:data-selectable="!!props.onSelect || !!props.onContextmenu"
408+
:data-selectable="!!props.onSelect || !!props.onHover || !!props.onContextmenu"
400409
:data-expanded="row.getIsExpanded()"
401410
:role="props.onSelect ? 'button' : undefined"
402411
:tabindex="props.onSelect ? 0 : undefined"
@@ -407,6 +416,8 @@ defineExpose({
407416
]
408417
})"
409418
@click="onRowSelect($event, row)"
419+
@pointerenter="onRowHover($event, row)"
420+
@pointerleave="onRowHover($event, null)"
410421
@contextmenu="onRowContextmenu($event, row)"
411422
>
412423
<td

0 commit comments

Comments
 (0)