Template Transclusion in AngularJs

elpddev

Eyal Lapid

Posted on December 4, 2020

Template Transclusion in AngularJs

How to do a semi template transclusion in AngularJs, using a customize transclude directive.

TL;DR

Custom AngularJs/1 transclude directive that allow the transcluded content access the grandparent scope as before and allow the parent to pass data to it as ng-repeat allows.

The custom directive is available here in GitHub and NPM.

App Component:

<div>{{ $ctrl.grandParentHeader }}</div>

<my-list items="$ctrl.movies">
   <div>App data: {{ $ctrl.grandParentHeader }}</div>
   <div>Name:{{ name }} Year: {{ year }}</div>
</my-list>
Enter fullscreen mode Exit fullscreen mode

MyList Component:

<ul>
  <li ng-repeat="item in $ctrl.items track by item.id">
   <cr-transclude context="item"></cr-transclude>
  </li>
</ul>
Enter fullscreen mode Exit fullscreen mode

Scenario

When drawing a table on the page, the basic to do so is using ng-repeat.

Now, when wanting incorporate a custom logic and presentation to the table and create a custom table component that does the ng-repeat inside but get the row to paint transcluded from outside, its not possible using the regular ng-transclude directive.

ng-transclude allow accessing the data from the grandparent, not the parent that render the transcluded content. The parent has no options to transfer data to the transcluded child. Meaning if we wanted to do something like this:

grandparent.js

<my-custom-table>
  <trn-row>
      <td><hero-image id="row.id"></td>
  </trn-row>
</my-custom-table>
Enter fullscreen mode Exit fullscreen mode

parent— my-custom-table.compoent.js

<div class="table">
   <ng-transclude ng-transclude-slot="trnRow"
     ng-repeat="row in $ctrl.rows>
   </ng-transclude>
</div>
Enter fullscreen mode Exit fullscreen mode

We can’t.

The trn-row has no access to row from the ng-repeat of the child component.

Other examples could be requirement to create a custom dropdown, carousel, and any other repeater component or even one projection component but with the need of the parent to transfer data to the transcluded content from the grandparent.

Angular/2 Solution

In Angular/2, this is easy to implement using template child content transferring from the parent and template outlet displaying in the child.

This example is taken from the excellent article on content projection in Angular/2 by Clarity Design System. Angular/2 documents are somewhat lack in this regard.

@Component({                         
  selector: 'wrapper',                         
  template: `                           
    <div class="box" *ngFor="let item of items">                            
      <ng-container [ngTemplateOutlet]="template; content: { item }"></ng-container>                           
    </div>                         `                       
})                       
class Wrapper {                         
  items = [0, 0, 0];                         
  @ContentChild(TemplateRef) template: TemplateRef;                       }@Component({
  selector: 'parrent',
  template: `
    <wrapper>                         
      <ng-template>                           
        {{ item.name }} - {{ item.amount }}                     
      </ng-template>                       
    </wrapper>
  `
})
class Parent {}
Enter fullscreen mode Exit fullscreen mode

Here, several things happens:

  1. The parent transfer a template to the wrapper child by template projection

  2. The child capture in a property and access the transferred template using @ContentChild content query.

  3. Then the child uses the template inside an ngForOf loop using ngTemplateOutlet

Whats most important to notice here regarding our case is the transference of context into the projected template. This is how the child can give data to the projected template.

AngularJs Solution

This feature has already been asked before and was not dealt officially in AngularJs core.

It was shown that this can be done in augmented or derivative directive of ng-transclude . Excellent examples were given that others build upon.

The solution take the code of what ng-transclude does — which is essentially using the $transclude function to attach a content — and adding a logic to it that provide the transcluded content the scope of the child.

The main logic can be condensed to providing the $transclude function a base scope of our own choosing instead of the default one that $transclude is using which is the grandparent (the root) scope:

// const customScope = $scope (which is the parent) and not the grandparent$transclude(customScope, function( clone ) {                                                  
  $element.empty();                                               
  $element.append( clone );                                           });
Enter fullscreen mode Exit fullscreen mode

This instead of the default way that ng-transclude does it, which is to provide the transcluded content access to the a specialized scope getting the properties of the grandparent.

