ucsd-cse112/team13

View on GitHub
src/core-slider/CoreSliderElement.js

Summary

Maintainability
B
5 hrs
Test Coverage
import CoreElement from '../core-element/CoreElement';
import TEMPLATE from './CoreSliderElement.html';
import STYLE from './CoreSliderElement.css';

// Create the template node specified by the imported html file, embedded with the imported style.
const CoreSliderTemplate = CoreElement.templateNode(TEMPLATE, STYLE);

/**
 * Sets up the event listeners to handle touch and mouse release/move events.
 * @private
 * @param {Event} e               the input event that triggered this; it will be consumed.
 * @param {String} upEvent        the up event name
 *                                (for mouse = 'mouseup', for touch = 'touchend')
 * @param {Function} upListener   the listener for the named up event, will be called with
 *                                new input event
 * @param {String} moveEvent      the move event name
 *                                (for mouse = 'mousemove', for touch = 'touchmove')
 * @param {Function} moveListener the listener for the named move event, will be called
 *                                with new input event
 */
function setupInputEventListeners(e, upEvent, upListener, moveEvent, moveListener) {
  document.addEventListener(upEvent, upListener);
  document.addEventListener(moveEvent, moveListener);
  e.preventDefault();
  e.stopPropagation();
}

/**
 * Cleans up the event listeners that were registered by setupInputEventListeners().
 * This is usually called on the up/release event, when those listeners are not longer needed.
 * @private
 * @param {Event} e               the input event that triggered this; it will be consumed.
 * @param {String} upEvent        the up event name
 *                                (for mouse = 'mouseup', for touch = 'touchend')
 * @param {Function} upListener   the registered listener for the named up event, will be removed
 * @param {String} moveEvent      the move event name
 *                                (for mouse = 'mousemove', for touch = 'touchmove')
 * @param {Function} moveListener the registered listener for the named move event, will be removed
 */
function cleanupInputEventListeners(e, upEvent, upListener, moveEvent, moveListener) {
  document.removeEventListener(upEvent, upListener);
  document.removeEventListener(moveEvent, moveListener);
}

/**
 * An element that selects a range of values by sliding... it's a slider.
 * @property {Number} step      the size of the intervals for the value's valid range.
 *                              The attribute name is 'step'.
 * @property {Number} min       the minimum value.
 *                              The attribute name is 'min'.
 * @property {Number} max       the maximum value.
 *                              The attribute name is 'max'.
 * @property {Number} value     the current value.
 *                              The attribute name is 'value'.
 * @property {Boolean} disabled whether this can be used.
 *                              The attribute name is 'disabled'.
 * @property {Boolean} vertical whether to display vertically.
 *                              The attribute name is 'vertical'.
 * @property {Boolean} rainbow  whether to display in a bunch of colors.
 *                              The attribute name is 'rainbow'.
 * @property {String} color     the color of the slider.
 *                              The attribute name is 'color'.
 */
class CoreSliderElement extends CoreElement {
  /** @private */
  static get properties() {
    return {
      step: { type: Number },
      min: { type: Number },
      max: { type: Number },
      value: { type: Number, reflect: true },
      disabled: { type: Boolean },
      vertical: { type: Boolean },
      rainbow: { type: Boolean },
      color: { type: String },
    };
  }

