Force elements to be the same height - with Angular (duh)

Disclaimer: This assumes that you have a general knowledge of Angular and how to set up a project.

One of the most common requests that I get from creatives when we start reviewing a project is that they would like all of the button CTA's to line up horizontally across the screen, or that they would like all of the borders to the same height; regardless of the length of the contents. But as I started to brainstorm and mentally compile a game plan on how to solve this issue, it dawned upon me that components like carousels and drop downs could very likely require this type of treatment.

Yes I know...

Before I begin to explain myself any further, I will disclose that I am fully aware that there other third-party solutions out in the development cosmos that perform this type of function; so I know that I'm actually duplicating some work and I'm essentially re-inventing the wheel. However this approach suited my needs more appropriately without the requirement to download and install yet one more library.

The directive mystery

One more bit of information that I will add is that I implemented this solution using an Angular directive. I know that for some Angular developers, directives can be that mystical unicorn that just work for us, yet we have no idea how they work. I won't bother you with a lengthy description for the basis of this how-to, but if want a little more information on these mythical creatures, here are the resources that I used to understand directives:

A couple building blocks

There are couple items that we need before we actually begin to build the directive (which is why you are here!)

Window resize directive

First we need to build an additional directive that will handle a window resize event. This allows us to fire events in a single place with one snippet of code without the unnecessary rewrites or performance dip that a browser can suffer with multiple chaining resize calls. First we will create a utility module like so:

var utility = angular.module('utilityModule', []);

This module will store all of our utility code that we can then inject as a dependency elsewhere in our project. Next we need to write the directive that will fire a $broadcast on both the local and root scope. If you don't know what a broadcast is, think of it as a drill sergeant yell into megaphone, telling the listeners (or soldiers) to do something.

utility.directive('ngWindowResize', ['$window', '$rootScope', function($window, $rootScope){
  return {
    restrict: 'CA',
    link: function(scope) {
      angular.element($window).on('resize', function(e) {
        scope.$broadcast('window-resize', {
          'eventTarget': e.target
        });

        $rootScope.$broadcast('window-resize', {
          'eventTarget': e.target
        });
      });
    }
  };
}]);

Notice the dependency annotation that I have added to prevent this code from breaking during the minification process. Once you have this directive written and saved, initialize it on the body tag within your application like so:

<body ng-window-resize>

Utility service

With most projects that you will be building, you will need to find a place for some utilitarian code that is generic and will most likely be reused at least once. That's why I usually create a service (actually its a factory, but who's keeping track) to house this code. This what my basic service looks like:

utility.factory('utilityService', ['$window', function($window) {
  var service = {};
  var mobileBreakPoint = 767;

  service.isMobile = function() {
    if ($window.innerWidth <= mobileBreakPoint) {
      return true;
    } else {
      return false;
    }
  };

  return service;
}]);

All this service does at the moment is look at the window width and determines if the dimensions makes it a mobile device. Feel free to add anything else you would like here.

The same height directive

Now that we have a basic foundational necessities in place, now its time for the meat and potatoes of our project. This directive will be written in such a way that we will place it on our parent element and all of the immediate children (only the next level down) elements will be the same height; like so:

<div class="slider-wrapper">
  <div class="slide"></div>
  <div class="slide"></div>
  <div class="slide"></div>
</div>

For demonstration purposes we will create a basic shell of our directive and then we will gradually add to it and make it smarter than the average bear.

utility.directive('ngAllSameHeight', ['$timeout', '$q', function($timeout, $q) {
  return {
    restrict: 'CAD',
    link: function(scope, element) {

    }
  }
}]);

Selecting our elements

In case you didn't know, within a directive, you can access everything about the element that you apply the directive to. For example, on the resize directive that we applied to the <body> tag above, using the element function parameter we can access everything form the height, width, class, and children elements. The array of children elements is what we want to access. This can be done like so:

var selectedElements;

$timeout(function() {
  selectedElements = element.children();
});

Pay special attention to the $timeout function that was wrapped around the selector. This was done to delay the archive till after the page is built. If this timeout was not added, the selector would return nothing. Paste the above code within the link function.

Important: for the time being, we will be pasting all of our code within the link parameter that is returned by our directive.

Calculate the height of our elements

Now that we have built our selector and we have an array of our elements, we now need to build a function to loop through every single element and perform the following functions:

  1. Get the element's height
  2. If the element's height is greater than the previous element's, increase the number within the height variable
  3. Apply an inline CSS height value of the greatest height value that was calculated in our loop
      scope.calculateHeight = function() {
        return $q(function(resolve) {
          var height = 0;

          for (var i = 0; i < selectedElements.length; i++) {
            var offsetHeight = selectedElements[i].offsetHeight;

            if (height < offsetHeight) {
              height = offsetHeight;
              scope.height = height;
            }
          }
          for (var i = 0; i < selectedElements.length; i++) {
            angular.element(selectedElements[i]).css('height', height + 'px');
          }
          resolve();
        });
      };

I want to direct your attention to the use of the $q variable that was used in the function above. This is a service provided to us by Angular to chain functions together in a specific order that can either handle values from function to function, or can be canceled without throwing an error. These are called promises.

If you want more information on promises, see the Angular documentation on promises or have a look at this good article.

Reset our height

