diff --git a/docs/en-US/component/input.md b/docs/en-US/component/input.md index c132716fb6..79a543819a 100644 --- a/docs/en-US/component/input.md +++ b/docs/en-US/component/input.md @@ -121,6 +121,7 @@ input/length-limiting | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | | type | type of input | ^[string]`'text' \| 'textarea' \| 'password' \| 'button' \| 'checkbox' \| 'file' \| 'number' \| 'radio' \| ...` [native input types](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#Form_%3Cinput%3E_types) | text | | model-value / v-model | binding value | ^[string] / ^[number] | — | +| model-modifiers ^(2.11.5) | v-model modifiers, reference [Vue modifiers](https://vuejs.org/guide/essentials/forms.html#modifiers) | ^[object]`{ lazy? boolean, number?: boolean, trim?: boolean }` | — | | maxlength | same as `maxlength` in native input | ^[string] / ^[number] | — | | minlength | same as `minlength` in native input | ^[string] / ^[number] | — | | show-word-limit | whether show word count, only works when `type` is 'text' or 'textarea' | ^[boolean] | false | diff --git a/packages/components/input/__tests__/input.test.tsx b/packages/components/input/__tests__/input.test.tsx index eff498b74a..17b41e89e3 100644 --- a/packages/components/input/__tests__/input.test.tsx +++ b/packages/components/input/__tests__/input.test.tsx @@ -289,12 +289,13 @@ describe('Input.vue', () => { expect(vm.$el.querySelector('input').value).not.toEqual('1000') vm.$el.querySelector('input').value = '1,000,000' + vm.$el.querySelector('input').dispatchEvent(event) + expect(val.value).toEqual('1000000') + expect(_val.value).toEqual('1000000') + vm.$el .querySelector('input') .dispatchEvent(new Event('change', { bubbles: true })) - expect(_val.value).toEqual('1000000') - - vm.$el.querySelector('input').dispatchEvent(event) expect(val.value).toEqual('1000000') expect(_val.value).toEqual('1000000') }) @@ -637,6 +638,128 @@ describe('Input.vue', () => { }) }) + test('modelValue modifiers', async () => { + const number = ref() + const trim = ref() + const lazy = ref() + const trimNumber = ref() + const trimLazy = ref() + const numberLazy = ref() + const trimNumberLazy = ref() + + const wrapper = mount(() => ( + <> + + + + + + + + + )) + + await nextTick() + + const triggerEvent = async (type: string, el: Element) => { + const event = new Event(type) + el.dispatchEvent(event) + await nextTick() + } + const mockActiveElement = vi.spyOn(document, 'activeElement', 'get') + + const numberEl = wrapper.find('#number').element as HTMLInputElement + const trimEl = wrapper.find('#trim').element as HTMLInputElement + const lazyEl = wrapper.find('#lazy').element as HTMLInputElement + const trimNumberEl = wrapper.find('#trim-number') + .element as HTMLInputElement + const trimLazyEl = wrapper.find('#trim-lazy').element as HTMLInputElement + const numberLazyEl = wrapper.find('#number-lazy') + .element as HTMLInputElement + const trimNumberLazyEl = wrapper.find('#trim-number-lazy') + .element as HTMLInputElement + + mockActiveElement.mockReturnValue(numberEl) + numberEl.value = '+01.2' + await triggerEvent('input', numberEl) + expect(number.value).toEqual(1.2) + expect(numberEl.value).toEqual('+01.2') + await triggerEvent('change', numberEl) + expect(numberEl.value).toEqual('1.2') + + mockActiveElement.mockReturnValue(trimEl) + trimEl.value = ' hello, world ' + await triggerEvent('input', trimEl) + expect(trim.value).toEqual('hello, world') + expect(trimEl.value).toEqual(' hello, world ') + await triggerEvent('change', trimEl) + expect(trimEl.value).toEqual('hello, world') + + mockActiveElement.mockReturnValue(lazyEl) + lazyEl.value = 'foo' + await triggerEvent('input', lazyEl) + expect(lazy.value).toBeUndefined() + await triggerEvent('change', lazyEl) + expect(lazy.value).toEqual('foo') + + mockActiveElement.mockReturnValue(trimNumberEl) + trimNumberEl.value = ' 1 ' + await triggerEvent('input', trimNumberEl) + expect(trimNumber.value).toEqual(1) + expect(trimNumberEl.value).toEqual(' 1 ') + await triggerEvent('change', trimNumberEl) + expect(trimNumberEl.value).toEqual('1') + + mockActiveElement.mockReturnValue(trimLazyEl) + trimLazyEl.value = ' hello, world ' + await triggerEvent('input', trimLazyEl) + expect(trimLazy.value).toBeUndefined() + expect(trimLazyEl.value).toEqual(' hello, world ') + await triggerEvent('change', trimLazyEl) + expect(trimLazy.value).toEqual('hello, world') + expect(trimLazyEl.value).toEqual('hello, world') + + mockActiveElement.mockReturnValue(numberLazyEl) + numberLazyEl.value = '+01.2' + await triggerEvent('input', numberLazyEl) + expect(numberLazy.value).toBeUndefined() + expect(numberLazyEl.value).toEqual('+01.2') + await triggerEvent('change', numberLazyEl) + expect(numberLazy.value).toEqual(1.2) + expect(numberLazyEl.value).toEqual('1.2') + + mockActiveElement.mockReturnValue(trimNumberLazyEl) + trimNumberLazyEl.value = ' +01.2 ' + await triggerEvent('input', trimNumberLazyEl) + expect(trimNumberLazy.value).toBeUndefined() + expect(trimNumberLazyEl.value).toEqual(' +01.2 ') + await triggerEvent('change', trimNumberLazyEl) + expect(trimNumberLazy.value).toEqual(1.2) + expect(trimNumberLazyEl.value).toEqual('1.2') + + mockActiveElement.mockRestore() + }) + // TODO: validateEvent & input containes select cases should be added after the rest components finished // ... }) diff --git a/packages/components/input/src/input.ts b/packages/components/input/src/input.ts index 131a43911f..78bb13810a 100644 --- a/packages/components/input/src/input.ts +++ b/packages/components/input/src/input.ts @@ -16,6 +16,11 @@ import type { __ExtractPublicPropTypes, } from 'vue' +export type InputModelModifiers = { + lazy?: boolean + number?: boolean + trim?: boolean +} export type InputAutoSize = { minRows?: number; maxRows?: number } | boolean export const inputProps = buildProps({ @@ -45,6 +50,13 @@ export const inputProps = buildProps({ ]), default: '', }, + /** + * @description v-model modifiers, reference [Vue modifiers](https://vuejs.org/guide/essentials/forms.html#modifiers) + */ + modelModifiers: { + type: definePropType(Object), + default: () => ({}), + }, /** * @description same as `maxlength` in native input */ diff --git a/packages/components/input/src/input.vue b/packages/components/input/src/input.vue index 705a8e5df6..ac0fc78887 100644 --- a/packages/components/input/src/input.vue +++ b/packages/components/input/src/input.vue @@ -189,7 +189,7 @@ import { INPUT_EVENT, UPDATE_MODEL_EVENT, } from '@element-plus/constants' -import { calcTextareaHeight } from './utils' +import { calcTextareaHeight, looseToNumber } from './utils' import { inputEmits, inputProps } from './input' import type { StyleValue } from 'vue' @@ -316,6 +316,9 @@ const suffixVisible = computed( isWordLimitVisible.value || (!!validateState.value && needStatusIcon.value) ) +const hasModelModifiers = computed( + () => !!Object.keys(props.modelModifiers).length +) const [recordCursor, setCursor] = useCursor(input) @@ -384,43 +387,69 @@ const setNativeInputValue = () => { input.value = formatterValue } -const handleInput = async (event: Event) => { - recordCursor() - - let { value } = event.target as TargetElement - +const formatValue = (value: string) => { + const { trim, number } = props.modelModifiers + if (trim) { + value = value.trim() + } + if (number) { + value = `${looseToNumber(value)}` + } if (props.formatter && props.parser) { value = props.parser(value) } + return value +} +const handleInput = async (event: Event) => { // should not emit input during composition // see: https://github.com/ElemeFE/element/issues/10516 if (isComposing.value) return - // hack for https://github.com/ElemeFE/element/issues/8548 - // should remove the following line when we don't support IE - if (value === nativeInputValue.value) { - setNativeInputValue() + const { lazy } = props.modelModifiers + let { value } = event.target as TargetElement + if (lazy) { + emit(INPUT_EVENT, value) return } + value = formatValue(value) + + // hack for https://github.com/ElemeFE/element/issues/8548 + // should remove the following line when we don't support IE + if (String(value) === nativeInputValue.value) { + // preserve native features while being compatible with #9501 + if (props.formatter) { + setNativeInputValue() + } + return + } + + recordCursor() emit(UPDATE_MODEL_EVENT, value) emit(INPUT_EVENT, value) // ensure native input value is controlled // see: https://github.com/ElemeFE/element/issues/12850 await nextTick() - setNativeInputValue() + + if ((props.formatter && props.parser) || !hasModelModifiers.value) { + setNativeInputValue() + } setCursor() } -const handleChange = (event: Event) => { +const handleChange = async (event: Event) => { let { value } = event.target as TargetElement - if (props.formatter && props.parser) { - value = props.parser(value) + value = formatValue(value) + if (props.modelModifiers.lazy) { + emit(UPDATE_MODEL_EVENT, value) } emit(CHANGE_EVENT, value) + + await nextTick() + setNativeInputValue() } const { @@ -479,7 +508,29 @@ watch( // native input value is set explicitly // do not use v-model / :value in template // see: https://github.com/ElemeFE/element/issues/14521 -watch(nativeInputValue, () => setNativeInputValue()) +watch(nativeInputValue, (newValue) => { + if (!_ref.value) { + return + } + const { trim, number } = props.modelModifiers + const elValue = _ref.value.value + const displayValue = + (number || props.type === 'number') && !/^0\d/.test(elValue) + ? `${looseToNumber(elValue)}` + : elValue + + if (displayValue === newValue) { + return + } + + if (document.activeElement === _ref.value && _ref.value.type !== 'range') { + if (trim && displayValue.trim() === newValue) { + return + } + } + + setNativeInputValue() +}) // when change between and