Reference Source

coral-spectrum/coral-component-slider/src/scripts/Slider.js

/**
 * Copyright 2019 Adobe. All rights reserved.
 * This file is licensed to you under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License. You may obtain a copy
 * of the License at http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software distributed under
 * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
 * OF ANY KIND, either express or implied. See the License for the specific language
 * governing permissions and limitations under the License.
 */

import {BaseComponent} from '../../../coral-base-component';
import {BaseFormField} from '../../../coral-base-formfield';
import {Collection} from '../../../coral-collection';
import base from '../templates/base';
import {transform, validate, events, commons, Keys} from '../../../coral-utils';
import {Decorator} from '../../../coral-decorator';

const CLASSNAME = '_coral-Slider';
const CLASSNAME_HANDLE = '_coral-Slider-handle';
const CLASSNAME_INPUT = '_coral-Slider-input';

/**
 Enumeration for {@link Slider} orientations.

 @typedef {Object} SliderOrientationEnum

 @property {String} HORIZONTAL
 Horizontal slider.
 @property {String} VERTICAL
 Not supported. Falls back to HORIZONTAL.
 */
const orientation = {
  HORIZONTAL: 'horizontal',
  VERTICAL: 'vertical'
};

/**
 @class Coral.Slider
 @classdesc A Slider component is a form field that can be used to set a number within a range.
 @htmltag coral-slider
 @extends {HTMLElement}
 @extends {BaseComponent}
 @extends {BaseFormField}
 */

class ExtensibleSlider extends BaseFormField(BaseComponent(HTMLElement)) {
  /** @ignore */
  constructor() {
    super();

    this._delegateEvents(commons.extend(this._events, {
      'key:up ._coral-Slider-handle': '_handleKey',
      'key:right ._coral-Slider-handle': '_handleKey',
      'key:down ._coral-Slider-handle': '_handleKey',
      'key:left ._coral-Slider-handle': '_handleKey',
      'key:pageUp ._coral-Slider-handle': '_handleKey',
      'key:pageDown ._coral-Slider-handle': '_handleKey',
      'key:home ._coral-Slider-handle': '_handleKey',
      'key:end ._coral-Slider-handle': '_handleKey',

      'input': '_onInputChangeHandler',

      'touchstart': '_onMouseDown',
      'mousedown': '_onMouseDown',

      'capture:focus': '_focus',
      'capture:blur': '_blur'
    }));

    // Prepare templates
    this._elements = {};
    this._getTemplate().call(this._elements, {commons});

    // Pre-define labellable element
    this._labellableElement = this._elements.leftInput;

    // Content zone
    this._elements.content = this.querySelector('coral-slider-content') || document.createElement('coral-slider-content');

    // Additional shortcuts
    const handleContainer = this._elements.controls;
    this._elements.handles = Array.prototype.slice.call(handleContainer.querySelectorAll(`.${CLASSNAME_HANDLE}`));
    this._elements.inputs = Array.prototype.slice.call(handleContainer.querySelectorAll(`.${CLASSNAME_INPUT}`));

    // Binding
    this._onInteraction = this._onInteraction.bind(this);

    // Init the collection mutation observer
    this.items._startHandlingItems(true);
  }

  /**
   The slider's content.

   @type {SliderContent}
   @contentzone
   */
  get content() {
    return this._getContentZone(this._elements.content);
  }

  set content(value) {
    this._setContentZone('content', value, {
      handle: 'content',
      tagName: 'coral-slider-content',
      insert: function (content) {
        this._elements.labelContent.appendChild(content);
      }
    });
  }

  /**
   The Collection Interface that allows interacting with the items that the component contains.

   @type {Collection}
   @readonly
   */
  get items() {
    // just init on demand
    if (!this._items) {
      this._items = new Collection({
        host: this,
        itemTagName: 'coral-slider-item'
      });
    }
    return this._items;
  }

  /**
   Increment value of one step.

   @type {Number}
   @default 1
   @htmlattribute step
   @htmlattributereflected
   */
  get step() {
    return this._getValueOf('step', 1);
  }

