Skip to content

Commit aec83c0

Browse files
committed
feat(cdk-experimental/menu): add the ability to open/close menus on mouse click and hover
Add the ability to open/close a menu when a user clicks a menu trigger and when a user hovers over menu items. Additionally, keep track of hovered menu items and sync them with the FocusKeyManager allowing a user to continue with a keyboard where they left off with their mouse.
1 parent 75e0612 commit aec83c0

File tree

8 files changed

+420
-5
lines changed

8 files changed

+420
-5
lines changed
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import {Component, QueryList, ElementRef, ViewChildren, AfterViewInit} from '@angular/core';
2+
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
3+
import {createMouseEvent, dispatchEvent} from '@angular/cdk/testing/private';
4+
import {FocusMouseManager, FocusableElement} from './focus-mouse-manager';
5+
6+
describe('FocusMouseManger', () => {
7+
let fixture: ComponentFixture<MultiElementWithConditionalComponent>;
8+
let focusManager: FocusMouseManager<MockWrapper>;
9+
let mockElements: MockWrapper[];
10+
11+
/** Get the components under test from the fixture. */
12+
function getComponentsForTesting() {
13+
focusManager = fixture.componentInstance.focusManager;
14+
mockElements = fixture.componentInstance._allItems.toArray();
15+
}
16+
17+
beforeEach(async(() => {
18+
TestBed.configureTestingModule({
19+
declarations: [MultiElementWithConditionalComponent, MockWrapper],
20+
}).compileComponents();
21+
}));
22+
23+
beforeEach(() => {
24+
fixture = TestBed.createComponent(MultiElementWithConditionalComponent);
25+
fixture.detectChanges();
26+
27+
getComponentsForTesting();
28+
});
29+
30+
it('should emit on mouseEnter observable when mouse enters a tracked element', () => {
31+
const spy = jasmine.createSpy('mouse enter spy');
32+
focusManager.mouseEntered.subscribe(spy);
33+
34+
const event = createMouseEvent('mouseenter');
35+
dispatchEvent(mockElements[0]._elementRef.nativeElement, event);
36+
fixture.detectChanges();
37+
38+
expect(spy).toHaveBeenCalledTimes(1);
39+
expect(spy).toHaveBeenCalledWith(mockElements[0]);
40+
});
41+
42+
it('should be aware of newly created/added components and track them', () => {
43+
const spy = jasmine.createSpy('mouse enter spy');
44+
focusManager.mouseEntered.subscribe(spy);
45+
46+
expect(fixture.componentInstance.showThird).toBeFalse();
47+
fixture.componentInstance.showThird = true;
48+
fixture.detectChanges();
49+
getComponentsForTesting();
50+
51+
const mouseEnter = createMouseEvent('mouseenter');
52+
dispatchEvent(mockElements[2]._elementRef.nativeElement, mouseEnter);
53+
54+
expect(spy).toHaveBeenCalledTimes(1);
55+
expect(spy).toHaveBeenCalledWith(mockElements[2]);
56+
});
57+
58+
it('should toggle focused items when hovering from one to another', () => {
59+
const spy = jasmine.createSpy('focus toggle spy');
60+
focusManager.mouseEntered.subscribe(spy);
61+
62+
const mouseEnter = createMouseEvent('mouseenter');
63+
dispatchEvent(mockElements[0]._elementRef.nativeElement, mouseEnter);
64+
dispatchEvent(mockElements[1]._elementRef.nativeElement, mouseEnter);
65+
66+
expect(spy).toHaveBeenCalledTimes(2);
67+
expect(spy.calls.argsFor(0)[0]).toEqual(mockElements[0]);
68+
expect(spy.calls.argsFor(1)[0]).toEqual(mockElements[1]);
69+
});
70+
});
71+
72+
@Component({
73+
selector: 'wrapper',
74+
template: `<ng-content></ng-content>`,
75+
})
76+
class MockWrapper implements FocusableElement {
77+
constructor(readonly _elementRef: ElementRef<HTMLElement>) {}
78+
}
79+
80+
@Component({
81+
template: `
82+
<div>
83+
<wrapper>First</wrapper>
84+
<wrapper>Second</wrapper>
85+
<wrapper *ngIf="showThird">Third</wrapper>
86+
</div>
87+
`,
88+
})
89+
class MultiElementWithConditionalComponent implements AfterViewInit {
90+
/** Whether the third element should be displayed. */
91+
showThird = false;
92+
93+
/** All mock elements. */
94+
@ViewChildren(MockWrapper)
95+
readonly _allItems: QueryList<MockWrapper>;
96+
97+
/** Manages elements under mouse focus. */
98+
focusManager: FocusMouseManager<MockWrapper>;
99+
100+
ngAfterViewInit() {
101+
this.focusManager = new FocusMouseManager(this._allItems);
102+
}
103+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {QueryList, ElementRef} from '@angular/core';
10+
import {fromEvent, Observable} from 'rxjs';
11+
import {mapTo, mergeAll, takeUntil, startWith, mergeMap} from 'rxjs/operators';
12+
13+
/** Item to track for mouse focus events. */
14+
export interface FocusableElement {
15+
/** A reference to the element to be tracked. */
16+
_elementRef: ElementRef<HTMLElement>;
17+
}
18+
19+
/**
20+
* FocusMouseManager keeps track of the element under mouse focus. It emits the element on an
21+
* observable when an element is moused into.
22+
*/
23+
export class FocusMouseManager<T extends FocusableElement> {
24+
/** Observable which emits when the mouse enters one of the managed elements. */
25+
readonly mouseEntered: Observable<T> = this._getMouseEnterObservable();
26+
27+
constructor(private readonly _elements: QueryList<T>) {}
28+
29+
/**
30+
* Get an observable which emits when the mouse enters a given FocusableElement and consider
31+
* any elements added or removed from the list of managed elements.
32+
*/
33+
private _getMouseEnterObservable() {
34+
return this._elements.changes.pipe(
35+
startWith(this._elements),
36+
mergeMap((list: QueryList<T>) =>
37+
list.map(element =>
38+
fromEvent(element._elementRef.nativeElement, 'mouseenter').pipe(
39+
mapTo(element),
40+
takeUntil(this._elements.changes)
41+
)
42+
)
43+
),
44+
mergeAll()
45+
);
46+
}
47+
}

src/cdk-experimental/menu/menu-bar.spec.ts

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
dispatchKeyboardEvent,
2828
createKeyboardEvent,
2929
dispatchEvent,
30+
createMouseEvent,
3031
} from '@angular/cdk/testing/private';
3132
import {CdkMenuBar} from './menu-bar';
3233
import {CdkMenuModule} from './menu-module';
@@ -837,6 +838,195 @@ describe('MenuBar', () => {
837838
.toBe(1);
838839
});
839840
});
841+
842+
describe('Mouse handling', () => {
843+
let fixture: ComponentFixture<MultiMenuWithSubmenu>;
844+
let nativeMenus: HTMLElement[];
845+
let menuBarNativeItems: HTMLButtonElement[];
846+
let fileMenuNativeItems: HTMLButtonElement[];
847+
let shareMenuNativeItems: HTMLButtonElement[];
848+
849+
/** Get menus and items used for tests. */
850+
function grabElementsForTesting() {
851+
nativeMenus = fixture.componentInstance.nativeMenus.map(e => e.nativeElement);
852+
853+
menuBarNativeItems = fixture.componentInstance.nativeItems
854+
.map(e => e.nativeElement)
855+
.slice(0, 2); // menu bar has the first 2 menu items
856+
857+
fileMenuNativeItems = fixture.componentInstance.nativeItems
858+
.map(e => e.nativeElement)
859+
.slice(2, 5); // file menu has the next 3 menu items
860+
861+
shareMenuNativeItems = fixture.componentInstance.nativeItems
862+
.map(e => e.nativeElement)
863+
.slice(5, 7); // share menu has the next 2 menu items
864+
}
865+
866+
/** Run change detection and extract then set the rendered elements. */
867+
function detectChanges() {
868+
fixture.detectChanges();
869+
grabElementsForTesting();
870+
}
871+
872+
/** Mock mouse events required to open the file menu. */
873+
function openFileMenu() {
874+
dispatchEvent(menuBarNativeItems[0], createMouseEvent('mouseenter'));
875+
dispatchEvent(menuBarNativeItems[0], createMouseEvent('click'));
876+
detectChanges();
877+
}
878+
879+
/** Mock mouse events required to open the share menu. */
880+
function openShareMenu() {
881+
dispatchEvent(fileMenuNativeItems[1], createMouseEvent('mouseenter'));
882+
detectChanges();
883+
}
884+
885+
beforeEach(async(() => {
886+
TestBed.configureTestingModule({
887+
imports: [CdkMenuModule],
888+
declarations: [MultiMenuWithSubmenu],
889+
}).compileComponents();
890+
}));
891+
892+
beforeEach(() => {
893+
fixture = TestBed.createComponent(MultiMenuWithSubmenu);
894+
detectChanges();
895+
});
896+
897+
it('should toggle menu from menu bar when clicked', () => {
898+
openFileMenu();
899+
900+
expect(nativeMenus.length).toBe(1);
901+
expect(nativeMenus[0].id).toBe('file_menu');
902+
903+
dispatchEvent(menuBarNativeItems[0], createMouseEvent('click'));
904+
detectChanges();
905+
906+
expect(nativeMenus.length).toBe(0);
907+
});
908+
909+
it('should not open menu when hovering over trigger in menu bar with no open siblings', () => {
910+
dispatchEvent(menuBarNativeItems[0], createMouseEvent('mouseenter'));
911+
detectChanges();
912+
913+
expect(nativeMenus.length).toBe(0);
914+
});
915+
916+
it(
917+
'should not change focused items when hovering over trigger in menu bar with no open ' +
918+
'siblings',
919+
() => {
920+
dispatchEvent(menuBarNativeItems[0], createMouseEvent('mouseenter'));
921+
detectChanges();
922+
923+
expect(document.querySelector(':focus')).not.toEqual(menuBarNativeItems[0]);
924+
expect(document.querySelector(':focus')).not.toEqual(menuBarNativeItems[1]);
925+
}
926+
);
927+
928+
it(
929+
'should toggle open menus in menu bar if sibling is open when mouse moves from one item ' +
930+
'to the other',
931+
() => {
932+
openFileMenu();
933+
934+
dispatchEvent(menuBarNativeItems[1], createMouseEvent('mouseenter'));
935+
detectChanges();
936+
937+
expect(nativeMenus.length).toBe(1);
938+
expect(nativeMenus[0].id).toBe('edit_menu');
939+
940+
dispatchEvent(menuBarNativeItems[0], createMouseEvent('mouseenter'));
941+
detectChanges();
942+
943+
expect(nativeMenus.length).toBe(1);
944+
expect(nativeMenus[0].id).toBe('file_menu');
945+
946+
dispatchEvent(menuBarNativeItems[1], createMouseEvent('mouseenter'));
947+
detectChanges();
948+
949+
expect(nativeMenus.length).toBe(1);
950+
expect(nativeMenus[0].id).toBe('edit_menu');
951+
}
952+
);
953+
954+
it('should not close the menu when re-hovering the trigger', () => {
955+
openFileMenu();
956+
957+
dispatchEvent(menuBarNativeItems[0], createMouseEvent('mouseenter'));
958+
959+
expect(nativeMenus.length).toBe(1);
960+
expect(nativeMenus[0].id).toBe('file_menu');
961+
});
962+
963+
it('should open a submenu when hovering over a trigger in a menu with no siblings open', () => {
964+
openFileMenu();
965+
966+
openShareMenu();
967+
968+
expect(nativeMenus.length).toBe(2);
969+
expect(nativeMenus[0].id).toBe('file_menu');
970+
expect(nativeMenus[1].id).toBe('share_menu');
971+
});
972+
973+
it('should close menu when hovering over non-triggering sibling menu item', () => {
974+
openFileMenu();
975+
openShareMenu();
976+
977+
dispatchEvent(fileMenuNativeItems[0], createMouseEvent('mouseenter'));
978+
detectChanges();
979+
980+
expect(nativeMenus.length).toBe(1);
981+
expect(nativeMenus[0].id).toBe('file_menu');
982+
});
983+
984+
it('should retain open menus when hovering over root level trigger which opened them', () => {
985+
openFileMenu();
986+
openShareMenu();
987+
988+
dispatchEvent(menuBarNativeItems[0], createMouseEvent('mouseenter'));
989+
detectChanges();
990+
991+
expect(nativeMenus.length).toBe(2);
992+
});
993+
994+
it('should close out the menu tree when hovering over sibling item in menu bar', () => {
995+
openFileMenu();
996+
openShareMenu();
997+
998+
dispatchEvent(menuBarNativeItems[1], createMouseEvent('mouseenter'));
999+
detectChanges();
1000+
1001+
expect(nativeMenus.length).toBe(1);
1002+
expect(nativeMenus[0].id).toBe('edit_menu');
1003+
});
1004+
1005+
it('should close out the menu tree when clicking a non-triggering menu item', () => {
1006+
openFileMenu();
1007+
openShareMenu();
1008+
1009+
dispatchEvent(shareMenuNativeItems[0], createMouseEvent('mouseenter'));
1010+
dispatchEvent(shareMenuNativeItems[0], createMouseEvent('click'));
1011+
detectChanges();
1012+
1013+
expect(nativeMenus.length).toBe(0);
1014+
});
1015+
1016+
it(
1017+
'should allow keyboard down arrow to focus next item after mouse sets focus to' +
1018+
' initial item',
1019+
() => {
1020+
openFileMenu();
1021+
dispatchEvent(fileMenuNativeItems[0], createMouseEvent('mouseenter'));
1022+
detectChanges();
1023+
1024+
dispatchKeyboardEvent(nativeMenus[0], 'keydown', DOWN_ARROW);
1025+
1026+
expect(document.querySelector(':focus')).toEqual(fileMenuNativeItems[1]);
1027+
}
1028+
);
1029+
});
8401030
});
8411031

8421032
@Component({

0 commit comments

Comments
 (0)