`fyzMatMenuTrigger`
@flyze/lib-core-angular 1.0.0-alpha.34· latestFyzMenuTrigger 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.
Public surface
Section titled “Public surface”| Member | Type | Default | Effect |
|---|---|---|---|
fyzMatMenuTriggerFor | MatMenuPanel | — | the menu to open, forwarded to MatMenuTrigger.menu |
gap | number | 0 | pixels between the trigger edge and the menu |
viewportMargin | number | 8 | how far the menu is kept from the viewport edge — see below |
closeOnScrollOutOfView | boolean | false | close the menu once the trigger is scrolled out of sight |
anchor | HTMLElement | ElementRef | — | anchor every open to this element instead of the host |
openOnClick | boolean | true | whether a click on the trigger opens the menu |
panelWidth | 'anchor' | null | null | size the panel to the element the menu attaches to |
openMenuAt(x, y) | method | — | open at viewport coordinates |
openMenuAtElement(element) | method | — | open 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 MatMenuTrigger — menuOpen, 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.
How it works
Section titled “How it works”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):
super._setPosition()is invoked with a shallow clone of the position strategy whosewithPositionsis a spy. Material passes its computedConnectedPosition[]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.- The origin is then overridden — a point after
openMenuAt(x, y), otherwise the element resolved as described in Where the menu attaches. - Every position captured in step 1 is re-applied with
gapfolded into itsoffsetY(fyz-mat-menu-trigger.directive.ts:151)._getGapOffset()decides the sign from the position itself, and adds to Material’s ownoffsetYrather than replacing it — Material uses that field for submenu padding. withViewportMargin(viewportMargin)is applied last. WhenpanelWidthasks 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.
Where the menu attaches
Section titled “Where the menu attaches”Four sources, most specific first
(fyz-mat-menu-trigger.directive.ts:190):
| Source | Lifetime | Origin kind |
|---|---|---|
openMenuAt(x, y) | one open | point |
openMenuAtElement(element) | one open | element |
anchor input | persistent | element |
host element (_element) | fallback | element |
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.
Clicking: openOnClick
Section titled “Clicking: openOnClick”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.
The gap and the four positions
Section titled “The gap and the four positions”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):
| Position | Gap offset | Why |
|---|---|---|
originY: 'bottom' | +gap | the menu sits below the trigger, push down |
originY: 'top' | -gap | the menu sits above the trigger, push up |
originY === overlayY | 0 | submenu / overlapTrigger — no room |
Adding +gap uniformly would push a menu that flipped above its trigger onto the trigger, so the
sign is not cosmetic.
The viewport margin is not symmetric
Section titled “The viewport margin is not symmetric”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 absentlet rightOverflow = x + overlay.width - viewport.width; // ← width is already 2 * margin smallerx 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:
| Edge | Distance actually kept |
|---|---|
| left, top | 0 |
| right, bottom | 16 — 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.
Sizing the panel: panelWidth
Section titled “Sizing the panel: 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 112px–280px 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.
Following the trigger
Section titled “Following the trigger”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().
closeOnScrollOutOfView
Section titled “closeOnScrollOutOfView”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 setoverflow: hiddenon the body, which would otherwise report every trigger as hidden. - The walk also stops at the first
position: fixedancestor, since a fixed subtree escapes the overflow of everything above it.
Known limitations
Section titled “Known limitations”- 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_targetElementonce 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.
panelWidthcouples to four further internals, listed under On a Material or CDK upgrade.