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 + " `;