Skip to content

Commit e394ff1

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 e394ff1

File tree

4 files changed

+61
-73
lines changed

4 files changed

+61
-73
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 & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import {
44
ViewChild,
55
ViewEncapsulation,
66
NgZone,
7-
OnDestroy,
87
Renderer,
98
ElementRef,
109
EventEmitter,
@@ -21,11 +20,6 @@ import {BasePortalHost, ComponentPortal, PortalHostDirective, TemplatePortal} fr
2120
import {MdDialogConfig} from './dialog-config';
2221
import {MdDialogContentAlreadyAttachedError} from './dialog-errors';
2322
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';
2923

3024

3125
/**
@@ -54,7 +48,7 @@ export type MdDialogContainerAnimationState = 'void' | 'enter' | 'exit' | 'exit-
5448
'(@slideDialog.done)': '_onAnimationDone($event)',
5549
},
5650
})
57-
export class MdDialogContainer extends BasePortalHost implements OnDestroy {
51+
export class MdDialogContainer extends BasePortalHost {
5852
/** The portal host inside of this container into which the dialog content will be loaded. */
5953
@ViewChild(PortalHostDirective) _portalHost: PortalHostDirective;
6054

@@ -68,13 +62,12 @@ export class MdDialogContainer extends BasePortalHost implements OnDestroy {
6862
dialogConfig: MdDialogConfig;
6963

7064
/** State of the dialog animation. */
71-
_state: MdDialogContainerAnimationState = 'enter';
65+
_state: 'void' | 'enter' | 'exit' = 'enter';
7266

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

7670
constructor(
77-
private _ngZone: NgZone,
7871
private _renderer: Renderer,
7972
private _elementRef: ElementRef,
8073
private _focusTrapFactory: FocusTrapFactory) {
@@ -108,7 +101,6 @@ export class MdDialogContainer extends BasePortalHost implements OnDestroy {
108101

109102
/**
110103
* Moves the focus inside the focus trap.
111-
* @private
112104
*/
113105
private _trapFocus() {
114106
if (!this._focusTrap) {
@@ -122,47 +114,36 @@ export class MdDialogContainer extends BasePortalHost implements OnDestroy {
122114
this._focusTrap.focusFirstTabbableElementWhenReady();
123115
}
124116

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-
134117
/**
135118
* Callback, invoked whenever an animation on the host completes.
136119
* @docs-private
137120
*/
138121
_onAnimationDone(event: AnimationEvent) {
122+
this._onAnimationStateChange.emit(event);
123+
139124
if (event.toState === 'enter') {
140125
this._trapFocus();
126+
} else if (event.toState === 'exit') {
127+
this._onAnimationStateChange.complete();
141128
}
142-
143-
this._onAnimationStateChange.emit(event.toState as MdDialogContainerAnimationState);
144129
}
145130

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-
}
131+
/**
132+
* Kicks off the leave animation and restores focus to the previously-focused element.
133+
* @docs-private
134+
*/
135+
_exit(): void {
136+
// We need the extra check, because IE can set the `activeElement` to null in some cases.
137+
let toFocus = this._elementFocusedBeforeDialogWasOpened;
160138

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

164143
if (this._focusTrap) {
165144
this._focusTrap.destroy();
166145
}
146+
147+
this._state = 'exit';
167148
}
168149
}

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+
tick();
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)