Skip to content

Commit

Permalink
feat: focustrap standalone service
Browse files Browse the repository at this point in the history
Closes #2322
  • Loading branch information
Benoit Charbonnier authored and maxokorokov committed Apr 19, 2018
1 parent 46a4b3b commit 6961cb3
Show file tree
Hide file tree
Showing 2 changed files with 257 additions and 0 deletions.
123 changes: 123 additions & 0 deletions src/util/focus-trap.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import {AfterViewInit, Component, ElementRef, Inject, InjectionToken, OnDestroy, ViewChild} from '@angular/core';
import {ComponentFixture, inject, TestBed} from '@angular/core/testing';

import {NgbFocusTrap, NgbFocusTrapFactory} from './focus-trap';

const getElement = (element: HTMLElement, selector: string): HTMLElement => {
return element.querySelector(selector) as HTMLElement;
};

const Autofocus = new InjectionToken('autofocus');

describe('ngbFocusTrap', () => {
beforeEach(() => { TestBed.configureTestingModule({providers: [NgbFocusTrapFactory]}); });

it('should be instantiated manually', inject([NgbFocusTrapFactory], (focusTrapFactory: NgbFocusTrapFactory) => {
const element = document.createElement('div');
const focusTrap = focusTrapFactory.create(element);
expect(focusTrap).toBeDefined();
focusTrap.destroy();
}));

describe('navigation', () => {
let fixture: ComponentFixture<TestComponent>;
let instance;

beforeEach(() => {
TestBed.configureTestingModule({
declarations: [TestComponent, FocusTrapComponent],
providers: [NgbFocusTrapFactory, {provide: Autofocus, useValue: false}]
});
fixture = TestBed.createComponent(TestComponent);
instance = fixture.componentInstance;
fixture.detectChanges();
});

it('should intercept any outside focus when already instantiated', () => {
const initial = document.querySelector('#initial') as HTMLElement;
const link = getElement(fixture.nativeElement, 'a');
initial.focus();
expect(document.activeElement).toBe(link);
});

it('should create/remove the end of document anchor accordingly', () => {
expect(document.body.querySelector('.ngb-focustrap-eod')).toBeDefined();
instance.show = false;
fixture.detectChanges();
expect(document.body.querySelector('.ngb-focustrap-eod')).toBeNull();
});
});

it('should save/restore focused element when autofocus is set', () => {
TestBed.configureTestingModule({
declarations: [TestComponent, FocusTrapComponent],
providers: [NgbFocusTrapFactory, {provide: Autofocus, useValue: true}]
});
const fixture = TestBed.createComponent(TestComponent);
const instance = fixture.componentInstance;
instance.show = false;
fixture.detectChanges();

// put focus somewhere
const initial = document.querySelector('#initial') as HTMLElement;
initial.focus();

// let's create the focustrap with autofocus (via <focus-trapped>)
instance.show = true;
fixture.detectChanges();
const button = getElement(fixture.nativeElement, 'button');
expect(document.activeElement).toBe(button);

// let's destroy the focustrap (removing <focus-trapped>)
instance.show = false;
fixture.detectChanges();
expect(document.activeElement).toBe(initial);
});
});

@Component({
selector: 'focus-trapped',
template: `
<div>
<a href="http://whatever.com">link</a>
<span>not important</span>
<button ngbAutofocus>button</button>
<span>not important</span>
<select>
<option>not important</option>
<option>not important</option>
<option>not important</option>
</select>
<span>not important</span>
</div>
`,
})
class FocusTrapComponent implements AfterViewInit,
OnDestroy {
focusTrap: NgbFocusTrap | null = null;
constructor(
private _focusTrapFactory: NgbFocusTrapFactory, private _element: ElementRef,
@Inject(Autofocus) private _autofocus) {}

ngAfterViewInit() { this.focusTrap = this._focusTrapFactory.create(this._element.nativeElement, this._autofocus); }

ngOnDestroy() {
if (this.focusTrap) {
this.focusTrap.destroy();
this.focusTrap = null;
}
}
}

@Component({
template: `
<div>
<input type="text" id="initial" />
<focus-trapped *ngIf="show"></focus-trapped>
</div>
`
})
class TestComponent {
show = true;
@ViewChild(FocusTrapComponent) wrapper;
}
134 changes: 134 additions & 0 deletions src/util/focus-trap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import {DOCUMENT} from '@angular/common';
import {Inject, Injectable, NgZone} from '@angular/core';

