Skip to content

Commit a2ca4d6

Browse files
willshowellmmalerba
authored andcommitted
feat(overlay): add keyboard dispatcher for targeting correct overlay (#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
1 parent c663fad commit a2ca4d6

File tree

13 files changed

+304
-32
lines changed

13 files changed

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

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: 4 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

@@ -44,6 +45,7 @@ export class Overlay {
4445
private _overlayContainer: OverlayContainer,
4546
private _componentFactoryResolver: ComponentFactoryResolver,
4647
private _positionBuilder: OverlayPositionBuilder,
48+
private _keyboardDispatcher: OverlayKeyboardDispatcher,
4749
private _appRef: ApplicationRef,
4850
private _injector: Injector,
4951
private _ngZone: NgZone) { }
@@ -56,7 +58,7 @@ export class Overlay {
5658
create(config: OverlayConfig = defaultConfig): OverlayRef {
5759
const pane = this._createPaneElement();
5860
const portalHost = this._createPortalHost(pane);
59-
return new OverlayRef(portalHost, pane, config, this._ngZone);
61+
return new OverlayRef(portalHost, pane, config, this._ngZone, this._keyboardDispatcher);
6062
}
6163

6264
/**
@@ -90,4 +92,5 @@ export class Overlay {
9092
private _createPortalHost(pane: HTMLElement): DomPortalHost {
9193
return new DomPortalHost(pane, this._componentFactoryResolver, this._appRef, this._injector);
9294
}
95+
9396
}

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)