angular / components

Component infrastructure and Material Design components for Angular
https://material.angular.io
MIT License
24.33k stars 6.73k forks source link

Right-click context menu #5007

Open jelbourn opened 7 years ago

jelbourn commented 7 years ago

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.

dschnelldavis commented 7 years ago

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.

heyanctil commented 7 years ago

@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?

jraadt commented 7 years ago

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.

irowbin commented 7 years ago

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

abdel-ships-it commented 6 years ago

@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

ghost commented 6 years ago

@jelbourn any progress on this ?

DennisSmolek commented 6 years ago

@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...

MikeAgostino commented 6 years ago

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

jelbourn commented 6 years ago

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

sssalib42 commented 6 years ago

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.

simonbland commented 6 years ago

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)

hgndgn commented 6 years ago

@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

simonbland commented 6 years ago

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)

hgndgn commented 6 years ago

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!

codestitch commented 5 years ago

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!

TauanMatos commented 5 years ago

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 ?

irowbin commented 5 years ago

@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

TauanMatos commented 5 years ago

@irowbin Thx XD

lasfrancisco commented 5 years ago

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,

ghost commented 5 years ago

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

NetanelBasal commented 5 years ago

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

lasfrancisco commented 5 years ago

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

diosney commented 5 years ago

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

philip-firstorder commented 5 years ago

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

<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:

<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>
simonbland commented 5 years ago

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!

philip-firstorder commented 5 years ago

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

simonbland commented 5 years ago

@philip-firstorder Done, thanks!

camargo commented 4 years ago

@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.

simonbland commented 4 years ago

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:

kreinerjm commented 4 years ago

@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

SimonGAndrews commented 4 years ago

Thank you , @simonbland and @camargo, Very useful indeed. Also I can confirm from a quick test, that the technique works in the Angular Material Tree control, within and below the 'mat-tree-node' tags as per the 'mat-list-item' in your code example.

SimonGAndrews commented 4 years ago

ah …. @simonbland and @camargo, …. is it correct that this technique is relying on the HTML contextmenu attribute that is no longer supported on browsers ?? https://developer.mozilla.org/en-US/docs/web/html/global_attributes/contextmenu

Edit: Ok Looked into this more and I got this wrong. I now understand, this is Not correct. It Is the HTML attribute 'contextmenu' that is being made obsolete. And In the example code here;

                         (contextmenu)="onContextMenu($event, item)

(contextmenu) is the HTML oncontextmenu Event (not the attribute) and the key learning for a newbie like myself is: Angular not using the 'on' in event names has to be considered when looking up the mechanics of code examples like these.

So … Back to where I started... Thanks for the great solution for a context menu in Angular material.

reference: https://www.w3schools.com/jsref/event_oncontextmenu.asp

simonbland commented 4 years ago

@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

Hi @kreinerjm,

You are right. Thank you for pointing this out. I've quickly tried to workaround this issue, but didn't find a solution using the CDK. If someone finds a nice solution for this, I will update the code examples.

I've used this context menu implementation for an Electron application, where the browser context menu is disabled, so this problem don't appear.

simonbland commented 4 years ago

Thank you , @simonbland and @camargo, Very useful indeed. Also I can confirm from a quick test, that the technique works in the Angular Material Tree control, within and below the 'mat-tree-node' tags as per the 'mat-list-item' in your code example.

Hi @SimonGAndrews,

Good to know!

simonbland commented 4 years ago

ah …. @simonbland and @camargo, …. is it correct that this technique is relying on the HTML contextmenu attribute that is no longer supported on browsers ?? https://developer.mozilla.org/en-US/docs/web/html/global_attributes/contextmenu

Edit: Ok Looked into this more and I got this wrong. I now understand, this is Not correct. It Is the HTML attribute 'contextmenu' that is being made obsolete. And In the example code here;

                         (contextmenu)="onContextMenu($event, item)

(contextmenu) is the HTML oncontextmenu Event (not the attribute) and the key learning for a newbie like myself is: Angular not using the 'on' in event names has to be considered when looking up the mechanics of code examples like these.

So … Back to where I started... Thanks for the great solution for a context menu in Angular material.

reference: https://www.w3schools.com/jsref/event_oncontextmenu.asp

Thank for sharing your reasoning. It looks correct to me.

However, I would say that (contextmenu) is the equivalent for the HTML oncontextmenu Event. Angular does not necessarily implement it like that. In fact, I don't see DOM onevent handlers in Angular generated code.

Here is some more documentation on this topic, for those who are interested:

camargo commented 4 years ago

FWIW I created a version of this that does not require adding a contextMenuPosition or a ViewChild to the host component. It requires accessing a MatMenuTrigger private _element property. If anyone has a better way to access aMatMenuTrigger native element let me know.

export function onContextMenu(
  event: MouseEvent,
  trigger: MatMenuTrigger,
  data: any,
) {
  event.preventDefault();
  // @ts-ignore
  const triggerElement: HTMLElement = trigger._element.nativeElement;
  triggerElement.style.setProperty('left', `${event.clientX}px`);
  triggerElement.style.setProperty('position', 'fixed');
  triggerElement.style.setProperty('top', `${event.clientY}px`);
  triggerElement.style.setProperty('visibility', 'hidden');
  trigger.menuData = { data };
  trigger.menu.focusFirstItem('mouse');
  trigger.openMenu();
}
<button (click)="onContextMenu($event, contextMenuTrigger, {})">
  Open Context Menu
</button>

<div #contextMenuTrigger="matMenuTrigger" [matMenuTriggerFor]="contextMenu">
  <mat-menu #contextMenu="matMenu">
    <ng-template matMenuContent let-data="data">
      <button mat-menu-item>
        <mat-icon>delete_forever</mat-icon>
        <span>Delete</span>
      </button>
    </ng-template>
  </mat-menu>
</div>
mmalerba commented 2 years ago

Context menus are now supported by the CdkMenu (https://material.angular.io/cdk/menu/overview#context-menus). We should re-implement the MatMenu to be based on the CdkMenu and it will get this feature for free.

jneuhaus20 commented 2 years ago

@mmalerba CdkMenu doesn't support passing a context to the template like MatMenu does, fyi. I just ran into that trying to replace a discontinued context menu library, so I'm stuck with working around MatMenu's shortcomings in the meantime. Luckily I don't need positioning, just a different triggering event.

jelbourn commented 2 years ago

@jneuhaus20 could you open a feature request for cdk menu for this? That will make it easier to track (and mark as a good community contribution)

jneuhaus20 commented 2 years ago

Feature request made

Eugeny commented 1 year ago

This is actually now possible with FlexibleConnectedPositionStrategy.setOrigin: https://gist.github.com/Eugeny/8935314c874c9fd784c942ebcf0679f9 (relevant code in ngAfterContentInit)