diff --git a/angularFiles.js b/angularFiles.js index 44614a8cfa3a..aac0cbb51166 100644 --- a/angularFiles.js +++ b/angularFiles.js @@ -9,6 +9,8 @@ angularFiles = { 'src/auto/injector.js', 'src/ng/anchorScroll.js', + 'src/ng/animation.js', + 'src/ng/animator.js', 'src/ng/browser.js', 'src/ng/cacheFactory.js', 'src/ng/compile.js', @@ -71,7 +73,6 @@ angularFiles = { 'src/ngMock/angular-mocks.js', 'src/ngMobile/mobile.js', 'src/ngMobile/directive/ngClick.js', - 'src/bootstrap/bootstrap.js' ], @@ -103,6 +104,7 @@ angularFiles = { 'test/ng/*.js', 'test/ng/directive/*.js', 'test/ng/filter/*.js', + 'test/ngAnimate/*.js', 'test/ngCookies/*.js', 'test/ngResource/*.js', 'test/ngSanitize/*.js', diff --git a/docs/src/ngdoc.js b/docs/src/ngdoc.js index f8f6cdf50963..1adfed01a353 100644 --- a/docs/src/ngdoc.js +++ b/docs/src/ngdoc.js @@ -328,6 +328,18 @@ Doc.prototype = { }); dom.html(param.description); }); + if(this.animations) { + dom.h('Animations', this.animations, function(animations){ + dom.html(''); + }); + } }, html_usage_returns: function(dom) { @@ -433,6 +445,45 @@ Doc.prototype = { dom.text(''); }); } + if(self.animations) { + var animations = [], matches = self.animations.split("\n"); + matches.forEach(function(ani) { + var name = ani.match(/^\s*(.+?)\s*-/)[1]; + animations.push(name); + }); + + dom.html('with animations'); + var comment; + if(animations.length == 1) { + comment = 'The ' + animations[0] + ' animation is supported'; + } + else { + var rhs = animations[animations.length-1]; + var lhs = ''; + for(var i=0;i0) { + lhs += ', '; + } + lhs += animations[i]; + } + comment = 'The ' + lhs + ' and ' + rhs + ' animations are supported'; + } + var element = self.element || 'ANY'; + dom.code(function() { + dom.text('//' + comment + "\n"); + dom.text('<' + element + ' '); + dom.text(dashCase(self.shortName)); + renderParams('\n ', '="', '"', true); + dom.text(' ng-animate="'); + animations.forEach(function(ani) { + dom.text(ani + ': ' + ani + '-animation; '); + }); + dom.text('">\n ...\n'); + dom.text(''); + }); + + dom.html('Click here to learn more about the steps involved in the animation.'); + } } self.html_usage_directiveInfo(dom); self.html_usage_parameters(dom); diff --git a/docs/src/templates/css/docs.css b/docs/src/templates/css/docs.css index 70d98a3c8bea..b9e31d449abc 100644 --- a/docs/src/templates/css/docs.css +++ b/docs/src/templates/css/docs.css @@ -3,6 +3,14 @@ img.AngularJS-small { height: 25px; } +.breadcrumb li > * { + float:left; + margin:0 2px 0 0; +} + +.breadcrumb { + padding-bottom:2px; +} .clear-navbar { margin-top: 60px; diff --git a/example/ngAnimate/css3/index.html b/example/ngAnimate/css3/index.html new file mode 100644 index 000000000000..23d042c6a546 --- /dev/null +++ b/example/ngAnimate/css3/index.html @@ -0,0 +1,163 @@ + + + + AngularJS Animation Demo + + + + + + + + +
+ +
+
+
+

+ CSS3 Animation + + enter: + fadeEnter + + + leave: + fadeLeave + +

+
+
+ +
+
+
+ +
+
+

+ Polyfill Animation + + enter: + fadeEnterCb + + + leave: + fadeLeaveCb + +

+
+
+ +
+
+
+
+ + + + + + diff --git a/example/ngAnimate/css3/module.js b/example/ngAnimate/css3/module.js new file mode 100644 index 000000000000..fade152a8d41 --- /dev/null +++ b/example/ngAnimate/css3/module.js @@ -0,0 +1,45 @@ +angular.module('Animator', []) + + .controller('AppCtrl', function($scope) { + $scope.items = ["Afghanistan"," Albania"," Algeria"," American Samoa"," Andorra"," Angola"," Anguilla"," Antarctica"," Antigua and Barbuda"," Argentina"," Armenia"," Aruba"," Ashmore and Cartier"," Australia"," Austria"," Azerbaijan"," Bahrain"," Baker Island"," Bangladesh"," Barbados"," Bassas da India"," Belarus"," Belgium"," Belize"," Benin"," Bermuda"," Bhutan"," Bolivia"," Bosnia and Herzegovina"," Botswana"," Bouvet Island"," Brazil"," British Indian Ocean Territory"," British Virgin Islands"," Brunei Darussalam"," Bulgaria"," Burkina Faso"," Burma"," Burundi"," Cambodia"," Cameroon"," Canada"," Cape Verde"," Cayman Islands"," Central African Republic"," Chad"," Chile"," China"," Christmas Island"," Clipperton Island"," Cocos (Keeling) Islands"," Colombia"," Comoros"," Congo, Democratic Republic of the"," Congo, Republic of the"," Cook Islands"," Coral Sea Islands"," Costa Rica"," Cote d'Ivoire"," Croatia"," Cuba"," Cyprus"," Czech Republic"," Denmark"," Djibouti"," Dominica"," Dominican Republic"," East Timor"," Ecuador"," Egypt"," El Salvador"," Equatorial Guinea"," Eritrea"," Estonia"," Ethiopia"," Europa Island"," Falkland Islands (Islas Malvinas)"," Faroe Islands"," Fiji"," Finland"," France"," France, Metropolitan"," French Guiana"," French Polynesia"," French Southern and Antarctic Lands"," Gabon"," Gaza Strip"," Georgia"," Germany"," Ghana"," Gibraltar"," Glorioso Islands"," Greece"," Greenland"," Grenada"," Guadeloupe"," Guam"," Guatemala"," Guernsey"," Guinea"," Guinea-Bissau"," Guyana"," Haiti"," Heard Island and McDonald Islands"," Holy See (Vatican City)"," Honduras"," Hong Kong (SAR)"," Howland Island"," Hungary"," Iceland"," India"," Indonesia"," Iran"," Iraq"," Ireland"," Israel"," Italy"," Jamaica"," Jan Mayen"," Japan"," Jarvis Island"," Jersey"," Johnston Atoll"," Jordan"," Juan de Nova Island"," Kazakhstan"," Kenya"," Kingman Reef"," Kiribati"," Korea, North"," Korea, South"," Kuwait"," Kyrgyzstan"," Laos"," Latvia"," Lebanon"," Lesotho"," Liberia"," Libya"," Liechtenstein"," Lithuania"," Luxembourg"," Macao"," Macedonia, The Former Yugoslav Republic of"," Madagascar"," Malawi"," Malaysia"," Maldives"," Mali"," Malta"," Man, Isle of"," Marshall Islands"," Martinique"," Mauritania"," Mauritius"," Mayotte"," Mexico"," Micronesia, Federated States of"," Midway Islands"," Miscellaneous (French)"," Moldova"," Monaco"," Mongolia"," Montenegro"," Montserrat"," Morocco"," Mozambique"," Myanmar"," Namibia"," Nauru"," Navassa Island"," Nepal"," Netherlands"," Netherlands Antilles"," New Caledonia"," New Zealand"," Nicaragua"," Niger"," Nigeria"," Niue"," Norfolk Island"," Northern Mariana Islands"," Norway"," Oman"," Pakistan"," Palau"," Palestinian Territory, Occupied"," Palmyra Atoll"," Panama"," Papua New Guinea"," Paracel Islands"," Paraguay"," Peru"," Philippines"," Pitcairn Islands"," Poland"," Portugal"," Puerto Rico"," Qatar"," Réunion"," Romania"," Russia"," Rwanda"," Saint Helena"," Saint Kitts and Nevis"," Saint Lucia"," Saint Pierre and Miquelon"," Saint Vincent and the Grenadines"," Samoa"," San Marino"," São Tomé and Príncipe"," Saudi Arabia"," Senegal"," Serbia"," Serbia and Montenegro"," Seychelles"," Sierra Leone"," Singapore"," Slovakia"," Slovenia"," Solomon Islands"," Somalia"," South Africa"," South Georgia and the South Sandwich Islands"," Spain"," Spratly Islands"," Sri Lanka"," Sudan"," Suriname"," Svalbard"," Swaziland"," Sweden"," Switzerland"," Syria"," Taiwan"," Tajikistan"," Tanzania"," Thailand"," The Bahamas"," The Gambia"," Togo"," Tokelau"," Tonga"," Trinidad and Tobago"," Tromelin Island"," Tunisia"," Turkey"," Turkmenistan"," Turks and Caicos Islands"," Tuvalu"," Uganda"," Ukraine"," United Arab Emirates"," United Kingdom"," United States"," United States Minor Outlying Islands"," Uruguay"," Uzbekistan"," Vanuatu"," Venezuela"," Vietnam"," Virgin Islands"," Virgin Islands (UK)"," Virgin Islands (US)"," Wake Island"," Wallis and Futuna"," West Bank"," Western Sahara"," Western Samoa"," Yemen"," Yugoslavia"," Zaire"," Zambia"," Zimbabwe"]; + + }) + + .animation('fade-enter-cb', function() { + return { + setup : function(element) { + element.css({ + 'opacity':0, + 'top': 100, + 'position':'absolute' + }); + }, + start : function(element, done, memo) { + element.animate({ + 'opacity':1, + 'top':0 + }, done); + } + } + }) + + .animation('fade-leave-cb', function() { + return { + setup : function(element) { + var height = element.height(); + return height; + element.css({ + 'opacity':1, + 'top': 0, + 'position':'absolute' + }); + }, + start : function(element, done, memo) { + element.animate({ + 'opacity':0, + 'top':-100, + duration: 2000 + }, done); + } + }; + }); diff --git a/example/ngAnimate/css3/nav.html b/example/ngAnimate/css3/nav.html new file mode 100644 index 000000000000..0128e4b43938 --- /dev/null +++ b/example/ngAnimate/css3/nav.html @@ -0,0 +1,5 @@ + diff --git a/example/ngAnimate/css3/ng-repeat.html b/example/ngAnimate/css3/ng-repeat.html new file mode 100644 index 000000000000..02eec80a9bd5 --- /dev/null +++ b/example/ngAnimate/css3/ng-repeat.html @@ -0,0 +1,91 @@ + + + + AngularJS Animation Demo + + + + + +
+ +
+
+
+

+ CSS3 Animation + + enter: + fadeEnter + + + leave: + fadeLeave + + + move: + fadeMove + +

+
+
+ +
+
+
+ {{ item }} +
+
+
+
+ + + + + + diff --git a/example/ngAnimate/css3/ng-show-hide.html b/example/ngAnimate/css3/ng-show-hide.html new file mode 100644 index 000000000000..424730998499 --- /dev/null +++ b/example/ngAnimate/css3/ng-show-hide.html @@ -0,0 +1,83 @@ + + + + AngularJS Animation Demo + + + + + +
+ +
+
+
+

+ CSS3 Animation + + enter: + fadeEnter + + + leave: + fadeLeave + + + move: + fadeMove + +

+
+
+ + +
+
+ content +
+
+
+ + + + + + diff --git a/example/ngAnimate/css3/ng-switch.html b/example/ngAnimate/css3/ng-switch.html new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/AngularPublic.js b/src/AngularPublic.js index 3c5e46cbb96e..a66c35b335c5 100644 --- a/src/AngularPublic.js +++ b/src/AngularPublic.js @@ -107,6 +107,8 @@ function publishExternalAPI(angular){ directive(ngEventDirectives); $provide.provider({ $anchorScroll: $AnchorScrollProvider, + $animation: $AnimationProvider, + $animator: $AnimatorProvider, $browser: $BrowserProvider, $cacheFactory: $CacheFactoryProvider, $controller: $ControllerProvider, diff --git a/src/loader.js b/src/loader.js index ecb166085460..5b74a4f3ac95 100644 --- a/src/loader.js +++ b/src/loader.js @@ -163,6 +163,33 @@ function setupModuleLoader(window) { */ constant: invokeLater('$provide', 'constant', 'unshift'), + /** + * @ngdoc method + * @name angular.Module#animation + * @methodOf angular.Module + * @param {string} name animation name + * @param {Function} animationFactory Factory function for creating new instance of an animation. + * @description + * + * Defines an animation hook that can be later used with {@link ng.directive:ngAnimate ngAnimate} + * alongside {@link ng.directive:ngAnimate#Description common ng directives} as well as custom directives. + *
+           * module.animation('animation-name', function($inject1, $inject2) {
+           *   return {
+           *     //this gets called in preparation to setup an animation
+           *     setup : function(element) { ... },
+           *
+           *     //this gets called once the animation is run
+           *     start : function(element, done, memo) { ... }
+           *   }
+           * })
+           * 
+ * + * See {@link ng.$animationProvider#register $animationProvider.register()} and + * {@link ng.directive:ngAnimate ngAnimate} for more information. + */ + animation: invokeLater('$animationProvider', 'register'), + /** * @ngdoc method * @name angular.Module#filter diff --git a/src/ng/animation.js b/src/ng/animation.js new file mode 100644 index 000000000000..e80af8d9afe6 --- /dev/null +++ b/src/ng/animation.js @@ -0,0 +1,53 @@ +/** + * @ngdoc object + * @name ng.$animationProvider + * @description + * + * The $AnimationProvider provider allows developers to register and access custom JavaScript animations directly inside of a module. + * + */ +$AnimationProvider.$inject = ['$provide']; +function $AnimationProvider($provide) { + var suffix = 'Animation'; + + /** + * @ngdoc function + * @name ng.$animation#register + * @methodOf ng.$animationProvider + * + * @description + * Registers a new animation function into the current module. + * + * @param {string} name The name of the animation. + * @param {function} Factory The factory function that will be executed to return the animation function + * + */ + this.register = function(name, factory) { + $provide.factory(camelCase(name) + suffix, factory); + }; + + this.$get = ['$injector', function($injector) { + /** + * @ngdoc function + * @name ng.$animation + * @function + * + * @description + * The $animation service is used to retrieve any defined animation functions. When executed, the $animation service + * will return a object that contains the setup and start functions that were defined for the animation. + * + * @param {String} name Name of the animation function to retrieve. Animation functions are registered and stored inside of the AngularJS DI so + * a call to $animate('custom') is the same as injecting `customAnimation` via dependency injection. + * @return {Object} the animation object which contains the `setup` and `start` functions that perform the animation. + */ + return function $animation(name) { + if (name) { + try { + return $injector.get(camelCase(name) + suffix); + } catch (e) { + //TODO(misko): this is a hack! we should have a better way to test if the injector has a given key. + } + } + } + }]; +}; diff --git a/src/ng/animator.js b/src/ng/animator.js new file mode 100644 index 000000000000..dd0035dc357b --- /dev/null +++ b/src/ng/animator.js @@ -0,0 +1,340 @@ +'use strict'; + +// NOTE: this is a pseudo directive. + +/** + * @ngdoc directive + * @name ng.directive:ngAnimate + * + * @description + * The `ngAnimate` directive works as an attribute that is attached alongside pre-existing ng directives (as well as custom directives) to + * tie in animations into the functionality of the directive. Animations with ngAnimate work by expanding the window between DOM events + * within directives. This allows for complex animations to take place while allowing common directives to work the way that you expect them to. + * The ngAnimate directive has been wired together with the ngRepeat, ngInclude, ngSwitch, ngShow, ngHide and ngView directives. Custom directives + * can be used via the {@link ng.$animator $animator service}. + * + * Below is a more detailed breakdown of the supported callback events provided by pre-exisitng ng directives: + * + * * {@link ng.directive:ngRepeat#animations ngRepeat} — enter, leave and move + * * {@link ng.directive:ngView#animations ngView} — enter and leave + * * {@link ng.directive:ngInclude#animations ngInclude} — enter and leave + * * {@link ng.directive:ngSwitch#animations ngSwitch} — enter and leave + * * {@link ng.directive:ngShow#animations ngShow & ngHide} - show and hide respectively + * + * You can find out more information about animations upon visiting each directive page. + * + * Below is an example of a directive that makes use of the ngAnimate attribute: + * + *
+ * 
+ * 
+ * 
+ * + * The `event1` and `event2` attributes refer to the animation events specific to the directive that has been assigned. + * + *

CSS-defined Animations

+ * By default, ngAnimate attaches two CSS3 classes per animation event to the DOM element that will make use the animations. This is up to you, + * the developer, to ensure that the animations take place using cross-browser CSS3 transitions. All that is required is the following CSS code: + * + *
+ * 
+ *
+ * 
+ *
+ * + * Upon animation, the setup class is added first and then, when the animation takes off, the start class is added. + * The ngAnimate directive will automatically extract the duration of the animation to figure out when it ends. Once + * the animation is over then both CSS classes will be removed from the DOM. If a browser does not support CSS transitions + * then the animation will start and end immediately resulting in a DOM element that is at it's final state. This final + * state is when the DOM element has no CSS animation classes surrounding it. + * + *

JavaScript-defined Animations

+ * In the event that you do not want to use CSS3 animations or if you wish to offer animations to browsers that do not + * yet support them, then you can make use of JavaScript animations defined inside ngModule. + * + *
+ * var ngModule = angular.module('YourApp', []);
+ * ngModule.animation('animation-name', ['$inject',function($inject) {
+ *   return {
+ *     setup : function(element) {
+ *       //prepare the element for animation
+ *       element.css({
+ *         'opacity':0
+ *       });
+ *       var memo = "..."; //this value is passed to the start function
+ *       return memo;
+ *     },
+ *     start : function(element, done, memo) {
+ *       //start the animation
+ *       element.animate({
+ *         'opacity' : 1
+ *       }, function() {
+ *         //call when the animation is complete
+ *         done()
+ *       });
+ *     }
+ *   }
+ * }])
+ * 
+ * + * As you can see, the JavaScript code follows a similar template to the CSS3 animations. Once defined, the animation can be used + * in the same way with the ngAnimate attribute. Keep in mind that, when using JavaScript-enabled animations, ngAnimate will also + * add in the same CSS classes that CSS-enabled animations do (even if you're using JavaScript animations) to the animated element, + * but it will not attempt to find any CSS3 transition duration value. It will instead close off the animation once the provided + * done function is executed. So it's important that you make sure your animations remember to fire off the done function once the + * animations are complete. + * @param {mapping expression} ngAnimate determines which animations will animate on which animation events. + * + */ + +/** + * @ngdoc function + * @name ng.$animator + * + * @description + * The $animator service provides the animation functionality that is triggered via the ngAnimate attribute. The service itself is + * used behind the scenes and isn't a common API for standard angularjs code. There are, however, various methods that are available + * for the $animator object which can be utilized in custom animations. + * + * @param {object} attr the attributes object which contains the ngAnimate key / value pair. + * @return {object} the animator object which contains the enter, leave, move, show, hide and animate methods. + */ +var $AnimatorProvider = function() { + this.$get = ['$animation', '$window', '$sniffer', function($animation, $window, $sniffer) { + return function(attrs) { + var ngAnimateAttr = attrs.ngAnimate; + var animation = {}; + var classes = {}; + var defaultCustomClass; + + if (ngAnimateAttr) { + //SAVED: http://rubular.com/r/0DCBzCtVml + var matches = ngAnimateAttr.split(/(?:([-\w]+)\ *:\ *([-\w]+)(?:;|$))+/g); + if(!matches) { + throw Error("Expected ngAnimate in form of 'animation: definition; ...;' but got '" + ngAnimateAttr + "'."); + } + if (matches.length == 1) { + defaultCustomClass = matches[0]; + classes.enter = matches[0] + '-enter'; + classes.leave = matches[0] + '-leave'; + classes.move = matches[0] + '-move'; + classes.show = matches[0] + '-show'; + classes.hide = matches[0] + '-hide'; + } else { + for(var i=1; i < matches.length; i++) { + var name = matches[i++]; + var value = matches[i++]; + if(name && value) { + classes[name] = value; + } + } + } + } + + /** + * @ngdoc function + * @name ng.animator#enter + * @methodOf ng.$animator + * @function + * + * @description + * Injects the element object into the DOM (inside of the parent element) and then runs the enter animation. + * + * @param {jQuery/jqLite element} element the element that will be the focus of the enter animation + * @param {jQuery/jqLite element} parent the parent element of the element that will be the focus of the enter animation + * @param {jQuery/jqLite element} after the sibling element (which is the previous element) of the element that will be the focus of the enter animation + */ + animation.enter = animateAction(classes.enter, $animation(classes.enter), insert, noop); + + /** + * @ngdoc function + * @name ng.animator#leave + * @methodOf ng.$animator + * @function + * + * @description + * Runs the leave animation operation and, upon completion, removes the element from the DOM. + * + * @param {jQuery/jqLite element} element the element that will be the focus of the leave animation + * @param {jQuery/jqLite element} parent the parent element of the element that will be the focus of the leave animation + */ + animation.leave = animateAction(classes.leave, $animation(classes.leave), noop, remove); + + /** + * @ngdoc function + * @name ng.animator#move + * @methodOf ng.$animator + * @function + * + * @description + * Fires the move DOM operation. Just before the animation starts, the animator will either append it into the parent container or + * add the element directly after the after element if present. Then the move animation will be run. + * + * @param {jQuery/jqLite element} element the element that will be the focus of the move animation + * @param {jQuery/jqLite element} parent the parent element of the element that will be the focus of the move animation + * @param {jQuery/jqLite element} after the sibling element (which is the previous element) of the element that will be the focus of the move animation + */ + animation.move = animateAction(classes.move, $animation(classes.move), move, noop); + + /** + * @ngdoc function + * @name ng.animator#show + * @methodOf ng.$animator + * @function + * + * @description + * Reveals the element by setting the CSS property `display` to `block` and then runs the show animation directly after. + * + * @param {jQuery/jqLite element} element the element that will be rendered visible or hidden + */ + animation.show = animateAction(classes.show, $animation(classes.show), show, noop); + + /** + * @ngdoc function + * @name ng.animator#hidee + * @methodOf ng.$animator + * + * @description + * Starts the hide animation first and sets the CSS `display` property to `none` upon completion. + * + * @param {jQuery/jqLite element} element the element that will be rendered visible or hidden + */ + animation.hide = animateAction(classes.hide, $animation(classes.hide), noop, hide); + + /** + * @ngdoc function + * @name ng.animator#animate + * @methodOf ng.$animator + * @function + * + * @description + * Fires the custom animate function (based off of the event name) on the given element. This function is designed for custom + * animations and therefore no default DOM manipulation will occur behind the scenes. Upon executing this function, the animation + * based off of the event parameter will be run. + * + * @param {string} event the animation event that you wish to execute + * @param {jQuery/jqLite element} element the element that will be the focus of the animation + * @param {jQuery/jqLite element} parent the parent element of the element that will be the focus of the animation + * @param {jQuery/jqLite element} after the sibling element (which is the previous element) of the element that will be the focus of the animation + */ + animation.animate = function(event, element, parent, after) { + animateAction(classes[event] || defaultCustomClass, $animation(classes[event]), noop, noop)(element, parent, after); + } + return animation; + } + + function show(element) { + element.css('display', 'block'); + } + + function hide(element) { + element.css('display', 'none'); + } + + function insert(element, parent, after) { + if (after) { + after.after(element); + } else { + parent.append(element); + } + } + + function remove(element) { + element.remove(); + } + + function move(element, parent, after) { + remove(element); + insert(element, parent, after); + } + + function animateAction(className, animationPolyfill, beforeFn, afterFn) { + if (!className) { + return function(element, parent, after) { + beforeFn(element, parent, after); + afterFn(element, parent, after); + } + } else { + var setupClass = className + '-setup'; + var startClass = className + '-start'; + console.log(startClass); + + return function(element, parent, after) { + if (element.length == 0) return done(); + + element.addClass(setupClass); + beforeFn(element, parent, after); + + var memento; + if (animationPolyfill && animationPolyfill.setup) { + memento = animationPolyfill.setup(element); + } + + // $window.setTimeout(beginAnimation, 0); this was causing the element not to animate + // keep at 1 for animation dom rerender + $window.setTimeout(beginAnimation, 1); + + function beginAnimation() { + element.addClass(startClass); + if (animationPolyfill && animationPolyfill.start) { + animationPolyfill.start(element, done, memento); + } else if (isFunction($window.getComputedStyle)) { + var vendorTransitionProp = $sniffer.vendorPrefix.toLowerCase() + 'Transition'; + var w3cTransitionProp = 'transition'; //one day all browsers will have this + + var durationKey = 'Duration'; + var duration = 0; + //we want all the styles defined before and after + forEach(element, function(element) { + var globalStyles = $window.getComputedStyle(element) || {}; + var localStyles = element.style || {}; + duration = Math.max( + parseFloat(localStyles[w3cTransitionProp + durationKey]) || + parseFloat(localStyles[vendorTransitionProp + durationKey]) || + parseFloat(globalStyles[w3cTransitionProp + durationKey]) || + parseFloat(globalStyles[vendorTransitionProp + durationKey]) || + 0, + duration); + }); + + $window.setTimeout(done, duration * 1000); + } else { + done(); + } + } + + function done() { + afterFn(element, parent, after); + element.removeClass(setupClass); + element.removeClass(startClass); + } + } + } + } + }]; +}; diff --git a/src/ng/directive/ngInclude.js b/src/ng/directive/ngInclude.js index d4eacbe3e0af..0269240014e5 100644 --- a/src/ng/directive/ngInclude.js +++ b/src/ng/directive/ngInclude.js @@ -12,6 +12,13 @@ * (e.g. ngInclude won't work for cross-domain requests on all browsers and for * file:// access on some browsers). * + * Additionally, you can also provide animations via the ngAnimate attribute to animate the enter + * and leave effects. + * + * @animations + * enter - happens just after the ngInclude contents change and a new DOM element is created and injected into the ngInclude container + * leave - happens just after the ngInclude contents change and just before the former contents are removed from the DOM + * * @scope * * @param {string} ngInclude|src angular expression evaluating to URL. If the source is a string constant, @@ -78,8 +85,8 @@ * @description * Emitted every time the ngInclude content is reloaded. */ -var ngIncludeDirective = ['$http', '$templateCache', '$anchorScroll', '$compile', - function($http, $templateCache, $anchorScroll, $compile) { +var ngIncludeDirective = ['$http', '$templateCache', '$anchorScroll', '$compile', '$animator', + function($http, $templateCache, $anchorScroll, $compile, $animator) { return { restrict: 'ECA', terminal: true, @@ -88,7 +95,8 @@ var ngIncludeDirective = ['$http', '$templateCache', '$anchorScroll', '$compile' onloadExp = attr.onload || '', autoScrollExp = attr.autoscroll; - return function(scope, element) { + return function(scope, element, attr) { + var animate = $animator(attr); var changeCounter = 0, childScope; @@ -97,8 +105,7 @@ var ngIncludeDirective = ['$http', '$templateCache', '$anchorScroll', '$compile' childScope.$destroy(); childScope = null; } - - element.html(''); + animate.leave(element.contents(), element); }; scope.$watch(srcExp, function ngIncludeWatchAction(src) { @@ -111,8 +118,21 @@ var ngIncludeDirective = ['$http', '$templateCache', '$anchorScroll', '$compile' if (childScope) childScope.$destroy(); childScope = scope.$new(); - element.html(response); - $compile(element.contents())(childScope); + //TODO: Igor + Matias (figure out better "onEmpty" checking solution) + if(element.children().length && /\S+/.test(element.html())) { + animate.leave(element.contents(), element); + } else { + element.html(''); + } + + //TODO misko, make a decision based on on wrapping contents + //var contents = jqLite('
').html(response); //everything is wrapped inside one parent
tag which works for animations + //var contents = jqLite('
').html(response).children(); (one or more items ... bad for animation ... also comments are included) + //var contents = jqLite('
').html(response).contents(); (one or more items ... bad for animation ... only elements) + + var contents = jqLite('
').html(response); + animate.enter(contents, element); + $compile(contents)(childScope); if (isDefined(autoScrollExp) && (!autoScrollExp || scope.$eval(autoScrollExp))) { $anchorScroll(); @@ -123,7 +143,9 @@ var ngIncludeDirective = ['$http', '$templateCache', '$anchorScroll', '$compile' }).error(function() { if (thisChangeId === changeCounter) clearContent(); }); - } else clearContent(); + } else { + clearContent(); + } }); }; } diff --git a/src/ng/directive/ngRepeat.js b/src/ng/directive/ngRepeat.js index c59fefacc956..a4f3b5520592 100644 --- a/src/ng/directive/ngRepeat.js +++ b/src/ng/directive/ngRepeat.js @@ -16,6 +16,13 @@ * * `$middle` – `{boolean}` – true if the repeated element is between the first and last in the iterator. * * `$last` – `{boolean}` – true if the repeated element is last in the iterator. * + * Additionally, you can also provide animations via the ngAnimate attribute to animate the enter, + * leave and move effects. + * + * @animations + * enter - when a new item is added to the list or when an item is revealed after a filter + * leave - when an item is removed from the list or when an item is filtered out + * move - when an adjacent item is filtered out causing a reorder or when the item contents are reordered * * @element ANY * @scope @@ -33,6 +40,24 @@ * * For example: `(name, age) in {'adam':10, 'amalie':12}`. * + * * `hash_expression from variable in expression` – You can also provide an optional hashing function + * which can be used to associate the objects in the collection with the DOM elements. If no hashing function + * is specified the ng-repeat associates elements by position in the collection. items. It is an error to have + * more then one item hash resolve to the same key. (This would mean that two distinct objects are mapped to + * the same DOM element, which is not possible.) + * + * For example: `item in items` is equivalent to `$index from item in items'. This implies that the DOM elements + * will be associated by item position in array. + * + * For example: `$hash(item) from item in items`. A built in `$hash()` function can be used to assign a unique + * `$$hashKey` property to each item in the array. This property is then used as a key to associated DOM elements + * with the corresponding item in the array by identity. Moving the same object in array would move the DOM + * element in the same way in the DOM. + * + * For example: `item.id from item it items` Is a typical pattern when the items come from the database. In this + * case the object identity does not matter. Two objects are considered the equivalent as long as their `id` + * is same. + * * @example * This example initializes the scope to a list of names and * then uses `ngRepeat` to display every person: @@ -57,133 +82,196 @@ */ -var ngRepeatDirective = ngDirective({ - transclude: 'element', - priority: 1000, - terminal: true, - compile: function(element, attr, linker) { - return function(scope, iterStartElement, attr){ - var expression = attr.ngRepeat; - var match = expression.match(/^\s*(.+)\s+in\s+(.*)\s*$/), - lhs, rhs, valueIdent, keyIdent; - if (! match) { - throw Error("Expected ngRepeat in form of '_item_ in _collection_' but got '" + - expression + "'."); - } - lhs = match[1]; - rhs = match[2]; - match = lhs.match(/^(?:([\$\w]+)|\(([\$\w]+)\s*,\s*([\$\w]+)\))$/); - if (!match) { - throw Error("'item' in 'item in collection' should be identifier or (key, value) but got '" + - lhs + "'."); - } - valueIdent = match[3] || match[1]; - keyIdent = match[2]; - - // Store a list of elements from previous run. This is a hash where key is the item from the - // iterator, and the value is an array of objects with following properties. - // - scope: bound scope - // - element: previous element. - // - index: position - // We need an array of these objects since the same object can be returned from the iterator. - // We expect this to be a rare case. - var lastOrder = new HashQueueMap(); - - scope.$watch(function ngRepeatWatch(scope){ - var index, length, - collection = scope.$eval(rhs), - cursor = iterStartElement, // current position of the node - // Same as lastOrder but it has the current state. It will become the - // lastOrder on the next iteration. - nextOrder = new HashQueueMap(), - arrayLength, - childScope, - key, value, // key/value of iteration - array, - last; // last object information {scope, element, index} - - - - if (!isArray(collection)) { - // if object, extract keys, sort them and use to determine order of iteration over obj props - array = []; - for(key in collection) { - if (collection.hasOwnProperty(key) && key.charAt(0) != '$') { - array.push(key); - } - } - array.sort(); +var ngRepeatDirective = ['$parse', '$animator', function($parse, $animator) { + return { + transclude: 'element', + priority: 1000, + terminal: true, + compile: function(element, attr, linker) { + return function(scope, iterStartElement, attr){ + var animate = $animator(attr); + var expression = attr.ngRepeat; + var match = expression.match(/^((.+)\s+from)?\s*(.+)\s+in\s+(.*)\s*$/), + hashExp, hashExpFn, hashFn, lhs, rhs, valueIdent, keyIdent, + hashFnLocals = {$hash: hashKey}; + + if (! match) { + throw Error("Expected ngRepeat in form of '(_hash_ from) _item_ in _collection_' but got '" + + expression + "'."); + } + + hashExp = match[2]; + lhs = match[3]; + rhs = match[4]; + + if (hashExp) { + hashExpFn = $parse(hashExp); + hashFn = function(key, value) { + if (keyIdent) hashFnLocals[keyIdent] = key; + hashFnLocals[valueIdent] = value; + return hashExpFn(scope, hashFnLocals); + }; } else { - array = collection || []; + hashFn = function(key, value) { + return key; + } + } + + match = lhs.match(/^(?:([\$\w]+)|\(([\$\w]+)\s*,\s*([\$\w]+)\))$/); + if (!match) { + throw Error("'item' in 'item in collection' should be identifier or (key, value) but got '" + + lhs + "'."); } + valueIdent = match[3] || match[1]; + keyIdent = match[2]; - arrayLength = array.length; + // Store a list of elements from previous run. This is a hash where key is the item from the + // iterator, and the value is objects with following properties. + // - scope: bound scope + // - element: previous element. + // - index: position + var lastOrder = new HashMap(); - // we are not using forEach for perf reasons (trying to avoid #call) - for (index = 0, length = array.length; index < length; index++) { - key = (collection === array) ? index : array[index]; - value = collection[key]; + // Store the list of item orders. Need so that we can compute moves. When an item at position + // #2 gets removed then item ot old position #3 becomes #2, but that is not considered a move + var blockHead = { + element: iterStartElement, + next: null + }, + blockRemove = function(block) { + var right = block.next; + var left = block.prev; - last = lastOrder.shift(value); + if (right) right.prev = left; + left.next = right; + }, + blockInsertAfter = function(afterBlock, newBlock) { + var right = afterBlock.next; - if (last) { - // if we have already seen this object, then we need to reuse the - // associated scope/element - childScope = last.scope; - nextOrder.push(value, last); + newBlock.next = right; + newBlock.prev = afterBlock; - if (index === last.index) { - // do nothing - cursor = last.element; - } else { - // existing item which got moved - last.index = index; - // This may be a noop, if the element is next, but I don't know of a good way to - // figure this out, since it would require extra DOM access, so let's just hope that - // the browsers realizes that it is noop, and treats it as such. - cursor.after(last.element); - cursor = last.element; - } + afterBlock.next = newBlock; + if (right) right.prev = newBlock; + }, + iterStartBlock = {element: iterStartElement, next: null, prev: null}; + + //watch props + scope.$watchProps(rhs, function ngRepeatWatch(collection){ + var index, length, + cursor = iterStartElement, // current position of the node + // Same as lastOrder but it has the current state. It will become the + // lastOrder on the next iteration. + nextOrder = new HashMap(), //use HashMap + arrayLength, + childScope, + key, value, // key/value of iteration + hashCode, + collectionKeys, + block, // last object information {scope, element, index} + blocks = [], + lastBlock = iterStartBlock; + + + if (isArray(collection)) { + collectionKeys = collection || []; } else { - // new item which we don't know about - childScope = scope.$new(); + // if object, extract keys, sort them and use to determine order of iteration over obj props + collectionKeys = []; + for (key in collection) { + if (collection.hasOwnProperty(key) && key.charAt(0) != '$') { + collectionKeys.push(key); + } + } + collectionKeys.sort(); } - childScope[valueIdent] = value; - if (keyIdent) childScope[keyIdent] = key; - childScope.$index = index; - - childScope.$first = (index === 0); - childScope.$last = (index === (arrayLength - 1)); - childScope.$middle = !(childScope.$first || childScope.$last); - - if (!last) { - linker(childScope, function(clone){ - cursor.after(clone); - last = { - scope: childScope, - element: (cursor = clone), - index: index - }; - nextOrder.push(value, last); - }); - } - } + arrayLength = collectionKeys.length; - //shrink children - for (key in lastOrder) { - if (lastOrder.hasOwnProperty(key)) { - array = lastOrder[key]; - while(array.length) { - value = array.pop(); - value.element.remove(); - value.scope.$destroy(); + // locate existing items + length = blocks.length = collectionKeys.length; + for(index = 0; index < length; index++) { + key = (collection === collectionKeys) ? index : collectionKeys[index]; + value = collection[key]; + hashCode = hashFn(key, value); + if((block = lastOrder.remove(hashCode))) { + nextOrder.put(hashCode, block); + blocks[index] = block; + } else if (nextOrder.get(hashCode)) { + // restore lastOrder + forEach(blocks, function(block) { + if (block && block.element) lastOrder.put(block.hash, block); + }); + // This is a duplicate and we need to throw an error + throw new Error('Duplicate hashes in the repeater are not allowed.'); + } else { + // new never before seen block + blocks[index] = { hash: hashCode }; + } + } + + // remove existing items + for (key in lastOrder) { + if (lastOrder.hasOwnProperty(key)) { + block = lastOrder[key]; + animate.leave(block.element); + block.scope.$destroy(); + blockRemove(block); } } - } - lastOrder = nextOrder; - }); - }; - } -}); + // we are not using forEach for perf reasons (trying to avoid #call) + for (index = 0, length = collectionKeys.length; index < length; index++) { + key = (collection === collectionKeys) ? index : collectionKeys[index]; + value = collection[key]; + block = blocks[index]; + + if (block.element) { + // if we have already seen this object, then we need to reuse the + // associated scope/element + childScope = block.scope; + + if (block.element == cursor) { + // do nothing + cursor = block.element; + } else { + // existing item which got moved + blockRemove(block); + blockInsertAfter(lastBlock, block); + animate.move(block.element, null, cursor || iterStartElement); + cursor = block.element; + } + } else { + // new item which we don't know about + childScope = scope.$new(); + } + + childScope[valueIdent] = value; + if (keyIdent) childScope[keyIdent] = key; + childScope.$index = index; + childScope.$first = (index === 0); + childScope.$last = (index === (arrayLength - 1)); + childScope.$middle = !(childScope.$first || childScope.$last); + + if (!block.element) { + linker(childScope, function(clone){ + animate.enter(clone, null, cursor || iterStartElement); + cursor = clone; + block.scope = childScope; + block.element = clone; + blockInsertAfter(lastBlock, block); + nextOrder.put(block.hash, block); + }); + } + } + if (block) { + block.next = null; + } else { + iterStartBlock.next = null; + } + lastOrder = nextOrder; + }); + }; + } + }; +}]; diff --git a/src/ng/directive/ngShowHide.js b/src/ng/directive/ngShowHide.js index 74195468915b..e45835a414b5 100644 --- a/src/ng/directive/ngShowHide.js +++ b/src/ng/directive/ngShowHide.js @@ -6,7 +6,18 @@ * * @description * The `ngShow` and `ngHide` directives show or hide a portion of the DOM tree (HTML) - * conditionally. + * conditionally based on "truthy" values evaluated within an {expression}. In other + * words, if the expression assigned to ngShow evaluates to a true value then the element is set to visible + * (via `display:block` in css) and if false then the element is set to hidden (so display:none). + * With ngHide this is the reverse whereas true values cause the element itself to become + * hidden. + * + * Additionally, you can also provide animations via the ngAnimate attribute to animate the show + * and hide effects. + * + * @animations + * show - happens after the ngShow expression evaluates to a truthy value and the contents are set to visible + * hide - happens before the ngShow expression evaluates to a non truthy value and just before the contents are set to hidden * * @element ANY * @param {expression} ngShow If the {@link guide/expression expression} is truthy @@ -33,11 +44,14 @@ */ //TODO(misko): refactor to remove element from the DOM -var ngShowDirective = ngDirective(function(scope, element, attr){ - scope.$watch(attr.ngShow, function ngShowWatchAction(value){ - element.css('display', toBoolean(value) ? '' : 'none'); - }); -}); +var ngShowDirective = ['$animator', function($animator) { + return function(scope, element, attr) { + var animate = $animator(attr); + scope.$watch(attr.ngShow, function ngShowWatchAction(value){ + animate[toBoolean(value) ? 'show' : 'hide'](element); + }); + }; +}]; /** @@ -45,8 +59,19 @@ var ngShowDirective = ngDirective(function(scope, element, attr){ * @name ng.directive:ngHide * * @description - * The `ngHide` and `ngShow` directives hide or show a portion of the DOM tree (HTML) - * conditionally. + * The `ngShow` and `ngHide` directives show or hide a portion of the DOM tree (HTML) + * conditionally based on "truthy" values evaluated within an {expression}. In other + * words, if the expression assigned to ngShow evaluates to a true value then the element is set to visible + * (via `display:block` in css) and if false then the element is set to hidden (so display:none). + * With ngHide this is the reverse whereas true values cause the element itself to become + * hidden. + * + * Additionally, you can also provide animations via the ngAnimate attribute to animate the show + * and hide effects. + * + * @animations + * show - happens after the ngHide expression evaluates to a non truthy value and the contents are set to visible + * hide - happens after the ngHide expression evaluates to a truthy value and just before the contents are set to hidden * * @element ANY * @param {expression} ngHide If the {@link guide/expression expression} is truthy then @@ -73,8 +98,11 @@ var ngShowDirective = ngDirective(function(scope, element, attr){ */ //TODO(misko): refactor to remove element from the DOM -var ngHideDirective = ngDirective(function(scope, element, attr){ - scope.$watch(attr.ngHide, function ngHideWatchAction(value){ - element.css('display', toBoolean(value) ? 'none' : ''); - }); -}); +var ngHideDirective = ['$animator', function($animator) { + return function(scope, element, attr) { + var animate = $animator(attr); + scope.$watch(attr.ngHide, function ngHideWatchAction(value){ + animate[toBoolean(value) ? 'hide' : 'show'](element); + }); + }; +}]; diff --git a/src/ng/directive/ngSwitch.js b/src/ng/directive/ngSwitch.js index 88b1e70130fa..fe712608f23e 100644 --- a/src/ng/directive/ngSwitch.js +++ b/src/ng/directive/ngSwitch.js @@ -6,21 +6,36 @@ * @restrict EA * * @description - * Conditionally change the DOM structure. Elements within ngSwitch but without - * ngSwitchWhen or ngSwitchDefault directives will be preserved at the location - * as specified in the template + * The ngSwitch directive is used to conditionally swap DOM structure on your template based on a scope expression. + * Elements within ngSwitch but without ngSwitchWhen or ngSwitchDefault directives will be preserved at the location + * as specified in the template. + * + * The directive itself works similar to ngInclude, however, instead of downloading template code (or loading it + * from the template cache), ngSwitch simply choses one of the nested elements and makes it visible based on which element + * matches the value obtained from the evaluated expression. In other words, you define a container element + * (where you place the directive), place an expression on the on="..." attribute + * (or the ng-switch="..." attribute), define any inner elements inside of the directive and place + * a when attribute per element. The when attribute is used to inform ngSwitch which element to display when the on + * expression is evaluated. If a matching expression is not found via a when attribute then an element with the default + * attribute is displayed. + * + * Additionally, you can also provide animations via the ngAnimate attribute to animate the enter + * and leave effects. + * + * @animations + * enter - happens after the ngSwtich contents change and the matched child element is placed inside the container + * leave - happens just after the ngSwitch contents change and just before the former contents are removed from the DOM * * @usageContent * ... * ... - * ... * ... * ... * * @scope * @param {*} ngSwitch|on expression to match against ng-switch-when. * @paramDescription - * On child elments add: + * On child elements add: * * * `ngSwitchWhen`: the case statement to match against. If match then this * case will be displayed. If the same match appears multiple times, all the @@ -66,43 +81,48 @@ */ -var NG_SWITCH = 'ng-switch'; -var ngSwitchDirective = valueFn({ - restrict: 'EA', - require: 'ngSwitch', - // asks for $scope to fool the BC controller module - controller: ['$scope', function ngSwitchController() { - this.cases = {}; - }], - link: function(scope, element, attr, ctrl) { - var watchExpr = attr.ngSwitch || attr.on, - selectedTranscludes, - selectedElements, - selectedScopes = []; +var ngSwitchDirective = ['$animator', function($animator) { + return { + restrict: 'EA', + require: 'ngSwitch', - scope.$watch(watchExpr, function ngSwitchWatchAction(value) { - for (var i= 0, ii=selectedScopes.length; ienter + * and leave effects. + * + * @animations + * enter - happens just after the ngView contents are changed (when the new view DOM element is inserted into the DOM) + * leave - happens just after the current ngView contents change and just before the former contents are removed from the DOM + * * @scope * @example @@ -105,15 +112,16 @@ * Emitted every time the ngView content is reloaded. */ var ngViewDirective = ['$http', '$templateCache', '$route', '$anchorScroll', '$compile', - '$controller', + '$controller', '$animator', function($http, $templateCache, $route, $anchorScroll, $compile, - $controller) { + $controller, $animator) { return { restrict: 'ECA', terminal: true, link: function(scope, element, attr) { var lastScope, - onloadExp = attr.onload || ''; + onloadExp = attr.onload || '', + animate = $animator(attr); scope.$on('$routeChangeSuccess', update); update(); @@ -127,7 +135,7 @@ var ngViewDirective = ['$http', '$templateCache', '$route', '$anchorScroll', '$c } function clearContent() { - element.html(''); + animate.leave(element.contents(), element); destroyLastScope(); } @@ -136,8 +144,8 @@ var ngViewDirective = ['$http', '$templateCache', '$route', '$anchorScroll', '$c template = locals && locals.$template; if (template) { - element.html(template); - destroyLastScope(); + clearContent(); + animate.enter(jqLite('
').html(template).contents(), element); var link = $compile(element.contents()), current = $route.current, diff --git a/src/ng/rootScope.js b/src/ng/rootScope.js index 5981b7e72975..445b21a389cb 100644 --- a/src/ng/rootScope.js +++ b/src/ng/rootScope.js @@ -320,6 +320,145 @@ function $RootScopeProvider(){ }; }, + + /** + * @ngdoc function + * @name ng.$rootScope.Scope#$watchProps + * @methodOf ng.$rootScope.Scope + * @function + * + * @description + * Shallow watches the properties of an object and fires whenever any of the properties change + * (for arrays this implies watching the array items, for object maps this implies watching the properties). + * If a change is detected the `listener` callback is fired. + * + * - The `obj` object is observed via standard $watch operation and is examined on every call to $digest() to + * see if anything has been modified within it's contents. + * - The `listener` is called whenever anything within the `obj` has changed. Examples include adding new items + * into the object or array, removing and moving items around. + * + * + * # Example + *
+          $scope.names = ['igor', 'matias', 'misko', 'james'];
+          $scope.dataCount = 4;
+
+          $scope.$watchProps('names', function(newNames, oldNames) {
+            $scope.dataCount = newNames.length;
+          });
+
+          expect($scope.dataCount).toEqual(4);
+          $scope.$digest();
+
+          //still at 4 ... no changes
+          expect($scope.dataCount).toEqual(4);
+
+          $scope.names.pop();
+          $scope.$digest();
+
+          //now there's been a change
+          expect($scope.dataCount).toEqual(3);
+       * 
+ * + * + * @param {string} watchExpression evaluated as {@link guide/expression expression}. The expression value should evaluate to + * an object or an array which is observed on each {@link ng.$rootScope.Scope#$digest $digest} cycle. + * Any change within the `obj` collection will trigger call to the `listener`. + * + * @param {function(newCollection, oldCollection)} listener a callback function that is fired with both + * the `newCollection` and `oldCollection` as parameters. + * The `newCollection` object is the newly modified data obtained from the `obj` expression and the `oldCollection` + * object is a copy of the former collection data. + * + * @returns {function()} Returns a de-registration function for this listener. When the de-registration function is executed + * then the internal watch operation is terminated. + */ + $watchProps: function(obj, listener) { + var self = this; + var oldValue; + var newValue; + var changeDetected = 0; + var objGetter = $parse(obj); + var myArray = []; + var myObject = []; + var oldLength = 0; + + return this.$watch( + function() { + newValue = objGetter(self); + var newLength, key; + + if (!newValue || typeof newValue !== 'object') { + if (oldValue !== newValue) { + oldValue = newValue; + changeDetected++; + } + } else if (isArray(newValue)) { + if (oldValue != myArray) { + // we are transitioning from something which was not an array into array. + oldValue = myArray; + oldLength = oldValue.length = 0; + changeDetected++; + } + + newLength = newValue.length; + + if (oldLength !== newLength) { + // if counts do not match we need to render + changeDetected++; + oldValue.length = oldLength = newLength; + } + // copy the items to oldValue and look for changes. + for (var i = 0; i < newLength; i++) { + if (oldValue[i] !== newValue[i]) { + changeDetected++; + oldValue[i] = newValue[i]; + } + } + } else { + if (oldValue != myObject) { + // we are transitioning from something which was not an object into object. + oldValue = myObject = {}; + oldLength = 0; + changeDetected++; + } + // copy the items to oldValue and look for changes. + newLength = 0; + for (key in newValue) { + if (newValue.hasOwnProperty(key)) { + newLength++; + if (oldValue.hasOwnProperty(key)) { + if (oldValue[key] !== newValue[key]) { + changeDetected++; + oldValue[key] = newValue[key]; + } + } else { + oldLength++; + oldValue[key] = newValue[key]; + changeDetected++; + } + } + } + if (oldLength > newLength) { + // we used to have more keys, need to find them and destroy them. + changeDetected++; + for(key in oldValue) { + if (oldValue.hasOwnProperty(key)) { + if (!newValue.hasOwnProperty(key)) { + oldLength--; + delete oldValue[key]; + } + } + } + } + } + return changeDetected; + }, + function() { + listener(newValue, oldValue, self); + }); + }, + /** * @ngdoc function * @name ng.$rootScope.Scope#$digest diff --git a/src/ng/sniffer.js b/src/ng/sniffer.js index 9342fbd51b57..594b9fb68c02 100644 --- a/src/ng/sniffer.js +++ b/src/ng/sniffer.js @@ -16,8 +16,23 @@ function $SnifferProvider() { this.$get = ['$window', '$document', function($window, $document) { var eventSupport = {}, - android = int((/android (\d+)/.exec(lowercase($window.navigator.userAgent)) || [])[1]), - document = $document[0]; + android = int((/android (\d+)/.exec(lowercase(($window.navigator || {}).userAgent)) || [])[1]), + document = $document[0] || {}, + vendorPrefix, + vendorRegex = /^(Moz|webkit|O|ms)(?=[A-Z])/, + bodyStyle = document.body && document.body.style, + transitions = false; + + if (bodyStyle) { + for(var prop in bodyStyle) { + if(vendorRegex.test(prop)) { + vendorPrefix = prop.match(vendorRegex)[0]; + break; + } + } + transitions = !!(vendorPrefix + 'Transition' in bodyStyle); + } + return { // Android has history.pushState, but it does not update location correctly @@ -41,7 +56,9 @@ function $SnifferProvider() { return eventSupport[event]; }, - csp: document.securityPolicy ? document.securityPolicy.isActive : false + csp: document.securityPolicy ? document.securityPolicy.isActive : false, + vendorPrefix: vendorPrefix, + supportsTransitions : transitions }; }]; } diff --git a/src/ngMock/angular-mocks.js b/src/ngMock/angular-mocks.js index f452cd057a3e..df2a7a4ac8dd 100644 --- a/src/ngMock/angular-mocks.js +++ b/src/ngMock/angular-mocks.js @@ -587,6 +587,31 @@ angular.mock.$LogProvider = function() { angular.mock.TzDate.prototype = Date.prototype; })(); +// TODO(misko): document +angular.mock.createMockWindow = function() { + var mockWindow = {}; + var setTimeoutQueue = []; + + mockWindow.document = window.document; + mockWindow.getComputedStyle = angular.bind(window, window.getComputedStyle); + mockWindow.scrollTo = angular.bind(window, window.scrollTo); + mockWindow.navigator = window.navigator; + mockWindow.setTimeout = function(fn, delay) { + setTimeoutQueue.push({fn: fn, delay: delay}); + }; + mockWindow.setTimeout.queue = []; + mockWindow.setTimeout.expect = function(delay) { + expect(setTimeoutQueue.length > 0).toBe(true); + expect(delay).toEqual(setTimeoutQueue[0].delay); + return { + process: function() { + setTimeoutQueue.shift().fn(); + } + }; + }; + + return mockWindow; +}; /** * @ngdoc function diff --git a/test/ng/animationSpec.js b/test/ng/animationSpec.js new file mode 100644 index 000000000000..86592643842c --- /dev/null +++ b/test/ng/animationSpec.js @@ -0,0 +1,15 @@ +'use strict'; + +describe('$animation', function() { + + it('should allow animation registration', function() { + var noopCustom = function(){}; + module(function($animationProvider) { + $animationProvider.register('noop-custom', valueFn(noopCustom)); + }); + inject(function($animation) { + expect($animation('noop-custom')).toBe(noopCustom); + }); + }); + +}); diff --git a/test/ng/animatorSpec.js b/test/ng/animatorSpec.js new file mode 100644 index 000000000000..97463d17c1a9 --- /dev/null +++ b/test/ng/animatorSpec.js @@ -0,0 +1,238 @@ +'use strict'; + +describe("$animator", function() { + + var element; + + afterEach(function(){ + dealoc(element); + }); + + describe("when not defined", function() { + var child, after, window, animator; + + beforeEach(function() { + module(function($animationProvider, $provide) { + $provide.value('$window', window = angular.mock.createMockWindow()); + }) + inject(function($animator, $compile, $rootScope) { + animator = $animator({}); + element = $compile('
')($rootScope); + }) + }); + + it("should properly animate the enter animation event", inject(function($animator, $compile, $rootScope) { + var child = $compile('
')($rootScope); + expect(element.contents().length).toBe(0); + animator.enter(child, element); + expect(element.contents().length).toBe(1); + })); + + it("should properly animate the leave animation event", inject(function($animator, $compile, $rootScope) { + var child = $compile('
')($rootScope); + element.append(child); + expect(element.contents().length).toBe(1); + animator.leave(child, element); + expect(element.contents().length).toBe(0); + })); + + it("should properly animate the move animation event", inject(function($animator, $compile, $rootScope) { + var child1 = $compile('
1
')($rootScope); + var child2 = $compile('
2
')($rootScope); + element.append(child1); + element.append(child2); + expect(element.text()).toBe('12'); + animator.move(child1, element, child2); + expect(element.text()).toBe('21'); + })); + + it("should properly animate the show animation event", inject(function($animator, $compile, $rootScope) { + element.css('display','none'); + expect(element.css('display')).toBe('none'); + animator.show(element); + expect(element.css('display')).toBe('block'); + })); + + it("should properly animate the hide animation event", inject(function($animator, $compile, $rootScope) { + element.css('display','block'); + expect(element.css('display')).toBe('block'); + animator.hide(element); + expect(element.css('display')).toBe('none'); + })); + + it("should silently run the custom animation", inject(function($animator, $compile, $rootScope) { + animator.animate('custom', element); + })); + + }); + + describe("when defined", function() { + + var child, after, window, animator; + + beforeEach(function() { + module(function($animationProvider, $provide) { + $provide.value('$window', window = angular.mock.createMockWindow()); + $animationProvider.register('custom', function() { + return function(element, done) { + done(); + } + }); + }) + inject(function($animator, $compile, $rootScope) { + element = $compile('
')($rootScope); + child = $compile('
')($rootScope); + after = $compile('
')($rootScope); + }) + }); + + it("should properly animate the enter animation event", inject(function($animator, $compile, $rootScope) { + animator = $animator({ + ngAnimate : 'enter: custom' + }); + expect(element.contents().length).toBe(0); + animator.enter(child, element); + window.setTimeout.expect(1).process(); + window.setTimeout.expect(0).process(); + })); + + it("should properly animate the leave animation event", inject(function($animator, $compile, $rootScope) { + animator = $animator({ + ngAnimate : 'leave: custom' + }); + element.append(child); + expect(element.contents().length).toBe(1); + animator.leave(child, element); + window.setTimeout.expect(1).process(); + window.setTimeout.expect(0).process(); + expect(element.contents().length).toBe(0); + })); + + it("should properly animate the move animation event", inject(function($animator, $compile, $rootScope) { + animator = $animator({ + ngAnimate : 'move: custom' + }); + var child1 = $compile('
1
')($rootScope); + var child2 = $compile('
2
')($rootScope); + element.append(child1); + element.append(child2); + expect(element.text()).toBe('12'); + animator.move(child1, element, child2); + expect(element.text()).toBe('21'); + window.setTimeout.expect(1).process(); + window.setTimeout.expect(0).process(); + })); + + it("should properly animate the show animation event", inject(function($animator, $compile, $rootScope) { + animator = $animator({ + ngAnimate : 'show: custom' + }); + element.css('display','none'); + expect(element.css('display')).toBe('none'); + animator.show(element); + expect(element.css('display')).toBe('block'); + window.setTimeout.expect(1).process(); + window.setTimeout.expect(0).process(); + expect(element.css('display')).toBe('block'); + })); + + it("should properly animate the hide animation event", inject(function($animator, $compile, $rootScope) { + animator = $animator({ + ngAnimate : 'hide: custom' + }); + element.css('display','block'); + expect(element.css('display')).toBe('block'); + animator.hide(element); + expect(element.css('display')).toBe('block'); + window.setTimeout.expect(1).process(); + window.setTimeout.expect(0).process(); + expect(element.css('display')).toBe('none'); + })); + + it("should silently run the custom animation", inject(function($animator, $compile, $rootScope) { + animator = $animator({ + ngAnimate : 'custom: custom' + }); + animator.animate('custom', element); + window.setTimeout.expect(1).process(); + window.setTimeout.expect(0).process(); + })); + + it("should assign the ngAnimate string to all events if a string is given", inject(function($animator, $compile, $rootScope) { + animator = $animator({ + ngAnimate : 'custom' + }); + + //enter + animator.enter(child, element); + expect(child.attr('class')).toContain('custom-enter-setup'); + window.setTimeout.expect(1).process(); + expect(child.attr('class')).toContain('custom-enter-start'); + window.setTimeout.expect(0).process(); + + //leave + element.append(after); + animator.move(child, element, after); + expect(child.attr('class')).toContain('custom-move-setup'); + window.setTimeout.expect(1).process(); + expect(child.attr('class')).toContain('custom-move-start'); + window.setTimeout.expect(0).process(); + + //hide + animator.hide(child); + expect(child.attr('class')).toContain('custom-hide-setup'); + window.setTimeout.expect(1).process(); + expect(child.attr('class')).toContain('custom-hide-start'); + window.setTimeout.expect(0).process(); + + //show + animator.show(child); + expect(child.attr('class')).toContain('custom-show-setup'); + window.setTimeout.expect(1).process(); + expect(child.attr('class')).toContain('custom-show-start'); + window.setTimeout.expect(0).process(); + + //leave + animator.leave(child); + expect(child.attr('class')).toContain('custom-leave-setup'); + window.setTimeout.expect(1).process(); + expect(child.attr('class')).toContain('custom-leave-start'); + window.setTimeout.expect(0).process(); + + //custom + animator.animate('custom', child); + expect(child.attr('class')).toContain('custom-setup'); + window.setTimeout.expect(1).process(); + expect(child.attr('class')).toContain('custom-start'); + window.setTimeout.expect(0).process(); + })); + }); + + it("should fire off any custom animations", function() { + + var window; + + module(function($animationProvider, $provide) { + $provide.value('$window', window = angular.mock.createMockWindow()); + $animationProvider.register('custom', function() { + return { + setup : function() { }, + start : function(element, done) { + element.addClass('i-was-here'); + done(); + } + } + }) + }); + + inject(function($animator, $compile, $rootScope) { + var animator = $animator({ ngAnimate: 'customAni: custom' }); + element = $compile('
')($rootScope); + animator.animate('customAni', element); + window.setTimeout.expect(1).process(); + expect(element.hasClass('i-was-here')).toBe(true); + }); + + }); + +}); diff --git a/test/ng/directive/ngAnimateSpec.js b/test/ng/directive/ngAnimateSpec.js new file mode 100644 index 000000000000..1dff830631f6 --- /dev/null +++ b/test/ng/directive/ngAnimateSpec.js @@ -0,0 +1,22 @@ +'use strict'; + +xdescribe("ngAnimate", function() { + + var element; + + afterEach(function(){ + dealoc(element); + }); + + it("should throw an error when an invalid ng-animate syntax is provided", inject(function($compile, $rootScope) { + var html = '
'; + try { + element = $compile(html)($rootScope); + expect(false).toBe(true); //throw an error + } + catch(e) { + expect(e).toMatch(/Expected ngAnimate in form of/i); + } + })); + +}); diff --git a/test/ng/directive/ngIncludeSpec.js b/test/ng/directive/ngIncludeSpec.js index 7c94a70ec5ba..03a6223078bb 100644 --- a/test/ng/directive/ngIncludeSpec.js +++ b/test/ng/directive/ngIncludeSpec.js @@ -282,3 +282,104 @@ describe('ngInclude', function() { })); }); }); + +describe('ngInclude ngAnimate', function() { + var element, vendorPrefix, window; + + beforeEach(module(function($animationProvider, $provide) { + $provide.value('$window', window = angular.mock.createMockWindow()); + return function($sniffer) { + vendorPrefix = '-' + $sniffer.vendorPrefix.toLowerCase() + '-'; + }; + })); + + afterEach(function(){ + dealoc(element); + }); + + it('should fire off the enter animation + add and remove the css classes', + inject(function($compile, $rootScope, $templateCache, $sniffer) { + + $templateCache.put('enter', [200, '
data
', {}]); + $rootScope.tpl = 'enter'; + element = $compile( + '
' + + '
' + )($rootScope); + $rootScope.$digest(); + + //if we add the custom css stuff here then it will get picked up before the animation takes place + var child = jqLite(element.children()[0]); + var cssProp = vendorPrefix + '-transition'; + var cssValue = '1s linear all'; + child.css(cssProp, cssValue); + + expect(child.attr('class')).toContain('custom-enter-setup'); + window.setTimeout.expect(1).process(); + + expect(child.attr('class')).toContain('custom-enter-start'); + window.setTimeout.expect(1000).process(); + + expect(child.attr('class')).not.toContain('custom-enter-setup'); + expect(child.attr('class')).not.toContain('custom-enter-start'); + })); + + it('should fire off the leave animation + add and remove the css classes', + inject(function($compile, $rootScope, $templateCache, $sniffer) { + $templateCache.put('enter', [200, '
data
', {}]); + $rootScope.tpl = 'enter'; + element = $compile( + '
' + + '
' + )($rootScope); + $rootScope.$digest(); + + //if we add the custom css stuff here then it will get picked up before the animation takes place + var child = jqLite(element.children()[0]); + var cssProp = vendorPrefix + '-transition'; + var cssValue = '1s linear all'; + child.css(cssProp, cssValue); + + $rootScope.tpl = ''; + $rootScope.$digest(); + + expect(child.attr('class')).toContain('custom-leave-setup'); + window.setTimeout.expect(1).process(); + + expect(child.attr('class')).toContain('custom-leave-start'); + window.setTimeout.expect(1000).process(); + + expect(child.attr('class')).not.toContain('custom-leave-setup'); + expect(child.attr('class')).not.toContain('custom-leave-start'); + })); + + it('should catch and use the correct duration for animation', + inject(function($compile, $rootScope, $templateCache, $sniffer) { + $templateCache.put('enter', [200, '
data
', {}]); + $rootScope.tpl = 'enter'; + element = $compile( + '
' + + '
' + )($rootScope); + $rootScope.$digest(); + + //if we add the custom css stuff here then it will get picked up before the animation takes place + var child = jqLite(element.children()[0]); + var cssProp = vendorPrefix + '-transition'; + var cssValue = '0.5s linear all'; + child.css(cssProp, cssValue); + + $rootScope.tpl = 'enter'; + $rootScope.$digest(); + + window.setTimeout.expect(1).process(); + window.setTimeout.expect(500).process(); + })); + +}); diff --git a/test/ng/directive/ngRepeatSpec.js b/test/ng/directive/ngRepeatSpec.js index 33e4dcfd0464..d896d45db341 100644 --- a/test/ng/directive/ngRepeatSpec.js +++ b/test/ng/directive/ngRepeatSpec.js @@ -1,16 +1,27 @@ 'use strict'; describe('ngRepeat', function() { - var element, $compile, scope; + var element, $compile, scope, $exceptionHandler; - beforeEach(inject(function(_$compile_, $rootScope) { + beforeEach(module(function($exceptionHandlerProvider) { + $exceptionHandlerProvider.mode('log'); + })); + + beforeEach(inject(function(_$compile_, $rootScope, _$exceptionHandler_) { $compile = _$compile_; + $exceptionHandler = _$exceptionHandler_; scope = $rootScope.$new(); })); - afterEach(function(){ + afterEach(function() { + if ($exceptionHandler.errors.length) { + dump(jasmine.getEnv().currentSpec.getFullName()); + dump('$exceptionHandler has errors'); + dump($exceptionHandler.errors); + expect(true).toBe(false); + } dealoc(element); }); @@ -199,19 +210,19 @@ describe('ngRepeat', function() { it('should error on wrong parsing of ngRepeat', function() { - expect(function() { - element = jqLite('
'); - $compile(element)(scope); - }).toThrow("Expected ngRepeat in form of '_item_ in _collection_' but got 'i dont parse'."); + element = jqLite('
'); + $compile(element)(scope); + expect($exceptionHandler.errors.shift()[0].message). + toEqual("Expected ngRepeat in form of '(_hash_ from) _item_ in _collection_' but got 'i dont parse'."); }); it("should throw error when left-hand-side of ngRepeat can't be parsed", function() { - expect(function() { element = jqLite('
'); $compile(element)(scope); - }).toThrow("'item' in 'item in collection' should be identifier or (key, value) but got " + - "'i dont parse'."); + expect($exceptionHandler.errors.shift()[0].message). + toEqual("'item' in 'item in collection' should be identifier or (key, value) but got " + + "'i dont parse'."); }); @@ -311,7 +322,7 @@ describe('ngRepeat', function() { it('should ignore $ and $$ properties', function() { element = $compile('
  • {{i}}|
')(scope); scope.items = ['a', 'b', 'c']; - scope.items.$$hashkey = 'xxx'; + scope.items.$$hashKey = 'xxx'; scope.items.$root = 'yyy'; scope.$digest(); @@ -370,7 +381,7 @@ describe('ngRepeat', function() { beforeEach(function() { element = $compile( '
    ' + - '
  • {{key}}:{{val}}|>
  • ' + + '
  • {{key}}:{{val}}|>
  • ' + '
')(scope); a = {}; b = {}; @@ -393,43 +404,23 @@ describe('ngRepeat', function() { }); - it('should support duplicates', function() { - scope.items = [a, a, b, c]; + it('should throw error on duplicates and recover', function() { + scope.items = [a, a, a]; scope.$digest(); - var newElements = element.find('li'); - expect(newElements[0]).toEqual(lis[0]); - expect(newElements[1]).not.toEqual(lis[0]); - expect(newElements[2]).toEqual(lis[1]); - expect(newElements[3]).toEqual(lis[2]); + expect($exceptionHandler.errors.shift().message). + toEqual('Duplicate hashes in the repeater are not allowed.'); - lis = newElements; + // recover + scope.items = [a]; scope.$digest(); - newElements = element.find('li'); + var newElements = element.find('li'); + expect(newElements.length).toEqual(1); expect(newElements[0]).toEqual(lis[0]); - expect(newElements[1]).toEqual(lis[1]); - expect(newElements[2]).toEqual(lis[2]); - expect(newElements[3]).toEqual(lis[3]); - scope.$digest(); - newElements = element.find('li'); - expect(newElements[0]).toEqual(lis[0]); - expect(newElements[1]).toEqual(lis[1]); - expect(newElements[2]).toEqual(lis[2]); - expect(newElements[3]).toEqual(lis[3]); - }); - - - it('should remove last item when one duplicate instance is removed', function() { - scope.items = [a, a, a]; - scope.$digest(); - lis = element.find('li'); - - scope.items = [a, a]; + scope.items = []; scope.$digest(); var newElements = element.find('li'); - expect(newElements.length).toEqual(2); - expect(newElements[0]).toEqual(lis[0]); - expect(newElements[1]).toEqual(lis[1]); + expect(newElements.length).toEqual(0); }); @@ -455,6 +446,7 @@ describe('ngRepeat', function() { scope.items = ['hello', 'cau', 'ahoj']; scope.$digest(); lis = element.find('li'); + lis[2].id = 'yes'; scope.items = ['ahoj', 'hello', 'cau']; scope.$digest(); @@ -466,3 +458,177 @@ describe('ngRepeat', function() { }); }); }); + +describe('ngRepeat ngAnimate', function() { + var element, vendorPrefix, window; + + beforeEach(module(function($animationProvider, $provide) { + $provide.value('$window', window = angular.mock.createMockWindow()); + return function($sniffer) { + vendorPrefix = '-' + $sniffer.vendorPrefix.toLowerCase() + '-'; + }; + })); + + afterEach(function(){ + dealoc(element); + }); + + it('should fire off the enter animation + add and remove the css classes', + inject(function($compile, $rootScope, $sniffer) { + + element = $compile( + '
' + + '{{ item }}' + + '
' + )($rootScope); + + $rootScope.items = ['1','2','3']; + $rootScope.$digest(); + + //if we add the custom css stuff here then it will get picked up before the animation takes place + var cssProp = vendorPrefix + '-transition'; + var cssValue = '1s linear all'; + var kids = element.children(); + for(var i=0;i
' + + '{{ item }}' + + '
' + )($rootScope); + + $rootScope.items = ['1','2','3']; + $rootScope.$digest(); + + //if we add the custom css stuff here then it will get picked up before the animation takes place + var cssProp = vendorPrefix + '-transition'; + var cssValue = '1s linear all'; + var kids = element.children(); + for(var i=0;i
' + + '{{ item }}' + + '
' + )($rootScope); + + $rootScope.items = ['1','2','3']; + $rootScope.$digest(); + + //if we add the custom css stuff here then it will get picked up before the animation takes place + var cssProp = '-' + $sniffer.vendorPrefix + '-transition'; + var cssValue = '1s linear all'; + var kids = element.children(); + for(var i=0;i
' + + '{{ item }}' + + '
' + )($rootScope); + + $rootScope.items = ['a','b']; + $rootScope.$digest(); + + //if we add the custom css stuff here then it will get picked up before the animation takes place + var kids = element.children(); + var first = jqLite(kids[0]); + var second = jqLite(kids[1]); + var cssProp = '-' + $sniffer.vendorPrefix + '-transition'; + var cssValue = '0.5s linear all'; + first.css(cssProp, cssValue); + second.css(cssProp, cssValue); + + window.setTimeout.expect(1).process(); + window.setTimeout.expect(1).process(); + window.setTimeout.expect(500).process(); + window.setTimeout.expect(500).process(); + })); + +}); diff --git a/test/ng/directive/ngShowHideSpec.js b/test/ng/directive/ngShowHideSpec.js index ee251dbf2dca..61eebc5431a9 100644 --- a/test/ng/directive/ngShowHideSpec.js +++ b/test/ng/directive/ngShowHideSpec.js @@ -41,3 +41,87 @@ describe('ngShow / ngHide', function() { })); }); }); + +describe('ngShow / ngHide - ngAnimate', function() { + var element, window; + var vendorPrefix; + + beforeEach(module(function($animationProvider, $provide) { + $provide.value('$window', window = angular.mock.createMockWindow()); + return function($sniffer) { + vendorPrefix = '-' + $sniffer.vendorPrefix.toLowerCase() + '-'; + }; + })); + + afterEach(function() { + dealoc(element); + }); + + describe('ngShow', function() { + it('should fire off the animator.show and animator.hide animation', inject(function($compile, $rootScope) { + var $scope = $rootScope.$new(); + $scope.on = true; + element = $compile( + '
' + + '
' + )($scope); + $scope.$digest(); + + expect(element.attr('class')).toContain('custom-show-setup'); + window.setTimeout.expect(1).process(); + + expect(element.attr('class')).toContain('custom-show-start'); + window.setTimeout.expect(1000).process(); + + expect(element.attr('class')).not.toContain('custom-show-start'); + expect(element.attr('class')).not.toContain('custom-show-setup'); + + $scope.on = false; + $scope.$digest(); + expect(element.attr('class')).toContain('custom-hide-setup'); + window.setTimeout.expect(1).process(); + expect(element.attr('class')).toContain('custom-hide-start'); + window.setTimeout.expect(1000).process(); + + expect(element.attr('class')).not.toContain('custom-hide-start'); + expect(element.attr('class')).not.toContain('custom-hide-setup'); + })); + }); + + describe('ngHide', function() { + it('should fire off the animator.show and animator.hide animation', inject(function($compile, $rootScope) { + var $scope = $rootScope.$new(); + $scope.off = true; + element = $compile( + '
' + + '
' + )($scope); + $scope.$digest(); + + expect(element.attr('class')).toContain('custom-hide-setup'); + window.setTimeout.expect(1).process(); + + expect(element.attr('class')).toContain('custom-hide-start'); + window.setTimeout.expect(1000).process(); + + expect(element.attr('class')).not.toContain('custom-hide-start'); + expect(element.attr('class')).not.toContain('custom-hide-setup'); + + $scope.off = false; + $scope.$digest(); + expect(element.attr('class')).toContain('custom-show-setup'); + window.setTimeout.expect(1).process(); + expect(element.attr('class')).toContain('custom-show-start'); + window.setTimeout.expect(1000).process(); + + expect(element.attr('class')).not.toContain('custom-show-start'); + expect(element.attr('class')).not.toContain('custom-show-setup'); + })); + }); +}); diff --git a/test/ng/directive/ngSwitchSpec.js b/test/ng/directive/ngSwitchSpec.js index 85240b19bd5a..e6e88e4c73d9 100644 --- a/test/ng/directive/ngSwitchSpec.js +++ b/test/ng/directive/ngSwitchSpec.js @@ -213,3 +213,102 @@ describe('ngSwitch', function() { // afterwards a global afterEach will check for leaks in jq data cache object })); }); + +describe('ngSwitch ngAnimate', function() { + var element, vendorPrefix, window; + + beforeEach(module(function($animationProvider, $provide) { + $provide.value('$window', window = angular.mock.createMockWindow()); + return function($sniffer) { + vendorPrefix = '-' + $sniffer.vendorPrefix.toLowerCase() + '-'; + }; + })); + + afterEach(function(){ + dealoc(element); + }); + + it('should fire off the enter animation + set and remove the classes', + inject(function($compile, $rootScope, $sniffer) { + var $scope = $rootScope.$new(); + var style = vendorPrefix + 'transition: 1s linear all'; + element = $compile( + '
' + + '
one
' + + '
two
' + + '
three
' + + '
' + )($scope); + + $scope.val = 'one'; + $scope.$digest(); + + expect(element.children().length).toBe(1); + var first = element.children()[0]; + + expect(first.className).toContain('cool-enter-setup'); + window.setTimeout.expect(1).process(); + + expect(first.className).toContain('cool-enter-start'); + window.setTimeout.expect(1000).process(); + + expect(first.className).not.toContain('cool-enter-setup'); + expect(first.className).not.toContain('cool-enter-start'); + })); + + + it('should fire off the leave animation + set and remove the classes', + inject(function($compile, $rootScope, $sniffer) { + + var $scope = $rootScope.$new(); + var style = vendorPrefix + 'transition: 1s linear all'; + element = $compile( + '
' + + '
one
' + + '
two
' + + '
three
' + + '
' + )($scope); + + $scope.val = 'two'; + $scope.$digest(); + + window.setTimeout.expect(1).process(); + window.setTimeout.expect(1000).process(); + + $scope.val = 'three'; + $scope.$digest(); + + expect(element.children().length).toBe(2); + var first = element.children()[0]; + + expect(first.className).toContain('cool-leave-setup'); + + window.setTimeout.expect(1).process(); + window.setTimeout.expect(1).process(); + + expect(first.className).toContain('cool-leave-start'); + + window.setTimeout.expect(1000).process(); + window.setTimeout.expect(1000).process(); + + expect(first.className).not.toContain('cool-leave-setup'); + expect(first.className).not.toContain('cool-leave-start'); + })); + + it('should catch and use the correct duration for animation', + inject(function($compile, $rootScope, $sniffer) { + element = $compile( + '
' + + '
one
' + + '
' + )($rootScope); + + $rootScope.val = 'one'; + $rootScope.$digest(); + + window.setTimeout.expect(1).process(); + window.setTimeout.expect(500).process(); + })); + +}); diff --git a/test/ng/directive/ngViewSpec.js b/test/ng/directive/ngViewSpec.js index e781b98b60f1..dcf024bef056 100644 --- a/test/ng/directive/ngViewSpec.js +++ b/test/ng/directive/ngViewSpec.js @@ -473,7 +473,7 @@ describe('ngView', function() { $rootScope.$digest(); forEach(element.contents(), function(node) { - if ( node.nodeType == 3 ) { + if ( node.nodeType == 3 /* text node */) { expect(jqLite(node).scope()).not.toBe($route.current.scope); expect(jqLite(node).controller()).not.toBeDefined(); } else { @@ -484,3 +484,93 @@ describe('ngView', function() { }); }); }); + +describe('ngAnimate', function() { + var element, window; + + beforeEach(module(function($provide, $routeProvider) { + $provide.value('$window', window = angular.mock.createMockWindow()); + $routeProvider.when('/foo', {controller: noop, templateUrl: '/foo.html'}); + return function($templateCache) { + $templateCache.put('/foo.html', [200, '
data
', {}]); + } + })); + + afterEach(function(){ + dealoc(element); + }); + + it('should fire off the enter animation + add and remove the css classes', + inject(function($compile, $rootScope, $sniffer, $location, $templateCache) { + element = $compile('
')($rootScope); + + $location.path('/foo'); + $rootScope.$digest(); + + //if we add the custom css stuff here then it will get picked up before the animation takes place + var child = jqLite(element.children()[0]); + var cssProp = '-' + $sniffer.vendorPrefix + '-transition'; + var cssValue = '1s linear all'; + child.css(cssProp, cssValue); + + expect(child.attr('class')).toContain('custom-enter-setup'); + window.setTimeout.expect(1).process(); + + expect(child.attr('class')).toContain('custom-enter-start'); + window.setTimeout.expect(1000).process(); + + expect(child.attr('class')).not.toContain('custom-enter-setup'); + expect(child.attr('class')).not.toContain('custom-enter-start'); + })); + + it('should fire off the leave animation + add and remove the css classes', + inject(function($compile, $rootScope, $sniffer, $location, $templateCache) { + $templateCache.put('/foo.html', [200, '
foo
', {}]); + element = $compile('
')($rootScope); + + $location.path('/foo'); + $rootScope.$digest(); + + $location.path('/'); + $rootScope.$digest(); + + //if we add the custom css stuff here then it will get picked up before the animation takes place + var child = jqLite(element.children()[0]); + var cssProp = '-' + $sniffer.vendorPrefix + '-transition'; + var cssValue = '1s linear all'; + child.css(cssProp, cssValue); + + expect(child.attr('class')).toContain('custom-leave-setup'); + window.setTimeout.expect(1).process(); + + expect(child.attr('class')).toContain('custom-leave-start'); + window.setTimeout.expect(1000).process(); + + expect(child.attr('class')).not.toContain('custom-leave-setup'); + expect(child.attr('class')).not.toContain('custom-leave-start'); + })); + + it('should catch and use the correct duration for animations', + inject(function($compile, $rootScope, $sniffer, $location, $templateCache) { + $templateCache.put('/foo.html', [200, '
foo
', {}]); + element = $compile( + '
' + + '
' + )($rootScope); + + $location.path('/foo'); + $rootScope.$digest(); + + //if we add the custom css stuff here then it will get picked up before the animation takes place + var child = jqLite(element.children()[0]); + var cssProp = '-' + $sniffer.vendorPrefix + '-transition'; + var cssValue = '0.5s linear all'; + child.css(cssProp, cssValue); + + window.setTimeout.expect(1).process(); + window.setTimeout.expect(500).process(); + })); + +}); diff --git a/test/ng/logSpec.js b/test/ng/logSpec.js index b416e044d499..eb8e02a8b80e 100644 --- a/test/ng/logSpec.js +++ b/test/ng/logSpec.js @@ -12,7 +12,7 @@ describe('$log', function() { beforeEach(module(function($provide){ - $window = {navigator: {}, document: {}}; + $window = {navigator: {}, document: window.document}; logger = ''; log = function() { logger+= 'log;'; }; warn = function() { logger+= 'warn;'; }; diff --git a/test/ng/rootScopeSpec.js b/test/ng/rootScopeSpec.js index 33db814cd96f..2e36980d55ce 100644 --- a/test/ng/rootScopeSpec.js +++ b/test/ng/rootScopeSpec.js @@ -362,6 +362,102 @@ describe('Scope', function() { $rootScope.$digest(); expect(log).toEqual([]); })); + + describe('$watchProps', function() { + var log, $rootScope; + + beforeEach(inject(function(_$rootScope_) { + log = []; + $rootScope = _$rootScope_; + $rootScope.$watchProps('obj', function logger(obj) { + log.push(toJson(obj)); + }); + })); + + + it('should not trigger if nothing change', inject(function($rootScope) { + $rootScope.$digest(); + expect(log).toEqual([undefined]); + + $rootScope.$digest(); + expect(log).toEqual([undefined]); + })); + + + it('should trigger when object changes ref', function() { + $rootScope.obj = 'test'; + $rootScope.$digest(); + expect(log).toEqual(['"test"']); + + $rootScope.obj = []; + $rootScope.$digest(); + expect(log).toEqual(['"test"', '[]']); + }); + + + it('should watch array properties', function() { + $rootScope.obj = []; + $rootScope.$digest(); + expect(log).toEqual(['[]']); + + $rootScope.obj.push('a'); + $rootScope.$digest(); + expect(log).toEqual(['[]', '["a"]']); + + $rootScope.obj[0] = 'b'; + $rootScope.$digest(); + expect(log).toEqual(['[]', '["a"]', '["b"]']); + + $rootScope.obj.push([]); + $rootScope.obj.push({}); + log = []; + $rootScope.$digest(); + expect(log).toEqual(['["b",[],{}]']); + + var temp = $rootScope.obj[1]; + $rootScope.obj[1] = $rootScope.obj[2]; + $rootScope.obj[2] = temp; + $rootScope.$digest(); + expect(log).toEqual([ '["b",[],{}]', '["b",{},[]]' ]); + + $rootScope.obj.shift() + log = []; + $rootScope.$digest(); + expect(log).toEqual([ '[{},[]]' ]); + }); + + + it('should watch object properties', function() { + $rootScope.obj = {}; + $rootScope.$digest(); + expect(log).toEqual(['{}']); + + $rootScope.obj.a= 'A'; + $rootScope.$digest(); + expect(log).toEqual(['{}', '{"a":"A"}']); + + $rootScope.obj.a = 'B'; + $rootScope.$digest(); + expect(log).toEqual(['{}', '{"a":"A"}', '{"a":"B"}']); + + $rootScope.obj.b = []; + $rootScope.obj.c = {}; + log = []; + $rootScope.$digest(); + expect(log).toEqual(['{"a":"B","b":[],"c":{}}']); + + var temp = $rootScope.obj.a; + $rootScope.obj.a = $rootScope.obj.b; + $rootScope.obj.c = temp; + $rootScope.$digest(); + expect(log).toEqual([ '{"a":"B","b":[],"c":{}}', '{"a":[],"b":[],"c":"B"}' ]); + + delete $rootScope.obj.a; + log = []; + $rootScope.$digest(); + expect(log).toEqual([ '{"b":[],"c":"B"}' ]); + }); + }); }); diff --git a/test/ng/snifferSpec.js b/test/ng/snifferSpec.js index 2369deaf61d8..be30fcf10983 100644 --- a/test/ng/snifferSpec.js +++ b/test/ng/snifferSpec.js @@ -5,6 +5,9 @@ describe('$sniffer', function() { function sniffer($window, $document) { $window.navigator = {}; $document = jqLite($document || {}); + if (!$document[0].body) { + $document[0].body = window.document.body; + } return new $SnifferProvider().$get[2]($window, $document); } @@ -21,11 +24,11 @@ describe('$sniffer', function() { describe('hashchange', function() { it('should be true if onhashchange property defined', function() { - expect(sniffer({onhashchange: true}, {}).hashchange).toBe(true); + expect(sniffer({onhashchange: true}).hashchange).toBe(true); }); it('should be false if onhashchange property not defined', function() { - expect(sniffer({}, {}).hashchange).toBe(false); + expect(sniffer({}).hashchange).toBe(false); }); it('should be false if documentMode is 7 (IE8 comp mode)', function() { @@ -83,7 +86,7 @@ describe('$sniffer', function() { describe('csp', function() { it('should be false if document.securityPolicy.isActive not available', function() { - expect(sniffer({}, {}).csp).toBe(false); + expect(sniffer({}).csp).toBe(false); }); @@ -96,4 +99,38 @@ describe('$sniffer', function() { expect(sniffer({}, createDocumentWithCSP(true)).csp).toBe(true); }); }); + + describe('vendorPrefix', function() { + + it('should return the correct vendor prefix based on the browser', function() { + inject(function($sniffer, $window) { + var expectedPrefix; + var ua = $window.navigator.userAgent.toLowerCase(); + if(/chrome/i.test(ua) || /safari/i.test(ua) || /webkit/i.test(ua)) { + expectedPrefix = 'webkit'; + } + else if(/firefox/i.test(ua)) { + expectedPrefix = 'moz'; + } + else if(/ie/i.test(ua)) { + expectedPrefix = 'ms'; + } + else if(/opera/i.test(ua)) { + expectedPrefix = 'o'; + } + expect($sniffer.vendorPrefix.toLowerCase()).toBe(expectedPrefix); + }); + }); + + }); + + describe('supportsTransitions', function() { + + it('should be either true or false', function() { + inject(function($sniffer) { + expect($sniffer.supportsTransitions).not.toBe(undefined); + }); + }); + + }); });