1

I'm fairly new to Angular 2 and am building an app which generates multiple instances of the same child component within the parent host component. Clicking on one of these components puts it into edit mode (display form inputs) and clicking outside of the active edit component should replace the edit component with the default read-only version.

@Component({
  selector: 'my-app',
  template: `
    <div *ngFor="let line of lines">

    <ng-container *ngIf="!line.edit">
      <read-only 
        [lineData]="line"
      ></read-only>
    </ng-container>

    <ng-container *ngIf="line.edit">
      <edit 
        [lineData]="line"
      ></edit>
    </ng-container>    
    </div>
  `,
})

export class App {
  name:string;
  lines:any[];

  constructor() {
    this.name = 'Angular2';
    this.lines = [{name:'apple'},{name:'pear'},{name'banana'}];
  }
}

An item is placed into edit mode by a (click) handler on the read-only component and similarly switched out of edit mode by a handler attached to a (clickOutside) event defined in a custom directive.

Read-only component

@Component({
  selector: 'read-only',
  template: `
    <div 
      (click)="setEditable()"
    >
     {{lineData.name}}
    </div>
  `,
   inputs['lineData']
})

export class ReadOnly {

 lineData:any;
 constructor(){

 }  

 setEditable(){
  this.lineData.edit=true;
 }
}

Edit component

@Component({
  selector: 'edit',
  template: `
    <div style="
      background-color:#cccc00;
      border-width:medium;
      border-color:#6677ff;
      border-style:solid"

      (clickOutside)="releaseEdit()"
      >
    {{lineData.name}}
  `,
  inputs['lineData']
})
export class Edit {

 lineData:any;
 constructor(){

 } 

 releaseEdit(){
   console.log('Releasing edit mode for '+this.lineData.name);
   delete this.lineData.edit;
 }
}

The problem is that the click event that switches to edit mode is also picked up by the clickOutside handler. Internally the clickOutside handler is triggered by a click event and tests whether the nativeElement of the edit component contains the click target - if not it emits a clickOutside event.

ClickOutside Directive

@Directive({
    selector: '[clickOutside]'
})
export class ClickOutsideDirective {

    ready: boolean;


    constructor(private _elementRef: ElementRef, private renderer: Renderer) {
    }

    @Output()
    public clickOutside = new EventEmitter<MouseEvent>();

    @HostListener('document:click', ['$event', '$event.target'])


    public onClick(event: MouseEvent, targetElement: HTMLElement): void {
        if (!targetElement) {
            return;
        }

        const clickedInside = this._elementRef.nativeElement.contains(targetElement);

        if (!clickedInside) {
            this.clickOutside.emit(event);
        }
    }
}

I've tried dynamically binding the (clickOutside) event to the edit component within ngAfterContentInit() and ngAfterContentChecked() lifecycle hooks and using Renderer listen() as detailed here but without success. I noticed that native events could be bound this way but I couldn't bind to an output of a custom directive.

I've attached a Plunk here demonstrating the issue. Clicking on the elements with console up demonstrates how the clickOutside event is fired (last) off the same click event that created the edit component - immediately reverting the component to read-only mode.

What's the cleanest way to handle this situation? Ideally I could dynamically bind the clickOutside event but haven't found a successful way to do so.

Community
  • 1
  • 1

2 Answers2

0

Partial solution (DOM dependent)

Excuse the reply to own post but hoping this will be useful for someone struggling in a similar way.

The main problem with the code above is that the setting of clickedInside inside the directive defining (clickOutside) always tests false.

const clickedInside = this._elementRef.nativeElement.contains(targetElement);

The reasons is that this._elementRef.nativeElement references the DOM element of the edit component while targetElement references the DOM element originally clicked on (i.e. the read-only component) so the condition always tests false, firing the clickOutside event.

A workaround which works with the simple DOM elements used in this case is to test for string equality on the trimmed HTML inside each of these properties instead.

const name = this.elementRef.nativeElement.children[0].innerHTML.trim();
const clickedInside = name == $event.target.innerHTML.trim();

The second problem is that the event handler as defined will persist and cause issues so we'll alter the click handler in the ClickOutsideDirective directive so its created dynamically in the constructor.

export class ClickOutsideDirective {
@Output() public clickOutside = new EventEmitter<MouseEvent>()

globalListenFunc:Function; 

 constructor(private elementRef:ElementRef, private renderer:Renderer){

  // assign ref to event handler destroyer method returned
  // by listenGlobal() method here 
  this.globalListenFunc = renderer.listenGlobal('document','click',($event)=>{

    const name = this.elementRef.nativeElement.innerHTML.trim();
    const clickedInside = name == $event.target.innerHTML.trim();

    if(!clickedInside){
        this.clickOutside.emit(event);
        this.globalListenFunction(); //destroy event handler
    }
  });

 } 

}

TODO

This works but its brittle and DOM dependent. It'd be good to find a solution that is DOM independent - and perhaps uses @View.. or @Content inputs. Ideally it would also provide a way of defining the clickOutside test condition to apply so the ClickOutsideDirective is truly generic and modular.

Any ideas?

0

Better solution - DOM Independent

A further improved solution to the problem. Key improvements over the previous solution:

  • Does not do DOM element querying
  • Cleaner handling of initial click event (if host component was instantiated from a click)
  • Clean-up of dynamic event handlers moved to ngOnDestroy()
  • Passes the click event object with the (clickOutside) event for better conditional handling of the (clickOutside) event by its listener.

The modification dynamically adds an additional (click) event handler to the directive which captures an event object for the host component (rather than the global document scope). This is compared with the event object generated by the listenGlobal() method and, if both are equivalent, assumes a click inside. If not, the (clickOutside) event is emitted and the click event object passed.

In order to prevent the directive from receiving and saving any click event object which may be in scope at the time the directive is initialised (if the host using the directive was instatiated by a mouse click for example) on first invocation we:

  • set a "ready" flag in the listenGlobal() handler
  • dynamically register the click handler we use to capture the local event object we use for comparison when testing for a clickInside.

Full updated code for clickOutside() directive

@Directive({
    selector: '[clickOutside]'
})
export class ClickOutsideDirective {
@Output() public clickOutside = new EventEmitter<MouseEvent>()

localevent:any;
lineData:any;
globalListenFunc:Function;
localListenFunc:Function;
ready:boolean;

 constructor(private elementRef:ElementRef, private renderer:Renderer){
      this._initClickOutside();
 }  


_initHandlers(){

  this.localListenFunc = this.renderer.listen(this.elementRef.nativeElement,'click',($event)=>{ 
    this.localevent = $event;

  });

}

_initClickOutside(){
  this.globalListenFunc = this.renderer.listenGlobal('document','click',($event)=>{

    if(!this.ready){
      this.ready = true;
      return;
    }

  if(!this.localListenFunc) this._initHandlers();

    const clickedInside = ($event == this.localevent);

    if(!clickedInside){
        this.clickOutside.emit($event);
        delete this.localevent;
    }
  });
}

ngOnDestroy() {
    // remove event handlers to prevent memory leaks etc.
    this.globalListenFunc();
    this.localListenFunc();
}

}

To use, attach the directive to your host component's template with a handler and event parameter like so:

(clickOutside) = "doSomething($event)"

Updated working Plunk example here