$transclude(ngTranscludeCloneAttachFn, null, slotName);
...
function ngTranscludeCloneAttachFn(clone, transcludedScope) { 
  ...                                                                    
  $element.append(clone);   
  ...                              
} 
Enter fullscreen mode Exit fullscreen mode

The API for the $transclude function is specified as:

    $transclude — A transclude linking function pre-bound to the correct transclusion scope: function([scope], cloneLinkingFn, futureParentElement, slotName):

    - scope: (optional) override the scope.

    - cloneLinkingFn: (optional) argument to create clones of the original transcluded content.

    - futureParentElement (optional):
    defines the parent to which the cloneLinkingFn will add the cloned elements.
    default: $element.parent() resp. $element for transclude:’element’ resp. transclude:true.

    only needed for transcludes that are allowed to contain non html elements (e.g. SVG elements) and when the cloneLinkingFn is passed, as those elements need to created and cloned in a special way when they are defined outside their usual containers (e.g. like <svg>).

    See also the directive.templateNamespace property.

    - slotName: (optional) the name of the slot to transclude. If falsy (e.g. null, undefined or ‘’) then the default transclusion is provided. The $transclude function also has a method on it, $transclude.isSlotFilled(slotName), which returns true if the specified slot contains content (i.e. one or more DOM nodes).
Enter fullscreen mode Exit fullscreen mode

Feature — Have Access to Both Parent and Grandparent Data

Those solutions can be built upon and add:

  • Explicit data binding to the transcluded content so the parent will have the option to provide the transcluded content only the data it wants to provide.

  • Allow the transcluded content access to the grandparent $scope as before — The same way it had using the regular ng-transclude.

We want to be able to give the transcluded content access to some data from the parent and to keep access to the scope of its declaration place — the grandparent

myAppModule.component('grandparent', {
  template: `
    <parent items="$ctrl.items>
     <div>{{ firstName }}</div> // this is from the parent data
     <div>{{ $ctrl.items.length }}</div> // this is from the grandparent
    </parent>
  `
  ...
});myAppModule.component('parent', {
  template: `
    <div ng-repeat="item in $ctrl.items">
     <custom-transclude data="item"></custom-transclude>
    </div>
  `
  ...
});
Enter fullscreen mode Exit fullscreen mode

NgRepeat as an Example

AngularJs already does something similar. In ng-repeat itself, we see some kind of this behavior. The ng-repeat acts as a parent, the container of the ng-repeat as a grandparent, and the grandparent specify to the ng-repeat the template to repeat. In that template — the grandson - it has access to:

  1. Its own scope — the grandparent scope

  2. Some explicit properties the ng-repeat gives it like: $index , $last , $first and others. Most important, is the valueIdentifier specified in the dsl expression myItem in $ctrl.items . The myItem is given to the transcluded content for each one with the key name specified in the expression: myItem.

How does ng-repeat does this?

Looking at ng-repeat code, this can be seen:

var updateScope = function(scope, index, valueIdentifier, value, 
    keyIdentifier, key, arrayLength) {

  scope[valueIdentifier] = value;                           
  if (keyIdentifier) scope[keyIdentifier] = key;                             
  scope.$index = index;                           
  scope.$first = (index === 0);                            
  scope.$last = (index === (arrayLength - 1));                           
  scope.$middle = !(scope.$first || scope.$last);                                                 
  scope.$odd = !(scope.$even = (index & 1) === 0);                         };...return {                           
  restrict: 'A',                           
  multiElement: true,                            
  transclude: 'element',                           
  priority: 1000,                           
  terminal: true,                           
  $$tlb: true,
  compile: function ngRepeatCompile($element, $attr) {
    return function ngRepeatLink($scope, $element, $attr, ctrl, 
       $transclude) {      $scope.$watchCollection(rhs, function 
          ngRepeatAction(collection) {
         ...
         // new item which we don't know about                                     
         $transclude(function ngRepeatTransclude(clone, scope) {                                       
           block.scope = scope; 
           ...
           updateScope(block.scope, index, valueIdentifier, value, 
             keyIdentifier, key, collectionLength);                                     
         });
      });
    }
  }
  ...
};
Enter fullscreen mode Exit fullscreen mode

