feat(overlay): add keyboard dispatcher for targeting correct overlay (#6682)

* feat(overlay): add keyboard dispatcher for targeting correct overlay

Fix typo and add comment

Address comments

Add keyboard tracking demo to overlay

Address comments

Revert no-longer-needed dispose changes

Address comments

Fix prerender error by lazily starting dispatcher

Address naming comment

* Update license

* Remove DOCUMENT token
This commit is contained in:
Will Howell
2017-10-27 13:15:51 -04:00
committed by mmalerba
parent c663fadaf2
commit a2ca4d6dbb
13 changed files with 304 additions and 32 deletions
@@ -0,0 +1,97 @@
import {TestBed, inject} from '@angular/core/testing';
import {dispatchKeyboardEvent} from '@angular/cdk/testing';
import {ESCAPE} from '@angular/cdk/keycodes';
import {Overlay} from '../overlay';
import {OverlayContainer} from '../overlay-container';
import {OverlayModule} from '../index';
import {OverlayKeyboardDispatcher} from './overlay-keyboard-dispatcher';
describe('OverlayKeyboardDispatcher', () => {
let keyboardDispatcher: OverlayKeyboardDispatcher;
let overlay: Overlay;
let overlayContainerElement: HTMLElement;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [OverlayModule],
providers: [
{provide: OverlayContainer, useFactory: () => {
overlayContainerElement = document.createElement('div');
return {getContainerElement: () => overlayContainerElement};
}}
],
});
});
beforeEach(inject([OverlayKeyboardDispatcher, Overlay],
(kbd: OverlayKeyboardDispatcher, o: Overlay) => {
keyboardDispatcher = kbd;
overlay = o;
}));
it('should track overlays in order as they are attached and detached', () => {
const overlayOne = overlay.create();
const overlayTwo = overlay.create();
// Attach overlays
keyboardDispatcher.add(overlayOne);
keyboardDispatcher.add(overlayTwo);
expect(keyboardDispatcher._attachedOverlays.length)
.toBe(2, 'Expected both overlays to be tracked.');
expect(keyboardDispatcher._attachedOverlays[0]).toBe(overlayOne, 'Expected one to be first.');
expect(keyboardDispatcher._attachedOverlays[1]).toBe(overlayTwo, 'Expected two to be last.');
// Detach first one and re-attach it
keyboardDispatcher.remove(overlayOne);
keyboardDispatcher.add(overlayOne);
expect(keyboardDispatcher._attachedOverlays[0])
.toBe(overlayTwo, 'Expected two to now be first.');
expect(keyboardDispatcher._attachedOverlays[1])
.toBe(overlayOne, 'Expected one to now be last.');
});
it('should dispatch body keyboard events to the most recently attached overlay', () => {
const overlayOne = overlay.create();
const overlayTwo = overlay.create();
const overlayOneSpy = jasmine.createSpy('overlayOne keyboard event spy');
const overlayTwoSpy = jasmine.createSpy('overlayOne keyboard event spy');
overlayOne.keydownEvents().subscribe(overlayOneSpy);
overlayTwo.keydownEvents().subscribe(overlayTwoSpy);
// Attach overlays
keyboardDispatcher.add(overlayOne);
keyboardDispatcher.add(overlayTwo);
dispatchKeyboardEvent(document.body, 'keydown', ESCAPE);
// Most recent overlay should receive event
expect(overlayOneSpy).not.toHaveBeenCalled();
expect(overlayTwoSpy).toHaveBeenCalled();
});
it('should dispatch targeted keyboard events to the overlay containing that target', () => {
const overlayOne = overlay.create();
const overlayTwo = overlay.create();
const overlayOneSpy = jasmine.createSpy('overlayOne keyboard event spy');
const overlayTwoSpy = jasmine.createSpy('overlayOne keyboard event spy');
overlayOne.keydownEvents().subscribe(overlayOneSpy);
overlayTwo.keydownEvents().subscribe(overlayTwoSpy);
// Attach overlays
keyboardDispatcher.add(overlayOne);
keyboardDispatcher.add(overlayTwo);
const overlayOnePane = overlayOne.overlayElement;
dispatchKeyboardEvent(document.body, 'keydown', ESCAPE, overlayOnePane);
// Targeted overlay should receive event
expect(overlayOneSpy).toHaveBeenCalled();
expect(overlayTwoSpy).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,95 @@
/**
* @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 {Injectable, Optional, SkipSelf, OnDestroy} from '@angular/core';
import {OverlayRef} from '../overlay-ref';
import {Subscription} from 'rxjs/Subscription';
import {RxChain, filter} from '@angular/cdk/rxjs';
import {fromEvent} from 'rxjs/observable/fromEvent';
/**
* Service for dispatching keyboard events that land on the body to appropriate overlay ref,
* if any. It maintains a list of attached overlays to determine best suited overlay based
* on event target and order of overlay opens.
*/
@Injectable()
export class OverlayKeyboardDispatcher implements OnDestroy {
/** Currently attached overlays in the order they were attached. */
_attachedOverlays: OverlayRef[] = [];
private _keydownEventSubscription: Subscription | null;
ngOnDestroy() {
if (this._keydownEventSubscription) {
this._keydownEventSubscription.unsubscribe();
this._keydownEventSubscription = null;
}
}
/** Add a new overlay to the list of attached overlay refs. */
add(overlayRef: OverlayRef): void {
// Lazily start dispatcher once first overlay is added
if (!this._keydownEventSubscription) {
this._subscribeToKeydownEvents();
}
this._attachedOverlays.push(overlayRef);
}
/** Remove an overlay from the list of attached overlay refs. */
remove(overlayRef: OverlayRef): void {
const index = this._attachedOverlays.indexOf(overlayRef);
if (index > -1) {
this._attachedOverlays.splice(index, 1);
}
}
/**
* Subscribe to keydown events that land on the body and dispatch those
* events to the appropriate overlay.
*/
private _subscribeToKeydownEvents(): void {
const bodyKeydownEvents = fromEvent<KeyboardEvent>(document.body, 'keydown');
this._keydownEventSubscription = RxChain.from(bodyKeydownEvents)
.call(filter, () => !!this._attachedOverlays.length)
.subscribe(event => {
// Dispatch keydown event to correct overlay reference
this._selectOverlayFromEvent(event)._keydownEvents.next(event);
});
}
/** Select the appropriate overlay from a keydown event. */
private _selectOverlayFromEvent(event: KeyboardEvent): OverlayRef {
// Check if any overlays contain the event
const targetedOverlay = this._attachedOverlays.find(overlay => {
return overlay.overlayElement === event.target ||
overlay.overlayElement.contains(event.target as HTMLElement);
});
// Use that overlay if it exists, otherwise choose the most recently attached one
return targetedOverlay || this._attachedOverlays[this._attachedOverlays.length - 1];
}
}
/** @docs-private */
export function OVERLAY_KEYBOARD_DISPATCHER_PROVIDER_FACTORY(
dispatcher: OverlayKeyboardDispatcher) {
return dispatcher || new OverlayKeyboardDispatcher();
}
/** @docs-private */
export const OVERLAY_KEYBOARD_DISPATCHER_PROVIDER = {
// If there is already an OverlayKeyboardDispatcher available, use that.
// Otherwise, provide a new one.
provide: OverlayKeyboardDispatcher,
deps: [[new Optional(), new SkipSelf(), OverlayKeyboardDispatcher]],
useFactory: OVERLAY_KEYBOARD_DISPATCHER_PROVIDER_FACTORY
};
+2
View File
@@ -18,11 +18,13 @@ import {
OverlayOrigin,
} from './overlay-directives';
import {OverlayPositionBuilder} from './position/overlay-position-builder';
import {OVERLAY_KEYBOARD_DISPATCHER_PROVIDER} from './keyboard/overlay-keyboard-dispatcher';
import {ScrollStrategyOptions} from './scroll/scroll-strategy-options';
export const OVERLAY_PROVIDERS: Provider[] = [
Overlay,
OverlayPositionBuilder,
OVERLAY_KEYBOARD_DISPATCHER_PROVIDER,
VIEWPORT_RULER_PROVIDER,
OVERLAY_CONTAINER_PROVIDER,
MAT_CONNECTED_OVERLAY_SCROLL_STRATEGY_PROVIDER,
+20 -4
View File
@@ -9,6 +9,7 @@
import {NgZone} from '@angular/core';
import {PortalHost, Portal} from '@angular/cdk/portal';
import {OverlayConfig} from './overlay-config';
import {OverlayKeyboardDispatcher} from './keyboard/overlay-keyboard-dispatcher';
import {Observable} from 'rxjs/Observable';
import {Subject} from 'rxjs/Subject';
import {first} from 'rxjs/operator/first';
@@ -24,11 +25,15 @@ export class OverlayRef implements PortalHost {
private _attachments = new Subject<void>();
private _detachments = new Subject<void>();
/** Stream of keydown events dispatched to this overlay. */
_keydownEvents = new Subject<KeyboardEvent>();
constructor(
private _portalHost: PortalHost,
private _pane: HTMLElement,
private _config: OverlayConfig,
private _ngZone: NgZone) {
private _ngZone: NgZone,
private _keyboardDispatcher: OverlayKeyboardDispatcher) {
if (_config.scrollStrategy) {
_config.scrollStrategy.attach(this);
@@ -87,6 +92,9 @@ export class OverlayRef implements PortalHost {
// Only emit the `attachments` event once all other setup is done.
this._attachments.next();
// Track this overlay by the keyboard dispatcher
this._keyboardDispatcher.add(this);
return attachResult;
}
@@ -115,6 +123,9 @@ export class OverlayRef implements PortalHost {
// Only emit after everything is detached.
this._detachments.next();
// Remove this overlay from keyboard dispatcher tracking
this._keyboardDispatcher.remove(this);
return detachmentResult;
}
@@ -146,22 +157,27 @@ export class OverlayRef implements PortalHost {
}
/**
* Returns an observable that emits when the backdrop has been clicked.
* Gets an observable that emits when the backdrop has been clicked.
*/
backdropClick(): Observable<void> {
return this._backdropClick.asObservable();
}
/** Returns an observable that emits when the overlay has been attached. */
/** Gets an observable that emits when the overlay has been attached. */
attachments(): Observable<void> {
return this._attachments.asObservable();
}
/** Returns an observable that emits when the overlay has been detached. */
/** Gets an observable that emits when the overlay has been detached. */
detachments(): Observable<void> {
return this._detachments.asObservable();
}
/** Gets an observable of keydown events targeted to this overlay. */
keydownEvents(): Observable<KeyboardEvent> {
return this._keydownEvents.asObservable();
}
/**
* Gets the current config of the overlay.
*/
+4 -1
View File
@@ -17,6 +17,7 @@ import {DomPortalHost} from '@angular/cdk/portal';
import {OverlayConfig} from './overlay-config';
import {OverlayRef} from './overlay-ref';
import {OverlayPositionBuilder} from './position/overlay-position-builder';
import {OverlayKeyboardDispatcher} from './keyboard/overlay-keyboard-dispatcher';
import {OverlayContainer} from './overlay-container';
import {ScrollStrategyOptions} from './scroll/index';
@@ -44,6 +45,7 @@ export class Overlay {
private _overlayContainer: OverlayContainer,
private _componentFactoryResolver: ComponentFactoryResolver,
private _positionBuilder: OverlayPositionBuilder,
private _keyboardDispatcher: OverlayKeyboardDispatcher,
private _appRef: ApplicationRef,
private _injector: Injector,
private _ngZone: NgZone) { }
@@ -56,7 +58,7 @@ export class Overlay {
create(config: OverlayConfig = defaultConfig): OverlayRef {
const pane = this._createPaneElement();
const portalHost = this._createPortalHost(pane);
return new OverlayRef(portalHost, pane, config, this._ngZone);
return new OverlayRef(portalHost, pane, config, this._ngZone, this._keyboardDispatcher);
}
/**
@@ -90,4 +92,5 @@ export class Overlay {
private _createPortalHost(pane: HTMLElement): DomPortalHost {
return new DomPortalHost(pane, this._componentFactoryResolver, this._appRef, this._injector);
}
}
+3 -2
View File
@@ -25,8 +25,9 @@ export function dispatchFakeEvent(node: Node | Window, type: string, canBubble?:
}
/** Shorthand to dispatch a keyboard event with a specified key code. */
export function dispatchKeyboardEvent(node: Node, type: string, keyCode: number): KeyboardEvent {
return dispatchEvent(node, createKeyboardEvent(type, keyCode)) as KeyboardEvent;
export function dispatchKeyboardEvent(node: Node, type: string, keyCode: number, target?: Element):
KeyboardEvent {
return dispatchEvent(node, createKeyboardEvent(type, keyCode, target)) as KeyboardEvent;
}
/** Shorthand to dispatch a mouse event on the specified coordinates. */
+8 -1
View File
@@ -23,7 +23,12 @@ import {InputDemo} from '../input/input-demo';
import {ListDemo} from '../list/list-demo';
import {LiveAnnouncerDemo} from '../live-announcer/live-announcer-demo';
import {MenuDemo} from '../menu/menu-demo';
import {OverlayDemo, RotiniPanel, SpagettiPanel} from '../overlay/overlay-demo';
import {
OverlayDemo,
RotiniPanel,
SpagettiPanel,
KeyboardTrackingPanel
} from '../overlay/overlay-demo';
import {PlatformDemo} from '../platform/platform-demo';
import {PortalDemo, ScienceJoke} from '../portal/portal-demo';
import {ProgressBarDemo} from '../progress-bar/progress-bar-demo';
@@ -80,6 +85,7 @@ import {DEMO_APP_ROUTES} from './routes';
IFrameDialog,
InputDemo,
JazzDialog,
KeyboardTrackingPanel,
ListDemo,
LiveAnnouncerDemo,
MatCheckboxDemoNestedChecklist,
@@ -119,6 +125,7 @@ import {DEMO_APP_ROUTES} from './routes';
DemoApp,
IFrameDialog,
JazzDialog,
KeyboardTrackingPanel,
RotiniPanel,
ScienceJoke,
SpagettiPanel,
+2
View File
@@ -36,3 +36,5 @@
</ng-template>
<button (click)="openPanelWithBackdrop()">Backdrop panel</button>
<button (click)="openKeyboardTracking()">Keyboard tracking</button>
+8
View File
@@ -27,3 +27,11 @@
background-color: orangered;
opacity: 0.5;
}
.demo-keyboard {
margin: 0;
padding: 10px;
border: 1px solid black;
background-color: mediumturquoise;
opacity: 0.7;
}
+31
View File
@@ -15,6 +15,8 @@ import {
ViewContainerRef,
ViewEncapsulation,
} from '@angular/core';
import 'rxjs/add/operator/filter';
import 'rxjs/add/operator/do';
@Component({
@@ -104,6 +106,26 @@ export class OverlayDemo {
overlayRef.backdropClick().subscribe(() => overlayRef.detach());
}
openKeyboardTracking() {
let config = new OverlayConfig();
config.positionStrategy = this.overlay.position()
.global()
.centerHorizontally()
.top(`${this.nextPosition}px`);
this.nextPosition += 30;
let overlayRef = this.overlay.create(config);
const componentRef = overlayRef
.attach(new ComponentPortal(KeyboardTrackingPanel, this.viewContainerRef));
overlayRef.keydownEvents()
.do(e => componentRef.instance.lastKeydown = e.key)
.filter(e => e.key === 'Escape')
.subscribe(() => overlayRef.detach());
}
}
/** Simple component to load into an overlay */
@@ -124,3 +146,12 @@ export class RotiniPanel {
export class SpagettiPanel {
value: string = 'Omega';
}
/** Simple component to load into an overlay */
@Component({
selector: 'keyboard-panel',
template: '<div class="demo-keyboard">Last Keydown: {{ lastKeydown }}</div>'
})
export class KeyboardTrackingPanel {
lastKeydown = '';
}
+7
View File
@@ -115,6 +115,13 @@ export class MatDialogRef<T> {
return this._overlayRef.backdropClick();
}
/**
* Gets an observable that emits when keydown events are targeted on the overlay.
*/
keydownEvents(): Observable<KeyboardEvent> {
return this._overlayRef.keydownEvents();
}
/**
* Updates the dialog's position.
* @param position New dialog position.
+21 -4
View File
@@ -25,7 +25,7 @@ import {SpyLocation} from '@angular/common/testing';
import {Directionality} from '@angular/cdk/bidi';
import {MatDialogContainer} from './dialog-container';
import {OverlayContainer} from '@angular/cdk/overlay';
import {ESCAPE} from '@angular/cdk/keycodes';
import {A, ESCAPE} from '@angular/cdk/keycodes';
import {dispatchKeyboardEvent} from '@angular/cdk/testing';
import {MAT_DIALOG_DATA, MatDialog, MatDialogModule, MatDialogRef} from './index';
@@ -203,7 +203,7 @@ describe('MatDialog', () => {
viewContainerRef: testViewContainerRef
});
dispatchKeyboardEvent(document, 'keydown', ESCAPE);
dispatchKeyboardEvent(document.body, 'keydown', ESCAPE);
viewContainerFixture.detectChanges();
viewContainerFixture.whenStable().then(() => {
@@ -277,6 +277,23 @@ describe('MatDialog', () => {
});
}));
it('should emit the keyboardEvent stream when key events target the overlay', async(() => {
const dialogRef = dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef});
const spy = jasmine.createSpy('keyboardEvent spy');
dialogRef.keydownEvents().subscribe(spy);
viewContainerFixture.detectChanges();
let backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement;
let container = overlayContainerElement.querySelector('mat-dialog-container') as HTMLElement;
dispatchKeyboardEvent(document.body, 'keydown', A);
dispatchKeyboardEvent(document.body, 'keydown', A, backdrop);
dispatchKeyboardEvent(document.body, 'keydown', A, container);
expect(spy).toHaveBeenCalledTimes(3);
}));
it('should notify the observers if a dialog has been opened', () => {
dialog.afterOpen.subscribe(ref => {
expect(dialog.open(PizzaMsg, {
@@ -665,7 +682,7 @@ describe('MatDialog', () => {
});
viewContainerFixture.detectChanges();
dispatchKeyboardEvent(document, 'keydown', ESCAPE);
dispatchKeyboardEvent(document.body, 'keydown', ESCAPE);
expect(overlayContainerElement.querySelector('mat-dialog-container')).toBeTruthy();
});
@@ -986,7 +1003,7 @@ describe('MatDialog with a parent MatDialog', () => {
it('should close the top dialog via the escape key', async(() => {
childDialog.open(PizzaMsg);
dispatchKeyboardEvent(document, 'keydown', ESCAPE);
dispatchKeyboardEvent(document.body, 'keydown', ESCAPE);
fixture.detectChanges();
fixture.whenStable().then(() => {
+6 -20
View File
@@ -15,7 +15,7 @@ import {
ScrollStrategy,
} from '@angular/cdk/overlay';
import {ComponentPortal, ComponentType, PortalInjector, TemplatePortal} from '@angular/cdk/portal';
import {startWith} from '@angular/cdk/rxjs';
import {RxChain, startWith, filter} from '@angular/cdk/rxjs';
import {Location} from '@angular/common';
import {
ComponentRef,
@@ -67,7 +67,6 @@ export class MatDialog {
private _openDialogsAtThisLevel: MatDialogRef<any>[] = [];
private _afterAllClosedAtThisLevel = new Subject<void>();
private _afterOpenAtThisLevel = new Subject<MatDialogRef<any>>();
private _boundKeydown = this._handleKeydown.bind(this);
/** Keeps track of the currently-open dialogs. */
get openDialogs(): MatDialogRef<any>[] {
@@ -135,10 +134,6 @@ export class MatDialog {
const dialogRef =
this._attachDialogContent(componentOrTemplateRef, dialogContainer, overlayRef, config);
if (!this.openDialogs.length) {
document.addEventListener('keydown', this._boundKeydown);
}
this.openDialogs.push(dialogRef);
dialogRef.afterClosed().subscribe(() => this._removeOpenDialog(dialogRef));
this.afterOpen.next(dialogRef);
@@ -246,6 +241,11 @@ export class MatDialog {
});
}
// Close when escape keydown event occurs
RxChain.from(overlayRef.keydownEvents())
.call(filter, event => event.keyCode === ESCAPE && !dialogRef.disableClose)
.subscribe(() => dialogRef.close());
if (componentOrTemplateRef instanceof TemplateRef) {
dialogContainer.attachTemplatePortal(
new TemplatePortal<T>(componentOrTemplateRef, null!,
@@ -304,23 +304,9 @@ export class MatDialog {
// no open dialogs are left, call next on afterAllClosed Subject
if (!this.openDialogs.length) {
this._afterAllClosed.next();
document.removeEventListener('keydown', this._boundKeydown);
}
}
}
/**
* Handles global key presses while there are open dialogs. Closes the
* top dialog when the user presses escape.
*/
private _handleKeydown(event: KeyboardEvent): void {
const topDialog = this.openDialogs[this.openDialogs.length - 1];
const canClose = topDialog ? !topDialog.disableClose : false;
if (event.keyCode === ESCAPE && canClose) {
topDialog.close();
}
}
}
/**