File

table/src/lib/table/table.component.ts

Description

Angular Material Table with Sort Feature

Metadata

Index

Properties
Methods
Inputs
Outputs

Constructor

constructor()

Sort data on load and set columns

Inputs

columns
Type : TableColumn[]

Columns in table

csvUrl
Type : string

CSV URL input

enableRowSelection
Type : boolean
Default value : false

Enable row selection with checkboxes

enableSort
Type : boolean
Default value : false

Enables sorting

hideHeaders
Type : boolean
Default value : false

Hide table headers

rows
Type : T[]

Unsorted data

style
Type : TableVariant
Default value : 'alternating'

Table style

verticalDividers
Type : boolean
Default value : false

Enables dividers between columns

Outputs

downloadHovered
Type : string

Emits download object id on download button hover

routeClicked
Type : string

Emits route

selectionChange
Type : T[]

Emits when selection changes

Methods

download
download(url: string)

Downloads a file from the url

Parameters :
Name Type Optional Description
url string No

File url

Returns : void
downloadButtonHover
downloadButtonHover(id: string | number | boolean | (string | number | boolean)[])

Emits the id of a row when its download button is hovered

Parameters :
Name Type Optional
id string | number | boolean | (string | number | boolean)[] No
Returns : void
getColumnType
getColumnType(column: TableColumn)

Returns the column type for a given column.

Parameters :
Name Type Optional Description
column TableColumn No

Table column

Returns : TableColumnType

Column type

getMenuOptions
getMenuOptions(options: string | number | boolean | MenuOptionsType[])

Returns download menu options as an array of MenuOptionsType

Parameters :
Name Type Optional Description
options string | number | boolean | MenuOptionsType[] No

Menu options

Returns : MenuOptionsType[]

Menu options as an array of MenuOptionsType

isAllSelected
isAllSelected()

Whether the number of selected elements matches the total number of rows.

Returns : boolean
routeClick
routeClick(url: string | number | boolean | (string | number | boolean)[])

Emits a route as string when object label is clicked

Parameters :
Name Type Optional
url string | number | boolean | (string | number | boolean)[] No
Returns : void
scrollToTop
scrollToTop()

Scrolls to top of the table

Returns : void
toggleAllRows
toggleAllRows()

Selects all rows if they are not all selected; otherwise clear selection.

Returns : void
toggleRow
toggleRow(row: TableRow)

Toggle row selection

Parameters :
Name Type Optional
row TableRow No
Returns : void

Properties

Protected Readonly _columns
Type : unknown
Default value : computed(() => this.columns() ?? this.inferColumns(this._rows()))

Table data columns

Protected Readonly _rows
Type : unknown
Default value : computed(() => this.rows() ?? this.csv.value())

Table data rows

Protected Readonly columnIds
Type : unknown
Default value : computed(() => { const columns = this._columns().map((col) => col.column); return this.enableRowSelection() ? ['select', ...columns] : columns; })

Table data column IDs

Protected Readonly dataSource
Type : unknown
Default value : new MatTableDataSource<T>([])

Table data source

Readonly scrollbar
Type : unknown
Default value : viewChild.required<NgScrollbar>('scrollbar')

Scrollbar ref

Readonly selection
Type : unknown
Default value : new SelectionModel<TableRow>(true, [])

Selection model for checkbox functionality

Readonly snackbar
Type : unknown
Default value : inject(SnackbarService)

Snackbar service for download notification

Protected Readonly totals
Type : unknown
Default value : computed(() => { const result = new Map<TableColumn, number>(); for (const col of this._columns()) { const options = this.getColumnType(col); if (options.type === 'numeric' && options.computeTotal) { const total = this._rows().reduce((acc, row) => { const value = Number(row[col.column as keyof T]); return acc + (Number.isNaN(value) ? 0 : value); }, 0); result.set(col, total); } } return result; })

Totals by column for which computeTotal is enabled

