Skip to content

Commit 1d5a984

Browse files
committed
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
1 parent 65d3630 commit 1d5a984

File tree

13 files changed

+308
-32
lines changed

13 files changed

+308
-32
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.add(overlayOne);
38+
keyboardDispatcher.add(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.remove(overlayOne);
47+
keyboardDispatcher.add(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.keydownEvents().subscribe(overlayOneSpy);
62+
overlayTwo.keydownEvents().subscribe(overlayTwoSpy);
63+
64+
// Attach overlays
65+
keyboardDispatcher.add(overlayOne);
66+
keyboardDispatcher.add(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.keydownEvents().subscribe(overlayOneSpy);
82+
overlayTwo.keydownEvents().subscribe(overlayTwoSpy);
83+
84+
// Attach overlays
85+
keyboardDispatcher.add(overlayOne);
86+
keyboardDispatcher.add(overlayTwo);
87+
88+
const overlayOnePane = overlayOne.overlayElement;
89+
90+
dispatchKeyboardEvent(document.body, 'keydown', ESCAPE, overlayOnePane);
91+
92+
// Targeted overlay should receive event
93+
expect(overlayOneSpy).toHaveBeenCalled();
94+
expect(overlayTwoSpy).not.toHaveBeenCalled();
95+
});
96+
97+
});
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
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 {Inject, Injectable, Optional, SkipSelf, OnDestroy} from '@angular/core';
10+
import {DOCUMENT} from '@angular/platform-browser';
11+
import {OverlayRef} from '../overlay-ref';
12+
import {Subscription} from 'rxjs/Subscription';
13+
import {RxChain, filter} from '@angular/cdk/rxjs';
14+
import {fromEvent} from 'rxjs/observable/fromEvent';
15+
16+
/**
17+
* Service for dispatching keyboard events that land on the body to appropriate overlay ref,
18+
* if any. It maintains a list of attached overlays to determine best suited overlay based
19+
* on event target and order of overlay opens.
20+
*/
21+
@Injectable()
22+
export class OverlayKeyboardDispatcher implements OnDestroy {
23+
24+
/** Currently attached overlays in the order they were attached. */
25+
_attachedOverlays: OverlayRef[] = [];
26+
27+
private _keydownEventSubscription: Subscription | null;
28+
29+
constructor(@Optional() @Inject(DOCUMENT) private _document: any) { }
30+
31+
ngOnDestroy() {
32+
if (this._keydownEventSubscription) {
33+
this._keydownEventSubscription.unsubscribe();
34+
this._keydownEventSubscription = null;
35+
}
36+
}
37+
38+
/** Add a new overlay to the list of attached overlay refs. */
39+
add(overlayRef: OverlayRef): void {
40+
// Lazily start dispatcher once first overlay is added
41+
if (!this._keydownEventSubscription) {
42+
this._subscribeToKeydownEvents();
43+
}
44+
45+
this._attachedOverlays.push(overlayRef);
46+
}
47+
48+
/** Remove an overlay from the list of attached overlay refs. */
49+
remove(overlayRef: OverlayRef): void {
50+
const index = this._attachedOverlays.indexOf(overlayRef);
51+
if (index > -1) {
52+
this._attachedOverlays.splice(index, 1);
53+
}
54+
}
55+
56+
/**
57+
* Subscribe to keydown events that land on the body and dispatch those
58+
* events to the appropriate overlay.
59+
*/
60+
private _subscribeToKeydownEvents(): void {
61+
const bodyKeydownEvents = fromEvent<KeyboardEvent>(this._document.body, 'keydown');
62+
63+
this._keydownEventSubscription = RxChain.from(bodyKeydownEvents)
64+
.call(filter, () => !!this._attachedOverlays.length)
65+
.subscribe(event => {
66+
// Dispatch keydown event to correct overlay reference
67+
this._selectOverlayFromEvent(event)._keydownEvents.next(event);
68+
});
69+
}
70+
71+
/** Select the appropriate overlay from a keydown event. */
72+
private _selectOverlayFromEvent(event: KeyboardEvent): OverlayRef {
73+
// Check if any overlays contain the event
74+
const targetedOverlay = this._attachedOverlays.find(overlay => {
75+
return overlay.overlayElement === event.target ||
76+
overlay.overlayElement.contains(event.target as HTMLElement);
77+
});
78+
79+
// Use that overlay if it exists, otherwise choose the most recently attached one
80+
return targetedOverlay || this._attachedOverlays[this._attachedOverlays.length - 1];
81+
}
82+
83+
}
84+
85+
/** @docs-private */
86+
export function OVERLAY_KEYBOARD_DISPATCHER_PROVIDER_FACTORY(
87+
dispatcher: OverlayKeyboardDispatcher, _document) {
88+
return dispatcher || new OverlayKeyboardDispatcher(_document);
89+
}
90+
91+
/** @docs-private */
92+
export const OVERLAY_KEYBOARD_DISPATCHER_PROVIDER = {
93+
// If there is already an OverlayKeyboardDispatcher available, use that.
94+
// Otherwise, provide a new one.
95+
provide: OverlayKeyboardDispatcher,
96+
deps: [[new Optional(), new SkipSelf(), OverlayKeyboardDispatcher], [new Optional(), DOCUMENT]],
97+
useFactory: OVERLAY_KEYBOARD_DISPATCHER_PROVIDER_FACTORY
98+
};