  set step(value) {
    value = transform.number(value);
    if (value > 0) {
      this._step = value;
      this._reflectAttribute('step', this._step);

      this._elements.inputs.forEach((input) => {
        input.setAttribute('step', this._step);
      });
    }
  }

  /**
   The minimum value.

   @type {Number}
   @default 1
   @htmlattribute min
   @htmlattributereflected
   */
  get min() {
    return this._getValueOf('min', 1);
  }

  set min(value) {
    this._min = transform.number(value);
    this._reflectAttribute('min', this._min);

    this._elements.inputs.forEach((input) => {
      input.setAttribute('min', this._min);
    });
  }

  /**
   The maximum value.

   @type {Number}
   @default 100
   @htmlattribute max
   @htmlattributereflected
   */
  get max() {
    return this._getValueOf('max', 100);
  }

  set max(value) {
    this._max = transform.number(value);
    this._reflectAttribute('max', this._max);

    this._elements.inputs.forEach((input) => {
      input.setAttribute('max', this._max);
    });
  }

  /**
   @ignore

   Not supported anymore. Use "showValue" instead.
   */
  get tooltips() {
    return this.showValue;
  }

  set tooltips(value) {
    this.showValue = value;
  }

  /**
   Display the slider value.

   @type {Boolean}
   @default false
   @htmlattribute showvalue
   @htmlattributereflected
   */
  get showValue() {
    return this._showValue || false;
  }

  set showValue(value) {
    this._showValue = transform.booleanAttr(value);
    this._reflectAttribute('showvalue', this._showValue);

    this._elements.labelValue.hidden = !this._showValue;
  }

  /**
   Orientation of the slider. See {@link SliderOrientationEnum}.

   @type {String}
   @default SliderOrientationEnum.HORIZONTAL
   @htmlattribute orientation
   @htmlattributereflected
   */
  get orientation() {
    return this._orientation || orientation.HORIZONTAL;
  }

  set orientation(value) {
    value = transform.string(value).toLowerCase();
    this._orientation = validate.enumeration(orientation)(value) && value || orientation.HORIZONTAL;
    this._reflectAttribute('orientation', this._orientation);
  }

  /**
   Fill a value or value range using a highlight color.

   @type {Boolean}
   @default false
   @htmlattribute filled
   @htmlattributereflected
   */
  get filled() {
    return this._filled || false;
  }

  set filled(value) {
    this._filled = transform.booleanAttr(value);
    this._reflectAttribute('filled', this._filled);

    this.classList.toggle(`${CLASSNAME}--filled`, this._filled);
  }

  /**
   The value returned as a Number. Value is <code>NaN</code> if conversion to Number is not possible.

   @type {Number}
   @default NaN
   */
  get valueAsNumber() {
    return parseFloat(this.value);
  }

  set valueAsNumber(value) {
    this.value = transform.float(value);
  }

  /**
   Name used to submit the data in a form.
   @type {String}
   @default ""
   @htmlattribute name
   @htmlattributereflected
   */
  get name() {
    return this._elements.inputs[0].name;
  }

  set name(value) {
    this._reflectAttribute('name', value);

    this._elements.inputs.forEach((input) => {
      input.name = this.getAttribute('name');
    });
  }

  /**
   This field's current value.
   @type {String}
   @default ""
   @htmlattribute value
   */
  get value() {
    return this._elements.inputs[0].value;
  }

  set value(value) {
    value = transform.number(value);

    // setting the value should always set the first value
    if (this._elements.handles.length === 1) {
      const input = this._elements.inputs[0];

      value = this._snapValueToStep(value, this.min, this.max, this.step);

      input.value = value;
      if (input.value) {
        input.setAttribute('aria-valuenow', value);
        input.setAttribute('aria-valuetext', this._getLabel(value));
      } else {
        input.removeAttribute('aria-valuenow');
        input.removeAttribute('aria-valuetext');
      }

      this._moveHandles();

      // in order to keep the reset value in sync, we need to handle the "value" attribute of the inner input
      const valueAttribute = this.getAttribute('value');
      input[valueAttribute ? 'setAttribute' : 'removeAttribute']('value', valueAttribute);
    }
  }

