d2-ui
Version:
585 lines (512 loc) • 16.9 kB
JSX
import React from 'react';
import ReactDOM from 'react-dom';
import StylePropable from './mixins/style-propable';
import Transitions from './styles/transitions';
import FocusRipple from './ripples/focus-ripple';
import getMuiTheme from './styles/getMuiTheme';
import autoPrefix from './styles/auto-prefix';
/**
* Verifies min/max range.
* @param {Object} props Properties of the React component.
* @param {String} propName Name of the property to validate.
* @param {String} componentName Name of the component whose property is being validated.
* @returns {Object} Returns an Error if min >= max otherwise null.
*/
let minMaxPropType = (props, propName, componentName) => {
let error = React.PropTypes.number(props, propName, componentName);
if (error !== null) return error;
if (props.min >= props.max) {
let errorMsg = (propName === 'min') ? 'min should be less than max' : 'max should be greater than min';
return new Error(errorMsg);
}
};
/**
* Verifies value is within the min/max range.
* @param {Object} props Properties of the React component.
* @param {String} propName Name of the property to validate.
* @param {String} componentName Name of the component whose property is being validated.
* @returns {Object} Returns an Error if the value is not within the range otherwise null.
*/
let valueInRangePropType = (props, propName, componentName) => {
let error = React.PropTypes.number(props, propName, componentName);
if (error !== null) return error;
let value = props[propName];
if (value < props.min || props.max < value) {
return new Error(propName + ' should be within the range specified by min and max');
}
};
const Slider = React.createClass({
propTypes: {
/**
* The default value of the slider.
*/
defaultValue: valueInRangePropType,
/**
* Describe the slider.
*/
description: React.PropTypes.string,
/**
* Disables focus ripple if set to true.
*/
disableFocusRipple: React.PropTypes.bool,
/**
* If true, the slider will not be interactable.
*/
disabled: React.PropTypes.bool,
/**
* An error message for the slider.
*/
error: React.PropTypes.string,
/**
* The maximum value the slider can slide to on
* a scale from 0 to 1 inclusive. Cannot be equal to min.
*/
max: minMaxPropType,
/**
* The minimum value the slider can slide to on a scale
* from 0 to 1 inclusive. Cannot be equal to max.
*/
min: minMaxPropType,
/**
* The name of the slider. Behaves like the name attribute
* of an input element.
*/
name: React.PropTypes.string,
/**
* Callback function that is fired when the focus has left the slider.
*/
onBlur: React.PropTypes.func,
/**
* Callback function that is fired when the user changes the slider's value.
*/
onChange: React.PropTypes.func,
/**
* Callback function that is fired when the slider has begun to move.
*/
onDragStart: React.PropTypes.func,
/**
* Callback function that is fried when the slide has stopped moving.
*/
onDragStop: React.PropTypes.func,
/**
* Callback fired when the user has focused on the slider.
*/
onFocus: React.PropTypes.func,
/**
* Whether or not the slider is required in a form.
*/
required: React.PropTypes.bool,
/**
* The granularity the slider can step through values.
*/
step: React.PropTypes.number,
/**
* Override the inline-styles of the root element.
*/
style: React.PropTypes.object,
/**
* The value of the slider.
*/
value: valueInRangePropType,
},
contextTypes: {
muiTheme: React.PropTypes.object,
},
//for passing default theme context to children
childContextTypes: {
muiTheme: React.PropTypes.object,
},
mixins: [
StylePropable,
],
getDefaultProps() {
return {
disabled: false,
disableFocusRipple: false,
max: 1,
min: 0,
required: true,
step: 0.01,
style: {},
};
},
getInitialState() {
let value = this.props.value;
if (value === undefined) {
value = this.props.defaultValue !== undefined ? this.props.defaultValue : this.props.min;
}
let percent = (value - this.props.min) / (this.props.max - this.props.min);
if (isNaN(percent)) percent = 0;
return {
active: false,
dragging: false,
focused: false,
hovered: false,
percent: percent,
value: value,
muiTheme: this.context.muiTheme || getMuiTheme(),
};
},
getChildContext() {
return {
muiTheme: this.state.muiTheme,
};
},
componentWillReceiveProps(nextProps, nextContext) {
let newMuiTheme = nextContext.muiTheme ? nextContext.muiTheme : this.state.muiTheme;
this.setState({muiTheme: newMuiTheme});
if (nextProps.value !== undefined && !this.state.dragging) {
this.setValue(nextProps.value);
}
},
getTheme() {
return this.state.muiTheme.slider;
},
getStyles() {
let fillGutter = this.getTheme().handleSize / 2;
let disabledGutter = this.getTheme().trackSize + this.getTheme().handleSizeDisabled / 2;
let calcDisabledSpacing = this.props.disabled ? ' - ' + disabledGutter + 'px' : '';
let styles = {
root: {
touchCallout: 'none',
userSelect: 'none',
cursor: 'default',
height: this.getTheme().handleSizeActive,
position: 'relative',
marginTop: 24,
marginBottom: 48,
},
track: {
position: 'absolute',
top: (this.getTheme().handleSizeActive - this.getTheme().trackSize) / 2,
left: 0,
width: '100%',
height: this.getTheme().trackSize,
},
filledAndRemaining: {
position: 'absolute',
top: 0,
height: '100%',
transition: Transitions.easeOut(null, 'margin'),
},
handle: {
boxSizing: 'border-box',
position: 'absolute',
cursor: 'pointer',
pointerEvents: 'inherit',
top: 0,
left: '0%',
zIndex: 1,
margin: (this.getTheme().trackSize / 2) + 'px 0 0 0',
width: this.getTheme().handleSize,
height: this.getTheme().handleSize,
backgroundColor: this.getTheme().selectionColor,
backgroundClip: 'padding-box',
border: '0px solid transparent',
borderRadius: '50%',
transform: 'translate(-50%, -50%)',
transition:
Transitions.easeOut('450ms', 'background') + ',' +
Transitions.easeOut('450ms', 'border-color') + ',' +
Transitions.easeOut('450ms', 'width') + ',' +
Transitions.easeOut('450ms', 'height'),
overflow: 'visible',
},
handleWhenDisabled: {
boxSizing: 'content-box',
cursor: 'not-allowed',
backgroundColor: this.getTheme().trackColor,
width: this.getTheme().handleSizeDisabled,
height: this.getTheme().handleSizeDisabled,
border: 'none',
},
handleWhenPercentZero: {
border: this.getTheme().trackSize + 'px solid ' + this.getTheme().handleColorZero,
backgroundColor: this.getTheme().handleFillColor,
boxShadow: 'none',
},
handleWhenPercentZeroAndDisabled: {
cursor: 'not-allowed',
width: this.getTheme().handleSizeDisabled,
height: this.getTheme().handleSizeDisabled,
},
handleWhenPercentZeroAndFocused: {
border: this.getTheme().trackSize + 'px solid ' +
this.getTheme().trackColorSelected,
},
handleWhenActive: {
width: this.getTheme().handleSizeActive,
height: this.getTheme().handleSizeActive,
},
ripple: {
height: this.getTheme().handleSize,
width: this.getTheme().handleSize,
overflow: 'visible',
},
rippleWhenPercentZero: {
top: -this.getTheme().trackSize,
left: -this.getTheme().trackSize,
},
rippleInner: {
height: '300%',
width: '300%',
top: -this.getTheme().handleSize,
left: -this.getTheme().handleSize,
},
};
styles.filled = this.mergeStyles(styles.filledAndRemaining, {
left: 0,
backgroundColor: (this.props.disabled) ?
this.getTheme().trackColor :
this.getTheme().selectionColor,
marginRight: fillGutter,
width: 'calc(' + (this.state.percent * 100) + '%' + calcDisabledSpacing + ')',
});
styles.remaining = this.mergeStyles(styles.filledAndRemaining, {
right: 0,
backgroundColor: this.getTheme().trackColor,
marginLeft: fillGutter,
width: 'calc(' + ((1 - this.state.percent) * 100) + '%' + calcDisabledSpacing + ')',
});
return styles;
},
// Needed to prevent text selection when dragging the slider handler.
// In the future, we should consider use <input type="range"> to avoid
// similar issues.
_toggleSelection(value) {
let body = document.getElementsByTagName('body')[0];
autoPrefix.set(body.style, 'userSelect', value, this.state.muiTheme);
},
_onHandleTouchStart(e) {
if (document) {
document.addEventListener('touchmove', this._dragTouchHandler, false);
document.addEventListener('touchup', this._dragTouchEndHandler, false);
document.addEventListener('touchend', this._dragTouchEndHandler, false);
document.addEventListener('touchcancel', this._dragTouchEndHandler, false);
}
this._onDragStart(e);
},
_onHandleMouseDown(e) {
if (document) {
document.addEventListener('mousemove', this._dragHandler, false);
document.addEventListener('mouseup', this._dragEndHandler, false);
this._toggleSelection('none');
}
this._onDragStart(e);
},
_dragHandler(e) {
if (this._dragRunning) {
return;
}
this._dragRunning = true;
requestAnimationFrame(() => {
this._onDragUpdate(e, e.clientX - this._getTrackLeft());
this._dragRunning = false;
});
},
_dragTouchHandler(e) {
if (this._dragRunning) {
return;
}
this._dragRunning = true;
requestAnimationFrame(() => {
this._onDragUpdate(e, e.touches[0].clientX - this._getTrackLeft());
this._dragRunning = false;
});
},
_dragEndHandler(e) {
if (document) {
document.removeEventListener('mousemove', this._dragHandler, false);
document.removeEventListener('mouseup', this._dragEndHandler, false);
this._toggleSelection('');
}
this._onDragStop(e);
},
_dragTouchEndHandler(e) {
if (document) {
document.removeEventListener('touchmove', this._dragTouchHandler, false);
document.removeEventListener('touchup', this._dragTouchEndHandler, false);
document.removeEventListener('touchend', this._dragTouchEndHandler, false);
document.removeEventListener('touchcancel', this._dragTouchEndHandler, false);
}
this._onDragStop(e);
},
getValue() {
return this.state.value;
},
setValue(i) {
// calculate percentage
let percent = (i - this.props.min) / (this.props.max - this.props.min);
if (isNaN(percent)) percent = 0;
// update state
this.setState({
value: i,
percent: percent,
});
},
getPercent() {
return this.state.percent;
},
setPercent(percent, callback) {
let value = this._alignValue(this._percentToValue(percent));
let {min, max} = this.props;
let alignedPercent = (value - min) / (max - min);
if (this.state.value !== value) {
this.setState({value: value, percent: alignedPercent}, callback);
}
},
clearValue() {
this.setValue(this.props.min);
},
_alignValue(val) {
let {step, min} = this.props;
let alignValue = Math.round((val - min) / step) * step + min;
return parseFloat(alignValue.toFixed(5));
},
_onFocus(e) {
this.setState({focused: true});
if (this.props.onFocus) this.props.onFocus(e);
},
_onBlur(e) {
this.setState({focused: false, active: false});
if (this.props.onBlur) this.props.onBlur(e);
},
_onMouseDown(e) {
if (!this.props.disabled) this._pos = e.clientX;
},
_onMouseEnter() {
this.setState({hovered: true});
},
_onMouseLeave() {
this.setState({hovered: false});
},
_getTrackLeft() {
return ReactDOM.findDOMNode(this.refs.track).getBoundingClientRect().left;
},
_onMouseUp(e) {
if (!this.props.disabled) this.setState({active: false});
if (!this.state.dragging && Math.abs(this._pos - e.clientX) < 5) {
let pos = e.clientX - this._getTrackLeft();
this._dragX(e, pos);
}
this._pos = undefined;
},
_onDragStart(e) {
this.setState({
dragging: true,
active: true,
});
if (this.props.onDragStart) this.props.onDragStart(e);
},
_onDragStop(e) {
this.setState({
dragging: false,
active: false,
});
if (this.props.onDragStop) this.props.onDragStop(e);
},
_onDragUpdate(e, pos) {
if (!this.state.dragging) return;
if (!this.props.disabled) this._dragX(e, pos);
},
_dragX(e, pos) {
let max = ReactDOM.findDOMNode(this.refs.track).clientWidth;
if (pos < 0) pos = 0; else if (pos > max) pos = max;
this._updateWithChangeEvent(e, pos / max);
},
_updateWithChangeEvent(e, percent) {
this.setPercent(percent, () => {
if (this.props.onChange) this.props.onChange(e, this.state.value);
});
},
_percentToValue(percent) {
return percent * (this.props.max - this.props.min) + this.props.min;
},
render() {
let {...others} = this.props;
let percent = this.state.percent;
if (percent > 1) percent = 1; else if (percent < 0) percent = 0;
let styles = this.getStyles();
const sliderStyles = this.mergeStyles(styles.root, this.props.style);
const handleStyles = percent === 0 ? this.mergeStyles(
styles.handle,
styles.handleWhenPercentZero,
this.state.active && styles.handleWhenActive,
this.state.focused && {outline: 'none'},
(this.state.hovered || this.state.focused) && !this.props.disabled
&& styles.handleWhenPercentZeroAndFocused,
this.props.disabled && styles.handleWhenPercentZeroAndDisabled
) : this.mergeStyles(
styles.handle,
this.state.active && styles.handleWhenActive,
this.state.focused && {outline: 'none'},
this.props.disabled && styles.handleWhenDisabled,
{
left: (percent * 100) + '%',
}
);
let rippleStyle = this.mergeStyles(
styles.ripple,
percent === 0 && styles.rippleWhenPercentZero
);
let remainingStyles = styles.remaining;
if ((this.state.hovered || this.state.focused) && !this.props.disabled) {
remainingStyles.backgroundColor = this.getTheme().trackColorSelected;
}
let rippleShowCondition = (this.state.hovered || this.state.focused) && !this.state.active;
let rippleColor = this.state.percent === 0 ? this.getTheme().handleColorZero : this.getTheme().rippleColor;
let focusRipple;
if (!this.props.disabled && !this.props.disableFocusRipple) {
focusRipple = (
<FocusRipple
ref="focusRipple"
key="focusRipple"
style={this.mergeStyles(rippleStyle)}
innerStyle={styles.rippleInner}
show={rippleShowCondition}
muiTheme={this.state.muiTheme}
color={rippleColor}
/>
);
}
let handleDragProps = {};
if (!this.props.disabled) {
handleDragProps = {
onTouchStart: this._onHandleTouchStart,
onMouseDown: this._onHandleMouseDown,
};
}
return (
<div {...others } style={this.prepareStyles(this.props.style)}>
<span>{this.props.description}</span>
<span>{this.props.error}</span>
<div
style={this.prepareStyles(sliderStyles)}
onFocus={this._onFocus}
onBlur={this._onBlur}
onMouseDown={this._onMouseDown}
onMouseEnter={this._onMouseEnter}
onMouseLeave={this._onMouseLeave}
onMouseUp={this._onMouseUp}
>
<div ref="track" style={this.prepareStyles(styles.track)}>
<div style={this.prepareStyles(styles.filled)}></div>
<div style={this.prepareStyles(remainingStyles)}></div>
<div style={this.prepareStyles(handleStyles)} tabIndex={0} {...handleDragProps}>
{focusRipple}
</div>
</div>
</div>
<input ref="input" type="hidden"
name={this.props.name}
value={this.state.value}
required={this.props.required}
min={this.props.min}
max={this.props.max}
step={this.props.step}
/>
</div>
);
},
});
export default Slider;