File

src/app/shared/components/tag-search/tag-search.component.ts

Description

Component for searching, selecting, and adding tags.

Implements

OnDestroy

Metadata

Index

Properties
Methods
Inputs
Outputs
HostBindings
HostListeners

Constructor

constructor(el: ElementRef, ga: GoogleAnalyticsService, cdr: ChangeDetectorRef)

Creates an instance of tag search component.

Parameters :
Name Type Optional Description
el ElementRef<Node> No

Element for this component

ga GoogleAnalyticsService No

Analytics service

cdr ChangeDetectorRef No

Reference to change detector

Inputs

placeholder
Type : string
Default value : 'Add Anatomical Structures ...'

Placeholder text

search
Type : function

Search method

searchLimit
Type : number

Maximum number of results to show

searchThrottle
Type : number

Throttle time between search calls

Outputs

added
Type : EventEmitter

Emits when tags are added

HostBindings

class
Type : "ccf-tag-search"
Default value : 'ccf-tag-search'

HTML class name

HostListeners

click
click()

Opens the results panel

focusin
focusin()

Opens the results panel

window:click
Arguments : '$event'
window:click(event: Event)

Closes the results panel

Parameters :
Name Optional Description
event No

DOM event

window:focusin
Arguments : '$event'
window:focusin(event: Event)

Closes the results panel

Parameters :
Name Optional Description
event No

DOM event

Methods

addTags
addTags()

Emits selected tags and resets the search and selections

Returns : void
closeResults
closeResults(event: Event)
Decorators :
@HostListener('window:click', ['$event'])
@HostListener('window:focusin', ['$event'])

Closes the results panel

Parameters :
Name Type Optional Description
event Event No

DOM event

Returns : void
hasCheckedTags
hasCheckedTags()

Determines whether any tags have been checked

Returns : boolean

true if any tag has been checked by the user

openResults
openResults()
Decorators :
@HostListener('click')
@HostListener('focusin')

Opens the results panel

Returns : void
tagId
tagId(_index: number, tag: Tag)

Extracts the tag identifier

Parameters :
Name Type Optional Description
_index number No

Unused

tag Tag No

A tag

Returns : TagId

The identifier corresponding to the tag

Properties

checkedResults
Type : Record<TagId | boolean>
Default value : {}

Object of currently checked search results

closeSearch
Type : ElementRef<HTMLElement>
Decorators :
@ViewChild('closeSearch', {read: ElementRef, static: false})

Element for close search button

Readonly clsName
Type : string
Default value : 'ccf-tag-search'
Decorators :
@HostBinding('class')

HTML class name

Readonly countMapping
Type : object
Default value : { /* eslint-disable-next-line @typescript-eslint/naming-convention */ '=1': '1 result', other: '# results', }

Mapping for pluralizing the result total count

resultsVisible
Default value : false

Whether results are shown

Readonly searchControl
Default value : new UntypedFormControl()

Search field controller

searchResults
Default value : EMPTY_RESULT

Search results

import {
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  ElementRef,
  EventEmitter,
  HostBinding,
  HostListener,
  Input,
  OnDestroy,
  Output,
  ViewChild,
} from '@angular/core';
import { UntypedFormControl } from '@angular/forms';
import { bind as Bind } from 'bind-decorator';
import { GoogleAnalyticsService } from 'ngx-google-analytics';
import { ObservableInput, Subject, from, interval } from 'rxjs';
import { catchError, map, switchMap, takeUntil, throttle } from 'rxjs/operators';

import { Tag, TagId, TagSearchResult } from '../../../core/models/anatomical-structure-tag';

/** Default search results limit */
const DEFAULT_SEARCH_LIMIT = 5;
/** Default search throttle time in ms */
const DEFAULT_SEARCH_THROTTLE = 100;
/** Empty search result object */
const EMPTY_RESULT: TagSearchResult = { totalCount: 0, results: [] };

/**
 * Component for searching, selecting, and adding tags.
 */
