import { Component, Model, Prop, Watch, Vue } from 'vue-property-decorator' import { Value, Mark, MarksProp, Styles, DotOption, DotStyle, Dot, Direction, Position, ProcessProp, Process, TooltipProp, TooltipFormatter, } from './typings' import VueSliderDot from './vue-slider-dot' import VueSliderMark from './vue-slider-mark' import { getSize, getPos, getKeyboardHandleFunc, HandleFunction } from './utils' import Decimal from './utils/decimal' import Control, { ERROR_TYPE } from './utils/control' import State, { StateMap } from './utils/state' import './styles/slider.scss' export const SliderState: StateMap = { None: 0, Drag: 1 << 1, Focus: 1 << 2, } const DEFAULT_SLIDER_SIZE = 4 @Component({ data() { return { control: null, } }, components: { VueSliderDot, VueSliderMark, }, }) export default class VueSlider extends Vue { control!: Control states: State = new State(SliderState) // The width of the component is divided into one hundred, the width of each one. scale: number = 1 // Currently dragged slider index focusDotIndex: number = 0 $refs!: { container: HTMLDivElement } $el!: HTMLDivElement @Model('change', { default: 0 }) value!: Value | Value[] @Prop({ type: Boolean, default: false }) silent!: boolean @Prop({ default: 'ltr', validator: dir => ['ltr', 'rtl', 'ttb', 'btt'].indexOf(dir) > -1, }) direction!: Direction @Prop({ type: [Number, String] }) width?: number | string @Prop({ type: [Number, String] }) height?: number | string // The size of the slider, optional [width, height] | size @Prop({ default: 14 }) dotSize!: [number, number] | number // whether or not the slider should be fully contained within its containing element @Prop({ default: false }) contained!: boolean @Prop({ type: Number, default: 0 }) min!: number @Prop({ type: Number, default: 100 }) max!: number @Prop({ type: Number, default: 1 }) interval!: number @Prop({ type: Boolean, default: false }) disabled!: boolean @Prop({ type: Boolean, default: true }) clickable!: boolean @Prop({ type: Boolean, default: false }) dragOnClick!: boolean // The duration of the slider slide, Unit second @Prop({ type: Number, default: 0.5 }) duration!: number @Prop(Array) data?: Value[] @Prop({ type: Boolean, default: false }) lazy!: boolean @Prop({ type: String, validator: val => ['none', 'always', 'focus', 'hover', 'active'].indexOf(val) > -1, default: 'active', }) tooltip!: TooltipProp @Prop({ type: [String, Array], validator: data => (Array.isArray(data) ? data : [data]).every( val => ['top', 'right', 'bottom', 'left'].indexOf(val) > -1, ), }) tooltipPlacement?: Position | Position[] @Prop({ type: [String, Array, Function] }) tooltipFormatter?: TooltipFormatter | TooltipFormatter[] // Keyboard control @Prop({ type: Boolean, default: false }) useKeyboard?: boolean // Keyboard controlled hook function @Prop(Function) keydownHook!: (e: KeyboardEvent) => HandleFunction | boolean // Whether to allow sliders to cross, only in range mode @Prop({ type: Boolean, default: true }) enableCross!: boolean // Whether to fix the slider interval, only in range mode @Prop({ type: Boolean, default: false }) fixed!: boolean // Whether to sort values, only in range mode // When order is false, the parameters [minRange, maxRange, fixed, enableCross] are invalid // e.g. When order = false, [50, 30] will not be automatically sorted into [30, 50] @Prop({ type: Boolean, default: true }) order!: boolean // Minimum distance between sliders, only in range mode @Prop(Number) minRange?: number // Maximum distance between sliders, only in range mode @Prop(Number) maxRange?: number @Prop({ type: [Boolean, Object, Array, Function], default: false }) marks?: MarksProp @Prop({ type: [Boolean, Function], default: true }) process?: ProcessProp // If the value is true , mark will be an independent value @Prop(Boolean) included?: boolean // If the value is true , dot will automatically adsorb to the nearest value @Prop(Boolean) adsorb?: boolean @Prop(Boolean) hideLabel?: boolean @Prop() dotOptions?: DotOption | DotOption[] @Prop() railStyle?: Styles @Prop() processStyle?: Styles @Prop() dotStyle?: Styles @Prop() tooltipStyle?: Styles @Prop() stepStyle?: Styles @Prop() stepActiveStyle?: Styles @Prop() labelStyle?: Styles @Prop() labelActiveStyle?: Styles get tailSize() { return getSize((this.isHorizontal ? this.height : this.width) || DEFAULT_SLIDER_SIZE) } get containerClasses() { return [ 'vue-slider', [`vue-slider-${this.direction}`], { 'vue-slider-disabled': this.disabled, }, ] } get containerStyles() { const [dotWidth, dotHeight] = Array.isArray(this.dotSize) ? this.dotSize : [this.dotSize, this.dotSize] const containerWidth = this.width ? getSize(this.width) : this.isHorizontal ? 'auto' : getSize(DEFAULT_SLIDER_SIZE) const containerHeight = this.height ? getSize(this.height) : this.isHorizontal ? getSize(DEFAULT_SLIDER_SIZE) : 'auto' return { padding: this.contained ? `${dotHeight / 2}px ${dotWidth / 2}px` : this.isHorizontal ? `${dotHeight / 2}px 0` : `0 ${dotWidth / 2}px`, width: containerWidth, height: containerHeight, } } get processArray(): Process[] { return this.control.processArray.map(([start, end, style], index) => { if (start > end) { /* tslint:disable:semicolon */ ;[start, end] = [end, start] } const sizeStyleKey = this.isHorizontal ? 'width' : 'height' return { start, end, index, style: { [this.isHorizontal ? 'height' : 'width']: '100%', [this.isHorizontal ? 'top' : 'left']: 0, [this.mainDirection]: `${start}%`, [sizeStyleKey]: `${end - start}%`, transitionProperty: `${sizeStyleKey},${this.mainDirection}`, transitionDuration: `${this.animateTime}s`, ...this.processStyle, ...style, }, } }) } get dotBaseStyle() { const [dotWidth, dotHeight] = Array.isArray(this.dotSize) ? this.dotSize : [this.dotSize, this.dotSize] let dotPos: { [key: string]: string } if (this.isHorizontal) { dotPos = { transform: `translate(${this.isReverse ? '50%' : '-50%'}, -50%)`, WebkitTransform: `translate(${this.isReverse ? '50%' : '-50%'}, -50%)`, top: '50%', [this.direction === 'ltr' ? 'left' : 'right']: '0', } } else { dotPos = { transform: `translate(-50%, ${this.isReverse ? '50%' : '-50%'})`, WebkitTransform: `translate(-50%, ${this.isReverse ? '50%' : '-50%'})`, left: '50%', [this.direction === 'btt' ? 'bottom' : 'top']: '0', } } return { width: `${dotWidth}px`, height: `${dotHeight}px`, ...dotPos, } } get mainDirection(): string { switch (this.direction) { case 'ltr': return 'left' case 'rtl': return 'right' case 'btt': return 'bottom' case 'ttb': return 'top' } } get isHorizontal(): boolean { return this.direction === 'ltr' || this.direction === 'rtl' } get isReverse(): boolean { return this.direction === 'rtl' || this.direction === 'btt' } get tooltipDirections(): Position[] { const dir = this.tooltipPlacement || (this.isHorizontal ? 'top' : 'left') if (Array.isArray(dir)) { return dir } else { return this.dots.map(() => dir) } } get dots(): Dot[] { return this.control.dotsPos.map((pos, index) => ({ pos, index, value: this.control.dotsValue[index], focus: this.states.has(SliderState.Focus) && this.focusDotIndex === index, disabled: this.disabled, style: this.dotStyle, ...((Array.isArray(this.dotOptions) ? this.dotOptions[index] : this.dotOptions) || {}), })) } get animateTime(): number { if (this.states.has(SliderState.Drag)) { return 0 } return this.duration } get canSort(): boolean { return this.order && !this.minRange && !this.maxRange && !this.fixed && this.enableCross } @Watch('value') onValueChanged() { if (this.control && !this.states.has(SliderState.Drag) && this.isNotSync) { this.control.setValue(this.value) } } created() { this.initControl() } mounted() { this.bindEvent() } beforeDestroy() { this.unbindEvent() } bindEvent() { document.addEventListener('touchmove', this.dragMove, { passive: false }) document.addEventListener('touchend', this.dragEnd, { passive: false }) document.addEventListener('mousedown', this.blurHandle) document.addEventListener('mousemove', this.dragMove) document.addEventListener('mouseup', this.dragEnd) document.addEventListener('mouseleave', this.dragEnd) document.addEventListener('keydown', this.keydownHandle) } unbindEvent() { document.removeEventListener('touchmove', this.dragMove) document.removeEventListener('touchend', this.dragEnd) document.removeEventListener('mousedown', this.blurHandle) document.removeEventListener('mousemove', this.dragMove) document.removeEventListener('mouseup', this.dragEnd) document.removeEventListener('mouseleave', this.dragEnd) document.removeEventListener('keydown', this.keydownHandle) } setScale() { this.scale = new Decimal( Math.floor(this.isHorizontal ? this.$el.offsetWidth : this.$el.offsetHeight), ) .divide(100) .toNumber() } initControl() { this.control = new Control({ value: this.value, data: this.data, enableCross: this.enableCross, fixed: this.fixed, max: this.max, min: this.min, interval: this.interval, minRange: this.minRange, maxRange: this.maxRange, order: this.order, marks: this.marks, included: this.included, process: this.process, adsorb: this.adsorb, dotOptions: this.dotOptions, onError: this.emitError, }) ;[ 'data', 'enableCross', 'fixed', 'max', 'min', 'interval', 'minRange', 'maxRange', 'order', 'marks', 'process', 'adsorb', 'included', 'dotOptions', ].forEach(name => { this.$watch(name, (val: any) => { if ( name === 'data' && Array.isArray(this.control.data) && Array.isArray(val) && this.control.data.length === val.length && val.every((item, index) => item === (this.control.data as Value[])[index]) ) { return false } ;(this.control as any)[name] = val if (['data', 'max', 'min', 'interval'].indexOf(name) > -1) { this.control.syncDotsPos() } }) }) } private syncValueByPos() { const values = this.control.dotsValue if (this.isDiff(values, Array.isArray(this.value) ? this.value : [this.value])) { this.$emit('change', values.length === 1 ? values[0] : [...values]) } } // Slider value and component internal value are inconsistent private get isNotSync() { const values = this.control.dotsValue return Array.isArray(this.value) ? this.value.length !== values.length || this.value.some((val, index) => val !== values[index]) : this.value !== values[0] } private isDiff(value1: Value[], value2: Value[]) { return value1.length !== value2.length || value1.some((val, index) => val !== value2[index]) } private emitError(type: ERROR_TYPE, message: string) { if (!this.silent) { console.error(`[VueSlider error]: ${message}`) } this.$emit('error', type, message) } /** * Get the drag range of the slider * * @private * @param {number} index slider index * @returns {[number, number]} range [start, end] * @memberof VueSlider */ private get dragRange(): [number, number] { const prevDot = this.dots[this.focusDotIndex - 1] const nextDot = this.dots[this.focusDotIndex + 1] return [prevDot ? prevDot.pos : -Infinity, nextDot ? nextDot.pos : Infinity] } private dragStartOnProcess(e: MouseEvent | TouchEvent) { if (this.dragOnClick) { this.setScale() const pos = this.getPosByEvent(e) const index = this.control.getRecentDot(pos) if (this.dots[index].disabled) { return } this.dragStart(index) this.control.setDotPos(pos, this.focusDotIndex) if (!this.lazy) { this.syncValueByPos() } } } private dragStart(index: number) { this.focusDotIndex = index this.setScale() this.states.add(SliderState.Drag) this.states.add(SliderState.Focus) this.$emit('drag-start') } private dragMove(e: MouseEvent | TouchEvent) { if (!this.states.has(SliderState.Drag)) { return false } e.preventDefault() const pos = this.getPosByEvent(e) this.isCrossDot(pos) this.control.setDotPos(pos, this.focusDotIndex) if (!this.lazy) { this.syncValueByPos() } const value = this.control.dotsValue this.$emit('dragging', value.length === 1 ? value[0] : [...value]) } // If the component is sorted, then when the slider crosses, toggle the currently selected slider index private isCrossDot(pos: number) { if (this.canSort) { const curIndex = this.focusDotIndex let curPos = pos if (curPos > this.dragRange[1]) { curPos = this.dragRange[1] this.focusDotIndex++ } else if (curPos < this.dragRange[0]) { curPos = this.dragRange[0] this.focusDotIndex-- } if (curIndex !== this.focusDotIndex) { this.control.setDotPos(curPos, curIndex) } } } private dragEnd() { if (!this.states.has(SliderState.Drag)) { return false } setTimeout(() => { if (this.lazy) { this.syncValueByPos() } if (this.included && this.isNotSync) { this.control.setValue(this.value) } else { // Sync slider position this.control.syncDotsPos() } this.states.delete(SliderState.Drag) // If useKeyboard is true, keep focus status after dragging if (!this.useKeyboard) { this.states.delete(SliderState.Focus) } this.$emit('drag-end') }) } private blurHandle(e: MouseEvent) { if ( !this.states.has(SliderState.Focus) || !this.$refs.container || this.$refs.container.contains(e.target as Node) ) { return false } this.states.delete(SliderState.Focus) } private clickHandle(e: MouseEvent | TouchEvent) { if (!this.clickable || this.disabled) { return false } if (this.states.has(SliderState.Drag)) { return } this.setScale() const pos = this.getPosByEvent(e) this.setValueByPos(pos) } focus(index: number = 0) { this.states.add(SliderState.Focus) this.focusDotIndex = index } blur() { this.states.delete(SliderState.Focus) } getValue() { const values = this.control.dotsValue return values.length === 1 ? values[0] : values } getIndex() { const indexes = this.control.dotsIndex return indexes.length === 1 ? indexes[0] : indexes } setValue(value: Value | Value[]) { this.control.setValue(Array.isArray(value) ? [...value] : [value]) this.syncValueByPos() } setIndex(index: number | number[]) { const value = Array.isArray(index) ? index.map(n => this.control.getValueByIndex(n)) : this.control.getValueByIndex(index) this.setValue(value) } setValueByPos(pos: number) { const index = this.control.getRecentDot(pos) if (this.disabled || this.dots[index].disabled) { return false } this.focusDotIndex = index this.control.setDotPos(pos, index) this.syncValueByPos() if (this.useKeyboard) { this.states.add(SliderState.Focus) } setTimeout(() => { if (this.included && this.isNotSync) { this.control.setValue(this.value) } else { this.control.syncDotsPos() } }) } keydownHandle(e: KeyboardEvent) { if (!this.useKeyboard || !this.states.has(SliderState.Focus)) { return false } const isInclude = this.included && this.marks const handleFunc = getKeyboardHandleFunc(e, { direction: this.direction, max: isInclude ? this.control.markList.length - 1 : this.control.total, min: 0, hook: this.keydownHook, }) if (handleFunc) { e.preventDefault() let newIndex = -1 let pos = 0 if (isInclude) { this.control.markList.some((mark, index) => { if (mark.value === this.control.dotsValue[this.focusDotIndex]) { newIndex = handleFunc(index) return true } return false }) if (newIndex < 0) { newIndex = 0 } else if (newIndex > this.control.markList.length - 1) { newIndex = this.control.markList.length - 1 } pos = this.control.markList[newIndex].pos } else { newIndex = handleFunc( this.control.getIndexByValue(this.control.dotsValue[this.focusDotIndex]), ) pos = this.control.parseValue(this.control.getValueByIndex(newIndex)) } this.isCrossDot(pos) this.control.setDotPos(pos, this.focusDotIndex) this.syncValueByPos() } } private getPosByEvent(e: MouseEvent | TouchEvent): number { return getPos(e, this.$el, this.isReverse)[this.isHorizontal ? 'x' : 'y'] / this.scale } private renderSlot(name: string, data: T, defaultSlot: any, isDefault?: boolean): any { const scopedSlot = this.$scopedSlots[name] return scopedSlot ? ( isDefault ? ( scopedSlot(data) ) : ( ) ) : ( defaultSlot ) } render() { return (
{/* rail */}
{this.processArray.map((item, index) => this.renderSlot( 'process', item,
, true, ), )} {/* mark */} {this.marks ? (
{this.control.markList.map((mark, index) => this.renderSlot( 'mark', mark, this.clickable && this.setValueByPos(pos)} > {this.renderSlot('step', mark, null)} {this.renderSlot('label', mark, null)} , true, ), )}
) : null} {/* dot */} {this.dots.map((dot, index) => ( this.dragStart(index)} > {this.renderSlot('dot', dot, null)} {this.renderSlot('tooltip', dot, null)} ))} {this.renderSlot('default', null, null, true)}
) } }