import { SelectionModel } from '@angular/cdk/collections';
import { httpResource } from '@angular/common/http';
import { Component, computed, Directive, effect, ErrorHandler, inject, input, output, viewChild } from '@angular/core';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatMenuModule } from '@angular/material/menu';
import { MatSort, MatSortModule } from '@angular/material/sort';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { HraCommonModule } from '@hra-ui/common';
import { injectAssetUrlResolver } from '@hra-ui/common/url';
import { ButtonsModule } from '@hra-ui/design-system/buttons';
import { TextHyperlinkDirective } from '@hra-ui/design-system/buttons/text-hyperlink';
import { IconsModule } from '@hra-ui/design-system/icons';
import { ScrollingModule } from '@hra-ui/design-system/scrolling';
import { SnackbarService } from '@hra-ui/design-system/snackbar';
import { PlainTooltipDirective } from '@hra-ui/design-system/tooltips/plain-tooltip';
import saveAs from 'file-saver';
import { MarkdownModule } from 'ngx-markdown';
import { NgScrollbar } from 'ngx-scrollbar';
import { parse } from 'papaparse';
import {
  IconColumnType,
  LinkColumnType,
  MarkdownColumnType,
  MenuButtonColumnType,
  MenuOptionsType,
  NumericColumnType,
  TableColumn,
  TableColumnType,
  TableColumnWithType,
  TableRow,
  TableVariant,
  TextColumnType,
} from '../types/page-table.schema';

/** Type for the row element context */
type RowElementContext<T, CT extends TableColumnType> = {
  $implicit: T;
  element: TableRow;
  column: TableColumnWithType<CT>;
};

/** Directive for typing the context of Text Row Element */
@Directive({
  selector: 'ng-template[hraTextRowElement]',
})
export class TextRowElementDirective {
  /* istanbul ignore next */

  /** Guard for the context of Text Row Element */
  static ngTemplateContextGuard(
    _dir: TextRowElementDirective,
    _ctx: unknown,
  ): _ctx is RowElementContext<string, TextColumnType> {
    return true;
  }
}

/** Directive for typing the context of Link Row Element */
@Directive({
  selector: 'ng-template[hraLinkRowElement]',
})
export class LinkRowElementDirective {
  /* istanbul ignore next */

  /** Guard for the context of Link Row Element */
  static ngTemplateContextGuard(
    _dir: LinkRowElementDirective,
    _ctx: unknown,
  ): _ctx is RowElementContext<string, LinkColumnType> {
    return true;
  }
}

/** Directive for typing the context of Markdown Row Element */
@Directive({
  selector: 'ng-template[hraMarkdownRowElement]',
})
export class MarkdownRowElementDirective {
  /* istanbul ignore next */

  /** Guard for the context of Markdown Row Element */
  static ngTemplateContextGuard(
    _dir: MarkdownRowElementDirective,
    _ctx: unknown,
  ): _ctx is RowElementContext<string, MarkdownColumnType> {
    return true;
  }
}

/** Directive for typing the context of Icon Row Element */
@Directive({
  selector: 'ng-template[hraIconRowElement]',
})
export class IconRowElementDirective {
  /* istanbul ignore next */

  /** Guard for the context of Icon Row Element */
  static ngTemplateContextGuard(
    _dir: IconRowElementDirective,
    _ctx: unknown,
  ): _ctx is RowElementContext<string, IconColumnType> {
    return true;
  }
}

/** Directive for typing the context of menuButton Row Element */
@Directive({
  selector: 'ng-template[hraMenuButtonRowElement]',
})
export class MenuButtonRowElementDirective {
  /* istanbul ignore next */

  /** Guard for the context of menuButton Row Element */
  static ngTemplateContextGuard(
    _dir: MenuButtonRowElementDirective,
    _ctx: unknown,
  ): _ctx is RowElementContext<string, MenuButtonColumnType> {
    return true;
  }
}

/** Directive for typing the context of Numeric Row Element */
@Directive({
  selector: 'ng-template[hraNumericRowElement]',
})
export class NumericRowElementDirective {
  /* istanbul ignore next */

  /** Guard for the context of Numeric Row Element */
  static ngTemplateContextGuard(
    _dir: NumericRowElementDirective,
    _ctx: unknown,
  ): _ctx is RowElementContext<string, NumericColumnType> {
    return true;
  }
}

/**
 * Angular Material Table with Sort Feature
 */
