Source: InputRange.js

/**
 * @module InputRange
 */

import React from 'react';
import Slider from './Slider';
import Track from './Track';
import Label from './Label';
import defaultClassNames from './defaultClassNames';
import valueTransformer from './valueTransformer';
import { autobind, captialize, distanceTo, isDefined, isObject, length } from './util';
import { maxMinValuePropType } from './propTypes';

/**
 * A map for storing internal members
 * @const {WeakMap}
 */
const internals = new WeakMap();

/**
 * An object storing keyboard key codes
 * @const {Object.<string, number>}
 */
const KeyCode = {
  LEFT_ARROW: 37,
  RIGHT_ARROW: 39,
};

/**
 * Check if values are within the max and min range of inputRange
 * @private
 * @param {InputRange} inputRange - React component
 * @param {Range} values - Min/max value of sliders
 * @return {boolean} True if within range
 */
function isWithinRange(inputRange, values) {
  const { props } = inputRange;

  if (inputRange.isMultiValue) {
    return values.min >= props.minValue &&
           values.max <= props.maxValue &&
           values.min < values.max;
  }

  return values.max >= props.minValue &&
         values.max <= props.maxValue;
}

/**
 * Check if the difference between values and the current values of inputRange
 * is greater or equal to its step amount
 * @private
 * @param {InputRange} inputRange - React component
 * @param {Range} values - Min/max value of sliders
 * @return {boolean} True if difference is greater or equal to step amount
 */
function hasStepDifference(inputRange, values) {
  const { props } = inputRange;
  const currentValues = valueTransformer.valuesFromProps(inputRange);

  return length(values.min, currentValues.min) >= props.step ||
         length(values.max, currentValues.max) >= props.step;
}

/**
 * Check if inputRange should update with new values
 * @private
 * @param {InputRange} inputRange - React component
 * @param {Range} values - Min/max value of sliders
 * @return {boolean} True if inputRange should update
 */
function shouldUpdate(inputRange, values) {
  return isWithinRange(inputRange, values) &&
         hasStepDifference(inputRange, values);
}

/**
 * Get the owner document of inputRange
 * @private
 * @param {InputRange} inputRange - React component
 * @return {Document} Document
 */
function getDocument(inputRange) {
  const { inputRange: { ownerDocument } } = inputRange.refs;

  return ownerDocument;
}

/**
 * Get the class name(s) of inputRange based on its props
 * @private
 * @param {InputRange} inputRange - React component
 * @return {string} A list of class names delimited with spaces
 */
function getComponentClassName(inputRange) {
  const { props } = inputRange;

  if (!props.disabled) {
    return props.classNames.component;
  }

  return `${props.classNames.component} is-disabled`;
}

/**
 * Get the key name of a slider
 * @private
 * @param {InputRange} inputRange - React component
 * @param {Slider} slider - React component
 * @return {string} Key name
 */
function getKeyFromSlider(inputRange, slider) {
  if (slider === inputRange.refs.sliderMin) {
    return 'min';
  }

  return 'max';
}

/**
 * Get all slider keys of inputRange
 * @private
 * @param {InputRange} inputRange - React component
 * @return {Array.<string>} Key names
 */
function getKeys(inputRange) {
  if (inputRange.isMultiValue) {
    return ['min', 'max'];
  }

  return ['max'];
}

/**
 * Get the key name of a slider that's the closest to a point
 * @private
 * @param {InputRange} inputRange - React component
 * @param {Point} position - x/y
 * @return {string} Key name
 */
function getKeyByPosition(inputRange, position) {
  const values = valueTransformer.valuesFromProps(inputRange);
  const positions = valueTransformer.positionsFromValues(inputRange, values);

  if (inputRange.isMultiValue) {
    const distanceToMin = distanceTo(position, positions.min);
    const distanceToMax = distanceTo(position, positions.max);

    if (distanceToMin < distanceToMax) {
      return 'min';
    }
  }

  return 'max';
}

/**
 * Get an array of slider HTML for rendering
 * @private
 * @param {InputRange} inputRange - React component
 * @return {Array.<string>} Array of HTML
 */
function renderSliders(inputRange) {
  const { classNames } = inputRange.props;
  const sliders = [];
  const keys = getKeys(inputRange);
  const values = valueTransformer.valuesFromProps(inputRange);
  const percentages = valueTransformer.percentagesFromValues(inputRange, values);

  for (const key of keys) {
    const value = values[key];
    const percentage = percentages[key];
    const ref = `slider${captialize(key)}`;

    let { maxValue, minValue } = inputRange.props;

    if (key === 'min') {
      maxValue = values.max;
    } else {
      minValue = values.min;
    }

    const slider = (
      <Slider
        classNames={ classNames }
        key={ key }
        maxValue={ maxValue }
        minValue={ minValue }
        onSliderKeyDown={ inputRange.handleSliderKeyDown }
        onSliderMouseMove={ inputRange.handleSliderMouseMove }
        percentage={ percentage }
        ref={ ref }
        type={ key }
        value={ value } />
    );

    sliders.push(slider);
  }

  return sliders;
}

