Superpowers with Directives and Dependency Injection: Part 3
Tags: angular, javascript, typescript
Published: 2023-03-21 12:31:15

_Original cover photo by [USGS](https://unsplash.com/@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](https://dev.to/this-is-angular/superpowers-with-directives-and-dependency-injection-part-1-ho7) and reuse it, and how [to use structural directives](https://dev.to/this-is-angular/superpowers-with-directives-and-dependency-injection-part-2-16ea) 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:

```typescript
function isLinkExternal(url: string) {
  return new URL(url).origin !== location.origin;
}
```

We are leveraging the [`URL`](https://developer.mozilla.org/en-US/docs/Web/API/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:

```typescript
@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](https://angular.io/api/core/HostBinding).

 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:

```typescript
@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:

```html
<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:

```html
<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:

```typescript
@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:

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:

{% embed https://stackblitz.com/edit/angular-mvuhpv?ctl=1&embed=1&file=src/main.ts %}

## Use case 2: hijacking existing components

This part is completely inspired by a blog post written by [Tim Deschryver](https://timdeschryver.dev/blog/use-angular-directives-to-extend-components-that-you-dont-own), 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:

```typescript
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!