@Component({
  selector: 'ccf-tag-search',
  templateUrl: './tag-search.component.html',
  styleUrls: ['./tag-search.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TagSearchComponent implements OnDestroy {
  /** HTML class name */
  @HostBinding('class') readonly clsName = 'ccf-tag-search';

  /** Placeholder text */
  @Input() placeholder = 'Add Anatomical Structures ...';

  /** Search method */
  @Input() search?: (text: string, limit: number) => ObservableInput<TagSearchResult>;

  /** Maximum number of results to show */
  @Input() searchLimit?: number;

  /** Throttle time between search calls */
  @Input() searchThrottle?: number;

  /** Emits when tags are added */
  @Output() readonly added = new EventEmitter<Tag[]>();

  /** Element for close search button */
  @ViewChild('closeSearch', { read: ElementRef, static: false }) closeSearch!: ElementRef<HTMLElement>;

  /** Mapping for pluralizing the result total count */
  readonly countMapping = {
    /* eslint-disable-next-line @typescript-eslint/naming-convention */
    '=1': '1 result',
    other: '# results',
  };

  /** Search field controller */
  readonly searchControl = new UntypedFormControl();

  /** Search results */
  searchResults = EMPTY_RESULT;

  /** Object of currently checked search results */
  checkedResults: Record<TagId, boolean> = {};

  /** Whether results are shown */
  resultsVisible = false;

  /** Emits and completes when component is destroyed. Used to clean up observables. */
  private readonly destroy$ = new Subject<void>();

  /**
   * Creates an instance of tag search component.
   *
   * @param el Element for this component
   * @param ga Analytics service
   * @param cdr Reference to change detector
   */
  constructor(
    private readonly el: ElementRef<Node>,
    private readonly ga: GoogleAnalyticsService,
    cdr: ChangeDetectorRef,
  ) {
    this.searchControl.valueChanges
      .pipe(
        takeUntil(this.destroy$),
        throttle(() => interval(this.searchThrottle ?? DEFAULT_SEARCH_THROTTLE), { leading: true, trailing: true }),
        switchMap(this.executeSearch),
      )
      .subscribe((result) => {
        this.searchResults = result;
        this.checkedResults = this.getUpdatedCheckedResults(result);
        cdr.markForCheck();
      });
  }

  /**
   * Cleans up component on destruction
   */
  ngOnDestroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }

  /**
   * Extracts the tag identifier
   *
   * @param _index Unused
   * @param tag A tag
   * @returns The identifier corresponding to the tag
   */
  tagId(_index: number, tag: Tag): TagId {
    return tag.id;
  }

  /**
   * Determines whether any tags have been checked
   *
   * @returns true if any tag has been checked by the user
   */
  hasCheckedTags(): boolean {
    return Object.values(this.checkedResults).some((v) => v);
  }

  /**
   * Emits selected tags and resets the search and selections
   */
  addTags(): void {
    const { searchControl, searchResults, checkedResults } = this;
    const tags = searchResults.results.filter((tag) => checkedResults[tag.id]);

    if (tags.length > 0) {
      searchControl.reset();
      this.searchResults = EMPTY_RESULT;
      this.checkedResults = {};
      this.ga.event('tags_added', 'tag_search', tags.map((tag) => tag.label).join(','));
      this.added.emit(tags);
    }
  }

  /**
   * Opens the results panel
   */
  @HostListener('click') // eslint-disable-line
  @HostListener('focusin') // eslint-disable-line
  openResults(): void {
    if (!this.resultsVisible) {
      this.resultsVisible = true;
    }
  }

  /**
   * Closes the results panel
   *
   * @param event DOM event
   */
  @HostListener('window:click', ['$event']) // eslint-disable-line
  @HostListener('window:focusin', ['$event']) // eslint-disable-line
  closeResults(event: Event): void {
    const { closeSearch } = this;
    if (this.resultsVisible && event.target instanceof Node) {
      if (!this.el.nativeElement.contains(event.target) || closeSearch.nativeElement.contains(event.target)) {
        this.resultsVisible = false;
      }
    }
  }

  /**
   * Executes a search on a piece of text.
   *
   * @param text Search text
   * @returns An observable of the search result.
   */
  @Bind
  private executeSearch(text: string): ObservableInput<TagSearchResult> {
    const { search, searchLimit = DEFAULT_SEARCH_LIMIT } = this;
    if (!text || !search) {
      return [EMPTY_RESULT];
    }

    return from(search(text, searchLimit)).pipe(
      catchError(() => [EMPTY_RESULT]),
      map(this.truncateResults),
    );
  }

  /**
   * Truncates the number of results returned by a search
   *
   * @param result The results
   * @returns Results with at most `searchLimit` items
   */
  @Bind
  private truncateResults(result: TagSearchResult): TagSearchResult {
    const { searchLimit = DEFAULT_SEARCH_LIMIT } = this;
    const items = result.results;

    if (items.length > searchLimit) {
      return {
        ...result,
        results: items.slice(0, searchLimit),
      };
    }

    return result;
  }

  /**
   * Computes a new checked object for result items. Already checked items are preserved.
   *
   * @param result New results
   * @returns A new checked object
   */
  private getUpdatedCheckedResults(result: TagSearchResult): Record<TagId, boolean> {
    const prev = this.checkedResults;
    return result.results.reduce<Record<TagId, boolean>>((acc, { id }) => {
      acc[id] = prev[id] ?? false;
      return acc;
    }, {});
  }
}
<div class="spacer"></div>

<mat-form-field class="overlay" [class.expanded]="resultsVisible" appearance="outline" subscriptSizing="dynamic">
  <div class="search-box">
    <input matInput type="search" [placeholder]="placeholder" [formControl]="searchControl" #search />
    <button
      class="add-button"
      [class.active]="hasCheckedTags()"
      [disabled]="!hasCheckedTags()"
      (click)="addTags(); search.focus()"
      matSuffix
      #closeSearch
    >
      <mat-icon class="icon">add</mat-icon>
    </button>
  </div>

  <div *ngIf="resultsVisible" class="results">
    <div *ngFor="let result of searchResults.results; trackBy: tagId" class="item">
      <mat-checkbox labelPosition="after" [(ngModel)]="checkedResults[result.id]">
        {{ result.label }}
      </mat-checkbox>
    </div>

    <div class="count">
      {{ searchResults.totalCount | i18nPlural: countMapping }}
    </div>
  </div>
</mat-form-field>

./tag-search.component.scss

:host {
  display: block;
  position: relative;

  .spacer {
    // Calculated by adding up all padding/margin/height of material form fields
    height: 3.5rem;
  }

  .overlay {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    z-index: 10;

    .search-box {
      display: flex;
      width: 100%;
      align-items: center;
      height: 3rem;

      .add-button {
        border-radius: 0.25rem;
        border: none;
        display: flex;
        justify-content: center;
        align-items: center;
        cursor: pointer;
        height: 100%;
      }
    }

    .results {
      margin-top: 0.5rem;

      .count {
        margin-top: 0.5rem;
        font-size: 0.75rem;
        text-align: end;
      }
    }

    ::ng-deep .mat-mdc-form-field-infix {
      min-height: inherit;
      padding: 0;
    }
  }
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""