Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 3 commits into from
Oct 27, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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