Skip to content

feat(cdk-experimental/menu): add the ability to open/close menus on mouse click and hover #20118

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 29, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/cdk-experimental/menu/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ ng_test_library(
"//src/cdk/keycodes",
"//src/cdk/testing/private",
"@npm//@angular/platform-browser",
"@npm//rxjs",
],
)

Expand Down
103 changes: 103 additions & 0 deletions src/cdk-experimental/menu/item-pointer-entries.spec.ts
Original file line number Diff line number Diff line change
@@ -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<MultiElementWithConditionalComponent>;
let mouseFocusChanged: Observable<MockWrapper>;
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: `<ng-content></ng-content>`,
})
class MockWrapper implements FocusableElement {
constructor(readonly _elementRef: ElementRef<HTMLElement>) {}
}

@Component({
template: `
<div>
<wrapper>First</wrapper>
<wrapper>Second</wrapper>
<wrapper *ngIf="showThird">Third</wrapper>
</div>
`,
})
class MultiElementWithConditionalComponent implements AfterViewInit {
/** Whether the third element should be displayed. */
showThird = false;

/** All mock elements. */
@ViewChildren(MockWrapper) readonly _allItems: QueryList<MockWrapper>;

/** Manages elements under mouse focus. */
mouseFocusChanged: Observable<MockWrapper>;

ngAfterViewInit() {
this.mouseFocusChanged = getItemPointerEntries(this._allItems);
}
}
40 changes: 40 additions & 0 deletions src/cdk-experimental/menu/item-pointer-entries.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>;
}

/**
* Gets a stream of pointer (mouse) entries into the given items.
* This should typically run outside the Angular zone.
*/
export function getItemPointerEntries<T extends FocusableElement>(
items: QueryList<T>
): Observable<T> {
return defer(() =>
items.changes.pipe(
startWith(items),
mergeMap((list: QueryList<T>) =>
list.map(element =>
fromEvent(element._elementRef.nativeElement, 'mouseenter').pipe(
mapTo(element),
takeUntil(items.changes)
)
)
),
mergeAll()
)
);
}
190 changes: 190 additions & 0 deletions src/cdk-experimental/menu/menu-bar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
dispatchKeyboardEvent,
createKeyboardEvent,
dispatchEvent,
dispatchMouseEvent,
} from '@angular/cdk/testing/private';
import {CdkMenuBar} from './menu-bar';
import {CdkMenuModule} from './menu-module';
Expand Down Expand Up @@ -837,6 +838,195 @@ describe('MenuBar', () => {
.toBe(1);
});
});

describe('Mouse handling', () => {
let fixture: ComponentFixture<MultiMenuWithSubmenu>;
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({
Expand Down
Loading