Skip to content

Commit 7739315

Browse files
authored
fix(material/menu): decouple menu lifecycle from animations (#30148)
Reworks the menu so that its removal isn't bound by animations. The current approach is somewhat brittle and makes it difficult to eventually switch to a fully CSS-based animation.
1 parent 0296713 commit 7739315

File tree

3 files changed

+47
-112
lines changed

3 files changed

+47
-112
lines changed

src/material/menu/menu-content.ts

+5-6
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ export class MatMenuContent implements OnDestroy {
4141
private _document = inject(DOCUMENT);
4242
private _changeDetectorRef = inject(ChangeDetectorRef);
4343

44-
private _portal: TemplatePortal<any>;
45-
private _outlet: DomPortalOutlet;
44+
private _portal: TemplatePortal<any> | undefined;
45+
private _outlet: DomPortalOutlet | undefined;
4646

4747
/** Emits when the menu content has been attached. */
4848
readonly _attached = new Subject<void>();
@@ -93,14 +93,13 @@ export class MatMenuContent implements OnDestroy {
9393
* @docs-private
9494
*/
9595
detach() {
96-
if (this._portal.isAttached) {
96+
if (this._portal?.isAttached) {
9797
this._portal.detach();
9898
}
9999
}
100100

101101
ngOnDestroy() {
102-
if (this._outlet) {
103-
this._outlet.dispose();
104-
}
102+
this.detach();
103+
this._outlet?.dispose();
105104
}
106105
}

src/material/menu/menu-trigger.ts

+41-59
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ import {
3939
ViewContainerRef,
4040
} from '@angular/core';
4141
import {normalizePassiveListenerOptions} from '@angular/cdk/platform';
42-
import {asapScheduler, merge, Observable, of as observableOf, Subscription} from 'rxjs';
43-
import {delay, filter, take, takeUntil} from 'rxjs/operators';
42+
import {merge, Observable, of as observableOf, Subscription} from 'rxjs';
43+
import {filter, takeUntil} from 'rxjs/operators';
4444
import {MatMenu, MenuCloseReason} from './menu';
4545
import {throwMatMenuRecursiveError} from './menu-errors';
4646
import {MatMenuItem} from './menu-item';
@@ -81,6 +81,9 @@ const passiveEventListenerOptions = normalizePassiveListenerOptions({passive: tr
8181
*/
8282
export const MENU_PANEL_TOP_PADDING = 8;
8383

84+
/** Mapping between menu panels and the last trigger that opened them. */
85+
const PANELS_TO_TRIGGERS = new WeakMap<MatMenuPanel, MatMenuTrigger>();
86+
8487
/** Directive applied to an element that should trigger a `mat-menu`. */
8588
@Directive({
8689
selector: `[mat-menu-trigger-for], [matMenuTriggerFor]`,
@@ -234,9 +237,8 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
234237
}
235238

236239
ngOnDestroy() {
237-
if (this._overlayRef) {
238-
this._overlayRef.dispose();
239-
this._overlayRef = null;
240+
if (this.menu && this._ownsMenu(this.menu)) {
241+
PANELS_TO_TRIGGERS.delete(this.menu);
240242
}
241243

242244
this._element.nativeElement.removeEventListener(
@@ -248,6 +250,11 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
248250
this._menuCloseSubscription.unsubscribe();
249251
this._closingActionsSubscription.unsubscribe();
250252
this._hoverSubscription.unsubscribe();
253+
254+
if (this._overlayRef) {
255+
this._overlayRef.dispose();
256+
this._overlayRef = null;
257+
}
251258
}
252259

253260
/** Whether the menu is open. */
@@ -335,7 +342,6 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
335342
return;
336343
}
337344

338-
const menu = this.menu;
339345
this._closingActionsSubscription.unsubscribe();
340346
this._overlayRef.detach();
341347

@@ -348,30 +354,10 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
348354
}
349355

350356
this._openedBy = undefined;
357+
this._setIsMenuOpen(false);
351358

352-
if (menu instanceof MatMenu) {
353-
menu._resetAnimation();
354-
355-
if (menu.lazyContent) {
356-
// Wait for the exit animation to finish before detaching the content.
357-
menu._animationDone
358-
.pipe(
359-
filter(event => event.toState === 'void'),
360-
take(1),
361-
// Interrupt if the content got re-attached.
362-
takeUntil(menu.lazyContent._attached),
363-
)
364-
.subscribe({
365-
next: () => menu.lazyContent!.detach(),
366-
// No matter whether the content got re-attached, reset the menu.
367-
complete: () => this._setIsMenuOpen(false),
368-
});
369-
} else {
370-
this._setIsMenuOpen(false);
371-
}
372-
} else {
373-
this._setIsMenuOpen(false);
374-
menu?.lazyContent?.detach();
359+
if (this.menu && this._ownsMenu(this.menu)) {
360+
PANELS_TO_TRIGGERS.delete(this.menu);
375361
}
376362
}
377363

@@ -380,6 +366,15 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
380366
* the menu was opened via the keyboard.
381367
*/
382368
private _initMenu(menu: MatMenuPanel): void {
369+
const previousTrigger = PANELS_TO_TRIGGERS.get(menu);
370+
371+
// If the same menu is currently attached to another trigger,
372+
// we need to close it so it doesn't end up in a broken state.
373+
if (previousTrigger && previousTrigger !== this) {
374+
previousTrigger.closeMenu();
375+
}
376+
377+
PANELS_TO_TRIGGERS.set(menu, this);
383378
menu.parentMenu = this.triggersSubmenu() ? this._parentMaterialMenu : undefined;
384379
menu.direction = this.dir;
385380
menu.focusFirstItem(this._openedBy || 'program');
@@ -520,10 +515,9 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
520515
const detachments = this._overlayRef!.detachments();
521516
const parentClose = this._parentMaterialMenu ? this._parentMaterialMenu.closed : observableOf();
522517
const hover = this._parentMaterialMenu
523-
? this._parentMaterialMenu._hovered().pipe(
524-
filter(active => active !== this._menuItemInstance),
525-
filter(() => this._menuOpen),
526-
)
518+
? this._parentMaterialMenu
519+
._hovered()
520+
.pipe(filter(active => this._menuOpen && active !== this._menuItemInstance))
527521
: observableOf();
528522

529523
return merge(backdrop, parentClose as Observable<MenuCloseReason>, hover, detachments);
@@ -578,35 +572,14 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
578572
/** Handles the cases where the user hovers over the trigger. */
579573
private _handleHover() {
580574
// Subscribe to changes in the hovered item in order to toggle the panel.
581-
if (!this.triggersSubmenu() || !this._parentMaterialMenu) {
582-
return;
583-
}
584-
585-
this._hoverSubscription = this._parentMaterialMenu
586-
._hovered()
587-
// Since we might have multiple competing triggers for the same menu (e.g. a sub-menu
588-
// with different data and triggers), we have to delay it by a tick to ensure that
589-
// it won't be closed immediately after it is opened.
590-
.pipe(
591-
filter(active => active === this._menuItemInstance && !active.disabled),
592-
delay(0, asapScheduler),
593-
)
594-
.subscribe(() => {
595-
this._openedBy = 'mouse';
596-
597-
// If the same menu is used between multiple triggers, it might still be animating
598-
// while the new trigger tries to re-open it. Wait for the animation to finish
599-
// before doing so. Also interrupt if the user moves to another item.
600-
if (this.menu instanceof MatMenu && this.menu._isAnimating) {
601-
// We need the `delay(0)` here in order to avoid
602-
// 'changed after checked' errors in some cases. See #12194.
603-
this.menu._animationDone
604-
.pipe(take(1), delay(0, asapScheduler), takeUntil(this._parentMaterialMenu!._hovered()))
605-
.subscribe(() => this.openMenu());
606-
} else {
575+
if (this.triggersSubmenu() && this._parentMaterialMenu) {
576+
this._hoverSubscription = this._parentMaterialMenu._hovered().subscribe(active => {
577+
if (active === this._menuItemInstance && !active.disabled) {
578+
this._openedBy = 'mouse';
607579
this.openMenu();
608580
}
609581
});
582+
}
610583
}
611584

612585
/** Gets the portal that should be attached to the overlay. */
@@ -620,4 +593,13 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
620593

621594
return this._portal;
622595
}
596+
597+
/**
598+
* Determines whether the trigger owns a specific menu panel, at the current point in time.
599+
* This allows us to distinguish the case where the same panel is passed into multiple triggers
600+
* and multiple are open at a time.
601+
*/
602+
private _ownsMenu(menu: MatMenuPanel): boolean {
603+
return PANELS_TO_TRIGGERS.get(menu) === this;
604+
}
623605
}

src/material/menu/menu.spec.ts

+1-47
Original file line numberDiff line numberDiff line change
@@ -1219,49 +1219,6 @@ describe('MatMenu', () => {
12191219
.toBe(true);
12201220
}));
12211221

1222-
it('should detach the lazy content when the menu is closed', fakeAsync(() => {
1223-
const fixture = createComponent(SimpleLazyMenu);
1224-
1225-
fixture.detectChanges();
1226-
fixture.componentInstance.trigger.openMenu();
1227-
fixture.detectChanges();
1228-
tick(500);
1229-
1230-
expect(fixture.componentInstance.items.length).toBeGreaterThan(0);
1231-
1232-
fixture.componentInstance.trigger.closeMenu();
1233-
fixture.detectChanges();
1234-
tick(500);
1235-
fixture.detectChanges();
1236-
1237-
expect(fixture.componentInstance.items.length).toBe(0);
1238-
}));
1239-
1240-
it('should wait for the close animation to finish before considering the panel as closed', fakeAsync(() => {
1241-
const fixture = createComponent(SimpleLazyMenu);
1242-
fixture.detectChanges();
1243-
const trigger = fixture.componentInstance.trigger;
1244-
1245-
expect(trigger.menuOpen).withContext('Expected menu to start off closed').toBe(false);
1246-
1247-
trigger.openMenu();
1248-
fixture.detectChanges();
1249-
tick(500);
1250-
1251-
expect(trigger.menuOpen).withContext('Expected menu to be open').toBe(true);
1252-
1253-
trigger.closeMenu();
1254-
fixture.detectChanges();
1255-
1256-
expect(trigger.menuOpen)
1257-
.withContext('Expected menu to be considered open while the close animation is running')
1258-
.toBe(true);
1259-
tick(500);
1260-
fixture.detectChanges();
1261-
1262-
expect(trigger.menuOpen).withContext('Expected menu to be closed').toBe(false);
1263-
}));
1264-
12651222
it('should focus the first menu item when opening a lazy menu via keyboard', async () => {
12661223
const fixture = createComponent(SimpleLazyMenu);
12671224
fixture.autoDetectChanges();
@@ -1741,15 +1698,12 @@ describe('MatMenu', () => {
17411698
}));
17421699

17431700
it('should complete the callback when the menu is destroyed', fakeAsync(() => {
1744-
const emitCallback = jasmine.createSpy('emit callback');
17451701
const completeCallback = jasmine.createSpy('complete callback');
17461702

1747-
fixture.componentInstance.menu.closed.subscribe(emitCallback, null, completeCallback);
1703+
fixture.componentInstance.menu.closed.subscribe(null, null, completeCallback);
17481704
fixture.destroy();
17491705
tick(500);
17501706

1751-
expect(emitCallback).toHaveBeenCalledWith(undefined);
1752-
expect(emitCallback).toHaveBeenCalledTimes(1);
17531707
expect(completeCallback).toHaveBeenCalled();
17541708
}));
17551709
});

0 commit comments

Comments
 (0)