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
Expand/Collapse 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>
<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:
Expand/Collapse html
<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>
<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.
Expand/Collapse 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:
Expand/Collapse html
<transclusion>
<p>This is some content</p>
</transclusion>
<transclusion>
<p>This is some content</p>
</transclusion>
And you’d get:
Expand/Collapse go
This is a title
This is some content <--- Pulled through the ng-content
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.
Expand/Collapse html
<div>
<ng-template let-location>
<p>{{ location.name }}</p>
</ng-template>
</div>
<div>
<ng-template let-location>
<p>{{ location.name }}</p>
</ng-template>
</div>
Expand/Collapse html
<ng-container *ngTemplateOutlet="template; context: { $implicit: location }"></ng-container>
<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
Expand/Collapse html
<div *ngFor="let holiday of holidays">
<div *ngFor="let location of holiday.locations">
<ng-container *ngTemplateOutlet="template; context: { $implicit: location }"/>
</div>
</div>
<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
Expand/Collapse 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>
<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.
Expand/Collapse html
<app-extracted [holidays]="holidays" [template]="template">
<ng-template let-location #template>
<strong>{{ location.name }}</strong>
<p>{{ location.picture }}</p>
</ng-template>
</app-extracted>
<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
Tags: angular