valor-software/angular2-bootstrap

View on GitHub
src/typeahead/typeahead.directive.ts

Summary

Maintainability
D
1 day
Test Coverage
/* tslint:disable:max-file-line-count */
import {
  ChangeDetectorRef,
  Directive,
  ElementRef,
  EventEmitter,
  HostListener,
  Input,
  OnDestroy,
  OnInit,
  Output,
  Renderer2,
  TemplateRef,
  ViewContainerRef
} from '@angular/core';
import { NgControl } from '@angular/forms';
import 'rxjs/add/observable/from';
import 'rxjs/add/operator/debounceTime';
import 'rxjs/add/operator/filter';
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/switchMap';
import 'rxjs/add/operator/mergeMap';
import 'rxjs/add/operator/toArray';

import { Observable } from 'rxjs/Observable';
import { Subscription } from 'rxjs/Subscription';
import { ComponentLoader, ComponentLoaderFactory } from '../component-loader/index';
import { TypeaheadContainerComponent } from './typeahead-container.component';
import { TypeaheadMatch } from './typeahead-match.class';
import { getValueFromObject, latinize, tokenize } from './typeahead-utils';

@Directive({selector: '[typeahead]', exportAs: 'bs-typeahead'})
export class TypeaheadDirective implements OnInit, OnDestroy {
  /** options source, can be Array of strings, objects or
   * an Observable for external matching process
   */
  @Input() typeahead: any;
  /** minimal no of characters that needs to be entered before
   * typeahead kicks-in. When set to 0, typeahead shows on focus with full
   * list of options (limited as normal by typeaheadOptionsLimit)
   */
  @Input() typeaheadMinLength: number = void 0;
  /** minimal wait time after last character typed before typeahead kicks-in */
  @Input() typeaheadWaitMs: number;
  /** maximum length of options items list */
  @Input() typeaheadOptionsLimit: number;
  /** when options source is an array of objects, the name of field
   * that contains the options value, we use array item as option in case
   * of this field is missing. Supports nested properties and methods.
   */
  @Input() typeaheadOptionField: string;
  /** when options source is an array of objects, the name of field that
   * contains the group value, matches are grouped by this field when set.
   */
  @Input() typeaheadGroupField: string;
  /** should be used only in case of typeahead attribute is array.
   * If true - loading of options will be async, otherwise - sync.
   * true make sense if options array is large.
   */
  @Input() typeaheadAsync: boolean = void 0;
  /** match latin symbols.
   * If true the word súper would match super and vice versa.
   */
  @Input() typeaheadLatinize = true;
  /** Can be use to search words by inserting a single white space between each characters
   *  for example 'C a l i f o r n i a' will match 'California'.
   */
  @Input() typeaheadSingleWords = true;
  /** should be used only in case typeaheadSingleWords attribute is true.
   * Sets the word delimiter to break words. Defaults to space.
   */
  @Input() typeaheadWordDelimiters = ' ';
  /** should be used only in case typeaheadSingleWords attribute is true.
   * Sets the word delimiter to match exact phrase.
   * Defaults to simple and double quotes.
   */
  @Input() typeaheadPhraseDelimiters = '\'"';
  /** used to specify a custom item template.
   * Template variables exposed are called item and index;
   */
  @Input() typeaheadItemTemplate: TemplateRef<any>;
  /** used to specify a custom options list template.
   * Template variables: matches, itemTemplate, query
   */
  @Input() optionsListTemplate: TemplateRef<any>;
  /** specifies if typeahead is scrollable  */
  @Input() typeaheadScrollable = false;
  /** specifies number of options to show in scroll view  */
  @Input() typeaheadOptionsInScrollableView = 5;
  /** fired when 'busy' state of this component was changed,
   * fired on async mode only, returns boolean
   */
  @Output() typeaheadLoading = new EventEmitter<boolean>();
  /** fired on every key event and returns true
   * in case of matches are not detected
   */
  @Output() typeaheadNoResults = new EventEmitter<boolean>();
  /** fired when option was selected, return object with data of this option */
  @Output() typeaheadOnSelect = new EventEmitter<TypeaheadMatch>();
  /** fired when blur event occurres. returns the active item */
  @Output() typeaheadOnBlur = new EventEmitter<any>();

  /**
   * A selector specifying the element the typeahead should be appended to.
   * Currently only supports "body".
   */
  @Input() container: string;

  /** This attribute indicates that the dropdown should be opened upwards */
  @Input() dropup = false;

  // not yet implemented
  /** if false restrict model values to the ones selected from the popup only will be provided */
  // @Input() protected typeaheadEditable:boolean;
  /** if false the first match automatically will not be focused as you type */
  // @Input() protected typeaheadFocusFirst:boolean;
  /** format the ng-model result after selection */
  // @Input() protected typeaheadInputFormatter:any;
  /** if true automatically select an item when there is one option that exactly matches the user input */
  // @Input() protected typeaheadSelectOnExact:boolean;
  /**  if true select the currently highlighted match on blur */
  // @Input() protected typeaheadSelectOnBlur:boolean;
  /**  if false don't focus the input element the typeahead directive is associated with on selection */
    // @Input() protected typeaheadFocusOnSelect:boolean;

