diff --git a/src/cdk/table/BUILD.bazel b/src/cdk/table/BUILD.bazel index 5284718aee7e..5a9e4a15741f 100644 --- a/src/cdk/table/BUILD.bazel +++ b/src/cdk/table/BUILD.bazel @@ -4,6 +4,7 @@ load( "ng_module", "ng_test_library", "ng_web_test_suite", + "sass_binary", ) package(default_visibility = ["//visibility:public"]) @@ -14,6 +15,7 @@ ng_module( ["**/*.ts"], exclude = ["**/*.spec.ts"], ), + assets = [":table.css"], module_name = "@angular/cdk/table", deps = [ "//src/cdk/bidi", @@ -26,6 +28,11 @@ ng_module( ], ) +sass_binary( + name = "table_scss", + src = "table.scss", +) + ng_test_library( name = "unit_test_sources", srcs = glob( diff --git a/src/cdk/table/sticky-styler.ts b/src/cdk/table/sticky-styler.ts index 1cb73461f1af..cdde20a97834 100644 --- a/src/cdk/table/sticky-styler.ts +++ b/src/cdk/table/sticky-styler.ts @@ -27,6 +27,8 @@ export const STICKY_DIRECTIONS: StickyDirection[] = ['top', 'bottom', 'left', 'r * @docs-private */ export class StickyStyler { + private _cachedCellWidths: number[] = []; + /** * @param _isNativeHtmlTable Whether the sticky logic should be based on a table * that uses the native `` element. @@ -83,9 +85,12 @@ export class StickyStyler { * in this index position should be stuck to the start of the row. * @param stickyEndStates A list of boolean states where each state represents whether the cell * in this index position should be stuck to the end of the row. + * @param recalculateCellWidths Whether the sticky styler should recalculate the width of each + * column cell. If `false` cached widths will be used instead. */ updateStickyColumns( - rows: HTMLElement[], stickyStartStates: boolean[], stickyEndStates: boolean[]) { + rows: HTMLElement[], stickyStartStates: boolean[], stickyEndStates: boolean[], + recalculateCellWidths = true) { if (!rows.length || !this._isBrowser || !(stickyStartStates.some(state => state) || stickyEndStates.some(state => state))) { return; @@ -93,7 +98,7 @@ export class StickyStyler { const firstRow = rows[0]; const numCells = firstRow.children.length; - const cellWidths: number[] = this._getCellWidths(firstRow); + const cellWidths: number[] = this._getCellWidths(firstRow, recalculateCellWidths); const startPositions = this._getStickyStartColumnPositions(cellWidths, stickyStartStates); const endPositions = this._getStickyEndColumnPositions(cellWidths, stickyEndStates); @@ -275,7 +280,11 @@ export class StickyStyler { } /** Gets the widths for each cell in the provided row. */ - _getCellWidths(row: HTMLElement): number[] { + _getCellWidths(row: HTMLElement, recalculateCellWidths = true): number[] { + if (!recalculateCellWidths && this._cachedCellWidths.length) { + return this._cachedCellWidths; + } + const cellWidths: number[] = []; const firstRowCells = row.children; for (let i = 0; i < firstRowCells.length; i++) { @@ -283,6 +292,7 @@ export class StickyStyler { cellWidths.push(cell.getBoundingClientRect().width); } + this._cachedCellWidths = cellWidths; return cellWidths; } diff --git a/src/cdk/table/table.scss b/src/cdk/table/table.scss new file mode 100644 index 000000000000..556226aa82e5 --- /dev/null +++ b/src/cdk/table/table.scss @@ -0,0 +1,3 @@ +.cdk-table-fixed-layout { + table-layout: fixed; +} diff --git a/src/cdk/table/table.ts b/src/cdk/table/table.ts index 1670b9bcdff5..f62734435c90 100644 --- a/src/cdk/table/table.ts +++ b/src/cdk/table/table.ts @@ -20,6 +20,7 @@ import { _ViewRepeaterOperation, } from '@angular/cdk/collections'; import {Platform} from '@angular/cdk/platform'; +import {ViewportRuler} from '@angular/cdk/scrolling'; import {DOCUMENT} from '@angular/common'; import { AfterContentChecked, @@ -188,8 +189,10 @@ export interface RenderRow { selector: 'cdk-table, table[cdk-table]', exportAs: 'cdkTable', template: CDK_TABLE_TEMPLATE, + styleUrls: ['table.css'], host: { 'class': 'cdk-table', + '[class.cdk-table-fixed-layout]': 'fixedLayout', }, encapsulation: ViewEncapsulation.None, // The "OnPush" status for the `MatTable` component is effectively a noop, so we are removing it. @@ -291,6 +294,19 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes */ private _footerRowDefChanged = true; + /** + * Whether the sticky column styles need to be updated. Set to `true` when the visible columns + * change. + */ + private _stickyColumnStylesNeedReset = true; + + /** + * Whether the sticky styler should recalculate cell widths when applying sticky styles. If + * `false`, cached values will be used instead. This is only applicable to tables with + * {@link fixedLayout} enabled. For other tables, cell widths will always be recalculated. + */ + private _forceRecalculateCellWidths = true; + /** * Cache of the latest rendered `RenderRow` objects as a map for easy retrieval when constructing * a new list of `RenderRow` objects for rendering rows. Since the new list is constructed with @@ -403,6 +419,19 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes } _multiTemplateDataRows: boolean = false; + /** + * Whether to use a fixed table layout. Enabling this option will enforce consistent column widths + * and optimize rendering sticky styles for native tables. No-op for flex tables. + */ + @Input() + get fixedLayout(): boolean { + return this._fixedLayout; + } + set fixedLayout(v: boolean) { + this._fixedLayout = coerceBooleanProperty(v); + } + private _fixedLayout: boolean = false; + // TODO(andrewseguin): Remove max value as the end index // and instead calculate the view on init and scroll. /** @@ -452,7 +481,9 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes // Optional for backwards compatibility, but a view repeater strategy will always // be provided. @Optional() @Inject(_VIEW_REPEATER_STRATEGY) - protected readonly _viewRepeater: _ViewRepeater, RowContext>) { + protected readonly _viewRepeater: _ViewRepeater, RowContext>, + // Optional for backwards compatibility, but a view ruler will always be provided. + @Optional() private readonly _viewportRuler: ViewportRuler) { if (!role) { this._elementRef.nativeElement.setAttribute('role', 'grid'); } @@ -474,6 +505,12 @@ 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; }); + + // Table cell dimensions may change after resizing the window. Signal the sticky styler to + // refresh its cache of cell widths the next time sticky styles are updated. + this._viewportRuler.change().pipe(takeUntil(this._onDestroy)).subscribe(() => { + this._forceRecalculateCellWidths = true; + }); } ngAfterContentChecked() { @@ -488,8 +525,9 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes // Render updates if the list of columns have been changed for the header, row, or footer defs. const columnsChanged = this._renderUpdatedColumns(); - const stickyColumnStyleUpdateNeeded = - columnsChanged || this._headerRowDefChanged || this._footerRowDefChanged; + const rowDefsChanged = columnsChanged || this._headerRowDefChanged || this._footerRowDefChanged; + this._stickyColumnStylesNeedReset = rowDefsChanged; + this._forceRecalculateCellWidths = rowDefsChanged; // If the header row definition has been changed, trigger a render to the header row. if (this._headerRowDefChanged) { @@ -507,7 +545,7 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes // connection has already been made. if (this.dataSource && this._rowDefs.length > 0 && !this._renderChangeSubscription) { this._observeRenderChanges(); - } else if (stickyColumnStyleUpdateNeeded) { + } else if (this._stickyColumnStylesNeedReset) { // In the above case, _observeRenderChanges will result in updateStickyColumnStyles being // called when it row data arrives. Otherwise, we need to call it proactively. this.updateStickyColumnStyles(); @@ -689,10 +727,18 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes const dataRows = this._getRenderedRows(this._rowOutlet); const footerRows = this._getRenderedRows(this._footerRowOutlet); - // Clear the left and right positioning from all columns in the table across all rows since - // sticky columns span across all table sections (header, data, footer) - this._stickyStyler.clearStickyPositioning( - [...headerRows, ...dataRows, ...footerRows], ['left', 'right']); + // For tables not using a fixed layout, the column widths may change when new rows are rendered. + // In a table using a fixed layout, row content won't affect column width, so sticky styles + // don't need to be cleared unless either the sticky column config changes or one of the row + // defs change. + if ((this._isNativeHtmlTable && !this._fixedLayout) + || this._stickyColumnStylesNeedReset) { + // Clear the left and right positioning from all columns in the table across all rows since + // sticky columns span across all table sections (header, data, footer) + this._stickyStyler.clearStickyPositioning( + [...headerRows, ...dataRows, ...footerRows], ['left', 'right']); + this._stickyColumnStylesNeedReset = false; + } // Update the sticky styles for each header row depending on the def's sticky state headerRows.forEach((headerRow, i) => { @@ -934,7 +980,9 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes }); const stickyStartStates = columnDefs.map(columnDef => columnDef.sticky); const stickyEndStates = columnDefs.map(columnDef => columnDef.stickyEnd); - this._stickyStyler.updateStickyColumns(rows, stickyStartStates, stickyEndStates); + this._stickyStyler.updateStickyColumns( + rows, stickyStartStates, stickyEndStates, + !this._fixedLayout || this._forceRecalculateCellWidths); } /** Gets the list of rows that have been rendered in the row outlet. */ @@ -1113,6 +1161,7 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes } if (Array.from(this._columnDefsByName.values()).reduce(stickyCheckReducer, false)) { + this._stickyColumnStylesNeedReset = true; this.updateStickyColumnStyles(); } } @@ -1154,6 +1203,7 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes } static ngAcceptInputType_multiTemplateDataRows: BooleanInput; + static ngAcceptInputType_fixedLayout: BooleanInput; } /** Utility function that gets a merged list of the entries in an array and values of a Set. */ diff --git a/src/components-examples/cdk/table/cdk-table-fixed-layout/cdk-table-fixed-layout-example.css b/src/components-examples/cdk/table/cdk-table-fixed-layout/cdk-table-fixed-layout-example.css new file mode 100644 index 000000000000..edaff91eb1a2 --- /dev/null +++ b/src/components-examples/cdk/table/cdk-table-fixed-layout/cdk-table-fixed-layout-example.css @@ -0,0 +1,7 @@ +table { + width: 100%; +} + +th { + text-align: left; +} diff --git a/src/components-examples/cdk/table/cdk-table-fixed-layout/cdk-table-fixed-layout-example.html b/src/components-examples/cdk/table/cdk-table-fixed-layout/cdk-table-fixed-layout-example.html new file mode 100644 index 000000000000..f981320a6b51 --- /dev/null +++ b/src/components-examples/cdk/table/cdk-table-fixed-layout/cdk-table-fixed-layout-example.html @@ -0,0 +1,28 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
No. {{element.position}} Name {{element.name}} Weight {{element.weight}} Symbol {{element.symbol}}
diff --git a/src/components-examples/cdk/table/cdk-table-fixed-layout/cdk-table-fixed-layout-example.ts b/src/components-examples/cdk/table/cdk-table-fixed-layout/cdk-table-fixed-layout-example.ts new file mode 100644 index 000000000000..c3cb10998f08 --- /dev/null +++ b/src/components-examples/cdk/table/cdk-table-fixed-layout/cdk-table-fixed-layout-example.ts @@ -0,0 +1,55 @@ +import {DataSource} from '@angular/cdk/collections'; +import {Component} from '@angular/core'; +import {BehaviorSubject, Observable} from 'rxjs'; + +export interface PeriodicElement { + name: string; + position: number; + weight: number; + symbol: string; +} + +const ELEMENT_DATA: PeriodicElement[] = [ + {position: 1, name: 'Hydrogen', weight: 1.0079, symbol: 'H'}, + {position: 2, name: 'Helium', weight: 4.0026, symbol: 'He'}, + {position: 3, name: 'Lithium', weight: 6.941, symbol: 'Li'}, + {position: 4, name: 'Beryllium', weight: 9.0122, symbol: 'Be'}, + {position: 5, name: 'Boron', weight: 10.811, symbol: 'B'}, + {position: 6, name: 'Carbon', weight: 12.0107, symbol: 'C'}, + {position: 7, name: 'Nitrogen', weight: 14.0067, symbol: 'N'}, + {position: 8, name: 'Oxygen', weight: 15.9994, symbol: 'O'}, + {position: 9, name: 'Fluorine', weight: 18.9984, symbol: 'F'}, + {position: 10, name: 'Neon', weight: 20.1797, symbol: 'Ne'}, +]; + +/** + * @title CDK table with a fixed layout. + */ +@Component({ + selector: 'cdk-table-fixed-layout-example', + styleUrls: ['cdk-table-fixed-layout-example.css'], + templateUrl: 'cdk-table-fixed-layout-example.html', +}) +export class CdkTableFixedLayoutExample { + displayedColumns: string[] = ['position', 'name', 'weight', 'symbol']; + dataSource = new ExampleDataSource(); +} + +/** + * Data source to provide what data should be rendered in the table. Note that the data source + * can retrieve its data in any way. In this case, the data source is provided a reference + * to a common data base, ExampleDatabase. It is not the data source's responsibility to manage + * the underlying data. Instead, it only needs to take the data and send the table exactly what + * should be rendered. + */ +export class ExampleDataSource extends DataSource { + /** Stream of data that is provided to the table. */ + data = new BehaviorSubject(ELEMENT_DATA); + + /** Connect function called by the table to retrieve one stream containing the data to render. */ + connect(): Observable { + return this.data; + } + + disconnect() {} +} diff --git a/src/components-examples/cdk/table/index.ts b/src/components-examples/cdk/table/index.ts index 5992abba0cfb..6944295d4ede 100644 --- a/src/components-examples/cdk/table/index.ts +++ b/src/components-examples/cdk/table/index.ts @@ -2,15 +2,20 @@ import {CdkTableModule} from '@angular/cdk/table'; import {NgModule} from '@angular/core'; import {CdkTableFlexBasicExample} from './cdk-table-flex-basic/cdk-table-flex-basic-example'; import {CdkTableBasicExample} from './cdk-table-basic/cdk-table-basic-example'; +import { + CdkTableFixedLayoutExample, +} from './cdk-table-fixed-layout/cdk-table-fixed-layout-example'; export { CdkTableBasicExample, CdkTableFlexBasicExample, + CdkTableFixedLayoutExample, }; const EXAMPLES = [ CdkTableBasicExample, CdkTableFlexBasicExample, + CdkTableFixedLayoutExample, ]; @NgModule({ diff --git a/src/dev-app/table/table-demo.html b/src/dev-app/table/table-demo.html index 4793e15e5484..c4c07b9e4783 100644 --- a/src/dev-app/table/table-demo.html +++ b/src/dev-app/table/table-demo.html @@ -1,6 +1,9 @@

Cdk table basic

+

Cdk table basic with fixed column widths

+ +

Cdk table basic flex

diff --git a/src/material-experimental/mdc-table/table.ts b/src/material-experimental/mdc-table/table.ts index 0e67b5d97f5a..2a79367bcffb 100644 --- a/src/material-experimental/mdc-table/table.ts +++ b/src/material-experimental/mdc-table/table.ts @@ -17,6 +17,7 @@ import {_DisposeViewRepeaterStrategy, _VIEW_REPEATER_STRATEGY} from '@angular/cd styleUrls: ['table.css'], host: { 'class': 'mat-mdc-table mdc-data-table__table', + '[class.mat-table-fixed-layout]': 'fixedLayout', }, providers: [ {provide: CdkTable, useExisting: MatTable}, diff --git a/src/material/table/table.scss b/src/material/table/table.scss index 8ee7994267e3..09c7bcc3ee75 100644 --- a/src/material/table/table.scss +++ b/src/material/table/table.scss @@ -120,3 +120,7 @@ th.mat-header-cell:last-of-type, td.mat-cell:last-of-type, td.mat-footer-cell:la .mat-table-sticky { @include vendor-prefixes.position-sticky; } + +.mat-table-fixed-layout { + table-layout: fixed; +} diff --git a/src/material/table/table.ts b/src/material/table/table.ts index be4a12d6ed95..468dfc2a69fc 100644 --- a/src/material/table/table.ts +++ b/src/material/table/table.ts @@ -25,6 +25,7 @@ import {_DisposeViewRepeaterStrategy, _VIEW_REPEATER_STRATEGY} from '@angular/cd styleUrls: ['table.css'], host: { 'class': 'mat-table', + '[class.mat-table-fixed-layout]': 'fixedLayout', }, providers: [ // TODO(michaeljamesparsons) Abstract the view repeater strategy to a directive API so this code diff --git a/tools/public_api_guard/cdk/table.d.ts b/tools/public_api_guard/cdk/table.d.ts index f4936087c8f9..17fba7c49c02 100644 --- a/tools/public_api_guard/cdk/table.d.ts +++ b/tools/public_api_guard/cdk/table.d.ts @@ -203,6 +203,8 @@ export declare class CdkTable implements AfterContentChecked, CollectionViewe protected readonly _viewRepeater: _ViewRepeater, RowContext>; get dataSource(): CdkTableDataSourceInput; set dataSource(dataSource: CdkTableDataSourceInput); + get fixedLayout(): boolean; + set fixedLayout(v: boolean); get multiTemplateDataRows(): boolean; set multiTemplateDataRows(v: boolean); protected needsPositionStickyOnElement: boolean; @@ -213,7 +215,7 @@ export declare class CdkTable implements AfterContentChecked, CollectionViewe start: number; end: number; }>; - constructor(_differs: IterableDiffers, _changeDetectorRef: ChangeDetectorRef, _coalescedStyleScheduler: _CoalescedStyleScheduler, _elementRef: ElementRef, role: string, _dir: Directionality, _document: any, _platform: Platform, _viewRepeater: _ViewRepeater, RowContext>); + constructor(_differs: IterableDiffers, _changeDetectorRef: ChangeDetectorRef, _coalescedStyleScheduler: _CoalescedStyleScheduler, _elementRef: ElementRef, role: string, _dir: Directionality, _document: any, _platform: Platform, _viewRepeater: _ViewRepeater, RowContext>, _viewportRuler: ViewportRuler); _getRenderedRows(rowOutlet: RowOutlet): HTMLElement[]; _getRowDefs(data: T, dataIndex: number): CdkRowDef[]; addColumnDef(columnDef: CdkColumnDef): void; @@ -231,9 +233,10 @@ export declare class CdkTable implements AfterContentChecked, CollectionViewe updateStickyColumnStyles(): void; updateStickyFooterRowStyles(): void; updateStickyHeaderRowStyles(): void; + static ngAcceptInputType_fixedLayout: BooleanInput; static ngAcceptInputType_multiTemplateDataRows: BooleanInput; - static ɵcmp: i0.ɵɵComponentDefWithMeta, "cdk-table, table[cdk-table]", ["cdkTable"], { "trackBy": "trackBy"; "dataSource": "dataSource"; "multiTemplateDataRows": "multiTemplateDataRows"; }, {}, ["_noDataRow", "_contentColumnDefs", "_contentRowDefs", "_contentHeaderRowDefs", "_contentFooterRowDefs"], ["caption", "colgroup, col"]>; - static ɵfac: i0.ɵɵFactoryDef, [null, null, null, null, { attribute: "role"; }, { optional: true; }, null, null, { optional: true; }]>; + static ɵcmp: i0.ɵɵComponentDefWithMeta, "cdk-table, table[cdk-table]", ["cdkTable"], { "trackBy": "trackBy"; "dataSource": "dataSource"; "multiTemplateDataRows": "multiTemplateDataRows"; "fixedLayout": "fixedLayout"; }, {}, ["_noDataRow", "_contentColumnDefs", "_contentRowDefs", "_contentHeaderRowDefs", "_contentFooterRowDefs"], ["caption", "colgroup, col"]>; + static ɵfac: i0.ɵɵFactoryDef, [null, null, null, null, { attribute: "role"; }, { optional: true; }, null, null, { optional: true; }, { optional: true; }]>; } export declare class CdkTableModule { @@ -321,13 +324,13 @@ export declare class StickyStyler { constructor(_isNativeHtmlTable: boolean, _stickCellCss: string, direction: Direction, _coalescedStyleScheduler: _CoalescedStyleScheduler, _isBrowser?: boolean, _needsPositionStickyOnElement?: boolean); _addStickyStyle(element: HTMLElement, dir: StickyDirection, dirValue: number): void; _getCalculatedZIndex(element: HTMLElement): string; - _getCellWidths(row: HTMLElement): number[]; + _getCellWidths(row: HTMLElement, recalculateCellWidths?: boolean): number[]; _getStickyEndColumnPositions(widths: number[], stickyStates: boolean[]): number[]; _getStickyStartColumnPositions(widths: number[], stickyStates: boolean[]): number[]; _removeStickyStyle(element: HTMLElement, stickyDirections: StickyDirection[]): void; clearStickyPositioning(rows: HTMLElement[], stickyDirections: StickyDirection[]): void; stickRows(rowsToStick: HTMLElement[], stickyStates: boolean[], position: 'top' | 'bottom'): void; - updateStickyColumns(rows: HTMLElement[], stickyStartStates: boolean[], stickyEndStates: boolean[]): void; + updateStickyColumns(rows: HTMLElement[], stickyStartStates: boolean[], stickyEndStates: boolean[], recalculateCellWidths?: boolean): void; updateStickyFooterContainer(tableElement: Element, stickyStates: boolean[]): void; }