diff --git a/src/lib/core/a11y/focus-trap.spec.ts b/src/lib/core/a11y/focus-trap.spec.ts new file mode 100644 index 000000000000..5cce6b61ebcc --- /dev/null +++ b/src/lib/core/a11y/focus-trap.spec.ts @@ -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; + + 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: ` + + + + + ` +}) +class FocusTrapTestApp { } diff --git a/src/lib/core/a11y/focus-trap.ts b/src/lib/core/a11y/focus-trap.ts new file mode 100644 index 000000000000..2196e5497e13 --- /dev/null +++ b/src/lib/core/a11y/focus-trap.ts @@ -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: ` +
+
+
`, + 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; + } +} diff --git a/src/lib/core/core.ts b/src/lib/core/core.ts index ef66a02b0eb6..c7362de69466 100644 --- a/src/lib/core/core.ts +++ b/src/lib/core/core.ts @@ -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