Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at cf.Objective() 2014 (Bloomington, MN) with: Oguz Demirkapi
Ben Nadel at cf.Objective() 2014 (Bloomington, MN) with: Oguz Demirkapi@demirkapi )

Consuming The Uniform jQuery Plugin In AngularJS

By Ben Nadel on

In the world of AngularJS, we concentrate so much on the View Model (Scope) that it is not always obvious as to how to consume functionality that reacts to the DOM (Document Object Model). Take, for example, the Uniform plugin in the jQuery ecosystem. The Uniform plugin presents more visually pleasing form elements that synchronize with the underlying DOM elements. But, when those DOM elements are, in turn, reacting to the View Model, it can be tricky to properly time of the Uniform synchronization.


 
 
 

 
 
 
 
 

Run this demo in my JavaScript Demos project on GitHub.

If thinking about ngModel and the DOM state wasn't hard enough, we can throw the ngValue directive into the mix as well. The ngValue directive allows view-model expressions (as opposed to simple string values) to be bound to the value of the ngModel. When we do this, the flow of information goes something like this:

View Model -> ngValue -> ngModel -> Input Element -> Uniform Element(s).

And, if thinking about the flow of data wasn't hard enough, we also have to consider the fact that the underlying mechanism of flow is driven by $watch() bindings that are bound during linking phases that occur at different priorities. And, on top of that, we don't want to do anything that will force the browser to repaint at an inappropriate time.

In order to integrate Uniform with AngularJS, we're going to need a directive. So, let's consider when that directive will execute. Here are the priorities of the other directives involved:

  • ngModel - Priority 0 (but links during the Pre-linking phase).
  • ngValue - Priority 100.

Remember, directives compile in descending priority order and then link in reverse order. Since we want to give the ngValue directive time to pipe the correct value into the relevant ngModel expression, we want our Uniform directive to link afterwards. As such, we'll give the Uniform plugin a priority of 101.

Once we have our link priority in order, we have to think about when we want to render and update the Uniform state. Typically, we never want to query the state of the DOM in the link function body as this can force a repaint. Instead, we'll want to defer synchronization until a later time. And, since we have to watch for changes in the ngModel expression anyway, we can do all of this inside of a $watch() binding in our directive.

Through trial and error, however, we will discover that synchronizing Uniform directly in our $watch() binding will force a browser repaint. This is not good. As such, we need to further defer the synchronization through the use of scope.$evalAsync(). This way, synchronization will happen at the end of the current $digest cycle.

Now, if we are going to be using scope.$evalAsync() to synchronize the Uniform plugin, the concept of directive priority really becomes moot. As such, you'll see that the code doesn't actually define a priority. There's no sense in worrying about the order of $watch() bindings if the update is being pushed into an async queue.

Finally, the last thing we have to do is teardown the jQuery plugin when the scope of the directive is destroyed. This is true for all jQuery plugins, and can be done inside of the $destroy binding.

NOTE: Many jQuery plugins do not have a teardown() or destroy() method which makes them unsuitable for a Single Page Application (SPA) and is the cause of much memory leakage. This has nothing to do with AngularJS and would be the same for any SPA built with any framework.

