GGizmos
GGizmos

Reputation: 3783

Using Angular Material matMenu as context menu

I have been trying to sort out how to use a matMenu as context menu triggered when somebody right clicks on one of my elements.

This is my menu:

<mat-menu #contextMenu="matMenu">
   <button mat-menu-item>
      <mat-icon>table_rows</mat-icon>
      <span>Select Whole Row</span>
      <span>⌘→</span>
   </button>
   <button mat-menu-item>
      <mat-icon>functions</mat-icon>
      <span>Insert Subtotal</span>
      <span>⌃S</span>
   </button>
</mat-menu>

I want to be able to trigger the menu when this element is right-clicked:

<div tabindex=0 *ngIf="!hasRowFocus" 
                class="display-cell" (keydown)="onSelectKeyDown($event)" 
                (click)=selectCellClick($event) 
                (dblclick)=selectCellDblClick($event)
                (contextmenu)="openContextMenu()"
                [ngClass]='selectClass'
                (mouseover)="mouseover()"
                (mouseout)="mouseout()"
           
                #cellSelect >
     <div (dragenter) = "mouseDragEnter($event)" (dragleave) = "mouseDragLeave($event)">{{templateDisplayValue}}</div>
</div>

However, according to the documentation, I need to specify this div should have the [matMenuTriggerFor] directive, so that so when openContextMenu() it triggered by right clicking, I can call get a reference to the trigger element and then call triggerElement.trigger() to spawn the menu.

Problem is it appears that setting [matMenuTriggerFor] is hooked up to the click event automatically, not the right click event, so anytime I left click on the element the context menu opens, which is not the desired behavor.

I have seen workarounds like this one on Stackblitz which creates a hidden div as the trigger element, but requires supplying x & y coordinates for the location of the menu element which seems suboptimal.

Any way to have the menu triggered by right clicking on the input element without having to create a dummy element to host the matMenuTriggerFor directive?

Upvotes: 6

Views: 20004

Answers (5)

Vince
Vince

Reputation: 787

For anyone else looking, this was the easiest setup. Follows mostly how MatMenuTrigger works just handles on (contextmenu) rather than (click).

import { Directive, HostBinding, Input } from '@angular/core';
import { MatMenuPanel, MatMenuTrigger } from '@angular/material/menu';


@Directive({
    selector: '[matMenuTriggerForContext]',
    host: {
        'class': 'mat-mdc-menu-trigger',
        '[attr.aria-haspopup]': 'menu ? "menu" : null',
        '[attr.aria-expanded]': 'menuOpen',
        '[attr.aria-controls]': 'menuOpen ? menu.panelId : null',
        '(contextmenu)': '_handleContextMenu($event)',
      },
      exportAs: 'matMenuTriggerContext'
})
export class MatMenuTriggerForContextDirective extends MatMenuTrigger {
    @Input('matMenuTriggerForContext')
    get _matMenuTriggerForContext(): MatMenuPanel | null {
        return this.menu;
      }
    set _matMenuTriggerForContext(v: MatMenuPanel | null) {
        this.menu = v;
    }

    _handleContextMenu($event: MouseEvent): boolean {
        $event?.stopPropagation();
        $event?.preventDefault();
        $event?.stopImmediatePropagation();
        this._handleClick($event);
        return false;
    }
}

Upvotes: 1

snesin
snesin

Reputation: 149

Luckily the code to matMenuTriggerFor is posted here: https://github.com/angular/components/blob/master/src/material/menu/menu-trigger.ts

If you look at the bottom of file at the MatMenuTrigger class, it is straightforward to override the same base class, do a few tricks, and roll our own matContextMenuTriggerFor directive:

import { Directive, HostListener, Input } from "@angular/core";
import { MatMenuPanel, _MatMenuTriggerBase } from "@angular/material/menu";
import { fromEvent, merge } from "rxjs";

// @Directive declaration styled same as matMenuTriggerFor
// with different selector and exportAs.
@Directive({
  selector: `[matContextMenuTriggerFor]`,
  host: {
    'class': 'mat-menu-trigger',
  },
  exportAs: 'matContextMenuTrigger',
})
export class MatContextMenuTrigger extends _MatMenuTriggerBase {

  // Duplicate the code for the matMenuTriggerFor binding
  // using a new property and the public menu accessors.
  @Input('matContextMenuTriggerFor')
  get menu_again() {
    return this.menu;
  }
  set menu_again(menu: MatMenuPanel) {
    this.menu = menu;
  }

  // Make sure to ignore the original binding
  // to allow separate menus on each button.
  @Input('matMenuTriggerFor')
  set ignoredMenu(value: any) { }

  // Override _handleMousedown, and call super._handleMousedown 
  // with a new MouseEvent having button numbers 2 and 0 reversed.
  _handleMousedown(event: MouseEvent): void {
    return super._handleMousedown(new MouseEvent(event.type, Object.assign({}, event, { button: event.button === 0 ? 2 : event.button === 2 ? 0 : event.button })));
  }

  // Override _handleClick to make existing binding to clicks do nothing.
  _handleClick(event: MouseEvent): void { }

  // Create a place to store the host element.
  private hostElement: EventTarget | null = null;

  // Listen for contextmenu events (right-clicks), then:
  //  1) Store the hostElement for use in later events.
  //  2) Prevent browser default action.
  //  3) Call super._handleClick to open the menu as expected.
  @HostListener('contextmenu', ['$event'])
  _handleContextMenu(event: MouseEvent): void {
    this.hostElement = event.target;
    if (event.shiftKey) return; // Hold a shift key to open original context menu. Delete this line if not desired behavior.
    event.preventDefault();
    super._handleClick(event);
  }

