Passing ngModelController Into A Component Directive Controller In AngularJS
When you're building an ngModel consumer component in AngularJS, you have to inject the ngModelController into your component's link() function (using the "require" property of your directive definition object). This is necessary because it is a transient object exposed as part of the current DOM (Document Object Model) structure and cannot be injected into a Controller like a normal service object. However, the other day in a Plunkr by Martin Hochel, I saw him do something rather interesting; he used the link() function to inject the relevant ngModelController instance into the Controller that managed the current component. I had never thought of doing this myself, so I wanted to give it a try.
Run this demo in my JavaScript Demos project on GitHub.
Before we get into the technical details, let's just step back and think about this workflow philosophically. In an AngularJS application, the role of the directive - and perhaps more specifically, the link() function - is to be the glue that binds the DOM structure to the view-model. By injecting the ngModelController into the component Controller from within the link() function, we are continuing to adhere to this philosophy. Since the ngModelController is a byproduct of the transient DOM structure, we are correctly using the link() function to "glue" the DOM to the view-model of our component.
I just wanted to stress the previous point in an effort to short-circuit any thinking that this is a workaround for a bug or a poorly implemented feature of AngularJS. AngularJS has a very strict separation of concerns - by design - in which the Controllers shouldn't know anything about the DOM (which is why I tend to think that injecting the $element or the $event object into a Controller is an anti-pattern). And, by using the link() function to coordinate the injection of the ngModelController, we continue to shield the component Controller from the DOM structure.
Ok, soap-box aside, let's look at some actual code. In the following demo, I've put together a simple Toggle input control that renders Yes or No based on the ngModel binding. As you will see, the Toggle component has a link() function; but, the link function's only purpose is to receive the transient ngModelController instance from the DOM and then to inject it into the component controller:
<!doctype html>
<html ng-app="Demo">
<head>
<meta charset="utf-8" />
<title>
Passing ngModelController Into A Component Directive Controller In AngularJS
</title>
<link rel="stylesheet" type="text/css" href="./demo.css"></link>
</head>
<body ng-controller="AppController as vm">
<h1>
Passing ngModelController Into A Component Directive Controller In AngularJS
</h1>
<p>
<a ng-click="vm.toggleAwesome()">Toggle Awesome</a> -
<a ng-click="vm.togglePlaya()">Toggle Playa</a> -
<a ng-click="vm.toggleGroovy()">Toggle Groovy</a> -
<a ng-click="vm.toggleAll()">Toggle All</a>
</p>
<!--
The bnToggle component is a custom component directive that uses the
ngModel binding and the underlying ngModelController to manage the
two-way data flow.
-->
<bn-toggle
ng-model="vm.isAwesome"
ng-change="vm.logChange( 'Awesome', vm.isAwesome )">
Awesome?
</bn-toggle>
<bn-toggle
ng-model="vm.isPlaya"
ng-change="vm.logChange( 'Playa', vm.isPlaya )">
Playa?
</bn-toggle>
<bn-toggle
ng-model="vm.isGroovy"
ng-change="vm.logChange( 'Groovy', vm.isGroovy )">
Groovy?
</bn-toggle>
<!-- Load scripts. -->
<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, $log ) {
var vm = this;
// I hold the toggle values.
vm.isAwesome = false;
vm.isPlaya = false;
vm.isGroovy = false;
// Expose the public methods.
vm.logChange = logChange;
vm.toggleAll = toggleAll;
vm.toggleAwesome = toggleAwesome;
vm.toggleGroovy = toggleGroovy;
vm.togglePlaya = togglePlaya;
// ---
// PUBLIC METHODS.
// ---
// I log the change to the given ngModel value.
// --
// NOTE: This is being invoked by the ngChange binding, which will only
// be invoked if the change is triggered by an action internal to the
// ngModel implementer. Meaning, it will not be invoked when the change
// it precipitated by THIS controller.
function logChange( label, value ) {
$log.info( label, "became", value );
}
// I toggle all of the values.
function toggleAll() {
toggleAwesome();
toggleGroovy();
togglePlaya();
}
// I toggle the awesome value.
function toggleAwesome() {
vm.isAwesome = ! vm.isAwesome;
}
// I toggle the groovy value.
function toggleGroovy() {
vm.isGroovy = ! vm.isGroovy;
}
// I toggle the playa value.
function togglePlaya() {
vm.isPlaya = ! vm.isPlaya;
}
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I provide a toggle control.
angular.module( "Demo" ).directive(
"bnToggle",
function bnToggleDirective() {
// Return the directive configuration object.
return({
controller: ToggleController,
controllerAs: "vm",
link: link,
require: [ "bnToggle", "ngModel" ],
restrict: "E",
scope: {},
transclude: true,
template:
`
<div ng-click="vm.toggle()" class="inner-dom">
<div class="label" ng-transclude>
<!-- Transcluded content, like a boss. -->
</div>
<div class="thumb-track" ng-class="{ checked: vm.isChecked }">
<div class="thumb-label on">
Yes
</div>
<div class="thumb-label off">
No
</div>
</div>
</div>
`
});
// I bind the JavaScript events to the view-model.
function link( scope, element, attributes, controllers ) {
// Our component controller.
var controller = controllers[ 0 ];
// The ngModel controller exposed on the DOM.
var ngModelController = controllers[ 1 ];
// Since the available ngModelControllers are a result of the DOM
// structure, we have to get them from the link function. However,
// once the DOM has linked, we can pass the available ngModelController
// into our component directive controller.
controller.setNgModelController( ngModelController );
}
// I control the toggle view-model.
function ToggleController( $scope ) {
var vm = this;
// I determine if the toggle is on (checked) or off.
vm.isChecked = false;
// At the time the component controller is instantiated, we don't
// yet have access to the relevant ngModelController. This is only
// available after the DOM has been linked and the relevant ngModel
// controller instance can be located.
var ngModelController = null;
// Expose public methods.
vm.setNgModelController = setNgModelController;
vm.toggle = toggle;
// ---
// PUBLIC METHODS.
// ---
// I set the ngModelController to be consumed by this component.
function setNgModelController( newNgModelController ) {
ngModelController = newNgModelController;
// Setup our formatters to make sure the $viewValue is easy to
// consume by the controller.
ngModelController.$formatters.push( formatModelValue );
// Setup the render method to make sure the toggle is kept in
// sync with the externally-changing ngModel binding.
ngModelController.$render = renderViewValue;
}
// I toggle the component state.
function toggle() {
vm.isChecked = ! vm.isChecked;
// Now that the component directive state has been changed, we
// have to tell the ngModelController that the value has changed
// so that the ngModel binding can be synchronized externally.
ngModelController.$setViewValue( vm.isChecked );
}
// ---
// PRIVATE METHODS.
// ---
// I format the incoming modelValue to be compatible with the toggle
// control. Since the toggle deals with booleans, this just makes sure
// that the modelValue results in a strict boolean.
function formatModelValue( modelValue ) {
return( !! modelValue );
}
// I update the toggle rendering to reflect the current $viewValue.
function renderViewValue() {
vm.isChecked = ngModelController.$viewValue;
}
}
}
);
</script>
</body>
</html>
As you can see, the bnToggle Controller and its view template act like any other view that you might see in AngularJS; it provides a view-model that is consumed by the view using other, core AngularJS directives. The only difference here is that as the view-model of the bnToggle Controller changes, it's also using the injected ngModelController to synchronize the internal state of the controller with the external state of the application at large.
I really like this! Having to route the ngModelController through the link() function has always been a mental hurdle for me in terms of keeping a proper separation of concerns in my custom input controls. But, using the link() function to provide the ngModelController to the component controller seems to remedy that disconnect quite nicely.
Want to use code from this post? Check out the license.
Reader Comments