@Component({
  selector: 'hra-table',
  imports: [
    HraCommonModule,
    MarkdownModule,
    MatMenuModule,
    MatSortModule,
    MatTableModule,
    ScrollingModule,
    MatCheckboxModule,
    TextHyperlinkDirective,
    LinkRowElementDirective,
    TextRowElementDirective,
    MarkdownRowElementDirective,
    MenuButtonRowElementDirective,
    NumericRowElementDirective,
    PlainTooltipDirective,
    IconsModule,
    ButtonsModule,
  ],
  templateUrl: 'table.component.html',
  styleUrl: 'table.component.scss',
  host: {
    '[class]': '"hra-table-style-" + style()',
    '[class.sortable]': 'enableSort()',
    '[class.vertical-dividers]': 'verticalDividers()',
  },
})
export class TableComponent<T = TableRow> {
  /** CSV URL input */
  readonly csvUrl = input<string>();

  /** Columns in table */
  readonly columns = input<TableColumn[]>();

  /** Unsorted data */
  readonly rows = input<T[]>();

  /** Table style */
  readonly style = input<TableVariant>('alternating');

  /** Enables sorting */
  readonly enableSort = input<boolean>(false);

  /** Enables dividers between columns */
  readonly verticalDividers = input<boolean>(false);

  /** Enable row selection with checkboxes */
  readonly enableRowSelection = input<boolean>(false);

  /** Hide table headers */
  readonly hideHeaders = input<boolean>(false);

  /** Emits when selection changes */
  readonly selectionChange = output<T[]>();

  /** Emits route */
  readonly routeClicked = output<string>();

  /** Emits download object id on download button hover */
  readonly downloadHovered = output<string>();

  /** Scrollbar ref */
  readonly scrollbar = viewChild.required<NgScrollbar>('scrollbar');

  /** Mat sort element */
  private readonly sort = viewChild.required(MatSort);

  /** Selection model for checkbox functionality */
  readonly selection = new SelectionModel<TableRow>(true, []);

  /** Error handler provider for logging errors */
  private readonly errorHandler = inject(ErrorHandler);

  /** Resolver for asset urls */
  private readonly resolveAssetUrl = injectAssetUrlResolver();

  /** Snackbar service for download notification */
  readonly snackbar = inject(SnackbarService);

  /** CSV resource from remote URL */
  private readonly csv = httpResource.text<T[]>(
    () => {
      const url = this.csvUrl();
      return url ? this.resolveAssetUrl(url) : url;
    },
    {
      defaultValue: [],
      parse: (data) => {
        const result = parse<T>(data, { header: true, dynamicTyping: true, skipEmptyLines: true });
        if (result.errors.length > 0) {
          this.errorHandler.handleError(result.errors);
        }
        return result.data;
      },
    },
  );

  /** Table data rows */
  protected readonly _rows = computed(() => this.rows() ?? this.csv.value());

  /** Table data columns */
  protected readonly _columns = computed(() => this.columns() ?? this.inferColumns(this._rows()));

  /** Table data column IDs */
  protected readonly columnIds = computed(() => {
    const columns = this._columns().map((col) => col.column);
    return this.enableRowSelection() ? ['select', ...columns] : columns;
  });

  /** Totals by column for which `computeTotal` is enabled */
  protected readonly totals = computed(() => {
    const result = new Map<TableColumn, number>();
    for (const col of this._columns()) {
      const options = this.getColumnType(col);
      if (options.type === 'numeric' && options.computeTotal) {
        const total = this._rows().reduce((acc, row) => {
          const value = Number(row[col.column as keyof T]);
          return acc + (Number.isNaN(value) ? 0 : value);
        }, 0);
        result.set(col, total);
      }
    }
    return result;
  });

  /** Table data source */
  protected readonly dataSource = new MatTableDataSource<T>([]);

  /** Sort data on load and set columns */
  constructor() {
    effect(() => {
      this.dataSource.data = this._rows();
    });

    effect(() => {
      this.dataSource.sort = this.sort();
    });
  }

  /**
   * Returns the column type for a given column.
   * @param column Table column
   * @returns Column type
   */
  getColumnType(column: TableColumn): TableColumnType {
    return typeof column.type === 'string' ? ({ type: column.type } as TableColumnType) : column.type;
  }

