Skip to content

Commit 469bd2e

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 469bd2e

File tree

3 files changed

+46
-106
lines changed

3 files changed

+46
-106
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-57
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,9 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
307314

308315
/** Closes the menu. */
309316
closeMenu(): void {
310-
this.menu?.close.emit();
317+
if (this._menuOpen) {
318+
this.menu?.close.emit();
319+
}
311320
}
312321

313322
/**
@@ -335,7 +344,6 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
335344
return;
336345
}
337346

338-
const menu = this.menu;
339347
this._closingActionsSubscription.unsubscribe();
340348
this._overlayRef.detach();
341349

@@ -348,30 +356,10 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
348356
}
349357

350358
this._openedBy = undefined;
359+
this._setIsMenuOpen(false);
351360

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();
361+
if (this.menu && this._ownsMenu(this.menu)) {
362+
PANELS_TO_TRIGGERS.delete(this.menu);
375363
}
376364
}
377365

@@ -380,6 +368,15 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
380368
* the menu was opened via the keyboard.
381369
*/
382370
private _initMenu(menu: MatMenuPanel): void {
371+
const previousTrigger = PANELS_TO_TRIGGERS.get(menu);
372+
373+
// If the same menu is currently attached to another trigger,
374+
// we need to close it so it doesn't end up in a broken state.
375+
if (previousTrigger && previousTrigger !== this) {
376+
previousTrigger.closeMenu();
377+
}
378+
379+
PANELS_TO_TRIGGERS.set(menu, this);
383380
menu.parentMenu = this.triggersSubmenu() ? this._parentMaterialMenu : undefined;
384381
menu.direction = this.dir;
385382
menu.focusFirstItem(this._openedBy || 'program');
@@ -520,10 +517,9 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
520517
const detachments = this._overlayRef!.detachments();
521518
const parentClose = this._parentMaterialMenu ? this._parentMaterialMenu.closed : observableOf();
522519
const hover = this._parentMaterialMenu
523-
? this._parentMaterialMenu._hovered().pipe(
524-
filter(active => active !== this._menuItemInstance),
525-
filter(() => this._menuOpen),
526-
)
520+
? this._parentMaterialMenu
521+
._hovered()
522+
.pipe(filter(active => this._menuOpen && active !== this._menuItemInstance))
527523
: observableOf();
528524

529525
return merge(backdrop, parentClose as Observable<MenuCloseReason>, hover, detachments);
@@ -578,35 +574,14 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
578574
/** Handles the cases where the user hovers over the trigger. */
579575
private _handleHover() {
580576
// 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 {
577+
if (this.triggersSubmenu() && this._parentMaterialMenu) {
578+
this._hoverSubscription = this._parentMaterialMenu._hovered().subscribe(active => {
579+
if (active === this._menuItemInstance && !active.disabled) {
580+
this._openedBy = 'mouse';
607581
this.openMenu();
608582
}
609583
});
584+
}
610585
}
611586

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

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

src/material/menu/menu.spec.ts

-43
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)