diff --git a/src/ng/compile.js b/src/ng/compile.js index d92d020ae3b0..2006f1e4d2d8 100644 --- a/src/ng/compile.js +++ b/src/ng/compile.js @@ -226,10 +226,10 @@ * * `$element` - Current element * * `$attrs` - Current attributes object for the element * * `$transclude` - A transclude linking function pre-bound to the correct transclusion scope: - * `function([scope], cloneLinkingFn, futureParentElement)`. - * * `scope`: optional argument to override the scope. - * * `cloneLinkingFn`: optional argument to create clones of the original transcluded content. - * * `futureParentElement`: + * `function([scope], cloneLinkingFn, futureParentElement, slotName)`: + * * `scope`: (optional) override the scope. + * * `cloneLinkingFn`: (optional) argument to create clones of the original transcluded content. + * * `futureParentElement` (optional): * * defines the parent to which the `cloneLinkingFn` will add the cloned elements. * * default: `$element.parent()` resp. `$element` for `transclude:'element'` resp. `transclude:true`. * * only needed for transcludes that are allowed to contain non html elements (e.g. SVG elements) @@ -237,7 +237,10 @@ * as those elements need to created and cloned in a special way when they are defined outside their * usual containers (e.g. like ``). * * See also the `directive.templateNamespace` property. - * + * * `slotName`: (optional) the name of the slot to transclude. If falsy (e.g. `null`, `undefined` or `''`) + * then the default translusion is provided. + * The `$transclude` function also has a method on it, `$transclude.isSlotFilled(slotName)`, which returns + * `true` if the specified slot contains content (i.e. one or more DOM nodes). * * #### `require` * Require another directive and inject its controller as the fourth argument to the linking function. The @@ -337,14 +340,6 @@ * The contents are compiled and provided to the directive as a **transclusion function**. See the * {@link $compile#transclusion Transclusion} section below. * - * There are two kinds of transclusion depending upon whether you want to transclude just the contents of the - * directive's element or the entire element: - * - * * `true` - transclude the content (i.e. the child nodes) of the directive's element. - * * `'element'` - transclude the whole of the directive's element including any directives on this - * element that defined at a lower priority than this directive. When used, the `template` - * property is ignored. - * * * #### `compile` * @@ -474,6 +469,30 @@ * Testing Transclusion Directives}. * * + * There are three kinds of transclusion depending upon whether you want to transclude just the contents of the + * directive's element, the entire element or multiple parts of the element contents: + * + * * `true` - transclude the content (i.e. the child nodes) of the directive's element. + * * `'element'` - transclude the whole of the directive's element including any directives on this + * element that defined at a lower priority than this directive. When used, the `template` + * property is ignored. + * * **`{...}` (an object hash):** - map elements of the content onto transclusion "slots" in the template. + * + * **Mult-slot transclusion** is declared by providing an object for the `transclude` property. + * + * This object is a map where the keys are the name of the slot to fill and the value is the element selector + * used to match the HTML to the slot. Only element names are supported for matching. If the element selector + * is prefixed with a `?` then that slot is optional. + * + * For example, the transclude object `{ slotA: '?my-custom-element' }` maps `` elements to + * the `slotA` slot, which can be accessed via the `$transclude` function or via the {@link ngTransclude} directive. + * + * Slots that are not marked as optional (`?`) will trigger a compile time error if there are no matching elements + * in the transclude content. If you wish to know if an optional slot was filled with content, then you can call + * `$transclude.isSlotFilled(slotName)` on the transclude function passed to the directive's link function and + * injectable into the directive's controller. + * + * * #### Transclusion Functions * * When a directive requests transclusion, the compiler extracts its contents and provides a **transclusion @@ -1511,7 +1530,11 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { // so that they are available inside the `controllersBoundTransclude` function var boundSlots = boundTranscludeFn.$$slots = createMap(); for (var slotName in transcludeFn.$$slots) { - boundSlots[slotName] = createBoundTranscludeFn(scope, transcludeFn.$$slots[slotName], previousBoundTranscludeFn); + if (transcludeFn.$$slots[slotName]) { + boundSlots[slotName] = createBoundTranscludeFn(scope, transcludeFn.$$slots[slotName], previousBoundTranscludeFn); + } else { + boundSlots[slotName] = null; + } } return boundTranscludeFn; @@ -1855,22 +1878,31 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { } else { var slots = createMap(); + $template = jqLite(jqLiteClone(compileNode)).contents(); if (isObject(directiveValue)) { - // We have transclusion slots - collect them up and compile them and store their - // transclusion functions + // We have transclusion slots, + // collect them up, compile them and store their transclusion functions $template = []; - var slotNames = createMap(); + + var slotMap = createMap(); var filledSlots = createMap(); - // Parse the slot names: if they start with a ? then they are optional - forEach(directiveValue, function(slotName, key) { - var optional = (slotName.charAt(0) === '?'); - slotName = optional ? slotName.substring(1) : slotName; - slotNames[key] = slotName; - slots[slotName] = []; + // Parse the element selectors + forEach(directiveValue, function(elementSelector, slotName) { + // If an element selector starts with a ? then it is optional + var optional = (elementSelector.charAt(0) === '?'); + elementSelector = optional ? elementSelector.substring(1) : elementSelector; + + slotMap[elementSelector] = slotName; + + // We explicitly assign `null` since this implies that a slot was defined but not filled. + // Later when calling boundTransclusion functions with a slot name we only error if the + // slot is `undefined` + slots[slotName] = null; + // filledSlots contains `true` for all slots that are either optional or have been // filled. This is used to check that we have not missed any required slots filledSlots[slotName] = optional; @@ -1878,9 +1910,10 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { // Add the matching elements into their slot forEach($compileNode.contents(), function(node) { - var slotName = slotNames[directiveNormalize(nodeName_(node))]; + var slotName = slotMap[nodeName_(node)]; if (slotName) { filledSlots[slotName] = true; + slots[slotName] = slots[slotName] || []; slots[slotName].push(node); } else { $template.push(node); @@ -1894,9 +1927,12 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { } }); - forEach(Object.keys(slots), function(slotName) { - slots[slotName] = compilationGenerator(mightHaveMultipleTransclusionError, slots[slotName], transcludeFn); - }); + for (var slotName in slots) { + if (slots[slotName]) { + // Only define a transclusion function if the slot was filled + slots[slotName] = compilationGenerator(mightHaveMultipleTransclusionError, slots[slotName], transcludeFn); + } + } } $compileNode.empty(); // clear contents @@ -2125,6 +2161,10 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { // is later passed as `parentBoundTranscludeFn` to `publicLinkFn` transcludeFn = controllersBoundTransclude; transcludeFn.$$boundTransclude = boundTranscludeFn; + // expose the slots on the `$transclude` function + transcludeFn.isSlotFilled = function(slotName) { + return !!boundTranscludeFn.$$slots[slotName]; + }; } if (controllerDirectives) { @@ -2221,16 +2261,22 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { futureParentElement = hasElementTranscludeDirective ? $element.parent() : $element; } if (slotName) { + // slotTranscludeFn can be one of three things: + // * a transclude function - a filled slot + // * `null` - an optional slot that was not filled + // * `undefined` - a slot that was not declared (i.e. invalid) var slotTranscludeFn = boundTranscludeFn.$$slots[slotName]; - if (!slotTranscludeFn) { + if (slotTranscludeFn) { + return slotTranscludeFn(scope, cloneAttachFn, transcludeControllers, futureParentElement, scopeToChild); + } else if (isUndefined(slotTranscludeFn)) { throw $compileMinErr('noslot', 'No parent directive that requires a transclusion with slot name "{0}". ' + 'Element: {1}', slotName, startingTag($element)); } - return slotTranscludeFn(scope, cloneAttachFn, transcludeControllers, futureParentElement, scopeToChild); + } else { + return boundTranscludeFn(scope, cloneAttachFn, transcludeControllers, futureParentElement, scopeToChild); } - return boundTranscludeFn(scope, cloneAttachFn, transcludeControllers, futureParentElement, scopeToChild); } } } diff --git a/src/ng/directive/ngTransclude.js b/src/ng/directive/ngTransclude.js index 805ef047ed4e..92b7720896b8 100644 --- a/src/ng/directive/ngTransclude.js +++ b/src/ng/directive/ngTransclude.js @@ -11,7 +11,10 @@ * You can specify that you want to insert a named transclusion slot, instead of the default slot, by providing the slot name * as the value of the `ng-transclude` or `ng-transclude-slot` attribute. * - * Any existing content of the element that this directive is placed on, will be removed before the transcluded content is inserted. + * If the transcluded content is not empty (i.e. contains one or more DOM nodes, including whitespace text nodes), any existing + * content of this element will be removed before the transcluded content is inserted. + * If the transcluded content is empty, the existing content is left intact. This lets you provide fallback content in the case + * that no transcluded content is provided. * * @element ANY * @@ -19,95 +22,142 @@ * or its value is the same as the name of the attribute then the default slot is used. * * @example - * ### Default transclusion - * This example demonstrates simple transclusion. - - -
' + - '
{{title}}
' + - '' + - '
' - }; - }) - .controller('ExampleController', ['$scope', function($scope) { - $scope.title = 'Lorem Ipsum'; - $scope.text = 'Neque porro quisquam est qui dolorem ipsum quia dolor...'; - }]); - -
-
-
- {{text}} -
- - - it('should have transcluded', function() { - var titleElement = element(by.model('title')); - titleElement.clear(); - titleElement.sendKeys('TITLE'); - var textElement = element(by.model('text')); - textElement.clear(); - textElement.sendKeys('TEXT'); - expect(element(by.binding('title')).getText()).toEqual('TITLE'); - expect(element(by.binding('text')).getText()).toEqual('TEXT'); - }); - - + * ### Basic transclusion + * This example demonstrates basic transclusion of content into a component directive. + * + * + * + *
+ *
+ *
+ * {{text}} + *
+ *
+ * + * it('should have transcluded', function() { + * var titleElement = element(by.model('title')); + * titleElement.clear(); + * titleElement.sendKeys('TITLE'); + * var textElement = element(by.model('text')); + * textElement.clear(); + * textElement.sendKeys('TEXT'); + * expect(element(by.binding('title')).getText()).toEqual('TITLE'); + * expect(element(by.binding('text')).getText()).toEqual('TEXT'); + * }); + * + *
+ * + * @example + * ### Transclude fallback content + * This example shows how to use `NgTransclude` with fallback content, that + * is displayed if no transcluded content is provided. + * + * + * + * + * + * + * + * + * Button2 + * + * + * + * it('should have different transclude element content', function() { + * expect(element(by.id('fallback')).getText()).toBe('Button1'); + * expect(element(by.id('modified')).getText()).toBe('Button2'); + * }); + * + * * * @example * ### Multi-slot transclusion - - -
-
-
- - {{title}} -

