diff --git a/src/generators/dom/visitors/Element/addBindings.ts b/src/generators/dom/visitors/Element/addBindings.ts index 8933f65654..af0aabf8bb 100644 --- a/src/generators/dom/visitors/Element/addBindings.ts +++ b/src/generators/dom/visitors/Element/addBindings.ts @@ -9,6 +9,11 @@ import getObject from '../../../../utils/getObject'; import getTailSnippet from '../../../../utils/getTailSnippet'; import visitBinding from './Binding'; import { generateRule } from '../../../../shared/index'; +import flatten from '../../../../utils/flattenReference'; + +interface Binding { + name: string; +} const types: Record + node.name === 'textarea' || + node.name === 'input' && !/radio|checkbox/.test(getStaticAttributeValue(node, 'type')) + }, + { + name: 'change', + filter: (node: Node, binding: Binding) => + node.name === 'select' || + node.name === 'input' && /radio|checkbox|range/.test(getStaticAttributeValue(node, 'type')) + }, + + // media events + { + name: 'timeupdate', + filter: (node: Node, binding: Binding) => + isMediaNode(node.name) && + (binding.name === 'currentTime' || binding.name === 'played') + }, + { + name: 'durationchange', + filter: (node: Node, binding: Binding) => + isMediaNode(node.name) && + binding.name === 'duration' + }, + { + name: 'pause', + filter: (node: Node, binding: Binding) => + isMediaNode(node.name) && + binding.name === 'paused' + }, + { + name: 'progress', + filter: (node: Node, binding: Binding) => + isMediaNode(node.name) && + binding.name === 'buffered' + }, + { + name: 'loadedmetadata', + filter: (node: Node, binding: Binding) => + isMediaNode(node.name) && + (binding.name === 'buffered' || binding.name === 'seekable') + } +]; + export default function addBindings( generator: DomGenerator, block: Block, @@ -40,304 +96,139 @@ export default function addBindings( const bindings: Node[] = node.attributes.filter((a: Node) => a.type === 'Binding'); if (bindings.length === 0) return; - types[node.name](generator, block, state, node, bindings); -} + if (node.name === 'select' || isMediaNode(node.name)) generator.hasComplexBindings = true; -function addInputBinding( - generator: DomGenerator, - block: Block, - state: State, - node: Node, - bindings: Node[] -) { - const attribute = bindings[0]; - - const { name } = getObject(attribute.value); - const { snippet, contexts, dependencies } = block.contextualise( - attribute.value - ); - - contexts.forEach(context => { - if (!~state.allUsedContexts.indexOf(context)) - state.allUsedContexts.push(context); - }); + const mungedBindings = bindings.map(binding => { + const needsLock = true; // TODO - const type = getStaticAttributeValue(node, 'type'); - const eventName = type === 'radio' || type === 'checkbox' ? 'change' : 'input'; - - const handler = block.getUniqueName( - `${node.var}_${eventName}_handler` - ); + const { name } = getObject(binding.value); + const { snippet, contexts, dependencies } = block.contextualise( + binding.value + ); - const bindingGroup = attribute.name === 'group' - ? getBindingGroup(generator, attribute.value) - : null; + contexts.forEach(context => { + if (!~state.allUsedContexts.indexOf(context)) + state.allUsedContexts.push(context); + }); + + // view to model + // TODO tidy this up + const valueFromDom = getBindingValue( + generator, + block, + node._state, + node, + binding, + node.name === 'select' && getStaticAttributeValue(node, 'multiple') === true, + isMediaNode(node.name), + binding.name === 'group' ? getBindingGroup(generator, binding.value) : null, + getStaticAttributeValue(node, 'type') + ); - const value = ( - attribute.name === 'group' ? - (type === 'checkbox' ? `@getBindingGroupValue(#component._bindingGroups[${bindingGroup}])` : `${node.var}.__value`) : - (type === 'range' || type === 'number') ? - `@toNumber(${node.var}.${attribute.name})` : - `${node.var}.${attribute.name}` - ); + const setter = getSetter( + generator, + block, + name, + snippet, + node, + binding, + dependencies, + valueFromDom + ); - let setter = getSetter(generator, block, name, snippet, node, attribute, dependencies, value); - let updateElement = `${node.var}.${attribute.name} = ${snippet};`; + // model to view + const update = getUpdater(node, binding, snippet); + block.builders.update.addLine(update); - const needsLock = !/radio|checkbox|range|color/.test(type); // TODO others? - const lock = `#${node.var}_updating`; - let updateConditions = needsLock ? [`!${lock}`] : []; + // special cases + if (binding.name === 'group') { + const bindingGroup = getBindingGroup(generator, binding.value); - if (needsLock) block.addVariable(lock, 'false'); + block.builders.hydrate.addLine( + `#component._bindingGroups[${bindingGroup}].push(${node.var});` + ); - if (attribute.name === 'group') { - // special case - if (type === 'radio') { - setter = deindent` - if (!${node.var}.checked) return; - ${setter} - `; + block.builders.destroy.addBlock( + `#component._bindingGroups[${bindingGroup}].splice(#component._bindingGroups[${bindingGroup}].indexOf(${node.var}), 1);` + ); } - const condition = type === 'checkbox' - ? `~${snippet}.indexOf(${node.var}.__value)` - : `${node.var}.__value === ${snippet}`; - - block.builders.hydrate.addLine( - `#component._bindingGroups[${bindingGroup}].push(${node.var});` - ); - - block.builders.destroy.addBlock( - `#component._bindingGroups[${bindingGroup}].splice(#component._bindingGroups[${bindingGroup}].indexOf(${node.var}), 1);` - ); - - updateElement = `${node.var}.checked = ${condition};`; - } - - block.builders.init.addBlock(deindent` - function ${handler}() { - ${needsLock && `${lock} = true;`} - ${setter} - ${needsLock && `${lock} = false;`} - } - `); + return { + name: binding.name, + object: name, + setter, + update, + needsLock + }; + }); - if (type === 'range') { - // need to bind to `input` and `change`, for the benefit of IE - block.builders.hydrate.addBlock(deindent` - @addListener(${node.var}, "input", ${handler}); - @addListener(${node.var}, "change", ${handler}); + const groups = events + .map(event => { + return { + name: event.name, + bindings: mungedBindings.filter(binding => event.filter(node, binding)) + }; + }) + .filter(group => group.bindings.length); + + groups.forEach(group => { + const handler = block.getUniqueName(`${node.var}_${group.name}_handler`); + + const needsLock = group.bindings.some(binding => binding.needsLock); + + const lock = needsLock ? block.getUniqueName(`${node.var}_updating`) : null; + if (needsLock) block.addVariable(lock, 'false'); + + block.builders.init.addBlock(deindent` + function ${handler}() { + ${needsLock && `${lock} = true;`} + ${group.bindings.map(binding => binding.setter)} + ${needsLock && `${lock} = false;`} + } `); - block.builders.destroy.addBlock(deindent` - @removeListener(${node.var}, "input", ${handler}); - @removeListener(${node.var}, "change", ${handler}); - `); - } else { block.builders.hydrate.addLine( - `@addListener(${node.var}, "${eventName}", ${handler});` + `@addListener(${node.var}, "${group.name}", ${handler});` ); block.builders.destroy.addLine( - `@removeListener(${node.var}, "${eventName}", ${handler});` + `@removeListener(${node.var}, "${group.name}", ${handler});` ); - } - - block.builders.update.addBlock( - needsLock ? - `if (!${lock}) ${updateElement}` : - updateElement - ); - node.initialUpdate = updateElement; -} - -function addSelectBinding( - generator: DomGenerator, - block: Block, - state: State, - node: Node, - bindings: Node[] -) { - const attribute = bindings[0]; + const allInitialStateIsDefined = group.bindings + .map(binding => `'${binding.object}' in state`) + .join(' && '); - const { name } = getObject(attribute.value); - const { snippet, contexts, dependencies } = block.contextualise( - attribute.value - ); + generator.hasComplexBindings = true; - contexts.forEach(context => { - if (!~state.allUsedContexts.indexOf(context)) - state.allUsedContexts.push(context); + block.builders.hydrate.addBlock( + `if (!(${allInitialStateIsDefined})) #component._root._beforecreate.push(${handler});` + ); }); - const lock = `#${node.var}_updating`; - block.addVariable(lock, 'false'); - - const handler = block.getUniqueName( - `${node.var}_change_handler` - ); - - const isMultipleSelect = getStaticAttributeValue(node, 'multiple') === true; - - // view to model - const value = isMultipleSelect ? - `[].map.call(${node.var}.querySelectorAll(':checked'), function(option) { return option.__value; })` : - `selectedOption && selectedOption.__value`; - - let setter = getSetter(generator, block, name, snippet, node, attribute, dependencies, value); - - if (!isMultipleSelect) { - setter = deindent` - var selectedOption = ${node.var}.querySelector(':checked') || ${node.var}.options[0]; - ${setter}`; - } - - generator.hasComplexBindings = true; - block.builders.hydrate.addBlock( - `if (!('${name}' in state)) #component._root._beforecreate.push(${handler});` - ); - - block.builders.init.addBlock(deindent` - function ${handler}() { - ${lock} = true; - ${setter} - ${lock} = false; - } - `); - - block.builders.hydrate.addLine( - `@addListener(${node.var}, "change", ${handler});` - ); - - block.builders.destroy.addLine( - `@removeListener(${node.var}, "change", ${handler});` - ); - - // model to view - const updateElement = isMultipleSelect ? - `@selectOptions(${node.var}, ${snippet});` : - `@selectOption(${node.var}, ${snippet});`; - - block.builders.update.addLine( - `if (!${lock}) ${updateElement}` - ); - - node.initialUpdate = updateElement; + node.initialUpdate = mungedBindings.map(binding => binding.update).join('\n'); } -function addMediaBinding( - generator: DomGenerator, - block: Block, - state: State, +function getUpdater( node: Node, - bindings: Node[] + binding: Node, + snippet: string ) { - const attribute = bindings[0]; - - const { name } = getObject(attribute.value); - const { snippet, contexts, dependencies } = block.contextualise( - attribute.value - ); - - contexts.forEach(context => { - if (!~state.allUsedContexts.indexOf(context)) - state.allUsedContexts.push(context); - }); - - const eventNames = getBindingEventName(node, attribute); - const handler = block.getUniqueName( - `${node.var}_${eventNames.join('_')}_handler` - ); - - const isReadOnly = readOnlyMediaAttributes.has(attribute.name) - - const value = (attribute.name === 'buffered' || attribute.name === 'seekable' || attribute.name === 'played') ? - `@timeRangesToArray(${node.var}.${attribute.name})` : - `${node.var}.${attribute.name}`; - - let setter = getSetter(generator, block, name, snippet, node, attribute, dependencies, value); - let updateElement = `${node.var}.${attribute.name} = ${snippet};`; - - const needsLock = !isReadOnly; - const lock = `#${node.var}_updating`; - let updateConditions = needsLock ? [`!${lock}`] : []; - - if (needsLock) block.addVariable(lock, 'false'); - - generator.hasComplexBindings = true; - block.builders.hydrate.addBlock(`#component._root._beforecreate.push(${handler});`); + if (binding.name === 'group') { + const type = getStaticAttributeValue(node, 'type'); - if (attribute.name === 'currentTime') { - const frame = block.getUniqueName(`${node.var}_animationframe`); - block.addVariable(frame); - setter = deindent` - cancelAnimationFrame(${frame}); - if (!${node.var}.paused) ${frame} = requestAnimationFrame(${handler}); - ${setter} - `; - - updateConditions.push(`!isNaN(${snippet})`); - } else if (attribute.name === 'paused') { - // this is necessary to prevent the audio restarting by itself - const last = block.getUniqueName(`${node.var}_paused_value`); - block.addVariable(last, 'true'); + const condition = type === 'checkbox' + ? `~${snippet}.indexOf(${node.var}.__value)` + : `${node.var}.__value === ${snippet}`; - updateConditions = [`${last} !== (${last} = ${snippet})`]; - updateElement = `${node.var}[${last} ? "pause" : "play"]();`; + return `${node.var}.checked = ${condition};` } - block.builders.init.addBlock(deindent` - function ${handler}() { - ${needsLock && `${lock} = true;`} - ${setter} - ${needsLock && `${lock} = false;`} - } - `); - - eventNames.forEach(eventName => { - block.builders.hydrate.addLine( - `@addListener(${node.var}, "${eventName}", ${handler});` - ); - - block.builders.destroy.addLine( - `@removeListener(${node.var}, "${eventName}", ${handler});` - ); - }); - - if (!isReadOnly) { // audio/video duration is read-only, it never updates - if (updateConditions.length) { - block.builders.update.addBlock(deindent` - if (${updateConditions.join(' && ')}) { - ${updateElement} - } - `); - } else { - block.builders.update.addBlock(deindent` - ${updateElement} - `); - } - } + if (binding.name === 'checked') { - if (attribute.name === 'paused') { - block.builders.create.addLine( - `@addListener(${node.var}, "play", ${handler});` - ); - block.builders.destroy.addLine( - `@removeListener(${node.var}, "play", ${handler});` - ); } -} - -function getBindingEventName(node: Node, attribute: Node) { - if (attribute.name === 'currentTime') return ['timeupdate']; - if (attribute.name === 'duration') return ['durationchange']; - if (attribute.name === 'paused') return ['pause']; - if (attribute.name === 'buffered') return ['progress', 'loadedmetadata']; - if (attribute.name === 'seekable') return ['loadedmetadata']; - if (attribute.name === 'played') return ['timeupdate']; - return ['change']; + return `${node.var}.${binding.name} = ${snippet};`; } function getBindingGroup(generator: DomGenerator, value: Node) { @@ -405,6 +296,51 @@ function getSetter( return `#component.set({ ${name}: ${value} });`; } +function getBindingValue( + generator: DomGenerator, + block: Block, + state: State, + node: Node, + attribute: Node, + isMultipleSelect: boolean, + isMediaElement: boolean, + bindingGroup: number, + type: string +) { + // + if (type === 'range' || type === 'number') { + return `@toNumber(${node.var}.${attribute.name})`; + } + + if (isMediaElement && (attribute.name === 'buffered' || attribute.name === 'seekable' || attribute.name === 'played')) { + return `@timeRangesToArray(${node.var}.${attribute.name})` + } + + // everything else + return `${node.var}.${attribute.name}`; +} + function isComputed(node: Node) { while (node.type === 'MemberExpression') { if (node.computed) return true; diff --git a/src/shared/dom.js b/src/shared/dom.js index 56655c14a2..9003faee3e 100644 --- a/src/shared/dom.js +++ b/src/shared/dom.js @@ -166,4 +166,15 @@ export function selectOptions(select, value) { var option = select.options[i]; option.selected = ~value.indexOf(option.__value); } +} + +export function selectValue(select) { + var selectedOption = select.querySelector(':checked') || select.options[0]; + return selectedOption && selectedOption.__value; +} + +export function selectMultipleValue(select) { + return [].map.call(select.querySelectorAll(':checked'), function(option) { + return option.__value; + }); } \ No newline at end of file diff --git a/test/runtime/samples/binding-input-checkbox-group/_config.js b/test/runtime/samples/binding-input-checkbox-group/_config.js index fac1795eca..bff66c903b 100644 --- a/test/runtime/samples/binding-input-checkbox-group/_config.js +++ b/test/runtime/samples/binding-input-checkbox-group/_config.js @@ -16,15 +16,15 @@ export default { - + - + - +

Beta

`, test ( assert, component, target, window ) { @@ -42,15 +42,15 @@ export default { - + - + - +

Alpha, Beta

` ); @@ -63,15 +63,15 @@ export default { - + - + - +

Beta, Gamma

` ); } diff --git a/test/runtime/samples/binding-input-checkbox-indeterminate/_config.js b/test/runtime/samples/binding-input-checkbox-indeterminate/_config.js index 817249f932..15a2d63002 100644 --- a/test/runtime/samples/binding-input-checkbox-indeterminate/_config.js +++ b/test/runtime/samples/binding-input-checkbox-indeterminate/_config.js @@ -1,5 +1,5 @@ export default { - // solo: true, + 'skip-ssr': true, data: { indeterminate: true, @@ -13,7 +13,8 @@ export default { test(assert, component, target, window) { const input = target.querySelector('input'); - assert.equal(input.checked, true); + assert.equal(input.checked, false); + assert.equal(input.indeterminate, true); const event = new window.Event('change');