Center Mat-Menu, Center MatMenu Overlay to Button

Viewed 6902

Per Material Issue 9631 centering the mat-menu to the button is as follows.

the issue is that supporting it deviates from the spec and may end up looking weird.


I have a need for this functionality... Because writing my own CDK overlay would be more time intensive than overriding the mat-menu component... I am not interested in re-creating the mat-menu, and simply need the menu to be centered... I am also not interested in any other library's to accomplish this, I want to use Material mat-menu, so my question is as follows.

Question:

Utilizing an angular directive, how can I override the private variables and methods of the MatMenuTrigger to center the CDK overlay to the button?

enter image description here

3 Answers

I chose a Directive because MatMenuTrigger is a Directive, it made sense to replicate the logic used by the Material source.

So far this is the approach I have come up with, I am not sure if reaching into the class like this is "acceptable" per se, or if there are any negative ramifications for doing it this way.

I am open to constructive discussion on this approach for any recommendations or improvements.


Stackblitz

https://stackblitz.com/edit/angular-jpgjdc-nqmyht?embed=1&file=app/center-matmenu.directive.ts

Essentially I am decoupling the matMenuTrigger from the button by placing it on a div wrapping the mat-menu... this is so I can programmatically open the menu from the directive and not the button.

  • I am also assigning menuTrigger to a templateRef on the div and passing it as an input to my center-mat-menu selector
<button mat-button [center-mat-menu]="menuTrigger">Menu</button>
<div #menuTrigger="matMenuTrigger" [matMenuTriggerFor]="menu">
 <mat-menu #menu="matMenu">

From there I am creating a listener via @HostListener

@HostListener('click', ['$event'])

And then replicating the logic of the MatMenuTrigger directive to manipulation the placement, initialize the menu, and fire the open animation on click.

This is basically a replication of the openMenu() method in the menu-trigger.ts source except I am manipulating the left and top styles after the menu is initialized, and before I call this.menuTrigger.menu['_startAnimation'](); to open the menu.

menu-trigger.ts

I store the dimension of the source button into variables and use that information to calculate the center point of the button, I then use that with the width of the initialized menu to calculate left

@Directive({
    selector: "[center-mat-menu]"
})
export class CenterMatmenuDirective {
    overlayRef: OverlayRef;
    overlayConf: OverlayConfig;
    dropDown: HTMLElement;
    overlayPositionBox: HTMLElement;
    menu: MatMenuPanel;
    button: HTMLElement;
    buttonWidth: number;
    buttonLeft: number;
    buttonBottom: number;

    @Input("center-mat-menu") private menuTrigger: MatMenuTrigger;

    constructor(private _menuButton: ElementRef, private _renderer: Renderer2) {}

    @HostListener("click", ["$event"])
    onclick(e) {
        this._setVariables();
        //menu not opened by keyboard down arrow, have to set this so MatMenuTrigger knows the menu was opened with a mouse click
        this.menuTrigger["_openedBy"] = e.button === 0 ? "mouse" : null;

        this._overrideMatMenu();

        this.dropDown = this.overlayRef.overlayElement.children[0].children[0] as HTMLElement;
        this.overlayPositionBox = this.overlayRef.hostElement;

        setTimeout(() => {
            this._styleDropDown(this.dropDown);
            this._setOverlayPosition(this.dropDown, this.overlayPositionBox);
            this._openMenu();
        });
    }

    private _setVariables() {
        const config = this.menuTrigger["_getOverlayConfig"]();
        this.menuTrigger["_overlayRef"] = this.menuTrigger["_overlay"].create(config);
        this.overlayRef = this.menuTrigger["_overlayRef"];
        this.overlayConf = this.overlayRef.getConfig();
        this.overlayRef.keydownEvents().subscribe();
        this.menu = this.menuTrigger.menu;
        this._setButtonVars();
    }

    private _setButtonVars() {
        this.button = this._menuButton.nativeElement;
        this.buttonWidth = this.button.getBoundingClientRect().width;
        this.buttonLeft = this.button.getBoundingClientRect().left;
        this.buttonBottom = this.button.getBoundingClientRect().bottom;
    }

    private _overrideMatMenu() {
        let strat = this.overlayConf.positionStrategy as FlexibleConnectedPositionStrategy;
        this.menuTrigger["_setPosition"](strat);
        strat.positionChanges.subscribe(() => {
            this._setButtonVars();
            this._setOverlayPosition(this.dropDown, this.overlayPositionBox);
        });
        this.overlayConf.hasBackdrop =
            this.menu.hasBackdrop == null ? !this.menuTrigger.triggersSubmenu() : this.menu.hasBackdrop;
        this.overlayRef.attach(this.menuTrigger["_getPortal"]());

        if (this.menu.lazyContent) {
            this.menu.lazyContent.attach();
        }

        this.menuTrigger["_closeSubscription"] = this.menuTrigger["_menuClosingActions"]().subscribe(() => {
            this.menuTrigger.closeMenu();
        });
        this.menuTrigger["_initMenu"]();
    }

    private _styleDropDown(dropDown: HTMLElement) {
        this._renderer.setStyle(this._renderer.parentNode(dropDown), "transform-origin", "center top 0px");
    }

    private _setOverlayPosition(dropDown: HTMLElement, overlayPositionBox: HTMLElement) {
        let dropDownleft = this.buttonWidth / 2 + this.buttonLeft - dropDown.offsetWidth / 2;

        this._renderer.setStyle(overlayPositionBox, "top", this.buttonBottom + 5 + "px");
        this._renderer.setStyle(overlayPositionBox, "left", dropDownleft + "px");
        this._renderer.setStyle(overlayPositionBox, "height", "100%");
    }

    private _openMenu() {
        this.menuTrigger.menu["_startAnimation"]();
    }
}

Marshal's answer is working fine for angular <14, if someone looking for the angular 14. Here is the updated directive: example

I have updated the directive based on the angular 14 compatibility.

There is an even simpler solution using css transform property.

First, you need to create two classes, like for example

.center-before {
  transform: translateX(50%);
}

.center-after {
  transform: translateX(-50%);
}

and also a directive which will add a proper class depends on your mat-menu xPosition.

@Directive({
  selector: 'mat-menu[pgcCenter]',
})
export class PGCMenuCenterDirective implements AfterViewInit {
  constructor(private menu: MatMenu) {}

  ngAfterViewInit(): void {
    this.menu.overlayPanelClass = `center-${this.menu.xPosition}`
  }
}

Finally, use the directive in your mat-menu, like this

<button mat-button [matMenuTriggerFor]="theMenu">Click me!</button>
<mat-menu
  #theMenu="matMenu"
  xPosition="before"
  yPosition="below"
  pgcCenter 
>
  <div>
    abc
  </div>
</mat-menu>
Related