12

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.

3
  • Does it really matter where you insert the component? You can just position it absolute. I would add a context menu component to the AppComponent and send instructions about what it should display using a shared global service. Hete is an example where HTML is added dynamically to the body but not components AFAIK github.com/angular/angular/blob/master/modules/… Commented Jan 18, 2016 at 18:39
  • 2
    It matters if the parent has overflow:hidden, which many do. Commented Jan 18, 2016 at 18:46
  • 1
    I've actually wound up using position:fixed, which seems to ignore its parent's overflow:hidden. It feels a little dirty, but it does get the job done. Commented Jan 19, 2016 at 18:27

1 Answer 1

27

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;
  }
}
Sign up to request clarification or add additional context in comments.

9 Comments

Can you please explain what = does in here public show:Subject<{event:MouseEvent,obj:any[]}> = new Subject();. I cannot find information about this notation. tsc complains about Type 'Subject<{}>' is not assignable to type 'Subject...
@Zhytkevich This is just assigning a new subject to show. The complaint is because I haven't specified the subject type. You can get rid of it by doing public show:Subject<{event:MouseEvent,obj:any[]}> = new Subject<{event:MouseEvent,obj:any[]}>();. Or simply by making the subject of type any like public show:Subject<any> = new Subject<any>();
hi @Abdulrahman, I tried the app but I am stuck at context-menu.service.ts on Injectable annotation, this compilation error is comming Unable to resolve signature of class decorator when called as an expression. Supplied parameters do not match any signature of call target.
@rish it seems like you are passing parameters into the @Injectable() decorator. While @Injectable() doesn't accept any parameters.
@Abdulrahman thnx for response, I tried but when I am executing in console there are no error showing but in browser nothing is displaying.
|

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.