@primer/behaviors
Version:
Shared behaviors for JavaScript components
135 lines (132 loc) • 5.87 kB
JavaScript
import { getFocusableChild, isTabbable } from './utils/iterate-focusable-elements.mjs';
import { polyfill } from './polyfills/event-listener-signal.mjs';
polyfill();
const suspendedTrapStack = [];
let activeTrap = undefined;
function tryReactivate() {
const trapToReactivate = suspendedTrapStack.pop();
if (trapToReactivate) {
focusTrap(trapToReactivate.container, trapToReactivate.initialFocus, trapToReactivate.originalSignal);
}
}
function followSignal(signal) {
const controller = new AbortController();
signal.addEventListener('abort', () => {
controller.abort();
});
return controller;
}
function observeFocusTrap(container, sentinels) {
const observer = new MutationObserver(mutations => {
for (const mutation of mutations) {
if (mutation.type === 'childList' && mutation.addedNodes.length) {
const sentinelChildren = Array.from(mutation.addedNodes).filter(e => e instanceof HTMLElement && e.classList.contains('sentinel') && e.tagName === 'SPAN');
if (sentinelChildren.length) {
return;
}
const firstChild = container.firstElementChild;
const lastChild = container.lastElementChild;
const [sentinelStart, sentinelEnd] = sentinels;
if (!(firstChild === null || firstChild === void 0 ? void 0 : firstChild.classList.contains('sentinel'))) {
container.insertAdjacentElement('afterbegin', sentinelStart);
}
if (!(lastChild === null || lastChild === void 0 ? void 0 : lastChild.classList.contains('sentinel'))) {
container.insertAdjacentElement('beforeend', sentinelEnd);
}
}
}
});
observer.observe(container, { childList: true });
return observer;
}
function focusTrap(container, initialFocus, abortSignal) {
const controller = new AbortController();
const signal = abortSignal !== null && abortSignal !== void 0 ? abortSignal : controller.signal;
container.setAttribute('data-focus-trap', 'active');
const sentinelStart = document.createElement('span');
sentinelStart.setAttribute('class', 'sentinel');
sentinelStart.setAttribute('tabindex', '0');
sentinelStart.setAttribute('aria-hidden', 'true');
sentinelStart.onfocus = () => {
const lastFocusableChild = getFocusableChild(container, true);
lastFocusableChild === null || lastFocusableChild === void 0 ? void 0 : lastFocusableChild.focus();
};
const sentinelEnd = document.createElement('span');
sentinelEnd.setAttribute('class', 'sentinel');
sentinelEnd.setAttribute('tabindex', '0');
sentinelEnd.setAttribute('aria-hidden', 'true');
sentinelEnd.onfocus = () => {
const firstFocusableChild = getFocusableChild(container);
firstFocusableChild === null || firstFocusableChild === void 0 ? void 0 : firstFocusableChild.focus();
};
const existingSentinels = Array.from(container.children).filter(e => e.classList.contains('sentinel') && e.tagName === 'SPAN');
if (!existingSentinels.length) {
container.prepend(sentinelStart);
container.append(sentinelEnd);
}
const observer = observeFocusTrap(container, [sentinelStart, sentinelEnd]);
let lastFocusedChild = undefined;
function ensureTrapZoneHasFocus(focusedElement) {
if (focusedElement instanceof HTMLElement && document.contains(container)) {
if (container.contains(focusedElement)) {
lastFocusedChild = focusedElement;
return;
}
else {
if (lastFocusedChild && isTabbable(lastFocusedChild) && container.contains(lastFocusedChild)) {
lastFocusedChild.focus();
return;
}
else if (initialFocus && container.contains(initialFocus)) {
initialFocus.focus();
return;
}
else {
const firstFocusableChild = getFocusableChild(container);
firstFocusableChild === null || firstFocusableChild === void 0 ? void 0 : firstFocusableChild.focus();
return;
}
}
}
}
const wrappingController = followSignal(signal);
if (activeTrap) {
const suspendedTrap = activeTrap;
activeTrap.container.setAttribute('data-focus-trap', 'suspended');
activeTrap.controller.abort();
suspendedTrapStack.push(suspendedTrap);
}
wrappingController.signal.addEventListener('abort', () => {
activeTrap = undefined;
});
signal.addEventListener('abort', () => {
container.removeAttribute('data-focus-trap');
const sentinels = container.getElementsByClassName('sentinel');
while (sentinels.length > 0)
sentinels[0].remove();
const suspendedTrapIndex = suspendedTrapStack.findIndex(t => t.container === container);
if (suspendedTrapIndex >= 0) {
suspendedTrapStack.splice(suspendedTrapIndex, 1);
}
observer.disconnect();
tryReactivate();
});
document.addEventListener('focus', event => {
ensureTrapZoneHasFocus(event.target);
}, { signal: wrappingController.signal, capture: true });
ensureTrapZoneHasFocus(document.activeElement);
activeTrap = {
container,
controller: wrappingController,
initialFocus,
originalSignal: signal,
};
const suspendedTrapIndex = suspendedTrapStack.findIndex(t => t.container === container);
if (suspendedTrapIndex >= 0) {
suspendedTrapStack.splice(suspendedTrapIndex, 1);
}
if (!abortSignal) {
return controller;
}
}
export { focusTrap };