mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
feat(dialog): add focus management (#1321)
* feat(dialog): add focus management * fix typos
This commit is contained in:
@@ -26,7 +26,7 @@ describe('FocusTrap', () => {
|
||||
|
||||
// Because we can't mimic a real tab press focus change in a unit test, just call the
|
||||
// focus event handler directly.
|
||||
focusTrapInstance.wrapFocus();
|
||||
focusTrapInstance.focusFirstTabbableElement();
|
||||
|
||||
expect(document.activeElement.nodeName.toLowerCase())
|
||||
.toBe('input', 'Expected input element to be focused');
|
||||
@@ -38,7 +38,7 @@ describe('FocusTrap', () => {
|
||||
|
||||
// Because we can't mimic a real tab press focus change in a unit test, just call the
|
||||
// focus event handler directly.
|
||||
focusTrapInstance.reverseWrapFocus();
|
||||
focusTrapInstance.focusLastTabbableElement();
|
||||
|
||||
expect(document.activeElement.nodeName.toLowerCase())
|
||||
.toBe('button', 'Expected button element to be focused');
|
||||
|
||||
@@ -15,9 +15,9 @@ import {InteractivityChecker} from './interactivity-checker';
|
||||
selector: 'focus-trap',
|
||||
// TODO(jelbourn): move this to a separate file.
|
||||
template: `
|
||||
<div tabindex="0" (focus)="reverseWrapFocus()"></div>
|
||||
<div tabindex="0" (focus)="focusLastTabbableElement()"></div>
|
||||
<div #trappedContent><ng-content></ng-content></div>
|
||||
<div tabindex="0" (focus)="wrapFocus()"></div>`,
|
||||
<div tabindex="0" (focus)="focusFirstTabbableElement()"></div>`,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
})
|
||||
export class FocusTrap {
|
||||
@@ -25,16 +25,16 @@ export class FocusTrap {
|
||||
|
||||
constructor(private _checker: InteractivityChecker) { }
|
||||
|
||||
/** Wrap focus from the end of the trapped region to the beginning. */
|
||||
wrapFocus() {
|
||||
/** Focuses the first tabbable element within the focus trap region. */
|
||||
focusFirstTabbableElement() {
|
||||
let redirectToElement = this._getFirstTabbableElement(this.trappedContent.nativeElement);
|
||||
if (redirectToElement) {
|
||||
redirectToElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/** Wrap focus from the beginning of the trapped region to the end. */
|
||||
reverseWrapFocus() {
|
||||
/** Focuses the last tabbable element within the focus trap region. */
|
||||
focusLastTabbableElement() {
|
||||
let redirectToElement = this._getLastTabbableElement(this.trappedContent.nativeElement);
|
||||
if (redirectToElement) {
|
||||
redirectToElement.focus();
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import {RtlModule} from './rtl/dir';
|
||||
import {MdRippleModule} from './ripple/ripple';
|
||||
import {PortalModule} from './portal/portal-directives';
|
||||
import {OverlayModule} from './overlay/overlay-directives';
|
||||
import {MdLiveAnnouncer} from './a11y/live-announcer';
|
||||
import {A11yModule} from './a11y/index';
|
||||
|
||||
|
||||
// RTL
|
||||
export {Dir, LayoutDirection, RtlModule} from './rtl/dir';
|
||||
@@ -77,14 +78,14 @@ export * from './keyboard/keycodes';
|
||||
|
||||
|
||||
@NgModule({
|
||||
imports: [MdLineModule, RtlModule, MdRippleModule, PortalModule, OverlayModule],
|
||||
exports: [MdLineModule, RtlModule, MdRippleModule, PortalModule, OverlayModule],
|
||||
imports: [MdLineModule, RtlModule, MdRippleModule, PortalModule, OverlayModule, A11yModule],
|
||||
exports: [MdLineModule, RtlModule, MdRippleModule, PortalModule, OverlayModule, A11yModule],
|
||||
})
|
||||
export class MdCoreModule {
|
||||
static forRoot(): ModuleWithProviders {
|
||||
return {
|
||||
ngModule: MdCoreModule,
|
||||
providers: [MdLiveAnnouncer]
|
||||
providers: [A11yModule.forRoot().providers],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
<template portalHost></template>
|
||||
<focus-trap>
|
||||
<template portalHost></template>
|
||||
</focus-trap>
|
||||
|
||||
@@ -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 instances 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 first tabbable element (input) in the dialog to be focused.');
|
||||
}));
|
||||
|
||||
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>) { }
|
||||
}
|
||||
|
||||
@@ -13,18 +13,18 @@ import {MdDialogConfig} from './dialog-config';
|
||||
import {MdDialogRef} from './dialog-ref';
|
||||
import {DialogInjector} from './dialog-injector';
|
||||
import {MdDialogContainer} from './dialog-container';
|
||||
import {A11yModule} from '../core/a11y/index';
|
||||
|
||||
export {MdDialogConfig} from './dialog-config';
|
||||
export {MdDialogRef} from './dialog-ref';
|
||||
|
||||
|
||||
// TODO(jelbourn): add shortcuts for `alert` and `confirm`.
|
||||
// TODO(jelbourn): add support for opening with a TemplateRef
|
||||
// TODO(jelbourn): add `closeAll` method
|
||||
// TODO(jelbourn): add backdrop
|
||||
// TODO(jelbourn): default dialog config
|
||||
// TODO(jelbourn): focus trapping
|
||||
// TODO(jelbourn): potentially change API from accepting component constructor to component factory.
|
||||
// TODO(jelbourn): escape key closes dialog
|
||||
// TODO(jelbourn): dialog content directives (e.g., md-dialog-header)
|
||||
// TODO(jelbourn): animations
|
||||
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ export class MdDialog {
|
||||
|
||||
|
||||
@NgModule({
|
||||
imports: [OverlayModule, PortalModule],
|
||||
imports: [OverlayModule, PortalModule, A11yModule],
|
||||
exports: [MdDialogContainer],
|
||||
declarations: [MdDialogContainer],
|
||||
entryComponents: [MdDialogContainer],
|
||||
@@ -132,7 +132,7 @@ export class MdDialogModule {
|
||||
static forRoot(): ModuleWithProviders {
|
||||
return {
|
||||
ngModule: MdDialogModule,
|
||||
providers: [MdDialog, OVERLAY_PROVIDERS],
|
||||
providers: [MdDialog, OVERLAY_PROVIDERS, A11yModule.forRoot().providers],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user