  _container: TypeaheadContainerComponent;
  isTypeaheadOptionsListActive = false;

  protected keyUpEventEmitter: EventEmitter<any> = new EventEmitter();
  protected _matches: TypeaheadMatch[];
  protected placement = 'bottom-left';
  // protected popup:ComponentRef<TypeaheadContainerComponent>;

  private _typeahead: ComponentLoader<TypeaheadContainerComponent>;
  private _subscriptions: Subscription[] = [];
  private _outsideClickListener: Function;

  constructor(private ngControl: NgControl,
              private element: ElementRef,
              viewContainerRef: ViewContainerRef,
              private renderer: Renderer2,
              cis: ComponentLoaderFactory,
              private changeDetection: ChangeDetectorRef) {
    this._typeahead = cis.createLoader<TypeaheadContainerComponent>(
      element,
      viewContainerRef,
      renderer
    );
  }

  ngOnInit(): void {
    this.typeaheadOptionsLimit = this.typeaheadOptionsLimit || 20;
    this.typeaheadMinLength =
      this.typeaheadMinLength === void 0 ? 1 : this.typeaheadMinLength;
    this.typeaheadWaitMs = this.typeaheadWaitMs || 0;

    // async should be false in case of array
    if (
      this.typeaheadAsync === undefined &&
      !(this.typeahead instanceof Observable)
    ) {
      this.typeaheadAsync = false;
    }

    if (this.typeahead instanceof Observable) {
      this.typeaheadAsync = true;
    }

    if (this.typeaheadAsync) {
      this.asyncActions();
    } else {
      this.syncActions();
    }
  }

  @HostListener('input', ['$event'])
  onInput(e: any): void {
    // For `<input>`s, use the `value` property. For others that don't have a
    // `value` (such as `<span contenteditable="true">`), use either
    // `textContent` or `innerText` (depending on which one is supported, i.e.
    // Firefox or IE).
    const value =
      e.target.value !== undefined
        ? e.target.value
        : e.target.textContent !== undefined
        ? e.target.textContent
        : e.target.innerText;
    if (value != null && value.trim().length >= this.typeaheadMinLength) {
      this.typeaheadLoading.emit(true);
      this.keyUpEventEmitter.emit(e.target.value);
    } else {
      this.typeaheadLoading.emit(false);
      this.typeaheadNoResults.emit(false);
      this.hide();
    }
  }

  @HostListener('keyup', ['$event'])
  onChange(e: any): void {
    if (this._container) {
      // esc
      if (e.keyCode === 27) {
        this.hide();

        return;
      }

      // up
      if (e.keyCode === 38) {
        this._container.prevActiveMatch();

        return;
      }

      // down
      if (e.keyCode === 40) {
        this._container.nextActiveMatch();

        return;
      }

      // enter, tab
      if (e.keyCode === 13) {
        this._container.selectActiveMatch();

        return;
      }
    }
  }

  @HostListener('click')
  @HostListener('focus')
  onFocus(): void {
    if (this.typeaheadMinLength === 0) {
      this.typeaheadLoading.emit(true);
      this.keyUpEventEmitter.emit(this.element.nativeElement.value || '');
    }
  }

  @HostListener('blur')
  onBlur(): void {
    if (this._container && !this._container.isFocused) {
      this.typeaheadOnBlur.emit(this._container.active);
    }
  }

  @HostListener('keydown', ['$event'])
  onKeydown(e: any): void {
    // no container - no problems
    if (!this._container) {
      return;
    }

    // if an item is visible - prevent form submission
    if (e.keyCode === 13) {
      e.preventDefault();

      return;
    }

    // if an item is visible - don't change focus
    if (e.keyCode === 9) {
      e.preventDefault();
      this._container.selectActiveMatch();

      return;
    }
  }

  changeModel(match: TypeaheadMatch): void {
    const valueStr: string = match.value;
    this.ngControl.viewToModelUpdate(valueStr);
    (this.ngControl.control).setValue(valueStr);
    this.changeDetection.markForCheck();
    this.hide();
  }

  get matches(): any[] {
    return this._matches;
  }

  show(): void {
    this._typeahead
      .attach(TypeaheadContainerComponent)
      // todo: add append to body, after updating positioning service
      .to(this.container)
      .position({attachment: `${this.dropup ? 'top' : 'bottom'} left`})
      .show({
        typeaheadRef: this,
        placement: this.placement,
        animation: false,
        dropup: this.dropup
      });

    this._outsideClickListener = this.renderer.listen('document', 'click', (e: MouseEvent) => {
      if (this.typeaheadMinLength === 0 && this.element.nativeElement.contains(e.target)) {
        return;
      }
      this.onOutsideClick();
    });

    this._container = this._typeahead.instance;
    this._container.parent = this;
    // This improves the speed as it won't have to be done for each list item
    const normalizedQuery = (this.typeaheadLatinize
      ? latinize(this.ngControl.control.value)
      : this.ngControl.control.value)
      .toString()
      .toLowerCase();
    this._container.query = this.typeaheadSingleWords
      ? tokenize(
        normalizedQuery,
        this.typeaheadWordDelimiters,
        this.typeaheadPhraseDelimiters
      )
      : normalizedQuery;
    this._container.matches = this._matches;
    this.element.nativeElement.focus();
  }

