kalidea/kaligraphi

View on GitHub
projects/kalidea/kaligraphi/src/lib/02-form/kal-datepicker/kal-datepicker.component.ts

Summary

Maintainability
C
7 hrs
Test Coverage
import {
  AfterContentInit,
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  ElementRef,
  forwardRef,
  HostListener,
  Inject,
  Injector,
  Input,
  OnDestroy,
  Optional,
  ViewChild,
  ViewEncapsulation
} from '@angular/core';
import { Overlay, OverlayRef } from '@angular/cdk/overlay';
import { TemplatePortal } from '@angular/cdk/portal';
import { ESCAPE } from '@angular/cdk/keycodes';
import { DOCUMENT } from '@angular/common';
import { FormControl, NgControl } from '@angular/forms';
import { fromEvent, merge, Observable, of, Subscription } from 'rxjs';
import { filter, map, take, tap } from 'rxjs/operators';
import dayjs from 'dayjs';
import localeData from 'dayjs/plugin/localeData';

import { coerceKalDateProperty, KalDate, KalDateType } from './kal-date';
import { KalMonthCalendarComponent } from './kal-month-calendar/kal-month-calendar.component';
import { KalDatepickerHeaderComponent } from './kal-datepicker-header/kal-datepicker-header.component';
import { buildProviders, FormElementComponent } from '../../utils/forms/form-element.component';
import { KalInputComponent } from '../kal-input/kal-input.component';
import { Coerce } from '../../utils/decorators/coerce';
import { AutoUnsubscribe } from '../../utils/decorators/auto-unsubscribe';
import { capitalize } from '../../utils/helpers/strings';

/**
 * Configure DayJS
 */
dayjs.extend(localeData);

/**
 * Possible views for the calendar.
 */
export type KalCalendarView = 'month' | 'multi';

@Component({
  selector: 'kal-datepicker',
  exportAs: 'kalDatepicker',
  templateUrl: './kal-datepicker.component.html',
  styleUrls: ['./kal-datepicker.sass'],
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush,
  providers: buildProviders(KalDatepickerComponent)
})
export class KalDatepickerComponent extends FormElementComponent<KalDate> implements AfterContentInit, OnDestroy {

  /**
   * Reference to calendar template.
   */
  @ViewChild('datepickerCalendar', {static: true}) datepickerCalendar: TemplatePortal<any>;

  /**
   * Reference to `KalMonthCalendarComponent`.
   */
  @ViewChild(KalMonthCalendarComponent, {static: false}) monthCalendar: KalMonthCalendarComponent;

  /**
   * Reference to `KalDatepickerHeaderComponent`.
   */
  @ViewChild(forwardRef(() => KalDatepickerHeaderComponent), {static: false}) datePickerHeader: KalDatepickerHeaderComponent;

  /**
   * reference to the kal input
   */
  @ViewChild(KalInputComponent, {static: true}) kalInput: KalInputComponent;

  /**
   * Whether the calendar is in month view.
   */
  currentView: KalCalendarView = 'month';

  /**
   * base control
   */
  control: FormControl;

  /**
   * Current displayed date.
   */
  currentDate: KalDate;

  /**
   * close datepicker when user select a date
   */
  @Input()
  @Coerce('boolean')
  closeOnPick = true;

  /**
   * open datepicker when user click on field
   */
  @Input()
  @Coerce('boolean')
  openOnClick = true;

  private readonly yearsIncrement = 30;

  /**
   * Subscription to `overlayRef.backdropClick()`.
   */
  @AutoUnsubscribe()
  private backdropClickSubscription = Subscription.EMPTY;

  /**
   * Subscriptions to watch.
   */
  @AutoUnsubscribe()
  private subscriptions: Subscription[] = [];

  /**
   * watch subscription outside datepicker
   */
  @AutoUnsubscribe()
  private clickOutsideSubscription = Subscription.EMPTY;

