Skip to content

Commit e074fa0

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
1 parent 70bd5fc commit e074fa0

File tree

13 files changed

+305
-32
lines changed

13 files changed

+305
-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: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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 {Subject} from 'rxjs/Subject';
12+
import {RxChain, filter, takeUntil} 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+
/** Emits when the service is destroyed. */
27+
private _onDestroy = new Subject<void>();
28+
29+
constructor() {
30+
this._dispatchKeydownEvents();
31+
}
32+
33+
ngOnDestroy() {
34+
this._onDestroy.next();
35+
this._onDestroy.complete();
36+
}
37+
38+
/** Add a new overlay to the list of attached overlay refs. */
39+
add(overlayRef: OverlayRef): void {
40+
this._attachedOverlays.push(overlayRef);
41+
}
42+
43+
/** Remove an overlay from the list of attached overlay refs. */
44+
remove(overlayRef: OverlayRef): void {
45+
const index = this._attachedOverlays.indexOf(overlayRef);
46+
if (index > -1) {
47+
this._attachedOverlays.splice(index, 1);
48+
}
49+
}
50+
51+
/**
52+
* Subscribe to keydown events that land on the body and dispatch those
53+
* events to the appropriate overlay.
54+
*/
55+
private _dispatchKeydownEvents(): void {
56+
const bodyKeydownEvents = fromEvent<KeyboardEvent>(document.body, 'keydown');
57+
58+
RxChain.from(bodyKeydownEvents)
59+
.call(filter, () => !!this._attachedOverlays.length)
60+
.call(takeUntil, this._onDestroy)
61+
.subscribe(event => {
62+
// Dispatch keydown event to correct overlay reference
63+
this._selectOverlayFromEvent(event)._dispatchedKeydownEvents.next(event);
64+
});
65+
}
66+
67+
/** Select the appropriate overlay from a keydown event. */
68+
private _selectOverlayFromEvent(event: KeyboardEvent): OverlayRef {
69+
// Check if any overlays contain the event
70+
const targetedOverlay = this._attachedOverlays.find(overlay => {
71+
return overlay.overlayElement.contains(event.target as HTMLElement) ||
72+
overlay.overlayElement === event.target;
73+
});
74+
75+
// Use that overlay if it exists, otherwise choose the most recently attached one
76+
return targetedOverlay ?
77+
targetedOverlay :
78+
this._attachedOverlays[this._attachedOverlays.length - 1];
79+
}
80+
81+
}
82+
83+
/** @docs-private */
84+
export function OVERLAY_KEYBOARD_DISPATCHER_PROVIDER_FACTORY(
85+
dispatcher: OverlayKeyboardDispatcher) {
86+
return dispatcher || new OverlayKeyboardDispatcher();
87+
}
88+
89+
/** @docs-private */
90+
export const OVERLAY_KEYBOARD_DISPATCHER_PROVIDER = {
91+
// If there is already an OverlayKeyboardDispatcher available, use that.
92+
// Otherwise, provide a new one.
93+
provide: OverlayKeyboardDispatcher,
94+
deps: [[new Optional(), new SkipSelf(), OverlayKeyboardDispatcher]],
95+
useFactory: OVERLAY_KEYBOARD_DISPATCHER_PROVIDER_FACTORY
96+
};

src/cdk/overlay/overlay-ref.ts

Lines changed: 19 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 {OverlayState} from './overlay-state';
12+
import {OverlayKeyboardDispatcher} from './keyboard/overlay-keyboard-dispatcher';
1213
import {Observable} from 'rxjs/Observable';
1314
import {Subject} from 'rxjs/Subject';
1415

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

