@destyler/focus-trap 0.0.7-beta.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023-PRESENT Elone Hoo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,236 @@
1
+ //#region src/types.d.ts
2
+ type FocusableElement = HTMLElement | SVGElement;
3
+ type FocusTargetValue = FocusableElement | string;
4
+ type FocusTargetValueOrFalse = FocusTargetValue | false;
5
+ /**
6
+ * A DOM node, a selector string (which will be passed to
7
+ * `document.querySelector()` to find the DOM node), or a function that
8
+ * returns a DOM node.
9
+ */
10
+ type FocusTarget = FocusTargetValue | (() => FocusTargetValue);
11
+ /**
12
+ * A DOM node, a selector string (which will be passed to
13
+ * `document.querySelector()` to find the DOM node), `false` to explicitly indicate
14
+ * an opt-out, or a function that returns a DOM node or `false`.
15
+ */
16
+ type FocusTargetOrFalse = FocusTargetValueOrFalse | (() => FocusTargetValueOrFalse);
17
+ type MouseEventToBoolean = (event: MouseEvent | TouchEvent) => boolean;
18
+ type KeyboardEventToBoolean = (event: KeyboardEvent) => boolean;
19
+ interface FocusTrapOptions {
20
+ /**
21
+ * A function that will be called **before** sending focus to the
22
+ * target element upon activation.
23
+ */
24
+ onActivate?: VoidFunction;
25
+ /**
26
+ * A function that will be called **after** focus has been sent to the
27
+ * target element upon activation.
28
+ */
29
+ onPostActivate?: VoidFunction;
30
+ /**
31
+ * A function that will be called immediately after the trap's state is updated to be paused.
32
+ */
33
+ onPause?: VoidFunction;
34
+ /**
35
+ * A function that will be called after the trap has been completely paused and is no longer
36
+ * managing/trapping focus.
37
+ */
38
+ onPostPause?: VoidFunction;
39
+ /**
40
+ * A function that will be called immediately after the trap's state is updated to be active
41
+ * again, but prior to updating its knowledge of what nodes are tabbable within its containers,
42
+ * and prior to actively managing/trapping focus.
43
+ */
44
+ onUnpause?: VoidFunction;
45
+ /**
46
+ * A function that will be called after the trap has been completely unpaused and is once
47
+ * again managing/trapping focus.
48
+ */
49
+ onPostUnpause?: VoidFunction;
50
+ /**
51
+ * A function for determining if it is safe to send focus to the focus trap
52
+ * or not.
53
+ *
54
+ * It should return a promise that only resolves once all the listed `containers`
55
+ * are able to receive focus.
56
+ *
57
+ * The purpose of this is to prevent early focus-trap activation on animated
58
+ * dialogs that fade in and out. When a dialog fades in, there is a brief delay
59
+ * between the activation of the trap and the trap element being focusable.
60
+ */
61
+ checkCanFocusTrap?: (containers: Array<HTMLElement | SVGElement>) => Promise<void>;
62
+ /**
63
+ * A function that will be called **before** sending focus to the
64
+ * trigger element upon deactivation.
65
+ */
66
+ onDeactivate?: VoidFunction;
67
+ /**
68
+ * A function that will be called after the trap is deactivated, after `onDeactivate`.
69
+ * If `returnFocus` was set, it will be called **after** focus has been sent to the trigger
70
+ * element upon deactivation; otherwise, it will be called after deactivation completes.
71
+ */
72
+ onPostDeactivate?: VoidFunction;
73
+ /**
74
+ * A function for determining if it is safe to send focus back to the `trigger` element.
75
+ *
76
+ * It should return a promise that only resolves once `trigger` is focusable.
77
+ *
78
+ * The purpose of this is to prevent the focus being sent to an animated trigger element too early.
79
+ * If a trigger element fades in upon trap deactivation, there is a brief delay between the deactivation
80
+ * of the trap and when the trigger element is focusable.
81
+ *
82
+ * `trigger` will be either the node that had focus prior to the trap being activated,
83
+ * or the result of the `setReturnFocus` option, if configured.
84
+ *
85
+ * This handler is **not** called if the `returnFocusOnDeactivate` configuration option
86
+ * (or the `returnFocus` deactivation option) is falsy.
87
+ */
88
+ checkCanReturnFocus?: (trigger: HTMLElement | SVGElement) => Promise<void>;
89
+ /**
90
+ * By default, when a focus trap is activated the first element in the
91
+ * focus trap's tab order will receive focus. With this option you can
92
+ * specify a different element to receive that initial focus, or use `false`
93
+ * for no initially focused element at all.
94
+ *
95
+ * NOTE: Setting this option to `false` (or a function that returns `false`)
96
+ * will prevent the `fallbackFocus` option from being used.
97
+ *
98
+ * Setting this option to `undefined` (or a function that returns `undefined`)
99
+ * will result in the default behavior.
100
+ */
101
+ initialFocus?: FocusTargetOrFalse | undefined | VoidFunction;
102
+ /**
103
+ * By default, an error will be thrown if the focus trap contains no
104
+ * elements in its tab order. With this option you can specify a
105
+ * fallback element to programmatically receive focus if no other
106
+ * tabbable elements are found. For example, you may want a popover's
107
+ * `<div>` to receive focus if the popover's content includes no
108
+ * tabbable elements. *Make sure the fallback element has a negative
109
+ * `tabindex` so it can be programmatically focused.
110
+ *
111
+ * NOTE: If `initialFocus` is `false` (or a function that returns `false`),
112
+ * this function will not be called when the trap is activated, and no element
113
+ * will be initially focused. This function may still be called while the trap
114
+ * is active if things change such that there are no longer any tabbable nodes
115
+ * in the trap.
116
+ */
117
+ fallbackFocus?: FocusTarget;
118
+ /**
119
+ * Default: `true`. If `false`, when the trap is deactivated,
120
+ * focus will *not* return to the element that had focus before activation.
121
+ */
122
+ returnFocusOnDeactivate?: boolean;
123
+ /**
124
+ * By default, focus trap on deactivation will return to the element
125
+ * that was focused before activation.
126
+ */
127
+ setReturnFocus?: FocusTargetValueOrFalse | ((nodeFocusedBeforeActivation: HTMLElement | SVGElement) => FocusTargetValueOrFalse);
128
+ /**
129
+ * Default: `true`. If `false` or returns `false`, the `Escape` key will not trigger
130
+ * deactivation of the focus trap. This can be useful if you want
131
+ * to force the user to make a decision instead of allowing an easy
132
+ * way out. Note that if a function is given, it's only called if the ESC key
133
+ * was pressed.
134
+ */
135
+ escapeDeactivates?: boolean | KeyboardEventToBoolean;
136
+ /**
137
+ * If `true` or returns `true`, a click outside the focus trap will
138
+ * deactivate the focus trap and allow the click event to do its thing (i.e.
139
+ * to pass-through to the element that was clicked). This option **takes
140
+ * precedence** over `allowOutsideClick` when it's set to `true`, causing
141
+ * that option to be ignored. Default: `false`.
142
+ */
143
+ clickOutsideDeactivates?: boolean | MouseEventToBoolean;
144
+ /**
145
+ * If set and is or returns `true`, a click outside the focus trap will not
146
+ * be prevented, even when `clickOutsideDeactivates` is `false`. When
147
+ * `clickOutsideDeactivates` is `true`, this option is **ignored** (i.e.
148
+ * if it's a function, it will not be called). Use this option to control
149
+ * if (and even which) clicks are allowed outside the trap in conjunction
150
+ * with `clickOutsideDeactivates: false`. Default: `false`.
151
+ */
152
+ allowOutsideClick?: boolean | MouseEventToBoolean;
153
+ /**
154
+ * By default, focus() will scroll to the element if not in viewport.
155
+ * It can produce unintended effects like scrolling back to the top of a modal.
156
+ * If set to `true`, no scroll will happen.
157
+ */
158
+ preventScroll?: boolean;
159
+ /**
160
+ * Default: `true`. Delays the autofocus when the focus trap is activated.
161
+ * This prevents elements within the focusable element from capturing
162
+ * the event that triggered the focus trap activation.
163
+ */
164
+ delayInitialFocus?: boolean;
165
+ /**
166
+ * Default: `window.document`. Document where the focus trap will be active.
167
+ * This allows to use FocusTrap in an iFrame context.
168
+ */
169
+ document?: Document;
170
+ /**
171
+ * Determines if the given keyboard event is a "tab forward" event that will move
172
+ * the focus to the next trapped element in tab order. Defaults to the `TAB` key.
173
+ * Use this to override the trap's behavior if you want to use arrow keys to control
174
+ * keyboard navigation within the trap, for example. Also see `isKeyBackward()` option.
175
+ */
176
+ isKeyForward?: KeyboardEventToBoolean;
177
+ /**
178
+ * Determines if the given keyboard event is a "tab backward" event that will move
179
+ * the focus to the previous trapped element in tab order. Defaults to the `SHIFT+TAB` key.
180
+ * Use this to override the trap's behavior if you want to use arrow keys to control
181
+ * keyboard navigation within the trap, for example. Also see `isKeyForward()` option.
182
+ */
183
+ isKeyBackward?: KeyboardEventToBoolean;
184
+ /**
185
+ * Default: `[]`. An array of FocusTrap instances that will be managed by this FocusTrap.
186
+ */
187
+ trapStack?: any[];
188
+ }
189
+ interface DeactivateOptions extends Pick<FocusTrapOptions, 'onDeactivate' | 'onPostDeactivate' | 'checkCanReturnFocus'> {
190
+ returnFocus?: boolean | undefined;
191
+ }
192
+ type ActivateOptions = Pick<FocusTrapOptions, 'onActivate' | 'onPostActivate' | 'checkCanFocusTrap'>;
193
+ type PauseOptions = Pick<FocusTrapOptions, 'onPause' | 'onPostPause'>;
194
+ type UnpauseOptions = Pick<FocusTrapOptions, 'onUnpause' | 'onPostUnpause'>;
195
+ //#endregion
196
+ //#region src/focus-trap.d.ts
197
+ declare class FocusTrap {
198
+ private trapStack;
199
+ private config;
200
+ private doc;
201
+ private state;
202
+ get active(): boolean;
203
+ get paused(): boolean;
204
+ constructor(elements: HTMLElement | HTMLElement[], options: FocusTrapOptions);
205
+ private findContainerIndex;
206
+ private updateTabbableNodes;
207
+ private listenerCleanups;
208
+ private addListeners;
209
+ private removeListeners;
210
+ private handleFocus;
211
+ private handlePointerDown;
212
+ private handleClick;
213
+ private handleTabKey;
214
+ private handleEscapeKey;
215
+ private _mutationObserver?;
216
+ private setupMutationObserver;
217
+ private updateObservedNodes;
218
+ private getInitialFocusNode;
219
+ private tryFocus;
220
+ activate(activateOptions?: ActivateOptions): this;
221
+ deactivate: (deactivateOptions?: DeactivateOptions) => this;
222
+ pause: (pauseOptions?: PauseOptions) => this;
223
+ unpause: (unpauseOptions?: UnpauseOptions) => this;
224
+ updateContainerElements: (containerElements: HTMLElement | HTMLElement[]) => this;
225
+ private getReturnFocusNode;
226
+ private getOption;
227
+ private getNodeForOption;
228
+ private findNextNavNode;
229
+ }
230
+ //#endregion
231
+ //#region index.d.ts
232
+ type ElementOrGetter = HTMLElement | null | (() => HTMLElement | null);
233
+ interface TrapFocusOptions extends Omit<FocusTrapOptions, 'document'> {}
234
+ declare function trapFocus(el: ElementOrGetter, options?: TrapFocusOptions): () => void;
235
+ //#endregion
236
+ export { FocusTrap, type FocusTrapOptions, TrapFocusOptions, trapFocus };
package/dist/index.js ADDED
@@ -0,0 +1,433 @@
1
+ import { addDomEvent, getActiveElement, getDocument, getEventTarget, getFocusables, getTabIndex, getTabbables, isDocument, isFocusable, isTabbable, raf } from "@destyler/dom";
2
+
3
+ //#region src/focus-trap.ts
4
+ const isTabEvent = (event) => event.key === "Tab";
5
+ const valueOrHandler = (value, ...params) => typeof value === "function" ? value(...params) : value;
6
+ const isEscapeEvent = (event) => !event.isComposing && event.key === "Escape";
7
+ const delay = (fn) => setTimeout(fn, 0);
8
+ function isSelectableInput(node) {
9
+ return node.localName === "input" && "select" in node && typeof node.select === "function";
10
+ }
11
+ const activeFocusTraps = {
12
+ activateTrap(trapStack, trap) {
13
+ if (trapStack.length > 0) {
14
+ const activeTrap = trapStack[trapStack.length - 1];
15
+ if (activeTrap !== trap) activeTrap.pause();
16
+ }
17
+ const trapIndex = trapStack.indexOf(trap);
18
+ if (trapIndex === -1) trapStack.push(trap);
19
+ else {
20
+ trapStack.splice(trapIndex, 1);
21
+ trapStack.push(trap);
22
+ }
23
+ },
24
+ deactivateTrap(trapStack, trap) {
25
+ const trapIndex = trapStack.indexOf(trap);
26
+ if (trapIndex !== -1) trapStack.splice(trapIndex, 1);
27
+ if (trapStack.length > 0) trapStack[trapStack.length - 1].unpause();
28
+ }
29
+ };
30
+ const sharedTrapStack = [];
31
+ var FocusTrap = class {
32
+ trapStack;
33
+ config;
34
+ doc;
35
+ state = {
36
+ containers: [],
37
+ containerGroups: [],
38
+ tabbableGroups: [],
39
+ nodeFocusedBeforeActivation: null,
40
+ mostRecentlyFocusedNode: null,
41
+ active: false,
42
+ paused: false,
43
+ delayInitialFocusTimer: void 0,
44
+ recentNavEvent: void 0
45
+ };
46
+ get active() {
47
+ return this.state.active;
48
+ }
49
+ get paused() {
50
+ return this.state.paused;
51
+ }
52
+ constructor(elements, options) {
53
+ this.trapStack = options.trapStack || sharedTrapStack;
54
+ const config = {
55
+ returnFocusOnDeactivate: true,
56
+ escapeDeactivates: true,
57
+ delayInitialFocus: true,
58
+ isKeyForward(e) {
59
+ return isTabEvent(e) && !e.shiftKey;
60
+ },
61
+ isKeyBackward(e) {
62
+ return isTabEvent(e) && e.shiftKey;
63
+ },
64
+ ...options
65
+ };
66
+ this.doc = config.document || getDocument(Array.isArray(elements) ? elements[0] : elements);
67
+ this.config = config;
68
+ this.updateContainerElements(elements);
69
+ this.setupMutationObserver();
70
+ }
71
+ findContainerIndex(element, event) {
72
+ const composedPath = typeof event?.composedPath === "function" ? event.composedPath() : void 0;
73
+ return this.state.containerGroups.findIndex(({ container, tabbableNodes }) => container.contains(element) || composedPath?.includes(container) || tabbableNodes.find((node) => node === element));
74
+ }
75
+ updateTabbableNodes() {
76
+ this.state.containerGroups = this.state.containers.map((container) => {
77
+ const tabbableNodes = getTabbables(container);
78
+ const focusableNodes = getFocusables(container);
79
+ const firstTabbableNode = tabbableNodes.length > 0 ? tabbableNodes[0] : void 0;
80
+ const lastTabbableNode = tabbableNodes.length > 0 ? tabbableNodes[tabbableNodes.length - 1] : void 0;
81
+ const firstDomTabbableNode = focusableNodes.find((node) => isTabbable(node));
82
+ const lastDomTabbableNode = focusableNodes.slice().reverse().find((node) => isTabbable(node));
83
+ const posTabIndexesFound = !!tabbableNodes.find((node) => getTabIndex(node) > 0);
84
+ function nextTabbableNode(node, forward = true) {
85
+ const nodeIdx = tabbableNodes.indexOf(node);
86
+ if (nodeIdx < 0) {
87
+ if (forward) return focusableNodes.slice(focusableNodes.indexOf(node) + 1).find((el) => isTabbable(el));
88
+ return focusableNodes.slice(0, focusableNodes.indexOf(node)).reverse().find((el) => isTabbable(el));
89
+ }
90
+ return tabbableNodes[nodeIdx + (forward ? 1 : -1)];
91
+ }
92
+ return {
93
+ container,
94
+ tabbableNodes,
95
+ focusableNodes,
96
+ posTabIndexesFound,
97
+ firstTabbableNode,
98
+ lastTabbableNode,
99
+ firstDomTabbableNode,
100
+ lastDomTabbableNode,
101
+ nextTabbableNode
102
+ };
103
+ });
104
+ this.state.tabbableGroups = this.state.containerGroups.filter((group) => group.tabbableNodes.length > 0);
105
+ 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");
106
+ 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.");
107
+ }
108
+ listenerCleanups = [];
109
+ addListeners() {
110
+ if (!this.state.active) return;
111
+ activeFocusTraps.activateTrap(this.trapStack, this);
112
+ this.state.delayInitialFocusTimer = this.config.delayInitialFocus ? delay(() => {
113
+ this.tryFocus(this.getInitialFocusNode());
114
+ }) : this.tryFocus(this.getInitialFocusNode());
115
+ this.listenerCleanups.push(addDomEvent(this.doc, "focusin", this.handleFocus, true), addDomEvent(this.doc, "mousedown", this.handlePointerDown, {
116
+ capture: true,
117
+ passive: false
118
+ }), addDomEvent(this.doc, "touchstart", this.handlePointerDown, {
119
+ capture: true,
120
+ passive: false
121
+ }), addDomEvent(this.doc, "click", this.handleClick, {
122
+ capture: true,
123
+ passive: false
124
+ }), addDomEvent(this.doc, "keydown", this.handleTabKey, {
125
+ capture: true,
126
+ passive: false
127
+ }), addDomEvent(this.doc, "keydown", this.handleEscapeKey));
128
+ return this;
129
+ }
130
+ removeListeners() {
131
+ if (!this.state.active) return;
132
+ this.listenerCleanups.forEach((cleanup) => cleanup());
133
+ this.listenerCleanups = [];
134
+ return this;
135
+ }
136
+ handleFocus = (event) => {
137
+ const target = getEventTarget(event);
138
+ const targetContained = this.findContainerIndex(target, event) >= 0;
139
+ if (targetContained || isDocument(target)) {
140
+ if (targetContained) this.state.mostRecentlyFocusedNode = target;
141
+ } else {
142
+ event.stopImmediatePropagation();
143
+ let nextNode;
144
+ let navAcrossContainers = true;
145
+ if (this.state.mostRecentlyFocusedNode) {
146
+ if (getTabIndex(this.state.mostRecentlyFocusedNode) > 0) {
147
+ const mruContainerIdx = this.findContainerIndex(this.state.mostRecentlyFocusedNode);
148
+ const { tabbableNodes } = this.state.containerGroups[mruContainerIdx];
149
+ if (tabbableNodes.length > 0) {
150
+ const mruTabIdx = tabbableNodes.findIndex((node) => node === this.state.mostRecentlyFocusedNode);
151
+ if (mruTabIdx >= 0) {
152
+ if (this.config.isKeyForward(this.state.recentNavEvent)) {
153
+ if (mruTabIdx + 1 < tabbableNodes.length) {
154
+ nextNode = tabbableNodes[mruTabIdx + 1];
155
+ navAcrossContainers = false;
156
+ }
157
+ } else if (mruTabIdx - 1 >= 0) {
158
+ nextNode = tabbableNodes[mruTabIdx - 1];
159
+ navAcrossContainers = false;
160
+ }
161
+ }
162
+ }
163
+ } else if (!this.state.containerGroups.some((g) => g.tabbableNodes.some((n) => getTabIndex(n) > 0))) navAcrossContainers = false;
164
+ } else navAcrossContainers = false;
165
+ if (navAcrossContainers) nextNode = this.findNextNavNode({
166
+ target: this.state.mostRecentlyFocusedNode,
167
+ isBackward: this.config.isKeyBackward(this.state.recentNavEvent)
168
+ });
169
+ if (nextNode) this.tryFocus(nextNode);
170
+ else this.tryFocus(this.state.mostRecentlyFocusedNode || this.getInitialFocusNode());
171
+ }
172
+ this.state.recentNavEvent = void 0;
173
+ };
174
+ handlePointerDown = (event) => {
175
+ const target = getEventTarget(event);
176
+ if (this.findContainerIndex(target, event) >= 0) return;
177
+ if (valueOrHandler(this.config.clickOutsideDeactivates, event)) {
178
+ this.deactivate({ returnFocus: this.config.returnFocusOnDeactivate });
179
+ return;
180
+ }
181
+ if (valueOrHandler(this.config.allowOutsideClick, event)) return;
182
+ event.preventDefault();
183
+ };
184
+ handleClick = (event) => {
185
+ const target = getEventTarget(event);
186
+ if (this.findContainerIndex(target, event) >= 0) return;
187
+ if (valueOrHandler(this.config.clickOutsideDeactivates, event)) return;
188
+ if (valueOrHandler(this.config.allowOutsideClick, event)) return;
189
+ event.preventDefault();
190
+ event.stopImmediatePropagation();
191
+ };
192
+ handleTabKey = (event) => {
193
+ if (this.config.isKeyForward(event) || this.config.isKeyBackward(event)) {
194
+ this.state.recentNavEvent = event;
195
+ const isBackward = this.config.isKeyBackward(event);
196
+ const destinationNode = this.findNextNavNode({
197
+ event,
198
+ isBackward
199
+ });
200
+ if (!destinationNode) return;
201
+ if (isTabEvent(event)) event.preventDefault();
202
+ this.tryFocus(destinationNode);
203
+ }
204
+ };
205
+ handleEscapeKey = (event) => {
206
+ if (isEscapeEvent(event) && valueOrHandler(this.config.escapeDeactivates, event) !== false) {
207
+ event.preventDefault();
208
+ this.deactivate();
209
+ }
210
+ };
211
+ _mutationObserver;
212
+ setupMutationObserver = () => {
213
+ const win = this.doc.defaultView || window;
214
+ this._mutationObserver = new win.MutationObserver((mutations) => {
215
+ const isFocusedNodeRemoved = mutations.some((mutation) => {
216
+ const removedNodes = Array.from(mutation.removedNodes);
217
+ return removedNodes.includes(this.state.mostRecentlyFocusedNode);
218
+ });
219
+ if (isFocusedNodeRemoved) this.tryFocus(this.getInitialFocusNode());
220
+ });
221
+ };
222
+ updateObservedNodes = () => {
223
+ this._mutationObserver?.disconnect();
224
+ if (this.state.active && !this.state.paused) this.state.containers.forEach((container) => {
225
+ this._mutationObserver?.observe(container, {
226
+ subtree: true,
227
+ childList: true
228
+ });
229
+ });
230
+ };
231
+ getInitialFocusNode = () => {
232
+ let node = this.getNodeForOption("initialFocus", { hasFallback: true });
233
+ if (node === false) return false;
234
+ if (node === void 0 || node && !isFocusable(node)) if (this.findContainerIndex(this.doc.activeElement) >= 0) node = this.doc.activeElement;
235
+ else {
236
+ const firstTabbableGroup = this.state.tabbableGroups[0];
237
+ const firstTabbableNode = firstTabbableGroup && firstTabbableGroup.firstTabbableNode;
238
+ node = firstTabbableNode || this.getNodeForOption("fallbackFocus");
239
+ }
240
+ else if (node === null) node = this.getNodeForOption("fallbackFocus");
241
+ if (!node) throw new Error("Your focus-trap needs to have at least one focusable element");
242
+ if (!node.isConnected) node = this.getNodeForOption("fallbackFocus");
243
+ return node;
244
+ };
245
+ tryFocus = (node) => {
246
+ if (node === false) return;
247
+ if (node === getActiveElement(this.doc)) return;
248
+ if (!node || !node.focus) {
249
+ this.tryFocus(this.getInitialFocusNode());
250
+ return;
251
+ }
252
+ node.focus({ preventScroll: !!this.config.preventScroll });
253
+ this.state.mostRecentlyFocusedNode = node;
254
+ if (isSelectableInput(node)) node.select();
255
+ };
256
+ activate(activateOptions) {
257
+ if (this.state.active) return this;
258
+ const onActivate = this.getOption(activateOptions, "onActivate");
259
+ const onPostActivate = this.getOption(activateOptions, "onPostActivate");
260
+ const checkCanFocusTrap = this.getOption(activateOptions, "checkCanFocusTrap");
261
+ if (!checkCanFocusTrap) this.updateTabbableNodes();
262
+ this.state.active = true;
263
+ this.state.paused = false;
264
+ this.state.nodeFocusedBeforeActivation = this.doc.activeElement || null;
265
+ onActivate?.();
266
+ const finishActivation = () => {
267
+ if (checkCanFocusTrap) this.updateTabbableNodes();
268
+ this.addListeners();
269
+ this.updateObservedNodes();
270
+ onPostActivate?.();
271
+ };
272
+ if (checkCanFocusTrap) {
273
+ checkCanFocusTrap(this.state.containers.concat()).then(finishActivation, finishActivation);
274
+ return this;
275
+ }
276
+ finishActivation();
277
+ return this;
278
+ }
279
+ deactivate = (deactivateOptions) => {
280
+ if (!this.state.active) return this;
281
+ const options = {
282
+ onDeactivate: this.config.onDeactivate,
283
+ onPostDeactivate: this.config.onPostDeactivate,
284
+ checkCanReturnFocus: this.config.checkCanReturnFocus,
285
+ ...deactivateOptions
286
+ };
287
+ clearTimeout(this.state.delayInitialFocusTimer);
288
+ this.state.delayInitialFocusTimer = void 0;
289
+ this.removeListeners();
290
+ this.state.active = false;
291
+ this.state.paused = false;
292
+ this.updateObservedNodes();
293
+ activeFocusTraps.deactivateTrap(this.trapStack, this);
294
+ const onDeactivate = this.getOption(options, "onDeactivate");
295
+ const onPostDeactivate = this.getOption(options, "onPostDeactivate");
296
+ const checkCanReturnFocus = this.getOption(options, "checkCanReturnFocus");
297
+ const returnFocus = this.getOption(options, "returnFocus", "returnFocusOnDeactivate");
298
+ onDeactivate?.();
299
+ const finishDeactivation = () => {
300
+ delay(() => {
301
+ if (returnFocus) {
302
+ const returnFocusNode = this.getReturnFocusNode(this.state.nodeFocusedBeforeActivation);
303
+ this.tryFocus(returnFocusNode);
304
+ }
305
+ onPostDeactivate?.();
306
+ });
307
+ };
308
+ if (returnFocus && checkCanReturnFocus) {
309
+ const returnFocusNode = this.getReturnFocusNode(this.state.nodeFocusedBeforeActivation);
310
+ checkCanReturnFocus(returnFocusNode).then(finishDeactivation, finishDeactivation);
311
+ return this;
312
+ }
313
+ finishDeactivation();
314
+ return this;
315
+ };
316
+ pause = (pauseOptions) => {
317
+ if (this.state.paused || !this.state.active) return this;
318
+ const onPause = this.getOption(pauseOptions, "onPause");
319
+ const onPostPause = this.getOption(pauseOptions, "onPostPause");
320
+ this.state.paused = true;
321
+ onPause?.();
322
+ this.removeListeners();
323
+ this.updateObservedNodes();
324
+ onPostPause?.();
325
+ return this;
326
+ };
327
+ unpause = (unpauseOptions) => {
328
+ if (!this.state.paused || !this.state.active) return this;
329
+ const onUnpause = this.getOption(unpauseOptions, "onUnpause");
330
+ const onPostUnpause = this.getOption(unpauseOptions, "onPostUnpause");
331
+ this.state.paused = false;
332
+ onUnpause?.();
333
+ this.updateTabbableNodes();
334
+ this.addListeners();
335
+ this.updateObservedNodes();
336
+ onPostUnpause?.();
337
+ return this;
338
+ };
339
+ updateContainerElements = (containerElements) => {
340
+ this.state.containers = Array.isArray(containerElements) ? containerElements.filter(Boolean) : [containerElements].filter(Boolean);
341
+ if (this.state.active) this.updateTabbableNodes();
342
+ this.updateObservedNodes();
343
+ return this;
344
+ };
345
+ getReturnFocusNode = (previousActiveElement) => {
346
+ const node = this.getNodeForOption("setReturnFocus", { params: [previousActiveElement] });
347
+ return node || (node === false ? false : previousActiveElement);
348
+ };
349
+ getOption = (configOverrideOptions, optionName, configOptionName) => {
350
+ return configOverrideOptions && configOverrideOptions[optionName] !== void 0 ? configOverrideOptions[optionName] : this.config[configOptionName || optionName];
351
+ };
352
+ getNodeForOption = (optionName, { hasFallback = false, params = [] } = {}) => {
353
+ let optionValue = this.config[optionName];
354
+ if (typeof optionValue === "function") optionValue = optionValue(...params);
355
+ if (optionValue === true) optionValue = void 0;
356
+ if (!optionValue) {
357
+ if (optionValue === void 0 || optionValue === false) return optionValue;
358
+ throw new Error(`\`${optionName}\` was specified but was not a node, or did not return a node`);
359
+ }
360
+ let node = optionValue;
361
+ if (typeof optionValue === "string") {
362
+ try {
363
+ node = this.doc.querySelector(optionValue);
364
+ } catch (err) {
365
+ throw new Error(`\`${optionName}\` appears to be an invalid selector; error="${err.message}"`);
366
+ }
367
+ if (!node) {
368
+ if (!hasFallback) throw new Error(`\`${optionName}\` as selector refers to no known node`);
369
+ }
370
+ }
371
+ return node;
372
+ };
373
+ findNextNavNode = (opts) => {
374
+ const { event, isBackward = false } = opts;
375
+ const target = opts.target || getEventTarget(event);
376
+ this.updateTabbableNodes();
377
+ let destinationNode = null;
378
+ if (this.state.tabbableGroups.length > 0) {
379
+ const containerIndex = this.findContainerIndex(target, event);
380
+ const containerGroup = containerIndex >= 0 ? this.state.containerGroups[containerIndex] : void 0;
381
+ if (containerIndex < 0) if (isBackward) destinationNode = this.state.tabbableGroups[this.state.tabbableGroups.length - 1].lastTabbableNode;
382
+ else destinationNode = this.state.tabbableGroups[0].firstTabbableNode;
383
+ else if (isBackward) {
384
+ let startOfGroupIndex = this.state.tabbableGroups.findIndex(({ firstTabbableNode }) => target === firstTabbableNode);
385
+ if (startOfGroupIndex < 0 && (containerGroup?.container === target || isFocusable(target) && !isTabbable(target) && !containerGroup?.nextTabbableNode(target, false))) startOfGroupIndex = containerIndex;
386
+ if (startOfGroupIndex >= 0) {
387
+ const destinationGroupIndex = startOfGroupIndex === 0 ? this.state.tabbableGroups.length - 1 : startOfGroupIndex - 1;
388
+ const destinationGroup = this.state.tabbableGroups[destinationGroupIndex];
389
+ destinationNode = getTabIndex(target) >= 0 ? destinationGroup.lastTabbableNode : destinationGroup.lastDomTabbableNode;
390
+ } else if (!isTabEvent(event)) destinationNode = containerGroup?.nextTabbableNode(target, false);
391
+ } else {
392
+ let lastOfGroupIndex = this.state.tabbableGroups.findIndex(({ lastTabbableNode }) => target === lastTabbableNode);
393
+ if (lastOfGroupIndex < 0 && (containerGroup?.container === target || isFocusable(target) && !isTabbable(target) && !containerGroup?.nextTabbableNode(target))) lastOfGroupIndex = containerIndex;
394
+ if (lastOfGroupIndex >= 0) {
395
+ const destinationGroupIndex = lastOfGroupIndex === this.state.tabbableGroups.length - 1 ? 0 : lastOfGroupIndex + 1;
396
+ const destinationGroup = this.state.tabbableGroups[destinationGroupIndex];
397
+ destinationNode = getTabIndex(target) >= 0 ? destinationGroup.firstTabbableNode : destinationGroup.firstDomTabbableNode;
398
+ } else if (!isTabEvent(event)) destinationNode = containerGroup?.nextTabbableNode(target);
399
+ }
400
+ } else destinationNode = this.getNodeForOption("fallbackFocus");
401
+ return destinationNode;
402
+ };
403
+ };
404
+
405
+ //#endregion
406
+ //#region index.ts
407
+ function trapFocus(el, options = {}) {
408
+ let trap;
409
+ const cleanup = raf(() => {
410
+ const contentEl = typeof el === "function" ? el() : el;
411
+ if (!contentEl) return;
412
+ trap = new FocusTrap(contentEl, {
413
+ escapeDeactivates: false,
414
+ allowOutsideClick: true,
415
+ preventScroll: true,
416
+ returnFocusOnDeactivate: true,
417
+ delayInitialFocus: false,
418
+ fallbackFocus: contentEl,
419
+ ...options,
420
+ document: getDocument(contentEl)
421
+ });
422
+ try {
423
+ trap.activate();
424
+ } catch {}
425
+ });
426
+ return function destroy() {
427
+ trap?.deactivate();
428
+ cleanup();
429
+ };
430
+ }
431
+
432
+ //#endregion
433
+ export { FocusTrap, trapFocus };
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@destyler/focus-trap",
3
+ "type": "module",
4
+ "version": "0.0.7-beta.9",
5
+ "description": "Focus trap util",
6
+ "author": "Elone Hoo <elonehoo@gmail.com>",
7
+ "license": "MIT",
8
+ "exports": {
9
+ ".": "./dist/index.js"
10
+ },
11
+ "main": "dist/index.js",
12
+ "module": "dist/index.js",
13
+ "types": "dist/index.d.ts",
14
+ "files": [
15
+ "*.d.ts",
16
+ "dist"
17
+ ],
18
+ "dependencies": {
19
+ "@destyler/dom": "0.0.7-beta.9"
20
+ },
21
+ "scripts": {
22
+ "clear": "rimraf ./dist",
23
+ "clear:node_modules": "rimraf ./node_modules",
24
+ "build": "pnpm run clear && tsdown",
25
+ "watch": "tsdown --watch"
26
+ }
27
+ }