Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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?

like image 875
GGizmos Avatar asked Aug 25 '26 22:08

GGizmos


2 Answers

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>
like image 56
snesin Avatar answered Aug 30 '26 14:08

snesin


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>
like image 33
Janith Widarshana Avatar answered Aug 30 '26 13:08

Janith Widarshana