191

How can I detect clicks outside a component in Angular?

2

12 Answers 12

305
import { Component, ElementRef, HostListener, Input } from '@angular/core';

@Component({
  selector: 'selector',
  template: `
    <div>
      {{text}}
    </div>
  `
})
export class AnotherComponent {
  public text: String;

  @HostListener('document:click', ['$event'])
  clickout(event) {
    if(this.eRef.nativeElement.contains(event.target)) {
      this.text = "clicked inside";
    } else {
      this.text = "clicked outside";
    }
  }

  constructor(private eRef: ElementRef) {
    this.text = 'no clicks yet';
  }
}

A working example - click here

Sign up to request clarification or add additional context in comments.

3 Comments

This doesn't work when there is an element controlled by an ngIf inside the trigger element, since the ngIf removing the element from the DOM happens before the click event: plnkr.co/edit/spctsLxkFCxNqLtfzE5q?p=preview
does it work on a component that created dynamiclly via : const factory = this.resolver.resolveComponentFactory(MyComponent); const elem = this.vcr.createComponent(factory);
A nice article on this topic: christianliebel.com/2016/05/…
93

An alternative to AMagyar's answer. This version works when you click on element that gets removed from the DOM with an ngIf.

http://plnkr.co/edit/4mrn4GjM95uvSbQtxrAS?p=preview

  private wasInside = false;

  @HostListener('click')
  clickInside() {
    this.text = "clicked inside";
    this.wasInside = true;
  }

  @HostListener('document:click')
  clickout() {
    if (!this.wasInside) {
      this.text = "clicked outside";
    }
    this.wasInside = false;
  }

3 Comments

This works perfectly with ngif or dynamic updates as well
Is it guaranteed that clickInside will be called earlier than clickout in the case that the click was done on an element lying inside?
I'm not 100% sure, but I believe it would be guaranteed due to event bubbling. DOM click events start at the target element and then bubble up to the root of the DOM. In this example, you could pass the MouseEvent to the clickInside() function and use event.stopPropagation() to prevent it bubbling up to the root. There are caveats to using stopPropegation though, so be wary.
85

Binding to a document click through @Hostlistener is costly. It can and will have a visible performance impact if you overuse it (for example, when building a custom dropdown component and you have multiple instances created in a form).

I suggest adding a @Hostlistener() to the document click event only once inside your main app component. The event should push the value of the clicked target element inside a public subject stored in a global utility service.

@Component({
  selector: 'app-root',
  template: '<router-outlet></router-outlet>'
})
export class AppComponent {

  constructor(private utilitiesService: UtilitiesService) {}

  @HostListener('document:click', ['$event'])
  documentClick(event: any): void {
    this.utilitiesService.documentClickedTarget.next(event.target)
  }
}

@Injectable({ providedIn: 'root' })
export class UtilitiesService {
   documentClickedTarget: Subject<HTMLElement> = new Subject<HTMLElement>()
}

Whoever is interested for the clicked target element should subscribe to the public subject of our utilities service and unsubscribe when the component is destroyed.

export class AnotherComponent implements OnInit {

  @ViewChild('somePopup', { read: ElementRef, static: false }) somePopup: ElementRef

  constructor(private utilitiesService: UtilitiesService) { }

  ngOnInit() {
      this.utilitiesService.documentClickedTarget
           .subscribe(target => this.documentClickListener(target))
  }

  documentClickListener(target: any): void {
     if (this.somePopup.nativeElement.contains(target))
        // Clicked inside
     else
        // Clicked outside
  }

4 Comments

I think that this one should become the accepted answer as it allows for many optimizations: like in this example
@lampshade Correct. I talked about this. Read the answer again. I leave the unsubscribe implementation to your style (takeUntil(), Subscription.add()). Don't forget to unsubscribe!
Is there a way for the HostListener to only be active when there are subscribers interested in the events?
This triggers tick() twice per click for me even when nothing is subscribed to it. I found stackoverflow.com/a/51152404/3266845 which uses Renderer2 and it also exhibits the same behavior, but with that one I wrapped it around runOutsideAngular(). Now my dropdown components don't trigger wasted change detections. medium.com/claritydesignsystem/…
17

Improving J. Frankenstein's answer:

  @HostListener('click')
  clickInside($event) {
    this.text = "clicked inside";
    $event.stopPropagation();
  }

