angularangular-materialangular-material2angular-material-6angular-material-5

Center Mat-Menu, Center MatMenu Overlay to Button


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


Solution

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

    <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"]();
        }
    }