  /**
   Whether this field is disabled or not.
   @type {Boolean}
   @default false
   @htmlattribute disabled
   @htmlattributereflected
   */
  get disabled() {
    return this._disabled || false;
  }

  set disabled(value) {
    this._disabled = transform.booleanAttr(value);
    this._reflectAttribute('disabled', this._disabled);

    this.classList.toggle('is-disabled', this._disabled);
    this[this._disabled ? 'setAttribute' : 'removeAttribute']('aria-disabled', this._disabled);
    this._elements.inputs.forEach((input) => {
      input.disabled = this._disabled;
    });
  }

  /**
   Whether this field is required or not.
   @type {Boolean}
   @default false
   @htmlattribute required
   @htmlattributereflected
   */
  get required() {
    return this._required || false;
  }

  set required(value) {
    this._required = transform.booleanAttr(value);
    this._reflectAttribute('required', this._required);

    this._elements.inputs.forEach((input) => {
      input.required = this._required;
    });
  }

  /**
   Whether this field is readOnly or not. Indicating that the user cannot modify the value of the control.
   @type {Boolean}
   @default false
   @htmlattribute readonly
   @htmlattributereflected
   */
  get readOnly() {
    return this._readOnly || false;
  }

  set readOnly(value) {
    this._readOnly = transform.booleanAttr(value);
    this._reflectAttribute('readonly', this._readOnly);

    this._elements.inputs.forEach((input) => {
      input.readOnly = this._readOnly;
    });
  }

  /**
   Inherited from {@link BaseFormField#labelledBy}.
   */
  get labelledBy() {
    return super.labelledBy;
  }

  set labelledBy(value) {
    super.labelledBy = value;

    if (this._elements.inputs.length > 1) {
      const input = this._elements.inputs[1];
      const labelledBy = this.labelledBy;

      input[labelledBy ? 'setAttribute' : 'removeAttribute']('aria-labelledby', labelledBy);
    }
  }

  /** @private */
  get _values() {
    return this._elements.inputs.map((input) => String(parseInt(input.value, 10)));
  }

  set _values(values) {
    if (values && values.length === this._elements.handles.length) {
      this._elements.inputs.forEach((input, i) => {
        const value = values[i] = this._snapValueToStep(values[i], this.min, this.max, this.step);

        input.value = value;
        if (input.value) {
          input.setAttribute('aria-valuenow', value);
          input.setAttribute('aria-valuetext', this._getLabel(value));
        } else {
          input.removeAttribute('aria-valuenow');
          input.removeAttribute('aria-valuetext');
        }
      });

      this._moveHandles();
    }
  }

  /** @private */
  _getValueOf(name, defaultValue) {
    if (typeof this[`_${name}`] === 'number') {
      return this[`_${name}`];
    } else if (this.hasAttribute(name)) {
      return parseFloat(this.getAttribute(name));
    }

    return defaultValue;
  }

  /**
   handles any mousedown/touchstart on the whole slider
   @private
   */
  _onMouseDown(event) {
    if (this.disabled) {
      return;
    }

    // do not accept right mouse button clicks
    if (event instanceof MouseEvent) {
      if ((event.which || event.button) !== 1) {
        return;
      }
    }

    event.preventDefault();

    this._currentHandle = event.target.closest(`.${CLASSNAME_HANDLE}`);

    // If no handle was touched:
    // the closest handle needs to jump to the closest valid position
    if (!this._currentHandle) {
      const p = this._getPoint(event);
      const val = this._getValueFromCoord(p.pageX, p.pageY, true);

      this._currentHandle = this._findNearestHandle(p.pageX, p.pageY);
      this._updateValue(this._currentHandle, val);
      this._setHandleFocus(this._currentHandle);
    }

    this._currentHandle.classList.add('is-dragged');
    document.body.classList.add('u-coral-closedHand');

    this._draggingHandler = this._handleDragging.bind(this);
    this._mouseUpHandler = this._mouseUp.bind(this);

    events.on('mousemove.Slider', this._draggingHandler);
    events.on('mouseup.Slider', this._mouseUpHandler);

    events.on('touchmove.Slider', this._draggingHandler);
    events.on('touchend.Slider', this._mouseUpHandler);
    events.on('touchcancel.Slider', this._mouseUpHandler);

    this._setHandleFocus(this._currentHandle);
  }

