48

This div will be dynamically shown on the page as an east panel when the open panel button is clicked. The bool showEastPanel variable is what is used to open and close the east panel. I am trying to use (clickoutside) to close the panel (setting showEastPanel to false) however the open panel runs first on the Angular hook and the panel is set to true then false and the panel doesn't display. Is the any way to scope clickoutside to not include the button?

<div [ngClass]="{'d-none': !showEastPanel, 'east-panel-container': showEastPanel}" (clickOutside)="ClosePanel()">
      <div id="east-panel">
        <ng-template #eastPanel></ng-template>
      </div>
</div>

<button (click)="ShowPanel()">Open Panel</button>
Lulutho Mgwali
  • 823
  • 1
  • 11
  • 33

10 Answers10

86

Here is the link to the working demo: Stackblitz Demo

I would do this by using the Angular recommended approach which is also easy to develop apps in environments with no DOM access, I mean Renderer 2 class which is an abstraction provided by Angular in the form of a service that allows manipulating elements of your app without having to touch the DOM directly.

In this approach, you need to inject Renderer2 into your component constructor, which the Renderer2 lets us to listen to triggered events elegantly. It just takes the element you're going to listen on as the first argument which can be window, document, body or any other element reference. For the second argument it takes the event we're going to listen on which in this case is click, and the third argument is actually the callback function which we do it by arrow function.

