Search code examples
angularangular-directivedirective

Pass directive event from child to parent component in Angular


I have a parent component (sidebar) and a child component (menu)

I have a custom directive that detects if a click is made outside the element:

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

@Directive({
  selector: '[appClickedOutside]',
  standalone: true,
})
export class ClickedOutsideDirective {
  constructor(private el: ElementRef) {}

  @Output() public clickedOutside = new EventEmitter();

  @HostListener('document:click', ['$event'])
  public onClick(event: any) {
    if (!this.el.nativeElement.contains(event.target)) {
      this.clickedOutside.emit(true);
    }
  }

  @HostListener('document:keydown.escape', ['$event'])
  onEscapeKeydownHandler(event: KeyboardEvent) {
    this.clickedOutside.emit(true);
  }
}

In the menu component I am emitting an event:

 @Output() menuClosed = new EventEmitter<boolean>();

In the menu template, I am applying the directive to the div of the menu and emitting true when a an outside click is made.

<div
  appClickedOutside
  (clickedOutside)="menuClosed.emit(true)"
></div>

In the sidebar component (parent) I am receiving this and closing the menu:

onMenuClosed(isClosed: boolean) {
    if (isClosed && this.isMenuOpen) {
      this.isMenuOpen = false;
    }
  }

However, when I click the button in the parent that opens the menu, it is not working anymore:

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

toggleMenu is:

 toggleMenu() {
    this.isMenuOpen = !this.isMenuOpen;
  }

What is wrong with this implementation?


Solution

  • UPDATE:

    After user share the minimal reproducible stackblitz, I added the class ignore-click on the button, then on the directive the below condition ignored the button click!

        ...
        if (
         !(
            this.el.nativeElement?.contains(event.target) ||
            event.target?.classList?.contains('ignore-click')
          )
        ) {
          this.clickedOutside.emit(true);
        }
        ...
    

    Full Code

    main.ts

    import { Component } from '@angular/core';
    import { bootstrapApplication } from '@angular/platform-browser';
    import 'zone.js';
    import { MenuComponent } from './app/menu/menu.component';
    import { ClickOutsideDirective } from './app/click-outside.directive';
    
    @Component({
      selector: 'app-root',
      imports: [MenuComponent, ClickOutsideDirective],
      standalone: true,
      template: `
      <div class="container">
        <div class="menu-container">
          <button (click)="toggleMenu()" class="ignore-click">Toggle menu</button>
          @if(isMenuOpen) {
          <app-menu appClickOutside (clickedOutside)="isMenuOpen=false;"/>
        }
        </div>
    </div>
      `,
      styles: `
        .menu-container {
          position: relative
        }
    
        .container {
          display: flex;
          justify-content: center;
          align-items: center;
          margin-top: 100px;
        }
      `,
    })
    export class App {
      isMenuOpen = false;
    
      toggleMenu() {
        this.isMenuOpen = !this.isMenuOpen;
      }
    }
    
    bootstrapApplication(App);
    

    directive

    import {
      Directive,
      ElementRef,
      EventEmitter,
      HostListener,
      Output,
    } from '@angular/core';
    
    @Directive({
      selector: '[appClickOutside]',
      standalone: true,
    })
    export class ClickOutsideDirective {
      constructor(private el: ElementRef) {}
    
      @Output() public clickedOutside = new EventEmitter();
    
      @HostListener('document:click', ['$event'])
      public onClick(event: any) {
        if (
          !(
            this.el.nativeElement?.contains(event.target) ||
            event.target?.classList?.contains('ignore-click')
          )
        ) {
          this.clickedOutside.emit(true);
        }
      }
    
      @HostListener('document:keydown.escape', ['$event'])
      onEscapeKeydownHandler(event: KeyboardEvent) {
        this.clickedOutside.emit(true);
      }
    }
    

    Working Stackblitz


    Its happening because the button click will also be considers as an outside click!!

    Please pass in the button ref also to the directive and ignore the click if it originates from the button!

    <button #buttonRef> this opens the menu!</button>
    <div
      appClickedOutside
      [buttonRef]="buttonRef"
      (clickedOutside)="menuClosed.emit(true)"
    ></div>
    

    Then the directive can be changed to

    import { 
      Input,
      Directive,
      ElementRef,
      EventEmitter,
      HostListener,
      Output,
    } from '@angular/core';
    
    @Directive({
      selector: '[appClickedOutside]',
      standalone: true,
    })
    export class ClickedOutsideDirective {
      @Input() buttonRef: ElementRef<any>;
      constructor(private el: ElementRef) {}
    
      @Output() public clickedOutside = new EventEmitter();
    
      @HostListener('document:click', ['$event'])
      public onClick(event: any) {
        if (!(this.el.nativeElement.contains(event.target) &&
              this.el.nativeElement === this.buttonRef.nativeElement)) { // <-changed here
          this.clickedOutside.emit(true);
        }
      }
    
      @HostListener('document:keydown.escape', ['$event'])
      onEscapeKeydownHandler(event: KeyboardEvent) {
        this.clickedOutside.emit(true);
      }
    }
    

    Since there is no stackblitz its difficult to debug the issue.


    There is no need to even pass in the button. You can define a class like do-not-notice-this and then check if the event target does not have this class!

    <button class="do-not-notice-this"> this opens the menu!</button>
    <div
      appClickedOutside
      (clickedOutside)="menuClosed.emit(true)"
    ></div>
    

    the directive can be

    import {
      Directive,
      ElementRef,
      EventEmitter,
      HostListener,
      Output,
    } from '@angular/core';
    
    @Directive({
      selector: '[appClickedOutside]',
      standalone: true,
    })
    export class ClickedOutsideDirective {
      constructor(private el: ElementRef) {}
    
      @Output() public clickedOutside = new EventEmitter();
    
      @HostListener('document:click', ['$event'])
      public onClick(event: any) {
        if (!(this.el.nativeElement.contains(event.target) &&
              this.el.nativeElement.classList.contains('do-not-notice-this')) { // <-changed here
          this.clickedOutside.emit(true);
        }
      }
    
      @HostListener('document:keydown.escape', ['$event'])
      onEscapeKeydownHandler(event: KeyboardEvent) {
        this.clickedOutside.emit(true);
      }
    }