Skip to content

feat(Table): add footer support to display column summary #4194

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 14 commits into
base: v3
Choose a base branch
from
Open
1 change: 1 addition & 0 deletions docs/content/3.components/table.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
10 changes: 10 additions & 0 deletions playground/app/pages/components/table.vue
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,16 @@ const columns: TableColumn<Payment>[] = [{
}, {
accessorKey: 'amount',
header: () => h('div', { class: 'text-right' }, 'Amount'),
footer: ({ column }) => {
const total = column.getFacetedRowModel().rows.reduce((acc: number, row: TableRow<Payment>) => 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'))

Expand Down
39 changes: 36 additions & 3 deletions src/runtime/components/Table.vue
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,10 @@ export interface TableProps<T extends TableData> extends TableOptions<T> {
*/
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
/**
Expand Down Expand Up @@ -206,12 +206,23 @@ const columns = computed<TableColumn<T>[]>(() => props.columns ?? Object.keys(da
const meta = computed(() => props.meta ?? {})

const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.table || {}) })({
sticky: props.sticky,
sticky: props.sticky === true || props.sticky === 'header',
stickyFooter: props.sticky === true || props.sticky === 'footer',
loading: props.loading,
loadingColor: props.loadingColor,
loadingAnimation: props.loadingAnimation
}))

const hasFooter = computed(() => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you mind explaining the logic of this? Wouldn't it be simpler with columns.value.map? πŸ€”

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Columns can be multi-leveled to create few rows of table headers or footers. For example:

const columns = [
  {
     header: 'Level 1',
     columns: [
        {
            header: 'Level 2'
        }
     ]
  }
]

This traverses the list of columns, if it finds a footer definition, it stops and returns true, if it finds sub columns, it adds them to the list it needs to check. Not sure, how to accomplish this with map considering we don't know the depth of the tree.

const queue: TableColumn<T>[] = [...columns.value]
while (queue.length) {
const column = queue.shift()!
if ('footer' in column) return true
if ('columns' in column) queue.push(...column.columns!)
}
return false
})

const globalFilterState = defineModel<string>('globalFilter', { default: undefined })
const columnFiltersState = defineModel<ColumnFiltersState>('columnFilters', { default: [] })
const columnOrderState = defineModel<ColumnOrderState>('columnOrder', { default: [] })
Expand Down Expand Up @@ -424,6 +435,28 @@ defineExpose({
</td>
</tr>
</tbody>

<tfoot v-if="hasFooter" :class="ui.tfoot({ class: [props.ui?.tfoot] })">
<tr v-for="footerGroup in tableApi.getFooterGroups()" :key="footerGroup.id" :class="ui.tr({ class: [props.ui?.tr] })">
<th
v-for="header in footerGroup.headers"
:key="header.id"
:data-pinned="header.column.getIsPinned()"
:colspan="header.colSpan > 1 ? header.colSpan : undefined"
:class="ui.th({
class: [
props.ui?.th,
typeof header.column.columnDef.meta?.class?.th === 'function' ? header.column.columnDef.meta.class.th(header) : header.column.columnDef.meta?.class?.th
],
pinned: !!header.column.getIsPinned()
})"
>
<slot :name="`${header.id}-footer`" v-bind="header.getContext()">
<FlexRender v-if="!header.isPlaceholder" :render="header.column.columnDef.footer" :props="header.getContext()" />
</slot>
</th>
</tr>
</tfoot>
</table>
</Primitive>
</template>
6 changes: 6 additions & 0 deletions src/theme/table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export default (options: Required<ModuleOptions>) => ({
caption: 'sr-only',
thead: 'relative [&>tr]:after:absolute [&>tr]:after:inset-x-0 [&>tr]:after:bottom-0 [&>tr]:after:h-px [&>tr]:after:bg-(--ui-border-accented)',
tbody: 'divide-y divide-default [&>tr]:data-[selectable=true]:hover:bg-elevated/50 [&>tr]:data-[selectable=true]:focus-visible:outline-primary',
tfoot: 'relative [&>tr]:after:absolute [&>tr]:after:inset-x-0 [&>tr]:after:top-0 [&>tr]:after:h-px [&>tr]:after:bg-(--ui-border-accented) [&>tr>th]:empty:p-0 [&>tr>th]:empty:border-none',
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',
Expand All @@ -25,6 +26,11 @@ export default (options: Required<ModuleOptions>) => ({
thead: 'sticky top-0 inset-x-0 bg-default/75 z-[1] backdrop-blur'
}
},
stickyFooter: {
true: {
tfoot: 'sticky bottom-0 inset-x-0 bg-default/75 z-[1] backdrop-blur'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this should go inside sticky.true.tfoot!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to control sticky footer and header individually, so table could have stuck header, but not footer or vice versa. I map prop.sticky to to separate variants on lines 209-210

}
},
loading: {
true: {
thead: 'after:absolute after:bottom-0 after:inset-x-0 after:h-px'
Expand Down
27 changes: 27 additions & 0 deletions test/components/__snapshots__/Table-vue.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ exports[`Table > renders with as correctly 1`] = `
</tr>
<!--v-if-->
</tbody>
<!--v-if-->
</table>
</section>"
`;
Expand Down Expand Up @@ -102,6 +103,7 @@ exports[`Table > renders with caption correctly 1`] = `
</tr>
<!--v-if-->
</tbody>
<!--v-if-->
</table>
</div>"
`;
Expand Down Expand Up @@ -155,6 +157,7 @@ exports[`Table > renders with caption slot correctly 1`] = `
</tr>
<!--v-if-->
</tbody>
<!--v-if-->
</table>
</div>"
`;
Expand Down Expand Up @@ -208,6 +211,7 @@ exports[`Table > renders with cell slot correctly 1`] = `
</tr>
<!--v-if-->
</tbody>
<!--v-if-->
</table>
</div>"
`;
Expand Down Expand Up @@ -261,6 +265,7 @@ exports[`Table > renders with class correctly 1`] = `
</tr>
<!--v-if-->
</tbody>
<!--v-if-->
</table>
</div>"
`;
Expand Down Expand Up @@ -451,6 +456,7 @@ exports[`Table > renders with columns correctly 1`] = `
</tr>
<!--v-if-->
</tbody>
<!--v-if-->
</table>
</div>"
`;
Expand Down Expand Up @@ -504,6 +510,7 @@ exports[`Table > renders with data correctly 1`] = `
</tr>
<!--v-if-->
</tbody>
<!--v-if-->
</table>
</div>"
`;
Expand All @@ -520,6 +527,7 @@ exports[`Table > renders with empty correctly 1`] = `
<td colspan="0" class="py-6 text-center text-sm text-muted">There is no data</td>
</tr>
</tbody>
<!--v-if-->
</table>
</div>"
`;
Expand Down Expand Up @@ -558,6 +566,7 @@ exports[`Table > renders with empty slot correctly 1`] = `
<td colspan="7" class="py-6 text-center text-sm text-muted">Empty slot</td>
</tr>
</tbody>
<!--v-if-->
</table>
</div>"
`;
Expand Down Expand Up @@ -611,6 +620,7 @@ exports[`Table > renders with expanded slot correctly 1`] = `
</tr>
<!--v-if-->
</tbody>
<!--v-if-->
</table>
</div>"
`;
Expand Down Expand Up @@ -664,6 +674,7 @@ exports[`Table > renders with header slot correctly 1`] = `
</tr>
<!--v-if-->
</tbody>
<!--v-if-->
</table>
</div>"
`;
Expand Down Expand Up @@ -717,6 +728,7 @@ exports[`Table > renders with loading animation carousel correctly 1`] = `
</tr>
<!--v-if-->
</tbody>
<!--v-if-->
</table>
</div>"
`;
Expand Down Expand Up @@ -770,6 +782,7 @@ exports[`Table > renders with loading animation carousel-inverse correctly 1`] =
</tr>
<!--v-if-->
</tbody>
<!--v-if-->
</table>
</div>"
`;
Expand Down Expand Up @@ -823,6 +836,7 @@ exports[`Table > renders with loading animation elastic correctly 1`] = `
</tr>
<!--v-if-->
</tbody>
<!--v-if-->
</table>
</div>"
`;
Expand Down Expand Up @@ -876,6 +890,7 @@ exports[`Table > renders with loading animation swing correctly 1`] = `
</tr>
<!--v-if-->
</tbody>
<!--v-if-->
</table>
</div>"
`;
Expand Down Expand Up @@ -929,6 +944,7 @@ exports[`Table > renders with loading color error correctly 1`] = `
</tr>
<!--v-if-->
</tbody>
<!--v-if-->
</table>
</div>"
`;
Expand Down Expand Up @@ -982,6 +998,7 @@ exports[`Table > renders with loading color info correctly 1`] = `
</tr>
<!--v-if-->
</tbody>
<!--v-if-->
</table>
</div>"
`;
Expand Down Expand Up @@ -1035,6 +1052,7 @@ exports[`Table > renders with loading color neutral correctly 1`] = `
</tr>
<!--v-if-->
</tbody>
<!--v-if-->
</table>
</div>"
`;
Expand Down Expand Up @@ -1088,6 +1106,7 @@ exports[`Table > renders with loading color primary correctly 1`] = `
</tr>
<!--v-if-->
</tbody>
<!--v-if-->
</table>
</div>"
`;
Expand Down Expand Up @@ -1141,6 +1160,7 @@ exports[`Table > renders with loading color secondary correctly 1`] = `
</tr>
<!--v-if-->
</tbody>
<!--v-if-->
</table>
</div>"
`;
Expand Down Expand Up @@ -1194,6 +1214,7 @@ exports[`Table > renders with loading color success correctly 1`] = `
</tr>
<!--v-if-->
</tbody>
<!--v-if-->
</table>
</div>"
`;
Expand Down Expand Up @@ -1247,6 +1268,7 @@ exports[`Table > renders with loading color warning correctly 1`] = `
</tr>
<!--v-if-->
</tbody>
<!--v-if-->
</table>
</div>"
`;
Expand Down Expand Up @@ -1300,6 +1322,7 @@ exports[`Table > renders with loading correctly 1`] = `
</tr>
<!--v-if-->
</tbody>
<!--v-if-->
</table>
</div>"
`;
Expand Down Expand Up @@ -1338,6 +1361,7 @@ exports[`Table > renders with loading slot correctly 1`] = `
<td colspan="7" class="py-6 text-center">Loading slot</td>
</tr>
</tbody>
<!--v-if-->
</table>
</div>"
`;
Expand Down Expand Up @@ -1391,6 +1415,7 @@ exports[`Table > renders with sticky correctly 1`] = `
</tr>
<!--v-if-->
</tbody>
<!--v-if-->
</table>
</div>"
`;
Expand Down Expand Up @@ -1444,6 +1469,7 @@ exports[`Table > renders with ui correctly 1`] = `
</tr>
<!--v-if-->
</tbody>
<!--v-if-->
</table>
</div>"
`;
Expand All @@ -1460,6 +1486,7 @@ exports[`Table > renders without data correctly 1`] = `
<td colspan="0" class="py-6 text-center text-sm text-muted">No data</td>
</tr>
</tbody>
<!--v-if-->
</table>
</div>"
`;
Loading