Skip to main content
Ben Nadel at InVision In Real Life (IRL) 2018 (Hollywood, CA) with: Pablo Fredrikson
Ben Nadel at InVision In Real Life (IRL) 2018 (Hollywood, CA) with: Pablo Fredrikson

Creating A Range-Loop Directive In AngularJS

By
Published in Comments (4)

Out-of-the-box, AngularJS allows you to loop over collections; but, it doesn't allow you to perform a simple for-loop. Granted, I've never actually needed a for-loop in production; but, I can definitely see some valid use-cases. And, more than that, I think creating a for-loop would be a fun exercise in creating AngularJS directives. In order to keep the syntax very concise, I've opted for "range" syntax (ex, 1..5) instead of a full-on for-loop.

Run this demo in my JavaScript Demos project on GitHub.

To create a Range-loop directive in AngularJS, we could build it completely from scratch. That means building all the looping logic, including DOM (Document Object Model) manipulation and Scope generation. Or, we could leverage the already existing, and super powerful, ngRepeat directive.

The ngRepeat directive works on collections. So, in order to leverage it, we have to be able to convert our range into a collection. That means taking this:

1...5

... and converting it into something like this:

[ 1, 2, 3, 4, 5 ]

Sure, you could do this with some sort of filter in the ngRepeat directive. But, the syntax for that is messy and obfuscates the intent of the code. Instead, I'd like to create a bnRange directive that uses the range syntax. Then, behind the scenes, the bnRange directive will actually compile itself down into an ngRepeat directive that uses a fleshed-out collection.

The exciting part about this is that we're creating a directive that, in some sense, recompiles itself. Not only does this mean that we get to inject directives; but, it also means that we have to be careful about which aspects of the DOM get compiled when. If we're not careful, we can end up compiling the DOM twice.

The ngRepeat directive executes at priority 1000. As such, the bnRange directive will have to execute at priority 1001 (or higher) so that it compiles before ngRepeat. And, since we're augmenting the same Element node (as opposed to child nodes), we have to explicitly $compile() the ngRepeat directive once it's been injected. And, of course, this means we have to use the "terminal" configuration, otherwise we end up compiling the sub-tree twice.

Ok, let's take a look. I've tried to make the bnRange directive flexible enough to use ascending and descending ranges. And, what's more, since it compiles down to ngRepeat, you can use all the same filters that you would normally (although you really shouldn't use filters in a production app, it's bad for performance).

<!doctype html>
<html ng-app="Demo">
<head>
	<meta charset="utf-8" />

	<title>
		Creating A Range-Loop Directive In AngularJS
	</title>
