Skip to content
This repository has been archived by the owner on Mar 27, 2023. It is now read-only.

Commit

Permalink
Trap focus inside doc (#2495)
Browse files Browse the repository at this point in the history
* [NG] keep focus within the document

Signed-off-by: stsogoo <[email protected]>
  • Loading branch information
Shijir committed Aug 8, 2018
1 parent b44af12 commit 3c52b7b
Show file tree
Hide file tree
Showing 7 changed files with 164 additions and 47 deletions.
14 changes: 1 addition & 13 deletions src/clr-angular/popover/common/_popover.clarity.scss
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,6 @@

@include exports('popover.clarity') {
.is-off-screen {
position: fixed !important;
border: none !important;
height: 1px !important;
width: 1px !important;

left: 0px !important;
top: -1px !important;

overflow: hidden !important;
visibility: hidden !important;

padding: 0 !important;
margin: 0 0 -1px 0 !important;
@include off-screen-styles();
}
}
3 changes: 3 additions & 0 deletions src/clr-angular/utils/_components.clarity.scss
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@
// no dependencies on other clarity scss
@import '../utils/animations/animations.clarity';

//Focus Trap
@import '../utils/focus-trap/focus-trap.clarity';

//Tabs
@import '../layout/tabs/tabs.clarity'; // no dependencies on other clarity scss

Expand Down
16 changes: 16 additions & 0 deletions src/clr-angular/utils/_mixins.clarity.scss
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,19 @@ $clr-outline-spread: 2px;
overflow: hidden;
text-overflow: ellipsis;
}

@mixin off-screen-styles() {
position: fixed !important;
border: none !important;
height: 1px !important;
width: 1px !important;

left: 0px !important;
top: -1px !important;

overflow: hidden !important;
visibility: hidden !important;

padding: 0 !important;
margin: 0 0 -1px 0 !important;
}
10 changes: 10 additions & 0 deletions src/clr-angular/utils/focus-trap/_focus-trap.clarity.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Copyright (c) 2016-2018 VMware, Inc. All Rights Reserved.
// This software is released under MIT license.
// The full license information can be found in LICENSE in the root directory of this project.

