diff --git a/src/material-experimental/mdc-menu/menu.spec.ts b/src/material-experimental/mdc-menu/menu.spec.ts index 36023c0fb6b0..09e6b24cfba0 100644 --- a/src/material-experimental/mdc-menu/menu.spec.ts +++ b/src/material-experimental/mdc-menu/menu.spec.ts @@ -89,6 +89,19 @@ describe('MDC-based MatMenu', () => { ); })); + it('should set aria-haspopup based on whether a menu is assigned', fakeAsync(() => { + const fixture = createComponent(SimpleMenu, [], [FakeIcon]); + fixture.detectChanges(); + const triggerElement = fixture.componentInstance.triggerEl.nativeElement; + + expect(triggerElement.getAttribute('aria-haspopup')).toBe('true'); + + fixture.componentInstance.trigger.menu = null; + fixture.detectChanges(); + + expect(triggerElement.hasAttribute('aria-haspopup')).toBe(false); + })); + it('should open the menu as an idempotent operation', fakeAsync(() => { const fixture = createComponent(SimpleMenu, [], [FakeIcon]); fixture.detectChanges(); @@ -828,20 +841,6 @@ describe('MDC-based MatMenu', () => { expect(triggerEl.hasAttribute('aria-expanded')).toBe(false); })); - it('should throw the correct error if the menu is not defined after init', fakeAsync(() => { - const fixture = createComponent(SimpleMenu, [], [FakeIcon]); - fixture.detectChanges(); - - fixture.componentInstance.trigger.menu = null!; - fixture.detectChanges(); - - expect(() => { - fixture.componentInstance.trigger.openMenu(); - fixture.detectChanges(); - tick(500); - }).toThrowError(/must pass in an mat-menu instance/); - })); - it('should throw if assigning a menu that contains the trigger', fakeAsync(() => { expect(() => { const fixture = createComponent(InvalidRecursiveMenu, [], [FakeIcon]); diff --git a/src/material/menu/menu-errors.ts b/src/material/menu/menu-errors.ts index 16a855a38e75..6027d4e3da5d 100644 --- a/src/material/menu/menu-errors.ts +++ b/src/material/menu/menu-errors.ts @@ -6,18 +6,6 @@ * found in the LICENSE file at https://angular.io/license */ -/** - * Throws an exception for the case when menu trigger doesn't have a valid mat-menu instance - * @docs-private - */ -export function throwMatMenuMissingError() { - throw Error(`matMenuTriggerFor: must pass in an mat-menu instance. - - Example: - - `); -} - /** * Throws an exception for the case when menu's x-position value isn't valid. * In other words, it doesn't match 'before' or 'after'. diff --git a/src/material/menu/menu-trigger.ts b/src/material/menu/menu-trigger.ts index ff1b4c208f20..fcfdbf44a325 100644 --- a/src/material/menu/menu-trigger.ts +++ b/src/material/menu/menu-trigger.ts @@ -43,7 +43,7 @@ import {normalizePassiveListenerOptions} from '@angular/cdk/platform'; import {asapScheduler, merge, Observable, of as observableOf, Subscription} from 'rxjs'; import {delay, filter, take, takeUntil} from 'rxjs/operators'; import {_MatMenuBase, MenuCloseReason} from './menu'; -import {throwMatMenuMissingError, throwMatMenuRecursiveError} from './menu-errors'; +import {throwMatMenuRecursiveError} from './menu-errors'; import {MatMenuItem} from './menu-item'; import {MAT_MENU_PANEL, MatMenuPanel} from './menu-panel'; import {MenuPositionX, MenuPositionY} from './menu-positions'; @@ -75,7 +75,7 @@ const passiveEventListenerOptions = normalizePassiveListenerOptions({passive: tr @Directive({ host: { - 'aria-haspopup': 'true', + '[attr.aria-haspopup]': 'menu ? true : null', '[attr.aria-expanded]': 'menuOpen || null', '[attr.aria-controls]': 'menuOpen ? menu.panelId : null', '(click)': '_handleClick($event)', @@ -117,19 +117,19 @@ export abstract class _MatMenuTriggerBase implements AfterContentInit, OnDestroy * @breaking-change 8.0.0 */ @Input('mat-menu-trigger-for') - get _deprecatedMatMenuTriggerFor(): MatMenuPanel { + get _deprecatedMatMenuTriggerFor(): MatMenuPanel | null { return this.menu; } - set _deprecatedMatMenuTriggerFor(v: MatMenuPanel) { + set _deprecatedMatMenuTriggerFor(v: MatMenuPanel | null) { this.menu = v; } /** References the menu instance that the trigger is associated with. */ @Input('matMenuTriggerFor') - get menu() { + get menu(): MatMenuPanel | null { return this._menu; } - set menu(menu: MatMenuPanel) { + set menu(menu: MatMenuPanel | null) { if (menu === this._menu) { return; } @@ -152,7 +152,7 @@ export abstract class _MatMenuTriggerBase implements AfterContentInit, OnDestroy }); } } - private _menu: MatMenuPanel; + private _menu: MatMenuPanel | null; /** Data to be passed along to any lazily-rendered content. */ @Input('matMenuTriggerData') menuData: any; @@ -244,7 +244,6 @@ export abstract class _MatMenuTriggerBase implements AfterContentInit, OnDestroy } ngAfterContentInit() { - this._checkMenu(); this._handleHover(); } @@ -287,31 +286,31 @@ export abstract class _MatMenuTriggerBase implements AfterContentInit, OnDestroy /** Opens the menu. */ openMenu(): void { - if (this._menuOpen) { + const menu = this.menu; + + if (this._menuOpen || !menu) { return; } - this._checkMenu(); - - const overlayRef = this._createOverlay(); + const overlayRef = this._createOverlay(menu); const overlayConfig = overlayRef.getConfig(); const positionStrategy = overlayConfig.positionStrategy as FlexibleConnectedPositionStrategy; - this._setPosition(positionStrategy); + this._setPosition(menu, positionStrategy); overlayConfig.hasBackdrop = - this.menu.hasBackdrop == null ? !this.triggersSubmenu() : this.menu.hasBackdrop; - overlayRef.attach(this._getPortal()); + menu.hasBackdrop == null ? !this.triggersSubmenu() : menu.hasBackdrop; + overlayRef.attach(this._getPortal(menu)); - if (this.menu.lazyContent) { - this.menu.lazyContent.attach(this.menuData); + if (menu.lazyContent) { + menu.lazyContent.attach(this.menuData); } this._closingActionsSubscription = this._menuClosingActions().subscribe(() => this.closeMenu()); - this._initMenu(); + this._initMenu(menu); - if (this.menu instanceof _MatMenuBase) { - this.menu._startAnimation(); - this.menu._directDescendantItems.changes.pipe(takeUntil(this.menu.close)).subscribe(() => { + if (menu instanceof _MatMenuBase) { + menu._startAnimation(); + menu._directDescendantItems.changes.pipe(takeUntil(menu.close)).subscribe(() => { // Re-adjust the position without locking when the amount of items // changes so that the overlay is allowed to pick a new optimal position. positionStrategy.withLockedPosition(false).reapplyLastPosition(); @@ -322,7 +321,7 @@ export abstract class _MatMenuTriggerBase implements AfterContentInit, OnDestroy /** Closes the menu. */ closeMenu(): void { - this.menu.close.emit(); + this.menu?.close.emit(); } /** @@ -386,10 +385,7 @@ export abstract class _MatMenuTriggerBase implements AfterContentInit, OnDestroy } } else { this._setIsMenuOpen(false); - - if (menu.lazyContent) { - menu.lazyContent.detach(); - } + menu?.lazyContent?.detach(); } } @@ -397,26 +393,26 @@ export abstract class _MatMenuTriggerBase implements AfterContentInit, OnDestroy * This method sets the menu state to open and focuses the first item if * the menu was opened via the keyboard. */ - private _initMenu(): void { - this.menu.parentMenu = this.triggersSubmenu() ? this._parentMaterialMenu : undefined; - this.menu.direction = this.dir; - this._setMenuElevation(); - this.menu.focusFirstItem(this._openedBy || 'program'); + private _initMenu(menu: MatMenuPanel): void { + menu.parentMenu = this.triggersSubmenu() ? this._parentMaterialMenu : undefined; + menu.direction = this.dir; + this._setMenuElevation(menu); + menu.focusFirstItem(this._openedBy || 'program'); this._setIsMenuOpen(true); } /** Updates the menu elevation based on the amount of parent menus that it has. */ - private _setMenuElevation(): void { - if (this.menu.setElevation) { + private _setMenuElevation(menu: MatMenuPanel): void { + if (menu.setElevation) { let depth = 0; - let parentMenu = this.menu.parentMenu; + let parentMenu = menu.parentMenu; while (parentMenu) { depth++; parentMenu = parentMenu.parentMenu; } - this.menu.setElevation(depth); + menu.setElevation(depth); } } @@ -430,24 +426,17 @@ export abstract class _MatMenuTriggerBase implements AfterContentInit, OnDestroy } } - /** - * This method checks that a valid instance of MatMenu has been passed into - * matMenuTriggerFor. If not, an exception is thrown. - */ - private _checkMenu() { - if (!this.menu && (typeof ngDevMode === 'undefined' || ngDevMode)) { - throwMatMenuMissingError(); - } - } - /** * This method creates the overlay from the provided menu's template and saves its * OverlayRef so that it can be attached to the DOM when openMenu is called. */ - private _createOverlay(): OverlayRef { + private _createOverlay(menu: MatMenuPanel): OverlayRef { if (!this._overlayRef) { - const config = this._getOverlayConfig(); - this._subscribeToPositions(config.positionStrategy as FlexibleConnectedPositionStrategy); + const config = this._getOverlayConfig(menu); + this._subscribeToPositions( + menu, + config.positionStrategy as FlexibleConnectedPositionStrategy, + ); this._overlayRef = this._overlay.create(config); // Consume the `keydownEvents` in order to prevent them from going to another overlay. @@ -463,7 +452,7 @@ export abstract class _MatMenuTriggerBase implements AfterContentInit, OnDestroy * This method builds the configuration object needed to create the overlay, the OverlayState. * @returns OverlayConfig */ - private _getOverlayConfig(): OverlayConfig { + private _getOverlayConfig(menu: MatMenuPanel): OverlayConfig { return new OverlayConfig({ positionStrategy: this._overlay .position() @@ -471,8 +460,8 @@ export abstract class _MatMenuTriggerBase implements AfterContentInit, OnDestroy .withLockedPosition() .withGrowAfterOpen() .withTransformOriginOn('.mat-menu-panel, .mat-mdc-menu-panel'), - backdropClass: this.menu.backdropClass || 'cdk-overlay-transparent-backdrop', - panelClass: this.menu.overlayPanelClass, + backdropClass: menu.backdropClass || 'cdk-overlay-transparent-backdrop', + panelClass: menu.overlayPanelClass, scrollStrategy: this._scrollStrategy(), direction: this._dir, }); @@ -483,8 +472,8 @@ export abstract class _MatMenuTriggerBase implements AfterContentInit, OnDestroy * on the menu based on the new position. This ensures the animation origin is always * correct, even if a fallback position is used for the overlay. */ - private _subscribeToPositions(position: FlexibleConnectedPositionStrategy): void { - if (this.menu.setPositionClasses) { + private _subscribeToPositions(menu: MatMenuPanel, position: FlexibleConnectedPositionStrategy) { + if (menu.setPositionClasses) { position.positionChanges.subscribe(change => { const posX: MenuPositionX = change.connectionPair.overlayX === 'start' ? 'after' : 'before'; const posY: MenuPositionY = change.connectionPair.overlayY === 'top' ? 'below' : 'above'; @@ -493,9 +482,9 @@ export abstract class _MatMenuTriggerBase implements AfterContentInit, OnDestroy // `positionChanges` fires outside of the `ngZone` and `setPositionClasses` might be // updating something in the view so we need to bring it back in. if (this._ngZone) { - this._ngZone.run(() => this.menu.setPositionClasses!(posX, posY)); + this._ngZone.run(() => menu.setPositionClasses!(posX, posY)); } else { - this.menu.setPositionClasses!(posX, posY); + menu.setPositionClasses!(posX, posY); } }); } @@ -506,12 +495,12 @@ export abstract class _MatMenuTriggerBase implements AfterContentInit, OnDestroy * so the overlay connects with the trigger correctly. * @param positionStrategy Strategy whose position to update. */ - private _setPosition(positionStrategy: FlexibleConnectedPositionStrategy) { + private _setPosition(menu: MatMenuPanel, positionStrategy: FlexibleConnectedPositionStrategy) { let [originX, originFallbackX]: HorizontalConnectionPos[] = - this.menu.xPosition === 'before' ? ['end', 'start'] : ['start', 'end']; + menu.xPosition === 'before' ? ['end', 'start'] : ['start', 'end']; let [overlayY, overlayFallbackY]: VerticalConnectionPos[] = - this.menu.yPosition === 'above' ? ['bottom', 'top'] : ['top', 'bottom']; + menu.yPosition === 'above' ? ['bottom', 'top'] : ['top', 'bottom']; let [originY, originFallbackY] = [overlayY, overlayFallbackY]; let [overlayX, overlayFallbackX] = [originX, originFallbackX]; @@ -520,10 +509,10 @@ export abstract class _MatMenuTriggerBase implements AfterContentInit, OnDestroy if (this.triggersSubmenu()) { // When the menu is a sub-menu, it should always align itself // to the edges of the trigger, instead of overlapping it. - overlayFallbackX = originX = this.menu.xPosition === 'before' ? 'start' : 'end'; + overlayFallbackX = originX = menu.xPosition === 'before' ? 'start' : 'end'; originFallbackX = overlayX = originX === 'end' ? 'start' : 'end'; offsetY = overlayY === 'bottom' ? MENU_PANEL_TOP_PADDING : -MENU_PANEL_TOP_PADDING; - } else if (!this.menu.overlapTrigger) { + } else if (!menu.overlapTrigger) { originY = overlayY === 'top' ? 'bottom' : 'top'; originFallbackY = overlayFallbackY === 'top' ? 'bottom' : 'top'; } @@ -644,12 +633,12 @@ export abstract class _MatMenuTriggerBase implements AfterContentInit, OnDestroy } /** Gets the portal that should be attached to the overlay. */ - private _getPortal(): TemplatePortal { + private _getPortal(menu: MatMenuPanel): TemplatePortal { // Note that we can avoid this check by keeping the portal on the menu panel. // While it would be cleaner, we'd have to introduce another required method on // `MatMenuPanel`, making it harder to consume. - if (!this._portal || this._portal.templateRef !== this.menu.templateRef) { - this._portal = new TemplatePortal(this.menu.templateRef, this._viewContainerRef); + if (!this._portal || this._portal.templateRef !== menu.templateRef) { + this._portal = new TemplatePortal(menu.templateRef, this._viewContainerRef); } return this._portal; diff --git a/src/material/menu/menu.spec.ts b/src/material/menu/menu.spec.ts index 6fec2b2a6a7b..c54d53601840 100644 --- a/src/material/menu/menu.spec.ts +++ b/src/material/menu/menu.spec.ts @@ -89,6 +89,19 @@ describe('MatMenu', () => { ); })); + it('should set aria-haspopup based on whether a menu is assigned', fakeAsync(() => { + const fixture = createComponent(SimpleMenu, [], [FakeIcon]); + fixture.detectChanges(); + const triggerElement = fixture.componentInstance.triggerEl.nativeElement; + + expect(triggerElement.getAttribute('aria-haspopup')).toBe('true'); + + fixture.componentInstance.trigger.menu = null; + fixture.detectChanges(); + + expect(triggerElement.hasAttribute('aria-haspopup')).toBe(false); + })); + it('should open the menu as an idempotent operation', fakeAsync(() => { const fixture = createComponent(SimpleMenu, [], [FakeIcon]); fixture.detectChanges(); @@ -827,20 +840,6 @@ describe('MatMenu', () => { expect(triggerEl.hasAttribute('aria-expanded')).toBe(false); })); - it('should throw the correct error if the menu is not defined after init', fakeAsync(() => { - const fixture = createComponent(SimpleMenu, [], [FakeIcon]); - fixture.detectChanges(); - - fixture.componentInstance.trigger.menu = null!; - fixture.detectChanges(); - - expect(() => { - fixture.componentInstance.trigger.openMenu(); - fixture.detectChanges(); - tick(500); - }).toThrowError(/must pass in an mat-menu instance/); - })); - it('should throw if assigning a menu that contains the trigger', fakeAsync(() => { expect(() => { const fixture = createComponent(InvalidRecursiveMenu, [], [FakeIcon]); diff --git a/tools/public_api_guard/material/menu.md b/tools/public_api_guard/material/menu.md index 8b887138a04a..264db2cc79f1 100644 --- a/tools/public_api_guard/material/menu.md +++ b/tools/public_api_guard/material/menu.md @@ -287,15 +287,15 @@ export abstract class _MatMenuTriggerBase implements AfterContentInit, OnDestroy constructor(overlay: Overlay, element: ElementRef, viewContainerRef: ViewContainerRef, scrollStrategy: any, parentMenu: MatMenuPanel, menuItemInstance: MatMenuItem, dir: Directionality, focusMonitor: FocusMonitor); closeMenu(): void; // @deprecated (undocumented) - get _deprecatedMatMenuTriggerFor(): MatMenuPanel; - set _deprecatedMatMenuTriggerFor(v: MatMenuPanel); + get _deprecatedMatMenuTriggerFor(): MatMenuPanel | null; + set _deprecatedMatMenuTriggerFor(v: MatMenuPanel | null); get dir(): Direction; focus(origin?: FocusOrigin, options?: FocusOptions): void; _handleClick(event: MouseEvent): void; _handleKeydown(event: KeyboardEvent): void; _handleMousedown(event: MouseEvent): void; - get menu(): MatMenuPanel; - set menu(menu: MatMenuPanel); + get menu(): MatMenuPanel | null; + set menu(menu: MatMenuPanel | null); readonly menuClosed: EventEmitter; menuData: any; get menuOpen(): boolean;