From 641edc3d51bb5cfb567d107482f68372cbf3a89f Mon Sep 17 00:00:00 2001 From: Andrew Seguin Date: Fri, 11 May 2018 10:38:13 -0700 Subject: [PATCH] feat(table): allow multiple header/footer rows (#11245) --- src/cdk/table/table-errors.ts | 2 +- src/cdk/table/table.spec.ts | 159 ++++++++++++--- src/cdk/table/table.ts | 183 ++++++++++++------ src/lib/table/table.spec.ts | 72 +++++-- .../table-multiple-header-footer-example.css | 19 ++ .../table-multiple-header-footer-example.html | 45 +++++ .../table-multiple-header-footer-example.ts | 32 +++ 7 files changed, 404 insertions(+), 108 deletions(-) create mode 100644 src/material-examples/table-multiple-header-footer/table-multiple-header-footer-example.css create mode 100644 src/material-examples/table-multiple-header-footer/table-multiple-header-footer-example.html create mode 100644 src/material-examples/table-multiple-header-footer/table-multiple-header-footer-example.ts diff --git a/src/cdk/table/table-errors.ts b/src/cdk/table/table-errors.ts index 6f9922794a7..25eab443465 100644 --- a/src/cdk/table/table-errors.ts +++ b/src/cdk/table/table-errors.ts @@ -45,7 +45,7 @@ export function getTableMissingMatchingRowDefError(data: any) { * @docs-private */ export function getTableMissingRowDefsError() { - return Error('Missing definitions for header and row, ' + + return Error('Missing definitions for header, footer, and row; ' + 'cannot determine which columns should be rendered.'); } diff --git a/src/cdk/table/table.spec.ts b/src/cdk/table/table.spec.ts index a8fb4a92e05..cdb21d42683 100644 --- a/src/cdk/table/table.spec.ts +++ b/src/cdk/table/table.spec.ts @@ -69,11 +69,11 @@ describe('CdkTable', () => { }); it('with a rendered header with the right number of header cells', () => { - const header = getHeaderRow(tableElement); + const header = getHeaderRows(tableElement)[0]; expect(header).toBeTruthy(); expect(header.classList).toContain('customHeaderRowClass'); - expect(getHeaderCells(tableElement).length).toBe(component.columnsToRender.length); + expect(getHeaderCells(header).length).toBe(component.columnsToRender.length); }); it('with rendered rows with right number of row cells', () => { @@ -87,7 +87,8 @@ describe('CdkTable', () => { }); it('with column class names provided to header and data row cells', () => { - getHeaderCells(tableElement).forEach((headerCell, index) => { + const header = getHeaderRows(tableElement)[0]; + getHeaderCells(header).forEach((headerCell, index) => { expect(headerCell.classList).toContain(`cdk-column-${component.columnsToRender[index]}`); }); @@ -101,8 +102,9 @@ describe('CdkTable', () => { it('with the right accessibility roles', () => { expect(tableElement.getAttribute('role')).toBe('grid'); - expect(getHeaderRow(tableElement).getAttribute('role')).toBe('row'); - getHeaderCells(tableElement).forEach(cell => { + expect(getHeaderRows(tableElement)[0].getAttribute('role')).toBe('row'); + const header = getHeaderRows(tableElement)[0]; + getHeaderCells(header).forEach(cell => { expect(cell.getAttribute('role')).toBe('columnheader'); }); @@ -279,6 +281,40 @@ describe('CdkTable', () => { expect(getRows(tableElement).length).toBe(0); })); + it('should be able to render multiple header and footer rows', () => { + setupTableTestApp(MultipleHeaderFooterRowsCdkTableApp); + fixture.detectChanges(); + + expectTableToMatchContent(tableElement, [ + ['first-header'], + ['second-header'], + ['first-footer'], + ['second-footer'], + ]); + }); + + it('should be able to render and change multiple header and footer rows', () => { + setupTableTestApp(MultipleHeaderFooterRowsCdkTableApp); + fixture.detectChanges(); + + expectTableToMatchContent(tableElement, [ + ['first-header'], + ['second-header'], + ['first-footer'], + ['second-footer'], + ]); + + component.showAlternativeHeadersAndFooters = true; + fixture.detectChanges(); + + expectTableToMatchContent(tableElement, [ + ['first-header'], + ['second-header'], + ['first-footer'], + ['second-footer'], + ]); + }); + describe('with different data inputs other than data source', () => { let baseData: TestData[] = [ {a: 'a_1', b: 'b_1', c: 'c_1'}, @@ -460,7 +496,8 @@ describe('CdkTable', () => { it('should be able to apply class-friendly css class names for the column cells', () => { setupTableTestApp(CrazyColumnNameCdkTableApp); // Column was named 'crazy-column-NAME-1!@#$%^-_&*()2' - expect(getHeaderCells(tableElement)[0].classList) + const header = getHeaderRows(tableElement)[0]; + expect(getHeaderCells(header)[0].classList) .toContain('cdk-column-crazy-column-NAME-1-------_----2'); }); @@ -488,7 +525,7 @@ describe('CdkTable', () => { setupTableTestApp(UndefinedColumnsCdkTableApp); // Header should be empty since there are no columns to display. - const headerRow = getHeaderRow(tableElement); + const headerRow = getHeaderRows(tableElement)[0]; expect(headerRow.textContent).toBe(''); // Rows should be empty since there are no columns to display. @@ -657,6 +694,8 @@ describe('CdkTable', () => { }); }); + + describe('with trackBy', () => { function createTestComponentWithTrackyByTable(trackByStrategy) { fixture = createComponent(TrackByCdkTableApp); @@ -1053,6 +1092,62 @@ class NullDataCdkTableApp { dataSource = observableOf(null); } + +@Component({ + template: ` + + + first-header + + + + second-header + + + + first-footer + + + + second-footer + + + + + + + + + + + alt-first-header + + + + alt-second-header + + + + alt-first-footer + + + + alt-second-footer + + + + + + + + + + ` +}) +class MultipleHeaderFooterRowsCdkTableApp { + showAlternativeHeadersAndFooters = false; +} + @Component({ template: ` @@ -1586,42 +1681,62 @@ function getElements(element: Element, query: string): Element[] { return [].slice.call(element.querySelectorAll(query)); } -function getHeaderRow(tableElement: Element): Element { - return tableElement.querySelector('.cdk-header-row')!; +function getHeaderRows(tableElement: Element): Element[] { + return [].slice.call(tableElement.querySelectorAll('.cdk-header-row'))!; } -function getFooterRow(tableElement: Element): Element { - return tableElement.querySelector('.cdk-footer-row')!; +function getFooterRows(tableElement: Element): Element[] { + return [].slice.call(tableElement.querySelectorAll('.cdk-footer-row'))!; } function getRows(tableElement: Element): Element[] { return getElements(tableElement, '.cdk-row'); } + function getCells(row: Element): Element[] { - return row ? getElements(row, '.cdk-cell') : []; + if (!row) { + return []; + } + + let cells = getElements(row, 'cdk-cell'); + if (!cells.length) { + cells = getElements(row, 'td'); + } + + return cells; } -function getHeaderCells(tableElement: Element): Element[] { - return getElements(getHeaderRow(tableElement), '.cdk-header-cell'); +function getHeaderCells(headerRow: Element): Element[] { + let cells = getElements(headerRow, 'cdk-header-cell'); + if (!cells.length) { + cells = getElements(headerRow, 'th'); + } + + return cells; } -function getFooterCells(tableElement: Element): Element[] { - return getElements(getFooterRow(tableElement), '.cdk-footer-cell'); +function getFooterCells(footerRow: Element): Element[] { + let cells = getElements(footerRow, 'cdk-footer-cell'); + if (!cells.length) { + cells = getElements(footerRow, 'td'); + } + + return cells; } function getActualTableContent(tableElement: Element): string[][] { let actualTableContent: Element[][] = []; - if (getHeaderRow(tableElement)) { - actualTableContent.push(getHeaderCells(tableElement)); - } + getHeaderRows(tableElement).forEach(row => { + actualTableContent.push(getHeaderCells(row)); + }); // Check data row cells const rows = getRows(tableElement).map(row => getCells(row)); actualTableContent = actualTableContent.concat(rows); - if (getFooterRow(tableElement)) { - actualTableContent.push(getFooterCells(tableElement)); - } + getFooterRows(tableElement).forEach(row => { + actualTableContent.push(getFooterCells(row)); + }); // Convert the nodes into their text content; return actualTableContent.map(row => row.map(cell => cell.textContent!.trim())); diff --git a/src/cdk/table/table.ts b/src/cdk/table/table.ts index 52e5c50c684..e5edccde882 100644 --- a/src/cdk/table/table.ts +++ b/src/cdk/table/table.ts @@ -13,7 +13,6 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, - ContentChild, ContentChildren, Directive, ElementRef, @@ -153,7 +152,7 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes private _data: T[]; /** List of the rendered rows as identified by their `RenderRow` object. */ - private _renderRows: RenderRow[]; + private _renderRows: RenderRow[]; /** Subscription that listens for the data provided by the data source. */ private _renderChangeSubscription: Subscription | null; @@ -166,34 +165,70 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes private _columnDefsByName = new Map(); /** - * Set of all row defitions that can be used by this table. Populated by the rows gathered by + * Set of all row definitions that can be used by this table. Populated by the rows gathered by * using `ContentChildren` as well as any custom row definitions added to `_customRowDefs`. */ private _rowDefs: CdkRowDef[]; + /** + * Set of all header row definitions that can be used by this table. Populated by the rows + * gathered by using `ContentChildren` as well as any custom row definitions added to + * `_customHeaderRowDefs`. + */ + private _headerRowDefs: CdkHeaderRowDef[]; + + /** + * Set of all row definitions that can be used by this table. Populated by the rows gathered by + * using `ContentChildren` as well as any custom row definitions added to + * `_customFooterRowDefs`. + */ + private _footerRowDefs: CdkFooterRowDef[]; + /** Differ used to find the changes in the data provided by the data source. */ private _dataDiffer: IterableDiffer>; /** Stores the row definition that does not have a when predicate. */ private _defaultRowDef: CdkRowDef | null; - /** Column definitions that were defined outside of the direct content children of the table. */ + /** + * Column definitions that were defined outside of the direct content children of the table. + * These will be defined when, e.g., creating a wrapper around the cdkTable that has + * column definitions as *it's* content child. + */ private _customColumnDefs = new Set(); - /** Row definitions that were defined outside of the direct content children of the table. */ + /** + * Data row definitions that were defined outside of the direct content children of the table. + * These will be defined when, e.g., creating a wrapper around the cdkTable that has + * built-in data rows as *it's* content child. + */ private _customRowDefs = new Set>(); /** - * Whether the header row definition has been changed. Triggers an update to the header row after - * content is checked. + * Header row definitions that were defined outside of the direct content children of the table. + * These will be defined when, e.g., creating a wrapper around the cdkTable that has + * built-in header rows as *it's* content child. */ - private _headerRowDefChanged = false; + private _customHeaderRowDefs = new Set(); + + /** + * Footer row definitions that were defined outside of the direct content children of the table. + * These will be defined when, e.g., creating a wrapper around the cdkTable that has a + * built-in footer row as *it's* content child. + */ + private _customFooterRowDefs = new Set(); + + /** + * Whether the header row definition has been changed. Triggers an update to the header row after + * content is checked. Initialized as true so that the table renders the initial set of rows. + */ + private _headerRowDefChanged = true; /** * Whether the footer row definition has been changed. Triggers an update to the footer row after - * content is checked. + * content is checked. Initialized as true so that the table renders the initial set of rows. */ - private _footerRowDefChanged = false; + private _footerRowDefChanged = true; /** * Cache of the latest rendered `RenderRow` objects as a map for easy retrieval when constructing @@ -268,7 +303,7 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes set multiTemplateDataRows(v: boolean) { this._multiTemplateDataRows = coerceBooleanProperty(v); if (this._rowOutlet.viewContainer.length) { - this._forceRenderRows(); + this._forceRenderDataRows(); } } _multiTemplateDataRows: boolean = false; @@ -293,24 +328,14 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes */ @ContentChildren(CdkColumnDef) _contentColumnDefs: QueryList; - /** Set of template definitions that used as the data row containers. */ + /** Set of data row definitions that were provided to the table as content children. */ @ContentChildren(CdkRowDef) _contentRowDefs: QueryList>; - /** - * Template definition used as the header container. By default it stores the header row - * definition found as a direct content child. Override this value through `setHeaderRowDef` if - * the header row definition should be changed or was not defined as a part of the table's - * content. - */ - @ContentChild(CdkHeaderRowDef) _headerRowDef: CdkHeaderRowDef; + /** Set of header row definitions that were provided to the table as content children. */ + @ContentChildren(CdkHeaderRowDef) _contentHeaderRowDefs: QueryList; - /** - * Template definition used as the footer container. By default it stores the footer row - * definition found as a direct content child. Override this value through `setFooterRowDef` if - * the footer row definition should be changed or was not defined as a part of the table's - * content. - */ - @ContentChild(CdkFooterRowDef) _footerRowDef: CdkFooterRowDef; + /** Set of footer row definitions that were provided to the table as content children. */ + @ContentChildren(CdkFooterRowDef) _contentFooterRowDefs: QueryList; constructor(protected readonly _differs: IterableDiffers, protected readonly _changeDetectorRef: ChangeDetectorRef, @@ -332,11 +357,6 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes this._dataDiffer = this._differs.find([]).create((_i: number, dataRow: RenderRow) => { return this.trackBy ? this.trackBy(dataRow.dataIndex, dataRow.data) : dataRow; }); - - // If the table has header or footer row definitions defined as part of its content, mark that - // there is a change so that the content check will render the row. - this._headerRowDefChanged = !!this._headerRowDef; - this._footerRowDefChanged = !!this._footerRowDef; } ngAfterContentChecked() { @@ -345,7 +365,7 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes this._cacheColumnDefs(); // Make sure that the user has at least added header, footer, or data row def. - if (!this._headerRowDef && !this._footerRowDef && !this._rowDefs.length) { + if (!this._headerRowDefs.length && !this._footerRowDefs.length && !this._rowDefs.length) { throw getTableMissingRowDefsError(); } @@ -354,13 +374,13 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes // If the header row definition has been changed, trigger a render to the header row. if (this._headerRowDefChanged) { - this._renderHeaderRow(); + this._forceRenderHeaderRows(); this._headerRowDefChanged = false; } // If the footer row definition has been changed, trigger a render to the footer row. if (this._footerRowDefChanged) { - this._renderFooterRow(); + this._forceRenderFooterRows(); this._footerRowDefChanged = false; } @@ -429,9 +449,12 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes * Sets the header row definition to be used. Overrides the header row definition gathered by * using `ContentChild`, if one exists. Sets a flag that will re-render the header row after the * table's content is checked. + * @docs-private + * @deprecated Use `addHeaderRowDef` and `removeHeaderRowDef` instead + * @deletion-target 8.0.0 */ setHeaderRowDef(headerRowDef: CdkHeaderRowDef) { - this._headerRowDef = headerRowDef; + this._customHeaderRowDefs = new Set([headerRowDef]); this._headerRowDefChanged = true; } @@ -439,32 +462,59 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes * Sets the footer row definition to be used. Overrides the footer row definition gathered by * using `ContentChild`, if one exists. Sets a flag that will re-render the footer row after the * table's content is checked. + * @docs-private + * @deprecated Use `addFooterRowDef` and `removeFooterRowDef` instead + * @deletion-target 8.0.0 */ setFooterRowDef(footerRowDef: CdkFooterRowDef) { - this._footerRowDef = footerRowDef; + this._customFooterRowDefs = new Set([footerRowDef]); this._footerRowDefChanged = true; } - /** Adds a column definition that was not included as part of the direct content children. */ + /** Adds a column definition that was not included as part of the content children. */ addColumnDef(columnDef: CdkColumnDef) { this._customColumnDefs.add(columnDef); } - /** Removes a column definition that was not included as part of the direct content children. */ + /** Removes a column definition that was not included as part of the content children. */ removeColumnDef(columnDef: CdkColumnDef) { this._customColumnDefs.delete(columnDef); } - /** Adds a row definition that was not included as part of the direct content children. */ + /** Adds a row definition that was not included as part of the content children. */ addRowDef(rowDef: CdkRowDef) { this._customRowDefs.add(rowDef); } - /** Removes a row definition that was not included as part of the direct content children. */ + /** Removes a row definition that was not included as part of the content children. */ removeRowDef(rowDef: CdkRowDef) { this._customRowDefs.delete(rowDef); } + /** Adds a header row definition that was not included as part of the content children. */ + addHeaderRowDef(headerRowDef: CdkHeaderRowDef) { + this._customHeaderRowDefs.add(headerRowDef); + this._headerRowDefChanged = true; + } + + /** Removes a header row definition that was not included as part of the content children. */ + removeHeaderRowDef(headerRowDef: CdkHeaderRowDef) { + this._customHeaderRowDefs.delete(headerRowDef); + this._headerRowDefChanged = true; + } + + /** Adds a footer row definition that was not included as part of the content children. */ + addFooterRowDef(footerRowDef: CdkFooterRowDef) { + this._customFooterRowDefs.add(footerRowDef); + this._footerRowDefChanged = true; + } + + /** Removes a footer row definition that was not included as part of the content children. */ + removeFooterRowDef(footerRowDef: CdkFooterRowDef) { + this._customFooterRowDefs.delete(footerRowDef); + this._footerRowDefChanged = true; + } + /** * Get the list of RenderRow objects to render according to the current list of data and defined * row definitions. If the previous list already contained a particular pair, it should be reused @@ -529,9 +579,7 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes private _cacheColumnDefs() { this._columnDefsByName.clear(); - const columnDefs = this._contentColumnDefs ? this._contentColumnDefs.toArray() : []; - this._customColumnDefs.forEach(columnDef => columnDefs.push(columnDef)); - + const columnDefs = mergeQueryListAndSet(this._contentColumnDefs, this._customColumnDefs); columnDefs.forEach(columnDef => { if (this._columnDefsByName.has(columnDef.name)) { throw getTableDuplicateColumnNameError(columnDef.name); @@ -542,9 +590,14 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes /** Update the list of all available row definitions that can be used. */ private _cacheRowDefs() { - this._rowDefs = this._contentRowDefs ? this._contentRowDefs.toArray() : []; - this._customRowDefs.forEach(rowDef => this._rowDefs.push(rowDef)); + this._headerRowDefs = + mergeQueryListAndSet(this._contentHeaderRowDefs, this._customHeaderRowDefs); + this._footerRowDefs = + mergeQueryListAndSet(this._contentFooterRowDefs, this._customFooterRowDefs); + this._rowDefs = + mergeQueryListAndSet(this._contentRowDefs, this._customRowDefs); + // After all row definitions are determined, find the row definition to be considered default. const defaultRowDefs = this._rowDefs.filter(def => !def.when); if (!this.multiTemplateDataRows && defaultRowDefs.length > 1) { throw getTableMultipleDefaultRowDefsError(); @@ -557,21 +610,18 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes * If there is a diff, then re-render that section. */ private _renderUpdatedColumns() { - // Re-render the rows when the row definition columns change. - this._rowDefs.forEach(def => { - if (def.getColumnsDiff()) { - this._forceRenderRows(); - } - }); + const defColumnsDiffReducer = (accumulator, def) => accumulator || !!def.getColumnsDiff(); - // Re-render the header row if there is a difference in its columns. - if (this._headerRowDef && this._headerRowDef.getColumnsDiff()) { - this._renderHeaderRow(); + if (this._rowDefs.reduce(defColumnsDiffReducer, false)) { + this._forceRenderDataRows(); } - // Re-render the footer row if there is a difference in its columns. - if (this._footerRowDef && this._footerRowDef.getColumnsDiff()) { - this._renderFooterRow(); + if (this._headerRowDefs.reduce(defColumnsDiffReducer, false)) { + this._forceRenderHeaderRows(); + } + + if (this._footerRowDefs.reduce(defColumnsDiffReducer, false)) { + this._forceRenderFooterRows(); } } @@ -638,26 +688,26 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes * Clears any existing content in the header row outlet and creates a new embedded view * in the outlet using the header row definition. */ - private _renderHeaderRow() { + private _forceRenderHeaderRows() { // Clear the footer row outlet if any content exists. if (this._headerRowOutlet.viewContainer.length > 0) { this._headerRowOutlet.viewContainer.clear(); } - this._renderRow(this._headerRowOutlet, this._headerRowDef); + this._headerRowDefs.forEach((def, i) => this._renderRow(this._headerRowOutlet, def, i)); } /** * Clears any existing content in the footer row outlet and creates a new embedded view * in the outlet using the footer row definition. */ - private _renderFooterRow() { + private _forceRenderFooterRows() { // Clear the footer row outlet if any content exists. if (this._footerRowOutlet.viewContainer.length > 0) { this._footerRowOutlet.viewContainer.clear(); } - this._renderRow(this._footerRowOutlet, this._footerRowDef); + this._footerRowDefs.forEach((def, i) => this._renderRow(this._footerRowOutlet, def, i)); } /** @@ -694,7 +744,7 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes private _insertRow(renderRow: RenderRow, renderIndex: number) { const rowDef = renderRow.rowDef; const context: RowContext = {$implicit: renderRow.data}; - this._renderRow(this._rowOutlet, rowDef, context, renderIndex); + this._renderRow(this._rowOutlet, rowDef, renderIndex, context); } /** @@ -703,7 +753,7 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes * of where to place the new row template in the outlet. */ private _renderRow( - outlet: RowOutlet, rowDef: BaseRowDef, context: RowContext = {}, index = 0) { + outlet: RowOutlet, rowDef: BaseRowDef, index: number, context: RowContext = {}) { // TODO(andrewseguin): enforce that one outlet was instantiated from createEmbeddedView outlet.viewContainer.createEmbeddedView(rowDef.template, context, index); @@ -774,9 +824,14 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes * change that affects the evaluation of which rows should be rendered, e.g. toggling * `multiTemplateDataRows` or adding/removing row definitions. */ - private _forceRenderRows() { + private _forceRenderDataRows() { this._dataDiffer.diff([]); this._rowOutlet.viewContainer.clear(); this.renderRows(); } } + +/** Utility function that gets a merged list of the entries in a QueryList and values of a Set. */ +function mergeQueryListAndSet(queryList: QueryList, set: Set): T[] { + return queryList.toArray().concat(Array.from(set)); +} diff --git a/src/lib/table/table.spec.ts b/src/lib/table/table.spec.ts index 72233fa0c00..89a32e074af 100644 --- a/src/lib/table/table.spec.ts +++ b/src/lib/table/table.spec.ts @@ -48,12 +48,12 @@ describe('MatTable', () => { const tableElement = fixture.nativeElement.querySelector('.mat-table'); expectTableToMatchContent(tableElement, [ - ['Column A', 'Column B', 'Column C'], + ['Column A'], ['a_1'], ['a_2'], ['a_3'], ['fourth_row'], - ['Footer A', 'Footer B', 'Footer C'], + ['Footer A'], ]); }); @@ -64,13 +64,13 @@ describe('MatTable', () => { const tableElement = fixture.nativeElement.querySelector('.mat-table'); expectTableToMatchContent(tableElement, [ - ['Column A', 'Column B', 'Column C'], + ['Column A'], ['a_1'], ['a_2'], ['a_3'], ['a_4'], // With multiple rows, this row shows up along with the special 'when' fourth_row ['fourth_row'], - ['Footer A', 'Footer B', 'Footer C'], + ['Footer A'], ]); }); }); @@ -693,42 +693,62 @@ function getElements(element: Element, query: string): Element[] { return [].slice.call(element.querySelectorAll(query)); } -function getHeaderRow(tableElement: Element): Element { - return tableElement.querySelector('.mat-header-row')!; +function getHeaderRows(tableElement: Element): Element[] { + return [].slice.call(tableElement.querySelectorAll('.mat-header-row'))!; } -function getFooterRow(tableElement: Element): Element { - return tableElement.querySelector('.mat-footer-row')!; +function getFooterRows(tableElement: Element): Element[] { + return [].slice.call(tableElement.querySelectorAll('.mat-footer-row'))!; } function getRows(tableElement: Element): Element[] { return getElements(tableElement, '.mat-row'); } + function getCells(row: Element): Element[] { - return row ? getElements(row, '.mat-cell') : []; + if (!row) { + return []; + } + + let cells = getElements(row, 'mat-cell'); + if (!cells.length) { + cells = getElements(row, 'td'); + } + + return cells; } -function getHeaderCells(tableElement: Element): Element[] { - return getElements(getHeaderRow(tableElement), '.mat-header-cell'); +function getHeaderCells(headerRow: Element): Element[] { + let cells = getElements(headerRow, 'mat-header-cell'); + if (!cells.length) { + cells = getElements(headerRow, 'th'); + } + + return cells; } -function getFooterCells(tableElement: Element): Element[] { - return getElements(getFooterRow(tableElement), '.mat-footer-cell'); +function getFooterCells(footerRow: Element): Element[] { + let cells = getElements(footerRow, 'mat-footer-cell'); + if (!cells.length) { + cells = getElements(footerRow, 'td'); + } + + return cells; } function getActualTableContent(tableElement: Element): string[][] { let actualTableContent: Element[][] = []; - if (getHeaderRow(tableElement)) { - actualTableContent.push(getHeaderCells(tableElement)); - } + getHeaderRows(tableElement).forEach(row => { + actualTableContent.push(getHeaderCells(row)); + }); // Check data row cells const rows = getRows(tableElement).map(row => getCells(row)); actualTableContent = actualTableContent.concat(rows); - if (getFooterRow(tableElement)) { - actualTableContent.push(getFooterCells(tableElement)); - } + getFooterRows(tableElement).forEach(row => { + actualTableContent.push(getFooterCells(row)); + }); // Convert the nodes into their text content; return actualTableContent.map(row => row.map(cell => cell.textContent!.trim())); @@ -738,19 +758,29 @@ function expectTableToMatchContent(tableElement: Element, expected: any[]) { const missedExpectations: string[] = []; function checkCellContent(actualCell: string, expectedCell: string) { if (actualCell !== expectedCell) { - missedExpectations.push(`Expected cell contents to be ${actualCell} but was ${expectedCell}`); + missedExpectations.push(`Expected cell contents to be ${expectedCell} but was ${actualCell}`); } } const actual = getActualTableContent(tableElement); + // Make sure the number of rows match if (actual.length !== expected.length) { missedExpectations.push(`Expected ${expected.length} total rows but got ${actual.length}`); fail(missedExpectations.join('\n')); } + actual.forEach((row, rowIndex) => { + const expectedRow = expected[rowIndex]; + + // Make sure the number of cells match + if (row.length !== expectedRow.length) { + missedExpectations.push(`Expected ${expectedRow.length} cells in row but got ${row.length}`); + fail(missedExpectations.join('\n')); + } + row.forEach((actualCell, cellIndex) => { - const expectedCell = expected[rowIndex] ? expected[rowIndex][cellIndex] : null; + const expectedCell = expectedRow ? expectedRow[cellIndex] : null; checkCellContent(actualCell, expectedCell); }); }); diff --git a/src/material-examples/table-multiple-header-footer/table-multiple-header-footer-example.css b/src/material-examples/table-multiple-header-footer/table-multiple-header-footer-example.css new file mode 100644 index 00000000000..8700a8afb9f --- /dev/null +++ b/src/material-examples/table-multiple-header-footer/table-multiple-header-footer-example.css @@ -0,0 +1,19 @@ +table { + width: 100%; +} + +.example-first-header-row th { + border-bottom: none; +} + +.example-second-header-row { + font-style: italic; +} + +.example-first-footer-row { + font-weight: bold; +} + +.example-second-footer-row td { + color: #900000; +} diff --git a/src/material-examples/table-multiple-header-footer/table-multiple-header-footer-example.html b/src/material-examples/table-multiple-header-footer/table-multiple-header-footer-example.html new file mode 100644 index 00000000000..04b5b162bbb --- /dev/null +++ b/src/material-examples/table-multiple-header-footer/table-multiple-header-footer-example.html @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Item {{transaction.item}} Total Cost {{transaction.cost | currency}} {{getTotalCost() | currency}} Name of the item purchased Cost of the item in USD + Please note that the cost of items displayed are completely and totally made up. +
diff --git a/src/material-examples/table-multiple-header-footer/table-multiple-header-footer-example.ts b/src/material-examples/table-multiple-header-footer/table-multiple-header-footer-example.ts new file mode 100644 index 00000000000..1832eb4ba3f --- /dev/null +++ b/src/material-examples/table-multiple-header-footer/table-multiple-header-footer-example.ts @@ -0,0 +1,32 @@ +import {Component} from '@angular/core'; +import {Transaction} from '../table-footer-row/table-footer-row-example'; + +export interface Transaction { + item: string; + cost: number; +} + +/** + * @title Table with multiple header and footer rows + */ +@Component({ + selector: 'table-multiple-header-footer-example', + styleUrls: ['table-multiple-header-footer-example.css'], + templateUrl: 'table-multiple-header-footer-example.html', +}) +export class TableMultipleHeaderFooterExample { + displayedColumns = ['item', 'cost']; + transactions: Transaction[] = [ + {item: 'Beach ball', cost: 4}, + {item: 'Towel', cost: 5}, + {item: 'Frisbee', cost: 2}, + {item: 'Sunscreen', cost: 4}, + {item: 'Cooler', cost: 25}, + {item: 'Swim suit', cost: 15}, + ]; + + /** Gets the total cost of all transactions. */ + getTotalCost() { + return this.transactions.map(t => t.cost).reduce((acc, value) => acc + value, 0); + } +}