refactor(focus-trap): convert to directive (#3184)

This commit is contained in:
Kristiyan Kostadinov
2017-03-02 08:02:37 +01:00
committed by Kara
parent f40b1b29f5
commit 4e4c6a6b78
10 changed files with 260 additions and 99 deletions
-3
View File
@@ -1,3 +0,0 @@
<div *ngIf="!disabled" tabindex="0" (focus)="focusLastTabbableElement()"></div>
<div #trappedContent class="cdk-focus-trap-content"><ng-content></ng-content></div>
<div *ngIf="!disabled" tabindex="0" (focus)="focusFirstTabbableElement()"></div>
+47 -20
View File
@@ -1,7 +1,6 @@
import {inject, ComponentFixture, TestBed, async} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {Component} from '@angular/core';
import {FocusTrap} from './focus-trap';
import {ComponentFixture, TestBed, async} from '@angular/core/testing';
import {Component, ViewChild} from '@angular/core';
import {FocusTrapFactory, FocusTrapDirective, FocusTrap} from './focus-trap';
import {InteractivityChecker} from './interactivity-checker';
import {Platform} from '../platform/platform';
@@ -16,16 +15,15 @@ describe('FocusTrap', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [FocusTrap, FocusTrapTestApp],
providers: [InteractivityChecker, Platform]
declarations: [FocusTrapDirective, FocusTrapTestApp],
providers: [InteractivityChecker, Platform, FocusTrapFactory]
});
TestBed.compileComponents();
}));
beforeEach(inject([InteractivityChecker], (c: InteractivityChecker) => {
fixture = TestBed.createComponent(FocusTrapTestApp);
focusTrapInstance = fixture.debugElement.query(By.directive(FocusTrap)).componentInstance;
fixture.detectChanges();
focusTrapInstance = fixture.componentInstance.focusTrapDirective.focusTrap;
}));
it('wrap focus from end to start', () => {
@@ -48,6 +46,30 @@ describe('FocusTrap', () => {
expect(document.activeElement.nodeName.toLowerCase())
.toBe(lastElement, `Expected ${lastElement} element to be focused`);
});
it('should clean up its anchor sibling elements on destroy', () => {
const rootElement = fixture.debugElement.nativeElement as HTMLElement;
expect(rootElement.querySelectorAll('div.cdk-visually-hidden').length).toBe(2);
fixture.componentInstance.renderFocusTrap = false;
fixture.detectChanges();
expect(rootElement.querySelectorAll('div.cdk-visually-hidden').length).toBe(0);
});
it('should set the appropriate tabindex on the anchors, based on the disabled state', () => {
const anchors = Array.from(
fixture.debugElement.nativeElement.querySelectorAll('div.cdk-visually-hidden')
) as HTMLElement[];
expect(anchors.every(current => current.getAttribute('tabindex') === '0')).toBe(true);
fixture.componentInstance.isFocusTrapEnabled = false;
fixture.detectChanges();
expect(anchors.every(current => current.getAttribute('tabindex') === '-1')).toBe(true);
});
});
describe('with focus targets', () => {
@@ -56,16 +78,15 @@ describe('FocusTrap', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [FocusTrap, FocusTrapTargetTestApp],
providers: [InteractivityChecker, Platform]
declarations: [FocusTrapDirective, FocusTrapTargetTestApp],
providers: [InteractivityChecker, Platform, FocusTrapFactory]
});
TestBed.compileComponents();
}));
beforeEach(inject([InteractivityChecker], (c: InteractivityChecker) => {
fixture = TestBed.createComponent(FocusTrapTargetTestApp);
focusTrapInstance = fixture.debugElement.query(By.directive(FocusTrap)).componentInstance;
fixture.detectChanges();
focusTrapInstance = fixture.componentInstance.focusTrapDirective.focusTrap;
}));
it('should be able to prioritize the first focus target', () => {
@@ -87,23 +108,29 @@ describe('FocusTrap', () => {
@Component({
template: `
<cdk-focus-trap>
<div *ngIf="renderFocusTrap" [cdkTrapFocus]="isFocusTrapEnabled">
<input>
<button>SAVE</button>
</cdk-focus-trap>
</div>
`
})
class FocusTrapTestApp { }
class FocusTrapTestApp {
@ViewChild(FocusTrapDirective) focusTrapDirective: FocusTrapDirective;
renderFocusTrap = true;
isFocusTrapEnabled = true;
}
@Component({
template: `
<cdk-focus-trap>
<div cdkTrapFocus>
<input>
<button id="last" cdk-focus-end></button>
<button id="first" cdk-focus-start>SAVE</button>
<input>
</cdk-focus-trap>
</div>
`
})
class FocusTrapTargetTestApp { }
class FocusTrapTargetTestApp {
@ViewChild(FocusTrapDirective) focusTrapDirective: FocusTrapDirective;
}
+158 -37
View File
@@ -1,78 +1,122 @@
import {Component, ViewEncapsulation, ViewChild, ElementRef, Input, NgZone} from '@angular/core';
import {
Directive,
ElementRef,
Input,
NgZone,
OnDestroy,
AfterContentInit,
Injectable,
} from '@angular/core';
import {InteractivityChecker} from './interactivity-checker';
import {coerceBooleanProperty} from '../coercion/boolean-property';
/**
* Directive for trapping focus within a region.
* Class that allows for trapping focus within a DOM element.
*
* NOTE: This directive currently uses a very simple (naive) approach to focus trapping.
* NOTE: This class currently uses a very simple (naive) approach to focus trapping.
* It assumes that the tab order is the same as DOM order, which is not necessarily true.
* Things like tabIndex > 0, flex `order`, and shadow roots can cause to two to misalign.
* This will be replaced with a more intelligent solution before the library is considered stable.
*/
@Component({
moduleId: module.id,
selector: 'cdk-focus-trap, focus-trap',
templateUrl: 'focus-trap.html',
encapsulation: ViewEncapsulation.None,
})
export class FocusTrap {
@ViewChild('trappedContent') trappedContent: ElementRef;
private _startAnchor: HTMLElement;
private _endAnchor: HTMLElement;
/** Whether the focus trap is active. */
@Input()
get disabled(): boolean { return this._disabled; }
set disabled(val: boolean) { this._disabled = coerceBooleanProperty(val); }
private _disabled: boolean = false;
get enabled(): boolean { return this._enabled; }
set enabled(val: boolean) {
this._enabled = val;
constructor(private _checker: InteractivityChecker, private _ngZone: NgZone) { }
if (this._startAnchor && this._endAnchor) {
this._startAnchor.tabIndex = this._endAnchor.tabIndex = this._enabled ? 0 : -1;
}
}
private _enabled: boolean = true;
constructor(
private _element: HTMLElement,
private _checker: InteractivityChecker,
private _ngZone: NgZone,
deferAnchors = false) {
if (!deferAnchors) {
this.attachAnchors();
}
}
/** Destroys the focus trap by cleaning up the anchors. */
destroy() {
if (this._startAnchor && this._startAnchor.parentNode) {
this._startAnchor.parentNode.removeChild(this._startAnchor);
}
if (this._endAnchor && this._endAnchor.parentNode) {
this._endAnchor.parentNode.removeChild(this._endAnchor);
}
this._startAnchor = this._endAnchor = null;
}
/**
* Waits for microtask queue to empty, then focuses the first tabbable element within the focus
* trap region.
* Inserts the anchors into the DOM. This is usually done automatically
* in the constructor, but can be deferred for cases like directives with `*ngIf`.
*/
attachAnchors(): void {
if (!this._startAnchor) {
this._startAnchor = this._createAnchor();
}
if (!this._endAnchor) {
this._endAnchor = this._createAnchor();
}
this._ngZone.runOutsideAngular(() => {
this._element
.insertAdjacentElement('beforebegin', this._startAnchor)
.addEventListener('focus', () => this.focusLastTabbableElement());
this._element
.insertAdjacentElement('afterend', this._endAnchor)
.addEventListener('focus', () => this.focusFirstTabbableElement());
});
}
/**
* Waits for microtask queue to empty, then focuses
* the first tabbable element within the focus trap region.
*/
focusFirstTabbableElementWhenReady() {
this._ngZone.onMicrotaskEmpty.first().subscribe(() => {
this.focusFirstTabbableElement();
});
this._ngZone.onMicrotaskEmpty.first().subscribe(() => this.focusFirstTabbableElement());
}
/**
* Waits for microtask queue to empty, then focuses the last tabbable element within the focus
* trap region.
* Waits for microtask queue to empty, then focuses
* the last tabbable element within the focus trap region.
*/
focusLastTabbableElementWhenReady() {
this._ngZone.onMicrotaskEmpty.first().subscribe(() => {
this.focusLastTabbableElement();
});
this._ngZone.onMicrotaskEmpty.first().subscribe(() => this.focusLastTabbableElement());
}
/**
* Focuses the first tabbable element within the focus trap region.
*/
/** Focuses the first tabbable element within the focus trap region. */
focusFirstTabbableElement() {
let rootElement = this.trappedContent.nativeElement;
let redirectToElement = rootElement.querySelector('[cdk-focus-start]') as HTMLElement ||
this._getFirstTabbableElement(rootElement);
let redirectToElement = this._element.querySelector('[cdk-focus-start]') as HTMLElement ||
this._getFirstTabbableElement(this._element);
if (redirectToElement) {
redirectToElement.focus();
}
}
/**
* Focuses the last tabbable element within the focus trap region.
*/
/** Focuses the last tabbable element within the focus trap region. */
focusLastTabbableElement() {
let rootElement = this.trappedContent.nativeElement;
let focusTargets = rootElement.querySelectorAll('[cdk-focus-end]');
let focusTargets = this._element.querySelectorAll('[cdk-focus-end]');
let redirectToElement: HTMLElement = null;
if (focusTargets.length) {
redirectToElement = focusTargets[focusTargets.length - 1] as HTMLElement;
} else {
redirectToElement = this._getLastTabbableElement(rootElement);
redirectToElement = this._getLastTabbableElement(this._element);
}
if (redirectToElement) {
@@ -114,4 +158,81 @@ export class FocusTrap {
return null;
}
/** Creates an anchor element. */
private _createAnchor(): HTMLElement {
let anchor = document.createElement('div');
anchor.tabIndex = this._enabled ? 0 : -1;
anchor.classList.add('cdk-visually-hidden');
anchor.classList.add('cdk-focus-trap-anchor');
return anchor;
}
}
/** Factory that allows easy instantiation of focus traps. */
@Injectable()
export class FocusTrapFactory {
constructor(private _checker: InteractivityChecker, private _ngZone: NgZone) { }
create(element: HTMLElement, deferAnchors = false): FocusTrap {
return new FocusTrap(element, this._checker, this._ngZone, deferAnchors);
}
}
/**
* Directive for trapping focus within a region.
* @deprecated
*/
@Directive({
selector: 'cdk-focus-trap',
})
export class FocusTrapDeprecatedDirective implements OnDestroy, AfterContentInit {
focusTrap: FocusTrap;
/** Whether the focus trap is active. */
@Input()
get disabled(): boolean { return !this.focusTrap.enabled; }
set disabled(val: boolean) {
this.focusTrap.enabled = !coerceBooleanProperty(val);
}
constructor(private _elementRef: ElementRef, private _focusTrapFactory: FocusTrapFactory) {
this.focusTrap = this._focusTrapFactory.create(this._elementRef.nativeElement, true);
}
ngOnDestroy() {
this.focusTrap.destroy();
}
ngAfterContentInit() {
this.focusTrap.attachAnchors();
}
}
/** Directive for trapping focus within a region. */
@Directive({
selector: '[cdkTrapFocus]'
})
export class FocusTrapDirective implements OnDestroy, AfterContentInit {
focusTrap: FocusTrap;
/** Whether the focus trap is active. */
@Input('cdkTrapFocus')
get enabled(): boolean { return this.focusTrap.enabled; }
set enabled(val: boolean) { this.focusTrap.enabled = val; }
constructor(private _elementRef: ElementRef, private _focusTrapFactory: FocusTrapFactory) {
this.focusTrap = this._focusTrapFactory.create(this._elementRef.nativeElement, true);
}
ngOnDestroy() {
this.focusTrap.destroy();
}
ngAfterContentInit() {
this.focusTrap.attachAnchors();
}
}
+4 -4
View File
@@ -1,5 +1,5 @@
import {NgModule, ModuleWithProviders} from '@angular/core';
import {FocusTrap} from './focus-trap';
import {FocusTrapDirective, FocusTrapDeprecatedDirective, FocusTrapFactory} from './focus-trap';
import {LIVE_ANNOUNCER_PROVIDER} from './live-announcer';
import {InteractivityChecker} from './interactivity-checker';
import {CommonModule} from '@angular/common';
@@ -7,9 +7,9 @@ import {PlatformModule} from '../platform/index';
@NgModule({
imports: [CommonModule, PlatformModule],
declarations: [FocusTrap],
exports: [FocusTrap],
providers: [InteractivityChecker, LIVE_ANNOUNCER_PROVIDER]
declarations: [FocusTrapDirective, FocusTrapDeprecatedDirective],
exports: [FocusTrapDirective, FocusTrapDeprecatedDirective],
providers: [InteractivityChecker, FocusTrapFactory, LIVE_ANNOUNCER_PROVIDER]
})
export class A11yModule {
/** @deprecated */
+1 -1
View File
@@ -80,7 +80,7 @@ export * from './selection/selection';
/** @deprecated */
export {LiveAnnouncer as MdLiveAnnouncer} from './a11y/live-announcer';
export {FocusTrap} from './a11y/focus-trap';
export * from './a11y/focus-trap';
export {InteractivityChecker} from './a11y/interactivity-checker';
export {isFakeMousedownFromScreenReader} from './a11y/fake-mousedown';
+1 -3
View File
@@ -1,3 +1 @@
<cdk-focus-trap>
<template cdkPortalHost></template>
</cdk-focus-trap>
<template cdkPortalHost></template>
+17 -4
View File
@@ -5,6 +5,8 @@ import {
ViewEncapsulation,
NgZone,
OnDestroy,
Renderer,
ElementRef,
animate,
state,
style,
@@ -16,7 +18,7 @@ import {
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 {FocusTrapFactory, FocusTrap} from '../core/a11y/focus-trap';
import 'rxjs/add/operator/first';
@@ -54,8 +56,8 @@ 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;
/** The class that traps and manages focus within the dialog. */
private _focusTrap: FocusTrap;
/** Element that was focused before the dialog was opened. Save this to restore upon close. */
private _elementFocusedBeforeDialogWasOpened: HTMLElement = null;
@@ -69,7 +71,12 @@ export class MdDialogContainer extends BasePortalHost implements OnDestroy {
/** Emits the current animation state whenever it changes. */
_onAnimationStateChange = new EventEmitter<MdDialogContainerAnimationState>();
constructor(private _ngZone: NgZone) {
constructor(
private _ngZone: NgZone,
private _renderer: Renderer,
private _elementRef: ElementRef,
private _focusTrapFactory: FocusTrapFactory) {
super();
}
@@ -106,6 +113,10 @@ export class MdDialogContainer extends BasePortalHost implements OnDestroy {
* @private
*/
private _trapFocus() {
if (!this._focusTrap) {
this._focusTrap = this._focusTrapFactory.create(this._elementRef.nativeElement);
}
// 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.
@@ -148,5 +159,7 @@ export class MdDialogContainer extends BasePortalHost implements OnDestroy {
this._onAnimationStateChange.complete();
});
this._focusTrap.destroy();
}
}
+1 -3
View File
@@ -1,3 +1 @@
<cdk-focus-trap class="mat-sidenav-focus-trap" [disabled]="isFocusTrapDisabled">
<ng-content></ng-content>
</cdk-focus-trap>
<ng-content></ng-content>
+3 -13
View File
@@ -100,6 +100,9 @@
z-index: 3;
min-width: 5vw;
outline: 0;
box-sizing: border-box;
height: 100%;
overflow-y: auto; // TODO(kara): revisit scrolling behavior for sidenavs
@include mat-sidenav-transition(0, -100%);
@@ -133,19 +136,6 @@
}
}
.mat-sidenav-focus-trap {
height: 100%;
> .cdk-focus-trap-content {
box-sizing: border-box;
height: 100%;
overflow-y: auto; // TODO(kara): revisit scrolling behavior for sidenavs
// Prevents unnecessary repaints while scrolling.
transform: translateZ(0);
}
}
.mat-sidenav-invalid {
display: none;
}
+28 -11
View File
@@ -13,13 +13,13 @@ import {
EventEmitter,
Renderer,
ViewEncapsulation,
ViewChild,
NgZone
NgZone,
OnDestroy,
} from '@angular/core';
import {CommonModule} from '@angular/common';
import {Dir, MdError, coerceBooleanProperty, CompatibilityModule} from '../core';
import {A11yModule} from '../core/a11y/index';
import {FocusTrap} from '../core/a11y/focus-trap';
import {FocusTrapFactory, FocusTrap} from '../core/a11y/focus-trap';
import {ESCAPE} from '../core/keyboard/keycodes';
import {OverlayModule} from '../core/overlay/overlay-directives';
import 'rxjs/add/operator/first';
@@ -71,8 +71,8 @@ export class MdSidenavToggleResult {
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
})
export class MdSidenav implements AfterContentInit {
@ViewChild(FocusTrap) _focusTrap: FocusTrap;
export class MdSidenav implements AfterContentInit, OnDestroy {
private _focusTrap: FocusTrap;
/** Alignment of the sidenav (direction neutral); whether 'start' or 'end'. */
private _align: 'start' | 'end' = 'start';
@@ -138,20 +138,24 @@ export class MdSidenav implements AfterContentInit {
*/
private _resolveToggleAnimationPromise: (animationFinished: boolean) => void = null;
get isFocusTrapDisabled() {
get isFocusTrapEnabled() {
// The focus trap is only enabled when the sidenav is open in any mode other than side.
return !this.opened || this.mode == 'side';
return this.opened && this.mode !== 'side';
}
/**
* @param _elementRef The DOM element reference. Used for transition and width calculation.
* If not available we do not hook on transitions.
*/
constructor(private _elementRef: ElementRef, private _renderer: Renderer) {
constructor(
private _elementRef: ElementRef,
private _renderer: Renderer,
private _focusTrapFactory: FocusTrapFactory) {
this.onOpen.subscribe(() => {
this._elementFocusedBeforeSidenavWasOpened = document.activeElement as HTMLElement;
if (!this.isFocusTrapDisabled) {
if (this.isFocusTrapEnabled && this._focusTrap) {
this._focusTrap.focusFirstTabbableElementWhenReady();
}
});
@@ -168,14 +172,23 @@ export class MdSidenav implements AfterContentInit {
}
ngAfterContentInit() {
// This can happen when the sidenav is set to opened in the template and the transition
// isn't ended.
this._focusTrap = this._focusTrapFactory.create(this._elementRef.nativeElement);
this._focusTrap.enabled = this.isFocusTrapEnabled;
// This can happen when the sidenav is set to opened in
// the template and the transition hasn't ended.
if (this._toggleAnimationPromise) {
this._resolveToggleAnimationPromise(true);
this._toggleAnimationPromise = this._resolveToggleAnimationPromise = null;
}
}
ngOnDestroy() {
if (this._focusTrap) {
this._focusTrap.destroy();
}
}
/**
* Whether the sidenav is opened. We overload this because we trigger an event when it
* starts or end.
@@ -220,6 +233,10 @@ export class MdSidenav implements AfterContentInit {
this._opened = isOpen;
if (this._focusTrap) {
this._focusTrap.enabled = this.isFocusTrapEnabled;
}
if (isOpen) {
this.onOpenStart.emit();
} else {