diff --git a/docs/content/guide/directive.ngdoc b/docs/content/guide/directive.ngdoc index 22dd639ee970..cfc4af90e0ae 100644 --- a/docs/content/guide/directive.ngdoc +++ b/docs/content/guide/directive.ngdoc @@ -444,7 +444,11 @@ compiler}. The attributes are: * `true` - transclude the content of the directive. * `'element'` - transclude the whole element including any directives defined at lower priority. - + * `'multi-element'` - if the node where this directive is present contains an attribute + named `${directiveName}-start` (were `${directiveName}` is the name of the directive) then + the entire block of elements is transcluded until a sibling node is found with and attribute + named `${directiveName}-end`. If and attribute name `${directiveName}-start` is not found + then it behaves like the `'element'` transclude. * `compile`: This is the compile function described in the section below. diff --git a/src/ng/compile.js b/src/ng/compile.js index 96529d3cdda9..1bd5ecb38529 100644 --- a/src/ng/compile.js +++ b/src/ng/compile.js @@ -357,7 +357,7 @@ function $CompileProvider($provide) { //================================ - function compile($compileNodes, transcludeFn, maxPriority) { + function compile($compileNodes, transcludeFn, maxPriority, limitMaxPriorityToFirstElement) { if (!($compileNodes instanceof jqLite)) { // jquery always rewraps, whereas we need to preserve the original selector so that we can modify it. $compileNodes = jqLite($compileNodes); @@ -369,7 +369,8 @@ function $CompileProvider($provide) { $compileNodes[index] = jqLite(node).wrap('').parent()[0]; } }); - var compositeLinkFn = compileNodes($compileNodes, transcludeFn, $compileNodes, maxPriority); + var compositeLinkFn = compileNodes($compileNodes, transcludeFn, $compileNodes, maxPriority, + limitMaxPriorityToFirstElement); return function publicLinkFn(scope, cloneConnectFn){ assertArg(scope, 'scope'); // important!!: we must call our jqLite.clone() since the jQuery one is trying to be smart @@ -418,9 +419,13 @@ function $CompileProvider($provide) { * rootElement must be set the jqLite collection of the compile root. This is * needed so that the jqLite collection items can be replaced with widgets. * @param {number=} max directive priority + * @param {boolean=} limitMaxPriorityToFirstElement if the max priority should only apply to + * the first element in the list. A true value here will make the maxPriority only apply + * to the first element on the list while the other elements on the list will not have + * a maxPriority set * @returns {?function} A composite linking function of all of the matched directives or null. */ - function compileNodes(nodeList, transcludeFn, $rootElement, maxPriority) { + function compileNodes(nodeList, transcludeFn, $rootElement, maxPriority, limitMaxPriorityToFirstElement) { var linkFns = [], nodeLinkFn, childLinkFn, directives, attrs, linkFnFound; @@ -428,7 +433,8 @@ function $CompileProvider($provide) { attrs = new Attributes(); // we must always refer to nodeList[i] since the nodes can be replaced underneath us. - directives = collectDirectives(nodeList[i], [], attrs, maxPriority); + directives = collectDirectives(nodeList[i], [], attrs, + (limitMaxPriorityToFirstElement && i != 0) ? undefined : maxPriority); nodeLinkFn = (directives.length) ? applyDirectivesToNode(directives, nodeList[i], attrs, transcludeFn, $rootElement) @@ -645,6 +651,24 @@ function $CompileProvider($provide) { compileNode = $compileNode[0]; replaceWith(jqCollection, jqLite($template[0]), compileNode); childTranscludeFn = compile($template, transcludeFn, terminalPriority); + } else if (directiveValue == 'multi-element') { + // We need to compile a clone of the elements, but at the same time we have to be sure that these elements are siblings + var nestedContent = extractMultiElementTransclude(compileNode, directiveName); + var cloneContent = jqLite('
'); + + $template = jqLite(compileNode); + $compileNode = templateAttrs.$$element = + jqLite(document.createComment(' ' + directiveName + ': ' + templateAttrs[directiveName] + ' ')); + compileNode = $compileNode[0]; + replaceWith(jqCollection, jqLite($template[0]), compileNode); + cloneContent.append($template); + forEach(nestedContent.splice(1, nestedContent.length - 1), + function(toRemove) { + removeElement(jqCollection, toRemove); + cloneContent.append(toRemove); + } + ); + childTranscludeFn = compile(cloneContent.contents(), transcludeFn, terminalPriority, true); } else { $template = jqLite(JQLiteClone(compileNode)).contents(); $compileNode.html(''); // clear contents @@ -731,6 +755,39 @@ function $CompileProvider($provide) { //////////////////// + + function extractMultiElementTransclude(cursor, directiveName) { + var transcludeContent = [], + c, count = 0, + transcludeStart = directiveName + 'Start', + transcludeEnd = directiveName + 'End'; + + do { + if (containsAttr(cursor, transcludeStart)) count++; + if (containsAttr(cursor, transcludeEnd)) count--; + transcludeContent.push(cursor); + cursor = cursor.nextSibling; + } while(count > 0 && cursor); + if (count > 0) throw new Error('Unmatched ' + transcludeStart + '.'); + if (count < 0) throw new Error('Unexpected ' + transcludeEnd + '.'); + return transcludeContent; + } + + + function containsAttr(node, attributeName) { + var attr, attrs = node.attributes, length = attrs && attrs.length; + if ( length ) { + for (var j = 0; j < length; j++) { + attr = attrs[j]; + if (attr.specified && directiveNormalize(attr.name) == attributeName) { + return true; + } + } + } + return false; + } + + function addLinkFns(pre, post) { if (pre) { pre.require = directive.require; @@ -1152,6 +1209,23 @@ function $CompileProvider($provide) { newNode[jqLite.expando] = oldNode[jqLite.expando]; $element[0] = newNode; } + + + function removeElement($rootElement, element) { + var i, ii, parent = element.parentNode; + + if ($rootElement) { + for(i = 0, ii = $rootElement.length; i < ii; i++) { + if ($rootElement[i] == element) { + $rootElement.splice(i, 1); + break; + } + } + } + if (parent) { + parent.removeChild(element); + } + } }]; } diff --git a/src/ng/directive/ngRepeat.js b/src/ng/directive/ngRepeat.js index 330f6abb18cc..86544cf19814 100644 --- a/src/ng/directive/ngRepeat.js +++ b/src/ng/directive/ngRepeat.js @@ -145,7 +145,7 @@ var ngRepeatDirective = ['$parse', '$animator', function($parse, $animator) { var NG_REMOVED = '$$NG_REMOVED'; return { - transclude: 'element', + transclude: 'multi-element', priority: 1000, terminal: true, compile: function(element, attr, linker) { @@ -258,7 +258,7 @@ var ngRepeatDirective = ['$parse', '$animator', function($parse, $animator) { if (lastBlockMap.hasOwnProperty(key)) { block = lastBlockMap[key]; animate.leave(block.element); - block.element[0][NG_REMOVED] = true; + forEach(block.element, function(leavingElement) { leavingElement[NG_REMOVED] = true; }); block.scope.$destroy(); } } @@ -274,7 +274,7 @@ var ngRepeatDirective = ['$parse', '$animator', function($parse, $animator) { // associated scope/element childScope = block.scope; - nextCursor = cursor[0]; + nextCursor = cursor[cursor.length - 1]; do { nextCursor = nextCursor.nextSibling; } while(nextCursor && nextCursor[NG_REMOVED]); @@ -284,7 +284,7 @@ var ngRepeatDirective = ['$parse', '$animator', function($parse, $animator) { cursor = block.element; } else { // existing item which got moved - animate.move(block.element, null, cursor); + animate.move(block.element, null, cursor.eq(-1)); cursor = block.element; } } else { @@ -301,7 +301,7 @@ var ngRepeatDirective = ['$parse', '$animator', function($parse, $animator) { if (!block.element) { linker(childScope, function(clone) { - animate.enter(clone, null, cursor); + animate.enter(clone, null, cursor.eq(-1)); cursor = clone; block.scope = childScope; block.element = clone; diff --git a/test/ng/compileSpec.js b/test/ng/compileSpec.js index 26f61357bee3..2f5181db0675 100755 --- a/test/ng/compileSpec.js +++ b/test/ng/compileSpec.js @@ -381,6 +381,46 @@ describe('$compile', function() { }) }); + it("should complain when the multi-element end tag can't be found among one of the following siblings", inject(function($compile) { + forEach([ + // no content, no end tag + '
', + + // content, no end tag + '
' + + '{{item.text}}>' + + '{{item.done}}', + + // content, end tag too deep + '
' + + '
' + + '{{item.text}}>' + + '{{item.done}}' + + '' + + '
', + + // content, end tag too high + '
' + + '
' + + '{{item.text}}>' + + '{{item.done}}' + + '
' + + '
' + ], function(template) { + expect(function() { + $compile('
' + template + '
'); + }).toThrow("Unmatched ngRepeatStart."); + }); + })); + + + it("should complain when there is an end attribute at the start of a multi-element directive", inject(function($compile) { + expect(function() { + $compile('
  • '); + }).toThrow("Unexpected ngRepeatEnd."); + })); + + describe('compiler control', function() { describe('priority', function() { it('should honor priority', inject(function($compile, $rootScope, log){ @@ -2345,6 +2385,35 @@ describe('$compile', function() { }); + it('should compile get templateFn on multi-element', function() { + module(function() { + directive('trans', function(log) { + return { + transclude: 'multi-element', + priority: 2, + controller: function($transclude) { this.$transclude = $transclude; }, + compile: function(element, attrs, template) { + log('compile: ' + angular.mock.dump(element)); + return function(scope, element, attrs, ctrl) { + log('link'); + var cursor = element; + template(scope.$new(), function(clone) {cursor.after(clone); cursor = clone.eq(-1);}); + ctrl.$transclude(function(clone) {cursor.after(clone)}); + }; + } + } + }); + }); + inject(function(log, $rootScope, $compile) { + element = $compile('
    {{$parent.$id}}-{{$id}};
    {{$parent.$id}}-{{$id}};
    ') + ($rootScope); + $rootScope.$apply(); + expect(log).toEqual('compile: ; HIGH; link; LOG; LOG'); + expect(element.text()).toEqual('001-002;001-002;001-003;001-003;'); + }); + }); + + it('should support transclude directive', function() { module(function() { directive('trans', function() { @@ -2485,6 +2554,30 @@ describe('$compile', function() { }); + it('should support transcluded multi-element on root content', function() { + var comment; + module(function() { + directive('transclude', valueFn({ + transclude: 'multi-element', + compile: function(element, attr, linker) { + return function(scope, element, attr) { + comment = element; + }; + } + })); + }); + inject(function($compile, $rootScope) { + var element = jqLite('
    before
    after
    ').contents(); + expect(element.length).toEqual(4); + expect(nodeName_(element[1])).toBe('DIV'); + $compile(element)($rootScope); + expect(nodeName_(element[1])).toBe('#comment'); + expect(nodeName_(comment)).toBe('#comment'); + expect(nodeName_(element[2])).toBe('DIV'); + }); + }); + + it('should safely create transclude comment node and not break with "-->"', inject(function($rootScope) { // see: https://github.com/angular/angular.js/issues/1740 diff --git a/test/ng/directive/ngRepeatSpec.js b/test/ng/directive/ngRepeatSpec.js index e7e9af3549cc..4791fb024754 100644 --- a/test/ng/directive/ngRepeatSpec.js +++ b/test/ng/directive/ngRepeatSpec.js @@ -75,6 +75,37 @@ describe('ngRepeat', function() { }); + it('should be able to handle multi-element blocks', function() { + element = $compile( + '')(scope); + + Array.prototype.extraProperty = "should be ignored"; + // INIT + scope.items = [{name: 'misko', number: 1, color: 'red'}, {name:'shyam', number: 2, color: 'blue'}]; + scope.$digest(); + expect(element.find('li').length).toEqual(6); + expect(element.text()).toEqual('misko;1;red;shyam;2;blue;'); + delete Array.prototype.extraProperty; + + // GROW + scope.items.push({name: 'adam', number: 3, color: 'green'}); + scope.$digest(); + expect(element.find('li').length).toEqual(9); + expect(element.text()).toEqual('misko;1;red;shyam;2;blue;adam;3;green;'); + + // SHRINK + scope.items.pop(); + scope.items.shift(); + scope.$digest(); + expect(element.find('li').length).toEqual(3); + expect(element.text()).toEqual('shyam;2;blue;'); + }); + + it('should iterate over on object/map', function() { element = $compile( '