Skip to content

Commit 9d6c8ec

Browse files
committed
feat: allow customization of component v-model prop/event via model option (close #4515)
1 parent bea4d87 commit 9d6c8ec

File tree

8 files changed

+149
-90
lines changed

8 files changed

+149
-90
lines changed

flow/compiler.js

+5
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,11 @@ declare type ASTElement = {
117117
transition?: string | true;
118118
transitionOnAppear?: boolean;
119119

120+
model?: {
121+
value: string;
122+
callback: string;
123+
};
124+
120125
directives?: Array<ASTDirective>;
121126

122127
forbidden?: true;

flow/vnode.js

+5-1
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,11 @@ declare interface VNodeData {
5454
};
5555
directives?: Array<VNodeDirective>;
5656
keepAlive?: boolean;
57-
scopedSlots?: { [key: string]: Function }
57+
scopedSlots?: { [key: string]: Function };
58+
model?: {
59+
value: any;
60+
callback: Function;
61+
};
5862
}
5963

6064
declare type VNodeDirective = {

src/compiler/codegen/index.js

+4
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,10 @@ function genData (el: ASTElement): string {
205205
if (el.scopedSlots) {
206206
data += `${genScopedSlots(el.scopedSlots)},`
207207
}
208+
// component v-model
209+
if (el.model) {
210+
data += `model:{value:${el.model.value},callback:${el.model.callback}},`
211+
}
208212
// inline-template
209213
if (el.inlineTemplate) {
210214
const inlineTemplate = genInlineTemplate(el)

src/core/vdom/create-component.js

+19
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ export function createComponent (
5757

5858
data = data || {}
5959

60+
// transform component v-model data into props & events
61+
if (data.model) {
62+
transformModel(Ctor.options, data)
63+
}
64+
6065
// extract props
6166
const propsData = extractProps(data, Ctor)
6267

@@ -320,3 +325,17 @@ function mergeHook (one: Function, two: Function): Function {
320325
two(a, b, c, d)
321326
}
322327
}
328+
329+
// transform component v-model info (value and callback) into
330+
// prop and event handler respectively.
331+
function transformModel (options, data: any) {
332+
const prop = (options.model && options.model.prop) || 'value'
333+
const event = (options.model && options.model.event) || 'input'
334+
;(data.props || (data.props = {}))[prop] = data.model.value
335+
const on = data.on || (data.on = {})
336+
if (on[event]) {
337+
on[event] = [data.model.callback].concat(on[event])
338+
} else {
339+
on[event] = data.model.callback
340+
}
341+
}

src/platforms/web/compiler/directives/model.js

+73-40
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/* @flow */
22

3+
import config from 'core/config'
34
import { isIE } from 'core/util/env'
45
import { addHandler, addProp, getBindingAttr, parseModel } from 'compiler/helpers'
56

@@ -40,8 +41,19 @@ export default function model (
4041
genCheckboxModel(el, value, modifiers)
4142
} else if (tag === 'input' && type === 'radio') {
4243
genRadioModel(el, value, modifiers)
43-
} else {
44+
} else if (tag === 'input' || tag === 'textarea') {
4445
genDefaultModel(el, value, modifiers)
46+
} else if (!config.isReservedTag(tag)) {
47+
genComponentModel(el, value, modifiers)
48+
// component v-model doesn't need extra runtime
49+
return false
50+
} else if (process.env.NODE_ENV !== 'production') {
51+
warn(
52+
`<${el.tag} v-model="${value}">: ` +
53+
`v-model is not supported on this element type. ` +
54+
'If you are working with contenteditable, it\'s recommended to ' +
55+
'wrap a library dedicated for that purpose inside a custom component.'
56+
)
4557
}
4658

4759
// ensure runtime directive metadata
@@ -107,6 +119,41 @@ function genRadioModel (
107119
addHandler(el, 'click', genAssignmentCode(value, valueBinding), null, true)
108120
}
109121

122+
function genSelect (
123+
el: ASTElement,
124+
value: string,
125+
modifiers: ?ASTModifiers
126+
) {
127+
if (process.env.NODE_ENV !== 'production') {
128+
el.children.some(checkOptionWarning)
129+
}
130+
131+
const number = modifiers && modifiers.number
132+
const selectedVal = `Array.prototype.filter` +
133+
`.call($event.target.options,function(o){return o.selected})` +
134+
`.map(function(o){var val = "_value" in o ? o._value : o.value;` +
135+
`return ${number ? '_n(val)' : 'val'}})`
136+
137+
const assignment = '$event.target.multiple ? $$selectedVal : $$selectedVal[0]'
138+
let code = `var $$selectedVal = ${selectedVal};`
139+
code = `${code} ${genAssignmentCode(value, assignment)}`
140+
addHandler(el, 'change', code, null, true)
141+
}
142+
143+
function checkOptionWarning (option: any): boolean {
144+
if (option.type === 1 &&
145+
option.tag === 'option' &&
146+
option.attrsMap.selected != null) {
147+
warn(
148+
`<select v-model="${option.parent.attrsMap['v-model']}">:\n` +
149+
'inline selected attributes on <option> will be ignored when using v-model. ' +
150+
'Declare initial values in the component\'s data option instead.'
151+
)
152+
return true
153+
}
154+
return false
155+
}
156+
110157
function genDefaultModel (
111158
el: ASTElement,
112159
value: string,
@@ -133,60 +180,46 @@ function genDefaultModel (
133180
const { lazy, number, trim } = modifiers || {}
134181
const event = lazy || (isIE && type === 'range') ? 'change' : 'input'
135182
const needCompositionGuard = !lazy && type !== 'range'
136-
const isNative = el.tag === 'input' || el.tag === 'textarea'
137183

138-
let valueExpression = isNative
139-
? `$event.target.value${trim ? '.trim()' : ''}`
140-
: trim ? `(typeof $event === 'string' ? $event.trim() : $event)` : `$event`
141-
valueExpression = number || type === 'number'
142-
? `_n(${valueExpression})`
143-
: valueExpression
184+
let valueExpression = '$event.target.value'
185+
if (trim) {
186+
valueExpression = `$event.target.value.trim()`
187+
}
188+
if (number) {
189+
valueExpression = `_n(${valueExpression})`
190+
}
144191

145192
let code = genAssignmentCode(value, valueExpression)
146-
if (isNative && needCompositionGuard) {
193+
if (needCompositionGuard) {
147194
code = `if($event.target.composing)return;${code}`
148195
}
149196

150-
addProp(el, 'value', isNative ? `_s(${value})` : `(${value})`)
197+
addProp(el, 'value', `(${value})`)
151198
addHandler(el, event, code, null, true)
152199
if (trim || number || type === 'number') {
153200
addHandler(el, 'blur', '$forceUpdate()')
154201
}
155202
}
156203

157-
function genSelect (
158-
el: ASTElement,
159-
value: string,
160-
modifiers: ?ASTModifiers
161-
) {
162-
if (process.env.NODE_ENV !== 'production') {
163-
el.children.some(checkOptionWarning)
164-
}
165-
166-
const number = modifiers && modifiers.number
167-
const selectedVal = `Array.prototype.filter` +
168-
`.call($event.target.options,function(o){return o.selected})` +
169-
`.map(function(o){var val = "_value" in o ? o._value : o.value;` +
170-
`return ${number ? '_n(val)' : 'val'}})`
204+
function genComponentModel (
205+
el: ASTElement,
206+
value: string,
207+
modifiers: ?ASTModifiers
208+
): ?boolean {
209+
const { number, trim } = modifiers || {}
171210

172-
const assignment = '$event.target.multiple ? $$selectedVal : $$selectedVal[0]'
173-
let code = `var $$selectedVal = ${selectedVal};`
174-
code = `${code} ${genAssignmentCode(value, assignment)}`
175-
addHandler(el, 'change', code, null, true)
176-
}
211+
let valueExpression = 'value'
212+
if (trim) {
213+
valueExpression = `(typeof value === 'string' ? value.trim() : value)`
214+
}
215+
if (number) {
216+
valueExpression = `_n(${valueExpression})`
217+
}
177218

178-
function checkOptionWarning (option: any): boolean {
179-
if (option.type === 1 &&
180-
option.tag === 'option' &&
181-
option.attrsMap.selected != null) {
182-
warn(
183-
`<select v-model="${option.parent.attrsMap['v-model']}">:\n` +
184-
'inline selected attributes on <option> will be ignored when using v-model. ' +
185-
'Declare initial values in the component\'s data option instead.'
186-
)
187-
return true
219+
el.model = {
220+
value,
221+
callback: `function (value) {${genAssignmentCode(value, valueExpression)}}`
188222
}
189-
return false
190223
}
191224

192225
function genAssignmentCode (value: string, assignment: string): string {

src/platforms/web/runtime/directives/model.js

-12
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@
66
import { looseEqual, looseIndexOf } from 'shared/util'
77
import { warn, isAndroid, isIE9, isIE, isEdge } from 'core/util/index'
88

9-
const modelableTagRE = /^input|select|textarea|vue-component-[0-9]+(-[0-9a-zA-Z_-]*)?$/
10-
119
/* istanbul ignore if */
1210
if (isIE9) {
1311
// http://www.matts411.com/post/internet-explorer-9-oninput/
@@ -21,16 +19,6 @@ if (isIE9) {
2119

2220
export default {
2321
inserted (el, binding, vnode) {
24-
if (process.env.NODE_ENV !== 'production') {
25-
if (!modelableTagRE.test(vnode.tag)) {
26-
warn(
27-
`v-model is not supported on element type: <${vnode.tag}>. ` +
28-
'If you are working with contenteditable, it\'s recommended to ' +
29-
'wrap a library dedicated for that purpose inside a custom component.',
30-
vnode.context
31-
)
32-
}
33-
}
3422
if (vnode.tag === 'select') {
3523
const cb = () => {
3624
setSelected(el, binding, vnode.context)

test/unit/features/directives/model-component.spec.js

+42-36
Original file line numberDiff line numberDiff line change
@@ -2,71 +2,77 @@ import Vue from 'vue'
22

33
describe('Directive v-model component', () => {
44
it('should work', done => {
5-
const spy = jasmine.createSpy()
65
const vm = new Vue({
76
data: {
8-
msg: ['hello']
9-
},
10-
watch: {
11-
msg: spy
7+
msg: 'hello'
128
},
139
template: `
1410
<div>
1511
<p>{{ msg }}</p>
16-
<validate v-model="msg[0]">
17-
<input type="text">
18-
</validate>
12+
<test v-model="msg"></test>
1913
</div>
2014
`,
2115
components: {
22-
validate: {
23-
template: '<div><slot></slot></div>',
16+
test: {
2417
props: ['value'],
25-
methods: {
26-
onInput (e) {
27-
// something validate ...
28-
this.$emit('input', e.target.value)
29-
}
30-
},
31-
mounted () {
32-
this.$el.addEventListener('input', this.onInput)
33-
},
34-
destroyed () {
35-
this.$el.removeEventListener('input', this.onInput)
36-
}
18+
template: `<input :value="value" @input="$emit('input', $event.target.value)">`
3719
}
3820
}
3921
}).$mount()
4022
document.body.appendChild(vm.$el)
4123
waitForUpdate(() => {
42-
expect('v-model is not supported on element type').not.toHaveBeenWarned()
4324
const input = vm.$el.querySelector('input')
4425
input.value = 'world'
4526
triggerEvent(input, 'input')
4627
}).then(() => {
47-
expect(vm.msg).toEqual(['world'])
48-
expect(spy).toHaveBeenCalled()
28+
expect(vm.msg).toEqual('world')
29+
expect(vm.$el.querySelector('p').textContent).toEqual('world')
30+
vm.msg = 'changed'
31+
}).then(() => {
32+
expect(vm.$el.querySelector('p').textContent).toEqual('changed')
33+
expect(vm.$el.querySelector('input').value).toEqual('changed')
4934
}).then(() => {
5035
document.body.removeChild(vm.$el)
51-
vm.$destroy()
5236
}).then(done)
5337
})
5438

55-
it('modifier: .lazy', () => {
39+
it('should support customization via model option', done => {
5640
const vm = new Vue({
57-
template: `<div><my-input ref="input" v-model.lazy="text"></my-input></div>`,
58-
data: { text: 'foo' },
41+
data: {
42+
msg: 'hello'
43+
},
44+
template: `
45+
<div>
46+
<p>{{ msg }}</p>
47+
<test v-model="msg"></test>
48+
</div>
49+
`,
5950
components: {
60-
'my-input': {
61-
template: '<input>'
51+
test: {
52+
model: {
53+
prop: 'currentValue',
54+
event: 'update'
55+
},
56+
props: ['currentValue'],
57+
template: `<input :value="currentValue" @input="$emit('update', $event.target.value)">`
6258
}
6359
}
6460
}).$mount()
65-
expect(vm.text).toBe('foo')
66-
vm.$refs.input.$emit('input', 'bar')
67-
expect(vm.text).toBe('foo')
68-
vm.$refs.input.$emit('change', 'bar')
69-
expect(vm.text).toBe('bar')
61+
document.body.appendChild(vm.$el)
62+
waitForUpdate(() => {
63+
const input = vm.$el.querySelector('input')
64+
input.value = 'world'
65+
triggerEvent(input, 'input')
66+
}).then(() => {
67+
expect(vm.msg).toEqual('world')
68+
expect(vm.$el.querySelector('p').textContent).toEqual('world')
69+
vm.msg = 'changed'
70+
}).then(() => {
71+
expect(vm.$el.querySelector('p').textContent).toEqual('changed')
72+
expect(vm.$el.querySelector('input').value).toEqual('changed')
73+
}).then(() => {
74+
document.body.removeChild(vm.$el)
75+
}).then(done)
7076
})
7177

7278
it('modifier: .number', () => {

test/unit/features/directives/model-text.spec.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ describe('Directive v-model text', () => {
235235
},
236236
template: '<div v-model="test"></div>'
237237
}).$mount()
238-
expect('v-model is not supported on element type: <div>').toHaveBeenWarned()
238+
expect('<div v-model="test">: v-model is not supported on this element type').toHaveBeenWarned()
239239
})
240240

241241
// #3468

0 commit comments

Comments
 (0)