From 16989d818759e87d61e72e6852f41559635f9d71 Mon Sep 17 00:00:00 2001 From: opengraphica <57385187+opengraphica@users.noreply.github.com> Date: Wed, 27 Apr 2022 09:38:47 -0400 Subject: [PATCH] feat(components): [slider] aria keyboard controls and attrs (#7389) * feat(components): [slider] aria keyboard controls and attrs fix #7350 * feat(components): [slider] fix lint error in slider types * feat(components): [slider] change start value for home/end unit test * feat(components): prevent scrolling on touch screen for runway * feat(components): [slider] type-o in locale prop * fix(components): [slider] PR comments r1 * fix(components): [slider] linting errors --- docs/en-US/component/slider.md | 3 + .../slider/__tests__/slider.test.ts | 268 ++++++++++++------ packages/components/slider/src/button.vue | 13 +- packages/components/slider/src/index.vue | 108 +++++-- packages/components/slider/src/slider.type.ts | 27 +- packages/components/slider/src/useSlide.ts | 87 ++++-- .../components/slider/src/useSliderButton.ts | 101 +++++-- packages/locale/lang/en.ts | 5 + 8 files changed, 432 insertions(+), 180 deletions(-) diff --git a/docs/en-US/component/slider.md b/docs/en-US/component/slider.md index 986d035060..e10b84ebf4 100644 --- a/docs/en-US/component/slider.md +++ b/docs/en-US/component/slider.md @@ -91,6 +91,9 @@ slider/show-marks | vertical | vertical mode | boolean | — | false | | height | Slider height, required in vertical mode | string | — | — | | label | label for screen reader | string | — | — | +| range-start-label | when `range` is true, screen reader label for the start of the range | string | — | — | +| range-end-label | when `range` is true, screen reader label for the end of the range | string | — | — | +| format-value-text | format to display the `aria-valuenow` attribute for screen readers | function(value) | — | — | | debounce | debounce delay when typing, in milliseconds, works when `show-input` is true | number | — | 300 | | tooltip-class | custom class name for the tooltip | string | — | — | | marks | marks, type of key must be `number` and must in closed interval `[min, max]`, each mark can custom style | object | — | — | diff --git a/packages/components/slider/__tests__/slider.test.ts b/packages/components/slider/__tests__/slider.test.ts index 44bc7f7927..b83dc9620a 100644 --- a/packages/components/slider/__tests__/slider.test.ts +++ b/packages/components/slider/__tests__/slider.test.ts @@ -1,7 +1,7 @@ import { h, nextTick } from 'vue' import { mount } from '@vue/test-utils' -import { describe, expect, it, vi } from 'vitest' -import sleep from '@element-plus/test-utils/sleep' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { EVENT_CODE } from '@element-plus/constants' import Slider from '../src/index.vue' vi.mock('lodash-unified', async () => { @@ -16,6 +16,14 @@ vi.mock('lodash-unified', async () => { }) describe('Slider', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + it('create', () => { const wrapper = mount(Slider) expect(wrapper.props().modelValue).toBe(0) @@ -34,7 +42,7 @@ describe('Slider', () => { }, }) - await sleep(10) + await nextTick() wrapper.vm.value = 40 await nextTick() expect(wrapper.vm.value).toBe(50) @@ -118,7 +126,7 @@ describe('Slider', () => { } }, methods: { - formatTooltip(val) { + formatTooltip(val: number) { return `$${val}` }, }, @@ -131,6 +139,7 @@ describe('Slider', () => { describe('drag', () => { it('horizontal', async () => { + vi.useRealTimers() const wrapper = mount( { template: ` @@ -151,9 +160,11 @@ describe('Slider', () => { ) const slider = wrapper.findComponent({ name: 'ElSliderButton' }) - const mockClientWidth = vi - .spyOn(wrapper.find('.el-slider__runway').element, 'clientWidth', 'get') - .mockImplementation(() => 200) + vi.spyOn( + wrapper.find('.el-slider__runway').element, + 'clientWidth', + 'get' + ).mockImplementation(() => 200) slider.trigger('mousedown', { clientX: 0 }) const mousemove = document.createEvent('MouseEvent') @@ -198,10 +209,10 @@ describe('Slider', () => { await nextTick() expect(wrapper.vm.value === 50).toBeTruthy() - mockClientWidth.mockRestore() }) it('vertical', async () => { + vi.useRealTimers() const wrapper = mount( { template: ` @@ -221,14 +232,11 @@ describe('Slider', () => { } ) const slider = wrapper.findComponent({ name: 'ElSliderButton' }) - - const mockClientHeight = vi - .spyOn( - wrapper.find('.el-slider__runway').element, - 'clientHeight', - 'get' - ) - .mockImplementation(() => 200) + vi.spyOn( + wrapper.find('.el-slider__runway').element, + 'clientHeight', + 'get' + ).mockImplementation(() => 200) slider.trigger('mousedown', { clientY: 0 }) const mousemove = document.createEvent('MouseEvent') mousemove.initMouseEvent( @@ -270,37 +278,123 @@ describe('Slider', () => { window.dispatchEvent(mouseup) await nextTick() expect(wrapper.vm.value).toBe(50) - mockClientHeight.mockRestore() }) }) - it('accessibility', (done) => { - const wrapper = mount({ - template: ` -
- -
- `, - components: { Slider }, - data() { - return { - value: 0.1, - } - }, - }) - const slider: any = wrapper.findComponent({ name: 'ElSliderButton' }) - slider.vm.onRightKeyDown() - setTimeout(() => { + describe('accessibility', () => { + it('left/right arrows', async () => { + const wrapper = mount({ + template: ` +
+ +
+ `, + components: { Slider }, + data() { + return { + value: 0.1, + } + }, + }) + const slider: any = wrapper.findComponent({ name: 'ElSliderButton' }) + + slider.vm.onKeyDown( + new KeyboardEvent('keydown', { key: EVENT_CODE.right }) + ) + await nextTick() expect(wrapper.vm.value).toBe(1) - slider.vm.onLeftKeyDown() - setTimeout(() => { - expect(wrapper.vm.value).toBe(0) - done() - }, 10) - }, 10) + + slider.vm.onKeyDown( + new KeyboardEvent('keydown', { key: EVENT_CODE.left }) + ) + await nextTick() + expect(wrapper.vm.value).toBe(0) + }) + + it('up/down arrows', async () => { + const wrapper = mount({ + template: ` +
+ +
+ `, + components: { Slider }, + data() { + return { + value: 0.1, + } + }, + }) + const slider: any = wrapper.findComponent({ name: 'ElSliderButton' }) + + slider.vm.onKeyDown(new KeyboardEvent('keydown', { key: EVENT_CODE.up })) + await nextTick() + expect(wrapper.vm.value).toBe(1) + + slider.vm.onKeyDown( + new KeyboardEvent('keydown', { key: EVENT_CODE.down }) + ) + await nextTick() + expect(wrapper.vm.value).toBe(0) + }) + + it('page up/down keys', async () => { + const wrapper = mount({ + template: ` +
+ +
+ `, + components: { Slider }, + data() { + return { + value: -1, + } + }, + }) + const slider: any = wrapper.findComponent({ name: 'ElSliderButton' }) + slider.vm.onKeyDown( + new KeyboardEvent('keydown', { key: EVENT_CODE.pageUp }) + ) + await nextTick() + expect(wrapper.vm.value).toBe(3) + + slider.vm.onKeyDown( + new KeyboardEvent('keydown', { key: EVENT_CODE.pageDown }) + ) + await nextTick() + expect(wrapper.vm.value).toBe(-1) + }) + + it('home/end keys', async () => { + const wrapper = mount({ + template: ` +
+ +
+ `, + components: { Slider }, + data() { + return { + value: 0, + } + }, + }) + const slider: any = wrapper.findComponent({ name: 'ElSliderButton' }) + slider.vm.onKeyDown( + new KeyboardEvent('keydown', { key: EVENT_CODE.home }) + ) + await nextTick() + expect(wrapper.vm.value).toBe(-5) + + slider.vm.onKeyDown(new KeyboardEvent('keydown', { key: EVENT_CODE.end })) + await nextTick() + expect(wrapper.vm.value).toBe(10) + }) }) it('step', (done) => { + vi.useRealTimers() const wrapper = mount( { template: ` @@ -372,6 +466,7 @@ describe('Slider', () => { }) it('click', (done) => { + vi.useRealTimers() const wrapper = mount({ template: `
@@ -385,17 +480,22 @@ describe('Slider', () => { } }, }) + const mockClientWidth = vi + .spyOn(wrapper.find('.el-slider__runway').element, 'clientWidth', 'get') + .mockImplementation(() => 200) const slider: any = wrapper.findComponent({ name: 'ElSlider' }) setTimeout(() => { - slider.vm.onSliderClick({ clientX: 100 }) + slider.vm.onSliderClick(new MouseEvent('mousedown', { clientX: 100 })) setTimeout(() => { expect(wrapper.vm.value > 0).toBeTruthy() done() + mockClientWidth.mockRestore() }, 10) }, 10) }) it('change event', (done) => { + vi.useRealTimers() const wrapper = mount({ template: `
@@ -411,7 +511,7 @@ describe('Slider', () => { } }, methods: { - onChange(val) { + onChange(val: number) { this.data = val }, }, @@ -432,7 +532,7 @@ describe('Slider', () => { .mockImplementation(() => 200) setTimeout(() => { expect(wrapper.vm.data).toBe(0) - slider.vm.onSliderClick({ clientX: 100 }) + slider.vm.onSliderClick(new MouseEvent('mousedown', { clientX: 100 })) setTimeout(() => { expect(wrapper.vm.data === 50).toBeTruthy() mockRectLeft.mockRestore() @@ -443,6 +543,7 @@ describe('Slider', () => { }) it('input event', async (done) => { + vi.useRealTimers() const wrapper = mount({ template: `
@@ -458,7 +559,7 @@ describe('Slider', () => { } }, methods: { - onInput(val) { + onInput(val: number) { this.data = val }, }, @@ -479,7 +580,7 @@ describe('Slider', () => { .mockImplementation(() => 200) await nextTick() expect(wrapper.vm.data).toBe(0) - slider.vm.onSliderClick({ clientX: 100 }) + slider.vm.onSliderClick(new MouseEvent('mousedown', { clientX: 100 })) await nextTick() expect(wrapper.vm.data === 50).toBeTruthy() mockRectLeft.mockRestore() @@ -488,6 +589,7 @@ describe('Slider', () => { }) it('disabled', (done) => { + vi.useRealTimers() const wrapper = mount({ template: `
@@ -552,6 +654,7 @@ describe('Slider', () => { }) it('show input', (done) => { + vi.useRealTimers() const wrapper = mount({ template: `
@@ -585,6 +688,7 @@ describe('Slider', () => { }) it('vertical mode', (done) => { + vi.useRealTimers() const wrapper = mount( { template: ` @@ -618,7 +722,7 @@ describe('Slider', () => { .mockImplementation(() => 200) const slider: any = wrapper.getComponent({ name: 'ElSlider' }) setTimeout(() => { - slider.vm.onSliderClick({ clientY: 100 }) + slider.vm.onSliderClick(new MouseEvent('mousedown', { clientX: 100 })) setTimeout(() => { expect(wrapper.vm.value > 0).toBeTruthy() mockRectBottom.mockRestore() @@ -665,7 +769,7 @@ describe('Slider', () => { expect(sliders.length).toBe(2) }) - it('should not exceed min and max', (done) => { + it('should not exceed min and max', async () => { const wrapper = mount({ template: `
@@ -680,20 +784,19 @@ describe('Slider', () => { } }, }) - setTimeout(() => { - wrapper.vm.value = [40, 60] - setTimeout(() => { - expect(wrapper.vm.value).toStrictEqual([50, 60]) - wrapper.vm.value = [50, 120] - setTimeout(() => { - expect(wrapper.vm.value).toStrictEqual([50, 100]) - done() - }, 10) - }, 10) - }, 10) + await nextTick() + + wrapper.vm.value = [40, 60] + await nextTick() + expect(wrapper.vm.value).toStrictEqual([50, 60]) + + wrapper.vm.value = [50, 120] + await nextTick() + expect(wrapper.vm.value).toStrictEqual([50, 100]) }) it('click', (done) => { + vi.useRealTimers() const wrapper = mount( { template: ` @@ -727,7 +830,7 @@ describe('Slider', () => { .mockImplementation(() => 200) const slider: any = wrapper.getComponent({ name: 'ElSlider' }) setTimeout(() => { - slider.vm.onSliderClick({ clientX: 100 }) + slider.vm.onSliderClick(new MouseEvent('mousedown', { clientX: 100 })) setTimeout(() => { // Because mock the clientWidth, so the targetValue is 50. // The behavior of the setPosition method in the useSlider.ts file should be that the value of the second button is 50 @@ -740,7 +843,7 @@ describe('Slider', () => { }, 10) }) - it('responsive to dynamic min and max', (done) => { + it('responsive to dynamic min and max', async () => { const wrapper = mount({ template: `
@@ -757,21 +860,19 @@ describe('Slider', () => { } }, }) - setTimeout(() => { - wrapper.vm.min = 60 - setTimeout(() => { - expect(wrapper.vm.value).toStrictEqual([60, 80]) - wrapper.vm.min = 30 - wrapper.vm.max = 40 - setTimeout(() => { - expect(wrapper.vm.value).toStrictEqual([40, 40]) - done() - }, 10) - }, 10) - }, 10) + await nextTick() + + wrapper.vm.min = 60 + await nextTick() + expect(wrapper.vm.value).toStrictEqual([60, 80]) + + wrapper.vm.min = 30 + wrapper.vm.max = 40 + await nextTick() + expect(wrapper.vm.value).toStrictEqual([40, 40]) }) - it('show stops', (done) => { + it('show stops', async () => { const wrapper = mount({ template: `
@@ -790,11 +891,9 @@ describe('Slider', () => { } }, }) - setTimeout(() => { - const stops = wrapper.findAll('.el-slider__stop') - expect(stops.length).toBe(5) - done() - }, 10) + await nextTick() + const stops = wrapper.findAll('.el-slider__stop') + expect(stops.length).toBe(5) }) it('marks', async () => { @@ -830,14 +929,11 @@ describe('Slider', () => { }, }) - nextTick().then(() => { - const stops = wrapper.findAll('.el-slider__marks-stop.el-slider__stop') - const marks = wrapper.findAll( - '.el-slider__marks .el-slider__marks-text' - ) - expect(marks.length).toBe(2) - expect(stops.length).toBe(2) - }) + await nextTick() + const stops = wrapper.findAll('.el-slider__marks-stop.el-slider__stop') + const marks = wrapper.findAll('.el-slider__marks .el-slider__marks-text') + expect(marks.length).toBe(2) + expect(stops.length).toBe(2) }) }) }) diff --git a/packages/components/slider/src/button.vue b/packages/components/slider/src/button.vue index 2adab65425..ad1c812849 100644 --- a/packages/components/slider/src/button.vue +++ b/packages/components/slider/src/button.vue @@ -10,10 +10,7 @@ @touchstart="onButtonDown" @focus="handleMouseEnter" @blur="handleMouseLeave" - @keydown.left="onLeftKeyDown" - @keydown.right="onRightKeyDown" - @keydown.down.prevent="onLeftKeyDown" - @keydown.up.prevent="onRightKeyDown" + @keydown="onKeyDown" >
@@ -101,20 +117,17 @@ import { import { debugWarn, isValidComponentSize, - off, - on, throwError, } from '@element-plus/utils' -import { useNamespace, useSize } from '@element-plus/hooks' +import { useLocale, useNamespace, useSize } from '@element-plus/hooks' import SliderButton from './button.vue' import SliderMarker from './marker.vue' import { useMarks } from './useMarks' import { useSlide } from './useSlide' import { useStops } from './useStops' -import type { PropType, Ref } from 'vue' +import type { PropType } from 'vue' import type { ComponentSize } from '@element-plus/constants' -import type { Nullable } from '@element-plus/utils' export default defineComponent({ name: 'ElSlider', @@ -194,6 +207,18 @@ export default defineComponent({ type: String, default: undefined, }, + rangeStartLabel: { + type: String, + default: undefined, + }, + rangeEndLabel: { + type: String, + default: undefined, + }, + formatValueText: { + type: Function as PropType<(val: number) => string>, + default: undefined, + }, tooltipClass: { type: String, default: undefined, @@ -205,6 +230,7 @@ export default defineComponent({ setup(props, { emit }) { const ns = useNamespace('slider') + const { t } = useLocale() const initData = reactive({ firstValue: 0, secondValue: 0, @@ -225,7 +251,9 @@ export default defineComponent({ barStyle, resetSize, emitChange, + onSliderWrapperPrevent, onSliderClick, + onSliderDown, setFirstValue, setSecondValue, } = useSlide(props, initData, emit) @@ -242,6 +270,40 @@ export default defineComponent({ () => props.inputSize || sliderWrapperSize.value ) + const groupLabel = computed(() => { + return ( + props.label || + t('el.slider.defaultLabel', { + min: props.min, + max: props.max, + }) + ) + }) + + const firstButtonLabel = computed(() => { + if (props.range) { + return props.rangeStartLabel || t('el.slider.defaultRangeStartLabel') + } else { + return groupLabel.value + } + }) + + const firstValueText = computed(() => { + return props.formatValueText + ? props.formatValueText(firstValue.value) + : `${firstValue.value}` + }) + + const secondButtonLabel = computed(() => { + return props.rangeEndLabel || t('el.slider.defaultRangeEndLabel') + }) + + const secondValueText = computed(() => { + return props.formatValueText + ? props.formatValueText(secondValue.value) + : `${secondValue.value}` + }) + const sliderKls = computed(() => [ ns.b(), ns.m(sliderWrapperSize.value), @@ -289,13 +351,20 @@ export default defineComponent({ sliderSize, slider, + groupLabel, firstButton, + firstButtonLabel, + firstValueText, secondButton, + secondButtonLabel, + secondValueText, sliderDisabled, runwayStyle, barStyle, emitChange, onSliderClick, + onSliderWrapperPrevent, + onSliderDown, getStopStyle, setFirstValue, setSecondValue, @@ -405,10 +474,9 @@ const useWatch = (props, initData, minValue, maxValue, emit, elFormItem) => { } const useLifecycle = (props, initData, resetSize) => { - const sliderWrapper: Ref> = ref(null) + const sliderWrapper = ref() onMounted(async () => { - let valuetext if (props.range) { if (Array.isArray(props.modelValue)) { initData.firstValue = Math.max(props.min, props.modelValue[0]) @@ -418,7 +486,6 @@ const useLifecycle = (props, initData, resetSize) => { initData.secondValue = props.max } initData.oldValue = [initData.firstValue, initData.secondValue] - valuetext = `${initData.firstValue}-${initData.secondValue}` } else { if ( typeof props.modelValue !== 'number' || @@ -432,25 +499,16 @@ const useLifecycle = (props, initData, resetSize) => { ) } initData.oldValue = initData.firstValue - valuetext = initData.firstValue } - sliderWrapper.value.setAttribute('aria-valuetext', valuetext) - - // label screen reader - sliderWrapper.value.setAttribute( - 'aria-label', - props.label ? props.label : `slider between ${props.min} and ${props.max}` - ) - - on(window, 'resize', resetSize) + window.addEventListener('resize', resetSize) await nextTick() resetSize() }) onBeforeUnmount(() => { - off(window, 'resize', resetSize) + window.removeEventListener('resize', resetSize) }) return { diff --git a/packages/components/slider/src/slider.type.ts b/packages/components/slider/src/slider.type.ts index 76c4843213..74d13a1fd0 100644 --- a/packages/components/slider/src/slider.type.ts +++ b/packages/components/slider/src/slider.type.ts @@ -1,5 +1,9 @@ -import type { CSSProperties, ComputedRef, Ref } from 'vue' -import type { Nullable } from '@element-plus/utils' +import type { + CSSProperties, + ComponentPublicInstance, + ComputedRef, + Ref, +} from 'vue' export interface ISliderProps { modelValue: number | number[] @@ -18,14 +22,17 @@ export interface ISliderProps { height: string debounce: number label: string + rangeStartLabel: string + rangeEndLabel: string + formatValueText: (val: number) => string tooltipClass: string marks?: Record } export interface ISliderInitData { - firstValue: Nullable - secondValue: Nullable - oldValue: Nullable + firstValue: number + secondValue: number + oldValue?: number dragging: boolean sliderSize: number } @@ -73,7 +80,7 @@ export type Slide = { } export type ButtonRefs = { - [s in 'firstButton' | 'secondButton']: Ref> + [s in 'firstButton' | 'secondButton']: Ref } export interface ISliderButtonProps { @@ -95,15 +102,15 @@ export interface ISliderButtonInitData { oldValue: number } -export interface ISliderButton { - tooltip: Ref> +export interface ISliderButton extends ComponentPublicInstance { + tooltip: Ref showTooltip: Ref wrapperStyle: ComputedRef formatValue: ComputedRef + dragging: boolean handleMouseEnter: () => void handleMouseLeave: () => void onButtonDown: (event: MouseEvent | TouchEvent) => void - onLeftKeyDown: () => void - onRightKeyDown: () => void + onKeyDown: (event: KeyboardEvent) => void setPosition: (newPosition: number) => void } diff --git a/packages/components/slider/src/useSlide.ts b/packages/components/slider/src/useSlide.ts index e541eec3c9..1ec879c972 100644 --- a/packages/components/slider/src/useSlide.ts +++ b/packages/components/slider/src/useSlide.ts @@ -5,24 +5,28 @@ import { UPDATE_MODEL_EVENT, } from '@element-plus/constants' import { formContextKey, formItemContextKey } from '@element-plus/tokens' -import type { CSSProperties } from 'vue' -import type { ButtonRefs, ISliderInitData, ISliderProps } from './slider.type' +import type { CSSProperties, ComponentInternalInstance, Ref } from 'vue' +import type { + ButtonRefs, + ISliderButton, + ISliderInitData, + ISliderProps, +} from './slider.type' import type { FormContext, FormItemContext } from '@element-plus/tokens' -import type { Nullable } from '@element-plus/utils' export const useSlide = ( props: ISliderProps, initData: ISliderInitData, - emit + emit: ComponentInternalInstance['emit'] ) => { const elForm = inject(formContextKey, {} as FormContext) const elFormItem = inject(formItemContextKey, {} as FormItemContext) - const slider = shallowRef>(null) + const slider = shallowRef() - const firstButton = ref(null) + const firstButton = ref() - const secondButton = ref(null) + const secondButton = ref() const buttonRefs: ButtonRefs = { firstButton, @@ -80,13 +84,14 @@ export const useSlide = ( } } - const setPosition = (percent: number) => { + const getButtonRefByPercent = ( + percent: number + ): Ref => { const targetValue = props.min + (percent * (props.max - props.min)) / 100 if (!props.range) { - firstButton.value.setPosition(percent) - return + return firstButton } - let buttonRefName: string + let buttonRefName: 'firstButton' | 'secondButton' if ( Math.abs(minValue.value - targetValue) < Math.abs(maxValue.value - targetValue) @@ -101,7 +106,13 @@ export const useSlide = ( ? 'firstButton' : 'secondButton' } - buttonRefs[buttonRefName].value.setPosition(percent) + return buttonRefs[buttonRefName] + } + + const setPosition = (percent: number): Ref => { + const buttonRef = getButtonRefByPercent(percent) + buttonRef.value!.setPosition(percent) + return buttonRef } const setFirstValue = (firstValue: number) => { @@ -130,21 +141,51 @@ export const useSlide = ( ) } - const onSliderClick = (event: MouseEvent) => { + const handleSliderPointerEvent = ( + event: MouseEvent | TouchEvent + ): Ref | undefined => { if (sliderDisabled.value || initData.dragging) return resetSize() + let newPercent = 0 if (props.vertical) { - const sliderOffsetBottom = slider.value.getBoundingClientRect().bottom - setPosition( - ((sliderOffsetBottom - event.clientY) / initData.sliderSize) * 100 - ) + const clientY = + (event as TouchEvent).touches?.item(0)?.clientY ?? + (event as MouseEvent).clientY + const sliderOffsetBottom = slider.value!.getBoundingClientRect().bottom + newPercent = ((sliderOffsetBottom - clientY) / initData.sliderSize) * 100 } else { - const sliderOffsetLeft = slider.value.getBoundingClientRect().left - setPosition( - ((event.clientX - sliderOffsetLeft) / initData.sliderSize) * 100 - ) + const clientX = + (event as TouchEvent).touches?.item(0)?.clientX ?? + (event as MouseEvent).clientX + const sliderOffsetLeft = slider.value!.getBoundingClientRect().left + newPercent = ((clientX - sliderOffsetLeft) / initData.sliderSize) * 100 + } + if (newPercent < 0 || newPercent > 100) return + return setPosition(newPercent) + } + + const onSliderWrapperPrevent = (event: TouchEvent) => { + if ( + buttonRefs['firstButton'].value?.dragging || + buttonRefs['secondButton'].value?.dragging + ) { + event.preventDefault() + } + } + + const onSliderDown = async (event: MouseEvent | TouchEvent) => { + const buttonRef = handleSliderPointerEvent(event) + if (buttonRef) { + await nextTick() + buttonRef.value!.onButtonDown(event) + } + } + + const onSliderClick = (event: MouseEvent | TouchEvent) => { + const buttonRef = handleSliderPointerEvent(event) + if (buttonRef) { + emitChange() } - emitChange() } return { @@ -160,7 +201,9 @@ export const useSlide = ( resetSize, setPosition, emitChange, + onSliderWrapperPrevent, onSliderClick, + onSliderDown, setFirstValue, setSecondValue, } diff --git a/packages/components/slider/src/useSliderButton.ts b/packages/components/slider/src/useSliderButton.ts index e91c87e742..114339d683 100644 --- a/packages/components/slider/src/useSliderButton.ts +++ b/packages/components/slider/src/useSliderButton.ts @@ -1,21 +1,22 @@ import { computed, inject, nextTick, ref, watch } from 'vue' import { debounce } from 'lodash-unified' -import { UPDATE_MODEL_EVENT } from '@element-plus/constants' -import { off, on } from '@element-plus/utils' +import { EVENT_CODE, UPDATE_MODEL_EVENT } from '@element-plus/constants' -import type { CSSProperties, ComputedRef } from 'vue' +import type { CSSProperties, ComponentInternalInstance, ComputedRef } from 'vue' import type { ISliderButtonInitData, ISliderButtonProps, ISliderProvider, } from './slider.type' +const { left, down, right, up, home, end, pageUp, pageDown } = EVENT_CODE + const useTooltip = ( props: ISliderButtonProps, formatTooltip: ComputedRef<(value: number) => number | string>, showTooltip: ComputedRef ) => { - const tooltip = ref(null) + const tooltip = ref() const tooltipVisible = ref(false) @@ -50,7 +51,7 @@ const useTooltip = ( export const useSliderButton = ( props: ISliderButtonProps, initData: ISliderButtonInitData, - emit + emit: ComponentInternalInstance['emit'] ) => { const { disabled, @@ -64,11 +65,13 @@ export const useSliderButton = ( emitChange, resetSize, updateDragging, - } = inject('SliderProvider') + } = inject('SliderProvider')! const { tooltip, tooltipVisible, formatValue, displayTooltip, hideTooltip } = useTooltip(props, formatTooltip, showTooltip) + const button = ref() + const currentPosition = computed(() => { return `${ ((props.modelValue - min.value) / (max.value - min.value)) * 100 @@ -99,31 +102,71 @@ export const useSliderButton = ( if (disabled.value) return event.preventDefault() onDragStart(event) - on(window, 'mousemove', onDragging) - on(window, 'touchmove', onDragging) - on(window, 'mouseup', onDragEnd) - on(window, 'touchend', onDragEnd) - on(window, 'contextmenu', onDragEnd) + window.addEventListener('mousemove', onDragging) + window.addEventListener('touchmove', onDragging) + window.addEventListener('mouseup', onDragEnd) + window.addEventListener('touchend', onDragEnd) + window.addEventListener('contextmenu', onDragEnd) + button.value.focus() } - const onLeftKeyDown = () => { - if (disabled.value) return - initData.newPosition = - Number.parseFloat(currentPosition.value) - - (step.value / (max.value - min.value)) * 100 - setPosition(initData.newPosition) - emitChange() - } - - const onRightKeyDown = () => { + const incrementPosition = (amount: number) => { if (disabled.value) return initData.newPosition = Number.parseFloat(currentPosition.value) + - (step.value / (max.value - min.value)) * 100 + (amount / (max.value - min.value)) * 100 setPosition(initData.newPosition) emitChange() } + const onLeftKeyDown = () => { + incrementPosition(-step.value) + } + + const onRightKeyDown = () => { + incrementPosition(step.value) + } + + const onPageDownKeyDown = () => { + incrementPosition(-step.value * 4) + } + + const onPageUpKeyDown = () => { + incrementPosition(step.value * 4) + } + + const onHomeKeyDown = () => { + if (disabled.value) return + setPosition(0) + emitChange() + } + + const onEndKeyDown = () => { + if (disabled.value) return + setPosition(100) + emitChange() + } + + const onKeyDown = (event: KeyboardEvent) => { + let isPreventDefault = true + if ([left, down].includes(event.key)) { + onLeftKeyDown() + } else if ([right, up].includes(event.key)) { + onRightKeyDown() + } else if (event.key === home) { + onHomeKeyDown() + } else if (event.key === end) { + onEndKeyDown() + } else if (event.key === pageDown) { + onPageDownKeyDown() + } else if (event.key === pageUp) { + onPageUpKeyDown() + } else { + isPreventDefault = false + } + isPreventDefault && event.preventDefault() + } + const getClientXY = (event: MouseEvent | TouchEvent) => { let clientX: number let clientY: number @@ -188,11 +231,11 @@ export const useSliderButton = ( emitChange() } }, 0) - off(window, 'mousemove', onDragging) - off(window, 'touchmove', onDragging) - off(window, 'mouseup', onDragEnd) - off(window, 'touchend', onDragEnd) - off(window, 'contextmenu', onDragEnd) + window.removeEventListener('mousemove', onDragging) + window.removeEventListener('touchmove', onDragging) + window.removeEventListener('mouseup', onDragEnd) + window.removeEventListener('touchend', onDragEnd) + window.removeEventListener('contextmenu', onDragEnd) } } @@ -227,6 +270,7 @@ export const useSliderButton = ( ) return { + button, tooltip, tooltipVisible, showTooltip, @@ -235,8 +279,7 @@ export const useSliderButton = ( handleMouseEnter, handleMouseLeave, onButtonDown, - onLeftKeyDown, - onRightKeyDown, + onKeyDown, setPosition, } } diff --git a/packages/locale/lang/en.ts b/packages/locale/lang/en.ts index d85760f4df..0590bf67a2 100644 --- a/packages/locale/lang/en.ts +++ b/packages/locale/lang/en.ts @@ -91,6 +91,11 @@ export default { preview: 'Preview', continue: 'Continue', }, + slider: { + defaultLabel: 'slider between {min} and {max}', + defaultRangeStartLabel: 'pick start value', + defaultRangeEndLabel: 'pick end value', + }, table: { emptyText: 'No Data', confirmFilter: 'Confirm',