Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Angular 2: Implement a custom context menu

Tags:

angular

I'm implementing an Angular 2 attribute directive to allow me to add a custom context menu to an element like this:

<p context-menu="myItems">Hello world</p>

That directive adds a mouse event handler to catch a right-click, and the idea is to then construct a context menu, add it to the DOM, and then destroy it when the user finishes with it.

I have a component that implements the context menu itself. I'd like to construct that component, call a method on it to set the item list, and then add it to the DOM.

It looks like I might be able to do this with AppViewManager.createHostViewInContainer. Is this an appropriate way to do this? And if so, is there a way to construct/get an ElementRef to document.body so that I can tell createHostViewInContainer to construct the component there? Obviously I don't want my menu to be clipped inside the element I'm adding the context menu to.

like image 559
Ben Dilts Avatar asked Jan 18 '16 18:01

Ben Dilts


1 Answers

Here is what I think is a good way to do it.
You need 1 service, 1 component and 1 directive.

Here is a plunker

Explanation:

The service ContextMenuService:

  • provides a subject of type {event:MouseEvent,obj:any[]} to be subscribed to by ContextMenuHolderComponent, and to receive values from ContextMenuDirective

Code:

import {Injectable} from 'angular2/core';
import {Subject} from 'rxjs/Rx';

@Injectable()
export class ContextMenuService{

    public show:Subject<{event:MouseEvent,obj:any[]}> = new Subject<{event:MouseEvent,obj:any[]}>();
}

And add it to the list of providers in bootstrap()

bootstrap(AppComponent,[ContextMenuService]);

The Component ContextMenuHolderComponent:

  • This component is added inside the root component. e.g. AppComponent and it has a fixed position.
  • It subscribes to the subject in ContextMenuService to receive:

    1. menu items of type {title:string,subject:Subject}[], the subject is used to send the clicked on value inside the menu
    2. MouseEvent object
  • It has a (document:click) event listener to close the menu on clicks outside the menu.

code:

@Component({
  selector:'context-menu-holder',
  styles:[
    '.container{width:150px;background-color:#eee}',
    '.link{}','.link:hover{background-color:#abc}',
    'ul{margin:0px;padding:0px;list-style-type: none}'
  ],
  host:{
    '(document:click)':'clickedOutside()'
  },
  template:
  `<div [ngStyle]="locationCss" class="container">
      <ul>
          <li (click)="link.subject.next(link.title)" class="link" *ngFor="#link of links">
              {{link.title}}
          </li>
      </ul>
    </div>
  `
})
class ContextMenuHolderComponent{
  links = [];
  isShown = false;
  private mouseLocation :{left:number,top:number} = {left:0;top:0};
  constructor(private _contextMenuService:ContextMenuService){
    _contextMenuService.show.subscribe(e => this.showMenu(e.event,e.obj));
  }
  // the css for the container div
  get locationCss(){
    return {
      'position':'fixed',
      'display':this.isShown ? 'block':'none',
      left:this.mouseLocation.left + 'px',
      top:this.mouseLocation.top + 'px',
    };
  }
  clickedOutside(){
    this.isShown= false; // hide the menu
  }

  // show the menu and set the location of the mouse
  showMenu(event,links){
    this.isShown = true;
    this.links = links;
    this.mouseLocation = {
      left:event.clientX,
      top:event.clientY
    }
  }
}

And add it to the root component:

@Component({
    selector: 'my-app',
    directives:[ContextMenuHolderComponent,ChildComponent],
    template: `
    <context-menu-holder></context-menu-holder>
    <div>Whatever contents</div>
    <child-component></child-component>
    `
})
export class AppComponent { }

The last one, ContextMenuDirective:

  • It adds a contextmenu event to the host element.
  • Accept an input of a list of items to be passed to ContextMenuHolderComponent.

Code:

@Directive({
  selector:'[context-menu]',
  host:{'(contextmenu)':'rightClicked($event)'}
})
class ContextMenuDirective{
  @Input('context-menu') links;
  constructor(private _contextMenuService:ContextMenuService){
  }
  rightClicked(event:MouseEvent){
    this._contextMenuService.show.next({event:event,obj:this.links});
    event.preventDefault(); // to prevent the browser contextmenu
  }
}

That's it. All you need to do now is attach the [context-menu] directive to an element and bind it to a list of items. For example:

@Component({
  selector:'child-component',
  directives:[ContextMenuDirective],
  template:`
  <div [context-menu]="links" >right click here ... {{firstRightClick}}</div>
  <div [context-menu]="anotherLinks">Also right click here...{{secondRightClick}}</div>
  `
})
class ChildComponent{
  firstRightClick; secondRightClick;
  links;
  anotherLinks;
  constructor(){
    this.links = [
      {title:'a',subject:new Subject()},
      {title:'b',subject:new Subject()},
      {title:'b',subject:new Subject()}
    ];
    this.anotherLinks = [
      {title:'link 1',subject:new Subject()},
      {title:'link 2',subject:new Subject()},
      {title:'link 3',subject:new Subject()}
    ];
  }

  // subscribe to subjects
  ngOnInit(){
    this.links.forEach(l => l.subject.subscribe(val=> this.firstCallback(val)));
    this.anotherLinks.forEach(l => l.subject.subscribe(val=> this.secondCallback(val)))
  }
  firstCallback(val){
    this.firstRightClick = val;
  }
  secondCallback(val){
    this.secondRightClick = val;
  }
}
like image 129
Abdulrahman Alsoghayer Avatar answered Oct 24 '22 17:10

Abdulrahman Alsoghayer