Skip to content

Commit c355cac

Browse files
gigorbenjamincanac
andauthored
feat(Table): add footer support to display column summary (#4194)
Co-authored-by: Benjamin Canac <canacb1@gmail.com>
1 parent a0e71d9 commit c355cac

File tree

8 files changed

+409
-5
lines changed

8 files changed

+409
-5
lines changed
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
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+
7+
type Payment = {
8+
id: string
9+
date: string
10+
status: 'paid' | 'failed' | 'refunded'
11+
email: string
12+
amount: number
13+
}
14+
15+
const data = ref<Payment[]>([{
16+
id: '4600',
17+
date: '2024-03-11T15:30:00',
18+
status: 'paid',
19+
email: 'james.anderson@example.com',
20+
amount: 594
21+
}, {
22+
id: '4599',
23+
date: '2024-03-11T10:10:00',
24+
status: 'failed',
25+
email: 'mia.white@example.com',
26+
amount: 276
27+
}, {
28+
id: '4598',
29+
date: '2024-03-11T08:50:00',
30+
status: 'refunded',
31+
email: 'william.brown@example.com',
32+
amount: 315
33+
}, {
34+
id: '4597',
35+
date: '2024-03-10T19:45:00',
36+
status: 'paid',
37+
email: 'emma.davis@example.com',
38+
amount: 529
39+
}, {
40+
id: '4596',
41+
date: '2024-03-10T15:55:00',
42+
status: 'paid',
43+
email: 'ethan.harris@example.com',
44+
amount: 639
45+
}])
46+
47+
const columns: TableColumn<Payment>[] = [{
48+
accessorKey: 'id',
49+
header: '#',
50+
cell: ({ row }) => `#${row.getValue('id')}`
51+
}, {
52+
accessorKey: 'date',
53+
header: 'Date',
54+
cell: ({ row }) => {
55+
return new Date(row.getValue('date')).toLocaleString('en-US', {
56+
day: 'numeric',
57+
month: 'short',
58+
hour: '2-digit',
59+
minute: '2-digit',
60+
hour12: false
61+
})
62+
}
63+
}, {
64+
accessorKey: 'status',
65+
header: 'Status',
66+
cell: ({ row }) => {
67+
const color = ({
68+
paid: 'success' as const,
69+
failed: 'error' as const,
70+
refunded: 'neutral' as const
71+
})[row.getValue('status') as string]
72+
73+
return h(UBadge, { class: 'capitalize', variant: 'subtle', color }, () => row.getValue('status'))
74+
}
75+
}, {
76+
accessorKey: 'email',
77+
header: 'Email'
78+
}, {
79+
accessorKey: 'amount',
80+
header: () => h('div', { class: 'text-right' }, 'Amount'),
81+
footer: ({ column }) => {
82+
const total = column.getFacetedRowModel().rows.reduce((acc: number, row: TableRow<Payment>) => acc + Number.parseFloat(row.getValue('amount')), 0)
83+
84+
const formatted = new Intl.NumberFormat('en-US', {
85+
style: 'currency',
86+
currency: 'EUR'
87+
}).format(total)
88+
89+
return h('div', { class: 'text-right font-medium' }, `Total: ${formatted}`)
90+
},
91+
cell: ({ row }) => {
92+
const amount = Number.parseFloat(row.getValue('amount'))
93+
94+
const formatted = new Intl.NumberFormat('en-US', {
95+
style: 'currency',
96+
currency: 'EUR'
97+
}).format(amount)
98+
99+
return h('div', { class: 'text-right font-medium' }, formatted)
100+
}
101+
}]
102+
</script>
103+
104+
<template>
105+
<UTable :data="data" :columns="columns" class="flex-1" />
106+
</template>

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

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ Use the `columns` prop as an array of [ColumnDef](https://tanstack.com/table/lat
7777

7878
- `accessorKey`: [The key of the row object to use when extracting the value for the column.]{class="text-muted"}
7979
- `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).]{class="text-muted"}
80+
- `footer`: [The footer to display for the column. Works exactly like header, but is displayed under the table.]{class="text-muted"}
8081
- `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).]{class="text-muted"}
8182
- `meta`: [Extra properties for the column.]{class="text-muted"}
8283
- `class`:
@@ -161,7 +162,7 @@ props:
161162

162163
### Sticky
163164