  /**
   @private
   @return {Object} which contains the real coordinates
   */
  _getPoint(event) {
    if (event.changedTouches && event.changedTouches.length > 0) {
      return event.changedTouches[0];
    } else if (event.touches && event.touches.length > 0) {
      return event.touches[0];
    }

    return event;
  }

  /**
   will set the focus either on the handle element
   or its input if range is supported

   @protected
   */
  _setHandleFocus(handle) {
    handle.querySelector(`.${CLASSNAME_INPUT}`).focus();
  }

  /**
   Handles keyboard interaction with the handlers.
   In case input[type=range] is supported, the focus
   will be on the input and keyboard handling will happen natively

   @private
   */
  _handleKey(event) {
    event.preventDefault();

    this._focus(event);

    const handle = event.matchedTarget;
    const idx = this._elements.handles.indexOf(handle);
    let v = parseInt(this._values[idx], 10);

    // increase
    if (event.keyCode === Keys.keyToCode('up') ||
      event.keyCode === Keys.keyToCode('right') ||
      event.keyCode === Keys.keyToCode('pageUp')) {
      v += this.step;
    }
    // decrease
    else if (event.keyCode === Keys.keyToCode('down') ||
      event.keyCode === Keys.keyToCode('left') ||
      event.keyCode === Keys.keyToCode('pageDown')) {
      v -= this.step;
    }
    // min
    else if (event.keyCode === Keys.keyToCode('home')) {
      v = this.min;
    }
    // max
    else if (event.keyCode === Keys.keyToCode('end')) {
      v = this.max;
    }

    this._updateValue(handle, v);
  }

  /**
   Finds the nearest handle based on X/Y coordinates

   @private
   */
  _findNearestHandle(mouseX, mouseY) {
    let closestDistance = Infinity;
    let closestHandle;

    function calculateDistance(elem, x, y) {
      const box = elem.getBoundingClientRect();
      const top = box.top + window.pageYOffset;
      const left = box.left + window.pageXOffset;

      return Math.floor(
        Math.sqrt(Math.pow(x - (left + box.width / 2), 2) + Math.pow(y - (top + box.height / 2), 2))
      );
    }

    // Find the nearest handle
    this._elements.handles.forEach((handle) => {
      const distance = calculateDistance(handle, mouseX, mouseY);

      if (distance < closestDistance) {
        closestDistance = distance;
        closestHandle = handle;
      }
    });

    return closestHandle;
  }

  /**
   Moves the handles to right position
   based on the data in this._values

   @private
   */
  _moveHandles() {
    const calculatePercent = (value) => (value - this.min) / (this.max - this.min) * 100;
    const labelValue = [];

    // Set the handle position as a percentage based on the stored values
    if (this._elements.handles.length === 1) {
      const handle = this._elements.handles[0];
      const percent = calculatePercent(this._values[0]);
      handle.style.left = `${percent}%`;

      handle.previousElementSibling.style.width = `${percent}%`;
      handle.nextElementSibling.style.width = `${100 - percent}%`;

      labelValue.push(this._getLabel(this._values[0]));
    } else {
      const leftHandle = this._elements.handles[0];
      const leftPercent = calculatePercent(this._values[0]);
      leftHandle.style.left = `${leftPercent}%`;

      const rightHandle = this._elements.handles[1];
      const rightPercent = calculatePercent(this._values[1]);
      rightHandle.style.left = `${rightPercent}%`;

      leftHandle.previousElementSibling.style.width = `${leftPercent}%`;
      leftHandle.nextElementSibling.style.left = `${leftPercent}%`;

      const middlePercent = 100 - rightPercent;
      leftHandle.nextElementSibling.style.right = `${middlePercent}%`;
      rightHandle.nextElementSibling.style.width = `${middlePercent}%`;

      labelValue.push(this._getLabel(this._values[0]));
      labelValue.push(this._getLabel(this._values[1]));
    }

    this._elements.labelValue.textContent = labelValue.length > 1 ? labelValue.join(' - ') : labelValue[0];
  }

