Skip to content

Commit

Permalink
refactor(focus-trap): convert to directive
Browse files Browse the repository at this point in the history
Refactors the focus trap to be used as a directive, rather than a component. This gives us a couple of advantages:
* It can be used on the same node as other components.
* It removes a level of nesting in the DOM. This makes it slightly more convenient to style projected in cases like the dialog (see #2546), where flexbox needs to be applied to the closest possible ancestor.

Also includes the following improvements:
* No longer triggers change detection when focus hits the start/end anchors.
* Resets the anchor tab index when trapping is disabled, instead of removing elements from the DOM.
* Adds missing unit tests for the disabled and cleanup logic.
  • Loading branch information
crisbeto committed Feb 18, 2017
1 parent b939cd8 commit 5be96d4
Show file tree
Hide file tree
Showing 4 changed files with 95 additions and 44 deletions.
3 changes: 0 additions & 3 deletions src/lib/core/a11y/focus-trap.html

This file was deleted.

53 changes: 40 additions & 13 deletions src/lib/core/a11y/focus-trap.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {inject, ComponentFixture, TestBed, async} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {Component} from '@angular/core';
import {ComponentFixture, TestBed, async} from '@angular/core/testing';
import {Component, ViewChild} from '@angular/core';
import {FocusTrap} from './focus-trap';
import {InteractivityChecker} from './interactivity-checker';
import {Platform} from '../platform/platform';
Expand All @@ -21,16 +20,15 @@ describe('FocusTrap', () => {
});

TestBed.compileComponents();
}));

beforeEach(inject([InteractivityChecker], (c: InteractivityChecker) => {
fixture = TestBed.createComponent(FocusTrapTestApp);
focusTrapInstance = fixture.debugElement.query(By.directive(FocusTrap)).componentInstance;
fixture.detectChanges();
focusTrapInstance = fixture.componentInstance.focusTrap;
}));

it('wrap focus from end to start', () => {
// Because we can't mimic a real tab press focus change in a unit test, just call the
// focus event handler directly.
// focus event handlerdiv directly.
focusTrapInstance.focusFirstTabbableElement();

expect(document.activeElement.nodeName.toLowerCase())
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.isFocusTrapDisabled = true;
fixture.detectChanges();

expect(anchors.every(current => current.getAttribute('tabindex') === '-1')).toBe(true);
});
});

describe('with focus targets', () => {
Expand All @@ -61,11 +83,10 @@ describe('FocusTrap', () => {
});

TestBed.compileComponents();
}));

beforeEach(inject([InteractivityChecker], (c: InteractivityChecker) => {
fixture = TestBed.createComponent(FocusTrapTargetTestApp);
focusTrapInstance = fixture.debugElement.query(By.directive(FocusTrap)).componentInstance;
fixture.detectChanges();
focusTrapInstance = fixture.componentInstance.focusTrap;
}));

it('should be able to prioritize the first focus target', () => {
Expand All @@ -87,13 +108,17 @@ describe('FocusTrap', () => {

@Component({
template: `
<cdk-focus-trap>
<cdk-focus-trap *ngIf="renderFocusTrap" [disabled]="isFocusTrapDisabled">
<input>
<button>SAVE</button>
</cdk-focus-trap>
`
})
class FocusTrapTestApp { }
class FocusTrapTestApp {
@ViewChild(FocusTrap) focusTrap: FocusTrap;
renderFocusTrap = true;
isFocusTrapDisabled = false;
}


@Component({
Expand All @@ -106,4 +131,6 @@ class FocusTrapTestApp { }
</cdk-focus-trap>
`
})
class FocusTrapTargetTestApp { }
class FocusTrapTargetTestApp {
@ViewChild(FocusTrap) focusTrap: FocusTrap;
}
70 changes: 50 additions & 20 deletions src/lib/core/a11y/focus-trap.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {Component, ViewEncapsulation, ViewChild, ElementRef, Input, NgZone} from '@angular/core';
import {Directive, ElementRef, Input, NgZone, AfterViewInit, OnDestroy} from '@angular/core';
import {InteractivityChecker} from './interactivity-checker';
import {coerceBooleanProperty} from '../coercion/boolean-property';

Expand All @@ -11,48 +11,72 @@ import {coerceBooleanProperty} from '../coercion/boolean-property';
* 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,
@Directive({
selector: 'cdk-focus-trap, focus-trap, [cdk-focus-trap], [focus-trap]',
})
export class FocusTrap {
@ViewChild('trappedContent') trappedContent: ElementRef;
export class FocusTrap implements AfterViewInit, OnDestroy {
private _startAnchor: HTMLElement = this._createAnchor();
private _endAnchor: HTMLElement = this._createAnchor();

/** Whether the focus trap is active. */
@Input()
get disabled(): boolean { return this._disabled; }
set disabled(val: boolean) { this._disabled = coerceBooleanProperty(val); }
set disabled(val: boolean) {
this._disabled = coerceBooleanProperty(val);
this._startAnchor.tabIndex = this._endAnchor.tabIndex = this._disabled ? -1 : 0;
}
private _disabled: boolean = false;

constructor(private _checker: InteractivityChecker, private _ngZone: NgZone) { }
constructor(
private _checker: InteractivityChecker,
private _ngZone: NgZone,
private _elementRef: ElementRef) { }

ngAfterViewInit() {
this._ngZone.runOutsideAngular(() => {
this._elementRef.nativeElement
.insertAdjacentElement('beforebegin', this._startAnchor)
.addEventListener('focus', () => this.focusLastTabbableElement());

this._elementRef.nativeElement
.insertAdjacentElement('afterend', this._endAnchor)
.addEventListener('focus', () => this.focusFirstTabbableElement());
});
}

ngOnDestroy() {
if (this._startAnchor.parentNode) {
this._startAnchor.parentNode.removeChild(this._startAnchor);
}

if (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.
*/
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.
*/
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.
*/
focusFirstTabbableElement() {
let rootElement = this.trappedContent.nativeElement;
let rootElement = this._elementRef.nativeElement;
let redirectToElement = rootElement.querySelector('[cdk-focus-start]') as HTMLElement ||
this._getFirstTabbableElement(rootElement);

Expand All @@ -65,14 +89,13 @@ export class FocusTrap {
* 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._elementRef.nativeElement.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._elementRef.nativeElement);
}

if (redirectToElement) {
Expand Down Expand Up @@ -114,4 +137,11 @@ export class FocusTrap {

return null;
}

private _createAnchor(): HTMLElement {
let anchor = document.createElement('div');
anchor.tabIndex = 0;
anchor.classList.add('cdk-visually-hidden');
return anchor;
}
}
13 changes: 5 additions & 8 deletions src/lib/sidenav/sidenav.scss
Original file line number Diff line number Diff line change
Expand Up @@ -134,16 +134,13 @@
}

.mat-sidenav-focus-trap {
box-sizing: border-box;
height: 100%;
display: block;
overflow-y: auto; // TODO(kara): revisit scrolling behavior for sidenavs

> .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);
}
// Prevents unnecessary repaints while scrolling.
transform: translateZ(0);
}

.mat-sidenav-invalid {
Expand Down

0 comments on commit 5be96d4

Please sign in to comment.