Superpowers with Directives and Dependency Injection: Part 7

armandotrue

Armen Vardanyan

Posted on January 30, 2024

Superpowers with Directives and Dependency Injection: Part 7

Original cover photo by Jr Korpa on Unsplash.

Lots of time has passed since I wrote my previous article covering the most powerful features of Angular directives (especially coupled with dependency injection), as I was focused on writing my book (more about it in the end), so this series was left essentially unfinished. However, having completed the book's draft, and with it being in Early Access now, I decided to go back to writing articles, as it is high time this series receives a useful ending. Today, there won't be much about dependency injection, however, we will dive deep into the world of Angular directive selectors, and see how we can use them to create really powerful directives.

Directive Selectors

All Angular directives have a selector, which specifies which HTML elements will the directive work with. Most often (in my experience, around 95% of scenarios), the selector is an attribute selector, which means that the directive will essentially be a custom HTML attribute.

However, in previous articles, we saw that directives can be much more than just custom attributes. So, let us finish this series by exploring all of the other selector types, how they can be used, and some possible pitfalls, all with real-life, useful examples.

Non-custom attribute selectors

In this scenario, instead of inventing a new attribute, we use an existing, valid HTML attribute. This is useful when we want to extend the functionality of an existing HTML element, without having to create a new attribute for it.

For instance, we might put ids on our HTML elements to mark things that are needed for E2E testing or something like that. However, some E2E test runners might be using different attributes, like data-test-id or data-qa-id. Instead of copy-pasting the same id attribute with a different value, we can create a directive that will add the attribute we need based on the id attribute:

@Directive({
  selector: '[id]',
  standalone: true,
})
export class TestingIdDirective {
  private readonly elRef = inject<ElementRef<HTMLElement>>(ElementRef);

  get id() {
    return this.elRef.nativeElement.id;
  }

  @HostBinding('attr.data-qa-id') get dataQaId() {
    return this.id;
  }
  @HostBinding('attr.data-test-id') get dataTestId() {
    return this.id;
  }
}
Enter fullscreen mode Exit fullscreen mode

In this example, we just pick any element that has an id property, and then set its value unto two new attributes data-qa-id and data-test-id using a HostBinding. In the end, it does not matter what type of element we are working with, as long as it has an id property, we can use this directive on it. You can find a working reproduction of this example here.

Of course, this example is very broad, and that is why we have to discuss our next, more narrow one.

Attribute selectors with values

As we saw, the previous example targeted elements that just had an id attribute, without much regard as to what that particular id is. However, there can be other motives for us to use an attribute selector. Let's consider the following example: we have many inputs of type number, but in our application, we only want to allow positive numbers. If the user inputs a negative number, we want to outline the input with the color red. We have a CSS style that does exactly that when encountering a .invalid class, so, what is left to us is to target inputs of type number, check their value, and add or remove the .invalid class based on that. We can do that with the following directive:

@Directive({
  selector: 'input[type="number"]',
  standalone: true,
})
export class PositiveNumberDirective {
  private readonly elRef = inject<ElementRef<HTMLInputElement>>(ElementRef);
  @HostBinding('class.invalid') value = false;

  @HostListener('input') 
  onInput() {
    const value = this.elRef.nativeElement.value ?? 0;
    this.value = (+value) < 0;
  }
}
Enter fullscreen mode Exit fullscreen mode

Again, we do not have to do much here other than importing the directive into the component we want to use it with, as it will automatically bind to the inputs of type number and add the .invalid class to them if the value is negative, which we will know because of the HostBinding. You can find a working reproduction of this example here.

Next, let us discuss an often-overlooked use case of directive selectors.

Class selectors

Yes, you might have not heard about them, but directives can actually bind to CSS classes too! Consider the following scenario: in the previous example, we added the .invalid class to the input, but now we want to add some logic to all the .invalid elements in the application. For example, we might want to add a tooltip to them, which is also accomplished with a separate directive that already exists. We can achieve this with the power of Host Directives combined with a class selector:

@Directive({
  selector: '.invalid',
  standalone: true,
  hostDirectives: [TooltipDirective],
})
export class InvalidTooltipDirective implements AfterViewInit {
  private readonly tooltipRef = inject(TooltipDirective);

  ngAfterViewInit() {
    this.tooltipRef.title = 'The value is invalid';
  }
}
Enter fullscreen mode Exit fullscreen mode

This will kinda achieve what we want, however has a glaring problem, which we will discuss shortly after we cover all the selector types. At the moment, let's focus on our next level of knowledge about directive selectors - that is way more complex and versatile ones.

Complex selectors

So far, we discussed selectors that specifically target some HTML elements. This is handy, but there are scenarios where we might want to target more than one type of element, or maybe exclude some elements from an otherwise too broad selector. Let's see how we can achieve that.

Combining selectors

There are two ways of combining selectors to either broaden the "target audience", so to speak, of a directive: specifying a more concrete attribute or using multiple selectors. We already discussed the first scenario (the input[type="number"] in the PositiveNumberDirective two examples prior), so let us just talk about using multiple selectors.

Let's say we have a social media application built with Angular, where users can interact and talk to each other. Each user has an online status, which is determined by a number of things (just having a page open is not enough to consider a user to be "online" at the moment). One of those things is whether the user is typing something. There are a number of places where a user can type, for example, input-s and textarea-s. We want a directive that will capture that event and send a quick HTTP call to the server notifying that the user is currently online (there are a bunch of optimizations we can do about this, for instance, checking if the user is not already marked as "online", and making sure we do not send an HTTP call on each keystroke, but we will skip them for the sake of brevity, as those are out of the scope of this article). Let's build such a directive:

