valor-software/ng2-select

View on GitHub
components/select/select.ts

Summary

Maintainability
F
4 days
Test Coverage
import {
  Component,
  Input,
  Output,
  EventEmitter,
  ElementRef
} from 'angular2/core';
import {
  CORE_DIRECTIVES,
  FORM_DIRECTIVES,
  NgClass,
  NgStyle
} from 'angular2/common';
import {SelectItem} from './select-item';
import {
  HightlightPipe,
  stripTags
} from './select-pipes';
import {IOptionsBehavior} from './select-interfaces';

let optionsTemplate = `
    <ul *ngIf="optionsOpened && options && options.length > 0 && !itemObjects[0].hasChildren()"
        class="ui-select-choices ui-select-choices-content ui-select-dropdown dropdown-menu">
      <li class="ui-select-choices-group">
        <div *ngFor="#o of options"
             class="ui-select-choices-row"
             [class.active]="isActive(o)"
             (mouseenter)="selectActive(o)"
             (click)="selectMatch(o, $event)">
          <a href="javascript:void(0)" class="ui-select-choices-row-inner">
            <div [innerHtml]="o.text | hightlight:inputValue"></div>
          </a>
        </div>
      </li>
    </ul>

    <ul *ngIf="optionsOpened && options && options.length > 0 && itemObjects[0].hasChildren()"
        class="ui-select-choices ui-select-choices-content ui-select-dropdown dropdown-menu">
      <li *ngFor="#c of options; #index=index" class="ui-select-choices-group">
        <div class="divider" *ngIf="index > 0"></div>
        <div class="ui-select-choices-group-label dropdown-header">{{c.text}}</div>

        <div *ngFor="#o of c.children"
             class="ui-select-choices-row"
             [class.active]="isActive(o)"
             (mouseenter)="selectActive(o)"
             (click)="selectMatch(o, $event)"
             [ngClass]="{'active': isActive(o)}">
          <a href="javascript:void(0)" class="ui-select-choices-row-inner">
            <div [innerHtml]="o.text | hightlight:inputValue"></div>
          </a>
        </div>
      </li>
    </ul>
`;

@Component({
  selector: 'ng-select',
  pipes: [HightlightPipe],
  template: `
  <div tabindex="0"
     *ngIf="multiple === false"
     (keyup)="mainClick($event)"
     class="ui-select-container ui-select-bootstrap dropdown open">
    <div [ngClass]="{'ui-disabled': disabled}"></div>
    <div class="ui-select-match"
         *ngIf="!inputMode">
      <span tabindex="-1"
          class="btn btn-default btn-secondary form-control ui-select-toggle"
          (^click)="matchClick()"
          style="outline: 0;">
        <span *ngIf="active.length <= 0" class="ui-select-placeholder text-muted">{{placeholder}}</span>
        <span *ngIf="active.length > 0" class="ui-select-match-text pull-left"
              [ngClass]="{'ui-select-allow-clear': allowClear && active.length > 0}"
              [innerHTML]="active[0].text"></span>
        <i class="dropdown-toggle pull-right"></i>
        <i class="caret pull-right"></i>
        <a *ngIf="allowClear && active.length>0" style="margin-right: 10px; padding: 0;"
          (click)="remove(activeOption)" class="btn btn-xs btn-link pull-right">
          <i class="glyphicon glyphicon-remove"></i>
        </a>
      </span>
    </div>
    <input type="text" autocomplete="false" tabindex="-1"
           (keydown)="inputEvent($event)"
           (keyup)="inputEvent($event, true)"
           [disabled]="disabled"
           class="form-control ui-select-search"
           *ngIf="inputMode"
           placeholder="{{active.length <= 0 ? placeholder : ''}}">
      ${optionsTemplate}
  </div>

  <div tabindex="0"
     *ngIf="multiple === true"
     (keyup)="mainClick($event)"
     (focus)="focusToInput('')"
     class="ui-select-container ui-select-multiple ui-select-bootstrap dropdown form-control open">
    <div [ngClass]="{'ui-disabled': disabled}"></div>
    <span class="ui-select-match">
        <span *ngFor="#a of active">
            <span class="ui-select-match-item btn btn-default btn-secondary btn-xs"
                  tabindex="-1"
                  type="button"
                  [ngClass]="{'btn-default': true}">
               <a class="close ui-select-match-close"
                  (click)="remove(a)">&nbsp;&times;</a>
               <span>{{a.text}}</span>
           </span>
        </span>
    </span>
    <input type="text"
           (keydown)="inputEvent($event)"
           (keyup)="inputEvent($event, true)"
           (click)="matchClick($event)"
           [disabled]="disabled"
           autocomplete="false"
           autocorrect="off"
           autocapitalize="off"
           spellcheck="false"
           class="ui-select-search input-xs"
           placeholder="{{active.length <= 0 ? placeholder : ''}}"
           role="combobox">
    ${optionsTemplate}
  </div>
  `
})
export class Select {
  @Input()
  allowClear:boolean = false;
  @Input()
  placeholder:string = '';
  @Input()
  initData:Array<any> = [];
  @Input()
  multiple:boolean = false;

