-
Notifications
You must be signed in to change notification settings - Fork 116
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(viewer): Add focus trap for fullscreen (#1439)
- Loading branch information
Conrad Chan
authored
Dec 16, 2021
1 parent
8a7de73
commit b09b349
Showing
5 changed files
with
404 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}, | ||
); | ||
}); | ||
}); |
Oops, something went wrong.