{{text}}

-
-
-
- - angular.module('multiSlotTranscludeExample', []) - .directive('pane', function(){ - return { - restrict: 'E', - transclude: { - 'paneTitle': '?title', - 'paneBody': 'body' - }, - template: '
' + - '
' + - '
' + - '
' - }; - }) - .controller('ExampleController', ['$scope', function($scope) { - $scope.title = 'Lorem Ipsum'; - $scope.link = "https://google.com"; - $scope.text = 'Neque porro quisquam est qui dolorem ipsum quia dolor...'; - }]); -
- - it('should have transcluded the title and the body', function() { - var titleElement = element(by.model('title')); - titleElement.clear(); - titleElement.sendKeys('TITLE'); - var textElement = element(by.model('text')); - textElement.clear(); - textElement.sendKeys('TEXT'); - expect(element(by.binding('title')).getText()).toEqual('TITLE'); - expect(element(by.binding('text')).getText()).toEqual('TEXT'); - }); - -
*/ + * This example demonstrates using multi-slot transclusion in a component directive. + * + * + * + *
+ *
+ *
+ * + * {{title}} + *

{{text}}

+ *
+ *
+ *
+ * + * angular.module('multiSlotTranscludeExample', []) + * .directive('pane', function(){ + * return { + * restrict: 'E', + * transclude: { + * 'title': '?pane-title', + * 'body': 'pane-body', + * 'footer': '?pane-footer' + * }, + * template: '
' + + * '
Fallback Title
' + + * '
' + + * '' + + * '
' + * }; + * }) + * .controller('ExampleController', ['$scope', function($scope) { + * $scope.title = 'Lorem Ipsum'; + * $scope.link = "https://google.com"; + * $scope.text = 'Neque porro quisquam est qui dolorem ipsum quia dolor...'; + * }]); + *
+ * + * it('should have transcluded the title and the body', function() { + * var titleElement = element(by.model('title')); + * titleElement.clear(); + * titleElement.sendKeys('TITLE'); + * var textElement = element(by.model('text')); + * textElement.clear(); + * textElement.sendKeys('TEXT'); + * expect(element(by.css('.title')).getText()).toEqual('TITLE'); + * expect(element(by.binding('text')).getText()).toEqual('TEXT'); + * expect(element(by.css('.footer')).getText()).toEqual('Fallback Footer'); + * }); + * + *
+ */ var ngTranscludeMinErr = minErr('ngTransclude'); var ngTranscludeDirective = ngDirective({ restrict: 'EAC', @@ -120,8 +170,10 @@ var ngTranscludeDirective = ngDirective({ } function ngTranscludeCloneAttachFn(clone) { - $element.empty(); - $element.append(clone); + if (clone.length) { + $element.empty(); + $element.append(clone); + } } if (!$transclude) { @@ -132,7 +184,10 @@ var ngTranscludeDirective = ngDirective({ startingTag($element)); } - $transclude(ngTranscludeCloneAttachFn, null, $attrs.ngTransclude || $attrs.ngTranscludeSlot); + // If there is no slot name defined or the slot name is not optional + // then transclude the slot + var slotName = $attrs.ngTransclude || $attrs.ngTranscludeSlot; + $transclude(ngTranscludeCloneAttachFn, null, slotName); } }); diff --git a/test/ng/compileSpec.js b/test/ng/compileSpec.js index 4c0845d13180..e742cfb74789 100755 --- a/test/ng/compileSpec.js +++ b/test/ng/compileSpec.js @@ -6252,7 +6252,8 @@ describe('$compile', function() { }); - it('should clear contents of the ng-translude element before appending transcluded content', function() { + it('should clear contents of the ng-translude element before appending transcluded content' + + ' if transcluded content exists', function() { module(function() { directive('trans', function() { return { @@ -6268,6 +6269,23 @@ describe('$compile', function() { }); }); + it('should NOT clear contents of the ng-translude element before appending transcluded content' + + ' if transcluded content does NOT exist', function() { + module(function() { + directive('trans', function() { + return { + transclude: true, + template: '
old stuff!
' + }; + }); + }); + inject(function(log, $rootScope, $compile) { + element = $compile('
')($rootScope); + $rootScope.$apply(); + expect(sortedHtml(element.html())).toEqual('
old stuff!
'); + }); + }); + it('should throw on an ng-transclude element inside no transclusion directive', function() { inject(function($rootScope, $compile) { @@ -7660,7 +7678,7 @@ describe('$compile', function() { restrict: 'E', scope: {}, transclude: { - boss: 'bossSlot' + bossSlot: 'boss' }, template: '
' @@ -7722,7 +7740,7 @@ describe('$compile', function() { restrict: 'E', scope: {}, transclude: { - boss: 'bossSlot' + bossSlot: 'boss' }, template: '
' @@ -7751,8 +7769,8 @@ describe('$compile', function() { restrict: 'E', scope: {}, transclude: { - minion: 'minionSlot', - boss: 'bossSlot' + minionSlot: 'minion', + bossSlot: 'boss' }, template: '
' + @@ -7784,8 +7802,8 @@ describe('$compile', function() { restrict: 'E', scope: {}, transclude: { - minion: 'minionSlot', - boss: 'bossSlot' + minionSlot: 'minion', + bossSlot: 'boss' }, template: '' + @@ -7816,8 +7834,8 @@ describe('$compile', function() { restrict: 'E', scope: {}, transclude: { - minion: 'minionSlot', - boss: 'bossSlot' + minionSlot: 'minion', + bossSlot: 'boss' }, template: '
' + @@ -7845,8 +7863,8 @@ describe('$compile', function() { restrict: 'E', scope: {}, transclude: { - minion: 'minionSlot', - boss: '?bossSlot' + minionSlot: 'minion', + bossSlot: '?boss' }, template: '
' + @@ -7875,7 +7893,7 @@ describe('$compile', function() { restrict: 'E', scope: {}, transclude: { - minion: 'minionSlot' + minionSlot: 'minion' }, template: '
' + @@ -7923,14 +7941,14 @@ describe('$compile', function() { }); - it('should match against the normalized form of the element', function() { + it('should not normalize the element name', function() { module(function() { directive('foo', function() { return { restrict: 'E', scope: {}, transclude: { - fooBar: 'fooBarSlot' + fooBarSlot: 'foo-bar' }, template: '
' @@ -7948,7 +7966,7 @@ describe('$compile', function() { }); - it('should provide the elements marked with matching transclude elements as additional transclude functions on the $$slots property', function() { + it('should return true from `isSlotFilled(slotName) for slots that have content in the transclusion', function() { var capturedTranscludeFn; module(function() { directive('minionComponent', function() { @@ -7956,8 +7974,8 @@ describe('$compile', function() { restrict: 'E', scope: {}, transclude: { - minion: 'minionSlot', - boss: 'bossSlot' + minionSlot: 'minion', + bossSlot: '?boss' }, template: '
' + @@ -7975,31 +7993,76 @@ describe('$compile', function() { ' stuart' + ' bob' + ' dorothy' + - ' gru' + '')($rootScope); $rootScope.$apply(); - var minionTranscludeFn = capturedTranscludeFn.$$boundTransclude.$$slots['minionSlot']; - var minions = minionTranscludeFn(); - expect(minions[0].outerHTML).toEqual('stuart'); - expect(minions[1].outerHTML).toEqual('bob'); - - var scope = element.scope(); - - var minionScope = jqLite(minions[0]).scope(); - expect(minionScope.$parent).toBe(scope); - - var bossTranscludeFn = capturedTranscludeFn.$$boundTransclude.$$slots['bossSlot']; - var boss = bossTranscludeFn(); - expect(boss[0].outerHTML).toEqual('gru'); + var hasMinions = capturedTranscludeFn.isSlotFilled('minionSlot'); + var hasBosses = capturedTranscludeFn.isSlotFilled('bossSlot'); - var bossScope = jqLite(boss[0]).scope(); - expect(bossScope.$parent).toBe(scope); + expect(hasMinions).toBe(true); + expect(hasBosses).toBe(false); + }); + }); - expect(bossScope).not.toBe(minionScope); + it('should not overwrite the contents of an `ng-transclude` element, if the matching optional slot is not filled', function() { + module(function() { + directive('minionComponent', function() { + return { + restrict: 'E', + scope: {}, + transclude: { + minionSlot: 'minion', + bossSlot: '?boss' + }, + template: + '
default boss content
' + + '
default minion content
' + + '
default content
' + }; + }); + }); + inject(function($rootScope, $compile) { + element = $compile( + '' + + 'stuart' + + 'dorothy' + + 'kevin' + + '')($rootScope); + $rootScope.$apply(); + expect(element.children().eq(0).text()).toEqual('default boss content'); + expect(element.children().eq(1).text()).toEqual('stuartkevin'); + expect(element.children().eq(2).text()).toEqual('dorothy'); + }); + }); - dealoc(boss); - dealoc(minions); + it('should not overwrite the contents of an `ng-transclude` element, if the matching optional slot is not filled', function() { + module(function() { + directive('minionComponent', function() { + return { + restrict: 'E', + scope: {}, + transclude: { + minionSlot: 'minion', + bossSlot: '?boss' + }, + template: + '
default boss content
' + + '
default minion content
' + + '
default content
' + }; + }); + }); + inject(function($rootScope, $compile) { + element = $compile( + '' + + 'stuart' + + 'dorothy' + + 'kevin' + + '')($rootScope); + $rootScope.$apply(); + expect(element.children().eq(0).text()).toEqual('default boss content'); + expect(element.children().eq(1).text()).toEqual('stuartkevin'); + expect(element.children().eq(2).text()).toEqual('dorothy'); }); }); });