Skip to content

Commit 95f9016

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 ac70420 commit 95f9016

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
@@ -16,12 +16,14 @@ import {
1616
OverlayOrigin,
1717
} from './overlay-directives';
1818
import {OverlayPositionBuilder} from './position/overlay-position-builder';
19+
import {OVERLAY_KEYBOARD_DISPATCHER_PROVIDER} from './keyboard/overlay-keyboard-dispatcher';
1920
import {OVERLAY_CONTAINER_PROVIDER} from './overlay-container';
2021
import {ScrollStrategyOptions} from './scroll/scroll-strategy-options';
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
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

@@ -23,11 +24,15 @@ export class OverlayRef implements PortalHost {
2324
private _attachments = new Subject<void>();
2425
private _detachments = new Subject<void>();
2526

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

3237
if (_config.scrollStrategy) {
3338
_config.scrollStrategy.attach(this);
@@ -80,6 +85,9 @@ export class OverlayRef implements PortalHost {
8085
// Only emit the `attachments` event once all other setup is done.
8186
this._attachments.next();
8287

88+
// Track this overlay by the keyboard dispatcher
89+
this._keyboardDispatcher.add(this);
90+
8391
return attachResult;
8492
}
8593

@@ -104,6 +112,9 @@ export class OverlayRef implements PortalHost {
104112
// Only emit after everything is detached.
105113
this._detachments.next();
106114

115+
// Remove this overlay from keyboard dispatcher tracking
116+
this._keyboardDispatcher.remove(this);
117+
107118
return detachmentResult;
108119
}
109120

@@ -135,22 +146,27 @@ export class OverlayRef implements PortalHost {
135146
}
136147

137148
/**
138-
* Returns an observable that emits when the backdrop has been clicked.
149+
* Gets an observable that emits when the backdrop has been clicked.
139150
*/
140151
backdropClick(): Observable<void> {
141152
return this._backdropClick.asObservable();
142153
}
143154

144-
/** Returns an observable that emits when the overlay has been attached. */
155+
/** Gets an observable that emits when the overlay has been attached. */
145156
attachments(): Observable<void> {
146157
return this._attachments.asObservable();
147158
}
148159

149-
/** Returns an observable that emits when the overlay has been detached. */
160+
/** Gets an observable that emits when the overlay has been detached. */
150161
detachments(): Observable<void> {
151162
return this._detachments.asObservable();
152163
}
153164

165+
/** Gets an observable of keydown events targeted to this overlay. */
166+
keydownEvents(): Observable<KeyboardEvent> {
167+
return this._keydownEvents.asObservable();
168+
}
169+
154170
/**
155171
* Gets the current config of the overlay.
156172
*/

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): Event {
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)