Here it can be seen that ng-repeat create for each item in the list a DOM copy by using the transclusion function with a value for the cloneLinkFn parameter. The $transclude api specify that if you give a cloneLinkFn function, the $transclude create a copy of the transcluded content and not use it directly.

The second important thing to notice here, the $transclude function gives the cloneLinkFn the clone DOM, and a special generated scope it created.

That special generated scope is inheriting prototypical from the grandparent — where the transcluded content comes from — but is connected via the $child-$parent relationship to the scope of the parent where the transclude function is used — the ng-repeat. Meaning the DOM transcluded copy has access to the grandparent scope data, but it get $destroy message from the parent when it goes away. It does not however has any access to the parent scope data.

To get access to the parent scope data, the ng-repeat directive expicitly attach data to its generated scope. For example the $index , $last , $first data that we can see.

A Look Into NgTransclude

After ngRepeat , How does ngTransclude does it work? Looking at it’s code, this is what can be seen:

var ngTranscludeDirective = ['$compile', function($compile) {return {                           
  restrict: 'EAC',                           
  compile: function ngTranscludeCompile(tElement) {
    return function ngTranscludePostLink($scope, $element, $attrs, 
        controller, $transclude) {
     };
     ...   
     $transclude(ngTranscludeCloneAttachFn, null, slotName);
     ...
     function ngTranscludeCloneAttachFn(clone, transcludedScope) {
       ...                                 
       $element.append(clone);
       ...
     }  }
}];
Enter fullscreen mode Exit fullscreen mode

We can see almost the same usage of the $transclude facility. Creating a DOM copy of the transcluded content by providing a cloneAttachFunction and adding that clone to the DOM.

Returning to our original quest, how can we have a directive that does a transclusion that keeps access to the grandparent data but allow giving the transcluded copy another data of our own also like ng-repeat ?

AngularJs/1 Augmented Transclude Directive

The solution is much simpler that anticipated.

Looking at the ngTransclude code, all we have to do is:

  1. Give it/Listen/Watch on a binding parameter context that we will use to give the directive a custom data.

  2. Attach that given data to the generated scope that then clone transcluded DOM is attached to.

Here, the custom transclusion function does 2 things:

  1. Watch over a directive attribute expression, gets it’s value and save it localy.

  2. Get the transcluded clone generated special scope and save it localy.

  3. Update the generated special scope with the custom data given to the directive of first time and each time it’s reference is updated.

return function ngTranscludePostLink(
   ...
  ) {
  let context = null;
  let childScope = null;
  ...
  $scope.$watch($attrs.context, (newVal, oldVal) => {
    context = newVal;
    updateScope(childScope, context);
  });
  ...
  $transclude(ngTranscludeCloneAttachFn, null, slotName);
  ...
  function ngTranscludeCloneAttachFn(clone, transcludedScope) {
     ...                                 
     $element.append(clone);
     childScope = transcludedScope;
     updateScope(childScope, context);
     ...
  }
  ...
  function updateScope(scope, varsHash) {
    if (!scope || !varsHash) {
      return;
    }    angular.extend(scope, varsHash);
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, with the brand new cr-transclude directive, we can create our one list generice list component that accpect from the outside template how to show its rendered items.

App Component:

<my-list items="$ctrl.movies">
   <div>App data: {{ $ctrl.header }}</div>
   <div>Name:{{ name }} Year: {{ year }} Rating: {{ rating 
          }}</div>
</my-list>
Enter fullscreen mode Exit fullscreen mode

MyList Component

<ul>
  <li ng-repeat="item in $ctrl.items track by item.id">
   <div>Ng repeat item scope id: {{ $id }}</div>
   <cr-transclude context="item"></cr-transclude>
  </li>
</ul>
Enter fullscreen mode Exit fullscreen mode

Conclusion

This is how a semi template projection can be done in AngularJs/1. Adding a small logic to the original ngTransclude that gives it the power to transfer custom data from the parent to the transcluded content.

Many thanks to the people who contributed their knowledge and time in the GitHub issues, documents and articles given below. They were invaluable.

The custom directive is available here in GitHub and NPM.

References

💖 💪 🙅 🚩
elpddev
Eyal Lapid

Posted on December 4, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related