Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(viewer): Add focus trap for fullscreen #1439

Merged
merged 6 commits into from
Dec 16, 2021
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
141 changes: 141 additions & 0 deletions src/lib/FocusTrap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { decodeKeydown } from './util';

const FOCUSABLE_ELEMENTS = [
ConradJChan marked this conversation as resolved.
Show resolved Hide resolved
ConradJChan marked this conversation as resolved.
Show resolved Hide resolved
'a[href]',
'button',
'textarea',
'input[type="text"]',
'input[type="radio"]',
'input[type="checkbox"]',
'select',
];
const ATTRIBUTES = ['disabled', 'tabindex="-1"', 'aria-disabled="true"'];
ConradJChan marked this conversation as resolved.
Show resolved Hide resolved
const FOCUSABLE_ELEMENTS_SELECTOR = FOCUSABLE_ELEMENTS.map(element => {
let selector = element;
ATTRIBUTES.forEach(attribute => {
selector += `:not([${attribute}])`;
});
return selector;
}).join(', ');

function isButton(element: HTMLElement): boolean {
return element.tagName.toLowerCase() === 'button';
}
function isVisible(element: HTMLElement): boolean {
return element.offsetHeight > 0 && element.offsetWidth > 0;
}
function createFocusAnchor(): HTMLElement {
const element = document.createElement('i');
element.setAttribute('aria-hidden', 'true');
element.tabIndex = 0;

return element;
}

class FocusTrap {
element: HTMLElement;

firstFocusableElement: HTMLElement | null = null;

lastFocusableElement: HTMLElement | null = null;

trapFocusableElement: HTMLElement | null = null;

constructor(element: HTMLElement) {
if (!element) {
throw new Error('FocusTrap needs an HTMLElement passed into the constructor');
ConradJChan marked this conversation as resolved.
Show resolved Hide resolved
}

this.element = element;
}

destroy(): void {
this.disable();
}

focusFirstElement = (): void => {
const focusableElements = this.getFocusableElements();
if (focusableElements.length > 0) {
focusableElements[0].focus();
} else if (this.trapFocusableElement) {
this.trapFocusableElement.focus();
}
};

focusLastElement = (): void => {
const focusableElements = this.getFocusableElements();
if (focusableElements.length > 0) {
focusableElements[focusableElements.length - 1].focus();
} else if (this.trapFocusableElement) {
this.trapFocusableElement.focus();
}
};

getFocusableElements = (): Array<HTMLElement> => {
// Look for focusable elements
const foundElements = this.element.querySelectorAll<HTMLElement>(FOCUSABLE_ELEMENTS_SELECTOR);
// Filter out the elements that are preview control buttons and not visible
ConradJChan marked this conversation as resolved.
Show resolved Hide resolved
return Array.from(foundElements).filter(el => (isButton(el) && isVisible(el)) || !isButton(el));
};

handleTrapKeydown = (event: KeyboardEvent): void => {
const key = decodeKeydown(event);
const isTabPressed = key === 'Tab' || key === 'Shift+Tab';

if (!isTabPressed) {
return;
}
event.stopPropagation();
event.preventDefault();
};

handleKeydown = (event: KeyboardEvent): void => {
const key = decodeKeydown(event);
const isTabPressed = key === 'Tab' || key === 'Shift+Tab';

if (this.element === document.activeElement && isTabPressed) {
ConradJChan marked this conversation as resolved.
Show resolved Hide resolved
this.focusFirstElement();
event.stopPropagation();
event.preventDefault();
}
};

enable(): void {
this.element.addEventListener('keydown', this.handleKeydown);

// Create focus anchors (beginning, end and trap)
ConradJChan marked this conversation as resolved.
Show resolved Hide resolved
this.firstFocusableElement = createFocusAnchor();
this.lastFocusableElement = createFocusAnchor();
this.trapFocusableElement = createFocusAnchor();

this.firstFocusableElement.addEventListener('focus', this.focusLastElement);
this.lastFocusableElement.addEventListener('focus', this.focusFirstElement);
this.trapFocusableElement.addEventListener('keydown', this.handleTrapKeydown);

this.element.insertBefore(this.firstFocusableElement, this.element.firstElementChild);
this.element.append(this.lastFocusableElement);
this.element.append(this.trapFocusableElement);
}

disable(): void {
this.element.removeEventListener('keydown', this.handleKeydown);
jstoffan marked this conversation as resolved.
Show resolved Hide resolved

if (this.firstFocusableElement) {
this.element.removeChild(this.firstFocusableElement);
}

if (this.lastFocusableElement) {
this.element.removeChild(this.lastFocusableElement);
}

if (this.trapFocusableElement) {
this.element.removeChild(this.trapFocusableElement);
}

this.firstFocusableElement = null;
this.lastFocusableElement = null;
this.trapFocusableElement = null;
}
}

export default FocusTrap;
19 changes: 19 additions & 0 deletions src/lib/viewers/BaseViewer.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import EventEmitter from 'events';
import cloneDeep from 'lodash/cloneDeep';
import debounce from 'lodash/debounce';
import FocusTrap from '../FocusTrap';
import fullscreen from '../Fullscreen';
import intlUtil from '../i18n';
import RepStatus from '../RepStatus';
Expand Down Expand Up @@ -79,6 +80,9 @@ class BaseViewer extends EventEmitter {
/** @property {boolean} - Flag for tracking whether or not this viewer has been destroyed */
destroyed = false;

/** @property {FocusTrap} - FocusTrap instance, if any */
focusTrap;

/** @property {number} - Number of milliseconds to wait, while loading, until messaging that the viewer took too long to load */
loadTimeout;

Expand Down Expand Up @@ -261,6 +265,10 @@ class BaseViewer extends EventEmitter {
document.defaultView.removeEventListener('resize', this.debouncedResizeHandler);
this.removeAllListeners();

if (this.focusTrap) {
this.focusTrap.destroy();
}

if (this.containerEl) {
this.containerEl.removeEventListener('contextmenu', this.preventDefault);
this.containerEl.innerHTML = '';
Expand All @@ -282,6 +290,7 @@ class BaseViewer extends EventEmitter {
this.annotatorPromiseResolver = null;
this.emittedMetrics = null;
this.fullscreenToggleEl = null;
this.focusTrap = null;
this.emit('destroy');
}

Expand Down Expand Up @@ -579,6 +588,12 @@ class BaseViewer extends EventEmitter {
if (this.fullscreenToggleEl && this.fullscreenToggleEl.focus) {
this.fullscreenToggleEl.focus();
}

if (!this.focusTrap) {
this.focusTrap = new FocusTrap(this.containerEl);
}

this.focusTrap.enable();
}

/**
Expand All @@ -593,6 +608,10 @@ class BaseViewer extends EventEmitter {
this.annotator.emit(ANNOTATOR_EVENT.setVisibility, true);
this.enableAnnotationControls();
}

if (this.focusTrap) {
this.focusTrap.disable();
}
}

/**
Expand Down