  /** Creates a CoreSlider element. */
  constructor() {
    super(CoreSliderTemplate);

    // For any callback function passed out to someone else must be bound to 'this' context.
    // Otherwise, you cannot use 'this' within the function.

    // These are the callback functions that handle mouse input for the thumb slider.
    this.onMouseDown = this.onMouseDown.bind(this);
    this.onMouseUp = this.onMouseUp.bind(this);
    this.onMouseMove = this.onMouseMove.bind(this);

    // These are equivalent callbacks for touch instead.
    this.onTouchStart = this.onTouchStart.bind(this);
    this.onTouchEnd = this.onTouchEnd.bind(this);
    this.onTouchMove = this.onTouchMove.bind(this);

    // Retreives the slider thumb element from the shadow root by id.
    this.sliderThumb = this.shadowRoot.querySelector('#slider-thumb');
    // Registers the callback functions.
    this.sliderThumb.addEventListener('mousedown', this.onMouseDown);
    this.sliderThumb.addEventListener('touchstart', this.onTouchStart);

    // Gets the slider bar element from the shadow root by id.
    this.sliderBar = this.shadowRoot.querySelector('#slider-bar');
    // Registers the callback functions.
    this.sliderBar.addEventListener('mousedown', this.onMouseDown);
    this.sliderBar.addEventListener('touchstart', this.onTouchStart);

    // Gets the slider progress element from the shadow root by id.
    this.sliderProgress = this.shadowRoot.querySelector('#slider-progress');

    // Gets the parent slider container from the shadow root by id.
    this.slider = this.shadowRoot.querySelector('#slider');

    // Sets the default values for properties...
    this.step = 1;
    this.min = 0;
    this.max = 100;
    this.value = 0;
  }

  /** @private */
  propertyChangedCallback(property, oldValue, newValue) {
    switch (property) {
      case 'value':
        {
          // Updates the value. Ensures that the value is always within bounds.
          const minValue = this.min;
          const maxValue = this.max;
          const stepSize = this.step;
          // Makes sure value is a multiple of stepSize.
          let result = Math.floor(newValue / stepSize) * stepSize;
          if (result < minValue) result = minValue;
          if (result > maxValue) result = maxValue;

          // Update the new value.
          this.value = result;

          // Update ARIA value
          // NOTE: these values are based off of the ARIA standard
          // https://www.w3.org/TR/wai-aria-practices/examples/slider/slider-1.html
          this.slider.setAttribute('aria-valuenow', `${this.value}`);

          // Update the thumb position to the new value.
          this.updateThumbPosition(result);
        }
        break;
      case 'color':
        this.slider.style.color = this.color;
        break;
      case 'min':
        // Update ARIA value
        this.slider.setAttribute('aria-valuemin', `${this.min}`);
        break;
      case 'max':
        // Update ARIA value
        this.slider.setAttribute('aria-valuemax', `${this.max}`);
        break;
      default:
        // Everything is should be handled by CoreElement automatically.
        // Nothing special should happen other than attribute data updates.
    }
  }

  /** @override */
  connectedCallback() {
    super.connectedCallback();

    // Makes sure that the thumb is set at the correct position on startup.
    this.updateThumbPosition(this.value);
  }

  /**
   * Updates the thumb position to reflect the slider value.
   * @private
   * @param {Number} value the current slider value
   */
  updateThumbPosition(value) {
    // Calculates the value with respect to the defined range (from this.min and this.max)
    const stepSize = this.step;
    const minValue = this.min;
    const maxValue = this.max;
    // ... also that the value is a multiple of stepSize ...
    let result = Math.floor(value / stepSize) * stepSize;
    if (result < minValue) result = minValue;
    if (result > maxValue) result = maxValue;

    // Calculates the pixel position of the thumb from this.value
    const valueRange = maxValue - minValue;
    let progress = (result - minValue) / valueRange;
    // You must have a progress between 0 and 1.
    // The thumb position should not be allowed to leave the "bar".
    if (progress > 1) progress = 1;
    if (progress < 0) progress = 0;

    // Depending on whether it is vertical, the position may be left->right or top->bottom.
    if (!this.vertical) {
      this.sliderThumb.style.left = `calc(${progress * 100}%)`;
      this.sliderThumb.style.top = '0px';
      this.sliderProgress.style.width = `${progress * 100}%`;
      this.sliderProgress.style.height = '100%';
    } else {
      this.sliderThumb.style.left = '0px';
      this.sliderThumb.style.top = `calc(${(1 - progress) * 100}%)`;
      this.sliderProgress.style.width = '100%';
      this.sliderProgress.style.height = `${progress * 100}%`;
    }
  }

  /**
   * Is called when the mouse is clicked on the thumb.
   * @param {Event} e the input event
   */
  onMouseDown(e) {
    setupInputEventListeners(e,
      'mouseup', this.onMouseUp,
      'mousemove', this.onMouseMove);

    // Actually handle the input logic (abstracted for touch input)
    this.onThumbStart();
    this.onThumbMove(e);
  }

