diff --git a/docs/content/docs/2.components/table.md b/docs/content/docs/2.components/table.md index fc3acab554..dd7aa2a48b 100644 --- a/docs/content/docs/2.components/table.md +++ b/docs/content/docs/2.components/table.md @@ -121,6 +121,10 @@ highlights: When rendering components with `h`, you can either use the `resolveComponent` function or import from `#components`. :: +#### Grouped column cell classes :badge{label="Soon" class="align-text-top"} + +Column groups can set `meta.class.td` to style all descendant cells. Functions receive the individual cell, and Tailwind utilities on nested groups and leaf columns take precedence over conflicting utilities from their parent groups. + ### Meta Use the `meta` prop as an object ([TableMeta](https://tanstack.com/table/v8/docs/api/core/table#meta)) to pass properties like: diff --git a/src/runtime/components/Table.vue b/src/runtime/components/Table.vue index 8420ddec8d..6e639bf617 100644 --- a/src/runtime/components/Table.vue +++ b/src/runtime/components/Table.vue @@ -262,6 +262,10 @@ const appConfig = useAppConfig() as Table['AppConfig'] const data = createRef(props.data ?? [], props.watchOptions?.deep !== false) // eslint-disable-next-line vue/no-dupe-keys const meta = computed(() => props.meta ?? {}) + +const explicitGroupMeta = Symbol('explicitGroupMeta') +type ProcessedColumn = TableColumn & { [explicitGroupMeta]?: true } + // TanStack resolves each column as `{ ...defaultColumn, ...columnDef }`, so any key set here shadows `defaultColumn`. // The fallback `header` and `cell` are only injected when `defaultColumn` doesn't provide its own. // eslint-disable-next-line vue/no-dupe-keys @@ -272,10 +276,14 @@ const columns = computed[]>(() => processColumns(props.columns ?? function processColumns(columns: TableColumn[]): TableColumn[] { return columns.map((column) => { - const col = { ...column } as TableColumn + const col: ProcessedColumn = { ...column } if ('columns' in col && col.columns) { col.columns = processColumns(col.columns as TableColumn[]) + // Record group metadata before TanStack fills in defaults. + if (col.meta) { + col[explicitGroupMeta] = true + } } if (!col.cell && !props.defaultColumn?.cell) { @@ -608,6 +616,19 @@ function resolveValue(prop: T | ((arg: A) => T), arg?: A): T | return prop } +function getCellClasses(cell: Cell) { + const classes = [resolveValue(cell.column.columnDef.meta?.class?.td, cell)] + + for (let column = cell.column.parent; column; column = column.parent) { + const columnDef = column.columnDef as ProcessedColumn + if (columnDef[explicitGroupMeta]) { + classes.unshift(resolveValue(columnDef.meta?.class?.td, cell)) + } + } + + return classes +} + function getColumnStyles(column: Column): Record { const styles: Record = {} @@ -700,7 +721,7 @@ defineExpose({ :class="ui.td({ class: [ props.ui?.td, - resolveValue(cell.column.columnDef.meta?.class?.td, cell) + ...getCellClasses(cell) ], pinned: !!cell.column.getIsPinned() })" diff --git a/test/components/Table.spec.ts b/test/components/Table.spec.ts index 64f8a53f21..5f8a115ed4 100644 --- a/test/components/Table.spec.ts +++ b/test/components/Table.spec.ts @@ -294,6 +294,63 @@ describe('Table', () => { ]) }) + it('inherits cell classes from a column group', async () => { + const wrapper = await mountSuspended(Table, { + props: { + data: data.slice(0, 1), + ui: { td: 'bg-default' }, + columns: [{ + accessorKey: 'id', + meta: { class: { td: 'font-bold' } } + }, { + header: 'Payment', + meta: { class: { td: 'bg-muted' }, colspan: { td: '2' }, rowspan: { td: '2' } }, + columns: [{ accessorKey: 'email' }, { accessorKey: 'amount' }] + }] + } + }) + + const [id, ...paymentCells] = wrapper.findAll('tbody td') + expect(id!.classes()).toEqual(expect.arrayContaining(['bg-default', 'font-bold'])) + expect(id!.classes()).not.toContain('bg-muted') + expect(paymentCells).toHaveLength(2) + for (const cell of paymentCells) { + expect(cell.classes()).toContain('bg-muted') + expect(cell.classes()).not.toContain('bg-default') + expect(cell.attributes('colspan')).toBeUndefined() + expect(cell.attributes('rowspan')).toBeUndefined() + } + }) + + it('merges dynamic cell classes from nested groups with child classes taking precedence', async () => { + const wrapper = await mountSuspended(Table, { + props: { + data: [data[0]!, data[4]!], + columns: [{ + header: 'Payment', + meta: { class: { td: ({ row }) => row.original.status === 'success' ? 'bg-default font-bold' : 'bg-default' } }, + columns: [{ + header: 'Details', + meta: { class: { td: 'bg-muted italic' } }, + columns: [{ accessorKey: 'status' }, { + accessorKey: 'amount', + meta: { class: { td: 'bg-elevated' } } + }] + }] + }] + } + }) + + const [status, amount, failedStatus, failedAmount] = wrapper.findAll('tbody td') + expect(status!.classes()).toEqual(expect.arrayContaining(['bg-muted', 'font-bold', 'italic'])) + expect(amount!.classes()).toEqual(expect.arrayContaining(['bg-elevated', 'font-bold', 'italic'])) + expect(status!.classes()).not.toContain('bg-default') + expect(amount!.classes()).not.toContain('bg-default') + expect(amount!.classes()).not.toContain('bg-muted') + expect(failedStatus!.classes()).not.toContain('font-bold') + expect(failedAmount!.classes()).not.toContain('font-bold') + }) + it('passes accessibility tests', async () => { const wrapper = await mountSuspended(Table, { props: { @@ -606,6 +663,29 @@ describe('Table', () => { expect(wrapper.find('td').text()).toBe('own') }) + + it.each([ + { name: 'applies default cell metadata only to leaves without their own metadata', explicitGroupMeta: false }, + { name: 'inherits explicit group metadata shared with defaultColumn', explicitGroupMeta: true } + ])('$name', async ({ explicitGroupMeta }) => { + const meta = { class: { td: 'font-bold' } } + const wrapper = await mountSuspended(Table, { + props: { + data: [{ id: 1, amount: 10 }], + defaultColumn: { meta }, + columns: [{ + header: 'Payment', + ...(explicitGroupMeta && { meta }), + columns: [{ accessorKey: 'id' }, { accessorKey: 'amount', meta: { class: { td: 'italic' } } }] + }] + } + }) + + const [id, amount] = wrapper.findAll('tbody td') + expect(id!.classes()).toContain('font-bold') + expect(amount!.classes()).toContain('italic') + expect(amount!.classes().includes('font-bold')).toBe(explicitGroupMeta) + }) }) it('reactive columns', async () => {