Skip to content

Commit

Permalink
feat(overlay): add keyboard dispatcher for targeting correct overlay (#…
Browse files Browse the repository at this point in the history
…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
  • Loading branch information
willshowell authored and mmalerba committed Oct 27, 2017
1 parent c663fad commit a2ca4d6
Show file tree
Hide file tree
Showing 13 changed files with 304 additions and 32 deletions.
97 changes: 97 additions & 0 deletions src/cdk/overlay/keyboard/overlay-keyboard-dispatcher.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});

});
95 changes: 95 additions & 0 deletions src/cdk/overlay/keyboard/overlay-keyboard-dispatcher.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 2 additions & 0 deletions src/cdk/overlay/overlay-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
24 changes: 20 additions & 4 deletions src/cdk/overlay/overlay-ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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.
*/
Expand Down
5 changes: 4 additions & 1 deletion src/cdk/overlay/overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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) { }
Expand All @@ -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);
}

/**
Expand Down Expand Up @@ -90,4 +92,5 @@ export class Overlay {
private _createPortalHost(pane: HTMLElement): DomPortalHost {
return new DomPortalHost(pane, this._componentFactoryResolver, this._appRef, this._injector);
}

}
5 changes: 3 additions & 2 deletions src/cdk/testing/dispatch-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
9 changes: 8 additions & 1 deletion src/demo-app/demo-app/demo-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -80,6 +85,7 @@ import {DEMO_APP_ROUTES} from './routes';
IFrameDialog,
InputDemo,
JazzDialog,
KeyboardTrackingPanel,
ListDemo,
LiveAnnouncerDemo,
MatCheckboxDemoNestedChecklist,
Expand Down Expand Up @@ -119,6 +125,7 @@ import {DEMO_APP_ROUTES} from './routes';
DemoApp,
IFrameDialog,
JazzDialog,
KeyboardTrackingPanel,
RotiniPanel,
ScienceJoke,
SpagettiPanel,
Expand Down
2 changes: 2 additions & 0 deletions src/demo-app/overlay/overlay-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,5 @@
</ng-template>

<button (click)="openPanelWithBackdrop()">Backdrop panel</button>

<button (click)="openKeyboardTracking()">Keyboard tracking</button>
8 changes: 8 additions & 0 deletions src/demo-app/overlay/overlay-demo.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading

0 comments on commit a2ca4d6

Please sign in to comment.