diff --git a/build/fragments/source-references.js b/build/fragments/source-references.js index ac7547da1..d1203ed88 100644 --- a/build/fragments/source-references.js +++ b/build/fragments/source-references.js @@ -20,6 +20,7 @@ knockoutDebugCallback([ 'src/virtualElements.js', 'src/components/loaderRegistry.js', 'src/components/defaultLoader.js', + 'src/components/customElements.js', 'src/binding/bindingProvider.js', 'src/binding/bindingAttributeSyntax.js', 'src/components/componentBinding.js', diff --git a/spec/components/customElementBehaviors.js b/spec/components/customElementBehaviors.js new file mode 100644 index 000000000..a03bae1d4 --- /dev/null +++ b/spec/components/customElementBehaviors.js @@ -0,0 +1,87 @@ +describe('Components: Custom elements', function() { + beforeEach(function() { + jasmine.prepareTestNode(); + jasmine.Clock.useMock(); + }); + + afterEach(function() { + jasmine.Clock.reset(); + ko.components.unregister('test-component'); + }); + + it('Inserts components into custom elements with matching names', function() { + ko.components.register('test-component', { + template: 'custom element <span data-bind="text: 123"></span>' + }); + var initialMarkup = '<div>hello <test-component></test-component></div>'; + testNode.innerHTML = initialMarkup; + + // Since components are loaded asynchronously, it doesn't show up synchronously + ko.applyBindings(null, testNode); + expect(testNode).toContainHtml(initialMarkup); + + // ... but when the component is loaded, it does show up + jasmine.Clock.tick(1); + expect(testNode).toContainHtml('<div>hello <test-component>custom element <span data-bind="text: 123">123</span></test-component></div>'); + }); + + it('Is possible to override getComponentNameForNode to determine which component goes into which element', function() { + ko.components.register('test-component', { template: 'custom element'}); + this.restoreAfter(ko.components, 'getComponentNameForNode'); + + // Set up a getComponentNameForNode function that maps "A" tags to test-component + testNode.innerHTML = '<div>hello <a></a> <b>ignored</b></div>'; + ko.components.getComponentNameForNode = function(node) { + return node.tagName === 'A' ? 'test-component' : null; + } + + // See the component show up + ko.applyBindings(null, testNode); + jasmine.Clock.tick(1); + expect(testNode).toContainHtml('<div>hello <a>custom element</a> <b>ignored</b></div>'); + }); + + it('Is possible to have regular data-bind bindings on a custom element, as long as they don\'t attempt to control descendants', function() { + ko.components.register('test-component', { template: 'custom element'}); + testNode.innerHTML = '<test-component data-bind="visible: shouldshow"></test-component>'; + + // Bind with a viewmodel that controls visibility + var viewModel = { shouldshow: ko.observable(true) }; + ko.applyBindings(viewModel, testNode); + jasmine.Clock.tick(1); + expect(testNode).toContainHtml('<test-component data-bind="visible: shouldshow">custom element</test-component>'); + expect(testNode.childNodes[0].style.display).not.toBe('none'); + + // See that the 'visible' binding still works + viewModel.shouldshow(false); + expect(testNode.childNodes[0].style.display).toBe('none'); + expect(testNode.childNodes[0].innerHTML).toBe('custom element'); + }); + + it('Is not possible to have regular data-bind bindings on a custom element if they also attempt to control descendants', function() { + ko.components.register('test-component', { template: 'custom element'}); + testNode.innerHTML = '<test-component data-bind="if: true"></test-component>'; + + expect(function() { ko.applyBindings(null, testNode); }) + .toThrowContaining('Multiple bindings (if and component) are trying to control descendant bindings of the same element.'); + }); + + it('Is possible to call applyBindings directly on a custom element', function() { + ko.components.register('test-component', { template: 'custom element'}); + testNode.innerHTML = '<test-component></test-component>'; + var customElem = testNode.childNodes[0]; + expect(customElem.tagName).toBe('TEST-COMPONENT'); + + ko.applyBindings(null, customElem); + jasmine.Clock.tick(1); + expect(customElem.innerHTML).toBe('custom element'); + }); + + it('Throws if you try to duplicate the \'component\' binding on a custom element that matches a component', function() { + ko.components.register('test-component', { template: 'custom element'}); + testNode.innerHTML = '<test-component data-bind="component: {}"></test-component>'; + + expect(function() { ko.applyBindings(null, testNode); }) + .toThrowContaining('Cannot use the "component" binding on a custom element matching a component'); + }); +}); diff --git a/spec/runner.html b/spec/runner.html index 6f585af09..8a41a8012 100755 --- a/spec/runner.html +++ b/spec/runner.html @@ -56,6 +56,7 @@ <script type="text/javascript" src="components/loaderRegistryBehaviors.js"></script> <script type="text/javascript" src="components/defaultLoaderBehaviors.js"></script> <script type="text/javascript" src="components/componentBindingBehaviors.js"></script> + <script type="text/javascript" src="components/customElementBehaviors.js"></script> <!-- Default bindings --> <script type="text/javascript" src="defaultBindings/attrBehaviors.js"></script> diff --git a/src/binding/bindingProvider.js b/src/binding/bindingProvider.js index fe5f71714..537ba256f 100644 --- a/src/binding/bindingProvider.js +++ b/src/binding/bindingProvider.js @@ -8,20 +8,25 @@ ko.utils.extend(ko.bindingProvider.prototype, { 'nodeHasBindings': function(node) { switch (node.nodeType) { - case 1: return node.getAttribute(defaultBindingAttributeName) != null; // Element - case 8: return ko.virtualElements.hasBindingValue(node); // Comment node + case 1: // Element + return node.getAttribute(defaultBindingAttributeName) != null + || ko.components['getComponentNameForNode'](node); + case 8: // Comment node + return ko.virtualElements.hasBindingValue(node); default: return false; } }, 'getBindings': function(node, bindingContext) { - var bindingsString = this['getBindingsString'](node, bindingContext); - return bindingsString ? this['parseBindingsString'](bindingsString, bindingContext, node) : null; + var bindingsString = this['getBindingsString'](node, bindingContext), + parsedBindings = bindingsString ? this['parseBindingsString'](bindingsString, bindingContext, node) : null; + return ko.components.addBindingsForCustomElement(parsedBindings, node, bindingContext, /* valueAccessors */ false); }, 'getBindingAccessors': function(node, bindingContext) { - var bindingsString = this['getBindingsString'](node, bindingContext); - return bindingsString ? this['parseBindingsString'](bindingsString, bindingContext, node, {'valueAccessors':true}) : null; + var bindingsString = this['getBindingsString'](node, bindingContext), + parsedBindings = bindingsString ? this['parseBindingsString'](bindingsString, bindingContext, node, { 'valueAccessors': true }) : null; + return ko.components.addBindingsForCustomElement(parsedBindings, node, bindingContext, /* valueAccessors */ true); }, // The following function is only used internally by this default provider. diff --git a/src/components/customElements.js b/src/components/customElements.js new file mode 100644 index 000000000..2f88bebe7 --- /dev/null +++ b/src/components/customElements.js @@ -0,0 +1,32 @@ +(function (undefined) { + // Overridable API for determining which component name applies to a given node. By overriding this, + // you can for example map specific tagNames to components that are not preregistered. + ko.components['getComponentNameForNode'] = function(node) { + var tagNameLower = ko.utils.tagNameLower(node); + return ko.components.isRegistered(tagNameLower) && tagNameLower; + }; + + ko.components.addBindingsForCustomElement = function(allBindings, node, bindingContext, valueAccessors) { + // Determine if it's really a custom element matching a component + if (node.nodeType === 1) { + var componentName = ko.components['getComponentNameForNode'](node); + if (componentName) { + // It does represent a component, so add a component binding for it + allBindings = allBindings || {}; + + if (allBindings['component']) { + // Avoid silently overwriting some other 'component' binding that may already be on the element + throw new Error('Cannot use the "component" binding on a custom element matching a component'); + } + + var componentBindingValue = { 'name': componentName }; + + allBindings['component'] = valueAccessors + ? function() { return componentBindingValue; } + : componentBindingValue; + } + } + + return allBindings; + } +})(); \ No newline at end of file