简体   繁体   中英

Angular Click Outside Directive issue with an extra click event

I have implemented a custom Click Outside Directive which is meant to be used to close modal dialogs, notifications, popovers, etc (let's call them 'popups' for simplicity). However, I'm struggling to implement a generic solution for both popups opened via mouse click (ie button click) and popups opened by some other action (ie a mouse over).

Here is a Plunker example of the issue I'm facing: the popup produced by the mouseover event needs two clicks to close, while the same popup produced by the 'Click Me' button only needs a single click to be dismissed.

Here is the code for the directive:

@Directive({
  selector: '[clickOutside]'
})
export class ClickOutsideDirective {
  @Output('clickOutside') clickOutsideEvent = new EventEmitter();

  private globalClick: Subscription;

  constructor(private elRef: ElementRef) { }

  ngOnInit() {
    this.globalClick = Observable
      .fromEvent(document, 'click')
      .skip(1)
      .subscribe((event: MouseEvent) => {
        this.clicked(event);
      });
  }

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

  private clicked(event: MouseEvent) {
    let clickedInside = this.elRef.nativeElement.contains(event.target);

    if (!clickedInside) {
      this.clickOutsideEvent.emit();
    }
  }
}

Inside ngOnInit() I initialise an observable that listens for the clicks on the document and passes the events to clicked() function, which will check if the click was made outside and raise an event if so. The observable uses a skip operator to filter the first click event. This is a workaround because if you click the 'Click Me' button, that original click event is the one I want to skip as it is not useful to me inside the directive. If I don't skip it, the popup will close itself, as it will think that there was a click outside. However, this workaround has a side effect that two clicks are now needed to close a popup which was triggered some other way, other then by a click (the Mouse Over example in Plunker demonstrates this issue).

I'm struggling to think of an elegant and generic solution to solve this problem. I can think of multiple 'hacky' solutions:

Hacky solution 1: I could stop the event propagation at the 'Click Me' button, meaning that the ClickOutsideDirective will not receive the initial click event and I can remove the .skip operator from the document click observable. That should theoretically work (have not tried this) but what I don't like is that the consumer of the directive now needs to know that they need to stop the event propagation, and if they don't the directive will not work as designed.

Hacky solution 2: Pass a boolean flag to the directive which can be used to determine if the first click needs to be skipped or not. Very easy to do, but again, the user of the API now needs to know that they need to set this flag. If they forget, the directive will not work as expected.

Hacky solution 3: Skip the first event that occurs a short period of time (let's say 100ms) after the directive is initialised. This is also easy to do and the advantage of this solution is that the user of the API does not need to provide any additional information to the directive (or stop any event propagation) and the directive will 'just work'. The problem here is to figure out what delay to use that will work across all browsers and hardware. The delay needs to be no smaller then the time necessary for the event to arrive after the directive was instantiated, but not big enough to skip a legitimate user clicks if the user decides to click away as soon as the popup is displayed.

Can anyone think of a better, more simple and elegant solution to this problem?

Instead of keeping the logic in a separate directive, why not keep it in the popup component, seeing as that is what it is responsible for closing.

Change the component, so that there is a fixed position div with a click listener, positioned behind the popup. Then, whenever it is clicked, send the clickOutside event.

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

@Component({
  selector: 'popup',
  styles: [`
      .background {
    z-index: 1;
    position: fixed;
    top: 0;
    bottom: 0;
    left: 0;
    right: 0;
    backgrouc
      }

      .modal-content {
    left: 0;
    right: 0;
    z-index: 3;
      }
  `],
  template: `
    <div class="background" (click)="close()">
      <div class="modal-content">
    Click outside to close me!
      </div>
    </div>
  `,
})
export class PopupComponent {

  @Output()
  clickOutside = new EventEmitter();

  close() {
    this.clickOutside.emit();
  }
}

Here is a working plunkr: https://plnkr.co/edit/9QBJ0PXOg2GAUpBacgQV?p=preview

So I have finally found an answer to this. The solution is not as clean as I was hoping, but basically I have added a timer observable (with a zero value) that will trigger the .fromEvent(document, 'click') observable. I have tested this in all modern browsers (including iPad and Android Chrome) and it seem to work fine everywhere.

ngOnInit() {
   this.globalClick = Observable
      .timer(0)
      .switchMap(() => {
          return Observable.fromEvent(this.document, 'click');
      })
      .subscribe((event: MouseEvent) => {
        this.clicked(event);
      });
}

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM