Superpowers with Directives and Dependency Injection: Part 3
Armen Vardanyan
Posted on March 21, 2023
Original cover photo by USGS on Unsplash.
So here we are, at the third part of my article series about directives and dependency injection. In previous parts, we explored how to use directives to maintain and export local state in templates and reuse it, and how to use structural directives to simplify our templates. In this one, we are going explore how we can use directives to hijack existing elements and components to add or modify functionality.
So, let's explore use cases.
Use case 1: Hijacking existing elements
Imagine the following scenario - we are maintaining a website that displays lots of content, and also has lots of internal pages. That content often contains links to external resources, and also obviously links for internal navigation between pages. Now, imagine, we want to (almost) always open external links in a new tab (for example, not to disrupt the user's reading flow and general UX). So, obviously, the simplest solution would be to just add target="_blank"
to all external links. This is okay, but obviously is a bit tedious, and also will require the developers always to remember to do that and pass this knowledge to future team members. What if we could automate it?
So, naturally, we need a directive that will
- Bind to all
a
elements - Determine if the link in its
href
attribute is external - Add
target="_blank"
to the element if it is - Possible add a way to exclude some links from this behavior
- Also keep in mind that some links can change dynamically
So, let's first examine how we can determine if a link is external or not. It can be done by a fairly simple function:
function isLinkExternal(url: string) {
return new URL(url).origin !== location.origin;
}
We are leveraging the URL
constructor to parse the URL and check its origin.
Next, let's build a simple directive that can add target="_blank"
to external links:
@Directive({
selector: 'a',
standalone: true,
})
export class ExternalLinkDirective implements
OnInit, AfterViewInit, OnDestroy {
private readonly elRef: ElementRef<HTMLAnchorElement> = inject(
ElementRef,
);
@HostBinding('target')
target: '_blank' | '_self' | '_parent' | '_top' | '';
ngOnInit() {
this.setAnchorTarget();
}
private setAnchorTarget() {
if (isLinkExternal(this.elRef.nativeElement.href)) {
this.target = '_blank';
}
}
}
Now, this directive actually does 80% of what we want to achieve and doesn't require any changes in the template. We use HostBinging
to set the target
attribute on the targeted a
element. You can read more about how the HostBinding
decorator works in the official documentation.
Now, let's make it so we can exclude some links from this behavior. We can do it by adding an exclude
input to the directive, but that would actually not be the best approach. See, this would require passing an actual boolean
value in the template, and we (obviously) are too lazy for that. We can do the following:
@Directive({
selector: 'a:not([noBlank])',
standalone: true,
})
export class ExternalLinkDirective implements
OnInit, AfterViewInit, OnDestroy {
private readonly elRef: ElementRef<HTMLAnchorElement> = inject(
ElementRef,
);
@HostBinding('target')
target: '_blank' | '_self' | '_parent' | '_top' | '';
ngOnInit() {
this.setAnchorTarget();
}
private setAnchorTarget() {
if (isLinkExternal(this.elRef.nativeElement.href)) {
this.target = '_blank';
}
}
}
The :not()
selector is a css selector supported by Angular Directive's API-s which allows the exclusion of some elements. Here we exclude all elements with noBlank
attribute. Now, we can just add noBlank
to the links we don't want to open in a new tab:
<a href="https://google.com">Google</a>
<a href="https://google.com" noBlank>Google</a>
The first link will open in a new tab, the second one won't. Now, most of our issues are completed, but here is a catch. What if we have something like this:
<a [href]="someUrl">Google</a>
Here, the href
attribute is dynamically bound to the someUrl
property, therefore, if the link changes in the future dynamically, the directive will not reevaluate the link, possibly resulting in bugs.
So what we want is to be notified about the href
attribute change, and reevaluate the link. Notably, Angular does not have a built-in way to do that, but we can use the MutationObserver
API to do exactly this. A MutationObserver
is a class that can take an HTML element, observe changes to its attributes, child nodes, and so on, and notify us about them, allowing us to run callbacks on those events. So, let's add a MutationObserver
to our directive and make it interop with Angular:
@Directive({
selector: 'a:not([noBlank])',
standalone: true,
})
export class ExternalLinkDirective implements
OnInit, AfterViewInit, OnDestroy {
private readonly elRef: ElementRef<HTMLAnchorElement> = inject(
ElementRef,
);
private readonly observer = new MutationObserver(() =>
this.setAnchorTarget()
);
@HostBinding('target')
target: '_blank' | '_self' | '_parent' | '_top' | '';
ngOnInit() {
this.setAnchorTarget();
}
ngAfterViewInit() {
this.observer.observe(this.elRef.nativeElement, {
attributes: true,
subtree: false,
childList: false,
});
}
ngOnDestroy() {
this.observer.disconnect();
}
private setAnchorTarget() {
if (isLinkExternal(this.elRef.nativeElement.href)) {
this.target = '_blank';
} else if (this.elRef.nativeElement.target === '_blank') {
this.target = '';
}
}
}
This code might feel a bit overwhelming, so let's examine what is being done here:
- We create a
MutationObserver
instance, and pass a callback to it. This callback will be called every time the observed element changes. In our case, we want to reevaluate the link every time it changes, so we just callsetAnchorTarget
in it. - In the
ngAfterViewInit
lifecycle hook, we start observing the element. We pass an object with the following options:-
attributes: true
- we want to observe attribute changes -
subtree: false
- we don't want to observe changes in child nodes -
childList: false
- we don't want to observe changes in direct child nodes
-
- In
ngOnDestroy
we disconnect the observer, so it stops observing the element - obviously, it no longer exists - We made a slight modification to the
setAnchorTarget
method. Now, if the link is not external, we remove thetarget
attribute from the element. This is needed for the cases when another value is provided in the template for thetarget
attribute, liketarget="_self"
or whatever.
Here is a full working example of this directive with a preview:
Use case 2: hijacking existing components
This part is completely inspired by a blog post written by Tim Deschryver, where he discusses how to use directives to extend functionality on components that we didn;t write. In that example, a directive is used to hijack the functionality of a Calendar
component from the PrimeNG UI library. Here is the example from this article:
import { Directive } from '@angular/core';
import { Calendar } from 'primeng/calendar';
@Directive({
selector: 'p-calendar',
})
export class CalenderDirective {
constructor(private calendar: Calendar) {
this.calendar.dateFormat = 'dd/mm/yy';
this.calendar.showIcon = true;
this.calendar.showButtonBar = true;
this.calendar.monthNavigator = true;
this.calendar.yearNavigator = true;
this.calendar.yearRange = '1900:2050';
this.calendar.firstDayOfWeek = 1;
}
}
Meaning we do not need to provide default inputs when working with the p-calendar
component, as they are already set by the directive. This is a great example of how to use directives to extend the functionality of existing components.
Give a read to Tim's article, it has lots of other very interesting use cases
The same approach can be used to modify existing components that we wrote in our application.
Conclusion
As we have seen. directives are also powerful when dealing with existing functionality, and can be used to extend it, thus avoiding the need to wrap everything in components. In the next part, we are going to explore how directives can be used to work with events - both custom and native. Stay tuned!
Posted on March 21, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 2, 2024
September 13, 2024