@include exports('focus-trap.clarity') {
.offscreen-focus-rebounder {
@include off-screen-styles();
visibility: visible !important;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ export class FocusTrapTracker {
this._current = value;
}

get nbFocusTrappers(): number {
return this._previousFocusTraps.length;
}

activatePreviousTrapper() {
this._current = this._previousFocusTraps.pop();
}
Expand Down
93 changes: 73 additions & 20 deletions src/clr-angular/utils/focus-trap/focus-trap.directive.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* This software is released under MIT license.
* The full license information can be found in LICENSE in the root directory of this project.
*/
import { Component, ViewChild } from '@angular/core';
import { Component, DebugElement, ViewChild } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import { By } from '@angular/platform-browser';
Expand All @@ -12,50 +12,103 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { itIgnore } from '../../../../tests/tests.helpers';
import { ClrModal } from '../../modal/modal';
import { ClrModalModule } from '../../modal/modal.module';

import { FocusTrapDirective } from './focus-trap.directive';
import { ClrFocusTrapModule } from './focus-trap.module';

describe('FocusTrap', () => {
let fixture: ComponentFixture<any>;
let compiled: any;
let component: TestComponent;
let directive: FocusTrapDirective;

let lastInput: HTMLElement;
const tabEvent = { shiftKey: false, keyCode: 9, preventDefault: () => {} };

let directiveDebugElement: DebugElement;
let directiveInstance: FocusTrapDirective;
let directiveElement: HTMLElement;

describe('default behavior', () => {
beforeEach(() => {
TestBed.configureTestingModule({ imports: [ClrFocusTrapModule], declarations: [TestComponent] });

fixture = TestBed.createComponent(TestComponent);
component = fixture.componentInstance;
fixture.detectChanges();
compiled = fixture.nativeElement;
directive = fixture.debugElement.query(By.directive(FocusTrapDirective)).injector.get(FocusTrapDirective);

component = fixture.componentInstance;
compiled = fixture.nativeElement;
lastInput = compiled.querySelector('#last');

directiveDebugElement = fixture.debugElement.query(By.directive(FocusTrapDirective));
directiveElement = directiveDebugElement.nativeElement;
directiveInstance = directiveDebugElement.injector.get(FocusTrapDirective);
});

afterEach(() => {
fixture.destroy();
});

it('should create directive', () => {
expect(directive).toBeTruthy();
expect(directiveInstance).toBeTruthy();
});

it('should add tabindex attribute with value zero', () => {
directive.ngAfterViewInit();
const element: HTMLElement = directive.elementRef.nativeElement;
expect(element.getAttribute('tabindex')).toEqual('0');
expect(directiveElement.getAttribute('tabindex')).toEqual('0');
});

it(`should focus on trappable element when tab key is pressed and last input is active`, () => {
const element = directive.elementRef.nativeElement;
lastInput.focus();
directive.onFocusIn(tabEvent);
expect(document.activeElement).toEqual(element);
it('should add its off-screen focus rebounder elements to document body on instantiation', () => {
const offScreenEls = document.body.querySelectorAll('span.offscreen-focus-rebounder');
expect(offScreenEls.length).toBe(2);
});

it('should add its off-screen focus elements as first an last elements in document body', () => {
const offScreenEls = document.body.querySelectorAll('span.offscreen-focus-rebounder');
expect(document.body.firstChild).toBe(offScreenEls[0]);
expect(document.body.lastChild).toBe(offScreenEls[1]);
});

it('should rebound focus back to the directive if one of rebounding elements gets focused', () => {
const offScreenEls = document.body.querySelectorAll('span.offscreen-focus-rebounder');

const beforeRebound = offScreenEls[0] as HTMLElement;
const afterRebound = offScreenEls[1] as HTMLElement;

beforeRebound.focus();
expect(document.activeElement).toBe(directiveElement);
afterRebound.focus();
expect(document.activeElement).toBe(directiveElement);
});

it('should remove its off-screen focus rebounder elements from parent element on removal', () => {
expect(document.body.querySelectorAll('span.offscreen-focus-rebounder').length).toBe(2);
component.mainFocusTrap = false;
fixture.detectChanges();
expect(document.body.querySelectorAll('span.offscreen-focus-rebounder').length).toBe(0);
});

it(`should add off-screen rebounder elements only once`, () => {
component.level1 = true;
fixture.detectChanges();
let offScreenEls = document.body.querySelectorAll('span.offscreen-focus-rebounder');
expect(offScreenEls.length).toBe(2);
component.level2 = true;
fixture.detectChanges();
offScreenEls = document.body.querySelectorAll('span.offscreen-focus-rebounder');
expect(offScreenEls.length).toBe(2);
});

it(`should remove off-screen rebounder elements only once`, () => {
component.level1 = true;
component.level2 = true;
fixture.detectChanges();
let offScreenEls = document.body.querySelectorAll('span.offscreen-focus-rebounder');
expect(offScreenEls.length).toBe(2);
component.level2 = false;
component.level1 = false;
fixture.detectChanges();
offScreenEls = document.body.querySelectorAll('span.offscreen-focus-rebounder');
expect(offScreenEls.length).toBe(2);
component.mainFocusTrap = false;
fixture.detectChanges();
offScreenEls = document.body.querySelectorAll('span.offscreen-focus-rebounder');
expect(offScreenEls.length).toBe(0);
});

itIgnore(['firefox'], `should keep focus within nested element with focus trap directive`, () => {
Expand Down Expand Up @@ -147,9 +200,8 @@ describe('FocusTrap', () => {
});

@Component({
template: `
<a href="#">Not in form</a>
<form clrFocusTrap>
template: `
<form clrFocusTrap *ngIf="mainFocusTrap">
<button id="first">
Button to test first input
</button>
Expand Down Expand Up @@ -179,6 +231,7 @@ class TestComponent {
level1 = false;
level2 = false;
level3 = false;
mainFocusTrap = true;
}

@Component({
Expand Down
71 changes: 57 additions & 14 deletions src/clr-angular/utils/focus-trap/focus-trap.directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,50 +13,93 @@ import {
Injector,
OnDestroy,
PLATFORM_ID,
Renderer2,
} from '@angular/core';

import { FocusTrapTracker } from './focus-trap-tracker.service';

@Directive({ selector: '[clrFocusTrap]' })
export class FocusTrapDirective implements AfterViewInit, OnDestroy {
private _previousActiveElement: HTMLElement;
/* tslint:disable-next-line:no-unused-variable */
private previousActiveElement: any;
private document: Document;

private topReboundEl: any;
private bottomReboundEl: any;

constructor(
public elementRef: ElementRef,
injector: Injector,
private el: ElementRef,
private injector: Injector,
private focusTrapsTracker: FocusTrapTracker,
private renderer: Renderer2,
@Inject(PLATFORM_ID) private platformId: Object
) {
this.document = injector.get(DOCUMENT);
this.document = this.injector.get(DOCUMENT);
this.focusTrapsTracker.current = this;

this.renderer.setAttribute(this.el.nativeElement, 'tabindex', '0');
}

@HostListener('document:focusin', ['$event'])
onFocusIn(event: any) {
const nativeElement: HTMLElement = this.elementRef.nativeElement;
const nativeElement: HTMLElement = this.el.nativeElement;

if (this.focusTrapsTracker.current === this && !nativeElement.contains(event.target)) {
if (this.focusTrapsTracker.current === this && event.target && !nativeElement.contains(event.target)) {
nativeElement.focus();
}
}

ngAfterViewInit() {
if (isPlatformBrowser(this.platformId)) {
this._previousActiveElement = <HTMLElement>document.activeElement;
const nativeElement: HTMLElement = this.elementRef.nativeElement;
nativeElement.setAttribute('tabindex', '0');
private createFocusableOffScreenEl(): any {
const offScreenSpan = this.renderer.createElement('span');
this.renderer.setAttribute(offScreenSpan, 'tabindex', '0');
this.renderer.addClass(offScreenSpan, 'offscreen-focus-rebounder');

return offScreenSpan;
}

private addReboundEls() {
// We will add these focus rebounding elements only in the following conditions:
// 1. It should be running inside browser platform as it accesses document.body element
// 2. We should NOT add them more than once. Hence, we are counting a number of focus trappers
// and only add on the first focus trapper.

if (isPlatformBrowser(this.platformId) && this.focusTrapsTracker.nbFocusTrappers === 1) {
this.topReboundEl = this.createFocusableOffScreenEl();
this.bottomReboundEl = this.createFocusableOffScreenEl();
// Add reboundBeforeTrapEl to the document body as the first child
this.renderer.insertBefore(this.document.body, this.topReboundEl, this.document.body.firstChild);
// Add reboundAfterTrapEl to the document body as the last child
this.renderer.appendChild(this.document.body, this.bottomReboundEl);
}
}

private removeReboundEls() {
if (
isPlatformBrowser(this.platformId) &&
this.focusTrapsTracker.nbFocusTrappers === 1 &&
this.topReboundEl &&
this.bottomReboundEl
) {
this.renderer.removeChild(this.document.body, this.topReboundEl);
this.renderer.removeChild(this.document.body, this.bottomReboundEl);
}
}

public setPreviousFocus(): void {
if (this._previousActiveElement && this._previousActiveElement.focus) {
this._previousActiveElement.focus();
if (this.previousActiveElement && this.previousActiveElement.focus) {
this.previousActiveElement.focus();
}
}

ngAfterViewInit() {
if (isPlatformBrowser(this.platformId)) {
this.previousActiveElement = <HTMLElement>this.document.activeElement;
}

this.addReboundEls();
}

ngOnDestroy() {
this.removeReboundEls();
this.setPreviousFocus();
this.focusTrapsTracker.activatePreviousTrapper();
}
Expand Down

0 comments on commit 3c52b7b

Please sign in to comment.