Skip to content

Commit eb34a48

Browse files
committed
fix(material/menu): decouple menu lifecycle from animations
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 0c40595 commit eb34a48

File tree

3 files changed

+47
-104
lines changed

3 files changed

+47
-104
lines changed

src/material/menu/menu-content.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export class MatMenuContent implements OnDestroy {
4242
private _changeDetectorRef = inject(ChangeDetectorRef);
4343

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

4747
/** Emits when the menu content has been attached. */
4848
readonly _attached = new Subject<void>();
@@ -99,8 +99,7 @@ export class MatMenuContent implements OnDestroy {
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

Lines changed: 44 additions & 57 deletions
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]`,
@@ -239,6 +242,10 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
239242
this._overlayRef = null;
240243
}
241244

245+
if (this.menu && this._ownsMenu(this.menu)) {
246+
PANELS_TO_TRIGGERS.delete(this.menu);
247+
}
248+
242249
this._element.nativeElement.removeEventListener(
243250
'touchstart',
244251
this._handleTouchStart,
@@ -307,7 +314,19 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
307314

308315
/** Closes the menu. */
309316
closeMenu(): void {
310-
this.menu?.close.emit();
317+
const menu = this.menu;
318+
319+
if (menu) {
320+
// If the trigger still "owns" the panel (e.g. no other trigger opened it while the current
321+
// trigger is still open), we can kick off the regular closing sequence which waits for the
322+
// animation before detaching. Otherwise we remove the overlay immediately, because its
323+
// content will soon be moved over into a new menu, leaving the panel empty.
324+
if (this._ownsMenu(menu)) {
325+
menu.close.emit();
326+
} else {
327+
this._destroyMenu();
328+
}
329+
}
311330
}
312331

313332
/**
@@ -335,7 +354,6 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
335354
return;
336355
}
337356

338-
const menu = this.menu;
339357
this._closingActionsSubscription.unsubscribe();
340358
this._overlayRef.detach();
341359

@@ -348,30 +366,10 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
348366
}
349367

350368
this._openedBy = undefined;
369+
this._setIsMenuOpen(false);
351370

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();
371+
if (this.menu && this._ownsMenu(this.menu)) {
372+
PANELS_TO_TRIGGERS.delete(this.menu);
375373
}
376374
}
377375

@@ -380,6 +378,8 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
380378
* the menu was opened via the keyboard.
381379
*/
382380
private _initMenu(menu: MatMenuPanel): void {
381+
// The most recent trigger is considered the "owner" of the panel.
382+
PANELS_TO_TRIGGERS.set(menu, this);
383383
menu.parentMenu = this.triggersSubmenu() ? this._parentMaterialMenu : undefined;
384384
menu.direction = this.dir;
385385
menu.focusFirstItem(this._openedBy || 'program');
@@ -520,10 +520,9 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
520520
const detachments = this._overlayRef!.detachments();
521521
const parentClose = this._parentMaterialMenu ? this._parentMaterialMenu.closed : observableOf();
522522
const hover = this._parentMaterialMenu
523-
? this._parentMaterialMenu._hovered().pipe(
524-
filter(active => active !== this._menuItemInstance),
525-
filter(() => this._menuOpen),
526-
)
523+
? this._parentMaterialMenu
524+
._hovered()
525+
.pipe(filter(active => this._menuOpen && active !== this._menuItemInstance))
527526
: observableOf();
528527

529528
return merge(backdrop, parentClose as Observable<MenuCloseReason>, hover, detachments);
@@ -578,35 +577,14 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
578577
/** Handles the cases where the user hovers over the trigger. */
579578
private _handleHover() {
580579
// 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 {
580+
if (this.triggersSubmenu() && this._parentMaterialMenu) {
581+
this._hoverSubscription = this._parentMaterialMenu._hovered().subscribe(active => {
582+
if (active === this._menuItemInstance && !active.disabled) {
583+
this._openedBy = 'mouse';
607584
this.openMenu();
608585
}
609586
});
587+
}
610588
}
611589

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

621599
return this._portal;
622600
}
601+
602+
/**
603+
* Determines whether the trigger owns a specific menu panel, at the current point in time.
604+
* This allows us to distinguish the case where the same panel is passed into multiple triggers
605+
* and multiple are open at a time.
606+
*/
607+
private _ownsMenu(menu: MatMenuPanel): boolean {
608+
return PANELS_TO_TRIGGERS.get(menu) === this;
609+
}
623610
}

src/material/menu/menu.spec.ts

Lines changed: 0 additions & 43 deletions
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();

0 commit comments

Comments
 (0)