import { ObjectDirective, VNode, DirectiveHook, DirectiveBinding, warn } from '@vue/runtime-core' import { addEventListener } from '../modules/events' import { isArray, looseEqual, looseIndexOf, invokeArrayFns, toNumber, isSet } from '@vue/shared' type AssignerFn = (value: any) => void const getModelAssigner = (vnode: VNode): AssignerFn => { const fn = vnode.props!['onUpdate:modelValue'] 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 trigger(target, 'input') } } function trigger(el: HTMLElement, type: string) { const e = document.createEvent('HTMLEvents') e.initEvent(type, true, true) el.dispatchEvent(e) } type ModelDirective = ObjectDirective // 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._assign = getModelAssigner(vnode) const castToNumber = number || el.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() } else if (castToNumber) { domValue = toNumber(domValue) } el._assign(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, modifiers: { trim, number } }, vnode) { el._assign = getModelAssigner(vnode) // avoid clearing unresolved text. #2302 if ((el as any).composing) return if (document.activeElement === el) { if (trim && el.value.trim() === value) { return } if ((number || el.type === 'number') && toNumber(el.value) === value) { return } } const newValue = value == null ? '' : value if (el.value !== newValue) { el.value = newValue } } } export const vModelCheckbox: ModelDirective = { created(el, binding, vnode) { setChecked(el, binding, vnode) el._assign = getModelAssigner(vnode) addEventListener(el, 'change', () => { const modelValue = (el as any)._modelValue const elementValue = getValue(el) const checked = el.checked const assign = el._assign 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)) { if (checked) { modelValue.add(elementValue) } else { modelValue.delete(elementValue) } } else { assign(getCheckboxValue(el, checked)) } }) }, beforeUpdate(el, binding, vnode) { el._assign = 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 = { created(el, { value }, vnode) { el.checked = looseEqual(value, vnode.props!.value) el._assign = getModelAssigner(vnode) addEventListener(el, 'change', () => { el._assign(getValue(el)) }) }, beforeUpdate(el, { value, oldValue }, vnode) { el._assign = getModelAssigner(vnode) if (value !== oldValue) { el.checked = looseEqual(value, vnode.props!.value) } } } export const vModelSelect: ModelDirective = { created(el, { modifiers: { number } }, vnode) { addEventListener(el, 'change', () => { const selectedVal = Array.prototype.filter .call(el.options, (o: HTMLOptionElement) => o.selected) .map( (o: HTMLOptionElement) => number ? toNumber(getValue(o)) : getValue(o) ) el._assign(el.multiple ? selectedVal : selectedVal[0]) }) el._assign = getModelAssigner(vnode) }, // set value in mounted & updated because 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 (isArray(value)) { option.selected = looseIndexOf(value, optionValue) > -1 } else { option.selected = value.has(optionValue) } } else { if (looseEqual(getValue(option), value)) { el.selectedIndex = i return } } } if (!isMultiple) { 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 callModelHook( el: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement, binding: DirectiveBinding, vnode: VNode, prevVNode: VNode | null, hook: keyof ObjectDirective ) { let modelToUse: ObjectDirective switch (el.tagName) { case 'SELECT': modelToUse = vModelSelect break case 'TEXTAREA': modelToUse = vModelText break default: switch (vnode.props && vnode.props.type) { case 'checkbox': modelToUse = vModelCheckbox break case 'radio': modelToUse = vModelRadio break default: modelToUse = vModelText } } const fn = modelToUse[hook] as DirectiveHook fn && fn(el, binding, vnode, prevVNode) } // SSR vnode transforms if (__NODE_JS__) { 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 } } } }