Skip to content

Commit 0809250

Browse files
committed
fix(dialog): fire afterClosed callback after all dialog actions are done
* Fires the afterClosed callback once the dialog is removed and the previously-focused element has been refocused. Until now the order was reversed, which prevents people from being able to refocus a different element. * Cleaned up the `MdDialogContainer` logic to simplify it and to remove the need to subscribe to zone events.
1 parent f40296e commit 0809250

File tree

4 files changed

+61
-74
lines changed

4 files changed

+61
-74
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<h1>Dialog demo</h1>
22

3-
<button md-raised-button color="primary" (click)="openJazz()" [disabled]="dialogRef">
3+
<button md-raised-button color="primary" (click)="openJazz()">
44
Open dialog
55
</button>
66
<button md-raised-button color="accent" (click)="openContentElement()">

src/lib/dialog/dialog-container.ts

Lines changed: 19 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@ import {
33
ComponentRef,
44
ViewChild,
55
ViewEncapsulation,
6-
NgZone,
7-
OnDestroy,
86
Renderer,
97
ElementRef,
108
EventEmitter,
@@ -21,11 +19,6 @@ import {BasePortalHost, ComponentPortal, PortalHostDirective, TemplatePortal} fr
2119
import {MdDialogConfig} from './dialog-config';
2220
import {MdDialogContentAlreadyAttachedError} from './dialog-errors';
2321
import {FocusTrapFactory, FocusTrap} from '../core/a11y/focus-trap';
24-
import 'rxjs/add/operator/first';
25-
26-
27-
/** Possible states for the dialog container animation. */
28-
export type MdDialogContainerAnimationState = 'void' | 'enter' | 'exit' | 'exit-start';
2922

3023

3124
/**
@@ -54,7 +47,7 @@ export type MdDialogContainerAnimationState = 'void' | 'enter' | 'exit' | 'exit-
5447
'(@slideDialog.done)': '_onAnimationDone($event)',
5548
},
5649
})
57-
export class MdDialogContainer extends BasePortalHost implements OnDestroy {
50+
export class MdDialogContainer extends BasePortalHost {
5851
/** The portal host inside of this container into which the dialog content will be loaded. */
5952
@ViewChild(PortalHostDirective) _portalHost: PortalHostDirective;
6053

@@ -68,13 +61,12 @@ export class MdDialogContainer extends BasePortalHost implements OnDestroy {
6861
dialogConfig: MdDialogConfig;
6962

7063
/** State of the dialog animation. */
71-
_state: MdDialogContainerAnimationState = 'enter';
64+
_state: 'void' | 'enter' | 'exit' = 'enter';
7265

7366
/** Emits the current animation state whenever it changes. */
74-
_onAnimationStateChange = new EventEmitter<MdDialogContainerAnimationState>();
67+
_onAnimationStateChange = new EventEmitter<AnimationEvent>();
7568

7669
constructor(
77-
private _ngZone: NgZone,
7870
private _renderer: Renderer,
7971
private _elementRef: ElementRef,
8072
private _focusTrapFactory: FocusTrapFactory) {
@@ -108,7 +100,6 @@ export class MdDialogContainer extends BasePortalHost implements OnDestroy {
108100

109101
/**
110102
* Moves the focus inside the focus trap.
111-
* @private
112103
*/
113104
private _trapFocus() {
114105
if (!this._focusTrap) {
@@ -122,47 +113,36 @@ export class MdDialogContainer extends BasePortalHost implements OnDestroy {
122113
this._focusTrap.focusFirstTabbableElementWhenReady();
123114
}
124115

125-
/**
126-
* Kicks off the leave animation.
127-
* @docs-private
128-
*/
129-
_exit(): void {
130-
this._state = 'exit';
131-
this._onAnimationStateChange.emit('exit-start');
132-
}
133-
134116
/**
135117
* Callback, invoked whenever an animation on the host completes.
136118
* @docs-private
137119
*/
138120
_onAnimationDone(event: AnimationEvent) {
121+
this._onAnimationStateChange.emit(event);
122+
139123
if (event.toState === 'enter') {
140124
this._trapFocus();
125+
} else if (event.toState === 'exit') {
126+
this._onAnimationStateChange.complete();
141127
}
142-
143-
this._onAnimationStateChange.emit(event.toState as MdDialogContainerAnimationState);
144128
}
145129

146-
ngOnDestroy() {
147-
// When the dialog is destroyed, return focus to the element that originally had it before
148-
// the dialog was opened. Wait for the DOM to finish settling before changing the focus so
149-
// that it doesn't end up back on the <body>. Also note that we need the extra check, because
150-
// IE can set the `activeElement` to null in some cases.
151-
let toFocus = this._elementFocusedBeforeDialogWasOpened as HTMLElement;
152-
153-
// We shouldn't use `this` inside of the NgZone subscription, because it causes a memory leak.
154-
let animationStream = this._onAnimationStateChange;
155-
156-
this._ngZone.onMicrotaskEmpty.first().subscribe(() => {
157-
if (toFocus && 'focus' in toFocus) {
158-
toFocus.focus();
159-
}
130+
/**
131+
* Kicks off the leave animation and restores focus to the previously-focused element.
132+
* @docs-private
133+
*/
134+
_exit(): void {
135+
// We need the extra check, because IE can set the `activeElement` to null in some cases.
136+
let toFocus = this._elementFocusedBeforeDialogWasOpened;
160137

161-
animationStream.complete();
162-
});
138+
if (toFocus && 'focus' in toFocus) {
139+
toFocus.focus();
140+
}
163141

164142
if (this._focusTrap) {
165143
this._focusTrap.destroy();
166144
}
145+
146+
this._state = 'exit';
167147
}
168148
}

src/lib/dialog/dialog-ref.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import {OverlayRef, GlobalPositionStrategy} from '../core';
2+
import {AnimationEvent} from '@angular/animations';
23
import {DialogPosition} from './dialog-config';
34
import {Observable} from 'rxjs/Observable';
45
import {Subject} from 'rxjs/Subject';
5-
import {MdDialogContainer, MdDialogContainerAnimationState} from './dialog-container';
6+
import {MdDialogContainer} from './dialog-container';
7+
import 'rxjs/add/operator/filter';
68

79

810
// TODO(jelbourn): resizing
@@ -23,17 +25,14 @@ export class MdDialogRef<T> {
2325
private _result: any;
2426

2527
constructor(private _overlayRef: OverlayRef, public _containerInstance: MdDialogContainer) {
26-
_containerInstance._onAnimationStateChange.subscribe(
27-
(state: MdDialogContainerAnimationState) => {
28-
if (state === 'exit-start') {
29-
// Transition the backdrop in parallel with the dialog.
30-
this._overlayRef.detachBackdrop();
31-
} else if (state === 'exit') {
32-
this._overlayRef.dispose();
33-
this._afterClosed.next(this._result);
34-
this._afterClosed.complete();
35-
this.componentInstance = null;
36-
}
28+
_containerInstance._onAnimationStateChange
29+
.filter((event: AnimationEvent) => event.toState === 'exit')
30+
.subscribe(() => {
31+
this._overlayRef.dispose();
32+
this.componentInstance = null;
33+
}, null, () => {
34+
this._afterClosed.next(this._result);
35+
this._afterClosed.complete();
3736
});
3837
}
3938

@@ -44,6 +43,7 @@ export class MdDialogRef<T> {
4443
close(dialogResult?: any): void {
4544
this._result = dialogResult;
4645
this._containerInstance._exit();
46+
this._overlayRef.detachBackdrop(); // Transition the backdrop in parallel with the dialog.
4747
}
4848

4949
/**

src/lib/dialog/dialog.spec.ts

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -335,28 +335,6 @@ describe('MdDialog', () => {
335335
expect(dialogContainer._state).toBe('exit');
336336
});
337337

338-
it('should emit an event with the proper animation state', async(() => {
339-
let dialogRef = dialog.open(PizzaMsg, { viewContainerRef: testViewContainerRef });
340-
let dialogContainer: MdDialogContainer =
341-
viewContainerFixture.debugElement.query(By.directive(MdDialogContainer)).componentInstance;
342-
let spy = jasmine.createSpy('animation state callback');
343-
344-
dialogContainer._onAnimationStateChange.subscribe(spy);
345-
viewContainerFixture.detectChanges();
346-
347-
viewContainerFixture.whenStable().then(() => {
348-
expect(spy).toHaveBeenCalledWith('enter');
349-
350-
dialogRef.close();
351-
viewContainerFixture.detectChanges();
352-
expect(spy).toHaveBeenCalledWith('exit-start');
353-
354-
viewContainerFixture.whenStable().then(() => {
355-
expect(spy).toHaveBeenCalledWith('exit');
356-
});
357-
});
358-
}));
359-
360338
describe('passing in data', () => {
361339
it('should be able to pass in data', () => {
362340
let config = {
@@ -470,6 +448,35 @@ describe('MdDialog', () => {
470448

471449
document.body.removeChild(button);
472450
}));
451+
452+
it('should allow the consumer to shift focus in afterClosed', fakeAsync(() => {
453+
// Create a element that has focus before the dialog is opened.
454+
let button = document.createElement('button');
455+
let input = document.createElement('input');
456+
457+
button.id = 'dialog-trigger';
458+
input.id = 'input-to-be-focused';
459+
460+
document.body.appendChild(button);
461+
document.body.appendChild(input);
462+
button.focus();
463+
464+
let dialogRef = dialog.open(PizzaMsg, { viewContainerRef: testViewContainerRef });
465+
466+
dialogRef.afterClosed().subscribe(() => input.focus());
467+
468+
dialogRef.close();
469+
tick(500);
470+
viewContainerFixture.detectChanges();
471+
flushMicrotasks();
472+
473+
expect(document.activeElement.id).toBe('input-to-be-focused',
474+
'Expected that the trigger was refocused after dialog close');
475+
476+
document.body.removeChild(button);
477+
document.body.removeChild(input);
478+
}));
479+
473480
});
474481

475482
describe('dialog content elements', () => {

0 commit comments

Comments
 (0)