diff --git a/docs/en-US/component/slider.md b/docs/en-US/component/slider.md
index 0a899df96fce2..b45a0957b1e45 100644
--- a/docs/en-US/component/slider.md
+++ b/docs/en-US/component/slider.md
@@ -117,6 +117,7 @@ slider/show-marks
| tooltip-class | custom class name for the tooltip | ^[string] | — |
| placement | position of Tooltip | ^[enum]`'top' \| 'top-start' \| 'top-end' \| 'bottom' \| 'bottom-start' \| 'bottom-end' \| 'left' \| 'left-start' \| 'left-end' \| 'right' \| 'right-start' \| 'right-end'` | top |
| marks | marks, type of key must be `number` and must in closed interval `[min, max]`, each mark can custom style | ^[object]`SliderMarks` | — |
+| mark-snap-percentage | while dragging slider, if within this slider percentage of a mark, snap to that mark. Requires `marks` | ^[number] | 0 |
| validate-event | whether to trigger form validation | ^[boolean] | true |
### Events
diff --git a/docs/examples/slider/show-marks.vue b/docs/examples/slider/show-marks.vue
index 59fc54e04f905..44932d2ca8204 100644
--- a/docs/examples/slider/show-marks.vue
+++ b/docs/examples/slider/show-marks.vue
@@ -1,7 +1,40 @@
+ No snap
+
+ With 2% snap
+
+
+
+ With 5% snap
+
+
+
+ No snap
+
+
+
+ With 2% snap
+
+
diff --git a/packages/components/slider/__tests__/slider.test.tsx b/packages/components/slider/__tests__/slider.test.tsx
index 0f6adad563baf..90d3114db3fb6 100644
--- a/packages/components/slider/__tests__/slider.test.tsx
+++ b/packages/components/slider/__tests__/slider.test.tsx
@@ -5,6 +5,7 @@ import { EVENT_CODE } from '@element-plus/constants'
import { ElFormItem } from '@element-plus/components/form'
import Slider from '../src/slider.vue'
import type { SliderProps } from '../src/slider'
+import type { VueWrapper } from '@vue/test-utils'
vi.mock('lodash-unified', async () => {
return {
@@ -17,6 +18,60 @@ vi.mock('lodash-unified', async () => {
}
})
+type DragState = { x: number; y: number; ctrlKey: boolean }
+
+class MouseDragger {
+ state: DragState = { x: 0, y: 0, ctrlKey: false }
+
+ async down(triggerComponent: VueWrapper, state: Partial) {
+ Object.assign(this.state, { ctrlKey: false }, state)
+ triggerComponent.trigger('mousedown', {
+ clientX: this.state.x,
+ clientY: this.state.y,
+ ctrlKey: this.state.ctrlKey,
+ })
+ await nextTick()
+ }
+
+ async dragTo(state: Partial) {
+ Object.assign(this.state, { ctrlKey: false }, state)
+ window.dispatchEvent(
+ new MouseEvent('mousemove', {
+ screenX: this.state.x,
+ screenY: this.state.y,
+ clientX: this.state.x,
+ clientY: this.state.y,
+ ctrlKey: this.state.ctrlKey,
+ })
+ )
+ await nextTick()
+ }
+
+ async done() {
+ window.dispatchEvent(
+ new MouseEvent('mouseup', {
+ screenX: this.state.x,
+ screenY: this.state.y,
+ clientX: this.state.x,
+ clientY: this.state.y,
+ ctrlKey: this.state.ctrlKey,
+ })
+ )
+ await nextTick()
+ }
+}
+
+async function mouseDrag(
+ triggerComponent: VueWrapper,
+ from: Partial,
+ to: Partial
+) {
+ const dragger = new MouseDragger()
+ await dragger.down(triggerComponent, from)
+ await dragger.dragTo(to)
+ await dragger.done()
+}
+
describe('Slider', () => {
beforeEach(() => {
vi.useFakeTimers()
@@ -125,25 +180,8 @@ describe('Slider', () => {
'clientWidth',
'get'
).mockImplementation(() => 200)
- slider.trigger('mousedown', { clientX: 0 })
- const mousemove = new MouseEvent('mousemove', {
- screenX: 100,
- screenY: 0,
- clientX: 100,
- clientY: 0,
- })
- window.dispatchEvent(mousemove)
-
- const mouseup = new MouseEvent('mouseup', {
- screenX: 100,
- screenY: 0,
- clientX: 100,
- clientY: 0,
- })
- window.dispatchEvent(mouseup)
-
- await nextTick()
+ await mouseDrag(slider, { x: 0 }, { x: 100 })
expect(value.value === 50).toBeTruthy()
})
@@ -167,28 +205,120 @@ describe('Slider', () => {
'clientHeight',
'get'
).mockImplementation(() => 200)
- slider.trigger('mousedown', { clientY: 0 })
- const mousemove = new MouseEvent('mousemove', {
- screenX: 0,
- screenY: -100,
- clientX: 0,
- clientY: -100,
- })
- window.dispatchEvent(mousemove)
-
- const mouseup = new MouseEvent('mouseup', {
- screenX: 0,
- screenY: -100,
- clientX: 0,
- clientY: -100,
- })
- window.dispatchEvent(mouseup)
- await nextTick()
+ await mouseDrag(slider, { y: 0 }, { y: -100 })
expect(value.value).toBe(50)
})
})
+ it('marks percentage snapping (horizontal + vertical)', async () => {
+ vi.useRealTimers()
+ const value = ref(0)
+ const marks = ref({
+ 40: '40',
+ 70: '70',
+ })
+ const vertical = ref(false)
+ const markSnapPercentage = ref(0)
+ const wrapper = mount(
+ () => (
+
+
+
+ ),
+ {
+ attachTo: document.body,
+ }
+ )
+
+ const slider = wrapper.findComponent({ name: 'ElSliderButton' })
+
+ vi.spyOn(
+ wrapper.find('.el-slider__runway').element,
+ 'clientWidth',
+ 'get'
+ ).mockImplementation(() => 200)
+ vi.spyOn(
+ wrapper.find('.el-slider__runway').element,
+ 'clientHeight',
+ 'get'
+ ).mockImplementation(() => 200)
+
+ const directions: Array<[string, number, boolean]> = [
+ ['x', 2, false],
+ ['y', -2, true],
+ ]
+ for (const [axis, mul, vert] of directions) {
+ vertical.value = vert
+ await nextTick()
+
+ // no snap
+ const dragger = new MouseDragger()
+ await dragger.down(slider, { [axis]: 0 })
+ expect(value.value).toBe(0)
+ await dragger.dragTo({ [axis]: mul * 39 })
+ expect(value.value).toBe(39)
+
+ // snap to 5%
+ markSnapPercentage.value = 5
+ await nextTick()
+
+ await dragger.dragTo({ [axis]: mul * 36 })
+ expect(value.value).toBe(40)
+ await dragger.dragTo({ [axis]: mul * 35 })
+ expect(value.value).toBe(40)
+ await dragger.dragTo({ [axis]: mul * 34 })
+ expect(value.value).toBe(34)
+ await dragger.dragTo({ [axis]: mul * 44 })
+ expect(value.value).toBe(40)
+
+ // ctrlKey disables snap
+ await dragger.dragTo({ [axis]: mul * 41, ctrlKey: true })
+ expect(value.value).toBe(41)
+ await dragger.dragTo({ [axis]: mul * 37, ctrlKey: true })
+ expect(value.value).toBe(37)
+
+ // snap to 2%
+ markSnapPercentage.value = 2
+ await nextTick()
+
+ await dragger.dragTo({ [axis]: mul * 64 })
+ expect(value.value).toBe(64)
+ await dragger.dragTo({ [axis]: mul * 65 })
+ expect(value.value).toBe(65)
+ await dragger.dragTo({ [axis]: mul * 68 })
+ expect(value.value).toBe(70)
+ await dragger.dragTo({ [axis]: mul * 69 })
+ expect(value.value).toBe(70)
+ await dragger.dragTo({ [axis]: mul * 72 })
+ expect(value.value).toBe(70)
+ await dragger.dragTo({ [axis]: mul * 73 })
+ expect(value.value).toBe(73)
+ await dragger.dragTo({ [axis]: mul * 75 })
+ expect(value.value).toBe(75)
+ await dragger.dragTo({ [axis]: mul * 71 })
+ expect(value.value).toBe(70)
+
+ // snap to negative = no snap
+ markSnapPercentage.value = -99
+ await nextTick()
+
+ await dragger.dragTo({ [axis]: mul * 39 })
+ expect(value.value).toBe(39)
+
+ // reset to zero
+ await dragger.dragTo({ [axis]: mul * 0 })
+ expect(value.value).toBe(0)
+ await dragger.done()
+ expect(value.value).toBe(0)
+ }
+ })
+
describe('accessibility', () => {
it('left/right arrows', async () => {
const value = ref(0)
@@ -285,25 +415,7 @@ describe('Slider', () => {
const slider = wrapper.findComponent({ name: 'ElSliderButton' })
await nextTick()
- slider.trigger('mousedown', { clientX: 0 })
-
- const mousemove = new MouseEvent('mousemove', {
- screenX: 100,
- screenY: 0,
- clientX: 100,
- clientY: 0,
- })
- window.dispatchEvent(mousemove)
-
- const mouseup = new MouseEvent('mouseup', {
- screenX: 100,
- screenY: 0,
- clientX: 100,
- clientY: 0,
- })
- await nextTick()
- window.dispatchEvent(mouseup)
- await nextTick()
+ await mouseDrag(slider, { x: 0 }, { x: 100 })
expect(value.value === 0.5).toBeTruthy()
mockClientWidth.mockRestore()
})
@@ -399,24 +511,8 @@ describe('Slider', () => {
.spyOn(wrapper.find('.el-slider__runway').element, 'clientWidth', 'get')
.mockImplementation(() => 200)
const slider = wrapper.findComponent({ name: 'ElSliderButton' })
- slider.vm.onButtonDown({ clientX: 0 })
- const mousemove = new MouseEvent('mousemove', {
- screenX: 50,
- screenY: 0,
- clientX: 50,
- clientY: 0,
- })
- window.dispatchEvent(mousemove)
-
- const mouseup = new MouseEvent('mouseup', {
- screenX: 50,
- screenY: 0,
- clientX: 50,
- clientY: 0,
- })
- window.dispatchEvent(mouseup)
- await nextTick()
+ await mouseDrag(slider, { x: 0 }, { x: 50 })
expect(value.value).toBe(0)
mockClientWidth.mockRestore()
})
diff --git a/packages/components/slider/src/composables/use-marks.ts b/packages/components/slider/src/composables/use-marks.ts
index 74392fd63d2a2..d3193c85fed9b 100644
--- a/packages/components/slider/src/composables/use-marks.ts
+++ b/packages/components/slider/src/composables/use-marks.ts
@@ -5,6 +5,7 @@ import type { SliderMarkerProps } from '../marker'
export interface Mark extends SliderMarkerProps {
point: number
position: number
+ snap: { min: number; max: number }
}
export const useMarks = (props: SliderProps) => {
@@ -12,18 +13,24 @@ export const useMarks = (props: SliderProps) => {
if (!props.marks) {
return []
}
+ const snapPercentage = Math.max(0, props.markSnapPercentage)
const marksKeys = Object.keys(props.marks)
return marksKeys
.map(Number.parseFloat)
.sort((a, b) => a - b)
.filter((point) => point <= props.max && point >= props.min)
- .map(
- (point): Mark => ({
+ .map((point): Mark => {
+ const position = ((point - props.min) * 100) / (props.max - props.min)
+ return {
point,
- position: ((point - props.min) * 100) / (props.max - props.min),
+ position,
mark: props.marks![point],
- })
- )
+ snap: {
+ min: Math.max(0, position - snapPercentage),
+ max: Math.min(100, position + snapPercentage),
+ },
+ }
+ })
})
}
diff --git a/packages/components/slider/src/composables/use-slider-button.ts b/packages/components/slider/src/composables/use-slider-button.ts
index 0d5f1699e71f8..47e6cba572c4b 100644
--- a/packages/components/slider/src/composables/use-slider-button.ts
+++ b/packages/components/slider/src/composables/use-slider-button.ts
@@ -61,6 +61,7 @@ export const useSliderButton = (
min,
max,
step,
+ markList,
showTooltip,
precision,
sliderSize,
@@ -211,7 +212,15 @@ export const useSliderButton = (
initData.currentX = clientX
diff = ((initData.currentX - initData.startX) / sliderSize.value) * 100
}
- initData.newPosition = initData.startPosition + diff
+
+ const newPosition = initData.startPosition + diff
+ const closestMark = !event.ctrlKey
+ ? markList.value.find(
+ (m) => newPosition >= m.snap.min && newPosition <= m.snap.max
+ )
+ : undefined
+
+ initData.newPosition = closestMark ? closestMark.position : newPosition
setPosition(initData.newPosition)
}
}
diff --git a/packages/components/slider/src/constants.ts b/packages/components/slider/src/constants.ts
index 4ce1d1640a577..4425e0d39d7db 100644
--- a/packages/components/slider/src/constants.ts
+++ b/packages/components/slider/src/constants.ts
@@ -1,7 +1,9 @@
import type { ComputedRef, InjectionKey, Ref, ToRefs } from 'vue'
+import type { Mark } from './composables'
import type { SliderProps } from './slider'
export interface SliderContext extends ToRefs {
+ markList: ComputedRef
precision: ComputedRef
sliderSize: Ref
emitChange: () => void
diff --git a/packages/components/slider/src/slider.ts b/packages/components/slider/src/slider.ts
index f5a7839c8bb83..beeb8bfaff1f3 100644
--- a/packages/components/slider/src/slider.ts
+++ b/packages/components/slider/src/slider.ts
@@ -168,6 +168,13 @@ export const sliderProps = buildProps({
marks: {
type: definePropType(Object),
},
+ /**
+ * @description snap to mark if value within this percentage (default to zero)
+ */
+ markSnapPercentage: {
+ type: Number,
+ default: 0,
+ },
/**
* @description whether to trigger form validation
*/
diff --git a/packages/components/slider/src/slider.vue b/packages/components/slider/src/slider.vue
index 7f764bd8abcff..e45c1ed29b07e 100644
--- a/packages/components/slider/src/slider.vue
+++ b/packages/components/slider/src/slider.vue
@@ -241,6 +241,7 @@ provide(sliderContextKey, {
disabled: sliderDisabled,
precision,
emitChange,
+ markList,
resetSize,
updateDragging,
})