Skip to content

`fyzMatMenuTrigger`

@flyze/lib-core-angular 1.0.0-alpha.34· latest

FyzMenuTrigger is a drop-in replacement for Angular Material’s matMenuTrigger. It adds what Material does not offer: a gap between trigger and menu, a configurable viewport margin, opening a menu at an arbitrary point or element, anchoring it to an element other than the trigger, keeping it attached while the page or a nested container scrolls, and handing the decision to open back to the consumer.

It is a standalone directive (fyz-mat-menu-trigger.directive.ts:20) — import the class, not a module — and matches both [fyzMatMenuTriggerFor] and [fyzMatMenuTrigger], exported as fyzMatMenuTrigger for @ViewChild / template refs. It sets class="mat-menu-trigger" on its host so Material’s own styling still applies.

MemberTypeDefaultEffect
fyzMatMenuTriggerForMatMenuPanelthe menu to open, forwarded to MatMenuTrigger.menu
gapnumber0pixels between the trigger edge and the menu
viewportMarginnumber8how far the menu is kept from the viewport edge — see below
closeOnScrollOutOfViewbooleanfalseclose the menu once the trigger is scrolled out of sight
anchorHTMLElement | ElementRefanchor every open to this element instead of the host
openOnClickbooleantruewhether a click on the trigger opens the menu
panelWidth'anchor' | nullnullsize the panel to the element the menu attaches to
openMenuAt(x, y)methodopen at viewport coordinates
openMenuAtElement(element)methodopen anchored to an element that is not the host, one open

Material’s own inherited inputs keep their Material names — matMenuTriggerData and matMenuTriggerRestoreFocus work on a fyz trigger, only menu is re-aliased.

Everything inherited from MatMenuTriggermenuOpen, menuOpened, menuClosed, closeMenu(), xPosition / yPosition on the menu — keeps working.

viewportMargin defaults to 8 rather than Material’s 0, so an unconfigured menu is kept off the window edge — but not by 8 on all four sides. See The viewport margin is not symmetric.

Material computes a menu’s position inside its private _setPosition(menu, positionStrategy). There is no public hook to influence it, so the directive replaces that method on itself in ngOnInit (fyz-mat-menu-trigger.directive.ts:89). Rationale and the per-upgrade checklist: ADR 0003.

The override does not reimplement Material’s logic — it calls it and intercepts the result (fyz-mat-menu-trigger.directive.ts:99):

  1. super._setPosition() is invoked with a shallow clone of the position strategy whose withPositions is a spy. Material passes its computed ConnectedPosition[] to that spy, which forwards to the real strategy and keeps a copy. This is how the directive learns which positions Material chose without duplicating the decision.
  2. The origin is then overridden — a point after openMenuAt(x, y), otherwise the element resolved as described in Where the menu attaches.
  3. Every position captured in step 1 is re-applied with gap folded into its offsetY (fyz-mat-menu-trigger.directive.ts:151). _getGapOffset() decides the sign from the position itself, and adds to Material’s own offsetY rather than replacing it — Material uses that field for submenu padding.
  4. withViewportMargin(viewportMargin) is applied last. When panelWidth asks for it, the override also resolves the width the pane should get — but applies it later, after the open; see panelWidth.

Connecting to the element in step 2 is what keeps the menu attached: Material creates the strategy with flexibleConnectedTo, and the CDK re-reads the origin’s bounding rect on every reposition. A point origin would return the same viewport coordinates forever.

Four sources, most specific first (fyz-mat-menu-trigger.directive.ts:190):

SourceLifetimeOrigin kind
openMenuAt(x, y)one openpoint
openMenuAtElement(element)one openelement
anchor inputpersistentelement
host element (_element)fallbackelement

anchor exists for the case where the trigger is not the thing the menu should line up with — a chips container around a search input, say, where anchoring to the input alone lets the menu drift sideways as chips push the input along. It is also the element the scroll tracking follows, so closeOnScrollOutOfView tests the anchor’s visibility rather than the host’s.

The two one-open sources are cleared as soon as they are used. openMenuAtElement() also clears its element when openMenu() refuses to open — the menu is already open, or there is no menu at all (fyz-mat-menu-trigger.directive.ts:236). Without that, a refused call left the element stashed and silently anchored the next open instead. Calling openMenuAtElement() from (menuOpened) is the way people hit this: menuOpened is emitted from inside openMenu(), after the overlay is attached, so the nested openMenu() early-returns and the element is never applied to the open it was meant for. Use anchor for that.

Material binds (click) on the trigger host to _handleClick, which calls toggleMenu(). Setting openOnClick to false swallows that click (fyz-mat-menu-trigger.directive.ts:149), leaving the menu linked, positioned and ARIA-wired while the consumer decides when to open it. A combobox needs this: toggleMenu() means a click on an already-open trigger closes the panel, so clicking a search input to move the caret would dismiss the list.

Unlike _setPosition, _handleClick is public in Material’s type, so this is a real override — a signature change on upgrade fails the build rather than silently doing nothing, and it needs none of ADR 0003’s machinery. It reaches the override because Angular copies Material’s host listeners onto this directive (ɵɵInheritDefinitionFeature) and dispatches them on the instance.

