From ffb771130661449f8f1cd197bdb50a88e96cc89d Mon Sep 17 00:00:00 2001 From: Andrew Seguin Date: Wed, 23 May 2018 14:41:45 -0700 Subject: [PATCH 1/8] feat(table): support sticky headers, footers, and columns --- .firebaserc | 5 + src/cdk/table/cell.ts | 29 +- src/cdk/table/has-sticky-state.ts | 52 ++ src/cdk/table/public-api.ts | 2 + src/cdk/table/row.ts | 63 ++- src/cdk/table/sticky-styler.ts | 228 +++++++++ src/cdk/table/table.spec.ts | 461 +++++++++++++++++- src/cdk/table/table.ts | 179 ++++++- src/demo-app/table/table-demo.ts | 5 + src/lib/table/_table-theme.scss | 7 + src/lib/table/cell.ts | 6 + src/lib/table/row.ts | 7 +- src/lib/table/table.md | 35 ++ src/lib/table/table.spec.ts | 29 ++ src/lib/table/table.ts | 3 + .../table-sticky-column-example.css | 26 + .../table-sticky-column-example.html | 39 ++ .../table-sticky-column-example.ts | 35 ++ .../table-sticky-complex-flex-example.css | 28 ++ .../table-sticky-complex-flex-example.html | 78 +++ .../table-sticky-complex-flex-example.ts | 53 ++ .../table-sticky-complex-example.css | 24 + .../table-sticky-complex-example.html | 78 +++ .../table-sticky-complex-example.ts | 53 ++ .../table-sticky-footer-example.css | 16 + .../table-sticky-footer-example.html | 21 + .../table-sticky-footer-example.ts | 31 ++ .../table-sticky-header-example.css | 8 + .../table-sticky-header-example.html | 31 ++ .../table-sticky-header-example.ts | 34 ++ 30 files changed, 1627 insertions(+), 39 deletions(-) create mode 100644 .firebaserc create mode 100644 src/cdk/table/has-sticky-state.ts create mode 100644 src/cdk/table/sticky-styler.ts create mode 100644 src/material-examples/table-sticky-columns/table-sticky-column-example.css create mode 100644 src/material-examples/table-sticky-columns/table-sticky-column-example.html create mode 100644 src/material-examples/table-sticky-columns/table-sticky-column-example.ts create mode 100644 src/material-examples/table-sticky-complex-flex/table-sticky-complex-flex-example.css create mode 100644 src/material-examples/table-sticky-complex-flex/table-sticky-complex-flex-example.html create mode 100644 src/material-examples/table-sticky-complex-flex/table-sticky-complex-flex-example.ts create mode 100644 src/material-examples/table-sticky-complex/table-sticky-complex-example.css create mode 100644 src/material-examples/table-sticky-complex/table-sticky-complex-example.html create mode 100644 src/material-examples/table-sticky-complex/table-sticky-complex-example.ts create mode 100644 src/material-examples/table-sticky-footer/table-sticky-footer-example.css create mode 100644 src/material-examples/table-sticky-footer/table-sticky-footer-example.html create mode 100644 src/material-examples/table-sticky-footer/table-sticky-footer-example.ts create mode 100644 src/material-examples/table-sticky-header/table-sticky-header-example.css create mode 100644 src/material-examples/table-sticky-header/table-sticky-header-example.html create mode 100644 src/material-examples/table-sticky-header/table-sticky-header-example.ts 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/cell.ts b/src/cdk/table/cell.ts index c63608f951e1..c7b97acbe6d4 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 {HasStickyState, mixinHasStickyInput} from './has-sticky-state'; /** Base interface for a cell definition. Captures a column's cell template definition. */ export interface CellDef { @@ -40,12 +42,17 @@ 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 { +export class CdkColumnDef extends _CdkColumnDefBase implements HasStickyState { /** Unique name for this column. */ @Input('cdkColumnDef') get name(): string { return this._name; } @@ -59,6 +66,26 @@ export class CdkColumnDef { } _name: string; + /** Whether this column should be sticky positioned on the left of the row */ + @Input('stickyLeft') + get stickyLeft(): boolean { return this._stickyLeft; } + set stickyLeft(v: boolean) { + const prevValue = this._stickyLeft; + this._stickyLeft = coerceBooleanProperty(v); + this._hasStickyChanged = prevValue !== this._stickyLeft; + } + _stickyLeft: boolean = false; + + /** Whether this column should be sticky positioned on the right of the right */ + @Input('stickyRight') + get stickyRight(): boolean { return this._stickyRight; } + set stickyRight(v: boolean) { + const prevValue = this._stickyRight; + this._stickyRight = coerceBooleanProperty(v); + this._hasStickyChanged = prevValue !== this._stickyRight; + } + _stickyRight: boolean = false; + /** @docs-private */ @ContentChild(CdkCellDef) cell: CdkCellDef; diff --git a/src/cdk/table/has-sticky-state.ts b/src/cdk/table/has-sticky-state.ts new file mode 100644 index 000000000000..e93ca978500b --- /dev/null +++ b/src/cdk/table/has-sticky-state.ts @@ -0,0 +1,52 @@ +/** + * @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 + */ + +/** @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 HasStickyState { + /** State of whether the sticky input has changed since it was last checked. */ + _hasStickyChanged: boolean; + + checkStickyChanged(): boolean; + + 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 { + /** State of 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. */ + checkStickyChanged(): 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/public-api.ts b/src/cdk/table/public-api.ts index 5ea6957e7ecc..8d81f43973f3 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 './has-sticky-state'; /** 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..6f91455ebd4d 100644 --- a/src/cdk/table/row.ts +++ b/src/cdk/table/row.ts @@ -20,6 +20,8 @@ import { ViewEncapsulation, } from '@angular/core'; import {CdkCellDef, CdkColumnDef} from './cell'; +import {coerceBooleanProperty} from '@angular/cdk/coercion'; +import {HasStickyState, mixinHasStickyInput} from './has-sticky-state'; /** * The row template that can be used by the mat-table. Should not be used outside of the @@ -44,8 +46,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 +62,68 @@ 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 { - constructor(template: TemplateRef, _differs: IterableDiffers) { - super(template, _differs); +export class CdkHeaderRowDef extends _CdkHeaderRowDefBase implements HasStickyState { + set sticky(v: boolean) { + const prevValue = this._sticky; + this._sticky = coerceBooleanProperty(v); + this._hasStickyChanged = prevValue !== this._sticky; } + get sticky(): boolean { return this._sticky; } + _sticky: boolean; - /** Gets this row def's relevant cell template from the provided column def. */ - extractCellTemplate(column: CdkColumnDef): TemplateRef { - return column.headerCell.template; + constructor(template: TemplateRef, _differs: IterableDiffers) { + super(template, _differs); } } +// 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 { - constructor(template: TemplateRef, _differs: IterableDiffers) { - super(template, _differs); +export class CdkFooterRowDef extends _CdkFooterRowDefBase implements HasStickyState { + set sticky(v: boolean) { + const prevValue = this._sticky; + this._sticky = coerceBooleanProperty(v); + this._hasStickyChanged = prevValue !== this._sticky; } + get sticky(): boolean { return this._sticky; } + _sticky: boolean; - /** Gets this row def's relevant cell template from the provided column def. */ - extractCellTemplate(column: CdkColumnDef): TemplateRef { - return column.footerCell.template; + constructor(template: TemplateRef, _differs: IterableDiffers) { + super(template, _differs); } } @@ -124,11 +150,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..2b89a2fb5bb0 --- /dev/null +++ b/src/cdk/table/sticky-styler.ts @@ -0,0 +1,228 @@ +/** + * @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. */ +export type StickyDirection = 'top' | 'bottom' | 'left' | 'right'; + +/** + * Z-index values that should be used when sticking row cells to the top and bottom. If one of those + * cells are also stuck to the left or right, their z-index should be incremented by one. In doing + * this, it is guaranteed that header cells will always cover footer cells, and both will always + * cover data rows. + */ +export enum StickyRowZIndex { + Top = 100, + Bottom = 10, + Left = 1, + Right = 1, +} + +/** Applies and removes sticky positioning styles to the `CdkTable` rows and columns cells. */ +export class StickyStyler { + constructor(private usesNativeHtmlTable: boolean, private stickyCellCSS: string) { } + + /** + * 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. + */ + clearStickyPositioningStyles(rows: HTMLElement[], stickyDirections: StickyDirection[]) { + rows.forEach(row => { + 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. + */ + updateStickyColumns( + rows: HTMLElement[], stickyLeftStates: boolean[], stickyRightStates: boolean[]) { + const hasStickyColumns = + stickyLeftStates.some(state => state) || stickyRightStates.some(state => state); + if (!rows.length || !hasStickyColumns) { + return; + } + + const cellWidths: number[] = this._getCellWidths(rows[0]); + const leftPositions = this._getStickyLeftColumnPositions(cellWidths, stickyLeftStates); + const rightPositions = this._getStickyRightColumnPositions(cellWidths, stickyRightStates); + + rows.forEach(row => { + for (let i = 0; i < row.children.length; i++) { + const cell = row.children[i] as HTMLElement; + if (stickyLeftStates[i]) { + this._addStickyStyle(cell, 'left', leftPositions[i]); + } + + if (stickyRightStates[i]) { + this._addStickyStyle(cell, 'right', rightPositions[i]); + } + } + }); + } + + /** + * Applies sticky positioning to the row's cells if using the native table layout, and to the + * row itself otherwise. + */ + stickRows(rows: HTMLElement[], stickyStates: boolean[], position: 'top' | 'bottom') { + // Bottom-positions rows should stick in reverse order + // (e.g. last stuck item will be bottom: 0px) + if (position === 'bottom') { + rows = rows.reverse(); + } + + let stickyHeight = 0; + rows.forEach((row, i) => { + if (!stickyStates[i]) { + return; + } + + if (this.usesNativeHtmlTable) { + 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.usesNativeHtmlTable) { + 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, resetting the + * z-index back to 0, removing each of the provided sticky directions, and removing the + * sticky position if there are no more directions. + */ + _removeStickyStyle(element: HTMLElement, stickyDirections: StickyDirection[]) { + stickyDirections.forEach(dir => 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 = ['top', 'bottom', 'left', 'right'].some(dir => element.style[dir]); + if (!hasDirection) { + element.style.position = ''; + element.classList.remove(this.stickyCellCSS); + } + } + + /** + * 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.stickyCellCSS); + 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 the sticky directions styles + * that it has set. It should be the case that elements with a top direction should always be at + * the forefront, followed by bottom direction elements. Finally, anything with left or right + * direction should come behind those. All else should be the lowest and not have any increased + * z-index. + */ + _getCalculatedZIndex(element: HTMLElement): string { + const zIndexIncrements = { + top: 100, + bottom: 10, + left: 1, + right: 1, + }; + + let zIndex = 0; + ['top', 'bottom', 'left', 'right'].forEach(dir => { + if (element.style[dir]) { + zIndex += zIndexIncrements[dir]; + } + }); + + return String(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. + */ + _getStickyLeftColumnPositions(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. + */ + _getStickyRightColumnPositions(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..62b60df0ff47 100644 --- a/src/cdk/table/table.spec.ts +++ b/src/cdk/table/table.spec.ts @@ -43,9 +43,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 +694,355 @@ 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-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 flex layout', () => { + 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.stickyLeftColumns = ['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.stickyLeftColumns = []; + 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.stickyRightColumns = ['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.stickyRightColumns = []; + 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.stickyLeftColumns = ['column-1']; + component.stickyRightColumns = ['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.stickyLeftColumns = []; + component.stickyRightColumns = []; + 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.stickyLeftColumns = ['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.stickyLeftColumns = []; + 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.stickyRightColumns = ['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.stickyRightColumns = []; + 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.stickyLeftColumns = ['column-1']; + component.stickyRightColumns = ['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.stickyLeftColumns = []; + component.stickyRightColumns = []; + 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 +1708,109 @@ 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; + + stickyHeaders: string[] = []; + stickyFooters: string[] = []; + stickyLeftColumns: string[] = []; + stickyRightColumns: 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[] = []; + stickyLeftColumns: string[] = []; + stickyRightColumns: string[] = []; + + isStuck(list: string[], id: string) { + return list.indexOf(id) != -1; + } +} + @Component({ template: ` @@ -1700,7 +2151,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 +2160,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 +2169,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..0bce51cb0739 100644 --- a/src/cdk/table/table.ts +++ b/src/cdk/table/table.ts @@ -17,6 +17,7 @@ import { Directive, ElementRef, EmbeddedViewRef, + InjectionToken, Input, isDevMode, IterableChangeRecord, @@ -24,7 +25,9 @@ import { IterableDiffers, OnDestroy, OnInit, + Optional, QueryList, + SkipSelf, TemplateRef, TrackByFunction, ViewChild, @@ -52,6 +55,7 @@ import { getTableUnknownDataSourceError } from './table-errors'; import {coerceBooleanProperty} from '@angular/cdk/coercion'; +import {StickyStyler} from './sticky-styler'; /** Interface used to provide an outlet for rows to be inserted into. */ export interface RowOutlet { @@ -245,6 +249,21 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes */ private _cachedRenderRowsMap = new Map, RenderRow[]>>(); + /** Whether the table is applied to a native ``. */ + private _usesNativeHtmlTable: 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-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 @@ -344,10 +363,14 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes if (!role) { this._elementRef.nativeElement.setAttribute('role', 'grid'); } + + this._usesNativeHtmlTable = this._elementRef.nativeElement.nodeName === 'TABLE'; } ngOnInit() { - if (this._elementRef.nativeElement.nodeName === 'TABLE') { + this._stickyStyler = new StickyStyler(this._usesNativeHtmlTable, this.stickyCssClass); + + if (this._usesNativeHtmlTable) { 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,88 @@ 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, left, and right. Then, evaluating which cells need to be stuck to the top. + * Then, adding sticky left and sticky right according to the column definitions for each cell + * in each row. This is automatically called when the header row changes its displayed set of + * columns, if its sticky input changes, or when any column definition changes its sticky input. + * May be called manually for cases where the cell content changes outside of these events. + */ + updateStickyHeaderRowStyles() { + const headerRows = this._getRenderedRows(this._headerRowOutlet); + this._stickyStyler.clearStickyPositioningStyles(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, left, and right. Then, evaluating which cells need to be stuck to the + * bottom. Then, adding sticky left and sticky right according to the column definitions for each + * cell in each row. This is automatically called when the footer row changes its displayed set of + * columns, if its sticky input changes, or when any column definition changes its sticky input. + * May be called manually for cases where the cell content changes outside of these events. + */ + updateStickyFooterRowStyles() { + const footerRows = this._getRenderedRows(this._footerRowOutlet); + this._stickyStyler.clearStickyPositioningStyles(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 for all columns + this._stickyStyler.clearStickyPositioningStyles( + [...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._updateStickyColumnStyles([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._updateStickyColumnStyles(rows, rowDef); + }); + + // Update the sticky styles for each footer row depending on the def's sticky state + footerRows.forEach((footerRow, i) => { + this._updateStickyColumnStyles([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 +715,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 +776,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 +801,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 +821,28 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes } this._footerRowDefs.forEach((def, i) => this._renderRow(this._footerRowOutlet, def, i)); + this.updateStickyFooterRowStyles(); + this.updateStickyColumnStyles(); + } + + /** Updates the sticky column styles for the rows according to the columns' stick states. */ + private _updateStickyColumnStyles(rows: HTMLElement[], rowDef: BaseRowDef) { + const columnDefs = Array.from(rowDef.columns || []).map(c => this._columnDefsByName.get(c)!); + const stickyLeftStates = columnDefs.map(columnDef => columnDef.stickyLeft); + const stickyRightStates = columnDefs.map(columnDef => columnDef.stickyRight); + this._stickyStyler.updateStickyColumns(rows, stickyLeftStates, stickyRightStates); + } + + /** 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 +963,30 @@ 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.checkStickyChanged(); + }; + + 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(); + } } } 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/_table-theme.scss b/src/lib/table/_table-theme.scss index e386dee8cc7c..f4a07822fe0e 100644 --- a/src/lib/table/_table-theme.scss +++ b/src/lib/table/_table-theme.scss @@ -10,6 +10,13 @@ background: mat-color($background, 'card'); } + // TODO(andrewseguin): Only needed for sticky styles, maybe have it triggered only then? + .mat-table thead, .mat-table tbody, .mat-table tfoot, + .mat-header-row, .mat-row, .mat-footer-row, + .mat-header-cell, .mat-cell, .mat-footer-cell { + 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..4f280ee0f908 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 on the left of the right */ + @Input() stickyLeft: boolean; + + /** Whether this column should be sticky positioned on the right of the right */ + @Input() stickyRight: 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..cf1a92455945 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 left or right of the scrolling viewport. To do this, +add the `stickyLeft` or `stickyRight` 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..2502d6f7222f 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-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..ab781f942364 100644 --- a/src/lib/table/table.ts +++ b/src/lib/table/table.ts @@ -33,6 +33,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-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. 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..3f6ae9c370ba --- /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-sticky:first-child { + border-right: 1px solid #e0e0e0; +} + +.mat-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..682c3b97e708 --- /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..dbeeafab83ea --- /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-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..eb5935d688f7 --- /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..31a48dd1cc9b --- /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-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..c58ab113c59c --- /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..74e2be48beb5 --- /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-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'}, +]; From 8f4587739a2e644e7786a03f1af2589e86a7fc39 Mon Sep 17 00:00:00 2001 From: Andrew Seguin Date: Fri, 25 May 2018 15:49:56 -0700 Subject: [PATCH 2/8] review --- .../{has-sticky-state.ts => can-stick.ts} | 14 +- src/cdk/table/cell.ts | 38 ++--- src/cdk/table/public-api.ts | 2 +- src/cdk/table/row.ts | 10 +- src/cdk/table/sticky-styler.ts | 131 +++++++++++------- src/cdk/table/table.spec.ts | 52 +++---- src/cdk/table/table.ts | 62 ++++----- src/lib/table/cell.ts | 8 +- src/lib/table/table.md | 4 +- src/lib/table/table.spec.ts | 2 +- src/lib/table/table.ts | 2 +- .../table-sticky-column-example.css | 4 +- .../table-sticky-column-example.html | 4 +- .../table-sticky-complex-flex-example.css | 2 +- .../table-sticky-complex-flex-example.html | 8 +- .../table-sticky-complex-example.css | 2 +- .../table-sticky-complex-example.html | 8 +- .../table-sticky-footer-example.css | 2 +- 18 files changed, 191 insertions(+), 164 deletions(-) rename src/cdk/table/{has-sticky-state.ts => can-stick.ts} (75%) diff --git a/src/cdk/table/has-sticky-state.ts b/src/cdk/table/can-stick.ts similarity index 75% rename from src/cdk/table/has-sticky-state.ts rename to src/cdk/table/can-stick.ts index e93ca978500b..adf6ef93f496 100644 --- a/src/cdk/table/has-sticky-state.ts +++ b/src/cdk/table/can-stick.ts @@ -15,12 +15,14 @@ export type Constructor = new(...args: any[]) => T; * sticky value. * @docs-private */ -export interface HasStickyState { - /** State of whether the sticky input has changed since it was last checked. */ +export interface CanStick { + /** Whether the sticky input has changed since it was last checked. */ _hasStickyChanged: boolean; - checkStickyChanged(): 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; } @@ -30,13 +32,13 @@ export interface HasStickyState { * sticky value. */ export function mixinHasStickyInput>(base: T): - Constructor & T { + Constructor & T { return class extends base { - /** State of whether the sticky input has changed since it was last checked. */ + /** 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. */ - checkStickyChanged(): boolean { + hasStickyChanged(): boolean { const hasStickyChanged = this._hasStickyChanged; this._hasStickyChanged = false; return hasStickyChanged; diff --git a/src/cdk/table/cell.ts b/src/cdk/table/cell.ts index c7b97acbe6d4..41c666ba26d4 100644 --- a/src/cdk/table/cell.ts +++ b/src/cdk/table/cell.ts @@ -8,7 +8,7 @@ import {ContentChild, Directive, ElementRef, Input, TemplateRef} from '@angular/core'; import {coerceBooleanProperty} from '@angular/cdk/coercion'; -import {HasStickyState, mixinHasStickyInput} from './has-sticky-state'; +import {CanStick, mixinHasStickyInput} from './can-stick'; /** Base interface for a cell definition. Captures a column's cell template definition. */ export interface CellDef { @@ -52,7 +52,7 @@ export const _CdkColumnDefBase = mixinHasStickyInput(CdkColumnDefBase); * Defines a set of cells available for a table column. */ @Directive({selector: '[cdkColumnDef]'}) -export class CdkColumnDef extends _CdkColumnDefBase implements HasStickyState { +export class CdkColumnDef extends _CdkColumnDefBase implements CanStick { /** Unique name for this column. */ @Input('cdkColumnDef') get name(): string { return this._name; } @@ -66,25 +66,25 @@ export class CdkColumnDef extends _CdkColumnDefBase implements HasStickyState { } _name: string; - /** Whether this column should be sticky positioned on the left of the row */ - @Input('stickyLeft') - get stickyLeft(): boolean { return this._stickyLeft; } - set stickyLeft(v: boolean) { - const prevValue = this._stickyLeft; - this._stickyLeft = coerceBooleanProperty(v); - this._hasStickyChanged = prevValue !== this._stickyLeft; + /** Whether this column should be sticky positioned on the start of the row */ + @Input('sticky') + get sticky(): boolean { return this._sticky; } + set sticky(v: boolean) { + const prevValue = this._sticky; + this._sticky = coerceBooleanProperty(v); + this._hasStickyChanged = prevValue !== this._sticky; } - _stickyLeft: boolean = false; - - /** Whether this column should be sticky positioned on the right of the right */ - @Input('stickyRight') - get stickyRight(): boolean { return this._stickyRight; } - set stickyRight(v: boolean) { - const prevValue = this._stickyRight; - this._stickyRight = coerceBooleanProperty(v); - this._hasStickyChanged = prevValue !== this._stickyRight; + _sticky: boolean = false; + + /** Whether this column should be sticky positioned on the end of the row */ + @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; } - _stickyRight: boolean = false; + _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 8d81f43973f3..f993cd312529 100644 --- a/src/cdk/table/public-api.ts +++ b/src/cdk/table/public-api.ts @@ -11,7 +11,7 @@ export * from './cell'; export * from './row'; export * from './table-module'; export * from './sticky-styler'; -export * from './has-sticky-state'; +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 6f91455ebd4d..7afc73aee879 100644 --- a/src/cdk/table/row.ts +++ b/src/cdk/table/row.ts @@ -21,7 +21,7 @@ import { } from '@angular/core'; import {CdkCellDef, CdkColumnDef} from './cell'; import {coerceBooleanProperty} from '@angular/cdk/coercion'; -import {HasStickyState, mixinHasStickyInput} from './has-sticky-state'; +import {CanStick, mixinHasStickyInput} from './can-stick'; /** * The row template that can be used by the mat-table. Should not be used outside of the @@ -86,13 +86,13 @@ export const _CdkHeaderRowDefBase = mixinHasStickyInput(CdkHeaderRowDefBase); selector: '[cdkHeaderRowDef]', inputs: ['columns: cdkHeaderRowDef', 'sticky: cdkHeaderRowDefSticky'], }) -export class CdkHeaderRowDef extends _CdkHeaderRowDefBase implements HasStickyState { +export class CdkHeaderRowDef extends _CdkHeaderRowDefBase implements CanStick { + get sticky(): boolean { return this._sticky; } set sticky(v: boolean) { const prevValue = this._sticky; this._sticky = coerceBooleanProperty(v); this._hasStickyChanged = prevValue !== this._sticky; } - get sticky(): boolean { return this._sticky; } _sticky: boolean; constructor(template: TemplateRef, _differs: IterableDiffers) { @@ -113,13 +113,13 @@ export const _CdkFooterRowDefBase = mixinHasStickyInput(CdkFooterRowDefBase); selector: '[cdkFooterRowDef]', inputs: ['columns: cdkFooterRowDef', 'sticky: cdkFooterRowDefSticky'], }) -export class CdkFooterRowDef extends _CdkFooterRowDefBase implements HasStickyState { +export class CdkFooterRowDef extends _CdkFooterRowDefBase implements CanStick { + get sticky(): boolean { return this._sticky; } set sticky(v: boolean) { const prevValue = this._sticky; this._sticky = coerceBooleanProperty(v); this._hasStickyChanged = prevValue !== this._sticky; } - get sticky(): boolean { return this._sticky; } _sticky: boolean; constructor(template: TemplateRef, _differs: IterableDiffers) { diff --git a/src/cdk/table/sticky-styler.ts b/src/cdk/table/sticky-styler.ts index 2b89a2fb5bb0..e4f607e40bea 100644 --- a/src/cdk/table/sticky-styler.ts +++ b/src/cdk/table/sticky-styler.ts @@ -6,88 +6,107 @@ * found in the LICENSE file at https://angular.io/license */ -/** Directions that can be used when setting sticky positioning. */ +/** + * Directions that can be used when setting sticky positioning. + * @docs-private + */ export type StickyDirection = 'top' | 'bottom' | 'left' | 'right'; /** - * Z-index values that should be used when sticking row cells to the top and bottom. If one of those - * cells are also stuck to the left or right, their z-index should be incremented by one. In doing - * this, it is guaranteed that header cells will always cover footer cells, and both will always - * cover data rows. + * List of all possible directions that can be used for sticky positioning. + * @docs-private */ -export enum StickyRowZIndex { - Top = 100, - Bottom = 10, - Left = 1, - Right = 1, -} +export const STICKY_DIRECTIONS: StickyDirection[] = ['top', 'bottom', 'left', 'right']; -/** Applies and removes sticky positioning styles to the `CdkTable` rows and columns cells. */ +/** + * Applies and removes sticky positioning styles to the `CdkTable` rows and columns cells. + * @docs-private + */ export class StickyStyler { - constructor(private usesNativeHtmlTable: boolean, private stickyCellCSS: string) { } + /** + * @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. + */ + constructor(private isNativeHtmlTable: boolean, private stickCellCSS: string) { } /** * 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. */ - clearStickyPositioningStyles(rows: HTMLElement[], stickyDirections: StickyDirection[]) { - rows.forEach(row => { + clearStickyPositioning(rows: HTMLElement[], stickyDirections: StickyDirection[]) { + for (let 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[], stickyLeftStates: boolean[], stickyRightStates: boolean[]) { + rows: HTMLElement[], stickyStartStates: boolean[], stickyEndStates: boolean[]) { const hasStickyColumns = - stickyLeftStates.some(state => state) || stickyRightStates.some(state => state); + stickyStartStates.some(state => state) || stickyEndStates.some(state => state); if (!rows.length || !hasStickyColumns) { return; } + const numCells = rows[0].children.length; const cellWidths: number[] = this._getCellWidths(rows[0]); - const leftPositions = this._getStickyLeftColumnPositions(cellWidths, stickyLeftStates); - const rightPositions = this._getStickyRightColumnPositions(cellWidths, stickyRightStates); + const startPositions = this._getStickyStartColumnPositions(cellWidths, stickyStartStates); + const endPositions = this._getStickyEndColumnPositions(cellWidths, stickyEndStates); - rows.forEach(row => { - for (let i = 0; i < row.children.length; i++) { + for (let row of rows) { + for (let i = 0; i < numCells; i++) { const cell = row.children[i] as HTMLElement; - if (stickyLeftStates[i]) { - this._addStickyStyle(cell, 'left', leftPositions[i]); + if (stickyStartStates[i]) { + this._addStickyStyle(cell, 'left', startPositions[i]); } - if (stickyRightStates[i]) { - this._addStickyStyle(cell, 'right', rightPositions[i]); + if (stickyEndStates[i]) { + this._addStickyStyle(cell, '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(rows: HTMLElement[], stickyStates: boolean[], position: 'top' | 'bottom') { - // Bottom-positions rows should stick in reverse order - // (e.g. last stuck item will be bottom: 0px) - if (position === 'bottom') { - rows = rows.reverse(); - } + 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; - rows.forEach((row, i) => { - if (!stickyStates[i]) { - return; + for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) { + if (!stickyStates[rowIndex]) { + continue; } - if (this.usesNativeHtmlTable) { + 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); @@ -99,7 +118,7 @@ export class StickyStyler { } stickyHeight += row.getBoundingClientRect().height; - }); + } } /** @@ -109,7 +128,7 @@ export class StickyStyler { * the tfoot element. */ updateStickyFooterContainer(tableElement: Element, stickyStates: boolean[]) { - if (!this.usesNativeHtmlTable) { + if (!this.isNativeHtmlTable) { return; } @@ -127,15 +146,17 @@ export class StickyStyler { * sticky position if there are no more directions. */ _removeStickyStyle(element: HTMLElement, stickyDirections: StickyDirection[]) { - stickyDirections.forEach(dir => element.style[dir] = ''); + for (let 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 = ['top', 'bottom', 'left', 'right'].some(dir => element.style[dir]); + const hasDirection = STICKY_DIRECTIONS.some(dir => !!element.style[dir]); if (!hasDirection) { element.style.position = ''; - element.classList.remove(this.stickyCellCSS); + element.classList.remove(this.stickCellCSS); } } @@ -145,18 +166,22 @@ export class StickyStyler { * direction and value. */ _addStickyStyle(element: HTMLElement, dir: StickyDirection, dirValue: number) { - element.classList.add(this.stickyCellCSS); + 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 the sticky directions styles - * that it has set. It should be the case that elements with a top direction should always be at - * the forefront, followed by bottom direction elements. Finally, anything with left or right - * direction should come behind those. All else should be the lowest and not have any increased - * z-index. + * 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 = { @@ -167,13 +192,13 @@ export class StickyStyler { }; let zIndex = 0; - ['top', 'bottom', 'left', 'right'].forEach(dir => { + for (let dir of STICKY_DIRECTIONS) { if (element.style[dir]) { zIndex += zIndexIncrements[dir]; } - }); + } - return String(zIndex); + return `${zIndex}`; } /** Gets the widths for each cell in the provided row. */ @@ -193,7 +218,7 @@ export class StickyStyler { * 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. */ - _getStickyLeftColumnPositions(widths: number[], stickyStates: boolean[]): number[] { + _getStickyStartColumnPositions(widths: number[], stickyStates: boolean[]): number[] { const positions: number[] = []; let nextPosition = 0; @@ -212,7 +237,7 @@ export class StickyStyler { * 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. */ - _getStickyRightColumnPositions(widths: number[], stickyStates: boolean[]): number[] { + _getStickyEndColumnPositions(widths: number[], stickyStates: boolean[]): number[] { const positions: number[] = []; let nextPosition = 0; diff --git a/src/cdk/table/table.spec.ts b/src/cdk/table/table.spec.ts index 62b60df0ff47..178ab15c48ab 100644 --- a/src/cdk/table/table.spec.ts +++ b/src/cdk/table/table.spec.ts @@ -704,7 +704,7 @@ describe('CdkTable', () => { function expectNoStickyStyles(elements: any[]) { elements.forEach(element => { - expect(element.classList.contains('cdk-sticky')); + 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 => { @@ -730,7 +730,7 @@ describe('CdkTable', () => { }); } - describe('on flex layout', () => { + describe('on "display: flex" table style', () => { let dataRows: Element[]; let headerRows: Element[]; let footerRows: Element[]; @@ -772,7 +772,7 @@ describe('CdkTable', () => { }); it('should stick and unstick left columns', () => { - component.stickyLeftColumns = ['column-1', 'column-3']; + component.stickyStartColumns = ['column-1', 'column-3']; fixture.detectChanges(); headerRows.forEach(row => { @@ -794,7 +794,7 @@ describe('CdkTable', () => { expectNoStickyStyles([cells[1], cells[3], cells[4], cells[5]]); }); - component.stickyLeftColumns = []; + component.stickyStartColumns = []; fixture.detectChanges(); headerRows.forEach(row => expectNoStickyStyles(getHeaderCells(row))); dataRows.forEach(row => expectNoStickyStyles(getCells(row))); @@ -802,7 +802,7 @@ describe('CdkTable', () => { }); it('should stick and unstick right columns', () => { - component.stickyRightColumns = ['column-4', 'column-6']; + component.stickyEndColumns = ['column-4', 'column-6']; fixture.detectChanges(); headerRows.forEach(row => { @@ -824,7 +824,7 @@ describe('CdkTable', () => { expectNoStickyStyles([cells[0], cells[1], cells[2], cells[4]]); }); - component.stickyRightColumns = []; + component.stickyEndColumns = []; fixture.detectChanges(); headerRows.forEach(row => expectNoStickyStyles(getHeaderCells(row))); dataRows.forEach(row => expectNoStickyStyles(getCells(row))); @@ -834,8 +834,8 @@ describe('CdkTable', () => { it('should stick and unstick combination of sticky header, footer, and columns', () => { component.stickyHeaders = ['header-1']; component.stickyFooters = ['footer-3']; - component.stickyLeftColumns = ['column-1']; - component.stickyRightColumns = ['column-6']; + component.stickyStartColumns = ['column-1']; + component.stickyEndColumns = ['column-6']; fixture.detectChanges(); let headerCells = getHeaderCells(headerRows[0]); @@ -861,8 +861,8 @@ describe('CdkTable', () => { component.stickyHeaders = []; component.stickyFooters = []; - component.stickyLeftColumns = []; - component.stickyRightColumns = []; + component.stickyStartColumns = []; + component.stickyEndColumns = []; fixture.detectChanges(); headerRows.forEach(row => expectNoStickyStyles([row, ...getHeaderCells(row)])); @@ -940,7 +940,7 @@ describe('CdkTable', () => { }); it('should stick and unstick left columns', () => { - component.stickyLeftColumns = ['column-1', 'column-3']; + component.stickyStartColumns = ['column-1', 'column-3']; fixture.detectChanges(); headerRows.forEach(row => { @@ -962,7 +962,7 @@ describe('CdkTable', () => { expectNoStickyStyles([cells[1], cells[3], cells[4], cells[5]]); }); - component.stickyLeftColumns = []; + component.stickyStartColumns = []; fixture.detectChanges(); headerRows.forEach(row => expectNoStickyStyles(getHeaderCells(row))); dataRows.forEach(row => expectNoStickyStyles(getCells(row))); @@ -970,7 +970,7 @@ describe('CdkTable', () => { }); it('should stick and unstick right columns', () => { - component.stickyRightColumns = ['column-4', 'column-6']; + component.stickyEndColumns = ['column-4', 'column-6']; fixture.detectChanges(); headerRows.forEach(row => { @@ -992,7 +992,7 @@ describe('CdkTable', () => { expectNoStickyStyles([cells[0], cells[1], cells[2], cells[4]]); }); - component.stickyRightColumns = []; + component.stickyEndColumns = []; fixture.detectChanges(); headerRows.forEach(row => expectNoStickyStyles(getHeaderCells(row))); dataRows.forEach(row => expectNoStickyStyles(getCells(row))); @@ -1002,8 +1002,8 @@ describe('CdkTable', () => { it('should stick and unstick combination of sticky header, footer, and columns', () => { component.stickyHeaders = ['header-1']; component.stickyFooters = ['footer-3']; - component.stickyLeftColumns = ['column-1']; - component.stickyRightColumns = ['column-6']; + component.stickyStartColumns = ['column-1']; + component.stickyEndColumns = ['column-6']; fixture.detectChanges(); const headerCells = getHeaderCells(headerRows[0]); @@ -1033,8 +1033,8 @@ describe('CdkTable', () => { component.stickyHeaders = []; component.stickyFooters = []; - component.stickyLeftColumns = []; - component.stickyRightColumns = []; + component.stickyStartColumns = []; + component.stickyEndColumns = []; fixture.detectChanges(); headerRows.forEach(row => expectNoStickyStyles([row, ...getHeaderCells(row)])); @@ -1712,8 +1712,8 @@ class TrackByCdkTableApp { template: ` + [sticky]="isStuck(stickyStartColumns, column)" + [stickyEnd]="isStuck(stickyEndColumns, column)"> Header {{column}} {{column}} Footer {{column}} @@ -1751,8 +1751,8 @@ class StickyFlexLayoutCdkTableApp { stickyHeaders: string[] = []; stickyFooters: string[] = []; - stickyLeftColumns: string[] = []; - stickyRightColumns: string[] = []; + stickyStartColumns: string[] = []; + stickyEndColumns: string[] = []; isStuck(list: string[], id: string) { return list.indexOf(id) != -1; @@ -1763,8 +1763,8 @@ class StickyFlexLayoutCdkTableApp { template: `
+ [sticky]="isStuck(stickyStartColumns, column)" + [stickyEnd]="isStuck(stickyEndColumns, column)"> @@ -1803,8 +1803,8 @@ class StickyNativeLayoutCdkTableApp { stickyHeaders: string[] = []; stickyFooters: string[] = []; - stickyLeftColumns: string[] = []; - stickyRightColumns: string[] = []; + stickyStartColumns: string[] = []; + stickyEndColumns: string[] = []; isStuck(list: string[], id: string) { return list.indexOf(id) != -1; diff --git a/src/cdk/table/table.ts b/src/cdk/table/table.ts index 0bce51cb0739..562fb81158cd 100644 --- a/src/cdk/table/table.ts +++ b/src/cdk/table/table.ts @@ -17,7 +17,6 @@ import { Directive, ElementRef, EmbeddedViewRef, - InjectionToken, Input, isDevMode, IterableChangeRecord, @@ -25,9 +24,7 @@ import { IterableDiffers, OnDestroy, OnInit, - Optional, QueryList, - SkipSelf, TemplateRef, TrackByFunction, ViewChild, @@ -250,7 +247,7 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes private _cachedRenderRowsMap = new Map, RenderRow[]>>(); /** Whether the table is applied to a native `
Header {{column}} {{column}} Footer {{column}}
`. */ - private _usesNativeHtmlTable: boolean; + private _isNativeHtmlTable: boolean; /** * Utility class that is responsible for applying the appropriate sticky positioning styles to @@ -262,7 +259,7 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes * CSS class added to any row or cell that has sticky positioning applied. May be overriden by * table subclasses. */ - protected stickyCssClass: string = 'cdk-sticky'; + protected stickyCssClass: string = 'cdk-table-sticky'; /** * Tracking function that will be used to check the differences in data changes. Used similarly @@ -364,13 +361,13 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes this._elementRef.nativeElement.setAttribute('role', 'grid'); } - this._usesNativeHtmlTable = this._elementRef.nativeElement.nodeName === 'TABLE'; + this._isNativeHtmlTable = this._elementRef.nativeElement.nodeName === 'TABLE'; } ngOnInit() { - this._stickyStyler = new StickyStyler(this._usesNativeHtmlTable, this.stickyCssClass); + this._stickyStyler = new StickyStyler(this._isNativeHtmlTable, this.stickyCssClass); - if (this._usesNativeHtmlTable) { + if (this._isNativeHtmlTable) { this._applyNativeTableSections(); } @@ -544,15 +541,14 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes /** * Updates the header sticky styles. First resets all applied styles with respect to the cells - * sticking to the top, left, and right. Then, evaluating which cells need to be stuck to the top. - * Then, adding sticky left and sticky right according to the column definitions for each cell - * in each row. This is automatically called when the header row changes its displayed set of - * columns, if its sticky input changes, or when any column definition changes its sticky input. - * May be called manually for cases where the cell content changes outside of these events. + * 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.clearStickyPositioningStyles(headerRows, ['top']); + this._stickyStyler.clearStickyPositioning(headerRows, ['top']); const stickyStates = this._headerRowDefs.map(def => def.sticky); this._stickyStyler.stickRows(headerRows, stickyStates, 'top'); @@ -563,15 +559,14 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes /** * Updates the footer sticky styles. First resets all applied styles with respect to the cells - * sticking to the bottom, left, and right. Then, evaluating which cells need to be stuck to the - * bottom. Then, adding sticky left and sticky right according to the column definitions for each - * cell in each row. This is automatically called when the footer row changes its displayed set of - * columns, if its sticky input changes, or when any column definition changes its sticky input. - * May be called manually for cases where the cell content changes outside of these events. + * 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.clearStickyPositioningStyles(footerRows, ['bottom']); + this._stickyStyler.clearStickyPositioning(footerRows, ['bottom']); const stickyStates = this._footerRowDefs.map(def => def.sticky); this._stickyStyler.stickRows(footerRows, stickyStates, 'bottom'); @@ -593,13 +588,14 @@ 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 for all columns - this._stickyStyler.clearStickyPositioningStyles( + // 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._updateStickyColumnStyles([headerRow], this._headerRowDefs[i]); + this._addStickyColumnStyles([headerRow], this._headerRowDefs[i]); }); // Update the sticky styles for each data row depending on its def's sticky state @@ -612,12 +608,12 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes } } - this._updateStickyColumnStyles(rows, rowDef); + this._addStickyColumnStyles(rows, rowDef); }); // Update the sticky styles for each footer row depending on the def's sticky state footerRows.forEach((footerRow, i) => { - this._updateStickyColumnStyles([footerRow], this._footerRowDefs[i]); + this._addStickyColumnStyles([footerRow], this._footerRowDefs[i]); }); // Reset the dirty state of the sticky input change since it has been used. @@ -825,12 +821,12 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes this.updateStickyColumnStyles(); } - /** Updates the sticky column styles for the rows according to the columns' stick states. */ - private _updateStickyColumnStyles(rows: HTMLElement[], rowDef: BaseRowDef) { + /** 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 stickyLeftStates = columnDefs.map(columnDef => columnDef.stickyLeft); - const stickyRightStates = columnDefs.map(columnDef => columnDef.stickyRight); - this._stickyStyler.updateStickyColumns(rows, stickyLeftStates, stickyRightStates); + 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. */ @@ -973,9 +969,13 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes */ private _checkStickyStates() { const stickyCheckReducer = (acc: boolean, d: CdkHeaderRowDef|CdkFooterRowDef|CdkColumnDef) => { - return acc || d.checkStickyChanged(); + 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(); } diff --git a/src/lib/table/cell.ts b/src/lib/table/cell.ts index 4f280ee0f908..11ba621ea165 100644 --- a/src/lib/table/cell.ts +++ b/src/lib/table/cell.ts @@ -72,11 +72,11 @@ export class MatColumnDef extends CdkColumnDef { /** Unique name for this column. */ @Input('matColumnDef') name: string; - /** Whether this column should be sticky positioned on the left of the right */ - @Input() stickyLeft: boolean; + /** 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 right of the right */ - @Input() stickyRight: 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/table.md b/src/lib/table/table.md index cf1a92455945..072c21aaec59 100644 --- a/src/lib/table/table.md +++ b/src/lib/table/table.md @@ -309,8 +309,8 @@ rendered footer rows. -It is also possible to fix cell columns to the left or right of the scrolling viewport. To do this, -add the `stickyLeft` or `stickyRight` directive to the `ng-container` column definition. +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. diff --git a/src/lib/table/table.spec.ts b/src/lib/table/table.spec.ts index 2502d6f7222f..0f2a4471f58c 100644 --- a/src/lib/table/table.spec.ts +++ b/src/lib/table/table.spec.ts @@ -124,7 +124,7 @@ describe('MatTable', () => { fixture.detectChanges(); const stuckCellElement = fixture.nativeElement.querySelector('.mat-table th')!; - expect(stuckCellElement.classList).toContain('mat-sticky'); + expect(stuckCellElement.classList).toContain('mat-table-sticky'); }); describe('with MatTableDataSource and sort/pagination/filter', () => { diff --git a/src/lib/table/table.ts b/src/lib/table/table.ts index ab781f942364..25e0e593f75d 100644 --- a/src/lib/table/table.ts +++ b/src/lib/table/table.ts @@ -34,7 +34,7 @@ import {CDK_TABLE_TEMPLATE, CdkTable} from '@angular/cdk/table'; }) export class MatTable extends CdkTable { /** Overrides the sticky CSS class set by the `CdkTable`. */ - protected stickyCssClass = 'mat-sticky'; + 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 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 index 3f6ae9c370ba..d0ad276606c1 100644 --- a/src/material-examples/table-sticky-columns/table-sticky-column-example.css +++ b/src/material-examples/table-sticky-columns/table-sticky-column-example.css @@ -17,10 +17,10 @@ th.mat-column-position, td.mat-column-position { padding-left: 8px; } -.mat-sticky:first-child { +.mat-table-sticky:first-child { border-right: 1px solid #e0e0e0; } -.mat-sticky:last-child { +.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 index 682c3b97e708..73b460f52121 100644 --- a/src/material-examples/table-sticky-columns/table-sticky-column-example.html +++ b/src/material-examples/table-sticky-columns/table-sticky-column-example.html @@ -2,7 +2,7 @@
- + @@ -26,7 +26,7 @@ - +
Name {{element.name}} more_vert 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 index dbeeafab83ea..8e5c53c55f3e 100644 --- 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 @@ -3,7 +3,7 @@ overflow: auto; } -.mat-sticky { +.mat-table-sticky { background: #59abfd; opacity: 1; } 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 index eb5935d688f7..c403cd0bd4ad 100644 --- 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 @@ -37,25 +37,25 @@
- + Position {{element.position}} Position Footer - + Name {{element.name}} Name Footer - + Weight {{element.weight}} Weight Footer - + Symbol {{element.symbol}} Symbol Footer 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 index 31a48dd1cc9b..d3edcb38f991 100644 --- a/src/material-examples/table-sticky-complex/table-sticky-complex-example.css +++ b/src/material-examples/table-sticky-complex/table-sticky-complex-example.css @@ -3,7 +3,7 @@ overflow: auto; } -.mat-sticky { +.mat-table-sticky { background: #59abfd; opacity: 1; } 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 index c58ab113c59c..24944caefeff 100644 --- a/src/material-examples/table-sticky-complex/table-sticky-complex-example.html +++ b/src/material-examples/table-sticky-complex/table-sticky-complex-example.html @@ -37,25 +37,25 @@
- + - + - + - + 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 index 74e2be48beb5..6b5869e7c72b 100644 --- a/src/material-examples/table-sticky-footer/table-sticky-footer-example.css +++ b/src/material-examples/table-sticky-footer/table-sticky-footer-example.css @@ -11,6 +11,6 @@ tr.mat-footer-row { font-weight: bold; } -.mat-sticky { +.mat-table-sticky { border-top: 1px solid #e0e0e0; } From 01cf286d341f6820c01f24146f902dc2d93dbab3 Mon Sep 17 00:00:00 2001 From: Andrew Seguin Date: Tue, 29 May 2018 14:55:46 -0700 Subject: [PATCH 3/8] support rtl sticky columns --- src/cdk/table/BUILD.bazel | 1 + src/cdk/table/sticky-styler.ts | 13 +++++++++--- src/cdk/table/table.spec.ts | 36 +++++++++++++++++++++++++++++++-- src/cdk/table/table.ts | 23 +++++++++++++++++++-- src/demo-app/example/example.ts | 10 +++++---- src/lib/table/BUILD.bazel | 1 + src/lib/table/table.ts | 7 +++++-- 7 files changed, 78 insertions(+), 13 deletions(-) diff --git a/src/cdk/table/BUILD.bazel b/src/cdk/table/BUILD.bazel index 3654d070812f..0a17df870ca4 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", diff --git a/src/cdk/table/sticky-styler.ts b/src/cdk/table/sticky-styler.ts index e4f607e40bea..842f469ee556 100644 --- a/src/cdk/table/sticky-styler.ts +++ b/src/cdk/table/sticky-styler.ts @@ -10,6 +10,8 @@ * Directions that can be used when setting sticky positioning. * @docs-private */ +import {Direction} from '@angular/cdk/bidi'; + export type StickyDirection = 'top' | 'bottom' | 'left' | 'right'; /** @@ -28,8 +30,12 @@ export class StickyStyler { * that uses the native `
Position {{element.position}} Position Footer Name {{element.name}} Name Footer Weight {{element.weight}} Weight Footer Symbol {{element.symbol}} Symbol Footer
` 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) { } + 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` @@ -68,16 +74,17 @@ export class StickyStyler { const cellWidths: number[] = this._getCellWidths(rows[0]); const startPositions = this._getStickyStartColumnPositions(cellWidths, stickyStartStates); const endPositions = this._getStickyEndColumnPositions(cellWidths, stickyEndStates); + const isLtr = this.direction === 'ltr'; for (let row of rows) { for (let i = 0; i < numCells; i++) { const cell = row.children[i] as HTMLElement; if (stickyStartStates[i]) { - this._addStickyStyle(cell, 'left', startPositions[i]); + this._addStickyStyle(cell, isLtr ? 'left' : 'right', startPositions[i]); } if (stickyEndStates[i]) { - this._addStickyStyle(cell, 'right', endPositions[i]); + this._addStickyStyle(cell, isLtr ? 'right' : 'left', endPositions[i]); } } } diff --git a/src/cdk/table/table.spec.ts b/src/cdk/table/table.spec.ts index 178ab15c48ab..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(); @@ -831,6 +832,36 @@ describe('CdkTable', () => { 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']; @@ -1710,7 +1741,7 @@ class TrackByCdkTableApp { @Component({ template: ` - + @@ -1749,6 +1780,7 @@ class StickyFlexLayoutCdkTableApp { @ViewChild(CdkTable) table: CdkTable; + dir = 'ltr'; stickyHeaders: string[] = []; stickyFooters: string[] = []; stickyStartColumns: string[] = []; diff --git a/src/cdk/table/table.ts b/src/cdk/table/table.ts index 562fb81158cd..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, @@ -53,6 +54,7 @@ import { } 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 { @@ -356,7 +358,8 @@ 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'); } @@ -365,7 +368,7 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes } ngOnInit() { - this._stickyStyler = new StickyStyler(this._isNativeHtmlTable, this.stickyCssClass); + this._setupStickyStyler(); if (this._isNativeHtmlTable) { this._applyNativeTableSections(); @@ -988,6 +991,22 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes 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(); + }); + } } /** Utility function that gets a merged list of the entries in a QueryList and values of a Set. */ 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/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.ts b/src/lib/table/table.ts index 25e0e593f75d..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. @@ -44,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); } } From acc559f729033ba9b1bee82cf708c375f3d7b291 Mon Sep 17 00:00:00 2001 From: Andrew Seguin Date: Tue, 29 May 2018 15:16:40 -0700 Subject: [PATCH 4/8] move sticky to mixin --- src/cdk/table/can-stick.ts | 14 ++++++++++++++ src/cdk/table/cell.ts | 21 +++++++++------------ src/cdk/table/row.ts | 17 ----------------- src/cdk/table/sticky-styler.ts | 8 ++++---- 4 files changed, 27 insertions(+), 33 deletions(-) diff --git a/src/cdk/table/can-stick.ts b/src/cdk/table/can-stick.ts index adf6ef93f496..41775e48a5e4 100644 --- a/src/cdk/table/can-stick.ts +++ b/src/cdk/table/can-stick.ts @@ -6,6 +6,8 @@ * 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; @@ -16,6 +18,9 @@ export type Constructor = new(...args: any[]) => T; * @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; @@ -34,6 +39,15 @@ export interface CanStick { 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; diff --git a/src/cdk/table/cell.ts b/src/cdk/table/cell.ts index 41c666ba26d4..e6cbb797664f 100644 --- a/src/cdk/table/cell.ts +++ b/src/cdk/table/cell.ts @@ -51,7 +51,10 @@ export const _CdkColumnDefBase = mixinHasStickyInput(CdkColumnDefBase); * Column definition for the CDK table. * Defines a set of cells available for a table column. */ -@Directive({selector: '[cdkColumnDef]'}) +@Directive({ + selector: '[cdkColumnDef]', + inputs: ['sticky'] +}) export class CdkColumnDef extends _CdkColumnDefBase implements CanStick { /** Unique name for this column. */ @Input('cdkColumnDef') @@ -66,17 +69,11 @@ export class CdkColumnDef extends _CdkColumnDefBase implements CanStick { } _name: string; - /** Whether this column should be sticky positioned on the start of the row */ - @Input('sticky') - 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 this column should be sticky positioned on the end of the row */ + /** + * 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) { diff --git a/src/cdk/table/row.ts b/src/cdk/table/row.ts index 7afc73aee879..b24799da21c6 100644 --- a/src/cdk/table/row.ts +++ b/src/cdk/table/row.ts @@ -20,7 +20,6 @@ import { ViewEncapsulation, } from '@angular/core'; import {CdkCellDef, CdkColumnDef} from './cell'; -import {coerceBooleanProperty} from '@angular/cdk/coercion'; import {CanStick, mixinHasStickyInput} from './can-stick'; /** @@ -87,14 +86,6 @@ export const _CdkHeaderRowDefBase = mixinHasStickyInput(CdkHeaderRowDefBase); inputs: ['columns: cdkHeaderRowDef', 'sticky: cdkHeaderRowDefSticky'], }) export class CdkHeaderRowDef extends _CdkHeaderRowDefBase implements CanStick { - 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; - constructor(template: TemplateRef, _differs: IterableDiffers) { super(template, _differs); } @@ -114,14 +105,6 @@ export const _CdkFooterRowDefBase = mixinHasStickyInput(CdkFooterRowDefBase); inputs: ['columns: cdkFooterRowDef', 'sticky: cdkFooterRowDefSticky'], }) export class CdkFooterRowDef extends _CdkFooterRowDefBase implements CanStick { - 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; - constructor(template: TemplateRef, _differs: IterableDiffers) { super(template, _differs); } diff --git a/src/cdk/table/sticky-styler.ts b/src/cdk/table/sticky-styler.ts index 842f469ee556..7a4f2aa48703 100644 --- a/src/cdk/table/sticky-styler.ts +++ b/src/cdk/table/sticky-styler.ts @@ -28,13 +28,13 @@ 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 + * @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, + private stickCellCss: string, public direction: Direction) { } /** @@ -163,7 +163,7 @@ export class StickyStyler { const hasDirection = STICKY_DIRECTIONS.some(dir => !!element.style[dir]); if (!hasDirection) { element.style.position = ''; - element.classList.remove(this.stickCellCSS); + element.classList.remove(this.stickCellCss); } } @@ -173,7 +173,7 @@ export class StickyStyler { * direction and value. */ _addStickyStyle(element: HTMLElement, dir: StickyDirection, dirValue: number) { - element.classList.add(this.stickCellCSS); + element.classList.add(this.stickCellCss); element.style[dir] = `${dirValue}px`; element.style.cssText += 'position: -webkit-sticky; position: sticky; '; element.style.zIndex = this._getCalculatedZIndex(element); From 8f034cb501c057c6c7d248252159b6a68e630f61 Mon Sep 17 00:00:00 2001 From: Andrew Seguin Date: Tue, 29 May 2018 15:34:42 -0700 Subject: [PATCH 5/8] add bidi to bazel BUILD for spec --- src/cdk/table/BUILD.bazel | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cdk/table/BUILD.bazel b/src/cdk/table/BUILD.bazel index 0a17df870ca4..bd6e1469962c 100644 --- a/src/cdk/table/BUILD.bazel +++ b/src/cdk/table/BUILD.bazel @@ -22,6 +22,7 @@ ts_library( srcs = glob(["**/*.spec.ts"]), deps = [ ":table", + "//src/cdk/bidi", "//src/cdk/collections", "@rxjs", "@rxjs//operators" From 7ed0e10801d4cc26289f64189cd59e2328015c65 Mon Sep 17 00:00:00 2001 From: Andrew Seguin Date: Thu, 31 May 2018 10:25:28 -0700 Subject: [PATCH 6/8] fix prerender; reverse rtl --- src/cdk/table/row.ts | 16 ++++++++++++++-- src/cdk/table/sticky-styler.ts | 6 +++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/cdk/table/row.ts b/src/cdk/table/row.ts index b24799da21c6..6b5363f62c92 100644 --- a/src/cdk/table/row.ts +++ b/src/cdk/table/row.ts @@ -85,10 +85,16 @@ export const _CdkHeaderRowDefBase = mixinHasStickyInput(CdkHeaderRowDefBase); selector: '[cdkHeaderRowDef]', inputs: ['columns: cdkHeaderRowDef', 'sticky: cdkHeaderRowDefSticky'], }) -export class CdkHeaderRowDef extends _CdkHeaderRowDefBase implements CanStick { +export class CdkHeaderRowDef extends _CdkHeaderRowDefBase implements CanStick, OnChanges { constructor(template: TemplateRef, _differs: IterableDiffers) { super(template, _differs); } + + // 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. @@ -104,10 +110,16 @@ export const _CdkFooterRowDefBase = mixinHasStickyInput(CdkFooterRowDefBase); selector: '[cdkFooterRowDef]', inputs: ['columns: cdkFooterRowDef', 'sticky: cdkFooterRowDefSticky'], }) -export class CdkFooterRowDef extends _CdkFooterRowDefBase implements CanStick { +export class CdkFooterRowDef extends _CdkFooterRowDefBase implements CanStick, OnChanges { constructor(template: TemplateRef, _differs: IterableDiffers) { super(template, _differs); } + + // 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); + } } /** diff --git a/src/cdk/table/sticky-styler.ts b/src/cdk/table/sticky-styler.ts index 7a4f2aa48703..9f3e8dbc7bf3 100644 --- a/src/cdk/table/sticky-styler.ts +++ b/src/cdk/table/sticky-styler.ts @@ -74,17 +74,17 @@ export class StickyStyler { const cellWidths: number[] = this._getCellWidths(rows[0]); const startPositions = this._getStickyStartColumnPositions(cellWidths, stickyStartStates); const endPositions = this._getStickyEndColumnPositions(cellWidths, stickyEndStates); - const isLtr = this.direction === 'ltr'; + const isRtl = this.direction === 'rtl'; for (let row of rows) { for (let i = 0; i < numCells; i++) { const cell = row.children[i] as HTMLElement; if (stickyStartStates[i]) { - this._addStickyStyle(cell, isLtr ? 'left' : 'right', startPositions[i]); + this._addStickyStyle(cell, isRtl ? 'right' : 'left', startPositions[i]); } if (stickyEndStates[i]) { - this._addStickyStyle(cell, isLtr ? 'right' : 'left', endPositions[i]); + this._addStickyStyle(cell, isRtl ? 'left' : 'right', endPositions[i]); } } } From 9a85a3dcb4ebfc536c107033f8df0ef80ba27e98 Mon Sep 17 00:00:00 2001 From: Andrew Seguin Date: Tue, 5 Jun 2018 09:38:51 -0700 Subject: [PATCH 7/8] minor revisions --- src/cdk/table/sticky-styler.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/cdk/table/sticky-styler.ts b/src/cdk/table/sticky-styler.ts index 9f3e8dbc7bf3..5fd20e14347a 100644 --- a/src/cdk/table/sticky-styler.ts +++ b/src/cdk/table/sticky-styler.ts @@ -44,7 +44,7 @@ export class StickyStyler { * @param stickyDirections The directions that should no longer be set as sticky on the rows. */ clearStickyPositioning(rows: HTMLElement[], stickyDirections: StickyDirection[]) { - for (let row of rows) { + 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; @@ -70,13 +70,15 @@ export class StickyStyler { return; } - const numCells = rows[0].children.length; - const cellWidths: number[] = this._getCellWidths(rows[0]); + 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 (let row of rows) { + for (const row of rows) { for (let i = 0; i < numCells; i++) { const cell = row.children[i] as HTMLElement; if (stickyStartStates[i]) { @@ -153,7 +155,7 @@ export class StickyStyler { * sticky position if there are no more directions. */ _removeStickyStyle(element: HTMLElement, stickyDirections: StickyDirection[]) { - for (let dir of stickyDirections) { + for (const dir of stickyDirections) { element.style[dir] = ''; } element.style.zIndex = this._getCalculatedZIndex(element); @@ -199,7 +201,7 @@ export class StickyStyler { }; let zIndex = 0; - for (let dir of STICKY_DIRECTIONS) { + for (const dir of STICKY_DIRECTIONS) { if (element.style[dir]) { zIndex += zIndexIncrements[dir]; } From 50dd0a05754e103a95639c50ad40cf00c5cf7456 Mon Sep 17 00:00:00 2001 From: Andrew Seguin Date: Wed, 6 Jun 2018 14:44:21 -0700 Subject: [PATCH 8/8] minor changes for g3 internal tests --- src/cdk/table/sticky-styler.ts | 6 ++--- src/lib/table/_table-theme.scss | 6 ++--- .../table-basic-flex-example.html | 24 +++++++++---------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/cdk/table/sticky-styler.ts b/src/cdk/table/sticky-styler.ts index 5fd20e14347a..8525fdb3e8b9 100644 --- a/src/cdk/table/sticky-styler.ts +++ b/src/cdk/table/sticky-styler.ts @@ -150,8 +150,8 @@ export class StickyStyler { } /** - * Removes the sticky style on the element by removing the sticky cell CSS class, resetting the - * z-index back to 0, removing each of the provided sticky directions, and removing the + * 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[]) { @@ -207,7 +207,7 @@ export class StickyStyler { } } - return `${zIndex}`; + return zIndex ? `${zIndex}` : ''; } /** Gets the widths for each cell in the provided row. */ diff --git a/src/lib/table/_table-theme.scss b/src/lib/table/_table-theme.scss index f4a07822fe0e..e1cbe5218541 100644 --- a/src/lib/table/_table-theme.scss +++ b/src/lib/table/_table-theme.scss @@ -10,10 +10,10 @@ background: mat-color($background, 'card'); } - // TODO(andrewseguin): Only needed for sticky styles, maybe have it triggered only then? .mat-table thead, .mat-table tbody, .mat-table tfoot, - .mat-header-row, .mat-row, .mat-footer-row, - .mat-header-cell, .mat-cell, .mat-footer-cell { + mat-header-row, mat-row, mat-footer-row, + [mat-header-row], [mat-row], [mat-footer-row], + .mat-table-sticky { background: inherit; } 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