fix($animate): delegate down to addClass/removeClass if setClass is not found
Closes #6463
This commit is contained in:
+180
-169
@@ -349,6 +349,148 @@ angular.module('ngAnimate', ['ng'])
|
||||
}
|
||||
}
|
||||
|
||||
function animationRunner(element, animationEvent, className) {
|
||||
//transcluded directives may sometimes fire an animation using only comment nodes
|
||||
//best to catch this early on to prevent any animation operations from occurring
|
||||
var node = element[0];
|
||||
if(!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
var isSetClassOperation = animationEvent == 'setClass';
|
||||
var isClassBased = isSetClassOperation ||
|
||||
animationEvent == 'addClass' ||
|
||||
animationEvent == 'removeClass';
|
||||
|
||||
var classNameAdd, classNameRemove;
|
||||
if(angular.isArray(className)) {
|
||||
classNameAdd = className[0];
|
||||
classNameRemove = className[1];
|
||||
className = classNameAdd + ' ' + classNameRemove;
|
||||
}
|
||||
|
||||
var currentClassName = element.attr('class');
|
||||
var classes = currentClassName + ' ' + className;
|
||||
if(!isAnimatableClassName(classes)) {
|
||||
return;
|
||||
}
|
||||
|
||||
var beforeComplete = noop,
|
||||
beforeCancel = [],
|
||||
before = [],
|
||||
afterComplete = noop,
|
||||
afterCancel = [],
|
||||
after = [];
|
||||
|
||||
var animationLookup = (' ' + classes).replace(/\s+/g,'.');
|
||||
forEach(lookup(animationLookup), function(animationFactory) {
|
||||
var created = registerAnimation(animationFactory, animationEvent);
|
||||
if(!created && isSetClassOperation) {
|
||||
registerAnimation(animationFactory, 'addClass');
|
||||
registerAnimation(animationFactory, 'removeClass');
|
||||
}
|
||||
});
|
||||
|
||||
function registerAnimation(animationFactory, event) {
|
||||
var afterFn = animationFactory[event];
|
||||
var beforeFn = animationFactory['before' + event.charAt(0).toUpperCase() + event.substr(1)];
|
||||
if(afterFn || beforeFn) {
|
||||
if(event == 'leave') {
|
||||
beforeFn = afterFn;
|
||||
//when set as null then animation knows to skip this phase
|
||||
afterFn = null;
|
||||
}
|
||||
after.push({
|
||||
event : event, fn : afterFn
|
||||
});
|
||||
before.push({
|
||||
event : event, fn : beforeFn
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function run(fns, cancellations, allCompleteFn) {
|
||||
var animations = [];
|
||||
forEach(fns, function(animation) {
|
||||
animation.fn && animations.push(animation);
|
||||
});
|
||||
|
||||
var count = 0;
|
||||
function afterAnimationComplete(index) {
|
||||
if(cancellations) {
|
||||
(cancellations[index] || noop)();
|
||||
if(++count < animations.length) return;
|
||||
cancellations = null;
|
||||
}
|
||||
allCompleteFn();
|
||||
}
|
||||
|
||||
//The code below adds directly to the array in order to work with
|
||||
//both sync and async animations. Sync animations are when the done()
|
||||
//operation is called right away. DO NOT REFACTOR!
|
||||
forEach(animations, function(animation, index) {
|
||||
var progress = function() {
|
||||
afterAnimationComplete(index);
|
||||
};
|
||||
switch(animation.event) {
|
||||
case 'setClass':
|
||||
cancellations.push(animation.fn(element, classNameAdd, classNameRemove, progress));
|
||||
break;
|
||||
case 'addClass':
|
||||
cancellations.push(animation.fn(element, classNameAdd || className, progress));
|
||||
break;
|
||||
case 'removeClass':
|
||||
cancellations.push(animation.fn(element, classNameRemove || className, progress));
|
||||
break;
|
||||
default:
|
||||
cancellations.push(animation.fn(element, progress));
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
if(cancellations && cancellations.length === 0) {
|
||||
allCompleteFn();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
node : node,
|
||||
event : animationEvent,
|
||||
className : className,
|
||||
isClassBased : isClassBased,
|
||||
isSetClassOperation : isSetClassOperation,
|
||||
before : function(allCompleteFn) {
|
||||
beforeComplete = allCompleteFn;
|
||||
run(before, beforeCancel, function() {
|
||||
beforeComplete = noop;
|
||||
allCompleteFn();
|
||||
});
|
||||
},
|
||||
after : function(allCompleteFn) {
|
||||
afterComplete = allCompleteFn;
|
||||
run(after, afterCancel, function() {
|
||||
afterComplete = noop;
|
||||
allCompleteFn();
|
||||
});
|
||||
},
|
||||
cancel : function() {
|
||||
if(beforeCancel) {
|
||||
forEach(beforeCancel, function(cancelFn) {
|
||||
(cancelFn || noop)(true);
|
||||
});
|
||||
beforeComplete(true);
|
||||
}
|
||||
if(afterCancel) {
|
||||
forEach(afterCancel, function(cancelFn) {
|
||||
(cancelFn || noop)(true);
|
||||
});
|
||||
afterComplete(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @ngdoc service
|
||||
* @name $animate
|
||||
@@ -624,22 +766,8 @@ angular.module('ngAnimate', ['ng'])
|
||||
*/
|
||||
function performAnimation(animationEvent, className, element, parentElement, afterElement, domOperation, doneCallback) {
|
||||
|
||||
var classNameAdd, classNameRemove, setClassOperation = animationEvent == 'setClass';
|
||||
if(setClassOperation) {
|
||||
classNameAdd = className[0];
|
||||
classNameRemove = className[1];
|
||||
className = classNameAdd + ' ' + classNameRemove;
|
||||
}
|
||||
|
||||
var currentClassName, classes, node = element[0];
|
||||
if(node) {
|
||||
currentClassName = node.className;
|
||||
classes = currentClassName + ' ' + className;
|
||||
}
|
||||
|
||||
//transcluded directives may sometimes fire an animation using only comment nodes
|
||||
//best to catch this early on to prevent any animation operations from occurring
|
||||
if(!node || !isAnimatableClassName(classes)) {
|
||||
var runner = animationRunner(element, animationEvent, className);
|
||||
if(!runner) {
|
||||
fireDOMOperation();
|
||||
fireBeforeCallbackAsync();
|
||||
fireAfterCallbackAsync();
|
||||
@@ -647,29 +775,30 @@ angular.module('ngAnimate', ['ng'])
|
||||
return;
|
||||
}
|
||||
|
||||
var elementEvents = angular.element._data(node);
|
||||
className = runner.className;
|
||||
var elementEvents = angular.element._data(runner.node);
|
||||
elementEvents = elementEvents && elementEvents.events;
|
||||
|
||||
var animationLookup = (' ' + classes).replace(/\s+/g,'.');
|
||||
if (!parentElement) {
|
||||
parentElement = afterElement ? afterElement.parent() : element.parent();
|
||||
}
|
||||
|
||||
var matches = lookup(animationLookup);
|
||||
var isClassBased = animationEvent == 'addClass' ||
|
||||
animationEvent == 'removeClass' ||
|
||||
setClassOperation;
|
||||
var ngAnimateState = element.data(NG_ANIMATE_STATE) || {};
|
||||
|
||||
var runningAnimations = ngAnimateState.active || {};
|
||||
var totalActiveAnimations = ngAnimateState.totalActive || 0;
|
||||
var lastAnimation = ngAnimateState.last;
|
||||
|
||||
//only allow animations if the currently running animation is not structural
|
||||
//or if there is no animation running at all
|
||||
var skipAnimations = runner.isClassBased ?
|
||||
ngAnimateState.disabled || (lastAnimation && !lastAnimation.isClassBased) :
|
||||
false;
|
||||
|
||||
//skip the animation if animations are disabled, a parent is already being animated,
|
||||
//the element is not currently attached to the document body or then completely close
|
||||
//the animation if any matching animations are not found at all.
|
||||
//NOTE: IE8 + IE9 should close properly (run closeAnimation()) in case a NO animation is not found.
|
||||
if (animationsDisabled(element, parentElement) || matches.length === 0) {
|
||||
//NOTE: IE8 + IE9 should close properly (run closeAnimation()) in case an animation was found.
|
||||
if (skipAnimations || animationsDisabled(element, parentElement)) {
|
||||
fireDOMOperation();
|
||||
fireBeforeCallbackAsync();
|
||||
fireAfterCallbackAsync();
|
||||
@@ -677,50 +806,10 @@ angular.module('ngAnimate', ['ng'])
|
||||
return;
|
||||
}
|
||||
|
||||
var animations = [];
|
||||
|
||||
//only add animations if the currently running animation is not structural
|
||||
//or if there is no animation running at all
|
||||
var allowAnimations = isClassBased ?
|
||||
!ngAnimateState.disabled && (!lastAnimation || lastAnimation.classBased) :
|
||||
true;
|
||||
|
||||
if(allowAnimations) {
|
||||
forEach(matches, function(animation) {
|
||||
//add the animation to the queue to if it is allowed to be cancelled
|
||||
if(!animation.allowCancel || animation.allowCancel(element, animationEvent, className)) {
|
||||
var beforeFn, afterFn = animation[animationEvent];
|
||||
|
||||
//Special case for a leave animation since there is no point in performing an
|
||||
//animation on a element node that has already been removed from the DOM
|
||||
if(animationEvent == 'leave') {
|
||||
beforeFn = afterFn;
|
||||
afterFn = null; //this must be falsy so that the animation is skipped for leave
|
||||
} else {
|
||||
beforeFn = animation['before' + animationEvent.charAt(0).toUpperCase() + animationEvent.substr(1)];
|
||||
}
|
||||
animations.push({
|
||||
before : beforeFn,
|
||||
after : afterFn
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//this would mean that an animation was not allowed so let the existing
|
||||
//animation do it's thing and close this one early
|
||||
if(animations.length === 0) {
|
||||
fireDOMOperation();
|
||||
fireBeforeCallbackAsync();
|
||||
fireAfterCallbackAsync();
|
||||
fireDoneCallbackAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
var skipAnimation = false;
|
||||
if(totalActiveAnimations > 0) {
|
||||
var animationsToCancel = [];
|
||||
if(!isClassBased) {
|
||||
if(!runner.isClassBased) {
|
||||
if(animationEvent == 'leave' && runningAnimations['ng-leave']) {
|
||||
skipAnimation = true;
|
||||
} else {
|
||||
@@ -747,14 +836,13 @@ angular.module('ngAnimate', ['ng'])
|
||||
}
|
||||
|
||||
if(animationsToCancel.length > 0) {
|
||||
angular.forEach(animationsToCancel, function(operation) {
|
||||
(operation.done || noop)(true);
|
||||
cancelAnimations(operation.animations);
|
||||
forEach(animationsToCancel, function(operation) {
|
||||
operation.cancel();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if(isClassBased && !setClassOperation && !skipAnimation) {
|
||||
if(runner.isClassBased && !runner.isSetClassOperation && !skipAnimation) {
|
||||
skipAnimation = (animationEvent == 'addClass') == element.hasClass(className); //opposite of XOR
|
||||
}
|
||||
|
||||
@@ -771,15 +859,11 @@ angular.module('ngAnimate', ['ng'])
|
||||
//is cancelled midway
|
||||
element.one('$destroy', function(e) {
|
||||
var element = angular.element(this);
|
||||
var state = element.data(NG_ANIMATE_STATE) || {};
|
||||
var activeLeaveAnimation = state.active['ng-leave'];
|
||||
if(activeLeaveAnimation) {
|
||||
var animations = activeLeaveAnimation.animations;
|
||||
|
||||
//if the before animation is completed then the element will be
|
||||
//removed shortly after so there is no need to cancel the animation
|
||||
if(!animations[0].beforeComplete) {
|
||||
cancelAnimations(animations);
|
||||
var state = element.data(NG_ANIMATE_STATE);
|
||||
if(state) {
|
||||
var activeLeaveAnimation = state.active['ng-leave'];
|
||||
if(activeLeaveAnimation) {
|
||||
activeLeaveAnimation.cancel();
|
||||
cleanup(element, 'ng-leave');
|
||||
}
|
||||
}
|
||||
@@ -791,18 +875,11 @@ angular.module('ngAnimate', ['ng'])
|
||||
element.addClass(NG_ANIMATE_CLASS_NAME);
|
||||
|
||||
var localAnimationCount = globalAnimationCounter++;
|
||||
lastAnimation = {
|
||||
classBased : isClassBased,
|
||||
event : animationEvent,
|
||||
animations : animations,
|
||||
done:onBeforeAnimationsComplete
|
||||
};
|
||||
|
||||
totalActiveAnimations++;
|
||||
runningAnimations[className] = lastAnimation;
|
||||
runningAnimations[className] = runner;
|
||||
|
||||
element.data(NG_ANIMATE_STATE, {
|
||||
last : lastAnimation,
|
||||
last : runner,
|
||||
active : runningAnimations,
|
||||
index : localAnimationCount,
|
||||
totalActive : totalActiveAnimations
|
||||
@@ -810,72 +887,21 @@ angular.module('ngAnimate', ['ng'])
|
||||
|
||||
//first we run the before animations and when all of those are complete
|
||||
//then we perform the DOM operation and run the next set of animations
|
||||
invokeRegisteredAnimationFns(animations, 'before', onBeforeAnimationsComplete);
|
||||
|
||||
function onBeforeAnimationsComplete(cancelled) {
|
||||
fireBeforeCallbackAsync();
|
||||
runner.before(function(cancelled) {
|
||||
var data = element.data(NG_ANIMATE_STATE);
|
||||
cancelled = cancelled ||
|
||||
!data || !data.active[className] ||
|
||||
(isClassBased && data.active[className].event != animationEvent);
|
||||
!data || !data.active[className] ||
|
||||
(runner.isClassBased && data.active[className].event != animationEvent);
|
||||
|
||||
fireDOMOperation();
|
||||
if(cancelled === true) {
|
||||
closeAnimation();
|
||||
return;
|
||||
} else {
|
||||
fireAfterCallbackAsync();
|
||||
runner.after(closeAnimation);
|
||||
}
|
||||
|
||||
//set the done function to the final done function
|
||||
//so that the DOM event won't be executed twice by accident
|
||||
//if the after animation is cancelled as well
|
||||
var currentAnimation = data.active[className];
|
||||
currentAnimation.done = closeAnimation;
|
||||
invokeRegisteredAnimationFns(animations, 'after', closeAnimation);
|
||||
}
|
||||
|
||||
function invokeRegisteredAnimationFns(animations, phase, allAnimationFnsComplete) {
|
||||
phase == 'after' ?
|
||||
fireAfterCallbackAsync() :
|
||||
fireBeforeCallbackAsync();
|
||||
|
||||
var endFnName = phase + 'End';
|
||||
forEach(animations, function(animation, index) {
|
||||
var animationPhaseCompleted = function() {
|
||||
progress(index, phase);
|
||||
};
|
||||
|
||||
//there are no before functions for enter + move since the DOM
|
||||
//operations happen before the performAnimation method fires
|
||||
if(phase == 'before' && (animationEvent == 'enter' || animationEvent == 'move')) {
|
||||
animationPhaseCompleted();
|
||||
return;
|
||||
}
|
||||
|
||||
if(animation[phase]) {
|
||||
if(setClassOperation) {
|
||||
animation[endFnName] = animation[phase](element, classNameAdd, classNameRemove, animationPhaseCompleted);
|
||||
} else {
|
||||
animation[endFnName] = isClassBased ?
|
||||
animation[phase](element, className, animationPhaseCompleted) :
|
||||
animation[phase](element, animationPhaseCompleted);
|
||||
}
|
||||
} else {
|
||||
animationPhaseCompleted();
|
||||
}
|
||||
});
|
||||
|
||||
function progress(index, phase) {
|
||||
var phaseCompletionFlag = phase + 'Complete';
|
||||
var currentAnimation = animations[index];
|
||||
currentAnimation[phaseCompletionFlag] = true;
|
||||
(currentAnimation[endFnName] || noop)();
|
||||
|
||||
for(var i=0;i<animations.length;i++) {
|
||||
if(!animations[i][phaseCompletionFlag]) return;
|
||||
}
|
||||
|
||||
allAnimationFnsComplete();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function fireDOMCallback(animationPhase) {
|
||||
var eventName = '$animate:' + animationPhase;
|
||||
@@ -924,7 +950,7 @@ angular.module('ngAnimate', ['ng'])
|
||||
animation, but class-based animations don't. An example of this
|
||||
failing would be when a parent HTML tag has a ng-class attribute
|
||||
causing ALL directives below to skip animations during the digest */
|
||||
if(isClassBased) {
|
||||
if(runner.isClassBased) {
|
||||
cleanup(element, className);
|
||||
} else {
|
||||
$$asyncCallback(function() {
|
||||
@@ -951,27 +977,14 @@ angular.module('ngAnimate', ['ng'])
|
||||
element = angular.element(element);
|
||||
var data = element.data(NG_ANIMATE_STATE);
|
||||
if(data && data.active) {
|
||||
angular.forEach(data.active, function(operation) {
|
||||
(operation.done || noop)(true);
|
||||
cancelAnimations(operation.animations);
|
||||
forEach(data.active, function(runner) {
|
||||
runner.cancel();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function cancelAnimations(animations) {
|
||||
var isCancelledFlag = true;
|
||||
forEach(animations, function(animation) {
|
||||
if(!animation.beforeComplete) {
|
||||
(animation.beforeEnd || noop)(isCancelledFlag);
|
||||
}
|
||||
if(!animation.afterComplete) {
|
||||
(animation.afterEnd || noop)(isCancelledFlag);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function cleanup(element, className) {
|
||||
if(isMatchingElement(element, $rootElement)) {
|
||||
if(!rootAnimateState.disabled) {
|
||||
@@ -982,11 +995,9 @@ angular.module('ngAnimate', ['ng'])
|
||||
var data = element.data(NG_ANIMATE_STATE) || {};
|
||||
|
||||
var removeAnimations = className === true;
|
||||
if(!removeAnimations) {
|
||||
if(data.active && data.active[className]) {
|
||||
data.totalActive--;
|
||||
delete data.active[className];
|
||||
}
|
||||
if(!removeAnimations && data.active && data.active[className]) {
|
||||
data.totalActive--;
|
||||
delete data.active[className];
|
||||
}
|
||||
|
||||
if(removeAnimations || !data.totalActive) {
|
||||
@@ -1244,7 +1255,7 @@ angular.module('ngAnimate', ['ng'])
|
||||
itemIndex : itemIndex,
|
||||
stagger : stagger,
|
||||
timings : timings,
|
||||
closeAnimationFn : angular.noop
|
||||
closeAnimationFn : noop
|
||||
});
|
||||
|
||||
//temporarily disable the transition so that the enter styles
|
||||
|
||||
@@ -364,6 +364,82 @@ describe("ngAnimate", function() {
|
||||
}));
|
||||
|
||||
|
||||
it("should exclusively animate the setClass animation event", function() {
|
||||
var count = 0, fallback = jasmine.createSpy('callback');
|
||||
module(function($animateProvider) {
|
||||
$animateProvider.register('.classify', function() {
|
||||
return {
|
||||
beforeAddClass : fallback,
|
||||
addClass : fallback,
|
||||
beforeRemoveClass : fallback,
|
||||
removeClass : fallback,
|
||||
|
||||
beforeSetClass : function(element, add, remove, done) {
|
||||
count++;
|
||||
expect(add).toBe('yes');
|
||||
expect(remove).toBe('no');
|
||||
done();
|
||||
},
|
||||
setClass : function(element, add, remove, done) {
|
||||
count++;
|
||||
expect(add).toBe('yes');
|
||||
expect(remove).toBe('no');
|
||||
done();
|
||||
}
|
||||
};
|
||||
});
|
||||
})
|
||||
inject(function($animate, $rootScope, $sniffer, $timeout) {
|
||||
child.attr('class','classify no');
|
||||
$animate.setClass(child, 'yes', 'no');
|
||||
|
||||
expect(child.hasClass('yes')).toBe(true);
|
||||
expect(child.hasClass('no')).toBe(false);
|
||||
expect(count).toBe(2);
|
||||
|
||||
expect(fallback).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it("should delegate down to addClass/removeClass if a setClass animation is not found", function() {
|
||||
var count = 0;
|
||||
module(function($animateProvider) {
|
||||
$animateProvider.register('.classify', function() {
|
||||
return {
|
||||
beforeAddClass : function(element, className, done) {
|
||||
count++;
|
||||
expect(className).toBe('yes');
|
||||
done();
|
||||
},
|
||||
addClass : function(element, className, done) {
|
||||
count++;
|
||||
expect(className).toBe('yes');
|
||||
done();
|
||||
},
|
||||
beforeRemoveClass : function(element, className, done) {
|
||||
count++;
|
||||
expect(className).toBe('no');
|
||||
done();
|
||||
},
|
||||
removeClass : function(element, className, done) {
|
||||
count++;
|
||||
expect(className).toBe('no');
|
||||
done();
|
||||
}
|
||||
};
|
||||
});
|
||||
})
|
||||
inject(function($animate, $rootScope, $sniffer, $timeout) {
|
||||
child.attr('class','classify no');
|
||||
$animate.setClass(child, 'yes', 'no');
|
||||
|
||||
expect(child.hasClass('yes')).toBe(true);
|
||||
expect(child.hasClass('no')).toBe(false);
|
||||
expect(count).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
it("should assign the ng-event className to all animation events when transitions/keyframes are used",
|
||||
inject(function($animate, $sniffer, $rootScope, $timeout) {
|
||||
|
||||
@@ -1584,6 +1660,29 @@ describe("ngAnimate", function() {
|
||||
expect(signature).toBe('AB');
|
||||
}));
|
||||
|
||||
it("should fire the setClass callback",
|
||||
inject(function($animate, $rootScope, $compile, $sniffer, $rootElement, $timeout) {
|
||||
|
||||
var parent = jqLite('<div><span class="off"></span></div>');
|
||||
var element = parent.find('span');
|
||||
$rootElement.append(parent);
|
||||
body.append($rootElement);
|
||||
|
||||
expect(element.hasClass('on')).toBe(false);
|
||||
expect(element.hasClass('off')).toBe(true);
|
||||
|
||||
var signature = '';
|
||||
$animate.setClass(element, 'on', 'off', function() {
|
||||
signature += 'Z';
|
||||
});
|
||||
|
||||
$animate.triggerCallbacks();
|
||||
|
||||
expect(signature).toBe('Z');
|
||||
expect(element.hasClass('on')).toBe(true);
|
||||
expect(element.hasClass('off')).toBe(false);
|
||||
}));
|
||||
|
||||
it('should fire DOM callbacks on the element being animated',
|
||||
inject(function($animate, $rootScope, $compile, $sniffer, $rootElement, $timeout) {
|
||||
|
||||
|
||||
Reference in New Issue
Block a user