Original cover photo by USGS on Unsplash.

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:

  1. Attaches to every a element
  2. Checks whether the href attribute points to an external site
  3. Assigns target="_blank" to the element when that's the case
  4. Offers a mechanism to opt out for specific links
  5. 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;
}
Enter fullscreen mode Exit fullscreen mode

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';
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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';
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 = '';
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This might look a bit daunting, so let's break down the steps:

  1. We instantiate a MutationObserver with a callback that fires whenever the observed element changes. In our case, that's simply invoking setAnchorTarget.
  2. In ngAfterViewInit, we begin observing. The configuration includes:
    1. attributes: true — we care about attribute changes
    2. subtree: false — no need to watch descendant nodes
    3. childList: false — direct children are irrelevant
  3. In ngOnDestroy, we disconnect the observer to stop monitoring, since the element is about to be destroyed.
  4. We tweaked setAnchorTarget so that if a link isn't external, the target attribute is removed. This prevents conflicts when templates specify something like target="_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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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!