Skip to content

Commit 856c016

Browse files
authored
feat(material/menu): allow for menu to be conditionally removed from trigger (#24437)
Adds support for conditionally removing the menu from a menu trigger by passing in `null`. Fixes #24030.
1 parent e86be88 commit 856c016

File tree

5 files changed

+82
-107
lines changed

5 files changed

+82
-107
lines changed

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

+13-14
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,19 @@ describe('MDC-based MatMenu', () => {
8989
);
9090
}));
9191

92+
it('should set aria-haspopup based on whether a menu is assigned', fakeAsync(() => {
93+
const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
94+
fixture.detectChanges();
95+
const triggerElement = fixture.componentInstance.triggerEl.nativeElement;
96+
97+
expect(triggerElement.getAttribute('aria-haspopup')).toBe('true');
98+
99+
fixture.componentInstance.trigger.menu = null;
100+
fixture.detectChanges();
101+
102+
expect(triggerElement.hasAttribute('aria-haspopup')).toBe(false);
103+
}));
104+
92105
it('should open the menu as an idempotent operation', fakeAsync(() => {
93106
const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
94107
fixture.detectChanges();
@@ -828,20 +841,6 @@ describe('MDC-based MatMenu', () => {
828841
expect(triggerEl.hasAttribute('aria-expanded')).toBe(false);
829842
}));
830843

831-
it('should throw the correct error if the menu is not defined after init', fakeAsync(() => {
832-
const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
833-
fixture.detectChanges();
834-
835-
fixture.componentInstance.trigger.menu = null!;
836-
fixture.detectChanges();
837-
838-
expect(() => {
839-
fixture.componentInstance.trigger.openMenu();
840-
fixture.detectChanges();
841-
tick(500);
842-
}).toThrowError(/must pass in an mat-menu instance/);
843-
}));
844-
845844
it('should throw if assigning a menu that contains the trigger', fakeAsync(() => {
846845
expect(() => {
847846
const fixture = createComponent(InvalidRecursiveMenu, [], [FakeIcon]);

src/material/menu/menu-errors.ts

-12
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,6 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
/**
10-
* Throws an exception for the case when menu trigger doesn't have a valid mat-menu instance
11-
* @docs-private
12-
*/
13-
export function throwMatMenuMissingError() {
14-
throw Error(`matMenuTriggerFor: must pass in an mat-menu instance.
15-
16-
Example:
17-
<mat-menu #menu="matMenu"></mat-menu>
18-
<button [matMenuTriggerFor]="menu"></button>`);
19-
}
20-
219
/**
2210
* Throws an exception for the case when menu's x-position value isn't valid.
2311
* In other words, it doesn't match 'before' or 'after'.

src/material/menu/menu-trigger.ts

+52-63
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ import {normalizePassiveListenerOptions} from '@angular/cdk/platform';
4343
import {asapScheduler, merge, Observable, of as observableOf, Subscription} from 'rxjs';
4444
import {delay, filter, take, takeUntil} from 'rxjs/operators';
4545
import {_MatMenuBase, MenuCloseReason} from './menu';
46-
import {throwMatMenuMissingError, throwMatMenuRecursiveError} from './menu-errors';
46+
import {throwMatMenuRecursiveError} from './menu-errors';
4747
import {MatMenuItem} from './menu-item';
4848
import {MAT_MENU_PANEL, MatMenuPanel} from './menu-panel';
4949
import {MenuPositionX, MenuPositionY} from './menu-positions';
@@ -75,7 +75,7 @@ const passiveEventListenerOptions = normalizePassiveListenerOptions({passive: tr
7575

7676
@Directive({
7777
host: {
78-
'aria-haspopup': 'true',
78+
'[attr.aria-haspopup]': 'menu ? true : null',
7979
'[attr.aria-expanded]': 'menuOpen || null',
8080
'[attr.aria-controls]': 'menuOpen ? menu.panelId : null',
8181
'(click)': '_handleClick($event)',
@@ -117,19 +117,19 @@ export abstract class _MatMenuTriggerBase implements AfterContentInit, OnDestroy
117117
* @breaking-change 8.0.0
118118
*/
119119
@Input('mat-menu-trigger-for')
120-
get _deprecatedMatMenuTriggerFor(): MatMenuPanel {
120+
get _deprecatedMatMenuTriggerFor(): MatMenuPanel | null {
121121
return this.menu;
122122
}
123-
set _deprecatedMatMenuTriggerFor(v: MatMenuPanel) {
123+
set _deprecatedMatMenuTriggerFor(v: MatMenuPanel | null) {
124124
this.menu = v;
125125
}
126126

127127
/** References the menu instance that the trigger is associated with. */
128128
@Input('matMenuTriggerFor')
129-
get menu() {
129+
get menu(): MatMenuPanel | null {
130130
return this._menu;
131131
}
132-
set menu(menu: MatMenuPanel) {
132+
set menu(menu: MatMenuPanel | null) {
133133
if (menu === this._menu) {
134134
return;
135135
}
@@ -152,7 +152,7 @@ export abstract class _MatMenuTriggerBase implements AfterContentInit, OnDestroy
152152
});
153153
}
154154
}
155-
private _menu: MatMenuPanel;
155+
private _menu: MatMenuPanel | null;
156156

157157
/** Data to be passed along to any lazily-rendered content. */
158158
@Input('matMenuTriggerData') menuData: any;
@@ -244,7 +244,6 @@ export abstract class _MatMenuTriggerBase implements AfterContentInit, OnDestroy
244244
}
245245

246246
ngAfterContentInit() {
247-
this._checkMenu();
248247
this._handleHover();
249248
}
250249

@@ -287,31 +286,31 @@ export abstract class _MatMenuTriggerBase implements AfterContentInit, OnDestroy
287286

288287
/** Opens the menu. */
289288
openMenu(): void {
290-
if (this._menuOpen) {
289+
const menu = this.menu;
290+
291+
if (this._menuOpen || !menu) {
291292
return;
292293
}
293294

294-
this._checkMenu();
295-
296-
const overlayRef = this._createOverlay();
295+
const overlayRef = this._createOverlay(menu);
297296
const overlayConfig = overlayRef.getConfig();
298297
const positionStrategy = overlayConfig.positionStrategy as FlexibleConnectedPositionStrategy;
299298

300-
this._setPosition(positionStrategy);
299+
this._setPosition(menu, positionStrategy);
301300
overlayConfig.hasBackdrop =
302-
this.menu.hasBackdrop == null ? !this.triggersSubmenu() : this.menu.hasBackdrop;
303-
overlayRef.attach(this._getPortal());
301+
menu.hasBackdrop == null ? !this.triggersSubmenu() : menu.hasBackdrop;
302+
overlayRef.attach(this._getPortal(menu));
304303

305-
if (this.menu.lazyContent) {
306-
this.menu.lazyContent.attach(this.menuData);
304+
if (menu.lazyContent) {
305+
menu.lazyContent.attach(this.menuData);
307306
}
308307

309308
this._closingActionsSubscription = this._menuClosingActions().subscribe(() => this.closeMenu());
310-
this._initMenu();
309+
this._initMenu(menu);
311310

312-
if (this.menu instanceof _MatMenuBase) {
313-
this.menu._startAnimation();
314-
this.menu._directDescendantItems.changes.pipe(takeUntil(this.menu.close)).subscribe(() => {
311+
if (menu instanceof _MatMenuBase) {
312+
menu._startAnimation();
313+
menu._directDescendantItems.changes.pipe(takeUntil(menu.close)).subscribe(() => {
315314
// Re-adjust the position without locking when the amount of items
316315
// changes so that the overlay is allowed to pick a new optimal position.
317316
positionStrategy.withLockedPosition(false).reapplyLastPosition();
@@ -322,7 +321,7 @@ export abstract class _MatMenuTriggerBase implements AfterContentInit, OnDestroy
322321

323322
/** Closes the menu. */
324323
closeMenu(): void {
325-
this.menu.close.emit();
324+
this.menu?.close.emit();
326325
}
327326

328327
/**
@@ -386,37 +385,34 @@ export abstract class _MatMenuTriggerBase implements AfterContentInit, OnDestroy
386385
}
387386
} else {
388387
this._setIsMenuOpen(false);
389-
390-
if (menu.lazyContent) {
391-
menu.lazyContent.detach();
392-
}
388+
menu?.lazyContent?.detach();
393389
}
394390
}
395391

396392
/**
397393
* This method sets the menu state to open and focuses the first item if
398394
* the menu was opened via the keyboard.
399395
*/
400-
private _initMenu(): void {
401-
this.menu.parentMenu = this.triggersSubmenu() ? this._parentMaterialMenu : undefined;
402-
this.menu.direction = this.dir;
403-
this._setMenuElevation();
404-
this.menu.focusFirstItem(this._openedBy || 'program');
396+
private _initMenu(menu: MatMenuPanel): void {
397+
menu.parentMenu = this.triggersSubmenu() ? this._parentMaterialMenu : undefined;
398+
menu.direction = this.dir;
399+
this._setMenuElevation(menu);
400+
menu.focusFirstItem(this._openedBy || 'program');
405401
this._setIsMenuOpen(true);
406402
}
407403

408404
/** Updates the menu elevation based on the amount of parent menus that it has. */
409-
private _setMenuElevation(): void {
410-
if (this.menu.setElevation) {
405+
private _setMenuElevation(menu: MatMenuPanel): void {
406+
if (menu.setElevation) {
411407
let depth = 0;
412-
let parentMenu = this.menu.parentMenu;
408+
let parentMenu = menu.parentMenu;
413409

414410
while (parentMenu) {
415411
depth++;
416412
parentMenu = parentMenu.parentMenu;
417413
}
418414

419-
this.menu.setElevation(depth);
415+
menu.setElevation(depth);
420416
}
421417
}
422418

@@ -430,24 +426,17 @@ export abstract class _MatMenuTriggerBase implements AfterContentInit, OnDestroy
430426
}
431427
}
432428

433-
/**
434-
* This method checks that a valid instance of MatMenu has been passed into
435-
* matMenuTriggerFor. If not, an exception is thrown.
436-
*/
437-
private _checkMenu() {
438-
if (!this.menu && (typeof ngDevMode === 'undefined' || ngDevMode)) {
439-
throwMatMenuMissingError();
440-
}
441-
}
442-
443429
/**
444430
* This method creates the overlay from the provided menu's template and saves its
445431
* OverlayRef so that it can be attached to the DOM when openMenu is called.
446432
*/
447-
private _createOverlay(): OverlayRef {
433+
private _createOverlay(menu: MatMenuPanel): OverlayRef {
448434
if (!this._overlayRef) {
449-
const config = this._getOverlayConfig();
450-
this._subscribeToPositions(config.positionStrategy as FlexibleConnectedPositionStrategy);
435+
const config = this._getOverlayConfig(menu);
436+
this._subscribeToPositions(
437+
menu,
438+
config.positionStrategy as FlexibleConnectedPositionStrategy,
439+
);
451440
this._overlayRef = this._overlay.create(config);
452441

453442
// Consume the `keydownEvents` in order to prevent them from going to another overlay.
@@ -463,16 +452,16 @@ export abstract class _MatMenuTriggerBase implements AfterContentInit, OnDestroy
463452
* This method builds the configuration object needed to create the overlay, the OverlayState.
464453
* @returns OverlayConfig
465454
*/
466-
private _getOverlayConfig(): OverlayConfig {
455+
private _getOverlayConfig(menu: MatMenuPanel): OverlayConfig {
467456
return new OverlayConfig({
468457
positionStrategy: this._overlay
469458
.position()
470459
.flexibleConnectedTo(this._element)
471460
.withLockedPosition()
472461
.withGrowAfterOpen()
473462
.withTransformOriginOn('.mat-menu-panel, .mat-mdc-menu-panel'),
474-
backdropClass: this.menu.backdropClass || 'cdk-overlay-transparent-backdrop',
475-
panelClass: this.menu.overlayPanelClass,
463+
backdropClass: menu.backdropClass || 'cdk-overlay-transparent-backdrop',
464+
panelClass: menu.overlayPanelClass,
476465
scrollStrategy: this._scrollStrategy(),
477466
direction: this._dir,
478467
});
@@ -483,8 +472,8 @@ export abstract class _MatMenuTriggerBase implements AfterContentInit, OnDestroy
483472
* on the menu based on the new position. This ensures the animation origin is always
484473
* correct, even if a fallback position is used for the overlay.
485474
*/
486-
private _subscribeToPositions(position: FlexibleConnectedPositionStrategy): void {
487-
if (this.menu.setPositionClasses) {
475+
private _subscribeToPositions(menu: MatMenuPanel, position: FlexibleConnectedPositionStrategy) {
476+
if (menu.setPositionClasses) {
488477
position.positionChanges.subscribe(change => {
489478
const posX: MenuPositionX = change.connectionPair.overlayX === 'start' ? 'after' : 'before';
490479
const posY: MenuPositionY = change.connectionPair.overlayY === 'top' ? 'below' : 'above';
@@ -493,9 +482,9 @@ export abstract class _MatMenuTriggerBase implements AfterContentInit, OnDestroy
493482
// `positionChanges` fires outside of the `ngZone` and `setPositionClasses` might be
494483
// updating something in the view so we need to bring it back in.
495484
if (this._ngZone) {
496-
this._ngZone.run(() => this.menu.setPositionClasses!(posX, posY));
485+
this._ngZone.run(() => menu.setPositionClasses!(posX, posY));
497486
} else {
498-
this.menu.setPositionClasses!(posX, posY);
487+
menu.setPositionClasses!(posX, posY);
499488
}
500489
});
501490
}
@@ -506,12 +495,12 @@ export abstract class _MatMenuTriggerBase implements AfterContentInit, OnDestroy
506495
* so the overlay connects with the trigger correctly.
507496
* @param positionStrategy Strategy whose position to update.
508497
*/
509-
private _setPosition(positionStrategy: FlexibleConnectedPositionStrategy) {
498+
private _setPosition(menu: MatMenuPanel, positionStrategy: FlexibleConnectedPositionStrategy) {
510499
let [originX, originFallbackX]: HorizontalConnectionPos[] =
511-
this.menu.xPosition === 'before' ? ['end', 'start'] : ['start', 'end'];
500+
menu.xPosition === 'before' ? ['end', 'start'] : ['start', 'end'];
512501

513502
let [overlayY, overlayFallbackY]: VerticalConnectionPos[] =
514-
this.menu.yPosition === 'above' ? ['bottom', 'top'] : ['top', 'bottom'];
503+
menu.yPosition === 'above' ? ['bottom', 'top'] : ['top', 'bottom'];
515504

516505
let [originY, originFallbackY] = [overlayY, overlayFallbackY];
517506
let [overlayX, overlayFallbackX] = [originX, originFallbackX];
@@ -520,10 +509,10 @@ export abstract class _MatMenuTriggerBase implements AfterContentInit, OnDestroy
520509
if (this.triggersSubmenu()) {
521510
// When the menu is a sub-menu, it should always align itself
522511
// to the edges of the trigger, instead of overlapping it.
523-
overlayFallbackX = originX = this.menu.xPosition === 'before' ? 'start' : 'end';
512+
overlayFallbackX = originX = menu.xPosition === 'before' ? 'start' : 'end';
524513
originFallbackX = overlayX = originX === 'end' ? 'start' : 'end';
525514
offsetY = overlayY === 'bottom' ? MENU_PANEL_TOP_PADDING : -MENU_PANEL_TOP_PADDING;
526-
} else if (!this.menu.overlapTrigger) {
515+
} else if (!menu.overlapTrigger) {
527516
originY = overlayY === 'top' ? 'bottom' : 'top';
528517
originFallbackY = overlayFallbackY === 'top' ? 'bottom' : 'top';
529518
}
@@ -644,12 +633,12 @@ export abstract class _MatMenuTriggerBase implements AfterContentInit, OnDestroy
644633
}
645634

646635
/** Gets the portal that should be attached to the overlay. */
647-
private _getPortal(): TemplatePortal {
636+
private _getPortal(menu: MatMenuPanel): TemplatePortal {
648637
// Note that we can avoid this check by keeping the portal on the menu panel.
649638
// While it would be cleaner, we'd have to introduce another required method on
650639
// `MatMenuPanel`, making it harder to consume.
651-
if (!this._portal || this._portal.templateRef !== this.menu.templateRef) {
652-
this._portal = new TemplatePortal(this.menu.templateRef, this._viewContainerRef);
640+
if (!this._portal || this._portal.templateRef !== menu.templateRef) {
641+
this._portal = new TemplatePortal(menu.templateRef, this._viewContainerRef);
653642
}
654643

655644
return this._portal;

src/material/menu/menu.spec.ts

+13-14
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,19 @@ describe('MatMenu', () => {
8989
);
9090
}));
9191

92+
it('should set aria-haspopup based on whether a menu is assigned', fakeAsync(() => {
93+
const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
94+
fixture.detectChanges();
95+
const triggerElement = fixture.componentInstance.triggerEl.nativeElement;
96+
97+
expect(triggerElement.getAttribute('aria-haspopup')).toBe('true');
98+
99+
fixture.componentInstance.trigger.menu = null;
100+
fixture.detectChanges();
101+
102+
expect(triggerElement.hasAttribute('aria-haspopup')).toBe(false);
103+
}));
104+
92105
it('should open the menu as an idempotent operation', fakeAsync(() => {
93106
const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
94107
fixture.detectChanges();
@@ -827,20 +840,6 @@ describe('MatMenu', () => {
827840
expect(triggerEl.hasAttribute('aria-expanded')).toBe(false);
828841
}));
829842

830-
it('should throw the correct error if the menu is not defined after init', fakeAsync(() => {
831-
const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
832-
fixture.detectChanges();
833-
834-
fixture.componentInstance.trigger.menu = null!;
835-
fixture.detectChanges();
836-
837-
expect(() => {
838-
fixture.componentInstance.trigger.openMenu();
839-
fixture.detectChanges();
840-
tick(500);
841-
}).toThrowError(/must pass in an mat-menu instance/);
842-
}));
843-
844843
it('should throw if assigning a menu that contains the trigger', fakeAsync(() => {
845844
expect(() => {
846845
const fixture = createComponent(InvalidRecursiveMenu, [], [FakeIcon]);

0 commit comments

Comments
 (0)