@zag-js/focus-trap
Version:
Focus trap utility
528 lines (523 loc) • 21.5 kB
JavaScript
'use strict';
var domQuery = require('@zag-js/dom-query');
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
var activeFocusTraps = {
activateTrap(trapStack, trap) {
if (trapStack.length > 0) {
const activeTrap = trapStack[trapStack.length - 1];
if (activeTrap !== trap) {
activeTrap.pause();
}
}
const trapIndex = trapStack.indexOf(trap);
if (trapIndex === -1) {
trapStack.push(trap);
} else {
trapStack.splice(trapIndex, 1);
trapStack.push(trap);
}
},
deactivateTrap(trapStack, trap) {
const trapIndex = trapStack.indexOf(trap);
if (trapIndex !== -1) {
trapStack.splice(trapIndex, 1);
}
if (trapStack.length > 0) {
trapStack[trapStack.length - 1].unpause();
}
}
};
var sharedTrapStack = [];
var FocusTrap = class {
constructor(elements, options) {
__publicField(this, "trapStack");
__publicField(this, "config");
__publicField(this, "doc");
__publicField(this, "state", {
containers: [],
containerGroups: [],
tabbableGroups: [],
nodeFocusedBeforeActivation: null,
mostRecentlyFocusedNode: null,
active: false,
paused: false,
delayInitialFocusTimer: void 0,
recentNavEvent: void 0
});
__publicField(this, "listenerCleanups", []);
__publicField(this, "handleFocus", (event) => {
const target = domQuery.getEventTarget(event);
const targetContained = this.findContainerIndex(target, event) >= 0;
if (targetContained || domQuery.isDocument(target)) {
if (targetContained) {
this.state.mostRecentlyFocusedNode = target;
}
} else {
event.stopImmediatePropagation();
let nextNode;
let navAcrossContainers = true;
if (this.state.mostRecentlyFocusedNode) {
if (domQuery.getTabIndex(this.state.mostRecentlyFocusedNode) > 0) {
const mruContainerIdx = this.findContainerIndex(this.state.mostRecentlyFocusedNode);
const { tabbableNodes } = this.state.containerGroups[mruContainerIdx];
if (tabbableNodes.length > 0) {
const mruTabIdx = tabbableNodes.findIndex((node) => node === this.state.mostRecentlyFocusedNode);
if (mruTabIdx >= 0) {
if (this.config.isKeyForward(this.state.recentNavEvent)) {
if (mruTabIdx + 1 < tabbableNodes.length) {
nextNode = tabbableNodes[mruTabIdx + 1];
navAcrossContainers = false;
}
} else {
if (mruTabIdx - 1 >= 0) {
nextNode = tabbableNodes[mruTabIdx - 1];
navAcrossContainers = false;
}
}
}
}
} else {
if (!this.state.containerGroups.some((g) => g.tabbableNodes.some((n) => domQuery.getTabIndex(n) > 0))) {
navAcrossContainers = false;
}
}
} else {
navAcrossContainers = false;
}
if (navAcrossContainers) {
nextNode = this.findNextNavNode({
// move FROM the MRU node, not event-related node (which will be the node that is
// outside the trap causing the focus escape we're trying to fix)
target: this.state.mostRecentlyFocusedNode,
isBackward: this.config.isKeyBackward(this.state.recentNavEvent)
});
}
if (nextNode) {
this.tryFocus(nextNode);
} else {
this.tryFocus(this.state.mostRecentlyFocusedNode || this.getInitialFocusNode());
}
}
this.state.recentNavEvent = void 0;
});
__publicField(this, "handlePointerDown", (event) => {
const target = domQuery.getEventTarget(event);
if (this.findContainerIndex(target, event) >= 0) {
return;
}
if (valueOrHandler(this.config.clickOutsideDeactivates, event)) {
this.deactivate({ returnFocus: this.config.returnFocusOnDeactivate });
return;
}
if (valueOrHandler(this.config.allowOutsideClick, event)) {
return;
}
event.preventDefault();
});
__publicField(this, "handleClick", (event) => {
const target = domQuery.getEventTarget(event);
if (this.findContainerIndex(target, event) >= 0) {
return;
}
if (valueOrHandler(this.config.clickOutsideDeactivates, event)) {
return;
}
if (valueOrHandler(this.config.allowOutsideClick, event)) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
});
__publicField(this, "handleTabKey", (event) => {
if (this.config.isKeyForward(event) || this.config.isKeyBackward(event)) {
this.state.recentNavEvent = event;
const isBackward = this.config.isKeyBackward(event);
const destinationNode = this.findNextNavNode({ event, isBackward });
if (!destinationNode) return;
if (isTabEvent(event)) {
event.preventDefault();
}
this.tryFocus(destinationNode);
}
});
__publicField(this, "handleEscapeKey", (event) => {
if (isEscapeEvent(event) && valueOrHandler(this.config.escapeDeactivates, event) !== false) {
event.preventDefault();
this.deactivate();
}
});
__publicField(this, "_mutationObserver");
__publicField(this, "setupMutationObserver", () => {
const win = this.doc.defaultView || window;
this._mutationObserver = new win.MutationObserver((mutations) => {
const isFocusedNodeRemoved = mutations.some((mutation) => {
const removedNodes = Array.from(mutation.removedNodes);
return removedNodes.some((node) => node === this.state.mostRecentlyFocusedNode);
});
if (isFocusedNodeRemoved) {
this.tryFocus(this.getInitialFocusNode());
}
});
});
__publicField(this, "updateObservedNodes", () => {
this._mutationObserver?.disconnect();
if (this.state.active && !this.state.paused) {
this.state.containers.map((container) => {
this._mutationObserver?.observe(container, { subtree: true, childList: true });
});
}
});
__publicField(this, "getInitialFocusNode", () => {
let node = this.getNodeForOption("initialFocus", { hasFallback: true });
if (node === false) {
return false;
}
if (node === void 0 || node && !domQuery.isFocusable(node)) {
if (this.findContainerIndex(this.doc.activeElement) >= 0) {
node = this.doc.activeElement;
} else {
const firstTabbableGroup = this.state.tabbableGroups[0];
const firstTabbableNode = firstTabbableGroup && firstTabbableGroup.firstTabbableNode;
node = firstTabbableNode || this.getNodeForOption("fallbackFocus");
}
} else if (node === null) {
node = this.getNodeForOption("fallbackFocus");
}
if (!node) {
throw new Error("Your focus-trap needs to have at least one focusable element");
}
if (!node.isConnected) {
node = this.getNodeForOption("fallbackFocus");
}
return node;
});
__publicField(this, "tryFocus", (node) => {
if (node === false) return;
if (node === domQuery.getActiveElement(this.doc)) return;
if (!node || !node.focus) {
this.tryFocus(this.getInitialFocusNode());
return;
}
node.focus({ preventScroll: !!this.config.preventScroll });
this.state.mostRecentlyFocusedNode = node;
if (isSelectableInput(node)) {
node.select();
}
});
__publicField(this, "deactivate", (deactivateOptions) => {
if (!this.state.active) return this;
const options = {
onDeactivate: this.config.onDeactivate,
onPostDeactivate: this.config.onPostDeactivate,
checkCanReturnFocus: this.config.checkCanReturnFocus,
...deactivateOptions
};
clearTimeout(this.state.delayInitialFocusTimer);
this.state.delayInitialFocusTimer = void 0;
this.removeListeners();
this.state.active = false;
this.state.paused = false;
this.updateObservedNodes();
activeFocusTraps.deactivateTrap(this.trapStack, this);
const onDeactivate = this.getOption(options, "onDeactivate");
const onPostDeactivate = this.getOption(options, "onPostDeactivate");
const checkCanReturnFocus = this.getOption(options, "checkCanReturnFocus");
const returnFocus = this.getOption(options, "returnFocus", "returnFocusOnDeactivate");
onDeactivate?.();
const finishDeactivation = () => {
delay(() => {
if (returnFocus) {
const returnFocusNode = this.getReturnFocusNode(this.state.nodeFocusedBeforeActivation);
this.tryFocus(returnFocusNode);
}
onPostDeactivate?.();
});
};
if (returnFocus && checkCanReturnFocus) {
const returnFocusNode = this.getReturnFocusNode(this.state.nodeFocusedBeforeActivation);
checkCanReturnFocus(returnFocusNode).then(finishDeactivation, finishDeactivation);
return this;
}
finishDeactivation();
return this;
});
__publicField(this, "pause", (pauseOptions) => {
if (this.state.paused || !this.state.active) {
return this;
}
const onPause = this.getOption(pauseOptions, "onPause");
const onPostPause = this.getOption(pauseOptions, "onPostPause");
this.state.paused = true;
onPause?.();
this.removeListeners();
this.updateObservedNodes();
onPostPause?.();
return this;
});
__publicField(this, "unpause", (unpauseOptions) => {
if (!this.state.paused || !this.state.active) {
return this;
}
const onUnpause = this.getOption(unpauseOptions, "onUnpause");
const onPostUnpause = this.getOption(unpauseOptions, "onPostUnpause");
this.state.paused = false;
onUnpause?.();
this.updateTabbableNodes();
this.addListeners();
this.updateObservedNodes();
onPostUnpause?.();
return this;
});
__publicField(this, "updateContainerElements", (containerElements) => {
this.state.containers = Array.isArray(containerElements) ? containerElements.filter(Boolean) : [containerElements].filter(Boolean);
if (this.state.active) {
this.updateTabbableNodes();
}
this.updateObservedNodes();
return this;
});
__publicField(this, "getReturnFocusNode", (previousActiveElement) => {
const node = this.getNodeForOption("setReturnFocus", {
params: [previousActiveElement]
});
return node ? node : node === false ? false : previousActiveElement;
});
__publicField(this, "getOption", (configOverrideOptions, optionName, configOptionName) => {
return configOverrideOptions && configOverrideOptions[optionName] !== void 0 ? configOverrideOptions[optionName] : (
// @ts-expect-error
this.config[configOptionName || optionName]
);
});
__publicField(this, "getNodeForOption", (optionName, { hasFallback = false, params = [] } = {}) => {
let optionValue = this.config[optionName];
if (typeof optionValue === "function") optionValue = optionValue(...params);
if (optionValue === true) optionValue = void 0;
if (!optionValue) {
if (optionValue === void 0 || optionValue === false) {
return optionValue;
}
throw new Error(`\`${optionName}\` was specified but was not a node, or did not return a node`);
}
let node = optionValue;
if (typeof optionValue === "string") {
try {
node = this.doc.querySelector(optionValue);
} catch (err) {
throw new Error(`\`${optionName}\` appears to be an invalid selector; error="${err.message}"`);
}
if (!node) {
if (!hasFallback) {
throw new Error(`\`${optionName}\` as selector refers to no known node`);
}
}
}
return node;
});
__publicField(this, "findNextNavNode", (opts) => {
const { event, isBackward = false } = opts;
const target = opts.target || domQuery.getEventTarget(event);
this.updateTabbableNodes();
let destinationNode = null;
if (this.state.tabbableGroups.length > 0) {
const containerIndex = this.findContainerIndex(target, event);
const containerGroup = containerIndex >= 0 ? this.state.containerGroups[containerIndex] : void 0;
if (containerIndex < 0) {
if (isBackward) {
destinationNode = this.state.tabbableGroups[this.state.tabbableGroups.length - 1].lastTabbableNode;
} else {
destinationNode = this.state.tabbableGroups[0].firstTabbableNode;
}
} else if (isBackward) {
let startOfGroupIndex = this.state.tabbableGroups.findIndex(
({ firstTabbableNode }) => target === firstTabbableNode
);
if (startOfGroupIndex < 0 && (containerGroup?.container === target || domQuery.isFocusable(target) && !domQuery.isTabbable(target) && !containerGroup?.nextTabbableNode(target, false))) {
startOfGroupIndex = containerIndex;
}
if (startOfGroupIndex >= 0) {
const destinationGroupIndex = startOfGroupIndex === 0 ? this.state.tabbableGroups.length - 1 : startOfGroupIndex - 1;
const destinationGroup = this.state.tabbableGroups[destinationGroupIndex];
destinationNode = domQuery.getTabIndex(target) >= 0 ? destinationGroup.lastTabbableNode : destinationGroup.lastDomTabbableNode;
} else if (!isTabEvent(event)) {
destinationNode = containerGroup?.nextTabbableNode(target, false);
}
} else {
let lastOfGroupIndex = this.state.tabbableGroups.findIndex(
({ lastTabbableNode }) => target === lastTabbableNode
);
if (lastOfGroupIndex < 0 && (containerGroup?.container === target || domQuery.isFocusable(target) && !domQuery.isTabbable(target) && !containerGroup?.nextTabbableNode(target))) {
lastOfGroupIndex = containerIndex;
}
if (lastOfGroupIndex >= 0) {
const destinationGroupIndex = lastOfGroupIndex === this.state.tabbableGroups.length - 1 ? 0 : lastOfGroupIndex + 1;
const destinationGroup = this.state.tabbableGroups[destinationGroupIndex];
destinationNode = domQuery.getTabIndex(target) >= 0 ? destinationGroup.firstTabbableNode : destinationGroup.firstDomTabbableNode;
} else if (!isTabEvent(event)) {
destinationNode = containerGroup?.nextTabbableNode(target);
}
}
} else {
destinationNode = this.getNodeForOption("fallbackFocus");
}
return destinationNode;
});
this.trapStack = options.trapStack || sharedTrapStack;
const config = {
returnFocusOnDeactivate: true,
escapeDeactivates: true,
delayInitialFocus: true,
isKeyForward(e) {
return isTabEvent(e) && !e.shiftKey;
},
isKeyBackward(e) {
return isTabEvent(e) && e.shiftKey;
},
...options
};
this.doc = config.document || domQuery.getDocument(Array.isArray(elements) ? elements[0] : elements);
this.config = config;
this.updateContainerElements(elements);
this.setupMutationObserver();
}
get active() {
return this.state.active;
}
get paused() {
return this.state.paused;
}
findContainerIndex(element, event) {
const composedPath = typeof event?.composedPath === "function" ? event.composedPath() : void 0;
return this.state.containerGroups.findIndex(
({ container, tabbableNodes }) => container.contains(element) || composedPath?.includes(container) || tabbableNodes.find((node) => node === element)
);
}
updateTabbableNodes() {
this.state.containerGroups = this.state.containers.map((container) => {
const tabbableNodes = domQuery.getTabbables(container);
const focusableNodes = domQuery.getFocusables(container);
const firstTabbableNode = tabbableNodes.length > 0 ? tabbableNodes[0] : void 0;
const lastTabbableNode = tabbableNodes.length > 0 ? tabbableNodes[tabbableNodes.length - 1] : void 0;
const firstDomTabbableNode = focusableNodes.find((node) => domQuery.isTabbable(node));
const lastDomTabbableNode = focusableNodes.slice().reverse().find((node) => domQuery.isTabbable(node));
const posTabIndexesFound = !!tabbableNodes.find((node) => domQuery.getTabIndex(node) > 0);
function nextTabbableNode(node, forward = true) {
const nodeIdx = tabbableNodes.indexOf(node);
if (nodeIdx < 0) {
if (forward) {
return focusableNodes.slice(focusableNodes.indexOf(node) + 1).find((el) => domQuery.isTabbable(el));
}
return focusableNodes.slice(0, focusableNodes.indexOf(node)).reverse().find((el) => domQuery.isTabbable(el));
}
return tabbableNodes[nodeIdx + (forward ? 1 : -1)];
}
return {
container,
tabbableNodes,
focusableNodes,
posTabIndexesFound,
firstTabbableNode,
lastTabbableNode,
firstDomTabbableNode,
lastDomTabbableNode,
nextTabbableNode
};
});
this.state.tabbableGroups = this.state.containerGroups.filter((group) => group.tabbableNodes.length > 0);
if (this.state.tabbableGroups.length <= 0 && !this.getNodeForOption("fallbackFocus")) {
throw new Error(
"Your focus-trap must have at least one container with at least one tabbable node in it at all times"
);
}
if (this.state.containerGroups.find((g) => g.posTabIndexesFound) && this.state.containerGroups.length > 1) {
throw new Error(
"At least one node with a positive tabindex was found in one of your focus-trap's multiple containers. Positive tabindexes are only supported in single-container focus-traps."
);
}
}
addListeners() {
if (!this.state.active) return;
activeFocusTraps.activateTrap(this.trapStack, this);
this.state.delayInitialFocusTimer = this.config.delayInitialFocus ? delay(() => {
this.tryFocus(this.getInitialFocusNode());
}) : this.tryFocus(this.getInitialFocusNode());
this.listenerCleanups.push(
domQuery.addDomEvent(this.doc, "focusin", this.handleFocus, true),
domQuery.addDomEvent(this.doc, "mousedown", this.handlePointerDown, { capture: true, passive: false }),
domQuery.addDomEvent(this.doc, "touchstart", this.handlePointerDown, { capture: true, passive: false }),
domQuery.addDomEvent(this.doc, "click", this.handleClick, { capture: true, passive: false }),
domQuery.addDomEvent(this.doc, "keydown", this.handleTabKey, { capture: true, passive: false }),
domQuery.addDomEvent(this.doc, "keydown", this.handleEscapeKey)
);
return this;
}
removeListeners() {
if (!this.state.active) return;
this.listenerCleanups.forEach((cleanup) => cleanup());
this.listenerCleanups = [];
return this;
}
activate(activateOptions) {
if (this.state.active) {
return this;
}
const onActivate = this.getOption(activateOptions, "onActivate");
const onPostActivate = this.getOption(activateOptions, "onPostActivate");
const checkCanFocusTrap = this.getOption(activateOptions, "checkCanFocusTrap");
if (!checkCanFocusTrap) {
this.updateTabbableNodes();
}
this.state.active = true;
this.state.paused = false;
this.state.nodeFocusedBeforeActivation = this.doc.activeElement || null;
onActivate?.();
const finishActivation = () => {
if (checkCanFocusTrap) {
this.updateTabbableNodes();
}
this.addListeners();
this.updateObservedNodes();
onPostActivate?.();
};
if (checkCanFocusTrap) {
checkCanFocusTrap(this.state.containers.concat()).then(finishActivation, finishActivation);
return this;
}
finishActivation();
return this;
}
};
var isTabEvent = (event) => event.key === "Tab";
var valueOrHandler = (value, ...params) => typeof value === "function" ? value(...params) : value;
var isEscapeEvent = (event) => !event.isComposing && event.key === "Escape";
var delay = (fn) => setTimeout(fn, 0);
var isSelectableInput = (node) => node.localName === "input" && "select" in node && typeof node.select === "function";
// src/index.ts
function trapFocus(el, options = {}) {
let trap;
const cleanup = domQuery.raf(() => {
const contentEl = typeof el === "function" ? el() : el;
if (!contentEl) return;
trap = new FocusTrap(contentEl, {
escapeDeactivates: false,
allowOutsideClick: true,
preventScroll: true,
returnFocusOnDeactivate: true,
delayInitialFocus: false,
fallbackFocus: contentEl,
...options,
document: domQuery.getDocument(contentEl)
});
try {
trap.activate();
} catch {
}
});
return function destroy() {
trap?.deactivate();
cleanup();
};
}
exports.FocusTrap = FocusTrap;
exports.trapFocus = trapFocus;