Skip to content

Commit 31bfaba

Browse files
committed
feat(overlay): add keyboard dispatcher for targeting correct overlay
1 parent ec4ea06 commit 31bfaba

File tree

10 files changed

+279
-28
lines changed

10 files changed

+279
-28
lines changed
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import {TestBed, inject} from '@angular/core/testing';
2+
import {dispatchKeyboardEvent} from '@angular/cdk/testing';
3+
import {ESCAPE} from '@angular/cdk/keycodes';
4+
import {Overlay} from '../overlay';
5+
import {OverlayContainer} from '../overlay-container';
6+
import {OverlayModule} from '../index';
7+
import {OverlayKeyboardDispatcher} from './overlay-keyboard-dispatcher';
8+
9+
describe('OverlayKeyboardDispatcher', () => {
10+
let keyboardDispatcher: OverlayKeyboardDispatcher;
11+
let overlay: Overlay;
12+
let overlayContainerElement: HTMLElement;
13+
14+
beforeEach(() => {
15+
TestBed.configureTestingModule({
16+
imports: [OverlayModule],
17+
providers: [
18+
{provide: OverlayContainer, useFactory: () => {
19+
overlayContainerElement = document.createElement('div');
20+
return {getContainerElement: () => overlayContainerElement};
21+
}}
22+
],
23+
});
24+
});
25+
26+
beforeEach(inject([OverlayKeyboardDispatcher, Overlay],
27+
(kbd: OverlayKeyboardDispatcher, o: Overlay) => {
28+
keyboardDispatcher = kbd;
29+
overlay = o;
30+
}));
31+
32+
it('should track overlays in order as they are attached and detached', () => {
33+
const overlayOne = overlay.create();
34+
const overlayTwo = overlay.create();
35+
36+
// Attach overlays
37+
keyboardDispatcher.attach(overlayOne);
38+
keyboardDispatcher.attach(overlayTwo);
39+
40+
expect(keyboardDispatcher._attachedOverlays.length)
41+
.toBe(2, 'Expected both overlays to be tracked.');
42+
expect(keyboardDispatcher._attachedOverlays[0]).toBe(overlayOne, 'Expected one to be first.');
43+
expect(keyboardDispatcher._attachedOverlays[1]).toBe(overlayTwo, 'Expected two to be last.');
44+
45+
// Detach first one and re-attach it
46+
keyboardDispatcher.detach(overlayOne);
47+
keyboardDispatcher.attach(overlayOne);
48+
49+
expect(keyboardDispatcher._attachedOverlays[0])
50+
.toBe(overlayTwo, 'Expected two to now be first.');
51+
expect(keyboardDispatcher._attachedOverlays[1])
52+
.toBe(overlayOne, 'Expected one to now be last.');
53+
});
54+
55+
it('should dispatch body keyboard events to the most recently attached overlay', () => {
56+
const overlayOne = overlay.create();
57+
const overlayTwo = overlay.create();
58+
const overlayOneSpy = jasmine.createSpy('overlayOne keyboard event spy');
59+
const overlayTwoSpy = jasmine.createSpy('overlayOne keyboard event spy');
60+
61+
overlayOne.keyboardEvents().subscribe(overlayOneSpy);
62+
overlayTwo.keyboardEvents().subscribe(overlayTwoSpy);
63+
64+
// Attach overlays
65+
keyboardDispatcher.attach(overlayOne);
66+
keyboardDispatcher.attach(overlayTwo);
67+
68+
dispatchKeyboardEvent(document.body, 'keydown', ESCAPE);
69+
70+
// Most recent overlay should receive event
71+
expect(overlayOneSpy).not.toHaveBeenCalled();
72+
expect(overlayTwoSpy).toHaveBeenCalled();
73+
});
74+
75+
it('should dispatch targeted keyboard events to the overlay containing that target', () => {
76+
const overlayOne = overlay.create();
77+
const overlayTwo = overlay.create();
78+
const overlayOneSpy = jasmine.createSpy('overlayOne keyboard event spy');
79+
const overlayTwoSpy = jasmine.createSpy('overlayOne keyboard event spy');
80+
81+
overlayOne.keyboardEvents().subscribe(overlayOneSpy);
82+
overlayTwo.keyboardEvents().subscribe(overlayTwoSpy);
83+
84+
// Attach overlays
85+
keyboardDispatcher.attach(overlayOne);
86+
keyboardDispatcher.attach(overlayTwo);
87+
88+
const overlayOnePane = overlayOne.overlayElement;
89+
90+
dispatchKeyboardEvent(document.body, 'keyup', ESCAPE, overlayOnePane);
91+
92+
// Targeted overlay should receive event
93+
expect(overlayOneSpy).toHaveBeenCalled();
94+
expect(overlayTwoSpy).not.toHaveBeenCalled();
95+
});
96+
97+
});
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {Injectable, Optional, SkipSelf, OnDestroy} from '@angular/core';
10+
import {OverlayRef} from '../overlay-ref';
11+
import {Observable} from 'rxjs/Observable';
12+
import {Subject} from 'rxjs/Subject';
13+
import {RxChain, filter, takeUntil} from '@angular/cdk/rxjs';
14+
import {fromEvent} from 'rxjs/observable/fromEvent';
15+
import {merge} from 'rxjs/observable/merge';
16+
17+
/**
18+
* Service for dispatching keyboard events that land on the body to appropriate overlay ref,
19+
* if any. It maintains a list of attached overlays to determine best suited overlay based
20+
* on event target and order of overlay opens.
21+
*/
22+
@Injectable()
23+
export class OverlayKeyboardDispatcher implements OnDestroy {
24+
25+
/** Currently attached overlays in the order they were attached. */
26+
_attachedOverlays: OverlayRef[] = [];
27+
28+
/** Emits when the service is destroyed. */
29+
private _onDestroy = new Subject<void>();
30+
31+
constructor() {
32+
this._dispatchKeyboardEvents();
33+
}
34+
35+
ngOnDestroy() {
36+
this._onDestroy.next();
37+
this._onDestroy.complete();
38+
}
39+
40+
/** Add a new overlay to the list of attached overlay refs. */
41+
attach(overlayRef: OverlayRef): void {
42+
this._attachedOverlays.push(overlayRef);
43+
}
44+
45+
/** Remove an overlay from the list of attached overlay refs. */
46+
detach(overlayRef: OverlayRef): void {
47+
const index = this._attachedOverlays.indexOf(overlayRef);
48+
if (index > -1) {
49+
this._attachedOverlays.splice(index, 1);
50+
}
51+
}
52+
53+
/**
54+
* Subscribe to all key events that land on the body and dipatch those
55+
* events to the appropriate overlay.
56+
*/
57+
private _dispatchKeyboardEvents(): void {
58+
const bodyKeyboardEvents = merge(
59+
fromEvent(document.body, 'keydown'),
60+
fromEvent(document.body, 'keypress'),
61+
fromEvent(document.body, 'keyup')) as Observable<KeyboardEvent>;
62+
63+
RxChain.from(bodyKeyboardEvents)
64+
.call(filter, () => !!this._attachedOverlays.length)
65+
.call(takeUntil, this._onDestroy)
66+
.subscribe(event => {
67+
// Dispatch keyboard event to correct overlay reference
68+
this._selectOverlayFromEvent(this._attachedOverlays, event)
69+
._dispatchedKeyboardEvents.next(event);
70+
});
71+
}
72+
73+
/** Select the appropriate overlay from a list of overlays and a keyboard event. */
74+
private _selectOverlayFromEvent(overlays: OverlayRef[], event: KeyboardEvent): OverlayRef {
75+
// Check if any overlays contain the event
76+
const targetedOverlay = this._attachedOverlays.find(overlay =>
77+
overlay.overlayElement.contains(event.target as HTMLElement));
78+
79+
return targetedOverlay ? targetedOverlay : overlays[overlays.length - 1];
80+
}
81+
82+
}
83+
84+
/** @docs-private */
85+
export function OVERLAY_KEYBOARD_DISPATCHER_PROVIDER_FACTORY(
86+
dispatcher: OverlayKeyboardDispatcher) {
87+
return dispatcher || new OverlayKeyboardDispatcher();
88+
}
89+
90+
/** @docs-private */
91+
export const OVERLAY_KEYBOARD_DISPATCHER_PROVIDER = {
92+
// If there is already an OverlayKeyboardDispatcher available, use that.
93+
// Otherwise, provide a new one.
94+
provide: OverlayKeyboardDispatcher,
95+
deps: [[new Optional(), new SkipSelf(), OverlayKeyboardDispatcher]],
96+
useFactory: OVERLAY_KEYBOARD_DISPATCHER_PROVIDER_FACTORY
97+
};

