diff --git a/docs/app/components/content/examples/table/TableColumnFooterExample.vue b/docs/app/components/content/examples/table/TableColumnFooterExample.vue
new file mode 100644
index 0000000000..844497c6c4
--- /dev/null
+++ b/docs/app/components/content/examples/table/TableColumnFooterExample.vue
@@ -0,0 +1,106 @@
+
+
+
+
+
diff --git a/docs/content/3.components/table.md b/docs/content/3.components/table.md
index eb2a9cfff8..d2bb5659a6 100644
--- a/docs/content/3.components/table.md
+++ b/docs/content/3.components/table.md
@@ -77,6 +77,7 @@ Use the `columns` prop as an array of [ColumnDef](https://tanstack.com/table/lat
- `accessorKey`: [The key of the row object to use when extracting the value for the column.]{class="text-muted"}
- `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"}
+- `footer`: [The footer to display for the column. Works exactly like header, but is displayed under the table.]{class="text-muted"}
- `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"}
- `meta`: [Extra properties for the column.]{class="text-muted"}
- `class`:
@@ -161,7 +162,7 @@ props:
### Sticky
-Use the `sticky` prop to make the header sticky.
+Use the `sticky` prop to make the header or footer sticky.
::component-code
---
@@ -172,6 +173,10 @@ ignore:
- class
external:
- data
+items:
+ sticky:
+ - true
+ - false
props:
sticky: true
data:
@@ -372,6 +377,22 @@ class: '!p-0'
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.
::
+### With column footer :badge{label="Soon" class="align-text-top"}
+
+You can add a `footer` property to the column definition to render a footer for the column.
+
+::component-example
+---
+prettier: true
+collapse: true
+name: 'table-column-footer-example'
+highlights:
+ - 94
+ - 108
+class: '!p-0'
+---
+::
+
### 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).
diff --git a/playground/app/pages/components/table.vue b/playground/app/pages/components/table.vue
index 03c71a0893..6cd0272219 100644
--- a/playground/app/pages/components/table.vue
+++ b/playground/app/pages/components/table.vue
@@ -242,6 +242,16 @@ const columns: TableColumn[] = [{
}, {
accessorKey: 'amount',
header: () => h('div', { class: 'text-right' }, 'Amount'),
+ footer: ({ column }) => {
+ const total = column.getFacetedRowModel().rows.reduce((acc: number, row: TableRow) => acc + Number.parseFloat(row.getValue('amount')), 0)
+
+ const formatted = new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: 'EUR'
+ }).format(total)
+
+ return h('div', { class: 'text-right font-medium' }, `Total: ${formatted}`)
+ },
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
diff --git a/src/runtime/components/Table.vue b/src/runtime/components/Table.vue
index 00b670082f..88871cb1dc 100644
--- a/src/runtime/components/Table.vue
+++ b/src/runtime/components/Table.vue
@@ -83,10 +83,10 @@ export interface TableProps extends TableOption
*/
empty?: string
/**
- * Whether the table should have a sticky header.
+ * Whether the table should have a sticky header or footer. True for both, 'header' for header only, 'footer' for footer only.
* @defaultValue false
*/
- sticky?: boolean
+ sticky?: boolean | 'header' | 'footer'
/** Whether the table should be in loading state. */
loading?: boolean
/**
@@ -172,6 +172,7 @@ export interface TableProps extends TableOption
}
type DynamicHeaderSlots = Record) => any> & Record<`${K extends string ? K : never}-header`, (props: HeaderContext) => any>
+type DynamicFooterSlots = Record) => any> & Record<`${K extends string ? K : never}-footer`, (props: HeaderContext) => any>
type DynamicCellSlots = Record) => any> & Record<`${K extends string ? K : never}-cell`, (props: CellContext) => any>
export type TableSlots = {
@@ -181,7 +182,7 @@ export type TableSlots = {
'caption': (props?: {}) => any
'body-top': (props?: {}) => any
'body-bottom': (props?: {}) => any
-} & DynamicHeaderSlots & DynamicCellSlots
+} & DynamicHeaderSlots & DynamicFooterSlots & DynamicCellSlots
@@ -216,6 +217,22 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.table || {})
loadingAnimation: props.loadingAnimation
}))
+const hasFooter = computed(() => {
+ function hasFooterRecursive(columns: TableColumn[]): boolean {
+ for (const column of columns) {
+ if ('footer' in column) {
+ return true
+ }
+ if ('columns' in column && hasFooterRecursive(column.columns as TableColumn[])) {
+ return true
+ }
+ }
+ return false
+ }
+
+ return hasFooterRecursive(columns.value)
+})
+
const globalFilterState = defineModel('globalFilter', { default: undefined })
const columnFiltersState = defineModel('columnFilters', { default: [] })
const columnOrderState = defineModel('columnOrder', { default: [] })
@@ -461,6 +478,30 @@ defineExpose({
+
+
+
+
+
+
+
+
+
+ |
+
+
diff --git a/src/theme/table.ts b/src/theme/table.ts
index 11985e0930..643e8cc58d 100644
--- a/src/theme/table.ts
+++ b/src/theme/table.ts
@@ -7,6 +7,7 @@ export default (options: Required) => ({
caption: 'sr-only',
thead: 'relative',
tbody: 'divide-y divide-default [&>tr]:data-[selectable=true]:hover:bg-elevated/50 [&>tr]:data-[selectable=true]:focus-visible:outline-primary',
+ tfoot: 'relative',
tr: 'data-[selected=true]:bg-elevated/50',
th: 'px-4 py-3.5 text-sm text-highlighted text-left rtl:text-right font-semibold [&:has([role=checkbox])]:pe-0',
td: 'p-4 text-sm text-muted whitespace-nowrap [&:has([role=checkbox])]:pe-0',
@@ -23,7 +24,14 @@ export default (options: Required) => ({
},
sticky: {
true: {
+ thead: 'sticky top-0 inset-x-0 bg-default/75 z-[1] backdrop-blur',
+ tfoot: 'sticky bottom-0 inset-x-0 bg-default/75 z-[1] backdrop-blur'
+ },
+ header: {
thead: 'sticky top-0 inset-x-0 bg-default/75 z-[1] backdrop-blur'
+ },
+ footer: {
+ tfoot: 'sticky bottom-0 inset-x-0 bg-default/75 z-[1] backdrop-blur'
}
},
loading: {
diff --git a/test/components/Table.spec.ts b/test/components/Table.spec.ts
index 0b1d183dc8..a0590c76d1 100644
--- a/test/components/Table.spec.ts
+++ b/test/components/Table.spec.ts
@@ -4,7 +4,7 @@ import { flushPromises } from '@vue/test-utils'
import { mountSuspended } from '@nuxt/test-utils/runtime'
import { UCheckbox, UButton, UBadge, UDropdownMenu } from '#components'
import Table from '../../src/runtime/components/Table.vue'
-import type { TableProps, TableSlots, TableColumn } from '../../src/runtime/components/Table.vue'
+import type { TableProps, TableSlots, TableColumn, TableRow } from '../../src/runtime/components/Table.vue'
import ComponentRender from '../component-render'
import theme from '#build/ui/table'
@@ -99,6 +99,16 @@ describe('Table', () => {
}, {
accessorKey: 'amount',
header: () => h('div', { class: 'text-right' }, 'Amount'),
+ footer: ({ column }) => {
+ const total = column.getFacetedRowModel().rows.reduce((acc: number, row: TableRow) => acc + Number.parseFloat(row.getValue('amount')), 0)
+
+ const formatted = new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: 'EUR'
+ }).format(total)
+
+ return h('div', { class: 'text-right font-medium' }, `Total: ${formatted}`)
+ },
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
diff --git a/test/components/__snapshots__/Table-vue.spec.ts.snap b/test/components/__snapshots__/Table-vue.spec.ts.snap
index b28ae60681..cbff99fa17 100644
--- a/test/components/__snapshots__/Table-vue.spec.ts.snap
+++ b/test/components/__snapshots__/Table-vue.spec.ts.snap
@@ -50,6 +50,7 @@ exports[`Table > renders with as correctly 1`] = `
+
"
`;
@@ -104,6 +105,7 @@ exports[`Table > renders with body-bottom slot correctly 1`] = `
Body bottom slot
+
"
`;
@@ -157,6 +159,7 @@ exports[`Table > renders with body-top slot correctly 1`] = `
+
"
`;
@@ -211,6 +214,7 @@ exports[`Table > renders with caption correctly 1`] = `
+
"
`;
@@ -265,6 +269,7 @@ exports[`Table > renders with caption slot correctly 1`] = `
+
"
`;
@@ -319,6 +324,7 @@ exports[`Table > renders with cell slot correctly 1`] = `
+
"
`;
@@ -373,6 +379,7 @@ exports[`Table > renders with class correctly 1`] = `
+
"
`;
@@ -564,6 +571,32 @@ exports[`Table > renders with columns correctly 1`] = `
+
+
+
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+ Total: €2,990.00
+ |
+
+
+ |
+
+
"
`;
@@ -618,6 +651,7 @@ exports[`Table > renders with data correctly 1`] = `
+
"
`;
@@ -635,6 +669,7 @@ exports[`Table > renders with empty correctly 1`] = `
There is no data |
+
"
`;
@@ -674,6 +709,32 @@ exports[`Table > renders with empty slot correctly 1`] = `
Empty slot |
+
+
+
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+ Total: €0.00
+ |
+
+
+ |
+
+
"
`;
@@ -728,6 +789,7 @@ exports[`Table > renders with expanded slot correctly 1`] = `
+
"
`;
@@ -782,6 +844,7 @@ exports[`Table > renders with header slot correctly 1`] = `
+
"
`;
@@ -836,6 +899,7 @@ exports[`Table > renders with loading animation carousel correctly 1`] = `
+
"
`;
@@ -890,6 +954,7 @@ exports[`Table > renders with loading animation carousel-inverse correctly 1`] =
+
"
`;
@@ -944,6 +1009,7 @@ exports[`Table > renders with loading animation elastic correctly 1`] = `
+
"
`;
@@ -998,6 +1064,7 @@ exports[`Table > renders with loading animation swing correctly 1`] = `
+
"
`;
@@ -1052,6 +1119,7 @@ exports[`Table > renders with loading color error correctly 1`] = `
+
"
`;
@@ -1106,6 +1174,7 @@ exports[`Table > renders with loading color info correctly 1`] = `
+
"
`;
@@ -1160,6 +1229,7 @@ exports[`Table > renders with loading color neutral correctly 1`] = `
+
"
`;
@@ -1214,6 +1284,7 @@ exports[`Table > renders with loading color primary correctly 1`] = `
+
"
`;
@@ -1268,6 +1339,7 @@ exports[`Table > renders with loading color secondary correctly 1`] = `
+
"
`;
@@ -1322,6 +1394,7 @@ exports[`Table > renders with loading color success correctly 1`] = `
+
"
`;
@@ -1376,6 +1449,7 @@ exports[`Table > renders with loading color warning correctly 1`] = `
+
"
`;
@@ -1430,6 +1504,7 @@ exports[`Table > renders with loading correctly 1`] = `
+
"
`;
@@ -1469,6 +1544,32 @@ exports[`Table > renders with loading slot correctly 1`] = `
Loading slot |
+
+
+
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+ Total: €0.00
+ |
+
+
+ |
+
+
"
`;
@@ -1523,6 +1624,7 @@ exports[`Table > renders with sticky correctly 1`] = `
+
"
`;
@@ -1577,6 +1679,7 @@ exports[`Table > renders with ui correctly 1`] = `
+
"
`;
@@ -1594,6 +1697,7 @@ exports[`Table > renders without data correctly 1`] = `
No data |
+
"
`;
diff --git a/test/components/__snapshots__/Table.spec.ts.snap b/test/components/__snapshots__/Table.spec.ts.snap
index 8d1b9fe83f..ee61ab7a67 100644
--- a/test/components/__snapshots__/Table.spec.ts.snap
+++ b/test/components/__snapshots__/Table.spec.ts.snap
@@ -50,6 +50,7 @@ exports[`Table > renders with as correctly 1`] = `
+
"
`;
@@ -104,6 +105,7 @@ exports[`Table > renders with body-bottom slot correctly 1`] = `
Body bottom slot
+
"
`;
@@ -157,6 +159,7 @@ exports[`Table > renders with body-top slot correctly 1`] = `
+
"
`;
@@ -211,6 +214,7 @@ exports[`Table > renders with caption correctly 1`] = `
+
"
`;
@@ -265,6 +269,7 @@ exports[`Table > renders with caption slot correctly 1`] = `
+
"
`;
@@ -319,6 +324,7 @@ exports[`Table > renders with cell slot correctly 1`] = `
+
"
`;
@@ -373,6 +379,7 @@ exports[`Table > renders with class correctly 1`] = `
+
"
`;
@@ -564,6 +571,32 @@ exports[`Table > renders with columns correctly 1`] = `
+
+
+
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+ Total: €2,990.00
+ |
+
+
+ |
+
+
"
`;
@@ -618,6 +651,7 @@ exports[`Table > renders with data correctly 1`] = `
+
"
`;
@@ -635,6 +669,7 @@ exports[`Table > renders with empty correctly 1`] = `
There is no data |
+
"
`;
@@ -674,6 +709,32 @@ exports[`Table > renders with empty slot correctly 1`] = `
Empty slot |
+
+
+
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+ Total: €0.00
+ |
+
+
+ |
+
+
"
`;
@@ -728,6 +789,7 @@ exports[`Table > renders with expanded slot correctly 1`] = `
+
"
`;
@@ -782,6 +844,7 @@ exports[`Table > renders with header slot correctly 1`] = `
+
"
`;
@@ -836,6 +899,7 @@ exports[`Table > renders with loading animation carousel correctly 1`] = `
+
"
`;
@@ -890,6 +954,7 @@ exports[`Table > renders with loading animation carousel-inverse correctly 1`] =
+
"
`;
@@ -944,6 +1009,7 @@ exports[`Table > renders with loading animation elastic correctly 1`] = `
+
"
`;
@@ -998,6 +1064,7 @@ exports[`Table > renders with loading animation swing correctly 1`] = `
+
"
`;
@@ -1052,6 +1119,7 @@ exports[`Table > renders with loading color error correctly 1`] = `
+
"
`;
@@ -1106,6 +1174,7 @@ exports[`Table > renders with loading color info correctly 1`] = `
+
"
`;
@@ -1160,6 +1229,7 @@ exports[`Table > renders with loading color neutral correctly 1`] = `
+
"
`;
@@ -1214,6 +1284,7 @@ exports[`Table > renders with loading color primary correctly 1`] = `
+
"
`;
@@ -1268,6 +1339,7 @@ exports[`Table > renders with loading color secondary correctly 1`] = `
+
"
`;
@@ -1322,6 +1394,7 @@ exports[`Table > renders with loading color success correctly 1`] = `
+
"
`;
@@ -1376,6 +1449,7 @@ exports[`Table > renders with loading color warning correctly 1`] = `
+
"
`;
@@ -1430,6 +1504,7 @@ exports[`Table > renders with loading correctly 1`] = `
+
"
`;
@@ -1469,6 +1544,32 @@ exports[`Table > renders with loading slot correctly 1`] = `
Loading slot |
+
+
+
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+ Total: €0.00
+ |
+
+
+ |
+
+
"
`;
@@ -1523,6 +1624,7 @@ exports[`Table > renders with sticky correctly 1`] = `
+
"
`;
@@ -1577,6 +1679,7 @@ exports[`Table > renders with ui correctly 1`] = `
+
"
`;
@@ -1594,6 +1697,7 @@ exports[`Table > renders without data correctly 1`] = `
No data |
+
"
`;