AngularJS - Explain why transcluded scopes must be siblings of an isolate scope - angularjs

In the Angular docs on directives, there is this paragraph:
However isolated scope creates a new problem: if a transcluded DOM is
a child of the widget isolated scope then it will not be able to bind
to anything. For this reason the transcluded scope is a child of the
original scope, before the widget created an isolated scope for its
local variables. This makes the transcluded and widget isolated scope
siblings.
Can someone please explain why "if a transcluded DOM is a child of the widget isolated scope then it will not be able to bind to anything"?

Imagine you have some markup like this:
<html ng-app="myApp">
<body>
<div ng-controller="myController">
<div ng-repeat="item in items">
<div my-widget>
{{item.name}}
</div>
</div>
</div>
</body>
</html>
That sets up a tree of scopes ($rootScope -> controller scope -> ng-repeat scope -> widget scope). Now say your controller has some things in it:
function myController($scope) {
$scope.items = [
{name: 'Stella Artois'},
{name: 'Red Stripe'}
];
}
You can read values from a scope any number of levels up, because they inherit from each other using prototypical inheritance. {{item}} doesn't exist in the widget scope, but it does in the parent ng-repeat scope, so it's found just fine.
If you use isolate scope, you get a brand new scope that doesn't inherit from anything. So if my-widget uses scope: {} for example, the scope tree looks more like:
$rootScope
└controller scope
└ng-repeat scope
widget scope
Then in the double curlies, "item" is unknown. Using transclusion, you can set the scope up as a sibling like so:
$rootScope
└controller scope
└ng-repeat scope
└widget contents
widget scope

Another subtlety that was mentioned in this talk is that if your transcluded content's scope is a child of the directive's, then the directive could clobber whatever variables the transcluded content was trying to reference in a parent scope. For example:
<body ng-controller="MainCtrl">
<my-directive>{{ name }}</my-directive>
</body>
JS:
app.controller("MainCtrl", function($scope) {
$scope.name = 'foo';
});
app.directive("myDirective", function() {
return {
scope: {},
transclude: true,
template: '<span ng-transclude></span>',
controller: function($scope) {
$scope.name = 'bar';
}
}
});
Transclusion ensures that {{name}} in the directive references 'foo' instead of 'bar'.

Related

Along with Transclude element, can i pass its scope too to a directive?