src/cdk/overlay/overlay-ref.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ export class OverlayRef implements PortalHost {
2222
private _backdropClick: Subject<any> = new Subject();
2323
private _attachments = new Subject<void>();
2424
private _detachments = new Subject<void>();
25+
private _disposed = new Subject<void>();
26+
27+
_dispatchedKeyboardEvents = new Subject<KeyboardEvent>();
2528

2629
constructor(
2730
private _portalHost: PortalHost,
@@ -114,6 +117,8 @@ export class OverlayRef implements PortalHost {
114117
this._backdropClick.complete();
115118
this._detachments.next();
116119
this._detachments.complete();
120+
this._disposed.next();
121+
this._disposed.complete();
117122
}
118123

119124
/**
@@ -140,6 +145,16 @@ export class OverlayRef implements PortalHost {
140145
return this._detachments.asObservable();
141146
}
142147

148+
/** Returns an observable that emits when the overlay has been disposed. */
149+
disposed(): Observable<void> {
150+
return this._disposed.asObservable();
151+
}
152+
153+
/** Returns an observable of keyboard events targeted to this overlay. */
154+
keyboardEvents(): Observable<KeyboardEvent> {
155+
return this._dispatchedKeyboardEvents.asObservable();
156+
}
157+
143158
/**
144159
* Gets the current state config of the overlay.
145160
*/

src/cdk/overlay/overlay.spec.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,19 +195,24 @@ describe('Overlay', () => {
195195

196196
it('should emit and complete the observables when an overlay is disposed', () => {
197197
let overlayRef = overlay.create();
198+
let detachSpy = jasmine.createSpy('detach spy');
198199
let disposeSpy = jasmine.createSpy('dispose spy');
199200
let attachCompleteSpy = jasmine.createSpy('attachCompleteSpy spy');
200201
let detachCompleteSpy = jasmine.createSpy('detachCompleteSpy spy');
202+
let disposeCompleteSpy = jasmine.createSpy('disposeCompleteSpy spy');
201203

202204
overlayRef.attachments().subscribe(undefined, undefined, attachCompleteSpy);
203-
overlayRef.detachments().subscribe(disposeSpy, undefined, detachCompleteSpy);
205+
overlayRef.detachments().subscribe(detachSpy, undefined, detachCompleteSpy);
206+
overlayRef.disposed().subscribe(disposeSpy, undefined, disposeCompleteSpy);
204207

205208
overlayRef.attach(componentPortal);
206209
overlayRef.dispose();
207210

211+
expect(detachSpy).toHaveBeenCalled();
208212
expect(disposeSpy).toHaveBeenCalled();
209213
expect(attachCompleteSpy).toHaveBeenCalled();
210214
expect(detachCompleteSpy).toHaveBeenCalled();
215+
expect(disposeCompleteSpy).toHaveBeenCalled();
211216
});
212217

213218
it('should complete the attachment observable before the detachment one', () => {

src/cdk/overlay/overlay.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@ import {
1414
NgZone,
1515
} from '@angular/core';
1616
import {DomPortalHost} from '@angular/cdk/portal';
17+
import {takeUntil} from '@angular/cdk/rxjs';
1718
import {OverlayState} from './overlay-state';
1819
import {OverlayRef} from './overlay-ref';
1920
import {OverlayPositionBuilder} from './position/overlay-position-builder';
21+
import {OverlayKeyboardDispatcher} from './keyboard/overlay-keyboard-dispatcher';
2022
import {OverlayContainer} from './overlay-container';
2123
import {ScrollStrategyOptions} from './scroll/index';
2224

@@ -38,10 +40,12 @@ let defaultState = new OverlayState();
3840
*/
3941
@Injectable()
4042
export class Overlay {
43+
4144
constructor(public scrollStrategies: ScrollStrategyOptions,
4245
private _overlayContainer: OverlayContainer,
4346
private _componentFactoryResolver: ComponentFactoryResolver,
4447
private _positionBuilder: OverlayPositionBuilder,
48+
private _keyboardDispatcher: OverlayKeyboardDispatcher,
4549
private _appRef: ApplicationRef,
4650
private _injector: Injector,
4751
private _ngZone: NgZone) { }
@@ -54,7 +58,9 @@ export class Overlay {
5458
create(state: OverlayState = defaultState): OverlayRef {
5559
const pane = this._createPaneElement();
5660
const portalHost = this._createPortalHost(pane);
57-
return new OverlayRef(portalHost, pane, state, this._ngZone);
61+
const overlayRef = new OverlayRef(portalHost, pane, state, this._ngZone);
62+
this._trackOverlayAttachments(overlayRef);
63+
return overlayRef;
5864
}
5965

6066
/**
@@ -87,4 +93,18 @@ export class Overlay {
8793
private _createPortalHost(pane: HTMLElement): DomPortalHost {
8894
return new DomPortalHost(pane, this._componentFactoryResolver, this._appRef, this._injector);
8995
}
96+
/**
97+
* Subscribe to attach and detach events and register them accordingly
98+
* with the keyboard dispatcher service.
99+
*/
100+
private _trackOverlayAttachments(overlayRef: OverlayRef): void {
101+
// Append overlay ref when attached
102+
takeUntil.call(overlayRef.attachments(), overlayRef.disposed())
103+
.subscribe(() => this._keyboardDispatcher.attach(overlayRef));
104+
105+
// Remove overlay ref when detached
106+
takeUntil.call(overlayRef.detachments(), overlayRef.disposed())
107+
.subscribe(() => this._keyboardDispatcher.detach(overlayRef));
108+
}
109+
90110
}

src/cdk/overlay/public_api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,15 @@ import {
1515
OverlayOrigin,
1616
} from './overlay-directives';
1717
import {OverlayPositionBuilder} from './position/overlay-position-builder';
18+
import {OVERLAY_KEYBOARD_DISPATCHER_PROVIDER} from './keyboard/overlay-keyboard-dispatcher';
1819
import {OVERLAY_CONTAINER_PROVIDER} from './overlay-container';
1920
import {ScrollStrategyOptions} from './scroll/scroll-strategy-options';
2021

2122

2223
export const OVERLAY_PROVIDERS: Provider[] = [
2324
Overlay,
2425
OverlayPositionBuilder,
26+
OVERLAY_KEYBOARD_DISPATCHER_PROVIDER,
2527
VIEWPORT_RULER_PROVIDER,
2628
OVERLAY_CONTAINER_PROVIDER,
2729
MD_CONNECTED_OVERLAY_SCROLL_STRATEGY_PROVIDER,

src/cdk/testing/dispatch-events.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@ export function dispatchFakeEvent(node: Node | Window, type: string): Event {
2020
}
2121

2222
/** Shorthand to dispatch a keyboard event with a specified key code. */
23-
export function dispatchKeyboardEvent(node: Node, type: string, keyCode: number): KeyboardEvent {
24-
return dispatchEvent(node, createKeyboardEvent(type, keyCode)) as KeyboardEvent;
23+
export function dispatchKeyboardEvent(node: Node, type: string, keyCode: number, target?: Element):
24+
KeyboardEvent {
25+
return dispatchEvent(node, createKeyboardEvent(type, keyCode, target)) as KeyboardEvent;
2526
}
2627

2728
/** Shorthand to dispatch a mouse event on the specified coordinates. */

src/lib/dialog/dialog-ref.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,13 @@ export class MdDialogRef<T> {
9696
return this._overlayRef.backdropClick();
9797
}
9898

99+
/**
100+
* Gets an observable that emits when keyboard events are targeted on the overlay.
101+
*/
102+
keyboardEvents(): Observable<KeyboardEvent> {
103+
return this._overlayRef.keyboardEvents();
104+
}
105+
99106
/**
100107
* Updates the dialog's position.
101108
* @param position New dialog position.

0 commit comments

Comments
 (0)