Skip to content

Commit 3e8b9d9

Browse files
jelbournkara
authored andcommitted
feat(a11y): add basic focus-trap directive (#1311)
* feat(a11y): add simple focus-trapping directive * fix too soon return * add unit tests * add todo
1 parent 54b2a03 commit 3e8b9d9

File tree

3 files changed

+139
-0
lines changed

3 files changed

+139
-0
lines changed

src/lib/core/a11y/focus-trap.spec.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import {inject, ComponentFixture, TestBed} from '@angular/core/testing';
2+
import {By} from '@angular/platform-browser';
3+
import {Component} from '@angular/core';
4+
import {FocusTrap} from './focus-trap';
5+
import {InteractivityChecker} from './interactivity-checker';
6+
7+
8+
describe('FocusTrap', () => {
9+
let checker: InteractivityChecker;
10+
let fixture: ComponentFixture<FocusTrapTestApp>;
11+
12+
describe('with default element', () => {
13+
beforeEach(() => TestBed.configureTestingModule({
14+
declarations: [FocusTrap, FocusTrapTestApp],
15+
providers: [InteractivityChecker]
16+
}));
17+
18+
beforeEach(inject([InteractivityChecker], (c: InteractivityChecker) => {
19+
checker = c;
20+
fixture = TestBed.createComponent(FocusTrapTestApp);
21+
}));
22+
23+
it('wrap focus from end to start', () => {
24+
let focusTrap = fixture.debugElement.query(By.directive(FocusTrap));
25+
let focusTrapInstance = focusTrap.componentInstance as FocusTrap;
26+
27+
// Because we can't mimic a real tab press focus change in a unit test, just call the
28+
// focus event handler directly.
29+
focusTrapInstance.wrapFocus();
30+
31+
expect(document.activeElement.nodeName.toLowerCase())
32+
.toBe('input', 'Expected input element to be focused');
33+
});
34+
35+
it('should wrap focus from start to end', () => {
36+
let focusTrap = fixture.debugElement.query(By.directive(FocusTrap));
37+
let focusTrapInstance = focusTrap.componentInstance as FocusTrap;
38+
39+
// Because we can't mimic a real tab press focus change in a unit test, just call the
40+
// focus event handler directly.
41+
focusTrapInstance.reverseWrapFocus();
42+
43+
expect(document.activeElement.nodeName.toLowerCase())
44+
.toBe('button', 'Expected button element to be focused');
45+
});
46+
});
47+
});
48+
49+
50+
@Component({
51+
template: `
52+
<focus-trap>
53+
<input>
54+
<button>SAVE</button>
55+
</focus-trap>
56+
`
57+
})
58+
class FocusTrapTestApp { }

src/lib/core/a11y/focus-trap.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import {Component, ViewEncapsulation, ViewChild, ElementRef} from '@angular/core';
2+
import {InteractivityChecker} from './interactivity-checker';
3+
4+
5+
/**
6+
* Directive for trapping focus within a region.
7+
*
8+
* NOTE: This directive currently uses a very simple (naive) approach to focus trapping.
9+
* It assumes that the tab order is the same as DOM order, which is not necessarily true.
10+
* Things like tabIndex > 0, flex `order`, and shadow roots can cause to two to misalign.
11+
* This will be replaced with a more intelligent solution before the library is considered stable.
12+
*/
13+
@Component({
14+
moduleId: module.id,
15+
selector: 'focus-trap',
16+
// TODO(jelbourn): move this to a separate file.
17+
template: `
18+
<div tabindex="0" (focus)="reverseWrapFocus()"></div>
19+
<div #trappedContent><ng-content></ng-content></div>
20+
<div tabindex="0" (focus)="wrapFocus()"></div>`,
21+
encapsulation: ViewEncapsulation.None,
22+
})
23+
export class FocusTrap {
24+
@ViewChild('trappedContent') trappedContent: ElementRef;
25+
26+
constructor(private _checker: InteractivityChecker) { }
27+
28+
/** Wrap focus from the end of the trapped region to the beginning. */
29+
wrapFocus() {
30+
let redirectToElement = this._getFirstTabbableElement(this.trappedContent.nativeElement);
31+
if (redirectToElement) {
32+
redirectToElement.focus();
33+
}
34+
}
35+
36+
/** Wrap focus from the beginning of the trapped region to the end. */
37+
reverseWrapFocus() {
38+
let redirectToElement = this._getLastTabbableElement(this.trappedContent.nativeElement);
39+
if (redirectToElement) {
40+
redirectToElement.focus();
41+
}
42+
}
43+
44+
/** Get the first tabbable element from a DOM subtree (inclusive). */
45+
private _getFirstTabbableElement(root: HTMLElement): HTMLElement {
46+
if (this._checker.isFocusable(root) && this._checker.isTabbable(root)) {
47+
return root;
48+
}
49+
50+
// Iterate in DOM order.
51+
let childCount = root.children.length;
52+
for (let i = 0; i < childCount; i++) {
53+
let tabbableChild = this._getFirstTabbableElement(root.children[i] as HTMLElement);
54+
if (tabbableChild) {
55+
return tabbableChild;
56+
}
57+
}
58+
59+
return null;
60+
}
61+
62+
/** Get the last tabbable element from a DOM subtree (inclusive). */
63+
private _getLastTabbableElement(root: HTMLElement): HTMLElement {
64+
if (this._checker.isFocusable(root) && this._checker.isTabbable(root)) {
65+
return root;
66+
}
67+
68+
// Iterate in reverse DOM order.
69+
for (let i = root.children.length - 1; i >= 0; i--) {
70+
let tabbableChild = this._getLastTabbableElement(root.children[i] as HTMLElement);
71+
if (tabbableChild) {
72+
return tabbableChild;
73+
}
74+
}
75+
76+
return null;
77+
}
78+
}

src/lib/core/core.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ export {
5050
LIVE_ANNOUNCER_ELEMENT_TOKEN,
5151
} from './a11y/live-announcer';
5252

53+
export {FocusTrap} from './a11y/focus-trap';
54+
export {InteractivityChecker} from './a11y/interactivity-checker';
55+
5356
export {
5457
MdUniqueSelectionDispatcher,
5558
MdUniqueSelectionDispatcherListener

0 commit comments

Comments
 (0)