  /**
   Handles "onchange" events from the input.
   This is only neede in case of IE10 which doesn't handle "oninput event".
   In that case, the _onInputChangeHandler will be called from this handler.

   @private
   */
  _onInputChange(event) {
    if (typeof event.target.oninput === 'undefined') {
      this._onInputChangeHandler(event);
    }
  }

  /**
   Handles "oninput" events from the input.
   This makes ensures native inputs like
   - direct keyboard interaction with input[type=range]
   - accessibility features with input[type=range]

   Note we are not using the "_onInputChange" directly because Firefox
   will trigger the "change" event only after the focus has been lost.

   @private
   */
  _onInputChangeHandler(event) {
    // stops the current event
    event.stopPropagation();

    const handle = event.target.closest(`.${CLASSNAME_HANDLE}`);

    if (event.target === document.activeElement) {
      this._focus(event);
    }

    this._updateValue(handle, event.target.value, true);
  }

  /**
   Handles "focusin" event from  either an input or its handle.

   @private
   */
  _focus(event) {
    // Depending on support for input[type=range],
    // the event.target could be either the handle or its child input.
    // Use closest() to locate the actual handle.
    event.target.closest(`.${CLASSNAME_HANDLE}`).classList.add('is-focused');

    events.on('touchstart.Slider', this._onInteraction);
    events.on('mousedown.Slider', this._onInteraction);
  }

  /**
   Handles the blur

   @private
   */
  _onInteraction(event) {
    if (!this.contains(event.target)) {
      return;
    }
    event.target.blur();
  }

  /**
   Handles "focusout" event from  either an input or its handle.

   @private
   */
  _blur(event) {
    // Depending on support for input[type=range],
    // the event.target could be either the handle or its child input.
    // Use closest() to locate the actual handle.
    event.target.closest(`.${CLASSNAME_HANDLE}`).classList.remove('is-focused');

    events.off('touchstart.Slider');
    events.off('mousedown.Slider');
  }

  /**
   handles mousemove/touchmove after a succesful start on an handle

   @private
   */
  _handleDragging(event) {
    const p = this._getPoint(event);

    this._updateValue(this._currentHandle, this._getValueFromCoord(p.pageX, p.pageY));

    event.preventDefault();
  }

  /**
   updates the value for a handle
   @param handle
   @param val
   @param {Boolean} forceEvent
   Always triggers the event. If <code>true</code> the change event will be triggered without checking if the value really changed. This is useful if we are called from something like the _onInputChange where new value has already been updated AND we are certain the change event should be triggered without checking.
   @protected
   */
  _updateValue(handle, val, forceEvent) {
    // this is prepared to work for multiple handles
    const idx = this._elements.handles.indexOf(handle);
    const values = this._values;

    values[idx] = val;

    const oldValues = this._values;
    this._values = values;
    const newValues = this._values;

    if (forceEvent || oldValues.join(':') !== newValues.join(':')) {
      this.trigger('change');
    }
  }

  /** @private */
  // eslint-disable-next-line no-unused-vars
  _getValueFromCoord(posX, posY, restrictBounds) {
    const boundingClientRect = this.getBoundingClientRect();
    const elementWidth = boundingClientRect.width;
    const percent = (posX - boundingClientRect.left) / elementWidth;

    // if the bounds are restricted, as with _handleClick, we shouldn't change the value.
    if (restrictBounds && (percent < 0 || percent > 1)) {
      return NaN;
    }

    const rawValue = this.min + (this.max - this.min) * percent;

    // Snap value to nearest step
    return this._snapValueToStep(rawValue, this.min, this.max, this.step);
  }

