libs/ui/table/src/lib/table/table.component.ts
The primary data table implementation
CdkTable
OnInit
AfterViewChecked
AfterContentInit
AfterContentChecked
OnDestroy
<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>
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 |
|
Properties |
|
Inputs |
Outputs |
Accessors |
constructor(platform: Platform, renderer: Renderer2, differs: IterableDiffers, changeDetectorRef: ChangeDetectorRef, role: string, document: any, dir: Directionality, elementRef: ElementRef, ngZone: NgZone, windowService: TsWindowService, viewportRuler: ViewportRuler)
|
||||||||||||||||||||||||||||||||||||
Parameters :
|
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 |
columnsChange | |
Type : EventEmitter
|
|
Emit when a column is resized NOTE: This output is not debounce or throttled and may be called repeatedly |
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 |
Public Readonly uid |
Default value : `ts-table-${nextUniqueId++}`
|
Define the default component ID |
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 :
Returns :
void
|
id | ||||||
getid()
|
||||||
setid(value: string)
|
||||||
Define a custom ID
Parameters :
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();
}
}
}