From f695f8f05e5c0e954fb6211aa289ff9cf4e40fc6 Mon Sep 17 00:00:00 2001 From: snowbitx <109521682+snowbitx@users.noreply.github.com> Date: Thu, 5 Mar 2026 22:08:35 +0800 Subject: [PATCH] feat(components): [time-picker] add `save-on-blur` prop (#23531) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(components): [time-picker] prevent auto-fill on focus when empty * docs: add doc * docs: update doc * 更新 time-picker.md * fix: propagate the effect until confirm * Update time-picker.md * fix: clear bug * fix: clear bug * fix: clear bug * fix: time-range clear bug * fix: test error * test: add test case * docs: update doc * fix: clear bug refactor * Update packages/components/time-picker/src/time-picker-com/basic-time-spinner.vue Co-authored-by: rzzf * refactor: use rAF * refactor: optimize useOldValue options * chore: use unknown --------- Co-authored-by: rzzf --- docs/en-US/component/time-picker.md | 1 + .../__tests__/time-picker.test.tsx | 88 +++++++++++++++++++ .../time-picker/src/common/picker.vue | 11 ++- .../time-picker/src/common/props.ts | 8 ++ .../src/composables/use-time-picker.ts | 23 +++-- .../time-picker-com/basic-time-spinner.vue | 16 +++- .../src/time-picker-com/panel-time-pick.vue | 10 ++- .../src/time-picker-com/panel-time-range.vue | 9 +- 8 files changed, 156 insertions(+), 10 deletions(-) diff --git a/docs/en-US/component/time-picker.md b/docs/en-US/component/time-picker.md index 0654246c2f..51983bdedb 100644 --- a/docs/en-US/component/time-picker.md +++ b/docs/en-US/component/time-picker.md @@ -75,6 +75,7 @@ time-picker/range | tabindex | input tabindex | ^[string] / ^[number] | 0 | | empty-values ^(2.7.0) | empty values of component, [see config-provider](./config-provider.md#empty-values-configurations) | ^[array] | — | | value-on-clear ^(2.7.0) | clear return value, [see config-provider](./config-provider.md#empty-values-configurations) | ^[string] / ^[number] / ^[boolean] / ^[Function] | — | +| save-on-blur ^(2.13.4) | Whether to auto-fill the input with the current time on focus when no value is selected | ^[boolean] | true | | label ^(a11y) ^(deprecated) | same as `aria-label` in native input | ^[string] | — | ### Events diff --git a/packages/components/time-picker/__tests__/time-picker.test.tsx b/packages/components/time-picker/__tests__/time-picker.test.tsx index cde14a3910..b9b47a8072 100644 --- a/packages/components/time-picker/__tests__/time-picker.test.tsx +++ b/packages/components/time-picker/__tests__/time-picker.test.tsx @@ -1001,6 +1001,94 @@ describe('TimePicker(range)', () => { expect(endInput.element.value).toBe('') }) + it('should keep empty input on focus when saveOnBlur is false', async () => { + const value = ref('') + const wrapper = mount(() => ( + + )) + + const input = wrapper.find('input') + await input.trigger('focus') + await nextTick() + + expect(input.element.value).toBe('') + }) + + it('should keep range inputs empty on focus when saveOnBlur is false', async () => { + const value = ref<[Date, Date] | []>([]) + const wrapper = mount(() => ( + + )) + + const [startInput, endInput] = wrapper.findAll('input') + await startInput.trigger('focus') + await nextTick() + + expect(startInput.element.value).toBe('') + expect(endInput.element.value).toBe('') + }) + + it('should keep clear state and restore confirmed value on cancel when saveOnBlur is false', async () => { + const value = ref('') + const wrapper = mount( + () => , + { + attachTo: document.body, + } + ) + const input = wrapper.find('input') + + const openPanel = async () => { + await input.trigger('blur') + await input.trigger('focus') + await nextTick() + await rAF() + } + + const selectTime = async (hour: number, minute: number, second: number) => { + const list = document.querySelectorAll('.el-time-spinner__list') + ;(list[0].querySelectorAll('.el-time-spinner__item')[hour] as any).click() + await nextTick() + ;( + list[1].querySelectorAll('.el-time-spinner__item')[minute] as any + ).click() + await nextTick() + ;( + list[2].querySelectorAll('.el-time-spinner__item')[second] as any + ).click() + await nextTick() + } + + await openPanel() + await selectTime(21, 36, 20) + ;(document.querySelector('.el-time-panel__btn.confirm') as any).click() + await nextTick() + expect(input.element.value).toBe('21:36:20') + + await wrapper.find('.el-input').trigger('mouseenter') + await rAF() + await wrapper.find('.clear-icon').trigger('click') + await nextTick() + expect(input.element.value).toBe('') + + await openPanel() + ;(document.querySelector('.el-time-panel__btn.cancel') as any).click() + await nextTick() + expect(input.element.value).toBe('') + + await openPanel() + await selectTime(5, 10, 0) + ;(document.querySelector('.el-time-panel__btn.confirm') as any).click() + await nextTick() + expect(input.element.value).toBe('05:10:00') + + await openPanel() + await selectTime(6, 20, 30) + ;(document.querySelector('.el-time-panel__btn.cancel') as any).click() + await nextTick() + expect(input.element.value).toBe('05:10:00') + }) + it('avoid update initial value when using disabledHours', async () => { const value = ref([]) diff --git a/packages/components/time-picker/src/common/picker.vue b/packages/components/time-picker/src/common/picker.vue index 007fa17906..8aa37d2082 100644 --- a/packages/components/time-picker/src/common/picker.vue +++ b/packages/components/time-picker/src/common/picker.vue @@ -295,7 +295,13 @@ const { isFocused, handleFocus, handleBlur } = useFocusController(inputRef, { ) }, afterBlur() { - handleChange() + if (isTimePicker.value && !props.saveOnBlur) { + if (!valueIsEmpty.value) { + pickerOptions.value.handleCancel?.() + } + } else { + handleChange() + } pickerVisible.value = false hasJustTabExitedInput = false props.validateEvent && @@ -407,6 +413,7 @@ const displayValue = computed(() => { } else if (userInput.value !== null) { return userInput.value } + if (isTimePicker.value && valueIsEmpty.value && !props.saveOnBlur) return '' if (!isTimePicker.value && valueIsEmpty.value) return '' if (!pickerVisible.value && valueIsEmpty.value) return '' if (formattedValue) { @@ -518,6 +525,8 @@ onBeforeUnmount(() => { }) const handleChange = () => { + if (isTimePicker.value && !props.saveOnBlur) return + if (userInput.value) { const value = parseUserInputToDayjs(displayValue.value) if (value) { diff --git a/packages/components/time-picker/src/common/props.ts b/packages/components/time-picker/src/common/props.ts index a1869597d1..4fd60924b2 100644 --- a/packages/components/time-picker/src/common/props.ts +++ b/packages/components/time-picker/src/common/props.ts @@ -105,6 +105,13 @@ export const timePickerDefaultProps = buildProps({ type: Boolean, default: true, }, + /** + * @description Whether to auto-fill the input with the current time on focus when no value is selected. + */ + saveOnBlur: { + type: Boolean, + default: true, + }, /** * @description Custom prefix icon component */ @@ -281,6 +288,7 @@ export interface PickerOptions { panelReady: boolean handleClear: () => void handleFocusPicker?: () => void + handleCancel?: () => void } export const timePickerRangeTriggerProps = buildProps({ diff --git a/packages/components/time-picker/src/composables/use-time-picker.ts b/packages/components/time-picker/src/composables/use-time-picker.ts index 8e7fe3feb9..625642a9bd 100644 --- a/packages/components/time-picker/src/composables/use-time-picker.ts +++ b/packages/components/time-picker/src/composables/use-time-picker.ts @@ -1,6 +1,7 @@ -import { ref, watch } from 'vue' +import { ref, toValue, watch } from 'vue' import { makeList } from '../utils' +import type { MaybeRefOrGetter } from 'vue' import type { Dayjs } from 'dayjs' import type { GetDisabledHours, @@ -88,15 +89,27 @@ export const buildAvailableTimeSlotGetter = ( } } -export const useOldValue = (props: { - parsedValue?: string | Dayjs | Dayjs[] - visible: boolean -}) => { +export const useOldValue = ( + props: { + parsedValue?: string | Dayjs | Dayjs[] + visible: boolean + }, + options: { + modelValue: MaybeRefOrGetter + valueOnClear: MaybeRefOrGetter + } +) => { const oldValue = ref(props.parsedValue) watch( () => props.visible, (val) => { + const modelValue = toValue(options.modelValue) + const valueOnClear = toValue(options.valueOnClear) + if (val && modelValue === valueOnClear) { + oldValue.value = valueOnClear as typeof oldValue.value + return + } if (!val) { oldValue.value = props.parsedValue } diff --git a/packages/components/time-picker/src/time-picker-com/basic-time-spinner.vue b/packages/components/time-picker/src/time-picker-com/basic-time-spinner.vue index 55394d0d6f..f4ed1b0025 100755 --- a/packages/components/time-picker/src/time-picker-com/basic-time-spinner.vue +++ b/packages/components/time-picker/src/time-picker-com/basic-time-spinner.vue @@ -86,7 +86,7 @@ import ElScrollbar from '@element-plus/components/scrollbar' import ElIcon from '@element-plus/components/icon' import { ArrowDown, ArrowUp } from '@element-plus/icons-vue' import { useNamespace } from '@element-plus/hooks' -import { getStyle, isNumber } from '@element-plus/utils' +import { getStyle, isNumber, rAF } from '@element-plus/utils' import { CHANGE_EVENT } from '@element-plus/constants' import { DEFAULT_FORMATS_TIME, @@ -104,7 +104,7 @@ import type { TimeList } from '../utils' const props = defineProps(basicTimeSpinnerProps) const pickerBase = inject(PICKER_BASE_INJECTION_KEY) as any -const { isRange, format } = pickerBase.props +const { isRange, format, saveOnBlur } = pickerBase.props const emit = defineEmits([CHANGE_EVENT, 'select-range', 'set-option']) const ns = useNamespace('time') @@ -117,6 +117,11 @@ const { getHoursList, getMinutesList, getSecondsList } = getTimeLists( // data let isScrolling = false +const ignoreScroll = { + hours: false, + minutes: false, + seconds: false, +} const currentScrollbar = ref() const listHoursRef = ref() @@ -223,6 +228,12 @@ const adjustSpinner = (type: TimeUnit, value: number) => { if (props.arrowControl) return const scrollbar = unref(listRefsMap[type]) if (scrollbar && scrollbar.$el) { + if (!saveOnBlur) { + ignoreScroll[type] = true + rAF(() => { + ignoreScroll[type] = false + }) + } getScrollbarElement(scrollbar.$el).scrollTop = Math.max( 0, value * typeItemHeight(type) @@ -310,6 +321,7 @@ const handleClick = ( } const handleScroll = (type: TimeUnit) => { + if (!saveOnBlur && ignoreScroll[type]) return const scrollbar = unref(listRefsMap[type]) if (!scrollbar) return diff --git a/packages/components/time-picker/src/time-picker-com/panel-time-pick.vue b/packages/components/time-picker/src/time-picker-com/panel-time-pick.vue index 03483316e5..5ea1bd18c5 100644 --- a/packages/components/time-picker/src/time-picker-com/panel-time-pick.vue +++ b/packages/components/time-picker/src/time-picker-com/panel-time-pick.vue @@ -76,7 +76,14 @@ const ns = useNamespace('time') const { t, lang } = useLocale() // data const selectionRange = ref([0, 2]) -const oldValue = useOldValue(props) + +const oldValue = useOldValue(props, { + modelValue: computed(() => pickerBase.props.modelValue), + valueOnClear: computed(() => + pickerBase?.emptyValues ? pickerBase.emptyValues.valueOnClear.value : null + ), +}) + // computed const transitionName = computed(() => { return isUndefined(props.actualVisible) @@ -191,4 +198,5 @@ emit('set-picker-option', ['parseUserInput', parseUserInput]) emit('set-picker-option', ['handleKeydownInput', handleKeydown]) emit('set-picker-option', ['getRangeAvailableTime', getRangeAvailableTime]) emit('set-picker-option', ['getDefaultValue', getDefaultValue]) +emit('set-picker-option', ['handleCancel', handleCancel]) diff --git a/packages/components/time-picker/src/time-picker-com/panel-time-range.vue b/packages/components/time-picker/src/time-picker-com/panel-time-range.vue index 402002dcb4..f85e2bf4c3 100644 --- a/packages/components/time-picker/src/time-picker-com/panel-time-range.vue +++ b/packages/components/time-picker/src/time-picker-com/panel-time-range.vue @@ -123,7 +123,12 @@ const endContainerKls = computed(() => [ const startTime = computed(() => props.parsedValue![0]) const endTime = computed(() => props.parsedValue![1]) -const oldValue = useOldValue(props) +const oldValue = useOldValue(props, { + modelValue: computed(() => pickerBase.props.modelValue), + valueOnClear: computed(() => + pickerBase?.emptyValues ? pickerBase.emptyValues.valueOnClear.value : null + ), +}) const handleCancel = () => { const old = oldValue.value emit('pick', old, false) @@ -131,6 +136,7 @@ const handleCancel = () => { oldValue.value = old }) } + const showSeconds = computed(() => { return props.format.includes('ss') }) @@ -310,4 +316,5 @@ emit('set-picker-option', ['isValidValue', isValidValue]) emit('set-picker-option', ['handleKeydownInput', handleKeydown]) emit('set-picker-option', ['getDefaultValue', getDefaultValue]) emit('set-picker-option', ['getRangeAvailableTime', getRangeAvailableTime]) +emit('set-picker-option', ['handleCancel', handleCancel])