Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(cdk-experimental/menu): add the ability to open/close menus on m…
…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
Showing
10 changed files
with
456 additions
and
11 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
) | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.