164-
Use the `sticky` prop to make the header sticky.
165+
Use the `sticky` prop to make the header or footer sticky.
165166

166167
::component-code
167168
---
@@ -172,6 +173,10 @@ ignore:
172173
- class
173174
external:
174175
- data
176+
items:
177+
sticky:
178+
- true
179+
- false
175180
props:
176181
sticky: true
177182
data:
@@ -372,6 +377,22 @@ class: '!p-0'
372377
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.
373378
::
374379

380+
### With column footer :badge{label="Soon" class="align-text-top"}
381+
382+
You can add a `footer` property to the column definition to render a footer for the column.
383+
384+
::component-example
385+
---
386+
prettier: true
387+
collapse: true
388+
name: 'table-column-footer-example'
389+
highlights:
390+
- 94
391+
- 108
392+
class: '!p-0'
393+
---
394+
::
395+
375396
### With column sorting
376397

377398
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: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,16 @@ const columns: TableColumn<Payment>[] = [{
242242
}, {
243243
accessorKey: 'amount',
244244
header: () => h('div', { class: 'text-right' }, 'Amount'),
245+
footer: ({ column }) => {
246+
const total = column.getFacetedRowModel().rows.reduce((acc: number, row: TableRow<Payment>) => acc + Number.parseFloat(row.getValue('amount')), 0)
247+
248+
const formatted = new Intl.NumberFormat('en-US', {
249+
style: 'currency',
250+
currency: 'EUR'
251+
}).format(total)
252+
253+
return h('div', { class: 'text-right font-medium' }, `Total: ${formatted}`)
254+
},
245255
cell: ({ row }) => {
246256
const amount = Number.parseFloat(row.getValue('amount'))
247257

‎src/runtime/components/Table.vue

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,10 @@ export interface TableProps<T extends TableData = TableData> extends TableOption
8383
*/
8484
empty?: string
8585
/**
86-
* Whether the table should have a sticky header.
86+
* Whether the table should have a sticky header or footer. True for both, 'header' for header only, 'footer' for footer only.
8787
* @defaultValue false
8888
*/
89-
sticky?: boolean
89+
sticky?: boolean | 'header' | 'footer'
9090
/** Whether the table should be in loading state. */
9191
loading?: boolean
9292
/**
@@ -172,6 +172,7 @@ export interface TableProps<T extends TableData = TableData> extends TableOption
172172
}
173173
174174
type DynamicHeaderSlots<T, K = keyof T> = Record<string, (props: HeaderContext<T, unknown>) => any> & Record<`${K extends string ? K : never}-header`, (props: HeaderContext<T, unknown>) => any>
175+
type DynamicFooterSlots<T, K = keyof T> = Record<string, (props: HeaderContext<T, unknown>) => any> & Record<`${K extends string ? K : never}-footer`, (props: HeaderContext<T, unknown>) => any>
175176
type DynamicCellSlots<T, K = keyof T> = Record<string, (props: CellContext<T, unknown>) => any> & Record<`${K extends string ? K : never}-cell`, (props: CellContext<T, unknown>) => any>
176177
177178
export type TableSlots<T extends TableData = TableData> = {
@@ -181,7 +182,7 @@ export type TableSlots<T extends TableData = TableData> = {
181182
'caption': (props?: {}) => any
182183
'body-top': (props?: {}) => any
183184
'body-bottom': (props?: {}) => any
184-
} & DynamicHeaderSlots<T> & DynamicCellSlots<T>
185+
} & DynamicHeaderSlots<T> & DynamicFooterSlots<T> & DynamicCellSlots<T>
185186
186187
</script>
187188

@@ -216,6 +217,22 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.table || {})
216217
loadingAnimation: props.loadingAnimation
217218
}))
218219
220+
const hasFooter = computed(() => {
221+
function hasFooterRecursive(columns: TableColumn<T>[]): boolean {
222+
for (const column of columns) {
223+
if ('footer' in column) {
224+
return true
225+
}
226+
if ('columns' in column && hasFooterRecursive(column.columns as TableColumn<T>[])) {
227+
return true
228+
}
229+
}
230+
return false
231+
}
232+
233+
return hasFooterRecursive(columns.value)
234+
})
235+
219236
const globalFilterState = defineModel<string>('globalFilter', { default: undefined })
220237
const columnFiltersState = defineModel<ColumnFiltersState>('columnFilters', { default: [] })
221238
const columnOrderState = defineModel<ColumnOrderState>('columnOrder', { default: [] })
@@ -461,6 +478,30 @@ defineExpose({
461478

462479
<slot name="body-bottom" />
463480
</tbody>
481+
482+
<tfoot v-if="hasFooter" :class="ui.tfoot({ class: [props.ui?.tfoot] })">
483+
<tr :class="ui.separator({ class: [props.ui?.separator] })" />
484+
485+
<tr v-for="footerGroup in tableApi.getFooterGroups()" :key="footerGroup.id" :class="ui.tr({ class: [props.ui?.tr] })">
486+
<th
487+
v-for="header in footerGroup.headers"
488+
:key="header.id"
489+
:data-pinned="header.column.getIsPinned()"
490+
:colspan="header.colSpan > 1 ? header.colSpan : undefined"
491+
:class="ui.th({
492+
class: [
493+
props.ui?.th,
494+
typeof header.column.columnDef.meta?.class?.th === 'function' ? header.column.columnDef.meta.class.th(header) : header.column.columnDef.meta?.class?.th
495+
],
496+
pinned: !!header.column.getIsPinned()
497+
})"
498+
>
499+
<slot :name="`${header.id}-footer`" v-bind="header.getContext()">
500+
<FlexRender v-if="!header.isPlaceholder" :render="header.column.columnDef.footer" :props="header.getContext()" />
501+
</slot>
502+
</th>
503+
</tr>
504+
</tfoot>
464505
</table>
465506
</Primitive>
466507
</template>

‎src/theme/table.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export default (options: Required<ModuleOptions>) => ({
77
caption: 'sr-only',
88
thead: 'relative',
99
tbody: 'divide-y divide-default [&>tr]:data-[selectable=true]:hover:bg-elevated/50 [&>tr]:data-[selectable=true]:focus-visible:outline-primary',
10+
tfoot: 'relative',
1011
tr: 'data-[selected=true]:bg-elevated/50',
1112
th: 'px-4 py-3.5 text-sm text-highlighted text-left rtl:text-right font-semibold [&:has([role=checkbox])]:pe-0',
1213
td: 'p-4 text-sm text-muted whitespace-nowrap [&:has([role=checkbox])]:pe-0',
@@ -23,7 +24,14 @@ export default (options: Required<ModuleOptions>) => ({
2324
},
2425
sticky: {
2526
true: {
27+
thead: 'sticky top-0 inset-x-0 bg-default/75 z-[1] backdrop-blur',
28+
tfoot: 'sticky bottom-0 inset-x-0 bg-default/75 z-[1] backdrop-blur'
29+
},
30+
header: {
2631
thead: 'sticky top-0 inset-x-0 bg-default/75 z-[1] backdrop-blur'
32+
},
33+
footer: {
34+
tfoot: 'sticky bottom-0 inset-x-0 bg-default/75 z-[1] backdrop-blur'
2735
}
2836
},
2937
loading: {

‎test/components/Table.spec.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { flushPromises } from '@vue/test-utils'
44
import { mountSuspended } from '@nuxt/test-utils/runtime'
55
import { UCheckbox, UButton, UBadge, UDropdownMenu } from '#components'
66
import Table from '../../src/runtime/components/Table.vue'
7-
import type { TableProps, TableSlots, TableColumn } from '../../src/runtime/components/Table.vue'
7+
import type { TableProps, TableSlots, TableColumn, TableRow } from '../../src/runtime/components/Table.vue'
88
import ComponentRender from '../component-render'
99
import theme from '#build/ui/table'
1010

@@ -99,6 +99,16 @@ describe('Table', () => {
9999
}, {
100100
accessorKey: 'amount',
101101
header: () => h('div', { class: 'text-right' }, 'Amount'),
102+
footer: ({ column }) => {
103+
const total = column.getFacetedRowModel().rows.reduce((acc: number, row: TableRow<typeof data[number]>) => acc + Number.parseFloat(row.getValue('amount')), 0)
104+
105+
const formatted = new Intl.NumberFormat('en-US', {
106+
style: 'currency',
107+
currency: 'EUR'
108+
}).format(total)
109+
110+
return h('div', { class: 'text-right font-medium' }, `Total: ${formatted}`)
111+
},
102112
cell: ({ row }) => {
103113
const amount = Number.parseFloat(row.getValue('amount'))
104114

0 commit comments

Comments
 (0)