diff --git a/src/cdk/overlay/keyboard/overlay-keyboard-dispatcher.spec.ts b/src/cdk/overlay/keyboard/overlay-keyboard-dispatcher.spec.ts new file mode 100644 index 000000000000..33fbcc3ef5fa --- /dev/null +++ b/src/cdk/overlay/keyboard/overlay-keyboard-dispatcher.spec.ts @@ -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(); + }); + +}); diff --git a/src/cdk/overlay/keyboard/overlay-keyboard-dispatcher.ts b/src/cdk/overlay/keyboard/overlay-keyboard-dispatcher.ts new file mode 100644 index 000000000000..e2b0b4854cf0 --- /dev/null +++ b/src/cdk/overlay/keyboard/overlay-keyboard-dispatcher.ts @@ -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(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 +}; diff --git a/src/cdk/overlay/overlay-module.ts b/src/cdk/overlay/overlay-module.ts index cd45630228c1..9d056cfb80ac 100644 --- a/src/cdk/overlay/overlay-module.ts +++ b/src/cdk/overlay/overlay-module.ts @@ -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, diff --git a/src/cdk/overlay/overlay-ref.ts b/src/cdk/overlay/overlay-ref.ts index d1bd5c4657b9..ba14fc01b57d 100644 --- a/src/cdk/overlay/overlay-ref.ts +++ b/src/cdk/overlay/overlay-ref.ts @@ -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(); private _detachments = new Subject(); + /** Stream of keydown events dispatched to this overlay. */ + _keydownEvents = new Subject(); + 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 { 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 { 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 { return this._detachments.asObservable(); } + /** Gets an observable of keydown events targeted to this overlay. */ + keydownEvents(): Observable { + return this._keydownEvents.asObservable(); + } + /** * Gets the current config of the overlay. */ diff --git a/src/cdk/overlay/overlay.ts b/src/cdk/overlay/overlay.ts index d1a3630410b3..0c4ca58f0202 100644 --- a/src/cdk/overlay/overlay.ts +++ b/src/cdk/overlay/overlay.ts @@ -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); } + } diff --git a/src/cdk/testing/dispatch-events.ts b/src/cdk/testing/dispatch-events.ts index 28413dd74a89..d1970f344218 100644 --- a/src/cdk/testing/dispatch-events.ts +++ b/src/cdk/testing/dispatch-events.ts @@ -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. */ diff --git a/src/demo-app/demo-app/demo-module.ts b/src/demo-app/demo-app/demo-module.ts index ed2841168317..edb4a5d06499 100644 --- a/src/demo-app/demo-app/demo-module.ts +++ b/src/demo-app/demo-app/demo-module.ts @@ -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, diff --git a/src/demo-app/overlay/overlay-demo.html b/src/demo-app/overlay/overlay-demo.html index df048577ba7d..ff9dfeecd2cc 100644 --- a/src/demo-app/overlay/overlay-demo.html +++ b/src/demo-app/overlay/overlay-demo.html @@ -36,3 +36,5 @@ + + diff --git a/src/demo-app/overlay/overlay-demo.scss b/src/demo-app/overlay/overlay-demo.scss index 275b5161e5f5..fef2a71da69d 100644 --- a/src/demo-app/overlay/overlay-demo.scss +++ b/src/demo-app/overlay/overlay-demo.scss @@ -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; +} diff --git a/src/demo-app/overlay/overlay-demo.ts b/src/demo-app/overlay/overlay-demo.ts index 88467b3242dc..1f354ae23834 100644 --- a/src/demo-app/overlay/overlay-demo.ts +++ b/src/demo-app/overlay/overlay-demo.ts @@ -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: '
Last Keydown: {{ lastKeydown }}
' +}) +export class KeyboardTrackingPanel { + lastKeydown = ''; +} diff --git a/src/lib/dialog/dialog-ref.ts b/src/lib/dialog/dialog-ref.ts index 8c8f29480eb1..38a95f7eeb89 100644 --- a/src/lib/dialog/dialog-ref.ts +++ b/src/lib/dialog/dialog-ref.ts @@ -115,6 +115,13 @@ export class MatDialogRef { return this._overlayRef.backdropClick(); } + /** + * Gets an observable that emits when keydown events are targeted on the overlay. + */ + keydownEvents(): Observable { + return this._overlayRef.keydownEvents(); + } + /** * Updates the dialog's position. * @param position New dialog position. diff --git a/src/lib/dialog/dialog.spec.ts b/src/lib/dialog/dialog.spec.ts index 4d4b0fde7242..66f6a86004e2 100644 --- a/src/lib/dialog/dialog.spec.ts +++ b/src/lib/dialog/dialog.spec.ts @@ -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(() => { diff --git a/src/lib/dialog/dialog.ts b/src/lib/dialog/dialog.ts index ce0b39d71c3f..c6dbacd7cb92 100644 --- a/src/lib/dialog/dialog.ts +++ b/src/lib/dialog/dialog.ts @@ -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[] = []; private _afterAllClosedAtThisLevel = new Subject(); private _afterOpenAtThisLevel = new Subject>(); - private _boundKeydown = this._handleKeydown.bind(this); /** Keeps track of the currently-open dialogs. */ get openDialogs(): MatDialogRef[] { @@ -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(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(); - } - } } /**