Skip to content

Instantly share code, notes, and snippets.

@bobpace
Created March 18, 2015 23:28
Show Gist options
  • Save bobpace/9c06776ee079671f4c24 to your computer and use it in GitHub Desktop.
Save bobpace/9c06776ee079671f4c24 to your computer and use it in GitHub Desktop.
React Slider with Rx Observable implementation of drag and drop
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;
.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