DEV Community

Cover image for Superpowers with Directives and Dependency Injection: Part 4
Armen Vardanyan for This is Angular

Posted on • Edited on

Superpowers with Directives and Dependency Injection: Part 4

Original cover photo by Justin Wolff on Unsplash.

Welcome to the fourth part of my exploration of Angular directives! We have explored directive usage in template-local logic, structural directives instead of components, and using directives to extend the functionality of existing components and/or elements. This time around, we are going to find out how directives can be used to work with events and add events to components that do not really exist.

Let's get started with two interesting use cases!

A click-away directive

Sometimes we need to know when the user clicked outside of a given element. This is a common use case for dropdowns, modals, and other components that need to be closed when the user clicks outside of them. This can also be useful in a gaming app, or an app that shows videos (clicking away pauses the video, etc). We could do something inside of the component that has this functionality, but that would not be a very reusable piece of logic, considering we might need something like that in other components too. So, let's build a directive for this!

It is going to:

  • take the target element
  • inject the Renderer2 instance to be able to listen to events
  • listen to all click events on the document element
  • if the target is not a descendant of the clicked element (thus being outside of it), emit an event
  • dispose of the event listener when the directive is destroyed

Here is our implementation:

@Directive({
  selector: '[clickAway]',
  standalone: true,
})
export class ClickOutsideDirective implements OnInit, OnDestroy {
  private readonly elRef: ElementRef<HTMLElement> = inject(
    ElementRef,
  );
  private readonly renderer = inject(Renderer2);
  private readonly document = inject(DOCUMENT);

  @Output() clickAway = new EventEmitter<void>();
  dispose: () => void;

  ngOnInit() {
    this.dispose = this.renderer.listen(
      this.document.body,
      'click',
      (event: MouseEvent) => {
        if (!this.elRef.nativeElement.contains(
          event.target as HTMLElement
        )) {
          this.clickAway.emit();
        }
      }
    );
  }

  ngOnDestroy() {
    this.dispose();
  }
}
Enter fullscreen mode Exit fullscreen mode

As you can see, the logic is very straightforward. We inject the ElementRef instance to get the target element, the Renderer2 instance to be able to listen to events, and the DOCUMENT token to get the document element. We then listen to all click events on the document element, and if the target is not a descendant of the clicked element (we check it using the Node.contains method), we emit a clickAway event. We then dispose of the event listener in ngOnDestroy.

Now, the cool thing about naming the EventEmitter the same as the directive selector is that we can just add this custom event on any element we like:

<div (clickAway)="onOutsideClick()">
  <h1>Click outside of me!</h1>
</div>
Enter fullscreen mode Exit fullscreen mode

Works like magic, as if it were a native event like (click) or (mouseover)!

Here is a working example with a preview:

Now, on to our next example.

Handling scrolling

Now, an often guest in different web apps is the ability to perform actions (like loading more content) when certain elements become visible in the view. This is a common use case for infinite scrolling, lazy loading, and other similar features. Again, as in the previous example, let us explore solutions that would allow us to reuse this logic in multiple places. Essentially, what we want is to have a custom event that would fire as soon as the element enters the viewport.

We are going to use an IntersectionObserver for this, which allows observing whether a given element is intersecting with (essentially being visible inside) another element or the entire viewport. This will be a simplified example (lots of nuances can be present based on how exactly we want this to work), but the basic is that we can

  • take a target element
  • listen to all of its intersection changes
  • If it is interesecting with the viewport, emit an event

Now, here is an implementation:

@Directive({
  selector: '[scrollIntoView]',
  standalone: true,
})
export class ScrollIntoViewDirective implements OnInit, OnDestroy {
  @Input() threshold = 0.25;
  @Output() scrollIntoView = new EventEmitter<void>();
  elRef = inject(ElementRef);
  observer: IntersectionObserver;

  ngOnInit() {
    this.observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            this.scrollIntoView.emit();
          }
        });
      },
      { threshold: this.threshold }
    );

    this.observer.observe(this.elRef.nativeElement);
  }

  ngOnDestroy() {
    this.observer.disconnect();
  }
}
Enter fullscreen mode Exit fullscreen mode

Here, we create an IntersectionObserver instance, and we listen to all of its changes. If the target element is intersecting with the viewport, we emit a scrollIntoView event. We then disconnect the observer in ngOnDestroy. As you can see, the implementation is fairly similar to what we did with the ClickAway directive, the difference being the "business logic". Here is how we can use it in a template:

<div>
  Really long content goes here
  <div (scrollIntoView)="loadMoreContent()">
    Dynamic content goes here
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Again, works like magic as any other native event!

Here is the preview:

Note: the example shows a long list of div-s, scroll to the bottom to see a text message being logged into the console.

Conclusion

As our exploration goes on, we learn more and more interesting use cases for Angular directives. In the next one, we will learn how to show templates outside of our components using directives and the concept of Portals. Stay tuned!

Top comments (1)

Collapse
 
szyszak profile image
szyszak

Absolutely love this series and can't wait for the next part! What do you think about using RXJS "fromEvent" observable in the ClickOutsideDirective instead? One downside would be having to manually unsubscribe in the "onDestroy" lifecycle hook. Other than that, I think it would be a simpler solution in this case.