  /**
   * Is called when the mouse moves. This is only registered when onMouseDown is called.
   * @param {Event} e the input event
   */
  onMouseMove(e) {
    // Actually handle the input logic (abstracted for touch input)
    this.onThumbMove(e);
  }

  /**
   * Is called when the mouse is released anywhere.
   * @param {Event} e the input event
   */
  onMouseUp(e) {
    cleanupInputEventListeners(e,
      'mouseup', this.onMouseUp,
      'mousemove', this.onMouseMove);

    // Actually handle the input logic (abstracted for touch input)
    this.onThumbStop(e);
  }

  /**
   * Is called when a touch is on the thumb.
   * @param {Event} e the input event
   */
  onTouchStart(e) {
    setupInputEventListeners(e,
      'touchend', this.onTouchEnd,
      'touchmove', this.onTouchMove);

    // Actually handle the input logic (abstracted for mouse input)
    this.onThumbStart();
    this.onTouchMove(e);
  }

  /**
   * Is called when the touch moves. This is only registered when onTouchStart is called.
   *
   * @param {Event} e the input event
   */
  onTouchMove(e) {
    // We only handle the first touch, any subsequent touches are simply ignored.
    const touchEvent = e.changedTouches[0];
    // Actually handle the input logic (abstracted for mouse input)
    this.onThumbMove(touchEvent);
  }

  /**
   * Is called when the touch is released anywhere.
   *
   * @param {Event} e the input event
   */
  onTouchEnd(e) {
    cleanupInputEventListeners(e,
      'touchend', this.onTouchEnd,
      'touchmove', this.onTouchMove);

    // Actually handle the input logic (abstracted for mouse input)
    this.onThumbStop(e);
  }

  /**
   * Is called when the thumb should move (for both the mouse AND touch)
   */
  onThumbStart() {
    // The thumb should be "in focus" now. We cannot manipulate pseudo-classes (which 'focus' is).
    // So we settle for class names. The CSS will mimic the pseudo-class.
    this.sliderThumb.classList.add('focus');
  }

  /**
   * Depending on whether it is vertical, calculates the proportional
   * value from the slider to the moving thumb.
   * @param {Event} e   the input event
   * @returns {Number}  the progress along the bar for the thumb. The value ranges
   *                    between [0, 1] and goes top-to-bottom and left-to-right.
   */
  getSliderProgressRatio(e) {
    const sliderBoundingRect = this.slider.getBoundingClientRect();
    return !this.vertical
      ? (e.clientX - sliderBoundingRect.left) / this.slider.clientWidth
      : 1 - ((e.clientY - sliderBoundingRect.top) / this.slider.clientHeight);
  }

  /**
   * Is called when the thumb is moving (for both the mouse AND touch)
   * @param {Event} e the input event that moved the thumb
   */
  onThumbMove(e) {
    const sliderRatio = this.getSliderProgressRatio(e);
    const minValue = this.min;
    const maxValue = this.max;
    const lengthValue = maxValue - minValue;
    const result = lengthValue * sliderRatio + minValue;

    // Only update the value if it is not the same...
    if (this.value !== result) {
      this.value = result;

      // ... also let anyone else listening for the 'input' event,
      // with addEventListener('event', () => {...}), to have a crack
      // at handling this event ...
      this.dispatchEvent(new CustomEvent('input', {
        bubbles: true, // Makes sure this event can bubble up to the parents...
        composed: true, // Makes sure that this event can be handled outside the shadow DOM...
      }));
    }
  }

  /**
   * Is called when the thumb should stop moving (for both the mouse AND touch)
   */
  onThumbStop() {
    // Added by onThumbStart()... so get rid of it. It should not be in focus anymore.
    this.sliderThumb.classList.remove('focus');
  }
}

// Registers the class with the custom tag
CoreElement.customTag('core-slider', CoreSliderElement);

export default CoreSliderElement;