  // The complex logic below is to handle submenus and hasBackdrop===false well.
  // Listen for click and contextmenu (right-click) events on entire document.
  // If this menu is open, one of the following conditional actions.
  //   1) If the click came from the overlay backdrop, close the menu and prevent default.
  //   2) If the click came inside the overlay container, it was on a menu. If it was
  //      a contextmenu event, prevent default and re-dispatch it as a click.
  //   3) If the event did not come from our host element, close the menu.
  private contextListenerSub = merge(
    fromEvent(document, "contextmenu"),
    fromEvent(document, "click"),
  ).subscribe(event => {
    if (this.menuOpen) {
      if (event.target) {
        const target = event.target as HTMLElement;
        if (target.classList.contains("cdk-overlay-backdrop")) {
          event.preventDefault();
          this.closeMenu();
        } else {
          let inOverlay = false;
          document.querySelectorAll(".cdk-overlay-container").forEach(e => {
            if (e.contains(target))
              inOverlay = true;
          });
          if (inOverlay) {
            if (event.type === "contextmenu") {
              event.preventDefault();
              event.target?.dispatchEvent(new MouseEvent("click", event));
            }
          } else
            if (target !== this.hostElement)
              this.closeMenu();
        }
      }
    }
  });

  // When destroyed, stop listening for the contextmenu events above, 
  // null the host element reference, then call super.
  ngOnDestroy() {
    this.contextListenerSub.unsubscribe();
    this.hostElement = null;
    return super.ngOnDestroy();
  }
}

Add the MatContextMenuTrigger directive to the declarations list in your module file, then it is just like using the normal one:

<div [matContextMenuTriggerFor]="myContextMenu">
  Right click here to open context menu.
</div>
<mat-menu #myContextMenu="matMenu">
  My context menu.
</mat-menu>

And you can have separate menus for both left-click and right-click:

<div [matMenuTriggerFor]="myNormalMenu" [matContextMenuTriggerFor]="myContextMenu">
  Left click here to open normal menu.<br>
  Right click here to open context menu.
</div>
<mat-menu #myNormalMenu="matMenu">
  My normal menu.
</mat-menu>
<mat-menu #myContextMenu="matMenu">
  My context menu.
</mat-menu>

Upvotes: 9

Janith Widarshana
Janith Widarshana

Reputation: 3483

Here is the solution I found. Context menu will open on cursor position.

Create a component named to ContextMenuComponent

    import { Component, HostBinding } from '@angular/core';
import { MatMenuTrigger } from '@angular/material/menu';

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

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

  // Intercepts the global context menu event
  public open({ x, y }: MouseEvent, data?: any) {

    // Pass along the context data to support lazily-rendered content
    if(!!data) { this.menuData = data; }

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

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

Use context menu as bellow on where you want

<app-context-menu [matMenuTriggerFor]="main" #menu>
        
    <mat-menu #main="matMenu">
    
        <ng-template matMenuContent let-name="name">
    
          <button mat-menu-item>{{ name }}</button>
          <button mat-menu-item>Menu item 1</button>
          <button mat-menu-item>Menu item 2</button>
    
          <button mat-menu-item [matMenuTriggerFor]="sub">
            Others...
          </button>
    
        </ng-template>
    
      </mat-menu>
    
      <mat-menu #sub="matMenu">
        <button mat-menu-item>Menu item 3</button>
        <button mat-menu-item>Menu item 4</button>
      </mat-menu>
        
        </app-context-menu>
    
    <section fxLayout="column" fxLayoutAlign="center center" (contextmenu)="menu.open($event, { name: 'Stack Overflow'} )">
    
      <p>Try to right click and see how it works!</p>
      <p>You do whatever you want here...</p>
    
    </section>

Upvotes: 4

GGizmos
GGizmos

Reputation: 3783

It doesn't seem like its possible to disconnect the (click) from triggering MatMenuTrigger short of disabling mouse events entirely, U can't do that in my app because the same element which needs a context menu also needs to respond to click hover and drag events. But the dummy element approach can be simplified and doesn't require seem to require providing positional information as in the following example, adapted from ttQuants stackblitz example in his useful answer.

export class ContextMenuExample {
  items = [
    { id: 1, name: "Item 1" },
    { id: 2, name: "Item 2" },
    { id: 3, name: "Item 3" }
  ];
  @ViewChildren(MatMenuTrigger)
  contextMenuTriggers: QueryList<MatMenuTrigger>;
  
  onContextMenu(event: MouseEvent, item: Item, idx : number) { 
    event.preventDefault();
    this.contextMenuTriggers.toArray()[idx].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;
}

Template:

<p>Right-click on the items below to show the context menu:</p>
<mat-list>
    <mat-list-item *ngFor="let item of items; let idx=index">
       <div (contextmenu)="onContextMenu($event, item, idx)">{{item.name}}
          <div [matMenuTriggerFor]="contextMenu" style="visibility: hidden; 
             pointer-events: none;">{{ item.name }}
          </div>
       </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>

Upvotes: 0

ttquang1063750
ttquang1063750

Reputation: 863

Maybe you can use css pointer-events: none; and wrap [matMenuTriggerFor]="contextMenu" inside the list like this

<p>Right-click on the items below to show the context menu:</p>
<mat-list>
    <mat-list-item *ngFor="let item of items" (contextmenu)="onContextMenu($event, item)">
    <div [matMenuTriggerFor]="contextMenu" style="pointer-events: none;">{{ item.name }}</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>

Here is the demo

But it is not preferable. I think you should use the overlay replace with menu

Upvotes: 3

Related Questions