Skip to content

Commit 4620df1

Browse files
authored
fix(material/menu): remove dependency on animations module (#30163)
Second attempt at reworking the menu so it no longer depends on the animations module (after #30099). This should allow us to avoid some of the pitfalls of the animations module like occasional memory leaks (e.g. #47748) and reduce the bundle size.
1 parent a141c22 commit 4620df1

File tree

6 files changed

+166
-83
lines changed

6 files changed

+166
-83
lines changed

src/material/menu/menu-animations.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import {
2020
* Animation duration and timing values are based on:
2121
* https://material.io/guidelines/components/menus.html#menus-usage
2222
* @docs-private
23+
* @deprecated No longer used, will be removed.
24+
* @breaking-change 21.0.0
2325
*/
2426
export const matMenuAnimations: {
2527
readonly transformMenu: AnimationTriggerMetadata;

src/material/menu/menu-trigger.ts

Lines changed: 41 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import {
4040
} from '@angular/core';
4141
import {normalizePassiveListenerOptions} from '@angular/cdk/platform';
4242
import {merge, Observable, of as observableOf, Subscription} from 'rxjs';
43-
import {filter, takeUntil} from 'rxjs/operators';
43+
import {filter, take, takeUntil} from 'rxjs/operators';
4444
import {MatMenu, MenuCloseReason} from './menu';
4545
import {throwMatMenuRecursiveError} from './menu-errors';
4646
import {MatMenuItem} from './menu-item';
@@ -115,6 +115,7 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
115115
private _closingActionsSubscription = Subscription.EMPTY;
116116
private _hoverSubscription = Subscription.EMPTY;
117117
private _menuCloseSubscription = Subscription.EMPTY;
118+
private _pendingRemoval: Subscription | undefined;
118119

119120
/**
120121
* We're specifically looking for a `MatMenu` here since the generic `MatMenuPanel`
@@ -247,6 +248,7 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
247248
passiveEventListenerOptions,
248249
);
249250

251+
this._pendingRemoval?.unsubscribe();
250252
this._menuCloseSubscription.unsubscribe();
251253
this._closingActionsSubscription.unsubscribe();
252254
this._hoverSubscription.unsubscribe();
@@ -285,24 +287,39 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
285287
return;
286288
}
287289

290+
this._pendingRemoval?.unsubscribe();
291+
const previousTrigger = PANELS_TO_TRIGGERS.get(menu);
292+
PANELS_TO_TRIGGERS.set(menu, this);
293+
294+
// If the same menu is currently attached to another trigger,
295+
// we need to close it so it doesn't end up in a broken state.
296+
if (previousTrigger && previousTrigger !== this) {
297+
previousTrigger.closeMenu();
298+
}
299+
288300
const overlayRef = this._createOverlay(menu);
289301
const overlayConfig = overlayRef.getConfig();
290302
const positionStrategy = overlayConfig.positionStrategy as FlexibleConnectedPositionStrategy;
291303

292304
this._setPosition(menu, positionStrategy);
293305
overlayConfig.hasBackdrop =
294306
menu.hasBackdrop == null ? !this.triggersSubmenu() : menu.hasBackdrop;
295-
overlayRef.attach(this._getPortal(menu));
296307

297-
if (menu.lazyContent) {
298-
menu.lazyContent.attach(this.menuData);
308+
// We need the `hasAttached` check for the case where the user kicked off a removal animation,
309+
// but re-entered the menu. Re-attaching the same portal will trigger an error otherwise.
310+
if (!overlayRef.hasAttached()) {
311+
overlayRef.attach(this._getPortal(menu));
312+
menu.lazyContent?.attach(this.menuData);
299313
}
300314

301315
this._closingActionsSubscription = this._menuClosingActions().subscribe(() => this.closeMenu());
302-
this._initMenu(menu);
316+
menu.parentMenu = this.triggersSubmenu() ? this._parentMaterialMenu : undefined;
317+
menu.direction = this.dir;
318+
menu.focusFirstItem(this._openedBy || 'program');
319+
this._setIsMenuOpen(true);
303320

304321
if (menu instanceof MatMenu) {
305-
menu._startAnimation();
322+
menu._setIsOpen(true);
306323
menu._directDescendantItems.changes.pipe(takeUntil(menu.close)).subscribe(() => {
307324
// Re-adjust the position without locking when the amount of items
308325
// changes so that the overlay is allowed to pick a new optimal position.
@@ -338,12 +355,28 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
338355

339356
/** Closes the menu and does the necessary cleanup. */
340357
private _destroyMenu(reason: MenuCloseReason) {
341-
if (!this._overlayRef || !this.menuOpen) {
358+
const overlayRef = this._overlayRef;
359+
const menu = this._menu;
360+
361+
if (!overlayRef || !this.menuOpen) {
342362
return;
343363
}
344364

345365
this._closingActionsSubscription.unsubscribe();
346-
this._overlayRef.detach();
366+
this._pendingRemoval?.unsubscribe();
367+
368+
// Note that we don't wait for the animation to finish if another trigger took
369+
// over the menu, because the panel will end up empty which looks glitchy.
370+
if (menu instanceof MatMenu && this._ownsMenu(menu)) {
371+
this._pendingRemoval = menu._animationDone.pipe(take(1)).subscribe(() => overlayRef.detach());
372+
menu._setIsOpen(false);
373+
} else {
374+
overlayRef.detach();
375+
}
376+
377+
if (menu && this._ownsMenu(menu)) {
378+
PANELS_TO_TRIGGERS.delete(menu);
379+
}
347380

348381
// Always restore focus if the user is navigating using the keyboard or the menu was opened
349382
// programmatically. We don't restore for non-root triggers, because it can prevent focus
@@ -355,30 +388,6 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
355388

356389
this._openedBy = undefined;
357390
this._setIsMenuOpen(false);
358-
359-
if (this.menu && this._ownsMenu(this.menu)) {
360-
PANELS_TO_TRIGGERS.delete(this.menu);
361-
}
362-
}
363-
364-
/**
365-
* This method sets the menu state to open and focuses the first item if
366-
* the menu was opened via the keyboard.
367-
*/
368-
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);
378-
menu.parentMenu = this.triggersSubmenu() ? this._parentMaterialMenu : undefined;
379-
menu.direction = this.dir;
380-
menu.focusFirstItem(this._openedBy || 'program');
381-
this._setIsMenuOpen(true);
382391
}
383392

384393
// set state rather than toggle to support triggers sharing a menu

src/material/menu/menu.html

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@
33
class="mat-mdc-menu-panel"
44
[id]="panelId"
55
[class]="_classList"
6+
[class.mat-menu-panel-animations-disabled]="_animationsDisabled"
7+
[class.mat-menu-panel-exit-animation]="_panelAnimationState === 'void'"
8+
[class.mat-menu-panel-animating]="_isAnimating"
69
(click)="closed.emit('click')"
7-
[@transformMenu]="_panelAnimationState"
8-
(@transformMenu.start)="_onAnimationStart($event)"
9-
(@transformMenu.done)="_onAnimationDone($event)"
1010
tabindex="-1"
1111
role="menu"
12+
(animationstart)="_onAnimationStart($event.animationName)"
13+
(animationend)="_onAnimationDone($event.animationName)"
14+
(animationcancel)="_onAnimationDone($event.animationName)"
1215
[attr.aria-label]="ariaLabel || null"
1316
[attr.aria-labelledby]="ariaLabelledby || null"
1417
[attr.aria-describedby]="ariaDescribedby || null">

src/material/menu/menu.scss

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,33 @@ mat-menu {
3131
}
3232
}
3333

34+
@keyframes _mat-menu-enter {
35+
from {
36+
opacity: 0;
37+
transform: scale(0.8);
38+
}
39+
40+
to {
41+
opacity: 1;
42+
transform: none;
43+
}
44+
}
45+
46+
@keyframes _mat-menu-exit {
47+
from {
48+
opacity: 1;
49+
}
50+
51+
to {
52+
opacity: 0;
53+
}
54+
}
55+
3456
.mat-mdc-menu-panel {
3557
@include menu-common.base;
3658
box-sizing: border-box;
3759
outline: 0;
60+
animation: _mat-menu-enter 120ms cubic-bezier(0, 0, 0.2, 1);
3861

3962
@include token-utils.use-tokens(tokens-mat-menu.$prefix, tokens-mat-menu.get-token-slots()) {
4063
@include token-utils.create-token-slot(border-radius, container-shape);
@@ -48,14 +71,22 @@ mat-menu {
4871
// We should clean it up eventually and re-approve all the screenshots.
4972
will-change: transform, opacity;
5073

74+
&.mat-menu-panel-exit-animation {
75+
animation: _mat-menu-exit 100ms 25ms linear forwards;
76+
}
77+
78+
&.mat-menu-panel-animations-disabled {
79+
animation: none;
80+
}
81+
5182
// Prevent users from interacting with the panel while it's animating. Note that
5283
// people won't be able to click through it, because the overlay pane will catch the click.
5384
// This fixes the following issues:
5485
// * Users accidentally opening sub-menus when the `overlapTrigger` option is enabled.
5586
// * Users accidentally tapping on content inside the sub-menu on touch devices, if the
5687
// sub-menu overlaps the trigger. The issue is due to touch devices emulating the
5788
// `mouseenter` event by dispatching it on tap.
58-
&.ng-animating {
89+
&.mat-menu-panel-animating {
5990
pointer-events: none;
6091

6192
// If the same menu is assigned to multiple triggers and the user moves quickly between them

0 commit comments

Comments
 (0)