swimlane/ngx-ui

View on GitHub
projects/swimlane/ngx-ui/src/lib/components/select/select.component.ts

Summary

Maintainability
C
1 day
Test Coverage
import {
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  ContentChildren,
  ElementRef,
  EventEmitter,
  forwardRef,
  Input,
  OnDestroy,
  Output,
  Renderer2,
  TemplateRef,
  ViewChild,
  ViewEncapsulation
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

import type { QueryList } from '@angular/core';

import { Appearance } from '../../mixins/appearance/appearance.enum';
import { InViewportMetadata } from 'ng-in-viewport';
import { take } from 'rxjs/operators';
import { KeyboardKeys } from '../../enums/keyboard-keys.enum';
import { sizeMixin } from '../../mixins/size/size.mixin';
import { SelectDropdownOption } from './select-dropdown-option.interface';
import { SelectDropdownComponent } from './select-dropdown.component';
import { SelectInputComponent } from './select-input.component';

import { SelectOptionDirective } from './select-option.directive';
import { CoerceBooleanProperty } from '../../utils/coerce/coerce-boolean';
import { CoerceNumberProperty } from '../../utils/coerce/coerce-number';

let nextId = 0;

const SELECT_VALUE_ACCESSOR = {
  provide: NG_VALUE_ACCESSOR,
  useExisting: forwardRef(() => SelectComponent),
  multi: true
};

class InputBase {}

const _InputMixinBase = sizeMixin(InputBase);

function arrayEquals(a, b) {
  return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((val, index) => val === b[index]);
}

@Component({
  exportAs: 'ngxSelect',
  selector: 'ngx-select',
  templateUrl: './select.component.html',
  styleUrls: ['./select.component.scss'],
  host: {
    class: 'ngx-select',
    '[id]': 'id',
    '[attr.name]': 'name',
    '[class.legacy]': 'appearance === "legacy"',
    '[class.fill]': 'appearance === "fill"',
    '[class.sm]': 'size === "sm"',
    '[class.md]': 'size === "md"',
    '[class.lg]': 'size === "lg"',
    '[class.invalid]': 'invalid && touched',
    '[class.tagging-selection]': 'tagging',
    '[class.multi-selection]': 'multiple',
    '[class.single-selection]': 'isSingleSelect',
    '[class.disabled]': 'disabled',
    '[class.active]': 'dropdownActive',
    '[class.active-selections]': 'hasSelections',
    '[class.has-placeholder]': 'hasPlaceholder',
    '[class.autosize]': 'autosize',
    '[style.min-width]': 'autosize ? autosizeMinWidth : undefined',
    '[attr.aria-expanded]': 'dropdownActive',
    '[class.no-label]': '!label'
  },
  providers: [SELECT_VALUE_ACCESSOR],
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class SelectComponent extends _InputMixinBase implements ControlValueAccessor, OnDestroy {
  @Input() id = `select-${++nextId}`;
  @Input() name: string;
  @Input() label: string;
  @Input() hint: string;
  @Input() placeholder = '';
  @Input() emptyPlaceholder = 'No options available';
  @Input() filterEmptyPlaceholder = 'No matches...';
  @Input() filterPlaceholder = 'Filter options...';
  @Input() forceDownwardOpening = false;
  @Input() allowAdditionsText = 'Add Value';
  @Input() groupBy: string;
  @Input() selectCaret: string;
  @Input() requiredIndicator: string | boolean = '*';

  @Input() options: SelectDropdownOption[] = [];
  @Input() identifier: string;
  @Input() appearance = Appearance.Legacy;

  @Input()
  @CoerceNumberProperty()
  minSelections?: number;

  @Input()
  @CoerceNumberProperty()
  maxSelections?: number;

  @Input()
  get autosizeMinWidth(): number | string {
    return this._autosizeMinWidth;
  }
  set autosizeMinWidth(autosizeMinWidth) {
    if (!isNaN(+autosizeMinWidth)) {
      this._autosizeMinWidth = `${autosizeMinWidth}px`;
    } else if (typeof autosizeMinWidth === 'string') {
      this._autosizeMinWidth = autosizeMinWidth;
    }
  }

  @Input()
  @CoerceBooleanProperty()
  autofocus = false;

  @Input()
  @CoerceBooleanProperty()
  autosize = false;

  @Input()
  @CoerceBooleanProperty()
  allowClear = true;

  @Input()
  @CoerceBooleanProperty()
  allowAdditions = false;

  @Input()
  @CoerceBooleanProperty()
  disableDropdown = false;

  @Input()
  @CoerceBooleanProperty()
  closeOnSelect = false;

  @Input()
  @CoerceBooleanProperty()
  closeOnBodyClick = true;

  @Input()
  @CoerceBooleanProperty()
  filterable = true;

  @Input()
  @CoerceBooleanProperty()
  required = false;

  @Input()
  @CoerceBooleanProperty()
  filterCaseSensitive = false;

  @Input()
  @CoerceBooleanProperty()
  tagging = false;

  @Input()
  @CoerceBooleanProperty()
  multiple = false;

  @Input()
  @CoerceBooleanProperty()
  disabled = false;

  @Output() change = new EventEmitter<any[]>();
  @Output() keyup = new EventEmitter<{ event: KeyboardEvent; value?: string }>();
  @Output() toggle = new EventEmitter<boolean>();
  @Output() clearQueryFilter = new EventEmitter<void>();

  @ViewChild(SelectInputComponent, { static: true })
  readonly inputComponent: SelectInputComponent;

  @ViewChild(SelectDropdownComponent, { static: false })
  readonly selectDropdown: SelectDropdownComponent;

  /**
   * Custom Template for groupBy
   * Only works with groupBy on
   *
   * TemplateContext:
   * - groupName: the name of the group (`option.value[this.groupBy]` is the value)
   * - index, first, last, odd, even (ngFor properties)
   */
  @Input() groupByTemplate: TemplateRef<unknown>;

  @ContentChildren(SelectOptionDirective, { descendants: true })
  get optionTemplates() {
    return this._optionTemplates;
  }

  set optionTemplates(val: QueryList<SelectOptionDirective>) {
    this._optionTemplates = val;

    if (val) {
      const arr = val.toArray();

      // In some circumstances, ngx-select-option-template and *ngFor under options cause infinite change detection
      if (arr && arr.length > 0 && this.options && this.options?.length > 0 && arrayEquals(arr, this.options)) {
        return;
      }

      if (arr.length) {
        this.options = arr;
      } else {
        this.options = [];
      }
    }

    this._cdr.markForCheck();
  }

  get invalid() {
    if (this.required && this.checkInvalidValue(this.value)) return true;
    if (this.maxSelections !== undefined && this.value && this.value.length > this.maxSelections) return true;
    if (this.minSelections !== undefined && (!this.value || this.value.length < this.minSelections)) return true;
    return false;
  }

  get requiredIndicatorView() {
    const required = this.required || (this.minSelections !== undefined && this.minSelections > 0);

    if (!this.requiredIndicator || !required) {
      return '';
    }

    return this.requiredIndicator as string;
  }

  get isSingleSelect() {
    return !this.multiple && !this.tagging;
  }

  get hasSelections() {
    return this.value && this.value.length > 0 && typeof this.value[0] !== 'undefined';
  }

  get hasPlaceholder() {
    return this.placeholder && this.placeholder.length > 0;
  }

  get value() {
    return this._value;
  }
  set value(val: any[]) {
    if (val !== this._value) {
      this._value = val;
      this.onChangeCallback(this._value);
      this.change.emit(this._value);
      this._cdr.markForCheck();
    }
  }

  get dropdownVisible() {
    if (this.disableDropdown) return false;
    if (this.tagging && (!this.options || !this.options.length)) return false;
    return this.dropdownActive;
  }

  toggleListener?: () => void;
  filterQuery: string;
  focusIndex = -1;
  dropdownActive = false;
  touched = false;

  private _optionTemplates: QueryList<SelectOptionDirective>;
  private _value: any[] = [];
  private _autosizeMinWidth = '60px';

  constructor(
    private readonly _element: ElementRef,
    private readonly _renderer: Renderer2,
    private readonly _cdr: ChangeDetectorRef
  ) {
    super();
  }

  ngOnDestroy(): void {
    this.toggleDropdown(false);
  }

  onDropdownSelection(selection: SelectDropdownOption, shouldClose = this.closeOnSelect || !this.multiple): void {
    if (selection.disabled) return;
    if (this.value.length === this.maxSelections) return;

    const idx = this.findIndex(selection);

    if (idx === -1) {
      this.value = this.multiple || this.tagging ? [...this.value, selection.value] : [selection.value];
    }
    this.afterSelect(shouldClose);
  }

  onDropdownDeselection(selection: SelectDropdownOption, shouldClose = this.closeOnSelect || !this.multiple): void {
    if (selection.disabled) return;
    if (!this.allowClear) return;

    const idx = this.findIndex(selection);

    if (idx > -1) {
      this.value = this.value.filter((_, i) => i !== idx);
    }
    this.afterSelect(shouldClose);
  }

  private afterSelect(shouldClose: boolean = this.closeOnSelect || !this.multiple) {
    // if tagging, we need to clear current text
    if (this.tagging) {
      this.inputComponent.clearInput();
    }

    if (shouldClose) this.onClose(true);
  }

  onInputSelection(selections: any[]): void {
    this.value = selections;
  }

  onFocus(): void {
    if (this.disabled) return;

    this.toggleDropdown(true);
    this.onTouchedCallback();
    this.focusOn(0);
  }

  onFocusLast(): void {
    if (this.disabled) return;

    this.toggleDropdown(true);
    this.onTouchedCallback();
    this.focusOn(-1);
  }

  focusOn(index: number): void {
    if (index < 0) index = this.options.length + index;
    this.focusIndex = index;
  }

  onClear(): void {
    this.value = [];
  }

  onBodyClick(event: Event): void {
    if (this.dropdownActive) {
      const contains = this._element.nativeElement.contains(event.target);

      /* istanbul ignore else */
      if (!contains) {
        this.toggleDropdown(false);
      }
    }
  }

  onClose(keepFocus = false): void {
    this.toggleDropdown(false);

    if (keepFocus) {
      setTimeout(() => {
        this.inputComponent.focus();
      }, 30);
    }
  }

  onToggle(): void {
    if (this.disabled) return;

    this.toggleDropdown(!this.dropdownActive);
    this.onTouchedCallback();
  }

  toggleDropdown(state: boolean): void {
    if (this.dropdownActive === state) return;

    this.dropdownActive = state;

    if (this.toggleListener) this.toggleListener();
    this.toggle.emit(this.dropdownActive);

    if (this.dropdownActive) {
      // if open
      if (this.closeOnBodyClick) {
        this.toggleListener = this._renderer.listen(document.body, 'click', this.onBodyClick.bind(this));
      }

      this._cdr.detectChanges();

      if (this.selectDropdown?.inViewport) {
        this.selectDropdown.inViewport.inViewportAction
          .pipe(take(1))
          .subscribe({ next: this.adjustMenuDirection.bind(this) });
      }
    }

    this._cdr.markForCheck();
  }

  onKeyUp({ event, value }: { event: KeyboardEvent; value?: string }): void {
    if (event && event.key === (KeyboardKeys.ARROW_DOWN as any) && this.focusIndex < this.options.length) {
      ++this.focusIndex;
    } else {
      this.filterQuery = value;
    }

    this.keyup.emit({ event, value });
  }

  writeValue(val: any[]): void {
    /* istanbul ignore else */
    if (val !== this._value) {
      this._value = val;
      this._cdr.markForCheck();
    }
  }

  registerOnChange(fn: any): void {
    this.onChangeCallback = fn;
  }

  registerOnTouched(fn: any): void {
    this.onTouchedCallback = () => {
      this.touched = true;
      fn();
    };
  }

  setDisabledState(isDisabled: boolean): void {
    this.disabled = isDisabled;
  }

  private findIndex(selection: SelectDropdownOption) {
    return this.value.findIndex(o => {
      if (this.identifier) {
        return o[this.identifier] === selection.value[this.identifier];
      }
      return o === selection.value;
    });
  }

  private adjustMenuDirection(event: {
    [InViewportMetadata]: { entry: IntersectionObserverEntry };
    target: HTMLElement;
    visible: boolean;
  }): void {
    const { entry } = event[InViewportMetadata];
    if (!this.forceDownwardOpening && this.isIntersectingBottom(entry) && !this.isIntersectingTop(entry)) {
      this._renderer.addClass(this.selectDropdown.element, 'ngx-select-dropdown--upwards');
    } else {
      this._renderer.addClass(this.selectDropdown.element, 'ngx-select-dropdown--downwards');
    }
  }

  private isIntersectingBottom(entry: IntersectionObserverEntry): boolean {
    return entry.boundingClientRect.bottom >= entry.rootBounds.bottom;
  }

  private isIntersectingTop(entry: IntersectionObserverEntry): boolean {
    return entry.boundingClientRect.top - entry.boundingClientRect.height <= entry.rootBounds.top;
  }

  private checkInvalidValue(value: any): boolean {
    if (Array.isArray(value)) {
      return !this.value.length || this.checkInvalidValue(value[0]);
    }

    return value === undefined;
  }

  /* istanbul ignore next */
  private onChangeCallback(_: any): void {
    // placeholder
  }

  /* istanbul ignore next */
  private onTouchedCallback(): void {
    this.touched = true;
  }
}