CS Coding With Cody

Button Loaders

September 29th, 2015

This post will detail how to build a simple button loader using AngularJs.

This directive allows for a busy state to quickly and simply be applied to a form button in an Angular app. To use this directive define a button as you normally would, and then apply the btn-busy directive with the scope variable that controls the toggle btn-busy="isProcessing".

<button ng-click="submit()" btn-busy="isSubmitting" ng-disabled="isSubmitting">Submit</button>

Optionally you can add an ng-disabled to prevent additional clicks while the form is processing. Typically I'm against ng-disabled, but I think this is a great use case for it.

#btnBusy Directive

For the directive definition we have a template, and an isolate scope which will get its busy attribute from the directive definition. By having busy: "=btnBusy" in the scope definition angular will look for btn-busy on the directive instance (seen above), but it will make the variable available for usage in the directive's template via the busy variable for clarity. This allows our directive to be compact and not require an additional attribute.

Finally we want transclude true to be set. Transclude can be a bit scary at first, but all it does is it allows for the directive to extract the content in the directive instance (markup that is between the opening and closing button tags, which is 'Submit' in the example above), and inject that at some point into the directive's template. This point is specified by the ng-transclude tag.

myApp.directive("btnBusy", () => {
return {
template: ...,
transclude: true,
scope: {
busy: "=btnBusy"
},
restrict: "A",
};
});

For the directive template we start with a containing div, this will be the element that gets a position: relative, and will allow the button to remain at the same width as it was when it had text in it, before we replaced it with a spinner.

If we just allowed Angular's default hidden state the text would get a display: none and the button would collapse down to the size of the spinner. This way we get a much more polished experience, as well as a nice directive to hide all the logic that would be a pain to repeat every time.

Inside this container element we place a div with ng-transclude, remember this is the place holder for our injected content. This is the element that allows our directive to be reusable with any content. We add ng-hide='busy' so that we can control the state of button. When busy is true the content will be hidden.

The busy container will hold the busy animation, and will get a position: absolute so that it can be positioned to not take up space, and will be placed on top of the content. When the directive switches to its busy state the content will be hidden, but will continue to take up space. This will prevent the button from collapsing, but will force us to absolutely position the busy animation on top of the content.

<div>
<div ng-transclude ng-hide="busy" class="btn-busy-text"></div>
<div class="busy-container">
<div class="busy" ng-show="busy"></div>
</div>
</div>

All together we have:

myApp.directive('btnBusy', () => {
return {
template: `<div>
<div ng-transclude ng-hide="busy" class="btn-busy-text"></div>
<div class="busy-container">
<div class="busy" ng-show="busy"></div>
</div>
</div>`,
transclude: true,
scope: {
busy: '=btnBusy',
},
restrict: 'A',
};
});

For the css, we need to define a processing animation. I have chosen a spinner because it fits nicely, but you can substitute your favorite loading state.

First we have the keyframe definition, then inside our directive name space we have directive specific styles.

We have the containing div with a relative position, this allows the position absolute to be positioned relative to this parent element.

The busy class gets all of the animation styles including the animation definition. Notice infinite which allows the animation to repeat itself. We define a boarder as well as a border radius to get our circular spinner. We also need to override the animation with a 0s animation once ng-hide-add class is added to the element, but only if you have the ngAnimate module installed.

For a quick aside on animations which only apply if you have the ngAnimate module installed, once the scope variable becomes true for ng-hide angular will apply an ng-hide-add class as well as an ng-animate. After this class is added it will scan the element for any transitions or animations. It will store the specified time, and then it will add the ng-hide-add-active and ng-hide classes. Once these classes are applied it will wait for the above stored time until it removes the element from view by removing the ng-animate class which was blocking the affects of ng-hide. It does this because it assumes the applied animation or transition is meant to transition the element out of view. Typically this is exactly what we want, but for this use case we don't want the element to remain in view for an extra .8 seconds, so we override it to 0 seconds before angular does its check.

For the btn-busy-text class we need to override the default ng-hide styles. The default styles apply a display: none !important and would cause the element to disappear and collapse the button. We want it to remain a block level element even when hidden so we add display: block !important; Note the !important which is required to override Angular's !important. We also give it an opacity of 0 so that it appears hidden.

Then we give the busy-container an absolute position. Then we position it and give it a full width so it appears centered.

I have also added a default height and width, as well as a larger version, these will be specific to your site's button dimensions.

@keyframes rotate-360 {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
[btn-busy] {
> div {
position: relative;
}
.busy {
animation: rotate-360 0.8s infinite linear;
border: 1px solid slategray;
border-radius: 50%;
border-right-color: midnightblue;
border-top-color: midnightblue;
display: inline-block;
&.ng-hide-add {
animation: none 0s; //allow for quick removal (ignore .8 second animation from rotate-360)
}
}
.btn-busy-text {
&.ng-hide {
display: block !important; //override angular's default
opacity: 0;
}
}
.busy-container {
position: absolute;
top: 0px;
left: 0px;
width: 100%;
}
.busy {
height: 20px;
width: 20px;
}
&.btn-busy-large .busy {
height: 32px;
width: 32px;
}
}

Finally to see it in action we can make a form and apply the directive to our submit button.

<div ng-app="myApp">
<div ng-controller="Main as MainCtrl" class="container">
<form name="timeForm" class="form-inline" novalidate>
<h5>How long should we wait (ms)?</h5>
<div class="form-group">
<input
type="text"
class="input-s form-control"
name="time"
ng-model="MainCtrl.waitTime"
placeholder="time"
ng-required="true"
/>
</div>
<button
class="btn btn-primary"
ng-click="MainCtrl.wait()"
btn-busy="MainCtrl.waiting"
ng-disabled="MainCtrl.waiting"
>
Let's Wait!
</button>
</form>
</div>
</div>

Come learn with me. For comments please file an issue or pull request at GitHub.

© 2020, Built with Gatsby