UNPKG

vuetify

Version:

Vue.js 2 Semantic Component Framework

524 lines (472 loc) 14.6 kB
// Styles import '../../stylus/components/_sliders.styl' // Components import { VScaleTransition } from '../transitions' // Extensions import VInput from '../VInput' // Directives import ClickOutside from '../../directives/click-outside' // Utilities import { addOnceEventListener, convertToUnit, createRange, keyCodes, deepEqual } from '../../util/helpers' import { consoleWarn } from '../../util/console' import Loadable from '../../mixins/loadable' /* @vue/component */ export default { name: 'v-slider', directives: { ClickOutside }, extends: VInput, mixins: [Loadable], props: { alwaysDirty: Boolean, inverseLabel: Boolean, label: String, min: { type: [Number, String], default: 0 }, max: { type: [Number, String], default: 100 }, range: Boolean, step: { type: [Number, String], default: 1 }, ticks: { type: [Boolean, String], default: false, validator: v => typeof v === 'boolean' || v === 'always' }, tickLabels: { type: Array, default: () => ([]) }, tickSize: { type: [Number, String], default: 1 }, thumbColor: { type: String, default: null }, thumbLabel: { type: [Boolean, String], default: null, validator: v => typeof v === 'boolean' || v === 'always' }, thumbSize: { type: [Number, String], default: 32 }, trackColor: { type: String, default: null }, value: [Number, String] }, data: vm => ({ app: {}, isActive: false, keyPressed: 0, lazyValue: typeof vm.value !== 'undefined' ? vm.value : Number(vm.min), oldValue: null }), computed: { classes () { return { 'v-input--slider': true, 'v-input--slider--ticks': this.showTicks, 'v-input--slider--inverse-label': this.inverseLabel, 'v-input--slider--ticks-labels': this.tickLabels.length > 0, 'v-input--slider--thumb-label': this.thumbLabel || this.$scopedSlots.thumbLabel } }, showTicks () { return this.tickLabels.length > 0 || (!this.disabled && this.stepNumeric && !!this.ticks) }, showThumbLabel () { return !this.disabled && ( !!this.thumbLabel || this.thumbLabel === '' || this.$scopedSlots['thumb-label'] ) }, computedColor () { if (this.disabled) return null return this.validationState || this.color || 'primary' }, computedTrackColor () { return this.disabled ? null : (this.trackColor || null) }, computedThumbColor () { if (this.disabled || !this.isDirty) return null return this.validationState || this.thumbColor || this.color || 'primary' }, internalValue: { get () { return this.lazyValue }, set (val) { const { min, max } = this // Round value to ensure the // entire slider range can // be selected with step const value = this.roundValue(Math.min(Math.max(val, min), max)) if (value === this.lazyValue) return this.lazyValue = value this.$emit('input', value) this.validate() } }, stepNumeric () { return this.step > 0 ? parseFloat(this.step) : 0 }, trackFillStyles () { const left = this.$vuetify.rtl ? 'auto' : 0 const right = this.$vuetify.rtl ? 0 : 'auto' let width = `${this.inputWidth}%` if (this.disabled) width = `calc(${this.inputWidth}% - 8px)` return { transition: this.trackTransition, left, right, width } }, trackPadding () { return ( this.isActive || this.inputWidth > 0 || this.disabled ) ? 0 : 7 }, trackStyles () { const trackPadding = this.disabled ? `calc(${this.inputWidth}% + 8px)` : `${this.trackPadding}px` const left = this.$vuetify.rtl ? 'auto' : trackPadding const right = this.$vuetify.rtl ? trackPadding : 'auto' const width = this.disabled ? `calc(${100 - this.inputWidth}% - 8px)` : '100%' return { transition: this.trackTransition, left, right, width } }, tickStyles () { const size = Number(this.tickSize) return { 'border-width': `${size}px`, 'border-radius': size > 1 ? '50%' : null, transform: size > 1 ? `translateX(-${size}px) translateY(-${size - 1}px)` : null } }, trackTransition () { return this.keyPressed >= 2 ? 'none' : '' }, numTicks () { return Math.ceil((this.max - this.min) / this.stepNumeric) }, inputWidth () { return (this.roundValue(this.internalValue) - this.min) / (this.max - this.min) * 100 }, isDirty () { return this.internalValue > this.min || this.alwaysDirty } }, watch: { min (val) { val > this.internalValue && this.$emit('input', parseFloat(val)) }, max (val) { val < this.internalValue && this.$emit('input', parseFloat(val)) }, value (val) { this.internalValue = val } }, mounted () { // Without a v-app, iOS does not work with body selectors this.app = document.querySelector('[data-app]') || consoleWarn('Missing v-app or a non-body wrapping element with the [data-app] attribute', this) }, methods: { genDefaultSlot () { const children = [this.genLabel()] const slider = this.genSlider() this.inverseLabel ? children.unshift(slider) : children.push(slider) children.push(this.genProgress()) return children }, genListeners () { return { blur: this.onBlur, click: this.onSliderClick, focus: this.onFocus, keydown: this.onKeyDown, keyup: this.onKeyUp } }, genInput () { return this.$createElement('input', { attrs: { 'aria-label': this.label, name: this.name, role: 'slider', tabindex: this.disabled ? -1 : this.$attrs.tabindex, value: this.internalValue, readonly: true, 'aria-readonly': String(this.readonly), 'aria-valuemin': this.min, 'aria-valuemax': this.max, 'aria-valuenow': this.internalValue, ...this.$attrs }, on: this.genListeners(), ref: 'input' }) }, genSlider () { return this.$createElement('div', { staticClass: 'v-slider', 'class': { 'v-slider--is-active': this.isActive }, directives: [{ name: 'click-outside', value: this.onBlur }] }, this.genChildren()) }, genChildren () { return [ this.genInput(), this.genTrackContainer(), this.genSteps(), this.genThumbContainer( this.internalValue, this.inputWidth, this.isFocused || this.isActive, this.onThumbMouseDown ) ] }, genSteps () { if (!this.step || !this.showTicks) return null const ticks = createRange(this.numTicks + 1).map(i => { const children = [] if (this.tickLabels[i]) { children.push(this.$createElement('span', this.tickLabels[i])) } return this.$createElement('span', { key: i, staticClass: 'v-slider__ticks', class: { 'v-slider__ticks--always-show': this.ticks === 'always' || this.tickLabels.length > 0 }, style: { ...this.tickStyles, left: `${i * (100 / this.numTicks)}%` } }, children) }) return this.$createElement('div', { staticClass: 'v-slider__ticks-container' }, ticks) }, genThumb () { return this.$createElement('div', this.setBackgroundColor(this.computedThumbColor, { staticClass: 'v-slider__thumb' })) }, genThumbContainer (value, valueWidth, isActive, onDrag) { const children = [this.genThumb()] const thumbLabelContent = this.getLabel(value) this.showThumbLabel && children.push(this.genThumbLabel(thumbLabelContent)) return this.$createElement('div', this.setTextColor(this.computedThumbColor, { staticClass: 'v-slider__thumb-container', 'class': { 'v-slider__thumb-container--is-active': isActive, 'v-slider__thumb-container--show-label': this.showThumbLabel }, style: { transition: this.trackTransition, left: `${this.$vuetify.rtl ? 100 - valueWidth : valueWidth}%` }, on: { touchstart: onDrag, mousedown: onDrag } }), children) }, genThumbLabel (content) { const size = convertToUnit(this.thumbSize) return this.$createElement(VScaleTransition, { props: { origin: 'bottom center' } }, [ this.$createElement('div', { staticClass: 'v-slider__thumb-label__container', directives: [ { name: 'show', value: this.isFocused || this.isActive || this.thumbLabel === 'always' } ] }, [ this.$createElement('div', this.setBackgroundColor(this.computedThumbColor, { staticClass: 'v-slider__thumb-label', style: { height: size, width: size } }), [content]) ]) ]) }, genTrackContainer () { const children = [ this.$createElement('div', this.setBackgroundColor(this.computedTrackColor, { staticClass: 'v-slider__track', style: this.trackStyles })), this.$createElement('div', this.setBackgroundColor(this.computedColor, { staticClass: 'v-slider__track-fill', style: this.trackFillStyles })) ] return this.$createElement('div', { staticClass: 'v-slider__track__container', ref: 'track' }, children) }, getLabel (value) { return this.$scopedSlots['thumb-label'] ? this.$scopedSlots['thumb-label']({ value }) : this.$createElement('span', value) }, onBlur (e) { if (this.keyPressed === 2) return this.isActive = false this.isFocused = false this.$emit('blur', e) }, onFocus (e) { this.isFocused = true this.$emit('focus', e) }, onThumbMouseDown (e) { this.oldValue = this.internalValue this.keyPressed = 2 const options = { passive: true } this.isActive = true this.isFocused = false if ('touches' in e) { this.app.addEventListener('touchmove', this.onMouseMove, options) addOnceEventListener(this.app, 'touchend', this.onSliderMouseUp) } else { this.app.addEventListener('mousemove', this.onMouseMove, options) addOnceEventListener(this.app, 'mouseup', this.onSliderMouseUp) } this.$emit('start', this.internalValue) }, onSliderMouseUp () { this.keyPressed = 0 const options = { passive: true } this.isActive = false this.isFocused = false this.app.removeEventListener('touchmove', this.onMouseMove, options) this.app.removeEventListener('mousemove', this.onMouseMove, options) this.$emit('end', this.internalValue) if (!deepEqual(this.oldValue, this.internalValue)) { this.$emit('change', this.internalValue) } }, onMouseMove (e) { const { value, isInsideTrack } = this.parseMouseMove(e) if (isInsideTrack) { this.setInternalValue(value) } }, onKeyDown (e) { if (this.disabled || this.readonly) return const value = this.parseKeyDown(e) if (value == null) return this.setInternalValue(value) this.$emit('change', value) }, onKeyUp () { this.keyPressed = 0 }, onSliderClick (e) { this.isFocused = true this.onMouseMove(e) this.$emit('change', this.internalValue) }, parseMouseMove (e) { const { left: offsetLeft, width: trackWidth } = this.$refs.track.getBoundingClientRect() const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX // It is possible for left to be NaN, force to number let left = Math.min(Math.max((clientX - offsetLeft) / trackWidth, 0), 1) || 0 if (this.$vuetify.rtl) left = 1 - left const isInsideTrack = clientX >= offsetLeft - 8 && clientX <= offsetLeft + trackWidth + 8 const value = parseFloat(this.min) + left * (this.max - this.min) return { value, isInsideTrack } }, parseKeyDown (e, value = this.internalValue) { if (this.disabled) return const { pageup, pagedown, end, home, left, right, down, up } = keyCodes if (![pageup, pagedown, end, home, left, right, down, up].includes(e.keyCode)) return e.preventDefault() const step = this.stepNumeric || 1 const steps = (this.max - this.min) / step if ([left, right, down, up].includes(e.keyCode)) { this.keyPressed += 1 const increase = this.$vuetify.rtl ? [left, up] : [right, up] const direction = increase.includes(e.keyCode) ? 1 : -1 const multiplier = e.shiftKey ? 3 : (e.ctrlKey ? 2 : 1) value = value + (direction * step * multiplier) } else if (e.keyCode === home) { value = parseFloat(this.min) } else if (e.keyCode === end) { value = parseFloat(this.max) } else /* if (e.keyCode === keyCodes.pageup || e.keyCode === pagedown) */ { // Page up/down const direction = e.keyCode === pagedown ? 1 : -1 value = value - (direction * step * (steps > 100 ? steps / 10 : 10)) } return value }, roundValue (value) { if (!this.stepNumeric) return value // Format input value using the same number // of decimals places as in the step prop const trimmedStep = this.step.toString().trim() const decimals = trimmedStep.indexOf('.') > -1 ? (trimmedStep.length - trimmedStep.indexOf('.') - 1) : 0 const offset = this.min % this.stepNumeric const newValue = Math.round((value - offset) / this.stepNumeric) * this.stepNumeric + offset return parseFloat(Math.min(newValue, this.max).toFixed(decimals)) }, setInternalValue (value) { this.internalValue = value } } }