Skip to content

Commit

Permalink
feat(a11y): add basic focus-trap directive (#1311)
Browse files Browse the repository at this point in the history
* feat(a11y): add simple focus-trapping directive

* fix too soon return

* add unit tests

* add todo
  • Loading branch information
jelbourn authored and kara committed Sep 23, 2016
1 parent 54b2a03 commit 3e8b9d9
Show file tree
Hide file tree
Showing 3 changed files with 139 additions and 0 deletions.
58 changes: 58 additions & 0 deletions src/lib/core/a11y/focus-trap.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import {inject, ComponentFixture, TestBed} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {Component} from '@angular/core';
import {FocusTrap} from './focus-trap';
import {InteractivityChecker} from './interactivity-checker';


describe('FocusTrap', () => {
let checker: InteractivityChecker;
let fixture: ComponentFixture<FocusTrapTestApp>;

describe('with default element', () => {
beforeEach(() => TestBed.configureTestingModule({
declarations: [FocusTrap, FocusTrapTestApp],
providers: [InteractivityChecker]
}));

beforeEach(inject([InteractivityChecker], (c: InteractivityChecker) => {
checker = c;
fixture = TestBed.createComponent(FocusTrapTestApp);
}));

it('wrap focus from end to start', () => {
let focusTrap = fixture.debugElement.query(By.directive(FocusTrap));
let focusTrapInstance = focusTrap.componentInstance as 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();

expect(document.activeElement.nodeName.toLowerCase())
.toBe('input', 'Expected input element to be focused');
});

it('should wrap focus from start to end', () => {
let focusTrap = fixture.debugElement.query(By.directive(FocusTrap));
let focusTrapInstance = focusTrap.componentInstance as 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();

expect(document.activeElement.nodeName.toLowerCase())
.toBe('button', 'Expected button element to be focused');
});
});
});


@Component({
template: `
<focus-trap>
<input>
<button>SAVE</button>
</focus-trap>
`
})
class FocusTrapTestApp { }
78 changes: 78 additions & 0 deletions src/lib/core/a11y/focus-trap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import {Component, ViewEncapsulation, ViewChild, ElementRef} from '@angular/core';
import {InteractivityChecker} from './interactivity-checker';


/**
* Directive for trapping focus within a region.
*
* NOTE: This directive 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: 'focus-trap',
// TODO(jelbourn): move this to a separate file.
template: `
<div tabindex="0" (focus)="reverseWrapFocus()"></div>
<div #trappedContent><ng-content></ng-content></div>
<div tabindex="0" (focus)="wrapFocus()"></div>`,
encapsulation: ViewEncapsulation.None,
})
export class FocusTrap {
@ViewChild('trappedContent') trappedContent: ElementRef;

constructor(private _checker: InteractivityChecker) { }

/** Wrap focus from the end of the trapped region to the beginning. */
wrapFocus() {
let redirectToElement = this._getFirstTabbableElement(this.trappedContent.nativeElement);
if (redirectToElement) {
redirectToElement.focus();
}
}

/** Wrap focus from the beginning of the trapped region to the end. */
reverseWrapFocus() {
let redirectToElement = this._getLastTabbableElement(this.trappedContent.nativeElement);
if (redirectToElement) {
redirectToElement.focus();
}
}

/** Get the first tabbable element from a DOM subtree (inclusive). */
private _getFirstTabbableElement(root: HTMLElement): HTMLElement {
if (this._checker.isFocusable(root) && this._checker.isTabbable(root)) {
return root;
}

// Iterate in DOM order.
let childCount = root.children.length;
for (let i = 0; i < childCount; i++) {
let tabbableChild = this._getFirstTabbableElement(root.children[i] as HTMLElement);
if (tabbableChild) {
return tabbableChild;
}
}

return null;
}

/** Get the last tabbable element from a DOM subtree (inclusive). */
private _getLastTabbableElement(root: HTMLElement): HTMLElement {
if (this._checker.isFocusable(root) && this._checker.isTabbable(root)) {
return root;
}

// Iterate in reverse DOM order.
for (let i = root.children.length - 1; i >= 0; i--) {
let tabbableChild = this._getLastTabbableElement(root.children[i] as HTMLElement);
if (tabbableChild) {
return tabbableChild;
}
}

return null;
}
}
3 changes: 3 additions & 0 deletions src/lib/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ export {
LIVE_ANNOUNCER_ELEMENT_TOKEN,
} from './a11y/live-announcer';

export {FocusTrap} from './a11y/focus-trap';
export {InteractivityChecker} from './a11y/interactivity-checker';

export {
MdUniqueSelectionDispatcher,
MdUniqueSelectionDispatcherListener
Expand Down

0 comments on commit 3e8b9d9

Please sign in to comment.