I'm working on an Application with a lot of dropdowns, I would like to be able to close the dropdown whenever a click happens outside of this one.
I found some good solutions, but none of them handle the case of having a ngFor in it, when I log the click event target in the ngFor, I get the element but this one doesn't have any parent. I can not detect it with 'find' or 'contains' neither.
Does someone have a solution to detect if this target is part of the dropdown ?
the directive
import {
Directive,
ElementRef,
EventEmitter,
Input,
OnInit,
Output,
SimpleChange
} from '#angular/core';
#Directive({selector: '[clickOutside]'})
export class ClickOutside implements OnInit {
#Output() clickOutside:EventEmitter<Event> = new EventEmitter<Event>();
constructor(private _el:ElementRef) {
this.onClickBody = this.onClickBody.bind(this);
}
ngOnInit() {
document.body.addEventListener('click', this.onClickBody);
}
private onClickBody(e:Event) {
if (!this.isClickInElement(e)) {
this.clickOutside.emit(e);
}
}
private isClickInElement(e:any):boolean {
var current = e.target;
do {
console.log(current);
if ( current === this._el.nativeElement ) {
return( true );
}
current = current.parentNode;
} while ( current );
return false;
}
}
Example of where I call the directive
<div (clickOutside)="onClickedOutside($event)">
<ul>
<li *ngFor="let item of itemsList" (click)="selectItem(item)">
<span class="item">
{{item.name}}
</span>
</li>
</ul>
</div>
When I click on item.name, console.log(current); returns me two lines
<span>Item</span>
<li>
<span>Item</span>
</li>
#Directive({selector: '[clickOutside]'})
export class ClickOutside implements OnInit {
#Output() clickOutside:EventEmitter<Event> = new EventEmitter<Event>();
constructor(private _eref: ElementRef) { }
#HostListener('window:click')
private onClickBody(e:Event) {
if (!this.isClickInElement(e)) {
this.clickOutside.emit(e);
}
}
private isClickInElement(e:any):boolean {
return this._eref.nativeElement.contains(event.target);
}
}
See also https://stackoverflow.com/a/35713421/217408
This solution works with Chrome but unfortunately not with IE. I'm still looking for another way to do it
private isClickInElement(e:any):boolean {
var current = e.target;
if(current == this._el.nativeElement) {
return true;
}
for(let parentKey in e.path) {
if(e.path[parentKey] == this._el.nativeElement) {
return true;
}
}
return false;
}
Related
How can I add an eventlistener on a <div> or other element, to hide something I am displaying via an *ngIf - using Angular, when I click away from that element?
Explanation: I am showing a custom CSS dropdown via *ngIf when you click on <label>Filter</label>, and I want the user to be able to click as many times as they wish in the custom dropdown, but when they click outside the custom dropdown, I would like to hide the custom dropdown via the *ngIf again.
The method called when a user clicks on the label is showHideSectionOptions(), which toggles the showHide variable to true or false.
This is my HTML code:
showHide = false;
<div class="form-row">
<div class="form-group" id="showAndHideSections">
<label (click)="showHideSectionOptions()">
<img src="../../../assets/icons/Filter.png" alt="" class="mr-3">Filter</label>
<div *ngIf="showHide" class="section-options">
// show or hide content
</div>
</div>
</div>
This is my component code:
showHideSectionOptions() {
this.showHide = !this.showHide;
}
I have tried adding an eventlistener as per the below, but I cannot set the value of my showHide variable, as I get the following error: Property 'showHide' does not exist on type 'HTMLElement'.ts(2339):
body.addEventListener('click', function() {
alert('wrapper');
}, false);
except.addEventListener('click', function(ev) {
alert('except');
ev.stopPropagation();
}, false);
Thanks in advance!
First of all, this already has an answer here
However, if you want an Angular solution, you can use a custom directive:
#Directive({
selector: '[clickOutside]'
})
export class ClickOutsideDirective {
#Output()
readonly clickOutside = new EventEmitter<MouseEvent>();
#Input()
include?: HTMLElement;
constructor(private el: ElementRef<HTMLElement>) {}
#HostListener('window:click', [ '$event' ])
onClick(event: MouseEvent): void {
if (this.isEventOutside(event)) {
this.clickOutside.emit(this.event);
}
}
private isEventOutside(event: MouseEvent): boolean {
const target = event.target as HTMLElement;
return !this.el.nativeElement.contains(target) &&
(!this.include || !this.include.contains(target))
}
}
Which you can use like this:
<div class="form-group" id="showAndHideSections">
<label (click)="showHideSectionOptions()" #label>
<img src="../../../assets/icons/Filter.png" alt="" class="mr-3">
Filter
</label>
<div *ngIf="showHide" class="section-options"
[include]="label" (clickOutside)="showHide = false">
// show or hide content
</div>
</div>
A more performant one would be one running outside of the ngZone. Because the subscribe happens outside of the directive it will be inside the ngZone when subscribing to the Output
#Directive({
selector: '[clickOutside]'
})
export class ClickOutsideDirective {
#Input()
include?: HTMLElement;
#Output()
readonly clickOutside = this.nz.runOutsideAngular(
() => fromEvent(window, 'click').pipe(
filter((event: MouseEvent) => this.isEventOutside(event))
)
);
constructor(private el: ElementRef<HTMLElement>, private nz: NgZone) {}
private isEventOutside(event: MouseEvent): boolean {
const target = event.target as HTMLElement;
return !this.el.nativeElement.contains(target) &&
(!this.include || !this.include.contains(target))
}
}
working stack
I've reduced this to its simplest form as below:
<select (change)="switch()" [hidden]="visible" [(ngModel)]="model">
<option *ngFor="let amount of [1,2,3]" [ngValue]="amount"> {{amount}} </option>
</select>
<div [hidden]="!visible">... we swap places</div>
export class SomeComponent {
model = 1;
visible = false;
switch() {
if (this.visible === 3) {
this.visible = true;
}
}
This seems to work fine, however it also throws: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: 'true'. Current value: 'false'.
How do I change it before it gets checked here?
You can handle this by explicitly triggering the change,
import { ChangeDetectorRef } from '#angular/core';
constructor(private cdr: ChangeDetectorRef) {}
switch() {
if (this.visible === 3) {
this.visible = true;
this.cdr.detectionChanges();
...
}
I'm writing Angular 2 application and inside it I have dropdown menu written on Bootstrap
<li class="dropdown" dropdown>
<a class="dropdown-toggle" data-toggle="dropdown">
User <span class="caret"></span>
</a>
<ul class="dropdown-menu" aria-labelledby="download">
<li><a routerLink="/user/profile">My Profile</a></li>
<li><a (click)="logout()">Log Out</a></li>
</ul>
</li>
All what I want is to write down a small directive for toggling menu. End here is it:
#Directive({
selector: "[dropdown]"
})
export class DropdownDirective implements OnInit {
private isOpen = false;
private defaultClassName: string;
#HostListener('click') toggle() {
let that = this;
if (!this.isOpen) {
this.elRef.nativeElement.className = this.defaultClassName + " open";
document.addEventListener("click", () => {
that.elRef.nativeElement.className = that.defaultClassName;
that.isOpen = false;
document.removeEventListener("click");
});
this.isOpen = !this.isOpen;
}
}
constructor(private elRef: ElementRef) {
}
ngOnInit(): void {
this.defaultClassName = this.elRef.nativeElement.className;
}
}
Looks good. But doesn't work. After short debug I found that event listener, which was added to the document, fires just after it has been assigned.
document.addEventListener("click", () => {
that.elRef.nativeElement.className = that.defaultClassName;
that.isOpen = false;
document.removeEventListener("click");
});
As a fact menu closing just after it has been opened. How to fix it and why this happening?
I've solved this same situation with a #HostListener(). On the component holding the dropdown:
#HostListener('document:click', ['$event'])
private clickAnywhere(event: MouseEvent): void {
if (this.IsSelected && !this.elementRef.nativeElement.contains(event.target)) {
this.IsSelected = false;
}
}
this.IsSelected is the binding property I use to show the dropdown.
The condition in the if() is checking whether the user has clicked on the menu or the document body in general.
Make sure to inject elementRef into the constructor so you can access the rendered HTML to check if that is what was clicked:
public constructor(private elementRef: ElementRef) { }
You can find out more about HostListener here.
So I am working on this app in which I have used ViewContainerRef along with dynamicComponentLoader like below:
generic.component.ts
export class GenericComponent implements OnInit, OnChanges{
#ViewChild('target', { read: ViewContainerRef }) target;
#Input('input-model') inputModel: any = {};
constructor(private dcl: DynamicComponentLoader) { }
ngAfterViewInit() {
this.dcl.loadNextToLocation(DemoComponent,this.target)
.then(ref => {
if (this.inputModel[this.objAttr] === undefined) {
ref.instance.inputModel = this.inputModel;
} else {
ref.instance.inputModel[this.objAttr] = this.inputModel[this.objAttr];
}
});
console.log('Generic Component :===== DemoComponent===== Loaded');
}
ngOnChanges(changes) {
console.log('ngOnChanges - propertyName = ' + JSON.stringify(changes['inputModel'].currentValue));
this.inputModel=changes['inputModel'].currentValue;
}
}
generic.html
<div #target></div>
So It renders the DemoComponentin target element correctly.
but when I am changing the inputModel then I want to reset the view of target element.
I tried onOnChanges to reset the inputModel , its getting changed correctly but the view is not getting updated for respective change.
So I want to know if is it possible to reset the view inside ngOnChanges after the inputModel is updated?
any inputs?
There is no connection between this.inputModel and ref.instance.inputModel. If one changes you need to copy it again.
For example like:
export class GenericComponent implements OnInit, OnChanges{
componentRef:ComponentRef;
#ViewChild('target', { read: ViewContainerRef }) target;
#Input('input-model') inputModel: any = {};
constructor(private dcl: DynamicComponentLoader) { }
ngAfterViewInit() {
this.dcl.loadNextToLocation(DemoComponent,this.target)
.then(ref => {
this.componentRef = ref;
this.updateModel();
});
console.log('Generic Component :===== DemoComponent===== Loaded');
}
updateModel() {
if(!this.componentRef) return;
if (this.inputModel[this.objAttr] === undefined) {
this.componentRef.instance.inputModel = this.inputModel;
} else {
this.componentRef.instance.inputModel[this.objAttr] = this.inputModel[this.objAttr];
}
}
ngOnChanges(changes) {
console.log('ngOnChanges - propertyName = ' + JSON.stringify(changes['inputModel'].currentValue));
this.inputModel=changes['inputModel'].currentValue;
this.updateModel();
}
}
I have Component which has a member array variable. This array is bind to DOM with *ngFor. When I add new variable to array my view changes accordingly. Array holds tab names and initially it is set to have only 1 tab. When I refresh page array reinitialized which is what I was expecting. But when I logout and then log back in(router navigation) I see all previous tabs. It is weird to me, because if I console.log(myTabs) array has only 1 element(homeTab).
UPDATE:
.html
<div style="display: table-caption" id="notify-tabs">
<ul class="nav nav-tabs" role="tablist" id="nav-bar">
<li role="presentation" data-toggle="tab" id="homeTab" [class.active]="activeTab==='homeTab'"><a (click)="setValues('home')">Home</a>
<li role="presentation" *ngFor="let tab of myTabs" data-toggle="tab" id={{tab}} [class.active]="activeTab===tab.toString()"><a (click)="setValues(tab)">{{tab}}</a>
</ul>
</div>
.component.ts
#Component({
selector: 'notify-homepage',
templateUrl: 'app/home/home.component.html',
styleUrls: ['styles/css/bootstrap.min.css', 'styles/home.css'],
directives: [DynamicComponent, TileComponent, MapComponent, HeaderComponent, ConversationComponent, ROUTER_DIRECTIVES]
})
export class HomeComponent{
public myTabs: number[] = [21442];
public activeTab: string = 'homeTab';
ngOnInit() {
//Assume fully operating MapService here
this.subscription = this.mapService.conversationId.subscribe(
(id: number) => {
this.myTabs.push(id);
this.setValues(id);
this.activeTab = id.toString();
})
}
ngOnDestroy() {
this.subscription.unsubscribe();
...
}
}
map.service.ts
#Injectable()
export class MapService {
private conversationIdSource = new ReplaySubject<number>();
public conversationId = this.conversationIdSource.asObservable();
...
showConversation(id: number) {
this.conversationIdSource.next(id);
}
}
The answer of #Andrei works, but in my opinion there's a better and more elegant solution.
Just use a combination of #ViewChild() and setters.
For example:
// component.html
<ng-el ... #myElement>
// component.ts
#ViewChild('myElement') set(el) {
if (el) {
console.log('element loaded!');
}
}
Check Lifecycle hooks:
OnChanges https://angular.io/docs/ts/latest/guide/lifecycle-hooks.html#!#onchanges
DoCheck https://angular.io/docs/ts/latest/guide/lifecycle-hooks.html#!#docheck
They help tracking changing in Input and local variables.
OnChanges for Input variables:
ngOnChanges(changes: {[propertyName: string]: SimpleChange}) {
for (let propName in changes) {
let chng = changes[propName];
let cur = JSON.stringify(chng.currentValue);
let prev = JSON.stringify(chng.previousValue);
this.changeLog.push(`${propName}: currentValue = ${cur}, previousValue = ${prev}`);
}
}
DoCheck for everything:
ngDoCheck() {
if (this.hero.name !== this.oldHeroName) {
this.changeDetected = true;
this.changeLog.push(`DoCheck: Hero name changed to "${this.hero.name}" from "${this.oldHeroName}"`);
this.oldHeroName = this.hero.name;
}
}