@Directive({
  selector: 'input, textarea',
  standalone: true,
})
export class OnlineStatusDirective {
  private readonly userService = inject(UserService);

  @HostListener('input') 
  onInput() {
    this.userService.setOnlineStatus(true);
  }
}
Enter fullscreen mode Exit fullscreen mode

And again, as with other directives, just importing it into the component we want to use it with will be enough to make it work and target multiple types of elements. You can find a working reproduction of this example here. Let's now move on to our final entry, which helps narrow down the selector's specificity instead of broadening it.

Selector negation

Sometimes, we write a selector that targets all the elements that we want but encounters this one element that it just shouldn't apply to, despite matching. For instance, in the second example in this article, the PositiveNumberDirective, targeted all the inputs of type number, but we might have a specific input that we do not want to apply this directive to.

Our first thought might be to add an Input property to signify if the directive should actually work or not:

@Directive({
  selector: 'input[type="number"]',
  standalone: true,
})
export class PositiveNumberDirective {
  @Input() enabled = true;
  private readonly elRef = inject(ElementRef);

  @HostBinding('class.invalid') get value() {
    const value = this.elRef.nativeElement.value ?? 0;
    return this.enabled && (+value) < 0;
  }
}
Enter fullscreen mode Exit fullscreen mode

This works, but it has three downsides:

  1. We have to provide the boolean with a value each time we don't want the directive to work, which is not a big deal, but it is still a bit of a hassle.
  2. We might have to use the enabled property multiple times in the directive, as it possibly can have several @HostListener-s, @HostBinding-s, etc. This makes the directive harder to read and understand.
  3. Finally, the directive still gets applied, making Angular spend some unnecessary computational power on it.

Now, let's see how we can solve this problem with a selector negation, more commonly known as the :not() selector. We can use it like this:

@Directive({
  selector: 'input[type="number"]:not([allowNegative])',
  standalone: true,
})
export class PositiveNumberDirective {
  private readonly elRef = inject(ElementRef);

  @HostBinding('class.invalid') get value() {
    const value = this.elRef.nativeElement.value ?? 0;
    return (+value) < 0;
  }
}
Enter fullscreen mode Exit fullscreen mode

As we can see, the directive's code/logic remained the same, we just changed the selector, excluding all elements that have the allowNegative attribute. Now, we can apply this "negative" attribute whenever we want to allow negative numbers, and the directive will not apply to that input.

<input type="number" allowNegative />
Enter fullscreen mode Exit fullscreen mode

The :not() selector specifier can accept any type of selector that is valid for a directive selector. You can find a working reproduction of this example here.

Finally, as we have covered all of the scenarios, it is time we briefly discuss some pitfalls we might encounter when working with directives.

The Pitfalls

With all the power of the selectors we saw in this article, it might be tempting to assume that it works exactly like a CSS selector. However, this could not be further from the truth. Let's see three main scenarios where this falls short.

Cannot target child-parent relations

While it certainly would be useful, it is impossible to target elements that are children of some specific parents only. We could try this:

@Directive({
  selector: 'div > button',
  standalone: true,
})
export class ParentChildDirective {
  @HostListener('click')
  onClick() {
    console.log('clicked')
  }
}
Enter fullscreen mode Exit fullscreen mode

And then put this template:

<div>
  <button>Directive is applied</button>
</div>
<button>Directive should not be applied</button>
Enter fullscreen mode Exit fullscreen mode

When we click on the first button, we might rejoice as it will log "clicked". However, it will also log when we click on the second button, which is not inside a div element. This is because Angular just picked the last element from the confusing (from its perspective) selector, and applied the directive to it. So, no complex parent-child (or sibling, ancestor, descendant, etc.) relations for directive selectors. Remember, Angular directive selectors are not exactly CSS selectors.

Directives cannot be applied dynamically

While it might certainly seem so, directives won't be "added" and "removed" as soon as an HTML element begins to match a certain selector. This becomes readily obvious with classes as directive selectors. When we built the InvalidTooltipDirective, we mentioned that it has a glaring problem; the thing is, it might create the impression that whenever an element obtains the invalid class, the directive will automatically apply to it; this is simply not the case: Angular directives get applied at compile-time, meaning this directive will be applied to elements that already have the invalid class, but not to those that will obtain it later. You can see an example of how this works (or not works) here.

Structural directives can only be attributes

With structural directives, what happens is that Angular uses some clever syntactic sugar: an element marked with an asterisk automatically becomes wrapped in an <ng-template>. So, if we just type, say, <div * class="some-class">Text</div>, it will automatically translate to <ng-template [class]="some-class"><div>Text</div></ng-template>. This is why structural directives can only be attributes, as they are applied to the <ng-template> element, not the element that is inside it. We cannot, for instance, target the div element in the previous example with a structural directive, as it is not the element that is marked with the asterisk. While this certainly restricts us from accomplishing some things, it is not very big of a deal, as we rarely need to do any logic in this regard.

Conclusion

I enjoyed writing this article series, and I hope it proved useful to readers as well. Angular directives are super powerful and underused, so I hope that this series will help you everyone them more often. My experience proved that doing this greatly simplifies codebases, especially the templates.

Small promotion

As I mentioned at the beginning of the article, I was busy writing a book. It is called "Modern Angular" and it is a comprehensive guide to all the new amazing features we got in recent versions (v14-v17), including standalone, improved inputs, signals (of course!), better RxJS interoperability, SSR, and much more. If this interested you, you can find it here. The manuscript is already entirely finished, with some polishing left to do, so it is currently in Early Access, with the first 5 chapters already published, and more to drop soon. If you want to keep yourself updated on the progress, you can follow me on Twitter or LinkedIn, where I will be posting whenever there are new chapters or promotions available.

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

Posted on January 30, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related