From f3fe012d5499f607656b152ce5fcb506c641f9f4 Mon Sep 17 00:00:00 2001 From: Evan You Date: Sat, 7 Oct 2017 23:18:11 -0400 Subject: [PATCH] feat(v-model): support dynamic input type binding --- flow/compiler.js | 11 +- src/compiler/helpers.js | 13 +- src/compiler/parser/index.js | 65 ++++++---- .../web/compiler/directives/model.js | 7 - src/platforms/web/compiler/modules/index.js | 4 +- src/platforms/web/compiler/modules/model.js | 77 +++++++++++ .../features/directives/model-dynamic.spec.js | 120 +++++++++++++++++- 7 files changed, 254 insertions(+), 43 deletions(-) create mode 100644 src/platforms/web/compiler/modules/model.js diff --git a/flow/compiler.js b/flow/compiler.js index f364351b..89473789 100644 --- a/flow/compiler.js +++ b/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; declare type ASTElementHandler = { value: string; @@ -74,6 +77,8 @@ declare type ASTElement = { parent: ASTElement | void; children: Array; + processed?: true; + static?: boolean; staticRoot?: boolean; staticInFor?: boolean; diff --git a/src/compiler/helpers.js b/src/compiler/helpers.js index 49d6d6ae..5c844bd4 100644 --- a/src/compiler/helpers.js +++ b/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 } diff --git a/src/compiler/parser/index.js b/src/compiler/parser/index.js index 466d7f52..5aeed3e2 100644 --- a/src/compiler/parser/index.js +++ b/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, + 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): ASTElement | void { } } -function addIfCondition (el, condition) { +export function addIfCondition (el: ASTElement, condition: ASTIfCondition) { if (!el.ifConditions) { el.ifConditions = [] } diff --git a/src/platforms/web/compiler/directives/model.js b/src/platforms/web/compiler/directives/model.js index 98dd5a52..4e97350f 100644 --- a/src/platforms/web/compiler/directives/model.js +++ b/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( - `:\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') { diff --git a/src/platforms/web/compiler/modules/index.js b/src/platforms/web/compiler/modules/index.js index d85ef0be..29114a53 100644 --- a/src/platforms/web/compiler/modules/index.js +++ b/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 ] diff --git a/src/platforms/web/compiler/modules/model.js b/src/platforms/web/compiler/modules/model.js new file mode 100644 index 00000000..8947f8fb --- /dev/null +++ b/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: + * + * into this: + * + * + * + */ + +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 +} diff --git a/test/unit/features/directives/model-dynamic.spec.js b/test/unit/features/directives/model-dynamic.spec.js index 87a4cb5c..16d990a5 100644 --- a/test/unit/features/directives/model-dynamic.spec.js +++ b/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: `` + template: `` }).$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: `
haha
` + }).$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: `
+ +
` + }).$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 +}