import React, { Component } from 'react'; import PropTypes from 'prop-types'; import * as valueTransformer from './value-transformer'; import rangePropType from './range-prop-type'; import valuePropType from './value-prop-type'; import Slider from './slider'; import Track from './track'; import { captialize, distanceTo, isDefined, isObject, length } from '../utils'; import styled from 'styled-components'; import remcalc from 'remcalc'; export const RangeStyled = styled.div` position: relative; min-height: ${remcalc(10)}; `; /** * A React component that allows users to input numeric values within a range * by dragging its sliders. */ export default class InputRange extends Component { /** * @ignore * @override * @return {Object} */ static get propTypes() { return { ariaLabelledby: PropTypes.string, ariaControls: PropTypes.string, classNames: PropTypes.objectOf(PropTypes.string), disabled: PropTypes.bool, draggableTrack: PropTypes.bool, formatLabel: PropTypes.func, maxValue: rangePropType, minValue: rangePropType, name: PropTypes.string, onChangeStart: PropTypes.func, onChange: PropTypes.func.isRequired, onChangeComplete: PropTypes.func, step: PropTypes.number, value: valuePropType }; } /** * @ignore * @override * @return {Object} */ static get defaultProps() { return { disabled: false, maxValue: 10, minValue: 0, step: 1 }; } /** * @param {Object} props * @param {string} [props.ariaLabelledby] * @param {string} [props.ariaControls] * @param {InputRangeClassNames} [props.classNames] * @param {boolean} [props.disabled = false] * @param {Function} [props.formatLabel] * @param {number|Range} [props.maxValue = 10] * @param {number|Range} [props.minValue = 0] * @param {string} [props.name] * @param {string} props.onChange * @param {Function} [props.onChangeComplete] * @param {Function} [props.onChangeStart] * @param {number} [props.step = 1] * @param {number|Range} props.value */ constructor(props) { super(props); /** * @private * @type {?number} */ this.startValue = null; /** * @private * @type {?Component} */ this.node = null; /** * @private * @type {?Component} */ this.trackNode = null; /** * @private * @type {bool} */ this.isSliderDragging = false; this.handleSliderDrag = this.handleSliderDrag.bind(this); this.handleTrackDrag = this.handleTrackDrag.bind(this); this.handleTrackMouseDown = this.handleTrackMouseDown.bind(this); this.handleInteractionStart = this.handleInteractionStart.bind(this); this.handleInteractionEnd = this.handleInteractionEnd.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this); this.handleMouseDown = this.handleMouseDown.bind(this); this.handleMouseUp = this.handleMouseUp.bind(this); this.handleKeyUp = this.handleKeyUp.bind(this); this.handleTouchStart = this.handleTouchStart.bind(this); this.handleTouchEnd = this.handleTouchEnd.bind(this); } /** * @ignore * @override * @return {void} */ componentWillUnmount() { this.removeDocumentMouseUpListener(); this.removeDocumentTouchEndListener(); } /** * Return the bounding rect of the track * @private * @return {ClientRect} */ getTrackClientRect() { return this.trackNode.getClientRect(); } /** * Return the slider key closest to a point * @private * @param {Point} position * @return {string} */ getKeyByPosition(position) { const values = valueTransformer.getValueFromProps( this.props, this.isMultiValue() ); const positions = valueTransformer.getPositionsFromValues( values, this.props.minValue, this.props.maxValue, this.getTrackClientRect() ); if (this.isMultiValue()) { const distanceToMin = distanceTo(position, positions.min); const distanceToMax = distanceTo(position, positions.max); if (distanceToMin < distanceToMax) { return 'min'; } } return 'max'; } /** * Return all the slider keys * @private * @return {string[]} */ getKeys() { if (this.isMultiValue()) { return ['min', 'max']; } return ['max']; } /** * Return true if the difference between the new and the current value is * greater or equal to the step amount of the component * @private * @param {Range} values * @return {boolean} */ hasStepDifference(values) { const currentValues = valueTransformer.getValueFromProps( this.props, this.isMultiValue() ); return ( length(values.min, currentValues.min) >= this.props.step || length(values.max, currentValues.max) >= this.props.step ); } /** * Return true if the component accepts a min and max value * @private * @return {boolean} */ isMultiValue() { return isObject(this.props.value); } /** * Return true if the range is within the max and min value of the component * @private * @param {Range} values * @return {boolean} */ isWithinRange(values) { if (this.isMultiValue()) { return ( values.min >= this.props.minValue && values.max <= this.props.maxValue && values.min < values.max ); } return ( values.max >= this.props.minValue && values.max <= this.props.maxValue ); } /** * Return true if the new value should trigger a render * @private * @param {Range} values * @return {boolean} */ shouldUpdate(values) { return this.isWithinRange(values) && this.hasStepDifference(values); } /** * Update the position of a slider * @private * @param {string} key * @param {Point} position * @return {void} */ updatePosition(key, position) { const values = valueTransformer.getValueFromProps( this.props, this.isMultiValue() ); const positions = valueTransformer.getPositionsFromValues( values, this.props.minValue, this.props.maxValue, this.getTrackClientRect() ); positions[key] = position; this.updatePositions(positions); } /** * Update the positions of multiple sliders * @private * @param {Object} positions * @param {Point} positions.min * @param {Point} positions.max * @return {void} */ updatePositions(positions) { const values = { min: valueTransformer.getValueFromPosition( positions.min, this.props.minValue, this.props.maxValue, this.getTrackClientRect() ), max: valueTransformer.getValueFromPosition( positions.max, this.props.minValue, this.props.maxValue, this.getTrackClientRect() ) }; const transformedValues = { min: valueTransformer.getStepValueFromValue(values.min, this.props.step), max: valueTransformer.getStepValueFromValue(values.max, this.props.step) }; this.updateValues(transformedValues); } /** * Update the value of a slider * @private * @param {string} key * @param {number} value * @return {void} */ updateValue(key, value) { const values = valueTransformer.getValueFromProps( this.props, this.isMultiValue() ); values[key] = value; this.updateValues(values); } /** * Update the values of multiple sliders * @private * @param {Range|number} values * @return {void} */ updateValues(values) { if (!this.shouldUpdate(values)) { return; } this.props.onChange(this.isMultiValue() ? values : values.max); } /** * Increment the value of a slider by key name * @private * @param {string} key * @return {void} */ incrementValue(key) { const values = valueTransformer.getValueFromProps( this.props, this.isMultiValue() ); const value = values[key] + this.props.step; this.updateValue(key, value); } /** * Decrement the value of a slider by key name * @private * @param {string} key * @return {void} */ decrementValue(key) { const values = valueTransformer.getValueFromProps( this.props, this.isMultiValue() ); const value = values[key] - this.props.step; this.updateValue(key, value); } /** * Listen to mouseup event * @private * @return {void} */ addDocumentMouseUpListener() { this.removeDocumentMouseUpListener(); this.node.ownerDocument.addEventListener('mouseup', this.handleMouseUp); } /** * Listen to touchend event * @private * @return {void} */ addDocumentTouchEndListener() { this.removeDocumentTouchEndListener(); this.node.ownerDocument.addEventListener('touchend', this.handleTouchEnd); } /** * Stop listening to mouseup event * @private * @return {void} */ removeDocumentMouseUpListener() { this.node.ownerDocument.removeEventListener('mouseup', this.handleMouseUp); } /** * Stop listening to touchend event * @private * @return {void} */ removeDocumentTouchEndListener() { this.node.ownerDocument.removeEventListener( 'touchend', this.handleTouchEnd ); } /** * Handle any "mousemove" event received by the slider * @private * @param {SyntheticEvent} event * @param {string} key * @return {void} */ handleSliderDrag(event, key) { if (this.props.disabled) { return; } const position = valueTransformer.getPositionFromEvent( event, this.getTrackClientRect() ); this.isSliderDragging = true; requestAnimationFrame(() => this.updatePosition(key, position)); } /** * Handle any "mousemove" event received by the track * @private * @param {SyntheticEvent} event * @return {void} */ handleTrackDrag(event, prevEvent) { if ( this.props.disabled || !this.props.draggableTrack || this.isSliderDragging ) { return; } const { maxValue, minValue, value: { max, min } } = this.props; const position = valueTransformer.getPositionFromEvent( event, this.getTrackClientRect() ); const value = valueTransformer.getValueFromPosition( position, minValue, maxValue, this.getTrackClientRect() ); const stepValue = valueTransformer.getStepValueFromValue( value, this.props.step ); const prevPosition = valueTransformer.getPositionFromEvent( prevEvent, this.getTrackClientRect() ); const prevValue = valueTransformer.getValueFromPosition( prevPosition, minValue, maxValue, this.getTrackClientRect() ); const prevStepValue = valueTransformer.getStepValueFromValue( prevValue, this.props.step ); const offset = prevStepValue - stepValue; const transformedValues = { min: min - offset, max: max - offset }; this.updateValues(transformedValues); } /** * Handle any "mousedown" event received by the track * @private * @param {SyntheticEvent} event * @param {Point} position * @return {void} */ handleTrackMouseDown(event, position) { if (this.props.disabled) { return; } const { maxValue, minValue, value: { max, min } } = this.props; event.preventDefault(); const value = valueTransformer.getValueFromPosition( position, minValue, maxValue, this.getTrackClientRect() ); const stepValue = valueTransformer.getStepValueFromValue( value, this.props.step ); if (!this.props.draggableTrack || stepValue > max || stepValue < min) { this.updatePosition(this.getKeyByPosition(position), position); } } /** * Handle the start of any mouse/touch event * @private * @return {void} */ handleInteractionStart() { if (this.props.onChangeStart) { this.props.onChangeStart(this.props.value); } if (this.props.onChangeComplete && !isDefined(this.startValue)) { this.startValue = this.props.value; } } /** * Handle the end of any mouse/touch event * @private * @return {void} */ handleInteractionEnd() { if (this.isSliderDragging) { this.isSliderDragging = false; } if (!this.props.onChangeComplete || !isDefined(this.startValue)) { return; } if (this.startValue !== this.props.value) { this.props.onChangeComplete(this.props.value); } this.startValue = null; } /** * Handle any "keydown" event received by the component * @private * @param {SyntheticEvent} event * @return {void} */ handleKeyDown(event) { this.handleInteractionStart(event); } /** * Handle any "keyup" event received by the component * @private * @param {SyntheticEvent} event * @return {void} */ handleKeyUp(event) { this.handleInteractionEnd(event); } /** * Handle any "mousedown" event received by the component * @private * @param {SyntheticEvent} event * @return {void} */ handleMouseDown(event) { this.handleInteractionStart(event); this.addDocumentMouseUpListener(); } /** * Handle any "mouseup" event received by the component * @private * @param {SyntheticEvent} event */ handleMouseUp(event) { this.handleInteractionEnd(event); this.removeDocumentMouseUpListener(); } /** * Handle any "touchstart" event received by the component * @private * @param {SyntheticEvent} event * @return {void} */ handleTouchStart(event) { this.handleInteractionStart(event); this.addDocumentTouchEndListener(); } /** * Handle any "touchend" event received by the component * @private * @param {SyntheticEvent} event */ handleTouchEnd(event) { this.handleInteractionEnd(event); this.removeDocumentTouchEndListener(); } /** * Return JSX of sliders * @private * @return {JSX.Element} */ renderSliders() { const values = valueTransformer.getValueFromProps( this.props, this.isMultiValue() ); const percentages = valueTransformer.getPercentagesFromValues( values, this.props.minValue, this.props.maxValue ); return this.getKeys().map(key => { const value = values[key]; const percentage = percentages[key]; let { maxValue, minValue } = this.props; if (key === 'min') { maxValue = values.max; } else { minValue = values.min; } const slider = ( ); return slider; }); } /** * Return JSX of hidden inputs * @private * @return {JSX.Element} */ renderHiddenInputs() { if (!this.props.name) { return []; } const isMultiValue = this.isMultiValue(); const values = valueTransformer.getValueFromProps(this.props, isMultiValue); return this.getKeys().map(key => { const value = values[key]; const name = isMultiValue ? `${this.props.name}${captialize(key)}` : this.props.name; return ; }); } /** * @ignore * @override * @return {JSX.Element} */ render() { const values = valueTransformer.getValueFromProps( this.props, this.isMultiValue() ); const percentages = valueTransformer.getPercentagesFromValues( values, this.props.minValue, this.props.maxValue ); return ( { this.node = node; }} onKeyDown={this.handleKeyDown} onKeyUp={this.handleKeyUp} onMouseDown={this.handleMouseDown} onTouchStart={this.handleTouchStart} > { this.trackNode = trackNode; }} percentages={percentages} onTrackDrag={this.handleTrackDrag} onTrackMouseDown={this.handleTrackMouseDown} > {this.renderSliders()} {this.renderHiddenInputs()} ); } }