diff --git a/src/lib/FocusTrap.ts b/src/lib/FocusTrap.ts new file mode 100644 index 000000000..276adbb9c --- /dev/null +++ b/src/lib/FocusTrap.ts @@ -0,0 +1,147 @@ +import { decodeKeydown } from './util'; + +const FOCUSABLE_ELEMENT_SELECTORS = [ + 'a[href]', + 'button', + 'textarea', + 'input[type="text"]', + 'input[type="radio"]', + 'input[type="checkbox"]', + 'select', +]; +const EXCLUDED_SELECTOR_ATTRIBUTES = ['disabled', 'tabindex="-1"', 'aria-disabled="true"']; +const FOCUSABLE_ELEMENTS_SELECTOR = FOCUSABLE_ELEMENT_SELECTORS.map(element => { + let selector = element; + EXCLUDED_SELECTOR_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({ className = '' }): HTMLElement { + const element = document.createElement('i'); + element.setAttribute('aria-hidden', 'true'); + element.tabIndex = 0; + element.className = className; + + 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'); + } + + 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 => { + // Look for focusable elements + const foundElements = this.element.querySelectorAll(FOCUSABLE_ELEMENTS_SELECTOR); + // Filter out the buttons that are invisible (in case display: 'none' is used for hidden buttons) + 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) { + return; + } + + if (key === 'Tab') { + this.focusFirstElement(); + } else { + this.focusLastElement(); + } + event.stopPropagation(); + event.preventDefault(); + }; + + enable(): void { + this.element.addEventListener('keydown', this.handleKeydown); + + this.firstFocusableElement = createFocusAnchor({ className: 'bp-FocusTrap-first' }); + this.lastFocusableElement = createFocusAnchor({ className: 'bp-FocusTrap-last' }); + this.trapFocusableElement = createFocusAnchor({ className: 'bp-FocusTrap-trap' }); + + 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.appendChild(this.lastFocusableElement); + this.element.appendChild(this.trapFocusableElement); + } + + disable(): void { + this.element.removeEventListener('keydown', this.handleKeydown); + + 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; diff --git a/src/lib/__tests__/FocusTrap-test.html b/src/lib/__tests__/FocusTrap-test.html new file mode 100644 index 000000000..c037d504c --- /dev/null +++ b/src/lib/__tests__/FocusTrap-test.html @@ -0,0 +1,4 @@ +
+ + +
diff --git a/src/lib/__tests__/FocusTrap-test.ts b/src/lib/__tests__/FocusTrap-test.ts new file mode 100644 index 000000000..40bba0d44 --- /dev/null +++ b/src/lib/__tests__/FocusTrap-test.ts @@ -0,0 +1,183 @@ +import FocusTrap from '../FocusTrap'; + +declare const fixture: { + load: (path: string) => void; +}; + +describe('lib/FocusTrap', () => { + beforeEach(() => { + // Fixture has 2 input elements as children + fixture.load('__tests__/FocusTrap-test.html'); + }); + + const getContainerElement = (): HTMLElement => + document.querySelector('#test-container') as HTMLElement; + const getFocusTrap = (): FocusTrap => new FocusTrap(getContainerElement()); + + describe('constructor', () => { + test('should save reference to element', () => { + const focusTrap = getFocusTrap(); + + expect(focusTrap.element).toBe(getContainerElement()); + }); + }); + + describe('enable()', () => { + test('should add 3 focus anchor elements', () => { + const focusTrap = getFocusTrap(); + expect(getContainerElement().children.length).toBe(2); + + focusTrap.enable(); + + const children = Array.from(getContainerElement().children); + expect(children.length).toBe(5); + expect(children[0].tagName.toLowerCase()).toBe('i'); + expect(children[1].tagName.toLowerCase()).toBe('input'); + expect(children[2].tagName.toLowerCase()).toBe('input'); + expect(children[3].tagName.toLowerCase()).toBe('i'); + expect(children[4].tagName.toLowerCase()).toBe('i'); + }); + }); + + describe('disable()', () => { + test('should remove the 3 focus anchor elements', () => { + const focusTrap = getFocusTrap(); + expect(getContainerElement().children.length).toBe(2); + + focusTrap.enable(); + expect(getContainerElement().children.length).toBe(5); + + focusTrap.disable(); + expect(getContainerElement().children.length).toBe(2); + }); + }); + + describe('focus management', () => { + let anchorFirstElement: HTMLElement; + let anchorLastElement: HTMLElement; + let anchorTrapElement: HTMLElement; + let firstInput: HTMLElement; + let focusTrap; + let secondInput: HTMLElement; + + const getTestElement = (selector: string): HTMLElement => + getContainerElement().querySelector(selector) as HTMLElement; + + beforeEach(() => { + focusTrap = getFocusTrap(); + focusTrap.enable(); + + anchorFirstElement = getTestElement('.bp-FocusTrap-first'); + anchorLastElement = getTestElement('.bp-FocusTrap-last'); + anchorTrapElement = getTestElement('.bp-FocusTrap-trap'); + firstInput = getTestElement('.firstInput'); + secondInput = getTestElement('.secondInput'); + }); + + test('should redirect focus from first anchor to last focusable element', () => { + firstInput.focus(); + expect(document.activeElement).toBe(firstInput); + + anchorFirstElement.focus(); + expect(document.activeElement).toBe(secondInput); + }); + + test('should redirect focus from last anchor to first focusable element', () => { + secondInput.focus(); + expect(document.activeElement).toBe(secondInput); + + anchorLastElement.focus(); + expect(document.activeElement).toBe(firstInput); + }); + + test('should keep focus trapped on trap anchor when Tab is pressed', () => { + anchorTrapElement.focus(); + expect(document.activeElement).toBe(anchorTrapElement); + + const mockEvent = new KeyboardEvent('keydown', { key: 'Tab' }); + jest.spyOn(mockEvent, 'stopPropagation'); + jest.spyOn(mockEvent, 'preventDefault'); + + anchorTrapElement.dispatchEvent(mockEvent); + expect(document.activeElement).toBe(anchorTrapElement); + expect(mockEvent.stopPropagation).toBeCalled(); + expect(mockEvent.preventDefault).toBeCalled(); + }); + + test('should keep focus trapped on trap anchor when Shift+Tab is pressed', () => { + anchorTrapElement.focus(); + expect(document.activeElement).toBe(anchorTrapElement); + + const mockEvent = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true }); + jest.spyOn(mockEvent, 'stopPropagation'); + jest.spyOn(mockEvent, 'preventDefault'); + + anchorTrapElement.dispatchEvent(mockEvent); + expect(document.activeElement).toBe(anchorTrapElement); + expect(mockEvent.stopPropagation).toBeCalled(); + expect(mockEvent.preventDefault).toBeCalled(); + }); + + test.each(['Enter', 'Escape', 'ArrowDown'])('should do nothing if key %s is pressed', key => { + anchorTrapElement.focus(); + expect(document.activeElement).toBe(anchorTrapElement); + + const mockEvent = new KeyboardEvent('keydown', { key }); + jest.spyOn(mockEvent, 'stopPropagation'); + jest.spyOn(mockEvent, 'preventDefault'); + + anchorTrapElement.dispatchEvent(mockEvent); + expect(document.activeElement).toBe(anchorTrapElement); + expect(mockEvent.stopPropagation).not.toBeCalled(); + expect(mockEvent.preventDefault).not.toBeCalled(); + }); + + test('should focus first element if Tab is pressed when container element has focus', () => { + const container = getContainerElement(); + container.focus(); + expect(document.activeElement).toBe(container); + + const mockEvent = new KeyboardEvent('keydown', { key: 'Tab' }); + jest.spyOn(mockEvent, 'stopPropagation'); + jest.spyOn(mockEvent, 'preventDefault'); + + container.dispatchEvent(mockEvent); + expect(document.activeElement).toBe(firstInput); + expect(mockEvent.stopPropagation).toBeCalled(); + expect(mockEvent.preventDefault).toBeCalled(); + }); + + test('should focus first element if Tab is pressed when container element has focus', () => { + const container = getContainerElement(); + container.focus(); + expect(document.activeElement).toBe(container); + + const mockEvent = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true }); + jest.spyOn(mockEvent, 'stopPropagation'); + jest.spyOn(mockEvent, 'preventDefault'); + + container.dispatchEvent(mockEvent); + expect(document.activeElement).toBe(secondInput); + expect(mockEvent.stopPropagation).toBeCalled(); + expect(mockEvent.preventDefault).toBeCalled(); + }); + + test.each(['Enter', 'Escape', 'ArrowDown'])( + 'should do nothing if %s is pressed when container element has focus', + key => { + const container = getContainerElement(); + container.focus(); + expect(document.activeElement).toBe(container); + + const mockEvent = new KeyboardEvent('keydown', { key }); + jest.spyOn(mockEvent, 'stopPropagation'); + jest.spyOn(mockEvent, 'preventDefault'); + + container.dispatchEvent(mockEvent); + expect(document.activeElement).toBe(container); + expect(mockEvent.stopPropagation).not.toBeCalled(); + expect(mockEvent.preventDefault).not.toBeCalled(); + }, + ); + }); +}); diff --git a/src/lib/viewers/BaseViewer.js b/src/lib/viewers/BaseViewer.js index 97c8adfbe..51a8c6c96 100644 --- a/src/lib/viewers/BaseViewer.js +++ b/src/lib/viewers/BaseViewer.js @@ -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'; @@ -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; @@ -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 = ''; @@ -282,6 +290,7 @@ class BaseViewer extends EventEmitter { this.annotatorPromiseResolver = null; this.emittedMetrics = null; this.fullscreenToggleEl = null; + this.focusTrap = null; this.emit('destroy'); } @@ -579,6 +588,17 @@ class BaseViewer extends EventEmitter { if (this.fullscreenToggleEl && this.fullscreenToggleEl.focus) { this.fullscreenToggleEl.focus(); } + + try { + if (!this.focusTrap) { + this.focusTrap = new FocusTrap(this.containerEl); + } + + this.focusTrap.enable(); + } catch (error) { + // eslint-disable-next-line + console.error('Unable to enable focus trap around Preview content'); + } } /** @@ -593,6 +613,10 @@ class BaseViewer extends EventEmitter { this.annotator.emit(ANNOTATOR_EVENT.setVisibility, true); this.enableAnnotationControls(); } + + if (this.focusTrap) { + this.focusTrap.disable(); + } } /** diff --git a/src/lib/viewers/__tests__/BaseViewer-test.js b/src/lib/viewers/__tests__/BaseViewer-test.js index 68d08e263..c4d4c7d64 100644 --- a/src/lib/viewers/__tests__/BaseViewer-test.js +++ b/src/lib/viewers/__tests__/BaseViewer-test.js @@ -5,6 +5,7 @@ import * as util from '../../util'; import Api from '../../api'; import BaseViewer from '../BaseViewer'; import Browser from '../../Browser'; +import FocusTrap from '../../FocusTrap'; import fullscreen from '../../Fullscreen'; import intl from '../../i18n'; import PreviewError from '../../PreviewError'; @@ -19,6 +20,8 @@ let containerEl; let stubs = {}; const { ANNOTATOR_EVENT } = constants; +jest.mock('../../FocusTrap'); + describe('lib/viewers/BaseViewer', () => { beforeEach(() => { fixture.load('viewers/__tests__/BaseViewer-test.html'); @@ -500,6 +503,9 @@ describe('lib/viewers/BaseViewer', () => { }); describe('handleFullscreenEnter()', () => { + beforeEach(() => { + base.containerEl = document.querySelector('.bp-content'); + }); test('should resize the viewer', () => { jest.spyOn(base, 'resize'); @@ -534,6 +540,28 @@ describe('lib/viewers/BaseViewer', () => { expect(base.disableAnnotationControls).toBeCalled(); expect(base.processAnnotationModeChange).toBeCalledWith(AnnotationMode.NONE); }); + + test('should enable the focus trap', () => { + jest.spyOn(FocusTrap.prototype, 'constructor'); + jest.spyOn(FocusTrap.prototype, 'enable'); + + base.handleFullscreenEnter(); + + expect(FocusTrap.prototype.constructor).toBeCalledWith(base.containerEl); + expect(FocusTrap.prototype.enable).toBeCalled(); + }); + + test('should reuse any existing focus trap', () => { + jest.spyOn(FocusTrap.prototype, 'constructor'); + + const mockFocusTrap = { destroy: jest.fn(), enable: jest.fn() }; + base.focusTrap = mockFocusTrap; + + base.handleFullscreenEnter(); + + expect(FocusTrap.prototype.constructor).not.toBeCalledWith(base.containerEl); + expect(mockFocusTrap.enable).toBeCalled(); + }); }); describe('handleFullscreenExit()', () => { @@ -563,6 +591,15 @@ describe('lib/viewers/BaseViewer', () => { expect(base.annotator.emit).toBeCalledWith(ANNOTATOR_EVENT.setVisibility, true); expect(base.enableAnnotationControls).toBeCalled(); }); + + test('should disable any existing focus trap', () => { + const mockFocusTrap = { destroy: jest.fn(), disable: jest.fn() }; + base.focusTrap = mockFocusTrap; + + base.handleFullscreenExit(); + + expect(mockFocusTrap.disable).toBeCalled(); + }); }); describe('resize()', () => { @@ -651,6 +688,15 @@ describe('lib/viewers/BaseViewer', () => { expect(base.containerEl.removeEventListener).toBeCalledWith('contextmenu', expect.any(Function)); }); + + test('should clean up any focus trap', () => { + const mockFocusTrap = { destroy: jest.fn() }; + + base.focusTrap = mockFocusTrap; + base.destroy(); + + expect(mockFocusTrap.destroy).toBeCalled(); + }); }); describe('emit()', () => {