Slider: add range support (#2751)

pull/2898/head
杨奕 2017-02-17 19:09:59 +08:00 committed by baiyaaaaa
parent 7f6d698f72
commit 450cf81ded
5 changed files with 542 additions and 164 deletions

View File

@ -7,7 +7,8 @@
value3: 42,
value4: 0,
value5: 0,
value6: 0
value6: 0,
value7: [4, 8]
};
}
}
@ -119,6 +120,35 @@ Set value via a input box.
```
:::
### Range selection
Selecting a range of values is supported.
:::demo Setting the `range` attribute activates range mode, where the binding value is an array made up of two boundary values.
```html
<template>
<div class="block">
<el-slider
v-model="value7"
range
show-stops
:max="10">
</el-slider>
</div>
</template>
<script>
export default {
data() {
return {
value7: [4, 8]
}
}
}
</script>
```
:::
## Attributes
| Attribute | Description | Type | Accepted Values | Default |
|---------- |-------------- |---------- |-------------------------------- |-------- |
@ -126,9 +156,10 @@ Set value via a input box.
| max | maximum value | number | — | 100 |
| disabled | whether Slider is disabled | boolean | — | false |
| step | step size | number | — | 1 |
| show-input | whether to display an input box | boolean | — | false |
| show-input | whether to display an input box, works when `range` is false | boolean | — | false |
| show-input-controls | whether to display control buttons when `show-input` is true | boolean | — | true |
| show-stops | whether to display breakpoints | boolean | — | false |
| range | whether to select a range | boolean | — | false |
## Events
| Event Name | Description | Parameters |

View File

@ -7,7 +7,8 @@
value3: 42,
value4: 0,
value5: 0,
value6: 0
value6: 0,
value7: [4, 8]
};
}
}
@ -143,6 +144,35 @@
```
:::
### 范围选择
支持选择某一数值范围
:::demo 设置`range`即可开启范围选择,此时绑定值是一个数组,其元素分别为最小边界值和最大边界值
```html
<template>
<div class="block">
<el-slider
v-model="value7"
range
show-stops
:max="10">
</el-slider>
</div>
</template>
<script>
export default {
data() {
return {
value7: [4, 8]
}
}
}
</script>
```
:::
### Attributes
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|---------- |-------------- |---------- |-------------------------------- |-------- |
@ -150,9 +180,10 @@
| max | 最大值 | number | — | 100 |
| disabled | 是否禁用 | boolean | — | false |
| step | 步长 | number | — | 1 |
| show-input | 是否显示输入框 | boolean | — | false |
| show-input | 是否显示输入框,仅在非范围选择时有效 | boolean | — | false |
| show-input-controls | 在显示输入框的情况下,是否显示输入框的控制按钮 | boolean | — | true|
| show-stops | 是否显示间断点 | boolean | — | false |
| range | 是否为范围选择 | boolean | — | false |
### Events
| 事件名称 | 说明 | 回调参数 |

View File