/**
 * Get an array of hidden input HTML for rendering
 * @private
 * @param {InputRange} inputRange - React component
 * @return {Array.<string>} Array of HTML
 */
function renderHiddenInputs(inputRange) {
  const inputs = [];
  const keys = getKeys(inputRange);

  for (const key of keys) {
    const name = inputRange.isMultiValue ? `${inputRange.props.name}${captialize(key)}` : inputRange.props.name;

    const input = (
      <input type="hidden" name={ name }/>
    );
  }

  return inputs;
}

/**
 * InputRange React component
 * @class
 * @extends React.Component
 * @param {Object} props - React component props
 */
export default class InputRange extends React.Component {
  constructor(props) {
    super(props);

    // Private
    internals.set(this, {});

    // Auto-bind
    autobind([
      'handleInteractionEnd',
      'handleInteractionStart',
      'handleKeyDown',
      'handleKeyUp',
      'handleMouseDown',
      'handleMouseUp',
      'handleSliderKeyDown',
      'handleSliderMouseMove',
      'handleTouchStart',
      'handleTouchEnd',
      'handleTrackMouseDown',
    ], this);
  }

  /**
   * Return the clientRect of the component's track
   * @member {ClientRect}
   */
  get trackClientRect() {
    const { track } = this.refs;

    if (track) {
      return track.clientRect;
    }

    return {
      height: 0,
      left: 0,
      top: 0,
      width: 0,
    };
  }

  /**
   * Return true if the component accepts a range of values
   * @member {boolean}
   */
  get isMultiValue() {
    return isObject(this.props.value) ||
           isObject(this.props.defaultValue);
  }

  /**
   * Update the position of a slider by key
   * @param {string} key - min/max
   * @param {Point} position x/y
   */
  updatePosition(key, position) {
    const values = valueTransformer.valuesFromProps(this);
    const positions = valueTransformer.positionsFromValues(this, values);

    positions[key] = position;

    this.updatePositions(positions);
  }

  /**
   * Update the position of sliders
   * @param {Object} positions
   * @param {Point} positions.min
   * @param {Point} positions.max
   */
  updatePositions(positions) {
    const values = {
      min: valueTransformer.valueFromPosition(this, positions.min),
      max: valueTransformer.valueFromPosition(this, positions.max),
    };

    const transformedValues = {
      min: valueTransformer.stepValueFromValue(this, values.min),
      max: valueTransformer.stepValueFromValue(this, values.max),
    };

    this.updateValues(transformedValues);
  }

  /**
   * Update the value of a slider by key
   * @param {string} key - max/min
   * @param {number} value - New value
   */
  updateValue(key, value) {
    const values = valueTransformer.valuesFromProps(this);

    values[key] = value;

    this.updateValues(values);
  }

  /**
   * Update the values of all sliders
   * @param {Object|number} values - Object if multi-value, number if single-value
   */
  updateValues(values) {
    if (!shouldUpdate(this, values)) {
      return;
    }

    if (this.isMultiValue) {
      this.props.onChange(this, values);
    } else {
      this.props.onChange(this, values.max);
    }
  }

  /**
   * Increment the value of a slider by key name
   * @param {string} key - max/min
   */
  incrementValue(key) {
    const values = valueTransformer.valuesFromProps(this);
    const value = values[key] + this.props.step;

    this.updateValue(key, value);
  }

  /**
   * Decrement the value of a slider by key name
   * @param {string} key - max/min
   */
  decrementValue(key) {
    const values = valueTransformer.valuesFromProps(this);
    const value = values[key] - this.props.step;

    this.updateValue(key, value);
  }

  /**
   * Handle any mousemove event received by the slider
   * @param {SyntheticEvent} event - User event
   * @param {Slider} slider - React component
   */
  handleSliderMouseMove(event, slider) {
    if (this.props.disabled) {
      return;
    }

    const key = getKeyFromSlider(this, slider);
    const position = valueTransformer.positionFromEvent(this, event);

    this.updatePosition(key, position);
  }

  /**
   * Handle any keydown event received by the slider
   * @param {SyntheticEvent} event - User event
   * @param {Slider} slider - React component
   */
  handleSliderKeyDown(event, slider) {
    if (this.props.disabled) {
      return;
    }

    const key = getKeyFromSlider(this, slider);

    switch (event.keyCode) {
    case KeyCode.LEFT_ARROW:
      this.decrementValue(key);
      break;

    case KeyCode.RIGHT_ARROW:
      this.incrementValue(key);
      break;

    default:
      break;
    }
  }

  /**
   * Handle any mousedown event received by the track
   * @param {SyntheticEvent} event - User event
   * @param {Slider} slider - React component
   * @param {Point} position - Mousedown position
   */
  handleTrackMouseDown(event, track, position) {
    if (this.props.disabled) {
      return;
    }

    const key = getKeyByPosition(this, position);

    this.updatePosition(key, position);
  }