  @Input() set items(value:Array<any>) {
    this._items = value;
    this.itemObjects = this._items.map((item:any) => new SelectItem(item));
  }

  @Input() set disabled(value:boolean) {
    this._disabled = value;
    if (this._disabled === true) {
      this.hideOptions();
    }
  }

  @Output()
  data:EventEmitter<any> = new EventEmitter();
  @Output()
  selected:EventEmitter<any> = new EventEmitter();
  @Output()
  removed:EventEmitter<any> = new EventEmitter();
  @Output()
  typed:EventEmitter<any> = new EventEmitter();

  public options:Array<SelectItem> = [];
  public itemObjects:Array<SelectItem> = [];
  public active:Array<SelectItem> = [];
  public activeOption:SelectItem;
  private offSideClickHandler:any;
  private inputMode:boolean = false;
  private optionsOpened:boolean = false;
  private behavior:IOptionsBehavior;
  private inputValue:string = '';
  private _items:Array<any> = [];
  private _disabled:boolean = false;

  constructor(public element:ElementRef) {
  }

  private focusToInput(value:string = '') {
    setTimeout(() => {
      let el = this.element.nativeElement.querySelector('div.ui-select-container > input');
      if (el) {
        el.focus();
        el.value = value;
      }
    }, 0);
  }

  private matchClick(e:any) {
    if (this._disabled === true) {
      return;
    }

    this.inputMode = !this.inputMode;
    if (this.inputMode === true && ((this.multiple === true && e) || this.multiple === false)) {
      this.focusToInput();
      this.open();
    }
  }

  private mainClick(e:any) {
    if (this.inputMode === true || this._disabled === true) {
      return;
    }

    if (e.keyCode === 46) {
      e.preventDefault();
      this.inputEvent(e);
      return;
    }

    if (e.keyCode === 8) {
      e.preventDefault();
      this.inputEvent(e, true);
      return;
    }

    if (e.keyCode === 9 || e.keyCode === 13 ||
      e.keyCode === 27 || (e.keyCode >= 37 && e.keyCode <= 40)) {
      e.preventDefault();
      return;
    }

    this.inputMode = true;
    let value = String
      .fromCharCode(96 <= e.keyCode && e.keyCode <= 105 ? e.keyCode - 48 : e.keyCode)
      .toLowerCase();
    this.focusToInput(value);
    this.open();
    e.srcElement.value = value;
    this.inputEvent(e);
  }

  private open() {
    this.options = this.itemObjects
      .filter(option => (this.multiple === false ||
      this.multiple === true && !this.active.find(o => option.text === o.text)));

    if (this.options.length > 0) {
      this.behavior.first();
    }

    this.optionsOpened = true;
  }

  ngOnInit() {
    this.behavior = this.itemObjects[0].hasChildren() ?
      new ChildrenBehavior(this) : new GenericBehavior(this);
    this.offSideClickHandler = this.getOffSideClickHandler(this);
    document.addEventListener('click', this.offSideClickHandler);

    if (this.initData) {
      this.active = this.initData.map(d => new SelectItem(d));
      this.data.emit(this.active);
    }
  }

  ngOnDestroy() {
    document.removeEventListener('click', this.offSideClickHandler);
    this.offSideClickHandler = null;
  }

  private getOffSideClickHandler(context:any) {
    return function (e:any) {
      if (e.target && e.target.nodeName === 'INPUT'
        && e.target.className && e.target.className.indexOf('ui-select') >= 0) {
        return;
      }

      if (e.srcElement.contains(context.element.nativeElement)
      && e.srcElement && e.srcElement.className &&
        e.srcElement.className.indexOf('ui-select') >= 0) {
        if (e.target.nodeName !== 'INPUT') {
          context.matchClick(null);
        }
        return;
      }

      context.inputMode = false;
      context.optionsOpened = false;
    };
  }

