Working with Angular Directives and Built-In Control Flow
In this chapter, you’ll learn about Angular directives in depth, with a real-world example of using a directive that highlights text on searching. You’ll also write your first structural directive and see how the ViewContainer
and TemplateRef
services work together to add/remove elements from the Document Object Model (DOM), just as in the case of *ngIf
. You’ll also create some really cool attribute directives that do different tasks. Finally, you’ll learn how to use the Directive Composition API to apply multiple directives to the same element.
Here are the recipes we’re going to cover in this chapter:
- Using attribute directives to handle the appearance of elements
- Creating a directive to calculate the read time for articles
- Creating a directive that allows you to vertically scroll to an element
- Writing your first custom structural directive
- How to apply multiple structural directives to the same element
- Applying multiple directives to the same element using the Directive Composition API
Technical requirements
For the recipes in this chapter, ensure your setup is complete as per the 'Technical Requirements' in the 'Angular-Cookbook-2E' GitHub repository. For setup details, visit: https://github.com/PacktPublishing/Angular-Cookbook-2E/tree/main/docs/technical-requirements.md. The starter code for this chapter is located at https://github.com/PacktPublishing/Angular-Cookbook-2E/tree/main/start/apps/chapter02.
Using attribute directives to handle the appearance of elements
In this recipe, you’ll work with an Angular attribute directive named highlight. With this directive, you’ll be able to search words and phrases within a paragraph and highlight them on the go. The whole paragraph’s container background will also be changed when we have a search in action. For example, by using the following code:
<p class="text-content max-w-2xl m-auto" appHighlight
[highlightText]="'de'">
<!--text here -->
The result will appear as an output as shown in Figure 2.1:
Figure 2.1: The result when using the highlight directive
Getting ready
The app that we are going to work with resides in start/apps/chapter02/ng-attribute-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-attribute-directive
This should open the app in a new browser tab, and you should see the following:
Figure 2.2: ng-attribute-directive app running on http://localhost:4200
How to do it…
The application has a search input and a paragraph of text. We want to be able to type a search query in the input so that we can highlight and find all the matching occurrences in the paragraph. Here are the steps to achieve this:
- We’ll create a property named
searchText
in theapp.component.ts
file that we’ll use as amodel
for the search-text input:... export class AppComponent { searchText = ''; }
- Then, we use the
searchText
property in the template, i.e., in theapp.component.html
file, with the search input as anngModel
, as follows:... <div class="content" role="main"> ... <input [(ngModel)]="searchText" type="text" placeholder="Quick Search..." class="pr-4 !pl-10 py-2"> </div>
- You will notice that
ngModel
doesn’t work yet. This is because we’re missing theFormsModule
in our application. Let’s import it into theapp.component.ts
file as follows:... import { FormsModule } from '@angular/forms'; @Component({ selector: 'app-root', templateUrl: './app.component.html', standalone: true, styleUrls: ['./app.component.scss'], imports: [CommonModule, RouterModule, FormsModule], }) export class AppComponent { searchText = ''; }
- Now, we’ll create an attribute directive named
highlight
by using the following command from the workspace root:cd start && nx g directive highlight --directory apps/chapter02/ng-attribute-directive/src/app --standalone
If asked, choose the
@nx/angular:directive schematics
and choose the “As provided” action. The preceding command generates a standalone directive that has a selector calledappHighlight
. See the How it works… section for why that happens, and for a brief explanation of the standalone API.
- Now that we have the directive in place, we’ll create two inputs for the directive to be passed from
AppComponent
(fromapp.component.html
)—one for the search text and another for the highlight color. The code should look like this in thehighlight.directive.ts
file:import { Directive, Input } from '@angular/core'; @Directive({ selector: '[appHighlight]', standalone: true }) export class HighlightDirective { @Input() highlightText = ''; @Input() highlightColor = 'yellow'; }
- Let’s use the
appHighlight
directive inapp.component.html
and pass thesearchText
model from there to theappHighlight
directive as follows:<div class="content" role="main"> ... <p class="text-content" appHighlight [highlightText]="searchText"> ... </p> </div>
- We’ll listen to the input changes now for the
searchText
input, usingngOnChanges
. Please see the Using ngOnChanges to intercept input property changes recipe in Chapter 1, Winning Component Communication, for how to listen to input changes. For now, we’ll only do aconsole.log
when the input changes. Let’s update thehighlight.directive.ts
as follows:import { Directive, Input, OnChanges, SimpleChanges } from '@angular/core'; ... export class HighlightDirective implements OnChanges { @Input() highlightText = ''; @Input() highlightColor = 'yellow'; ngOnChanges(changes: SimpleChanges) { if (changes['highlightText']?.firstChange) { return; } const { currentValue } = changes['highlightText']; console.log({ currentValue }); } }
If you type in the search input and see the console logs, you’ll see the new value being logged whenever you change the value.
- Now, we’ll write the logic for highlighting the search text. We’ll first import the
ElementRef
service so that we can get access to the template element on which our directive is applied. Here’s how we’ll do this:import { Directive, Input, SimpleChanges, OnChanges, ElementRef } from '@angular/core'; @Directive({ selector: '[appHighlight]' }) export class HighlightDirective implements OnChanges { @Input() highlightText = ''; @Input() highlightColor = 'yellow'; constructor(private el: ElementRef) { } ... }
- Now we’ll replace every matching text in our
el
element with a custom<span>
tag with some hardcoded styles. Update yourngOnChanges
code inhighlight.directive.ts
as follows, and see the result:ngOnChanges(changes: SimpleChanges) { if (changes.highlightText.firstChange) { return; } const { currentValue } = changes.highlightText; if (currentValue) { const regExp = new RegExp(`(${currentValue})`,'gi') this.el.nativeElement.innerHTML = this.el .nativeElement.innerHTML.replace(regExp, `<span style="background-color: ${this.highlightColor}" >\$1</span>`) } }
TIP
You’ll notice that if you type a word, it will still show only one letter highlighted. That’s because whenever we replace the
innerHTML
property, we end up changing the original text. Let’s fix that in the next step.
- To keep the original text intact, let’s create a property named
originalHTML
and assign an initial value to it on the first change. We’ll also use theoriginalHTML
property while replacing the values:... export class HighlightDirective implements OnChanges { @Input() highlightText = ''; @Input() highlightColor = 'yellow'; originalHTML = ''; constructor(private el: ElementRef) { } ngOnChanges(changes: SimpleChanges) { if (changes.highlightText.firstChange) { this.originalHTML = this.el .nativeElement.innerHTML; return; } const { currentValue } = changes.highlightText; if (currentValue) { const regExp = new RegExp(`(${currentValue})`,'gi') this.el.nativeElement.innerHTML = this.originalHTML .replace(regExp, `<span style="background-color: ${this.highlightColor}">\$1</span>`) } } }
- Now, we’ll write some logic to reset everything back to the
originalHTML
property when we remove our search query (when the search text is empty). In order to do so, let’s add anelse
condition, as follows:... export class HighlightDirective implements OnChanges { ... ngOnChanges(changes: SimpleChanges) { ... if (currentValue) { const regExp = new RegExp(`(${currentValue})`,'gi') this.el.nativeElement.innerHTML = this.originalHTML .replace(regExp, `<span style="background- color: ${this.highlightColor}">\$1</span>`) } else { this.el.nativeElement.innerHTML = this.originalHTML; } } }
How it works…
We created an attribute directive named highlight
(appHighlight
) that takes two inputs: highlightText
and highlightColor
. The directive listens to the input changes for the highlightText
input using the SimpleChanges
from the ngOnChanges
life cycle hook by Angular. Every property in this SimpleChanges
object is a SimpleChange
object that contains the following properties:
previousValue
: anycurrentValue
: anyfirstChange
: booleanisFirstChange()
: boolean
First, we make sure to save the original content of the target element by getting the attached element using the ElementRef
service. We get it using the .nativeElement.innerHTML
property on the element we apply the directive to. We save the initial value to the originalHTML
property of the directive.
Whenever the input changes, we assign a replaced version of the originalHTML
by replacing all the instances of the searched term in the paragraph with an additional HTML element (a <span>
element). We also add the background color to this <span>
element. The background color applied comes from the highlightColor
input. You can modify it to highlight using a different color. Play around and make this example your own.
See also
- Testing Attribute Directives official documentation: https://angular.io/guide/testing-attribute-directives
- Angular
SimpleChange
docs: https://angular.io/api/core/SimpleChange
Creating a directive to calculate the read time for articles
In this recipe, you’ll create an attribute directive to calculate the read time of an article, just like Medium (https://medium.com), which is a platform for sharing articles and blog posts. The code for this recipe is highly inspired by my existing repository on GitHub, which you can view at the following link: https://github.com/AhsanAyaz/ngx-read-time.
Getting ready
The app that we are going to work with resides in start/apps/chapter02/ng-read-time-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-read-time-directive
This should open the app in a new browser tab and you should see the following:
Figure 2.3: ng-read-time-directive app running on http://localhost:4200
How to do it…
Right now, we have a paragraph in our app.component.html
file for which we need to calculate the read-time
in minutes. Let’s get started:
- First, we’ll create an attribute directive named
read-time
. To do that, run the following command from the project root and select the@nx/angular:directive schematics
when asked:cd start && nx g directive read-time --directory apps/chapter02/ng-read-time-directive/src/app/directives --standalone=false
If asked, choose the
@nx/angular:directive
schematics
and choose the “As provided” action.Note that we’re using
--standalone = false
in the command. That is because we have anNgModule
based application and theAppComponent
is not a standalone component.
- The preceding command creates a directive with the class name
ReadTimeDirective
and hasappReadTime
as the selector. We’ll apply this directive to thediv
that hasid
set tomainContent
inside theapp.component.html
file as follows:... <div class="content" role="main" id="mainContent" appReadTime> ... </div>
- Now, we’ll create a configuration object for our
appReadTime
directive. This configuration will contain awordsPerMinute
value, on the basis of which we’ll calculate the read time. Let’s create an input inside theread-time.directive.ts
file with aReadTimeConfig
exported interface for the configuration, as follows:import { Directive, Input } from '@angular/core'; export interface ReadTimeConfig { wordsPerMinute: number; } @Directive({ selector: '[appReadTime]' }) export class ReadTimeDirective { @Input() configuration: ReadTimeConfig = { wordsPerMinute: 200 } constructor() { } }
- We can now move on to getting the text to calculate the read time. For this, we’ll use the
ElementRef
service to retrieve thetextContent
property of the element. We’ll extract thetextContent
property and assign it to a local variable namedtext
in thengOnInit
life cycle hook, as follows:import { Directive, Input, ElementRef, OnInit } from '@angular/core'; ... export class ReadTimeDirective implements OnInit { @Input() configuration: ReadTimeConfig = { wordsPerMinute: 200 } constructor(private el: ElementRef) { } ngOnInit() { const text = this.el.nativeElement.textContent; } }
- Now that we have our text variable filled up with the element’s entire text content, we can calculate the time to read this text. For this, we’ll create a method named
calculateReadTime
by passing thetext
property to it, as follows:... export class ReadTimeDirective implements OnInit { ... ngOnInit() { const text = this.el.nativeElement.textContent; const time = this.calculateReadTime(text); console.log({ readTime: time }); } calculateReadTime(text: string) { const wordsCount = text.split(/\s+/g).length; const minutes = wordsCount / this.configuration. wordsPerMinute; return Math.ceil(minutes); } }
If you look at the console now, you should see an object containing the
readTime
property being logged. The value ofreadTime
is the time in minutes:Figure 2.4: Console log showing the time in minutes
- We’ve got the time now in minutes, but it’s not in a user-readable format at the moment since it is just a number. We need to show it in a way that is understandable for the end user. To do so, we’ll do some minor calculations and create an appropriate string to show on the UI. The code is shown here:
... @Directive({ selector: '[appReadTime]' }) export class ReadTimeDirective implements OnInit { ... ngOnInit() { const text = this.el.nativeElement.textContent; const time = this.calculateReadTime(text); const timeStr = this.createTimeString(time); console.log({ readTime: timeStr }); } ... createTimeString(timeInMinutes: number) { if (timeInMinutes < 1) { return '< 1 minute'; } else if (timeInMinutes === 1) { return '1 minute'; } else { return `${timeInMinutes} minutes`; } } }
Note that with the code so far, you should be able to see the minutes on the console when you refresh the application.
- Now, let’s add an
@Output()
to the directive so that we can get the read time in the parent component and display it on the UI. Let’s add it as follows in theread-time.directive.ts
file:import { Directive, Input, ElementRef, OnInit, Output, EventEmitter } from '@angular/core'; ... export class ReadTimeDirective implements OnInit { @Input() configuration: ReadTimeConfig = { wordsPerMinute: 200 } @Output() readTimeCalculated = new EventEmitter<string>(); constructor(private el: ElementRef) { } ... }
- Let’s use the
readTimeCalculated
output to emit the value of thetimeStr
variable from thengOnInit
method when we’ve calculated the read time:... export class ReadTimeDirective { ... ngOnInit() { const text = this.el.nativeElement.textContent; const time = this.calculateReadTime(text); const timeStr = this.createTimeString(time); this.readTimeCalculated.emit(timeStr); } ... }
- Since we emit the
read-time
value using thereadTimeCalculated
output, we have to listen to this output’s event in theapp.component.html
file and assign it to a property of theAppComponent
class so that we can show this on the view. But before that, we’ll create a local property in theapp.component.ts
file to store the output event’s value, and we’ll also create a method to be called upon when the output event is triggered. The code is shown here:... export class AppComponent { readTime!: string; onReadTimeCalculated(readTimeStr: string) { this.readTime = readTimeStr; } }
- We can now listen to the output event in the
app.component.html
file, and we can then call theonReadTimeCalculated
method when thereadTimeCalculated
output event is triggered:... <div class="content" role="main" id="mainContent" appReadTime (readTimeCalculated)= "onReadTimeCalculated($event)"> ... </div>
- Now, we can finally show the read time in the
app.component.html
file, as follows:<div class="content" role="main" id="mainContent" appReadTime (readTimeCalculated)="onReadTimeCalculated($event)"> <h4 class="text-3xl">Read Time = {{readTime}}</h4> <p class="text-content"> Silent sir say desire fat him letter. Whatever settling goodness too and honoured she building answered her. ... </p> ... </div>
If you now go to
http://localhost:4200
, you should be able to see the read time in the app, as shown in the following image:Figure 2.5: Read time being displayed in the app
How it works…
The appReadTime
directive is at the heart of this recipe. While creating the directive, we create it as a non-standalone directive because the application itself is bootstrapped using an NgModule instead of a standalone AppComponent
. We use the ElementRef
service inside the directive to get the native element that the directive is attached to and then we take out its text content. The only thing that remains then is to perform the calculation. We first split the entire text content into words by using the /\s+/g
regular expression (regex), and thus we count the total words in the text content. Then, we divide the word count by the wordsPerMinute
value we have in the configuration to calculate how many minutes it would take to read the entire text. Finally, we make it readable in a better way using the createTimeString
method. Easy peasy, lemon squeezy.
See also
ngx-read-time
library: https://github.com/AhsanAyaz/ngx-read-time- Angular attribute directives documentation: https://angular.io/guide/testing-attribute-directives
Creating a directive that allows you to vertically scroll to an element
Can you imagine being able to instantly jump to any place that your eyes can see? That would be awesome! Wouldn’t it? But what if we wanted our app to be able to do that? In this recipe, you’ll create a directive that the user can click to jump to specific sessions in an Angular application.
Getting ready
The app that we are going to work with resides in start/apps/chapter02/ng-scroll-to-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-scroll-to-directive
This should open the app in a new browser tab, and you should see the following:
Figure 2.6: ng-scroll-to-directive app running on http://localhost:4200
How to do it…
- First, we’ll create a
scroll-to
directive so that we can enhance our application with smooth scrolls to different sections. We’ll do this using the following command in the workspace root folder:cd start && nx g directive scroll-to --directory apps/chapter02/ng-scroll-to-directive/src/app/directives
If asked, choose the
@nx/angular:component schematics
and choose the “As provided” action.
- Now, we need to make the directive capable of accepting an
@Input()
that’ll contain the CSS Query Selector for our target section, which we’ll scroll to upon the element’sclick
event. Let’s add the input as follows to ourscroll-to.directive.ts
file:import { Directive, Input } from '@angular/core'; @Directive({ selector: '[appScrollTo]' }) export class ScrollToDirective { @Input() target = ''; }
- Now, we’ll apply the
appScrollTo
directive to the links in theapp.component.html
file along with the respective targets. We’ll replace thehref
attribute with thetarget
attribute. The code should look like this:... <main class="content" role="main"> <div class="page-links"> <h4 class="page-links__heading"> Links </h4> <a class="page-links__link" appScrollTotarget= "#resources">Resources</a> <a class="page-links__link" appScrollTotarget= "#nextSteps">Next Steps</a> <a class="page-links__link" appScrollTotarget= "#moreContent">More Content</a> <a class="page-links__link" appScrollTotarget= "#furtherContent">Further Content</a> <a class="page-links__link" appScrollTotarget= "#moreToRead">More To Read</a> </div> </main> ... <a appScrollTo target="#toolbar" class="to-top-button w-12 h-12 text-white flex items-center justify-center"> <span class="material-symbols-outlined text-3xl text- white"> expand_less </span> </a>
- Now, we’ll implement the
HostListener()
decorator to bind theclick
event to the element the directive is attached to. We’ll just log thetarget
input when we click the links. Let’s implement this, and then you can try clicking on the links to see the value of thetarget
input on the console:import { Directive, Input, HostListener } from '@angular/core'; @Directive({ selector: '[appScrollTo]' }) export class ScrollToDirective { @Input() target = ''; @HostListener('click') onClick() { console.log(this.target); } ... }
- We will now implement the logic to scroll to a particular target. We’ll use the
document.querySelector
method, using thetarget
variable’s value to get the element, and then theElement.scrollIntoView
web API to scroll to the target element. With this change, you should see the page scrolling to the target element already when you click the corresponding link:... export class ScrollToDirective { @Input() target = ''; @HostListener('click') onClick() { const targetElement = document.querySelector(this.target); if (!targetElement) { throw new Error('`target' is required.`); } targetElement.scrollIntoView(); } ... }
- All right—we got the scroll to work. “But what’s new, Ahsan? Isn’t this exactly what we were already doing with the href implementation before?” Well, you’re right. But we’re going to make the scroll super smoooooth. We’ll pass
scrollIntoViewOptions
as an argument to thescrollIntoView
method with the{behavior: "smooth"}
value to use an animation during the scroll. The code should look like this:... export class ScrollToDirective { @Input() target = ''; @HostListener('click') onClick() { const targetElement = document.querySelector (this.target); targetElement.scrollIntoView({behavior: 'smooth'}); } }
How it works…
The essence of this recipe is the web API that we’re using within an Angular directive, which is Element.scrollIntoView
. We first attach our appScrollTo
directive to the elements that should trigger scrolling upon clicking them. We also specify which element to scroll to by using the target
input for each directive attached. Then, we implement the click
handler inside the directive with the scrollIntoView
method to scroll to a particular target, and to use a smooth animation while scrolling, we pass the {behavior: 'smooth'}
object as an argument to the scrollIntoView
method.
See also
scrollIntoView
method documentation: https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView- Angular attribute directives documentation: https://angular.io/guide/testing-attribute-directives
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
How to apply multiple structural directives to the same element
In certain situations, you might want to use more than one structural directive on the same host or for the same element—for example, a combination of *ngIf
and *ngFor
together—which is not something Angular supports out of the box. The reason is that it is hard to identify which directive takes precedence over the other, and even if there was a system, I think the apps would become too complex and hard to manage. In this recipe, we will show a message conditionally using *ngIf
when we have no items in the bucket. Since we’re supposed to show it conditionally and apply the for
loop on the element, this is a perfect example to use for this recipe.
Getting ready
The app that we are going to work with resides in start/apps/chapter02/ng-multi-struc-directives
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-multi-struc-directives
This should open the app in a new browser tab, and you should see the following:
Figure 2.10: ng-multi-struc-directives app running on http://localhost:4200
Now that we have the app running, let’s see the steps for this recipe in the next section.
How to do it…
- We’ll start by creating a template for the message to be shown when there are no items in the bucket. We’ll modify the
app.component.html
file for this as follows:<div class="fruits"> ... <ng-template #bucketEmptyMessage> <div class="fruits__no-items-msg"> No items in bucket. Add some fruits! </div> </ng-template> </div>
- Now we’ll try to apply the
*ngIf
condition to the element that renders the fruits. Let’s modify the code in the same file, as follows:... <div class="fruits"> <div class="fruits__item" *ngFor="let item of bucket" *ngIf="bucket.length > 0; else bucketEmptyMessage" >...</div> <ng-template #bucketEmptyMessage>...</ng-template> </div>
As soon as you save the preceding code, you’ll see the application breaks, saying that we can’t use multiple template bindings on one element. This means we can’t use multiple structural directives on one element:
Figure 2.11: Angular Language Service explaining we can’t use two structural directives on the same element
- We can fix this by moving one of the structural directives into a
<ng-container>
wrapper, which doesn’t create any additional HTML elements in the DOM. Let’s modify the code as follows:<div class="fruits"> <ng-container *ngIf="bucket.length > 0; else bucketEmptyMessage"> <div class="fruits__item" *ngFor="let item of bucket"> ... </div> </ng-container> <ng-template #bucketEmptyMessage>...</ng-template> </div>
With the change above, you should be able to see the message when there are no items in the bucket, as follows:
Figure 2.12: The final result with *ngIf and *ngFor together
How it works…
Since we can’t use two structural directives on the same element (let’s say a button), we can always use another HTML element as a wrapper (parent) to use one of the structural directive on it, and the other structural directive on the target element (button in our case). However, that adds another element to the DOM and might cause problems for your element hierarchy or other layout behavioral issues, based on your implementation. However, <ng-container>
is a magical element from Angular that is not added to the DOM. Instead, it just wraps the logic/condition that you apply to it, which makes it ideal for us to use in cases like these.
See also
- Grouping sibling elements with the
<ng-container>
documentation: https://angular.io/guide/structural-directives#group-sibling-elements-with-ng-container
Applying multiple directives to the same element using the Directive Composition API
In this recipe, you’ll use the Directive Composition API to create multiple components and apply directives to them directly for reusability instead of having to apply the directives to each component or create additional elements inside the template of the component to apply the directives.
Getting ready
The app that we are going to work with resides in start/apps/chapter02/ng-directive-comp-api
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-directive-comp-api
This should open the app in a new browser tab, and you should see the following:
Figure 2.13: ng-directive-comp-api app running on http://localhost:4200
How to do it…
- First, we’ll create a couple of components for our application. We’ll create one directive for the filled button, one for the outline button, and one for a button with a tooltip. Run the following command from the
start
folder within the workspace:nx g directive button-filled --directory apps/chapter02/ng-directive-comp-api/src/app/directives --standalone=false nx g directive button-outlined --directory apps/chapter02/ng-directive-comp-api/src/app/directives --standalone=false nx g directive button-with-tooltip --directory apps/chapter02/ng-directive-comp-api/src/app/directives --standalone=false
If asked, choose the
@nx/angular:component schematics
and choose the “As provided” action.Note that all the directives we have created are non-standalone directives. That is because the application is bootstrapped with an
NgModule
and theAppComponent
is not a standalone component. Therefore, we these directives to be imported in theapp.module.ts
for this recipe to work.
- Let’s make the
ButtonDirective
a standalone directive, which means this isn’t going to be a part of anyNgModule
. Update thebutton.directive.ts
as follows:... @Directive({ selector: '[appButton]', standalone: true, }) export class ButtonDirective { ... }
- Let’s also remove it from the
app.module.ts
file as it is now astandalone
directive. Update theapp.module.ts
file as follows:... import { ButtonDirective } from './directives/button.directive'; // <-- remove the import ... @NgModule({ declarations: [ ..., ButtonDirective, // <-- remove this ... ], ... }) export class AppModule {}
You’ll notice that none of the buttons have the required styles anymore as follows:
Figure 2.14: Styles from the button directive are gone
- Let’s update the
ButtonFilledDirective
to use theButtonDirective
using the Directive Composition API. Update thebutton-filled.directive.ts
file as follows:import { Directive, HostBinding } from '@angular/core'; import { ButtonDirective } from './button.directive'; @Directive({ selector: '[appButtonFilled]', hostDirectives: [ { directive: ButtonDirective, inputs: ['color'], }, ], }) export class ButtonFilledDirective { @HostBinding('attr.fill') fill = 'filled'; }
- We can use the
appButtonFilled
directive in theapp.component.html
file as follows:... <main class="content" role="main"> <ul class="flex flex-col"> <li class="flex gap-4 items-center border-b justify- between border-slate-300 py-3">...</li> <li class="flex gap-4 items-center border-b justify- between border-slate-300 py-3"> <h4 class="text-lg">Filled Button:</h4> <button appButtonFilled color="yellow">Click Me</button> </li> <li class="flex gap-4 items-center border-b justify- between border-slate-300 py-3">...</li> <li class="flex gap-4 items-center border-b justify- between border-slate-300 py-3">...</li> </ul> </main>
Notice that we’ve removed the fill
attribute from the element.
- Let’s update the
ButtonOutlined
directive as well. We’ll modify thebutton-outlined.directive.ts
as follows:import { Directive, HostBinding } from '@angular/core'; import { ButtonDirective } from './button.directive'; @Directive({ selector: '[appButtonOutlined]', hostDirectives: [ { directive: ButtonDirective, inputs: ['color'], }, ], }) export class ButtonOutlinedDirective { @HostBinding('attr.fill') fill = 'outlined'; }
- Let’s also modify the
ButtonWithTooltipDirective
class. We’ll update thebutton-with-tooltip.directive.ts
as follows:import { Directive } from '@angular/core'; import { ButtonDirective } from './button.directive'; import { TooltipDirective } from './tooltip.directive'; @Directive({ selector: '[appButtonWithTooltip]', hostDirectives: [ { directive: ButtonDirective, inputs: ['color', 'fill'], }, { directive: TooltipDirective, inputs: ['appTooltip: tooltip'], }, ], }) export class ButtonWithTooltipDirective {}
You will notice that the app starts throwing an error that
TooltipDirective
is not a standalone component. That’s true. We need to do the same thing we did for theButtonDirective
in step 2 and step 3 for theTooltipDirective
as well. Move on to the next step once you’ve done that.
- Now, update the
app.component.html
file to use both theappButtonOutlined
andappButtonTooltip
directives as follows:... <main class="content" role="main"> <ul class="flex flex-col"> <li class="flex gap-4 items-center border-b justify- between border-slate-300 py-3">...</li> <li class="flex gap-4 items-center border-b justify- between border-slate-300 py-3">...</li> <li class="flex gap-4 items-center border-b justify- between border-slate-300 py-3"> <h4 class="text-lg">Outlined Button:</h4> <button appButtonOutlined>Click Me</button> </li> <li class="flex gap-4 items-center border-b justify- between border-slate-300 py-3"> <h4 class="text-lg">Button with Tooltip:</h4> <div class="flex flex-col gap-4"> <button appButtonWithTooltip tooltip="code with ahsan" fill="outlined" color="blue"> Click Me </button> <button appButtonWithTooltip tooltip="code with ahsan" fill="filled" color="blue"> Click Me </button> </div> </li> </ul> </main>
If you’ve followed all the steps correctly, you should be able to see the final result as follows:
Figure 2.15: Final result containing buttons with different directives applied
How it works…
The Directive Composition API was introduced in Angular v15 and has been one of the most requested features from the Angular community. In this recipe, we tried to create some components that bind the directives to the component directly in the component’s TypeScript classes rather than in the template. This eliminates the need to create a wrapper element within the components to then apply the directives or to map the inputs of the components to the inputs of the directives. This also allows multiple directives to be bound to the same component – even if they may have inputs with the same names, we can alias them differently.
The flow of the directives in our application works in the following way:
- The
AppComponent
uses theButtonFilledDirective
,ButtonOutlinedDirective
, andButtonWithTooltipDirective
directives. For this, these directive need to be non-standalone since the application is bootstrapped with anNgModule
ButtonFilledDirective
,ButtonOutlinedDirective
, andButtonWithTooltipDirective
directives use the directive composition API to use theButtonDirective
and theTooltipDirective
. These need to be standalone directives to be used as ‘hostDirectives
'
The key to using the Directive Composition API is to construct your base-directives with the standalone: true
flag. This means your directives aren’t part of any NgModule
and can be imported directly into the imports array of any component they’re being used in. This is why we make both the ButtonDirective
and the TooltipDirective
standalone in steps 2, 3, and 7. Then, we use those directives in ButtonFilledDirective
, ButtonOutlinedDirective
, and ButtonWithTooltipDirective
to be able to reuse the logic without having to create any wrapper component or additional HTML. We do it using the hostDirectives
property in the directive metadata. Notice that we pass an array of objects to this property and each object can contain the directive
property, which takes the class of the directive
to be applied. And we can also provide inputs and outputs for the host bindings. As you saw for the ButtonWithTooltipDirective
, we also aliased the appTooltip
input of the TooltipDirective
with the tooltip
input of the ButtonWithTooltipDirective
. One thing to notice is that if you don’t want to map any inputs or outputs and just want to bind a directive in the hostDirectives
, you can just provide an array of the classes of the directives to be applied as follows:
hostDirectives: [
ButtonDirective,
TooltipDirective
],
See also
- Directive Composition API documentation: https://angular.io/guide/directive-composition-api#directive-composition-api
- Standalone components: https://angular.io/guide/standalone-components
Learn more on Discord
To join the Discord community for this book – where you can share feedback, ask questions to the author, and learn about new releases – follow the QR code below: