I recently came across a scenario where I had several components with the same structure in them, so I figured I’d extract it out into it’s own component.
Simple right? Everyday scenario?
Were it so easy…
TL;DR
Problem: Can’t do transclusion and pass properties to content.
Solution: Use ng-template
with ng-container *ngTemplateOutlet
with a context: { $implicit: variable-name }
property.
Html
<div>
<h1>Super cool page title</h1>
<p>You're going to love this demo</p>
<div *ngFor="let holiday of holidays">
<div *ngFor="let location of holiday.locations">
<strong>{{ location.name }}</strong>
<p>{{ location.picture }}</p>
</div>
</div>
</div>
Typescript
Expand/Collapse ts
import { Component, OnInit } from '@angular/core';
export interface Location {
name: string;
picture: string;
}
export interface Holiday {
locations: Location[];
}
@Component({
selector: 'app-original',
templateUrl: './original.component.html',
styleUrls: ['./original.component.scss']
})
export class OriginalComponent implements OnInit {
holidays: Holiday[] = [];
constructor() { }
ngOnInit() {
this.holidays = []; // Initialisation removed for brevity
}
}
import { Component, OnInit } from '@angular/core';
export interface Location {
name: string;
picture: string;
}
export interface Holiday {
locations: Location[];
}
@Component({
selector: 'app-original',
templateUrl: './original.component.html',
styleUrls: ['./original.component.scss']
})
export class OriginalComponent implements OnInit {
holidays: Holiday[] = [];
constructor() { }
ngOnInit() {
this.holidays = []; // Initialisation removed for brevity
}
}
I wanted to extract the two *ngFor
divs into their own component for reuse in another part of the codebase.
I wanted to be able to do something like:
<div>
<h1>Super cool page title</h1>
<p>You're going to love this demo</p>
<EXTRACTED-COMPONENT [data]="holidays">
<strong>{{ location.name }}</strong>
<p>{{ location.picture }}</p>
</EXTRACTED-COMPONENT>
</div>
I searched around the internet and found several relevant posts and articles (links also at the bottom of the article), but none really explained how it worked or how to make it work properly.
Transclusion was the first stop. This is where an element indicates where it’s child content will be rendered without explicitly knowing what the content is.
Angular provides the ng-content
component for this purpose.
transclusion.component.html html
<div>
<p>This is a title</p>
<ng-content></ng-content>
</div>
<div>
<p>This is a title</p>
<ng-content></ng-content>
</div>
Such a component would be used like this:
<transclusion>
<p>This is some content</p>
</transclusion>
And you’d get:
This is a title
This is some content <--- Pulled through the ng-content
However I’m looking to have the transclusion BUT with some context from structural directives like *ngFor
. I figured you’d be able to pass some context to this content, but unfortunately ng-content
doesn’t allow dynamic content (docs says it’s performed at compile time), so I needed another solution.
I talked with Tristan Menzel about how to approach this, and he pointed me towards structural directives and templates.
ng-template
provides a way of specifying dynamic transclusion content that can be modified/populated at runtime.
Templates can be rendered inside a ng-container
via the *ngTemplateOutput
directive.
<div>
<ng-template let-location>
<p>{{ location.name }}</p>
</ng-template>
</div>
<ng-container *ngTemplateOutlet="template; context: { $implicit: location }"></ng-container>
The above code indicates that the ng-container
should render the template called template providing it with the implicit knowledge of a variable called location, so the template itself can function. How does this context work?
It’ll be easier to just go through the solution as a whole.
I had a hard time putting this succinctly, so let’s start with the finished source.
Html
<div *ngFor="let holiday of holidays">
<div *ngFor="let location of holiday.locations">
<ng-container *ngTemplateOutlet="template; context: { $implicit: location }"/>
</div>
</div>
Typescript
Expand/Collapse ts
import { Component, OnInit, Input, ContentChild, TemplateRef } from '@angular/core';
import { Holiday } from '../original/original.component';
@Component({
selector: 'app-extracted',
templateUrl: './extracted.component.html',
styleUrls: ['./extracted.component.scss']
})
export class ExtractedComponent implements OnInit {
@Input() holidays: Holiday[];
// Searches the tree for an element of type TemplateRef (ng-template)
// and sets this property
@ContentChild(TemplateRef) template: TemplateRef<any>;
constructor() { }
ngOnInit() {
}
}
import { Component, OnInit, Input, ContentChild, TemplateRef } from '@angular/core';
import { Holiday } from '../original/original.component';
@Component({
selector: 'app-extracted',
templateUrl: './extracted.component.html',
styleUrls: ['./extracted.component.scss']
})
export class ExtractedComponent implements OnInit {
@Input() holidays: Holiday[];
// Searches the tree for an element of type TemplateRef (ng-template)
// and sets this property
@ContentChild(TemplateRef) template: TemplateRef<any>;
constructor() { }
ngOnInit() {
}
}
Html
<div>
<h1>Super cool page title</h1>
<p>You're going to love this demo</p>
<app-extracted [holidays]="holidays">
<ng-template let-location>
<strong>{{ location.name }}</strong>
<p>{{ location.picture }}</p>
</ng-template>
</app-extracted>
</div>
Typescript
Expand/Collapse ts
import { Component, OnInit } from '@angular/core';
export interface Location {
name: string;
picture: string;
}
export interface Holiday {
locations: Location[];
}
@Component({
selector: 'app-refactored',
templateUrl: './refactored.component.html',
styleUrls: ['./refactored.component.scss']
})
export class RefactoredComponent implements OnInit {
holidays: Holiday[] = [];
constructor() { }
ngOnInit() {
this.holidays = []; // Initialisation removed for brevity
}
}
import { Component, OnInit } from '@angular/core';
export interface Location {
name: string;
picture: string;
}
export interface Holiday {
locations: Location[];
}
@Component({
selector: 'app-refactored',
templateUrl: './refactored.component.html',
styleUrls: ['./refactored.component.scss']
})
export class RefactoredComponent implements OnInit {
holidays: Holiday[] = [];
constructor() { }
ngOnInit() {
this.holidays = []; // Initialisation removed for brevity
}
}
EDIT: Note that for reduced black box magic, you can specify the template directly instead of with @ContentChild
.
See below. @ContentChild
replaced in the Typescript with @Input()
and the html altered slightly to give the template an id with #template
and the app-extracted
component takes the template as a variable directly.
<app-extracted [holidays]="holidays" [template]="template">
<ng-template let-location #template>
<strong>{{ location.name }}</strong>
<p>{{ location.picture }}</p>
</ng-template>
</app-extracted>
*ngFor
divs out into a new component*ngFor
*ngFor
in a ng-template
insteadlocation
variable to this as it’s undefined right nowlet-location
directiveng-template
we just defined.ng-template
context.*ng-container
renders the ng-template
from the parent component.ng-template
is discovered by the ng-container
via the @ContentChild(TemplateRef)
decorator.ng-template
)context: { $implicit: location}
part of the ng-container *ngTemplateOutput
statement.ng-template let-location
statement, this creates an implicit context containing the variable location as if it was declared.let-location
or any use of the location variable afterwards.ng-template
an implicit variable it can expectng-container
by expanding on the *ngTemplateOutlet
property@ContentChild
can be used to find the template for you or you can provide it as a standard angular input variable (but looks messier)https://stackoverflow.com/questions/42978082/what-is-let-in-angular-2-templates
https://blog.angular-university.io/angular-ng-content/
https://medium.com/claritydesignsystem/ng-content-the-hidden-docs-96a29d70d11b
https://stackoverflow.com/questions/51807192/passing-for-variable-to-ng-content