diff --git a/packages/runtime-dom/__tests__/vModel.spec.ts b/packages/runtime-dom/__tests__/vModel.spec.ts new file mode 100644 index 00000000..80319562 --- /dev/null +++ b/packages/runtime-dom/__tests__/vModel.spec.ts @@ -0,0 +1,441 @@ +import { + createApp, + h, + nextTick, + createComponent, + vModelDynamic, + applyDirectives, + VNode +} from '@vue/runtime-dom' + +const triggerEvent = (type: string, el: Element) => { + const event = new Event(type) + el.dispatchEvent(event) +} + +const withVModel = (node: VNode, arg: any, mods?: any) => + applyDirectives(node, [[vModelDynamic, arg, '', mods]]) + +const setValue = function(this: any, value: any) { + this.value = value +} + +let app: any, root: any + +beforeEach(() => { + app = createApp() + root = document.createElement('div') as any +}) + +describe('vModel', () => { + it('should work with text input', async () => { + const component = createComponent({ + data() { + return { value: null } + }, + render() { + return [ + withVModel( + h('input', { + 'onUpdate:modelValue': setValue.bind(this) + }), + this.value + ) + ] + } + }) + app.mount(component, root) + + const input = root.querySelector('input') + const data = root._vnode.component.data + + input.value = 'foo' + triggerEvent('input', input) + await nextTick() + expect(data.value).toEqual('foo') + + data.value = 'bar' + await nextTick() + expect(input.value).toEqual('bar') + }) + + it('should work with textarea', async () => { + const component = createComponent({ + data() { + return { value: null } + }, + render() { + return [ + withVModel( + h('textarea', { + 'onUpdate:modelValue': setValue.bind(this) + }), + this.value + ) + ] + } + }) + app.mount(component, root) + + const input = root.querySelector('textarea') + const data = root._vnode.component.data + + input.value = 'foo' + triggerEvent('input', input) + await nextTick() + expect(data.value).toEqual('foo') + + data.value = 'bar' + await nextTick() + expect(input.value).toEqual('bar') + }) + + it('should support modifiers', async () => { + const component = createComponent({ + data() { + return { number: null, trim: null, lazy: null } + }, + render() { + return [ + withVModel( + h('input', { + class: 'number', + 'onUpdate:modelValue': (val: any) => { + this.number = val + } + }), + this.number, + { + number: true + } + ), + withVModel( + h('input', { + class: 'trim', + 'onUpdate:modelValue': (val: any) => { + this.trim = val + } + }), + this.trim, + { + trim: true + } + ), + withVModel( + h('input', { + class: 'lazy', + 'onUpdate:modelValue': (val: any) => { + this.lazy = val + } + }), + this.lazy, + { + lazy: true + } + ) + ] + } + }) + app.mount(component, root) + + const number = root.querySelector('.number') + const trim = root.querySelector('.trim') + const lazy = root.querySelector('.lazy') + const data = root._vnode.component.data + + number.value = '+01.2' + triggerEvent('input', number) + await nextTick() + expect(data.number).toEqual(1.2) + + trim.value = ' hello, world ' + triggerEvent('input', trim) + await nextTick() + expect(data.trim).toEqual('hello, world') + + lazy.value = 'foo' + triggerEvent('change', lazy) + await nextTick() + expect(data.lazy).toEqual('foo') + }) + + it('should work with checkbox', async () => { + const component = createComponent({ + data() { + return { value: null } + }, + render() { + return [ + withVModel( + h('input', { + type: 'checkbox', + 'onUpdate:modelValue': setValue.bind(this) + }), + this.value + ) + ] + } + }) + app.mount(component, root) + + const input = root.querySelector('input') + const data = root._vnode.component.data + + input.checked = true + triggerEvent('change', input) + await nextTick() + expect(data.value).toEqual(true) + + data.value = false + await nextTick() + expect(input.checked).toEqual(false) + }) + + it(`should support array as a checkbox model`, async () => { + const component = createComponent({ + data() { + return { value: [] } + }, + render() { + return [ + withVModel( + h('input', { + type: 'checkbox', + class: 'foo', + value: 'foo', + 'onUpdate:modelValue': setValue.bind(this) + }), + this.value + ), + withVModel( + h('input', { + type: 'checkbox', + class: 'bar', + value: 'bar', + 'onUpdate:modelValue': setValue.bind(this) + }), + this.value + ) + ] + } + }) + app.mount(component, root) + + const foo = root.querySelector('.foo') + const bar = root.querySelector('.bar') + const data = root._vnode.component.data + + foo.checked = true + triggerEvent('change', foo) + await nextTick() + expect(data.value).toMatchObject(['foo']) + + bar.checked = true + triggerEvent('change', bar) + await nextTick() + expect(data.value).toMatchObject(['foo', 'bar']) + + bar.checked = false + triggerEvent('change', bar) + await nextTick() + expect(data.value).toMatchObject(['foo']) + + foo.checked = false + triggerEvent('change', foo) + await nextTick() + expect(data.value).toMatchObject([]) + + data.value = ['foo'] + await nextTick() + expect(bar.checked).toEqual(false) + expect(foo.checked).toEqual(true) + + data.value = ['bar'] + await nextTick() + expect(foo.checked).toEqual(false) + expect(bar.checked).toEqual(true) + + data.value = [] + await nextTick() + expect(foo.checked).toEqual(false) + expect(bar.checked).toEqual(false) + }) + + it('should work with radio', async () => { + const component = createComponent({ + data() { + return { value: null } + }, + render() { + return [ + withVModel( + h('input', { + type: 'radio', + class: 'foo', + value: 'foo', + 'onUpdate:modelValue': setValue.bind(this) + }), + this.value + ), + withVModel( + h('input', { + type: 'radio', + class: 'bar', + value: 'bar', + 'onUpdate:modelValue': setValue.bind(this) + }), + this.value + ) + ] + } + }) + app.mount(component, root) + + const foo = root.querySelector('.foo') + const bar = root.querySelector('.bar') + const data = root._vnode.component.data + + foo.checked = true + triggerEvent('change', foo) + await nextTick() + expect(data.value).toEqual('foo') + + bar.checked = true + triggerEvent('change', bar) + await nextTick() + expect(data.value).toEqual('bar') + + data.value = null + await nextTick() + expect(foo.checked).toEqual(false) + expect(bar.checked).toEqual(false) + + data.value = 'foo' + await nextTick() + expect(foo.checked).toEqual(true) + expect(bar.checked).toEqual(false) + + data.value = 'bar' + await nextTick() + expect(foo.checked).toEqual(false) + expect(bar.checked).toEqual(true) + }) + + it('should work with single select', async () => { + const component = createComponent({ + data() { + return { value: null } + }, + render() { + return [ + withVModel( + h( + 'select', + { + value: null, + 'onUpdate:modelValue': setValue.bind(this) + }, + [h('option', { value: 'foo' }), h('option', { value: 'bar' })] + ), + this.value + ) + ] + } + }) + app.mount(component, root) + + const input = root.querySelector('select') + const foo = root.querySelector('option[value=foo]') + const bar = root.querySelector('option[value=bar]') + const data = root._vnode.component.data + + foo.selected = true + triggerEvent('change', input) + await nextTick() + expect(data.value).toEqual('foo') + + foo.selected = false + bar.selected = true + triggerEvent('change', input) + await nextTick() + expect(data.value).toEqual('bar') + + foo.selected = false + bar.selected = false + data.value = 'foo' + await nextTick() + expect(input.value).toEqual('foo') + expect(foo.selected).toEqual(true) + expect(bar.selected).toEqual(false) + + foo.selected = true + bar.selected = false + data.value = 'bar' + await nextTick() + expect(input.value).toEqual('bar') + expect(foo.selected).toEqual(false) + expect(bar.selected).toEqual(true) + }) + + it('should work with multiple select', async () => { + const component = createComponent({ + data() { + return { value: [] } + }, + render() { + return [ + withVModel( + h( + 'select', + { + value: null, + multiple: true, + 'onUpdate:modelValue': setValue.bind(this) + }, + [h('option', { value: 'foo' }), h('option', { value: 'bar' })] + ), + this.value + ) + ] + } + }) + app.mount(component, root) + + const input = root.querySelector('select') + const foo = root.querySelector('option[value=foo]') + const bar = root.querySelector('option[value=bar]') + const data = root._vnode.component.data + + foo.selected = true + triggerEvent('change', input) + await nextTick() + expect(data.value).toMatchObject(['foo']) + + foo.selected = false + bar.selected = true + triggerEvent('change', input) + await nextTick() + expect(data.value).toMatchObject(['bar']) + + foo.selected = true + bar.selected = true + triggerEvent('change', input) + await nextTick() + expect(data.value).toMatchObject(['foo', 'bar']) + + foo.selected = false + bar.selected = false + data.value = ['foo'] + await nextTick() + expect(input.value).toEqual('foo') + expect(foo.selected).toEqual(true) + expect(bar.selected).toEqual(false) + + foo.selected = false + bar.selected = false + data.value = ['foo', 'bar'] + await nextTick() + expect(foo.selected).toEqual(true) + expect(bar.selected).toEqual(true) + }) +}) diff --git a/packages/runtime-dom/src/directives/vModel.ts b/packages/runtime-dom/src/directives/vModel.ts index b67250d7..96acadf2 100644 --- a/packages/runtime-dom/src/directives/vModel.ts +++ b/packages/runtime-dom/src/directives/vModel.ts @@ -79,15 +79,19 @@ export const vModelCheckbox: Directive = { addEventListener(el, 'change', () => { const modelValue = (el as any)._modelValue const elementValue = getValue(el) + const checked = el.checked if (isArray(modelValue)) { - const i = looseIndexOf(modelValue, elementValue) - if (i > -1) { - assign([...modelValue.slice(0, i), ...modelValue.slice(i + 1)]) - } else { + const index = looseIndexOf(modelValue, elementValue) + const found = index !== -1 + if (checked && !found) { assign(modelValue.concat(elementValue)) + } else if (!checked && found) { + const filtered = [...modelValue] + filtered.splice(index, 1) + assign(filtered) } } else { - assign(el.checked) + assign(checked) } }) },