Browse Source

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

Evan You 8 years ago
parent
commit
f3fe012d54

+ 8 - 3
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;

+ 12 - 1
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
 }

+ 40 - 25
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 = []
   }

+ 0 - 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') {

+ 3 - 1
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 - 0
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
+}

+ 114 - 6
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
+}