3

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

Marshal
  • 10,499
  • 2
  • 34
  • 53
  • There is no spec for "centering" a menu with its trigger - that's why it seems ambiguous. The alignment spec is RTL (or LTR) - the same as with lists - because a menu is a list (and the trigger is a list header). From the spec: "Menus display a **list** of choices." IMO, that's not ambiguous at all. Look at the examples - the trigger and menu content line up RTL (except for screen/browser edge cases). MD doesn't really have a concept of "center" alignment, so if you want to do that, you have to define your own rules - don't look for them in the specs. I strongly doubt a directive could work. – G. Tranter Jan 10 '19 at 17:52
  • Looking at the `Exposed dropdown menu` examples, there are a couple of screenshots in the behavior section that depict a menu directly below.. maybe it is because the items in the dropdown do not extend beyond the content of the menu, so it does not offset to either RTL or LTR... I still feel this to be ambiguous, as the examples depicted infer a centered drop down. https://material.io/design/components/menus.html#exposed-dropdown-menu – Marshal Jan 10 '19 at 18:02
  • 1
    Depending on how you choose to look at it, "directly below" is not necessarily the same as "centered" (although it might _also happen to be_ visually centered). The screenshots are "directly below" based on RTL alignment of content and _having the same width_. I don't see any examples of a menu that is wider or narrower than the trigger and yet still centered. If your menu is the same width as your trigger, RTL alignment (or LTR) would cause it to appear "centered" like a dropdown box. I don't interpret this type of "centered" _appearance_ as being a center _alignment_ because content is RTL. – G. Tranter Jan 10 '19 at 20:43
  • Thank you for your perspective on this, what you have described definitely helps me to better understand the interpretation of the spec. – Marshal Jan 10 '19 at 22:11

3 Answers3

4

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"]();
    }
}
Robouste
  • 3,020
  • 4
  • 33
  • 55
Marshal
  • 10,499
  • 2
  • 34
  • 53
  • 2
    Nice job but it only works when you first open it. If you resize the app window while it is open, it goes back to original alignment. And personally, I think it looks "wrong" - just an opinion. It _might_ look right if the menu was not a list (grid maybe) so the items were not layed out in RTL rows, but in that case I don't think centering under the trigger would matter much. – G. Tranter Jan 10 '19 at 20:51
  • 1
    Nice catch! I forgot to subscribe to the `positionChanges` observable in the `positionStrategy`. I revised stackblitz to include a fix for this, let me know if you spot anything else. I see what you mean about left align items in the dropdown compared to the button above looking "wrong"... in my scenario I am loading `[innerHTML]` in the drop down and didn't think about that... your observations and feedback is much appreciated! – Marshal Jan 10 '19 at 22:05
  • How to fix this: Property '_closeSubscription' does not exist on type 'MatMenuTrigger'. Property '_startAnimation' does not exist on type 'MatMenuPanel'. I'm using @angular/material@13.2.2 – Mikolaj Aug 06 '22 at 08:42
  • On version 13.2.x it looks like '_closeSubscription' was renamed to '_menuCloseSubscription', and not sure about '_startAnimation', it is still there, but only if you are adding this directive to an instance of'_MatMenuBase' https://github.com/angular/components/blob/d29818d54ef2454f7d0fdcd81f1f33888d4a76ef/src/material/menu/menu-trigger.ts#L313 – Marshal Aug 06 '22 at 12:12
0

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.

Sonu Sindhu
  • 1,752
  • 15
  • 25
  • Essentially the `_getOverlayConfig`, `_setPosition` and `_initMenu` functions now require a reference to the `menu` passed as an argument? May be helpful to others if you document what changes you made to the directive in order to make it compliant... an equally important note is that these changes are a requirement of `@Angular/Material v14` and not necessarily Angular itself. – Marshal Jun 22 '22 at 16:52
0

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>
Mikolaj
  • 1,231
  • 1
  • 16
  • 34