this.renderer.listen('window', 'click',(e:Event)=>{ // your code here})

The rest of the solution is easy, you just need to set a boolean flag which keeps the status of the menu (or panel) visibility, and what we should do is to assign false to that flag when it's clicked outside of the menu.

HTML

<button #toggleButton (click)="toggleMenu()"> Toggle Menu</button>

<div class="menu" *ngIf="isMenuOpen" #menu>
I'm the menu. Click outside to close me
</div>

app.component.ts

    export class AppComponent {
      /**
       * This is the toogle button elemenbt, look at HTML and see its defination
       */
      @ViewChild('toggleButton') toggleButton: ElementRef;
      @ViewChild('menu') menu: ElementRef;
    
      constructor(private renderer: Renderer2) {
        /**
         * This events get called by all clicks on the page
         */
        this.renderer.listen('window', 'click',(e:Event)=>{
             /**
              * Only run when toggleButton is not clicked
              * If we don't check this, all clicks (even on the toggle button) gets into this
              * section which in the result we might never see the menu open!
              * And the menu itself is checked here, and it's where we check just outside of
              * the menu and button the condition abbove must close the menu
              */
            if(e.target !== this.toggleButton.nativeElement && e.target!==this.menu.nativeElement){
                this.isMenuOpen=false;
            }
        });
      }
    
      isMenuOpen = false;
    
      toggleMenu() {
        this.isMenuOpen = !this.isMenuOpen;
      }
    }

Again, if you like to see the working demo, use this link: Stackblitz Demo

Shashank Vivek
  • 16,888
  • 8
  • 62
  • 104
Mohammad Kermani
  • 5,188
  • 7
  • 37
  • 61
  • 7
    Detecting where, exactly, was the click, can be a hell of a pain, because of bubbling. Better do the test with `path` array - has them all! ;-) `if (evt.path.indexOf( this.toggleButton.nativeElement ) === -1 ) {...}` – Pedro Ferreira Mar 24 '19 at 00:39
  • 1
    Would you mind pointing out where can I find this recommendation on Angular docs? – MMalke Aug 10 '20 at 13:50
  • 1
    Perfectly worked! And I think it is useful in many things. – nipun-kavishka Aug 16 '20 at 10:31
  • @MMalke by "Angular recommended approach" I meant not directly touching the DOM and using Renderer2 – Mohammad Kermani Aug 16 '20 at 10:49
  • 6
    If you also need to detect click event on target children use this: `!this.toggleButton.nativeElement.contains(e.target)` as explained on https://stackoverflow.com/a/57391798/9348886 – Mosrainis Dec 08 '20 at 13:56
  • 4
    If want to prevent menu close on click of inner elements of menu add, `e.target !== this.toggleButton.nativeElement && e.target !== this.menu.nativeElement && !this.menu.nativeElement.contains(e.target)` – JotK. May 07 '21 at 09:52
  • You can assign a variable to the renderer to unlisten at ngOnDestroy(). Also consider moving it into runOutsideAngular() if you don't want to trigger change detection, all explained here: https://medium.com/claritydesignsystem/four-ways-of-listening-to-dom-events-in-angular-part-3-renderer2-listen-14c6fe052b59 – Kerry Johnson Nov 23 '22 at 20:17
25

you can do something like this

  @HostListener('document:mousedown', ['$event'])
  onGlobalClick(event): void {
     if (!this.elementRef.nativeElement.contains(event.target)) {
        // clicked outside => close dropdown list
     this.isOpen = false;
     }
  }

and use *ngIf=isOpen for the panel

Sujay
  • 613
  • 1
  • 5
  • 16
18

I would like to add the solution that helped me to achieve proper result.

When using embedded elements and you want to detect click on parent, event.target gives reference to the basic child.

HTML

<div #toggleButton (click)="toggleMenu()">
    <u>Toggle Menu</u>
    <span class="some-icon"></span>
</div>

<div #menu class="menu" *ngIf="isMenuOpen">
    <h1>I'm the menu.</h1>
    <div>
        I have some complex content containing multiple children.
        <i>Click outside to close me</i>
    </div>
</div>

I click on "Toggle menu" text, event.target returns reference to 'u' element instead of #toggleButton div.

For this case I used M98's solution including Renderer2, but changed the condition to the one from Sujay's answer.

ToggleButton.nativeElement.contains(e.target) returns true even if the target of click event is in nativeElement's children, which solves the problem.

component.ts

export class AppComponent {
/**
 * This is the toogle button element, look at HTML and see its definition
 */
    @ViewChild('toggleButton') toggleButton: ElementRef;
    @ViewChild('menu') menu: ElementRef;
    isMenuOpen = false;

    constructor(private renderer: Renderer2) {
    /**
     * This events get called by all clicks on the page
     */
        this.renderer.listen('window', 'click',(e:Event)=>{
            /**
             * Only run when toggleButton is not clicked
             * If we don't check this, all clicks (even on the toggle button) gets into this
             * section which in the result we might never see the menu open!
             * And the menu itself is checked here, and it's where we check just outside of
             * the menu and button the condition abbove must close the menu
             */
            if(!this.toggleButton.nativeElement.contains(e.target) && !this.menu.nativeElement.contains(e.target)) {
                this.isMenuOpen=false;
            }
        });
    }

    toggleMenu() {
        this.isMenuOpen = !this.isMenuOpen;
    }
}
kyselm
  • 300
  • 3
  • 11
17

I like the answer by Sujay. If you wish to create a directive instead (to be used in several components). This is how I would do it.

import {
  Directive,
  EventEmitter,
  HostListener,
  Output,
  ElementRef,
} from '@angular/core';

@Directive({
  selector: '[outsideClick]',
})
export class OutsideClickDirective {
  @Output()
  outsideClick: EventEmitter<MouseEvent> = new EventEmitter();

  @HostListener('document:mousedown', ['$event'])
  onClick(event: MouseEvent): void {
    if (!this.elementRef.nativeElement.contains(event.target)) {
      this.outsideClick.emit(event);
    }
  }

  constructor(private elementRef: ElementRef) {}
}

You would then use the directive like so:

<div class="menu" *ngIf="isMenuOpen" (outsideClick)="isMenuOpen = false" outsideClick #menu>
  I'm the menu. Click outside to close me
</div>
Emric Månsson
  • 495
  • 6
  • 15
8

Here's a reusable directive, it also cover the case if the element is inside an ngIf:

import { Directive, ElementRef, Optional, Inject, Output, EventEmitter, OnInit, OnDestroy } from '@angular/core';
import { fromEvent, Subscription } from 'rxjs';
import { DOCUMENT } from '@angular/common';
import { filter } from 'rxjs/operators';

@Directive({
  selector: '[outsideClick]',
})
export class OutsideClickDirective implements OnInit, OnDestroy {
  @Output('outsideClick') outsideClick = new EventEmitter<MouseEvent>();

  private subscription: Subscription;

  constructor(private element: ElementRef, @Optional() @Inject(DOCUMENT) private document: any) {}

  ngOnInit() {
    setTimeout(() => {
      this.subscription = fromEvent<MouseEvent>(this.document, 'click')
        .pipe(
          filter(event => {
            const clickTarget = event.target as HTMLElement;
            return !this.isOrContainsClickTarget(this.element.nativeElement, clickTarget);
          }),
        )
        .subscribe(event => this.outsideClick.emit());
    }, 0);
  }

  private isOrContainsClickTarget(element: HTMLElement, clickTarget: HTMLElement) {
    return element == clickTarget || element.contains(clickTarget);
  }

  ngOnDestroy() {
    if (this.subscription) this.subscription.unsubscribe();
  }
}

Credits to https://github.com/ngez/platform, I got most of the logic out of it.

What I was missing was the setTimeout(..., 0), which makes sure to schedule the check after the component using the directive has been rendered.

Useful links:

michelepatrassi
  • 2,016
  • 18
  • 32
6

More Simplified Code with demo on: StackBlitz

I have made a common function to close the menu on outside click and Prevent the closeing if click triggered on specific elements.

HTML

<button (click)="toggleMenu(); preventCloseOnClick()">Toggle Menu</button>
<ul (click)="preventCloseOnClick()" *ngIf="menuOpen">
  <li>Menu 1</li>
  <li>Menu 2</li>
  <li>Menu 3</li>
  <li>Menu 4</li>
  <li>Menu 5</li>
</ul>

TS

import { Component, VERSION, Renderer2 } from '@angular/core';

export class AppComponent {
  menuOpen: boolean = false;
  menuBtnClick: boolean = false;

  constructor(private renderer: Renderer2) {
    this.renderer.listen('window', 'click', (e: Event) => {
      if (!this.menuBtnClick) {
        this.menuOpen = false;
      }
      this.menuBtnClick = false;
    });
  }
  toggleMenu() {
    this.menuOpen = !this.menuOpen;
  }
  preventCloseOnClick() {
    this.menuBtnClick = true;
  }
}
2

thanks Emerica ng-click-outside works perfect, this is what i was needing, i was testing on my modal, but when i click it, the first click on the button, it dettects outside click and then didnt work to put on modal, but i only added delayClickOutsideInit="true" from docs and it works very good, this is the final result:

<button
  (click)="imageModal()"
>
<button/>

<div
  *ngIf="isMenuOpen"
>
  <div
    (clickOutside)="onClickedOutside($event)"
    delayClickOutsideInit="true"
  >
   Modal content
  </div>
</div>

and this is my component

import {
  Component,
} from '@angular/core';

@Component({
  selector: 'app-modal-header',
  templateUrl: './modal-header.component.html',
  styleUrls: ['./modal-header.component.css'],
})
export class ModalHeaderComponent implements OnInit {
  public isMenuOpen = false;

  constructor() {}

  imageModal() {
    this.isMenuOpen = !this.isMenuOpen;
  }
  closeModal() {
//you can do an only close function click
    this.isMenuOpen = false;
  }
  onClickedOutside(e: Event) {
    this.isMenuOpen = false;
  }
}
1

i did other way unlike the previous answers.

i put mouseleave, mouseenter event on dropdown menu

<div
    class="dropdown-filter"
    (mouseleave)="onMouseOutFilter($event)"
    (mouseenter)="onMouseEnterFilter($event)"
  >
    <ng-container *ngIf="dropdownVisible">
      <input
        type="text"
        placeholder="search.."
        class="form-control"
        [(ngModel)]="keyword"
        id="myInput"
        (keyup)="onKeyUp($event)"
      />
    </ng-container>
    <ul
      class="dropdown-content"
      *ngIf="dropdownVisible"
    >
      <ng-container *ngFor="let item of filteredItems; let i = index">
        <li
          (click)="onClickItem($event, item)"
          [ngStyle]="listWidth && {width: listWidth + 'px'}"
        >
          <span>{{ item.label }}</span>
        </li>
      </ng-container>
    </ul>
  </div>
  constructor(private renderer: Renderer2) {
    /**
     * this.renderer instance would be shared with the other multiple same components
     * so you should have one more flag to divide the components
     * the only dropdown with mouseInFilter which is false should be close
     */
    this.renderer.listen('document', 'click', (e: Event) => {
      if (!this.mouseInFilter) {
        // this is the time to hide dropdownVisible
        this.dropdownVisible = false;
      }
    });
  }

  onMouseOutFilter(e) {
    this.mouseInFilter = false;
  }

  onMouseEnterFilter(e) {
    this.mouseInFilter = true;
  }

and make sure the defaultValue of mouseInFilter is false;

  ngOnInit() {
    this.mouseInFilter = false;
    this.dropdownVisible = false;
  }

and when dropdown should be visible mouseInFilter is going to be true

  toggleDropDownVisible() {
    if (!this.dropdownVisible) {
      this.mouseInFilter = true;
    }
    this.dropdownVisible = !this.dropdownVisible;
  }
YOSEPH NOH
  • 87
  • 4
0

I have did the same in one of my requirement to show mega menu popup when user clicks on menu icon but want to close it whet user clicks outside it. Here i am trying to prevent click on icon as well. Please have a look.

In HTML

 <div #menuIcon (click)="onMenuClick()">
  <a><i class="fa fa-reorder"></i></a>
 </div>
<div #menuPopup  *ngIf="showContainer">
   <!-- Something in the popup like menu -->
</div>

In TS

  @ViewChild('menuIcon', { read: ElementRef, static: false })  menuIcon: ElementRef;
  @ViewChild('menuPopup', { read: ElementRef, static: false })  menuPopup: ElementRef;
   showContainer = false;

      constructor(private renderer2: Renderer2) {
      this.renderer2.listen('window', 'click', (e: Event) => {
        if (
         (this.menuPopup && this.menuPopup.nativeElement.contains(e.target)) ||
          (this.menuIcon && this.menuIcon.nativeElement.contains(e.target))
         ) {
              // Clicked inside plus preventing click on icon
             this.showContainer = true;
           } else {
             // Clicked outside
             this.showContainer = false;
         }
      });
    }

     onMenuClick() {
        this.isShowMegaMenu = true;
      }
Anup Bangale
  • 581
  • 7
  • 7
0

You can use https://github.com/arkon/ng-click-outside which is pretty easy to use with a lot of useful features:

@Component({
  selector: 'app',
  template: `
    <div (clickOutside)="onClickedOutside($event)">Click outside this</div>
  `
})
export class AppComponent {
  onClickedOutside(e: Event) {
    console.log('Clicked outside:', e);
  }
}

About performance, the lib uses ngOnDestroy to remove the listener when the directive isn't active (use clickOutsideEnabled property) which is really important and most of proposed solutions don't do that. See the source code here.

Emeric
  • 6,315
  • 2
  • 41
  • 54