Skip to content

Commit cdbf2c1

Browse files
authored
feat(cdk-experimental/menu): add the ability to open/close menus on mouse click and hover (#20118)
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 c5ec29f commit cdbf2c1

File tree

10 files changed

+456
-11
lines changed

10 files changed

+456
-11
lines changed

src/cdk-experimental/menu/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ ng_test_library(
3333
"//src/cdk/keycodes",
3434
"//src/cdk/testing/private",
3535
"@npm//@angular/platform-browser",
36+
"@npm//rxjs",
3637
],
3738
)
3839

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 {Observable} from 'rxjs';
5+
import {FocusableElement, getItemPointerEntries} from './item-pointer-entries';
6+
7+
describe('FocusMouseManger', () => {
8+
let fixture: ComponentFixture<MultiElementWithConditionalComponent>;
9+
let mouseFocusChanged: Observable<MockWrapper>;
10+
let mockElements: MockWrapper[];
11+
12+
/** Get the components under test from the fixture. */
13+
function getComponentsForTesting() {
14+
mouseFocusChanged = fixture.componentInstance.mouseFocusChanged;
15+
mockElements = fixture.componentInstance._allItems.toArray();
16+
}
17+
18+
beforeEach(async(() => {
19+
TestBed.configureTestingModule({
20+
declarations: [MultiElementWithConditionalComponent, MockWrapper],
21+
}).compileComponents();
22+
}));
23+
24+
beforeEach(() => {
25+
fixture = TestBed.createComponent(MultiElementWithConditionalComponent);
26+
fixture.detectChanges();
27+
28+
getComponentsForTesting();
29+
});
30+
31+
it('should emit on mouseEnter observable when mouse enters a tracked element', () => {
32+
const spy = jasmine.createSpy('mouse enter spy');
33+
mouseFocusChanged.subscribe(spy);
34+
35+
const event = createMouseEvent('mouseenter');
36+
dispatchEvent(mockElements[0]._elementRef.nativeElement, event);
37+
fixture.detectChanges();
38+
39+
expect(spy).toHaveBeenCalledTimes(1);
40+
expect(spy).toHaveBeenCalledWith(mockElements[0]);
41+
});
42+
43+
it('should be aware of newly created/added components and track them', () => {
44+
const spy = jasmine.createSpy('mouse enter spy');
45+
mouseFocusChanged.subscribe(spy);
46+
47+
expect(mockElements.length).toBe(2);
48+
fixture.componentInstance.showThird = true;
49+
fixture.detectChanges();
50+
getComponentsForTesting();
51+
52+
const mouseEnter = createMouseEvent('mouseenter');
53+
dispatchEvent(mockElements[2]._elementRef.nativeElement, mouseEnter);
54+
55+
expect(spy).toHaveBeenCalledTimes(1);
56+
expect(spy).toHaveBeenCalledWith(mockElements[2]);
57+
});
58+
59+
it('should toggle focused items when hovering from one to another', () => {
60+
const spy = jasmine.createSpy('focus toggle spy');
61+
mouseFocusChanged.subscribe(spy);
62+
63+
const mouseEnter = createMouseEvent('mouseenter');
64+
dispatchEvent(mockElements[0]._elementRef.nativeElement, mouseEnter);
65+
dispatchEvent(mockElements[1]._elementRef.nativeElement, mouseEnter);
66+
67+
expect(spy).toHaveBeenCalledTimes(2);
68+
expect(spy.calls.argsFor(0)[0]).toEqual(mockElements[0]);
69+
expect(spy.calls.argsFor(1)[0]).toEqual(mockElements[1]);
70+
});
71+
});
72+
73+
@Component({
74+
selector: 'wrapper',
75+
template: `<ng-content></ng-content>`,
76+
})
77+
class MockWrapper implements FocusableElement {
78+
constructor(readonly _elementRef: ElementRef<HTMLElement>) {}
79+
}
80+
81+
@Component({
82+
template: `
83+
<div>
84+
<wrapper>First</wrapper>
85+
<wrapper>Second</wrapper>
86+
<wrapper *ngIf="showThird">Third</wrapper>
87+
</div>
88+
`,
89+
})
90+
class MultiElementWithConditionalComponent implements AfterViewInit {
91+
/** Whether the third element should be displayed. */
92+
showThird = false;
93+
94+
/** All mock elements. */
95+
@ViewChildren(MockWrapper) readonly _allItems: QueryList<MockWrapper>;
96+
97+
/** Manages elements under mouse focus. */
98+
mouseFocusChanged: Observable<MockWrapper>;
99+
100+
ngAfterViewInit() {
101+
this.mouseFocusChanged = getItemPointerEntries(this._allItems);
102+
}
103+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
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, defer} 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+
* Gets a stream of pointer (mouse) entries into the given items.
21+
* This should typically run outside the Angular zone.
22+
*/
23+
export function getItemPointerEntries<T extends FocusableElement>(
24+
items: QueryList<T>
25+
): Observable<T> {
26+
return defer(() =>
27+
items.changes.pipe(
28+
startWith(items),
29+
mergeMap((list: QueryList<T>) =>
30+
list.map(element =>
31+
fromEvent(element._elementRef.nativeElement, 'mouseenter').pipe(
32+
mapTo(element),
33+
takeUntil(items.changes)
34+
)
35+
)
36+
),
37+
mergeAll()
38+
)
39+
);
40+
}

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+
dispatchMouseEvent,
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+
dispatchMouseEvent(menuBarNativeItems[0], 'mouseenter');
875+
dispatchMouseEvent(menuBarNativeItems[0], 'click');
876+
detectChanges();
877+
}
878+
879+
/** Mock mouse events required to open the share menu. */
880+
function openShareMenu() {
881+
dispatchMouseEvent(fileMenuNativeItems[1], '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+
dispatchMouseEvent(menuBarNativeItems[0], '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+
dispatchMouseEvent(menuBarNativeItems[0], '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+
dispatchMouseEvent(menuBarNativeItems[0], '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+
dispatchMouseEvent(menuBarNativeItems[1], 'mouseenter');
935+
detectChanges();
936+
937+
expect(nativeMenus.length).toBe(1);
938+
expect(nativeMenus[0].id).toBe('edit_menu');
939+
940+
dispatchMouseEvent(menuBarNativeItems[0], 'mouseenter');
941+
detectChanges();
942+
943+
expect(nativeMenus.length).toBe(1);
944+
expect(nativeMenus[0].id).toBe('file_menu');
945+
946+
dispatchMouseEvent(menuBarNativeItems[1], '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+
dispatchMouseEvent(menuBarNativeItems[0], '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+
dispatchMouseEvent(fileMenuNativeItems[0], '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+
dispatchMouseEvent(menuBarNativeItems[0], '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+
dispatchMouseEvent(menuBarNativeItems[1], '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+
dispatchMouseEvent(shareMenuNativeItems[0], 'mouseenter');
1010+
dispatchMouseEvent(shareMenuNativeItems[0], '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+
dispatchMouseEvent(fileMenuNativeItems[0], '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)