Superpowers with Directives and Dependency Injection: Part 3

armandotrue

Armen Vardanyan

Posted on March 21, 2023

Superpowers with Directives and Dependency Injection: Part 3

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

  1. Bind to all a elements
  2. Determine if the link in its href attribute is external
  3. Add target="_blank" to the element if it is
  4. Possible add a way to exclude some links from this behavior
  5. 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;
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

This code might feel a bit overwhelming, so let's examine what is being done here:

  1. 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 call setAnchorTarget in it.
  2. In the ngAfterViewInit lifecycle hook, we start observing the element. We pass an object with the following options:
    1. attributes: true - we want to observe attribute changes
    2. subtree: false - we don't want to observe changes in child nodes
    3. childList: false - we don't want to observe changes in direct child nodes
  3. In ngOnDestroy we disconnect the observer, so it stops observing the element - obviously, it no longer exists
  4. We made a slight modification to the setAnchorTarget method. Now, if the link is not external, we remove the target attribute from the element. This is needed for the cases when another value is provided in the template for the target attribute, like target="_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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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!

πŸ’– πŸ’ͺ πŸ™… 🚩
armandotrue
Armen Vardanyan

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