  @HostListener('document:click')
  clickOutside() {
      this.text = "clicked outside";
  }

5 Comments

Niece solution, but stopPropagation might affect logic outside of the component: google analytics, closing of another component, etc
$event returns undefined now.
@Shadoweb should add listener second param: @HostListener('click', ['$event'])
doesn't work correctly in case of multiple same components initiated.
if you need the $event then you can use the following: @HostListener('click', ['$event'])
7

The previous answers are correct, but what if you are doing a heavy process after losing the focus from the relevant component? For that, I came with a solution with two flags where the focus out event process will only take place when losing the focus from relevant component only.

isFocusInsideComponent = false;
isComponentClicked = false;

@HostListener('click')
clickInside() {
    this.isFocusInsideComponent = true;
    this.isComponentClicked = true;
}

@HostListener('document:click')
clickout() {
    if (!this.isFocusInsideComponent && this.isComponentClicked) {
        // Do the heavy processing

        this.isComponentClicked = false;
    }
    this.isFocusInsideComponent = false;
}

Comments

5

ginalx's answer should be set as the default one imo: this method allows for many optimizations.

The problem

Say that we have a list of items and on every item we want to include a menu that needs to be toggled. We include a toggle on a button that listens for a click event on itself (click)="toggle()", but we also want to toggle the menu whenever the user clicks outside of it. If the list of items grows and we attach a @HostListener('document:click') on every menu, then every menu loaded within the item will start listening for the click on the entire document, even when the menu is toggled off. Besides the obvious performance issues, this is unnecessary.

You can, for example, subscribe whenever the popup gets toggled via a click and start listening for "outside clicks" only then.


isActive: boolean = false;

// to prevent memory leaks and improve efficiency, the menu
// gets loaded only when the toggle gets clicked
private _toggleMenuSubject$: BehaviorSubject<boolean>;
private _toggleMenu$: Observable<boolean>;

private _toggleMenuSub: Subscription;
private _clickSub: Subscription = null;


constructor(
 ...
 private _utilitiesService: UtilitiesService,
 private _elementRef: ElementRef,
){
 ...
 this._toggleMenuSubject$ = new BehaviorSubject(false);
 this._toggleMenu$ = this._toggleMenuSubject$.asObservable();

}

ngOnInit() {
 this._toggleMenuSub = this._toggleMenu$.pipe(
      tap(isActive => {
        logger.debug('Label Menu is active', isActive)
        this.isActive = isActive;

        // subscribe to the click event only if the menu is Active
        // otherwise unsubscribe and save memory
        if(isActive === true){
          this._clickSub = this._utilitiesService.documentClickedTarget
           .subscribe(target => this._documentClickListener(target));
        }else if(isActive === false && this._clickSub !== null){
          this._clickSub.unsubscribe();
        }

      }),
      // other observable logic
      ...
      ).subscribe();
}

toggle() {
    this._toggleMenuSubject$.next(!this.isActive);
}

private _documentClickListener(targetElement: HTMLElement): void {
    const clickedInside = this._elementRef.nativeElement.contains(targetElement);
    if (!clickedInside) {
      this._toggleMenuSubject$.next(false);
    }    
 }

ngOnDestroy(){
 this._toggleMenuSub.unsubscribe();
}

And, in *.component.html:


<button (click)="toggle()">Toggle the menu</button>

1 Comment

While I agree with the way you think, I'd suggest not stuffing all logic in a tap operator. Instead, use skipWhile(() => !this.isActive), switchMap(() => this._utilitiesService.documentClickedTarget), filter((target) => !this._elementRef.nativeElement.contains(target)), tap(() => this._toggleMenuSubject$.next(false)). This way you utilize way more of RxJs and skip some subscriptions.
2

Alternative to MVP, you only need to watch for Event

@HostListener('focusout', ['$event'])
  protected onFocusOut(event: FocusEvent): void {
    console.log(
      'click away from component? :',
      event.currentTarget && event.relatedTarget
    );
  }

1 Comment

What do you mean by "MVP"? Model–view–presenter? Or something else?
2

nice and tidy with rxjs. i used this for aggrid custom cell editor to detect clicks inside my custom cell editor.

private clickSubscription: Subscription | undefined;

public ngOnInit(): void {
this.clickSubscription = fromEvent(document, "click").subscribe(event => {
      console.log("event: ", event.target);
      if (!this.eRef.nativeElement.contains(event.target)) {
        // ... click outside
      } else {
        // ... click inside
    });
    
    
public ngOnDestroy(): void {
    console.log("ON DESTROY");
    this.clickSubscription?.unsubscribe();
  }
  
  

Comments

1

Solution

Get all parents

var paths       = event['path'] as Array<any>;

Checks if any parent is the component

var inComponent = false;    
paths.forEach(path => {
    if (path.tagName != undefined) {
        var tagName = path.tagName.toString().toLowerCase();
        if (tagName == 'app-component')
            inComponent = true;
    }
});

If you have the component as parent then click inside the component

if (inComponent) {
    console.log('clicked inside');
}else{
    console.log('clicked outside');
}

Complete method

@HostListener('document:click', ['$event'])
clickout(event: PointerEvent) {
    
    var paths       = event['path'] as Array<any>;
    
    var inComponent = false;    
    paths.forEach(path => {
        if (path.tagName != undefined) {
            var tagName = path.tagName.toString().toLowerCase();
            if (tagName == 'app-component')
                inComponent = true;
        }
    });
    
    if (inComponent) {
        console.log('clicked inside');
    }else{
        console.log('clicked outside');
    }
    
}

Comments

1

You can use the clickOutside() method from the ng-click-outside package; it offers a directive "for handling click events outside an element".

NB: This package is currently deprecated. See https://github.com/arkon/ng-sidebar/issues/229 for more info.

Comments

1

Another possible solution using event.stopPropagation():

  1. define a click listener on the top most parent component which clears the click-inside variable
  2. define a click listener on the child component which first calls the event.stopPropagation() and then sets the click-inside variable

Comments

0

You can call an event function like (focusout) or (blur); then you would put in your code:

<div tabindex=0 (blur)="outsideClick()">raw data </div>

outsideClick() {
  alert('put your condition here');
}

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.