Those of you who already worked with angular.js 1.x might be already familiar with the concepts described in this post. In angular.js 1.x you might know this concept under the infamous name transclusion. It’s not a concept invented by the Angular Team but rather one that describes how content of any document can be projected into another. Read more about it on Wikipedia. As the term transclusion caused a lot of confusion in the angular 1 days the core team has decided to ban the term and go with a more meaningful name: Content Projection and this post focusses on one part of it, namely .
When designing simple Angular components you’re probably working with @Input() to pass data to your Component. This is mostly fine, but let’s spin up an imaginary scenario and work through it within the following pages. Lets say you are supposed to create a card component, just like the Angular Material Card. The Angular Material team works heavily with the concept we’re going to explore now. The card component that we are going to design should include a default styling and shall have a headline, content and footer as string only inputs.
//card.component.ts
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-card',
templateUrl: './card.component.html',
styleUrls: ['./card.component.css']
})
export class CardComponent {
@Input()
headline: string;
@Input()
body: string;
@Input()
footer: string;
constructor() { }
}
<!-- card.component.html -->
<div class="card">
<div class="header">
<h2>{{headline}}</h2>
</div>
<hr>
<div class="body">
<div>{{body}}</div>
</div>
<hr>
<div class="footer">
<div>{{footer}}</div>
</div>
</div>
The usage of the Component would look like this
<app-card headline="My Headline"
body="My Body"
footer="My Footer">
</app-card>
you can see it live in action on Stackblitz: https://stackblitz.com/edit/angular-card-example
Until now everything is fine, but all of a sudden your PM approaches you and says that we need to display additional HMTL inside the body of our Card Component. How would you solve it? One approach would be to use ng-content.
You may also like: Content Projection/Transclusion in Angular 8 with ng-content
ng-content can be used to pass HTML content to a child component. You can not only pass in plain HTML but also property bindings and events. The bindings and events are bound to the parent component and not the child component. Using this approach you can get rid of your @Input() that defines the body text of the Card Component. There is not much you have to do, just by throwing in a into the HTML file of your child component you define the spot Angular will render (project) the content to. The content that gets projected into the tag is defined inside the tag of your child component (the card component in our case). Let’s look at how it looks like in the code. In the card-component.ts we’re just going to remove the @Input() for the body.
//card.component.ts
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-card',
templateUrl: './card.component.html',
styleUrls: ['./card.component.css']
})
export class CardComponent {
@Input()
headline: string;
@Input()
footer: string;
constructor() { }
}
In the card.component.html we’re just going to add a tag where the {{ body }} binding was previously
<!-- card.component.html -->
<div class="card">
<div class="header">
<h2>{{headline}}</h2>
</div>
<hr>
<div class="body">
<ng-content></ng-content>
</div>
<hr>
<div class="footer">
<div>{{footer}}</div>
</div>
</div>
The usage of the Component would look like this
<app-card headline="My Headline"
footer="My Footer">
<h3>My Body as ng-content</h3>
</app-card>
Now you’re all good but again you PM approaches you and tells you that in some of the Card Components we now have to display a button that triggers some action and in some other cards he wants to just display text as before. Now we’re running into a bit of trouble, we could either expand the public interface of our Card Component like to add some @Input() or even duplicate the component but we recently learned about and I’m happy to tell you, we can solve the requirements with nearly the same technique as examined above.
Angular allows you to have more than one slot to project your content. The only exception is that you have to somehow declare which content should be projected into which in the child component. There are multiple ways to do it, but we’re going to stick with the one that’s most often used in the numerous open source projects and you might have already wondered how they did.
We want to pass the header, footer and body as HTML content to the Card Component. In Order do this we are going to extend the card.component.ts as follows.
We are going to introduce 3 new directives. If you don’t know already a directive is pretty much the same as a component, except it does not have it’s own template. In our case we are using these directives as a marker directive which simply means that we’re assigning meaning to those directives but they do not contain any sort of logic, but are just used for the purpose of defining which element should be projected into which slot. Make sure to also add these Directives to the declarations Array of the corresponding Module.
The second thing we are doing is to define a @ContentChild for every of those 3 mentioned directives. A ContentChild is used to get the first element or the directive matching the selector from the content DOM. We use these properties later on to conditionally show/hide whole blocks within our card template, like showing
//card.component.ts
import { Component, Input, ContentChild, Directive} from '@angular/core';
@Directive({
selector: '[appCardBody]'
})
export class CardBodyDirective {
}
@Directive({
selector: '[appCardFooter]'
})
export class CardFooterDirective {
}
@Directive({
selector: '[appCardHeader]'
})
export class CardHeaderDirective {
}
@Component({
selector: 'app-card',
templateUrl: './card.component.html',
styleUrls: ['./card.component.css']
})
export class CardComponent {
@ContentChild(CardHeaderDirective) header?: CardHeaderDirective;
@ContentChild(CardBodyDirective) body?: CardBodyDirective;
@ContentChild(CardFooterDirective) footer?: CardFooterDirective;
constructor() { }
}
In the template we now have to define where we want to render the content. The two thing I want to highlight are:
<!-- card.component.html -->
<div class="card">
<div class="header" *ngIf="header">
<ng-content select="[appCardHeader]"></ng-content>
<hr>
</div>
<div class="body" *ngIf="body">
<ng-content select="[appCardBody]"></ng-content>
</div>
<div class="footer" *ngIf="footer">
<hr>
<ng-content select="[appCardFooter]"></ng-content>
</div>
</div>
The usage now looks like this. The important thing to look at is how we use the marker directives (attributes) to mark the DOM Nodes according to where we want to display them. The order of them does not matter, they could be all mixed up and Angular would find them because we select them by a css selector (attribute selector) in the card.component.html
We can now pass any HTML to the Card Component, we can even use property bindings and all of the nice Angular templating features.
<app-card>
<h2 appCardHeader>My Header</h2>
<div appCardBody>My Body</div>
<div appCardFooter>
<span>My Footer</span>
<button>My Button</button>
</div>
</app-card>
Pros
Cons
There is not IDE Support / IntelliSense for this feature, you have to know how the component you are using is working internally. This can be solved by having a good documentation, but let’s be honest: This works for large community driver projects like Angular Material, but rarely does in your own projects.
Creating marker directives is tedious. You could work with class selectors but this makes everything more fragile.
Can break when refactoring/renaming the marker directives
Not very good to unit test. You need more code to setup your unit test.
Originally published by Kai Henzler at thecodecampus.de
#angular #web-development