  hide(): void {
    if (this._typeahead.isShown) {
      this._typeahead.hide();
      this._outsideClickListener();
      this._container = null;
    }
  }

  onOutsideClick(): void {
    if (this._container && !this._container.isFocused) {
      this.hide();
    }
  }

  ngOnDestroy(): any {
    // clean up subscriptions
    for (const sub of this._subscriptions) {
      sub.unsubscribe();
    }
    this._typeahead.dispose();
  }

  protected asyncActions(): void {
    this._subscriptions.push(
      this.keyUpEventEmitter
        .debounceTime(this.typeaheadWaitMs)
        .switchMap(() => this.typeahead)
        .subscribe((matches: any[]) => {
          this.finalizeAsyncCall(matches);
        })
    );
  }

  protected syncActions(): void {
    this._subscriptions.push(
      this.keyUpEventEmitter
        .debounceTime(this.typeaheadWaitMs)
        .mergeMap((value: string) => {
          const normalizedQuery = this.normalizeQuery(value);

          return Observable.from(this.typeahead)
            .filter((option: any) => {
              return (
                option &&
                this.testMatch(this.normalizeOption(option), normalizedQuery)
              );
            })
            .toArray();
        })
        .subscribe((matches: any[]) => {
          this.finalizeAsyncCall(matches);
        })
    );
  }

  protected normalizeOption(option: any): any {
    const optionValue: string = getValueFromObject(
      option,
      this.typeaheadOptionField
    );
    const normalizedOption = this.typeaheadLatinize
      ? latinize(optionValue)
      : optionValue;

    return normalizedOption.toLowerCase();
  }

  protected normalizeQuery(value: string): any {
    // If singleWords, break model here to not be doing extra work on each
    // iteration
    let normalizedQuery: any = (this.typeaheadLatinize
      ? latinize(value)
      : value)
      .toString()
      .toLowerCase();
    normalizedQuery = this.typeaheadSingleWords
      ? tokenize(
        normalizedQuery,
        this.typeaheadWordDelimiters,
        this.typeaheadPhraseDelimiters
      )
      : normalizedQuery;

    return normalizedQuery;
  }

  protected testMatch(match: string, test: any): boolean {
    let spaceLength: number;

    if (typeof test === 'object') {
      spaceLength = test.length;
      for (let i = 0; i < spaceLength; i += 1) {
        if (test[i].length > 0 && match.indexOf(test[i]) < 0) {
          return false;
        }
      }

      return true;
    }

    return match.indexOf(test) >= 0;
  }

  protected finalizeAsyncCall(matches: any[]): void {
    this.prepareMatches(matches);

    this.typeaheadLoading.emit(false);
    this.typeaheadNoResults.emit(!this.hasMatches());

    if (!this.hasMatches()) {
      this.hide();

      return;
    }

    if (this._container) {
      // This improves the speed as it won't have to be done for each list item
      const normalizedQuery = (this.typeaheadLatinize
        ? latinize(this.ngControl.control.value)
        : this.ngControl.control.value)
        .toString()
        .toLowerCase();
      this._container.query = this.typeaheadSingleWords
        ? tokenize(
          normalizedQuery,
          this.typeaheadWordDelimiters,
          this.typeaheadPhraseDelimiters
        )
        : normalizedQuery;
      this._container.matches = this._matches;
    } else {
      this.show();
    }
  }

  protected prepareMatches(options: any[]): void {
    const limited: any[] = options.slice(0, this.typeaheadOptionsLimit);

    if (this.typeaheadGroupField) {
      let matches: TypeaheadMatch[] = [];

      // extract all group names
      const groups = limited
        .map((option: any) =>
          getValueFromObject(option, this.typeaheadGroupField)
        )
        .filter((v: string, i: number, a: any[]) => a.indexOf(v) === i);

      groups.forEach((group: string) => {
        // add group header to array of matches
        matches.push(new TypeaheadMatch(group, group, true));

        // add each item of group to array of matches
        matches = matches.concat(
          limited
            .filter(
              (option: any) =>
                getValueFromObject(option, this.typeaheadGroupField) === group
            )
            .map(
              (option: any) =>
                new TypeaheadMatch(
                  option,
                  getValueFromObject(option, this.typeaheadOptionField)
                )
            )
        );
      });

      this._matches = matches;
    } else {
      this._matches = limited.map(
        (option: any) =>
          new TypeaheadMatch(
            option,
            getValueFromObject(option, this.typeaheadOptionField)
          )
      );
    }
  }

  protected hasMatches(): boolean {
    return this._matches.length > 0;
  }
}