From 3c52b7beb8575e317e03c7173f5ccad40978de76 Mon Sep 17 00:00:00 2001 From: Shijir Tsogoo Date: Tue, 7 Aug 2018 09:50:26 -0700 Subject: [PATCH] Trap focus inside doc (#2495) * [NG] keep focus within the document Signed-off-by: stsogoo --- .../popover/common/_popover.clarity.scss | 14 +-- .../utils/_components.clarity.scss | 3 + src/clr-angular/utils/_mixins.clarity.scss | 16 ++++ .../utils/focus-trap/_focus-trap.clarity.scss | 10 ++ .../focus-trap/focus-trap-tracker.service.ts | 4 + .../focus-trap/focus-trap.directive.spec.ts | 93 +++++++++++++++---- .../utils/focus-trap/focus-trap.directive.ts | 71 +++++++++++--- 7 files changed, 164 insertions(+), 47 deletions(-) create mode 100644 src/clr-angular/utils/focus-trap/_focus-trap.clarity.scss diff --git a/src/clr-angular/popover/common/_popover.clarity.scss b/src/clr-angular/popover/common/_popover.clarity.scss index 356bce1c5b..c5663b1c10 100644 --- a/src/clr-angular/popover/common/_popover.clarity.scss +++ b/src/clr-angular/popover/common/_popover.clarity.scss @@ -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(); } } diff --git a/src/clr-angular/utils/_components.clarity.scss b/src/clr-angular/utils/_components.clarity.scss index 477a83c4b6..ba12c326c5 100644 --- a/src/clr-angular/utils/_components.clarity.scss +++ b/src/clr-angular/utils/_components.clarity.scss @@ -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 diff --git a/src/clr-angular/utils/_mixins.clarity.scss b/src/clr-angular/utils/_mixins.clarity.scss index 3ca1c82403..0c5b8d8286 100644 --- a/src/clr-angular/utils/_mixins.clarity.scss +++ b/src/clr-angular/utils/_mixins.clarity.scss @@ -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; +} diff --git a/src/clr-angular/utils/focus-trap/_focus-trap.clarity.scss b/src/clr-angular/utils/focus-trap/_focus-trap.clarity.scss new file mode 100644 index 0000000000..e28af5c9d5 --- /dev/null +++ b/src/clr-angular/utils/focus-trap/_focus-trap.clarity.scss @@ -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; + } +} diff --git a/src/clr-angular/utils/focus-trap/focus-trap-tracker.service.ts b/src/clr-angular/utils/focus-trap/focus-trap-tracker.service.ts index 35d937e18e..7fe3ad91a8 100644 --- a/src/clr-angular/utils/focus-trap/focus-trap-tracker.service.ts +++ b/src/clr-angular/utils/focus-trap/focus-trap-tracker.service.ts @@ -20,6 +20,10 @@ export class FocusTrapTracker { this._current = value; } + get nbFocusTrappers(): number { + return this._previousFocusTraps.length; + } + activatePreviousTrapper() { this._current = this._previousFocusTraps.pop(); } diff --git a/src/clr-angular/utils/focus-trap/focus-trap.directive.spec.ts b/src/clr-angular/utils/focus-trap/focus-trap.directive.spec.ts index d17e825bb9..7d7a40a93e 100644 --- a/src/clr-angular/utils/focus-trap/focus-trap.directive.spec.ts +++ b/src/clr-angular/utils/focus-trap/focus-trap.directive.spec.ts @@ -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'; @@ -12,7 +12,6 @@ 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'; @@ -20,21 +19,26 @@ describe('FocusTrap', () => { let fixture: ComponentFixture; 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(() => { @@ -42,20 +46,69 @@ describe('FocusTrap', () => { }); 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`, () => { @@ -147,9 +200,8 @@ describe('FocusTrap', () => { }); @Component({ - template: ` - Not in form -
+ template: ` + @@ -179,6 +231,7 @@ class TestComponent { level1 = false; level2 = false; level3 = false; + mainFocusTrap = true; } @Component({ diff --git a/src/clr-angular/utils/focus-trap/focus-trap.directive.ts b/src/clr-angular/utils/focus-trap/focus-trap.directive.ts index 2993542410..7f58d7f27d 100644 --- a/src/clr-angular/utils/focus-trap/focus-trap.directive.ts +++ b/src/clr-angular/utils/focus-trap/focus-trap.directive.ts @@ -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 = 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 = this.document.activeElement; } + + this.addReboundEls(); } ngOnDestroy() { + this.removeReboundEls(); this.setPreviousFocus(); this.focusTrapsTracker.activatePreviousTrapper(); }