Rendering A TemplateRef As A Child Of The Body Element In Angular 9.0.0-rc.5
CAUTION: The concept that I explore here goes outside my area of expertise. As such, please take this with a grain of salt and forgive anything that is grossly inaccurate.
A few weeks ago, I discovered that you could translocate Angular DOM nodes without breaking template bindings. This kind of blew my mind; and, since then, I've been on the lookout for how you might leverage such a feature. For example, I recently used it to render Select options in the root Stacking context. Then, this morning, I was poking around in the Angular Material repository when I saw that the Angular team using a similar technique to render Overlays. Only, they were doing it with a ng-template
/ TemplateRef
. This blew my mind even further! So, I wanted to try it out for myself, creating a demo in which I render a TemplateRef
in the document.body
DOM node using Angular 9.0.0-rc.5.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
Not only was the Angular Material library using an ng-template
element in their Overlay implementation, they were combining it with content-projection. This way, they could - loosely speaking - project content from within the Angular App into the document.body
node (which is outside the Angular app). For my exploration, I tried to create a super simple version of what they were doing.
I created <app-body-content>
. This Angular component takes the "child content" from the calling context and projects it / renders it in the body
tag. This component has no appreciable output of its own, other than its host element - it simply projects the content into a TemplateRef
:
// Import the core angular services.
import { Component } from "@angular/core";
import { TemplateRef } from "@angular/core";
import { ViewChild } from "@angular/core";
import { ViewContainerRef } from "@angular/core";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Component({
selector: "app-body-content",
queries: {
contentRef: new ViewChild( "contentRef" )
},
styleUrls: [ "./body-content.component.less" ],
template:
`
<!--
NOTE: On its own, the NgTemplate has no rendered output. As such, the
projected content will have no output until the component explicitly renders
it using the ViewContainerRef (in this case).
-->
<ng-template #contentRef>
<ng-content></ng-content>
</ng-template>
`
})
export class BodyContentComponent {
public contentRef!: TemplateRef<any>;
private viewContainerRef: ViewContainerRef;
// I initialize the body content component.
constructor( viewContainerRef: ViewContainerRef ) {
this.viewContainerRef = viewContainerRef;
}
// ---
// PUBLIC METHODS.
// ---
// I get called once after the view bindings have been wired-up.
public ngAfterViewInit() : void {
// Render the TemplateRef as a SIBLING to THIS component.
var embeddedViewRef = this.viewContainerRef.createEmbeddedView( this.contentRef );
// NOTE: I don't if this call is actually needed. It doesn't seem to make a
// difference in this particular demo; however, it is called in the Angular
// Material code, so I assume it is important (in at least some cases).
embeddedViewRef.detectChanges();
// At this point, the embedded-view DOM (Document Object Model) branch has been
// wired-together, complete with view-model bindings. We can now move the DOM
// nodes - which, in this case, is made up of the NgContent-projected nodes -
// into the BODY without breaking the template bindings.
for ( var node of embeddedViewRef.rootNodes ) {
document.body.appendChild( node );
}
}
}
This component is short, but it's complicated. First, I query for the TemplateRef
which contains the projected-content from the calling context. On its own, a TemplateRef
has no rendered output. As such, the user won't see the projected-content until we explicitly render it using the injected ViewContainerRef
.
When we call .createEmbeddedView()
, the projected-content is actually rendered as a set of sibling nodes to the <app-body-content>
component. However, before the user has a chance to see the content, we move all of the rendered DOM nodes into the document.body
. And, since view-template fragments can be safely translocated, all of the rendered DOM nodes retain their view-model bindings.
NOTE: In my code, I am calling
.detectChanges()
on the rendered template. This is what the Angular Material code was doing. In my demo, including or excluding that call seemed to make no difference. As such, I am not entirely sure what it is doing.
Now, to see this BodyContentComponent
in action, let's consume it within our App component and project some dynamic template fragments into the document.body
:
// Import the core angular services.
import { Component } from "@angular/core";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Component({
selector: "app-root",
styleUrls: [ "./app.component.less" ],
template:
`
<p>
<a (click)="toggleContent()">Toggle Content</a>
</p>
<!--
The content below this component will be projected into the Body.
--
NOTE: I'm using the term "projected" loosely here. The content is still
technically projected into the BodyContent component; but, it is, in turn,
moved into the document.body node.
-->
<app-body-content *ngIf="isShowingContent">
The time is: {{ time.toTimeString() }}.
<p [ngSwitch]="( time.getSeconds() % 2 )">
<ng-template [ngSwitchCase]="0">
The seconds are: even.
</ng-template>
<ng-template [ngSwitchCase]="1">
The seconds are: odd.
</ng-template>
</p>
<div *ngFor="let recording of recordings">
{{ recording }}
</div>
</app-body-content>
`
})
export class AppComponent {
public isShowingContent: boolean;
public recordings: any[];
public time: Date;
// I initialize the app component.
constructor() {
this.isShowingContent = false;
this.recordings = [];
this.time = new Date();
setInterval(
() => {
this.time = new Date();
this.recordings.unshift( this.time );
},
1000
);
}
// ---
// PUBLIC METHODS.
// ---
// I toggle the rendering of the body-content component.
public toggleContent() : void {
this.isShowingContent = ! this.isShowingContent;
}
}
Within the App component, we are dynamically rendering the <app-body-content>
using an ngIf
structural directive. Then, within the content of this tag, we have some silly time-based bindings that will demonstrate that the translocated template fragment continues to update even after it is moved into the document.body
node. And, when we run this Angular code, we get the following ouptut:
As you can see from the Elements tab (in the Chrome Dev Tools), the content that we defined within our App component is being rendered as a direct child of the document.body
element. And, the setInterval()
that we configured from our App component class is continuing to update the values within said content.
This is so cool!
I absolutely love how dynamic and flexible and powerful Angular is. It constantly amazes me. I'm still trying to figure out how to best take advantage of features like the rendering of TemplateRef
content in the document.body
node. But, the more I learn, the more inspired I become.
Want to use code from this post? Check out the license.
Reader Comments
This is very similar to Angular Material Portals.
I had recently done some thing similar to a dropdown which was being cut off because it's parent had overflow hidden element. So I used portal to render the template of the dropdown in parent component which did not have overflow: hidden set. I did not wanted to add angular material dependency to my project so I used example from the link below.
https://medium.com/angular-in-depth/how-do-cdk-portals-work-7c097c14a494
@Hassam,
The Angular Material code is kind of mind-blowing. I've tried to poke around in it a bit, but it has so many abstractions and inversion-of-control and classes all over the place, it's a bit hard to follow. But, yes -- the portal stuff they have is really cool. I had never seen anything like that before I started playing around with these ideas.
Thanks for the link - Juri Strumpflohner really knows his stuff!
I just switched to Angular 9 / Ivy and experience problems with this approach that worked fine for me until now.
errors.ts:30 ERROR TypeError: Cannot read property 'createEmbeddedView' of undefined
at ViewContainerRef.createEmbeddedView (view_engine_compatibility.ts:217)
at BodyContentComponent.ngAfterViewInit (app-body-content.ts:45)
at callHook (hooks.ts:245)
at callHooks (hooks.ts:214)
at executeInitAndCheckHooks (hooks.ts:159)
at refreshView (shared.ts:478)
at refreshComponent (shared.ts:1680)
at refreshChildComponents (shared.ts:141)
at refreshView (shared.ts:456)
at refreshComponent (shared.ts:1680)
What might that be and how do I solve it?
Regards, Dirk, Germany
@Dirk,
Hmmmm, I'm not sure. I'm looking at the "Breaking Changes" list for Angular 9.0.0 and I'm not seeing anything that's clicking in my head:
https://github.com/angular/angular/blob/master/CHANGELOG.md#breaking-changes-2
I'll see if I can carve out some time to try this with the latest release.
Thank to you i was able to solve a serious problem. I was inserting EmbeddedViewRef´s created from Templates from another Component.
This only renders a comment as native element, so i was not able to get it to apply styles before it is attached to the DOM.
It seems that the part
<viewRef>.detectChanges();
renders the element and makes it available.It might be because im using ivy, because there seemed to be a problem with ivy not registering outside templates to the change detection. I might try that out later.
It´s very interisting that the inclusion of some code used by official angular devs helped me. Thanks for not removing it.
@Marcel,
I'm glad this post has helped. I'm a bit rusty on my Angular (been working on a lot of server-side code the last year or two); so, I had re-read this blog post in order to get my bearings 😄 I'm super excited to jump back into Angular and try the new "stand alone components" that are in the dev-preview.
I've tried to use this for my situation, but it does not work. What I'm trying to achieve...
I have a Google Map component created dynamically. I need to pass through a popup template, which needs to be rendered when clicked on the created pin (also some variables passed through too need to be evaluated per each popup). I cannot achieve this. Using the approach above, I'm getting error: "Cannot read properties of undefined (reading 'createEmbeddedView')". What is a proper way to implement what I need?
Allright - I made it! I used your approach + passing templateref into component. This way, inside the component which received the references I was able to render the template. Please, keep in mind one useful thing: if you need to process data in your template - you can use second parameter like this:
@Sasha,
Glad you got it working! The
templateRef
stuff is really complex, but really powerful. I'm still trying to wrap my head around how it works. The "basic" stuff in Angular is so nice and simple; but, then it can get really complicated really fast when you need to very advanced things like passing around templates. Good work!Just want to make one note after today's findings. You need this:
if you have *ngIf, ng-container and etc. within your template. Otherwise the content won't be generated so far and you will get an empty markup with "ng-" placeholders.
@Sasha,
Great tip! Thank you.