Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(focus-trap): convert to directive #3184

Merged
merged 2 commits into from
Mar 2, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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',
Copy link
Contributor

@kara kara Mar 2, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it intentional to only deprecate one selector and not the other? e.g. <focus-trap> breaks immediately.

Copy link
Contributor

@mmalerba mmalerba Mar 2, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

<focus-trap> has been deprecated, so i think its fine to remove, but now <cdk-focus-trap> is deprectaed too, since we're moving to an attribute version

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, just wanted to double-check.

})
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