@ -0,0 +1,156 @@
<template>
<div
class="el-slider__button-wrapper"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@mousedown="onButtonDown"
:class="{ 'hover': hovering, 'dragging': dragging }"
:style="{ left: currentPosition }"
ref="button">
<el-tooltip placement="top" ref="tooltip">
<span slot="content">{{ value }}</span>
<div class="el-slider__button" :class="{ 'hover': hovering, 'dragging': dragging }"></div>
</el-tooltip>
</div>
</template>
<script>
import ElTooltip from 'element-ui/packages/tooltip';
export default {
name: 'ElSliderButton',
components: {
ElTooltip
},
props: {
value: {
type: Number,
default: 0
}
},
data() {
return {
hovering: false,
dragging: false,
startX: 0,
currentX: 0,
startPosition: 0,
newPosition: null,
oldValue: this.value
};
},
computed: {
disabled() {
return this.$parent.disabled;
},
max() {
return this.$parent.max;
},
min() {
return this.$parent.min;
},
step() {
return this.$parent.step;
},
precision() {
return this.$parent.precision;
},
currentPosition() {
return `${ (this.value - this.min) / (this.max - this.min) * 100 }%`;
}
},
watch: {
dragging(val) {
this.$parent.dragging = val;
}
},
methods: {
showTooltip() {
this.$refs.tooltip && (this.$refs.tooltip.showPopper = true);
},
hideTooltip() {
this.$refs.tooltip && (this.$refs.tooltip.showPopper = false);
},
handleMouseEnter() {
this.hovering = true;
this.showTooltip();
},
handleMouseLeave() {
this.hovering = false;
this.hideTooltip();
},
onButtonDown(event) {
if (this.disabled) return;
this.onDragStart(event);
window.addEventListener('mousemove', this.onDragging);
window.addEventListener('mouseup', this.onDragEnd);
window.addEventListener('contextmenu', this.onDragEnd);
},
onDragStart(event) {
this.dragging = true;
this.startX = event.clientX;
this.startPosition = parseInt(this.currentPosition, 10);
},
onDragging(event) {
if (this.dragging) {
this.showTooltip();
this.currentX = event.clientX;
const diff = (this.currentX - this.startX) / this.$parent.$sliderWidth * 100;
this.newPosition = this.startPosition + diff;
this.setPosition(this.newPosition);
}
},
onDragEnd() {
if (this.dragging) {
/*
* 防止在 mouseup 后立即触发 click导致滑块有几率产生一小段位移
* 不使用 preventDefault 是因为 mouseup click 没有注册在同一个 DOM
*/
setTimeout(() => {
this.dragging = false;
this.hideTooltip();
this.setPosition(this.newPosition);
}, 0);
window.removeEventListener('mousemove', this.onDragging);
window.removeEventListener('mouseup', this.onDragEnd);
window.removeEventListener('contextmenu', this.onDragEnd);
}
},
setPosition(newPosition) {
if (newPosition < 0) {
newPosition = 0;
} else if (newPosition > 100) {
newPosition = 100;
}
const lengthPerStep = 100 / ((this.max - this.min) / this.step);
const steps = Math.round(newPosition / lengthPerStep);
let value = steps * lengthPerStep * (this.max - this.min) * 0.01 + this.min;
value = parseFloat(value.toFixed(this.precision));
this.$emit('input', value);
this.$refs.tooltip && this.$refs.tooltip.updatePopper();
if (!this.dragging && this.value !== this.oldValue) {
this.oldValue = this.value;
}
}
}
};
</script>

View File

