Here we are again, at the third installment of our series on directives and dependency injection. The previous parts covered using directives for managing and exporting local state in templates and leveraging structural directives to keep templates concise. This time around, we explore how directives can intercept existing elements and components to enhance or alter their behavior.
Let's dive into some scenarios.
Use case 1: Hijacking existing elements
Consider this situation — we're managing a content-heavy website with many internal pages. The content frequently includes links to external sites, alongside the usual internal navigation. Suppose we want to ensure that external links, almost always, open in a fresh tab to keep users engaged with our content. The straightforward fix is manually adding target="_blank" to each external link. While functional, this is monotonous and prone to human error — new developers might forget, and the knowledge needs to be passed down. Can we automate this?
What we need is a directive that:
- Attaches to every
aelement - Checks whether the
hrefattribute points to an external site - Assigns
target="_blank"to the element when that's the case - Offers a mechanism to opt out for specific links
- Handles dynamic changes to the link
First, let's look at how to tell if a link is external. A simple utility using the URL constructor can compare origins:
function isLinkExternal(url: string) {
return new URL(url).origin !== location.origin;
}
Next, we build a simple directive that sets the target:
@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';
}
}
}
This directive already covers a large portion of our requirements without any template changes. By using HostBinding, we directly manipulate the target attribute on the anchor element. If you're unfamiliar with the HostBinding decorator, the official docs are a good place to start.
Now, let's introduce an opt-out mechanism. We could add an exclude input flag, but that would force us to bind a boolean property in the template—too much overhead for us. Instead, we can be more clever:
@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() CSS selector, supported in Angular directive selectors, lets us filter out specific elements. Here, we exclude any link with the noBlank attribute. So in the template, marking a link with noBlank is all it takes to keep it navigable in the same tab:
<a href="https://google.com">Google</a>
<a href="https://google.com" noBlank>Google</a>
In this snippet, the first anchor opens in a new tab; the second does not. Most of our checklist is done, but there is a hidden catch. Consider a dynamic URL:
<a [href]="someUrl">Google</a>
Here, the href is bound to the someUrl property. If that property changes at runtime, our directive won't react, which could lead to incorrect behavior.
To handle this, we need to know when the href attribute changes and reevaluate. Angular has no built-in hook for that, but the MutationObserver API can help. This class lets you watch elements for attribute modifications, child node changes, and more, firing callbacks as a result. Let's integrate it into our directive and bridge it with Angular's lifecycle:
@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 might look a bit daunting, so let's break down the steps:
- We instantiate a
MutationObserverwith a callback that fires whenever the observed element changes. In our case, that's simply invokingsetAnchorTarget. - In
ngAfterViewInit, we begin observing. The configuration includes:attributes: true— we care about attribute changessubtree: false— no need to watch descendant nodeschildList: false— direct children are irrelevant
- In
ngOnDestroy, we disconnect the observer to stop monitoring, since the element is about to be destroyed. - We tweaked
setAnchorTargetso that if a link isn't external, thetargetattribute is removed. This prevents conflicts when templates specify something liketarget="_self".
Here's a full working demo of this directive:
Use case 2: hijacking existing components
This section is heavily inspired by a post from Tim Deschryver about extending components you don't own. His example shows a directive enhancing the Calendar component from the PrimeNG library. Here's the essence:
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;
}
}
In practice, this means we can use p-calendar without specifying common inputs, as the directive pre-populates them. It's a compelling illustration of leveraging directives to augment third-party components.
Be sure to check out Tim's article for many more intriguing scenarios.
Naturally, this same technique applies to our own application components as well.
Conclusion
As demonstrated, directives are mighty when it comes to augmenting existing functionality, removing the need to wrap everything in custom components. In the upcoming part, we'll see how directives interact with events—both custom and native ones. See you there!
