diff --git a/src/cdk-experimental/menu/BUILD.bazel b/src/cdk-experimental/menu/BUILD.bazel index f7d8f4d69871..dd0df2dae0f4 100644 --- a/src/cdk-experimental/menu/BUILD.bazel +++ b/src/cdk-experimental/menu/BUILD.bazel @@ -33,6 +33,7 @@ ng_test_library( "//src/cdk/keycodes", "//src/cdk/testing/private", "@npm//@angular/platform-browser", + "@npm//rxjs", ], ) diff --git a/src/cdk-experimental/menu/item-pointer-entries.spec.ts b/src/cdk-experimental/menu/item-pointer-entries.spec.ts new file mode 100644 index 000000000000..e2ae3023bd31 --- /dev/null +++ b/src/cdk-experimental/menu/item-pointer-entries.spec.ts @@ -0,0 +1,103 @@ +import {Component, QueryList, ElementRef, ViewChildren, AfterViewInit} from '@angular/core'; +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import {createMouseEvent, dispatchEvent} from '@angular/cdk/testing/private'; +import {Observable} from 'rxjs'; +import {FocusableElement, getItemPointerEntries} from './item-pointer-entries'; + +describe('FocusMouseManger', () => { + let fixture: ComponentFixture; + let mouseFocusChanged: Observable; + let mockElements: MockWrapper[]; + + /** Get the components under test from the fixture. */ + function getComponentsForTesting() { + mouseFocusChanged = fixture.componentInstance.mouseFocusChanged; + mockElements = fixture.componentInstance._allItems.toArray(); + } + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [MultiElementWithConditionalComponent, MockWrapper], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MultiElementWithConditionalComponent); + fixture.detectChanges(); + + getComponentsForTesting(); + }); + + it('should emit on mouseEnter observable when mouse enters a tracked element', () => { + const spy = jasmine.createSpy('mouse enter spy'); + mouseFocusChanged.subscribe(spy); + + const event = createMouseEvent('mouseenter'); + dispatchEvent(mockElements[0]._elementRef.nativeElement, event); + fixture.detectChanges(); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(mockElements[0]); + }); + + it('should be aware of newly created/added components and track them', () => { + const spy = jasmine.createSpy('mouse enter spy'); + mouseFocusChanged.subscribe(spy); + + expect(mockElements.length).toBe(2); + fixture.componentInstance.showThird = true; + fixture.detectChanges(); + getComponentsForTesting(); + + const mouseEnter = createMouseEvent('mouseenter'); + dispatchEvent(mockElements[2]._elementRef.nativeElement, mouseEnter); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(mockElements[2]); + }); + + it('should toggle focused items when hovering from one to another', () => { + const spy = jasmine.createSpy('focus toggle spy'); + mouseFocusChanged.subscribe(spy); + + const mouseEnter = createMouseEvent('mouseenter'); + dispatchEvent(mockElements[0]._elementRef.nativeElement, mouseEnter); + dispatchEvent(mockElements[1]._elementRef.nativeElement, mouseEnter); + + expect(spy).toHaveBeenCalledTimes(2); + expect(spy.calls.argsFor(0)[0]).toEqual(mockElements[0]); + expect(spy.calls.argsFor(1)[0]).toEqual(mockElements[1]); + }); +}); + +@Component({ + selector: 'wrapper', + template: ``, +}) +class MockWrapper implements FocusableElement { + constructor(readonly _elementRef: ElementRef) {} +} + +@Component({ + template: ` +
+ First + Second + Third +
+ `, +}) +class MultiElementWithConditionalComponent implements AfterViewInit { + /** Whether the third element should be displayed. */ + showThird = false; + + /** All mock elements. */ + @ViewChildren(MockWrapper) readonly _allItems: QueryList; + + /** Manages elements under mouse focus. */ + mouseFocusChanged: Observable; + + ngAfterViewInit() { + this.mouseFocusChanged = getItemPointerEntries(this._allItems); + } +} diff --git a/src/cdk-experimental/menu/item-pointer-entries.ts b/src/cdk-experimental/menu/item-pointer-entries.ts new file mode 100644 index 000000000000..c6614fc1404f --- /dev/null +++ b/src/cdk-experimental/menu/item-pointer-entries.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {QueryList, ElementRef} from '@angular/core'; +import {fromEvent, Observable, defer} from 'rxjs'; +import {mapTo, mergeAll, takeUntil, startWith, mergeMap} from 'rxjs/operators'; + +/** Item to track for mouse focus events. */ +export interface FocusableElement { + /** A reference to the element to be tracked. */ + _elementRef: ElementRef; +} + +/** + * Gets a stream of pointer (mouse) entries into the given items. + * This should typically run outside the Angular zone. + */ +export function getItemPointerEntries( + items: QueryList +): Observable { + return defer(() => + items.changes.pipe( + startWith(items), + mergeMap((list: QueryList) => + list.map(element => + fromEvent(element._elementRef.nativeElement, 'mouseenter').pipe( + mapTo(element), + takeUntil(items.changes) + ) + ) + ), + mergeAll() + ) + ); +} diff --git a/src/cdk-experimental/menu/menu-bar.spec.ts b/src/cdk-experimental/menu/menu-bar.spec.ts index d20cda1d496c..bb4eb53eff41 100644 --- a/src/cdk-experimental/menu/menu-bar.spec.ts +++ b/src/cdk-experimental/menu/menu-bar.spec.ts @@ -27,6 +27,7 @@ import { dispatchKeyboardEvent, createKeyboardEvent, dispatchEvent, + dispatchMouseEvent, } from '@angular/cdk/testing/private'; import {CdkMenuBar} from './menu-bar'; import {CdkMenuModule} from './menu-module'; @@ -837,6 +838,195 @@ describe('MenuBar', () => { .toBe(1); }); }); + + describe('Mouse handling', () => { + let fixture: ComponentFixture; + let nativeMenus: HTMLElement[]; + let menuBarNativeItems: HTMLButtonElement[]; + let fileMenuNativeItems: HTMLButtonElement[]; + let shareMenuNativeItems: HTMLButtonElement[]; + + /** Get menus and items used for tests. */ + function grabElementsForTesting() { + nativeMenus = fixture.componentInstance.nativeMenus.map(e => e.nativeElement); + + menuBarNativeItems = fixture.componentInstance.nativeItems + .map(e => e.nativeElement) + .slice(0, 2); // menu bar has the first 2 menu items + + fileMenuNativeItems = fixture.componentInstance.nativeItems + .map(e => e.nativeElement) + .slice(2, 5); // file menu has the next 3 menu items + + shareMenuNativeItems = fixture.componentInstance.nativeItems + .map(e => e.nativeElement) + .slice(5, 7); // share menu has the next 2 menu items + } + + /** Run change detection and extract then set the rendered elements. */ + function detectChanges() { + fixture.detectChanges(); + grabElementsForTesting(); + } + + /** Mock mouse events required to open the file menu. */ + function openFileMenu() { + dispatchMouseEvent(menuBarNativeItems[0], 'mouseenter'); + dispatchMouseEvent(menuBarNativeItems[0], 'click'); + detectChanges(); + } + + /** Mock mouse events required to open the share menu. */ + function openShareMenu() { + dispatchMouseEvent(fileMenuNativeItems[1], 'mouseenter'); + detectChanges(); + } + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [CdkMenuModule], + declarations: [MultiMenuWithSubmenu], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MultiMenuWithSubmenu); + detectChanges(); + }); + + it('should toggle menu from menu bar when clicked', () => { + openFileMenu(); + + expect(nativeMenus.length).toBe(1); + expect(nativeMenus[0].id).toBe('file_menu'); + + dispatchMouseEvent(menuBarNativeItems[0], 'click'); + detectChanges(); + + expect(nativeMenus.length).toBe(0); + }); + + it('should not open menu when hovering over trigger in menu bar with no open siblings', () => { + dispatchMouseEvent(menuBarNativeItems[0], 'mouseenter'); + detectChanges(); + + expect(nativeMenus.length).toBe(0); + }); + + it( + 'should not change focused items when hovering over trigger in menu bar with no open ' + + 'siblings', + () => { + dispatchMouseEvent(menuBarNativeItems[0], 'mouseenter'); + detectChanges(); + + expect(document.querySelector(':focus')).not.toEqual(menuBarNativeItems[0]); + expect(document.querySelector(':focus')).not.toEqual(menuBarNativeItems[1]); + } + ); + + it( + 'should toggle open menus in menu bar if sibling is open when mouse moves from one item ' + + 'to the other', + () => { + openFileMenu(); + + dispatchMouseEvent(menuBarNativeItems[1], 'mouseenter'); + detectChanges(); + + expect(nativeMenus.length).toBe(1); + expect(nativeMenus[0].id).toBe('edit_menu'); + + dispatchMouseEvent(menuBarNativeItems[0], 'mouseenter'); + detectChanges(); + + expect(nativeMenus.length).toBe(1); + expect(nativeMenus[0].id).toBe('file_menu'); + + dispatchMouseEvent(menuBarNativeItems[1], 'mouseenter'); + detectChanges(); + + expect(nativeMenus.length).toBe(1); + expect(nativeMenus[0].id).toBe('edit_menu'); + } + ); + + it('should not close the menu when re-hovering the trigger', () => { + openFileMenu(); + + dispatchMouseEvent(menuBarNativeItems[0], 'mouseenter'); + + expect(nativeMenus.length).toBe(1); + expect(nativeMenus[0].id).toBe('file_menu'); + }); + + it('should open a submenu when hovering over a trigger in a menu with no siblings open', () => { + openFileMenu(); + + openShareMenu(); + + expect(nativeMenus.length).toBe(2); + expect(nativeMenus[0].id).toBe('file_menu'); + expect(nativeMenus[1].id).toBe('share_menu'); + }); + + it('should close menu when hovering over non-triggering sibling menu item', () => { + openFileMenu(); + openShareMenu(); + + dispatchMouseEvent(fileMenuNativeItems[0], 'mouseenter'); + detectChanges(); + + expect(nativeMenus.length).toBe(1); + expect(nativeMenus[0].id).toBe('file_menu'); + }); + + it('should retain open menus when hovering over root level trigger which opened them', () => { + openFileMenu(); + openShareMenu(); + + dispatchMouseEvent(menuBarNativeItems[0], 'mouseenter'); + detectChanges(); + + expect(nativeMenus.length).toBe(2); + }); + + it('should close out the menu tree when hovering over sibling item in menu bar', () => { + openFileMenu(); + openShareMenu(); + + dispatchMouseEvent(menuBarNativeItems[1], 'mouseenter'); + detectChanges(); + + expect(nativeMenus.length).toBe(1); + expect(nativeMenus[0].id).toBe('edit_menu'); + }); + + it('should close out the menu tree when clicking a non-triggering menu item', () => { + openFileMenu(); + openShareMenu(); + + dispatchMouseEvent(shareMenuNativeItems[0], 'mouseenter'); + dispatchMouseEvent(shareMenuNativeItems[0], 'click'); + detectChanges(); + + expect(nativeMenus.length).toBe(0); + }); + + it( + 'should allow keyboard down arrow to focus next item after mouse sets focus to' + + ' initial item', + () => { + openFileMenu(); + dispatchMouseEvent(fileMenuNativeItems[0], 'mouseenter'); + detectChanges(); + + dispatchKeyboardEvent(nativeMenus[0], 'keydown', DOWN_ARROW); + + expect(document.querySelector(':focus')).toEqual(fileMenuNativeItems[1]); + } + ); + }); }); @Component({ diff --git a/src/cdk-experimental/menu/menu-bar.ts b/src/cdk-experimental/menu/menu-bar.ts index c4fcf8cb92b2..e414ff6937ee 100644 --- a/src/cdk-experimental/menu/menu-bar.ts +++ b/src/cdk-experimental/menu/menu-bar.ts @@ -14,16 +14,18 @@ import { AfterContentInit, OnDestroy, Optional, + NgZone, } from '@angular/core'; import {Directionality} from '@angular/cdk/bidi'; import {FocusKeyManager, FocusOrigin} from '@angular/cdk/a11y'; import {LEFT_ARROW, RIGHT_ARROW, UP_ARROW, DOWN_ARROW, ESCAPE, TAB} from '@angular/cdk/keycodes'; import {takeUntil, mergeAll, mapTo, startWith, mergeMap, switchMap} from 'rxjs/operators'; -import {Subject, merge} from 'rxjs'; +import {Subject, merge, Observable} from 'rxjs'; import {CdkMenuGroup} from './menu-group'; import {CDK_MENU, Menu} from './menu-interface'; import {CdkMenuItem} from './menu-item'; import {MenuStack, MenuStackItem, FocusNext} from './menu-stack'; +import {getItemPointerEntries} from './item-pointer-entries'; /** * Check if the given element is part of the cdk menu module. @@ -68,6 +70,9 @@ export class CdkMenuBar extends CdkMenuGroup implements Menu, AfterContentInit, /** Handles keyboard events for the MenuBar. */ private _keyManager: FocusKeyManager; + /** Emits when a child MenuItem is moused over. */ + private _mouseFocusChanged: Observable; + /** Emits when the MenuBar is destroyed. */ private readonly _destroyed: Subject = new Subject(); @@ -78,7 +83,11 @@ export class CdkMenuBar extends CdkMenuGroup implements Menu, AfterContentInit, /** The Menu Item which triggered the open submenu. */ private _openItem?: CdkMenuItem; - constructor(readonly _menuStack: MenuStack, @Optional() private readonly _dir?: Directionality) { + constructor( + readonly _menuStack: MenuStack, + private readonly _ngZone: NgZone, + @Optional() private readonly _dir?: Directionality + ) { super(); } @@ -88,6 +97,7 @@ export class CdkMenuBar extends CdkMenuGroup implements Menu, AfterContentInit, this._setKeyManager(); this._subscribeToMenuOpen(); this._subscribeToMenuStack(); + this._subscribeToMouseManager(); } /** Place focus on the first MenuItem in the menu and set the focus origin. */ @@ -163,6 +173,21 @@ export class CdkMenuBar extends CdkMenuGroup implements Menu, AfterContentInit, } } + /** + * Set the FocusMouseManager and ensure that when mouse focus changes the key manager is updated + * with the latest menu item under mouse focus. + */ + private _subscribeToMouseManager() { + this._ngZone.runOutsideAngular(() => { + this._mouseFocusChanged = getItemPointerEntries(this._allItems); + this._mouseFocusChanged.pipe(takeUntil(this._destroyed)).subscribe(item => { + if (this._hasOpenSubmenu()) { + this._keyManager.setActiveItem(item); + } + }); + }); + } + /** Subscribe to the MenuStack close and empty observables. */ private _subscribeToMenuStack() { this._menuStack.closed @@ -235,7 +260,7 @@ export class CdkMenuBar extends CdkMenuGroup implements Menu, AfterContentInit, target = target.parentElement; } - this._openItem?.getMenuTrigger()?.toggle(); + this._menuStack.closeAll(); } } diff --git a/src/cdk-experimental/menu/menu-item-radio.ts b/src/cdk-experimental/menu/menu-item-radio.ts index c2a42c5eb469..a3baf58d836e 100644 --- a/src/cdk-experimental/menu/menu-item-radio.ts +++ b/src/cdk-experimental/menu/menu-item-radio.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ import {UniqueSelectionDispatcher} from '@angular/cdk/collections'; -import {Directive, OnDestroy, ElementRef, Self, Optional, Inject} from '@angular/core'; +import {Directive, OnDestroy, ElementRef, Self, Optional, Inject, NgZone} from '@angular/core'; import {Directionality} from '@angular/cdk/bidi'; import {CdkMenuItemSelectable} from './menu-item-selectable'; import {CdkMenuItem} from './menu-item'; @@ -40,6 +40,7 @@ export class CdkMenuItemRadio extends CdkMenuItemSelectable implements OnDestroy constructor( private readonly _selectionDispatcher: UniqueSelectionDispatcher, element: ElementRef, + ngZone: NgZone, @Inject(CDK_MENU) parentMenu: Menu, @Optional() dir?: Directionality, /** Reference to the CdkMenuItemTrigger directive if one is added to the same element */ @@ -47,7 +48,7 @@ export class CdkMenuItemRadio extends CdkMenuItemSelectable implements OnDestroy // tslint:disable-next-line: lightweight-tokens @Self() @Optional() menuTrigger?: CdkMenuItemTrigger ) { - super(element, parentMenu, dir, menuTrigger); + super(element, parentMenu, ngZone, dir, menuTrigger); this._registerDispatcherListener(); } @@ -69,6 +70,7 @@ export class CdkMenuItemRadio extends CdkMenuItemSelectable implements OnDestroy } ngOnDestroy() { + super.ngOnDestroy(); this._removeDispatcherListener(); } } diff --git a/src/cdk-experimental/menu/menu-item-trigger.ts b/src/cdk-experimental/menu/menu-item-trigger.ts index 50291eac0696..597f6a2a3c45 100644 --- a/src/cdk-experimental/menu/menu-item-trigger.ts +++ b/src/cdk-experimental/menu/menu-item-trigger.ts @@ -45,6 +45,8 @@ import {FocusNext} from './menu-stack'; exportAs: 'cdkMenuTriggerFor', host: { '(keydown)': '_toggleOnKeydown($event)', + '(mouseenter)': '_toggleOnMouseEnter()', + '(click)': 'toggle()', 'tabindex': '-1', 'aria-haspopup': 'menu', '[attr.aria-expanded]': 'isMenuOpen()', @@ -132,6 +134,27 @@ export class CdkMenuItemTrigger implements OnDestroy { return this.menuPanel?._menu; } + /** + * If there are existing open menus and this menu is not open, close sibling menus and open + * this one. + */ + _toggleOnMouseEnter() { + const menuStack = this._getMenuStack(); + if (!menuStack.isEmpty() && !this.isMenuOpen()) { + // If nothing was removed from the stack and the last element is not the parent item + // that means that the parent menu is a menu bar since we don't put the menu bar on the + // stack + const isParentMenuBar = + !menuStack.closeSubMenuOf(this._parentMenu) && menuStack.peek() !== this._parentMenu; + + if (isParentMenuBar) { + menuStack.closeAll(); + } + + this.openMenu(); + } + } + /** * Handles keyboard events for the menu item, specifically opening/closing the attached menu and * focusing the appropriate submenu item. diff --git a/src/cdk-experimental/menu/menu-item.ts b/src/cdk-experimental/menu/menu-item.ts index ac1041ab88de..9d1b953343b9 100644 --- a/src/cdk-experimental/menu/menu-item.ts +++ b/src/cdk-experimental/menu/menu-item.ts @@ -16,14 +16,19 @@ import { EventEmitter, Inject, HostListener, + NgZone, + OnDestroy, } from '@angular/core'; import {coerceBooleanProperty, BooleanInput} from '@angular/cdk/coercion'; import {FocusableOption} from '@angular/cdk/a11y'; import {SPACE, ENTER, RIGHT_ARROW, LEFT_ARROW} from '@angular/cdk/keycodes'; import {Directionality} from '@angular/cdk/bidi'; +import {Subject, fromEvent} from 'rxjs'; +import {takeUntil, filter} from 'rxjs/operators'; import {CdkMenuItemTrigger} from './menu-item-trigger'; import {Menu, CDK_MENU} from './menu-interface'; import {FocusNext} from './menu-stack'; +import {FocusableElement} from './item-pointer-entries'; // TODO refactor this to be configurable allowing for custom elements to be removed /** Removes all icons from within the given element. */ @@ -49,7 +54,7 @@ function removeIcons(element: Element) { '[attr.aria-disabled]': 'disabled || null', }, }) -export class CdkMenuItem implements FocusableOption { +export class CdkMenuItem implements FocusableOption, FocusableElement, OnDestroy { /** Whether the CdkMenuItem is disabled - defaults to false */ @Input() get disabled(): boolean { @@ -66,21 +71,32 @@ export class CdkMenuItem implements FocusableOption { */ @Output('cdkMenuItemTriggered') triggered: EventEmitter = new EventEmitter(); + /** Emits when the menu item is destroyed. */ + private readonly _destroyed: Subject = new Subject(); + constructor( - private readonly _elementRef: ElementRef, + readonly _elementRef: ElementRef, @Inject(CDK_MENU) private readonly _parentMenu: Menu, + private readonly _ngZone: NgZone, @Optional() private readonly _dir?: Directionality, /** Reference to the CdkMenuItemTrigger directive if one is added to the same element */ // `CdkMenuItem` is commonly used in combination with a `CdkMenuItemTrigger`. // tslint:disable-next-line: lightweight-tokens @Self() @Optional() private readonly _menuTrigger?: CdkMenuItemTrigger - ) {} + ) { + this._setupMouseEnter(); + } /** Place focus on the element. */ focus() { this._elementRef.nativeElement.focus(); } + // In Ivy the `host` metadata will be merged, whereas in ViewEngine it is overridden. In order + // to avoid double event listeners, we need to use `HostListener`. Once Ivy is the default, we + // can move this back into `host`. + // tslint:disable:no-host-decorator-in-concrete + @HostListener('click') /** * If the menu item is not disabled and the element does not have a menu trigger attached, emit * on the cdkMenuItemTriggered emitter and close all open menus. @@ -164,6 +180,23 @@ export class CdkMenuItem implements FocusableOption { } } + /** + * Subscribe to the mouseenter events and close any sibling menu items if this element is moused + * into. + */ + private _setupMouseEnter() { + this._ngZone.runOutsideAngular(() => + fromEvent(this._elementRef.nativeElement, 'mouseenter') + .pipe( + filter(() => !this._getMenuStack().isEmpty() && !this.hasMenu()), + takeUntil(this._destroyed) + ) + .subscribe(() => { + this._ngZone.run(() => this._getMenuStack().closeSubMenuOf(this._parentMenu)); + }) + ); + } + /** Return true if the enclosing parent menu is configured in a horizontal orientation. */ private _isParentVertical() { return this._parentMenu.orientation === 'vertical'; @@ -177,5 +210,9 @@ export class CdkMenuItem implements FocusableOption { return this._parentMenu._menuStack; } + ngOnDestroy() { + this._destroyed.next(); + } + static ngAcceptInputType_disabled: BooleanInput; } diff --git a/src/cdk-experimental/menu/menu-stack.ts b/src/cdk-experimental/menu/menu-stack.ts index 999fbf418efd..cbea938e10bc 100644 --- a/src/cdk-experimental/menu/menu-stack.ts +++ b/src/cdk-experimental/menu/menu-stack.ts @@ -40,14 +40,14 @@ export class MenuStack { private readonly _empty: Subject = new Subject(); /** Observable which emits the MenuStackItem which has been requested to close. */ - readonly closed: Observable = this._close.asObservable(); + readonly closed: Observable = this._close; /** * Observable which emits when the MenuStack is empty after popping off the last element. It * emits a FocusNext event which specifies the action the closer has requested the listener * perform. */ - readonly emptied: Observable = this._empty.asObservable(); + readonly emptied: Observable = this._empty; /** @param menu the MenuStackItem to put on the stack. */ push(menu: MenuStackItem) { @@ -80,13 +80,17 @@ export class MenuStack { * Pop items off of the stack up to but excluding `lastItem` and emit each on the close * observable. If the stack is empty or `lastItem` is not on the stack it does nothing. * @param lastItem the element which should be left on the stack + * @return whether or not an item was removed from the stack */ closeSubMenuOf(lastItem: MenuStackItem) { + let removed = false; if (this._elements.indexOf(lastItem) >= 0) { + removed = this.peek() !== lastItem; while (this.peek() !== lastItem) { this._close.next(this._elements.pop()); } } + return removed; } /** diff --git a/src/cdk-experimental/menu/menu.ts b/src/cdk-experimental/menu/menu.ts index bc903b4f5eac..84589dc9641b 100644 --- a/src/cdk-experimental/menu/menu.ts +++ b/src/cdk-experimental/menu/menu.ts @@ -17,6 +17,7 @@ import { OnDestroy, Optional, OnInit, + NgZone, } from '@angular/core'; import {FocusKeyManager, FocusOrigin} from '@angular/cdk/a11y'; import { @@ -30,13 +31,14 @@ import { } from '@angular/cdk/keycodes'; import {Directionality} from '@angular/cdk/bidi'; import {take, takeUntil, startWith, mergeMap, mapTo, mergeAll, switchMap} from 'rxjs/operators'; -import {merge} from 'rxjs'; +import {merge, Observable} from 'rxjs'; import {CdkMenuGroup} from './menu-group'; import {CdkMenuPanel} from './menu-panel'; import {Menu, CDK_MENU} from './menu-interface'; import {throwMissingMenuPanelError} from './menu-errors'; import {CdkMenuItem} from './menu-item'; import {MenuStack, MenuStackItem, FocusNext} from './menu-stack'; +import {getItemPointerEntries} from './item-pointer-entries'; /** * Directive which configures the element as a Menu which should contain child elements marked as @@ -75,6 +77,9 @@ export class CdkMenu extends CdkMenuGroup implements Menu, AfterContentInit, OnI /** Handles keyboard events for the menu. */ private _keyManager: FocusKeyManager; + /** Emits when a child MenuItem is moused over. */ + private _mouseFocusChanged: Observable; + /** List of nested CdkMenuGroup elements */ @ContentChildren(CdkMenuGroup, {descendants: true}) private readonly _nestedGroups: QueryList; @@ -96,6 +101,7 @@ export class CdkMenu extends CdkMenuGroup implements Menu, AfterContentInit, OnI @Input('cdkMenuPanel') private readonly _explicitPanel?: CdkMenuPanel; constructor( + private readonly _ngZone: NgZone, @Optional() private readonly _dir?: Directionality, // `CdkMenuPanel` is always used in combination with a `CdkMenu`. // tslint:disable-next-line: lightweight-tokens @@ -115,6 +121,7 @@ export class CdkMenu extends CdkMenuGroup implements Menu, AfterContentInit, OnI this._setKeyManager(); this._subscribeToMenuOpen(); this._subscribeToMenuStack(); + this._subscribeToMouseManager(); } /** Place focus on the first MenuItem in the menu and set the focus origin. */ @@ -220,6 +227,19 @@ export class CdkMenu extends CdkMenuGroup implements Menu, AfterContentInit, OnI } } + /** + * Set the FocusMouseManager and ensure that when mouse focus changes the key manager is updated + * with the latest menu item under mouse focus. + */ + private _subscribeToMouseManager() { + this._ngZone.runOutsideAngular(() => { + this._mouseFocusChanged = getItemPointerEntries(this._allItems); + this._mouseFocusChanged + .pipe(takeUntil(this.closed)) + .subscribe(item => this._keyManager.setActiveItem(item)); + }); + } + /** Subscribe to the MenuStack close and empty observables. */ private _subscribeToMenuStack() { this._menuStack.closed