Writing your first custom structural directive
In this recipe, you’ll write your first custom structural directive named showFor
(or *appShowFor
with the prefix). A structural directive is one that can add or remove elements from the DOM. So, with this directive, we will add the particular element to the DOM if a provided Boolean is true, and we will remove it after the specified time (provided as a number representing milliseconds).
Getting ready
The app that we are going to work with resides in start/apps/chapter02/ng-show-for-directive
inside the cloned repository:
- Open the code repository in your code editor.
- Open the terminal, navigate to the code repository directory, and run the following command to serve the project:
npm run serve ng-show-for-directive
This should open the app in a new browser tab, and you should see the following:
Figure 2.7: ng-show-for-directive app running on http://localhost:4200
How to do it…
- First of all, we’ll create a directive using the following command in the workspace root folder:
cd start && nx g directive show-for --directory apps/chapter02/ng-show-for-directive/src/app/directives --standalone=false
If asked, choose the
@nx/angular:component schematics
and choose the “As provided” action.
- Now, instead of the
*ngIf
directive in theapp.component.html
file on the element with the class"dialog"
, we can use our*appShowFor
directive:... <main class="content" role="main"> <button (click)="toggleDialog()">Toggle Dialog</button> <div class="dialog" *appShowFor="showDialog"> <div class="dialog__heading">...</div> <div class="dialog__body">...</div> </div> </main>
- Now that we have set the condition, we need to create two
@Input
properties inside the directive’s TypeScript file, one being aboolean
property and one being anumber
. We’ll use asetter
to intercept the Boolean value’s changes and will log the value to the console for now:import { Directive, Input } from '@angular/core'; @Directive({ selector: '[appShowFor]', }) export class ShowForDirective { @Input() duration = 1500; @Input() set appShowFor(value: boolean) { console.log({ showForValue: value }); } }
- If you tap on the Toggle Dialog button now, you should see the values being changed and reflected on the console, as follows:
Figure 2.8: Console logs displaying changes for the appShowFor directive values
- Now, we’re moving toward the actual implementation of showing and hiding the content based on the value being
false
andtrue
respectively. For that, we first need theTemplateRef
service and theViewContainerRef
service injected into the constructor of theif-not.directive.ts
file. Let’s add these, as follows:import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core'; @Directive({ selector: '[appShowFor]' }) export class ShowForDirective{ @Input() duration = 1500; @Input() set appShowFor(value: boolean) { console.log({ showForValue: value }); } constructor( private templateRef: TemplateRef<any>, private viewContainerRef: ViewContainerRef ) {} }
- Now let’s show the element. We’re going to create a
show
method and we’ll call it when the value of theappShowFor
property becomestrue
. The code should look as follows:... export class ShowForDirective { @Input() duration = 1500; @Input() set appShowFor(value: boolean) { console.log({ showForValue: value }); if (value) { this.show(); } } show() { this.viewContainerRef.createEmbeddedView( this.templateRef ); } constructor(...) {} }
If you click the Toggle Dialog button now, you should be able to see the dialog as follows:
Figure 2.9: Dialog being shown using the show method
- Let’s implement the logic of hiding the dialog. We’ll use an
@Output()
prop with anEventEmitter
for this as we want the value ofappShowFor
that’s passed by the parent to be updated, instead of updating it within the directive. Modify the code as follows:import { ... , EventEmitter } from '@angular/core'; ... export class ShowForDirective { @Input() duration = 1500; @Input() set appShowFor(value: boolean) { ... } @Output() elementHidden = new EventEmitter(); show() {...} hide() { this.viewContainerRef.clear(); } constructor(...) {} }
- Now that we have the
hide
method there, let’s call it after the duration time saved in theduration
property of the directive. This is so the dialog hides after that duration. Modify the code of theshow
method as follows:show() { this.viewContainerRef.createEmbeddedView( this.templateRef ); setTimeout(() => { this.elementHidden.emit(); }, this.duration); }
With this change, you’ll see that nothing happens if you click the Toggle Dialog button after the dialog is shown, i.e., it never gets hidden. For that, we need to listen to the
elementHidden
event emitter we just created.
- Let’s make the
app.component.html
listen to theelementHidden
event listener to change the value of theshowDialog
property as follows:<div class="dialog" *appShowFor="showDialog" (elementHidden)="toggleDialog()"> <div class="dialog__heading"> I am a Dialog </div> <div class="dialog__body"> And this is some random content </div> </div>
With this change, you’ll notice that it still doesn’t work. Yep! Because we need to call the
hide
method when the value ofshowDialog
passed as theappShowFor
prop is set tofalse
.
- Let’s call the
hide
method in theShowForDirective
(in theappShowFor
property’sset
method) when the value ofappShowFor
becomesfalse
as follows:@Input() set appShowFor(value: boolean) { console.log({ showForValue: value }); if (value) { this.show(); } else { this.hide(); } }
The thing is… this still won’t work because a structural directive in Angular can’t emit values. Or even if it does, the parent element won’t be able to listen to it. The following Stack Overflow question discusses why and links to an open GitHub issue in the Angular repository as well: https://stackoverflow.com/q/44235638.
- To make our structural directive work, we need to get rid of the syntactic sugar it comes with. Let’s modify the
app.component.html
to use the directive in a different (expanded) way, as follows:<main class="content" role="main"> <button (click)="toggleDialog()">Toggle Dialog</button> <ng-template [appShowFor]="showDialog" (elementHidden)="toggleDialog()"> <div class="dialog"> <div class="dialog__heading"> I am a Dialog </div> <div class="dialog__body"> And this is some random content </div> </div> </ng-template> </main>
The dialog should be hidden now. Yay! But wait. Try clicking the Toggle Dialog button lots of times quickly. You’ll see that the app goes crazy. That’s because we end up having too many
setTimeout
functions registered.
- Let’s clear the
setTimeout
if we toggle the dialog to manually hide it. Update the code for theShowForDirective
class as follows:... export class ShowForDirective { ... timer!: ReturnType<typeof setTimeout>; show() { this.viewContainerRef.createEmbeddedView( this.templateRef ); this.timer = setTimeout(() => { this.elementHidden.emit(); }, this.duration); } hide() { clearTimeout(this.timer); this.viewContainerRef.clear(); } constructor(...) {} }
Awesome! You’ll notice that even if you click the Toggle Dialog button fast and too many times, the app behaves correctly.
How it works…
Structural directives in Angular are special for multiple reasons. First, they allow you to manipulate DOM elements—that is, not just showing and hiding but also adding and removing elements entirely from the DOM based on your needs. Moreover, they have the *
prefix, which binds to all the magic Angular does behind the scenes. For example, Angular automatically provides the TemplateRef
and ViewContainer
for working with this directive. As an example, *ngIf
and *ngFor
are both structural directives that work behind the scenes with the <ng-template>
directive containing the content you bind the directive to. They then create the required variables/properties for you in the scope of ng-template
. In this recipe, we do the same. We use the TemplateRef
service to access the <ng-template>
directive that Angular creates for us behind the scenes, containing the host element to which our appShowFor
directive is applied. We use the ViewContainerRef
service to add the TemplateRef
to the DOM via the createEmbeddedView
method.
We do this when the value of the appShowFor
property becomes true
. Notice that we’re intercepting the property appShowFor
using a setter
. We learned about this in Chapter 1, Winning Components Communication. We then use a setTimeout
to automatically notify the parent component that the value passed to the appShowFor
property needs to be changed to false
. We do this using an @Output()
emitter named elementHidden
. Notice that we’re not supposed to make it false
within the directive. The parent component is supposed to do it and it will automatically reflect in the directive. Our directive is supposed to react to that change and hide (or remove) the TemplateRef
from the ViewContainer
. You can see that we do this in the hide
method using the this.viewContainerRef.clear();
statement. One of the key things to learn from this recipe is that if we use syntactic sugar, i.e., *appShowFor
, in the app.component.html
, we can’t listen to the elementHidden
event emitter. That’s because this is a quirk of Angular - there’s an open issue on GitHub about this (check the See also section). For this to work, we removed the syntactic sugar and expanded the syntax by using a <ng-template>
to wrap our dialog’s HTML in step 11. Notice that we just used [appShowFor]
to pass the showDialog
variable instead of *appShowFor="showDialog"
. And we are also listening to the elementHidden
event on the <ng-template>
element itself.
See also
- Angular structural directive microsyntax documentation: https://angular.io/guide/structural-directives#microsyntax
- Angular structural directives documentation: https://angular.io/guide/structural-directives
- Creating a Structural Directive by Rangle.io: https://angular-2-training-book.rangle.io/advanced-angular/directives/creating_a_structural_directive
- Sugar (*) syntax does not support @Output (and exportAs): https://github.com/angular/angular/issues/12121