@zag-js/focus-trap
Version:
Focus trap utility
605 lines (603 loc) • 25.6 kB
JavaScript
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
// src/focus-trap.ts
var focus_trap_exports = {};
__export(focus_trap_exports, {
FocusTrap: () => FocusTrap
});
module.exports = __toCommonJS(focus_trap_exports);
var import_dom_query = require("@zag-js/dom-query");
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
});
// Track portal containers that contain controlled elements
__publicField(this, "portalContainers", /* @__PURE__ */ new Set());
__publicField(this, "listenerCleanups", []);
__publicField(this, "handleFocus", (event) => {
const target = (0, import_dom_query.getEventTarget)(event);
const targetContained = this.findContainerIndex(target, event) >= 0;
if (targetContained || (0, import_dom_query.isDocument)(target)) {
if (targetContained) {
this.state.mostRecentlyFocusedNode = target;
}
} else {
event.stopImmediatePropagation();
let nextNode;
let navAcrossContainers = true;
if (this.state.mostRecentlyFocusedNode) {
if ((0, import_dom_query.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) => (0, import_dom_query.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 = (0, import_dom_query.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 = (0, import_dom_query.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());
}
const hasControlledChanges = mutations.some((mutation) => {
if (mutation.type === "attributes" && (mutation.attributeName === "aria-controls" || mutation.attributeName === "aria-expanded")) {
return true;
}
if (mutation.type === "childList" && mutation.addedNodes.length > 0) {
return Array.from(mutation.addedNodes).some((node) => {
if (node.nodeType !== Node.ELEMENT_NODE) return false;
const element = node;
if ((0, import_dom_query.hasControllerElements)(element)) {
return true;
}
if (element.id && !this.state.containers.some((c) => c.contains(element))) {
return (0, import_dom_query.isControlledByExpandedController)(element);
}
return false;
});
}
return false;
});
if (hasControlledChanges && this.state.active && !this.state.paused) {
this.updateTabbableNodes();
this.updatePortalContainers();
}
});
});
__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,
attributes: true,
attributeFilter: ["aria-controls", "aria-expanded"]
});
});
this.portalContainers.forEach((portalContainer) => {
this.observePortalContainer(portalContainer);
});
}
});
__publicField(this, "getInitialFocusNode", () => {
let node = this.getNodeForOption("initialFocus", { hasFallback: true });
if (node === false) {
return false;
}
if (node === void 0 || node && !(0, import_dom_query.isFocusable)(node)) {
const activeElement = (0, import_dom_query.getActiveElement)(this.doc);
if (activeElement && this.findContainerIndex(activeElement) >= 0) {
node = 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");
}
if (!node || !node.isConnected) {
throw new Error("Your focus-trap needs to have at least one focusable element");
}
return node;
});
__publicField(this, "tryFocus", (node) => {
if (node === false) return;
if (node === (0, import_dom_query.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);
this.portalContainers.clear();
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 || (0, import_dom_query.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 || (0, import_dom_query.isFocusable)(target) && !(0, import_dom_query.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 = (0, import_dom_query.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 || (0, import_dom_query.isFocusable)(target) && !(0, import_dom_query.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 = (0, import_dom_query.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,
followControlledElements: true,
isKeyForward,
isKeyBackward,
...options
};
this.doc = config.document || (0, import_dom_query.getDocument)(Array.isArray(elements) ? elements[0] : elements);
this.config = config;
this.updateContainerElements(elements);
this.setupMutationObserver();
}
addPortalContainer(controlledElement) {
const portalContainer = controlledElement.parentElement;
if (portalContainer && !this.portalContainers.has(portalContainer)) {
this.portalContainers.add(portalContainer);
if (this.state.active && !this.state.paused) {
this.observePortalContainer(portalContainer);
}
}
}
observePortalContainer(portalContainer) {
this._mutationObserver?.observe(portalContainer, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ["aria-controls", "aria-expanded"]
});
}
updatePortalContainers() {
if (!this.config.followControlledElements) return;
this.state.containers.forEach((container) => {
const controlledElements = (0, import_dom_query.getControlledElements)(container);
controlledElements.forEach((controlledElement) => {
this.addPortalContainer(controlledElement);
});
});
}
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) || this.isControlledElement(container, element)
);
}
isControlledElement(container, element) {
if (!this.config.followControlledElements) return false;
return (0, import_dom_query.isControlledElement)(container, element);
}
updateTabbableNodes() {
this.state.containerGroups = this.state.containers.map((container) => {
const tabbableNodes = (0, import_dom_query.getTabbables)(container, { getShadowRoot: this.config.getShadowRoot });
const focusableNodes = (0, import_dom_query.getFocusables)(container, { getShadowRoot: this.config.getShadowRoot });
const firstTabbableNode = tabbableNodes[0];
const lastTabbableNode = tabbableNodes[tabbableNodes.length - 1];
const firstDomTabbableNode = firstTabbableNode;
const lastDomTabbableNode = lastTabbableNode;
let posTabIndexesFound = false;
for (let i = 0; i < tabbableNodes.length; i++) {
if ((0, import_dom_query.getTabIndex)(tabbableNodes[i]) > 0) {
posTabIndexesFound = true;
break;
}
}
function nextTabbableNode(node, forward = true) {
const nodeIdx = tabbableNodes.indexOf(node);
if (nodeIdx >= 0) {
return tabbableNodes[nodeIdx + (forward ? 1 : -1)];
}
const focusableIdx = focusableNodes.indexOf(node);
if (focusableIdx < 0) return void 0;
if (forward) {
for (let i = focusableIdx + 1; i < focusableNodes.length; i++) {
if ((0, import_dom_query.isTabbable)(focusableNodes[i])) return focusableNodes[i];
}
} else {
for (let i = focusableIdx - 1; i >= 0; i--) {
if ((0, import_dom_query.isTabbable)(focusableNodes[i])) return focusableNodes[i];
}
}
return void 0;
}
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(
(0, import_dom_query.addDomEvent)(this.doc, "focusin", this.handleFocus, true),
(0, import_dom_query.addDomEvent)(this.doc, "mousedown", this.handlePointerDown, { capture: true, passive: false }),
(0, import_dom_query.addDomEvent)(this.doc, "touchstart", this.handlePointerDown, { capture: true, passive: false }),
(0, import_dom_query.addDomEvent)(this.doc, "click", this.handleClick, { capture: true, passive: false }),
(0, import_dom_query.addDomEvent)(this.doc, "keydown", this.handleTabKey, { capture: true, passive: false }),
(0, import_dom_query.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 = (0, import_dom_query.getActiveElement)(this.doc);
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 isKeyboardEvent = (event) => event?.type === "keydown";
var isTabEvent = (event) => isKeyboardEvent(event) && event?.key === "Tab";
var isKeyForward = (e) => isKeyboardEvent(e) && e.key === "Tab" && !e?.shiftKey;
var isKeyBackward = (e) => isKeyboardEvent(e) && e.key === "Tab" && e?.shiftKey;
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";
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
FocusTrap
});