@ -2,9 +2,8 @@
<div class="el-slider">
<el-input-number
v-model="inputValue"
v-if="showInput"
v-if="showInput && !range"
class="el-slider__input"
@keyup.native="onInputChange"
ref="input"
:step="step"
:disabled="disabled"
@ -15,29 +14,37 @@
</el-input-number>
<div class="el-slider__runway"
:class="{ 'show-input': showInput, 'disabled': disabled }"
@click="onSliderClick" ref="slider">
<div class="el-slider__bar" :style="{ width: currentPosition }"></div>
@click="onSliderClick"
ref="slider">
<div
class="el-slider__button-wrapper"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@mousedown="onButtonDown"
:class="{ 'hover': hovering, 'dragging': dragging }"
:style="{left: currentPosition}"
ref="button">
<el-tooltip placement="top" ref="tooltip">
<span slot="content">{{ value }}</span>
<div class="el-slider__button" :class="{ 'hover': hovering, 'dragging': dragging }"></div>
</el-tooltip>
class="el-slider__bar"
:style="{
width: barWidth,
left: barLeft
}">
</div>
<slider-button
v-model="firstValue"
ref="button1">
</slider-button>
<slider-button
v-model="secondValue"
ref="button2"
v-if="range">
</slider-button>
<div
class="el-slider__stop"
v-for="item in stops"
:style="{ 'left': item + '%' }"
v-if="showStops">
</div>
<div class="el-slider__stop" v-for="item in stops" :style="{ 'left': item + '%' }" v-if="showStops"></div>
</div>
</div>
</template>
<script type="text/babel">
import ElInputNumber from 'element-ui/packages/input-number';
import ElTooltip from 'element-ui/packages/tooltip';
import SliderButton from './button.vue';
import { getStyle } from 'element-ui/src/utils/dom';
export default {
@ -56,12 +63,8 @@
type: Number,
default: 1
},
defaultValue: {
type: Number,
default: 0
},
value: {
type: Number,
type: [Number, Array],
default: 0
},
showInput: {
@ -79,142 +82,136 @@
disabled: {
type: Boolean,
default: false
},
range: {
type: Boolean,
default: false
}
},
components: {
ElInputNumber,
ElTooltip
SliderButton
},
data() {
return {
firstValue: null,
secondValue: null,
oldValue: null,
precision: 0,
inputValue: null,
timeout: null,
hovering: false,
dragging: false,
startX: 0,
currentX: 0,
startPos: 0,
newPos: null,
oldValue: this.value,
currentPosition: (this.value - this.min) / (this.max - this.min) * 100 + '%'
dragging: false
};
},
watch: {
inputValue(val) {
this.$emit('input', Number(val));
this.firstValue = val;
},
value(val) {
this.$nextTick(() => {
this.updatePopper();
});
if (typeof val !== 'number' || isNaN(val) || val < this.min) {
this.$emit('input', this.min);
value(val, oldVal) {
if (this.dragging ||
Array.isArray(val) &&
Array.isArray(oldVal) &&
val.every((item, index) => item === oldVal[index])) {
return;
}
if (val > this.max) {
this.$emit('input', this.max);
return;
this.setValues();
},
dragging(val) {
if (!val) {
this.setValues();
}
this.inputValue = val;
this.setPosition((val - this.min) * 100 / (this.max - this.min));
},
firstValue(val) {
if (this.range) {
this.$emit('input', [this.minValue, this.maxValue]);
} else {
this.inputValue = val;
this.$emit('input', val);
}
},
secondValue() {
if (this.range) {
this.$emit('input', [this.minValue, this.maxValue]);
}
},
min() {
this.setValues();
},
max() {
this.setValues();
}
},
methods: {
handleMouseEnter() {
this.hovering = true;
this.$refs.tooltip.showPopper = true;
},
handleMouseLeave() {
this.hovering = false;
this.$refs.tooltip.showPopper = false;
},
updatePopper() {
this.$refs.tooltip.updatePopper();
},
setPosition(newPos) {
if (newPos < 0) {
newPos = 0;
} else if (newPos > 100) {
newPos = 100;
valueChanged() {
if (this.range) {
return ![this.minValue, this.maxValue]
.every((item, index) => item === this.oldValue[index]);
} else {
return this.value !== this.oldValue;
}
const lengthPerStep = 100 / ((this.max - this.min) / this.step);
const steps = Math.round(newPos / lengthPerStep);
let value = steps * lengthPerStep * (this.max - this.min) * 0.01 + this.min;
value = parseFloat(value.toFixed(this.precision));
this.$emit('input', value);
this.currentPosition = (this.value - this.min) / (this.max - this.min) * 100 + '%';
if (!this.dragging) {
if (this.value !== this.oldValue) {
this.$emit('change', this.value);
this.oldValue = this.value;
},
setValues() {
const val = this.value;
if (this.range && Array.isArray(val)) {
if (val[1] < this.min) {
this.$emit('input', [this.min, this.min]);
} else if (val[0] > this.max) {
this.$emit('input', [this.max, this.max]);
} else if (val[0] < this.min) {
this.$emit('input', [this.min, val[1]]);
} else if (val[1] > this.max) {
this.$emit('input', [val[0], this.max]);
} else {
this.firstValue = val[0];
this.secondValue = val[1];
if (this.valueChanged()) {
this.$emit('change', [this.minValue, this.maxValue]);
this.oldValue = val.slice();
}
}
} else if (!this.range && typeof val === 'number' && !isNaN(val)) {
if (val < this.min) {
this.$emit('input', this.min);
} else if (val > this.max) {
this.$emit('input', this.max);
} else {
this.firstValue = val;
if (this.valueChanged()) {
this.$emit('change', val);
this.oldValue = val;
}
}
}
},
setPosition(percent) {
const targetValue = this.min + percent * (this.max - this.min) / 100;
if (!this.range) {
this.$refs.button1.setPosition(percent);
return;
}
let button;
if (Math.abs(this.minValue - targetValue) < Math.abs(this.maxValue - targetValue)) {
button = this.firstValue < this.secondValue ? 'button1' : 'button2';
} else {
button = this.firstValue > this.secondValue ? 'button1' : 'button2';
}
this.$refs[button].setPosition(percent);
},
onSliderClick(event) {
if (this.disabled || this.dragging) return;
const sliderOffsetLeft = this.$refs.slider.getBoundingClientRect().left;
this.setPosition((event.clientX - sliderOffsetLeft) / this.$sliderWidth * 100);
},
onInputChange() {
if (this.value === '') {
return;
}
if (!isNaN(this.value)) {
this.setPosition((this.value - this.min) * 100 / (this.max - this.min));
}
},
onDragStart(event) {
this.dragging = true;
this.startX = event.clientX;
this.startPos = parseInt(this.currentPosition, 10);
},
onDragging(event) {
if (this.dragging) {
this.$refs.tooltip.showPopper = true;
this.currentX = event.clientX;
const diff = (this.currentX - this.startX) / this.$sliderWidth * 100;
this.newPos = this.startPos + diff;
this.setPosition(this.newPos);
}
},
onDragEnd() {
if (this.dragging) {
/*
* 防止在 mouseup 后立即触发 click导致滑块有几率产生一小段位移
* 不使用 preventDefault 是因为 mouseup click 没有注册在同一个 DOM
*/
setTimeout(() => {
this.dragging = false;
this.$refs.tooltip.showPopper = false;
this.setPosition(this.newPos);
}, 0);
window.removeEventListener('mousemove', this.onDragging);
window.removeEventListener('mouseup', this.onDragEnd);
window.removeEventListener('contextmenu', this.onDragEnd);
}
},
onButtonDown(event) {
if (this.disabled) return;
this.onDragStart(event);
window.addEventListener('mousemove', this.onDragging);
window.addEventListener('mouseup', this.onDragEnd);
window.addEventListener('contextmenu', this.onDragEnd);
}
},
@ -224,31 +221,67 @@
},
stops() {
const stopCount = (this.max - this.value) / this.step;
const currentLeft = parseFloat(this.currentPosition);
const stopCount = (this.max - this.min) / this.step;
const stepWidth = 100 * this.step / (this.max - this.min);
const result = [];
for (let i = 1; i < stopCount; i++) {
result.push(currentLeft + i * stepWidth);
result.push(i * stepWidth);
}
return result;
if (this.range) {
return result.filter(step => {
return step < 100 * (this.minValue - this.min) / (this.max - this.min) ||
step > 100 * (this.maxValue - this.min) / (this.max - this.min);
});
} else {
return result.filter(step => step > 100 * (this.firstValue - this.min) / (this.max - this.min));
}
},
minValue() {
return Math.min(this.firstValue, this.secondValue);
},
maxValue() {
return Math.max(this.firstValue, this.secondValue);
},
barWidth() {
return this.range
? `${ 100 * (this.maxValue - this.minValue) / (this.max - this.min) }%`
: `${ 100 * (this.firstValue - this.min) / (this.max - this.min) }%`;
},
barLeft() {
return this.range
? `${ 100 * (this.minValue - this.min) / (this.max - this.min) }%`
: '0%';
}
},
created() {
if (typeof this.value !== 'number' ||
isNaN(this.value) ||
this.value < this.min) {
this.$emit('input', this.min);
} else if (this.value > this.max) {
this.$emit('input', this.max);
mounted() {
if (this.range) {
if (Array.isArray(this.value)) {
this.firstValue = Math.max(this.min, this.value[0]);
this.secondValue = Math.min(this.max, this.value[1]);
} else {
this.firstValue = this.min;
this.secondValue = this.max;
}
this.oldValue = [this.firstValue, this.secondValue];
} else {
if (typeof this.value !== 'number' || isNaN(this.value)) {
this.firstValue = this.min;
} else {
this.firstValue = Math.min(this.max, Math.max(this.min, this.value));
}
this.oldValue = this.firstValue;
}
let precisions = [this.min, this.max, this.step].map(item => {
let decimal = ('' + item).split('.')[1];
return decimal ? decimal.length : 0;
});
this.precision = Math.max.apply(null, precisions);
this.inputValue = this.inputValue || this.value;
this.inputValue = this.inputValue || this.firstValue;
}
};
</script>

View File

@ -37,7 +37,7 @@ describe('Slider', () => {
done();
});
});
}, 100);
}, 10);
});
it('show tooltip', () => {
@ -55,7 +55,7 @@ describe('Slider', () => {
};
}
}, true);
const slider = vm.$children[0];
const slider = vm.$children[0].$children[0];
slider.handleMouseEnter();
expect(slider.$refs.tooltip.showPopper).to.true;
slider.handleMouseLeave();
@ -76,14 +76,14 @@ describe('Slider', () => {
};
}
}, true);
const slider = vm.$children[0];
const slider = vm.$children[0].$children[0];
slider.onButtonDown({ clientX: 0 });
slider.onDragging({ clientX: 100 });
slider.onDragEnd();
setTimeout(() => {
slider.onButtonDown({ clientX: 0 });
slider.onDragging({ clientX: 100 });
slider.onDragEnd();
expect(vm.value > 0).to.true;
done();
}, 150);
}, 10);
});
it('step', done => {
@ -100,14 +100,14 @@ describe('Slider', () => {
};
}
}, true);
const slider = vm.$children[0];
const slider = vm.$children[0].$children[0];
slider.onButtonDown({ clientX: 0 });
slider.onDragging({ clientX: 100 });
slider.onDragEnd();
setTimeout(() => {
slider.onButtonDown({ clientX: 0 });
slider.onDragging({ clientX: 100 });
slider.onDragEnd();
expect(vm.value > 0.4 && vm.value < 0.6).to.true;
done();
}, 150);
}, 10);
});
it('click', done => {
@ -130,8 +130,8 @@ describe('Slider', () => {
setTimeout(() => {
expect(vm.value > 0).to.true;
done();
}, 150);
}, 150);
}, 10);
}, 10);
});
it('disabled', done => {
@ -148,15 +148,14 @@ describe('Slider', () => {
};
}
}, true);
const slider = vm.$children[0];
const slider = vm.$children[0].$children[0];
slider.onButtonDown({ clientX: 0 });
slider.onDragging({ clientX: 100 });
slider.onDragEnd();
setTimeout(() => {
slider.onButtonDown({ clientX: 0 });
slider.onDragging({ clientX: 100 });
slider.onDragEnd();
slider.onSliderClick({ clientX: 200 });
expect(vm.value).to.equal(0);
done();
}, 100);
}, 10);
});
it('show input', done => {
@ -180,17 +179,145 @@ describe('Slider', () => {
setTimeout(() => {
expect(vm.value).to.equal(40);
done();
}, 150);
}, 150);
}, 10);
}, 10);
});
it('show stops', done => {
it('show stops', () => {
vm = createTest(Slider, {
showStops: true,
step: 10
}, true);
const stops = vm.$el.querySelectorAll('.el-slider__stop');
expect(stops.length).to.equal(9);
done();
});
describe('range', () => {
it('basic ranged slider', () => {
vm = createVue({
template: `
<div>
<el-slider v-model="value" range></el-slider>
</div>
`,
data() {
return {
value: [10, 20]
};
}
}, true);
const buttons = vm.$children[0].$children;
expect(buttons.length).to.equal(2);
});
it('should not exceed min and max', done => {
vm = createVue({
template: `
<div>
<el-slider v-model="value" range :min="50">
</el-slider>
</div>
`,
data() {
return {
value: [50, 60]
};
}
}, true);
setTimeout(() => {
vm.value = [40, 60];
setTimeout(() => {
expect(vm.value).to.deep.equal([50, 60]);
vm.value = [50, 120];
setTimeout(() => {
expect(vm.value).to.deep.equal([50, 100]);
done();
}, 10);
}, 10);
}, 10);
});
it('click', done => {
vm = createVue({
template: `
<div style="width: 200px;">
<el-slider range v-model="value"></el-slider>
</div>
`,
data() {
return {
value: [0, 100]
};
}
}, true);
const slider = vm.$children[0];
setTimeout(() => {
slider.onSliderClick({ clientX: 100 });
setTimeout(() => {
expect(vm.value[0] > 0).to.true;
expect(vm.value[1]).to.equal(100);
done();
}, 10);
}, 10);
});
it('responsive to dynamic min and max', done => {
vm = createVue({
template: `
<div>
<el-slider v-model="value" range :min="min" :max="max">
</el-slider>
</div>
`,
data() {
return {
min: 0,
max: 100,
value: [50, 80]
};
}
}, true);
setTimeout(() => {
vm.min = 60;
setTimeout(() => {
expect(vm.value).to.deep.equal([60, 80]);
vm.min = 30;
vm.max = 40;
setTimeout(() => {
expect(vm.value).to.deep.equal([40, 40]);
done();
}, 10);
}, 10);
}, 10);
});
it('show stops', done => {
vm = createVue({
template: `
<div>
<el-slider
v-model="value"
range
:step="10"
show-stops></el-slider>
</div>
`,
data() {
return {
value: [30, 60]
};
}
}, true);
setTimeout(() => {
const stops = vm.$el.querySelectorAll('.el-slider__stop');
expect(stops.length).to.equal(5);
done();
}, 10);
});
});
});