-
Notifications
You must be signed in to change notification settings - Fork 6.7k
/
overlay-ref.ts
439 lines (357 loc) · 14.4 KB
/
overlay-ref.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {Direction, Directionality} from '@angular/cdk/bidi';
import {ComponentPortal, Portal, PortalOutlet, TemplatePortal} from '@angular/cdk/portal';
import {ComponentRef, EmbeddedViewRef, NgZone} from '@angular/core';
import {Observable, Subject, merge} from 'rxjs';
import {take, takeUntil} from 'rxjs/operators';
import {OverlayKeyboardDispatcher} from './keyboard/overlay-keyboard-dispatcher';
import {OverlayConfig} from './overlay-config';
import {coerceCssPixelValue, coerceArray} from '@angular/cdk/coercion';
import {OverlayReference} from './overlay-reference';
/** An object where all of its properties cannot be written. */
export type ImmutableObject<T> = {
readonly [P in keyof T]: T[P];
};
/**
* Reference to an overlay that has been created with the Overlay service.
* Used to manipulate or dispose of said overlay.
*/
export class OverlayRef implements PortalOutlet, OverlayReference {
private _backdropElement: HTMLElement | null = null;
private _backdropClick: Subject<MouseEvent> = new Subject();
private _attachments = new Subject<void>();
private _detachments = new Subject<void>();
/**
* Reference to the parent of the `_host` at the time it was detached. Used to restore
* the `_host` to its original position in the DOM when it gets re-attached.
*/
private _previousHostParent: HTMLElement;
private _keydownEventsObservable: Observable<KeyboardEvent> = Observable.create(observer => {
const subscription = this._keydownEvents.subscribe(observer);
this._keydownEventSubscriptions++;
return () => {
subscription.unsubscribe();
this._keydownEventSubscriptions--;
};
});
/** Stream of keydown events dispatched to this overlay. */
_keydownEvents = new Subject<KeyboardEvent>();
/** Amount of subscriptions to the keydown events. */
_keydownEventSubscriptions = 0;
constructor(
private _portalOutlet: PortalOutlet,
private _host: HTMLElement,
private _pane: HTMLElement,
private _config: ImmutableObject<OverlayConfig>,
private _ngZone: NgZone,
private _keyboardDispatcher: OverlayKeyboardDispatcher,
private _document: Document) {
if (_config.scrollStrategy) {
_config.scrollStrategy.attach(this);
}
}
/** The overlay's HTML element */
get overlayElement(): HTMLElement {
return this._pane;
}
/** The overlay's backdrop HTML element. */
get backdropElement(): HTMLElement | null {
return this._backdropElement;
}
/**
* Wrapper around the panel element. Can be used for advanced
* positioning where a wrapper with specific styling is
* required around the overlay pane.
*/
get hostElement(): HTMLElement {
return this._host;
}
attach<T>(portal: ComponentPortal<T>): ComponentRef<T>;
attach<T>(portal: TemplatePortal<T>): EmbeddedViewRef<T>;
attach(portal: any): any;
/**
* Attaches content, given via a Portal, to the overlay.
* If the overlay is configured to have a backdrop, it will be created.
*
* @param portal Portal instance to which to attach the overlay.
* @returns The portal attachment result.
*/
attach(portal: Portal<any>): any {
let attachResult = this._portalOutlet.attach(portal);
if (this._config.positionStrategy) {
this._config.positionStrategy.attach(this);
}
// Update the pane element with the given configuration.
if (!this._host.parentElement && this._previousHostParent) {
this._previousHostParent.appendChild(this._host);
}
this._updateStackingOrder();
this._updateElementSize();
this._updateElementDirection();
if (this._config.scrollStrategy) {
this._config.scrollStrategy.enable();
}
// Update the position once the zone is stable so that the overlay will be fully rendered
// before attempting to position it, as the position may depend on the size of the rendered
// content.
this._ngZone.onStable
.asObservable()
.pipe(take(1))
.subscribe(() => {
// The overlay could've been detached before the zone has stabilized.
if (this.hasAttached()) {
this.updatePosition();
}
});
// Enable pointer events for the overlay pane element.
this._togglePointerEvents(true);
if (this._config.hasBackdrop) {
this._attachBackdrop();
}
if (this._config.panelClass) {
this._toggleClasses(this._pane, this._config.panelClass, true);
}
// Only emit the `attachments` event once all other setup is done.
this._attachments.next();
// Track this overlay by the keyboard dispatcher
this._keyboardDispatcher.add(this);
return attachResult;
}
/**
* Detaches an overlay from a portal.
* @returns The portal detachment result.
*/
detach(): any {
if (!this.hasAttached()) {
return;
}
this.detachBackdrop();
// When the overlay is detached, the pane element should disable pointer events.
// This is necessary because otherwise the pane element will cover the page and disable
// pointer events therefore. Depends on the position strategy and the applied pane boundaries.
this._togglePointerEvents(false);
if (this._config.positionStrategy && this._config.positionStrategy.detach) {
this._config.positionStrategy.detach();
}
if (this._config.scrollStrategy) {
this._config.scrollStrategy.disable();
}
if (this._config.panelClass) {
this._toggleClasses(this._pane, this._config.panelClass, false);
}
const detachmentResult = this._portalOutlet.detach();
// Only emit after everything is detached.
this._detachments.next();
// Remove this overlay from keyboard dispatcher tracking.
this._keyboardDispatcher.remove(this);
// Keeping the host element in DOM the can cause scroll jank, because it still gets rendered,
// even though it's transparent and unclickable. We can't remove the host here immediately,
// because the overlay pane's content might still be animating. This stream helps us avoid
// interrupting the animation by waiting for the pane to become empty.
const subscription = this._ngZone.onStable
.asObservable()
.pipe(takeUntil(merge(this._attachments, this._detachments)))
.subscribe(() => {
// Needs a couple of checks for the pane and host, because
// they may have been removed by the time the zone stabilizes.
if (!this._pane || !this._host || this._pane.children.length === 0) {
if (this._host && this._host.parentElement) {
this._previousHostParent = this._host.parentElement;
this._previousHostParent.removeChild(this._host);
}
subscription.unsubscribe();
}
});
return detachmentResult;
}
/** Cleans up the overlay from the DOM. */
dispose(): void {
const isAttached = this.hasAttached();
if (this._config.positionStrategy) {
this._config.positionStrategy.dispose();
}
if (this._config.scrollStrategy) {
this._config.scrollStrategy.disable();
}
this.detachBackdrop();
this._keyboardDispatcher.remove(this);
this._portalOutlet.dispose();
this._attachments.complete();
this._backdropClick.complete();
this._keydownEvents.complete();
if (this._host && this._host.parentNode) {
this._host.parentNode.removeChild(this._host);
this._host = null!;
}
this._previousHostParent = this._pane = null!;
if (isAttached) {
this._detachments.next();
}
this._detachments.complete();
}
/** Whether the overlay has attached content. */
hasAttached(): boolean {
return this._portalOutlet.hasAttached();
}
/** Gets an observable that emits when the backdrop has been clicked. */
backdropClick(): Observable<MouseEvent> {
return this._backdropClick.asObservable();
}
/** Gets an observable that emits when the overlay has been attached. */
attachments(): Observable<void> {
return this._attachments.asObservable();
}
/** Gets an observable that emits when the overlay has been detached. */
detachments(): Observable<void> {
return this._detachments.asObservable();
}
/** Gets an observable of keydown events targeted to this overlay. */
keydownEvents(): Observable<KeyboardEvent> {
return this._keydownEventsObservable;
}
/** Gets the the current overlay configuration, which is immutable. */
getConfig(): OverlayConfig {
return this._config;
}
/** Updates the position of the overlay based on the position strategy. */
updatePosition() {
if (this._config.positionStrategy) {
this._config.positionStrategy.apply();
}
}
/** Update the size properties of the overlay. */
updateSize(sizeConfig: OverlaySizeConfig) {
this._config = {...this._config, ...sizeConfig};
this._updateElementSize();
}
/** Sets the LTR/RTL direction for the overlay. */
setDirection(dir: Direction | Directionality) {
this._config = {...this._config, direction: dir};
this._updateElementDirection();
}
/**
* Returns the layout direction of the overlay panel.
*/
getDirection(): Direction {
const direction = this._config.direction;
if (!direction) {
return 'ltr';
}
return typeof direction === 'string' ? direction : direction.value;
}
/** Updates the text direction of the overlay panel. */
private _updateElementDirection() {
this._host.setAttribute('dir', this.getDirection());
}
/** Updates the size of the overlay element based on the overlay config. */
private _updateElementSize() {
const style = this._pane.style;
style.width = coerceCssPixelValue(this._config.width);
style.height = coerceCssPixelValue(this._config.height);
style.minWidth = coerceCssPixelValue(this._config.minWidth);
style.minHeight = coerceCssPixelValue(this._config.minHeight);
style.maxWidth = coerceCssPixelValue(this._config.maxWidth);
style.maxHeight = coerceCssPixelValue(this._config.maxHeight);
}
/** Toggles the pointer events for the overlay pane element. */
private _togglePointerEvents(enablePointer: boolean) {
this._pane.style.pointerEvents = enablePointer ? 'auto' : 'none';
}
/** Attaches a backdrop for this overlay. */
private _attachBackdrop() {
const showingClass = 'cdk-overlay-backdrop-showing';
this._backdropElement = this._document.createElement('div');
this._backdropElement.classList.add('cdk-overlay-backdrop');
if (this._config.backdropClass) {
this._toggleClasses(this._backdropElement, this._config.backdropClass, true);
}
// Insert the backdrop before the pane in the DOM order,
// in order to handle stacked overlays properly.
this._host.parentElement!.insertBefore(this._backdropElement, this._host);
// Forward backdrop clicks such that the consumer of the overlay can perform whatever
// action desired when such a click occurs (usually closing the overlay).
this._backdropElement.addEventListener('click',
(event: MouseEvent) => this._backdropClick.next(event));
// Add class to fade-in the backdrop after one frame.
if (typeof requestAnimationFrame !== 'undefined') {
this._ngZone.runOutsideAngular(() => {
requestAnimationFrame(() => {
if (this._backdropElement) {
this._backdropElement.classList.add(showingClass);
}
});
});
} else {
this._backdropElement.classList.add(showingClass);
}
}
/**
* Updates the stacking order of the element, moving it to the top if necessary.
* This is required in cases where one overlay was detached, while another one,
* that should be behind it, was destroyed. The next time both of them are opened,
* the stacking will be wrong, because the detached element's pane will still be
* in its original DOM position.
*/
private _updateStackingOrder() {
if (this._host.nextSibling) {
this._host.parentNode!.appendChild(this._host);
}
}
/** Detaches the backdrop (if any) associated with the overlay. */
detachBackdrop(): void {
let backdropToDetach = this._backdropElement;
if (backdropToDetach) {
let timeoutId: number;
let finishDetach = () => {
// It may not be attached to anything in certain cases (e.g. unit tests).
if (backdropToDetach && backdropToDetach.parentNode) {
backdropToDetach.parentNode.removeChild(backdropToDetach);
}
// It is possible that a new portal has been attached to this overlay since we started
// removing the backdrop. If that is the case, only clear the backdrop reference if it
// is still the same instance that we started to remove.
if (this._backdropElement == backdropToDetach) {
this._backdropElement = null;
}
clearTimeout(timeoutId);
};
backdropToDetach.classList.remove('cdk-overlay-backdrop-showing');
if (this._config.backdropClass) {
this._toggleClasses(backdropToDetach, this._config.backdropClass, false);
}
this._ngZone.runOutsideAngular(() => {
backdropToDetach!.addEventListener('transitionend', finishDetach);
});
// If the backdrop doesn't have a transition, the `transitionend` event won't fire.
// In this case we make it unclickable and we try to remove it after a delay.
backdropToDetach.style.pointerEvents = 'none';
// Run this outside the Angular zone because there's nothing that Angular cares about.
// If it were to run inside the Angular zone, every test that used Overlay would have to be
// either async or fakeAsync.
timeoutId = this._ngZone.runOutsideAngular(() => setTimeout(finishDetach, 500));
}
}
/** Toggles a single CSS class or an array of classes on an element. */
private _toggleClasses(element: HTMLElement, cssClasses: string | string[], isAdd: boolean) {
const classList = element.classList;
coerceArray(cssClasses).forEach(cssClass => {
// We can't do a spread here, because IE doesn't support setting multiple classes.
isAdd ? classList.add(cssClass) : classList.remove(cssClass);
});
}
}
/** Size properties for an overlay. */
export interface OverlaySizeConfig {
width?: number | string;
height?: number | string;
minWidth?: number | string;
minHeight?: number | string;
maxWidth?: number | string;
maxHeight?: number | string;
}