UNPKG

@zag-js/focus-trap

Version:
528 lines (523 loc) 21.5 kB
'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;