Skip to content

Commit f119525

Browse files
committed
fix(material/menu): adjust overlay size when amount of items changes
Currently we lock the menu into a position after it is opened so that it doesn't jump around when the user scrolls, but this means that if the amount of items changes, it might not be the optimal position anymore. These changes add some code to re-calculate the position if the amount of items changes. Fixes #21456.
1 parent 2575b00 commit f119525

File tree

5 files changed

+71
-16
lines changed

5 files changed

+71
-16
lines changed

src/material-experimental/mdc-menu/menu.spec.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import {
4141
MockNgZone,
4242
} from '../../cdk/testing/private';
4343
import {Subject} from 'rxjs';
44-
import {ScrollDispatcher} from '@angular/cdk/scrolling';
44+
import {ScrollDispatcher, ViewportRuler} from '@angular/cdk/scrolling';
4545
import {FocusMonitor} from '@angular/cdk/a11y';
4646
import {
4747
MAT_MENU_SCROLL_STRATEGY,
@@ -57,6 +57,7 @@ const MENU_PANEL_TOP_PADDING = 8;
5757
describe('MDC-based MatMenu', () => {
5858
let overlayContainerElement: HTMLElement;
5959
let focusMonitor: FocusMonitor;
60+
let viewportRuler: ViewportRuler;
6061

6162
function createComponent<T>(
6263
component: Type<T>,
@@ -71,6 +72,7 @@ describe('MDC-based MatMenu', () => {
7172

7273
overlayContainerElement = TestBed.inject(OverlayContainer).getContainerElement();
7374
focusMonitor = TestBed.inject(FocusMonitor);
75+
viewportRuler = TestBed.inject(ViewportRuler);
7476
const fixture = TestBed.createComponent<T>(component);
7577
window.scroll(0, 0);
7678
return fixture;
@@ -1142,6 +1144,28 @@ describe('MDC-based MatMenu', () => {
11421144
expect(panel.classList).toContain('mat-menu-after');
11431145
}));
11441146

1147+
it('should keep the panel in the viewport when more items are added while open', () => {
1148+
const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
1149+
fixture.detectChanges();
1150+
1151+
const triggerEl = fixture.componentInstance.triggerEl.nativeElement;
1152+
triggerEl.style.position = 'absolute';
1153+
triggerEl.style.left = '200px';
1154+
triggerEl.style.bottom = '300px';
1155+
triggerEl.click();
1156+
fixture.detectChanges();
1157+
1158+
const panel = overlayContainerElement.querySelector('.mat-mdc-menu-panel')!;
1159+
const viewportHeight = viewportRuler.getViewportSize().height;
1160+
let panelRect = panel.getBoundingClientRect();
1161+
expect(Math.floor(panelRect.bottom)).toBeLessThan(viewportHeight);
1162+
1163+
fixture.componentInstance.extraItems = new Array(50).fill('Hello there');
1164+
fixture.detectChanges();
1165+
panelRect = panel.getBoundingClientRect();
1166+
expect(Math.floor(panelRect.bottom)).toBe(viewportHeight);
1167+
});
1168+
11451169
describe('lazy rendering', () => {
11461170
it('should be able to render the menu content lazily', fakeAsync(() => {
11471171
const fixture = createComponent(SimpleLazyMenu);

src/material/menu/menu-trigger.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,8 +278,9 @@ export abstract class _MatMenuTriggerBase implements AfterContentInit, OnDestroy
278278

279279
const overlayRef = this._createOverlay();
280280
const overlayConfig = overlayRef.getConfig();
281+
const positionStrategy = overlayConfig.positionStrategy as FlexibleConnectedPositionStrategy;
281282

282-
this._setPosition(overlayConfig.positionStrategy as FlexibleConnectedPositionStrategy);
283+
this._setPosition(positionStrategy);
283284
overlayConfig.hasBackdrop =
284285
this.menu.hasBackdrop == null ? !this.triggersSubmenu() : this.menu.hasBackdrop;
285286
overlayRef.attach(this._getPortal());
@@ -293,6 +294,12 @@ export abstract class _MatMenuTriggerBase implements AfterContentInit, OnDestroy
293294

294295
if (this.menu instanceof _MatMenuBase) {
295296
this.menu._startAnimation();
297+
this.menu._directDescendantItems.changes.pipe(takeUntil(this.menu.close)).subscribe(() => {
298+
// Re-adjust the position without locking when the amount of items
299+
// changes so that the overlay is allowed to pick a new optimal position.
300+
positionStrategy.withLockedPosition(false).reapplyLastPosition();
301+
positionStrategy.withLockedPosition(true);
302+
});
296303
}
297304
}
298305

src/material/menu/menu.spec.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
TAB,
1212
} from '@angular/cdk/keycodes';
1313
import {Overlay, OverlayContainer} from '@angular/cdk/overlay';
14-
import {ScrollDispatcher} from '@angular/cdk/scrolling';
14+
import {ScrollDispatcher, ViewportRuler} from '@angular/cdk/scrolling';
1515
import {
1616
createKeyboardEvent,
1717
createMouseEvent,
@@ -57,6 +57,7 @@ import {MAT_MENU_SCROLL_STRATEGY, MENU_PANEL_TOP_PADDING} from './menu-trigger';
5757
describe('MatMenu', () => {
5858
let overlayContainerElement: HTMLElement;
5959
let focusMonitor: FocusMonitor;
60+
let viewportRuler: ViewportRuler;
6061

6162
function createComponent<T>(
6263
component: Type<T>,
@@ -71,6 +72,7 @@ describe('MatMenu', () => {
7172

7273
overlayContainerElement = TestBed.inject(OverlayContainer).getContainerElement();
7374
focusMonitor = TestBed.inject(FocusMonitor);
75+
viewportRuler = TestBed.inject(ViewportRuler);
7476
const fixture = TestBed.createComponent<T>(component);
7577
window.scroll(0, 0);
7678
return fixture;
@@ -1137,6 +1139,28 @@ describe('MatMenu', () => {
11371139
expect(panel.classList).toContain('mat-menu-after');
11381140
}));
11391141

1142+
it('should keep the panel in the viewport when more items are added while open', () => {
1143+
const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
1144+
fixture.detectChanges();
1145+
1146+
const triggerEl = fixture.componentInstance.triggerEl.nativeElement;
1147+
triggerEl.style.position = 'absolute';
1148+
triggerEl.style.left = '200px';
1149+
triggerEl.style.bottom = '300px';
1150+
triggerEl.click();
1151+
fixture.detectChanges();
1152+
1153+
const panel = overlayContainerElement.querySelector('.mat-menu-panel')!;
1154+
const viewportHeight = viewportRuler.getViewportSize().height;
1155+
let panelRect = panel.getBoundingClientRect();
1156+
expect(Math.floor(panelRect.bottom)).toBeLessThan(viewportHeight);
1157+
1158+
fixture.componentInstance.extraItems = new Array(50).fill('Hello there');
1159+
fixture.detectChanges();
1160+
panelRect = panel.getBoundingClientRect();
1161+
expect(Math.floor(panelRect.bottom)).toBe(viewportHeight);
1162+
});
1163+
11401164
describe('lazy rendering', () => {
11411165
it('should be able to render the menu content lazily', fakeAsync(() => {
11421166
const fixture = createComponent(SimpleLazyMenu);

src/material/menu/menu.ts

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ export class _MatMenuBase
109109
@ContentChildren(MatMenuItem, {descendants: true}) _allItems: QueryList<MatMenuItem>;
110110

111111
/** Only the direct descendant menu items. */
112-
private _directDescendantItems = new QueryList<MatMenuItem>();
112+
_directDescendantItems = new QueryList<MatMenuItem>();
113113

114114
/** Subscription to tab events on the menu panel */
115115
private _tabSubscription = Subscription.EMPTY;
@@ -279,7 +279,9 @@ export class _MatMenuBase
279279
}
280280

281281
ngAfterContentInit() {
282-
this._updateDirectDescendants();
282+
this._updateDirectDescendants(this._allItems, false);
283+
this._allItems.changes.subscribe(items => this._updateDirectDescendants(items, true));
284+
283285
this._keyManager = new FocusKeyManager(this._directDescendantItems)
284286
.withWrap()
285287
.withTypeAhead()
@@ -487,18 +489,15 @@ export class _MatMenuBase
487489
}
488490

489491
/**
490-
* Sets up a stream that will keep track of any newly-added menu items and will update the list
491-
* of direct descendants. We collect the descendants this way, because `_allItems` can include
492-
* items that are part of child menus, and using a custom way of registering items is unreliable
493-
* when it comes to maintaining the item order.
492+
* Updates the list of direct descendant items by filtering the
493+
* items from a list who belong to this menu instance.
494494
*/
495-
private _updateDirectDescendants() {
496-
this._allItems.changes
497-
.pipe(startWith(this._allItems))
498-
.subscribe((items: QueryList<MatMenuItem>) => {
499-
this._directDescendantItems.reset(items.filter(item => item._parentMenu === this));
500-
this._directDescendantItems.notifyOnChanges();
501-
});
495+
private _updateDirectDescendants(items: QueryList<MatMenuItem>, shouldNotify: boolean) {
496+
this._directDescendantItems.reset(items.filter(item => item._parentMenu === this));
497+
498+
if (shouldNotify) {
499+
this._directDescendantItems.notifyOnChanges();
500+
}
502501
}
503502
}
504503

tools/public_api_guard/material/menu.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ export class _MatMenuBase implements AfterContentInit, MatMenuPanel<MatMenuItem>
110110
// @deprecated
111111
readonly close: EventEmitter<MenuCloseReason>;
112112
readonly closed: EventEmitter<MenuCloseReason>;
113+
_directDescendantItems: QueryList<MatMenuItem>;
113114
direction: Direction;
114115
// (undocumented)
115116
protected _elevationPrefix: string;

0 commit comments

Comments
 (0)