@primer/components
Version:
Primer react components
170 lines (142 loc) • 6.17 kB
JavaScript
import { isTabbable, iterateFocusableElements } from '../utils/iterateFocusableElements';
import { polyfill as eventListenerSignalPolyfill } from '../polyfills/eventListenerSignal';
eventListenerSignalPolyfill();
const suspendedTrapStack = [];
let activeTrap = undefined;
function tryReactivate() {
const trapToReactivate = suspendedTrapStack.pop();
if (trapToReactivate) {
focusTrap(trapToReactivate.container, trapToReactivate.initialFocus, trapToReactivate.originalSignal);
}
} // @todo If AbortController.prototype.follow is ever implemented, that
// could replace this function. @see https://github.com/whatwg/dom/issues/920
function followSignal(signal) {
const controller = new AbortController();
signal.addEventListener('abort', () => {
controller.abort();
});
return controller;
}
/**
* Returns the first focusable child of `container`. If `lastChild` is true,
* returns the last focusable child of `container`.
* @param container
* @param lastChild
*/
function getFocusableChild(container, lastChild = false) {
return iterateFocusableElements(container, {
reverse: lastChild,
strict: true,
onlyTabbable: true
}).next().value;
}
/**
* Traps focus within the given container.
* @param container The container in which to trap focus
* @returns AbortController - call `.abort()` to disable the focus trap
*/
export function focusTrap(container, initialFocus, abortSignal) {
// Set up an abort controller if a signal was not passed in
const controller = new AbortController();
const signal = abortSignal !== null && abortSignal !== void 0 ? abortSignal : controller.signal;
container.setAttribute('data-focus-trap', 'active');
let lastFocusedChild = undefined; // Ensure focus remains in the trap zone by checking that a given recently-focused
// element is inside the trap zone. If it isn't, redirect focus to a suitable
// element within the trap zone. If need to redirect focus and a suitable element
// is not found, focus the container.
function ensureTrapZoneHasFocus(focusedElement) {
if (focusedElement instanceof HTMLElement && document.contains(container)) {
if (container.contains(focusedElement)) {
// If a child of the trap zone was focused, remember it
lastFocusedChild = focusedElement;
return;
} else {
if (lastFocusedChild && isTabbable(lastFocusedChild) && container.contains(lastFocusedChild)) {
lastFocusedChild.focus();
return;
} else if (initialFocus && container.contains(initialFocus)) {
initialFocus.focus();
return;
} else {
// Ensure the container is focusable:
// - Either the container already has a `tabIndex`
// - Or provide a temporary `tabIndex`
const containerNeedsTemporaryTabIndex = container.getAttribute('tabindex') === null;
if (containerNeedsTemporaryTabIndex) {
container.setAttribute('tabindex', '-1');
} // Focus the container.
container.focus(); // If a temporary `tabIndex` was provided, remove it.
if (containerNeedsTemporaryTabIndex) {
// Once focus has moved from the container to a child within the FocusTrap,
// the container can be made un-refocusable by removing `tabIndex`.
container.addEventListener('blur', () => container.removeAttribute('tabindex'), {
once: true
}); // NB: If `tabIndex` was removed *before* `blur`, then certain browsers (e.g. Chrome)
// would consider `body` the `activeElement`, and as a result, keyboard navigation
// between children would break, since `body` is outside the `FocusTrap`.
}
return;
}
}
}
}
const wrappingController = followSignal(signal);
container.addEventListener('keydown', event => {
if (event.key !== 'Tab' || event.defaultPrevented) {
return;
}
const {
target
} = event;
const firstFocusableChild = getFocusableChild(container);
const lastFocusableChild = getFocusableChild(container, true);
if (target === firstFocusableChild && event.shiftKey) {
event.preventDefault();
lastFocusableChild === null || lastFocusableChild === void 0 ? void 0 : lastFocusableChild.focus();
} else if (target === lastFocusableChild && !event.shiftKey) {
event.preventDefault();
firstFocusableChild === null || firstFocusableChild === void 0 ? void 0 : firstFocusableChild.focus();
}
}, {
signal: wrappingController.signal
});
if (activeTrap) {
const suspendedTrap = activeTrap;
activeTrap.container.setAttribute('data-focus-trap', 'suspended');
activeTrap.controller.abort();
suspendedTrapStack.push(suspendedTrap);
} // When this trap is canceled, either by the user or by us for suspension
wrappingController.signal.addEventListener('abort', () => {
activeTrap = undefined;
}); // Only when user-canceled
signal.addEventListener('abort', () => {
container.removeAttribute('data-focus-trap');
const suspendedTrapIndex = suspendedTrapStack.findIndex(t => t.container === container);
if (suspendedTrapIndex >= 0) {
suspendedTrapStack.splice(suspendedTrapIndex, 1);
}
tryReactivate();
}); // Prevent focus leaving the trap container
document.addEventListener('focus', event => {
ensureTrapZoneHasFocus(event.target);
}, // use capture to ensure we get all events. focus events do not bubble
{
signal: wrappingController.signal,
capture: true
}); // focus the first element
ensureTrapZoneHasFocus(document.activeElement);
activeTrap = {
container,
controller: wrappingController,
initialFocus,
originalSignal: signal
}; // If we are activating a focus trap for a container that was previously
// suspended, just remove it from the suspended list.
const suspendedTrapIndex = suspendedTrapStack.findIndex(t => t.container === container);
if (suspendedTrapIndex >= 0) {
suspendedTrapStack.splice(suspendedTrapIndex, 1);
}
if (!abortSignal) {
return controller;
}
}