</head>
<body>

	<h1>
		Creating A Range-Loop Directive In AngularJS
	</h1>

	<p>
		<!-- Incrementing range, inclusive. -->
		<span bn-range="i in -5...5">
			{{ i }}
		</span>
	</p>

	<p>
		<!-- Incrementing range, exclusive. -->
		<span bn-range="i in -5..5">
			{{ i }}
		</span>
	</p>

	<p>
		<!-- Decrementing range, inclusive. -->
		<span bn-range="i in 5...-5">
			{{ i }}
		</span>
	</p>

	<p>
		<!-- Decrementing range, exclusive. -->
		<span bn-range="i in 5..-5">
			{{ i }}
		</span>
	</p>

	<p>
		<!-- Incrementing range with filter. -->
		<span bn-range="i in -5...5 | limitTo:7">
			{{ i }}
		</span>
	</p>

	<p>
		<!-- Incrementing range with TWO filters. -->
		<span bn-range="i in -5...5 | orderBy:i:true | limitTo:7">
			{{ i }}
		</span>
	</p>

	<p>
		<!-- Incrementing range with TWO filters. -->
		<span bn-range="i in -5...5 | filter:3 | orderBy:i:true">
			{{ i }}
		</span>
	</p>

	<p>
		<!-- Edge case. -->
		<span bn-range="i in 0..0">
			{{ i }}
		</span>
	</p>

	<p>
		<!-- Edge case. -->
		<span bn-range="i in 0...0">
			{{ i }}
		</span>
	</p>


	<!-- Load scripts. -->
	<script type="text/javascript" src="../../vendor/angularjs/angular-1.3.6.min.js"></script>
	<script type="text/javascript">

		// Create an application module for our demo.
		var app = angular.module( "Demo", [] );


		// -------------------------------------------------- //
		// -------------------------------------------------- //


		// I provide a range (ex, M..N) loop that compiles down to an ngRepeat directive.
		// As such, you can use all the same filters and features of an ngRepeat loop if
		// you assume that repeat-set is an array of indices.
		app.directive(
			"bnRange",
			function( $compile ) {

				// The range pattern allows for two number separated by 2 or 3 dots. Two
				// dot (ex, 1..10) indicates exclusive end while three dots (ex, 1...10)
				// indicates inclusive end.
				var rangePattern = /(-?\d+)(\.\.\.?)(-?\d+)/i;

				// I keep cached sets so that they don't have to be constructed over and
				// over again. Minor optimization.
				// --
				// NOTE: The sets are cached in serialized JSON format.
				var cachedSets = {};

				// Return the directive configuration. Since this directive compiles down
				// into a ngRepeat directive, we need to compile at 1001 - above the
				// ngRpeat priority (1000). Furthermore, we have to use terminal compiling
				// otherwise, the content will actually be compiled TWICE.
				return({
					compile: compile,
					priority: 1001,
					restirct: "A",
					terminal: true
				});


				// ---
				// PUBLIC METHODS.
				// ---


				// I compile the bnRange directive, replacing it with an ngRepeat
				// directive that can iterate over the generated range set.
				function compile( tElement, tAttributes ) {

					var input = tAttributes.bnRange;

					if ( missingRange( input ) ) {

						throw( new Error( "Missing valid range in the form of M..N (exclusive) or M...N (inclusive)." ) );

					}

					// Add the ngRepeat directive that has consumes an array made up of
					// the generated indices.
					// --
					// NOTE: We have to use $set() here, as opposed to .attr(), since the
					// directives have already been collected for this element. As such,
					// we have to explicitly let AngularJS know that new directives have
					// been added.
					tAttributes.$set(
						"ngRepeat",
						input.replace( rangePattern, replacePatternWithSet )
					);

					// Remove the bnRange directive from the markup (to make things look
					// a little it nicer).
					tAttributes.$set( tAttributes.$attr.bnRange, null );

					// Return the linking function to complete the compilation of the
					// ngRepeat directive we just injected.
					return( link );

				}


				// I link the JavaScript events to the local scope.
				function link( scope, element, attributes ) {

					// Once we have injected the ngRepeat directive, we have to make sure
					// that is compiles and links. Since the bnRange directive executes at
					// priority 1001, we want to continue the compilation at directives
					// that execute below 1001 (ie, the ngRepeat).
					$compile( element, null, 1001 )( scope );

				}


				// ---
				// PRIVATE METHODS.
				// ---


				// I build a set whose values are an ordered collection of the indices
				// required to iterate over FROM->TO. If the set is exclusive, the last
				// value in the set is excluded.
				function buildSet( from, to, isExclusive ) {

					var set = [];

					// Incrementing range.
					if ( from <= to ) {

						for ( var i = from ; i <= to ; i++ ) {

							set.push( i );

						}

					// Decrementing range.
					} else {

						for ( var i = from ; i >= to ; i-- ) {

							set.push( i );

						}

					}

					if ( isExclusive ) {

						set.pop();

					}

					return( set );

				}


				// I check to see if the directive input is missing a valid range (ex, M..N).
				function missingRange( input ) {

					return( String( input ).search( rangePattern ) === -1 );

				}


				// I replace the matched range with a serialized set of indices.
				function replacePatternWithSet( range, start, operator, end ) {

					// If this range has been parsed before, just return the cached set.
					if ( cachedSets[ range ] ) {

						return( cachedSets[ range ] );

					}

					var from = parseInt( start, 10 );
					var to = parseInt( end, 10 );
					var isExclusive = ( operator === ".." );
					var set = buildSet( from, to, isExclusive );

					// Once we have the set, serialize it into something that the
					// ngRepeat directive can use, cache it, and return it.
					return( cachedSets[ range ] = angular.toJson( set ) );

				}

			}
		);

	</script>

</body>
</html>

Wait, AngularJS directives and an excuse to use Regular Expressions? Simmer down - I know it's a little too exciting for a Saturday morning. But, when we run the above code, we get the following page output:

-5 -4 -3 -2 -1 0 1 2 3 4 5

-5 -4 -3 -2 -1 0 1 2 3 4

5 4 3 2 1 0 -1 -2 -3 -4 -5

5 4 3 2 1 0 -1 -2 -3 -4

-5 -4 -3 -2 -1 0 1

5 4 3 2 1 0 -1

3 -3

0

And, if we look at the live source code of the document, you can see that all of the bnRange directives have been compiled down into ngRepeat directives:

Creating a range-loop directive in AngularJS by compiling down to a native ngRepeat directive.

More than anything practical, I thought this was worthwhile because it forced me to think about the compilation and linking process used by AngularJS; it's not always the most obvious workflow, so the more you practice, the easier it becomes!

Want to use code from this post? Check out the license.

Reader Comments

1 Comments

Hi Ben
Love your directive you've explained so well. I've needed this some time. I have dealt with this within my MVC controller or cshtml view.
Have a great Xmas and New Year.

Cheers

Paul

15,902 Comments

@Paul,

Thank you very much, my man! Directives that include partial-complication can be a bit of a mind-bender. Glad you found the explanation clear!

2 Comments

How could I substitute $scope values for the lower and upper bounds instead of hardcoding an integer.

For example, how would I accomplish the following:
<p>
<span bn-range="i in {{ $scope.lower }}...{{ $scope.upper }}">
{{ i }}
</span>
</p>

2 Comments

Just an update to my previous comment, I am more interested in evaluating the parent's context. This is what I have:

<ul>
<li ng-repeat="item in myList">
<div bn-range="i in item.lower...item.upper">
{{ i }}
</div>
</li>
</ul>

Is this possible to do with your directive?

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel