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