diff --git a/src/runtime/components/InputNumber.vue b/src/runtime/components/InputNumber.vue index e669f81872..896be448c7 100644 --- a/src/runtime/components/InputNumber.vue +++ b/src/runtime/components/InputNumber.vue @@ -117,7 +117,13 @@ const modelValue = useVModel, 'modelValue', 'update:mod const { t } = useLocale() const appConfig = useAppConfig() as InputNumber['AppConfig'] -const rootProps = useForwardProps(reactivePick(props, 'as', 'stepSnapping', 'formatOptions', 'disableWheelChange', 'invertWheelChange', 'required', 'readonly', 'focusOnChange', 'locale'), emits) +// `update:modelValue` is normalized in `onUpdate()` instead of being forwarded as-is +const forwardedEmits = ((event: any, ...args: any[]) => { + if (event !== 'update:modelValue') { + (emits as any)(event, ...args) + } +}) as typeof emits +const rootProps = useForwardProps(reactivePick(props, 'as', 'stepSnapping', 'formatOptions', 'disableWheelChange', 'invertWheelChange', 'required', 'readonly', 'focusOnChange', 'locale'), forwardedEmits) const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, id, color, size: formFieldSize, name, highlight, disabled, ariaAttrs } = useFormField>(_props) const { orientation, size: fieldGroupSize } = useFieldGroup>(_props) @@ -145,10 +151,17 @@ const decrementIcon = computed(() => props.decrementIcon || (props.orientation = const inputRef = useTemplateRef('inputRef') function onUpdate(value: ApplyModifiers | undefined) { - if (props.modelModifiers?.optional) { - modelValue.value = value = value ?? undefined + if (value === undefined) { + // `undefined` covers both a cleared input and unparseable text ("." / "-") + if (inputRef.value?.$el?.value) { + return // non-empty text = unparseable; the controlled root restores the previous value + } + + value = (props.modelModifiers?.optional ? undefined : null) as ApplyModifiers } + modelValue.value = value + // @ts-expect-error - 'target' does not exist in type 'EventInit' const event = new Event('change', { target: { value } }) emits('change', event) diff --git a/test/components/InputNumber.spec.ts b/test/components/InputNumber.spec.ts index c43c91e7c7..d933a95978 100644 --- a/test/components/InputNumber.spec.ts +++ b/test/components/InputNumber.spec.ts @@ -78,6 +78,34 @@ describe('InputNumber', () => { await input.trigger('blur') expect(wrapper.emitted()).toMatchObject({ blur: [[{ type: 'blur' }]] }) }) + + test('keeps the previous value when committing unparseable input', async () => { + const wrapper = await mountSuspended(InputNumber, { props: { modelValue: 5 } }) + const input = wrapper.find('input') + await input.setValue('.') + await input.trigger('keydown', { key: 'Enter' }) + + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + expect((input.element as HTMLInputElement).value).toBe('5') + }) + + test('emits null when cleared', async () => { + const wrapper = await mountSuspended(InputNumber, { props: { modelValue: 5 } }) + const input = wrapper.find('input') + await input.setValue('') + await input.trigger('keydown', { key: 'Enter' }) + + expect(wrapper.emitted('update:modelValue')).toMatchObject([[null]]) + }) + + test('emits undefined when cleared with the optional modifier', async () => { + const wrapper = await mountSuspended(InputNumber, { props: { modelValue: 5, modelModifiers: { optional: true } } }) + const input = wrapper.find('input') + await input.setValue('') + await input.trigger('keydown', { key: 'Enter' }) + + expect(wrapper.emitted('update:modelValue')).toMatchObject([[undefined]]) + }) }) describe('form integration', async () => {