I am working on an Angular app using Bootstrap.
In order to minimize the Bootstrap footprint on my HTML, I've introduced two directives for forms:
form-control.js
module.directive('formControl', function() {
return {
restrict : 'E',
templateUrl : 'form-control.tmpl.html',
scope: {
label: '@'
},
transclude : true
};
});
form-control.tmpl.html
<div class="form-group">
<label class="control-label col-sm-2">
{{ label }}
</label>
<div class="col-sm-10"
ng-transclude>
</div>
</div>
I also have a handful of "extensions" to this directive, for various form input fields. e.g.:
form-input-text.js
module.directive('formInputText', function() {
return {
restrict : 'E',
templateUrl : 'form-input-text.tmpl.html',
scope: {
label: '@',
value: '=ngModel'
}
};
});
form-input-text.tmpl.html
<form-control label="{{label}}">
<input type="text"
class="form-control"
ng-model="value">
</form-control>
app.html
<form-input-text label="Name"
ng-model="person.name">
</form-input-text>
Here I run into a problem. There are a number of scopes in play in this example:
appScope = { person : { name : "John" } };
isolateScope = {
label: "Name",
value: "John" // bound two-way with appScope.person.name
};
transcludeScope = {
__proto__: isolateScope,
label: "Name", // inherited from isolateScope
value: "John" // inherited from isolateScope
};
If I change the text in the input text box, then only transcludeScope is modified:
appScope = { person : { name : "John" } };
isolateScope = {
label: "Name",
value: "John" // bound two-way with appScope.person.name
};
transcludeScope = {
__proto__: isolateScope,
label: "Name", // inherited from isolateScope
value: "Alice" // overrides value from isolateScope
};
This is because the <input> is bound directly to a property of transcludeScope. transcludeScope.value is changed directly, and the parent scope isolateScope is unaffected. Thus any model changes in the input never make it back to appScope.
What I'd like to do is create a two-way binding between appScope.person.name and a nested property of isolateScope, e.g. isolateScope.model.value.
Ideally I'd like to declare my directive like this:
form-input-text.js
module.directive('formInputText', function() {
return {
restrict : 'E',
templateUrl : 'form-input-text.tmpl.html',
scope: {
model: {
label: '@',
value: '=ngModel'
}
}
};
});
This would allow the transcluded portion to bind to model.value, which would make changes visible to isolateScope, which would in turn propagate changes in isolateScope back to appScope.
This usage does not seem to be directly supported by Angular.
Can anyone point me to a feature of Angular that supports this use case, or if not, provide a workaround?
Edit:
For now, my solution is to inline the form-control template into the form-input-text template.
form-input-text.tmpl.html
<div class="form-group">
<label class="control-label col-sm-2">
{{ label }}
</label>
<div class="col-sm-10">
<input type="text"
class="form-control"
ng-model="value">
</div>
</div>
This eliminates the child scope introduced ng-transclude, but it also duplicates markup, which is what I was hoping to refactor into a single place.
Thinking about scopes is actually going slightly on the wrong track, and I don't think the transclusion has much to do with it. To do it "properly", you should integrate with ngModelController. This allows any later integrated parsers and formatters (which could contain validation logic) to run at the appropriate times. It's a bit complicated as you have 2 of them: the parent one in the application, and the one in the template of the directive, and each one has 2 "pipelines" to be integrated with:
The view value of the parent ngModelController is then used as the model value of the inner ngModelController. So the overall pipelines look like
To do this:
Make sure you require: 'ngModel' in the directive definition, to have access to the parent ngModelController
Changes from the parent ngModelController to the inner, are done using the $render method of the parent ngModelController, using its $viewValue. This ensures that any functions in the parent $formatters have run.
User initiated changes from the inner directive are done by adding a function to its $viewChangeListeners array, which calls $setViewValue on the parent ngModelController. To access this from the linking function scope, you need a named form and input elements. A slight annoyance is that the form is only registered on the scope of the directive after its linking function of the directive has run, so you need a watcher to access it.
Just in case of any weirdnesses, make sure that the model in formInputText is in an object. (I'm not sure this is technically necessary)
You then don't need to have the model in the scope object of the inner directive.
Putting this together,
app.directive('formInputText', function() {
return {
restrict : 'E',
templateUrl : 'form-input-text.tmpl.html',
scope: {
label: '@'
},
require: 'ngModel',
link: function(scope, element, attrs, ngModelController) {
scope.model = {};
// Propagate changes from parent model to local
ngModelController.$render = function() {
scope.model.value = ngModelController.$viewValue;
};
// Propagate local user-initiated changes to parent model
scope.$watch('form', function(form) {
if (!form) return;
form.input.$viewChangeListeners.push(function() {
ngModelController.$setViewValue(form.input.$modelValue);
});
});
}
};
});
And its template looks like
<form-control label="{{label}}" ng-form name="form">
<input type="text"
class="form-control"
name="input"
ng-model="model.value">
</form-control>
This can be seen working at http://plnkr.co/edit/vLGa6c55Ll4wV46a9HRi?p=preview
I would use a custom control for your case, as described here, making the custom <form-input-*> directives true controls. It does need some additional work. To outline a simplistic version of a solution:
form-input-text.js
app.directive('formInputText', function() {
return {
restrict : 'E',
template : '<form-control label="{{label}}"><input type="text" class="form-control" /></form-control>',
scope: {
label: '@'
},
require: 'ngModel',
link: function(scope, elem, attrs, ngModel) {
var input = angular.element(elem[0].querySelectorAll("input")[0]);
ngModel.$render = function() {
input.val(ngModel.$viewValue || '');
};
input.on('blur keyup change', function() {
scope.$apply(read);
});
function read() {
ngModel.$setViewValue(input.val());
}
}
};
});
In short, you require the ngModel and implement its methods, as per the documentation. The ngModel is just another directive applied to your control and other things will work, e.g. custom validators, ng-required and so on.
A working fiddle: http://jsfiddle.net/1n53q59z/
Bear in mind that you may need to do some tweaking depending on your use case.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With