  /**
   * Handle the start of any user-triggered event
   * @param {SyntheticEvent} event - User event
   */
  handleInteractionStart() {
    const _this = internals.get(this);

    if (!this.props.onChangeComplete || isDefined(_this.startValue)) {
      return;
    }

    _this.startValue = this.props.value;
  }

  /**
   * Handle the end of any user-triggered event
   * @param {SyntheticEvent} event - User event
   */
  handleInteractionEnd() {
    const _this = internals.get(this);

    if (!this.props.onChangeComplete || !isDefined(_this.startValue)) {
      return;
    }

    if (_this.startValue !== this.props.value) {
      this.props.onChangeComplete(this, this.props.value);
    }

    _this.startValue = null;
  }

  /**
   * Handle any keydown event received by the component
   * @param {SyntheticEvent} event - User event
   */
  handleKeyDown(event) {
    this.handleInteractionStart(event);
  }

  /**
   * Handle any keyup event received by the component
   * @param {SyntheticEvent} event - User event
   */
  handleKeyUp(event) {
    this.handleInteractionEnd(event);
  }

  /**
   * Handle any mousedown event received by the component
   * @param {SyntheticEvent} event - User event
   */
  handleMouseDown(event) {
    const document = getDocument(this);

    this.handleInteractionStart(event);

    document.addEventListener('mouseup', this.handleMouseUp);
  }

  /**
   * Handle any mouseup event received by the component
   * @param {SyntheticEvent} event - User event
   */
  handleMouseUp(event) {
    const document = getDocument(this);

    this.handleInteractionEnd(event);

    document.removeEventListener('mouseup', this.handleMouseUp);
  }

  /**
   * Handle any touchstart event received by the component
   * @param {SyntheticEvent} event - User event
   */
  handleTouchStart(event) {
    const document = getDocument(this);

    this.handleInteractionStart(event);

    document.addEventListener('touchend', this.handleTouchEnd);
  }

  /**
   * Handle any touchend event received by the component
   * @param {SyntheticEvent} event - User event
   */
  handleTouchEnd(event) {
    const document = getDocument(this);

    this.handleInteractionEnd(event);

    document.removeEventListener('touchend', this.handleTouchEnd);
  }

  /**
   * Render method of the component
   * @return {string} Component JSX
   */
  render() {
    const { classNames } = this.props;
    const componentClassName = getComponentClassName(this);
    const values = valueTransformer.valuesFromProps(this);
    const percentages = valueTransformer.percentagesFromValues(this, values);

    return (
      <div
        aria-disabled={ this.props.disabled }
        ref="inputRange"
        className={ componentClassName }
        onKeyDown={ this.handleKeyDown }
        onKeyUp={ this.handleKeyUp }
        onMouseDown={ this.handleMouseDown }
        onTouchStart={ this.handleTouchStart }>
        <Label
          className={ classNames.labelMin }
          containerClassName={ classNames.labelContainer }>
          { this.props.minValue }
        </Label>

        <Track
          classNames={ classNames }
          ref="track"
          percentages={ percentages }
          onTrackMouseDown={ this.handleTrackMouseDown }>

          { renderSliders(this) }
        </Track>

        <Label
          className={ classNames.labelMax }
          containerClassName={ classNames.labelContainer }>
          { this.props.maxValue }
        </Label>

        { renderHiddenInputs(this) }
      </div>
    );
  }
}

/**
 * Accepted propTypes of InputRange
 * @static {Object}
 * @property {Function} ariaLabelledby
 * @property {Function} classNames
 * @property {Function} defaultValue
 * @property {Function} disabled
 * @property {Function} maxValue
 * @property {Function} minValue
 * @property {Function} name
 * @property {Function} onChange
 * @property {Function} onChangeComplete
 * @property {Function} step
 * @property {Function} value
 */
InputRange.propTypes = {
  ariaLabelledby: React.PropTypes.string,
  classNames: React.PropTypes.objectOf(React.PropTypes.string),
  defaultValue: maxMinValuePropType,
  disabled: React.PropTypes.bool,
  maxValue: maxMinValuePropType,
  minValue: maxMinValuePropType,
  name: React.PropTypes.string,
  onChange: React.PropTypes.func.isRequired,
  onChangeComplete: React.PropTypes.func,
  step: React.PropTypes.number,
  value: maxMinValuePropType,
};

/**
 * Default props of InputRange
 * @static {Object}
 * @property {Object.<string, string>} defaultClassNames
 * @property {Range|number} defaultValue
 * @property {boolean} disabled
 * @property {number} maxValue
 * @property {number} minValue
 * @property {number} step
 * @property {Range|number} value
 */
InputRange.defaultProps = {
  classNames: defaultClassNames,
  defaultValue: 0,
  disabled: false,
  maxValue: 10,
  minValue: 0,
  step: 1,
  value: null,
};