From 2c3f1c9f02ea3f2a90054556637a11f142010764 Mon Sep 17 00:00:00 2001
From: Adam Bradley <adambradley25@gmail.com>
Date: Tue, 26 Aug 2014 22:23:07 -0500
Subject: [PATCH] feat($ionicBody): service to simplify body ele interaction

Many services/directives have to interact with the body element, and
each one has to write the same long code. The $ionicBody service
provides some useful methods to clean up and reduce redundant code.
---
 js/angular/controller/sideMenuController.js   | 18 ++---
 js/angular/directive/sideMenus.js             | 15 +---
 js/angular/service/actionSheet.js             | 10 +--
 js/angular/service/body.js                    | 80 +++++++++++++++++++
 js/angular/service/loading.js                 | 14 ++--
 js/angular/service/modal.js                   | 10 +--
 js/angular/service/popup.js                   | 10 +--
 .../controller/sideMenuController.unit.js     |  8 ++
 test/unit/angular/directive/sideMenu.unit.js  | 12 +++
 test/unit/angular/service/body.unit.js        | 71 ++++++++++++++++
 10 files changed, 203 insertions(+), 45 deletions(-)
 create mode 100644 js/angular/service/body.js
 create mode 100644 test/unit/angular/service/body.unit.js

diff --git a/js/angular/controller/sideMenuController.js b/js/angular/controller/sideMenuController.js
index 4c628b020f3..417ca3f4016 100644
--- a/js/angular/controller/sideMenuController.js
+++ b/js/angular/controller/sideMenuController.js
@@ -4,8 +4,8 @@ IonicModule
   '$attrs',
   '$ionicSideMenuDelegate',
   '$ionicPlatform',
