Skip to content

`panelWidth`: sizing the menu panel to its origin

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

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

ValueEffect
'anchor'the menu is exactly as wide as the element it attaches to, and stays that way
nullthe directive does nothing — Material’s own 112px280px 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.

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.

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.

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 112px280px.

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.

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.

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:

  • minWidth in the OverlayConfig, to unlock the flexible-dimensions branch (overlay.mjs:1579, which reads minWidth from the config — a mat-menu sets none). Fails at the trailing edge: availableWidth = viewport.right - point.x is 192 for a 200px origin flush right.
  • minWidth = width - margin. Unlocks the branch, but then _calculateBoundingBoxRect (:1704) gives the bounding box width = viewport.right - origin.x, and .cdk-overlay-pane { max-width: 100% } clamps the pane to it — the menu comes out margin px too narrow.

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:915
this._setPosition(menu, positionStrategy); // ← updateSize() in here swaps _config out
overlayConfig.hasBackdrop = ...; // ← lands on the orphaned object
overlayRef.attach(this._getPortal(menu)); // ← attach() reads this._config.hasBackdrop, :802

So 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 panelWidth backdrop specs exist for this. The behavioral one — open, click .cdk-overlay-backdrop, expect closed — is what would have caught it; the narrower getConfig().hasBackdrop assertion names the cause so a failure is diagnosable.

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.

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-panel is 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 to 112px280px: no error, no failed build, just a wrong-looking menu. Deliberately no firstElementChild fallback, which would upgrade a no-op into styling whatever happened to be there.
  • MatMenuTrigger._overlayRef keeps that name and is still assigned by _createOverlay() before _setPosition() runs.
  • openMenu() still captures getConfig() before _setPosition and writes hasBackdrop to it afterwards, so nothing in the override may replace the config object in between — see why the application cannot happen earlier. And backdropClick() 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.width on the pane. If _setOverlayElementStyles started managing it, the pane would be resized out from under us.
  • _getOverlayFit still measures the leading edge against a literal 0. If the CDK made the viewport margin symmetric, withViewportMargin(0) would begin costing a real top/left margin and the default in the viewportMargin getter 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.

  • A consumer rule with !important on width, min-width or max-width still 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. panelWidth takes no pixel or CSS value. A fixed width has none of this feature’s problems — panelClass plus a consumer rule covers it — and a length input drags in three traps: a template attribute reaches style.width as a unitless string, which is invalid CSS the CSSOM silently discards (coerceCssPixelValue passes strings through untouched, node_modules/@angular/cdk/fesm2022/coercion.mjs:27-32); 0 coerces 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 a 0); and it forces a units decision. Widening the union later is not a breaking change.
  • Submenus are not a target. A triggersSubmenu() trigger is a mat-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.
  • mat-menu-trigger — the directive this input belongs to
  • ADR 0003 — why the directive patches a private Material method at all