lion/packages/overlays/src/utils/contain-focus.js

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 };
}