-  '$document',
-function($scope, $attrs, $ionicSideMenuDelegate, $ionicPlatform, $document) {
+  '$ionicBody',
+function($scope, $attrs, $ionicSideMenuDelegate, $ionicPlatform, $ionicBody) {
   var self = this;
   var rightShowing, leftShowing, isDragging;
   var startX, lastX, offsetX, isAsideExposed;
@@ -132,11 +132,9 @@ function($scope, $attrs, $ionicSideMenuDelegate, $ionicPlatform, $document) {
       self.openAmount(self.right.width * p);
     }
 
-    if(percentage !== 0) {
-      $document[0].body.classList.add('menu-open');
-    } else {
-      $document[0].body.classList.remove('menu-open');
-    }
+    // add the CSS class "menu-open" if the percentage does not
+    // equal 0, otherwise remove the class from the body element
+    $ionicBody.enableClass( (percentage !== 0), 'menu-open');
   };
 
   /**
@@ -269,11 +267,7 @@ function($scope, $attrs, $ionicSideMenuDelegate, $ionicPlatform, $document) {
   };
 
   self.activeAsideResizing = function(isResizing) {
-    if(isResizing) {
-      $document[0].body.classList.add('aside-resizing');
-    } else {
-      $document[0].body.classList.remove('aside-resizing');
-    }
+    $ionicBody.enableClass(isResizing, 'aside-resizing');
   };
 
   // End a drag with the given event
diff --git a/js/angular/directive/sideMenus.js b/js/angular/directive/sideMenus.js
index d008b35e71d..b8857174385 100644
--- a/js/angular/directive/sideMenus.js
+++ b/js/angular/directive/sideMenus.js
@@ -64,10 +64,7 @@ IonicModule
  * with {@link ionic.service:$ionicSideMenuDelegate}.
  *
  */
-.directive('ionSideMenus', ['$document', function($document) {
-
-  var ASIDE_OPEN_CSS = 'aside-open';
-
+.directive('ionSideMenus', ['$ionicBody', function($ionicBody) {
   return {
     restrict: 'ECA',
     controller: '$ionicSideMenus',
@@ -76,21 +73,15 @@ IonicModule
 
       return { pre: prelink };
       function prelink($scope) {
-        var bodyClassList = $document[0].body.classList;
 
         $scope.$on('$ionicExposeAside', function(evt, isAsideExposed){
           if(!$scope.$exposeAside) $scope.$exposeAside = {};
           $scope.$exposeAside.active = isAsideExposed;
-          if(isAsideExposed) {
-            bodyClassList.add(ASIDE_OPEN_CSS);
-          } else {
-            bodyClassList.remove(ASIDE_OPEN_CSS);
-          }
+          $ionicBody.enableClass(isAsideExposed, 'aside-open');
         });
 
         $scope.$on('$destroy', function(){
-          bodyClassList.remove('menu-open');
-          bodyClassList.remove(ASIDE_OPEN_CSS);
+          $ionicBody.removeClass('menu-open', 'aside-open');
         });
 
       }
diff --git a/js/angular/service/actionSheet.js b/js/angular/service/actionSheet.js
index f69a7366deb..17eabd26e67 100644
--- a/js/angular/service/actionSheet.js
+++ b/js/angular/service/actionSheet.js
@@ -51,13 +51,13 @@
 IonicModule
 .factory('$ionicActionSheet', [
   '$rootScope',
-  '$document',
   '$compile',
   '$animate',
   '$timeout',
   '$ionicTemplateLoader',
   '$ionicPlatform',
-function($rootScope, $document, $compile, $animate, $timeout, $ionicTemplateLoader, $ionicPlatform) {
+  '$ionicBody',
+function($rootScope, $compile, $animate, $timeout, $ionicTemplateLoader, $ionicPlatform, $ionicBody) {
 
   return {
     show: actionSheet
@@ -119,7 +119,7 @@ function($rootScope, $document, $compile, $animate, $timeout, $ionicTemplateLoad
 
       scope.removed = true;
       sheetEl.removeClass('action-sheet-up');
-      $document[0].body.classList.remove('action-sheet-open');
+      $ionicBody.removeClass('action-sheet-open');
       scope.$deregisterBackButton();
       stateChangeListenDone();
 
@@ -135,8 +135,8 @@ function($rootScope, $document, $compile, $animate, $timeout, $ionicTemplateLoad
     scope.showSheet = function(done) {
       if (scope.removed) return;
 
-      $document[0].body.appendChild(element[0]);
-      $document[0].body.classList.add('action-sheet-open');
+      $ionicBody.append(element)
+                .addClass('action-sheet-open');
 
       $animate.addClass(element, 'active', function() {
         if (scope.removed) return;
diff --git a/js/angular/service/body.js b/js/angular/service/body.js
new file mode 100644
index 00000000000..05d4fd451af
--- /dev/null
+++ b/js/angular/service/body.js
@@ -0,0 +1,80 @@
+/**
+ * @ngdoc service
+ * @name $ionicBody
+ * @module ionic
+ * @description An angular utility service to easily and efficiently
+ * add and remove CSS classes from the document's body element.
+ */
+IonicModule
+.factory('$ionicBody', ['$document', function($document) {
+  return {
+    /**
+     * @ngdoc method
+     * @name $ionicBody#add
+     * @description Add a class to the document's body element.
+     * @param {string} class Each argument will be added to the body element.
+     * @returns {$ionicBody} The $ionicBody service so methods can be chained.
+     */
+    addClass: function() {
+      for(var x=0; x<arguments.length; x++) {
+        $document[0].body.classList.add(arguments[x]);
+      }
+      return this;
+    },
+    /**
+     * @ngdoc method
+     * @name $ionicBody#removeClass
+     * @description Remove a class from the document's body element.
+     * @param {string} class Each argument will be removed from the body element.
+     * @returns {$ionicBody} The $ionicBody service so methods can be chained.
+     */
+    removeClass: function() {
+      for(var x=0; x<arguments.length; x++) {
+        $document[0].body.classList.remove(arguments[x]);
+      }
+      return this;
+    },
+    /**
+     * @ngdoc method
+     * @name $ionicBody#enableClass
+     * @description Similar to the `add` method, except the first parameter accepts a boolean
+     * value determining if the class should be added or removed. Rather than writing user code,
+     * such as "if true then add the class, else then remove the class", this method can be
+     * given a true or false value which reduces redundant code.
+     * @param {boolean} shouldEnableClass A true/false value if the class should be added or removed.
+     * @param {string} class Each remaining argument would be added or removed depending on
+     * the first argument.
+     * @returns {$ionicBody} The $ionicBody service so methods can be chained.
+     */
+    enableClass: function(shouldEnableClass) {
+      var args = Array.prototype.slice.call(arguments).slice(1);
+      if(shouldEnableClass) {
+        this.addClass.apply(this, args);
+      } else {
+        this.removeClass.apply(this, args);
+      }
+      return this;
+    },
+    /**
+     * @ngdoc method
+     * @name $ionicBody#append
+     * @description Append a child to the document's body.
+     * @param {element} element The element to be appended to the body. The passed in element
+     * can be either a jqLite element, or a DOM element.
+     * @returns {$ionicBody} The $ionicBody service so methods can be chained.
+     */
+    append: function(ele) {
+      $document[0].body.appendChild( ele.length ? ele[0] : ele );
+      return this;
+    },
+    /**
+     * @ngdoc method
+     * @name $ionicBody#get
+     * @description Get the document's body element.
+     * @returns {element} Returns the document's body element.
+     */
+    get: function() {
+      return $document[0].body;
+    }
+  };
+}]);
diff --git a/js/angular/service/loading.js b/js/angular/service/loading.js
index 13e81e59c3f..a2f7ab25921 100644
--- a/js/angular/service/loading.js
+++ b/js/angular/service/loading.js
@@ -58,7 +58,7 @@ IonicModule
 })
 .factory('$ionicLoading', [
   '$ionicLoadingConfig',
-  '$document',
+  '$ionicBody',
   '$ionicTemplateLoader',
   '$ionicBackdrop',
   '$timeout',
@@ -66,7 +66,7 @@ IonicModule
   '$log',
   '$compile',
   '$ionicPlatform',
-function($ionicLoadingConfig, $document, $ionicTemplateLoader, $ionicBackdrop, $timeout, $q, $log, $compile, $ionicPlatform) {
+function($ionicLoadingConfig, $ionicBody, $ionicTemplateLoader, $ionicBackdrop, $timeout, $q, $log, $compile, $ionicPlatform) {
 
   var loaderInstance;
   //default values
@@ -104,7 +104,7 @@ function($ionicLoadingConfig, $document, $ionicTemplateLoader, $ionicBackdrop, $
     if (!loaderInstance) {
       loaderInstance = $ionicTemplateLoader.compile({
         template: LOADING_TPL,
-        appendTo: $document[0].body
+        appendTo: $ionicBody.get()
       })
       .then(function(loader) {
         var self = loader;
@@ -144,8 +144,10 @@ function($ionicLoadingConfig, $document, $ionicTemplateLoader, $ionicBackdrop, $
             if (self.isShown) {
               self.element.addClass('visible');
               ionic.requestAnimationFrame(function() {
-                self.isShown && self.element.addClass('active');
-                self.isShown && $document[0].body.classList.add('loading-active');
+                if(self.isShown) {
+                  self.element.addClass('active');
+                  $ionicBody.addClass('loading-active');
+                }
               });
             }
           });
@@ -159,7 +161,7 @@ function($ionicLoadingConfig, $document, $ionicTemplateLoader, $ionicBackdrop, $
               $ionicBackdrop.getElement().removeClass('backdrop-loading');
             }
             self.element.removeClass('active');
-            $document[0].body.classList.remove('loading-active');
+            $ionicBody.removeClass('loading-active');
             setTimeout(function() {
               !self.isShown && self.element.removeClass('visible');
             }, 200);
diff --git a/js/angular/service/modal.js b/js/angular/service/modal.js
index 6e2863b7793..c3c7accc0a3 100644
--- a/js/angular/service/modal.js
+++ b/js/angular/service/modal.js
@@ -61,14 +61,14 @@
 IonicModule
 .factory('$ionicModal', [
   '$rootScope',
-  '$document',
+  '$ionicBody',
   '$compile',
   '$timeout',
   '$ionicPlatform',
   '$ionicTemplateLoader',
   '$q',
   '$log',
-function($rootScope, $document, $compile, $timeout, $ionicPlatform, $ionicTemplateLoader, $q, $log) {
+function($rootScope, $ionicBody, $compile, $timeout, $ionicPlatform, $ionicTemplateLoader, $q, $log) {
 
   /**
    * @ngdoc controller
@@ -124,12 +124,12 @@ function($rootScope, $document, $compile, $timeout, $ionicPlatform, $ionicTempla
 
       self.el.classList.remove('hide');
       $timeout(function(){
-        $document[0].body.classList.add(self.viewType + '-open');
+        $ionicBody.addClass(self.viewType + '-open');
       }, 400);
 
       if(!self.el.parentElement) {
         modalEl.addClass(self.animation);
-        $document[0].body.appendChild(self.el);
+        $ionicBody.append(self.el);
       }
 
       if(target && self.positionView) {
@@ -192,7 +192,7 @@ function($rootScope, $document, $compile, $timeout, $ionicPlatform, $ionicTempla
       ionic.views.Modal.prototype.hide.call(self);
 
       return $timeout(function(){
-        $document[0].body.classList.remove(self.viewType + '-open');
+        $ionicBody.removeClass(self.viewType + '-open');
         self.el.classList.add('hide');
       }, self.hideDelay || 500);
     },
diff --git a/js/angular/service/popup.js b/js/angular/service/popup.js
index a9529a02299..96adc248a85 100644
--- a/js/angular/service/popup.js
+++ b/js/angular/service/popup.js
@@ -110,10 +110,10 @@ IonicModule
   '$q',
   '$timeout',
   '$rootScope',
-  '$document',
+  '$ionicBody',
   '$compile',
   '$ionicPlatform',
-function($ionicTemplateLoader, $ionicBackdrop, $q, $timeout, $rootScope, $document, $compile, $ionicPlatform) {
+function($ionicTemplateLoader, $ionicBackdrop, $q, $timeout, $rootScope, $ionicBody, $compile, $ionicPlatform) {
   //TODO allow this to be configured
   var config = {
     stackPushDelay: 75
@@ -278,7 +278,7 @@ function($ionicTemplateLoader, $ionicBackdrop, $q, $timeout, $rootScope, $docume
     var popupPromise = $ionicTemplateLoader.compile({
       template: POPUP_TPL,
       scope: options.scope && options.scope.$new(),
-      appendTo: $document[0].body
+      appendTo: $ionicBody.get()
     });
     var contentPromise = options.templateUrl ?
       $ionicTemplateLoader.load(options.templateUrl) :
@@ -370,7 +370,7 @@ function($ionicTemplateLoader, $ionicBackdrop, $q, $timeout, $rootScope, $docume
     .then(function(popup) {
       if (!previousPopup) {
         //Add popup-open & backdrop if this is first popup
-        document.body.classList.add('popup-open');
+        $ionicBody.addClass('popup-open');
         $ionicBackdrop.retain();
         //only show the backdrop on the first popup
         $ionicPopup._backButtonActionDone = $ionicPlatform.registerBackButtonAction(
@@ -398,7 +398,7 @@ function($ionicTemplateLoader, $ionicBackdrop, $q, $timeout, $rootScope, $docume
           previousPopup.show();
         } else {
           //Remove popup-open & backdrop if this is last popup
-          document.body.classList.remove('popup-open');
+          $ionicBody.removeClass('popup-open');
           $ionicBackdrop.release();
           ($ionicPopup._backButtonActionDone || angular.noop)();
         }
diff --git a/test/unit/angular/controller/sideMenuController.unit.js b/test/unit/angular/controller/sideMenuController.unit.js
index ff9e20dd981..9ec6706b243 100644
--- a/test/unit/angular/controller/sideMenuController.unit.js
+++ b/test/unit/angular/controller/sideMenuController.unit.js
@@ -84,6 +84,14 @@ describe('$ionicSideMenus controller', function() {
     expect(ctrl.getOpenPercentage()).toEqual(-50);
   });
 
+  it('should add/remove menu-open from the body class', inject(function($document) {
+    expect($document[0].body.classList.contains('menu-open')).toEqual(false);
+    ctrl.openPercentage(100);
+    expect($document[0].body.classList.contains('menu-open')).toEqual(true);
+    ctrl.openPercentage(0);
+    expect($document[0].body.classList.contains('menu-open')).toEqual(false);
+  }));
+
   // Open
   it('should toggle left', function() {
     ctrl.toggleLeft();
diff --git a/test/unit/angular/directive/sideMenu.unit.js b/test/unit/angular/directive/sideMenu.unit.js
index 0892f90bf1a..0a048f7605e 100644
--- a/test/unit/angular/directive/sideMenu.unit.js
+++ b/test/unit/angular/directive/sideMenu.unit.js
@@ -40,6 +40,18 @@ describe('Ionic Angular Side Menu', function() {
     expect(sideMenuController.isAsideExposed()).toBe(false);
   }));
 
+  it('should add/remove "aside-resizing" from the body tag when using activeAsideResizing', inject(function($compile, $rootScope, $document) {
+    var el = $compile('<ion-side-menus><ion-side-menu></><ion-side-menu-content></ion-side-menu-content></ion-side-menus>')($rootScope.$new());
+    $rootScope.$apply();
+    var sideMenuController = el.controller('ionSideMenus');
+
+    expect($document[0].body.classList.contains('aside-resizing')).toEqual(false);
+    sideMenuController.activeAsideResizing(true);
+    expect($document[0].body.classList.contains('aside-resizing')).toEqual(true);
+    sideMenuController.activeAsideResizing(false);
+    expect($document[0].body.classList.contains('aside-resizing')).toEqual(false);
+  }));
+
   it('should emit $ionicexposeAside', inject(function($compile, $rootScope) {
     var el = $compile('<ion-side-menus><ion-side-menu></><ion-side-menu-content></ion-side-menu-content></ion-side-menus>')($rootScope.$new());
     $rootScope.$apply();
diff --git a/test/unit/angular/service/body.unit.js b/test/unit/angular/service/body.unit.js
new file mode 100644
index 00000000000..493f845e944
--- /dev/null
+++ b/test/unit/angular/service/body.unit.js
@@ -0,0 +1,71 @@
+describe('Ionic Body Class Service', function() {
+  var ionicBody, doc;
+
+  beforeEach(module('ionic'));
+
+  beforeEach(inject(function($ionicBody, $document) {
+    ionicBody = $ionicBody;
+    body = $document[0].body;
+  }));
+
+  it('Should add/remove one class the body tag', function() {
+    ionicBody.addClass('class1');
+    expect(body.classList.contains('class1')).toEqual(true);
+    ionicBody.removeClass('class1');
+    expect(body.classList.contains('class1')).toEqual(false);
+  });
+
+  it('Should add/remove multiple classes the body tag', function() {
+    ionicBody.addClass('class1', 'class2', 'class3');
+    expect(body.classList.contains('class1')).toEqual(true);
+    expect(body.classList.contains('class2')).toEqual(true);
+    expect(body.classList.contains('class3')).toEqual(true);
+    ionicBody.removeClass('class1', 'class2', 'class3');
+    expect(body.classList.contains('class1')).toEqual(false);
+    expect(body.classList.contains('class2')).toEqual(false);
+    expect(body.classList.contains('class3')).toEqual(false);
+  });
+
+  it('Should add/remove classes by chaining methods', function() {
+    ionicBody.addClass('class1').addClass('class2').addClass('class3');
+    expect(body.classList.contains('class1')).toEqual(true);
+    expect(body.classList.contains('class2')).toEqual(true);
+    expect(body.classList.contains('class3')).toEqual(true);
+    ionicBody.removeClass('class1').removeClass('class2').removeClass('class3');
+    expect(body.classList.contains('class1')).toEqual(false);
+    expect(body.classList.contains('class2')).toEqual(false);
+    expect(body.classList.contains('class3')).toEqual(false);
+  });
+
+  it('Should add/false one class using addTo and boolean first arg', function() {
+    ionicBody.enableClass(true, 'class1');
+    expect(body.classList.contains('class1')).toEqual(true);
+    ionicBody.enableClass(false, 'class1');
+    expect(body.classList.contains('class1')).toEqual(false);
+  });
+
+  it('Should add/false multiple classes using addTo and boolean first arg', function() {
+    ionicBody.enableClass(true, 'class1', 'class2', 'class3');
+    expect(body.classList.contains('class1')).toEqual(true);
+    expect(body.classList.contains('class2')).toEqual(true);
+    expect(body.classList.contains('class3')).toEqual(true);
+    ionicBody.enableClass(false, 'class1', 'class2', 'class3');
+    expect(body.classList.contains('class1')).toEqual(false);
+    expect(body.classList.contains('class2')).toEqual(false);
+    expect(body.classList.contains('class3')).toEqual(false);
+  });
+
+  it('Should append a jqLite element to the body', function() {
+    var jqLiteElement = angular.element('<div>jqLite</div>');
+    ionicBody.append(jqLiteElement);
+    expect(body.lastChild.outerHTML).toEqual('<div>jqLite</div>');
+  });
+
+  it('Should append a DOM element to the body', function() {
+    var domElement = document.createElement('span');
+    domElement.innerText = 'domElement'
+    ionicBody.append(domElement);
+    expect(body.lastChild.outerHTML).toEqual('<span>domElement</span>');
+  });
+
+});