feat(components): [time-picker] add save-on-blur prop (#23531)

* 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 <cszhjh@gmail.com>

* refactor: use rAF

* refactor: optimize useOldValue options

* chore: use unknown

---------

Co-authored-by: rzzf <cszhjh@gmail.com>
This commit is contained in:
snowbitx
2026-03-05 22:08:35 +08:00
committed by GitHub
parent ea926472df
commit f695f8f05e
8 changed files with 156 additions and 10 deletions

View File

@@ -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

View File

@@ -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(() => (
<TimePicker v-model={value.value} saveOnBlur={false} />
))
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(() => (
<TimePicker v-model={value.value} is-range saveOnBlur={false} />
))
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(
() => <TimePicker v-model={value.value} saveOnBlur={false} clearable />,
{
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([])

View File

@@ -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<UserInput>(() => {
} 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) {

View File

@@ -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({

View File

@@ -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<unknown>
valueOnClear: MaybeRefOrGetter<unknown>
}
) => {
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
}

View File

@@ -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<TimeUnit>()
const listHoursRef = ref<ScrollbarInstance>()
@@ -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

View File

@@ -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])
</script>

View File

@@ -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])
</script>