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('
+ * 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
+ * + *+ * + * The `event1` and `event2` attributes refer to the animation events specific to the directive that has been assigned. + * + *+ *
+ * + * + * + *+ * + * 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. + * + *
+ * 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
+ $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('