mirror of
https://github.com/element-plus/element-plus.git
synced 2026-03-13 07:51:17 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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([])
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user