File

libs/ui/table/src/lib/table/table.component.ts

Description

The primary data table implementation

Extends

CdkTable

Implements

OnInit AfterViewChecked AfterContentInit AfterContentChecked OnDestroy

Example

<ts-table
  [columns]="myColumns"
  [dataSource]="dataSource"
  [multiTemplateDataRows]="false"
  [trackBy]="myTrackByFn"
  (columnsChange)="columnsWereUpdated($event)
  #myTable="tsTable"
>
  <ng-container tsColumnDef="title" [noWrap]="false">
    <ts-header-cell *tsHeaderCellDef>
      Title
    </ts-header-cell>
    <ts-cell *tsCellDef="let item">
      {{ item.title }}
    </ts-cell>
  </ng-container>

  <ng-container tsColumnDef="id" alignment="right">
    <ts-header-cell *tsHeaderCellDef>
      ID
    </ts-header-cell>
    <ts-cell *tsCellDef="let item">
      {{ item.id }},
    </ts-cell>
  </ng-container>

  <ts-header-row *tsHeaderRowDef="myTable.columnNames"></ts-header-row>
  <ts-row *tsRowDef="let row; columns: myTable.columnNames;"></ts-row>
</ts-table>

<example-url>https://getterminus.github.io/ui-demos-release/components/table</example-url>

Metadata

changeDetection ChangeDetectionStrategy.OnPush
encapsulation ViewEncapsulation.None
exportAs tsTable
host {
}
providers { provide: CdkTable, useExisting: TsTableComponent, }
selector ts-table, table[ts-table]
styleUrls ./table.component.scss
template
CDK_TABLE_TEMPLATE

Index

Properties
Inputs
Outputs
Accessors

Constructor

constructor(platform: Platform, renderer: Renderer2, differs: IterableDiffers, changeDetectorRef: ChangeDetectorRef, role: string, document: any, dir: Directionality, elementRef: ElementRef, ngZone: NgZone, windowService: TsWindowService, viewportRuler: ViewportRuler)
Parameters :
Name Type Optional
platform Platform No
renderer Renderer2 No
differs IterableDiffers No
changeDetectorRef ChangeDetectorRef No
role string No
document any No
dir Directionality No
elementRef ElementRef No
ngZone NgZone No
windowService TsWindowService No
viewportRuler ViewportRuler No

Inputs

columns

Define the array of columns

density
Type : TsTableDensity
Default value : 'comfy'

Define the density of the cells

id
Type : string

Define a custom ID

Outputs

columnsChange
Type : EventEmitter

Emit when a column is resized

NOTE: This output is not debounce or throttled and may be called repeatedly

Properties