  /**
   * Infers the table column types from the loaded data.
   * @param data Table data
   * @returns Inferred table column with types as an array.
   */
  private inferColumns(data: T[]): TableColumn[] {
    if (data.length === 0) {
      return [];
    }

    const item = data[0];
    const columns: TableColumn[] = [];
    for (const key in item) {
      const type = typeof item[key] === 'number' ? 'numeric' : 'text';
      columns.push({ column: key, label: key, type });
    }

    return columns;
  }

  /**
   * Whether the number of selected elements matches the total number of rows.
   */
  isAllSelected(): boolean {
    const numSelected = this.selection.selected.length;
    const numRows = this.dataSource.data.length;
    return numSelected === numRows;
  }

  /**
   * Selects all rows if they are not all selected; otherwise clear selection.
   */
  toggleAllRows(): void {
    if (this.isAllSelected()) {
      this.selection.clear();
    } else {
      this.selection.select(...(this.dataSource.data as TableRow[]));
    }
    this.selectionChange.emit(this.selection.selected as T[]);
  }

  /**
   * Toggle row selection
   */
  toggleRow(row: TableRow): void {
    this.selection.toggle(row as TableRow);
    this.selectionChange.emit(this.selection.selected as T[]);
  }

  /**
   * Returns download menu options as an array of MenuOptionsType
   * @param options Menu options
   * @returns Menu options as an array of MenuOptionsType
   */
  getMenuOptions(options: string | number | boolean | MenuOptionsType[]): MenuOptionsType[] {
    return options as MenuOptionsType[];
  }

  /** Emits a route as string when object label is clicked */
  routeClick(url: string | number | boolean | (string | number | boolean)[]): void {
    this.routeClicked.emit(url as string);
  }

  /** Emits the id of a row when its download button is hovered */
  downloadButtonHover(id: string | number | boolean | (string | number | boolean)[]): void {
    this.downloadHovered.emit(id as string);
  }

  /**
   * Downloads a file from the url
   * @param url File url
   */
  download(url: string): void {
    saveAs(url, url.split('/').pop());
  }

