diff --git a/components/vc-slider/assets/index.less b/components/vc-slider/assets/index.less new file mode 100644 index 000000000..1777d2cc8 --- /dev/null +++ b/components/vc-slider/assets/index.less @@ -0,0 +1,302 @@ +@prefixClass: rc-slider; + +@disabledColor: #ccc; +@border-radius-base: 6px; +@primary-color: #2db7f5; +@tooltip-color: #fff; +@tooltip-bg: tint(#666, 4%); +@tooltip-arrow-width: 4px; +@tooltip-distance: @tooltip-arrow-width+4; +@tooltip-arrow-color: @tooltip-bg; +@ease-out-quint : cubic-bezier(0.23, 1, 0.32, 1); +@ease-in-quint : cubic-bezier(0.755, 0.05, 0.855, 0.06); + +.borderBox() { + box-sizing: border-box; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); // remove tap highlight color for mobile safari + + * { + box-sizing: border-box; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); // remove tap highlight color for mobile safari + } +} + +.@{prefixClass} { + position: relative; + height: 14px; + padding: 5px 0; + width: 100%; + border-radius: @border-radius-base; + touch-action: none; + .borderBox(); + + &-rail { + position: absolute; + width: 100%; + background-color: #e9e9e9; + height: 4px; + border-radius: @border-radius-base; + } + + &-track { + position: absolute; + left: 0; + height: 4px; + border-radius: @border-radius-base; + background-color: tint(@primary-color, 60%); + } + + &-handle { + position: absolute; + margin-left: -7px; + margin-top: -5px; + width: 14px; + height: 14px; + cursor: pointer; + cursor: -webkit-grab; + cursor: grab; + border-radius: 50%; + border: solid 2px tint(@primary-color, 50%); + background-color: #fff; + touch-action: pan-x; + + &:focus { + border-color: tint(@primary-color, 20%); + box-shadow: 0 0 0 5px tint(@primary-color, 50%); + outline: none; + } + + &-click-focused:focus { + border-color: tint(@primary-color, 50%); + box-shadow: unset; + } + + &:hover { + border-color: tint(@primary-color, 20%); + } + + &:active { + border-color: tint(@primary-color, 20%); + box-shadow: 0 0 5px tint(@primary-color, 20%); + cursor: -webkit-grabbing; + cursor: grabbing; + } + } + + &-mark { + position: absolute; + top: 18px; + left: 0; + width: 100%; + font-size: 12px; + } + + &-mark-text { + position: absolute; + display: inline-block; + vertical-align: middle; + text-align: center; + cursor: pointer; + color: #999; + + &-active { + color: #666; + } + } + + &-step { + position: absolute; + width: 100%; + height: 4px; + background: transparent; + } + + &-dot { + position: absolute; + bottom: -2px; + margin-left: -4px; + width: 8px; + height: 8px; + border: 2px solid #e9e9e9; + background-color: #fff; + cursor: pointer; + border-radius: 50%; + vertical-align: middle; + &-active { + border-color: tint(@primary-color, 50%); + } + } + + &-disabled { + background-color: #e9e9e9; + + .@{prefixClass}-track { + background-color: @disabledColor; + } + + .@{prefixClass}-handle, .@{prefixClass}-dot { + border-color: @disabledColor; + box-shadow: none; + background-color: #fff; + cursor: not-allowed; + } + + .@{prefixClass}-mark-text, .@{prefixClass}-dot { + cursor: not-allowed!important; + } + } +} + +.@{prefixClass}-vertical { + width: 14px; + height: 100%; + padding: 0 5px; + + .@{prefixClass} { + &-rail { + height: 100%; + width: 4px; + } + + &-track { + left: 5px; + bottom: 0; + width: 4px; + } + + &-handle { + margin-left: -5px; + margin-bottom: -7px; + touch-action: pan-y; + } + + &-mark { + top: 0; + left: 18px; + height: 100%; + } + + &-step { + height: 100%; + width: 4px; + } + + &-dot { + left: 2px; + margin-bottom: -4px; + &:first-child { + margin-bottom: -4px; + } + &:last-child { + margin-bottom: -4px; + } + } + } +} + +.motion-common() { + animation-duration: .3s; + animation-fill-mode: both; + display: block !important; +} + +.make-motion(@className, @keyframeName) { + .@{className}-enter, .@{className}-appear { + .motion-common(); + animation-play-state: paused; + } + .@{className}-leave { + .motion-common(); + animation-play-state: paused; + } + .@{className}-enter.@{className}-enter-active, .@{className}-appear.@{className}-appear-active { + animation-name: ~"@{keyframeName}In"; + animation-play-state: running; + } + .@{className}-leave.@{className}-leave-active { + animation-name: ~"@{keyframeName}Out"; + animation-play-state: running; + } +} +.zoom-motion(@className, @keyframeName) { + .make-motion(@className, @keyframeName); + .@{className}-enter, .@{className}-appear { + transform: scale(0, 0); // need this by yiminghe + animation-timing-function: @ease-out-quint; + } + .@{className}-leave { + animation-timing-function: @ease-in-quint; + } +} +.zoom-motion(rc-slider-tooltip-zoom-down, rcSliderTooltipZoomDown); + +@keyframes rcSliderTooltipZoomDownIn { + 0% { + opacity: 0; + transform-origin: 50% 100%; + transform: scale(0, 0); + } + 100% { + transform-origin: 50% 100%; + transform: scale(1, 1); + } +} + +@keyframes rcSliderTooltipZoomDownOut { + 0% { + transform-origin: 50% 100%; + transform: scale(1, 1); + } + 100% { + opacity: 0; + transform-origin: 50% 100%; + transform: scale(0, 0); + } +} + +.@{prefixClass}-tooltip { + position: absolute; + left: -9999px; + top: -9999px; + visibility: visible; + + .borderBox(); + + &-hidden { + display: none; + } + + &-placement-top { + padding: @tooltip-arrow-width 0 @tooltip-distance 0; + } + + &-inner { + padding: 6px 2px; + min-width: 24px; + height: 24px; + font-size: 12px; + line-height: 1; + color: @tooltip-color; + text-align: center; + text-decoration: none; + background-color: @tooltip-bg; + border-radius: @border-radius-base; + box-shadow: 0 0 4px #d9d9d9; + } + + &-arrow { + position: absolute; + width: 0; + height: 0; + border-color: transparent; + border-style: solid; + } + + &-placement-top &-arrow { + bottom: @tooltip-distance - @tooltip-arrow-width; + left: 50%; + margin-left: -@tooltip-arrow-width; + border-width: @tooltip-arrow-width @tooltip-arrow-width 0; + border-top-color: @tooltip-arrow-color; + } +} diff --git a/components/vc-slider/src/Handle.vue b/components/vc-slider/src/Handle.vue new file mode 100644 index 000000000..918ccc1e7 --- /dev/null +++ b/components/vc-slider/src/Handle.vue @@ -0,0 +1,109 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import addEventListener from 'rc-util/lib/Dom/addEventListener'; + +export default class Handle extends React.Component { + state = { + clickFocused: false, + } + + componentDidMount() { + // mouseup won't trigger if mouse moved out of handle, + // so we listen on document here. + this.onMouseUpListener = addEventListener(document, 'mouseup', this.handleMouseUp); + } + + componentWillUnmount() { + if (this.onMouseUpListener) { + this.onMouseUpListener.remove(); + } + } + + setClickFocus(focused) { + this.setState({ clickFocused: focused }); + } + + handleMouseUp = () => { + if (document.activeElement === this.handle) { + this.setClickFocus(true); + } + } + + handleBlur = () => { + this.setClickFocus(false); + } + + handleKeyDown = () => { + this.setClickFocus(false); + } + + clickFocus() { + this.setClickFocus(true); + this.focus(); + } + + focus() { + this.handle.focus(); + } + + blur() { + this.handle.blur(); + } + + render() { + const { + prefixCls, vertical, offset, style, disabled, min, max, value, tabIndex, ...restProps, + } = this.props; + + const className = classNames( + this.props.className, + { + [`${prefixCls}-handle-click-focused`]: this.state.clickFocused, + } + ); + + const postionStyle = vertical ? { bottom: `${offset}%` } : { left: `${offset}%` }; + const elStyle = { + ...style, + ...postionStyle, + }; + let ariaProps = {}; + if (value !== undefined) { + ariaProps = { + ...ariaProps, + 'aria-valuemin': min, + 'aria-valuemax': max, + 'aria-valuenow': value, + 'aria-disabled': !!disabled, + }; + } + + return ( +
(this.handle = node)} + role="slider" + tabIndex= {disabled ? null : (tabIndex || 0)} + {...ariaProps} + {...restProps} + className={className} + style={elStyle} + onBlur={this.handleBlur} + onKeyDown={this.handleKeyDown} + /> + ); + } +} + +Handle.propTypes = { + prefixCls: PropTypes.string, + className: PropTypes.string, + vertical: PropTypes.bool, + offset: PropTypes.number, + style: PropTypes.object, + disabled: PropTypes.bool, + min: PropTypes.number, + max: PropTypes.number, + value: PropTypes.number, + tabIndex: PropTypes.number, +}; diff --git a/components/vc-slider/src/Range.vue b/components/vc-slider/src/Range.vue new file mode 100644 index 000000000..03876b052 --- /dev/null +++ b/components/vc-slider/src/Range.vue @@ -0,0 +1,382 @@ +/* eslint-disable react/prop-types */ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import shallowEqual from 'shallowequal'; +import Track from './common/Track'; +import createSlider from './common/createSlider'; +import * as utils from './utils'; + +class Range extends React.Component { + static displayName = 'Range'; + static propTypes = { + defaultValue: PropTypes.arrayOf(PropTypes.number), + value: PropTypes.arrayOf(PropTypes.number), + count: PropTypes.number, + pushable: PropTypes.oneOfType([ + PropTypes.bool, + PropTypes.number, + ]), + allowCross: PropTypes.bool, + disabled: PropTypes.bool, + tabIndex: PropTypes.arrayOf(PropTypes.number), + }; + + static defaultProps = { + count: 1, + allowCross: true, + pushable: false, + tabIndex: [], + }; + + constructor(props) { + super(props); + + const { count, min, max } = props; + const initialValue = Array.apply(null, Array(count + 1)) + .map(() => min); + const defaultValue = 'defaultValue' in props ? + props.defaultValue : initialValue; + const value = props.value !== undefined ? + props.value : defaultValue; + const bounds = value.map((v, i) => this.trimAlignValue(v, i)); + const recent = bounds[0] === max ? 0 : bounds.length - 1; + + this.state = { + handle: null, + recent, + bounds, + }; + } + + componentWillReceiveProps(nextProps) { + if (!('value' in nextProps || 'min' in nextProps || 'max' in nextProps)) return; + if (this.props.min === nextProps.min && + this.props.max === nextProps.max && + shallowEqual(this.props.value, nextProps.value)) { + return; + } + + const { bounds } = this.state; + const value = nextProps.value || bounds; + const nextBounds = value.map((v, i) => this.trimAlignValue(v, i, nextProps)); + if (nextBounds.length === bounds.length && nextBounds.every((v, i) => v === bounds[i])) return; + + this.setState({ bounds: nextBounds }); + + if (bounds.some(v => utils.isValueOutOfRange(v, nextProps))) { + const newValues = value.map((v) => { + return utils.ensureValueInRange(v, nextProps); + }); + this.props.onChange(newValues); + } + } + + onChange(state) { + const props = this.props; + const isNotControlled = !('value' in props); + if (isNotControlled) { + this.setState(state); + } else if (state.handle !== undefined) { + this.setState({ handle: state.handle }); + } + + const data = { ...this.state, ...state }; + const changedValue = data.bounds; + props.onChange(changedValue); + } + + onStart(position) { + const props = this.props; + const state = this.state; + const bounds = this.getValue(); + props.onBeforeChange(bounds); + + const value = this.calcValueByPos(position); + this.startValue = value; + this.startPosition = position; + + const closestBound = this.getClosestBound(value); + this.prevMovedHandleIndex = this.getBoundNeedMoving(value, closestBound); + + this.setState({ + handle: this.prevMovedHandleIndex, + recent: this.prevMovedHandleIndex, + }); + + const prevValue = bounds[this.prevMovedHandleIndex]; + if (value === prevValue) return; + + const nextBounds = [...state.bounds]; + nextBounds[this.prevMovedHandleIndex] = value; + this.onChange({ bounds: nextBounds }); + } + + onEnd = () => { + this.removeDocumentEvents(); + this.props.onAfterChange(this.getValue()); + } + + onMove(e, position) { + utils.pauseEvent(e); + const state = this.state; + + const value = this.calcValueByPos(position); + const oldValue = state.bounds[state.handle]; + if (value === oldValue) return; + + this.moveTo(value); + } + + onKeyboard(e) { + const valueMutator = utils.getKeyboardValueMutator(e); + + if (valueMutator) { + utils.pauseEvent(e); + const { state, props } = this; + const { bounds, handle } = state; + const oldValue = bounds[handle]; + const mutatedValue = valueMutator(oldValue, props); + const value = this.trimAlignValue(mutatedValue); + if (value === oldValue) return; + const isFromKeyboardEvent = true; + this.moveTo(value, isFromKeyboardEvent); + } + } + + getValue() { + return this.state.bounds; + } + + getClosestBound(value) { + const { bounds } = this.state; + let closestBound = 0; + for (let i = 1; i < bounds.length - 1; ++i) { + if (value > bounds[i]) { closestBound = i; } + } + if (Math.abs(bounds[closestBound + 1] - value) < Math.abs(bounds[closestBound] - value)) { + closestBound = closestBound + 1; + } + return closestBound; + } + + getBoundNeedMoving(value, closestBound) { + const { bounds, recent } = this.state; + let boundNeedMoving = closestBound; + const isAtTheSamePoint = (bounds[closestBound + 1] === bounds[closestBound]); + + if (isAtTheSamePoint && bounds[recent] === bounds[closestBound]) { + boundNeedMoving = recent; + } + + if (isAtTheSamePoint && (value !== bounds[closestBound + 1])) { + boundNeedMoving = value < bounds[closestBound + 1] ? closestBound : closestBound + 1; + } + return boundNeedMoving; + } + + getLowerBound() { + return this.state.bounds[0]; + } + + getUpperBound() { + const { bounds } = this.state; + return bounds[bounds.length - 1]; + } + + /** + * Returns an array of possible slider points, taking into account both + * `marks` and `step`. The result is cached. + */ + getPoints() { + const { marks, step, min, max } = this.props; + const cache = this._getPointsCache; + if (!cache || cache.marks !== marks || cache.step !== step) { + const pointsObject = { ...marks }; + if (step !== null) { + for (let point = min; point <= max; point += step) { + pointsObject[point] = point; + } + } + const points = Object.keys(pointsObject).map(parseFloat); + points.sort((a, b) => a - b); + this._getPointsCache = { marks, step, points }; + } + return this._getPointsCache.points; + } + + moveTo(value, isFromKeyboardEvent) { + const { state, props } = this; + const nextBounds = [...state.bounds]; + nextBounds[state.handle] = value; + let nextHandle = state.handle; + if (props.pushable !== false) { + this.pushSurroundingHandles(nextBounds, nextHandle); + } else if (props.allowCross) { + nextBounds.sort((a, b) => a - b); + nextHandle = nextBounds.indexOf(value); + } + this.onChange({ + handle: nextHandle, + bounds: nextBounds, + }); + if (isFromKeyboardEvent) { + // known problem: because setState is async, + // so trigger focus will invoke handler's onEnd and another handler's onStart too early, + // cause onBeforeChange and onAfterChange receive wrong value. + // here use setState callback to hack,but not elegant + this.setState({}, () => { + this.handlesRefs[nextHandle].focus(); + }); + } + } + + pushSurroundingHandles(bounds, handle) { + const value = bounds[handle]; + let { pushable: threshold } = this.props; + threshold = Number(threshold); + + let direction = 0; + if (bounds[handle + 1] - value < threshold) { + direction = +1; // push to right + } + if (value - bounds[handle - 1] < threshold) { + direction = -1; // push to left + } + + if (direction === 0) { return; } + + const nextHandle = handle + direction; + const diffToNext = direction * (bounds[nextHandle] - value); + if (!this.pushHandle(bounds, nextHandle, direction, threshold - diffToNext)) { + // revert to original value if pushing is impossible + bounds[handle] = bounds[nextHandle] - (direction * threshold); + } + } + + pushHandle(bounds, handle, direction, amount) { + const originalValue = bounds[handle]; + let currentValue = bounds[handle]; + while (direction * (currentValue - originalValue) < amount) { + if (!this.pushHandleOnePoint(bounds, handle, direction)) { + // can't push handle enough to create the needed `amount` gap, so we + // revert its position to the original value + bounds[handle] = originalValue; + return false; + } + currentValue = bounds[handle]; + } + // the handle was pushed enough to create the needed `amount` gap + return true; + } + + pushHandleOnePoint(bounds, handle, direction) { + const points = this.getPoints(); + const pointIndex = points.indexOf(bounds[handle]); + const nextPointIndex = pointIndex + direction; + if (nextPointIndex >= points.length || nextPointIndex < 0) { + // reached the minimum or maximum available point, can't push anymore + return false; + } + const nextHandle = handle + direction; + const nextValue = points[nextPointIndex]; + const { pushable: threshold } = this.props; + const diffToNext = direction * (bounds[nextHandle] - nextValue); + if (!this.pushHandle(bounds, nextHandle, direction, threshold - diffToNext)) { + // couldn't push next handle, so we won't push this one either + return false; + } + // push the handle + bounds[handle] = nextValue; + return true; + } + + trimAlignValue(v, handle, nextProps = {}) { + const mergedProps = { ...this.props, ...nextProps }; + const valInRange = utils.ensureValueInRange(v, mergedProps); + const valNotConflict = this.ensureValueNotConflict(handle, valInRange, mergedProps); + return utils.ensureValuePrecision(valNotConflict, mergedProps); + } + + ensureValueNotConflict(handle, val, { allowCross, pushable: thershold }) { + const state = this.state || {}; + const { bounds } = state; + handle = handle === undefined ? state.handle : handle; + thershold = Number(thershold); + /* eslint-disable eqeqeq */ + if (!allowCross && handle != null && bounds !== undefined) { + if (handle > 0 && val <= (bounds[handle - 1] + thershold)) { + return bounds[handle - 1] + thershold; + } + if (handle < bounds.length - 1 && val >= (bounds[handle + 1] - thershold)) { + return bounds[handle + 1] - thershold; + } + } + /* eslint-enable eqeqeq */ + return val; + } + + render() { + const { + handle, + bounds, + } = this.state; + const { + prefixCls, + vertical, + included, + disabled, + min, + max, + handle: handleGenerator, + trackStyle, + handleStyle, + tabIndex, + } = this.props; + + const offsets = bounds.map(v => this.calcOffset(v)); + + const handleClassName = `${prefixCls}-handle`; + const handles = bounds.map((v, i) => handleGenerator({ + className: classNames({ + [handleClassName]: true, + [`${handleClassName}-${i + 1}`]: true, + }), + prefixCls, + vertical, + offset: offsets[i], + value: v, + dragging: handle === i, + index: i, + tabIndex: tabIndex[i] || 0, + min, + max, + disabled, + style: handleStyle[i], + ref: h => this.saveHandle(i, h), + })); + + const tracks = bounds.slice(0, -1).map((_, index) => { + const i = index + 1; + const trackClassName = classNames({ + [`${prefixCls}-track`]: true, + [`${prefixCls}-track-${i}`]: true, + }); + return ( + + ); + }); + + return { tracks, handles }; + } +} + +export default createSlider(Range); diff --git a/components/vc-slider/src/Slider.vue b/components/vc-slider/src/Slider.vue new file mode 100644 index 000000000..b0af9d600 --- /dev/null +++ b/components/vc-slider/src/Slider.vue @@ -0,0 +1,191 @@ +/* eslint-disable react/prop-types */ +import React from 'react' +import PropTypes from 'prop-types' +import warning from 'warning' +import Track from './common/Track' +import createSlider from './common/createSlider' +import * as utils from './utils' + +class Slider extends React.Component { + static propTypes = { + defaultValue: PropTypes.number, + value: PropTypes.number, + disabled: PropTypes.bool, + autoFocus: PropTypes.bool, + tabIndex: PropTypes.number, + }; + + constructor (props) { + super(props) + + const defaultValue = props.defaultValue !== undefined + ? props.defaultValue : props.min + const value = props.value !== undefined + ? props.value : defaultValue + + this.state = { + value: this.trimAlignValue(value), + dragging: false, + } + if (process.env.NODE_ENV !== 'production') { + warning( + !('minimumTrackStyle' in props), + 'minimumTrackStyle will be deprecate, please use trackStyle instead.' + ) + warning( + !('maximumTrackStyle' in props), + 'maximumTrackStyle will be deprecate, please use railStyle instead.' + ) + } + } + + componentDidMount () { + const { autoFocus, disabled } = this.props + if (autoFocus && !disabled) { + this.focus() + } + } + + componentWillReceiveProps (nextProps) { + if (!('value' in nextProps || 'min' in nextProps || 'max' in nextProps)) return + + const prevValue = this.state.value + const value = nextProps.value !== undefined + ? nextProps.value : prevValue + const nextValue = this.trimAlignValue(value, nextProps) + if (nextValue === prevValue) return + + this.setState({ value: nextValue }) + if (utils.isValueOutOfRange(value, nextProps)) { + this.props.onChange(nextValue) + } + } + + onChange (state) { + const props = this.props + const isNotControlled = !('value' in props) + if (isNotControlled) { + this.setState(state) + } + + const changedValue = state.value + props.onChange(changedValue) + } + + onStart (position) { + this.setState({ dragging: true }) + const props = this.props + const prevValue = this.getValue() + props.onBeforeChange(prevValue) + + const value = this.calcValueByPos(position) + this.startValue = value + this.startPosition = position + + if (value === prevValue) return + + this.prevMovedHandleIndex = 0 + + this.onChange({ value }) + } + + onEnd = () => { + this.setState({ dragging: false }) + this.removeDocumentEvents() + this.props.onAfterChange(this.getValue()) + } + + onMove (e, position) { + utils.pauseEvent(e) + const { value: oldValue } = this.state + const value = this.calcValueByPos(position) + if (value === oldValue) return + + this.onChange({ value }) + } + + onKeyboard (e) { + const valueMutator = utils.getKeyboardValueMutator(e) + + if (valueMutator) { + utils.pauseEvent(e) + const state = this.state + const oldValue = state.value + const mutatedValue = valueMutator(oldValue, this.props) + const value = this.trimAlignValue(mutatedValue) + if (value === oldValue) return + + this.onChange({ value }) + } + } + + getValue () { + return this.state.value + } + + getLowerBound () { + return this.props.min + } + + getUpperBound () { + return this.state.value + } + + trimAlignValue (v, nextProps = {}) { + const mergedProps = { ...this.props, ...nextProps } + const val = utils.ensureValueInRange(v, mergedProps) + return utils.ensureValuePrecision(val, mergedProps) + } + + render () { + const { + prefixCls, + vertical, + included, + disabled, + minimumTrackStyle, + trackStyle, + handleStyle, + tabIndex, + min, + max, + handle: handleGenerator, + } = this.props + const { value, dragging } = this.state + const offset = this.calcOffset(value) + const handle = handleGenerator({ + className: `${prefixCls}-handle`, + prefixCls, + vertical, + offset, + value, + dragging, + disabled, + min, + max, + index: 0, + tabIndex, + style: handleStyle[0] || handleStyle, + ref: h => this.saveHandle(0, h), + }) + + const _trackStyle = trackStyle[0] || trackStyle + const track = ( + + ) + + return { tracks: track, handles: handle } + } +} + +export default createSlider(Slider) diff --git a/components/vc-slider/src/common/Marks.vue b/components/vc-slider/src/common/Marks.vue new file mode 100644 index 000000000..f869ca143 --- /dev/null +++ b/components/vc-slider/src/common/Marks.vue @@ -0,0 +1,66 @@ +import React from 'react' +import classNames from 'classnames' + +const Marks = ({ + className, + vertical, + marks, + included, + upperBound, + lowerBound, + max, min, + onClickLabel, +}) => { + const marksKeys = Object.keys(marks) + const marksCount = marksKeys.length + const unit = marksCount > 1 ? 100 / (marksCount - 1) : 100 + const markWidth = unit * 0.9 + + const range = max - min + const elements = marksKeys.map(parseFloat).sort((a, b) => a - b).map(point => { + const markPoint = marks[point] + const markPointIsObject = typeof markPoint === 'object' && + !React.isValidElement(markPoint) + const markLabel = markPointIsObject ? markPoint.label : markPoint + if (!markLabel && markLabel !== 0) { + return null + } + + const isActive = (!included && point === upperBound) || + (included && point <= upperBound && point >= lowerBound) + const markClassName = classNames({ + [`${className}-text`]: true, + [`${className}-text-active`]: isActive, + }) + + const bottomStyle = { + marginBottom: '-50%', + bottom: `${(point - min) / range * 100}%`, + } + + const leftStyle = { + width: `${markWidth}%`, + marginLeft: `${-markWidth / 2}%`, + left: `${(point - min) / range * 100}%`, + } + + const style = vertical ? bottomStyle : leftStyle + const markStyle = markPointIsObject + ? { ...style, ...markPoint.style } : style + return ( + onClickLabel(e, point)} + onTouchStart={(e) => onClickLabel(e, point)} + > + {markLabel} + + ) + }) + + return
{elements}
+}; + +export default Marks diff --git a/components/vc-slider/src/common/Steps.vue b/components/vc-slider/src/common/Steps.vue new file mode 100644 index 000000000..2859a9582 --- /dev/null +++ b/components/vc-slider/src/common/Steps.vue @@ -0,0 +1,44 @@ +import React from 'react' +import classNames from 'classnames' +import warning from 'warning' + +const calcPoints = (vertical, marks, dots, step, min, max) => { + warning( + dots ? step > 0 : true, + '`Slider[step]` should be a positive number in order to make Slider[dots] work.' + ) + const points = Object.keys(marks).map(parseFloat) + if (dots) { + for (let i = min; i <= max; i = i + step) { + if (points.indexOf(i) >= 0) continue + points.push(i) + } + } + return points +} + +const Steps = ({ prefixCls, vertical, marks, dots, step, included, + lowerBound, upperBound, max, min, dotStyle, activeDotStyle }) => { + const range = max - min + const elements = calcPoints(vertical, marks, dots, step, min, max).map((point) => { + const offset = `${Math.abs(point - min) / range * 100}%` + + const isActived = (!included && point === upperBound) || + (included && point <= upperBound && point >= lowerBound) + let style = vertical ? { bottom: offset, ...dotStyle } : { left: offset, ...dotStyle } + if (isActived) { + style = { ...style, ...activeDotStyle } + } + + const pointClassName = classNames({ + [`${prefixCls}-dot`]: true, + [`${prefixCls}-dot-active`]: isActived, + }) + + return + }) + + return
{elements}
+} + +export default Steps diff --git a/components/vc-slider/src/common/Track.vue b/components/vc-slider/src/common/Track.vue new file mode 100644 index 000000000..6fb6910ac --- /dev/null +++ b/components/vc-slider/src/common/Track.vue @@ -0,0 +1,22 @@ +/* eslint-disable react/prop-types */ +import React from 'react' + +const Track = (props) => { + const { className, included, vertical, offset, length, style } = props + + const positonStyle = vertical ? { + bottom: `${offset}%`, + height: `${length}%`, + } : { + left: `${offset}%`, + width: `${length}%`, + } + + const elStyle = { + ...style, + ...positonStyle, + } + return included ?
: null +} + +export default Track diff --git a/components/vc-slider/src/common/createSlider.vue b/components/vc-slider/src/common/createSlider.vue new file mode 100644 index 000000000..9ef8ddcbe --- /dev/null +++ b/components/vc-slider/src/common/createSlider.vue @@ -0,0 +1,343 @@ +import React from 'react' +import PropTypes from 'prop-types' +import addEventListener from 'rc-util/lib/Dom/addEventListener' +import classNames from 'classnames' +import warning from 'warning' +import Steps from './Steps' +import Marks from './Marks' +import Handle from '../Handle' +import * as utils from '../utils' + +function noop () {} + +export default function createSlider (Component) { + return class ComponentEnhancer extends Component { + static displayName = `ComponentEnhancer(${Component.displayName})`; + static propTypes = { + ...Component.propTypes, + min: PropTypes.number, + max: PropTypes.number, + step: PropTypes.number, + marks: PropTypes.object, + included: PropTypes.bool, + className: PropTypes.string, + prefixCls: PropTypes.string, + disabled: PropTypes.bool, + children: PropTypes.any, + onBeforeChange: PropTypes.func, + onChange: PropTypes.func, + onAfterChange: PropTypes.func, + handle: PropTypes.func, + dots: PropTypes.bool, + vertical: PropTypes.bool, + style: PropTypes.object, + minimumTrackStyle: PropTypes.object, // just for compatibility, will be deperecate + maximumTrackStyle: PropTypes.object, // just for compatibility, will be deperecate + handleStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.arrayOf(PropTypes.object)]), + trackStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.arrayOf(PropTypes.object)]), + railStyle: PropTypes.object, + dotStyle: PropTypes.object, + activeDotStyle: PropTypes.object, + autoFocus: PropTypes.bool, + onFocus: PropTypes.func, + onBlur: PropTypes.func, + }; + + static defaultProps = { + ...Component.defaultProps, + prefixCls: 'rc-slider', + className: '', + min: 0, + max: 100, + step: 1, + marks: {}, + handle ({ index, ...restProps }) { + delete restProps.dragging + return + }, + onBeforeChange: noop, + onChange: noop, + onAfterChange: noop, + included: true, + disabled: false, + dots: false, + vertical: false, + trackStyle: [{}], + handleStyle: [{}], + railStyle: {}, + dotStyle: {}, + activeDotStyle: {}, + }; + + constructor (props) { + super(props) + + if (process.env.NODE_ENV !== 'production') { + const { step, max, min } = props + warning( + step && Math.floor(step) === step ? (max - min) % step === 0 : true, + 'Slider[max] - Slider[min] (%s) should be a multiple of Slider[step] (%s)', + max - min, + step + ) + } + this.handlesRefs = {} + } + + componentWillUnmount () { + if (super.componentWillUnmount) super.componentWillUnmount() + this.removeDocumentEvents() + } + + componentDidMount () { + // Snapshot testing cannot handle refs, so be sure to null-check this. + this.document = this.sliderRef && this.sliderRef.ownerDocument + } + + onMouseDown = (e) => { + if (e.button !== 0) { return } + + const isVertical = this.props.vertical + let position = utils.getMousePosition(isVertical, e) + if (!utils.isEventFromHandle(e, this.handlesRefs)) { + this.dragOffset = 0 + } else { + const handlePosition = utils.getHandleCenterPosition(isVertical, e.target) + this.dragOffset = position - handlePosition + position = handlePosition + } + this.removeDocumentEvents() + this.onStart(position) + this.addDocumentMouseEvents() + } + + onTouchStart = (e) => { + if (utils.isNotTouchEvent(e)) return + + const isVertical = this.props.vertical + let position = utils.getTouchPosition(isVertical, e) + if (!utils.isEventFromHandle(e, this.handlesRefs)) { + this.dragOffset = 0 + } else { + const handlePosition = utils.getHandleCenterPosition(isVertical, e.target) + this.dragOffset = position - handlePosition + position = handlePosition + } + this.onStart(position) + this.addDocumentTouchEvents() + utils.pauseEvent(e) + } + + onFocus = (e) => { + const { onFocus, vertical } = this.props + if (utils.isEventFromHandle(e, this.handlesRefs)) { + const handlePosition = utils.getHandleCenterPosition(vertical, e.target) + this.dragOffset = 0 + this.onStart(handlePosition) + utils.pauseEvent(e) + if (onFocus) { + onFocus(e) + } + } + } + + onBlur = (e) => { + const { onBlur } = this.props + this.onEnd(e) + if (onBlur) { + onBlur(e) + } + }; + + addDocumentTouchEvents () { + // just work for Chrome iOS Safari and Android Browser + this.onTouchMoveListener = addEventListener(this.document, 'touchmove', this.onTouchMove) + this.onTouchUpListener = addEventListener(this.document, 'touchend', this.onEnd) + } + + addDocumentMouseEvents () { + this.onMouseMoveListener = addEventListener(this.document, 'mousemove', this.onMouseMove) + this.onMouseUpListener = addEventListener(this.document, 'mouseup', this.onEnd) + } + + removeDocumentEvents () { + /* eslint-disable no-unused-expressions */ + this.onTouchMoveListener && this.onTouchMoveListener.remove() + this.onTouchUpListener && this.onTouchUpListener.remove() + + this.onMouseMoveListener && this.onMouseMoveListener.remove() + this.onMouseUpListener && this.onMouseUpListener.remove() + /* eslint-enable no-unused-expressions */ + } + + onMouseUp = () => { + if (this.handlesRefs[this.prevMovedHandleIndex]) { + this.handlesRefs[this.prevMovedHandleIndex].clickFocus() + } + } + + onMouseMove = (e) => { + if (!this.sliderRef) { + this.onEnd() + return; + } + const position = utils.getMousePosition(this.props.vertical, e) + this.onMove(e, position - this.dragOffset) + } + + onTouchMove = (e) => { + if (utils.isNotTouchEvent(e) || !this.sliderRef) { + this.onEnd() + return; + } + + const position = utils.getTouchPosition(this.props.vertical, e) + this.onMove(e, position - this.dragOffset) + } + + onKeyDown = (e) => { + if (this.sliderRef && utils.isEventFromHandle(e, this.handlesRefs)) { + this.onKeyboard(e) + } + } + + focus () { + if (!this.props.disabled) { + this.handlesRefs[0].focus() + } + } + + blur () { + if (!this.props.disabled) { + this.handlesRefs[0].blur() + } + } + + getSliderStart () { + const slider = this.sliderRef + const rect = slider.getBoundingClientRect() + + return this.props.vertical ? rect.top : rect.left + } + + getSliderLength () { + const slider = this.sliderRef + if (!slider) { + return 0 + } + + const coords = slider.getBoundingClientRect() + return this.props.vertical ? coords.height : coords.width + } + + calcValue (offset) { + const { vertical, min, max } = this.props + const ratio = Math.abs(Math.max(offset, 0) / this.getSliderLength()) + const value = vertical ? (1 - ratio) * (max - min) + min : ratio * (max - min) + min + return value + } + + calcValueByPos (position) { + const pixelOffset = position - this.getSliderStart() + const nextValue = this.trimAlignValue(this.calcValue(pixelOffset)) + return nextValue + } + + calcOffset (value) { + const { min, max } = this.props + const ratio = (value - min) / (max - min) + return ratio * 100 + } + + saveSlider = (slider) => { + this.sliderRef = slider + } + + saveHandle (index, handle) { + this.handlesRefs[index] = handle + } + + onClickMarkLabel = (e, value) => { + e.stopPropagation() + this.onChange({ value }) + } + + render () { + const { + prefixCls, + className, + marks, + dots, + step, + included, + disabled, + vertical, + min, + max, + children, + maximumTrackStyle, + style, + railStyle, + dotStyle, + activeDotStyle, + } = this.props + const { tracks, handles } = super.render() + + const sliderClassName = classNames(prefixCls, { + [`${prefixCls}-with-marks`]: Object.keys(marks).length, + [`${prefixCls}-disabled`]: disabled, + [`${prefixCls}-vertical`]: vertical, + [className]: className, + }) + return ( +
+
+ {tracks} + + {handles} + + {children} +
+ ) + } + } +} diff --git a/components/vc-slider/src/createSliderWithTooltip.vue b/components/vc-slider/src/createSliderWithTooltip.vue new file mode 100644 index 000000000..002357a48 --- /dev/null +++ b/components/vc-slider/src/createSliderWithTooltip.vue @@ -0,0 +1,80 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Tooltip from 'rc-tooltip'; +import Handle from './Handle'; + +export default function createSliderWithTooltip(Component) { + return class ComponentWrapper extends React.Component { + static propTypes = { + tipFormatter: PropTypes.func, + handleStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.arrayOf(PropTypes.object)]), + tipProps: PropTypes.object, + }; + static defaultProps = { + tipFormatter(value) { return value; }, + handleStyle: [{}], + tipProps: {}, + }; + constructor(props) { + super(props); + this.state = { visibles: {} }; + } + handleTooltipVisibleChange = (index, visible) => { + this.setState((prevState) => { + return { + visibles: { + ...prevState.visibles, + [index]: visible, + }, + }; + }); + } + handleWithTooltip = ({ value, dragging, index, disabled, ...restProps }) => { + const { + tipFormatter, + tipProps, + handleStyle, + } = this.props; + + const { + prefixCls = 'rc-slider-tooltip', + overlay = tipFormatter(value), + placement = 'top', + visible = visible || false, + ...restTooltipProps, + } = tipProps; + + let handleStyleWithIndex; + if (Array.isArray(handleStyle)) { + handleStyleWithIndex = handleStyle[index] || handleStyle[0]; + } else { + handleStyleWithIndex = handleStyle; + } + + return ( + + + this.handleTooltipVisibleChange(index, true)} + onMouseLeave={() => this.handleTooltipVisibleChange(index, false)} + /> + + ); + } + render() { + return ; + } + }; +} diff --git a/components/vc-slider/src/index.js b/components/vc-slider/src/index.js new file mode 100644 index 000000000..9ad6d20d7 --- /dev/null +++ b/components/vc-slider/src/index.js @@ -0,0 +1,10 @@ +import Slider from './Slider' +import Range from './Range' +import Handle from './Handle' +import createSliderWithTooltip from './createSliderWithTooltip' + +Slider.Range = Range +Slider.Handle = Handle +Slider.createSliderWithTooltip = createSliderWithTooltip +export default Slider +export { Range, Handle, createSliderWithTooltip } diff --git a/components/vc-slider/src/utils.js b/components/vc-slider/src/utils.js new file mode 100644 index 000000000..1b2f4a036 --- /dev/null +++ b/components/vc-slider/src/utils.js @@ -0,0 +1,92 @@ +import { findDOMNode } from 'react-dom' +import keyCode from '../../_util/KeyCode' + +export function isEventFromHandle (e, handles) { + return Object.keys(handles) + .some(key => e.target === findDOMNode(handles[key])) +} + +export function isValueOutOfRange (value, { min, max }) { + return value < min || value > max +} + +export function isNotTouchEvent (e) { + return e.touches.length > 1 || + (e.type.toLowerCase() === 'touchend' && e.touches.length > 0) +} + +export function getClosestPoint (val, { marks, step, min }) { + const points = Object.keys(marks).map(parseFloat) + if (step !== null) { + const closestStep = + Math.round((val - min) / step) * step + min + points.push(closestStep) + } + const diffs = points.map(point => Math.abs(val - point)) + return points[diffs.indexOf(Math.min(...diffs))] +} + +export function getPrecision (step) { + const stepString = step.toString() + let precision = 0 + if (stepString.indexOf('.') >= 0) { + precision = stepString.length - stepString.indexOf('.') - 1 + } + return precision +} + +export function getMousePosition (vertical, e) { + return vertical ? e.clientY : e.pageX +} + +export function getTouchPosition (vertical, e) { + return vertical ? e.touches[0].clientY : e.touches[0].pageX +} + +export function getHandleCenterPosition (vertical, handle) { + const coords = handle.getBoundingClientRect() + return vertical + ? coords.top + (coords.height * 0.5) + : coords.left + (coords.width * 0.5) +} + +export function ensureValueInRange (val, { max, min }) { + if (val <= min) { + return min + } + if (val >= max) { + return max + } + return val +} + +export function ensureValuePrecision (val, props) { + const { step } = props + const closestPoint = getClosestPoint(val, props) + return step === null ? closestPoint + : parseFloat(closestPoint.toFixed(getPrecision(step))) +} + +export function pauseEvent (e) { + e.stopPropagation() + e.preventDefault() +} + +export function getKeyboardValueMutator (e) { + switch (e.keyCode) { + case keyCode.UP: + case keyCode.RIGHT: + return (value, props) => value + props.step + + case keyCode.DOWN: + case keyCode.LEFT: + return (value, props) => value - props.step + + case keyCode.END: return (value, props) => props.max + case keyCode.HOME: return (value, props) => props.min + case keyCode.PAGE_UP: return (value, props) => value + props.step * 2 + case keyCode.PAGE_DOWN: return (value, props) => value - props.step * 2 + + default: return undefined + } +} diff --git a/components/vc-steps/Step.vue b/components/vc-steps/Step.vue index 7f2942811..e150f4126 100644 --- a/components/vc-steps/Step.vue +++ b/components/vc-steps/Step.vue @@ -71,8 +71,8 @@ export default { adjustMarginRight, } = getOptionProps(this) - const title = this.title || this.$slots.title - const description = this.description || this.$slots.description + const title = getComponentFromProp(this, 'title') + const description = getComponentFromProp(this, 'description') const classString = { [`${prefixCls}-item`]: true, diff --git a/contributors.md b/contributors.md index bd37f3959..36a3f2161 100644 --- a/contributors.md +++ b/contributors.md @@ -41,7 +41,7 @@ TreeSelect ##王 Rate | done -Pagination | done|select完成后补全 +Pagination | done Avatar | done Badge | done Breadcrumb | done @@ -49,7 +49,7 @@ Card | done Collapse | done Spin | done Switch | done -Steps +Steps | done Progress Slider Table diff --git a/examples/routes.js b/examples/routes.js index c798a71bd..928891f4b 100644 --- a/examples/routes.js +++ b/examples/routes.js @@ -3,7 +3,7 @@ const AsyncComp = () => { const hashs = window.location.hash.split('/') const d = hashs[hashs.length - 1] return { - component: import(`../components/pagination/demo/${d}`), + component: import(`../components/steps/demo/${d}`), } } export default [