  public remove(item:SelectItem) {
    if (this._disabled === true) {
      return;
    }

    if (this.multiple === true && this.active) {
      let index = this.active.indexOf(item);
      this.active.splice(index, 1);
      this.data.next(this.active);
      this.doEvent('removed', item);
    }

    if (this.multiple === false) {
      this.active = [];
      this.data.next(this.active);
      this.doEvent('removed', item);
    }
  }

  public doEvent(type:string, value:any) {
    if ((<any>this)[type] && value) {
      (<any>this)[type].next(value);
    }
  }

  private hideOptions() {
    this.inputMode = false;
    this.optionsOpened = false;
  }

  public inputEvent(e:any, isUpMode:boolean = false) {
    // tab
    if (e.keyCode === 9) {
      return;
    }

    if (isUpMode && (e.keyCode === 37 || e.keyCode === 39 || e.keyCode === 38 ||
      e.keyCode === 40 || e.keyCode === 13)) {
      e.preventDefault();
      return;
    }

    // backspace
    if (!isUpMode && e.keyCode === 8) {
      let el:any = this.element.nativeElement
        .querySelector('div.ui-select-container > input');

      if (!el.value || el.value.length <= 0) {
        if (this.active.length > 0) {
          this.remove(this.active[this.active.length - 1]);
        }

        e.preventDefault();
      }
    }

    // esc
    if (!isUpMode && e.keyCode === 27) {
      this.hideOptions();
      this.element.nativeElement.children[0].focus();
      e.preventDefault();
      return;
    }

    // del
    if (!isUpMode && e.keyCode === 46) {
      if (this.active.length > 0) {
        this.remove(this.active[this.active.length - 1]);
      }
      e.preventDefault();
    }

    // left
    if (!isUpMode && e.keyCode === 37 && this._items.length > 0) {
      this.behavior.first();
      e.preventDefault();
      return;
    }

    // right
    if (!isUpMode && e.keyCode === 39 && this._items.length > 0) {
      this.behavior.last();
      e.preventDefault();
      return;
    }

    // up
    if (!isUpMode && e.keyCode === 38) {
      this.behavior.prev();
      e.preventDefault();
      return;
    }

    // down
    if (!isUpMode && e.keyCode === 40) {
      this.behavior.next();
      e.preventDefault();
      return;
    }

    // enter
    if (!isUpMode && e.keyCode === 13) {
      if (this.active.indexOf(this.activeOption) == -1) {
        this.selectActiveMatch();
        this.behavior.next();
      }
      e.preventDefault();
      return;
    }

    if (e.srcElement) {
      this.inputValue = e.srcElement.value;
      this.behavior.filter(new RegExp(this.inputValue, 'ig'));
      this.doEvent('typed', this.inputValue);
    }
  }

  private selectActiveMatch() {
    this.selectMatch(this.activeOption);
  }

  private selectMatch(value:SelectItem, e:Event = null) {
    if (e) {
      e.stopPropagation();
      e.preventDefault();
    }

    if (this.options.length <= 0) {
      return;
    }

    if (this.multiple === true) {
      this.active.push(value);
      this.data.next(this.active);
    }

    if (this.multiple === false) {
      this.active[0] = value;
      this.data.next(this.active[0]);
    }

    this.doEvent('selected', value);
    this.hideOptions();

    if (this.multiple === true) {
      this.focusToInput('');
    } else {
      this.focusToInput( stripTags(value.text) );
      this.element.nativeElement.querySelector('.ui-select-container').focus();
    }
  }

  private selectActive(value:SelectItem) {
    this.activeOption = value;
  }

  private isActive(value:SelectItem):boolean {
    return this.activeOption.text === value.text;
  }
}

export class Behavior {
  public optionsMap:Map<string, number> = new Map<string, number>();

  constructor(public actor:Select) {
  }

  private getActiveIndex(optionsMap:Map<string, number> = null):number {
    let ai = this.actor.options.indexOf(this.actor.activeOption);

    if (ai < 0 && optionsMap !== null) {
      ai = optionsMap.get(this.actor.activeOption.id);
    }

    return ai;
  }

  public fillOptionsMap() {
    this.optionsMap.clear();
    let startPos = 0;
    this.actor.itemObjects.map(i => {
      startPos = i.fillChildrenHash(this.optionsMap, startPos);
    });
  }

