Skip to content

Commit

Permalink
refactor(focus-trap): convert to directive (angular#3184)
Browse files Browse the repository at this point in the history
  • Loading branch information
crisbeto authored and kara committed Mar 2, 2017
1 parent f40b1b2 commit 4e4c6a6
Show file tree
Hide file tree
Showing 10 changed files with 259 additions and 98 deletions.
3 changes: 0 additions & 3 deletions src/lib/core/a11y/focus-trap.html

This file was deleted.

67 changes: 47 additions & 20 deletions src/lib/core/a11y/focus-trap.spec.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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', () => {
Expand All @@ -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', () => {
Expand All @@ -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', () => {
Expand All @@ -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;
}
193 changes: 157 additions & 36 deletions src/lib/core/a11y/focus-trap.ts
Original file line number Diff line number Diff line change
@@ -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`.
*/
focusFirstTabbableElementWhenReady() {
this._ngZone.onMicrotaskEmpty.first().subscribe(() => {
this.focusFirstTabbableElement();
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 last tabbable element within the focus
* trap region.
* Waits for microtask queue to empty, then focuses
* the first tabbable element within the focus trap region.
*/
focusLastTabbableElementWhenReady() {
this._ngZone.onMicrotaskEmpty.first().subscribe(() => {
this.focusLastTabbableElement();
});
focusFirstTabbableElementWhenReady() {
this._ngZone.onMicrotaskEmpty.first().subscribe(() => this.focusFirstTabbableElement());
}

/**
* Focuses the first 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());
}

/** 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) {
Expand Down Expand Up @@ -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();
}
}
8 changes: 4 additions & 4 deletions src/lib/core/a11y/index.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
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';
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 */
Expand Down
2 changes: 1 addition & 1 deletion src/lib/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
4 changes: 1 addition & 3 deletions src/lib/dialog/dialog-container.html
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
<cdk-focus-trap>
<template cdkPortalHost></template>
</cdk-focus-trap>
<template cdkPortalHost></template>
Loading

0 comments on commit 4e4c6a6

Please sign in to comment.