workylab/materialize-angular

View on GitHub
src/app/completed-components/slider/slider.component.ts

Summary

Maintainability
B
5 hrs
Test Coverage
/**
 * @license
 * Copyright Workylab. All Rights Reserved.
 *
 * Use of this source code is governed by an MIT-style license that can be
 * found in the LICENSE file at https://raw.githubusercontent.com/workylab/materialize-angular/master/LICENSE
 */

import {
  AfterContentInit,
  AfterViewInit,
  Component,
  ContentChildren,
  ElementRef,
  EventEmitter,
  forwardRef,
  Input,
  Output,
  QueryList,
  Renderer2,
  ViewChild
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { supportedEvents, supportTouchEvents } from '../../utils/get-supported-events.util';
import { config } from '../../config';
import { SliderModel } from './slider.model';
import { SliderOptionComponent } from './slider-option/slider-option.component';
import { SupportedEventsModel } from '../../components/common/models/supported-events.model';

@Component({
  providers: [{
    multi: true,
    provide: NG_VALUE_ACCESSOR,
    useExisting: forwardRef(() => SliderComponent)
  }],
  selector: `${ config.components.prefix }-slider }`,
  templateUrl: './slider.component.html'
})
export class SliderComponent implements AfterContentInit, AfterViewInit, ControlValueAccessor, SliderModel {
  static readonly tickClassName = config.components.prefix + '-slider-step';

  static readonly defaultProps: SliderModel = {
    className: '',
    disabled: false,
    required: false,
    showLabels: true,
    showTicks: false,
    value: null
  };

  @ContentChildren(SliderOptionComponent) options: QueryList<SliderOptionComponent>;

  @ViewChild('sliderIndicatorContainer', { static: true }) sliderIndicatorContainer: ElementRef;
  @ViewChild('sliderTrack', { static: true }) sliderTrack: ElementRef;
  @ViewChild('sliderTrackBackground', { static: true }) sliderTrackBackground: ElementRef;
  @ViewChild('sliderTrackInterval', { static: true }) sliderTrackInterval: ElementRef;

  @Output('onChange') onChangeEmitter: EventEmitter<number | string | boolean | null>;

  @Input() className: string = SliderComponent.defaultProps.className;
  @Input() disabled: boolean = SliderComponent.defaultProps.disabled;
  @Input() required: boolean = SliderComponent.defaultProps.required;
  @Input() showLabels: boolean = SliderComponent.defaultProps.showLabels;
  @Input() showTicks: boolean = SliderComponent.defaultProps.showTicks;
  @Input() value: number | string | boolean | null = SliderComponent.defaultProps.value;

  public prefix = config.components.prefix;

  public isFocused: boolean;
  public supportedEvents: SupportedEventsModel;

  constructor(private renderer: Renderer2) {
    this.isFocused = false;
    this.supportedEvents = supportedEvents();
    this.onChangeEmitter = new EventEmitter();

    this.actionDown = this.actionDown.bind(this);
    this.actionMove = this.actionMove.bind(this);
    this.actionUp = this.actionUp.bind(this);
    this.onOptionClick = this.onOptionClick.bind(this);
    this.update = this.update.bind(this);

    window.addEventListener(this.supportedEvents.resize, this.update);
  }

  ngAfterViewInit() {
    this.sliderTrack.nativeElement.addEventListener(this.supportedEvents.down, this.actionDown);
  }

  ngAfterContentInit() {
    this.update();

    this.options.changes.subscribe(this.update);
  }

  update() {
    setTimeout(() => {
      this.registerEventOptions();
      this.renderPositions();
      this.moveToValue(this.value, false);
    }, 0);
  }

  registerEventOptions() {
    this.options.forEach(option => {
      option.onClickEmitter.subscribe(this.onOptionClick);
    });
  }

  onOptionClick(value: number | string | boolean | null) {
    this.value = value;
    this.onChangeEmitter.emit(this.value);
    this.onChange(this.value);
    this.moveToValue(this.value, true);
  }

  renderPositions() {
    const pixelInterval = this.getPixelInterval();

    this.removeTicks();

    this.options.forEach((option, index) => {
      const leftSpace = pixelInterval * index;
      const { nativeElement } = option.templateRef;

      this.renderer.setStyle(nativeElement, 'left', `${ leftSpace }px`);

      if (this.showTicks) {
        const tick = this.renderer.createElement('div');

        this.renderer.setStyle(tick, 'left', `${ leftSpace }px`);
        this.renderer.addClass(tick, SliderComponent.tickClassName);
        this.renderer.appendChild(this.sliderTrackInterval.nativeElement, tick);
      }
    });
  }

  removeTicks() {
    const { nativeElement } = this.sliderTrackInterval;

    while (nativeElement.firstChild) {
      this.renderer.removeChild(nativeElement, nativeElement.firstChild);
    }
  }

  actionDown(event: any) {
    if (!this.disabled) {
      const x = this.getXCoordinate(event, this.supportedEvents.down);

      this.animate(x, true);

      window.addEventListener(this.supportedEvents.up, this.actionUp);
      window.addEventListener(this.supportedEvents.move, this.actionMove);
    }
  }

  actionMove(event: any) {
    const x = this.getXCoordinate(event, this.supportedEvents.move);

    this.value = this.getValueFromXCoordinate(x);
    this.animate(x, false);
  }

  actionUp(event: any) {
    window.removeEventListener(this.supportedEvents.up, this.actionUp);
    window.removeEventListener(this.supportedEvents.move, this.actionMove);

    this.renderer.setStyle(this.sliderIndicatorContainer.nativeElement, 'transitionDuration', null);

    const x = this.getXCoordinate(event, this.supportedEvents.up);

    this.value = this.getValueFromXCoordinate(x);
    this.onChangeEmitter.emit(this.value);
    this.onChange(this.value);
    this.moveToValue(this.value, true);
  }

  moveToValue(value: number | string | boolean | null, hasAnimation: boolean) {
    const options = this.options.toArray();
    const index = options.findIndex(option => option.value === value);
    const validatedIndex = index >= 0
      ? index
      : 0;
    const pixelInterval = this.getPixelInterval();
    const nextXCoordinate = validatedIndex * pixelInterval;

    this.animate(nextXCoordinate, hasAnimation);
  }

  activeOption(value: number | string | boolean | null) {
    this.options.forEach(item => {
      item.isActive = (item.value === value);
    });
  }

  getValueFromXCoordinate(x: number) {
    const index = this.getIndexFromXCoordinate(x);
    const options = this.options.toArray();
    const value = options[index].value;

    return value;
  }

  getIndexFromXCoordinate(x: number) {
    const pixelInterval = this.getPixelInterval();

    if (pixelInterval) {
      const index = Math.round(x / pixelInterval);

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

    return 0;
  }

  getXCoordinateByEventType(event: any, eventType: string): number {
    if (supportTouchEvents()) {
      if (eventType === this.supportedEvents.up) {
        return event.changedTouches[0].clientX;
      }

      return event.touches[0].clientX;
    }

    return event.clientX;
  }

  getXCoordinate(event: any, eventType: string) {
    const rect = this.sliderTrack.nativeElement.getBoundingClientRect();
    const xCoordinateEvent = this.getXCoordinateByEventType(event, eventType);
    const x = xCoordinateEvent - rect.left;

    if (x < 0) {
      return 0;
    }

    if (x > this.sliderTrack.nativeElement.offsetWidth) {
      return this.sliderTrack.nativeElement.offsetWidth;
    }

    return x;
  }

  getPixelInterval() {
    const maxOptionsSize = this.options.length - 1;

    if (maxOptionsSize > 0) {
      return this.sliderTrack.nativeElement.offsetWidth / maxOptionsSize;
    }

    return 0;
  }

  animate(x: number, hasAnimation: boolean) {
    this.activeOption(this.value);

    const transitionDuration = hasAnimation
      ? null
      : '0ms';

    this.renderer.setStyle(this.sliderIndicatorContainer.nativeElement, 'transitionDuration', transitionDuration);
    this.renderer.setStyle(this.sliderIndicatorContainer.nativeElement, 'left', `${ x }px`);
  }

  onFocus(): void {
    if (!this.disabled) {
      this.isFocused = true;

      this.onTouched();
    }
  }

  onBlur(): void {
    this.isFocused = false;
  }

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

  writeValue(value: number | string | boolean | null): void {
    setTimeout(() => {
      this.value = value;

      this.moveToValue(value, false);
    }, 0);
  }

  registerOnChange(fn: (value: number | string | boolean | null) => void): void {
    this.onChange = fn;
  }

  registerOnTouched(fn: () => void): void {
    this.onTouched = fn;
  }

  onChange(value: number | string | boolean | null): void {}

  onTouched(): void {}
}