const FOCUSABLE_ELEMENTS_SELECTOR = [
'a[href]', 'button:not([disabled])', 'input:not([disabled]):not([type="hidden"])', 'select:not([disabled])',
'textarea:not([disabled])', '[contenteditable]', '[tabindex]:not([tabindex="-1"])'
].join(', ');

enum DIRECTION {
BACKWARD,
FORWARD
}

/**
* Class that enforce the browser focus to be trapped inside a DOM element.
*
* The implementation is rather simple, the class add a `focusin` listener on the document with capture phase.
* Any focus event will then be caught, and therefore the class will only allow the one for elements contained inside
* it's own element.
*
* In case the element is not contained, the class will determine which new element has to be focused based on the `tab`
* navigation direction.
*
* Should not be used directly. Use only via {@link NgbFocusTrapFactory}
*/
export class NgbFocusTrap {
private _removeDocumentListener;
private _direction: DIRECTION = DIRECTION.FORWARD;
private _previouslyFocused: HTMLElement | null = null;
private _endOfDocument: HTMLElement | null = null;

/**
* Guess the next focusable element.
* Computation is based on specific CSS selector and [tab] navigation direction
*/
private get focusableElement(): HTMLElement {
const list: NodeListOf<HTMLElement> = this._element.querySelectorAll(FOCUSABLE_ELEMENTS_SELECTOR);
return this._direction === DIRECTION.BACKWARD ? list[list.length - 1] : list[0];
}

/**
* @param _element The element around which focus will be trapped inside
* @param autofocus Initially put the focus on specific element with a `ngbFocustrap` attribute. Will also remenber
* and restore any previously focused element on destroy.
* @param _document Document on which `focusin` and `keydown.TAB` events are listened
* @param _ngZone The zone Angular is running in
*/
constructor(private _element: HTMLElement, autofocus: boolean, private _document: Document, private _ngZone: NgZone) {
this._enforceFocus = this._enforceFocus.bind(this);
this._detectDirection = this._detectDirection.bind(this);

const eod = this._endOfDocument = this._document.createElement('i');
eod.className = 'ngb-focustrap-eod';
eod.tabIndex = 0;
this._document.body.appendChild(eod);

this._ngZone.runOutsideAngular(() => {
this._document.addEventListener('focusin', this._enforceFocus, true);
this._document.addEventListener('keydown', this._detectDirection);

this._removeDocumentListener = () => {
this._document.removeEventListener('focusin', this._enforceFocus, true);
this._document.removeEventListener('keydown', this._detectDirection);
}
});

if (autofocus === true) {
this._previouslyFocused = document.activeElement as HTMLElement;
this._focusInitial();
}
}

/** Detect if incoming focus event should be prevented or not */
private _enforceFocus(event) {
const {target} = event;
if (this._document !== target && this._element !== target && !this._element.contains(target)) {
this._ngZone.run(() => {
const element = this.focusableElement;
if (element) {
element.focus();
event.stopPropagation();
}
});
}
}

/** Event handler detecting current `tab` navigation direction */
private _detectDirection(event) {
const {shiftKey, key} = event;
if (key === 'Tab') {
this._direction = shiftKey ? DIRECTION.BACKWARD : DIRECTION.FORWARD;
}
}

/** Try to set focus on the first found element that has an ngbAutofocus attribute */
private _focusInitial() {
const element = this._element.querySelector('[ngbAutofocus]') as HTMLElement;
if (element) {
element.focus();
}
}

/**
* Destroys the focustrap by removing all event listeners set on document.
*
* Eventually put the focus back on the previously focused element at the time
* focustrap has been initialized.
*/
destroy() {
this._removeDocumentListener();
this._document.body.removeChild(this._endOfDocument);
if (this._previouslyFocused) {
this._previouslyFocused.focus();
}
}
}

/**
* Factory service to easily create a `NgbFocusTrap` instance on an element
*/
@Injectable()
export class NgbFocusTrapFactory {
constructor(@Inject(DOCUMENT) private _document: Document, private _ngZone: NgZone) {}

/**
* Create an instance of {@link NgbFocusTrap} and return it
* @param element HTMLElement to trap focus inside
* @param autofocus Whether the focustrap should automatically move focus into the trapped element upon
* initialization and return focus to the previous activeElement upon destruction.
*/
create(element: HTMLElement, autofocus = false): NgbFocusTrap {
return new NgbFocusTrap(element, autofocus, this._document, this._ngZone);
}
}

0 comments on commit 6961cb3

Please sign in to comment.