Skip to content
This repository has been archived by the owner on Dec 8, 2022. It is now read-only.

Fix wait not blocking focus nav #4

Merged
merged 29 commits into from
Dec 13, 2018
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
44c02c9
moved over changes from skyux2 PR
blackbaud-conorwright Sep 25, 2018
80f6e5c
fixed a11y issue with test fixture for wait
blackbaud-conorwright Sep 25, 2018
97d27be
Merge branch 'master' into fix-wait-not-blocking-focus-nav
blackbaud-conorwright Sep 26, 2018
2022723
Merge branch 'master' into fix-wait-not-blocking-focus-nav
blackbaud-conorwright Sep 27, 2018
8acdfa3
Merge remote-tracking branch 'origin/master' into fix-wait-not-blocki…
blackbaud-conorwright Oct 15, 2018
ef2ca41
fixed while condition and addressed pr comments
blackbaud-conorwright Oct 17, 2018
9754cdc
Merge branch 'master' into fix-wait-not-blocking-focus-nav
blackbaud-conorwright Oct 18, 2018
ac1d214
fixed tab nav temporarily focusing inside waited element
blackbaud-conorwright Oct 24, 2018
37057fa
fixed switching wait type while waiting.Added focus nav wrap
blackbaud-conorwright Oct 24, 2018
61437ef
updated unit tests for new tab propagation strat
blackbaud-conorwright Oct 24, 2018
1d0d0c3
fixed tslint issues
blackbaud-conorwright Oct 25, 2018
36e971f
trying out a condition
blackbaud-conorwright Oct 25, 2018
9eebda1
fixed missing coverage
blackbaud-conorwright Oct 25, 2018
219e256
addressed constructor misses
blackbaud-conorwright Oct 30, 2018
9b042e9
cleaned up the visual demo a little for wait
blackbaud-conorwright Oct 30, 2018
5cd9fbc
fixed the issue Alex found with shift-tab nav
blackbaud-conorwright Oct 30, 2018
96f85ac
reimplemented to only maintain 1 listener
blackbaud-conorwright Nov 1, 2018
dd142f4
fixed the focus nav not ignoring hidden elements
blackbaud-conorwright Nov 3, 2018
03902f0
Merge branch 'master' into fix-wait-not-blocking-focus-nav
blackbaud-conorwright Nov 13, 2018
853bd53
Merge branch 'master' into fix-wait-not-blocking-focus-nav
Blackbaud-SteveBrush Nov 19, 2018
b588b0f
implemented focusin strategy (#21)
blackbaud-conorwright Dec 6, 2018
4d63c89
fixed failing test
blackbaud-conorwright Dec 6, 2018
6ff1289
removed unused import
blackbaud-conorwright Dec 7, 2018
c34ba87
switched back to using id instead of waitcmp
blackbaud-conorwright Dec 11, 2018
14f23b1
renamed id parameter
blackbaud-conorwright Dec 13, 2018
e2c20b4
Made id private
blackbaud-conorwright Dec 13, 2018
284d524
moved to private area
blackbaud-conorwright Dec 13, 2018
836d73f
removed leftover code. Added additional coverage
blackbaud-conorwright Dec 13, 2018
6cd6698
Merge branch 'fix-wait-not-blocking-focus-nav' of github.com:blackbau…
blackbaud-conorwright Dec 13, 2018
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
43 changes: 35 additions & 8 deletions src/app/public/modules/wait/fixtures/wait.component.fixture.html
Original file line number Diff line number Diff line change
@@ -1,9 +1,36 @@
<div class="sky-wait-test-component">
<sky-wait
[ariaLabel]="ariaLabel"
[isFullPage]="isFullPage"
[isNonBlocking]="isNonBlocking"
[isWaiting]="isWaiting"
>
</sky-wait>
<button *ngIf="showAnchor0"
class="sky-btn sky-btn-secondary"
id="anchor-0"
href="/"
>
Anchor tag 0
</button>
<button
class="sky-btn sky-btn-secondary"
id="anchor-1"
href="/"
>
Anchor tag 1
</button>
<div class="sky-wait-test-component">
<button
class='sky-btn sky-btn-primary'
id="inside-button"
>
I am button
</button>
<sky-wait
[ariaLabel]="ariaLabel"
[isFullPage]="isFullPage"
[isNonBlocking]="isNonBlocking"
[isWaiting]="isWaiting"
>
</sky-wait>
</div>
<button *ngIf="showAnchor2"
class="sky-btn sky-btn-secondary"
id="anchor-2"
href="/"
>
Anchor tag 2
</button>
13 changes: 12 additions & 1 deletion src/app/public/modules/wait/fixtures/wait.component.fixture.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import {
Component,
Input
Input,
ViewChild
} from '@angular/core';

import {
SkyWaitComponent
} from '..';

@Component({
selector: 'sky-test-cmp',
templateUrl: './wait.component.fixture.html'
Expand All @@ -19,4 +24,10 @@ export class SkyWaitTestComponent {

@Input()
public ariaLabel: string;

public showAnchor0 = true;
public showAnchor2 = true;

@ViewChild(SkyWaitComponent)
public waitComponent: SkyWaitComponent;
}
109 changes: 103 additions & 6 deletions src/app/public/modules/wait/wait-adapter.service.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,28 @@
import {
ElementRef,
Injectable,
Renderer
Renderer,
OnDestroy
} from '@angular/core';

import {
SkyWaitComponent
} from './wait.component';

@Injectable()
export class SkyWaitAdapterService {
export class SkyWaitAdapterService implements OnDestroy {
private parentListeners: {[key: string]: Function} = {};

constructor(private renderer: Renderer) { }
constructor(
private renderer: Renderer
) { }

public ngOnDestroy() {
for (let key of Object.keys(this.parentListeners)) {
this.parentListeners[key]();
blackbaud-conorwright marked this conversation as resolved.
Show resolved Hide resolved
delete this.parentListeners[key];
}
}

public setWaitBounds(waitEl: ElementRef) {
this.renderer.setElementStyle(waitEl.nativeElement.parentElement, 'position', 'relative');
Expand All @@ -21,13 +36,95 @@ export class SkyWaitAdapterService {
waitEl: ElementRef,
isFullPage: boolean,
isWaiting: boolean,
isNonBlocking = false
): void {
id: string,
waitCmp: SkyWaitComponent
blackbaud-conorwright marked this conversation as resolved.
Show resolved Hide resolved
) {
let busyEl = isFullPage ? document.body : waitEl.nativeElement.parentElement;
let state = isWaiting ? 'true' : undefined;

if (!isNonBlocking) {
if (!(waitCmp && waitCmp.isNonBlocking)) {
this.renderer.setElementAttribute(busyEl, 'aria-busy', state);

// Remove focus from page when full page blocking wait
if (isFullPage || busyEl.contains(document.activeElement)) {
this.clearDocumentFocus();
blackbaud-conorwright marked this conversation as resolved.
Show resolved Hide resolved
}

if (isWaiting) {
// Prevent tab navigation within the waited page
let endListenerFunc = this.renderer.listen(document.body, 'keydown', (event: KeyboardEvent) => {
if (event.key.toLowerCase() === 'tab') {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
if (!waitCmp.isFullPage) {
// Propagate tab navigation if attempted into waited element
this.focusNextElement(busyEl, event.shiftKey);
} else {
this.clearDocumentFocus();
}
}
});
this.parentListeners[id] = endListenerFunc;
} else if (id in this.parentListeners) {
// Clean up existing listener
this.parentListeners[id]();
delete this.parentListeners[id];
}
}
}

private focusNextElement(parentElement: any, shiftKey: boolean): void {
// Select all possible focussable elements
let focussableElements =
'a[href], ' +
Blackbaud-AlexKingman marked this conversation as resolved.
Show resolved Hide resolved
'area[href], ' +
'input:not([disabled]):not([tabindex=\'-1\']), ' +
'button:not([disabled]):not([tabindex=\'-1\']), ' +
'select:not([disabled]):not([tabindex=\'-1\']), ' +
'textarea:not([disabled]):not([tabindex=\'-1\']), ' +
'iframe, object, embed, ' +
'*[tabindex]:not([tabindex=\'-1\']), ' +
Blackbaud-AlexKingman marked this conversation as resolved.
Show resolved Hide resolved
'*[contenteditable=true]';
let focussable = Array.prototype.filter.call(document.body.querySelectorAll(focussableElements),
(element: any) => {
return element.offsetWidth > 0 || element.offsetHeight > 0 || element === document.activeElement;
});
// If shift tab, go in the other direction
let modifier = shiftKey ? -1 : 1;
blackbaud-conorwright marked this conversation as resolved.
Show resolved Hide resolved

// Find the next navigable element that isn't waiting
let startingIndex = focussable.indexOf(document.activeElement);
let curIndex = startingIndex + modifier;
while (focussable[curIndex] && parentElement.contains(focussable[curIndex])) {
curIndex += modifier;
}

if (focussable[curIndex]) {
focussable[curIndex].focus();
} else {
// Try wrapping the navigation
curIndex = modifier > 0 ? 0 : focussable.length - 1;
while (
curIndex < startingIndex &&
focussable[curIndex] &&
parentElement.contains(focussable[curIndex])
) {
curIndex += modifier;
}
if (focussable[curIndex] && curIndex !== startingIndex) {
focussable[curIndex].focus();
} else {
// No valid target, wipe focus
this.clearDocumentFocus();
}
}
}

private clearDocumentFocus() {
if (document.activeElement && (document.activeElement as any).blur) {
(document.activeElement as any).blur();
}
document.body.focus();
}
}
116 changes: 111 additions & 5 deletions src/app/public/modules/wait/wait.component.spec.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import {
async,
TestBed
TestBed,
ComponentFixture,
tick,
fakeAsync
} from '@angular/core/testing';

import {
SkyLibResourcesService
} from '@skyux/i18n';

import {
SkyLibResourcesTestService
} from '@skyux/i18n/testing';

import {
expect
expect,
SkyAppTestUtility
} from '@skyux-sdk/testing';

import {
Expand Down Expand Up @@ -75,7 +77,6 @@ describe('Wait component', () => {
fixture.componentInstance.isWaiting = false;
fixture.detectChanges();
expect(el.querySelector('.sky-wait-test-component').style.position).toBe('');

});

it('should set the appropriate class when wait component fullPage is set to true', () => {
Expand Down Expand Up @@ -120,6 +121,80 @@ describe('Wait component', () => {
expect(el.querySelector('.sky-wait-mask-loading-blocking')).toBeNull();
});

it('should propagate tab navigation forward and backward avoiding waited element', fakeAsync(() => {
let fixture = TestBed.createComponent(SkyWaitTestComponent);
fixture.detectChanges();

fixture.componentInstance.isFullPage = false;
fixture.componentInstance.isWaiting = true;
fixture.detectChanges();

let anchor0: any = document.body.querySelector('#anchor-0');
let anchor1: any = document.body.querySelector('#anchor-1');
let anchor2: any = document.body.querySelector('#anchor-2');
anchor1.focus();

SkyAppTestUtility.fireDomEvent(document.body, 'keydown', {
keyboardEventInit: { key: 'Tab' }
});
fixture.detectChanges();
tick();
fixture.detectChanges();
expect(document.activeElement).toBe(anchor2);

anchor2.focus();
SkyAppTestUtility.fireDomEvent(document.body, 'keydown', {
keyboardEventInit: { key: 'Tab', shiftKey: true }
});
fixture.detectChanges();
tick();
fixture.detectChanges();
expect(document.activeElement).toBe(anchor1);

fixture.componentInstance.showAnchor2 = false;
anchor1.focus();
fixture.detectChanges();
tick();
fixture.detectChanges();
SkyAppTestUtility.fireDomEvent(document.body, 'keydown', {
keyboardEventInit: { key: 'Tab', shiftKey: false }
});

fixture.detectChanges();
tick();
fixture.detectChanges();
expect(document.activeElement).toBe(anchor0);

fixture.componentInstance.showAnchor0 = false;
fixture.componentInstance.showAnchor2 = false;
anchor1.focus();
fixture.detectChanges();
tick();
fixture.detectChanges();
SkyAppTestUtility.fireDomEvent(document.body, 'keydown', {
keyboardEventInit: { key: 'Tab', shiftKey: false }
});

fixture.detectChanges();
tick();
fixture.detectChanges();
expect(document.activeElement).toBe(document.body);

fixture.componentInstance.isWaiting = false;
fixture.detectChanges();
tick();
fixture.detectChanges();

anchor1.focus();
SkyAppTestUtility.fireDomEvent(document.body, 'keydown', {
keyboardEventInit: { key: 'Tab' }
});
fixture.detectChanges();
tick();
fixture.detectChanges();
expect(document.activeElement).toBe(anchor1);
}));

it('should set aria-busy on document body when fullPage is true', async(() => {
const fixture = TestBed.createComponent(SkyWaitTestComponent);

Expand Down Expand Up @@ -154,6 +229,37 @@ describe('Wait component', () => {
expect(el.querySelector('.sky-wait-test-component').getAttribute('aria-busy')).toBeNull();
});

let testlistenerCreated = (fixture: ComponentFixture<SkyWaitTestComponent>) => {
fixture.detectChanges();

let waitComponent: any = fixture.componentInstance.waitComponent;
let adapter: any = waitComponent.adapterService;
expect(waitComponent.id in adapter.parentListeners).toBeTruthy();
expect(Object.keys(adapter.parentListeners).length).toBe(1);

fixture.componentInstance.isWaiting = false;
fixture.detectChanges();
expect(waitComponent.id in adapter.parentListeners).toBeFalsy();
expect(Object.keys(adapter.parentListeners).length).toBe(0);
};

it('should create listener on document body when fullPage is true', () => {
let fixture = TestBed.createComponent(SkyWaitTestComponent);
fixture.detectChanges();

fixture.componentInstance.isFullPage = true;
fixture.componentInstance.isWaiting = true;
testlistenerCreated(fixture);
});

it('should create listener on containing div when fullPage is set to false', () => {
let fixture = TestBed.createComponent(SkyWaitTestComponent);
fixture.detectChanges();

fixture.componentInstance.isWaiting = true;
testlistenerCreated(fixture);
});

function getAriaLabel(): string {
return document.body.querySelector('.sky-wait-mask').getAttribute('aria-label');
}
Expand Down
7 changes: 6 additions & 1 deletion src/app/public/modules/wait/wait.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
SkyWaitAdapterService
} from './wait-adapter.service';

let nextId = 0;

@Component({
selector: 'sky-wait',
templateUrl: './wait.component.html',
Expand All @@ -28,6 +30,8 @@ export class SkyWaitComponent implements OnInit {
@Input()
public ariaLabel: string;

private id: string = `sky-wait-${++nextId}`;

@Input()
public set isWaiting(value: boolean) {
if (value && !this._isFullPage) {
Expand All @@ -40,7 +44,8 @@ export class SkyWaitComponent implements OnInit {
this.elRef,
this._isFullPage,
value,
this.isNonBlocking
this.id,
this
blackbaud-conorwright marked this conversation as resolved.
Show resolved Hide resolved
);

this._isWaiting = value;
Expand Down
Loading