202 lines
7.1 KiB
JavaScript
202 lines
7.1 KiB
JavaScript
/* eslint-disable no-param-reassign */
|
|
|
|
/**
|
|
* Implementation based on: https://github.com/PolymerElements/iron-overlay-behavior/blob/master/iron-focusables-helper.html
|
|
* The original implementation does not work for non-Polymer web components,
|
|
* and contains several bugs on IE11.
|
|
*/
|
|
|
|
import { getDeepActiveElement } from './get-deep-active-element.js';
|
|
import { getFocusableElements } from './get-focusable-elements.js';
|
|
import { deepContains } from './deep-contains.js';
|
|
import { keyCodes } from './key-codes.js';
|
|
|
|
/**
|
|
* Rotates focus within a list of elements. If shift key was not pressed and focus
|
|
* is on last item, puts focus on the first item. Reversed if shift key.
|
|
*
|
|
* @param {HTMLElement} rootElement The root element
|
|
* @param {KeyboardEvent} e The keyboard event
|
|
*/
|
|
export function rotateFocus(rootElement, e) {
|
|
// Find focusable elements
|
|
const els = getFocusableElements(rootElement);
|
|
// Determine the focus rotation boundaries.
|
|
let boundaryEls;
|
|
|
|
// If more than two elements, take the first and last
|
|
if (els.length >= 2) {
|
|
boundaryEls = [els[0], els[els.length - 1]];
|
|
|
|
// If 1 element, it is the boundary
|
|
} else if (els.length === 1) {
|
|
boundaryEls = [els[0], els[0]];
|
|
|
|
// If no focusable elements, root becomes the boundary
|
|
} else {
|
|
boundaryEls = [rootElement, rootElement];
|
|
}
|
|
|
|
// Reverse direction of boundaries if shift key was pressed
|
|
if (e.shiftKey) {
|
|
boundaryEls.reverse();
|
|
}
|
|
|
|
// Take first and last elements within boundary
|
|
const [first, last] = boundaryEls;
|
|
|
|
// Get the currently focused element
|
|
const activeElement = /** @type {HTMLElement} */ (getDeepActiveElement());
|
|
|
|
/**
|
|
* If currently focused on the root element or an element contained within the root element:
|
|
* allow native browser behavior (tab to the next node in DOM order).
|
|
*
|
|
* If currently focused on the last focusable element within the root element, or on an element
|
|
* outside of the root element: redirect focus to the first focusable element.
|
|
*/
|
|
if (activeElement === rootElement || (els.includes(activeElement) && last !== activeElement)) {
|
|
return;
|
|
}
|
|
|
|
e.preventDefault();
|
|
first.focus();
|
|
}
|
|
|
|
/**
|
|
* Contains focus within given root element. When focus is on the last focusable
|
|
* element inside the root element, the next focus will be redirected to the first
|
|
* focusable element.
|
|
*
|
|
* @param {HTMLElement} rootElement The element to contain focus within
|
|
* @returns {{ disconnect: () => void }} handler with a disconnect callback
|
|
*/
|
|
export function containFocus(rootElement) {
|
|
const focusableElements = getFocusableElements(rootElement);
|
|
// Initial focus goes to first element with autofocus, or the root element
|
|
const initialFocus = focusableElements.find(e => e.hasAttribute('autofocus')) || rootElement;
|
|
/** @type {HTMLElement} */
|
|
let tabDetectionElement;
|
|
/** @type {MutationObserver} */
|
|
let rootElementMutationObserver;
|
|
|
|
// If root element will receive focus, it should have a tabindex of -1.
|
|
// This makes it focusable through js, but it won't appear in the tab order
|
|
if (initialFocus === rootElement) {
|
|
rootElement.tabIndex = -1;
|
|
rootElement.style.setProperty('outline', 'none');
|
|
}
|
|
|
|
// Focus first focusable element
|
|
initialFocus.focus();
|
|
|
|
/**
|
|
* Ensures focus stays inside root element on tab
|
|
* @param {KeyboardEvent} e
|
|
*/
|
|
function handleKeydown(e) {
|
|
if (e.keyCode === keyCodes.tab) {
|
|
rotateFocus(rootElement, e);
|
|
}
|
|
}
|
|
|
|
function createHelpersDetectingTabDirection() {
|
|
tabDetectionElement = document.createElement('div');
|
|
tabDetectionElement.style.display = 'none';
|
|
tabDetectionElement.setAttribute('data-is-tab-detection-element', '');
|
|
rootElement.insertBefore(tabDetectionElement, rootElement.children[0]);
|
|
|
|
rootElementMutationObserver = new MutationObserver(mutationsList => {
|
|
for (const mutation of mutationsList) {
|
|
if (mutation.type === 'childList') {
|
|
const tabDetectionElIsMissing = !Array.from(rootElement.children).find(el =>
|
|
el.hasAttribute('data-is-tab-detection-element'),
|
|
);
|
|
const foundTabDetectionElInMutations = Array.from(mutation.addedNodes).find(
|
|
/** @param {Node} el */ el =>
|
|
el instanceof HTMLElement && el.hasAttribute('data-is-tab-detection-element'),
|
|
);
|
|
// Prevent infinite loop by detecting that mutation event is not from adding the tab detection el
|
|
if (tabDetectionElIsMissing && !foundTabDetectionElInMutations) {
|
|
rootElementMutationObserver.disconnect();
|
|
createHelpersDetectingTabDirection();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
rootElementMutationObserver.observe(rootElement, { childList: true });
|
|
}
|
|
|
|
function isForwardTabInWindow() {
|
|
const compareMask = tabDetectionElement.compareDocumentPosition(
|
|
/** @type {Element} */ (document.activeElement),
|
|
);
|
|
return compareMask === Node.DOCUMENT_POSITION_PRECEDING;
|
|
}
|
|
|
|
/**
|
|
* @param {Object} [opts]
|
|
* @param {boolean} [opts.resetToRoot]
|
|
* @desc When we simulate a modal dialog, we need to restore the focus to the first or last
|
|
* element of the rootElement
|
|
*/
|
|
function setFocusInRootElement({ resetToRoot = false } = {}) {
|
|
if (deepContains(rootElement, /** @type {HTMLElement} */ (getDeepActiveElement()))) {
|
|
return;
|
|
}
|
|
|
|
let nextActive;
|
|
if (resetToRoot) {
|
|
nextActive = rootElement;
|
|
} else {
|
|
nextActive = focusableElements[isForwardTabInWindow() ? 0 : focusableElements.length - 1];
|
|
}
|
|
|
|
if (nextActive) {
|
|
nextActive.focus();
|
|
}
|
|
}
|
|
|
|
function handleFocusin() {
|
|
window.removeEventListener('focusin', handleFocusin);
|
|
setFocusInRootElement();
|
|
}
|
|
|
|
function handleFocusout() {
|
|
/**
|
|
* There is a moment in time between focusout and focusin (when focus shifts)
|
|
* where the activeElement is reset to body first. So we use an async task to check
|
|
* a little bit later for activeElement, so we don't get a false positive.
|
|
*
|
|
* We used to check for focusin event for this, however,
|
|
* it can happen that focusout happens, but focusin never does, e.g. click outside but no focusable
|
|
* element is found to focus. If this happens, we should take the focus back to the rootElement.
|
|
*/
|
|
setTimeout(() => {
|
|
if (!deepContains(rootElement, /** @type {HTMLElement} */ (getDeepActiveElement()))) {
|
|
setFocusInRootElement({ resetToRoot: true });
|
|
}
|
|
});
|
|
|
|
window.addEventListener('focusin', handleFocusin);
|
|
}
|
|
|
|
function disconnect() {
|
|
window.removeEventListener('keydown', handleKeydown);
|
|
window.removeEventListener('focusin', handleFocusin);
|
|
window.removeEventListener('focusout', handleFocusout);
|
|
// Guard this, since we also disconnect if we notice a missing tab
|
|
// detection element. We reinsert it, so it's okay to not fail here.
|
|
rootElementMutationObserver.disconnect();
|
|
if (Array.from(rootElement.children).includes(tabDetectionElement)) {
|
|
rootElement.removeChild(tabDetectionElement);
|
|
}
|
|
rootElement.style.removeProperty('outline');
|
|
}
|
|
|
|
window.addEventListener('keydown', handleKeydown);
|
|
window.addEventListener('focusout', handleFocusout);
|
|
createHelpersDetectingTabDirection();
|
|
|
|
return { disconnect };
|
|
}
|