src/cdk/overlay/overlay-module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@ import {
1818
OverlayOrigin,
1919
} from './overlay-directives';
2020
import {OverlayPositionBuilder} from './position/overlay-position-builder';
21+
import {OVERLAY_KEYBOARD_DISPATCHER_PROVIDER} from './keyboard/overlay-keyboard-dispatcher';
2122
import {ScrollStrategyOptions} from './scroll/scroll-strategy-options';
2223

2324
export const OVERLAY_PROVIDERS: Provider[] = [
2425
Overlay,
2526
OverlayPositionBuilder,
27+
OVERLAY_KEYBOARD_DISPATCHER_PROVIDER,
2628
VIEWPORT_RULER_PROVIDER,
2729
OVERLAY_CONTAINER_PROVIDER,
2830
MAT_CONNECTED_OVERLAY_SCROLL_STRATEGY_PROVIDER,

src/cdk/overlay/overlay-ref.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import {NgZone} from '@angular/core';
1010
import {PortalHost, Portal} from '@angular/cdk/portal';
1111
import {OverlayConfig} from './overlay-config';
12+
import {OverlayKeyboardDispatcher} from './keyboard/overlay-keyboard-dispatcher';
1213
import {Observable} from 'rxjs/Observable';
1314
import {Subject} from 'rxjs/Subject';
1415
import {first} from 'rxjs/operator/first';
@@ -24,11 +25,15 @@ export class OverlayRef implements PortalHost {
2425
private _attachments = new Subject<void>();
2526
private _detachments = new Subject<void>();
2627

28+
/** Stream of keydown events dispatched to this overlay. */
29+
_keydownEvents = new Subject<KeyboardEvent>();
30+
2731
constructor(
2832
private _portalHost: PortalHost,
2933
private _pane: HTMLElement,
3034
private _config: OverlayConfig,
31-
private _ngZone: NgZone) {
35+
private _ngZone: NgZone,
36+
private _keyboardDispatcher: OverlayKeyboardDispatcher) {
3237

3338
if (_config.scrollStrategy) {
3439
_config.scrollStrategy.attach(this);
@@ -87,6 +92,9 @@ export class OverlayRef implements PortalHost {
8792
// Only emit the `attachments` event once all other setup is done.
8893
this._attachments.next();
8994

95+
// Track this overlay by the keyboard dispatcher
96+
this._keyboardDispatcher.add(this);
97+
9098
return attachResult;
9199
}
92100

@@ -115,6 +123,9 @@ export class OverlayRef implements PortalHost {
115123
// Only emit after everything is detached.
116124
this._detachments.next();
117125

126+
// Remove this overlay from keyboard dispatcher tracking
127+
this._keyboardDispatcher.remove(this);
128+
118129
return detachmentResult;
119130
}
120131

@@ -146,22 +157,27 @@ export class OverlayRef implements PortalHost {
146157
}
147158

148159
/**
149-
* Returns an observable that emits when the backdrop has been clicked.
160+
* Gets an observable that emits when the backdrop has been clicked.
150161
*/
151162
backdropClick(): Observable<void> {
152163
return this._backdropClick.asObservable();
153164
}
154165

155-
/** Returns an observable that emits when the overlay has been attached. */
166+
/** Gets an observable that emits when the overlay has been attached. */
156167
attachments(): Observable<void> {
157168
return this._attachments.asObservable();
158169
}
159170

160-
/** Returns an observable that emits when the overlay has been detached. */
171+
/** Gets an observable that emits when the overlay has been detached. */
161172
detachments(): Observable<void> {
162173
return this._detachments.asObservable();
163174
}
164175

176+
/** Gets an observable of keydown events targeted to this overlay. */
177+
keydownEvents(): Observable<KeyboardEvent> {
178+
return this._keydownEvents.asObservable();
179+
}
180+
165181
/**
166182
* Gets the current config of the overlay.
167183
*/

src/cdk/overlay/overlay.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {DomPortalHost} from '@angular/cdk/portal';
1717
import {OverlayConfig} from './overlay-config';
1818
import {OverlayRef} from './overlay-ref';
1919
import {OverlayPositionBuilder} from './position/overlay-position-builder';
20+
import {OverlayKeyboardDispatcher} from './keyboard/overlay-keyboard-dispatcher';
2021
import {OverlayContainer} from './overlay-container';
2122
import {ScrollStrategyOptions} from './scroll/index';
2223

@@ -38,10 +39,12 @@ let defaultConfig = new OverlayConfig();
3839
*/
3940
@Injectable()
4041
export class Overlay {
42+
4143
constructor(public scrollStrategies: ScrollStrategyOptions,
4244
private _overlayContainer: OverlayContainer,
4345
private _componentFactoryResolver: ComponentFactoryResolver,
4446
private _positionBuilder: OverlayPositionBuilder,
47+
private _keyboardDispatcher: OverlayKeyboardDispatcher,
4548
private _appRef: ApplicationRef,
4649
private _injector: Injector,
4750
private _ngZone: NgZone) { }
@@ -54,7 +57,7 @@ export class Overlay {
5457
create(config: OverlayConfig = defaultConfig): OverlayRef {
5558
const pane = this._createPaneElement();
5659
const portalHost = this._createPortalHost(pane);
57-
return new OverlayRef(portalHost, pane, config, this._ngZone);
60+
return new OverlayRef(portalHost, pane, config, this._ngZone, this._keyboardDispatcher);
5861
}
5962

6063
/**
@@ -87,4 +90,5 @@ export class Overlay {
8790
private _createPortalHost(pane: HTMLElement): DomPortalHost {
8891
return new DomPortalHost(pane, this._componentFactoryResolver, this._appRef, this._injector);
8992
}
93+
9094
}

src/cdk/testing/dispatch-events.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,9 @@ export function dispatchFakeEvent(node: Node | Window, type: string, canBubble?:
2525
}
2626

2727
/** Shorthand to dispatch a keyboard event with a specified key code. */
28-
export function dispatchKeyboardEvent(node: Node, type: string, keyCode: number): KeyboardEvent {
29-
return dispatchEvent(node, createKeyboardEvent(type, keyCode)) as KeyboardEvent;
28+
export function dispatchKeyboardEvent(node: Node, type: string, keyCode: number, target?: Element):
29+
KeyboardEvent {
30+
return dispatchEvent(node, createKeyboardEvent(type, keyCode, target)) as KeyboardEvent;
3031
}
3132

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

src/demo-app/demo-app/demo-module.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,12 @@ import {InputDemo} from '../input/input-demo';
2323
import {ListDemo} from '../list/list-demo';
2424
import {LiveAnnouncerDemo} from '../live-announcer/live-announcer-demo';
2525
import {MenuDemo} from '../menu/menu-demo';
26-
import {OverlayDemo, RotiniPanel, SpagettiPanel} from '../overlay/overlay-demo';
26+
import {
27+
OverlayDemo,
28+
RotiniPanel,
29+
SpagettiPanel,
30+
KeyboardTrackingPanel
31+
} from '../overlay/overlay-demo';
2732
import {PlatformDemo} from '../platform/platform-demo';
2833
import {PortalDemo, ScienceJoke} from '../portal/portal-demo';
2934
import {ProgressBarDemo} from '../progress-bar/progress-bar-demo';
@@ -80,6 +85,7 @@ import {DEMO_APP_ROUTES} from './routes';
8085
IFrameDialog,
8186
InputDemo,
8287
JazzDialog,
88+
KeyboardTrackingPanel,
8389
ListDemo,
8490
LiveAnnouncerDemo,
8591
MatCheckboxDemoNestedChecklist,
@@ -119,6 +125,7 @@ import {DEMO_APP_ROUTES} from './routes';
119125
DemoApp,
120126
IFrameDialog,
121127
JazzDialog,
128+
KeyboardTrackingPanel,
122129
RotiniPanel,
123130
ScienceJoke,
124131
SpagettiPanel,

src/demo-app/overlay/overlay-demo.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,5 @@
3636
</ng-template>
3737

3838
<button (click)="openPanelWithBackdrop()">Backdrop panel</button>
39+
40+
<button (click)="openKeyboardTracking()">Keyboard tracking</button>

src/demo-app/overlay/overlay-demo.scss

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,11 @@
2727
background-color: orangered;
2828
opacity: 0.5;
2929
}
30+
31+
.demo-keyboard {
32+
margin: 0;
33+
padding: 10px;
34+
border: 1px solid black;
35+
background-color: mediumturquoise;
36+
opacity: 0.7;
37+
}

0 commit comments

Comments
 (0)