  /** Scrolls to top of the table */
  scrollToTop(): void {
    this.scrollbar().scrollTo({ top: 0, duration: 0 });
  }
}
<ng-container hraFeature="table">
  <!-- TODO fix overflow fade
    hraScrollOverflowFade
    [scrollOverflowFadeOffset]="40"
  -->
  <ng-scrollbar
    externalViewport=".scroll-viewport"
    externalContentWrapper=".content-wrapper"
    externalSpacer="content-wrapper"
    asyncDetection="auto"
    #scrollbar
  >
    <div class="scroll-viewport">
      <div class="content-wrapper">
        <table
          mat-table
          matSort
          aria-label="Table with sort function"
          [dataSource]="dataSource"
          [matSortDisabled]="!enableSort()"
          [class.vertical-divider]="verticalDividers()"
        >
          @let templates = { text, numeric, markdown, link, icon, menu };

          <!-- Selection Column (if enabled) -->
          @if (enableRowSelection()) {
            <ng-container hraFeature="select" matColumnDef="select">
              @if (!hideHeaders()) {
                <th *matHeaderCellDef mat-header-cell data-column-type="checkbox">
                  <mat-checkbox
                    hraFeature="toggle-all"
                    disableRipple
                    hraPlainTooltip="Hide all"
                    aria-label="Hide all"
                    [hraClickEvent]="{ checked: !(selection.hasValue() && isAllSelected()) }"
                    [checked]="selection.hasValue() && isAllSelected()"
                    [indeterminate]="selection.hasValue() && !isAllSelected()"
                    (change)="toggleAllRows()"
                  />
                </th>
              }
              <td *matCellDef="let element" mat-cell data-column-type="checkbox">
                <mat-checkbox
                  hraFeature="toggle"
                  disableRipple
                  hraPlainTooltip="Hide"
                  aria-label="Hide"
                  [hraClickEvent]="{ checked: selection.isSelected(element) }"
                  [checked]="selection.isSelected(element)"
                  (click)="$event.stopPropagation()"
                  (change)="$event ? toggleRow(element) : null"
                />
              </td>
            </ng-container>
          }

          @for (column of _columns(); track column.column) {
            @let type = getColumnType(column).type;
            @let template = templates[type];
            @let isTotalsLabelColumn = enableRowSelection() ? $index === 1 : $first;

            <ng-container [matColumnDef]="column.column">
              @if (!hideHeaders()) {
                <th
                  *matHeaderCellDef
                  hraClickEvent
                  mat-header-cell
                  mat-sort-header
                  [hraFeature]="`header-cell.${(column.label | slugify)}`"
                  [attr.data-column-type]="type"
                  [sortActionDescription]="`Sort by ${column.label}`"
                >
                  {{ column.label }}
                </th>
              }
              <td *matCellDef="let element" mat-cell [attr.data-column-type]="type">
                <ng-container
                  *ngTemplateOutlet="template; context: { $implicit: element[column.column], element, column }"
                />
              </td>

              <!-- Total Footer -->
              <td *matFooterCellDef mat-footer-cell [attr.data-column-type]="type">
                @if (isTotalsLabelColumn) {
                  <span class="total-label"> Total </span>
                } @else if (totals().get(column)) {
                  <span class="numeric">
                    {{ totals().get(column) | number }}
                  </span>
                }
              </td>
            </ng-container>
          }

          @if (!hideHeaders()) {
            <tr *matHeaderRowDef="columnIds(); sticky: true" mat-header-row></tr>
          }
          <tr *matRowDef="let row; let i = index; columns: columnIds()" mat-row></tr>

          @if (totals().size > 0) {
            <tr *matFooterRowDef="columnIds(); sticky: true" mat-footer-row></tr>
          }
        </table>

        <ng-content />
      </div>
    </div>
  </ng-scrollbar>

  <ng-template let-text hraTextRowElement #text>
    <span class="text">{{ text }}</span>
  </ng-template>

  <ng-template let-value hraNumericRowElement #numeric>
    <span class="numeric">{{ value | number }}</span>
  </ng-template>

  <ng-template let-markdown hraMarkdownRowElement #markdown>
    <markdown class="markdown" [data]="markdown" />
  </ng-template>

  <ng-template let-icon let-column="column" let-element="element" hraIconRowElement #icon>
    <hra-icon
      hraFeature="table-icon"
      [hraHoverEvent]="{ value: element[column.type.tooltip] | slugify }"
      [svgIcon]="icon"
      [hraPlainTooltip]="element[column.type.tooltip]"
    />
  </ng-template>

  <ng-template let-column="column" let-element="element" hraFeature="menu-cell" hraMenuButtonRowElement #menu>
    @let options = element[column.type.options];
    <button
      hraFeature="download"
      hraClickEvent
      mat-icon-button
      [matMenuTriggerFor]="menuOptions"
      [hraPlainTooltip]="column.type.tooltip"
      (mouseover)="downloadButtonHover(element['id'])"
      (focus)="downloadButtonHover(element['id'])"
      #trigger="matMenuTrigger"
    >
      <hra-icon [fontIcon]="column.type.icon" />
    </button>

    <mat-menu hraFeature="download-options" class="table-menu" #menuOptions="matMenu">
      @for (option of getMenuOptions(options); track $index) {
        <a
          hraClickEvent
          mat-menu-item
          download
          [hraFeature]="option.name | slugify"
          [attr.href]="option.url"
          (click)="
            $event.preventDefault();
            download(option.url!);
            snackbar.open('File downloaded', '', false, 'start', { duration: 5000 })
          "
        >
          <mat-icon>{{ option.icon }}</mat-icon>
          <div class="option-text">
            <span class="option-name">
              {{ option.name }}
            </span>
            <span class="option-description">
              {{ option.description }}
            </span>
          </div>
        </a>
      }
    </mat-menu>
  </ng-template>

  <ng-template let-label let-column="column" let-element="element" hraFeature="link-cell" hraLinkRowElement #link>
    @let url = element[column.type.urlColumn];
    @let isInternal = column.type.internal;
    @if (isInternal) {
      <a hraHyperlink class="link internal" tabindex="0" (keyup)="routeClick(url)" (click)="routeClick(url)">
        {{ label }}
      </a>
    } @else {
      <a hraHyperlink class="link" target="_blank" rel="noopener noreferrer" [attr.href]="url">
        {{ label }}
      </a>
    }
  </ng-template>
</ng-container>
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""