What it does not suppress: _handleMousedown and _handleKeydown still run, because they only record _openedBy for the menu’s focus origin. A submenu therefore still opens on hover and RIGHT_ARROW — hence openOnClick rather than a broader autoOpen. Worth re-checking on a Material upgrade that opening still happens in _handleClick; if it moved, the spec case “neither opens nor toggles on click when openOnClick is false” fails.

Material emits four positions — a preferred one plus three fallbacks (menu.mjs:1041). The gap has to be applied to all of them, with a sign that depends on the position (fyz-mat-menu-trigger.directive.ts:189):

PositionGap offsetWhy
originY: 'bottom'+gapthe menu sits below the trigger, push down
originY: 'top'-gapthe menu sits above the trigger, push up
originY === overlayY0submenu / overlapTrigger — no room

Adding +gap uniformly would push a menu that flipped above its trigger onto the trigger, so the sign is not cosmetic.

viewportMargin is handed straight to FlexibleConnectedPositionStrategy.withViewportMargin(), and the CDK spends it unevenly. _getNarrowedViewportRect() shrinks the viewport by the margin on every side, but the fit test that decides whether a position is usable — _getOverlayFit(), node_modules/@angular/cdk/fesm2022/overlay.mjs:1543 — compares the narrowed width against un-narrowed coordinates:

let leftOverflow = 0 - x; // ← a literal 0, so the margin is absent
let rightOverflow = x + overlay.width - viewport.width; // ← width is already 2 * margin smaller

x is viewport-space (_getOriginPoint reads getBoundingClientRect()), so the leading edge is measured against 0 and only the trailing edge pays. The result, for viewportMargin: 8:

EdgeDistance actually kept
left, top0
right, bottom16 — twice the value

A menu whose preferred position is flush against the left edge therefore fits as-is and is placed exactly on its trigger. One flush against the right edge fails the fit test by 2 * margin; because Material’s menu sets no minWidth in its OverlayConfig, _canFitWithFlexibleDimensions (:1579) cannot rescue it either, so _pushOverlayOnScreen (:1602) shifts the panel 16px inward.

This is invisible for a menu at its natural width — being pushed off the edge is what the margin is for. It becomes visible as a misalignment as soon as the panel is exactly as wide as its trigger, which is why the trailing edge is the one case that matters to panelWidth.

There is no clean fix at this layer: the margin is a single scalar and the asymmetry lives in the CDK’s own comparison, so passing a different number cannot make both edges agree. Treat the value as “the trailing/bottom margin, doubled” and set it accordingly.

This is also why panelWidth: 'anchor' drops the margin’s default to 0 — an exactly-matched panel is the one case where a 16px push reads as a misalignment rather than as the margin doing its job. See panelWidth.

Set it to 'anchor' and the menu is exactly as wide as the element it attaches to, from the first open, and stays that way while the menu is open. null, the default, means the directive does nothing and Material’s 112px280px panel sizing stands.

It needs two boxes sized rather than one, reaches into Material’s own DOM for the second, and changes the viewportMargin default, so it has its own doc: panelWidth: sizing the menu panel to its origin.

While a menu is open, a scroll listener on the document calls Material’s public updatePosition(), throttled to one requestAnimationFrame (fyz-mat-menu-trigger.directive.ts:206). It is registered through NgZone.runOutsideAngular and removed on the first menuClosed and on destroy.

The listener is in the capture phase on purpose. scroll does not bubble, and the CDK’s ScrollDispatcher only listens on the document, so an overflow: auto container that the consumer has not marked cdkScrollable is otherwise invisible to it. Capture phase sees the event on its way down to any container, marked or not.

Resize needs no listener: FlexibleConnectedPositionStrategy.attach() already re-applies the position on ViewportRuler.change().

Off by default, which keeps Material’s behavior — the menu follows the trigger and is clamped to the viewport. When set, each throttled scroll callback also asks whether the trigger is still visible (fyz-mat-menu-trigger.directive.ts:248) and closes the menu if it is not.

“Visible” is checked against the viewport and against every clipping ancestor, because being clipped away by an inner scroll container is the common case and a viewport-only check would report such a trigger as visible. Two details that are easy to get wrong:

  • The walk stops at <body> / <html>. The document root scrolls the viewport rather than its own box, so its rect is the content height and says nothing about what is on screen — and apps routinely set overflow: hidden on the body, which would otherwise report every trigger as hidden.
  • The walk also stops at the first position: fixed ancestor, since a fixed subtree escapes the overflow of everything above it.
  • Nothing behind the menu can be scrolled or clicked while a menu with a backdrop is open → proposals/menu-trigger-scroll-pass-through. This is also why the playground drives its scroll containers with a button instead of the wheel.
  • Menus opened with openMenuAt(x, y) stay pinned to their point. Intended — that is what a context menu should do — and the reason the origin is reset on every open.
  • openMenuAtElement(element) clears _targetElement once the menu opens, so the element is only used for that one open. Re-opening the same menu without calling it again falls back to the host element.
  • The positioning depends on Angular Material internals, so treat a Material or CDK upgrade as a reason to re-run the spec next to the directive and re-check menu placement in your app. ADR 0003 explains why, and carries the per-upgrade checklist. The spec’s first case fails loudly if the patched method is renamed. panelWidth couples to four further internals, listed under On a Material or CDK upgrade.