  public ensureHighlightVisible(optionsMap:Map<string, number> = null) {
    let container = this.actor.element.nativeElement.querySelector('.ui-select-choices-content');

    if (!container) {
      return;
    }

    let choices = container.querySelectorAll('.ui-select-choices-row');
    if (choices.length < 1) {
      return;
    }

    let activeIndex = this.getActiveIndex(optionsMap);
    if (activeIndex < 0) {
      return;
    }

    let highlighted:any = choices[activeIndex];
    if (!highlighted) {
      return;
    }

    let posY:number = highlighted.offsetTop + highlighted.clientHeight - container.scrollTop;
    let height:number = container.offsetHeight;

    if (posY > height) {
      container.scrollTop += posY - height;
    } else if (posY < highlighted.clientHeight) {
      container.scrollTop -= highlighted.clientHeight - posY;
    }
  }
}

export class GenericBehavior extends Behavior implements IOptionsBehavior {
  constructor(public actor:Select) {
    super(actor);
  }

  public first() {
    this.actor.activeOption = this.actor.options[0];
    super.ensureHighlightVisible();
  }

  public last() {
    this.actor.activeOption = this.actor.options[this.actor.options.length - 1];
    super.ensureHighlightVisible();
  }

  public prev() {
    let index = this.actor.options.indexOf(this.actor.activeOption);
    this.actor.activeOption = this.actor
      .options[index - 1 < 0 ? this.actor.options.length - 1 : index - 1];
    super.ensureHighlightVisible();
  }

  public next() {
    let index = this.actor.options.indexOf(this.actor.activeOption);
    this.actor.activeOption = this.actor
      .options[index + 1 > this.actor.options.length - 1 ? 0 : index + 1];
    super.ensureHighlightVisible();
  }

  public filter(query:RegExp) {
    let options = this.actor.itemObjects
      .filter(option => stripTags(option.text).match(query) &&
      (this.actor.multiple === false ||
      (this.actor.multiple === true &&
      this.actor.active.indexOf(option) < 0)));
    this.actor.options = options;

    if (this.actor.options.length > 0) {
      this.actor.activeOption = this.actor.options[0];
      super.ensureHighlightVisible();
    }
  }

}

export class ChildrenBehavior extends Behavior implements IOptionsBehavior {
  constructor(public actor:Select) {
    super(actor);
  }

  public first() {
    this.actor.activeOption = this.actor.options[0].children[0];
    this.fillOptionsMap();
    this.ensureHighlightVisible(this.optionsMap);
  }

  public last() {
    this.actor.activeOption =
      this.actor
        .options[this.actor.options.length - 1]
        .children[this.actor.options[this.actor.options.length - 1].children.length - 1];
    this.fillOptionsMap();
    this.ensureHighlightVisible(this.optionsMap);
  }

  public prev() {
    let indexParent = this.actor.options
      .findIndex(a => this.actor.activeOption.parent && this.actor.activeOption.parent.id === a.id);
    let index = this.actor.options[indexParent].children
      .findIndex(a => this.actor.activeOption && this.actor.activeOption.id === a.id);
    this.actor.activeOption = this.actor.options[indexParent].children[index - 1];

    if (!this.actor.activeOption) {
      if (this.actor.options[indexParent - 1]) {
        this.actor.activeOption = this.actor
          .options[indexParent - 1]
          .children[this.actor.options[indexParent - 1].children.length - 1];
      }
    }

    if (!this.actor.activeOption) {
      this.last();
    }

    this.fillOptionsMap();
    this.ensureHighlightVisible(this.optionsMap);
  }

  public next() {
    let indexParent = this.actor.options
      .findIndex(a => this.actor.activeOption.parent && this.actor.activeOption.parent.id === a.id);
    let index = this.actor.options[indexParent].children
      .findIndex(a => this.actor.activeOption && this.actor.activeOption.id === a.id);
    this.actor.activeOption = this.actor.options[indexParent].children[index + 1];
    if (!this.actor.activeOption) {
      if (this.actor.options[indexParent + 1]) {
        this.actor.activeOption = this.actor.options[indexParent + 1].children[0];
      }
    }

    if (!this.actor.activeOption) {
      this.first();
    }

    this.fillOptionsMap();
    this.ensureHighlightVisible(this.optionsMap);
  }

  public filter(query:RegExp) {
    let options:Array<SelectItem> = [];
    let optionsMap:Map<string, number> = new Map<string, number>();
    let startPos = 0;

    for (let si of this.actor.itemObjects) {
      let children:Array<SelectItem> = si.children.filter(option => query.test(option.text));
      startPos = si.fillChildrenHash(optionsMap, startPos);

      if (children.length > 0) {
        let newSi = si.getSimilar();
        newSi.children = children;
        options.push(newSi);
      }
    }

    this.actor.options = options;

    if (this.actor.options.length > 0) {
      this.actor.activeOption = this.actor.options[0].children[0];
      super.ensureHighlightVisible(optionsMap);
    }
  }
}