  /**
   * Overlay reference.
   */
  private overlayRef: OverlayRef;

  private _maxYear: number;

  private _minYear = 1940;

  constructor(private overlay: Overlay,
              private elementRef: ElementRef<HTMLElement>,
              private cdr: ChangeDetectorRef,
              private injector: Injector,
              @Optional() @Inject(DOCUMENT) private _document: any) {
    super();
  }

  /**
   * Max year that should be displayed in year selection.
   */
  @Input()
  @Coerce('number')
  get maxYear(): number {
    if (this._maxYear) {
      return this._maxYear;
    } else if (this.isCurrentDateValid) {
      return this.currentDate.getYear() + this.yearsIncrement;
    } else {
      return dayjs().year() + this.yearsIncrement;
    }
  }

  set maxYear(maxYear: number) {
    // check if we have a value and year length is valid
    if (maxYear && ('' + maxYear).length !== 4) {
      return;
    }

    this._maxYear = maxYear;
    this.cdr.markForCheck();
  }

  @Input()
  @Coerce('number')
  get minYear(): number {
    return this._minYear;
  }

  set minYear(minYear: number) {
    // check if year length is valid
    if (('' + minYear).length !== 4) {
      return;
    }

    this._minYear = minYear;
    this.cdr.markForCheck();
  }

  /**
   * Display the current period : month as string + year.
   */
  get currentPeriod(): string {
    let date: KalDate = null;

    if (this.monthCalendar) {
      date = this.monthCalendar.displayedDate;
    } else if (this.currentDate.valid) {
      date = this.currentDate;
    } else {
      date = new KalDate();
    }

    const month = dayjs().localeData().months()[date.getMonth()];
    return month ? capitalize(month) + ' ' + date.getYear() : '';
  }

  /**
   * Whether the current view is the `multi` view.
   */
  get isMultiView(): boolean {
    return this.currentView === 'multi';
  }

  get parentControlValidator() {
    const parentControl = this.injector.get(NgControl, null);
    return parentControl.control.validator;
  }