  /** @private */
  _snapValueToStep(rawValue, min, max, step) {
    step = parseFloat(step);
    const remainder = (rawValue - min) % step;
    const floatString = step.toString().replace(/^(?:\d+)(?:\.(\d+))?$/g, '$1');
    const precision = floatString.length;
    let snappedValue;

    if (Math.abs(remainder) * 2 >= step) {
      snappedValue = rawValue - Math.abs(remainder) + step;
    } else {
      snappedValue = rawValue - remainder;
    }

    if (snappedValue < min) {
      snappedValue = min;
    } else if (snappedValue > max) {
      snappedValue = min + Math.floor((max - min) / step) * step;
    }

    // correct floating point behavior by rounding to step precision
    if (precision > 0) {
      snappedValue = parseFloat(snappedValue.toFixed(precision));
    }

    return snappedValue;
  }

  /**
   end operation of a dragging flow
   @private
   */
  _mouseUp() {
    if (this._currentHandle) {
      this._currentHandle.style.cursor = 'grab';
      this._currentHandle.classList.remove('is-dragged');
    }
    document.body.classList.remove('u-coral-closedHand');

    events.off('mousemove.Slider', this._draggingHandler);
    events.off('touchmove.Slider', this._draggingHandler);
    events.off('mouseup.Slider', this._mouseUpHandler);
    events.off('touchend.Slider', this._mouseUpHandler);
    events.off('touchcancel.Slider', this._mouseUpHandler);

    this._currentHandle = null;
    this._draggingHandler = null;
    this._mouseUpHandler = null;
  }

  /**
   Gets the label for a passed value.

   @param value
   @return {String|Number} the known label from the item or the value itself
   @protected
   */
  _getLabel(value) {
    const items = this.items.getAll();
    let item;

    for (let i = 0 ; i < items.length ; i++) {
      if (transform.number(items[i].getAttribute('value')) === transform.number(value)) {
        item = items[i];
        break;
      }
    }

    // Use the innerHTML of the item if one was found
    return item ? item.innerHTML : value;
  }

  // To be overridden by RangedSlider
  _getTemplate() {
    return base;
  }

  get _contentZones() {
    return {'coral-slider-content': 'content'};
  }

  /**
   Returns {@link Slider} orientation options.

   @return {SliderOrientationEnum}
   */
  static get orientation() {
    return orientation;
  }

  static get _attributePropertyMap() {
    return commons.extend(super._attributePropertyMap, {
      showvalue: 'showValue'
    });
  }

  /** @ignore */
  static get observedAttributes() {
    return super.observedAttributes.concat([
      'step',
      'min',
      'max',
      'tooltips',
      'showvalue',
      'orientation',
      'filled'
    ]);
  }

  /** @ignore */
  render() {
    super.render();

    this.classList.add(CLASSNAME);

    // Default reflected attributes
    if (!this._min) {
      this.min = this.min;
    }
    if (!this._max) {
      this.max = this.max;
    }
    if (!this._step) {
      this.step = this.step;
    }
    if (!this._orientation) {
      this.orientation = orientation.HORIZONTAL;
    }

    // A11y
    this.setAttribute('role', 'presentation');

    // Support cloneNode
    const template = this.querySelectorAll('._coral-Slider-labelContainer, ._coral-Slider-controls');
    for (let i = 0 ; i < template.length ; i++) {
      template[i].remove();
    }

    // Render the main template
    const frag = document.createDocumentFragment();
    frag.appendChild(this._elements.label);
    frag.appendChild(this._elements.controls);

    const content = this._elements.content;

    // If no default content zone was provided, move everything there
    if (!content.parentNode) {
      // Process remaining elements as necessary
      while (this.firstChild) {
        const child = this.firstChild;

        if (child.nodeName === 'CORAL-SLIDER-ITEM') {
          // Add items to the fragment
          frag.appendChild(child);
        } else {
          // Add anything else to the content
          content.appendChild(child);
        }
      }
    }

    // Add the frag to the component
    this.appendChild(frag);

    // Assign the content zone so the insert function will be called
    this.content = content;

    // Defaults
    this._moveHandles();
  }
}

const Slider = Decorator(ExtensibleSlider);

export {ExtensibleSlider, Slider};