diff --git a/src/cdk/a11y/key-manager/list-key-manager.ts b/src/cdk/a11y/key-manager/list-key-manager.ts index d23f69db9eae..15fb2838e558 100644 --- a/src/cdk/a11y/key-manager/list-key-manager.ts +++ b/src/cdk/a11y/key-manager/list-key-manager.ts @@ -52,17 +52,22 @@ export class ListKeyManager { // Buffer for the letters that the user has pressed when the typeahead option is turned on. private _pressedLetters: string[] = []; - constructor(private _items: QueryList) { - _items.changes.subscribe((newItems: QueryList) => { - if (this._activeItem) { - const itemArray = newItems.toArray(); - const newIndex = itemArray.indexOf(this._activeItem); - - if (newIndex > -1 && newIndex !== this._activeItemIndex) { - this._activeItemIndex = newIndex; + constructor(private _items: QueryList | T[]) { + // We allow for the items to be an array because, in some cases, the consumer may + // not have access to a QueryList of the items they want to manage (e.g. when the + // items aren't being collected via `ViewChildren` or `ContentChildren`). + if (_items instanceof QueryList) { + _items.changes.subscribe((newItems: QueryList) => { + if (this._activeItem) { + const itemArray = newItems.toArray(); + const newIndex = itemArray.indexOf(this._activeItem); + + if (newIndex > -1 && newIndex !== this._activeItemIndex) { + this._activeItemIndex = newIndex; + } } - } - }); + }); + } } /** @@ -132,7 +137,7 @@ export class ListKeyManager { filter(() => this._pressedLetters.length > 0), map(() => this._pressedLetters.join('')) ).subscribe(inputString => { - const items = this._items.toArray(); + const items = this._getItemsArray(); // Start at 1 because we want to start searching at the item immediately // following the current active item. @@ -288,7 +293,7 @@ export class ListKeyManager { updateActiveItem(item: T): void; updateActiveItem(item: any): void { - const itemArray = this._items.toArray(); + const itemArray = this._getItemsArray(); const index = typeof item === 'number' ? item : itemArray.indexOf(item); this._activeItemIndex = index; @@ -310,9 +315,8 @@ export class ListKeyManager { * currently active item and the new active item. It will calculate differently * depending on whether wrap mode is turned on. */ - private _setActiveItemByDelta(delta: -1 | 1, items = this._items.toArray()): void { - this._wrap ? this._setActiveInWrapMode(delta, items) - : this._setActiveInDefaultMode(delta, items); + private _setActiveItemByDelta(delta: -1 | 1): void { + this._wrap ? this._setActiveInWrapMode(delta) : this._setActiveInDefaultMode(delta); } /** @@ -320,7 +324,9 @@ export class ListKeyManager { * down the list until it finds an item that is not disabled, and it will wrap if it * encounters either end of the list. */ - private _setActiveInWrapMode(delta: -1 | 1, items: T[]): void { + private _setActiveInWrapMode(delta: -1 | 1): void { + const items = this._getItemsArray(); + for (let i = 1; i <= items.length; i++) { const index = (this._activeItemIndex + (delta * i) + items.length) % items.length; const item = items[index]; @@ -337,8 +343,8 @@ export class ListKeyManager { * continue to move down the list until it finds an item that is not disabled. If * it encounters either end of the list, it will stop and not wrap. */ - private _setActiveInDefaultMode(delta: -1 | 1, items: T[]): void { - this._setActiveItemByIndex(this._activeItemIndex + delta, delta, items); + private _setActiveInDefaultMode(delta: -1 | 1): void { + this._setActiveItemByIndex(this._activeItemIndex + delta, delta); } /** @@ -346,8 +352,9 @@ export class ListKeyManager { * item is disabled, it will move in the fallbackDelta direction until it either * finds an enabled item or encounters the end of the list. */ - private _setActiveItemByIndex(index: number, fallbackDelta: -1 | 1, - items = this._items.toArray()): void { + private _setActiveItemByIndex(index: number, fallbackDelta: -1 | 1): void { + const items = this._getItemsArray(); + if (!items[index]) { return; } @@ -362,4 +369,9 @@ export class ListKeyManager { this.setActiveItem(index); } + + /** Returns the items as an array. */ + private _getItemsArray(): T[] { + return this._items instanceof QueryList ? this._items.toArray() : this._items; + } } diff --git a/src/lib/menu/menu-directive.ts b/src/lib/menu/menu-directive.ts index 58689e262eca..7761e49d71b4 100644 --- a/src/lib/menu/menu-directive.ts +++ b/src/lib/menu/menu-directive.ts @@ -25,8 +25,8 @@ import { OnDestroy, OnInit, Output, - QueryList, TemplateRef, + QueryList, ViewChild, ViewEncapsulation, } from '@angular/core'; @@ -36,7 +36,7 @@ import {matMenuAnimations} from './menu-animations'; import {MatMenuContent} from './menu-content'; import {throwMatMenuInvalidPositionX, throwMatMenuInvalidPositionY} from './menu-errors'; import {MatMenuItem} from './menu-item'; -import {MatMenuPanel} from './menu-panel'; +import {MAT_MENU_PANEL, MatMenuPanel} from './menu-panel'; import {MenuPositionX, MenuPositionY} from './menu-positions'; @@ -84,18 +84,27 @@ const MAT_MENU_BASE_ELEVATION = 2; styleUrls: ['menu.css'], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, + exportAs: 'matMenu', animations: [ matMenuAnimations.transformMenu, matMenuAnimations.fadeInItems ], - exportAs: 'matMenu' + providers: [ + {provide: MAT_MENU_PANEL, useExisting: MatMenu} + ] }) -export class MatMenu implements OnInit, AfterContentInit, MatMenuPanel, OnDestroy { +export class MatMenu implements OnInit, AfterContentInit, MatMenuPanel, OnDestroy { private _keyManager: FocusKeyManager; private _xPosition: MenuPositionX = this._defaultOptions.xPosition; private _yPosition: MenuPositionY = this._defaultOptions.yPosition; private _previousElevation: string; + /** Menu items inside the current menu. */ + private _items: MatMenuItem[] = []; + + /** Emits whenever the amount of menu items changes. */ + private _itemChanges = new Subject(); + /** Subscription to tab events on the menu panel */ private _tabSubscription = Subscription.EMPTY; @@ -106,7 +115,10 @@ export class MatMenu implements OnInit, AfterContentInit, MatMenuPanel, OnDestro _panelAnimationState: 'void' | 'enter' = 'void'; /** Emits whenever an animation on the menu completes. */ - _animationDone = new Subject(); + _animationDone = new Subject(); + + /** Whether the menu is animating. */ + _isAnimating: boolean; /** Parent menu of the current menu panel. */ parentMenu: MatMenuPanel | undefined; @@ -142,7 +154,11 @@ export class MatMenu implements OnInit, AfterContentInit, MatMenuPanel, OnDestro /** @docs-private */ @ViewChild(TemplateRef) templateRef: TemplateRef; - /** List of the items inside of a menu. */ + /** + * List of the items inside of a menu. + * @deprecated + * @deletion-target 7.0.0 + */ @ContentChildren(MatMenuItem) items: QueryList; /** @@ -218,7 +234,7 @@ export class MatMenu implements OnInit, AfterContentInit, MatMenuPanel, OnDestro } ngAfterContentInit() { - this._keyManager = new FocusKeyManager(this.items).withWrap().withTypeAhead(); + this._keyManager = new FocusKeyManager(this._items).withWrap().withTypeAhead(); this._tabSubscription = this._keyManager.tabOut.subscribe(() => this.close.emit('tab')); } @@ -229,16 +245,10 @@ export class MatMenu implements OnInit, AfterContentInit, MatMenuPanel, OnDestro /** Stream that emits whenever the hovered menu item changes. */ _hovered(): Observable { - if (this.items) { - return this.items.changes.pipe( - startWith(this.items), - switchMap(items => merge(...items.map(item => item._hovered))) - ); - } - - return this._ngZone.onStable - .asObservable() - .pipe(take(1), switchMap(() => this._hovered())); + return this._itemChanges.pipe( + startWith(this._items), + switchMap(items => merge(...items.map(item => item._hovered))) + ); } /** Handle a keyboard event from the menu, delegating to the appropriate action. */ @@ -322,6 +332,35 @@ export class MatMenu implements OnInit, AfterContentInit, MatMenuPanel, OnDestro } } + /** + * Registers a menu item with the menu. + * @docs-private + */ + addItem(item: MatMenuItem) { + // We register the items through this method, rather than picking them up through + // `ContentChildren`, because we need the items to be picked up by their closest + // `mat-menu` ancestor. If we used `@ContentChildren(MatMenuItem, {descendants: true})`, + // all descendant items will bleed into the top-level menu in the case where the consumer + // has `mat-menu` instances nested inside each other. + if (this._items.indexOf(item) === -1) { + this._items.push(item); + this._itemChanges.next(this._items); + } + } + + /** + * Removes an item from the menu. + * @docs-private + */ + removeItem(item: MatMenuItem) { + const index = this._items.indexOf(item); + + if (this._items.indexOf(item) > -1) { + this._items.splice(index, 1); + this._itemChanges.next(this._items); + } + } + /** Starts the enter animation. */ _startAnimation() { // @deletion-target 7.0.0 Combine with _resetAnimation. @@ -335,7 +374,8 @@ export class MatMenu implements OnInit, AfterContentInit, MatMenuPanel, OnDestro } /** Callback that is invoked when the panel animation completes. */ - _onAnimationDone() { - this._animationDone.next(); + _onAnimationDone(event: AnimationEvent) { + this._animationDone.next(event); + this._isAnimating = false; } } diff --git a/src/lib/menu/menu-item.ts b/src/lib/menu/menu-item.ts index 5f2430c10743..54ed0baff60b 100644 --- a/src/lib/menu/menu-item.ts +++ b/src/lib/menu/menu-item.ts @@ -14,6 +14,7 @@ import { OnDestroy, ViewEncapsulation, Inject, + Optional, } from '@angular/core'; import { CanDisable, @@ -23,6 +24,7 @@ import { } from '@angular/material/core'; import {Subject} from 'rxjs'; import {DOCUMENT} from '@angular/common'; +import {MAT_MENU_PANEL, MatMenuPanel} from './menu-panel'; // Boilerplate for applying mixins to MatMenuItem. /** @docs-private */ @@ -70,7 +72,8 @@ export class MatMenuItem extends _MatMenuItemMixinBase constructor( private _elementRef: ElementRef, @Inject(DOCUMENT) document?: any, - private _focusMonitor?: FocusMonitor) { + private _focusMonitor?: FocusMonitor, + @Inject(MAT_MENU_PANEL) @Optional() private _parentMenu?: MatMenuPanel) { // @deletion-target 7.0.0 make `_focusMonitor` and `document` required params. super(); @@ -82,6 +85,10 @@ export class MatMenuItem extends _MatMenuItemMixinBase _focusMonitor.monitor(this._getHostElement(), false); } + if (_parentMenu && _parentMenu.addItem) { + _parentMenu.addItem(this); + } + this._document = document; } @@ -99,6 +106,10 @@ export class MatMenuItem extends _MatMenuItemMixinBase this._focusMonitor.stopMonitoring(this._getHostElement()); } + if (this._parentMenu && this._parentMenu.removeItem) { + this._parentMenu.removeItem(this); + } + this._hovered.complete(); } diff --git a/src/lib/menu/menu-panel.ts b/src/lib/menu/menu-panel.ts index 344841a81f55..f37238ebffcf 100644 --- a/src/lib/menu/menu-panel.ts +++ b/src/lib/menu/menu-panel.ts @@ -6,17 +6,23 @@ * found in the LICENSE file at https://angular.io/license */ -import {EventEmitter, TemplateRef} from '@angular/core'; +import {EventEmitter, TemplateRef, InjectionToken} from '@angular/core'; import {MenuPositionX, MenuPositionY} from './menu-positions'; import {Direction} from '@angular/cdk/bidi'; import {FocusOrigin} from '@angular/cdk/a11y'; import {MatMenuContent} from './menu-content'; +/** + * Injection token used to provide the parent menu to menu-specific components. + * @docs-private + */ +export const MAT_MENU_PANEL = new InjectionToken('MAT_MENU_PANEL'); + /** * Interface for a custom menu panel that can be used with `matMenuTriggerFor`. * @docs-private */ -export interface MatMenuPanel { +export interface MatMenuPanel { xPosition: MenuPositionX; yPosition: MenuPositionY; overlapTrigger: boolean; @@ -31,4 +37,6 @@ export interface MatMenuPanel { lazyContent?: MatMenuContent; backdropClass?: string; hasBackdrop?: boolean; + addItem?: (item: T) => void; + removeItem?: (item: T) => void; } diff --git a/src/lib/menu/menu-trigger.ts b/src/lib/menu/menu-trigger.ts index 32256ba3232e..507b1691daa8 100644 --- a/src/lib/menu/menu-trigger.ts +++ b/src/lib/menu/menu-trigger.ts @@ -19,7 +19,7 @@ import { VerticalConnectionPos, } from '@angular/cdk/overlay'; import {TemplatePortal} from '@angular/cdk/portal'; -import {filter, take} from 'rxjs/operators'; +import {filter, take, delay, takeUntil} from 'rxjs/operators'; import { AfterContentInit, Directive, @@ -35,7 +35,7 @@ import { Self, ViewContainerRef, } from '@angular/core'; -import {Subscription, merge, of as observableOf} from 'rxjs'; +import {Subscription, merge, of as observableOf, asapScheduler} from 'rxjs'; import {MatMenu} from './menu-directive'; import {throwMatMenuMissingError} from './menu-errors'; import {MatMenuItem} from './menu-item'; @@ -149,15 +149,7 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy { } }); - if (this.triggersSubmenu()) { - // Subscribe to changes in the hovered item in order to toggle the panel. - this._hoverSubscription = this._parentMenu._hovered() - .pipe(filter(active => active === this._menuItemInstance && !active.disabled)) - .subscribe(() => { - this._openedByMouse = true; - this.openMenu(); - }); - } + this._handleHover(); } ngOnDestroy() { @@ -468,4 +460,35 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy { } } + /** Handles the cases where the user hovers over the trigger. */ + private _handleHover() { + // Subscribe to changes in the hovered item in order to toggle the panel. + if (!this.triggersSubmenu()) { + return; + } + + this._hoverSubscription = this._parentMenu._hovered() + // Since we might have multiple competing triggers for the same menu (e.g. a sub-menu + // with different data and triggers), we have to delay it by a tick to ensure that + // it won't be closed immediately after it is opened. + .pipe( + filter(active => active === this._menuItemInstance && !active.disabled), + delay(0, asapScheduler) + ) + .subscribe(() => { + this._openedByMouse = true; + + // If the same menu is used between multiple triggers, it might still be animating + // while the new trigger tries to re-open it. Wait for the animation to finish + // before doing so. Also interrupt if the user moves to another item. + if (this.menu instanceof MatMenu && this.menu._isAnimating) { + this.menu._animationDone + .pipe(take(1), takeUntil(this._parentMenu._hovered())) + .subscribe(() => this.openMenu()); + } else { + this.openMenu(); + } + }); + } + } diff --git a/src/lib/menu/menu.html b/src/lib/menu/menu.html index ba48f52b6548..b173e3cccc36 100644 --- a/src/lib/menu/menu.html +++ b/src/lib/menu/menu.html @@ -5,7 +5,8 @@ (keydown)="_handleKeydown($event)" (click)="closed.emit('click')" [@transformMenu]="_panelAnimationState" - (@transformMenu.done)="_onAnimationDone()" + (@transformMenu.start)="_isAnimating = true" + (@transformMenu.done)="_onAnimationDone($event)" tabindex="-1" role="menu">
diff --git a/src/lib/menu/menu.spec.ts b/src/lib/menu/menu.spec.ts index 23130170865f..9ed8db8be104 100644 --- a/src/lib/menu/menu.spec.ts +++ b/src/lib/menu/menu.spec.ts @@ -922,6 +922,9 @@ describe('MatMenu', () => { dispatchMouseEvent(levelOneTrigger, 'mouseenter'); fixture.detectChanges(); + tick(); + fixture.detectChanges(); + expect(levelOneTrigger.classList) .toContain('mat-menu-item-highlighted', 'Expected the trigger to be highlighted'); expect(overlay.querySelectorAll('.mat-menu-panel').length).toBe(2, 'Expected two open menus'); @@ -946,10 +949,12 @@ describe('MatMenu', () => { dispatchMouseEvent(levelOneTrigger, 'mouseenter'); fixture.detectChanges(); + tick(); const levelTwoTrigger = overlay.querySelector('#level-two-trigger')! as HTMLElement; dispatchMouseEvent(levelTwoTrigger, 'mouseenter'); fixture.detectChanges(); + tick(); expect(overlay.querySelectorAll('.mat-menu-panel').length) .toBe(3, 'Expected three open menus'); @@ -1390,6 +1395,7 @@ describe('MatMenu', () => { dispatchMouseEvent(lazyTrigger, 'mouseenter'); fixture.detectChanges(); tick(500); + fixture.detectChanges(); expect(lazyTrigger.classList) .toContain('mat-menu-item-highlighted', 'Expected the trigger to be highlighted'); @@ -1427,6 +1433,99 @@ describe('MatMenu', () => { expect(overlay.querySelectorAll('.mat-menu-panel').length).toBe(2, 'Expected two open menus'); })); + it('should be able to trigger the same nested menu from different triggers', fakeAsync(() => { + const repeaterFixture = createComponent(NestedMenuRepeater); + overlay = overlayContainerElement; + + repeaterFixture.detectChanges(); + repeaterFixture.componentInstance.rootTriggerEl.nativeElement.click(); + repeaterFixture.detectChanges(); + tick(500); + expect(overlay.querySelectorAll('.mat-menu-panel').length).toBe(1, 'Expected one open menu'); + + const triggers = overlay.querySelectorAll('.level-one-trigger'); + + dispatchMouseEvent(triggers[0], 'mouseenter'); + repeaterFixture.detectChanges(); + tick(500); + expect(overlay.querySelectorAll('.mat-menu-panel').length).toBe(2, 'Expected two open menus'); + + dispatchMouseEvent(triggers[1], 'mouseenter'); + repeaterFixture.detectChanges(); + tick(500); + + expect(overlay.querySelectorAll('.mat-menu-panel').length).toBe(2, 'Expected two open menus'); + })); + + it('should close the initial menu if the user moves away while animating', fakeAsync(() => { + const repeaterFixture = createComponent(NestedMenuRepeater); + overlay = overlayContainerElement; + + repeaterFixture.detectChanges(); + repeaterFixture.componentInstance.rootTriggerEl.nativeElement.click(); + repeaterFixture.detectChanges(); + tick(500); + expect(overlay.querySelectorAll('.mat-menu-panel').length).toBe(1, 'Expected one open menu'); + + const triggers = overlay.querySelectorAll('.level-one-trigger'); + + dispatchMouseEvent(triggers[0], 'mouseenter'); + repeaterFixture.detectChanges(); + tick(100); + dispatchMouseEvent(triggers[1], 'mouseenter'); + repeaterFixture.detectChanges(); + tick(500); + + expect(overlay.querySelectorAll('.mat-menu-panel').length).toBe(2, 'Expected two open menus'); + })); + + it('should be able to open a submenu through an item that is not a direct descendant ' + + 'of the panel', fakeAsync(() => { + const nestedFixture = createComponent(SubmenuDeclaredInsideParentMenu); + overlay = overlayContainerElement; + + nestedFixture.detectChanges(); + nestedFixture.componentInstance.rootTriggerEl.nativeElement.click(); + nestedFixture.detectChanges(); + tick(500); + expect(overlay.querySelectorAll('.mat-menu-panel').length) + .toBe(1, 'Expected one open menu'); + + dispatchMouseEvent(overlay.querySelector('.level-one-trigger')!, 'mouseenter'); + nestedFixture.detectChanges(); + tick(500); + + expect(overlay.querySelectorAll('.mat-menu-panel').length) + .toBe(2, 'Expected two open menus'); + })); + + it('should not close when hovering over a menu item inside a sub-menu panel that is declared' + + 'inside the root menu', fakeAsync(() => { + const nestedFixture = createComponent(SubmenuDeclaredInsideParentMenu); + overlay = overlayContainerElement; + + nestedFixture.detectChanges(); + nestedFixture.componentInstance.rootTriggerEl.nativeElement.click(); + nestedFixture.detectChanges(); + tick(500); + expect(overlay.querySelectorAll('.mat-menu-panel').length) + .toBe(1, 'Expected one open menu'); + + dispatchMouseEvent(overlay.querySelector('.level-one-trigger')!, 'mouseenter'); + nestedFixture.detectChanges(); + tick(500); + + expect(overlay.querySelectorAll('.mat-menu-panel').length) + .toBe(2, 'Expected two open menus'); + + dispatchMouseEvent(overlay.querySelector('.level-two-item')!, 'mouseenter'); + nestedFixture.detectChanges(); + tick(500); + + expect(overlay.querySelectorAll('.mat-menu-panel').length) + .toBe(2, 'Expected two open menus to remain'); + })); + it('should not re-focus a child menu trigger when hovering another trigger', fakeAsync(() => { compileTestComponent(); @@ -1439,6 +1538,7 @@ describe('MatMenu', () => { dispatchMouseEvent(levelOneTrigger, 'mouseenter'); fixture.detectChanges(); + tick(); expect(overlay.querySelectorAll('.mat-menu-panel').length).toBe(2, 'Expected two open menus'); dispatchMouseEvent(items[items.indexOf(levelOneTrigger) + 1], 'mouseenter'); @@ -1679,11 +1779,28 @@ class NestedMenuCustomElevation { }) class NestedMenuRepeater { @ViewChild('rootTriggerEl') rootTriggerEl: ElementRef; - @ViewChild('levelOneTrigger') levelOneTrigger: MatMenuTrigger; - items = ['one', 'two', 'three']; } + +@Component({ + template: ` + + + + + + + + + + ` +}) +class SubmenuDeclaredInsideParentMenu { + @ViewChild('rootTriggerEl') rootTriggerEl: ElementRef; +} + + @Component({ selector: 'fake-icon', template: ''