fix: 🐛 修复 Slider 处理边界值异常的问题,优化样式和事件处理逻辑 (#1050)

 Closes: #1023
This commit is contained in:
不如摸鱼去 2025-05-14 21:43:39 +08:00 committed by GitHub
parent 33b556546a
commit 0d7ed8129c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 336 additions and 295 deletions

View File

@ -1,10 +1,10 @@
<!--
* @Author: weisheng
* @Date: 2023-10-10 17:02:32
* @LastEditTime: 2025-03-30 21:45:09
* @LastEditTime: 2025-05-13 13:16:42
* @LastEditors: weisheng
* @Description:
* @FilePath: /wot-design-uni/src/pages/slider/Index.vue
* @FilePath: /wot-design-uni/src/subPages/slider/Index.vue
* 记得注释
-->
<template>
@ -14,6 +14,7 @@
</demo-block>
<demo-block :title="$t('zhi-ding-xuan-ze-fan-wei')">
<wd-slider v-model="value2" :min="-10" :max="10" />
<wd-slider v-model="value8" :min="160" :max="280" :step="30" />
</demo-block>
<demo-block :title="$t('zhi-ding-bu-chang')">
<wd-slider v-model="value4" hide-min-max :step="10" />
@ -34,6 +35,8 @@ import { ref } from 'vue'
const value1 = ref<number>(30)
const value2 = ref<number>(479)
const value8 = ref<number>(185)
const value4 = ref<number>(11)
const value5 = ref<number>(70)
const value6 = ref<number[]>([20, 40])

View File

@ -58,10 +58,16 @@
}
@include e(button-wrapper) {
width: calc($-slider-handle-radius * 2);
/* 右侧滑块按钮定位向右偏移自身宽度的50% */
transform: translate3d(50%, -50%, 0);
position: absolute;
right: 0;
top: 0;
transform: translate3d(-50%, -50%, 0);
@include m(left){
left: 0;
/* 左侧滑块按钮定位向左偏移自身宽度的50% */
transform: translate3d(-50%, -50%, 0);
}
}
@include e(has-label) {
padding-top: calc($-slider-fs * 1.2 + 8px);

View File

@ -1,15 +1,11 @@
/*
* @Author: weisheng
* @Date: 2024-06-03 23:43:43
* @LastEditTime: 2024-06-06 22:03:57
* @LastEditors: weisheng
* @Description:
* @FilePath: /wot-design-uni/src/uni_modules/wot-design-uni/components/wd-slider/types.ts
*
*/
import type { ComponentPublicInstance, PropType } from 'vue'
import type { ComponentPublicInstance, ExtractPropTypes, PropType } from 'vue'
import { baseProps, makeBooleanProp, makeNumberProp, makeStringProp } from '../common/props'
/**
* -
*/
export type SliderValue = number | number[]
export const sliderProps = {
...baseProps,
@ -89,11 +85,47 @@ export const sliderProps = {
* 默认值: 0
*/
modelValue: {
type: [Number, Array] as PropType<number | number[]>,
type: [Number, Array] as PropType<SliderValue>,
default: 0
}
}
/**
*
*/
export interface SliderDragEvent {
/**
*
*
*/
value: SliderValue
}
/**
*
*/
export type SliderEmits = {
/**
*
*/
dragstart: [event: SliderDragEvent]
/**
*
*/
dragmove: [event: SliderDragEvent]
/**
*
*/
dragend: [event: SliderDragEvent]
/**
* v-model绑定
*/
'update:modelValue': [value: SliderValue]
}
export type SliderExpose = {
/**
* slider宽度
@ -101,4 +133,6 @@ export type SliderExpose = {
initSlider: () => void
}
export type SliderInstance = ComponentPublicInstance<SliderExpose>
export type SliderProps = ExtractPropTypes<typeof sliderProps>
export type SliderInstance = ComponentPublicInstance<SliderProps, SliderExpose>

View File

@ -3,39 +3,51 @@
<!-- #ifdef MP-DINGTALK -->
<view :id="sliderId" style="flex: 1" :class="rootClass">
<!-- #endif -->
<view :class="`wd-slider__label-min ${customMinClass}`" v-if="!hideMinMax">
{{ minValue }}
</view>
<view class="wd-slider__bar-wrapper" :style="barWrapperStyle">
<view class="wd-slider__bar" :style="barCustomStyle"></view>
<!-- 左边 -->
<view
class="wd-slider__button-wrapper"
:style="buttonLeftStyle"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
@touchcancel="onTouchEnd"
>
<view class="wd-slider__label" v-if="!hideLabel">{{ leftNewValue }}</view>
<view class="wd-slider__button" />
</view>
<!-- 右边 -->
<view
v-if="showRight"
class="wd-slider__button-wrapper"
:style="buttonRightStyle"
@touchstart="onTouchStartRight"
@touchmove="onTouchMoveRight"
@touchend="onTouchEndRight"
@touchcancel="onTouchEndRight"
>
<view class="wd-slider__label" v-if="!hideLabel">{{ rightNewValue }}</view>
<view class="wd-slider__button" />
<view :class="`wd-slider__label-min ${customMinClass}`" v-if="!hideMinMax">{{ minProp }}</view>
<view class="wd-slider__bar-wrapper" :style="wrapperStyle">
<view class="wd-slider__bar" :style="barStyle">
<template v-if="isRange">
<!-- 左边滑块 -->
<view
class="wd-slider__button-wrapper wd-slider__button-wrapper--left"
:style="sliderButtonStyle"
@touchstart="(event) => onSliderTouchStart(event, 0)"
@touchmove="onSliderTouchMove"
@touchend="onSliderTouchEnd"
@touchcancel="onSliderTouchEnd"
>
<view class="wd-slider__label" v-if="!hideLabel">{{ firstValue }}</view>
<view class="wd-slider__button" />
</view>
<!-- 右边滑块 -->
<view
class="wd-slider__button-wrapper wd-slider__button-wrapper--right"
:style="sliderButtonStyle"
@touchstart="(event) => onSliderTouchStart(event, 1)"
@touchmove="onSliderTouchMove"
@touchend="onSliderTouchEnd"
@touchcancel="onSliderTouchEnd"
>
<view class="wd-slider__label" v-if="!hideLabel">{{ secondValue }}</view>
<view class="wd-slider__button" />
</view>
</template>
<view
v-else
class="wd-slider__button-wrapper"
:style="sliderButtonStyle"
@touchstart="(event) => onSliderTouchStart(event, 0)"
@touchmove="onSliderTouchMove"
@touchend="onSliderTouchEnd"
@touchcancel="onSliderTouchEnd"
>
<view class="wd-slider__label" v-if="!hideLabel">{{ firstValue }}</view>
<view class="wd-slider__button" />
</view>
</view>
</view>
<view :class="`wd-slider__label-max ${customMaxClass}`" v-if="!hideMinMax">
{{ maxValue }}
{{ maxProp }}
</view>
<!-- #ifdef MP-DINGTALK -->
</view>
@ -55,298 +67,284 @@ export default {
</script>
<script lang="ts" setup>
import { computed, getCurrentInstance, onMounted, ref } from 'vue'
import { getRect, isArray, isDef, isNumber, uuid } from '../common/util'
import { computed, type CSSProperties, getCurrentInstance, onMounted, ref, watch } from 'vue'
import { deepClone, getRect, isArray, isDef, isEqual, objToStyle, uuid } from '../common/util'
import { useTouch } from '../composables/useTouch'
import { watch } from 'vue'
import { sliderProps, type SliderExpose } from './types'
import { sliderProps, type SliderExpose, type SliderEmits, type SliderDragEvent, type SliderValue } from './types'
const props = defineProps(sliderProps)
const emit = defineEmits(['dragstart', 'dragmove', 'dragend', 'update:modelValue'])
const emit = defineEmits<SliderEmits>()
//
const rightSlider = {
startValue: 0,
deltaX: 0,
newValue: 0
}
// ----------- -----------
const sliderId = ref<string>(`sliderId${uuid()}`)
const touch = useTouch()
const touchIndex = ref<number>(0)
const { proxy } = getCurrentInstance() as any
const touchLeft = useTouch()
const touchRight = useTouch()
const showRight = ref<boolean>(false)
const barStyle = ref<string>('')
const leftNewValue = ref<number>(0)
const rightNewValue = ref<number>(0)
const rightBarPercent = ref<number>(0)
const leftBarPercent = ref<number>(0)
// ----------- -----------
const trackWidth = ref<number>(0)
const trackLeft = ref<number>(0)
const startValue = ref<number>(0)
const currentValue = ref<number | number[]>(0)
const newValue = ref<number>(0)
const maxValue = ref<number>(100) //
const minValue = ref<number>(0) //
const stepValue = ref<number>(1) //
// ----------- -----------
/**
* 最小值 - 直接使用props减少状态同步
*/
const minProp = computed(() => props.min)
watch(
() => props.max,
(newValue) => {
if (newValue <= props.min) {
maxValue.value = props.min //
minValue.value = newValue
console.warn('[wot ui] warning(wd-slider): max value must be greater than min value')
} else {
maxValue.value = newValue //
}
calcBarPercent()
},
{ immediate: true }
)
/**
* 最大值 - 直接使用props减少状态同步
*/
const maxProp = computed(() => props.max)
watch(
() => props.min,
(newValue) => {
if (newValue >= props.max) {
minValue.value = props.max //
maxValue.value = newValue
console.warn('[wot ui] warning(wd-slider): min value must be less than max value')
} else {
minValue.value = newValue //
}
calcBarPercent()
},
{ immediate: true }
)
/**
* 步长值 - 直接使用props减少状态同步
*/
const stepProp = computed(() => {
if (props.step <= 0) {
console.warn('[wot ui] warning(wd-slider): step must be greater than 0')
return 1
}
return props.step
})
watch(
() => props.step,
(newValue) => {
if (newValue < 1) {
stepValue.value = 1
console.warn('[wot ui] warning(wd-slider): step must be greater than 0')
} else {
stepValue.value = Math.floor(newValue)
}
},
{ immediate: true }
)
const startValue = ref<SliderValue>(0)
const modelValue = ref<SliderValue>(getInitValue())
// ----------- -----------
/**
* 是否为双滑块模式
*/
const isRange = computed(() => isArray(modelValue.value))
/**
* 计算滑块的取值范围大小
*/
const scope = computed(() => maxProp.value - minProp.value)
/**
* 获取左侧滑块的值
*/
const firstValue = computed(() => (isRange.value ? (modelValue.value as number[])[0] : (modelValue.value as number)))
/**
* 获取右侧滑块的值仅双滑块模式有效
*/
const secondValue = computed(() => (isRange.value ? (modelValue.value as number[])[1] : 0))
/**
* 根类名计算
*/
const rootClass = computed(() => {
return `wd-slider ${!props.hideLabel ? 'wd-slider__has-label' : ''} ${props.disabled ? 'wd-slider--disabled' : ''} ${props.customClass}`
})
/**
* 滑块按钮样式
*/
const sliderButtonStyle = computed(() => {
return objToStyle({
visibility: !props.disabled ? 'visible' : 'hidden'
})
})
/**
* 轨道包装器样式
*/
const wrapperStyle = computed(() => {
const style: CSSProperties = {}
if (props.inactiveColor) {
style.background = props.inactiveColor
}
return objToStyle(style)
})
/**
* 进度条样式计算
*/
const barStyle = computed(() => {
const style: CSSProperties = {}
if (scope.value === 0) return objToStyle(style)
if (isRange.value) {
//
const [left, right] = normalizeRangeValues(modelValue.value as number[])
style.width = `${((right - left) * 100) / scope.value}%`
style.left = `${((left - minProp.value) * 100) / scope.value}%`
} else {
//
style.width = `${(((modelValue.value as number) - minProp.value) * 100) / scope.value}%`
style.left = '0'
}
//
if (props.activeColor) {
style.background = props.activeColor
}
return objToStyle(style)
})
// ----------- -----------
/**
* 监听 modelValue 属性变化
*/
watch(
() => props.modelValue,
(newValue) => {
// (nullundefinedundefinedvoid (0)undefined)
if (!isDef(newValue)) {
emit('update:modelValue', currentValue.value)
console.warn('[wot ui] warning(wd-slider): value can nott be null or undefined')
} else if (isArray(newValue) && newValue.length !== 2) {
console.warn('[wot ui] warning(wd-slider): value must be dyadic array')
} else if (!isNumber(newValue) && !isArray(newValue)) {
emit('update:modelValue', currentValue.value)
console.warn('[wot ui] warning(wd-slider): value must be dyadic array Or Number')
}
//
if (isArray(newValue)) {
if (currentValue.value && isArray(currentValue.value) && equal(newValue, currentValue.value)) return
currentValue.value = newValue
showRight.value = true
if (leftBarPercent.value <= rightBarPercent.value) {
leftBarSlider(newValue[0])
rightBarSlider(newValue[1])
} else {
leftBarSlider(newValue[1])
rightBarSlider(newValue[0])
}
} else {
if (newValue === currentValue.value) return
currentValue.value = newValue
leftBarSlider(newValue)
if (!isEqual(newValue, modelValue.value)) {
modelValue.value = getInitValue()
}
},
{ deep: true, immediate: true }
{ deep: true }
)
const { proxy } = getCurrentInstance() as any
const rootClass = computed(() => {
const rootClass = `wd-slider ${!props.hideLabel ? 'wd-slider__has-label' : ''} ${props.disabled ? 'wd-slider--disabled' : ''} ${props.customClass}`
return rootClass
})
const barWrapperStyle = computed(() => {
const barWrapperStyle = `${props.inactiveColor ? 'background:' + props.inactiveColor : ''}`
return barWrapperStyle
})
const barCustomStyle = computed(() => {
const barWrapperStyle = `${barStyle.value};${props.activeColor ? 'background:' + props.activeColor : ''}`
return barWrapperStyle
})
const buttonLeftStyle = computed(() => {
const buttonLeftStyle = `left: ${leftBarPercent.value}%; visibility: ${!props.disabled ? 'show' : 'hidden'};`
return buttonLeftStyle
})
const buttonRightStyle = computed(() => {
const buttonRightStyle = `left: ${rightBarPercent.value}%; visibility: ${!props.disabled ? 'show' : 'hidden'}`
return buttonRightStyle
/**
* 向上发射模型值变化
*/
watch(modelValue, (newVal) => {
emit('update:modelValue', newVal)
})
// ----------- -----------
onMounted(() => {
initSlider()
})
// ----------- -----------
/**
* 初始化slider宽度
* 检查组件是否处于禁用状态
*/
function isDisabled(): boolean {
return props.disabled
}
/**
* 限制值在指定范围内
*/
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max)
}
// ----------- -----------
/**
* 初始化滑块宽度
*/
function initSlider() {
getRect(`#${sliderId.value}`, false, proxy).then((data) => {
// trackWidth:
trackWidth.value = Number(data.width)
// trackLeft:
trackLeft.value = Number(data.left)
})
}
function onTouchStart(event: any) {
const { disabled, modelValue } = props
if (disabled) return
touchLeft.touchStart(event)
startValue.value = !isArray(modelValue)
? format(modelValue)
: leftBarPercent.value < rightBarPercent.value
? format(modelValue[0])
: format(modelValue[1])
emit('dragstart', {
value: currentValue.value
})
}
function onTouchMove(event: any) {
const { disabled } = props
if (disabled) return
touchLeft.touchMove(event)
// deltaX (-)(+)
const diff = (touchLeft.deltaX.value / trackWidth.value) * (maxValue.value - minValue.value)
newValue.value = startValue.value + diff
//
leftBarSlider(newValue.value)
emit('dragmove', {
value: currentValue.value
})
}
function onTouchEnd() {
if (props.disabled) return
emit('dragend', {
value: currentValue.value
})
}
//
function onTouchStartRight(event: any) {
if (props.disabled) return
const { modelValue } = props
//
touchRight.touchStart(event)
//
rightSlider.startValue = leftBarPercent.value < rightBarPercent.value ? format((modelValue as number[])[1]) : format((modelValue as number[])[0])
emit('dragstart', {
value: currentValue.value
})
}
function onTouchMoveRight(event: any) {
if (props.disabled) return
touchRight.touchMove(event)
// deltaX
const diff = (touchRight.deltaX.value / trackWidth.value) * (maxValue.value - minValue.value)
rightSlider.newValue = format(rightSlider.startValue + diff)
//
rightBarSlider(rightSlider.newValue)
emit('dragmove', {
value: currentValue.value
})
}
function onTouchEndRight() {
if (props.disabled) return
emit('dragend', {
value: currentValue.value
})
}
/**
* 控制右侧滑轮滑动 value校验
* @param {Number} value 当前滑轮绑定值
* 获取初始值
*/
function rightBarSlider(value: number) {
value = format(value)
rightNewValue.value = value
emit('update:modelValue', [Math.min(leftNewValue.value, rightNewValue.value), Math.max(leftNewValue.value, rightNewValue.value)])
rightBarPercent.value = formatPercent(value)
styleControl()
}
/**
* 控制左滑轮滑动更新渲染数据 value 进行校验取整
* @param {Number} value 当前滑轮绑定值
*/
function leftBarSlider(value: number) {
value = format(value)
// value
const percent = formatPercent(value)
if (!showRight.value) {
emit('update:modelValue', value)
leftNewValue.value = value
leftBarPercent.value = percent
barStyle.value = `width: ${percent}%; `
function getInitValue(): SliderValue {
if (isArray(props.modelValue)) {
return normalizeRangeValues(props.modelValue as number[])
} else {
leftNewValue.value = value
leftBarPercent.value = percent
emit('update:modelValue', [Math.min(leftNewValue.value, rightNewValue.value), Math.max(leftNewValue.value, rightNewValue.value)])
styleControl()
return clamp(props.modelValue as number, minProp.value, maxProp.value)
}
}
//
function styleControl() {
//
const barLeft =
leftBarPercent.value < rightBarPercent.value ? [leftBarPercent.value, rightBarPercent.value] : [rightBarPercent.value, leftBarPercent.value]
// barLeft[1] - barLeft[0]
const barStyleTemp = `width: ${barLeft[1] - barLeft[0]}%; left: ${barLeft[0]}%`
currentValue.value =
leftNewValue.value < rightNewValue.value ? [leftNewValue.value, rightNewValue.value] : [rightNewValue.value, leftNewValue.value]
barStyle.value = barStyleTemp
}
function equal(arr1: number[], arr2: number[]) {
if (!isDef(arr1) || !isDef(arr2)) {
return false
/**
* 处理双滑块模式下的值确保值有效
*/
function normalizeRangeValues(value: number[]): [number, number] {
//
if (!Array.isArray(value) || value.length < 2) {
console.warn('[wot ui] warning(wd-slider): range value should be an array with at least 2 elements')
return [minProp.value, maxProp.value]
}
let i = 0
arr1.forEach((item, index) => {
item === arr2[index] && i++
})
return i === 2
}
function format(value: number) {
const formatValue = Math.round(Math.max(minValue.value, Math.min(value, maxValue.value)) / stepValue.value) * stepValue.value
return formatValue
const left = clamp(value[0], minProp.value, maxProp.value)
const right = clamp(value[1], minProp.value, maxProp.value)
return left > right ? [right, left] : [left, right]
}
//
function formatPercent(value: number) {
const percentage = ((value - minValue.value) / (maxValue.value - minValue.value)) * 100
return percentage
/**
* 将值对齐到最近的步长倍数
*/
function snapValueToStep(value: number): number {
//
value = clamp(value, minProp.value, maxProp.value)
//
const steps = Math.round((value - minProp.value) / stepProp.value)
//
return parseFloat((minProp.value + steps * stepProp.value).toFixed(10))
}
//
function calcBarPercent() {
const { modelValue } = props
let value = !isArray(modelValue) ? format(modelValue) : leftBarPercent.value < rightBarPercent.value ? format(modelValue[0]) : format(modelValue[1])
value = format(value)
// value
const percent = formatPercent(value)
leftBarPercent.value = percent
barStyle.value = `width: ${percent}%; `
/**
* 统一更新滑块值的函数
*/
function updateValue(value: SliderValue) {
let newValue: SliderValue = deepClone(value)
if (isArray(value)) {
newValue = normalizeRangeValues(value as number[]).map((v) => snapValueToStep(v)) as [number, number]
} else {
newValue = snapValueToStep(value as number)
}
if (!isEqual(newValue, modelValue.value)) {
modelValue.value = newValue
}
}
// ----------- -----------
/**
* 滑块触摸开始事件处理
*/
function onSliderTouchStart(event: any, index: number) {
if (isDisabled()) return
touchIndex.value = index
touch.touchStart(event)
//
startValue.value = deepClone(modelValue.value)
//
emit('dragstart', { value: modelValue.value })
}
/**
* 滑块触摸移动事件处理
*/
function onSliderTouchMove(event: any) {
if (isDisabled()) return
//
touch.touchMove(event)
//
const diff = (touch.deltaX.value / trackWidth.value) * scope.value
let newValue = deepClone(startValue.value)
if (isArray(newValue)) {
;(newValue as number[])[touchIndex.value] += diff
} else {
newValue = (newValue as number) + diff
}
updateValue(newValue)
//
emit('dragmove', { value: modelValue.value })
}
/**
* 滑块触摸结束事件处理
*/
function onSliderTouchEnd() {
if (isDisabled()) return
emit('dragend', { value: modelValue.value })
}
defineExpose<SliderExpose>({

View File

@ -63,8 +63,8 @@ describe('WdSlider', () => {
// 检查是否四舍五入到最近的步长值
const emitted = wrapper.emitted() as Record<string, any[]>
expect(emitted['update:modelValue']).toBeTruthy()
// 由于 format 函数会将值四舍五入到最近的步长值,所以 25 应该被四舍五入到 30
expect(emitted['update:modelValue'][0][0]).toBe(30)
// 初始化时slider只处理边界值所以 25 不会被处理
expect(emitted['update:modelValue'][0][0]).toBe(25)
})
// 测试自定义样式