Public Readonly columnResizeChanges$
Type : Observable<TsHeaderCellDirective>
Default value : defer(() => { if (this.headerCells && this.headerCells.length) { // TODO: Refactor deprecation // eslint-disable-next-line deprecation/deprecation return merge<TsHeaderCellResizeEvent>(...this.headerCells.map(cell => cell.resized)).pipe( pluck('instance'), untilComponentDestroyed(this), ); } // If there are any subscribers before `ngAfterViewInit`, the `autocomplete` will be undefined. // In that case, return a stream that we'll replace with the real one once everything is in place. return this.ngZone.onStable .asObservable() // TODO: Refactor deprecation // eslint-disable-next-line deprecation/deprecation .pipe(take(1), switchMap(() => this.columnResizeChanges$)); })

Combined stream of all of the columns resized events

Public debouncedStickyColumnUpdate
Default value : debounce(this.updateStickyColumnStyles, COLUMN_DEBOUNCE_DELAY)

Create a debounced function to update CDK sticky styles

Public document
Type : any
Decorators :
@Inject(DOCUMENT)
Public Readonly elementRef
Type : ElementRef
Public headerCells
Type : QueryList<TsHeaderCellDirective>
Decorators :
@ContentChildren(TsHeaderCellDirective, {descendants: true})

Access header cells

Public rows
Type : QueryList<TsRowComponent>
Decorators :
@ContentChildren(TsRowComponent)

Access child rows

Protected stickyCssClass
Type : string
Default value : 'ts-table--sticky'

Override the sticky CSS class set by the CdkTable

Public Readonly uid
Default value : `ts-table-${nextUniqueId++}`

Define the default component ID

Accessors

columnNames
getcolumnNames()

Return a simple array of column names

Used by TsHeaderRowDefDirective and TsRowDefDirective.

Returns : string[]
columnsToSendToConsumer
getcolumnsToSendToConsumer()

Build array of columns to emit out to the consumer

Returns : TsColumn[]
containerWidth
getcontainerWidth()

Return the width of the element wrapping the table

Returns : number
hasOverflowX
gethasOverflowX()

Determine if the container around the table has overflow (ie the table is scrollable)

Returns : boolean
columns
getcolumns()
setcolumns(value)

Define the array of columns

Parameters :
Name Optional
value No
Returns : void
id
getid()
setid(value: string)

Define a custom ID

Parameters :
Name Type Optional
value string No
Returns : void
import { Directionality } from '@angular/cdk/bidi';
import { Platform } from '@angular/cdk/platform';
import { ViewportRuler } from '@angular/cdk/scrolling';
import {
  CDK_TABLE_TEMPLATE,
  CdkTable,
} from '@angular/cdk/table';
import { DOCUMENT } from '@angular/common';
import {
  AfterContentChecked,
  AfterContentInit,
  AfterViewChecked,
  Attribute,
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  ContentChildren,
  ElementRef,
  EventEmitter,
  Inject,
  Input,
  IterableDiffers,
  NgZone,
  OnDestroy,
  OnInit,
  Optional,
  Output,
  QueryList,
  Renderer2,
  ViewEncapsulation,
} from '@angular/core';
import {
  defer,
  merge,
  Observable,
  Subscription,
} from 'rxjs';
import {
  pluck,
  switchMap,
  take,
} from 'rxjs/operators';

import {
  debounce,
  TsWindowService,
  untilComponentDestroyed,
} from '@terminus/fe-utilities';

import {
  TsHeaderCellDirective,
  TsHeaderCellResizeEvent,
} from '../cell/cell';
import { TsRowComponent } from '../row/row';


/**
 * The definition for a single column
 */
export interface TsColumn {
  // The column name
  name: string;
  // The desired pixel width as an integer (eg '200')
  width: number;
  // Allow any other data properties the consumer may need
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  [key: string]: any;
}

/**
 * The possible table density settings
 */
export type TsTableDensity
  = 'comfy'
  | 'compact'
;

/**
 * The default debounce delay for column sizing update calls
 */
const COLUMN_DEBOUNCE_DELAY = 100;
const VIEWPORT_DEBOUNCE = 500;

/**
 * The payload for a columns change event
 */
export class TsTableColumnsChangeEvent {
  constructor(
    // The table instance that originated the event
    public table: TsTableComponent,
    // The updated array of columns
    public columns: TsColumn[],
  ) {}
}

// Unique ID for each instance
let nextUniqueId = 0;


/**
 * The primary data table implementation
 *
 * @example
 *  <ts-table
 *               [columns]="myColumns"
 *               [dataSource]="dataSource"
 *               [multiTemplateDataRows]="false"
 *               [trackBy]="myTrackByFn"
 *               (columnsChange)="columnsWereUpdated($event)
 *               #myTable="tsTable"
 *  >
 *               <ng-container tsColumnDef="title" [noWrap]="false">
 *                 <ts-header-cell *tsHeaderCellDef>
 *                   Title
 *                 </ts-header-cell>
 *                 <ts-cell *tsCellDef="let item">
 *                   {{ item.title }}
 *                 </ts-cell>
 *               </ng-container>
 *
 *               <ng-container tsColumnDef="id" alignment="right">
 *                 <ts-header-cell *tsHeaderCellDef>
 *                   ID
 *                 </ts-header-cell>
 *                 <ts-cell *tsCellDef="let item">
 *                   {{ item.id }},
 *                 </ts-cell>
 *               </ng-container>
 *
 *               <ts-header-row *tsHeaderRowDef="myTable.columnNames"></ts-header-row>
 *               <ts-row *tsRowDef="let row; columns: myTable.columnNames;"></ts-row>
 *  </ts-table>
 *
 * <example-url>https://getterminus.github.io/ui-demos-release/components/table</example-url>
 */
@Component({
  selector: 'ts-table, table[ts-table]',
  template: CDK_TABLE_TEMPLATE,
  styleUrls: ['./table.component.scss'],
  host: {
    'class': 'ts-table',
    '[class.ts-table--comfy]': 'density === "comfy"',
    '[class.ts-table--compact]': 'density === "compact"',
    '[id]': 'id',
  },
  providers: [{
    provide: CdkTable,
    useExisting: TsTableComponent,
  }],
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush,
  exportAs: 'tsTable',
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export class TsTableComponent<T = any> extends CdkTable<T> implements
  OnInit,
  AfterViewChecked,
  AfterContentInit,
  AfterContentChecked,
  OnDestroy {
  /**
   * Combined stream of all of the columns resized events
   */
  public readonly columnResizeChanges$: Observable<TsHeaderCellDirective> = defer(() => {
    if (this.headerCells && this.headerCells.length) {
      // TODO: Refactor deprecation
      // eslint-disable-next-line deprecation/deprecation
      return merge<TsHeaderCellResizeEvent>(...this.headerCells.map(cell => cell.resized)).pipe(
        pluck('instance'),
        untilComponentDestroyed(this),
      );
    }

    // If there are any subscribers before `ngAfterViewInit`, the `autocomplete` will be undefined.
    // In that case, return a stream that we'll replace with the real one once everything is in place.
    return this.ngZone.onStable
      .asObservable()
      // TODO: Refactor deprecation
      // eslint-disable-next-line deprecation/deprecation
      .pipe(take(1), switchMap(() => this.columnResizeChanges$));
  });

  /**
   * Create a debounced function to update CDK sticky styles
   */
  public debouncedStickyColumnUpdate = debounce(this.updateStickyColumnStyles, COLUMN_DEBOUNCE_DELAY);

  /**
   * Store the header cell subscription
   */
  private headerCellSubscription!: Subscription;

  /**
   * Store a mutable array of internal column definitions
   */
  private columnsInternal: TsColumn[] = [];

  /**
   * Override the sticky CSS class set by the `CdkTable`
   */
  protected stickyCssClass = 'ts-table--sticky';

  /**
   * Store the stream of viewport changes
   */
  private viewportChange$!: Observable<Event>;

  /**
   * Define the default component ID
   */
  public readonly uid = `ts-table-${nextUniqueId++}`;

  /**
   * Return a simple array of column names
   *
   * Used by {@link TsHeaderRowDefDirective} and {@link TsRowDefDirective}.
   */
  public get columnNames(): string[] {
    return this.columns.map(c => c.name);
  }

  /**
   * Build array of columns to emit out to the consumer
   */
  public get columnsToSendToConsumer(): TsColumn[] {
    const internalColumns = this.getFreshColumnsCopy(this.columnsInternal);
    const userColumns = this.getFreshColumnsCopy();
    const lastIndex = internalColumns.length - 1;
    // Reset the last column width to the consumer defined width
    internalColumns[lastIndex].width = userColumns[lastIndex].width;
    return internalColumns;
  }

  /**
   * Return the width of the element wrapping the table
   */
  public get containerWidth(): number {
    return this.parentElement.offsetWidth;
  }

  /**
   * Determine if the container around the table has overflow (ie the table is scrollable)
   */
  public get hasOverflowX(): boolean {
    return this.parentElement.scrollWidth > this.tableWidth;
  }

  /**
   * Return the parent HTMLElement
   */
  private get parentElement(): HTMLElement {
    return (this.elementRef.nativeElement as HTMLElement).parentNode as HTMLElement;
  }

  /**
   * Determine the remaining space in the table after the columns take up their needed width
   */
  private get remainingTableSpace(): number {
    // NOTE: The outer borders take up 2px so we subtract them here to avoid a 2px overflow.
    const borderOffset = 2;
    const remainingWidth = (this.containerWidth - this.totalWidthOfColumns) - borderOffset;
    return (remainingWidth > 0) ? remainingWidth : 0;
  }

  /**
   * Return the width of the table
   */
  private get tableWidth(): number {
    return this.elementRef.nativeElement.offsetWidth;
  }

  /**
   * Return the total width of all visible columns
   */
  private get totalWidthOfColumns(): number {
    const currentWidths = this.headerCells.map(hc => hc.cellWidth);
    const userWidths = this.columns.map(v => v.width);
    const columnsToReduce = currentWidths.slice();
    // NOTE: Since the last column is never resized by the user, we should use the original size for the last column and the current
    // size for all other columns.
    const lastIndex = userWidths.length - 1;
    columnsToReduce[lastIndex] = this.columns[lastIndex].width;
    return columnsToReduce.reduce((a, b) => a + b, 0);
  }

  /**
   * Access header cells
   */
  @ContentChildren(TsHeaderCellDirective, { descendants: true })
  public headerCells!: QueryList<TsHeaderCellDirective>;

  /**
   * Access child rows
   */
  @ContentChildren(TsRowComponent)
  public rows!: QueryList<TsRowComponent>;

  /**
   * Define the array of columns
   *
   * @param value
   */
  @Input()
  public set columns(value: ReadonlyArray<TsColumn>) {
    // istanbul ignore else
    if (value && (value.length > 0)) {
      this._columns = this.getFreshColumnsCopy(value);
      this.columnsInternal = this.getFreshColumnsCopy(value);
    }
  }
  public get columns(): ReadonlyArray<TsColumn> {
    return this._columns;
  }
  private _columns: TsColumn[] = [];

  /**
   * Define the density of the cells
   */
  @Input()
  public density: TsTableDensity = 'comfy';

  /**
   * Define a custom ID
   *
   * @param value
   */
  @Input()
  public set id(value: string) {
    this._id = value || this.uid;
  }
  public get id(): string {
    return this._id;
  }
  private _id: string = this.uid;

  /**
   * Emit when a column is resized
   *
   * NOTE: This output is not debounce or throttled and may be called repeatedly
   */
  @Output()
  public readonly columnsChange = new EventEmitter<TsTableColumnsChangeEvent>();


  constructor(
    protected platform: Platform,
    protected renderer: Renderer2,
    protected readonly differs: IterableDiffers,
    protected readonly changeDetectorRef: ChangeDetectorRef,
    @Attribute('role') role: string,
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    @Inject(DOCUMENT) public document: any,
    @Optional() protected readonly dir: Directionality,
    public readonly elementRef: ElementRef,
    private ngZone: NgZone,
    private windowService: TsWindowService,
    private viewportRuler: ViewportRuler,
  ) {
    super(differs, changeDetectorRef, elementRef, role, dir, document, platform);
  }


  /**
   * Subscribe to viewport changes
   */
  public ngOnInit(): void {
    super.ngOnInit();

    this.viewportChange$ = this.viewportRuler.change(VIEWPORT_DEBOUNCE).pipe(untilComponentDestroyed(this));
    this.viewportChange$.pipe(untilComponentDestroyed(this)).subscribe(() => {
      this.windowService.nativeWindow.requestAnimationFrame(() => {
        this.updateInternalColumns(this.getFreshColumnsCopy());
        this.columnsChange.emit(new TsTableColumnsChangeEvent(this, this.columnsToSendToConsumer));
      });
    });
  }

  /**
   * Set up header cell changes subscription
   */
  public ngAfterViewChecked(): void {
    this.subscribeToHeaderCellChanges();
  }

  /**
   * Subscribe to column resize events
   */
  public ngAfterContentInit(): void {
    this.columnResizeChanges$
      .subscribe(v => {
        this.updateLastColumnWidth();
        // Update the recorded width for the changed column
        const found = this.columnsInternal.find(column => column.name === v.columnDef.name);
        // istanbul ignore else
        if (found) {
          found.width = v.cellWidth;
        }
        this.columnsChange.emit(new TsTableColumnsChangeEvent(this, this.columnsToSendToConsumer));
      });
  }

  /**
   * NOTE: Must be present for `untilComponentDestroyed`
   */
  public ngOnDestroy(): void {
    // istanbul ignore else
    if (this.headerCellSubscription) {
      this.headerCellSubscription.unsubscribe();
    }
  }

  /**
   * Adjusts the last column of the array to fill any remaining space inside the table
   *
   * NOTE: Due to issues during testing, we have not made this function static.
   *
   * @param columns - The array of columns to adjust
   * @param remainingWidth - The remaining table width to be added to the last column
   * @returns The adjusted array of columns
   */
  private addRemainingSpaceToLastColumn(columns: TsColumn[], remainingWidth: number): TsColumn[] {
    const lastColumn = columns[columns.length - 1];
    lastColumn.width = lastColumn.width + remainingWidth;
    return columns;
  }

  /**
   * Return a fresh clone of the passed in array of columns
   *
   * @param columns - The array of columns to clone
   * @returns The array of fresh columns
   */
  private getFreshColumnsCopy(columns: ReadonlyArray<TsColumn> = this.columns): TsColumn[] {
    return columns.slice().map(c => ({ ...c }));
  }

  /**
   * Set the column widths for all columns passed in
   *
   * @param columns - The array of columns
   */
  private setAllColumnsToDefinedWidths(columns: TsColumn[]): void {
    for (const column of columns) {
      this.setColumnWidthStyle(column.name, column.width, false);
    }
    this.updateStickyCellsIfNeeded();
  }

  /**
   * Set the width for a specific column
   *
   * @param columnName - The name of the column that needs it's width updated
   * @param width - The width to set
   * @param updateStickCells - Whether the sticky cells should be updated
   */
  private setColumnWidthStyle(columnName: string, width: number, updateStickCells = true): void {
    // eslint-disable-next-line no-underscore-dangle
    const columnDirective = this.headerCells.find(cell => cell.columnDef._name === columnName);
    // istanbul ignore else
    if (columnDirective) {
      columnDirective.setColumnWidth(width);

      // istanbul ignore else
      if (updateStickCells) {
        this.updateStickyCellsIfNeeded();
      }
    }
  }

  /**
   * Set up subscription to header cell changes
   */
  private subscribeToHeaderCellChanges(): void {
    if (this.headerCellSubscription) {
      this.headerCellSubscription.unsubscribe();
    }

    this.headerCellSubscription = this.headerCells.changes
      .pipe(untilComponentDestroyed(this))
      .subscribe(() => {
        // 1. Set user widths
        this.setAllColumnsToDefinedWidths(this.getFreshColumnsCopy());
        // 2. Add space to last column as needed
        this.updateLastColumnWidth();
        // 3. Set all widths to internal columns
        this.setAllColumnsToDefinedWidths(this.getFreshColumnsCopy(this.columnsInternal));
        // 4. Alert the consumer
        this.columnsChange.emit(new TsTableColumnsChangeEvent(this, this.columnsToSendToConsumer));

        // Inject the header cell resize element in every cell except the last (last column is not resizable)
        this.headerCells.forEach((headerCellDirective, i) => {
          if (i !== this.headerCells.length - 1) {
            headerCellDirective.injectResizeElement();
          }
        });
      });
  }

  /**
   * Update the internal columns array and set widths
   *
   * @param columns - The array of columns to update
   */
  private updateInternalColumns(columns: TsColumn[]): void {
    // If there is space left over, add all remaining space to the last column
    if (!this.hasOverflowX) {
      columns = this.addRemainingSpaceToLastColumn(columns, this.remainingTableSpace);
    }
    this.columnsInternal = columns;
    this.setAllColumnsToDefinedWidths(this.columnsInternal);
  }

  /**
   * Update the last column's width and update the internal columns
   */
  private updateLastColumnWidth(): void {
    // 1. Determine last column width
    const columns = this.getFreshColumnsCopy();
    const lastIndex = columns.length - 1;
    const lastColumn = columns[lastIndex];
    let newWidth = lastColumn.width;
    if (!this.hasOverflowX) {
      newWidth = lastColumn.width + this.remainingTableSpace;
    }
    // 2. Set the width
    this.setColumnWidthStyle(lastColumn.name, newWidth);
    // 3. Update internal columns
    this.columnsInternal[lastIndex].width = newWidth;
  }

  /**
   * Trigger an update on sticky cells if they exist
   */
  private updateStickyCellsIfNeeded(): void {
    // NOTE: To lessen the thrashing, only call the sticky column updater if there are defined sticky columns
    const stickyCells = this.headerCells.toArray().filter(c => c.columnDef.sticky || c.columnDef.stickyEnd);
    // istanbul ignore else
    if (stickyCells.length) {
      this.debouncedStickyColumnUpdate();
    }
  }
}

result-matching ""

    No results matching ""