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();
}
}
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>
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();
}
}
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>
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)
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.