Skip to content

Commit

Permalink
feat: allow customization of component v-model prop/event via model o…
Browse files Browse the repository at this point in the history
…ption (close #4515)
  • Loading branch information
yyx990803 committed Jan 22, 2017
1 parent bea4d87 commit 9d6c8ec
Show file tree
Hide file tree
Showing 8 changed files with 149 additions and 90 deletions.
5 changes: 5 additions & 0 deletions flow/compiler.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,11 @@ declare type ASTElement = {
transition?: string | true;
transitionOnAppear?: boolean;

model?: {
value: string;
callback: string;
};

directives?: Array<ASTDirective>;

forbidden?: true;
Expand Down
6 changes: 5 additions & 1 deletion flow/vnode.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,11 @@ declare interface VNodeData {
};
directives?: Array<VNodeDirective>;
keepAlive?: boolean;
scopedSlots?: { [key: string]: Function }
scopedSlots?: { [key: string]: Function };
model?: {
value: any;
callback: Function;
};
}

declare type VNodeDirective = {
Expand Down
4 changes: 4 additions & 0 deletions src/compiler/codegen/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,10 @@ function genData (el: ASTElement): string {
if (el.scopedSlots) {
data += `${genScopedSlots(el.scopedSlots)},`
}
// component v-model
if (el.model) {
data += `model:{value:${el.model.value},callback:${el.model.callback}},`
}
// inline-template
if (el.inlineTemplate) {
const inlineTemplate = genInlineTemplate(el)
Expand Down
19 changes: 19 additions & 0 deletions src/core/vdom/create-component.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ export function createComponent (

data = data || {}

// transform component v-model data into props & events
if (data.model) {
transformModel(Ctor.options, data)
}

// extract props
const propsData = extractProps(data, Ctor)

Expand Down Expand Up @@ -320,3 +325,17 @@ function mergeHook (one: Function, two: Function): Function {
two(a, b, c, d)
}
}

// transform component v-model info (value and callback) into
// prop and event handler respectively.
function transformModel (options, data: any) {
const prop = (options.model && options.model.prop) || 'value'
const event = (options.model && options.model.event) || 'input'
;(data.props || (data.props = {}))[prop] = data.model.value
const on = data.on || (data.on = {})
if (on[event]) {
on[event] = [data.model.callback].concat(on[event])
} else {
on[event] = data.model.callback
}
}
113 changes: 73 additions & 40 deletions src/platforms/web/compiler/directives/model.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* @flow */

import config from 'core/config'
import { isIE } from 'core/util/env'
import { addHandler, addProp, getBindingAttr, parseModel } from 'compiler/helpers'

Expand Down Expand Up @@ -40,8 +41,19 @@ export default function model (
genCheckboxModel(el, value, modifiers)
} else if (tag === 'input' && type === 'radio') {
genRadioModel(el, value, modifiers)
} else {
} else if (tag === 'input' || tag === 'textarea') {
genDefaultModel(el, value, modifiers)
} else if (!config.isReservedTag(tag)) {
genComponentModel(el, value, modifiers)
// component v-model doesn't need extra runtime
return false
} else if (process.env.NODE_ENV !== 'production') {
warn(
`<${el.tag} v-model="${value}">: ` +
`v-model is not supported on this element type. ` +
'If you are working with contenteditable, it\'s recommended to ' +
'wrap a library dedicated for that purpose inside a custom component.'
)
}

// ensure runtime directive metadata
Expand Down Expand Up @@ -107,6 +119,41 @@ function genRadioModel (
addHandler(el, 'click', genAssignmentCode(value, valueBinding), null, true)
}

function genSelect (
el: ASTElement,
value: string,
modifiers: ?ASTModifiers
) {
if (process.env.NODE_ENV !== 'production') {
el.children.some(checkOptionWarning)
}

const number = modifiers && modifiers.number
const selectedVal = `Array.prototype.filter` +
`.call($event.target.options,function(o){return o.selected})` +
`.map(function(o){var val = "_value" in o ? o._value : o.value;` +
`return ${number ? '_n(val)' : 'val'}})`

const assignment = '$event.target.multiple ? $$selectedVal : $$selectedVal[0]'
let code = `var $$selectedVal = ${selectedVal};`
code = `${code} ${genAssignmentCode(value, assignment)}`
addHandler(el, 'change', code, null, true)
}

function checkOptionWarning (option: any): boolean {
if (option.type === 1 &&
option.tag === 'option' &&
option.attrsMap.selected != null) {
warn(
`<select v-model="${option.parent.attrsMap['v-model']}">:\n` +
'inline selected attributes on <option> will be ignored when using v-model. ' +
'Declare initial values in the component\'s data option instead.'
)
return true
}
return false
}

function genDefaultModel (
el: ASTElement,
value: string,
Expand All @@ -133,60 +180,46 @@ function genDefaultModel (
const { lazy, number, trim } = modifiers || {}
const event = lazy || (isIE && type === 'range') ? 'change' : 'input'
const needCompositionGuard = !lazy && type !== 'range'
const isNative = el.tag === 'input' || el.tag === 'textarea'

let valueExpression = isNative
? `$event.target.value${trim ? '.trim()' : ''}`
: trim ? `(typeof $event === 'string' ? $event.trim() : $event)` : `$event`
valueExpression = number || type === 'number'
? `_n(${valueExpression})`
: valueExpression
let valueExpression = '$event.target.value'
if (trim) {
valueExpression = `$event.target.value.trim()`
}
if (number) {
valueExpression = `_n(${valueExpression})`
}

let code = genAssignmentCode(value, valueExpression)
if (isNative && needCompositionGuard) {
if (needCompositionGuard) {
code = `if($event.target.composing)return;${code}`
}

addProp(el, 'value', isNative ? `_s(${value})` : `(${value})`)
addProp(el, 'value', `(${value})`)
addHandler(el, event, code, null, true)
if (trim || number || type === 'number') {
addHandler(el, 'blur', '$forceUpdate()')
}
}

function genSelect (
el: ASTElement,
value: string,
modifiers: ?ASTModifiers
) {
if (process.env.NODE_ENV !== 'production') {
el.children.some(checkOptionWarning)
}

const number = modifiers && modifiers.number
const selectedVal = `Array.prototype.filter` +
`.call($event.target.options,function(o){return o.selected})` +
`.map(function(o){var val = "_value" in o ? o._value : o.value;` +
`return ${number ? '_n(val)' : 'val'}})`
function genComponentModel (
el: ASTElement,
value: string,
modifiers: ?ASTModifiers
): ?boolean {
const { number, trim } = modifiers || {}

const assignment = '$event.target.multiple ? $$selectedVal : $$selectedVal[0]'
let code = `var $$selectedVal = ${selectedVal};`
code = `${code} ${genAssignmentCode(value, assignment)}`
addHandler(el, 'change', code, null, true)
}
let valueExpression = 'value'
if (trim) {
valueExpression = `(typeof value === 'string' ? value.trim() : value)`
}
if (number) {
valueExpression = `_n(${valueExpression})`
}

function checkOptionWarning (option: any): boolean {
if (option.type === 1 &&
option.tag === 'option' &&
option.attrsMap.selected != null) {
warn(
`<select v-model="${option.parent.attrsMap['v-model']}">:\n` +
'inline selected attributes on <option> will be ignored when using v-model. ' +
'Declare initial values in the component\'s data option instead.'
)
return true
el.model = {
value,
callback: `function (value) {${genAssignmentCode(value, valueExpression)}}`
}
return false
}

function genAssignmentCode (value: string, assignment: string): string {
Expand Down
12 changes: 0 additions & 12 deletions src/platforms/web/runtime/directives/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
import { looseEqual, looseIndexOf } from 'shared/util'
import { warn, isAndroid, isIE9, isIE, isEdge } from 'core/util/index'

const modelableTagRE = /^input|select|textarea|vue-component-[0-9]+(-[0-9a-zA-Z_-]*)?$/

/* istanbul ignore if */
if (isIE9) {
// http://www.matts411.com/post/internet-explorer-9-oninput/
Expand All @@ -21,16 +19,6 @@ if (isIE9) {

export default {
inserted (el, binding, vnode) {
if (process.env.NODE_ENV !== 'production') {
if (!modelableTagRE.test(vnode.tag)) {
warn(
`v-model is not supported on element type: <${vnode.tag}>. ` +
'If you are working with contenteditable, it\'s recommended to ' +
'wrap a library dedicated for that purpose inside a custom component.',
vnode.context
)
}
}
if (vnode.tag === 'select') {
const cb = () => {
setSelected(el, binding, vnode.context)
Expand Down
78 changes: 42 additions & 36 deletions test/unit/features/directives/model-component.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,71 +2,77 @@ import Vue from 'vue'

describe('Directive v-model component', () => {
it('should work', done => {
const spy = jasmine.createSpy()
const vm = new Vue({
data: {
msg: ['hello']
},
watch: {
msg: spy
msg: 'hello'
},
template: `
<div>
<p>{{ msg }}</p>
<validate v-model="msg[0]">
<input type="text">
</validate>
<test v-model="msg"></test>
</div>
`,
components: {
validate: {
template: '<div><slot></slot></div>',
test: {
props: ['value'],
methods: {
onInput (e) {
// something validate ...
this.$emit('input', e.target.value)
}
},
mounted () {
this.$el.addEventListener('input', this.onInput)
},
destroyed () {
this.$el.removeEventListener('input', this.onInput)
}
template: `<input :value="value" @input="$emit('input', $event.target.value)">`
}
}
}).$mount()
document.body.appendChild(vm.$el)
waitForUpdate(() => {
expect('v-model is not supported on element type').not.toHaveBeenWarned()
const input = vm.$el.querySelector('input')
input.value = 'world'
triggerEvent(input, 'input')
}).then(() => {
expect(vm.msg).toEqual(['world'])
expect(spy).toHaveBeenCalled()
expect(vm.msg).toEqual('world')
expect(vm.$el.querySelector('p').textContent).toEqual('world')
vm.msg = 'changed'
}).then(() => {
expect(vm.$el.querySelector('p').textContent).toEqual('changed')
expect(vm.$el.querySelector('input').value).toEqual('changed')
}).then(() => {
document.body.removeChild(vm.$el)
vm.$destroy()
}).then(done)
})

it('modifier: .lazy', () => {
it('should support customization via model option', done => {
const vm = new Vue({
template: `<div><my-input ref="input" v-model.lazy="text"></my-input></div>`,
data: { text: 'foo' },
data: {
msg: 'hello'
},
template: `
<div>
<p>{{ msg }}</p>
<test v-model="msg"></test>
</div>
`,
components: {
'my-input': {
template: '<input>'
test: {
model: {
prop: 'currentValue',
event: 'update'
},
props: ['currentValue'],
template: `<input :value="currentValue" @input="$emit('update', $event.target.value)">`
}
}
}).$mount()
expect(vm.text).toBe('foo')
vm.$refs.input.$emit('input', 'bar')
expect(vm.text).toBe('foo')
vm.$refs.input.$emit('change', 'bar')
expect(vm.text).toBe('bar')
document.body.appendChild(vm.$el)
waitForUpdate(() => {
const input = vm.$el.querySelector('input')
input.value = 'world'
triggerEvent(input, 'input')
}).then(() => {
expect(vm.msg).toEqual('world')
expect(vm.$el.querySelector('p').textContent).toEqual('world')
vm.msg = 'changed'
}).then(() => {
expect(vm.$el.querySelector('p').textContent).toEqual('changed')
expect(vm.$el.querySelector('input').value).toEqual('changed')
}).then(() => {
document.body.removeChild(vm.$el)
}).then(done)
})

it('modifier: .number', () => {
Expand Down
2 changes: 1 addition & 1 deletion test/unit/features/directives/model-text.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ describe('Directive v-model text', () => {
},
template: '<div v-model="test"></div>'
}).$mount()
expect('v-model is not supported on element type: <div>').toHaveBeenWarned()
expect('<div v-model="test">: v-model is not supported on this element type').toHaveBeenWarned()
})

// #3468
Expand Down

0 comments on commit 9d6c8ec

Please sign in to comment.