`panelWidth`: sizing the menu panel to its origin
@flyze/lib-core-angular 1.0.0-alpha.34· latestpanelWidth is an input on fyzMatMenuTrigger that makes the menu exactly
as wide as the element it is anchored to — the thing a mat-menu used as a select or combobox
popover needs, and which Material offers no way to ask for.
| Value | Effect |
|---|---|
'anchor' | the menu is exactly as wide as the element it attaches to, and stays that way |
null | the directive does nothing — Material’s own 112px–280px panel sizing stands |
null is the default, so an unconfigured trigger behaves exactly as a plain matMenuTrigger does.
A value rather than a boolean, so a minimum-width mode or an explicit length can arrive later as
further values instead of as more inputs that have to interact.
What made this hard
Section titled “What made this hard”Material exposes no width input, its _overlayRef is private, and .mat-mdc-menu-panel ships a
hard cap (node_modules/@angular/material/fesm2022/menu.mjs:630, ViewEncapsulation.None):
.mat-mdc-menu-panel { min-width: 112px; max-width: 280px; box-sizing: border-box;}Consumers therefore faked it from inside the panel — a zero-height spacer element whose width was
driven by a ResizeObserver, plus a global ::ng-deep block forcing max-width: unset. That takes
two boxes to size, one of which the consumer cannot reach.
How it works
Section titled “How it works”Both halves of the sizing are needed, because they size different elements.
1. The pane — resolved in _setPosition, applied after the open
Section titled “1. The pane — resolved in _setPosition, applied after the open”The width is decided and applied in two different places, and the split is load-bearing.
_setPositionOverride resolves it and records it on _pendingPaneWidth
(fyz-mat-menu-trigger.directive.ts:246),
measuring getBoundingClientRect().width rather than clientWidth — the origin’s border is part of
what the user sees the menu line up with. Resolution belongs here because this is the only place all
three origin sources are still visible: _targetElement is consumed and cleared a few lines down,
so a menuOpened handler could no longer tell which element an openMenuAtElement() open was aimed
at and would silently fall back to the host. The spec case “matches the one-open element of
openMenuAtElement” pins that.
openMenu() applies it, after super.openMenu() has returned
(fyz-mat-menu-trigger.directive.ts:200),
with OverlayRef.updateSize({ width }). It must not be applied from inside _setPosition —
that cost the menu its backdrop once already, see
why the application cannot happen earlier.
The _pendingPaneWidth field is also the signal that _setPosition ran for this call:
openMenu() is a no-op when the menu is already open, and re-applying a previous open’s width would
be wrong.
The call is unconditional, passing { width: undefined } when there is nothing to apply. The
OverlayRef is cached (menu.mjs:961) and updateSize carries the config forward, so a width
would otherwise survive panelWidth going back to null and would follow the trigger into an
openMenuAt(x, y) that has no origin to match. That is also why openMenuAt() calls
this.openMenu() rather than super.openMenu() — super would skip the reset.
This is broadly what MatAutocompleteTrigger does for its own panel: _getHostWidth() reads the
bounding rect (autocomplete.mjs:949), the width goes into the OverlayConfig (:896), and
updateSize() re-applies it on later opens (:839, :846) because the OverlayRef is cached.
The width survives positioning because the strategy never writes style.width on the pane:
_setOverlayElementStyles (overlay.mjs:1839) sets only
top/left/bottom/right/position/transform, plus maxWidth/maxHeight when the config
carries them — the menu’s never does. _resetOverlayElementStyles (:1828) clears those same six.
_setBoundingBoxStyles (:1768) does write width, but on the bounding box, the pane’s parent.
2. The panel, after attach()
Section titled “2. The panel, after attach()”Sizing the pane does not size the panel inside it: .cdk-overlay-pane is display: flex with the
default flex-direction: row (node_modules/@angular/cdk/overlay-prebuilt.css), so the panel is a
flex item at its own content width, and .mat-mdc-menu-panel clamps that to 112px–280px.
attach() is synchronous, so once super.openMenu() returns the panel element exists. The override
resolves it out of the pane and sets three inline properties
(fyz-mat-menu-trigger.directive.ts:184):
width: 100%; min-width: 0; max-width: none;min-width: 0 is not optional. It lives in the same declaration as the max-width that an inline
width does beat, so without it an origin narrower than 112px renders a 112px panel overflowing
a pane the CDK positioned as if it were 60px wide. Material’s own matched panel has no such floor
— .mat-mdc-autocomplete-panel is width: 100%; box-sizing: border-box with neither bound.
No reset is needed on close: detaching the TemplatePortal destroys the embedded view, so every
open builds a fresh panel element.
The step ends with updatePosition(). The pane has no explicit height, so constraining the panel’s
width can wrap its content and make the pane taller than the height the fit tests measured.
Since Material locks the position, that call routes to reapplyLastPosition() (overlay.mjs:1363),
which re-measures the pane and re-applies the same position — it recomputes the bounding box but
will not flip to a fallback. A mitigation, not a guarantee.
3. Following the origin while open
Section titled “3. Following the origin while open”A ResizeObserver on the origin element, registered through NgZone.runOutsideAngular and torn
down by _stopWidthTracking on the first menuClosed and in ngOnDestroy
(fyz-mat-menu-trigger.directive.ts:400)
— the same five-point lifecycle as
the scroll tracking, including the defensive teardown
at the start of every open.
It earns its keep because the CDK repositions on ViewportRuler.change() but never re-measures the
origin’s width, so a responsive layout would otherwise move a stale-width menu. Callbacks that
report an unchanged width return early: a ResizeObserver reports the current size as soon as it is
connected, so without that guard every open would pay for a redundant resize and reposition.
Owning the observer here also removes a leak consumers kept reinventing — one disconnected from a
menuClosed handler alone survives a component destroyed with its menu still open.
Why it also drops the viewport margin
Section titled “Why it also drops the viewport margin”panelWidth: 'anchor' changes the default of viewportMargin from 8 to 0. An explicit
[viewportMargin] still wins, so a consumer can keep a margin and knowingly accept the offset.
The reason is
the CDK’s asymmetric viewport margin:
the leading edges get no margin at all and the trailing ones get twice the value, so an origin flush
against the right window edge has its exactly-matched panel pushed 16px inward. The spec
measures precisely that — removing the default makes “stays on an origin flush against the trailing
edge” fail with Expected 534 to be close to 550.
Dropping the margin is safe only because the match is exact: an overlay exactly as wide as its
origin is on screen whenever that origin is. That is why the 0 is tied to the 'anchor' value
rather than to the input being set at all.
Two alternatives were measured and rejected:
minWidthin theOverlayConfig, to unlock the flexible-dimensions branch (overlay.mjs:1579, which readsminWidthfrom the config — a mat-menu sets none). Fails at the trailing edge:availableWidth = viewport.right - point.xis192for a 200px origin flush right.minWidth = width - margin. Unlocks the branch, but then_calculateBoundingBoxRect(:1704) gives the bounding boxwidth = viewport.right - origin.x, and.cdk-overlay-pane { max-width: 100% }clamps the pane to it — the menu comes outmarginpx too narrow.
Why the application cannot happen earlier
Section titled “Why the application cannot happen earlier”The first version of this feature called updateSize() from inside _setPositionOverride, and it
silently removed the backdrop from every fyzMatMenuTrigger in the app — so no outside click
closed a menu any more, and clicks went through to the page behind it. Worth recording in full,
because the mechanism is invisible at the call site.
OverlayRef.updateSize() does not mutate the config, it replaces it
(node_modules/@angular/cdk/fesm2022/overlay.mjs:939):
updateSize(sizeConfig) { this._config = { ...this._config, ...sizeConfig }; // ← a new object this._updateElementSize();}And MatMenuTrigger.openMenu() captures that object by reference before calling _setPosition,
then writes to it after (menu.mjs:840-846):
const overlayConfig = overlayRef.getConfig(); // by reference — overlay.mjs:915this._setPosition(menu, positionStrategy); // ← updateSize() in here swaps _config outoverlayConfig.hasBackdrop = ...; // ← lands on the orphaned objectoverlayRef.attach(this._getPortal(menu)); // ← attach() reads this._config.hasBackdrop, :802So attach() saw hasBackdrop: undefined, _attachBackdrop() never ran, and
_menuClosingActions() lost its only outside-click source — this._overlayRef.backdropClick().
positionStrategy, backdropClass, panelClass and direction all survive the spread, which is
why positioning kept working perfectly and only the backdrop broke.
Applying the width after super.openMenu() returns avoids all of it: Material has written
hasBackdrop, attach() has consumed it, and replacing the config is then harmless.
Two things follow that are easy to lose:
- Nothing in the override may replace the overlay config while Material is holding it. Writing an individual property in place is fine; swapping the object is not.
- The three
panelWidthbackdrop specs exist for this. The behavioral one — open, click.cdk-overlay-backdrop, expect closed — is what would have caught it; the narrowergetConfig().hasBackdropassertion names the cause so a failure is diagnosable.
The ordering, precisely
Section titled “The ordering, precisely”It is worth recording what is not true here, because it reads as though it should be.
MatMenuTrigger.openMenu() (menu.mjs:835) runs _createOverlay() → _setPosition() →
overlayRef.attach(), and it is tempting to conclude that attach() measures the pane and
therefore that menuOpened — which emits after it — is too late to set a width. It is not.
OverlayRef.attach() never positions inline; it defers to the zone (overlay.mjs:793):
this._ngZone.onStable.pipe(take(1)).subscribe(() => { if (this.hasAttached()) this.updatePosition(); // ← apply() runs here, asynchronously});and FlexibleConnectedPositionStrategy.attach() (:1190) only wires up state — it never calls
apply(). So any width written synchronously during openMenu(), menuOpened included, lands
before the CDK measures anything. This was verified by removal: moving the updateSize() call to
after super.openMenu() leaves the whole suite green.
The practical consequence is the opposite of the intuition: the reason a consumer’s hand-rolled
version was wrong on the first open and right on every one after was never Material’s ordering. It
was that their width arrived through an async pipeline — a subject fed by a ResizeObserver, then
change detection — which lands after onStable, and on later opens the cached OverlayRef was
already carrying the previous width.
On a Material or CDK upgrade
Section titled “On a Material or CDK upgrade”ADR 0003 carries the checklist for the positioning override. This feature adds four items to re-check, and the spec next to the directive asserts every one of them by measuring rendered geometry:
.mat-mdc-menu-panelis still the panel’s class, and still a direct child of the pane. The weakest coupling here and the one that fails worst — everything else couples to a method or property name, this reaches into another component’s DOM and matches it by string. A rename makes the inline sizing a no-op and the panel silently reverts to112px–280px: no error, no failed build, just a wrong-looking menu. Deliberately nofirstElementChildfallback, which would upgrade a no-op into styling whatever happened to be there.MatMenuTrigger._overlayRefkeeps that name and is still assigned by_createOverlay()before_setPosition()runs.openMenu()still capturesgetConfig()before_setPositionand writeshasBackdropto it afterwards, so nothing in the override may replace the config object in between — see why the application cannot happen earlier. AndbackdropClick()is still the menu’s only outside-click close source, so losing the backdrop means losing the close.- The position strategy still does not write
style.widthon the pane. If_setOverlayElementStylesstarted managing it, the pane would be resized out from under us. _getOverlayFitstill measures the leading edge against a literal0. If the CDK made the viewport margin symmetric,withViewportMargin(0)would begin costing a real top/left margin and the default in theviewportMargingetter would need revisiting.
A tempting change to avoid: do not add withFlexibleDimensions(false) in the name of matching
autocomplete, which pairs it with width matching (autocomplete.mjs:905). Autocomplete can afford
to because .mat-mdc-autocomplete-panel caps its own height at 256px; .mat-mdc-menu-panel has
no height cap, so the flexible bounding box is the only thing keeping a long menu on screen.
Known limitations
Section titled “Known limitations”- A consumer rule with
!importantonwidth,min-widthormax-widthstill beats the directive’s inline style. That is the usual cascade, but it is exactly the kind of rule the old workaround left behind, so it is the first thing to check when the feature “does nothing”. - No minimum-width mode. Exact match only. A panel wider than its origin can overflow the viewport, which is what would make the dropped margin unsafe.
- No explicit length.
panelWidthtakes no pixel or CSS value. A fixed width has none of this feature’s problems —panelClassplus a consumer rule covers it — and a length input drags in three traps: a template attribute reachesstyle.widthas a unitless string, which is invalid CSS the CSSOM silently discards (coerceCssPixelValuepasses strings through untouched,node_modules/@angular/cdk/fesm2022/coercion.mjs:27-32);0coerces to'0px'rather than “unset”, a trap Material’s own autocomplete fell into (this.autocomplete.panelWidth || this._getHostWidth(),autocomplete.mjs:946, substitutes the host width for a0); and it forces a units decision. Widening the union later is not a breaking change. - Submenus are not a target. A
triggersSubmenu()trigger is amat-menu-item, so matching its width is rarely meaningful. Nothing blocks it; the input is simply off by default and does not inherit into nested triggers. openMenuAt(x, y)ignores the input — a menu opened at coordinates has no origin element.
Related
Section titled “Related”- mat-menu-trigger — the directive this input belongs to
- ADR 0003 — why the directive patches a private Material method at all