diff --git a/src/ng/directive/ngClass.js b/src/ng/directive/ngClass.js
index 7e138b065dc2..a26c1c77187b 100644
--- a/src/ng/directive/ngClass.js
+++ b/src/ng/directive/ngClass.js
@@ -8,51 +8,71 @@
function classDirective(name, selector) {
name = 'ngClass' + name;
- return ['$animate', function($animate) {
+ var indexWatchExpression;
+
+ return ['$parse', function($parse) {
return {
restrict: 'AC',
link: function(scope, element, attr) {
- var oldVal;
+ var expression = attr[name].trim();
+ var isOneTime = (expression.charAt(0) === ':') && (expression.charAt(1) === ':');
- scope.$watch(attr[name], ngClassWatchAction, true);
+ var watchInterceptor = isOneTime ? toFlatValue : toClassString;
+ var watchExpression = $parse(expression, watchInterceptor);
+ var watchAction = isOneTime ? ngClassOneTimeWatchAction : ngClassWatchAction;
- attr.$observe('class', function(value) {
- ngClassWatchAction(scope.$eval(attr[name]));
- });
+ var classCounts = element.data('$classCounts');
+ var oldModulo = true;
+ var oldClassString;
+ if (!classCounts) {
+ // Use createMap() to prevent class assumptions involving property
+ // names in Object.prototype
+ classCounts = createMap();
+ element.data('$classCounts', classCounts);
+ }
if (name !== 'ngClass') {
- scope.$watch('$index', function($index, old$index) {
- /* eslint-disable no-bitwise */
- var mod = $index & 1;
- if (mod !== (old$index & 1)) {
- var classes = arrayClasses(scope.$eval(attr[name]));
- if (mod === selector) {
- addClasses(classes);
- } else {
- removeClasses(classes);
- }
- }
- /* eslint-enable */
- });
+ if (!indexWatchExpression) {
+ indexWatchExpression = $parse('$index', function moduloTwo($index) {
+ // eslint-disable-next-line no-bitwise
+ return $index & 1;
+ });
+ }
+
+ scope.$watch(indexWatchExpression, ngClassIndexWatchAction);
}
- function addClasses(classes) {
- var newClasses = digestClassCounts(classes, 1);
- attr.$addClass(newClasses);
+ scope.$watch(watchExpression, watchAction, isOneTime);
+
+ function addClasses(classString) {
+ classString = digestClassCounts(split(classString), 1);
+ attr.$addClass(classString);
}
- function removeClasses(classes) {
- var newClasses = digestClassCounts(classes, -1);
- attr.$removeClass(newClasses);
+ function removeClasses(classString) {
+ classString = digestClassCounts(split(classString), -1);
+ attr.$removeClass(classString);
}
- function digestClassCounts(classes, count) {
- // Use createMap() to prevent class assumptions involving property
- // names in Object.prototype
- var classCounts = element.data('$classCounts') || createMap();
+ function updateClasses(oldClassString, newClassString) {
+ var oldClassArray = split(oldClassString);
+ var newClassArray = split(newClassString);
+
+ var toRemoveArray = arrayDifference(oldClassArray, newClassArray);
+ var toAddArray = arrayDifference(newClassArray, oldClassArray);
+
+ var toRemoveString = digestClassCounts(toRemoveArray, -1);
+ var toAddString = digestClassCounts(toAddArray, 1);
+
+ attr.$addClass(toAddString);
+ attr.$removeClass(toRemoveString);
+ }
+
+ function digestClassCounts(classArray, count) {
var classesToUpdate = [];
- forEach(classes, function(className) {
+
+ forEach(classArray, function(className) {
if (count > 0 || classCounts[className]) {
classCounts[className] = (classCounts[className] || 0) + count;
if (classCounts[className] === +(count > 0)) {
@@ -60,77 +80,106 @@ function classDirective(name, selector) {
}
}
});
- element.data('$classCounts', classCounts);
+
return classesToUpdate.join(' ');
}
- function updateClasses(oldClasses, newClasses) {
- var toAdd = arrayDifference(newClasses, oldClasses);
- var toRemove = arrayDifference(oldClasses, newClasses);
- toAdd = digestClassCounts(toAdd, 1);
- toRemove = digestClassCounts(toRemove, -1);
- if (toAdd && toAdd.length) {
- $animate.addClass(element, toAdd);
- }
- if (toRemove && toRemove.length) {
- $animate.removeClass(element, toRemove);
+ function ngClassIndexWatchAction(newModulo) {
+ // This watch-action should run before the `ngClass[OneTime]WatchAction()`, thus it
+ // adds/removes `oldClassString`. If the `ngClass` expression has changed as well, the
+ // `ngClass[OneTime]WatchAction()` will update the classes.
+ if (newModulo === selector) {
+ addClasses(oldClassString);
+ } else {
+ removeClasses(oldClassString);
}
+
+ oldModulo = newModulo;
}
- function ngClassWatchAction(newVal) {
- // eslint-disable-next-line no-bitwise
- if (selector === true || (scope.$index & 1) === selector) {
- var newClasses = arrayClasses(newVal || []);
- if (!oldVal) {
- addClasses(newClasses);
- } else if (!equals(newVal,oldVal)) {
- var oldClasses = arrayClasses(oldVal);
- updateClasses(oldClasses, newClasses);
- }
+ function ngClassOneTimeWatchAction(newClassValue) {
+ var newClassString = toClassString(newClassValue);
+
+ if (newClassString !== oldClassString) {
+ ngClassWatchAction(newClassString);
}
- if (isArray(newVal)) {
- oldVal = newVal.map(function(v) { return shallowCopy(v); });
- } else {
- oldVal = shallowCopy(newVal);
+ }
+
+ function ngClassWatchAction(newClassString) {
+ if (oldModulo === selector) {
+ updateClasses(oldClassString, newClassString);
}
+
+ oldClassString = newClassString;
}
}
};
+ }];
- function arrayDifference(tokens1, tokens2) {
- var values = [];
+ // Helpers
+ function arrayDifference(tokens1, tokens2) {
+ if (!tokens1 || !tokens1.length) return [];
+ if (!tokens2 || !tokens2.length) return tokens1;
- outer:
- for (var i = 0; i < tokens1.length; i++) {
- var token = tokens1[i];
- for (var j = 0; j < tokens2.length; j++) {
- if (token === tokens2[j]) continue outer;
- }
- values.push(token);
+ var values = [];
+
+ outer:
+ for (var i = 0; i < tokens1.length; i++) {
+ var token = tokens1[i];
+ for (var j = 0; j < tokens2.length; j++) {
+ if (token === tokens2[j]) continue outer;
}
- return values;
+ values.push(token);
}
- function arrayClasses(classVal) {
- var classes = [];
- if (isArray(classVal)) {
- forEach(classVal, function(v) {
- classes = classes.concat(arrayClasses(v));
- });
- return classes;
- } else if (isString(classVal)) {
- return classVal.split(' ');
- } else if (isObject(classVal)) {
- forEach(classVal, function(v, k) {
- if (v) {
- classes = classes.concat(k.split(' '));
- }
- });
- return classes;
+ return values;
+ }
+
+ function split(classString) {
+ return classString && classString.split(' ');
+ }
+
+ function toClassString(classValue) {
+ var classString = classValue;
+
+ if (isArray(classValue)) {
+ classString = classValue.map(toClassString).join(' ');
+ } else if (isObject(classValue)) {
+ classString = Object.keys(classValue).
+ filter(function(key) { return classValue[key]; }).
+ join(' ');
+ }
+
+ return classString;
+ }
+
+ function toFlatValue(classValue) {
+ var flatValue = classValue;
+
+ if (isArray(classValue)) {
+ flatValue = classValue.map(toFlatValue);
+ } else if (isObject(classValue)) {
+ var hasUndefined = false;
+
+ flatValue = Object.keys(classValue).filter(function(key) {
+ var value = classValue[key];
+
+ if (!hasUndefined && isUndefined(value)) {
+ hasUndefined = true;
+ }
+
+ return value;
+ });
+
+ if (hasUndefined) {
+ // Prevent the `oneTimeLiteralWatchInterceptor` from unregistering
+ // the watcher, by including at least one `undefined` value.
+ flatValue.push(undefined);
}
- return classVal;
}
- }];
+
+ return flatValue;
+ }
}
/**
diff --git a/test/ng/directive/ngClassSpec.js b/test/ng/directive/ngClassSpec.js
index b30d1318317c..ae671d050bd3 100644
--- a/test/ng/directive/ngClassSpec.js
+++ b/test/ng/directive/ngClassSpec.js
@@ -244,21 +244,34 @@ describe('ngClass', function() {
}));
- it('should allow ngClassOdd/Even on the same element with overlapping classes', inject(function($rootScope, $compile, $animate) {
- var className;
-
- element = $compile('
')($rootScope);
+ it('should allow ngClassOdd/Even on the same element with overlapping classes',
+ inject(function($compile, $rootScope) {
+ element = $compile(
+ '' +
+ '- ' +
+ '
' +
+ '')($rootScope);
$rootScope.$digest();
- var e1 = jqLite(element[0].childNodes[1]);
- var e2 = jqLite(element[0].childNodes[5]);
- expect(e1.hasClass('same')).toBeTruthy();
- expect(e1.hasClass('odd')).toBeTruthy();
- expect(e2.hasClass('same')).toBeTruthy();
- expect(e2.hasClass('odd')).toBeTruthy();
+
+ var e1 = element.children().eq(0);
+ var e2 = element.children().eq(1);
+ var e3 = element.children().eq(2);
+
+ expect(e1).toHaveClass('same');
+ expect(e1).toHaveClass('odd');
+ expect(e1).not.toHaveClass('even');
+ expect(e2).toHaveClass('same');
+ expect(e2).not.toHaveClass('odd');
+ expect(e2).toHaveClass('even');
+ expect(e3).toHaveClass('same');
+ expect(e3).toHaveClass('odd');
+ expect(e3).not.toHaveClass('even');
})
);
- it('should allow ngClass with overlapping classes', inject(function($rootScope, $compile, $animate) {
+ it('should allow ngClass with overlapping classes', inject(function($rootScope, $compile) {
element = $compile('')($rootScope);
$rootScope.$digest();
@@ -266,9 +279,7 @@ describe('ngClass', function() {
expect(element).not.toHaveClass('yes');
expect(element).toHaveClass('no');
- $rootScope.$apply(function() {
- $rootScope.test = true;
- });
+ $rootScope.$apply('test = true');
expect(element).toHaveClass('same');
expect(element).toHaveClass('yes');
@@ -299,38 +310,79 @@ describe('ngClass', function() {
expect(e2.hasClass('D')).toBeFalsy();
}));
-
- it('should reapply ngClass when interpolated class attribute changes', inject(function($rootScope, $compile) {
- element = $compile('')($rootScope);
-
- $rootScope.$apply(function() {
- $rootScope.cls = 'two';
- $rootScope.four = true;
- });
- expect(element).toHaveClass('one');
- expect(element).toHaveClass('two'); // interpolated
- expect(element).toHaveClass('three');
- expect(element).toHaveClass('four');
-
- $rootScope.$apply(function() {
- $rootScope.cls = 'too';
- });
- expect(element).toHaveClass('one');
- expect(element).toHaveClass('too'); // interpolated
- expect(element).toHaveClass('three');
- expect(element).toHaveClass('four'); // should still be there
- expect(element.hasClass('two')).toBeFalsy();
-
- $rootScope.$apply(function() {
- $rootScope.cls = 'to';
- });
- expect(element).toHaveClass('one');
- expect(element).toHaveClass('to'); // interpolated
- expect(element).toHaveClass('three');
- expect(element).toHaveClass('four'); // should still be there
- expect(element.hasClass('two')).toBeFalsy();
- expect(element.hasClass('too')).toBeFalsy();
- }));
+ it('should reapply ngClass when interpolated class attribute changes',
+ inject(function($compile, $rootScope) {
+ element = $compile(
+ '')($rootScope);
+ var e1 = element.children().eq(0);
+ var e2 = element.children().eq(1);
+
+ $rootScope.$apply('two = "two"; five = true');
+
+ expect(e1).toHaveClass('one');
+ expect(e1).toHaveClass('two');
+ expect(e1).toHaveClass('three');
+ expect(e1).not.toHaveClass('four');
+ expect(e1).toHaveClass('five');
+ expect(e2).toHaveClass('one');
+ expect(e2).toHaveClass('two');
+ expect(e2).toHaveClass('three');
+ expect(e2).not.toHaveClass('four');
+ expect(e2).toHaveClass('five');
+
+ $rootScope.$apply('two = "another-two"');
+
+ expect(e1).toHaveClass('one');
+ expect(e1).not.toHaveClass('two');
+ expect(e1).toHaveClass('another-two');
+ expect(e1).toHaveClass('three');
+ expect(e1).not.toHaveClass('four');
+ expect(e1).toHaveClass('five');
+ expect(e2).toHaveClass('one');
+ expect(e2).not.toHaveClass('two');
+ expect(e2).toHaveClass('another-two');
+ expect(e2).toHaveClass('three');
+ expect(e2).not.toHaveClass('four');
+ expect(e2).toHaveClass('five');
+
+ $rootScope.$apply('two = "two-more"; four = "four"');
+
+ expect(e1).toHaveClass('one');
+ expect(e1).not.toHaveClass('two');
+ expect(e1).not.toHaveClass('another-two');
+ expect(e1).toHaveClass('two-more');
+ expect(e1).toHaveClass('three');
+ expect(e1).not.toHaveClass('four');
+ expect(e1).toHaveClass('five');
+ expect(e2).toHaveClass('one');
+ expect(e2).not.toHaveClass('two');
+ expect(e2).not.toHaveClass('another-two');
+ expect(e2).toHaveClass('two-more');
+ expect(e2).toHaveClass('three');
+ expect(e2).toHaveClass('four');
+ expect(e2).toHaveClass('five');
+
+ $rootScope.$apply('five = false');
+
+ expect(e1).toHaveClass('one');
+ expect(e1).not.toHaveClass('two');
+ expect(e1).not.toHaveClass('another-two');
+ expect(e1).toHaveClass('two-more');
+ expect(e1).toHaveClass('three');
+ expect(e1).not.toHaveClass('four');
+ expect(e1).not.toHaveClass('five');
+ expect(e2).toHaveClass('one');
+ expect(e2).not.toHaveClass('two');
+ expect(e2).not.toHaveClass('another-two');
+ expect(e2).toHaveClass('two-more');
+ expect(e2).toHaveClass('three');
+ expect(e2).toHaveClass('four');
+ expect(e2).not.toHaveClass('five');
+ })
+ );
it('should not mess up class value due to observing an interpolated class attribute', inject(function($rootScope, $compile) {
@@ -409,6 +461,47 @@ describe('ngClass', function() {
expect(e2.hasClass('odd')).toBeFalsy();
}));
+
+ it('should add/remove the correct classes when the expression and `$index` change simultaneously',
+ inject(function($compile, $rootScope) {
+ element = $compile(
+ '')($rootScope);
+ var odd = element.children().eq(0);
+ var even = element.children().eq(1);
+
+ $rootScope.$apply('$index = 0; foo = "class1"');
+
+ expect(odd).toHaveClass('class1');
+ expect(odd).not.toHaveClass('class2');
+ expect(even).not.toHaveClass('class1');
+ expect(even).not.toHaveClass('class2');
+
+ $rootScope.$apply('$index = 1; foo = "class2"');
+
+ expect(odd).not.toHaveClass('class1');
+ expect(odd).not.toHaveClass('class2');
+ expect(even).not.toHaveClass('class1');
+ expect(even).toHaveClass('class2');
+
+ $rootScope.$apply('foo = "class1"');
+
+ expect(odd).not.toHaveClass('class1');
+ expect(odd).not.toHaveClass('class2');
+ expect(even).toHaveClass('class1');
+ expect(even).not.toHaveClass('class2');
+
+ $rootScope.$apply('$index = 2');
+
+ expect(odd).toHaveClass('class1');
+ expect(odd).not.toHaveClass('class2');
+ expect(even).not.toHaveClass('class1');
+ expect(even).not.toHaveClass('class2');
+ })
+ );
+
it('should support mixed array/object variable with a mutating object',
inject(function($rootScope, $compile) {
element = $compile('')($rootScope);
@@ -424,6 +517,125 @@ describe('ngClass', function() {
})
);
+ it('should do value stabilization as expected when one-time binding',
+ inject(function($rootScope, $compile) {
+ element = $compile('')($rootScope);
+
+ $rootScope.$apply('className = "foo"');
+ expect(element).toHaveClass('foo');
+
+ $rootScope.$apply('className = "bar"');
+ expect(element).toHaveClass('foo');
+ })
+ );
+
+ it('should remove the watcher when static array one-time binding',
+ inject(function($rootScope, $compile) {
+ element = $compile('')($rootScope);
+
+ $rootScope.$apply('className = "foo"');
+ expect(element).toHaveClass('foo');
+
+ $rootScope.$apply('className = "bar"');
+ expect(element).toHaveClass('foo');
+ expect(element).not.toHaveClass('bar');
+ })
+ );
+
+ it('should remove the watcher when static map one-time binding',
+ inject(function($rootScope, $compile) {
+ element = $compile('')($rootScope);
+
+ $rootScope.$apply('fooPresent = true');
+ expect(element).toHaveClass('foo');
+
+ $rootScope.$apply('fooPresent = false');
+ expect(element).toHaveClass('foo');
+ })
+ );
+
+ it('should track changes of mutating object inside an array',
+ inject(function($rootScope, $compile) {
+ $rootScope.classVar = [{orange: true}];
+ element = $compile('')($rootScope);
+
+ $rootScope.$digest();
+ expect(element).toHaveClass('orange');
+
+ $rootScope.$apply('classVar[0].orange = false');
+ expect(element).not.toHaveClass('orange');
+ })
+ );
+
+ describe('large objects', function() {
+ var getProp;
+ var veryLargeObj;
+
+ beforeEach(function() {
+ getProp = jasmine.createSpy('getProp');
+ veryLargeObj = {};
+
+ Object.defineProperty(veryLargeObj, 'prop', {
+ get: getProp,
+ enumerable: true
+ });
+ });
+
+ it('should not be copied when using an expression', inject(function($compile, $rootScope) {
+ element = $compile('')($rootScope);
+ $rootScope.fooClass = {foo: veryLargeObj};
+ $rootScope.$digest();
+
+ expect(element).toHaveClass('foo');
+ expect(getProp).not.toHaveBeenCalled();
+ }));
+
+ it('should not be copied when using a literal', inject(function($compile, $rootScope) {
+ element = $compile('')($rootScope);
+ $rootScope.veryLargeObj = veryLargeObj;
+ $rootScope.$digest();
+
+ expect(element).toHaveClass('foo');
+ expect(getProp).not.toHaveBeenCalled();
+ }));
+
+ it('should not be copied when inside an array', inject(function($compile, $rootScope) {
+ element = $compile('')($rootScope);
+ $rootScope.veryLargeObj = veryLargeObj;
+ $rootScope.$digest();
+
+ expect(element).toHaveClass('foo');
+ expect(getProp).not.toHaveBeenCalled();
+ }));
+
+ it('should not be copied when using one-time binding', inject(function($compile, $rootScope) {
+ element = $compile('')($rootScope);
+ $rootScope.veryLargeObj = veryLargeObj;
+ $rootScope.$digest();
+
+ expect(element).toHaveClass('foo');
+ expect(element).not.toHaveClass('bar');
+ expect(getProp).not.toHaveBeenCalled();
+
+ $rootScope.$apply('veryLargeObj.bar = "bar"');
+
+ expect(element).toHaveClass('foo');
+ expect(element).not.toHaveClass('bar');
+ expect(getProp).not.toHaveBeenCalled();
+
+ $rootScope.$apply('bar = "bar"');
+
+ expect(element).toHaveClass('foo');
+ expect(element).toHaveClass('bar');
+ expect(getProp).not.toHaveBeenCalled();
+
+ $rootScope.$apply('veryLargeObj.bar = "qux"');
+
+ expect(element).toHaveClass('foo');
+ expect(element).toHaveClass('bar');
+ expect(getProp).not.toHaveBeenCalled();
+ }));
+ });
});
describe('ngClass animations', function() {