Skip to content

Commit 5967f6e

Browse files
crisbetotinayuangao
authored andcommitted
fix(dialog): better handling of custom ViewContainerRef with OnPush change detection (#6164)
* Adds a `markForCheck` call after starting the dialog animation, in order to ensure that it animates away if it was opened from a component with OnPush change detection. * Since we can't know whether the animation will start right after the `markForCheck`, these changes switch to starting the backdrop animation together with the dialog animation. This avoids some slight timing issues where the backdrop can start its animation a little too early. * Simplifies the dialog animation streams to avoid having to subscribe to the `completed` callback in the `MdDialogRef`. * Fixes the demo app sidenav jumping to the bottom when it is opened from the homepage. * Fixes some alignment in the dialog demo that got thrown off by the new input width. The dialog changes should solve issues like #5118.
1 parent 916d1f3 commit 5967f6e

File tree

6 files changed

+97
-32
lines changed

6 files changed

+97
-32
lines changed

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

+2-1
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,13 @@
1414
<hr>
1515

1616
<a md-list-item
17+
tabindex="-1"
1718
(click)="start.close()"
1819
[routerLink]="['/baseline']">
1920
Baseline
2021
</a>
2122
</md-nav-list>
22-
<button md-button (click)="start.close()">CLOSE</button>
23+
<button md-button tabindex="-1" (click)="start.close()">CLOSE</button>
2324
</md-sidenav>
2425
<div>
2526
<md-toolbar color="primary">

src/demo-app/dialog/dialog-demo.scss

+1-5
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
1-
.demo-dialog {
2-
color: rebeccapurple;
3-
}
4-
51
.demo-dialog-card {
6-
max-width: 350px;
2+
max-width: 405px;
73
margin: 20px 0;
84
}

src/lib/datepicker/datepicker.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,8 @@ export class MdDatepicker<D> implements OnDestroy {
288288
/** Open the calendar as a dialog. */
289289
private _openAsDialog(): void {
290290
this._dialogRef = this._dialog.open(MdDatepickerContent, {
291-
direction: this._dir ? this._dir.value : 'ltr'
291+
direction: this._dir ? this._dir.value : 'ltr',
292+
viewContainerRef: this._viewContainerRef,
292293
});
293294
this._dialogRef.afterClosed().subscribe(() => this.close());
294295
this._dialogRef.componentInstance.datepicker = this;

src/lib/dialog/dialog-container.ts

+22-11
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
EventEmitter,
1717
Inject,
1818
Optional,
19+
ChangeDetectorRef,
1920
} from '@angular/core';
2021
import {
2122
animate,
@@ -68,7 +69,7 @@ export function throwMdDialogContentAlreadyAttachedError() {
6869
'[attr.aria-labelledby]': '_ariaLabelledBy',
6970
'[attr.aria-describedby]': '_config?.ariaDescribedBy || null',
7071
'[@slideDialog]': '_state',
71-
'(@slideDialog.start)': 'this._isAnimating = true',
72+
'(@slideDialog.start)': '_onAnimationStart($event)',
7273
'(@slideDialog.done)': '_onAnimationDone($event)',
7374
},
7475
})
@@ -82,17 +83,14 @@ export class MdDialogContainer extends BasePortalHost {
8283
/** Element that was focused before the dialog was opened. Save this to restore upon close. */
8384
private _elementFocusedBeforeDialogWasOpened: HTMLElement | null = null;
8485

85-
/** Reference to the global document object. */
86-
private _document: Document;
87-
8886
/** The dialog configuration. */
8987
_config: MdDialogConfig;
9088

9189
/** State of the dialog animation. */
9290
_state: 'void' | 'enter' | 'exit' = 'enter';
9391

94-
/** Emits the current animation state whenever it changes. */
95-
_onAnimationStateChange = new EventEmitter<AnimationEvent>();
92+
/** Emits when an animation state changes. */
93+
_animationStateChanged = new EventEmitter<AnimationEvent>();
9694

9795
/** ID of the element that should be considered as the dialog's label. */
9896
_ariaLabelledBy: string | null = null;
@@ -104,10 +102,10 @@ export class MdDialogContainer extends BasePortalHost {
104102
private _ngZone: NgZone,
105103
private _elementRef: ElementRef,
106104
private _focusTrapFactory: FocusTrapFactory,
107-
@Optional() @Inject(DOCUMENT) _document: any) {
105+
private _changeDetectorRef: ChangeDetectorRef,
106+
@Optional() @Inject(DOCUMENT) private _document: any) {
108107

109108
super();
110-
this._document = _document;
111109
}
112110

113111
/**
@@ -171,15 +169,28 @@ export class MdDialogContainer extends BasePortalHost {
171169

172170
/** Callback, invoked whenever an animation on the host completes. */
173171
_onAnimationDone(event: AnimationEvent) {
174-
this._onAnimationStateChange.emit(event);
175-
176172
if (event.toState === 'enter') {
177173
this._trapFocus();
178174
} else if (event.toState === 'exit') {
179175
this._restoreFocus();
180-
this._onAnimationStateChange.complete();
181176
}
182177

178+
this._animationStateChanged.emit(event);
183179
this._isAnimating = false;
184180
}
181+
182+
/** Callback, invoked when an animation on the host starts. */
183+
_onAnimationStart(event: AnimationEvent) {
184+
this._isAnimating = true;
185+
this._animationStateChanged.emit(event);
186+
}
187+
188+
/** Starts the dialog exit animation. */
189+
_startExitAnimation(): void {
190+
this._state = 'exit';
191+
192+
// Mark the container for check so it can react if the
193+
// view container is using OnPush change detection.
194+
this._changeDetectorRef.markForCheck();
195+
}
185196
}

src/lib/dialog/dialog-ref.ts

+14-7
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,11 @@
77
*/
88

99
import {OverlayRef, GlobalPositionStrategy} from '../core';
10-
import {AnimationEvent} from '@angular/animations';
1110
import {DialogPosition} from './dialog-config';
1211
import {Observable} from 'rxjs/Observable';
1312
import {Subject} from 'rxjs/Subject';
1413
import {MdDialogContainer} from './dialog-container';
15-
import {filter} from '../core/rxjs/index';
14+
import {RxChain, first, filter} from '../core/rxjs/index';
1615

1716

1817
// TODO(jelbourn): resizing
@@ -36,9 +35,11 @@ export class MdDialogRef<T> {
3635
private _result: any;
3736

3837
constructor(private _overlayRef: OverlayRef, private _containerInstance: MdDialogContainer) {
39-
filter.call(_containerInstance._onAnimationStateChange,
40-
(event: AnimationEvent) => event.toState === 'exit')
41-
.subscribe(() => this._overlayRef.dispose(), undefined, () => {
38+
RxChain.from(_containerInstance._animationStateChanged)
39+
.call(filter, event => event.phaseName === 'done' && event.toState === 'exit')
40+
.call(first)
41+
.subscribe(() => {
42+
this._overlayRef.dispose();
4243
this._afterClosed.next(this._result);
4344
this._afterClosed.complete();
4445
this.componentInstance = null!;
@@ -51,8 +52,14 @@ export class MdDialogRef<T> {
5152
*/
5253
close(dialogResult?: any): void {
5354
this._result = dialogResult;
54-
this._containerInstance._state = 'exit';
55-
this._overlayRef.detachBackdrop(); // Transition the backdrop in parallel with the dialog.
55+
56+
// Transition the backdrop in parallel to the dialog.
57+
RxChain.from(this._containerInstance._animationStateChanged)
58+
.call(filter, event => event.phaseName === 'start')
59+
.call(first)
60+
.subscribe(() => this._overlayRef.detachBackdrop());
61+
62+
this._containerInstance._startExitAnimation();
5663
}
5764

5865
/**

src/lib/dialog/dialog.spec.ts

+56-7
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
ViewContainerRef,
1616
Injector,
1717
Inject,
18+
ChangeDetectionStrategy,
1819
} from '@angular/core';
1920
import {By} from '@angular/platform-browser';
2021
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
@@ -153,6 +154,31 @@ describe('MdDialog', () => {
153154
});
154155
}));
155156

157+
it('should close from a ViewContainerRef with OnPush change detection', fakeAsync(() => {
158+
const onPushFixture = TestBed.createComponent(ComponentWithOnPushViewContainer);
159+
160+
onPushFixture.detectChanges();
161+
162+
const dialogRef = dialog.open(PizzaMsg, {
163+
viewContainerRef: onPushFixture.componentInstance.viewContainerRef
164+
});
165+
166+
flushMicrotasks();
167+
onPushFixture.detectChanges();
168+
flushMicrotasks();
169+
170+
expect(overlayContainerElement.querySelectorAll('md-dialog-container').length)
171+
.toBe(1, 'Expected one open dialog.');
172+
173+
dialogRef.close();
174+
flushMicrotasks();
175+
onPushFixture.detectChanges();
176+
tick(500);
177+
178+
expect(overlayContainerElement.querySelectorAll('md-dialog-container').length)
179+
.toBe(0, 'Expected no open dialogs.');
180+
}));
181+
156182
it('should close when clicking on the overlay backdrop', async(() => {
157183
dialog.open(PizzaMsg, {
158184
viewContainerRef: testViewContainerRef
@@ -385,14 +411,25 @@ describe('MdDialog', () => {
385411

386412
it('should have the componentInstance available in the afterClosed callback', fakeAsync(() => {
387413
let dialogRef = dialog.open(PizzaMsg);
414+
let spy = jasmine.createSpy('afterClosed spy');
415+
416+
flushMicrotasks();
417+
viewContainerFixture.detectChanges();
418+
flushMicrotasks();
388419

389420
dialogRef.afterClosed().subscribe(() => {
421+
spy();
390422
expect(dialogRef.componentInstance).toBeTruthy('Expected component instance to be defined.');
391423
});
392424

393425
dialogRef.close();
394-
tick(500);
426+
427+
flushMicrotasks();
395428
viewContainerFixture.detectChanges();
429+
tick(500);
430+
431+
// Ensure that the callback actually fires.
432+
expect(spy).toHaveBeenCalled();
396433
}));
397434

398435
describe('passing in data', () => {
@@ -566,22 +603,22 @@ describe('MdDialog', () => {
566603
document.body.appendChild(button);
567604
button.focus();
568605

569-
let dialogRef = dialog.open(PizzaMsg, {
570-
viewContainerRef: testViewContainerRef
571-
});
606+
let dialogRef = dialog.open(PizzaMsg, { viewContainerRef: testViewContainerRef });
572607

608+
flushMicrotasks();
573609
viewContainerFixture.detectChanges();
574610
flushMicrotasks();
611+
575612
expect(document.activeElement.id)
576613
.not.toBe('dialog-trigger', 'Expected the focus to change when dialog was opened.');
577614

578615
dialogRef.close();
579616
expect(document.activeElement.id).not.toBe('dialog-trigger',
580617
'Expcted the focus not to have changed before the animation finishes.');
581618

582-
tick(500);
583-
viewContainerFixture.detectChanges();
584619
flushMicrotasks();
620+
viewContainerFixture.detectChanges();
621+
tick(500);
585622

586623
expect(document.activeElement.id).toBe('dialog-trigger',
587624
'Expected that the trigger was refocused after the dialog is closed.');
@@ -603,9 +640,12 @@ describe('MdDialog', () => {
603640

604641
let dialogRef = dialog.open(PizzaMsg, { viewContainerRef: testViewContainerRef });
605642

606-
dialogRef.afterClosed().subscribe(() => input.focus());
643+
tick(500);
644+
viewContainerFixture.detectChanges();
607645

646+
dialogRef.afterClosed().subscribe(() => input.focus());
608647
dialogRef.close();
648+
609649
tick(500);
610650
viewContainerFixture.detectChanges();
611651
flushMicrotasks();
@@ -775,6 +815,14 @@ class DirectiveWithViewContainer {
775815
constructor(public viewContainerRef: ViewContainerRef) { }
776816
}
777817

818+
@Component({
819+
changeDetection: ChangeDetectionStrategy.OnPush,
820+
template: 'hello',
821+
})
822+
class ComponentWithOnPushViewContainer {
823+
constructor(public viewContainerRef: ViewContainerRef) { }
824+
}
825+
778826
@Component({
779827
selector: 'arbitrary-component',
780828
template: `<dir-with-view-container></dir-with-view-container>`,
@@ -829,6 +877,7 @@ const TEST_DIRECTIVES = [
829877
ComponentWithChildViewContainer,
830878
PizzaMsg,
831879
DirectiveWithViewContainer,
880+
ComponentWithOnPushViewContainer,
832881
ContentElementDialog,
833882
DialogWithInjectedData
834883
];

0 commit comments

Comments
 (0)