Managing A Shared Global UI Component In AngularJS
Lately, I've been doing a lot of thinking about how to manage a shared, global component in AngularJS. It's a rather complicated thing, especially when you want to keep the lines of communication "clean" and within the bounds of the "Angular Way." This means, no inline HTML in a directive's linking function and no concept of the DOM (Document Object Model) inside the controllers or the services. To explore this challenge, I wanted to see if I could create a popup-list that could render a list of items, positioned relative to the trigger element.
Run this demo in my JavaScript Demos project on GitHub.
A popup-list poses a few different challenges. First, I want to be able to communicate a result from the popup-list back to the controller. This means that I'll want the controller to call something that returns a Promise that will eventually resolve with the selected item or reject without selection. Second, I want the popup-list to open relatively close the element that triggered it. This means that something in the popup-list ecosystem needs to know about the DOM; but certainly, that something can't be the Controller or any services that it calls.
To be honest, solving this problem took me at least 5 hours with a good bit of trial-and-error in terms of how I put everything together. Ultimately, the breakthrough - for me - in solving this problem was to finally think about two different and distinct paths of communication. One was managing the state and view-model of the popup-list; the other was worrying about the position of the popup-list. By splitting the communication between DOM-agnostic and DOM-aware concerns, it made it much more clear how data should flow and where responsibilities should reside.
Bold Statement: If you pass $event into your Controller, I believe you are breaking the separation of concerns. $event (and $element for that matter) are DOM-considerations and should not be known to the Controller.
When thinking about the state and view-model of the popup-list, my flow of data looks like this:
Controller ---> (<-- promise) PopupList Service <---> PopupList Component
Since the popup-list needs to be a globally-shared component, I have to have some globally-accessible hook into the popup-list lifecycle. This is exactly what an AngularJS service can do. However, a service is not associated with the DOM - services exist independently of the DOM. As such, I needed to have another component that does nothing but render the state of the popup service.
When thinking about this, I borrowed heavily from my previous thinking on creating a simple Modal System in AngularJS. In that blog-post, I also have an intermediary Service that manages the global state of popups and can return promises.
Having to position the popup-list was a big mental block for me. At first, I tried passing $event.pageX and $event.pageY into the Controller. But, that felt sloppy and, as it turns out, was insufficient because it didn't really tell me anything about the Element that triggered the request. Now, because of the "Angular Way," I certainly couldn't pass the $element into the Controller; so, I had to figure out another path of communication for the state of the DOM. What I ended up creating was a small attribute-directive, for the trigger element, that emits the DOM-related information:
View ---> Trigger Directive ---> PopupList Component (link function)
Putting it all together, the flow of communication looks something like this:
As you can see, the Controller only ever talks to the Service layer, neither of which know anything about the DOM. All DOM-related interaction is handled by the various directives.
A really nice outcome of this organization is that the popup-list component is a "real" component that has its own template and life-cycle. To me, this is such an important part of what a component is in the AngularJS world. I see too many globally-shared components, like Tooltip directives, that munge HTML into a string within a directive's linking function. If you are concatenating HTML strings in an AngularJS application, I think something when wrong and things need to be refactored.
Anyway, here's the code that I came up with. To demonstrate the architecture, I have two truncated lists of people on the page. Each truncated list is accompanied by a way to trigger the popup-list which will show the full list.
<!doctype html>
<html ng-app="Demo">
<head>
<meta charset="utf-8" />
<title>
Managing A Shared Global UI Component In AngularJS
</title>
<link rel="stylesheet" type="text/css" href="./demo.css"></link>
</head>
<body ng-controller="AppController as vm">
<h1>
Managing A Shared Global UI Component In AngularJS
</h1>
<!--
Here, we have two different lists, each of which have a "show more" button.
This show more button will trigger a popup that is a shared, global component.
This component needs to receive view-model data and DOM-related data. However,
since the Controller cannot know about the DOM, both of these data-sets are
transmitted over different means.
Showing popup: Controllers / Services.
Position popup: Directives.
-->
<div class="list">
<h2>
Friends
</h2>
<ul>
<li ng-repeat="friend in vm.friends|limitTo:3 track by friend.id">
{{ friend.name }}
</li>
</ul>
<!--
Here, the Controller and showAllFriends() method takes care of showing
the popup while the popup-list-trigger positions the popup relative to
the trigger element.
--
NOTE: I do NOT have to pass in the $event to the Controller, which feels
like a much cleaner separation to me.
-->
<a ng-click="vm.showAllFriends()" popup-list-trigger class="show-more">
+ {{ ( vm.friends.length - 3 ) }} more
</a>
</div>
<div class="list">
<h2>
Enemies
</h2>
<ul>
<li ng-repeat="enemy in vm.enemies|limitTo:3 track by enemy.id">
{{ enemy.name }}
</li>
</ul>
<!--
Here, the Controller and showAllEnemies() method takes care of showing
the popup while the popup-list-trigger positions the popup relative to
the trigger element.
--
NOTE: I do NOT have to pass in the $event to the Controller, which feels
like a much cleaner separation to me.
-->
<a ng-click="vm.showAllEnemies()" popup-list-trigger class="show-more">
+ {{ ( vm.enemies.length - 3 ) }} more
</a>
</div>
<!--
I am the shared, global popup-list component. Notice that the popup-list
component is always part of the page. However, it is managing the rendering
of the content through the use of an internal, isolate-scope variable: isOpen.
This is helpful because it allows the global component to have a complex layout
that can be defined in an external template (though I am keeping it inline for
the sake of simplicity). By managing the rendering internally, though, it also
means that the parent controller doesn't have to worry about managing or exposing
popup-related values.
--
NOTE: If you are defining HTML inside your JavaScript (like a tooltip element),
it is my OPINION that this is not the "AngularJS way."
-->
<popup-list>
<section ng-if="popup.isOpen" ng-style=":: { left: popup.x, top: popup.y }">
<header>
<h1>
{{ :: popup.title }}
</h1>
<span class="count">
{{ :: popup.items.length }} Items
</span>
</header>
<ul>
<li
ng-repeat="item in popup.items track by $index"
ng-click="popup.selectItem( item )">
{{ :: popup.getItemText( item ) }}
</li>
</ul>
</section>
<!-- Backdrop to protect clicks from bleeding through. -->
<div ng-if="popup.isOpen" ng-mousedown="popup.close()" class="click-shield">
<br />
</div>
</popup-list>
<!-- Load scripts. -->
<script type="text/javascript" src="../../vendor/jquery/jquery-2.1.0.min.js"></script>
<script type="text/javascript" src="../../vendor/angularjs/angular-1.4.7.min.js"></script>
<script type="text/javascript">
// Create an application module for our demo.
angular.module( "Demo", [] );
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I control the root of the application.
angular.module( "Demo" ).controller(
"AppController",
function AppController( $scope, popupList ) {
var vm = this;
// Build our list collections.
vm.friends = buildCollection( "Joanna", "Kit", "Tricia", "Elle", "Kim", "Sarah", "Sam", "Kristen" );
vm.enemies = buildCollection( "Jenny", "Lisa", "Pam", "Amanda", "Kelly", "Tara", "Tina", "Zena", "Gina" );
// Expose public methods.
vm.showAllEnemies = showAllEnemies;
vm.showAllFriends = showAllFriends;
// ---
// PUBLIC METHODS.
// ---
// I show the complete enemies list in a popup.
function showAllEnemies() {
// When you ask the popup list to open, it returns a promise. That
// promise will either be resolved with the item that was selected
// (out of the given collection); or, it will be rejected without
// reason.
popupList
.open( "Enemies", vm.enemies, "name" )
.then(
function handleResolve( enemy ) {
console.log( "Enemy Selected:", enemy.name );
},
function handleReject() {
console.log( "Closed without resolution." );
}
)
;
}
// I show the complete friends list in a popup.
function showAllFriends() {
// When you ask the popup list to open, it returns a promise. That
// promise will either be resolved with the item that was selected
// (out of the given collection); or, it will be rejected without
// reason.
popupList
.open( "Friends", vm.friends, "name" )
.then(
function handleResolve( friend ) {
console.log( "Friend Selected:", friend.name );
},
function handleReject() {
console.log( "Closed without resolution." );
}
)
;
}
// ---
// PRIVATE METHODS.
// ---
// I build an id/name collection using the given arguments as names.
function buildCollection() {
var collection = [];
for ( var i = 0, length = arguments.length ; i < length ; i++ ) {
collection.push({
id: ( i + 1 ),
name: arguments[ i ]
});
}
return( collection );
}
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I manages the global state of the popup list.
// --
// NOTE: This service knows nothing about the state of the DOM.
angular.module( "Demo" ).factory(
"popupList",
function popupListFactory( $q, $rootScope ) {
// I hold the internal state.
var popup = {
deferred: null,
title: "",
items: [],
textAccessor: angular.noop
};
// Return the public API.
return({
getItems: getItems,
getItemText: getItemText,
getTitle: getTitle,
open: open,
reject: reject,
resolve: resolve
});
// ---
// PUBLIC METHODS.
// ---
// I get the items for the popup list.
function getItems() {
return( popup.items );
}
// I get the label-text associated with the given item.
function getItemText( item ) {
return( popup.textAccessor( item ) );
}
// I get the title for the popup list.
function getTitle() {
return( popup.title );
}
// I open the popup list with the given title, items, and text-accessor.
// Returns a promise that will resolve with the selected item or reject
// without reason.
// --
// NOTE: This method can't actually open the "DOM" element since it
// doesn't know anything about the DOM. However, it will emit an event
// that the popup list component is listening for.
function open( title, items, textAccessor ) {
if ( popup.deferred ) {
popup.deferred.reject();
}
popup.deferred = $q.defer();
popup.title = title;
popup.items = items;
// If the text accessor is already a function, just assume that it is a
// function that takes an item and returns the text value. If it is a
// string, assume it is a property that references the text of the item.
popup.textAccessor = angular.isFunction( textAccessor )
? textAccessor
: function( item ) {
return( item[ textAccessor ] );
}
;
// Since the popupList service knows nothing about the DOM, we have
// to emit an event that the popupList component will use as a signal
// to open the popupList element.
$rootScope.$emit( "popupList.open" );
return( popup.deferred.promise );
}
// I move the popup into a rejected state, meaning no item was selected.
function reject() {
if ( popup.deferred ) {
popup.deferred.reject();
popup.deferred = null;
}
// Since the popupList knows nothing about the DOM, we have to
// trigger an event that the popupList component is listening for.
$rootScope.$emit( "popupList.close" );
}
// I move the popup into a resolved state, meaning an item was selected.
function resolve( value ) {
if ( popup.deferred ) {
popup.deferred.resolve( value );
popup.deferred = null;
}
// Since the popupList knows nothing about the DOM, we have to
// trigger an event that the popupList component is listening for.
$rootScope.$emit( "popupList.close" );
}
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I am the directive "glue" that binds the popupList service to the popupList
// component and DOM (Document Object Model).
angular.module( "Demo" ).directive(
"popupList",
function popupListDirective( $document, $rootScope ) {
// Return the directive configuration object.
return({
controller: PopupListController,
controllerAs: "popup",
link: link,
restrict: "E"
});
// I bind the JavaScript events to the view model.
function link( scope, element, attributes, controller ) {
// The primary means of this linking function is to aide in the
// positioning of the popup. Since neither the Controller nor the
// Service are supposed to know anything about the DOM, this linker
// will listen for trigger events and position the popup relative
// to the origin event.
var deregisterHandlePosition = $rootScope.$on( "popupList.position", handlePosition );
scope.$on( "$destroy", handleDestroy );
// ---
// PRIVATE METHODS.
// ---
// I teardown the directive bindings.
function handleDestroy() {
deregisterHandlePosition();
}
// I handle the position event, calculating the relative position of
// the popup and then telling the controller to update the view-model.
function handlePosition( event, target, x, y ) {
var offset = target.offset();
controller.setPosition(
( offset.left + target.outerWidth() + 20 ),
( offset.top - 75 )
);
}
}
// I manage the popup list component.
function PopupListController( $scope, $rootScope, popupList ) {
var vm = this;
// Setup the view-model.
vm.isOpen = false;
vm.title = "";
vm.items = [];
vm.x = 0;
vm.y = 0;
// Since this component is being used to render the state of the
// popupList service, we need to register event listeners so that
// we know when to synchronize the state of the component.
var deregisterHandleOpen = $rootScope.$on( "popupList.open", handleOpen );
var deregisterHandleClose = $rootScope.$on( "popupList.close", handleClose );
$scope.$on( "$destroy", handleDestroy );
// Expose public methods.
vm.close = close;
vm.getItemText = getItemText;
vm.selectItem = selectItem;
vm.setPosition = setPosition;
// ---
// PUBLIC METHODS.
// ---
// I close the popupList component.
function close() {
popupList.reject();
}
// I return the label text for the given item.
function getItemText( item ) {
return( popupList.getItemText( item ) );
}
// I put the popupList service into a resolved state using the
// given item.
function selectItem( item ) {
popupList.resolve( item );
}
// I position the popupList component.
function setPosition( x, y ) {
vm.x = x;
vm.y = y;
}
// ---
// PRIVATE METHODS.
// ---
// I handle the close event emitted by the popupList service.
function handleClose( event ) {
vm.isOpen = false;
}
// I teardown the controller's bindings.
function handleDestroy() {
deregisterHandleOpen();
deregisterHandleClose();
}
// I handle the open event emitted by the popupList service.
function handleOpen( event ) {
vm.isOpen = true;
// When the popupList component is being rendered in an open
// state, we have to re-synchronize the local view-model with
// the popupList component to make sure that we are rendering
// the correct data.
vm.title = popupList.getTitle();
vm.items = popupList.getItems();
}
}
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I provide a means to position the popup relative to the current element.
angular.module( "Demo" ).directive(
"popupListTrigger",
function popupListTriggerDirective( $rootScope ) {
// Return the directive configuration object.
return({
link: link,
restrict: "A"
});
// I bind the JavaScript events to the view-model.
function link( scope, element, attributes ) {
// By default, we'll assume the trigger action is Click; but, this
// can be overridden by the attribute value.
var eventType = ( attributes.popupListTrigger || "click" );
element.on( eventType, handleTrigger );
// ---
// PUBLIC METHODS.
// ---
// When the popup is triggered, I emit the data that is needed to
// position the popup relative to the trigger element.
function handleTrigger( event ) {
$rootScope.$emit(
"popupList.position",
angular.element( event.target ),
event.pageX,
event.pageY
);
}
}
}
);
</script>
</body>
</html>
To be sure, it's a non-trivial amount of code. But, I believe this is also a non-trivial problem. I think the most important take-away here is the understanding that complex UI (user interface) workflows don't have to be housed within a single component. Rather, a workflow may be most effectively articulated through a collection of components working together, each with a specific set of responsibilities.
Want to use code from this post? Check out the license.
Reader Comments
Can you please explain why "no inline HTML in a directive's linking function" or point me to a reference? Thanks!
@Gabriel,
I don't know if I can find any reference to it. It might just be in my mind. I guess the way I see it is that when you start using inline HTML in your link function, you're sort of falling back to the "jQuery Plugin" mindset where you have code that it like:
$( "<div class='tooltip'></div> ).append( attributes.tooltip ).appendTo( document.body )
... which, at least to me, is not really how AngularJS is supposed to work. If you need a directive template, then it should be factored out into a directive that can have a template, rather than trying to wedge one into a directive that has no "body."
That said, I am NOT saying to avoid having the template in the directive definition object:
return({ template: `<div class='my-template'></div>` });
... that is totally fine. Personally, I like having my templates in a different file. But, that's just personal preference.
Why you never use ng-click for directives?
Sorry for the unclear question.
what I mean is that why you never use the default angular directives in your custom directive.
for example: you always use element.bind('click') instead of ng-click in the html or always use addClass instead of ng-class...
Is there a reason for this?
By the way your blog really help me on my angular project.
Great explanation. Beside how to create a reusable component, this article also helped me to finally understand how to work with services in scenarios like this.
Regarding article I have one question. Because of controllerAs syntax and you are referencing directive data model as a 'popup' I assuming that directive html will work if I add template inside directive?
Thanks!
@Lukas,
When possible, I will use the ng-click directive to route click events into the view-model. However, in this particular case, I went with element.on() because the triggering event could by dynamic:
element.on( eventType, handleTrigger );
By default, it is the "click" event. But, it could just as easily been the "mousedown" or even then "mouseenter" event. Being able to dynamically wire up the different core directives would have required a compile() step, which probably would have been more work than necessary.
@Vladimir,
Correct - I am using "popup" as the Controller reference since I left the HTML "template" inline with the rest of the demo. I do this so that I can keep all the HTML in the same place in the demo. If this were "real" code, I would have moved the HTML into a separate .htm file and would likely have used controllerAs:"vm" (instead of "popup") as is the emerging standard in the AngularJS world (thanks to John Papa's style guide).