-
Notifications
You must be signed in to change notification settings - Fork 1.5k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Showing
2 changed files
with
257 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |