components icon indicating copy to clipboard operation
components copied to clipboard

Right-click context menu

Open jelbourn opened this issue 7 years ago • 40 comments

This would effectively be md-menu but triggered by right-click instead of a specific element on the page.

Would need some investigation for a11y.

jelbourn avatar Jun 07 '17 16:06 jelbourn

As a temporary workaround, until material2 adds this feature, it's currently possible to simulate a context menu by putting a hidden menu trigger next to the item you want to right-click, like so:

import { Component, ViewChild } from '@angular/core';
import { MdMenuTrigger } from '@angular/material';

@Component({
  selector: 'contextmenu-example',
  template: `
    <span [mdMenuTriggerFor]="contextMenu"></span>
    <button md-button (contextmenu)="openContextMenu($event)">Context Menu</button>
    <md-menu #contextMenu="mdMenu">
      <button md-menu-item>Item 1</button>
      <button md-menu-item>Item 2</button>
    </md-menu>
  `,
})
export class ContextMenuExample {
  @ViewChild(MdMenuTrigger) contextMenu: MdMenuTrigger;

  openContextMenu(event) {
    event.preventDefault(); // Suppress the browser's context menu
    this.contextMenu.openMenu(); // Open your custom context menu instead
  }
}

This workaround is functional, but not perfect—so I'm looking forward to when material2 adds built-in support for context menus.

dschnelldavis avatar Jul 16 '17 23:07 dschnelldavis

@dschnelldavis we did something similar to use mdMenu as a contextual menu. But have you encountered problems with the overlay backdrop? I explain my case. We have a map with different markers and we show mdMenu on right clicking these markers. But, between each right click, if you don't close the menu, the backdrop intercept the right click and display the browser contextual menu instead. Have you manage this situation?

heyanctil avatar Aug 18 '17 15:08 heyanctil

It would be great if it could support dynamic menus with a variable number of submenus, like what was suggested in https://github.com/angular/material2/issues/4995.

Different elements I right click on may produce slightly different menu options and submenus. I'm not sure how to create dynamic submenus since I think I would need dynamic template reference variables on those submenus.

jraadt avatar Sep 02 '17 14:09 jraadt

I've created a temporary contextmenu of mdMenu with small css changes. This is just a temp until material release. I am having hard time when each element have different logic. Somehow i made it work with all component by creating re-usable module and all the items of mdMenu is dynamically created.

For instance.

capture

irowbin avatar Sep 03 '17 02:09 irowbin

@jelbourn Would it be acceptable to have the menu items become navigable through keyboard arrow keys? That's how context menus work in chrome macOS, or would we rather have it work with the tab key.

I am currently making a context menu by utilizing the Overlay package in the cdk

abdel-ships-it avatar Oct 26 '17 11:10 abdel-ships-it

@jelbourn any progress on this ?

ghost avatar Jan 18 '18 15:01 ghost

@heyanctil Until they expose the overlay I added a littlebit of a hack that has been working well:

this.trigger.openMenu();
document.getElementsByClassName('cdk-overlay-backdrop')[0].addEventListener('contextmenu', (offEvent: any) => {
    console.log('Context menu triggered!');
     offEvent.preventDefault();
    this.trigger.closeMenu();
});

The CDK destroys the element when it's closed which destroys the listener...

DennisSmolek avatar Feb 04 '18 22:02 DennisSmolek

Hey @jelbourn, has there been any progress on integrating this into Angular Material?

MikeAgostino avatar Jun 12 '18 19:06 MikeAgostino

Nope- context menu isn't super high on our priority list

jelbourn avatar Jun 12 '18 20:06 jelbourn

Ideally, when a context menu is up, and the user right-clicks on a different element which has a context menu, the expected behavior is that the first context menu will be closed and the second context menu, triggered by the right-click, be opened. Please consider.

sssalib42 avatar Jul 25 '18 14:07 sssalib42

I needed this too.

Here is how I've implemented it, inspired by the solution of @dschnelldavis. I've added precise positioning of the context menu and reference to the contextual data:

<mat-list>
  <mat-list-item *ngFor="let item of items" (contextmenu)="onContextMenu($event, item)">
    {{ item.name }}
  </mat-list-item>
</mat-list>
<div style="visibility: hidden; position: fixed"
    [style.left]="contextMenuPosition.x"
    [style.top]="contextMenuPosition.y"
    [matMenuTriggerFor]="contextMenu">
</div>
<mat-menu #contextMenu="matMenu">
  <ng-template matMenuContent let-item="item">
    <button mat-menu-item (click)="onContextMenuAction1(item)">Action 1</button>
    <button mat-menu-item (click)="onContextMenuAction2(item)">Action 2</button>
  </ng-template>
</mat-menu>
import { Component, ViewChild } from '@angular/core';
import { MatMenuTrigger } from '@angular/material';

@Component({
  selector: 'context-menu-example',
  templateUrl: 'context-menu-example.html'
})
export class ContextMenuExample {

  items = [
    {id: 1, name: 'Item 1'},
    {id: 2, name: 'Item 2'},
    {id: 3, name: 'Item 3'}
  ];

  @ViewChild(MatMenuTrigger)
  contextMenu: MatMenuTrigger;

  contextMenuPosition = { x: '0px', y: '0px' };

  onContextMenu(event: MouseEvent, item: Item) {
    event.preventDefault();
    this.contextMenuPosition.x = event.clientX + 'px';
    this.contextMenuPosition.y = event.clientY + 'px';
    this.contextMenu.menuData = { 'item': item };
    this.contextMenu.menu.focusFirstItem('mouse');
    this.contextMenu.openMenu();
  }

  onContextMenuAction1(item: Item) {
    alert(`Click on Action 1 for ${item.name}`);
  }

  onContextMenuAction2(item: Item) {
    alert(`Click on Action 2 for ${item.name}`);
  }
}

export interface Item {
  id: number;
  name: string;
}

Here is a working example on StackBlitz.

(code edited based on https://github.com/angular/components/issues/5007#issuecomment-508992078 and https://github.com/angular/components/issues/5007#issuecomment-554124365)

simonbland avatar Aug 29 '18 08:08 simonbland

@simonbland Do you know why your solution does not work properly with a material-table ? I can not manipulate the x/y position of the context menu. It shows up only on left top or right top of the table element. If you could help, it would be nice

hgndgn avatar Sep 09 '18 20:09 hgndgn

Hi @hgndgn,

Here is another working example, but with a table instead of list, also on StackBlitz.

This is the same implementation, except that the table was replaced with a list and this is working fine.

(code edited based on https://github.com/angular/components/issues/5007#issuecomment-508992078 and https://github.com/angular/components/issues/5007#issuecomment-554124365)

simonbland avatar Sep 14 '18 13:09 simonbland

Thank you @simonbland it works now. I had before this part

<td>
   <div style="position: absolute"
          [style.left]="contextMenuPosition.x"
          [style.top]="contextMenuPosition.y"
          [matMenuTriggerFor]="contextMenu"
          [matMenuTriggerData]="{item: item}">
   </div>
 </td>

inside the last <tr> tag (displayedColumns) of the table. But now, it does not matter in which column I insert this, it works correct.

Thank you again!

hgndgn avatar Sep 14 '18 16:09 hgndgn

I've created a temporary contextmenu of mdMenu with small css changes. This is just a temp until material release. I am having hard time when each element have different logic. Somehow i made it work with all component by creating re-usable module and all the items of mdMenu is dynamically created.

For instance.

capture

@irowbin do you have a stackblitz example of this? This is really great!

codestitch avatar Nov 16 '18 11:11 codestitch

I've created a temporary contextmenu of mdMenu with small css changes. This is just a temp until material release. I am having hard time when each element have different logic. Somehow i made it work with all component by creating re-usable module and all the items of mdMenu is dynamically created.

For instance.

capture

@irowbin This is awesome! Can you share it on stackblitz ?

TauanMatos avatar Nov 20 '18 13:11 TauanMatos

@codestitch @TauanMatos sorry that the source code from the image above is not available at the moment.😢 To popup the context-menu you write few css rules for the mat-menu, few js code to adjust position dynamically based on the event target wrapper and that's it.😉 I did the same thing

Take a look at these links to get an idea which is written in vanilla js. Not the Angular or Material Design. Its is easy to implement as needed on angular.

codepen link 1 & codepen link 2

irowbin avatar Nov 21 '18 02:11 irowbin

@irowbin Thx XD

TauanMatos avatar Nov 21 '18 12:11 TauanMatos

Hi there, I came across the need of implementing a contextual menu with angular material today and the simplest solution I could figure out has been a component extending the MatMenuTrigger directive as per the following:

@Component({
  selector: 'wm-context-menu',
  template: '<ng-content></ng-content>',
  styles: ['']
})
export class ContextMenuComponent extends MatMenuTrigger {

  @HostBinding('style.position') private position = 'fixed';
  @HostBinding('style.left') private x: string;
  @HostBinding('style.top') private y: string;

  // Intercepts the global context menu event
  @HostListener('document:contextmenu', ['$event']) menuContext(ev: MouseEvent) {

    // Closes the menu when already opened
    if(this.menuOpen) {
      this.closeMenu();
    }
    else {

      // Adjust the menu anchor position
      this.x = ev.clientX + 'px';
      this.y = ev.clientY + 'px';

      // Opens the menu
      this.openMenu();
    }
    // prevents default
    return false;
  }
}

There's a working demo on stackblitz here: https://stackblitz.com/edit/wizdm-contextmenu

Hope this helps, Cheers,

lasfrancisco avatar Jan 16 '19 15:01 lasfrancisco

@s2-abdo can you give us an example about how to use the new implementation? Thanks!

ghost avatar Jan 29 '19 12:01 ghost

@eusaro https://netbasal.com/context-menus-made-easy-with-angular-cdk-963797e679fc

NetanelBasal avatar Jan 29 '19 13:01 NetanelBasal

Amanzing. Can't wait to see matMenuTrigger taking advantage from it.

lasfrancisco avatar Jan 30 '19 09:01 lasfrancisco

@wizdmio Thanks! Your solution works as a charm aside to be very clean.

diosney avatar Mar 12 '19 23:03 diosney

@simonbland Why put the trigger inside *ngFor and have it duplicated?

  • Since it's position: fixed it will just create a multitude of empty divs at the top-left page corner at start-up.
  • It triggers a more intensive angular rendering as all of them will have to update the same contextMenuPosition values
  • It might interfere with other css styling in mat-list-item
<mat-list>
  <mat-list-item *ngFor="let item of items" (contextmenu)="onContextMenu($event, item)">
    {{ item.name }}
    <div style="position: fixed"
        [style.left]="contextMenuPosition.x"
        [style.top]="contextMenuPosition.y"
        [matMenuTriggerFor]="contextMenu"
        [matMenuTriggerData]="{item: item}">
    </div>
  </mat-list-item>
</mat-list>
<mat-menu #contextMenu="matMenu">
  <ng-template matMenuContent let-item="item">
    <button mat-menu-item (click)="onContextMenuAction1(item)">Action 1</button>
    <button mat-menu-item (click)="onContextMenuAction2(item)">Action 2</button>
  </ng-template>
</mat-menu>

Putting it only once like this accomplishes the same result in a more efficient way:

  • I put an extra visibility: hidden to make sure it doesn't render;
  • I removed [matMenuTriggerData] since you set it dynamically in .ts anyways.
<mat-list>
  <mat-list-item *ngFor="let item of items" (contextmenu)="onContextMenu($event, item)">
    {{ item.name }}
  </mat-list-item>
</mat-list>

<div style="visibility: hidden; position: fixed;"
    [style.left]="contextMenuPosition.x"
    [style.top]="contextMenuPosition.y"
    [matMenuTriggerFor]="contextMenu">
</div>
<mat-menu #contextMenu="matMenu">
  <ng-template matMenuContent let-item="item">
    <button mat-menu-item (click)="onContextMenuAction1(item)">Action 1</button>
    <button mat-menu-item (click)="onContextMenuAction2(item)">Action 2</button>
  </ng-template>
</mat-menu>

philip-firstorder avatar Jul 07 '19 11:07 philip-firstorder

Thank you @philip-firstorder!

I agree with all your points.

I remember I was not totally happy with putting the trigger inside *ngFor, but simply didn't realise at the time I wrote this code that [matMenuTriggerData] was not necessary and hence the trigger could be moved outside the loop.

I've update the original example on StackBlitz with your enhancement.

Cheers!

simonbland avatar Jul 09 '19 09:07 simonbland

@simonbland Very nice, you could also change the code in your original comment, so it matches the stackblitz

philip-firstorder avatar Jul 09 '19 12:07 philip-firstorder

@philip-firstorder Done, thanks!

simonbland avatar Jul 17 '19 09:07 simonbland

@simonbland Great and simple solution, thanks for posting it. One small issue I'm seeing is that when the mat-menu contextmenu opens, the first mat-menu-item is always highlighted. Do you or anyone here know whats going on with that?

EDIT: I found a solution. I had to update onContextMenu as follows:

  onContextMenu(event: MouseEvent, item: Item) {
    event.preventDefault();
    this.contextMenuPosition.x = event.clientX + 'px';
    this.contextMenuPosition.y = event.clientY + 'px';
    this.contextMenu.menuData = { item };
    this.contextMenu._openedBy = 'mouse';
    this.contextMenu.openMenu();
  }

You need to tell the context menu trigger that it's opened by a mouse or it highlights the first item for keyboard selection (defaults to 'program' instead of 'mouse').

Note you could also create a ViewChild to the context menu itself, and call focusFirstItem('mouse'); on it if you don't want to overwrite the _openedBy private variable.

camargo avatar Nov 14 '19 23:11 camargo

Hi @camargo,

Thank you for the improvement and for the explanations why the first item is highlighted :+1:

To fix this, I've found that we can merge the two alternative solutions you proposed, and instead of:

this.contextMenu._openedBy = 'mouse';

We can write this:

this.contextMenu.menu.focusFirstItem('mouse');

This doesn't involve calling the private _openedBy field, and also doesn't requires that we create a new ViewChild to the context menu itself.

I've updated the examples on StackBlitz:

  • https://stackblitz.com/edit/angular-material-context-menu
  • https://stackblitz.com/edit/angular-material-context-menu-table

simonbland avatar Nov 24 '19 18:11 simonbland

@simonbland Is this sort of thing possible with your implementation? In your examples right clicking anywhere while a context menu is open shows the browser context menu.

Ideally, when a context menu is up, and the user right-clicks on a different element which has a context menu, the expected behavior is that the first context menu will be closed and the second context menu, triggered by the right-click, be opened. Please consider.

I found an example that has this functionality, but it doesn't use the material context menu (I would prefer material over cdk).

https://stackblitz.com/edit/angular-yd6ay3

kreinerjm avatar Dec 05 '19 16:12 kreinerjm