diff --git a/src/cdk/table/text-column.spec.ts b/src/cdk/table/text-column.spec.ts index ffb4a54a3e85..c840178f551d 100644 --- a/src/cdk/table/text-column.spec.ts +++ b/src/cdk/table/text-column.spec.ts @@ -16,7 +16,13 @@ describe('CdkTextColumn', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [CdkTableModule, BasicTextColumnApp, MissingTableApp, TextColumnWithoutNameApp], + imports: [ + CdkTableModule, + BasicTextColumnApp, + MissingTableApp, + TextColumnWithoutNameApp, + TextColumnWithFooter, + ], }); })); @@ -148,12 +154,104 @@ describe('CdkTextColumn', () => { ]); }); }); + + describe('with footer', () => { + const expectedDefaultTableHeaderAndData = [ + ['PropertyA', 'PropertyB', 'PropertyC', 'PropertyD'], + ['Laptop', 'Electronics', 'New', '999.99'], + ['Charger', 'Accessories', 'Used', '49.99'], + ]; + + function createTestComponent(options: TextColumnOptions) { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [CdkTableModule, TextColumnWithFooter], + providers: [{provide: TEXT_COLUMN_OPTIONS, useValue: options}], + }); + + fixture = TestBed.createComponent(TextColumnWithFooter); + component = fixture.componentInstance; + fixture.detectChanges(); + + tableElement = fixture.nativeElement.querySelector('.cdk-table'); + } + + it('should be able to provide a default footer text transformation (function)', () => { + const expectedFooterPropertyA = 'propertyA!'; + const expectedFooterPropertyB = 'propertyB!'; + const expectedFooterPropertyC = ''; + const expectedFooterPropertyD = ''; + const defaultFooterTextTransform = (name: string) => `${name}!`; + createTestComponent({defaultFooterTextTransform}); + + expectTableToMatchContent(tableElement, [ + ...expectedDefaultTableHeaderAndData, + [ + expectedFooterPropertyA, + expectedFooterPropertyB, + expectedFooterPropertyC, + expectedFooterPropertyD, + ], + ]); + }); + + it('should be able to provide a footer text transformation (function)', () => { + createTestComponent({}); + const expectedFooterPropertyA = ''; + const expectedFooterPropertyB = ''; + const expectedFooterPropertyC = ''; + const expectedFooterPropertyD = '1049.98'; + // footer text transformation function + component.getTotal = (): string => { + const total = component.data + .map(t => t.propertyD) + .reduce((acc, value) => (acc || 0) + (value || 0), 0); + return total ? total.toString() : ''; + }; + + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + + expectTableToMatchContent(tableElement, [ + ...expectedDefaultTableHeaderAndData, + [ + expectedFooterPropertyA, + expectedFooterPropertyB, + expectedFooterPropertyC, + expectedFooterPropertyD, + ], + ]); + }); + + it('should be able to provide a plain footer text', () => { + createTestComponent({}); + const expectedFooterPropertyA = ''; + const expectedFooterPropertyB = ''; + const expectedFooterPropertyC = 'Total'; + const expectedFooterPropertyD = ''; + + component.footerTextPropertyC = 'Total'; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + + expectTableToMatchContent(tableElement, [ + ...expectedDefaultTableHeaderAndData, + [ + expectedFooterPropertyA, + expectedFooterPropertyB, + expectedFooterPropertyC, + expectedFooterPropertyD, + ], + ]); + }); + }); }); interface TestData { propertyA: string; propertyB: string; propertyC: string; + propertyD?: number; } @Component({ @@ -179,8 +277,12 @@ class BasicTextColumnApp { ]; headerTextB: string; + footerTextPropertyC: string = ''; dataAccessorA: (data: TestData) => string; - justifyC = 'start'; + justifyC: 'start' | 'end' | 'center' = 'start'; + getTotal() { + return ''; + } } @Component({ @@ -205,3 +307,27 @@ class MissingTableApp {} imports: [CdkTableModule], }) class TextColumnWithoutNameApp extends BasicTextColumnApp {} + +@Component({ + template: ` + + + + + + + + + + + `, + standalone: true, + imports: [CdkTableModule], +}) +class TextColumnWithFooter extends BasicTextColumnApp { + override displayedColumns = ['propertyA', 'propertyB', 'propertyC', 'propertyD']; + override data = [ + {propertyA: 'Laptop', propertyB: 'Electronics', propertyC: 'New', propertyD: 999.99}, + {propertyA: 'Charger', propertyB: 'Accessories', propertyC: 'Used', propertyD: 49.99}, + ]; +} diff --git a/src/cdk/table/text-column.ts b/src/cdk/table/text-column.ts index 81a65cc2697f..d5bc9dadc91a 100644 --- a/src/cdk/table/text-column.ts +++ b/src/cdk/table/text-column.ts @@ -17,7 +17,15 @@ import { ViewChild, ViewEncapsulation, } from '@angular/core'; -import {CdkCellDef, CdkColumnDef, CdkHeaderCellDef, CdkHeaderCell, CdkCell} from './cell'; +import { + CdkCellDef, + CdkColumnDef, + CdkHeaderCellDef, + CdkHeaderCell, + CdkCell, + CdkFooterCellDef, + CdkFooterCell, +} from './cell'; import {CdkTable} from './table'; import { getTableTextColumnMissingParentTableError, @@ -26,13 +34,15 @@ import { import {TEXT_COLUMN_OPTIONS, TextColumnOptions} from './tokens'; /** - * Column that simply shows text content for the header and row cells. Assumes that the table - * is using the native table implementation (``). + * Column that simply shows text content for the header, row cells, and optionally for the footer. + * Assumes that the table is using the native table implementation (`
`). * * By default, the name of this column will be the header text and data property accessor. * The header text can be overridden with the `headerText` input. Cell values can be overridden with - * the `dataAccessor` input. Change the text justification to the start or end using the `justify` - * input. + * the `dataAccessor` input. If the table has a footer definition, the default footer text for this + * column will be empty. The footer text can be overridden with the `footerText` or + * `footerDataAccessor` input. Change the text justification to the start or end using the + * `justify` input. */ @Component({ selector: 'cdk-text-column', @@ -44,6 +54,9 @@ import {TEXT_COLUMN_OPTIONS, TextColumnOptions} from './tokens'; + `, encapsulation: ViewEncapsulation.None, @@ -55,7 +68,15 @@ import {TEXT_COLUMN_OPTIONS, TextColumnOptions} from './tokens'; // tslint:disable-next-line:validate-decorators changeDetection: ChangeDetectionStrategy.Default, standalone: true, - imports: [CdkColumnDef, CdkHeaderCellDef, CdkHeaderCell, CdkCellDef, CdkCell], + imports: [ + CdkCell, + CdkCellDef, + CdkColumnDef, + CdkFooterCell, + CdkFooterCellDef, + CdkHeaderCell, + CdkHeaderCellDef, + ], }) export class CdkTextColumn implements OnDestroy, OnInit { /** Column name that should be used to reference this column. */ @@ -86,6 +107,20 @@ export class CdkTextColumn implements OnDestroy, OnInit { */ @Input() dataAccessor: (data: T, name: string) => string; + /** + * Text label that should be used for the column footer. If this property is not + * set, the footer won't be displayed unless `footerDataAccessor` is set. + */ + @Input() footerText: string; + + /** + * Footer data accessor function. If this property is set, it will take precedence over the + * footerText property. If footerText is set and footerDataAccessor is not, footerText will be + * used. If neither is set, and the table has a footer defined, the footer cells will render an + * empty string. + */ + @Input() footerTextTransform: (name: string) => string; + /** Alignment of the cell values. */ @Input() justify: 'start' | 'end' | 'center' = 'start'; @@ -110,12 +145,18 @@ export class CdkTextColumn implements OnDestroy, OnInit { */ @ViewChild(CdkHeaderCellDef, {static: true}) headerCell: CdkHeaderCellDef; + /** + * The column footerCell is provided to the column during `ngOnInit` with a static query. + * @docs-private + */ + @ViewChild(CdkFooterCellDef, {static: true}) footerCell: CdkFooterCellDef; + constructor( // `CdkTextColumn` is always requiring a table, but we just assert it manually // for better error reporting. // tslint:disable-next-line: lightweight-tokens - @Optional() private _table: CdkTable, - @Optional() @Inject(TEXT_COLUMN_OPTIONS) private _options: TextColumnOptions, + @Optional() private readonly _table: CdkTable, + @Optional() @Inject(TEXT_COLUMN_OPTIONS) private readonly _options: TextColumnOptions, ) { this._options = _options || {}; } @@ -132,12 +173,15 @@ export class CdkTextColumn implements OnDestroy, OnInit { this._options.defaultDataAccessor || ((data: T, name: string) => (data as any)[name]); } + this._defineFooterTextTransform(); + if (this._table) { // Provide the cell and headerCell directly to the table with the static `ViewChild` query, // since the columnDef will not pick up its content by the time the table finishes checking // its content and initializing the rows. this.columnDef.cell = this.cell; this.columnDef.headerCell = this.headerCell; + this.columnDef.footerCell = this.footerCell; this._table.addColumnDef(this.columnDef); } else if (typeof ngDevMode === 'undefined' || ngDevMode) { throw getTableTextColumnMissingParentTableError(); @@ -154,7 +198,7 @@ export class CdkTextColumn implements OnDestroy, OnInit { * Creates a default header text. Use the options' header text transformation function if one * has been provided. Otherwise simply capitalize the column name. */ - _createDefaultHeaderText() { + _createDefaultHeaderText(): string { const name = this.name; if (!name && (typeof ngDevMode === 'undefined' || ngDevMode)) { @@ -169,9 +213,27 @@ export class CdkTextColumn implements OnDestroy, OnInit { } /** Synchronizes the column definition name with the text column name. */ - private _syncColumnDefName() { + private _syncColumnDefName(): void { if (this.columnDef) { this.columnDef.name = this.name; } } + + /** + * Defines the function to transform the footer text for the column. + * If `footerTextTransform` is not set, it will: + * - Use `footerText` if defined, or + * - Use `defaultFooterTextTransform` from options, or + * - Default to an empty string. + */ + private _defineFooterTextTransform(): void { + if (!this.footerTextTransform) { + // footerText can just be an empty string + if (this.footerText !== undefined) { + this.footerTextTransform = () => this.footerText; + } else { + this.footerTextTransform = this._options.defaultFooterTextTransform || (() => ''); + } + } + } } diff --git a/src/cdk/table/tokens.ts b/src/cdk/table/tokens.ts index 3ac037490551..97e2be7696d1 100644 --- a/src/cdk/table/tokens.ts +++ b/src/cdk/table/tokens.ts @@ -24,6 +24,9 @@ export interface TextColumnOptions { /** Default data accessor to use if one is not provided. */ defaultDataAccessor?: (data: T, name: string) => string; + + /** Default footer text transform to use if one is not provided. */ + defaultFooterTextTransform?: (name: string) => string; } /** Injection token that can be used to specify the text column options. */ diff --git a/src/components-examples/material/table/index.ts b/src/components-examples/material/table/index.ts index ef7a2c516b9d..9758d58b63b3 100644 --- a/src/components-examples/material/table/index.ts +++ b/src/components-examples/material/table/index.ts @@ -30,3 +30,4 @@ export {TableDynamicArrayDataExample} from './table-dynamic-array-data/table-dyn export {TableDynamicObservableDataExample} from './table-dynamic-observable-data/table-dynamic-observable-data-example'; export {TableGeneratedColumnsExample} from './table-generated-columns/table-generated-columns-example'; export {TableFlexLargeRowExample} from './table-flex-large-row/table-flex-large-row-example'; +export {TableTextColumnWithFooterExample} from './table-text-column-with-footer/table-text-column-with-footer-example'; diff --git a/src/components-examples/material/table/table-text-column-with-footer/table-text-column-with-footer-example.css b/src/components-examples/material/table/table-text-column-with-footer/table-text-column-with-footer-example.css new file mode 100644 index 000000000000..1922e7ffa3ad --- /dev/null +++ b/src/components-examples/material/table/table-text-column-with-footer/table-text-column-with-footer-example.css @@ -0,0 +1,3 @@ +table { + width: 100%; +} diff --git a/src/components-examples/material/table/table-text-column-with-footer/table-text-column-with-footer-example.html b/src/components-examples/material/table/table-text-column-with-footer/table-text-column-with-footer-example.html new file mode 100644 index 000000000000..fffe04481532 --- /dev/null +++ b/src/components-examples/material/table/table-text-column-with-footer/table-text-column-with-footer-example.html @@ -0,0 +1,10 @@ +
{{dataAccessor(data, name)}} + {{footerTextTransform(name)}} +
+ + + + + + + + +
diff --git a/src/components-examples/material/table/table-text-column-with-footer/table-text-column-with-footer-example.ts b/src/components-examples/material/table/table-text-column-with-footer/table-text-column-with-footer-example.ts new file mode 100644 index 000000000000..b4052170d5a1 --- /dev/null +++ b/src/components-examples/material/table/table-text-column-with-footer/table-text-column-with-footer-example.ts @@ -0,0 +1,47 @@ +import {Component} from '@angular/core'; +import {MatTableDataSource, MatTableModule} from '@angular/material/table'; +import {DecimalPipe} from '@angular/common'; + +export interface Product { + name: string; + price: number; + insurance: number; + category: string; +} + +const PRODUCT_DATA: Product[] = [ + {name: 'Laptop', price: 999.99, insurance: 100.5, category: 'Electronics'}, + {name: 'Phone', price: 699.99, insurance: 50.5, category: 'Electronics'}, + {name: 'Tablet', price: 399.99, insurance: 25.5, category: 'Electronics'}, + {name: 'Headphones', price: 199.99, insurance: 15, category: 'Accessories'}, + {name: 'Charger', price: 49.99, insurance: 0, category: 'Accessories'}, +]; + +/** + * @title Demonstrates the use of `mat-text-column` with footer cells. This example includes a fixed + * footer text for the 'name' column. The 'price' and 'insurance' columns use a text transformation + * function to determine their footer text. The 'category' column has a default empty footer text. + */ +@Component({ + selector: 'table-text-column-with-footer-example', + styleUrl: 'table-text-column-with-footer-example.css', + templateUrl: 'table-text-column-with-footer-example.html', + standalone: true, + imports: [MatTableModule], +}) +export class TableTextColumnWithFooterExample { + nameFooterText = 'Total'; + displayedColumns: string[] = ['name', 'price', 'insurance', 'category']; + dataSource = new MatTableDataSource(PRODUCT_DATA); + + decimalPipe = new DecimalPipe('en-US'); + + /** Function to sum the values of a given column. */ + getTotal = (column: string): string => { + const total = PRODUCT_DATA.map(t => t[column as keyof Product] as number).reduce( + (acc, value) => acc + value, + 0, + ); + return this.decimalPipe.transform(total, '1.2-2') || ''; + }; +} diff --git a/src/dev-app/table/table-demo.html b/src/dev-app/table/table-demo.html index 478cfd929d77..50186439b538 100644 --- a/src/dev-app/table/table-demo.html +++ b/src/dev-app/table/table-demo.html @@ -79,6 +79,9 @@

Table with mat-text-column

Table with mat-text-column advanced

+

Table with mat-text-column and footer

+ +

Table wrapped in reusable component

diff --git a/src/dev-app/table/table-demo.ts b/src/dev-app/table/table-demo.ts index 7a5c26c6a5c4..0a31b67b6040 100644 --- a/src/dev-app/table/table-demo.ts +++ b/src/dev-app/table/table-demo.ts @@ -37,6 +37,7 @@ import { TableStickyHeaderExample, TableTextColumnAdvancedExample, TableTextColumnExample, + TableTextColumnWithFooterExample, TableWrappedExample, } from '@angular/components-examples/material/table'; import {ChangeDetectionStrategy, Component} from '@angular/core'; @@ -70,6 +71,7 @@ import {ChangeDetectionStrategy, Component} from '@angular/core'; TableStickyHeaderExample, TableTextColumnAdvancedExample, TableTextColumnExample, + TableTextColumnWithFooterExample, TableWrappedExample, TableReorderableExample, TableRecycleRowsExample, diff --git a/src/material/table/text-column.ts b/src/material/table/text-column.ts index 0af0cc00db42..b57b68960004 100644 --- a/src/material/table/text-column.ts +++ b/src/material/table/text-column.ts @@ -8,16 +8,26 @@ import {CdkTextColumn} from '@angular/cdk/table'; import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core'; -import {MatColumnDef, MatHeaderCellDef, MatHeaderCell, MatCellDef, MatCell} from './cell'; +import { + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatFooterCell, + MatFooterCellDef, +} from './cell'; /** - * Column that simply shows text content for the header and row cells. Assumes that the table - * is using the native table implementation (``). + * Column that simply shows text content for the header, row cells, and optionally for the footer. + * Assumes that the table is using the native table implementation (`
`). * * By default, the name of this column will be the header text and data property accessor. * The header text can be overridden with the `headerText` input. Cell values can be overridden with - * the `dataAccessor` input. Change the text justification to the start or end using the `justify` - * input. + * the `dataAccessor` input. If the table has a footer definition, the default footer text for this + * column will be empty. The footer text can be overridden with the `footerText` or + * `footerDataAccessor` input. Change the text justification to the start or end using the + * `justify` input. */ @Component({ selector: 'mat-text-column', @@ -29,6 +39,9 @@ import {MatColumnDef, MatHeaderCellDef, MatHeaderCell, MatCellDef, MatCell} from + `, encapsulation: ViewEncapsulation.None, @@ -40,6 +53,14 @@ import {MatColumnDef, MatHeaderCellDef, MatHeaderCell, MatCellDef, MatCell} from // tslint:disable-next-line:validate-decorators changeDetection: ChangeDetectionStrategy.Default, standalone: true, - imports: [MatColumnDef, MatHeaderCellDef, MatHeaderCell, MatCellDef, MatCell], + imports: [ + MatCell, + MatCellDef, + MatColumnDef, + MatFooterCell, + MatFooterCellDef, + MatHeaderCell, + MatHeaderCellDef, + ], }) export class MatTextColumn extends CdkTextColumn {} diff --git a/tools/public_api_guard/cdk/table.md b/tools/public_api_guard/cdk/table.md index 3587400512f0..749550bed106 100644 --- a/tools/public_api_guard/cdk/table.md +++ b/tools/public_api_guard/cdk/table.md @@ -412,6 +412,9 @@ export class CdkTextColumn implements OnDestroy, OnInit { columnDef: CdkColumnDef; _createDefaultHeaderText(): string; dataAccessor: (data: T, name: string) => string; + footerCell: CdkFooterCellDef; + footerText: string; + footerTextTransform: (name: string) => string; headerCell: CdkHeaderCellDef; headerText: string; justify: 'start' | 'end' | 'center'; @@ -424,7 +427,7 @@ export class CdkTextColumn implements OnDestroy, OnInit { // (undocumented) ngOnInit(): void; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration, "cdk-text-column", never, { "name": { "alias": "name"; "required": false; }; "headerText": { "alias": "headerText"; "required": false; }; "dataAccessor": { "alias": "dataAccessor"; "required": false; }; "justify": { "alias": "justify"; "required": false; }; }, {}, never, never, true, never>; + static ɵcmp: i0.ɵɵComponentDeclaration, "cdk-text-column", never, { "name": { "alias": "name"; "required": false; }; "headerText": { "alias": "headerText"; "required": false; }; "dataAccessor": { "alias": "dataAccessor"; "required": false; }; "footerText": { "alias": "footerText"; "required": false; }; "footerTextTransform": { "alias": "footerTextTransform"; "required": false; }; "justify": { "alias": "justify"; "required": false; }; }, {}, never, never, true, never>; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, [{ optional: true; }, { optional: true; }]>; } @@ -593,6 +596,7 @@ export const TEXT_COLUMN_OPTIONS: InjectionToken>; // @public export interface TextColumnOptions { defaultDataAccessor?: (data: T, name: string) => string; + defaultFooterTextTransform?: (name: string) => string; defaultHeaderTextTransform?: (name: string) => string; }
{{dataAccessor(data, name)}} + {{footerTextTransform(name)}} +