Files
mini-yu/uni_modules/tdesign-uniapp/components/slider/slider.vue
lingxiao865 c5af079d8c first commit
2026-02-10 08:05:03 +08:00

720 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view
:style="tools._style([customStyle])"
:class="
tools.cls(classPrefix, [
['top', label || scaleTextArray.length],
['disabled', disabled],
['range', range]
]) +
' ' +
tClass +
' ' +
(vertical ? classPrefix + '--vertical' : '')
"
>
<block v-if="!range">
<text
v-if="showExtremeValue"
:class="classPrefix + '__value ' + classPrefix + '__value--min'"
>
{{ label ? getValue(label, min) : min }}
</text>
<view
id="sliderLine"
:class="tools.cls(classPrefix + '__bar', [['disabled', disabled], theme, ['marks', isScale && theme == 'capsule']]) + ' ' + tClassBar"
@click="onSingleLineTap"
>
<block v-if="isScale">
<view
v-for="(item, index) in scaleArray"
:key="index"
:class="
tools.cls(classPrefix + '__scale-item', [
['active', _value >= item.val],
['disabled', disabled],
theme,
['hidden', ((index == 0 || index == scaleArray.length - 1) && theme == 'capsule') || value == item.val]
])
"
:style="(vertical ? 'top' : 'left') + ':' + item.left + 'px; ' + (vertical ? 'transform: translate(-50%, -50%);' : 'transform: translateX(-50%);')"
:aria-hidden="true"
>
<view
v-if="scaleTextArray.length"
:class="tools.cls(classPrefix + '__scale-desc', [theme])"
>
{{ scaleTextArray[index] }}
</view>
</view>
</block>
<view
:class="tools.cls(classPrefix + '__line', [['disabled', disabled], theme, 'single']) + ' ' + tClassBarActive"
:style="(vertical ? 'height' : 'width') + ': ' + lineBarWidth"
>
<view
id="singleDot"
:class="classPrefix + '__dot ' + tClassCursor"
@touchstart.stop.prevent="onTouchStart"
@touchmove.stop.prevent="onSingleLineTap"
@touchend.stop.prevent="onTouchEnd"
@touchcancel.stop.prevent="onTouchEnd"
>
<view
v-if="label || isVisibleToScreenReader"
:class="tools.cls(classPrefix + '__dot-value', [['sr-only', !label]])"
aria-role="alert"
aria-live="assertive"
:aria-hidden="!isVisibleToScreenReader"
>
{{ getValue(label, _value) || _value }}
</view>
<view
:class="classPrefix + '__dot-slider'"
aria-role="slider"
:aria-disabled="disabled"
:aria-valuemax="max"
:aria-valuemin="min"
:aria-valuenow="_value"
:aria-valuetext="getValue(label, _value) || _value"
/>
</view>
</view>
</view>
<text
v-if="showExtremeValue"
:class="classPrefix + '__value ' + classPrefix + '__value--max'"
>
{{ label ? getValue(label, max) : max }}
</text>
</block>
<block v-if="range">
<view
v-if="showExtremeValue"
:class="classPrefix + '__range-extreme ' + classPrefix + '__range-extreme--min'"
>
{{ min }}
</view>
<view
id="sliderLine"
:class="tools.cls(classPrefix + '__bar', [['disabled', disabled], theme, ['marks', isScale && theme == 'capsule']]) + ' ' + tClassBar"
@click="onLineTap"
>
<block v-if="isScale">
<view
v-for="(item, index) in scaleArray"
:key="index"
:class="
tools.cls(classPrefix + '__scale-item', [
['active', dotTopValue[1] >= item.val && item.val >= dotTopValue[0]],
['disabled', disabled],
theme,
['hidden', ((index == 0 || index == scaleArray.length - 1) && theme == 'capsule') || value == item.val]
])
"
:style="(vertical ? 'top' : 'left') + ':' + item.left + 'px; ' + (vertical ? 'transform: translate(-50%, -50%);' : 'transform: translateX(-50%);')"
:aria-hidden="true"
>
<view
v-if="scaleTextArray.length"
:class="tools.cls(classPrefix + '__scale-desc', [theme])"
>
{{ scaleTextArray[index] }}
</view>
</view>
</block>
<view
:class="tools.cls(classPrefix + '__line', [['disabled', disabled], theme]) + ' ' + tClassBarActive"
:style="(vertical ? 'top' : 'left') + ': ' + lineLeft + 'px; ' + (vertical ? 'bottom' : 'right') + ': ' + lineRight + 'px'"
>
<view
id="leftDot"
:class="classPrefix + '__dot ' + classPrefix + '__dot--left ' + tClassCursor"
@touchstart.stop.prevent="onTouchStart"
@touchmove.stop.prevent="onTouchMoveLeft"
@touchend.stop.prevent="onTouchEnd"
@touchcancel.stop.prevent="onTouchEnd"
>
<view
v-if="label || isVisibleToScreenReader"
:class="tools.cls(classPrefix + '__dot-value', [['sr-only', !label]])"
aria-role="alert"
aria-live="assertive"
:aria-hidden="!isVisibleToScreenReader"
>
{{ getValue(label, dotTopValue[0]) || dotTopValue[0] }}
</view>
<view
:class="classPrefix + '__dot-slider'"
aria-role="slider"
:aria-disabled="disabled"
:aria-valuemax="max"
:aria-valuemin="min"
:aria-valuenow="dotTopValue[0]"
:aria-valuetext="getValue(label, dotTopValue[0]) || dotTopValue[0]"
/>
</view>
<view
id="rightDot"
:class="classPrefix + '__dot ' + classPrefix + '__dot--right ' + tClassCursor"
@touchstart.stop.prevent="onTouchStart"
@touchmove.stop.prevent="onTouchMoveRight"
@touchend.stop.prevent="onTouchEnd"
@touchcancel.stop.prevent="onTouchEnd"
>
<view
v-if="label || isVisibleToScreenReader"
:class="tools.cls(classPrefix + '__dot-value', [['sr-only', !label]])"
aria-role="alert"
aria-live="assertive"
:aria-hidden="!isVisibleToScreenReader"
>
{{ getValue(label, dotTopValue[1]) || dotTopValue[1] }}
</view>
<view
:class="classPrefix + '__dot-slider'"
aria-role="slider"
:aria-disabled="disabled"
:aria-valuemax="max"
:aria-valuemin="min"
:aria-valuenow="dotTopValue[1]"
:aria-valuetext="getValue(label, dotTopValue[1]) || dotTopValue[1]"
/>
</view>
</view>
</view>
<view
v-if="showExtremeValue"
:class="classPrefix + '__range-extreme ' + classPrefix + '__range-extreme--max'"
>
{{ max }}
</view>
</block>
</view>
</template>
<script>
import { uniComponent } from '../common/src/index';
import { prefix } from '../common/config';
import { trimSingleValue, trimValue } from './tool';
import props from './props';
import { getRect, coalesce, nextTick } from '../common/utils';
import Bus from '../common/bus';
import tools from '../common/utils.wxs';
import { getValue } from './computed.js';
import { isString, isFunction } from '../common/validator';
const name = `${prefix}-slider`;
export default uniComponent({
name,
options: {
styleIsolation: 'shared',
},
controlledProps: [
{
key: 'value',
event: 'change',
},
],
externalClasses: [
`${prefix}-class`,
`${prefix}-class-bar`,
`${prefix}-class-bar-active`,
`${prefix}-class-bar-disabled`,
`${prefix}-class-cursor`,
],
props: {
...props,
},
data() {
return {
// 按钮样式列表
sliderStyles: '',
classPrefix: name,
initialLeft: null,
initialRight: null,
activeLeft: 0,
activeRight: 0,
maxRange: 0,
lineLeft: 0,
lineRight: 0,
dotTopValue: [0, 0],
_value: 0,
blockSize: 20,
isScale: false,
scaleArray: [],
scaleTextArray: [],
prefix,
realLabel: '',
extremeLabel: [],
isVisibleToScreenReader: false,
identifier: [-1, -1],
__inited: false,
tools,
lineBarWidth: 0,
};
},
watch: {
value: {
handler(newValue) {
this.handlePropsChange(newValue);
},
// immediate: true,
deep: true,
},
_value: {
handler(newValue) {
this.bus.on('initial', () => this.renderLine(newValue));
this.toggleA11yTips();
},
// immediate: true,
deep: true,
},
marks: {
handler(val) {
this.bus.on('initial', () => this.handleMark(val));
},
// immediate: true,
deep: true,
},
label: {
handler(val) {
this.isShowLabel = Boolean(val);
},
immediate: true,
},
showExtremeValue: 'getwExtremeLabel',
min: 'getwExtremeLabel',
max: 'getwExtremeLabel',
},
created() {
this.bus = new Bus();
this.bus.on('initial', () => this.handleMark(this.marks));
this.bus.on('initial', () => {
nextTick().then(() => {
this.renderLine(this._value);
});
});
this.toggleA11yTips();
},
mounted() {
const { value, defaultValue } = this;
// if (!value)
this.handlePropsChange(coalesce(value, defaultValue, 0));
this.init();
this.injectPageScroll();
},
methods: {
getValue,
getwExtremeLabel() {
const { showExtremeValue, min, max } = this;
if (!showExtremeValue) return;
this.extremeLabel = [this.getLabelByValue(Number(min), 'min'), this.getLabelByValue(Number(max), 'max')];
},
injectPageScroll() {
const { range, vertical } = this;
if (!range || !vertical) return;
const pages = getCurrentPages() || [];
let curPage = null;
if (pages && pages.length - 1 >= 0) {
curPage = pages[pages.length - 1];
}
if (!curPage) return;
const originPageScroll = curPage?.onPageScroll;
curPage.onPageScroll = (rest) => {
originPageScroll?.call(this, rest);
this.observerScrollTop(rest);
};
},
observerScrollTop(rest) {
const { scrollTop } = rest || {};
this.pageScrollTop = scrollTop;
},
toggleA11yTips() {
this.isVisibleToScreenReader = true;
setTimeout(() => {
this.isVisibleToScreenReader = false;
}, 2000);
},
renderLine(val) {
const { min, max, range } = this;
const { maxRange } = this;
if (range) {
const left = (maxRange * (val[0] - Number(min))) / (Number(max) - Number(min));
const right = (maxRange * (Number(max) - val[1])) / (Number(max) - Number(min));
// 因为要计算点相对于线的绝对定位,所以要取整条线的长度而非可滑动的范围
this.setLineStyle(left, right);
} else {
this.setSingleBarWidth(val);
}
},
triggerValue(value) {
const { min, max, range } = this;
const trimmedValue = trimValue(value, {
min, max, range,
});
if (JSON.stringify(this.preval) === JSON.stringify(trimmedValue)) return;
this.preval = value;
this._trigger('change', {
value: trimmedValue,
});
if (this._selfControlled) {
this._value = trimmedValue;
}
},
getLabelByValue(value, position) {
const { label } = this;
if (isString(label)) {
let text = String(value);
try {
const rule = /\${value}%/g;
const enableToReplace = rule.test(label);
if (enableToReplace) {
text = label.replace(rule, String(value));
} else {
text = label;
throw new Error();
}
} catch (e) {
}
return text;
}
if (isFunction(label)) {
return label(value, position);
}
return String(value);
},
handlePropsChange(newValue) {
const { min, max, range } = this;
const value = trimValue(newValue, {
min, max, range,
});
const realLabel = this.getLabelByValue(value);
// 避免受控模式下死循环,同时不影响初始化后的首次点击
if (this.preval !== undefined) {
this.preval = value;
}
const setValueAndTrigger = () => {
this._value = value;
this.realLabel = realLabel;
};
// 基本样式未初始化,等待初始化后在改变数据。
if (this.maxRange === 0) {
this.init().then(setValueAndTrigger);
return;
}
setValueAndTrigger();
},
valueToPosition(value) {
const { min, max, theme } = this;
const { blockSize, maxRange } = this;
const halfBlock = (theme) === 'capsule' ? Number(blockSize) / 2 : 0;
return Math.round(((Number(value) - Number(min)) / (Number(max) - Number(min))) * maxRange) + halfBlock;
},
handleMark(marks) {
const calcPos = arr => arr.map(item => ({
val: item,
left: this.valueToPosition(item),
}));
if (marks?.length && Array.isArray(marks)) {
this.isScale = true;
this.scaleArray = calcPos(marks);
this.scaleTextArray = [];
}
if (Object.prototype.toString.call(marks) === '[object Object]') {
const scaleArray = Object.keys(marks).map(item => Number(item));
const scaleTextArray = scaleArray.map(item => marks[item]);
this.isScale = scaleArray.length > 0;
this.scaleArray = calcPos(scaleArray);
this.scaleTextArray = scaleTextArray;
}
},
setSingleBarWidth(value) {
const width = this.valueToPosition(value);
this.lineBarWidth = `${width}px`;
},
async init() {
if (this.__inited) return;
await this.getInitialInfo();
this.__inited = true;
this.bus.emit('initial');
},
async getInitialInfo() {
const line = await getRect(this, '#sliderLine');
const { blockSize } = this;
const { theme, vertical } = this;
const halfBlock = Number(blockSize) / 2;
const { top, bottom, right, left } = line;
let maxRange = vertical ? bottom - top : right - left;
let initialLeft = vertical ? top : left;
let initialRight = vertical ? bottom : right;
if (initialLeft === 0 && initialRight === 0) return;
if ((theme) === 'capsule') {
maxRange = maxRange - Number(blockSize) - 6; // 6 是边框宽度
initialLeft -= halfBlock;
initialRight -= halfBlock;
}
this.maxRange = maxRange;
this.initialLeft = initialLeft;
this.initialRight = initialRight;
},
stepValue(value) {
const { step, min, max } = this;
const decimal = String(step).indexOf('.') > -1 ? String(step).length - String(step).indexOf('.') - 1 : 0;
const closestStep = trimSingleValue(
Number((Math.round(value / Number(step)) * Number(step)).toFixed(decimal)),
Number(min),
Number(max),
);
return closestStep;
},
// 点击滑动条的事件
async onSingleLineTap(e) {
await this.getInitialInfo();
const { disabled } = this;
if (disabled) return;
const isSingleLineTap = this.identifier[0] === -1; // 区分点击滑动条和单游标的滑动
if (isSingleLineTap) {
const [touch] = e.changedTouches;
this.identifier[0] = touch.identifier;
}
const value = await this.getSingleChangeValue(e);
if (isSingleLineTap) {
this.identifier[0] = -1;
}
this.triggerValue(value);
},
async getSingleChangeValue(e) {
// await this.getInitialInfo();
const { min, max, theme, vertical } = this;
const { initialLeft, maxRange, blockSize } = this;
const touch = e.changedTouches.find(item => item.identifier === this.identifier[0]);
if (!touch) return;
const pagePosition = this.getPagePosition(touch);
let offset = 0;
if ((theme) === 'capsule') {
offset = Number(blockSize);
if (vertical) {
}
offset += 3;
} else if (vertical) {
}
const currentLeft = pagePosition - initialLeft - offset;
let value = 0;
if (currentLeft <= 0) {
value = Number(min);
} else if (currentLeft >= maxRange) {
value = Number(max);
} else {
value = (currentLeft / maxRange) * (Number(max) - Number(min)) + Number(min);
}
return this.stepValue(value);
},
/**
* 将位置转换为值
*
* @param {number} posValue 位置数据
* @param {(0 | 1)} dir 方向: 0-left 1-right
* @return {number}
* @memberof Slider
*/
convertPosToValue(posValue, dir) {
const { maxRange } = this;
const { max, min } = this;
return dir === 0
? (posValue / maxRange) * (Number(max) - Number(min)) + Number(min)
: Number(max) - (posValue / maxRange) * (Number(max) - Number(min));
},
// 点击范围选择滑动条的事件
onLineTap(e) {
const { disabled, theme, vertical } = this;
const { initialLeft, initialRight, maxRange, blockSize } = this;
if (disabled) return;
const [touch] = e.changedTouches;
const pagePosition = this.getPagePosition(touch);
const halfBlock = (theme) === 'capsule' ? Number(blockSize) / 2 : 0;
const currentLeft = pagePosition - initialLeft;
const currentRight = -(pagePosition - initialRight);
if (currentLeft < 0 || currentRight > maxRange + Number(blockSize)) return;
Promise.all([getRect(this, '#leftDot'), getRect(this, '#rightDot')]).then(([leftDot, rightDot]) => {
const pageScrollTop = this.pageScrollTop || 0;
// 点击处-halfblock 与 leftDot左侧的距离绝对值
const leftDotPosition = vertical ? leftDot.top + pageScrollTop : leftDot.left;
const distanceLeft = Math.abs(pagePosition - leftDotPosition - halfBlock);
// 点击处-halfblock 与 rightDot左侧的距离绝对值
const rightDotPosition = vertical ? rightDot.top + pageScrollTop : rightDot.left;
const distanceRight = Math.abs(rightDotPosition - pagePosition + halfBlock);
// 哪个绝对值小就移动哪个Dot
const isMoveLeft = distanceLeft < distanceRight;
let offset = 0;
if ((theme) === 'capsule') {
offset = Number(blockSize);
if (vertical) {
offset *= 2;
}
offset += 6;
} else if (vertical) {
offset = Number(blockSize);
}
if (isMoveLeft) {
// 当前leftdot中心 + 左侧偏移量 = 目标左侧中心距离
const left = pagePosition - initialLeft - offset;
const leftValue = this.convertPosToValue(left, 0);
this.triggerValue([this.stepValue(leftValue), this._value[1]]);
} else {
let right = -(pagePosition - initialRight);
if (vertical) {
right += offset / 2;
}
const rightValue = this.convertPosToValue(right, 1);
this.triggerValue([this._value[0], this.stepValue(rightValue)]);
}
});
},
async onTouchStart(e) {
await this.getInitialInfo();
this.$emit('dragstart', { e });
const [touch] = e.changedTouches;
if (e.currentTarget.id === 'rightDot') {
this.identifier[1] = touch.identifier;
} else {
this.identifier[0] = touch.identifier;
}
},
onTouchMoveLeft(e) {
const { disabled, theme } = this;
const { initialLeft, _value, blockSize } = this;
if (disabled) return;
const touch = e.changedTouches.find(item => item.identifier === this.identifier[0]);
if (!touch) return;
const pagePosition = this.getPagePosition(touch);
let offset = 0;
if ((theme) === 'capsule') {
offset = Number(blockSize) + 3;
}
const currentLeft = pagePosition - initialLeft - offset;
const newData = [...(_value)];
const leftValue = this.convertPosToValue(currentLeft, 0);
newData[0] = this.stepValue(leftValue);
this.triggerValue(newData);
},
onTouchMoveRight(e) {
const { disabled, vertical, theme } = this;
const { initialRight, _value } = this;
if (disabled) return;
const touch = e.changedTouches.find(item => item.identifier === this.identifier[1]);
if (!touch) return;
const pagePosition = this.getPagePosition(touch);
let offset = 0;
if ((theme) === 'capsule') {
offset -= 3;
} else if (vertical) {
}
const currentRight = -(pagePosition - initialRight - offset);
const newData = [...(_value)];
const rightValue = this.convertPosToValue(currentRight, 1);
newData[1] = this.stepValue(rightValue);
this.triggerValue(newData);
},
setLineStyle(left, right) {
const { theme } = this;
const { blockSize, maxRange } = this;
const halfBlock = (theme) === 'capsule' ? Number(blockSize) / 2 : 0;
const [a, b] = this._value ;
const cut = v => parseInt(v, 10);
this.dotTopValue = [a, b];
this.realLabel = [this.getLabelByValue(a, 'start'), this.getLabelByValue(b, 'end')];
if (left + right <= maxRange) {
this.lineLeft = cut(left + halfBlock);
this.lineRight = cut(right + halfBlock);
} else {
this.lineLeft = cut(maxRange + halfBlock - right);
this.lineRight = cut(maxRange - left + halfBlock * 1.5);
}
},
onTouchEnd(e) {
this.$emit('dragend', { e, value: this._value });
if (e.currentTarget.id === 'rightDot') {
this.identifier[1] = -1;
} else {
this.identifier[0] = -1;
}
},
getPagePosition(touch) {
const { clientX, clientY } = touch;
const { vertical } = this;
return vertical ? clientY : clientX;
},
},
});
</script>
<style scoped>
@import './slider.css';
</style>