diff --git a/.firebaserc b/.firebaserc new file mode 100644 index 000000000000..29239030f445 --- /dev/null +++ b/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "staging": "material2-dev" + } +} \ No newline at end of file diff --git a/src/cdk/table/BUILD.bazel b/src/cdk/table/BUILD.bazel index 3654d070812f..bd6e1469962c 100644 --- a/src/cdk/table/BUILD.bazel +++ b/src/cdk/table/BUILD.bazel @@ -8,6 +8,7 @@ ng_module( srcs = glob(["**/*.ts"], exclude=["**/*.spec.ts"]), module_name = "@angular/cdk/table", deps = [ + "//src/cdk/bidi", "//src/cdk/collections", "//src/cdk/coercion", "@rxjs", @@ -21,6 +22,7 @@ ts_library( srcs = glob(["**/*.spec.ts"]), deps = [ ":table", + "//src/cdk/bidi", "//src/cdk/collections", "@rxjs", "@rxjs//operators" diff --git a/src/cdk/table/can-stick.ts b/src/cdk/table/can-stick.ts new file mode 100644 index 000000000000..41775e48a5e4 --- /dev/null +++ b/src/cdk/table/can-stick.ts @@ -0,0 +1,68 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {coerceBooleanProperty} from '@angular/cdk/coercion'; + +/** @docs-private */ +export type Constructor = new(...args: any[]) => T; + +/** + * Interface for a mixin to provide a directive with a function that checks if the sticky input has + * been changed since the last time the function was called. Essentially adds a dirty-check to the + * sticky value. + * @docs-private + */ +export interface CanStick { + /** Whether sticky positioning should be applied. */ + sticky: boolean; + + /** Whether the sticky input has changed since it was last checked. */ + _hasStickyChanged: boolean; + + /** Whether the sticky value has changed since this was last called. */ + hasStickyChanged(): boolean; + + /** Resets the dirty check for cases where the sticky state has been used without checking. */ + resetStickyChanged(): void; +} + +/** + * Mixin to provide a directive with a function that checks if the sticky input has been + * changed since the last time the function was called. Essentially adds a dirty-check to the + * sticky value. + */ +export function mixinHasStickyInput>(base: T): + Constructor & T { + return class extends base { + /** Whether sticky positioning should be applied. */ + get sticky(): boolean { return this._sticky; } + set sticky(v: boolean) { + const prevValue = this._sticky; + this._sticky = coerceBooleanProperty(v); + this._hasStickyChanged = prevValue !== this._sticky; + } + _sticky: boolean = false; + + /** Whether the sticky input has changed since it was last checked. */ + _hasStickyChanged: boolean = false; + + /** Whether the sticky value has changed since this was last called. */ + hasStickyChanged(): boolean { + const hasStickyChanged = this._hasStickyChanged; + this._hasStickyChanged = false; + return hasStickyChanged; + } + + /** Resets the dirty check for cases where the sticky state has been used without checking. */ + resetStickyChanged() { + this._hasStickyChanged = false; + } + + constructor(...args: any[]) { super(...args); } + }; +} diff --git a/src/cdk/table/cell.ts b/src/cdk/table/cell.ts index c63608f951e1..e6cbb797664f 100644 --- a/src/cdk/table/cell.ts +++ b/src/cdk/table/cell.ts @@ -7,6 +7,8 @@ */ import {ContentChild, Directive, ElementRef, Input, TemplateRef} from '@angular/core'; +import {coerceBooleanProperty} from '@angular/cdk/coercion'; +import {CanStick, mixinHasStickyInput} from './can-stick'; /** Base interface for a cell definition. Captures a column's cell template definition. */ export interface CellDef { @@ -40,12 +42,20 @@ export class CdkFooterCellDef implements CellDef { constructor(/** @docs-private */ public template: TemplateRef) { } } +// Boilerplate for applying mixins to CdkColumnDef. +/** @docs-private */ +export class CdkColumnDefBase {} +export const _CdkColumnDefBase = mixinHasStickyInput(CdkColumnDefBase); + /** * Column definition for the CDK table. * Defines a set of cells available for a table column. */ -@Directive({selector: '[cdkColumnDef]'}) -export class CdkColumnDef { +@Directive({ + selector: '[cdkColumnDef]', + inputs: ['sticky'] +}) +export class CdkColumnDef extends _CdkColumnDefBase implements CanStick { /** Unique name for this column. */ @Input('cdkColumnDef') get name(): string { return this._name; } @@ -59,6 +69,20 @@ export class CdkColumnDef { } _name: string; + /** + * Whether this column should be sticky positioned on the end of the row. Should make sure + * that it mimics the `CanStick` mixin such that `_hasStickyChanged` is set to true if the value + * has been changed. + */ + @Input('stickyEnd') + get stickyEnd(): boolean { return this._stickyEnd; } + set stickyEnd(v: boolean) { + const prevValue = this._stickyEnd; + this._stickyEnd = coerceBooleanProperty(v); + this._hasStickyChanged = prevValue !== this._stickyEnd; + } + _stickyEnd: boolean = false; + /** @docs-private */ @ContentChild(CdkCellDef) cell: CdkCellDef; diff --git a/src/cdk/table/public-api.ts b/src/cdk/table/public-api.ts index 5ea6957e7ecc..f993cd312529 100644 --- a/src/cdk/table/public-api.ts +++ b/src/cdk/table/public-api.ts @@ -10,6 +10,8 @@ export * from './table'; export * from './cell'; export * from './row'; export * from './table-module'; +export * from './sticky-styler'; +export * from './can-stick'; /** Re-export DataSource for a more intuitive experience for users of just the table. */ export {DataSource} from '@angular/cdk/collections'; diff --git a/src/cdk/table/row.ts b/src/cdk/table/row.ts index 4e1dbb41bd3c..6b5363f62c92 100644 --- a/src/cdk/table/row.ts +++ b/src/cdk/table/row.ts @@ -20,6 +20,7 @@ import { ViewEncapsulation, } from '@angular/core'; import {CdkCellDef, CdkColumnDef} from './cell'; +import {CanStick, mixinHasStickyInput} from './can-stick'; /** * The row template that can be used by the mat-table. Should not be used outside of the @@ -44,8 +45,8 @@ export abstract class BaseRowDef implements OnChanges { ngOnChanges(changes: SimpleChanges): void { // Create a new columns differ if one does not yet exist. Initialize it based on initial value // of the columns property or an empty array if none is provided. - const columns = changes['columns'].currentValue || []; if (!this._columnsDiffer) { + const columns = (changes['columns'] && changes['columns'].currentValue) || []; this._columnsDiffer = this._differs.find(columns).create(); this._columnsDiffer.diff(columns); } @@ -60,44 +61,64 @@ export abstract class BaseRowDef implements OnChanges { } /** Gets this row def's relevant cell template from the provided column def. */ - abstract extractCellTemplate(column: CdkColumnDef): TemplateRef; + extractCellTemplate(column: CdkColumnDef): TemplateRef { + if (this instanceof CdkHeaderRowDef) { + return column.headerCell.template; + } if (this instanceof CdkFooterRowDef) { + return column.footerCell.template; + } else { + return column.cell.template; + } + } } +// Boilerplate for applying mixins to CdkHeaderRowDef. +/** @docs-private */ +export class CdkHeaderRowDefBase extends BaseRowDef {} +export const _CdkHeaderRowDefBase = mixinHasStickyInput(CdkHeaderRowDefBase); + /** * Header row definition for the CDK table. * Captures the header row's template and other header properties such as the columns to display. */ @Directive({ selector: '[cdkHeaderRowDef]', - inputs: ['columns: cdkHeaderRowDef'], + inputs: ['columns: cdkHeaderRowDef', 'sticky: cdkHeaderRowDefSticky'], }) -export class CdkHeaderRowDef extends BaseRowDef { +export class CdkHeaderRowDef extends _CdkHeaderRowDefBase implements CanStick, OnChanges { constructor(template: TemplateRef, _differs: IterableDiffers) { super(template, _differs); } - /** Gets this row def's relevant cell template from the provided column def. */ - extractCellTemplate(column: CdkColumnDef): TemplateRef { - return column.headerCell.template; + // Prerender fails to recognize that ngOnChanges in a part of this class through inheritance. + // Explicitly define it so that the method is called as part of the Angular lifecycle. + ngOnChanges(changes: SimpleChanges): void { + super.ngOnChanges(changes); } } +// Boilerplate for applying mixins to CdkFooterRowDef. +/** @docs-private */ +export class CdkFooterRowDefBase extends BaseRowDef {} +export const _CdkFooterRowDefBase = mixinHasStickyInput(CdkFooterRowDefBase); + /** * Footer row definition for the CDK table. * Captures the footer row's template and other footer properties such as the columns to display. */ @Directive({ selector: '[cdkFooterRowDef]', - inputs: ['columns: cdkFooterRowDef'], + inputs: ['columns: cdkFooterRowDef', 'sticky: cdkFooterRowDefSticky'], }) -export class CdkFooterRowDef extends BaseRowDef { +export class CdkFooterRowDef extends _CdkFooterRowDefBase implements CanStick, OnChanges { constructor(template: TemplateRef, _differs: IterableDiffers) { super(template, _differs); } - /** Gets this row def's relevant cell template from the provided column def. */ - extractCellTemplate(column: CdkColumnDef): TemplateRef { - return column.footerCell.template; + // Prerender fails to recognize that ngOnChanges in a part of this class through inheritance. + // Explicitly define it so that the method is called as part of the Angular lifecycle. + ngOnChanges(changes: SimpleChanges): void { + super.ngOnChanges(changes); } } @@ -124,11 +145,6 @@ export class CdkRowDef extends BaseRowDef { constructor(template: TemplateRef, _differs: IterableDiffers) { super(template, _differs); } - - /** Gets this row def's relevant cell template from the provided column def. */ - extractCellTemplate(column: CdkColumnDef): TemplateRef { - return column.cell.template; - } } /** Context provided to the row cells when `multiTemplateDataRows` is false */ diff --git a/src/cdk/table/sticky-styler.ts b/src/cdk/table/sticky-styler.ts new file mode 100644 index 000000000000..8525fdb3e8b9 --- /dev/null +++ b/src/cdk/table/sticky-styler.ts @@ -0,0 +1,262 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/** + * Directions that can be used when setting sticky positioning. + * @docs-private + */ +import {Direction} from '@angular/cdk/bidi'; + +export type StickyDirection = 'top' | 'bottom' | 'left' | 'right'; + +/** + * List of all possible directions that can be used for sticky positioning. + * @docs-private + */ +export const STICKY_DIRECTIONS: StickyDirection[] = ['top', 'bottom', 'left', 'right']; + +/** + * Applies and removes sticky positioning styles to the `CdkTable` rows and columns cells. + * @docs-private + */ +export class StickyStyler { + /** + * @param isNativeHtmlTable Whether the sticky logic should be based on a table + * that uses the native `` element. + * @param stickCellCss The CSS class that will be applied to every row/cell that has + * sticky positioning applied. + * @param direction The directionality context of the table (ltr/rtl); affects column positioning + * by reversing left/right positions. + */ + constructor(private isNativeHtmlTable: boolean, + private stickCellCss: string, + public direction: Direction) { } + + /** + * Clears the sticky positioning styles from the row and its cells by resetting the `position` + * style, setting the zIndex to 0, and unsetting each provided sticky direction. + * @param rows The list of rows that should be cleared from sticking in the provided directions + * @param stickyDirections The directions that should no longer be set as sticky on the rows. + */ + clearStickyPositioning(rows: HTMLElement[], stickyDirections: StickyDirection[]) { + for (const row of rows) { + this._removeStickyStyle(row, stickyDirections); + for (let i = 0; i < row.children.length; i++) { + const cell = row.children[i] as HTMLElement; + this._removeStickyStyle(cell, stickyDirections); + } + } + } + + /** + * Applies sticky left and right positions to the cells of each row according to the sticky + * states of the rendered column definitions. + * @param rows The rows that should have its set of cells stuck according to the sticky states. + * @param stickyStartStates A list of boolean states where each state represents whether the cell + * 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. + */ + updateStickyColumns( + rows: HTMLElement[], stickyStartStates: boolean[], stickyEndStates: boolean[]) { + const hasStickyColumns = + stickyStartStates.some(state => state) || stickyEndStates.some(state => state); + if (!rows.length || !hasStickyColumns) { + return; + } + + const firstRow = rows[0]; + const numCells = firstRow.children.length; + const cellWidths: number[] = this._getCellWidths(firstRow); + + const startPositions = this._getStickyStartColumnPositions(cellWidths, stickyStartStates); + const endPositions = this._getStickyEndColumnPositions(cellWidths, stickyEndStates); + const isRtl = this.direction === 'rtl'; + + for (const row of rows) { + for (let i = 0; i < numCells; i++) { + const cell = row.children[i] as HTMLElement; + if (stickyStartStates[i]) { + this._addStickyStyle(cell, isRtl ? 'right' : 'left', startPositions[i]); + } + + if (stickyEndStates[i]) { + this._addStickyStyle(cell, isRtl ? 'left' : 'right', endPositions[i]); + } + } + } + } + + /** + * Applies sticky positioning to the row's cells if using the native table layout, and to the + * row itself otherwise. + * @param rowsToStick The list of rows that should be stuck according to their corresponding + * sticky state and to the provided top or bottom position. + * @param stickyStates A list of boolean states where each state represents whether the row + * should be stuck in the particular top or bottom position. + * @param position The position direction in which the row should be stuck if that row should be + * sticky. + * + */ + stickRows(rowsToStick: HTMLElement[], stickyStates: boolean[], position: 'top' | 'bottom') { + // If positioning the rows to the bottom, reverse their order when evaluating the sticky + // position such that the last row stuck will be "bottom: 0px" and so on. + const rows = position === 'bottom' ? rowsToStick.reverse() : rowsToStick; + + let stickyHeight = 0; + for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) { + if (!stickyStates[rowIndex]) { + continue; + } + + const row = rows[rowIndex]; + if (this.isNativeHtmlTable) { + for (let j = 0; j < row.children.length; j++) { + const cell = row.children[j] as HTMLElement; + this._addStickyStyle(cell, position, stickyHeight); + } + } else { + // Flex does not respect the stick positioning on the cells, needs to be applied to the row. + // If this is applied on a native table, Safari causes the header to fly in wrong direction. + this._addStickyStyle(row, position, stickyHeight); + } + + stickyHeight += row.getBoundingClientRect().height; + } + } + + /** + * When using the native table in Safari, sticky footer cells do not stick. The only way to stick + * footer rows is to apply sticky styling to the tfoot container. This should only be done if + * all footer rows are sticky. If not all footer rows are sticky, remove sticky positioning from + * the tfoot element. + */ + updateStickyFooterContainer(tableElement: Element, stickyStates: boolean[]) { + if (!this.isNativeHtmlTable) { + return; + } + + const tfoot = tableElement.querySelector('tfoot')!; + if (stickyStates.some(state => !state)) { + this._removeStickyStyle(tfoot, ['bottom']); + } else { + this._addStickyStyle(tfoot, 'bottom', 0); + } + } + + /** + * Removes the sticky style on the element by removing the sticky cell CSS class, re-evaluating + * the zIndex, removing each of the provided sticky directions, and removing the + * sticky position if there are no more directions. + */ + _removeStickyStyle(element: HTMLElement, stickyDirections: StickyDirection[]) { + for (const dir of stickyDirections) { + element.style[dir] = ''; + } + element.style.zIndex = this._getCalculatedZIndex(element); + + // If the element no longer has any more sticky directions, remove sticky positioning and + // the sticky CSS class. + const hasDirection = STICKY_DIRECTIONS.some(dir => !!element.style[dir]); + if (!hasDirection) { + element.style.position = ''; + element.classList.remove(this.stickCellCss); + } + } + + /** + * Adds the sticky styling to the element by adding the sticky style class, changing position + * to be sticky (and -webkit-sticky), setting the appropriate zIndex, and adding a sticky + * direction and value. + */ + _addStickyStyle(element: HTMLElement, dir: StickyDirection, dirValue: number) { + element.classList.add(this.stickCellCss); + element.style[dir] = `${dirValue}px`; + element.style.cssText += 'position: -webkit-sticky; position: sticky; '; + element.style.zIndex = this._getCalculatedZIndex(element); + } + + /** + * Calculate what the z-index should be for the element, depending on what directions (top, + * bottom, left, right) have been set. It should be true that elements with a top direction + * should have the highest index since these are elements like a table header. If any of those + * elements are also sticky in another direction, then they should appear above other elements + * that are only sticky top (e.g. a sticky column on a sticky header). Bottom-sticky elements + * (e.g. footer rows) should then be next in the ordering such that they are below the header + * but above any non-sticky elements. Finally, left/right sticky elements (e.g. sticky columns) + * should minimally increment so that they are above non-sticky elements but below top and bottom + * elements. + */ + _getCalculatedZIndex(element: HTMLElement): string { + const zIndexIncrements = { + top: 100, + bottom: 10, + left: 1, + right: 1, + }; + + let zIndex = 0; + for (const dir of STICKY_DIRECTIONS) { + if (element.style[dir]) { + zIndex += zIndexIncrements[dir]; + } + } + + return zIndex ? `${zIndex}` : ''; + } + + /** Gets the widths for each cell in the provided row. */ + _getCellWidths(row: HTMLElement): number[] { + const cellWidths: number[] = []; + const firstRowCells = row.children; + for (let i = 0; i < firstRowCells.length; i++) { + let cell: HTMLElement = firstRowCells[i] as HTMLElement; + cellWidths.push(cell.getBoundingClientRect().width); + } + + return cellWidths; + } + + /** + * Determines the left and right positions of each sticky column cell, which will be the + * accumulation of all sticky column cell widths to the left and right, respectively. + * Non-sticky cells do not need to have a value set since their positions will not be applied. + */ + _getStickyStartColumnPositions(widths: number[], stickyStates: boolean[]): number[] { + const positions: number[] = []; + let nextPosition = 0; + + for (let i = 0; i < widths.length; i++) { + if (stickyStates[i]) { + positions[i] = nextPosition; + nextPosition += widths[i]; + } + } + + return positions; + } + + /** + * Determines the left and right positions of each sticky column cell, which will be the + * accumulation of all sticky column cell widths to the left and right, respectively. + * Non-sticky cells do not need to have a value set since their positions will not be applied. + */ + _getStickyEndColumnPositions(widths: number[], stickyStates: boolean[]): number[] { + const positions: number[] = []; + let nextPosition = 0; + + for (let i = widths.length; i > 0; i--) { + if (stickyStates[i]) { + positions[i] = nextPosition; + nextPosition += widths[i]; + } + } + + return positions; + } +} diff --git a/src/cdk/table/table.spec.ts b/src/cdk/table/table.spec.ts index cdb21d42683f..4e2d3980a9ef 100644 --- a/src/cdk/table/table.spec.ts +++ b/src/cdk/table/table.spec.ts @@ -24,6 +24,7 @@ import { getTableUnknownColumnError, getTableUnknownDataSourceError } from './table-errors'; +import {BidiModule} from '@angular/cdk/bidi'; describe('CdkTable', () => { let fixture: ComponentFixture; @@ -33,7 +34,7 @@ describe('CdkTable', () => { function createComponent(componentType: Type, declarations: any[] = []): ComponentFixture { TestBed.configureTestingModule({ - imports: [CdkTableModule], + imports: [CdkTableModule, BidiModule], declarations: [componentType, ...declarations], }).compileComponents(); @@ -43,9 +44,9 @@ describe('CdkTable', () => { function setupTableTestApp(componentType: Type, declarations: any[] = []) { fixture = createComponent(componentType, declarations); component = fixture.componentInstance; - tableElement = fixture.nativeElement.querySelector('cdk-table'); - fixture.detectChanges(); + + tableElement = fixture.nativeElement.querySelector('.cdk-table'); } describe('in a typical simple use case', () => { @@ -694,7 +695,385 @@ describe('CdkTable', () => { }); }); + describe('with sticky positioning', () => { + interface PositionDirections { + top?: string; + bottom?: string; + left?: string; + right?: string; + } + + function expectNoStickyStyles(elements: any[]) { + elements.forEach(element => { + expect(element.classList.contains('cdk-table-sticky')); + expect(element.style.position).toBe(''); + expect(element.style.zIndex || '0').toBe('0'); + ['top', 'bottom', 'left', 'right'].forEach(d => { + expect(element.style[d] || 'unset').toBe('unset', `Expected ${d} to be unset`); + }); + }); + } + + function expectStickyStyles( + element: any, zIndex: string, directions: PositionDirections = {}) { + expect(element.style.position).toContain('sticky'); + expect(element.style.zIndex).toBe(zIndex, `Expected zIndex to be ${zIndex}`); + + ['top', 'bottom', 'left', 'right'].forEach(d => { + if (!directions[d]) { + // If no expected position for this direction, must either be unset or empty string + expect(element.style[d] || 'unset').toBe('unset', `Expected ${d} to be unset`); + return; + } + + expect(element.style[d]) + .toBe(directions[d], `Expected direction ${d} to be ${directions[d]}`); + }); + } + + describe('on "display: flex" table style', () => { + let dataRows: Element[]; + let headerRows: Element[]; + let footerRows: Element[]; + + beforeEach(() => { + setupTableTestApp(StickyFlexLayoutCdkTableApp); + + headerRows = getHeaderRows(tableElement); + footerRows = getFooterRows(tableElement); + dataRows = getRows(tableElement); + }); + + it('should stick and unstick headers', () => { + component.stickyHeaders = ['header-1', 'header-3']; + fixture.detectChanges(); + + expectStickyStyles(headerRows[0], '100', {top: '0px'}); + expectNoStickyStyles([headerRows[1]]); + expectStickyStyles(headerRows[2], '100', + {top: headerRows[0].getBoundingClientRect().height + 'px'}); + + component.stickyHeaders = []; + fixture.detectChanges(); + expectNoStickyStyles(headerRows); + }); + + it('should stick and unstick footers', () => { + component.stickyFooters = ['footer-1', 'footer-3']; + fixture.detectChanges(); + + expectStickyStyles(footerRows[0], '10', + {bottom: footerRows[1].getBoundingClientRect().height + 'px'}); + expectNoStickyStyles([footerRows[1]]); + expectStickyStyles(footerRows[2], '10', {bottom: '0px'}); + + component.stickyFooters = []; + fixture.detectChanges(); + expectNoStickyStyles(footerRows); + }); + + it('should stick and unstick left columns', () => { + component.stickyStartColumns = ['column-1', 'column-3']; + fixture.detectChanges(); + + headerRows.forEach(row => { + let cells = getHeaderCells(row); + expectStickyStyles(cells[0], '1', {left: '0px'}); + expectStickyStyles(cells[2], '1', {left: '20px'}); + expectNoStickyStyles([cells[1], cells[3], cells[4], cells[5]]); + }); + dataRows.forEach(row => { + let cells = getCells(row); + expectStickyStyles(cells[0], '1', {left: '0px'}); + expectStickyStyles(cells[2], '1', {left: '20px'}); + expectNoStickyStyles([cells[1], cells[3], cells[4], cells[5]]); + }); + footerRows.forEach(row => { + let cells = getFooterCells(row); + expectStickyStyles(cells[0], '1', {left: '0px'}); + expectStickyStyles(cells[2], '1', {left: '20px'}); + expectNoStickyStyles([cells[1], cells[3], cells[4], cells[5]]); + }); + + component.stickyStartColumns = []; + fixture.detectChanges(); + headerRows.forEach(row => expectNoStickyStyles(getHeaderCells(row))); + dataRows.forEach(row => expectNoStickyStyles(getCells(row))); + footerRows.forEach(row => expectNoStickyStyles(getFooterCells(row))); + }); + + it('should stick and unstick right columns', () => { + component.stickyEndColumns = ['column-4', 'column-6']; + fixture.detectChanges(); + + headerRows.forEach(row => { + let cells = getHeaderCells(row); + expectStickyStyles(cells[5], '1', {right: '0px'}); + expectStickyStyles(cells[3], '1', {right: '20px'}); + expectNoStickyStyles([cells[0], cells[1], cells[2], cells[4]]); + }); + dataRows.forEach(row => { + let cells = getCells(row); + expectStickyStyles(cells[5], '1', {right: '0px'}); + expectStickyStyles(cells[3], '1', {right: '20px'}); + expectNoStickyStyles([cells[0], cells[1], cells[2], cells[4]]); + }); + footerRows.forEach(row => { + let cells = getFooterCells(row); + expectStickyStyles(cells[5], '1', {right: '0px'}); + expectStickyStyles(cells[3], '1', {right: '20px'}); + expectNoStickyStyles([cells[0], cells[1], cells[2], cells[4]]); + }); + + component.stickyEndColumns = []; + fixture.detectChanges(); + headerRows.forEach(row => expectNoStickyStyles(getHeaderCells(row))); + dataRows.forEach(row => expectNoStickyStyles(getCells(row))); + footerRows.forEach(row => expectNoStickyStyles(getFooterCells(row))); + }); + + it('should reverse directions for sticky columns in rtl', () => { + component.dir = 'rtl'; + component.stickyStartColumns = ['column-1', 'column-2']; + component.stickyEndColumns = ['column-5', 'column-6']; + fixture.detectChanges(); + + const firstColumnWidth = getHeaderCells(headerRows[0])[0].getBoundingClientRect().width; + const lastColumnWidth = getHeaderCells(headerRows[0])[5].getBoundingClientRect().width; + + let headerCells = getHeaderCells(headerRows[0]); + expectStickyStyles(headerCells[0], '1', {right: '0px'}); + expectStickyStyles(headerCells[1], '1', {right: `${firstColumnWidth}px`}); + expectStickyStyles(headerCells[4], '1', {left: `${lastColumnWidth}px`}); + expectStickyStyles(headerCells[5], '1', {left: '0px'}); + + dataRows.forEach(row => { + let cells = getCells(row); + expectStickyStyles(cells[0], '1', {right: '0px'}); + expectStickyStyles(cells[1], '1', {right: `${firstColumnWidth}px`}); + expectStickyStyles(cells[4], '1', {left: `${lastColumnWidth}px`}); + expectStickyStyles(cells[5], '1', {left: '0px'}); + }); + + let footerCells = getFooterCells(footerRows[0]); + expectStickyStyles(footerCells[0], '1', {right: '0px'}); + expectStickyStyles(footerCells[1], '1', {right: `${firstColumnWidth}px`}); + expectStickyStyles(footerCells[4], '1', {left: `${lastColumnWidth}px`}); + expectStickyStyles(footerCells[5], '1', {left: '0px'}); + }); + + it('should stick and unstick combination of sticky header, footer, and columns', () => { + component.stickyHeaders = ['header-1']; + component.stickyFooters = ['footer-3']; + component.stickyStartColumns = ['column-1']; + component.stickyEndColumns = ['column-6']; + fixture.detectChanges(); + + let headerCells = getHeaderCells(headerRows[0]); + expectStickyStyles(headerRows[0], '100', {top: '0px'}); + expectStickyStyles(headerCells[0], '1', {left: '0px'}); + expectStickyStyles(headerCells[5], '1', {right: '0px'}); + expectNoStickyStyles([headerCells[1], headerCells[2], headerCells[3], headerCells[4]]); + expectNoStickyStyles([headerRows[1], headerRows[2]]); + + dataRows.forEach(row => { + let cells = getCells(row); + expectStickyStyles(cells[0], '1', {left: '0px'}); + expectStickyStyles(cells[5], '1', {right: '0px'}); + expectNoStickyStyles([cells[1], cells[2], cells[3], cells[4]]); + }); + + let footerCells = getFooterCells(footerRows[0]); + expectStickyStyles(footerRows[0], '10', {bottom: '0px'}); + expectStickyStyles(footerCells[0], '1', {left: '0px'}); + expectStickyStyles(footerCells[5], '1', {right: '0px'}); + expectNoStickyStyles([footerCells[1], footerCells[2], footerCells[3], footerCells[4]]); + expectNoStickyStyles([footerRows[1], footerRows[2]]); + + component.stickyHeaders = []; + component.stickyFooters = []; + component.stickyStartColumns = []; + component.stickyEndColumns = []; + fixture.detectChanges(); + + headerRows.forEach(row => expectNoStickyStyles([row, ...getHeaderCells(row)])); + dataRows.forEach(row => expectNoStickyStyles([row, ...getCells(row)])); + footerRows.forEach(row => expectNoStickyStyles([row, ...getFooterCells(row)])); + }); + }); + + describe('on native table layout', () => { + let dataRows: Element[]; + let headerRows: Element[]; + let footerRows: Element[]; + + beforeEach(() => { + setupTableTestApp(StickyNativeLayoutCdkTableApp); + + headerRows = getHeaderRows(tableElement); + footerRows = getFooterRows(tableElement); + dataRows = getRows(tableElement); + }); + + it('should stick and unstick headers', () => { + component.stickyHeaders = ['header-1', 'header-3']; + fixture.detectChanges(); + + getHeaderCells(headerRows[0]).forEach(cell => { + expectStickyStyles(cell, '100', {top: '0px'}); + }); + const firstHeaderHeight = headerRows[0].getBoundingClientRect().height; + getHeaderCells(headerRows[2]).forEach(cell => { + expectStickyStyles(cell, '100', {top: firstHeaderHeight + 'px'}); + }); + expectNoStickyStyles(getHeaderCells(headerRows[1])); + expectNoStickyStyles(headerRows); // No sticky styles on rows for native table + + component.stickyHeaders = []; + fixture.detectChanges(); + expectNoStickyStyles(headerRows); // No sticky styles on rows for native table + headerRows.forEach(row => expectNoStickyStyles(getHeaderCells(row))); + }); + + it('should stick and unstick footers', () => { + component.stickyFooters = ['footer-1', 'footer-3']; + fixture.detectChanges(); + + getFooterCells(footerRows[2]).forEach(cell => { + expectStickyStyles(cell, '10', {bottom: '0px'}); + }); + const thirdFooterHeight = footerRows[2].getBoundingClientRect().height; + getFooterCells(footerRows[0]).forEach(cell => { + expectStickyStyles(cell, '10', {bottom: thirdFooterHeight + 'px'}); + }); + expectNoStickyStyles(getFooterCells(footerRows[1])); + expectNoStickyStyles(footerRows); // No sticky styles on rows for native table + + component.stickyFooters = []; + fixture.detectChanges(); + expectNoStickyStyles(footerRows); // No sticky styles on rows for native table + footerRows.forEach(row => expectNoStickyStyles(getFooterCells(row))); + }); + + it('should stick tfoot when all rows are stuck', () => { + const tfoot = tableElement.querySelector('tfoot'); + component.stickyFooters = ['footer-1']; + fixture.detectChanges(); + expectNoStickyStyles([tfoot]); + + component.stickyFooters = ['footer-1', 'footer-2', 'footer-3']; + fixture.detectChanges(); + expectStickyStyles(tfoot, '10', {bottom: '0px'}); + + component.stickyFooters = ['footer-1', 'footer-2']; + fixture.detectChanges(); + expectNoStickyStyles([tfoot]); + }); + + it('should stick and unstick left columns', () => { + component.stickyStartColumns = ['column-1', 'column-3']; + fixture.detectChanges(); + + headerRows.forEach(row => { + let cells = getHeaderCells(row); + expectStickyStyles(cells[0], '1', {left: '0px'}); + expectStickyStyles(cells[2], '1', {left: '20px'}); + expectNoStickyStyles([cells[1], cells[3], cells[4], cells[5]]); + }); + dataRows.forEach(row => { + let cells = getCells(row); + expectStickyStyles(cells[0], '1', {left: '0px'}); + expectStickyStyles(cells[2], '1', {left: '20px'}); + expectNoStickyStyles([cells[1], cells[3], cells[4], cells[5]]); + }); + footerRows.forEach(row => { + let cells = getFooterCells(row); + expectStickyStyles(cells[0], '1', {left: '0px'}); + expectStickyStyles(cells[2], '1', {left: '20px'}); + expectNoStickyStyles([cells[1], cells[3], cells[4], cells[5]]); + }); + + component.stickyStartColumns = []; + fixture.detectChanges(); + headerRows.forEach(row => expectNoStickyStyles(getHeaderCells(row))); + dataRows.forEach(row => expectNoStickyStyles(getCells(row))); + footerRows.forEach(row => expectNoStickyStyles(getFooterCells(row))); + }); + + it('should stick and unstick right columns', () => { + component.stickyEndColumns = ['column-4', 'column-6']; + fixture.detectChanges(); + headerRows.forEach(row => { + let cells = getHeaderCells(row); + expectStickyStyles(cells[5], '1', {right: '0px'}); + expectStickyStyles(cells[3], '1', {right: '20px'}); + expectNoStickyStyles([cells[0], cells[1], cells[2], cells[4]]); + }); + dataRows.forEach(row => { + let cells = getCells(row); + expectStickyStyles(cells[5], '1', {right: '0px'}); + expectStickyStyles(cells[3], '1', {right: '20px'}); + expectNoStickyStyles([cells[0], cells[1], cells[2], cells[4]]); + }); + footerRows.forEach(row => { + let cells = getFooterCells(row); + expectStickyStyles(cells[5], '1', {right: '0px'}); + expectStickyStyles(cells[3], '1', {right: '20px'}); + expectNoStickyStyles([cells[0], cells[1], cells[2], cells[4]]); + }); + + component.stickyEndColumns = []; + fixture.detectChanges(); + headerRows.forEach(row => expectNoStickyStyles(getHeaderCells(row))); + dataRows.forEach(row => expectNoStickyStyles(getCells(row))); + footerRows.forEach(row => expectNoStickyStyles(getFooterCells(row))); + }); + + it('should stick and unstick combination of sticky header, footer, and columns', () => { + component.stickyHeaders = ['header-1']; + component.stickyFooters = ['footer-3']; + component.stickyStartColumns = ['column-1']; + component.stickyEndColumns = ['column-6']; + fixture.detectChanges(); + + const headerCells = getHeaderCells(headerRows[0]); + expectStickyStyles(headerCells[0], '101', {top: '0px', left: '0px'}); + expectStickyStyles(headerCells[1], '100', {top: '0px'}); + expectStickyStyles(headerCells[2], '100', {top: '0px'}); + expectStickyStyles(headerCells[3], '100', {top: '0px'}); + expectStickyStyles(headerCells[4], '100', {top: '0px'}); + expectStickyStyles(headerCells[5], '101', {top: '0px', right: '0px'}); + expectNoStickyStyles(headerRows); + + dataRows.forEach(row => { + let cells = getCells(row); + expectStickyStyles(cells[0], '1', {left: '0px'}); + expectStickyStyles(cells[5], '1', {right: '0px'}); + expectNoStickyStyles([cells[1], cells[2], cells[3], cells[4]]); + }); + + const footerCells = getFooterCells(footerRows[0]); + expectStickyStyles(footerCells[0], '11', {bottom: '0px', left: '0px'}); + expectStickyStyles(footerCells[1], '10', {bottom: '0px'}); + expectStickyStyles(footerCells[2], '10', {bottom: '0px'}); + expectStickyStyles(footerCells[3], '10', {bottom: '0px'}); + expectStickyStyles(footerCells[4], '10', {bottom: '0px'}); + expectStickyStyles(footerCells[5], '11', {bottom: '0px', right: '0px'}); + expectNoStickyStyles(footerRows); + + component.stickyHeaders = []; + component.stickyFooters = []; + component.stickyStartColumns = []; + component.stickyEndColumns = []; + fixture.detectChanges(); + + headerRows.forEach(row => expectNoStickyStyles([row, ...getHeaderCells(row)])); + dataRows.forEach(row => expectNoStickyStyles([row, ...getCells(row)])); + footerRows.forEach(row => expectNoStickyStyles([row, ...getFooterCells(row)])); + }); + }); + }); describe('with trackBy', () => { function createTestComponentWithTrackyByTable(trackByStrategy) { @@ -1360,6 +1739,110 @@ class TrackByCdkTableApp { } } +@Component({ + template: ` + + + Header {{column}} + {{column}} + Footer {{column}} + + + + + + + + + + + + + + + + + + + `, + styles: [` + .cdk-header-cell, .cdk-cell, .cdk-footer-cell { + display: block; + width: 20px; + } + `] +}) +class StickyFlexLayoutCdkTableApp { + dataSource: FakeDataSource = new FakeDataSource(); + columns = ['column-1', 'column-2', 'column-3', 'column-4', 'column-5', 'column-6']; + + @ViewChild(CdkTable) table: CdkTable; + + dir = 'ltr'; + stickyHeaders: string[] = []; + stickyFooters: string[] = []; + stickyStartColumns: string[] = []; + stickyEndColumns: string[] = []; + + isStuck(list: string[], id: string) { + return list.indexOf(id) != -1; + } +} + +@Component({ + template: ` +
+ + + + + + + + + + + + + + + + + + + + + +
Header {{column}} {{column}} Footer {{column}}
+ `, + styles: [` + .cdk-header-cell, .cdk-cell, .cdk-footer-cell { + display: block; + width: 20px; + box-sizing: border-box; + } + `] +}) +class StickyNativeLayoutCdkTableApp { + dataSource: FakeDataSource = new FakeDataSource(); + columns = ['column-1', 'column-2', 'column-3', 'column-4', 'column-5', 'column-6']; + + @ViewChild(CdkTable) table: CdkTable; + + stickyHeaders: string[] = []; + stickyFooters: string[] = []; + stickyStartColumns: string[] = []; + stickyEndColumns: string[] = []; + + isStuck(list: string[], id: string) { + return list.indexOf(id) != -1; + } +} + @Component({ template: ` @@ -1700,7 +2183,7 @@ function getCells(row: Element): Element[] { let cells = getElements(row, 'cdk-cell'); if (!cells.length) { - cells = getElements(row, 'td'); + cells = getElements(row, 'td.cdk-cell'); } return cells; @@ -1709,7 +2192,7 @@ function getCells(row: Element): Element[] { function getHeaderCells(headerRow: Element): Element[] { let cells = getElements(headerRow, 'cdk-header-cell'); if (!cells.length) { - cells = getElements(headerRow, 'th'); + cells = getElements(headerRow, 'th.cdk-header-cell'); } return cells; @@ -1718,7 +2201,7 @@ function getHeaderCells(headerRow: Element): Element[] { function getFooterCells(footerRow: Element): Element[] { let cells = getElements(footerRow, 'cdk-footer-cell'); if (!cells.length) { - cells = getElements(footerRow, 'td'); + cells = getElements(footerRow, 'td.cdk-footer-cell'); } return cells; diff --git a/src/cdk/table/table.ts b/src/cdk/table/table.ts index e5edccde8822..2f7afa5470bc 100644 --- a/src/cdk/table/table.ts +++ b/src/cdk/table/table.ts @@ -24,6 +24,7 @@ import { IterableDiffers, OnDestroy, OnInit, + Optional, QueryList, TemplateRef, TrackByFunction, @@ -52,6 +53,8 @@ import { getTableUnknownDataSourceError } from './table-errors'; import {coerceBooleanProperty} from '@angular/cdk/coercion'; +import {StickyStyler} from './sticky-styler'; +import {Direction, Directionality} from '@angular/cdk/bidi'; /** Interface used to provide an outlet for rows to be inserted into. */ export interface RowOutlet { @@ -245,6 +248,21 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes */ private _cachedRenderRowsMap = new Map, RenderRow[]>>(); + /** Whether the table is applied to a native ``. */ + private _isNativeHtmlTable: boolean; + + /** + * Utility class that is responsible for applying the appropriate sticky positioning styles to + * the table's rows and cells. + */ + private _stickyStyler: StickyStyler; + + /** + * CSS class added to any row or cell that has sticky positioning applied. May be overriden by + * table subclasses. + */ + protected stickyCssClass: string = 'cdk-table-sticky'; + /** * Tracking function that will be used to check the differences in data changes. Used similarly * to `ngFor` `trackBy` function. Optimize row operations by identifying a row based on its data @@ -340,14 +358,19 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes constructor(protected readonly _differs: IterableDiffers, protected readonly _changeDetectorRef: ChangeDetectorRef, protected readonly _elementRef: ElementRef, - @Attribute('role') role: string) { + @Attribute('role') role: string, + @Optional() protected readonly _dir: Directionality) { if (!role) { this._elementRef.nativeElement.setAttribute('role', 'grid'); } + + this._isNativeHtmlTable = this._elementRef.nativeElement.nodeName === 'TABLE'; } ngOnInit() { - if (this._elementRef.nativeElement.nodeName === 'TABLE') { + this._setupStickyStyler(); + + if (this._isNativeHtmlTable) { this._applyNativeTableSections(); } @@ -389,6 +412,8 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes if (this.dataSource && this._rowDefs.length > 0 && !this._renderChangeSubscription) { this._observeRenderChanges(); } + + this._checkStickyStates(); } ngOnDestroy() { @@ -443,6 +468,8 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes const rowView = >viewContainer.get(record.currentIndex!); rowView.context.$implicit = record.item.data; }); + + this.updateStickyColumnStyles(); } /** @@ -515,6 +542,87 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes this._footerRowDefChanged = true; } + /** + * Updates the header sticky styles. First resets all applied styles with respect to the cells + * sticking to the top. Then, evaluating which cells need to be stuck to the top. This is + * automatically called when the header row changes its displayed set of columns, or if its + * sticky input changes. May be called manually for cases where the cell content changes outside + * of these events. + */ + updateStickyHeaderRowStyles() { + const headerRows = this._getRenderedRows(this._headerRowOutlet); + this._stickyStyler.clearStickyPositioning(headerRows, ['top']); + + const stickyStates = this._headerRowDefs.map(def => def.sticky); + this._stickyStyler.stickRows(headerRows, stickyStates, 'top'); + + // Reset the dirty state of the sticky input change since it has been used. + this._headerRowDefs.forEach(def => def.resetStickyChanged()); + } + + /** + * Updates the footer sticky styles. First resets all applied styles with respect to the cells + * sticking to the bottom. Then, evaluating which cells need to be stuck to the bottom. This is + * automatically called when the footer row changes its displayed set of columns, or if its + * sticky input changes. May be called manually for cases where the cell content changes outside + * of these events. + */ + updateStickyFooterRowStyles() { + const footerRows = this._getRenderedRows(this._footerRowOutlet); + this._stickyStyler.clearStickyPositioning(footerRows, ['bottom']); + + const stickyStates = this._footerRowDefs.map(def => def.sticky); + this._stickyStyler.stickRows(footerRows, stickyStates, 'bottom'); + this._stickyStyler.updateStickyFooterContainer(this._elementRef.nativeElement, stickyStates); + + // Reset the dirty state of the sticky input change since it has been used. + this._footerRowDefs.forEach(def => def.resetStickyChanged()); + } + + /** + * Updates the column sticky styles. First resets all applied styles with respect to the cells + * sticking to the left and right. Then sticky styles are added for the left and right according + * to the column definitions for each cell in each row. This is automatically called when + * the data source provides a new set of data or when a column definition changes its sticky + * input. May be called manually for cases where the cell content changes outside of these events. + */ + updateStickyColumnStyles() { + const headerRows = this._getRenderedRows(this._headerRowOutlet); + 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']); + + // Update the sticky styles for each header row depending on the def's sticky state + headerRows.forEach((headerRow, i) => { + this._addStickyColumnStyles([headerRow], this._headerRowDefs[i]); + }); + + // Update the sticky styles for each data row depending on its def's sticky state + this._rowDefs.forEach(rowDef => { + // Collect all the rows rendered with this row definition. + const rows: HTMLElement[] = []; + for (let i = 0; i < dataRows.length; i++) { + if (this._renderRows[i].rowDef === rowDef) { + rows.push(dataRows[i]); + } + } + + this._addStickyColumnStyles(rows, rowDef); + }); + + // Update the sticky styles for each footer row depending on the def's sticky state + footerRows.forEach((footerRow, i) => { + this._addStickyColumnStyles([footerRow], this._footerRowDefs[i]); + }); + + // Reset the dirty state of the sticky input change since it has been used. + Array.from(this._columnDefsByName.values()).forEach(def => def.resetStickyChanged()); + } + /** * 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 @@ -606,21 +714,24 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes } /** - * Check if the header, data, or footer rows have changed what columns they want to display. - * If there is a diff, then re-render that section. + * Check if the header, data, or footer rows have changed what columns they want to display or + * whether the sticky states have changed for the header or footer. If there is a diff, then + * re-render that section. */ private _renderUpdatedColumns() { - const defColumnsDiffReducer = (accumulator, def) => accumulator || !!def.getColumnsDiff(); + const columnsDiffReducer = (acc: boolean, def: BaseRowDef) => acc || !!def.getColumnsDiff(); - if (this._rowDefs.reduce(defColumnsDiffReducer, false)) { + // Force re-render data rows if the list of column definitions have changed. + if (this._rowDefs.reduce(columnsDiffReducer, false)) { this._forceRenderDataRows(); } - if (this._headerRowDefs.reduce(defColumnsDiffReducer, false)) { + // Force re-render header/footer rows if the list of column definitions have changed.. + if (this._headerRowDefs.reduce(columnsDiffReducer, false)) { this._forceRenderHeaderRows(); } - if (this._footerRowDefs.reduce(defColumnsDiffReducer, false)) { + if (this._footerRowDefs.reduce(columnsDiffReducer, false)) { this._forceRenderFooterRows(); } } @@ -664,7 +775,7 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes // Cannot check this.dataSource['connect'] due to potential property renaming, nor can it // checked as an instanceof DataSource since the table should allow for data sources // that did not explicitly extend DataSource. - if ((this.dataSource as DataSource).connect instanceof Function) { + if ((this.dataSource as DataSource).connect instanceof Function) { dataStream = (this.dataSource as DataSource).connect(this); } else if (this.dataSource instanceof Observable) { dataStream = this.dataSource; @@ -689,14 +800,15 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes * in the outlet using the header row definition. */ private _forceRenderHeaderRows() { - // Clear the footer row outlet if any content exists. + // Clear the header row outlet if any content exists. if (this._headerRowOutlet.viewContainer.length > 0) { this._headerRowOutlet.viewContainer.clear(); } this._headerRowDefs.forEach((def, i) => this._renderRow(this._headerRowOutlet, def, i)); + this.updateStickyHeaderRowStyles(); + this.updateStickyColumnStyles(); } - /** * Clears any existing content in the footer row outlet and creates a new embedded view * in the outlet using the footer row definition. @@ -708,6 +820,28 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes } this._footerRowDefs.forEach((def, i) => this._renderRow(this._footerRowOutlet, def, i)); + this.updateStickyFooterRowStyles(); + this.updateStickyColumnStyles(); + } + + /** Adds the sticky column styles for the rows according to the columns' stick states. */ + private _addStickyColumnStyles(rows: HTMLElement[], rowDef: BaseRowDef) { + const columnDefs = Array.from(rowDef.columns || []).map(c => this._columnDefsByName.get(c)!); + const stickyStartStates = columnDefs.map(columnDef => columnDef.sticky); + const stickyEndStates = columnDefs.map(columnDef => columnDef.stickyEnd); + this._stickyStyler.updateStickyColumns(rows, stickyStartStates, stickyEndStates); + } + + /** Gets the list of rows that have been rendered in the row outlet. */ + _getRenderedRows(rowOutlet: RowOutlet) { + const renderedRows: HTMLElement[] = []; + + for (let i = 0; i < rowOutlet.viewContainer.length; i++) { + const viewRef = (rowOutlet.viewContainer.get(i)! as EmbeddedViewRef); + renderedRows.push(viewRef.rootNodes[0]); + } + + return renderedRows; } /** @@ -828,6 +962,50 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes this._dataDiffer.diff([]); this._rowOutlet.viewContainer.clear(); this.renderRows(); + this.updateStickyColumnStyles(); + } + + /** + * Checks if there has been a change in sticky states since last check and applies the correct + * sticky styles. Since checking resets the "dirty" state, this should only be performed once + * during a change detection and after the inputs are settled (after content check). + */ + private _checkStickyStates() { + const stickyCheckReducer = (acc: boolean, d: CdkHeaderRowDef|CdkFooterRowDef|CdkColumnDef) => { + return acc || d.hasStickyChanged(); + }; + + // Note that the check needs to occur for every definition since it notifies the definition + // that it can reset its dirty state. Using another operator like `some` may short-circuit + // remaining definitions and leave them in an unchecked state. + + if (this._headerRowDefs.reduce(stickyCheckReducer, false)) { + this.updateStickyHeaderRowStyles(); + } + + if (this._footerRowDefs.reduce(stickyCheckReducer, false)) { + this.updateStickyFooterRowStyles(); + } + + if (Array.from(this._columnDefsByName.values()).reduce(stickyCheckReducer, false)) { + this.updateStickyColumnStyles(); + } + } + + /** + * Creates the sticky styler that will be used for sticky rows and columns. Listens + * for directionality changes and provides the latest direction to the styler. Re-applies column + * stickiness when directionality changes. + */ + private _setupStickyStyler() { + const direction: Direction = this._dir ? this._dir.value : 'ltr'; + this._stickyStyler = new StickyStyler(this._isNativeHtmlTable, this.stickyCssClass, direction); + (this._dir ? this._dir.change : observableOf()) + .pipe(takeUntil(this._onDestroy)) + .subscribe(value => { + this._stickyStyler.direction = value; + this.updateStickyColumnStyles(); + }); } } diff --git a/src/demo-app/example/example.ts b/src/demo-app/example/example.ts index 8139be7cf693..a28ec4ea820e 100644 --- a/src/demo-app/example/example.ts +++ b/src/demo-app/example/example.ts @@ -7,7 +7,7 @@ */ import {coerceBooleanProperty} from '@angular/cdk/coercion'; -import {Component, ElementRef, Input, OnInit} from '@angular/core'; +import {Component, ElementRef, Injector, Input, OnInit} from '@angular/core'; import {EXAMPLE_COMPONENTS} from '@angular/material-examples'; @Component({ @@ -54,11 +54,13 @@ export class Example implements OnInit { title: string; - constructor(private elementRef: ElementRef) { } + constructor(private elementRef: ElementRef, private injector: Injector) { } ngOnInit() { - const element = document.createElement(this.id); - this.elementRef.nativeElement.appendChild(element); + // Should be created with this component's injector to capture the whole injector which may + // include provided things like Directionality. + const exampleElementCtor = customElements.get(this.id); + this.elementRef.nativeElement.appendChild(new exampleElementCtor(this.injector)); this.title = EXAMPLE_COMPONENTS[this.id] ? EXAMPLE_COMPONENTS[this.id].title : ''; } diff --git a/src/demo-app/table/table-demo.ts b/src/demo-app/table/table-demo.ts index 098f477f6147..0b060c27c638 100644 --- a/src/demo-app/table/table-demo.ts +++ b/src/demo-app/table/table-demo.ts @@ -30,5 +30,10 @@ export class TableDemo { 'table-selection', 'table-sorting', 'table-expandable-rows', + 'table-sticky-header', + 'table-sticky-column', + 'table-sticky-footer', + 'table-sticky-complex', + 'table-sticky-complex-flex', ]; } diff --git a/src/lib/table/BUILD.bazel b/src/lib/table/BUILD.bazel index 9ae4d69d2e88..b21a162b9249 100644 --- a/src/lib/table/BUILD.bazel +++ b/src/lib/table/BUILD.bazel @@ -9,6 +9,7 @@ ng_module( module_name = "@angular/material/table", assets = [":table_css"], deps = [ + "//src/cdk/bidi", "//src/lib/core", "//src/lib/paginator", "//src/lib/sort", diff --git a/src/lib/table/_table-theme.scss b/src/lib/table/_table-theme.scss index e386dee8cc7c..e1cbe5218541 100644 --- a/src/lib/table/_table-theme.scss +++ b/src/lib/table/_table-theme.scss @@ -10,6 +10,13 @@ background: mat-color($background, 'card'); } + .mat-table thead, .mat-table tbody, .mat-table tfoot, + mat-header-row, mat-row, mat-footer-row, + [mat-header-row], [mat-row], [mat-footer-row], + .mat-table-sticky { + background: inherit; + } + mat-row, mat-header-row, mat-footer-row, th.mat-header-cell, td.mat-cell, td.mat-footer-cell { border-bottom-color: mat-color($foreground, divider); diff --git a/src/lib/table/cell.ts b/src/lib/table/cell.ts index b7897e5179e7..11ba621ea165 100644 --- a/src/lib/table/cell.ts +++ b/src/lib/table/cell.ts @@ -71,6 +71,12 @@ export class MatFooterCellDef extends CdkFooterCellDef { export class MatColumnDef extends CdkColumnDef { /** Unique name for this column. */ @Input('matColumnDef') name: string; + + /** Whether this column should be sticky positioned at the start of the row */ + @Input() sticky: boolean; + + /** Whether this column should be sticky positioned on the end of the row */ + @Input() stickyEnd: boolean; } /** Header cell template container that adds the right classes and role. */ diff --git a/src/lib/table/row.ts b/src/lib/table/row.ts index 1a7addbe6094..11ea0b95b79c 100644 --- a/src/lib/table/row.ts +++ b/src/lib/table/row.ts @@ -28,7 +28,7 @@ import { @Directive({ selector: '[matHeaderRowDef]', providers: [{provide: CdkHeaderRowDef, useExisting: MatHeaderRowDef}], - inputs: ['columns: matHeaderRowDef'], + inputs: ['columns: matHeaderRowDef', 'sticky: matHeaderRowDefSticky'], }) export class MatHeaderRowDef extends CdkHeaderRowDef { // TODO(andrewseguin): Remove this constructor after compiler-cli is updated; see issue #9329 @@ -44,7 +44,7 @@ export class MatHeaderRowDef extends CdkHeaderRowDef { @Directive({ selector: '[matFooterRowDef]', providers: [{provide: CdkFooterRowDef, useExisting: MatFooterRowDef}], - inputs: ['columns: matFooterRowDef'], + inputs: ['columns: matFooterRowDef', 'sticky: matFooterRowDefSticky'], }) export class MatFooterRowDef extends CdkFooterRowDef { // TODO(andrewseguin): Remove this constructor after compiler-cli is updated; see issue #9329 @@ -82,6 +82,7 @@ export class MatRowDef extends CdkRowDef { changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, exportAs: 'matHeaderRow', + providers: [{provide: CdkHeaderRow, useExisting: MatHeaderRow}], }) export class MatHeaderRow extends CdkHeaderRow { } @@ -97,6 +98,7 @@ export class MatHeaderRow extends CdkHeaderRow { } changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, exportAs: 'matFooterRow', + providers: [{provide: CdkFooterRow, useExisting: MatFooterRow}], }) export class MatFooterRow extends CdkFooterRow { } @@ -112,5 +114,6 @@ export class MatFooterRow extends CdkFooterRow { } changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, exportAs: 'matRow', + providers: [{provide: CdkRow, useExisting: MatRow}], }) export class MatRow extends CdkRow { } diff --git a/src/lib/table/table.md b/src/lib/table/table.md index 20741e221928..072c21aaec59 100644 --- a/src/lib/table/table.md +++ b/src/lib/table/table.md @@ -292,6 +292,41 @@ the ripple effect to extend beyond the cell. +#### Sticky Rows and Columns + +By using `position: sticky` styling, the table's rows and columns can be fixed so that they do not +leave the viewport even when scrolled. The table provides inputs that will automatically apply the +correct CSS styling so that the rows and columns become sticky. + +In order to fix the header row to the top of the scrolling viewport containing the table, you can +add a `sticky` input to the `matHeaderRowDef`. + + + +Similarly, this can also be applied to the table's footer row. Note that if you are using the native +`
` and using Safari, then the footer will only stick if `sticky` is applied to all the +rendered footer rows. + + + +It is also possible to fix cell columns to the start or end of the horizontally scrolling viewport. +To do this, add the `sticky` or `stickyEnd` directive to the `ng-container` column definition. + + + +This feature is supported by Chrome, Firefox, Safari, and Edge. It is not supported in IE, but +it does fail gracefully so that the rows simply do not stick. + +Note that on Safari mobile when using the flex-based table, a cell stuck in more than one direction +will struggle to stay in the correct position as you scroll. For example, if a header row is stuck +to the top and the first column is stuck, then the top-left-most cell will appear jittery as you +scroll. + +Also, sticky positioning in Edge will appear shaky for special cases. For example, if the scrolling +container has a complex box shadow and has sibling elements, the stuck cells will appear jittery. +There is currently an [open issue with Edge](https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/17514118/) +to resolve this. + ### Accessibility Tables without text or labels should be given a meaningful label via `aria-label` or `aria-labelledby`. The `aria-readonly` defaults to `true` if it's not set. diff --git a/src/lib/table/table.spec.ts b/src/lib/table/table.spec.ts index dd832e586a47..0f2a4471f58c 100644 --- a/src/lib/table/table.spec.ts +++ b/src/lib/table/table.spec.ts @@ -21,6 +21,7 @@ describe('MatTable', () => { NativeHtmlTableApp, MatTableWithSortApp, MatTableWithPaginatorApp, + StickyTableApp, ], }).compileComponents(); })); @@ -118,6 +119,14 @@ describe('MatTable', () => { ]); }); + it('should apply custom sticky CSS class to sticky cells', () => { + let fixture = TestBed.createComponent(StickyTableApp); + fixture.detectChanges(); + + const stuckCellElement = fixture.nativeElement.querySelector('.mat-table th')!; + expect(stuckCellElement.classList).toContain('mat-table-sticky'); + }); + describe('with MatTableDataSource and sort/pagination/filter', () => { let tableElement: HTMLElement; let fixture: ComponentFixture; @@ -500,6 +509,26 @@ class NativeHtmlTableApp { @ViewChild(MatTable) table: MatTable; } +@Component({ + template: ` +
+ + + + + + + +
Column A {{row.a}}
+ ` +}) +class StickyTableApp { + dataSource = new FakeDataSource(); + columnsToRender = ['column_a']; + + @ViewChild(MatTable) table: MatTable; +} + @Component({ template: ` diff --git a/src/lib/table/table.ts b/src/lib/table/table.ts index af2be413587d..b3542fe8a247 100644 --- a/src/lib/table/table.ts +++ b/src/lib/table/table.ts @@ -13,9 +13,11 @@ import { Component, ElementRef, IterableDiffers, + Optional, ViewEncapsulation } from '@angular/core'; import {CDK_TABLE_TEMPLATE, CdkTable} from '@angular/cdk/table'; +import {Directionality} from '@angular/cdk/bidi'; /** * Wrapper for the CdkTable with Material design styles. @@ -33,6 +35,9 @@ import {CDK_TABLE_TEMPLATE, CdkTable} from '@angular/cdk/table'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class MatTable extends CdkTable { + /** Overrides the sticky CSS class set by the `CdkTable`. */ + protected stickyCssClass = 'mat-table-sticky'; + // TODO(andrewseguin): Remove this explicitly set constructor when the compiler knows how to // properly build the es6 version of the class. Currently sets ctorParameters to empty due to a // fixed bug. @@ -41,7 +46,8 @@ export class MatTable extends CdkTable { constructor(protected _differs: IterableDiffers, protected _changeDetectorRef: ChangeDetectorRef, protected _elementRef: ElementRef, - @Attribute('role') role: string) { - super(_differs, _changeDetectorRef, _elementRef, role); + @Attribute('role') role: string, + @Optional() protected readonly _dir: Directionality) { + super(_differs, _changeDetectorRef, _elementRef, role, _dir); } } diff --git a/src/material-examples/table-basic-flex/table-basic-flex-example.html b/src/material-examples/table-basic-flex/table-basic-flex-example.html index e0316d41f2cf..09bb2c212897 100644 --- a/src/material-examples/table-basic-flex/table-basic-flex-example.html +++ b/src/material-examples/table-basic-flex/table-basic-flex-example.html @@ -1,28 +1,28 @@ - + - - + No. + {{element.position}} - - + Name + {{element.name}} - - + Weight + {{element.weight}} - - + Symbol + {{element.symbol}} - - -
No. {{element.position}} Name {{element.name}} Weight {{element.weight}} Symbol {{element.symbol}}
\ No newline at end of file + + + \ No newline at end of file diff --git a/src/material-examples/table-sticky-columns/table-sticky-column-example.css b/src/material-examples/table-sticky-columns/table-sticky-column-example.css new file mode 100644 index 000000000000..d0ad276606c1 --- /dev/null +++ b/src/material-examples/table-sticky-columns/table-sticky-column-example.css @@ -0,0 +1,26 @@ +.example-container { + height: 400px; + width: 550px; + overflow: auto; +} + +table { + width: 800px; +} + +td.mat-column-star { + width: 20px; + padding-right: 8px; +} + +th.mat-column-position, td.mat-column-position { + padding-left: 8px; +} + +.mat-table-sticky:first-child { + border-right: 1px solid #e0e0e0; +} + +.mat-table-sticky:last-child { + border-left: 1px solid #e0e0e0; +} diff --git a/src/material-examples/table-sticky-columns/table-sticky-column-example.html b/src/material-examples/table-sticky-columns/table-sticky-column-example.html new file mode 100644 index 000000000000..73b460f52121 --- /dev/null +++ b/src/material-examples/table-sticky-columns/table-sticky-column-example.html @@ -0,0 +1,39 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Name {{element.name}} No. {{element.position}} Weight {{element.weight}} Symbol {{element.symbol}} + more_vert +
+
diff --git a/src/material-examples/table-sticky-columns/table-sticky-column-example.ts b/src/material-examples/table-sticky-columns/table-sticky-column-example.ts new file mode 100644 index 000000000000..490f2a797dd9 --- /dev/null +++ b/src/material-examples/table-sticky-columns/table-sticky-column-example.ts @@ -0,0 +1,35 @@ +import {Component} from '@angular/core'; + +/** + * @title Table with a sticky columns + */ +@Component({ + selector: 'table-sticky-column-example', + styleUrls: ['table-sticky-column-example.css'], + templateUrl: 'table-sticky-column-example.html', +}) +export class TableStickyColumnExample { + displayedColumns = + ['name', 'position', 'weight', 'symbol', 'position', 'weight', 'symbol', 'star']; + dataSource = ELEMENT_DATA; +} + +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'}, +]; diff --git a/src/material-examples/table-sticky-complex-flex/table-sticky-complex-flex-example.css b/src/material-examples/table-sticky-complex-flex/table-sticky-complex-flex-example.css new file mode 100644 index 000000000000..8e5c53c55f3e --- /dev/null +++ b/src/material-examples/table-sticky-complex-flex/table-sticky-complex-flex-example.css @@ -0,0 +1,28 @@ +.example-container { + height: 400px; + overflow: auto; +} + +.mat-table-sticky { + background: #59abfd; + opacity: 1; +} + +.example-sticky-toggle-group { + margin: 8px; +} + +.mat-column-filler { + padding: 0 8px; + font-size: 10px; + text-align: center; +} + +.mat-header-cell, .mat-footer-cell, .mat-cell { + min-width: 80px; + box-sizing: border-box; +} + +.mat-header-row, .mat-footer-row, .mat-row { + min-width: 1920px; /* 24 columns, 80px each */ +} diff --git a/src/material-examples/table-sticky-complex-flex/table-sticky-complex-flex-example.html b/src/material-examples/table-sticky-complex-flex/table-sticky-complex-flex-example.html new file mode 100644 index 000000000000..c403cd0bd4ad --- /dev/null +++ b/src/material-examples/table-sticky-complex-flex/table-sticky-complex-flex-example.html @@ -0,0 +1,78 @@ +
+ + +
+ +
+ Sticky Headers: + + Row 1 + Row 2 + +
+ +
+ Sticky Footers: + + Row 1 + Row 2 + +
+ +
+ Sticky Columns: + + Position + Name + Weight + Symbol + +
+ +
+ + + Position + {{element.position}} + Position Footer + + + + Name + {{element.name}} + Name Footer + + + + Weight + {{element.weight}} + Weight Footer + + + + Symbol + {{element.symbol}} + Symbol Footer + + + + Filler header cell + Filler data cell + Filler footer cell + + + + + + + + + + +
diff --git a/src/material-examples/table-sticky-complex-flex/table-sticky-complex-flex-example.ts b/src/material-examples/table-sticky-complex-flex/table-sticky-complex-flex-example.ts new file mode 100644 index 000000000000..8882ca5e7dea --- /dev/null +++ b/src/material-examples/table-sticky-complex-flex/table-sticky-complex-flex-example.ts @@ -0,0 +1,53 @@ +import {Component} from '@angular/core'; +import {MatButtonToggleGroup} from '@angular/material'; + +/** + * @title Flex-layout tables with toggle-able sticky headers, footers, and columns + */ +@Component({ + selector: 'table-sticky-complex-flex-example', + styleUrls: ['table-sticky-complex-flex-example.css'], + templateUrl: 'table-sticky-complex-flex-example.html', +}) +export class TableStickyComplexFlexExample { + displayedColumns: string[] = []; + dataSource = ELEMENT_DATA; + + tables = [0]; + + constructor() { + this.displayedColumns.length = 24; + this.displayedColumns.fill('filler'); + + // The first two columns should be position and name; the last two columns: weight, symbol + this.displayedColumns[0] = 'position'; + this.displayedColumns[1] = 'name'; + this.displayedColumns[22] = 'weight'; + this.displayedColumns[23] = 'symbol'; + } + + /** Whether the button toggle group contains the id as an active value. */ + isSticky(buttonToggleGroup: MatButtonToggleGroup, id: string) { + return (buttonToggleGroup.value || []).indexOf(id) !== -1; + } +} + +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'}, +]; diff --git a/src/material-examples/table-sticky-complex/table-sticky-complex-example.css b/src/material-examples/table-sticky-complex/table-sticky-complex-example.css new file mode 100644 index 000000000000..d3edcb38f991 --- /dev/null +++ b/src/material-examples/table-sticky-complex/table-sticky-complex-example.css @@ -0,0 +1,24 @@ +.example-container { + height: 400px; + overflow: auto; +} + +.mat-table-sticky { + background: #59abfd; + opacity: 1; +} + +.example-sticky-toggle-group { + margin: 8px; +} + +.mat-column-filler { + padding: 0 8px; + font-size: 10px; + text-align: center; +} + +.mat-header-cell, .mat-footer-cell, .mat-cell { + min-width: 80px; + box-sizing: border-box; +} diff --git a/src/material-examples/table-sticky-complex/table-sticky-complex-example.html b/src/material-examples/table-sticky-complex/table-sticky-complex-example.html new file mode 100644 index 000000000000..24944caefeff --- /dev/null +++ b/src/material-examples/table-sticky-complex/table-sticky-complex-example.html @@ -0,0 +1,78 @@ +
+ + +
+ +
+ Sticky Headers: + + Row 1 + Row 2 + +
+ +
+ Sticky Footers: + + Row 1 + Row 2 + +
+ +
+ Sticky Columns: + + Position + Name + Weight + Symbol + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Position {{element.position}} Position Footer Name {{element.name}} Name Footer Weight {{element.weight}} Weight Footer Symbol {{element.symbol}} Symbol Footer Filler header cell Filler data cell Filler footer cell
+
diff --git a/src/material-examples/table-sticky-complex/table-sticky-complex-example.ts b/src/material-examples/table-sticky-complex/table-sticky-complex-example.ts new file mode 100644 index 000000000000..912d9ad91098 --- /dev/null +++ b/src/material-examples/table-sticky-complex/table-sticky-complex-example.ts @@ -0,0 +1,53 @@ +import {Component} from '@angular/core'; +import {MatButtonToggleGroup} from '@angular/material'; + +/** + * @title Tables with toggle-able sticky headers, footers, and columns + */ +@Component({ + selector: 'table-sticky-complex-example', + styleUrls: ['table-sticky-complex-example.css'], + templateUrl: 'table-sticky-complex-example.html', +}) +export class TableStickyComplexExample { + displayedColumns: string[] = []; + dataSource = ELEMENT_DATA; + + tables = [0]; + + constructor() { + this.displayedColumns.length = 24; + this.displayedColumns.fill('filler'); + + // The first two columns should be position and name; the last two columns: weight, symbol + this.displayedColumns[0] = 'position'; + this.displayedColumns[1] = 'name'; + this.displayedColumns[22] = 'weight'; + this.displayedColumns[23] = 'symbol'; + } + + /** Whether the button toggle group contains the id as an active value. */ + isSticky(buttonToggleGroup: MatButtonToggleGroup, id: string) { + return (buttonToggleGroup.value || []).indexOf(id) !== -1; + } +} + +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'}, +]; diff --git a/src/material-examples/table-sticky-footer/table-sticky-footer-example.css b/src/material-examples/table-sticky-footer/table-sticky-footer-example.css new file mode 100644 index 000000000000..6b5869e7c72b --- /dev/null +++ b/src/material-examples/table-sticky-footer/table-sticky-footer-example.css @@ -0,0 +1,16 @@ +.example-container { + height: 270px; + overflow: auto; +} + +table { + width: 100%; +} + +tr.mat-footer-row { + font-weight: bold; +} + +.mat-table-sticky { + border-top: 1px solid #e0e0e0; +} diff --git a/src/material-examples/table-sticky-footer/table-sticky-footer-example.html b/src/material-examples/table-sticky-footer/table-sticky-footer-example.html new file mode 100644 index 000000000000..7f4a26817859 --- /dev/null +++ b/src/material-examples/table-sticky-footer/table-sticky-footer-example.html @@ -0,0 +1,21 @@ +
+ + + + + + + + + + + + + + + + + + +
Item {{transaction.item}} Total Cost {{transaction.cost | currency}} {{getTotalCost() | currency}}
+
\ No newline at end of file diff --git a/src/material-examples/table-sticky-footer/table-sticky-footer-example.ts b/src/material-examples/table-sticky-footer/table-sticky-footer-example.ts new file mode 100644 index 000000000000..dbb4b265e3dd --- /dev/null +++ b/src/material-examples/table-sticky-footer/table-sticky-footer-example.ts @@ -0,0 +1,31 @@ +import {Component} from '@angular/core'; + +export interface Transaction { + item: string; + cost: number; +} + +/** + * @title Table with a sticky footer + */ +@Component({ + selector: 'table-sticky-footer-example', + styleUrls: ['table-sticky-footer-example.css'], + templateUrl: 'table-sticky-footer-example.html', +}) +export class TableStickyFooterExample { + 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); + } +} diff --git a/src/material-examples/table-sticky-header/table-sticky-header-example.css b/src/material-examples/table-sticky-header/table-sticky-header-example.css new file mode 100644 index 000000000000..4eca688d9b47 --- /dev/null +++ b/src/material-examples/table-sticky-header/table-sticky-header-example.css @@ -0,0 +1,8 @@ +.example-container { + height: 400px; + overflow: auto; +} + +table { + width: 100%; +} diff --git a/src/material-examples/table-sticky-header/table-sticky-header-example.html b/src/material-examples/table-sticky-header/table-sticky-header-example.html new file mode 100644 index 000000000000..ccf93e2696d3 --- /dev/null +++ b/src/material-examples/table-sticky-header/table-sticky-header-example.html @@ -0,0 +1,31 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
No. {{element.position}} Name {{element.name}} Weight {{element.weight}} Symbol {{element.symbol}}
+
\ No newline at end of file diff --git a/src/material-examples/table-sticky-header/table-sticky-header-example.ts b/src/material-examples/table-sticky-header/table-sticky-header-example.ts new file mode 100644 index 000000000000..21a17a3536da --- /dev/null +++ b/src/material-examples/table-sticky-header/table-sticky-header-example.ts @@ -0,0 +1,34 @@ +import {Component} from '@angular/core'; + +/** + * @title Table with sticky header + */ +@Component({ + selector: 'table-sticky-header-example', + styleUrls: ['table-sticky-header-example.css'], + templateUrl: 'table-sticky-header-example.html', +}) +export class TableStickyHeaderExample { + displayedColumns = ['position', 'name', 'weight', 'symbol']; + dataSource = ELEMENT_DATA; +} + +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'}, +];