Now that we have written a function to calculate our height, we now need the ability to reset the height of our children elements. This need will come up when we resize our device's window/viewport. Again, notice the use of the promise.

      scope.resetHeight = function () {
        return $q(function(resolve) {
          for (var i = 0; i < selectedElements.length; i++) {
            angular.element(selectedElements[i]).css('height', 'auto');
          }
          resolve();
        });
      };

Acting upon our device

Before we add another function, we now need to inject our utility service that we built a couple sections ago. Once again, this gives us the ability to quickly return a boolean if the window has the width of a mobile device. To inject the service, we need to add it to our directive definition and annotate it like so:

utility.directive('ngAllSameHeight', ['$timeout', '$q', 'utilityService', function($timeout, $q, utilityService) {

Then we need to assign the service to a scope variable:

scope.service = utilityService;

Now that this is in place, lets add one more function that will reset the height of each element, look at the device width, and then recalculate the height if necessary.

      scope.checkMobileView = function() {
        if (scope.service.isMobile()) {
          scope.resetHeight();
        } else {
          scope.resetHeight().then(function() {
            scope.calculateHeight();
          });
        }
      };

Before we go any further, we cannot forget to add this function to our first timeout function at the top of the file. Otherwise nothing will happen when the page loads, which is not what we want. This is what your timeout function should look like now:

$timeout(function() {
  selectedElements = element.children();

  scope.checkMobileView();
});

Wrangling the window

Remember that window resize directive that we wrote a bit earlier? Now it's time to tap into its power.

With the newest function that we just wrote, we can now reset and calculate the height. However this function will not work on its own. We need to tell it to recalculate these values when the window is resized. That is where the $on Angular service comes in. This is a basic listening service that we use to listen to those $broadcasted messages. So, if you refer to the window resize directive, we need to listen for the window-resize message and then fire a function when that happens, like so:

      scope.$on('window-resize', function() {
        $timeout(function() {
          scope.checkMobileView();
        });
      });

But wait there's more

So at this point you now have a directive that will be able to successfully calculate the height of a bunch of immediate children elements within a single parent.

But what if you wanted to target elements that were directly nested within the parent? For instance, you have a bunch of cards floating to the left and you want all of the text containers to be the same height so the CTA buttons all line up. Well we would need an additional selector to do that.

First we need to add a scope variable listener to the directive, like so:

utility.directive('ngAllSameHeight', ['$timeout', '$q', 'utilityService', function($timeout, $q, utilityService) {
  return {
    restrict: 'CAD',
    scope: {
      targetEl: '@targetElement'
    },
    link: function(scope, element) {
      /* all of your other code is here */
    }
  }
}]);

Next we need to add a couple lines to our first $timeout function like so:

      $timeout(function() {
        if (!scope.targetEl) {
          // if an element is not defined, it will look for the next available element
          selectedElements = element.children();
        } else {
          selectedElements = angular.element(element).find(scope.targetEl);
        }

        scope.checkMobileView();
      });

To define the scope variable scope.targetEl, we will need to add an attribute in addition to our directive definition, like so:

<div class="slider-wrapper" ng-all-same-height target-element="p.description"></div>

The target-element variable is renamed to scope.targetEl and then passed to a jQLite .find() function to find all of the elements with those criteria, and then the directive's functionality runs as expected.

We are all finished

Assuming everything went as planned, you now have a completely custom directive that will automatically calculate and recalculate an element's children's height properties and mass assign them. Congrats!

Below is a version of the completed code for your records:

utility.directive('ngAllSameHeight', ['$timeout', '$q', 'utilityService', function($timeout, $q, utilityService) {
  return {
    restrict: 'CAD',
    scope: {
      targetEl: '@targetElement'
    },
    link: function(scope, element) {
      var selectedElements;

      scope.height = null;
      scope.service = utilityService;

      $timeout(function() {
        if (!scope.targetEl) {
          // if an element is not defined, it will look for the next available element
          selectedElements = element.children();
        } else {
          selectedElements = angular.element(element).find(scope.targetEl);
        }

        scope.checkMobileView();
      });

      // sets height of element
      scope.calculateHeight = function() {
        return $q(function(resolve) {
          var height = 0;

          for (var i = 0; i < selectedElements.length; i++) {
            var offsetHeight = selectedElements[i].offsetHeight;

            if (height < offsetHeight) {
              height = offsetHeight;
              scope.height = height;
            }
          }
          for (var i = 0; i < selectedElements.length; i++) {
            angular.element(selectedElements[i]).css('height', height + 'px');
          }
          resolve();
        });
      };

      // resets height to auto
      scope.resetHeight = function () {
        return $q(function(resolve) {
          for (var i = 0; i < selectedElements.length; i++) {
            angular.element(selectedElements[i]).css('height', 'auto');
          }
          resolve();
        });
      };

      // checks to see if mobile and applies class and styles
      scope.checkMobileView = function() {
        if (scope.service.isMobile()) {
          scope.resetHeight();
        } else {
          scope.resetHeight().then(function() {
            scope.calculateHeight();
          });
        }
      };

      // watches screen resize and re-calculates the height of the elements
      scope.$on('window-resize', function() {
        $timeout(function() {
          scope.checkMobileView();
        });
      });
    }
  }
}]);

Show Comments