Pulling it all together, the code looks like this:

  • <!doctype html>
  • <html ng-app="Demo">
  • <head>
  • <meta charset="utf-8" />
  •  
  • <title>
  • Consuming The Uniform jQuery Plugin In AngularJS
  • </title>
  •  
  • <link rel="stylesheet" type="text/css" href="./demo.css"></link>
  • <link rel="stylesheet" type="text/css" href="../../vendor/uniform/theme/css/uniform.default.css"></link>
  • </head>
  • <body ng-controller="AppController as vm">
  •  
  • <h1>
  • Consuming The Uniform jQuery Plugin In AngularJS
  • </h1>
  •  
  • <p>
  • <a ng-click="vm.toggleForm()">Toggle the form</a>.
  • </p>
  •  
  • <!-- We are going to toggle to form to showcase DOM element creation and linking. -->
  • <form ng-if="vm.isShowingForm">
  •  
  • <p>
  • Who is the most bad-ass actress:
  • </p>
  •  
  • <ul>
  • <li ng-repeat="actor in vm.actors track by actor.id">
  •  
  • <!--
  • Each Input will have the Uniform directive, which will take care of
  • synchronizing the state of the input to the Uniform instance.
  • -->
  • <label>
  • <input
  • type="radio"
  • ng-model="vm.mostBadass"
  • ng-value="actor"
  • bn-uniform
  • />
  • {{ actor.name }}
  • </label>
  •  
  • </li>
  • </ul>
  •  
  • <p>
  • <!-- This is just an alternate input element for the same selection. -->
  • <select
  • ng-model="vm.mostBadass"
  • ng-options="actor.name for actor in vm.actors track by actor.id"
  • bn-uniform>
  •  
  • <option value="">None selected</option>
  •  
  • </select>
  • </p>
  •  
  • <p>
  • <a ng-click="vm.selectLastActor()">Select last actor</a>
  • &mdash;
  • <a ng-click="vm.selectNone()">Select no actor</a>.
  • </p>
  •  
  • <!--
  • This forces the page to have vertical scrolling. This is here to show that
  • our Uniform plugin won't force a repaint before the DOM has been structured
  • properly.
  • -->
  • <p ng-if="vm.isShowingForm" class="spacer">
  • <br />
  • </p>
  •  
  • </form>
  •  
  • <!-- More forced vertical scrolling. -->
  • <p ng-if="! vm.isShowingForm" class="spacer">
  • <br />
  • </p>
  •  
  •  
  • <!-- Load scripts. -->
  • <script type="text/javascript" src="../../vendor/jquery/jquery-2.1.0.min.js"></script>
  • <script type="text/javascript" src="../../vendor/uniform/jquery.uniform-2.1.2.js"></script>
  • <script type="text/javascript" src="../../vendor/angularjs/angular-1.3.16.js"></script>
  • <script type="text/javascript">
  •  
  • // Create an application module for our demo.
  • angular.module( "Demo", [] );
  •  
  •  
  • // --------------------------------------------------------------------------- //
  • // --------------------------------------------------------------------------- //
  •  
  •  
  • // I control the root for the application.
  • angular.module( "Demo" ).controller(
  • "AppController",
  • function( $scope ) {
  •  
  • var vm = this;
  •  
  • // I am the badass actresses to list.
  • vm.actors = [
  • {
  • id: 1,
  • name: "Angela Bassett"
  • },
  • {
  • id: 2,
  • name: "Linda Hamilton"
  • },
  • {
  • id: 3,
  • name: "Michelle Yeoh"
  • },
  • {
  • id: 4,
  • name: "Gina Carano"
  • }
  • ];
  •  
  • // I hold the currently-selected badass.
  • vm.mostBadass = null
  •  
  • // I determine if the form is currently being show.
  • vm.isShowingForm = true;
  •  
  • // I watch for changes in the actress selection.
  • $scope.$watch( "vm.mostBadass", handleBadassChange );
  •  
  • // Expose the public API.
  • vm.selectLastActor = selectLastActor;
  • vm.selectNone = selectNone;
  • vm.toggleForm = toggleForm;
  •  
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  •  
  • // I select the last actress in the list.
  • function selectLastActor() {
  •  
  • vm.mostBadass = vm.actors[ vm.actors.length - 1 ];
  •  
  • }
  •  
  •  
  • // I deselect the most badass actress.
  • function selectNone() {
  •  
  • vm.mostBadass = null;
  •  
  • }
  •  
  •  
  • // I toggle the visibility of the form.
  • function toggleForm() {
  •  
  • vm.isShowingForm = ! vm.isShowingForm;
  •  
  • }
  •  
  •  
  • // ---
  • // PRIVATE METHODS.
  • // ---
  •  
  •  
  • // I log the selected value upon change.
  • function handleBadassChange( newValue ) {
  •  
  • if ( newValue ) {
  •  
  • console.log( "Changed to %s.", newValue.name );
  •  
  • } else {
  •  
  • console.log( "Changed to no selection." );
  •  
  • }
  •  
  • }
  •  
  • }
  • );
  •  
  •  
  • // --------------------------------------------------------------------------- //
  • // --------------------------------------------------------------------------- //
  •  
  •  
  • // I bind the Uniform jQuery plugin to the current Form element. This depends
  • // on the existence of the ngModel directive.
  • angular.module( "Demo" ).directive(
  • "bnUniform",
  • function() {
  •  
  • // Return the directive configuration object.
  • return({
  • link: link,
  • restrict: "A"
  • });
  •  
  •  
  • // I bind the JavaScript events to the view-model.
  • function link( scope, element, attributes ) {
  •  
  • // Because we are deferring the application of the Uniform plugin,
  • // this will help us keep track of whether or not the plugin has been
  • // applied.
  • var uniformedElement = null;
  •  
  • // We don't want to link-up the Uniform plugin right away as it will
  • // query the DOM (Document Object Model) layout which will cause the
  • // browser to repaint which will, in turn, lead to unexpected and poor
  • // behaviors like forcing a scroll of the page. Since we have to watch
  • // for ngModel value changes anyway, we'll defer our Uniform plugin
  • // instantiation until after the first $watch() has fired.
  • scope.$watch( attributes.ngModel, handleModelChange );
  •  
  • // When the scope is destroyed, we have to teardown our jQuery plugin
  • // to in order to make sure that it releases memory.
  • scope.$on( "$destroy", handleDestroy );
  •  
  •  
  • // ---
  • // PRIVATE METHODS.
  • // ---
  •  
  •  
  • // I clean up the directive when the scope is destroyed.
  • function handleDestroy() {
  •  
  • // If the Uniform plugin has not yet been applied, there's nothing
  • // that we have to explicitly teardown.
  • if ( ! uniformedElement ) {
  •  
  • return;
  •  
  • }
  •  
  • uniformedElement.uniform.restore( uniformedElement );
  •  
  • }
  •  
  •  
  • // I handle changes in the ngModel value, translating it into an
  • // update to the Uniform plugin.
  • function handleModelChange( newValue, oldValue ) {
  •  
  • // If we try to call render right away, two things will go wrong:
  • // first, we won't give the ngValue directive time to pipe the
  • // correct value into ngModle; and second, it will force an
  • // undesirable repaint of the browser. As such, we'll perform the
  • // Uniform synchronization at a later point in the $digest.
  • scope.$evalAsync( synchronizeUniform );
  •  
  • }
  •  
  •  
  • // I synchronize Uniform with the underlying form element.
  • function synchronizeUniform() {
  •  
  • // Since we are executing this at a later point in the $digest
  • // life-cycle, we need to ensure that the scope hasn't been
  • // destroyed in the interim period. While this is unlikely (if
  • // not impossible - I haven't poured over the details of the $digest
  • // in this context) it's still a good idea as it embraces the
  • // nature of the asynchronous control flow.
  • // --
  • // NOTE: During the $destroy event, scope is detached from the
  • // scope tree and the parent scope is nullified. This is why we
  • // are checking for the absence of a parent scope to indicate
  • // destruction of the directive.
  • if ( ! scope.$parent ) {
  •  
  • return;
  •  
  • }
  •  
  • // If Uniform has not yet been integrated, apply it to the element.
  • if ( ! uniformedElement ) {
  •  
  • return( uniformedElement = element.uniform() );
  •  
  • }
  •  
  • // Otherwise, update the existing instance.
  • uniformedElement.uniform.update( uniformedElement );
  •  
  • }
  •  
  • }
  •  
  • }
  • );
  •  
  • </script>
  •  
  • </body>
  • </html>

