vue3-core/packages/runtime-dom/src/directives/vModel.ts

371 lines
10 KiB
TypeScript

import {
type DirectiveBinding,
type DirectiveHook,
type ObjectDirective,
type VNode,
nextTick,
warn,
} from '@vue/runtime-core'
import { addEventListener } from '../modules/events'
import {
invokeArrayFns,
isArray,
isSet,
looseEqual,
looseIndexOf,
looseToNumber,
} from '@vue/shared'
type AssignerFn = (value: any) => void
const getModelAssigner = (vnode: VNode): AssignerFn => {
const fn =
vnode.props!['onUpdate:modelValue'] ||
(__COMPAT__ && vnode.props!['onModelCompat:input'])
return isArray(fn) ? value => invokeArrayFns(fn, value) : fn
}
function onCompositionStart(e: Event) {
;(e.target as any).composing = true
}
function onCompositionEnd(e: Event) {
const target = e.target as any
if (target.composing) {
target.composing = false
target.dispatchEvent(new Event('input'))
}
}
const assignKey = Symbol('_assign')
type ModelDirective<T> = ObjectDirective<
T & { [assignKey]: AssignerFn; _assigning?: boolean }
>
// We are exporting the v-model runtime directly as vnode hooks so that it can
// be tree-shaken in case v-model is never used.
export const vModelText: ModelDirective<
HTMLInputElement | HTMLTextAreaElement
> = {
created(el, { modifiers: { lazy, trim, number } }, vnode) {
el[assignKey] = getModelAssigner(vnode)
const castToNumber =
number || (vnode.props && vnode.props.type === 'number')
addEventListener(el, lazy ? 'change' : 'input', e => {
if ((e.target as any).composing) return
let domValue: string | number = el.value
if (trim) {
domValue = domValue.trim()
}
if (castToNumber) {
domValue = looseToNumber(domValue)
}
el[assignKey](domValue)
})
if (trim) {
addEventListener(el, 'change', () => {
el.value = el.value.trim()
})
}
if (!lazy) {
addEventListener(el, 'compositionstart', onCompositionStart)
addEventListener(el, 'compositionend', onCompositionEnd)
// Safari < 10.2 & UIWebView doesn't fire compositionend when
// switching focus before confirming composition choice
// this also fixes the issue where some browsers e.g. iOS Chrome
// fires "change" instead of "input" on autocomplete.
addEventListener(el, 'change', onCompositionEnd)
}
},
// set value on mounted so it's after min/max for type="range"
mounted(el, { value }) {
el.value = value == null ? '' : value
},
beforeUpdate(
el,
{ value, oldValue, modifiers: { lazy, trim, number } },
vnode
) {
el[assignKey] = getModelAssigner(vnode)
// avoid clearing unresolved text. #2302
if ((el as any).composing) return
const elValue =
(number || el.type === 'number') && !/^0\d/.test(el.value)
? looseToNumber(el.value)
: el.value
const newValue = value == null ? '' : value
if (elValue === newValue) {
return
}
if (document.activeElement === el && el.type !== 'range') {
// #8546
if (lazy && value === oldValue) {
return
}
if (trim && el.value.trim() === newValue) {
return
}
}
el.value = newValue
},
}
export const vModelCheckbox: ModelDirective<HTMLInputElement> = {
// #4096 array checkboxes need to be deep traversed
deep: true,
created(el, _, vnode) {
el[assignKey] = getModelAssigner(vnode)
addEventListener(el, 'change', () => {
const modelValue = (el as any)._modelValue
const elementValue = getValue(el)
const checked = el.checked
const assign = el[assignKey]
if (isArray(modelValue)) {
const index = looseIndexOf(modelValue, elementValue)
const found = index !== -1
if (checked && !found) {
assign(modelValue.concat(elementValue))
} else if (!checked && found) {
const filtered = [...modelValue]
filtered.splice(index, 1)
assign(filtered)
}
} else if (isSet(modelValue)) {
const cloned = new Set(modelValue)
if (checked) {
cloned.add(elementValue)
} else {
cloned.delete(elementValue)
}
assign(cloned)
} else {
assign(getCheckboxValue(el, checked))
}
})
},
// set initial checked on mount to wait for true-value/false-value
mounted: setChecked,
beforeUpdate(el, binding, vnode) {
el[assignKey] = getModelAssigner(vnode)
setChecked(el, binding, vnode)
},
}
function setChecked(
el: HTMLInputElement,
{ value, oldValue }: DirectiveBinding,
vnode: VNode,
) {
// store the v-model value on the element so it can be accessed by the
// change listener.
;(el as any)._modelValue = value
if (isArray(value)) {
el.checked = looseIndexOf(value, vnode.props!.value) > -1
} else if (isSet(value)) {
el.checked = value.has(vnode.props!.value)
} else if (value !== oldValue) {
el.checked = looseEqual(value, getCheckboxValue(el, true))
}
}
export const vModelRadio: ModelDirective<HTMLInputElement> = {
created(el, { value }, vnode) {
el.checked = looseEqual(value, vnode.props!.value)
el[assignKey] = getModelAssigner(vnode)
addEventListener(el, 'change', () => {
el[assignKey](getValue(el))
})
},
beforeUpdate(el, { value, oldValue }, vnode) {
el[assignKey] = getModelAssigner(vnode)
if (value !== oldValue) {
el.checked = looseEqual(value, vnode.props!.value)
}
},
}
export const vModelSelect: ModelDirective<HTMLSelectElement> = {
// <select multiple> value need to be deep traversed
deep: true,
created(el, { value, modifiers: { number } }, vnode) {
const isSetModel = isSet(value)
addEventListener(el, 'change', () => {
const selectedVal = Array.prototype.filter
.call(el.options, (o: HTMLOptionElement) => o.selected)
.map((o: HTMLOptionElement) =>
number ? looseToNumber(getValue(o)) : getValue(o),
)
el[assignKey](
el.multiple
? isSetModel
? new Set(selectedVal)
: selectedVal
: selectedVal[0],
)
el._assigning = true
nextTick(() => {
el._assigning = false
})
})
el[assignKey] = getModelAssigner(vnode)
},
// set value in mounted & updated because <select> relies on its children
// <option>s.
mounted(el, { value, modifiers: { number } }) {
setSelected(el, value, number)
},
beforeUpdate(el, _binding, vnode) {
el[assignKey] = getModelAssigner(vnode)
},
updated(el, { value, modifiers: { number } }) {
if (!el._assigning) {
setSelected(el, value, number)
}
},
}
function setSelected(el: HTMLSelectElement, value: any, number: boolean) {
const isMultiple = el.multiple
const isArrayValue = isArray(value)
if (isMultiple && !isArrayValue && !isSet(value)) {
__DEV__ &&
warn(
`<select multiple v-model> expects an Array or Set value for its binding, ` +
`but got ${Object.prototype.toString.call(value).slice(8, -1)}.`,
)
return
}
for (let i = 0, l = el.options.length; i < l; i++) {
const option = el.options[i]
const optionValue = getValue(option)
if (isMultiple) {
if (isArrayValue) {
const optionType = typeof optionValue
// fast path for string / number values
if (optionType === 'string' || optionType === 'number') {
option.selected = value.some(v => String(v) === String(optionValue))
} else {
option.selected = looseIndexOf(value, optionValue) > -1
}
} else {
option.selected = value.has(optionValue)
}
} else if (looseEqual(getValue(option), value)) {
if (el.selectedIndex !== i) el.selectedIndex = i
return
}
}
if (!isMultiple && el.selectedIndex !== -1) {
el.selectedIndex = -1
}
}
// retrieve raw value set via :value bindings
function getValue(el: HTMLOptionElement | HTMLInputElement) {
return '_value' in el ? (el as any)._value : el.value
}
// retrieve raw value for true-value and false-value set via :true-value or :false-value bindings
function getCheckboxValue(
el: HTMLInputElement & { _trueValue?: any; _falseValue?: any },
checked: boolean,
) {
const key = checked ? '_trueValue' : '_falseValue'
return key in el ? el[key] : checked
}
export const vModelDynamic: ObjectDirective<
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
> = {
created(el, binding, vnode) {
callModelHook(el, binding, vnode, null, 'created')
},
mounted(el, binding, vnode) {
callModelHook(el, binding, vnode, null, 'mounted')
},
beforeUpdate(el, binding, vnode, prevVNode) {
callModelHook(el, binding, vnode, prevVNode, 'beforeUpdate')
},
updated(el, binding, vnode, prevVNode) {
callModelHook(el, binding, vnode, prevVNode, 'updated')
},
}
function resolveDynamicModel(tagName: string, type: string | undefined) {
switch (tagName) {
case 'SELECT':
return vModelSelect
case 'TEXTAREA':
return vModelText
default:
switch (type) {
case 'checkbox':
return vModelCheckbox
case 'radio':
return vModelRadio
default:
return vModelText
}
}
}
function callModelHook(
el: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement,
binding: DirectiveBinding,
vnode: VNode,
prevVNode: VNode | null,
hook: keyof ObjectDirective,
) {
const modelToUse = resolveDynamicModel(
el.tagName,
vnode.props && vnode.props.type,
)
const fn = modelToUse[hook] as DirectiveHook
fn && fn(el, binding, vnode, prevVNode)
}
// SSR vnode transforms, only used when user includes client-oriented render
// function in SSR
export function initVModelForSSR() {
vModelText.getSSRProps = ({ value }) => ({ value })
vModelRadio.getSSRProps = ({ value }, vnode) => {
if (vnode.props && looseEqual(vnode.props.value, value)) {
return { checked: true }
}
}
vModelCheckbox.getSSRProps = ({ value }, vnode) => {
if (isArray(value)) {
if (vnode.props && looseIndexOf(value, vnode.props.value) > -1) {
return { checked: true }
}
} else if (isSet(value)) {
if (vnode.props && value.has(vnode.props.value)) {
return { checked: true }
}
} else if (value) {
return { checked: true }
}
}
vModelDynamic.getSSRProps = (binding, vnode) => {
if (typeof vnode.type !== 'string') {
return
}
const modelToUse = resolveDynamicModel(
// resolveDynamicModel expects an uppercase tag name, but vnode.type is lowercase
vnode.type.toUpperCase(),
vnode.props && vnode.props.type,
)
if (modelToUse.getSSRProps) {
return modelToUse.getSSRProps(binding, vnode)
}
}
}