Skip to content

Commit ee1a5a7

Browse files
crisbetojelbourn
authored andcommitted
fix(dialog): fire afterClosed callback after all dialog actions are done (#3892)
* 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 7336bdc commit ee1a5a7

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
@@ -334,28 +334,6 @@ describe('MdDialog', () => {
334334
expect(dialogContainer._state).toBe('exit');
335335
});
336336

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

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

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

0 commit comments

Comments
 (0)