27+
_dispatchedKeydownEvents = new Subject<KeyboardEvent>();
28+
2629
constructor(
2730
private _portalHost: PortalHost,
2831
private _pane: HTMLElement,
2932
private _state: OverlayState,
30-
private _ngZone: NgZone) {
33+
private _ngZone: NgZone,
34+
private _keyboardDispatcher: OverlayKeyboardDispatcher) {
3135

3236
_state.scrollStrategy.attach(this);
3337
}
@@ -75,6 +79,9 @@ export class OverlayRef implements PortalHost {
7579
// Only emit the `attachments` event once all other setup is done.
7680
this._attachments.next();
7781

82+
// Track this overlay by the keyboard dispatcher
83+
this._keyboardDispatcher.add(this);
84+
7885
return attachResult;
7986
}
8087

@@ -96,6 +103,9 @@ export class OverlayRef implements PortalHost {
96103
// Only emit after everything is detached.
97104
this._detachments.next();
98105

106+
// Remove this overlay from keyboard dispatcher tracking
107+
this._keyboardDispatcher.remove(this);
108+
99109
return detachmentResult;
100110
}
101111

@@ -124,22 +134,27 @@ export class OverlayRef implements PortalHost {
124134
}
125135

126136
/**
127-
* Returns an observable that emits when the backdrop has been clicked.
137+
* Gets an observable that emits when the backdrop has been clicked.
128138
*/
129139
backdropClick(): Observable<void> {
130140
return this._backdropClick.asObservable();
131141
}
132142

133-
/** Returns an observable that emits when the overlay has been attached. */
143+
/** Gets an observable that emits when the overlay has been attached. */
134144
attachments(): Observable<void> {
135145
return this._attachments.asObservable();
136146
}
137147

138-
/** Returns an observable that emits when the overlay has been detached. */
148+
/** Gets an observable that emits when the overlay has been detached. */
139149
detachments(): Observable<void> {
140150
return this._detachments.asObservable();
141151
}
142152

153+
/** Gets an observable of keydown events targeted to this overlay. */
154+
keydownEvents(): Observable<KeyboardEvent> {
155+
return this._dispatchedKeydownEvents.asObservable();
156+
}
157+
143158
/**
144159
* Gets the current state config of the overlay.
145160
*/

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 {OverlayState} from './overlay-state';
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 defaultState = new OverlayState();
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(state: OverlayState = defaultState): OverlayRef {
5558
const pane = this._createPaneElement();
5659
const portalHost = this._createPortalHost(pane);
57-
return new OverlayRef(portalHost, pane, state, this._ngZone);
60+
return new OverlayRef(portalHost, pane, state, 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/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/demo-app/demo-app/demo-module.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@ import {ListDemo} from '../list/list-demo';
1919
import {BaselineDemo} from '../baseline/baseline-demo';
2020
import {GridListDemo} from '../grid-list/grid-list-demo';
2121
import {LiveAnnouncerDemo} from '../live-announcer/live-announcer-demo';
22-
import {OverlayDemo, RotiniPanel, SpagettiPanel} from '../overlay/overlay-demo';
22+
import {
23+
OverlayDemo,
24+
RotiniPanel,
25+
SpagettiPanel,
26+
KeyboardTrackingPanel
27+
} from '../overlay/overlay-demo';
2328
import {SlideToggleDemo} from '../slide-toggle/slide-toggle-demo';
2429
import {ToolbarDemo} from '../toolbar/toolbar-demo';
2530
import {ButtonDemo} from '../button/button-demo';
@@ -72,6 +77,7 @@ import {TableHeaderDemo} from '../table/table-header-demo';
7277
IconDemo,
7378
InputDemo,
7479
JazzDialog,
80+
KeyboardTrackingPanel,
7581
ContentElementDialog,
7682
IFrameDialog,
7783
ListDemo,
@@ -117,6 +123,7 @@ import {TableHeaderDemo} from '../table/table-header-demo';
117123
RotiniPanel,
118124
ScienceJoke,
119125
SpagettiPanel,
126+
KeyboardTrackingPanel,
120127
],
121128
})
122129
export class DemoModule {}

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)