Skip to content

Commit

Permalink
feat(viewer): Add focus trap for fullscreen (#1439)
Browse files Browse the repository at this point in the history
  • Loading branch information
Conrad Chan authored Dec 16, 2021
1 parent 8a7de73 commit b09b349
Show file tree
Hide file tree
Showing 5 changed files with 404 additions and 0 deletions.
147 changes: 147 additions & 0 deletions src/lib/FocusTrap.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement> => {
// Look for focusable elements
const foundElements = this.element.querySelectorAll<HTMLElement>(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;
4 changes: 4 additions & 0 deletions src/lib/__tests__/FocusTrap-test.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<div id="test-container" tabindex="0">
<input class="firstInput" type="text" placeholder="first input" />
<input class="secondInput" type="text" placeholder="second input" />
</div>
183 changes: 183 additions & 0 deletions src/lib/__tests__/FocusTrap-test.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>('#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<HTMLElement>(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();
},
);
});
});
Loading

0 comments on commit b09b349

Please sign in to comment.