Browse Source

feat(v-model): support dynamic input type binding

dev
Evan You 7 years ago
parent
commit
f3fe012d54
  1. 11
      flow/compiler.js
  2. 13
      src/compiler/helpers.js
  3. 65
      src/compiler/parser/index.js
  4. 7
      src/platforms/web/compiler/directives/model.js
  5. 4
      src/platforms/web/compiler/modules/index.js
  6. 77
      src/platforms/web/compiler/modules/model.js
  7. 120
      test/unit/features/directives/model-dynamic.spec.js

11
flow/compiler.js

@ -36,8 +36,10 @@ declare type CompiledResult = {
};
declare type ModuleOptions = {
preTransformNode: (el: ASTElement) => void;
transformNode: (el: ASTElement) => void; // transform an element's AST node
// returning an ASTElement from pre/transforms replaces the element
preTransformNode: (el: ASTElement) => ?ASTElement;
transformNode: (el: ASTElement) => ?ASTElement;
// cannot return replacement in postTransform because tree is already finalized
postTransformNode: (el: ASTElement) => void;
genData: (el: ASTElement) => string; // generate extra data string for an element
transformCode?: (el: ASTElement, code: string) => string; // further transform generated code for an element
@ -45,7 +47,8 @@ declare type ModuleOptions = {
};
declare type ASTModifiers = { [key: string]: boolean };
declare type ASTIfConditions = Array<{ exp: ?string; block: ASTElement }>;
declare type ASTIfCondition = { exp: ?string; block: ASTElement };
declare type ASTIfConditions = Array<ASTIfCondition>;
declare type ASTElementHandler = {
value: string;
@ -74,6 +77,8 @@ declare type ASTElement = {
parent: ASTElement | void;
children: Array<ASTNode>;
processed?: true;
static?: boolean;
staticRoot?: boolean;
staticInFor?: boolean;

13
src/compiler/helpers.js

@ -104,7 +104,15 @@ export function getBindingAttr (
}
}
export function getAndRemoveAttr (el: ASTElement, name: string): ?string {
// note: this only removes the attr from the Array (attrsList) so that it
// doesn't get processed by processAttrs.
// By default it does NOT remove it from the map (attrsMap) because the map is
// needed during codegen.
export function getAndRemoveAttr (
el: ASTElement,
name: string,
removeFromMap?: boolean
): ?string {
let val
if ((val = el.attrsMap[name]) != null) {
const list = el.attrsList
@ -115,5 +123,8 @@ export function getAndRemoveAttr (el: ASTElement, name: string): ?string {
}
}
}
if (removeFromMap) {
delete el.attrsMap[name]
}
return val
}

65
src/compiler/parser/index.js

@ -40,6 +40,22 @@ let platformIsPreTag
let platformMustUseProp
let platformGetTagNamespace
type Attr = { name: string; value: string }
export function createASTElement (
tag: string,
attrs: Array<Attr>,
parent: ASTElement | void
): ASTElement {
return {
type: 1,
tag,
attrsList: attrs,
attrsMap: makeAttrsMap(attrs),
parent,
children: []
}
}
/**
* Convert HTML string to AST.
*/
@ -102,14 +118,7 @@ export function parse (
attrs = guardIESVGBug(attrs)
}
const element: ASTElement = {
type: 1,
tag,
attrsList: attrs,
attrsMap: makeAttrsMap(attrs),
parent: currentParent,
children: []
}
let element: ASTElement = createASTElement(tag, attrs, currentParent)
if (ns) {
element.ns = ns
}
@ -125,7 +134,7 @@ export function parse (
// apply pre-transforms
for (let i = 0; i < preTransforms.length; i++) {
preTransforms[i](element, options)
element = preTransforms[i](element, options) || element
}
if (!inVPre) {
@ -139,23 +148,13 @@ export function parse (
}
if (inVPre) {
processRawAttrs(element)
} else {
} else if (!element.processed) {
// structural directives
processFor(element)
processIf(element)
processOnce(element)
processKey(element)
// determine whether this is a plain element after
// removing structural attributes
element.plain = !element.key && !attrs.length
processRef(element)
processSlot(element)
processComponent(element)
for (let i = 0; i < transforms.length; i++) {
transforms[i](element, options)
}
processAttrs(element)
// element-scope stuff
processElement(element, options)
}
function checkRootConstraints (el) {
@ -309,6 +308,22 @@ function processRawAttrs (el) {
}
}
export function processElement (element: ASTElement, options: CompilerOptions) {
processKey(element)
// determine whether this is a plain element after
// removing structural attributes
element.plain = !element.key && !element.attrsList.length
processRef(element)
processSlot(element)
processComponent(element)
for (let i = 0; i < transforms.length; i++) {
element = transforms[i](element, options) || element
}
processAttrs(element)
}
function processKey (el) {
const exp = getBindingAttr(el, 'key')
if (exp) {
@ -327,7 +342,7 @@ function processRef (el) {
}
}
function processFor (el) {
export function processFor (el: ASTElement) {
let exp
if ((exp = getAndRemoveAttr(el, 'v-for'))) {
const inMatch = exp.match(forAliasRE)
@ -403,7 +418,7 @@ function findPrevElement (children: Array<any>): ASTElement | void {
}
}
function addIfCondition (el, condition) {
export function addIfCondition (el: ASTElement, condition: ASTIfCondition) {
if (!el.ifConditions) {
el.ifConditions = []
}

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

@ -22,13 +22,6 @@ export default function model (
const type = el.attrsMap.type
if (process.env.NODE_ENV !== 'production') {
const dynamicType = el.attrsMap['v-bind:type'] || el.attrsMap[':type']
if (tag === 'input' && dynamicType) {
warn(
`<input :type="${dynamicType}" v-model="${value}">:\n` +
`v-model does not support dynamic input types. Use v-if branches instead.`
)
}
// inputs with type="file" are read only and setting the input's
// value will throw an error.
if (tag === 'input' && type === 'file') {

4
src/platforms/web/compiler/modules/index.js

@ -1,7 +1,9 @@
import klass from './class'
import style from './style'
import model from './model'
export default [
klass,
style
style,
model
]

77
src/platforms/web/compiler/modules/model.js

@ -0,0 +1,77 @@
/* @flow */
/**
* Expand input[v-model] with dyanmic type bindings into v-if-else chains
* Turn this:
* <input v-model="data[type]" :type="type">
* into this:
* <input v-if="type === 'checkbox'" type="checkbox" v-model="data[type]">
* <input v-else-if="type === 'radio'" type="radio" v-model="data[type]">
* <input v-else :type="type" v-model="data[type]">
*/
import {
getBindingAttr,
getAndRemoveAttr
} from 'compiler/helpers'
import {
processFor,
processElement,
addIfCondition,
createASTElement
} from 'compiler/parser/index'
function preTransformNode (el: ASTElement, options: CompilerOptions) {
if (el.tag === 'input') {
const map = el.attrsMap
if (map['v-model'] && (map['v-bind:type'] || map[':type'])) {
const typeBinding: any = getBindingAttr(el, 'type')
const ifCondition = getAndRemoveAttr(el, 'v-if', true)
// 1. checkbox
const branch0 = cloneASTElement(el)
// process for on the main node
processFor(branch0)
addRawAttr(branch0, 'type', 'checkbox')
processElement(branch0, options)
branch0.processed = true // prevent it from double-processed
branch0.if = `type==='checkbox'` + (ifCondition ? `&&(${ifCondition})` : ``)
addIfCondition(branch0, {
exp: branch0.if,
block: branch0
})
// 2. add radio else-if condition
const branch1 = cloneASTElement(el)
getAndRemoveAttr(branch1, 'v-for', true)
addRawAttr(branch1, 'type', 'radio')
processElement(branch1, options)
addIfCondition(branch0, {
exp: `type==='radio'` + (ifCondition ? `&&(${ifCondition})` : ``),
block: branch1
})
// 3. other
const branch2 = cloneASTElement(el)
getAndRemoveAttr(branch2, 'v-for', true)
addRawAttr(branch2, ':type', typeBinding)
processElement(branch2, options)
addIfCondition(branch0, {
exp: ifCondition,
block: branch2
})
return branch0
}
}
}
function cloneASTElement (el) {
return createASTElement(el.tag, el.attrsList.slice(), el.parent)
}
function addRawAttr (el, name, value) {
el.attrsMap[name] = value
el.attrsList.push({ name, value })
}
export default {
preTransformNode
}

120
test/unit/features/directives/model-dynamic.spec.js

@ -1,14 +1,122 @@
import Vue from 'vue'
describe('Directive v-model dynamic input type', () => {
it('should warn', function () {
new Vue({
it('should work', done => {
const vm = new Vue({
data: {
type: 'text',
text: 'hi'
type: null,
test: 'b'
},
template: `<input :type="type" v-model="text">`
template: `<input :type="type" v-model="test">`
}).$mount()
expect(`v-model does not support dynamic input types`).toHaveBeenWarned()
document.body.appendChild(vm.$el)
// test text
assertInputWorks(vm).then(done)
})
it('with v-if', done => {
const vm = new Vue({
data: {
ok: true,
type: null,
test: 'b'
},
template: `<input v-if="ok" :type="type" v-model="test"><div v-else>haha</div>`
}).$mount()
document.body.appendChild(vm.$el)
const chain = assertInputWorks(vm).then(() => {
vm.ok = false
}).then(() => {
expect(vm.$el.textContent).toBe('haha')
}).then(() => {
// reset
vm.ok = true
vm.type = null
vm.test = 'b'
})
assertInputWorks(vm, chain).then(done)
})
it('with v-for', done => {
const vm = new Vue({
data: {
data: {
text: 'foo',
checkbox: true
},
types: ['text', 'checkbox']
},
template: `<div>
<input v-for="type in types" :type="type" v-model="data[type]">
</div>`
}).$mount()
document.body.appendChild(vm.$el)
let el1 = vm.$el.children[0]
expect(el1.type).toBe('text')
expect(el1.value).toBe('foo')
el1.value = 'bar'
triggerEvent(el1, 'input')
expect(vm.data.text).toBe('bar')
let el2 = vm.$el.children[1]
expect(el2.type).toBe('checkbox')
expect(el2.checked).toBe(true)
el2.click()
expect(vm.data.checkbox).toBe(false)
// now in reverse!
vm.types.reverse()
waitForUpdate(() => {
el1 = vm.$el.children[0]
expect(el1.type).toBe('checkbox')
expect(el1.checked).toBe(false)
el1.click()
expect(vm.data.checkbox).toBe(true)
el2 = vm.$el.children[1]
expect(el2.type).toBe('text')
expect(el2.value).toBe('bar')
el2.value = 'foo'
triggerEvent(el2, 'input')
expect(vm.data.text).toBe('foo')
}).then(done)
})
})
function assertInputWorks (vm, chain) {
if (!chain) chain = waitForUpdate()
chain.then(() => {
expect(vm.$el.value).toBe('b')
vm.test = 'a'
}).then(() => {
expect(vm.$el.value).toBe('a')
vm.$el.value = 'c'
triggerEvent(vm.$el, 'input')
expect(vm.test).toBe('c')
}).then(() => {
// change it to password
vm.type = 'password'
vm.test = 'b'
}).then(() => {
expect(vm.$el.type).toBe('password')
expect(vm.$el.value).toBe('b')
vm.$el.value = 'c'
triggerEvent(vm.$el, 'input')
expect(vm.test).toBe('c')
}).then(() => {
// change it to checkbox...
vm.type = 'checkbox'
}).then(() => {
expect(vm.$el.type).toBe('checkbox')
expect(vm.$el.checked).toBe(true)
}).then(() => {
vm.$el.click()
expect(vm.$el.checked).toBe(false)
expect(vm.test).toBe(false)
})
return chain
}

Loading…
Cancel
Save