@zag-js/focus-trap
Advanced tools
Comparing version 0.79.1 to 0.79.2
@@ -1,9 +0,234 @@ | ||
import { Options } from 'focus-trap'; | ||
export { FocusTrap, createFocusTrap } from 'focus-trap'; | ||
type FocusableElement = HTMLElement | SVGElement; | ||
type FocusTargetValue = FocusableElement | string; | ||
type FocusTargetValueOrFalse = FocusTargetValue | false; | ||
/** | ||
* A DOM node, a selector string (which will be passed to | ||
* `document.querySelector()` to find the DOM node), or a function that | ||
* returns a DOM node. | ||
*/ | ||
type FocusTarget = FocusTargetValue | (() => FocusTargetValue); | ||
/** | ||
* A DOM node, a selector string (which will be passed to | ||
* `document.querySelector()` to find the DOM node), `false` to explicitly indicate | ||
* an opt-out, or a function that returns a DOM node or `false`. | ||
*/ | ||
type FocusTargetOrFalse = FocusTargetValueOrFalse | (() => FocusTargetValueOrFalse); | ||
type MouseEventToBoolean = (event: MouseEvent | TouchEvent) => boolean; | ||
type KeyboardEventToBoolean = (event: KeyboardEvent) => boolean; | ||
interface FocusTrapOptions { | ||
/** | ||
* A function that will be called **before** sending focus to the | ||
* target element upon activation. | ||
*/ | ||
onActivate?: VoidFunction; | ||
/** | ||
* A function that will be called **after** focus has been sent to the | ||
* target element upon activation. | ||
*/ | ||
onPostActivate?: VoidFunction; | ||
/** | ||
* A function that will be called immediately after the trap's state is updated to be paused. | ||
*/ | ||
onPause?: VoidFunction; | ||
/** | ||
* A function that will be called after the trap has been completely paused and is no longer | ||
* managing/trapping focus. | ||
*/ | ||
onPostPause?: VoidFunction; | ||
/** | ||
* A function that will be called immediately after the trap's state is updated to be active | ||
* again, but prior to updating its knowledge of what nodes are tabbable within its containers, | ||
* and prior to actively managing/trapping focus. | ||
*/ | ||
onUnpause?: VoidFunction; | ||
/** | ||
* A function that will be called after the trap has been completely unpaused and is once | ||
* again managing/trapping focus. | ||
*/ | ||
onPostUnpause?: VoidFunction; | ||
/** | ||
* A function for determining if it is safe to send focus to the focus trap | ||
* or not. | ||
* | ||
* It should return a promise that only resolves once all the listed `containers` | ||
* are able to receive focus. | ||
* | ||
* The purpose of this is to prevent early focus-trap activation on animated | ||
* dialogs that fade in and out. When a dialog fades in, there is a brief delay | ||
* between the activation of the trap and the trap element being focusable. | ||
*/ | ||
checkCanFocusTrap?: (containers: Array<HTMLElement | SVGElement>) => Promise<void>; | ||
/** | ||
* A function that will be called **before** sending focus to the | ||
* trigger element upon deactivation. | ||
*/ | ||
onDeactivate?: VoidFunction; | ||
/** | ||
* A function that will be called after the trap is deactivated, after `onDeactivate`. | ||
* If `returnFocus` was set, it will be called **after** focus has been sent to the trigger | ||
* element upon deactivation; otherwise, it will be called after deactivation completes. | ||
*/ | ||
onPostDeactivate?: VoidFunction; | ||
/** | ||
* A function for determining if it is safe to send focus back to the `trigger` element. | ||
* | ||
* It should return a promise that only resolves once `trigger` is focusable. | ||
* | ||
* The purpose of this is to prevent the focus being sent to an animated trigger element too early. | ||
* If a trigger element fades in upon trap deactivation, there is a brief delay between the deactivation | ||
* of the trap and when the trigger element is focusable. | ||
* | ||
* `trigger` will be either the node that had focus prior to the trap being activated, | ||
* or the result of the `setReturnFocus` option, if configured. | ||
* | ||
* This handler is **not** called if the `returnFocusOnDeactivate` configuration option | ||
* (or the `returnFocus` deactivation option) is falsy. | ||
*/ | ||
checkCanReturnFocus?: (trigger: HTMLElement | SVGElement) => Promise<void>; | ||
/** | ||
* By default, when a focus trap is activated the first element in the | ||
* focus trap's tab order will receive focus. With this option you can | ||
* specify a different element to receive that initial focus, or use `false` | ||
* for no initially focused element at all. | ||
* | ||
* NOTE: Setting this option to `false` (or a function that returns `false`) | ||
* will prevent the `fallbackFocus` option from being used. | ||
* | ||
* Setting this option to `undefined` (or a function that returns `undefined`) | ||
* will result in the default behavior. | ||
*/ | ||
initialFocus?: FocusTargetOrFalse | undefined | VoidFunction; | ||
/** | ||
* By default, an error will be thrown if the focus trap contains no | ||
* elements in its tab order. With this option you can specify a | ||
* fallback element to programmatically receive focus if no other | ||
* tabbable elements are found. For example, you may want a popover's | ||
* `<div>` to receive focus if the popover's content includes no | ||
* tabbable elements. *Make sure the fallback element has a negative | ||
* `tabindex` so it can be programmatically focused. | ||
* | ||
* NOTE: If `initialFocus` is `false` (or a function that returns `false`), | ||
* this function will not be called when the trap is activated, and no element | ||
* will be initially focused. This function may still be called while the trap | ||
* is active if things change such that there are no longer any tabbable nodes | ||
* in the trap. | ||
*/ | ||
fallbackFocus?: FocusTarget; | ||
/** | ||
* Default: `true`. If `false`, when the trap is deactivated, | ||
* focus will *not* return to the element that had focus before activation. | ||
*/ | ||
returnFocusOnDeactivate?: boolean; | ||
/** | ||
* By default, focus trap on deactivation will return to the element | ||
* that was focused before activation. | ||
*/ | ||
setReturnFocus?: FocusTargetValueOrFalse | ((nodeFocusedBeforeActivation: HTMLElement | SVGElement) => FocusTargetValueOrFalse); | ||
/** | ||
* Default: `true`. If `false` or returns `false`, the `Escape` key will not trigger | ||
* deactivation of the focus trap. This can be useful if you want | ||
* to force the user to make a decision instead of allowing an easy | ||
* way out. Note that if a function is given, it's only called if the ESC key | ||
* was pressed. | ||
*/ | ||
escapeDeactivates?: boolean | KeyboardEventToBoolean; | ||
/** | ||
* If `true` or returns `true`, a click outside the focus trap will | ||
* deactivate the focus trap and allow the click event to do its thing (i.e. | ||
* to pass-through to the element that was clicked). This option **takes | ||
* precedence** over `allowOutsideClick` when it's set to `true`, causing | ||
* that option to be ignored. Default: `false`. | ||
*/ | ||
clickOutsideDeactivates?: boolean | MouseEventToBoolean; | ||
/** | ||
* If set and is or returns `true`, a click outside the focus trap will not | ||
* be prevented, even when `clickOutsideDeactivates` is `false`. When | ||
* `clickOutsideDeactivates` is `true`, this option is **ignored** (i.e. | ||
* if it's a function, it will not be called). Use this option to control | ||
* if (and even which) clicks are allowed outside the trap in conjunction | ||
* with `clickOutsideDeactivates: false`. Default: `false`. | ||
*/ | ||
allowOutsideClick?: boolean | MouseEventToBoolean; | ||
/** | ||
* By default, focus() will scroll to the element if not in viewport. | ||
* It can produce unintended effects like scrolling back to the top of a modal. | ||
* If set to `true`, no scroll will happen. | ||
*/ | ||
preventScroll?: boolean; | ||
/** | ||
* Default: `true`. Delays the autofocus when the focus trap is activated. | ||
* This prevents elements within the focusable element from capturing | ||
* the event that triggered the focus trap activation. | ||
*/ | ||
delayInitialFocus?: boolean; | ||
/** | ||
* Default: `window.document`. Document where the focus trap will be active. | ||
* This allows to use FocusTrap in an iFrame context. | ||
*/ | ||
document?: Document; | ||
/** | ||
* Determines if the given keyboard event is a "tab forward" event that will move | ||
* the focus to the next trapped element in tab order. Defaults to the `TAB` key. | ||
* Use this to override the trap's behavior if you want to use arrow keys to control | ||
* keyboard navigation within the trap, for example. Also see `isKeyBackward()` option. | ||
*/ | ||
isKeyForward?: KeyboardEventToBoolean; | ||
/** | ||
* Determines if the given keyboard event is a "tab backward" event that will move | ||
* the focus to the previous trapped element in tab order. Defaults to the `SHIFT+TAB` key. | ||
* Use this to override the trap's behavior if you want to use arrow keys to control | ||
* keyboard navigation within the trap, for example. Also see `isKeyForward()` option. | ||
*/ | ||
isKeyBackward?: KeyboardEventToBoolean; | ||
/** | ||
* Default: `[]`. An array of FocusTrap instances that will be managed by this FocusTrap. | ||
*/ | ||
trapStack?: any[]; | ||
} | ||
interface DeactivateOptions extends Pick<FocusTrapOptions, "onDeactivate" | "onPostDeactivate" | "checkCanReturnFocus"> { | ||
returnFocus?: boolean | undefined; | ||
} | ||
type ActivateOptions = Pick<FocusTrapOptions, "onActivate" | "onPostActivate" | "checkCanFocusTrap">; | ||
type PauseOptions = Pick<FocusTrapOptions, "onPause" | "onPostPause">; | ||
type UnpauseOptions = Pick<FocusTrapOptions, "onUnpause" | "onPostUnpause">; | ||
declare class FocusTrap { | ||
private trapStack; | ||
private config; | ||
private doc; | ||
private state; | ||
get active(): boolean; | ||
get paused(): boolean; | ||
constructor(elements: HTMLElement | HTMLElement[], options: FocusTrapOptions); | ||
private findContainerIndex; | ||
private updateTabbableNodes; | ||
private listenerCleanups; | ||
private addListeners; | ||
private removeListeners; | ||
private handleFocus; | ||
private handlePointerDown; | ||
private handleClick; | ||
private handleTabKey; | ||
private handleEscapeKey; | ||
private _mutationObserver?; | ||
private setupMutationObserver; | ||
private updateObservedNodes; | ||
private getInitialFocusNode; | ||
private tryFocus; | ||
activate(activateOptions?: ActivateOptions): this; | ||
deactivate: (deactivateOptions?: DeactivateOptions) => this; | ||
pause: (pauseOptions?: PauseOptions) => this; | ||
unpause: (unpauseOptions?: UnpauseOptions) => this; | ||
updateContainerElements: (containerElements: HTMLElement | HTMLElement[]) => this; | ||
private getReturnFocusNode; | ||
private getOption; | ||
private getNodeForOption; | ||
private findNextNavNode; | ||
} | ||
type ElementOrGetter = HTMLElement | null | (() => HTMLElement | null); | ||
interface TrapFocusOptions extends Omit<Options, "document"> { | ||
interface TrapFocusOptions extends Omit<FocusTrapOptions, "document"> { | ||
} | ||
declare function trapFocus(el: ElementOrGetter, options?: TrapFocusOptions): () => void; | ||
export { type TrapFocusOptions, trapFocus }; | ||
export { FocusTrap, type FocusTrapOptions, type TrapFocusOptions, trapFocus }; |
'use strict'; | ||
var domQuery = require('@zag-js/dom-query'); | ||
var focusTrap = require('focus-trap'); | ||
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; | ||
domQuery.raf(() => { | ||
const cleanup = domQuery.raf(() => { | ||
const contentEl = typeof el === "function" ? el() : el; | ||
if (!contentEl) return; | ||
trap = focusTrap.createFocusTrap(contentEl, { | ||
trap = new FocusTrap(contentEl, { | ||
escapeDeactivates: false, | ||
@@ -29,9 +522,7 @@ allowOutsideClick: true, | ||
trap?.deactivate(); | ||
cleanup(); | ||
}; | ||
} | ||
Object.defineProperty(exports, "createFocusTrap", { | ||
enumerable: true, | ||
get: function () { return focusTrap.createFocusTrap; } | ||
}); | ||
exports.FocusTrap = FocusTrap; | ||
exports.trapFocus = trapFocus; |
{ | ||
"name": "@zag-js/focus-trap", | ||
"version": "0.79.1", | ||
"version": "0.79.2", | ||
"description": "Focus trap utility", | ||
@@ -27,4 +27,3 @@ "keywords": [ | ||
"dependencies": { | ||
"focus-trap": "7.6.2", | ||
"@zag-js/dom-query": "0.79.1" | ||
"@zag-js/dom-query": "0.79.2" | ||
}, | ||
@@ -31,0 +30,0 @@ "devDependencies": { |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
67009
1
1275
1
+ Added@zag-js/dom-query@0.79.2(transitive)
- Removedfocus-trap@7.6.2
- Removed@zag-js/dom-query@0.79.1(transitive)
- Removedfocus-trap@7.6.2(transitive)
- Removedtabbable@6.2.0(transitive)
Updated@zag-js/dom-query@0.79.2