Created
March 18, 2015 23:28
-
-
Save bobpace/9c06776ee079671f4c24 to your computer and use it in GitHub Desktop.
React Slider with Rx Observable implementation of drag and drop
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
var React = require('react'); | |
var Input = require('react-bootstrap/src/Input'); | |
var Glyphicon = require('react-bootstrap/src/Glyphicon'); | |
var _ = require('lodash'); | |
var Rx = require('rx'); | |
var canUseDom = require('can-use-dom'); | |
var changeCase = require('change-case'); | |
function events(event) { | |
return canUseDom ? Rx.Observable.fromEvent(document, event) : Rx.Observable.empty; | |
} | |
function typedConditionsMet(state) { | |
return state.leftNumber <= state.rightNumber; | |
} | |
var mouseMoves = events('mousemove') | |
var mouseUps = events('mouseup'); | |
var scalingFor = { left: 1, right: -1 }; | |
var oppositeDirections = { left: 'right', right: 'left' }; | |
var directions = Rx.Observable.fromArray(['left', 'right']); | |
var Slider = React.createClass({ | |
propTypes: { | |
width: React.PropTypes.number.isRequired, | |
numberOfSliders: React.PropTypes.oneOf([1, 2]), | |
leftNumber: React.PropTypes.number.isRequired, | |
rightNumber: React.PropTypes.number.isRequired, | |
initialLeftNumber: React.PropTypes.number, | |
initialRightNumber: React.PropTypes.number, | |
scalingFunction: React.PropTypes.func, | |
inverseScalingFunction: React.PropTypes.func, | |
onSliderUpdate: React.PropTypes.func, | |
onlyUpdateOnRelease: React.PropTypes.bool, | |
addonAfter: React.PropTypes.string | |
}, | |
getDefaultProps() { | |
return { | |
scalingFunction(x, constantBase) { | |
return Math.pow(x, 2) / Math.pow(constantBase, 2); | |
}, | |
inverseScalingFunction(x, constantBase) { | |
return Math.sqrt(x) / Math.sqrt(constantBase); | |
}, | |
onSliderUpdate: _.noop, | |
numberOfSliders: 2 | |
} | |
}, | |
getInitialState() { | |
// invariant: left <= right | |
var stateObject = { | |
leftNumber: this.props.initialLeftNumber || this.props.leftNumber, | |
rightNumber: this.props.initialRightNumber || this.props.rightNumber | |
}; | |
var position = this.positionForNumbers(stateObject); | |
return _.extend({ | |
storedNumber: this.props.leftNumber, | |
leftActive: false, | |
rightActive: false, | |
}, stateObject, position); | |
}, | |
componentWillReceiveProps(nextProps) { | |
var stateObject = { | |
leftNumber: nextProps.initialLeftNumber || this.props.leftNumber, | |
rightNumber: nextProps.initialRightNumber || this.props.rightNumber | |
}; | |
_.extend(stateObject, this.positionForNumbers(stateObject)); | |
this.setState(stateObject); | |
}, | |
componentDidMount() { | |
//hook up mouse events for both sliders | |
var mouseDrags = directions | |
.take(this.props.numberOfSliders) | |
.flatMap((direction) => { | |
var sliderRef = direction + 'Slider'; | |
var activeProperty = direction + "Active"; | |
var node = this.refs[sliderRef].getDOMNode(); | |
var mouseDowns = Rx.Observable.fromEvent(node, 'mousedown'); | |
return mouseDowns | |
.doOnNext(this.sliderActivated.bind(this, activeProperty)) | |
.flatMap((md) => { | |
var startX = md.clientX; | |
var originalPosition = this.state[direction]; | |
//take all mouse moves until a mouse up | |
return mouseMoves.map((mm) => { | |
mm.preventDefault(); | |
var newX = mm.clientX; | |
var scalar = scalingFor[direction]; | |
return this.buildNextState(direction, originalPosition + scalar * (newX - startX)); | |
}) | |
.filter(this.moveConditionsMet) | |
.takeUntil(mouseUps.doOnNext(this.sliderReleased)); | |
}); | |
}); | |
this.subscription = mouseDrags.subscribe((nextState) => { | |
//update state when mouse is dragging | |
this.setState(nextState, () => { | |
//optionally fire sliderUpdated if onlyUpdateOnRelease is not set | |
if (!this.props.onlyUpdateOnRelease) { | |
this.sliderUpdated({ | |
leftNumber: nextState.leftNumber, | |
rightNumber: nextState.rightNumber | |
}); | |
} | |
}); | |
}); | |
this.setState(this.getSliderState()); | |
}, | |
componentWillUnmount() { | |
this.subscription.dispose(); | |
}, | |
moveConditionsMet(state) { | |
var leftWithinBounds = state.left >= 0 && state.left < this.props.width; | |
var rightWithinBounds = state.right >= 0 && state.right < this.props.width; | |
var leftNotCrossing = state.left <= this.props.width - state.right; | |
var rightNotCrossing = this.props.width - state.right >= state.left; | |
return leftWithinBounds && rightWithinBounds && leftNotCrossing && rightNotCrossing; | |
}, | |
getSliderState() { | |
var sliderBar = this.refs.sliderBar.getDOMNode(); | |
var rect = sliderBar.getBoundingClientRect(); | |
return { | |
sliderStartX: rect.left, | |
sliderEndX: rect.left + rect.width | |
}; | |
}, | |
sliderActivated(activeProperty, e) { | |
//set active state on mousedown | |
var activeState; | |
e.preventDefault(); | |
if (this.state[activeProperty] === false) { | |
activeState = {}; | |
activeState[activeProperty] = true; | |
this.setState(activeState); | |
} | |
}, | |
sliderUpdated(state) { | |
var updatedProperties = _.take(['leftNumber', 'rightNumber'], this.props.numberOfSliders) | |
var updatedState = _.pick(state, updatedProperties); | |
this.props.onSliderUpdate(updatedState); | |
}, | |
sliderReleased() { | |
//fire sliderUpdated if onlyUpdateOnRelease is set | |
if (this.props.onlyUpdateOnRelease && (this.state.leftActive || this.state.rightActive)) { | |
this.sliderUpdated({ | |
leftNumber: this.state.leftNumber, | |
rightNumber: this.state.rightNumber | |
}); | |
} | |
//turn off active state on mouse up | |
this.setState({ | |
leftActive: false, | |
rightActive: false | |
}); | |
}, | |
handleSliderClick(re) { | |
var clickX = re.clientX; | |
var relativeClickX = clickX - this.state.sliderStartX; | |
var leftDistance = Math.abs(relativeClickX - this.state.left); | |
var rightDistance = Math.abs(relativeClickX - (this.props.width - this.state.right)); | |
var closer = (this.props.numberOfSliders === 1 || leftDistance < rightDistance) ? 'left' : 'right'; | |
var updateLocation = closer === 'left' ? relativeClickX : this.props.width - relativeClickX; | |
var nextState = this.buildNextState(closer, updateLocation); | |
// A state generated by a click is guaranteed to be valid | |
this.setState(nextState, () => { | |
this.sliderUpdated({ | |
leftNumber: nextState.leftNumber, | |
rightNumber: nextState.rightNumber | |
}); | |
}) | |
}, | |
inverseScale(currentNumber, limit) { | |
return this.props.inverseScalingFunction(currentNumber, limit) * this.props.width; | |
}, | |
positionForNumbers(state) { | |
var left = state.leftNumber; | |
var right = state.rightNumber; | |
return { | |
left: Math.floor(this.inverseScale(left - this.props.leftNumber, this.props.rightNumber)), | |
right: this.props.width - this.inverseScale(right, this.props.rightNumber) | |
} | |
}, | |
buildNextState(updateDirection, updateLocation) { | |
var nextState = {}; | |
var otherDirection = oppositeDirections[updateDirection]; | |
var otherNumberProperty = otherDirection + "Number"; | |
var updateLocation = Math.max(0, updateLocation); | |
var absoluteLocation = updateDirection === 'left' ? updateLocation : this.props.width - updateLocation; | |
nextState[updateDirection] = updateLocation; | |
nextState[updateDirection + 'Number'] = Math.ceil(this.props.scalingFunction(absoluteLocation, this.props.width) * (this.props.rightNumber - this.props.leftNumber) + this.props.leftNumber); | |
nextState[otherDirection] = this.state[otherDirection]; | |
nextState[otherNumberProperty] = this.state[otherNumberProperty]; | |
return nextState; | |
}, | |
handleBlur(targetProperty, e) { | |
var stateObject; | |
if (e.target.value === "") { | |
stateObject = {}; | |
stateObject[targetProperty] = this.state.storedNumber; | |
this.setState(stateObject); | |
} | |
}, | |
handleFocus(targetProperty) { | |
var storedNumber = this.state[targetProperty]; | |
this.setState({ | |
storedNumber: storedNumber | |
}); | |
}, | |
handleChange(targetProperty, direction, e) { | |
var stateObject = {}; | |
var otherDirection = oppositeDirections[direction]; | |
var otherNumberProperty = otherDirection + "Number"; | |
var inputValue = e.target.value; | |
var afterStateChange = _.noop; | |
// Restrictions on entered values. Storage of temporary value when user clears | |
// input field. | |
if (inputValue === "") { | |
stateObject[targetProperty] = ""; | |
stateObject.storedNumber = this.state[targetProperty]; | |
} | |
else if (/^\d+$/.test(inputValue) && inputValue <= this.props.rightNumber && inputValue >= this.props.leftNumber) { | |
stateObject[targetProperty] = parseInt(inputValue); | |
stateObject[otherNumberProperty] = this.state[otherNumberProperty]; | |
if (!typedConditionsMet(stateObject)) { | |
stateObject[otherNumberProperty] = stateObject[targetProperty]; | |
} | |
_.extend(stateObject, this.positionForNumbers(stateObject)); | |
afterStateChange = () => this.sliderUpdated(stateObject); | |
} | |
if (!_.isEmpty(stateObject)) { | |
this.setState(stateObject, afterStateChange); | |
} | |
}, | |
numberContainerFor(direction) { | |
var numberProperty = direction + "Number"; | |
return ( | |
<div className="numerical-container"> | |
<Input type="text" | |
className="slider-input" | |
onFocus={this.handleFocus.bind(this, numberProperty)} | |
onBlur={this.handleBlur.bind(this, numberProperty)} | |
onChange={this.handleChange.bind(this, numberProperty, direction)} | |
addonAfter={this.props.addonAfter} | |
value={this.state[numberProperty]} /> | |
</div> | |
) | |
}, | |
slider(direction) { | |
var activeClass = this.state[direction + 'Active'] ? "slider-active" : ""; | |
var style = {}; | |
style[direction] = this.state[direction]; | |
style['margin' + changeCase.upperCaseFirst(direction)] = -8; | |
return ( | |
<div ref={direction + "Slider"} className={"slider-handle " + activeClass} | |
style={style}> | |
<Glyphicon glyph="tag" className="rotate45" /> | |
</div> | |
); | |
}, | |
rightSlider() { | |
return { | |
rightNumberContainer: this.numberContainerFor('right'), | |
rightSlider: this.slider('right'), | |
sliderStyle: { | |
left: this.state.left, | |
width: this.props.width - this.state.left - this.state.right | |
} | |
} | |
}, | |
nullRightSlider() { | |
return { | |
rightNumberContainer: null, | |
rightSlider: null, | |
sliderStyle: { | |
left: 0, | |
width: this.state.left | |
} | |
} | |
}, | |
render() { | |
var leftActive = this.state.leftActive ? "slider-active" : ""; | |
var leftNumberContainer = this.numberContainerFor('left'); | |
var {rightNumberContainer, rightSlider, sliderStyle} = this.props.numberOfSliders === 2 ? this.rightSlider() : this.nullRightSlider(); | |
return ( | |
<div className="slider-container"> | |
{leftNumberContainer} | |
<div className="slider-control-container" style={{width: this.props.width}}> | |
<div className="full-slider" | |
ref="sliderBar" | |
onClick={this.handleSliderClick} style={{width: this.props.width}}> | |
<div className="slider" style={sliderStyle} /> | |
</div> | |
<div className="handle-container" style={{width: this.props.width}}> | |
{this.slider('left')} | |
{rightSlider} | |
</div> | |
</div> | |
{rightNumberContainer} | |
</div> | |
) | |
} | |
}); | |
module.exports = Slider; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
.slider-container { | |
display: inline-block; | |
} | |
.numerical-container { | |
display: inline-block; | |
vertical-align: top; | |
font-size: 10px; | |
max-width: 150px; | |
padding: 5px; | |
} | |
.numerical-container, .slider-input { | |
border-radius: 5px; | |
} | |
.slider-input { | |
width: 100%; | |
} | |
.slider-control-container { | |
margin: 10px; | |
display: inline-block; | |
} | |
.full-slider { | |
height: 8px; | |
position: relative; | |
border: 1px solid @brand-info; | |
border-radius: 5px; | |
} | |
.slider { | |
height: 7px; | |
position: absolute; | |
background: @brand-primary; | |
border-radius: 5px; | |
} | |
.handle-container { | |
position: relative; | |
height: 20px; | |
} | |
.slider-handle { | |
font-size: 16px; | |
color: @btn-success-bg; | |
position: absolute; | |
width: 16px; | |
height: 16px; | |
& > span { | |
pointer-events: none; | |
} | |
} | |
.slider-active { | |
color: @btn-success-border; | |
} | |
.rotate45 { | |
transform: rotate(45deg); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment