UNPKG

vuetify

Version:

Vue Material Component Framework

593 lines (530 loc) 18.4 kB
import './VSlider.sass' // Components import VInput from '../VInput' import { VScaleTransition } from '../transitions' // Mixins import mixins, { ExtractVue } from '../../util/mixins' import Loadable from '../../mixins/loadable' // Directives import ClickOutside from '../../directives/click-outside' // Helpers import { addOnceEventListener, deepEqual, keyCodes, createRange, convertToUnit, passiveSupported } from '../../util/helpers' import { consoleWarn } from '../../util/console' // Types import Vue, { VNode, VNodeChildrenArrayContents, PropType } from 'vue' import { ScopedSlotChildren } from 'vue/types/vnode' import { PropValidator } from 'vue/types/options' interface options extends Vue { $refs: { track: HTMLElement } } export default mixins<options & /* eslint-disable indent */ ExtractVue<[ typeof VInput, typeof Loadable ]> /* eslint-enable indent */ >( VInput, Loadable /* @vue/component */ ).extend({ name: 'v-slider', directives: { ClickOutside, }, mixins: [Loadable], props: { disabled: Boolean, inverseLabel: Boolean, max: { type: [Number, String], default: 100, }, min: { type: [Number, String], default: 0, }, step: { type: [Number, String], default: 1, }, thumbColor: String, thumbLabel: { type: [Boolean, String] as PropType<boolean | 'always' | undefined>, default: undefined, validator: v => typeof v === 'boolean' || v === 'always', }, thumbSize: { type: [Number, String], default: 32, }, tickLabels: { type: Array, default: () => ([]), } as PropValidator<string[]>, ticks: { type: [Boolean, String] as PropType<boolean | 'always'>, default: false, validator: v => typeof v === 'boolean' || v === 'always', }, tickSize: { type: [Number, String], default: 2, }, trackColor: String, trackFillColor: String, value: [Number, String], vertical: Boolean, }, data: () => ({ app: null as any, oldValue: null as any, keyPressed: 0, isFocused: false, isActive: false, noClick: false, // Prevent click event if dragging took place, hack for #7915 }), computed: { classes (): object { return { ...VInput.options.computed.classes.call(this), 'v-input__slider': true, 'v-input__slider--vertical': this.vertical, 'v-input__slider--inverse-label': this.inverseLabel, } }, internalValue: { get (): number { return this.lazyValue }, set (val: number) { val = isNaN(val) ? this.minValue : val // Round value to ensure the // entire slider range can // be selected with step const value = this.roundValue(Math.min(Math.max(val, this.minValue), this.maxValue)) if (value === this.lazyValue) return this.lazyValue = value this.$emit('input', value) }, }, trackTransition (): string { return this.keyPressed >= 2 ? 'none' : '' }, minValue (): number { return parseFloat(this.min) }, maxValue (): number { return parseFloat(this.max) }, stepNumeric (): number { return this.step > 0 ? parseFloat(this.step) : 0 }, inputWidth (): number { return (this.roundValue(this.internalValue) - this.minValue) / (this.maxValue - this.minValue) * 100 }, trackFillStyles (): Partial<CSSStyleDeclaration> { const startDir = this.vertical ? 'bottom' : 'left' const endDir = this.vertical ? 'top' : 'right' const valueDir = this.vertical ? 'height' : 'width' const start = this.$vuetify.rtl ? 'auto' : '0' const end = this.$vuetify.rtl ? '0' : 'auto' const value = this.isDisabled ? `calc(${this.inputWidth}% - 10px)` : `${this.inputWidth}%` return { transition: this.trackTransition, [startDir]: start, [endDir]: end, [valueDir]: value, } }, trackStyles (): Partial<CSSStyleDeclaration> { const startDir = this.vertical ? this.$vuetify.rtl ? 'bottom' : 'top' : this.$vuetify.rtl ? 'left' : 'right' const endDir = this.vertical ? 'height' : 'width' const start = '0px' const end = this.isDisabled ? `calc(${100 - this.inputWidth}% - 10px)` : `calc(${100 - this.inputWidth}%)` return { transition: this.trackTransition, [startDir]: start, [endDir]: end, } }, showTicks (): boolean { return this.tickLabels.length > 0 || !!(!this.isDisabled && this.stepNumeric && this.ticks) }, numTicks (): number { return Math.ceil((this.maxValue - this.minValue) / this.stepNumeric) }, showThumbLabel (): boolean { return !this.isDisabled && !!( this.thumbLabel || this.$scopedSlots['thumb-label'] ) }, computedTrackColor (): string | undefined { if (this.isDisabled) return undefined if (this.trackColor) return this.trackColor if (this.isDark) return this.validationState return this.validationState || 'primary lighten-3' }, computedTrackFillColor (): string | undefined { if (this.isDisabled) return undefined if (this.trackFillColor) return this.trackFillColor return this.validationState || this.computedColor }, computedThumbColor (): string | undefined { if (this.thumbColor) return this.thumbColor return this.validationState || this.computedColor }, }, watch: { min (val) { const parsed = parseFloat(val) parsed > this.internalValue && this.$emit('input', parsed) }, max (val) { const parsed = parseFloat(val) parsed < this.internalValue && this.$emit('input', parsed) }, value: { handler (v: number) { this.internalValue = v }, }, }, // If done in as immediate in // value watcher, causes issues // with vue-test-utils beforeMount () { this.internalValue = this.value }, 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 (): VNodeChildrenArrayContents { const children: VNodeChildrenArrayContents = [this.genLabel()] const slider = this.genSlider() this.inverseLabel ? children.unshift(slider) : children.push(slider) children.push(this.genProgress()) return children }, genSlider (): VNode { return this.$createElement('div', { class: { 'v-slider': true, 'v-slider--horizontal': !this.vertical, 'v-slider--vertical': this.vertical, 'v-slider--focused': this.isFocused, 'v-slider--active': this.isActive, 'v-slider--disabled': this.isDisabled, 'v-slider--readonly': this.isReadonly, ...this.themeClasses, }, directives: [{ name: 'click-outside', value: this.onBlur, }], on: { click: this.onSliderClick, }, }, this.genChildren()) }, genChildren (): VNodeChildrenArrayContents { return [ this.genInput(), this.genTrackContainer(), this.genSteps(), this.genThumbContainer( this.internalValue, this.inputWidth, this.isActive, this.isFocused, this.onThumbMouseDown, this.onFocus, this.onBlur, ), ] }, genInput (): VNode { return this.$createElement('input', { attrs: { value: this.internalValue, id: this.computedId, disabled: this.isDisabled, readonly: true, tabindex: -1, ...this.$attrs, }, // on: this.genListeners(), // TODO: do we need to attach the listeners to input? }) }, genTrackContainer (): VNode { const children = [ this.$createElement('div', this.setBackgroundColor(this.computedTrackColor, { staticClass: 'v-slider__track-background', style: this.trackStyles, })), this.$createElement('div', this.setBackgroundColor(this.computedTrackFillColor, { staticClass: 'v-slider__track-fill', style: this.trackFillStyles, })), ] return this.$createElement('div', { staticClass: 'v-slider__track-container', ref: 'track', }, children) }, genSteps (): VNode | null { if (!this.step || !this.showTicks) return null const tickSize = parseFloat(this.tickSize) const range = createRange(this.numTicks + 1) const direction = this.vertical ? 'bottom' : (this.$vuetify.rtl ? 'right' : 'left') const offsetDirection = this.vertical ? (this.$vuetify.rtl ? 'left' : 'right') : 'top' if (this.vertical) range.reverse() const ticks = range.map(index => { const children = [] if (this.tickLabels[index]) { children.push(this.$createElement('div', { staticClass: 'v-slider__tick-label', }, this.tickLabels[index])) } const width = index * (100 / this.numTicks) const filled = this.$vuetify.rtl ? (100 - this.inputWidth) < width : width < this.inputWidth return this.$createElement('span', { key: index, staticClass: 'v-slider__tick', class: { 'v-slider__tick--filled': filled, }, style: { width: `${tickSize}px`, height: `${tickSize}px`, [direction]: `calc(${width}% - ${tickSize / 2}px)`, [offsetDirection]: `calc(50% - ${tickSize / 2}px)`, }, }, children) }) return this.$createElement('div', { staticClass: 'v-slider__ticks-container', class: { 'v-slider__ticks-container--always-show': this.ticks === 'always' || this.tickLabels.length > 0, }, }, ticks) }, genThumbContainer ( value: number, valueWidth: number, isActive: boolean, isFocused: boolean, onDrag: Function, onFocus: Function, onBlur: Function, ref = 'thumb' ): VNode { const children = [this.genThumb()] const thumbLabelContent = this.genThumbLabelContent(value) this.showThumbLabel && children.push(this.genThumbLabel(thumbLabelContent)) return this.$createElement('div', this.setTextColor(this.computedThumbColor, { ref, key: ref, staticClass: 'v-slider__thumb-container', class: { 'v-slider__thumb-container--active': isActive, 'v-slider__thumb-container--focused': isFocused, 'v-slider__thumb-container--show-label': this.showThumbLabel, }, style: this.getThumbContainerStyles(valueWidth), attrs: { role: 'slider', tabindex: this.isDisabled ? -1 : this.$attrs.tabindex ? this.$attrs.tabindex : 0, 'aria-label': this.label, 'aria-valuemin': this.min, 'aria-valuemax': this.max, 'aria-valuenow': this.internalValue, 'aria-readonly': String(this.isReadonly), 'aria-orientation': this.vertical ? 'vertical' : 'horizontal', ...this.$attrs, }, on: { focus: onFocus, blur: onBlur, keydown: this.onKeyDown, keyup: this.onKeyUp, touchstart: onDrag, mousedown: onDrag, }, }), children) }, genThumbLabelContent (value: number | string): ScopedSlotChildren { return this.$scopedSlots['thumb-label'] ? this.$scopedSlots['thumb-label']!({ value }) : [this.$createElement('span', [String(value)])] }, genThumbLabel (content: ScopedSlotChildren): VNode { const size = convertToUnit(this.thumbSize) const transform = this.vertical ? `translateY(20%) translateY(${(Number(this.thumbSize) / 3) - 1}px) translateX(55%) rotate(135deg)` : `translateY(-20%) translateY(-12px) translateX(-50%) rotate(45deg)` 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, transform, }, }), [this.$createElement('div', content)]), ]), ]) }, genThumb (): VNode { return this.$createElement('div', this.setBackgroundColor(this.computedThumbColor, { staticClass: 'v-slider__thumb', })) }, getThumbContainerStyles (width: number): object { const direction = this.vertical ? 'top' : 'left' let value = this.$vuetify.rtl ? 100 - width : width value = this.vertical ? 100 - value : value return { transition: this.trackTransition, [direction]: `${value}%`, } }, onThumbMouseDown (e: MouseEvent) { e.preventDefault() this.oldValue = this.internalValue this.keyPressed = 2 this.isActive = true const mouseUpOptions = passiveSupported ? { passive: true, capture: true } : true const mouseMoveOptions = passiveSupported ? { passive: true } : false if ('touches' in e) { this.app.addEventListener('touchmove', this.onMouseMove, mouseMoveOptions) addOnceEventListener(this.app, 'touchend', this.onSliderMouseUp, mouseUpOptions) } else { this.app.addEventListener('mousemove', this.onMouseMove, mouseMoveOptions) addOnceEventListener(this.app, 'mouseup', this.onSliderMouseUp, mouseUpOptions) } this.$emit('start', this.internalValue) }, onSliderMouseUp (e: Event) { e.stopPropagation() this.keyPressed = 0 const mouseMoveOptions = passiveSupported ? { passive: true } : false this.app.removeEventListener('touchmove', this.onMouseMove, mouseMoveOptions) this.app.removeEventListener('mousemove', this.onMouseMove, mouseMoveOptions) this.$emit('mouseup', e) this.$emit('end', this.internalValue) if (!deepEqual(this.oldValue, this.internalValue)) { this.$emit('change', this.internalValue) this.noClick = true } this.isActive = false }, onMouseMove (e: MouseEvent) { const { value } = this.parseMouseMove(e) this.internalValue = value }, onKeyDown (e: KeyboardEvent) { if (!this.isInteractive) return const value = this.parseKeyDown(e, this.internalValue) if ( value == null || value < this.minValue || value > this.maxValue ) return this.internalValue = value this.$emit('change', value) }, onKeyUp () { this.keyPressed = 0 }, onSliderClick (e: MouseEvent) { if (this.noClick) { this.noClick = false return } const thumb = this.$refs.thumb as HTMLElement thumb.focus() this.onMouseMove(e) this.$emit('change', this.internalValue) }, onBlur (e: Event) { this.isFocused = false this.$emit('blur', e) }, onFocus (e: Event) { this.isFocused = true this.$emit('focus', e) }, parseMouseMove (e: MouseEvent) { const start = this.vertical ? 'top' : 'left' const length = this.vertical ? 'height' : 'width' const click = this.vertical ? 'clientY' : 'clientX' const { [start]: trackStart, [length]: trackLength, } = this.$refs.track.getBoundingClientRect() as any const clickOffset = 'touches' in e ? (e as any).touches[0][click] : e[click] // Can we get rid of any here? // It is possible for left to be NaN, force to number let clickPos = Math.min(Math.max((clickOffset - trackStart) / trackLength, 0), 1) || 0 if (this.vertical) clickPos = 1 - clickPos if (this.$vuetify.rtl) clickPos = 1 - clickPos const isInsideTrack = clickOffset >= trackStart && clickOffset <= trackStart + trackLength const value = parseFloat(this.min) + clickPos * (this.maxValue - this.minValue) return { value, isInsideTrack } }, parseKeyDown (e: KeyboardEvent, value: number) { if (!this.isInteractive) 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.maxValue - this.minValue) / 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 = this.minValue } else if (e.keyCode === end) { value = this.maxValue } else { const direction = e.keyCode === pagedown ? 1 : -1 value = value - (direction * step * (steps > 100 ? steps / 10 : 10)) } return value }, roundValue (value: number): number { 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.minValue % this.stepNumeric const newValue = Math.round((value - offset) / this.stepNumeric) * this.stepNumeric + offset return parseFloat(Math.min(newValue, this.maxValue).toFixed(decimals)) }, }, })