diff --git a/packages/element-plus/index.ts b/packages/element-plus/index.ts index ed49b8aa67..e6ac032b0d 100644 --- a/packages/element-plus/index.ts +++ b/packages/element-plus/index.ts @@ -28,6 +28,7 @@ import ElCollapse from '@element-plus/collapse' import ElPopper from '@element-plus/popper' import ElTabs from '@element-plus/tabs' import ElTooltip from '@element-plus/tooltip' +import ElSlider from '@element-plus/slider' export { ElAlert, @@ -58,6 +59,7 @@ export { ElCollapse, ElTabs, ElTooltip, + ElSlider, } export default function install(app: App): void { @@ -90,4 +92,5 @@ export default function install(app: App): void { ElPopper(app) ElTabs(app) ElTooltip(app) + ElSlider(app) } diff --git a/packages/slider/index.ts b/packages/slider/index.ts new file mode 100644 index 0000000000..4bda5f2390 --- /dev/null +++ b/packages/slider/index.ts @@ -0,0 +1,6 @@ +import { App } from 'vue' +import Slider from './src/index.vue' + +export default (app: App): void => { + app.component(Slider.name, Slider) +} diff --git a/packages/slider/package.json b/packages/slider/package.json new file mode 100644 index 0000000000..86487c9f31 --- /dev/null +++ b/packages/slider/package.json @@ -0,0 +1,12 @@ +{ + "name": "@element-plus/slider", + "version": "0.0.0", + "main": "dist/index.js", + "license": "MIT", + "peerDependencies": { + "vue": "^3.0.0-rc.5" + }, + "devDependencies": { + "@vue/test-utils": "^2.0.0-beta.0" + } +} diff --git a/packages/slider/src/Slider.d.ts b/packages/slider/src/Slider.d.ts new file mode 100644 index 0000000000..76398b7045 --- /dev/null +++ b/packages/slider/src/Slider.d.ts @@ -0,0 +1,110 @@ +import { ComputedRef, Ref } from 'vue' + +export interface ISliderProps { + modelValue: number | number[] + min: number + max: number + step: number + value: number | number[] + showInput: boolean + showInputControls: boolean + inputSize: string + showStops: boolean + showTooltip: boolean + formatTooltip: (val: number) => number | string + disabled: boolean + range: boolean + vertical: boolean + height: string + debounce: number + label: string + tooltipClass: string + marks: Record +} + +export interface ISliderInitData { + firstValue: Nullable + secondValue: Nullable + oldValue: Nullable + dragging: boolean + sliderSize: number +} + +export interface ISliderProvider { + disabled: ComputedRef + min: ComputedRef + max: ComputedRef + step: ComputedRef + showTooltip: ComputedRef + precision: ComputedRef + sliderSize: ComputedRef + formatTooltip: ComputedRef<(value: number) => number | string> + emitChange: () => void + resetSize: () => void + updateDragging: (val: boolean) => void +} + +export type Mark = { + point: number + position: number + mark: {[s: string]: any;} +} + +export type Marks = ComputedRef + +export type Stops = { + stops: ComputedRef + getStopStyle: (position: number) => CSSStyleDeclaration +} + +export type Slide = { + slider: Ref + firstButton: Ref + secondButton: Ref + sliderDisabled: ComputedRef + minValue: ComputedRef + maxValue: ComputedRef + runwayStyle: ComputedRef + barStyle: ComputedRef + resetSize: () => void + setPosition: (percent: number) => void + emitChange: () => void + onSliderClick: (event: MouseEvent) => void +} + +export type ButtonRefs = { + [s in 'firstButton' | 'secondButton']: Ref> +} + +export interface ISliderButtonProps { + modelValue: number + vertical: boolean + tooltipClass: string +} + +export interface ISliderButtonInitData { + hovering: boolean + dragging: boolean + isClick: boolean + startX: number + currentX: number + startY: number + currentY: number + startPosition: number + newPosition: number + oldValue: number +} + +export interface ISliderButton { + tooltip: Ref> + tooltipVisible: Ref + showTooltip: Ref + wrapperStyle: ComputedRef + formatValue: ComputedRef + handleMouseEnter: () => void + handleMouseLeave: () => void + onButtonDown: (event: MouseEvent | TouchEvent) => void + onLeftKeyDown: () => void + onRightKeyDown: () => void + setPosition: (newPosition: number) => void +} diff --git a/packages/slider/src/button.vue b/packages/slider/src/button.vue new file mode 100644 index 0000000000..04010c6551 --- /dev/null +++ b/packages/slider/src/button.vue @@ -0,0 +1,119 @@ + + + diff --git a/packages/slider/src/index.vue b/packages/slider/src/index.vue new file mode 100644 index 0000000000..2abf952727 --- /dev/null +++ b/packages/slider/src/index.vue @@ -0,0 +1,420 @@ + + + diff --git a/packages/slider/src/marker.vue b/packages/slider/src/marker.vue new file mode 100644 index 0000000000..40fbffbc63 --- /dev/null +++ b/packages/slider/src/marker.vue @@ -0,0 +1,28 @@ + diff --git a/packages/slider/src/useMarks.ts b/packages/slider/src/useMarks.ts new file mode 100644 index 0000000000..44e222e776 --- /dev/null +++ b/packages/slider/src/useMarks.ts @@ -0,0 +1,21 @@ +import { computed } from 'vue' +import { ISliderProps, Mark } from './Slider' + + +export const useMarks = (props: ISliderProps) => { + return computed(() => { + if (!props.marks) { + return [] + } + + const marksKeys = Object.keys(props.marks) + return marksKeys.map(parseFloat) + .sort((a, b) => a - b) + .filter(point => point <= props.max && point >= props.min) + .map((point): Mark => ({ + point, + position: (point - props.min) * 100 / (props.max - props.min), + mark: props.marks[point], + })) + }) +} diff --git a/packages/slider/src/useSlide.ts b/packages/slider/src/useSlide.ts new file mode 100644 index 0000000000..d2b313566f --- /dev/null +++ b/packages/slider/src/useSlide.ts @@ -0,0 +1,110 @@ +import { computed, inject, nextTick, ref } from 'vue' +import { ButtonRefs, ISliderInitData, ISliderProps } from './Slider' + +export const useSlide = (props: ISliderProps, initData: ISliderInitData, emit) => { + const elForm: {disabled?: boolean;} = inject('elForm', {}) + + const slider = ref>(null) + + const firstButton = ref(null) + + const secondButton = ref(null) + + const buttonRefs: ButtonRefs = { + firstButton, + secondButton, + } + + const sliderDisabled = computed(() => { + return props.disabled || (elForm?.disabled || false) + }) + + const minValue = computed(() => { + return Math.min(initData.firstValue, initData.secondValue) + }) + + const maxValue = computed(() => { + return Math.max(initData.firstValue, initData.secondValue) + }) + + const barSize = computed(() => { + return props.range + ? `${ 100 * (maxValue.value - minValue.value) / (props.max - props.min) }%` + : `${ 100 * (initData.firstValue - props.min) / (props.max - props.min) }%` + }) + + const barStart = computed(() => { + return props.range + ? `${ 100 * (minValue.value - props.min) / (props.max - props.min) }%` + : '0%' + }) + + const runwayStyle = computed(() => { + return (props.vertical ? { height: props.height } : {}) as CSSStyleDeclaration + }) + + const barStyle = computed(() => { + return (props.vertical + ? { + height: barSize.value, + bottom: barStart.value, + } : { + width: barSize.value, + left: barStart.value, + }) as CSSStyleDeclaration + }) + + const resetSize = () => { + if (slider.value) { + initData.sliderSize = slider.value[`client${ props.vertical ? 'Height' : 'Width' }`] + } + } + + const setPosition = (percent: number) => { + const targetValue = props.min + percent * (props.max - props.min) / 100 + if (!props.range) { + firstButton.value.setPosition(percent) + return + } + let buttonRefName: string + if (Math.abs(minValue.value - targetValue) < Math.abs(maxValue.value - targetValue)) { + buttonRefName = initData.firstValue < initData.secondValue ? 'firstButton' : 'secondButton' + } else { + buttonRefName = initData.firstValue > initData.secondValue ? 'firstButton' : 'secondButton' + } + buttonRefs[buttonRefName].value.setPosition(percent) + } + + const emitChange = async () => { + await nextTick() + emit('change', props.range ? [minValue.value, maxValue.value] : props.modelValue) + } + + const onSliderClick = (event: MouseEvent) => { + if (sliderDisabled.value || initData.dragging) return + resetSize() + if (props.vertical) { + const sliderOffsetBottom = slider.value.getBoundingClientRect().bottom + setPosition((sliderOffsetBottom - event.clientY) / initData.sliderSize * 100) + } else { + const sliderOffsetLeft = slider.value.getBoundingClientRect().left + setPosition((event.clientX - sliderOffsetLeft) / initData.sliderSize * 100) + } + emitChange() + } + + return { + slider, + firstButton, + secondButton, + sliderDisabled, + minValue, + maxValue, + runwayStyle, + barStyle, + resetSize, + setPosition, + emitChange, + onSliderClick, + } +} diff --git a/packages/slider/src/useSliderButton.ts b/packages/slider/src/useSliderButton.ts new file mode 100644 index 0000000000..cf99268689 --- /dev/null +++ b/packages/slider/src/useSliderButton.ts @@ -0,0 +1,221 @@ +import { UPDATE_MODEL_EVENT } from '@element-plus/utils/constants' +import { off, on } from '@element-plus/utils/dom' +import { computed, ComputedRef, inject, nextTick, ref, watch } from 'vue' +import { ISliderButtonInitData, ISliderButtonProps, ISliderProvider } from './Slider' + + +const useTooltip = (props: ISliderButtonProps, formatTooltip: ComputedRef<(value: number) => number | string>, showTooltip: ComputedRef) => { + + const tooltip = ref(null) + + const tooltipVisible = ref(false) + + const enableFormat = computed(() => { + return formatTooltip.value instanceof Function + }) + + const formatValue = computed(() => { + return enableFormat.value && formatTooltip.value(props.modelValue) || props.modelValue + }) + + const displayTooltip = () => { + showTooltip.value && (tooltipVisible.value = true) + } + + const hideTooltip = () => { + showTooltip.value && (tooltipVisible.value = false) + } + + return { + tooltip, + tooltipVisible, + formatValue, + displayTooltip, + hideTooltip, + } +} + + +export const useSliderButton = (props: ISliderButtonProps, initData: ISliderButtonInitData, emit) => { + const { + disabled, + min, + max, + step, + showTooltip, + precision, + sliderSize, + formatTooltip, + emitChange, + resetSize, + updateDragging, + } = inject('SliderProvider') + + const { + tooltip, + tooltipVisible, + formatValue, + displayTooltip, + hideTooltip, + } = useTooltip(props, formatTooltip, showTooltip) + + const currentPosition = computed(() => { + return `${ (props.modelValue - min.value) / (max.value - min.value) * 100 }%` + }) + + const wrapperStyle = computed(() => { + return (props.vertical ? { bottom: currentPosition.value } : { left: currentPosition.value }) as CSSStyleDeclaration + }) + + + const handleMouseEnter = () => { + initData.hovering = true + displayTooltip() + } + + const handleMouseLeave = () => { + initData.hovering = false + hideTooltip() + } + + const onButtonDown = (event: MouseEvent | TouchEvent) => { + if (disabled.value) return + event.preventDefault() + onDragStart(event) + on(window, 'mousemove', onDragging) + on(window, 'touchmove', onDragging) + on(window, 'mouseup', onDragEnd) + on(window, 'touchend', onDragEnd) + on(window, 'contextmenu', onDragEnd) + } + + const onLeftKeyDown = () => { + if (disabled.value) return + initData.newPosition = parseFloat(currentPosition.value) - step.value / (max.value - min.value) * 100 + setPosition(initData.newPosition) + emitChange() + } + + const onRightKeyDown = () => { + if (disabled.value) return + initData.newPosition = parseFloat(currentPosition.value) + step.value / (max.value - min.value) * 100 + setPosition(initData.newPosition) + emitChange() + } + + const getClientXY = (event: MouseEvent | TouchEvent) => { + let clientX: number + let clientY: number + if (event.type === 'touchstart') { + clientY = (event as TouchEvent).touches[0].clientY + clientX = (event as TouchEvent).touches[0].clientX + } else { + clientY = (event as MouseEvent).clientY + clientX = (event as MouseEvent).clientX + } + return { + clientX, + clientY, + } + } + + const onDragStart = (event: MouseEvent | TouchEvent) => { + initData.dragging = true + initData.isClick = true + const { + clientX, + clientY, + } = getClientXY(event) + if (props.vertical) { + initData.startY = clientY + } else { + initData.startX = clientX + } + initData.startPosition = parseFloat(currentPosition.value) + initData.newPosition = initData.startPosition + } + + const onDragging = (event: MouseEvent | TouchEvent) => { + if (initData.dragging) { + initData.isClick = false + displayTooltip() + resetSize() + let diff: number + const { + clientX, + clientY, + } = getClientXY(event) + if (props.vertical) { + initData.currentY = clientY + diff = (initData.startY - initData.currentY) / sliderSize.value * 100 + } else { + initData.currentX = clientX + diff = (initData.currentX - initData.startX) / sliderSize.value * 100 + } + initData.newPosition = initData.startPosition + diff + setPosition(initData.newPosition) + } + } + + const onDragEnd = () => { + if (initData.dragging) { + /* + * 防止在 mouseup 后立即触发 click,导致滑块有几率产生一小段位移 + * 不使用 preventDefault 是因为 mouseup 和 click 没有注册在同一个 DOM 上 + */ + setTimeout(() => { + initData.dragging = false + hideTooltip() + if (!initData.isClick) { + setPosition(initData.newPosition) + emitChange() + } + }, 0) + off(window, 'mousemove', onDragging) + off(window, 'touchmove', onDragging) + off(window, 'mouseup', onDragEnd) + off(window, 'touchend', onDragEnd) + off(window, 'contextmenu', onDragEnd) + } + } + + const setPosition = async (newPosition: number) => { + if (newPosition === null || isNaN(newPosition)) return + if (newPosition < 0) { + newPosition = 0 + } else if (newPosition > 100) { + newPosition = 100 + } + const lengthPerStep = 100 / ((max.value - min.value) / step.value) + const steps = Math.round(newPosition / lengthPerStep) + let value = steps * lengthPerStep * (max.value - min.value) * 0.01 + min.value + value = parseFloat(value.toFixed(precision.value)) + emit(UPDATE_MODEL_EVENT, value) + + if (!initData.dragging && props.modelValue !== initData.oldValue) { + initData.oldValue = props.modelValue + } + + await nextTick() + initData.dragging && displayTooltip() + tooltip.value && tooltip.value.popperInstance.update() + } + + watch(() => initData.dragging, val => { + updateDragging(val) + }) + + return { + tooltip, + tooltipVisible, + showTooltip, + wrapperStyle, + formatValue, + handleMouseEnter, + handleMouseLeave, + onButtonDown, + onLeftKeyDown, + onRightKeyDown, + setPosition, + } +} diff --git a/packages/slider/src/useStops.ts b/packages/slider/src/useStops.ts new file mode 100644 index 0000000000..4db6b2e035 --- /dev/null +++ b/packages/slider/src/useStops.ts @@ -0,0 +1,34 @@ +import { computed, ComputedRef } from 'vue' +import { ISliderInitData, ISliderProps, Stops } from './Slider' + +export const useStops = (props: ISliderProps, initData: ISliderInitData, minValue: ComputedRef, maxValue: ComputedRef): Stops => { + const stops = computed(() => { + if (!props.showStops || props.min > props.max) return [] + if (props.step === 0) { + process.env.NODE_ENV !== 'production' && console.warn('[Element Warn][Slider]step should not be 0.') + return [] + } + + const stopCount = (props.max - props.min) / props.step + const stepWidth = 100 * props.step / (props.max - props.min) + const result = Array.from({ length: stopCount - 1 }).map((_, index) => ((index + 1) * stepWidth)) + + if (props.range) { + return result.filter(step => { + return step < 100 * (minValue.value - props.min) / (props.max - props.min) + || step > 100 * (maxValue.value - props.min) / (props.max - props.min) + }) + } else { + return result.filter(step => step > 100 * (initData.firstValue - props.min) / (props.max - props.min)) + } + }) + + const getStopStyle = (position: number) => { + return (props.vertical ? { 'bottom': position + '%' } : { 'left': position + '%' }) as CSSStyleDeclaration + } + + return { + stops, + getStopStyle, + } +}