feat(components): [input] add modelModifiers prop (#22415)

* feat(components): [input] add `modelModifiers` prop

* refactor: update

* refactor: update

* fix: input uncontrolled in lazy mode

* docs: add desc

* perf: cache `Object.keys(props.modelModifiers).length`

Co-authored-by: warmthsea <2586244885@qq.com>

---------

Co-authored-by: warmthsea <2586244885@qq.com>
Co-authored-by: btea <2356281422@qq.com>
This commit is contained in:
Zhong
2025-10-16 09:49:54 +08:00
committed by GitHub
parent aca3724be4
commit 645df6d95a
5 changed files with 210 additions and 18 deletions

View File

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

View File

@@ -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(() => (
<>
<Input
id="number"
v-model={number.value}
modelModifiers={{ number: true }}
/>
<Input id="trim" v-model={trim.value} modelModifiers={{ trim: true }} />
<Input id="lazy" v-model={lazy.value} modelModifiers={{ lazy: true }} />
<Input
id="trim-number"
v-model={trimNumber.value}
modelModifiers={{ trim: true, number: true }}
/>
<Input
id="trim-lazy"
v-model={trimLazy.value}
modelModifiers={{ trim: true, lazy: true }}
/>
<Input
id="number-lazy"
v-model={numberLazy.value}
modelModifiers={{ number: true, lazy: true }}
/>
<Input
id="trim-number-lazy"
v-model={trimNumberLazy.value}
modelModifiers={{ trim: true, number: true, lazy: true }}
/>
</>
))
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
// ...
})

View File

@@ -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<InputModelModifiers>(Object),
default: () => ({}),
},
/**
* @description same as `maxlength` in native input
*/

View File

@@ -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 <input> and <textarea>,
// update DOM dependent value and styles

View File

@@ -43,6 +43,11 @@ type TextAreaHeight = {
minHeight?: string
}
export const looseToNumber = (val: any): any => {
const n = Number.parseFloat(val)
return Number.isNaN(n) ? val : n
}
function calculateNodeStyling(targetElement: Element): NodeStyle {
const style = window.getComputedStyle(targetElement)