  private get positionStrategy() {
    return this.overlay
      .position()
      .flexibleConnectedTo(this.elementRef)
      .withPush(false)
      .withPositions([
        {originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top'},
        {originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom'}
      ]);
  }

  /**
   * Whether the current date is valid.
   */
  private get isCurrentDateValid(): boolean {
    return this.currentDate && this.currentDate.valid;
  }

  getOverlayRef(): OverlayRef {
    if (!this.overlayRef) {
      this.createOverlay();
    }

    return this.overlayRef;
  }

  /**
   * Switch between views to display.
   */
  changeCurrentView() {
    this.currentView = this.isMultiView ? 'month' : 'multi';

    // We should manually trigger change detection because header arrows depends on `KalDatepickerComponent`
    // and header doesn't know when it should refresh itself.
    this.datePickerHeader.markForCheck();
  }

  toggle() {
    if (this.getOverlayRef().hasAttached()) {
      this.close();
    } else {
      this.open(null, 'icon');
    }
  }

  open($event: MouseEvent = null, origin: 'icon' | 'mouse' = 'mouse') {
    // stop propagation of this event
    if ($event) {
      $event.stopPropagation();
    }

    // should we open overlay ?
    if (!this.disabled && (origin === 'icon' || this.openOnClick)) {
      if (!this.getOverlayRef().hasAttached()) {
        this.getOverlayRef().attach(this.datepickerCalendar);

        // watch for click outside
        this.clickOutsideSubscription = this.getOutsideClickStream()
          .pipe(take(1)) // take next outside click only
          .subscribe(() => this.close());
      }
    }
  }

  @HostListener('keydown.shift.tab')
  @HostListener('keydown.tab')
  @HostListener('keydown.enter')
  close() {
    if (this.overlayRef && this.overlayRef.hasAttached()) {
      this.overlayRef.detach();
    }

    this.clickOutsideSubscription.unsubscribe();

    // Set the current view to `month` because if the datepicker is
    // closed then opened it will keep its last view.
    this.currentView = 'month';

    // Reset displayed date to avoid keeping selected month and year in multiview.
    if (this.monthCalendar) {
      this.monthCalendar.displayedDate = this.currentDate;
    }
  }

  /**
   * Update the input value with the given date.
   */
  setInputValue(date: KalDate, event = {emitEvent: true}): void {
    const displayedDate = (date && date.valid) ? date.toString() : '';
    this.control.setValue(displayedDate, event);
    // close calendar if user pick
    if (event.emitEvent && this.closeOnPick) {
      this.close();
    }
  }

  /**
   * @inheritDoc
   */
  writeValue(value: KalDateType) {
    // transform given value as date
    const kalDate = coerceKalDateProperty(value);

    // store the date
    this.currentDate = kalDate;

    // update control only if provided
    if (this.control) {

      super.writeValue(kalDate);

      // if we get a `null` from the parent we should empty the input
      // and not display the current date
      this.setInputValue(value ? kalDate : null, {emitEvent: false});

    }
  }

  /**
   * Update the view according to `$event` parameter.
   * If we receive a `null` value it means that we're currently displaying the `multi` view and
   * we wants to display the `month` view.
   */
  updateView($event: number | null): void {
    if ($event === null) {
      this.changeCurrentView();
    } else {
      this.monthCalendar.updateMonth($event);
    }
  }

  private getOutsideClickStream(): Observable<any> {
    if (!this._document) {
      return of(null);
    }

    return merge(
      fromEvent<MouseEvent>(this._document, 'click'),
      fromEvent<TouchEvent>(this._document, 'touchend')
    )
      .pipe(filter(event => {
        const clickTarget = event.target as HTMLElement;

        const hasAttached = this.overlayRef.hasAttached();
        const datepickerContentClicked = this.elementRef.nativeElement.contains(clickTarget);
        const datepickerItselfClicked = clickTarget === this.elementRef.nativeElement;
        const overlayContentClicked = (!!this.overlayRef && this.overlayRef.overlayElement.contains(clickTarget));

        return hasAttached && !datepickerContentClicked && !datepickerItselfClicked && !overlayContentClicked;
      }));
  }

  private createOverlay(): void {
    this.overlayRef = this.overlay.create({
      positionStrategy: this.positionStrategy,
      scrollStrategy: this.overlay.scrollStrategies.close({threshold: 300}),
      width: '240px',
    });

    // watch escape key
    const escapeKeySubscription = this.overlayRef.keydownEvents()
      .pipe(
        filter(event => event.keyCode === ESCAPE)
      )
      .subscribe(() => this.close());

    this.subscriptions.push(escapeKeySubscription);
  }

  ngOnDestroy() {
    super.ngOnDestroy();
    this.close();
  }

  ngAfterContentInit(): void {

    this.control = this.createControlAndSubscriptions(this.injector, 'blur');

    // watch value changes
    const valueChangesSubscription = this.control.valueChanges.pipe(
      map(value => !!value ? coerceKalDateProperty(value) : null), // transform as date or send null if the input is empty
      tap((date: KalDate) => {
        // notify parent for validation
        super.notifyUpdate(date);

        // emit value
        this.valueChanges.emit(date);

        // if there's no date or if the given input is invalid, we should apply one
        // date manually so the datepicker can open at the current date
        if (date === null || !date.valid) {
          date = new KalDate();
        }

        if (this.monthCalendar) {
          this.monthCalendar.currentDate = date;
        }

        this.currentDate = date;
      })
    ).subscribe();
    this.subscriptions.push(valueChangesSubscription);

    const focusOnKalInputSubscription = fromEvent<MouseEvent>(this.kalInput.inputElement.nativeElement, 'focus')
      .subscribe(() => this.open());
    this.subscriptions.push(focusOnKalInputSubscription);
  }
}