As you can see, there's actually very little code involved in the Uniform plugin. The complicated part about consuming the Uniform plugin is more about timing than anything else - when do we instantiate, when to update, and how do we keep the user experience positive?


 
 
 

 
 Consuming the Uniform jQuery plugin in an AngularJS context. 
 
 
 

At first, I tried to attack this problem by injecting the ngModelController and binding to the $render() method and the $viewChangeListeners[] collection. But, the timing never quite worked and the logic ended up being more complicated (especially when you need to keep the original $render() binding - by the ngModel directive - in tact). The $watch()-based approach feels like it's the cleanest and the easiest to reason about.




Reader Comments

Interesting article, Ben. I was wondering whether you had seen Angular Form Lib (https://uglow.github.io/angular-form-lib/)? It's a pure Angular form library that keeps labels, error messages and form controls in-sync and accessible. Maybe it would allow you to accomplish the same behaviour without requiring jQuery?

Reply to this Comment

Thanks for yet another great walktrough. I really enjoy the format of your blogs/videos. Thumbs up!
I've been using uniformJS for its great styling capabilities for years, but when I switched to angular I started working on a project with the same styling-goal in mind. Thought you might find it interesting - its not done yet, but Im using it production on multiple sites with great succes. https://github.com/skybrud/skyform

Reply to this Comment

Very nice article!!!

I tried to create a directive that i'll use the attribute 'bn-uniform' in forms elements that are of checkbox type.

But didnĀ“t work....

Here is the code what i was trying to write:

appModule.directive('customerForm', function () {
return {
restrict: 'AE',
linl: link,
templateUrl: '/customerForm.html'
};

function link(scope, element, attrs) {
var checkboxs = element[0].querySelectorAll("input[type='checkbox'");
for (var i = 0; i < checkboxs.length - 1; i++) {
checkboxs[i].setAttribute('bn-uniform', '');
};
};
});

Do you have any idea what this doesn't work?

Thanks!

Reply to this Comment

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.