Skip to content

Commit

Permalink
feat(cdk-experimental/menu): add the ability to open/close menus on m…
Browse files Browse the repository at this point in the history
…ouse 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.
  • Loading branch information
andy9775 committed Jul 29, 2020
1 parent c5ec29f commit cdbf2c1
Show file tree
Hide file tree
Showing 10 changed files with 456 additions and 11 deletions.
1 change: 1 addition & 0 deletions src/cdk-experimental/menu/BUILD.bazel
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
@@ -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
@@ -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
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

0 comments on commit cdbf2c1

Please sign in to comment.