Moment i felt i have understood enough about Transclude i came across this statement :
Transclude allows us to pass in an entire template, including its scope, to a directive.
Doing so gives us the opportunity to pass in arbitrary content and arbitrary scope to a directive.
Does this mean, if there is a scope attached to Transclude element and it can be passed on to the directive ? If that's true then am not able to access that scope property inside directive template.
Let me take couple of steps back and explain with code about what am trying to do :
JSFiddle Link
My directive is directive-box and transclude: true is defined in Directive Definition Object(DDO).
Now there is a Child Div, which is the element to be Transcluded
<div ng-controller='TransCtrl'>Inside Transclude Scope : {{name}}</div>
and it has controller TransCtrl attached to it.
Now am trying to access $scope.name property which is part of TransCtrl from directive level after defining this in DDO :
scope: {
title: '#directiveTitle',
name: '='
}
Is this possible ?
This is more like a Parent scope trying to access Child scope property, is this permitted in JavaScript Protoypical inheritance ? Or is there something else i need to know ??
If this is not possible what does first statement mean ?
Transclude allows us to pass in an entire template, including its scope, to a directive.
UPDATE 1 :
My primary concern is Controller should remain with Transclude element, still we should be able to pass its (Transclude element) scope to Directive and then Directive should be able to consume that scope i.e., name from TransCtrl controller .
<div ng-controller='TransCtrl'>Inside Transclude Scope : {{name}}</div>
Above line of code should remain as is.
I may be completely wrong with my question but please let me if this can be accomplished.
The problem seems to be with the way the controller is defined within the ng-transcluded html.
I have made it clearer by using
the bindToController construct
using a controller at the directive level
Refer this fiddle for a working example.
controllerAs: "TransCtrl",
bindToController: true
And your statement, 'Parent scope trying to access Child scope property' is incorrect right? Since we are trying to use the parent scope property, i.e. name from within the child (ng-transcluded content), which is possible with protypical inheritance, and not the other way around.
Does this answer your question: https://jsfiddle.net/marssfa4/4/?
In it I have created a new controller on the outside (effectively replacing the functionality of your rootScope for inside the directive) and I made the directive's controller be set inside your controller template.
The long and short of it is though that you can see that it is possible to transclude html along with its scope even into a directive with its own scope.
The html:
<div ng-app='myApp' ng-controller="OutsideScope">
<h1>{{externalWorld}}</h1>
<div directive-box directive-title='{{directiveWorld}}' name='name'>
<div>Inside Transclude Scope : {{name}}</div>
</div>
</div>
JS (includes Update 1):
angular.module('myApp', [])
.directive('directiveBox', function() {
return {
restrict: 'EA',
scope: {
title: '#directiveTitle',
name: '='
},
transclude: true,
template: '<div ng-controller="TransCtrl">\
<h2 class="header">{{ title }}</h2>\
<div class="dirContent">Directive Element</div>\
<div>Outside Transclude Scope : {{name}}</div>\
<div class="content" ng-transclude></div>\
</div>'
}
})
.controller('TransCtrl', function($scope) {
$scope.name = 'Transclude World'
})
.controller('OutsideScope', function($scope) {
$scope.name = 'External World'
})
.run(function($rootScope) {
$rootScope.externalWorld = 'External World',
$rootScope.directiveWorld = 'Here comes directive'
});
UPDATE 1: JSFIDDLE
I restored the original scope declarations as the scope: false was a mistake.
If I understand your comment correctly you want to leave the controller on the element to be transcluded but still have the {{name}} within that element ignore its immediate controller and use as controller its parent (i.e. the directive's) scope.
The reason I placed the controller within the template directive is because that is the only way to limit the directive's scope on the directive and not its transcluded elements. If you are explicitly placing a controller on an element, then regardless of whether it is contained within a directive with another scope, its closest scope will override whatever scope has been declared on the directive. In other words, regardless of what the directive's scope is, the {{name}} in
<div ng-controller='TransCtrl'>Inside Transclude Scope : {{name}}</div>
will always be whatever $scope.name is in TransCtrl.

Controller $scope and <form> in TranscludeScope

I have a directive that has a transclude. The transcluded content is a form which has a submit option calling a method in the (parent) controller. This method is called from the transcluded scope so any variables I access in the controller I can access via this.variable as this reflects the callers (current) scope. However, $scope.variable is undefined as $scope is that of the parent scope, which is the scope of the controller.
Should I indeed use this to access the forms values or is there a convention that I should implement to access via $scope in the (parent) controller?
Disclaimer: I know I'm not using controllerAs functionality, and right now I am not able to change that for the project.
Plunker to demonstrate the issue:
http://plnkr.co/edit/TdgZFIRNbUcHNfe3a7uO?p=preview
As you can see the value inside the directive updates when you change the value in the textbox. This is as expected as both refer to the transcluded scope. The {{name}} outside the directive does not change as this is the parent scope and not the transcluded scope. One-way traffic.
What is the proper solution? Use this.name or modify the directive so that $scope.name can be used outside the directive?
For those who like code over plunkers:
HTML:
<body ng-app="docsTransclusionExample">
<div ng-controller="Controller">
{{name}}
<my-dialog>
Check out the contents, {{name}}!
<form>
<input ng-model="name">
</form>
</my-dialog>
</div>
</body>
Directive:
(function(angular) {
'use strict';
angular.module('docsTransclusionExample', [])
.controller('Controller', function($scope) {
$scope.name = 'Tobias';
})
.directive('myDialog', function() {
return {
restrict: 'E',
transclude: true,
scope: {},
templateUrl: 'my-dialog.html'
};
});
})(window.angular);
Directive template:
<div class="alert" ng-transclude></div>
This is related to "The Dot Rule" in angular.
This means, that when you have scope heirarchy, and a child scope is trying to modify a parent scope, it's creating it's own scope.
When you use data objects, you access members "with a dot", and so you don;t change the scope directly, and not creating a new child scope
In your case
$scope.name = 'Tobias';
shuold change to
$scope.data = {name: 'Tobias'};
see plunker:
http://plnkr.co/edit/RqORVm8r4jph532dT6nY?p=preview
for further reading:
Why don't the AngularJS docs use a dot in the model directive?
https://www.youtube.com/watch?v=DTx23w4z6Kc
PS.
This is not related to directive transclusion (whitch is about building the DOM).
If you do: scope: {} in directive, you explicitally create a child scope.

Directive with root element with ngRepeat and replace: true

Can someone explain the root cause behind the following behavior?
If a directive with isolate scope scope: {} has a root element with ng-repeat AND replace: true then it "breaks" the isolate scope, meaning that the isolate scope is not accessible/visible from within the directive, and the directive starts receiving variables from the outer scope.
Here's the minimally reproducible example I could make:
app.controller('MainCtrl', function($scope) {
$scope.name = 'MainCtrl';
});
app.directive("foo", function(){
return {
replace: true,
scope: {},
template: "<div ng-repeat='item in [1]'>{{name}}</div>",
controller: function($scope){
$scope.name = "foo";
}
};
});
The following view would render "MainCtrl":
<div ng-controller="MainCtrl">
<foo></foo>
</div>
Adding either a non-ng-repeat-able root to the template OR setting replace: false renders the expected result "foo".
Plunker
It does not just happen for ng-repeat, this seems to happen for any other directives that creates a scope like ng-if as well. And it seems like, this is because the directive's isolated scope gets overwritten by the ng-repeat's child scope. And because of replace:true option ng-repeat becomes a part of the directive source element i.e <foo></foo> and the child scope of ng-repeat is calculated from the ultimate parent scope MainCtrl (Which appears to be wrong) and this causes the entire directive template to be bound to the child scope of controller and any interpolations are evaluated against that scope. Hence you see main controller's scope being expanded in the directive. This seems like a bug.

Angular directive: using ng-model within isolate scope

I'm having trouble working out how I can define a custom directive that both:
Uses isolate scope, and
Uses the ng-model directive in a new scope within in its template.
Here's an example:
HTML:
<body ng-app="app">
<div ng-controller="ctrl">
<dir model="foo.bar"></dir>
Outside directive: {{foo.bar}}
</div>
</body>
JS:
var app = angular.module('app',[])
.controller('ctrl', function($scope){
$scope.foo = { bar: 'baz' };
})
.directive('dir', function(){
return {
restrict: 'E',
scope: {
model: '='
},
template: '<div ng-if="true"><input type="text" ng-model="model" /><br/></div>'
}
});
The desired behaviour here is that the input's value is bound to the outer scope's foo.bar property, via the the directive's (isolate) scope model property. That doesn't happen, because the ng-if directive on the template's enclosing div creates a new scope, so it's that scope's model that gets updated, not the directive's scope's.
Ordinarily you solve these ng-model issues by making sure there's a dot in the expression, but I can't see any way to do that here. I wondered if I might be able to use something like this for my directive:
{
restrict: 'E',
scope: {
model: {
value: '=model'
}
},
template: '<div ng-if="true"><input type="text" ng-model="model.value" /><br/></div>'
}
but that doesn't work...
Plunker
You are right - ng-if creates a child scope which is causing a problem when text is entered in the input text field. It creates a shadow property named 'model' in child scope which is a copy of the parent scope variable with the same name - effectively breaking the two-way model binding.
The fix for this is simple. In your template, specify the $parent prefix:
template: '<div ng-if="true">
<input type="text" ng-model="$parent.model" /><br/>
</div>'
This ensures that it will resolve 'model' from the $parent scope, which you've already setup for two-way model binding through the isolated scope.
In the end, the '.' in ng-model saves the day. I find it useful to think about anything left of the dot as a way for Angular to resolve the property through scope inheritance. Without the dot, resolving the property only becomes an issue when we're assigning scope variables (otherwise, lookups are fine, including read-only {{model}} binding expressions).
ng-if creates an additional prototypally inheriting scope, so ng-model="model" binds to the inherited property of the new scope and not to the 2-way binded property of the directive scope.
Change it to ng-show and it will work.
You can use a small Firebug extension i've written to inspect angular scopes.

Why does declaring an isolate scope block $emit events from being heard?

I'm using a directive that has a controller as a child scope and i'm trying to make them talk. It seems that when I $emit an an event from the child controller, the parent directive can only hear this event when it does not have an isolate scope declared. Why does declaring an isolate scope in my directive block $emit events from being heard?
html
<flippy data-click-toggle="true" data-mouseover-toggle="true">
<flippy-front>
<div ng-controller="test">
<div class="test" ng-click="flip()">
front
</div>
</div>
</flippy-front>
<flippy-back>
<div ng-controller="test">
<div class="test" ng-click="flip()">
back
</div>
</div>
</flippy-back>
</flippy>
js
poop = angular.module('angular-flippy', []);
poop.directive('flippy', function() {
return {
restrict: 'E',
scope: {}, <------- works when isolate scope not declared
link: function($scope, $elem, $attrs) {
var flip = function() {
$elem.toggleClass('flipped');
}
$scope.$on("flipped", flip);
}
};
});
poop.controller('test', ['$scope', function($scope) {
$scope.flip = function() {
$scope.$emit("flipped");
};
}]);
codepen:
http://codepen.io/anon/pen/xzteC
It happens because your controllers scopes are not children scopes of your directive.
If you debug your application you will see
-->$rootScope (id: 002)
-->directive scope (id: 003)
-->first controller scope (id: 004)
-->second controller scope (id: 005)
As you can see, directive scope and controllers scopes are siblings - relation parent-child needed to listen the event doesn't exist.
It works if you omit scope:{} because then your directive uses rootScope as its own scope, so it becomes parent of test controllers scopes.
Check the console logs to see this structure: http://codepen.io/anon/pen/tiGEy. (Watch $parent properties of objects)

Resources