-
Notifications
You must be signed in to change notification settings - Fork 6.7k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(dialog): add focus management #1321
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
import {NgModule, ModuleWithProviders} from '@angular/core'; | ||
import {FocusTrap} from './focus-trap'; | ||
import {MdLiveAnnouncer} from './live-announcer'; | ||
import {InteractivityChecker} from './interactivity-checker'; | ||
|
||
export {FocusTrap} from './focus-trap'; | ||
export {MdLiveAnnouncer} from './live-announcer'; | ||
export {InteractivityChecker} from './interactivity-checker'; | ||
|
||
|
||
@NgModule({ | ||
declarations: [FocusTrap], | ||
exports: [FocusTrap], | ||
}) | ||
export class A11yModule { | ||
static forRoot(): ModuleWithProviders { | ||
return { | ||
ngModule: A11yModule, | ||
providers: [MdLiveAnnouncer, InteractivityChecker], | ||
}; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,3 @@ | ||
<template portalHost></template> | ||
<focus-trap> | ||
<template portalHost></template> | ||
</focus-trap> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,12 +1,16 @@ | ||
import {Component, ComponentRef, ViewChild, ViewEncapsulation} from '@angular/core'; | ||
import { | ||
BasePortalHost, | ||
ComponentPortal, | ||
PortalHostDirective, | ||
TemplatePortal | ||
} from '../core'; | ||
Component, | ||
ComponentRef, | ||
ViewChild, | ||
ViewEncapsulation, | ||
NgZone, | ||
OnDestroy | ||
} from '@angular/core'; | ||
import {BasePortalHost, ComponentPortal, PortalHostDirective, TemplatePortal} from '../core'; | ||
import {MdDialogConfig} from './dialog-config'; | ||
import {MdDialogContentAlreadyAttachedError} from './dialog-errors'; | ||
import {FocusTrap} from '../core/a11y/focus-trap'; | ||
import 'rxjs/add/operator/first'; | ||
|
||
|
||
/** | ||
|
@@ -23,23 +27,52 @@ import {MdDialogContentAlreadyAttachedError} from './dialog-errors'; | |
}, | ||
encapsulation: ViewEncapsulation.None, | ||
}) | ||
export class MdDialogContainer extends BasePortalHost { | ||
export class MdDialogContainer extends BasePortalHost implements OnDestroy { | ||
/** The portal host inside of this container into which the dialog content will be loaded. */ | ||
@ViewChild(PortalHostDirective) _portalHost: PortalHostDirective; | ||
|
||
/** The directive that traps and manages focus within the dialog. */ | ||
@ViewChild(FocusTrap) _focusTrap: FocusTrap; | ||
|
||
/** Element that was focused before the dialog was opened. Save this to restore upon close. */ | ||
private _elementFocusedBeforeDialogWasOpened: Element = null; | ||
|
||
/** The dialog configuration. */ | ||
dialogConfig: MdDialogConfig; | ||
|
||
constructor(private _ngZone: NgZone) { | ||
super(); | ||
} | ||
|
||
/** Attach a portal as content to this dialog container. */ | ||
attachComponentPortal<T>(portal: ComponentPortal<T>): ComponentRef<T> { | ||
if (this._portalHost.hasAttached()) { | ||
throw new MdDialogContentAlreadyAttachedError(); | ||
} | ||
|
||
return this._portalHost.attachComponentPortal(portal); | ||
let attachResult = this._portalHost.attachComponentPortal(portal); | ||
|
||
// If were to attempt to focus immediately, then the content of the dialog would not yet be | ||
// ready in stances where change detection has to run first. To deal with this, we simply | ||
// wait for the microtask queue to be empty. | ||
this._ngZone.onMicrotaskEmpty.first().subscribe(() => { | ||
this._elementFocusedBeforeDialogWasOpened = document.activeElement; | ||
this._focusTrap.focusFirstTabbableElement(); | ||
}); | ||
|
||
return attachResult; | ||
} | ||
|
||
attachTemplatePortal(portal: TemplatePortal): Map<string, any> { | ||
throw Error('Not yet implemented'); | ||
} | ||
|
||
ngOnDestroy() { | ||
// When the dialog is destroyed, return focus to the element that originally had it before | ||
// the dialog was opened. Wait for the DOM to finish settling before changing the focus so | ||
// that it doesn't end up back on the <body>. | ||
this._ngZone.onMicrotaskEmpty.first().subscribe(() => { | ||
(this._elementFocusedBeforeDialogWasOpened as HTMLElement).focus(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do you prefer There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I didn't know the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't feel strongly about it; was just curious |
||
}); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,11 @@ | ||
import {inject, async, ComponentFixture, TestBed} from '@angular/core/testing'; | ||
import { | ||
inject, | ||
async, | ||
fakeAsync, | ||
flushMicrotasks, | ||
ComponentFixture, | ||
TestBed, | ||
} from '@angular/core/testing'; | ||
import {NgModule, Component, Directive, ViewChild, ViewContainerRef} from '@angular/core'; | ||
import {MdDialog, MdDialogModule} from './dialog'; | ||
import {OverlayContainer} from '../core'; | ||
|
@@ -100,6 +107,55 @@ describe('MdDialog', () => { | |
|
||
expect(overlayContainerElement.querySelector('md-dialog-container')).toBeFalsy(); | ||
}); | ||
|
||
describe('focus management', () => { | ||
|
||
// When testing focus, all of the elements must be in the DOM. | ||
beforeEach(() => { | ||
document.body.appendChild(overlayContainerElement); | ||
}); | ||
|
||
afterEach(() => { | ||
document.body.removeChild(overlayContainerElement); | ||
}); | ||
|
||
it('should focus the first tabbable element of the dialog on open', fakeAsync(() => { | ||
let config = new MdDialogConfig(); | ||
config.viewContainerRef = testViewContainerRef; | ||
|
||
dialog.open(PizzaMsg, config); | ||
viewContainerFixture.detectChanges(); | ||
flushMicrotasks(); | ||
|
||
expect(document.activeElement.tagName) | ||
.toBe('INPUT', 'Expected the first tabbale element (input) in the dialog to be focused.'); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done |
||
})); | ||
|
||
it('should re-focus trigger element when dialog closes', fakeAsync(() => { | ||
// Create a element that has focus before the dialog is opened. | ||
let button = document.createElement('button'); | ||
button.id = 'dialog-trigger'; | ||
document.body.appendChild(button); | ||
button.focus(); | ||
|
||
let config = new MdDialogConfig(); | ||
config.viewContainerRef = testViewContainerRef; | ||
|
||
let dialogRef = dialog.open(PizzaMsg, config); | ||
viewContainerFixture.detectChanges(); | ||
flushMicrotasks(); | ||
|
||
expect(document.activeElement.id) | ||
.not.toBe('dialog-trigger', 'Expected the focus to change when dialog was opened.'); | ||
|
||
dialogRef.close(); | ||
viewContainerFixture.detectChanges(); | ||
flushMicrotasks(); | ||
|
||
expect(document.activeElement.id) | ||
.toBe('dialog-trigger', 'Expected that the trigger was refocused after dialog close'); | ||
})); | ||
}); | ||
}); | ||
|
||
|
||
|
@@ -121,7 +177,7 @@ class ComponentWithChildViewContainer { | |
} | ||
|
||
/** Simple component for testing ComponentPortal. */ | ||
@Component({template: '<p>Pizza</p>'}) | ||
@Component({template: '<p>Pizza</p> <input> <button>Close</button>'}) | ||
class PizzaMsg { | ||
constructor(public dialogRef: MdDialogRef<PizzaMsg>) { } | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
in stances
->in instances
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done