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 @@ 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, })